Compare commits

...

18 Commits

Author SHA1 Message Date
stringadmin 9d9c3ce186 Merge branch 'master' into codex/time-driven-progress
Web Quality / verify (push) Has been cancelled
Web Quality / verify (pull_request) Has been cancelled
2026-06-10 10:04:59 +00:00
stringadmin 228e89cfb6 Merge pull request 'feat: 多页面UI打磨 — 设置面板、状态反馈与样式升级' (#35) from feat/ui-polish-and-skills into master
Web Quality / verify (push) Has been cancelled
Reviewed-on: #35
2026-06-10 09:57:06 +00:00
stringadmin 0fbb5372d5 Merge branch 'master' into feat/ui-polish-and-skills
Web Quality / verify (pull_request) Has been cancelled
2026-06-10 09:56:47 +00:00
ludan a6626beb32 feat: 多页面UI打磨 — 设置面板、状态反馈与样式升级
Web Quality / verify (pull_request) Has been cancelled
本次更新对多个功能页面进行了系统性的 UI/UX 打磨,统一了交互模式并补充了缺失的状态反馈。

## 新增功能
- WorkbenchPage: 图片提示词案例区域新增加载骨架屏、错误回退、空数据三种状态展示
- CharacterMixPage: 新增左侧设置面板(驱动提示词、图像检测开关、水印开关),支持清除已上传的人物图/参考视频
- DigitalHumanPage: 新增左侧设置面板(提示词输入、去水印/保留原声开关),支持清除已上传的人像/音频,增加取消生成按钮
- ImageWorkbenchPage / ResolutionUpscalePage: 新增参数设置面板和资产清除交互
- MorePage: 新增页面入口

## UI 优化
- 统一 Toggle 开关组件: 所有设置页面采用一致的 .studio-toggle 交互模式
- 资产清除: 各上传区域新增清除按钮,含二次确认和提示反馈
- 生成按钮: 统一为带图标的 .studio-generate-btn,增加 disabled/loading 状态
- ConversationSidebar / ProjectSidebar: 侧边栏交互细节优化

## 样式升级
- image-workbench.css: 大幅扩展样式 (+1900 行),覆盖设置面板、上传区、结果展示等
- workbench.css: 新增 666 行样式,含骨架屏动画、案例卡片网格、状态占位等
- subtitle-removal.css: 补充设置面板样式
2026-06-10 17:54:45 +08:00
stringadmin aa5ba96764 Merge branch 'master' into codex/time-driven-progress
Web Quality / verify (push) Has been cancelled
Web Quality / verify (pull_request) Has been cancelled
2026-06-10 09:51:55 +00:00
stringadmin ba2e7cfda2 Consolidate generation task stores
Web Quality / verify (push) Has been cancelled
Web Quality / verify (pull_request) Has been cancelled
2026-06-10 17:37:18 +08:00
stringadmin e9601a651c Use time-driven generation progress
Web Quality / verify (push) Has been cancelled
2026-06-10 16:00:26 +08:00
stringadmin 82bd939e26 Merge pull request 'Codex/canvas pricing cleanup' (#33) from codex/canvas-pricing-cleanup into master
Web Quality / verify (push) Has been cancelled
Reviewed-on: #33
2026-06-10 07:35:45 +00:00
stringadmin 9e080bbb8f Use server enterprise video pricing
Web Quality / verify (push) Has been cancelled
Web Quality / verify (pull_request) Has been cancelled
2026-06-10 14:27:42 +08:00
stringadmin d28889fd0c Use server prices for text billing estimates
Web Quality / verify (push) Has been cancelled
2026-06-10 14:12:55 +08:00
stringadmin bfb70bab26 Fix canvas generation cleanup 2026-06-10 14:12:14 +08:00
stringadmin 77ffd01a50 Merge pull request 'Codex/web quality gates' (#32) from codex/web-quality-gates into master
Web Quality / verify (push) Has been cancelled
Reviewed-on: #32
2026-06-10 02:49:18 +00:00
stringadmin f50a5b1f77 Merge remote-tracking branch 'origin/master' into codex/web-quality-gates
Web Quality / verify (push) Has been cancelled
Web Quality / verify (pull_request) Has been cancelled
2026-06-10 10:32:27 +08:00
stringadmin 4d7bec0dd7 chore: trim large-page lint warnings
Web Quality / verify (push) Has been cancelled
2026-06-09 12:07:08 +08:00
stringadmin 4a298d205b chore: reduce frontend lint warnings
Web Quality / verify (push) Has been cancelled
2026-06-09 12:02:30 +08:00
stringadmin f322679d4a test: add web quality gates
Web Quality / verify (push) Has been cancelled
2026-06-09 11:34:56 +08:00
stringadmin af5081d382 fix: harden generation task recovery 2026-06-09 11:11:50 +08:00
stringadmin eb095bbe98 docs: update legal compliance pages 2026-06-09 11:11:49 +08:00
77 changed files with 6886 additions and 806 deletions
+30
View File
@@ -0,0 +1,30 @@
name: Web Quality
on:
pull_request:
push:
branches:
- main
- master
- "codex/**"
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- name: Install dependencies
run: npm ci
- name: Verify
run: npm run verify
+10
View File
@@ -0,0 +1,10 @@
dist
node_modules
coverage
tmp
.codex-tmp
.codex-logs
screenshots
*.log
*.tmp
package-lock.json
+9
View File
@@ -0,0 +1,9 @@
# Optimization Backlog
## Progress Contract Frontend Consumption
- Status: pending
- Priority: medium
- Context: The backend now returns `progressSource`, `stage`, `startedAt`, and `expectedDurationMs` on generation task status payloads. The frontend progress UI currently still derives these values locally from message state and static defaults.
- Follow-up: Wire the backend task progress contract through `aiGenerationClient`, task/message view models, and the progress card components so model-aware `expectedDurationMs` and real provider progress can be consumed end to end.
- Boundary: Keep this separate from the task store consolidation. The store consolidation is complete without requiring these fields because `WebGenerationPreviewTask` is not the source for Workbench progress cards.
+77
View File
@@ -0,0 +1,77 @@
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: [
"dist/**",
"node_modules/**",
"coverage/**",
"tmp/**",
".codex-tmp/**",
".codex-logs/**",
"screenshots/**",
"*.log",
"*.tmp",
"vite-*.log",
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: [
"src/**/*.{ts,tsx}",
"scripts/**/*.mjs",
"vite.config.ts",
"eslint.config.js",
],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
AbortController: "readonly",
AbortSignal: "readonly",
Blob: "readonly",
clearInterval: "readonly",
clearTimeout: "readonly",
console: "readonly",
crypto: "readonly",
document: "readonly",
File: "readonly",
fetch: "readonly",
FormData: "readonly",
Headers: "readonly",
HTMLTextAreaElement: "readonly",
localStorage: "readonly",
navigator: "readonly",
process: "readonly",
React: "readonly",
RequestInit: "readonly",
Response: "readonly",
setInterval: "readonly",
setTimeout: "readonly",
URL: "readonly",
URLSearchParams: "readonly",
window: "readonly",
},
},
plugins: {
"react-hooks": reactHooks,
},
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-unused-expressions": "warn",
"no-empty": ["error", { allowEmptyCatch: true }],
"no-undef": "off",
"no-useless-escape": "warn",
"prefer-const": "warn",
},
},
);
+1367 -102
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -7,7 +7,11 @@
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1",
"test": "node scripts/run-unit-tests.mjs",
"type-check": "tsc -p tsconfig.json --noEmit",
"lint": "eslint .",
"format:check": "prettier --check .github/workflows/web-quality.yml eslint.config.js scripts/run-unit-tests.mjs src/test src/api/generationConcurrency.test.ts src/utils/enterpriseVideoPolicy.test.ts src/utils/taskLifecycle.test.ts",
"verify": "npm run test && npm run type-check && npm run lint && npm run format:check && npm run governance:check && npm run style:check && npm run build",
"governance:check": "node scripts/check-governance.mjs",
"style:check": "node scripts/check-style-governance.mjs",
"smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs"
@@ -20,13 +24,18 @@
"zustand": "5.0.13"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
"@vitejs/plugin-react": "4.2.1",
"eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1",
"playwright": "1.60.0",
"prettier": "^3.8.3",
"sharp": "0.34.5",
"typescript": "5.3.3",
"vite": "5.1.0",
"typescript-eslint": "^8.60.1",
"vite": "5.4.21",
"vite-plugin-compression2": "2.5.3"
}
}
+8 -3
View File
@@ -27,7 +27,6 @@ console.log(' 1. MODULE DEPENDENCY GRAPH ANALYSIS');
console.log('═══════════════════════════════════════════════');
const importMap = new Map(); // file -> [imports]
const importedBy = new Map(); // file -> [importers]
for (const r of results) {
const imports = [];
@@ -71,10 +70,13 @@ function findCircular(file, visited = new Set(), path = []) {
}
}
}
for (const file of importMap.keys()) {
findCircular(file);
}
// Check high-fanin files (imported by many)
const fanIn = new Map();
for (const [file, imports] of importMap) {
for (const imports of importMap.values()) {
for (const imp of imports) {
const key = imp.replace(/\[dynamic\]/, '');
fanIn.set(key, (fanIn.get(key) || 0) + 1);
@@ -101,7 +103,7 @@ for (const [file, count] of sortedFanOut) {
// Dynamic imports analysis (lazy loading effectiveness)
console.log('\n--- Lazy Loading (Dynamic Imports) ---');
let dynamicImports = 0, staticImports = 0;
for (const [file, imports] of importMap) {
for (const imports of importMap.values()) {
for (const imp of imports) {
if (imp.startsWith('[dynamic]')) dynamicImports++;
else staticImports++;
@@ -177,6 +179,9 @@ for (const r of results) {
if (noDeps > 0) {
console.log(` [RENDER-COST] ${r.file}: ${noDeps} useEffect(s) run EVERY render`);
}
if (emptyDeps > 0) {
console.log(` [MOUNT-EFFECT] ${r.file}: ${emptyDeps} useEffect(s) run on mount only`);
}
}
// ─── 4. Zustand Store Analysis ───
+3 -3
View File
@@ -3,7 +3,7 @@
* Measures: page load, bundle sizes, memory, rendering, network.
*/
import { chromium } from 'playwright';
import { readFileSync, readdirSync, statSync } from 'fs';
import { readdirSync, statSync } from 'fs';
import { join } from 'path';
const DIST = join(import.meta.dirname, '..', 'dist');
@@ -187,7 +187,7 @@ async function runtimeAnalysis() {
const allElements = document.querySelectorAll('*');
const tagCounts = {};
let maxDepth = 0;
let totalNodes = allElements.length;
const totalNodes = allElements.length;
allElements.forEach(el => {
const tag = el.tagName.toLowerCase();
@@ -297,7 +297,7 @@ console.log('╔═════════════════════
console.log('║ OmniAI Web Preview - Performance Analysis ║');
console.log('╚═══════════════════════════════════════════════╝');
const bundleResult = analyzeBundles();
analyzeBundles();
await runtimeAnalysis();
console.log('\n═══════════════════════════════════════════════');
+55
View File
@@ -0,0 +1,55 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { createServer } from "vite";
const repoRoot = process.cwd();
function normalizePath(value) {
return value.replace(/\\/g, "/");
}
function findTestFiles(dir) {
const result = [];
if (!fs.existsSync(dir)) return result;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules" || entry.name === "dist") continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
result.push(...findTestFiles(fullPath));
continue;
}
if (/\.test\.tsx?$/.test(entry.name)) result.push(fullPath);
}
return result.sort();
}
const server = await createServer({
configFile: path.join(repoRoot, "vite.config.ts"),
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
try {
const harness = await server.ssrLoadModule("/src/test/testHarness");
const testFiles = findTestFiles(path.join(repoRoot, "src"));
if (testFiles.length === 0) {
console.error("No test files found.");
process.exitCode = 1;
} else {
console.log(`Running ${testFiles.length} test files`);
for (const file of testFiles) {
const modulePath = `/${normalizePath(path.relative(repoRoot, file))}`;
await server.ssrLoadModule(modulePath);
}
const result = await harness.runRegisteredTests();
console.log(`Unit test result: ${result.passed}/${result.total} passed`);
if (result.failed > 0) process.exitCode = 1;
}
} finally {
await server.close();
}
+1 -2
View File
@@ -1,4 +1,4 @@
import { readdirSync, readFileSync, statSync } from 'fs';
import { readdirSync, readFileSync } from 'fs';
import { join, relative } from 'path';
const SRC = join(import.meta.dirname, '..', 'src');
@@ -114,7 +114,6 @@ for (const r of results) {
console.log('\n=== HIGH COMPLEXITY: Deep nesting ===');
for (const r of results) {
const lines = r.content.split('\n');
let maxIndent = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === '') continue;
-26
View File
@@ -295,7 +295,6 @@ function App() {
openDeleteProject: openDeleteProjectModal,
closeDeleteProject: closeDeleteProjectModal,
setDeleteProjectSubmitting,
clearProjectState,
} = useProjectStore(useShallow((s) => ({
projects: s.projects,
projectsLoaded: s.projectsLoaded,
@@ -310,7 +309,6 @@ function App() {
openDeleteProject: s.openDeleteProject,
closeDeleteProject: s.closeDeleteProject,
setDeleteProjectSubmitting: s.setDeleteProjectSubmitting,
clearProjectState: s.clearProjectState,
})));
// Task store
@@ -349,7 +347,6 @@ function App() {
setBackendHealth,
markNotificationRead,
markAllNotificationsRead,
clearAppState,
} = useAppStore(useShallow((s) => ({
usage: s.usage,
runtimeNotifications: s.runtimeNotifications,
@@ -370,7 +367,6 @@ function App() {
setBackendHealth: s.setBackendHealth,
markNotificationRead: s.markNotificationRead,
markAllNotificationsRead: s.markAllNotificationsRead,
clearAppState: s.clearAppState,
})));
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
@@ -1104,28 +1100,6 @@ function App() {
[handleSetView, setImageWorkbenchTool],
);
const renderAdminOnlyPage = useCallback(
(content: React.ReactNode) => {
if (isAdminAccount(session)) return content;
return (
<div className="feature-access-gate">
<div className="feature-access-gate__content" aria-hidden="true">
{content}
</div>
<div className="feature-access-gate__overlay" role="dialog" aria-modal="true" aria-labelledby="feature-access-title">
<section className="feature-access-gate__panel panel-surface">
<span className="feature-access-gate__eyebrow"></span>
<h2 id="feature-access-title"></h2>
<p></p>
</section>
</div>
</div>
);
},
[session],
);
const PUBLIC_VIEWS = PUBLIC_VIEW_SET;
useEffect(() => {
+15 -6
View File
@@ -3,6 +3,19 @@ import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"];
function combineAbortSignals(signal: AbortSignal | undefined, timeoutSignal: AbortSignal): AbortSignal {
if (!signal) return timeoutSignal;
const controller = new AbortController();
const abort = () => controller.abort();
if (signal.aborted || timeoutSignal.aborted) {
abort();
return controller.signal;
}
signal.addEventListener("abort", abort, { once: true });
timeoutSignal.addEventListener("abort", abort, { once: true });
return controller.signal;
}
export interface AdVideoUserConfig {
platform: string;
aspectRatio: string;
@@ -162,9 +175,7 @@ async function chat(
{ role: "user", content: userContent },
];
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = options?.signal
? AbortSignal.any([options.signal, timeoutSignal])
: timeoutSignal;
const combinedSignal = combineAbortSignals(options?.signal, timeoutSignal);
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
headers: buildAuthHeaders(),
@@ -210,9 +221,7 @@ async function visionChat(
let lastError: Error | null = null;
for (const model of VISION_MODELS) {
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal;
const combinedSignal = combineAbortSignals(signal, timeoutSignal);
try {
const out = await retryOnTransient(async () => {
const res = await fetch(buildApiUrl("ai/chat"), {
+90 -2
View File
@@ -4,6 +4,7 @@ import {
isRecord,
readJsonResponse,
serverRequest,
isServerRequestError,
throwResponseError,
} from "./serverConnection";
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
@@ -149,6 +150,10 @@ export interface AiTaskStatus {
type: "image" | "video";
status: "pending" | "running" | "completed" | "failed" | "cancelled";
progress: number;
progressSource?: "real" | "estimated" | string | null;
stage?: string | null;
startedAt?: string | null;
expectedDurationMs?: number | null;
resultUrl: string | null;
error: string | null;
params?: Record<string, unknown>;
@@ -247,6 +252,46 @@ let taskHistoryRouteMissing = false;
const TASK_SUBMIT_TIMEOUT_MS = 90_000;
const TASK_STATUS_TIMEOUT_MS = 20_000;
const NON_RETRYING_REQUEST = { maxRetries: 0 };
const PENDING_CANCEL_TASKS_KEY = "omniai:pending-task-cancellations";
function readPendingCancelTaskIds(): string[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(PENDING_CANCEL_TASKS_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string" && id.trim().length > 0) : [];
} catch {
return [];
}
}
function writePendingCancelTaskIds(taskIds: string[]): void {
if (typeof window === "undefined") return;
try {
const uniqueIds = Array.from(new Set(taskIds.filter(Boolean)));
if (uniqueIds.length) {
window.localStorage.setItem(PENDING_CANCEL_TASKS_KEY, JSON.stringify(uniqueIds));
} else {
window.localStorage.removeItem(PENDING_CANCEL_TASKS_KEY);
}
} catch {
// Pending cancellation recovery is best-effort.
}
}
function markTaskCancelPending(taskId: string): void {
writePendingCancelTaskIds([...readPendingCancelTaskIds(), taskId]);
}
function clearPendingTaskCancel(taskId: string): void {
writePendingCancelTaskIds(readPendingCancelTaskIds().filter((id) => id !== taskId));
}
function shouldRetryTaskCancel(error: unknown): boolean {
if (!isServerRequestError(error)) return true;
const status = error.status;
return status === 429 || status === undefined || status >= 500;
}
export const aiGenerationClient = {
async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> {
@@ -335,18 +380,48 @@ export const aiGenerationClient = {
},
async cancelTask(taskId: string): Promise<void> {
markTaskCancelPending(taskId);
try {
await serverRequest<void>(`ai/tasks/${taskId}/cancel`, {
method: "PATCH",
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Task cancel failed",
});
clearPendingTaskCancel(taskId);
} catch (error) {
if (isOptionalApiRouteMissing(error)) return;
if (isOptionalApiRouteMissing(error) || !shouldRetryTaskCancel(error)) {
clearPendingTaskCancel(taskId);
return;
}
throw error;
}
},
cancelTaskOnUnload(taskId: string): void {
markTaskCancelPending(taskId);
const url = buildApiUrl(`ai/tasks/${encodeURIComponent(taskId)}/cancel`);
const headers = buildAuthHeaders();
const body = JSON.stringify({ reason: "page_unload" });
try {
void fetch(url, {
method: "PATCH",
headers,
body,
credentials: "include",
keepalive: true,
});
} catch {
// Page unload cancellation is best-effort.
}
},
flushPendingTaskCancellations(): void {
readPendingCancelTaskIds().forEach((taskId) => {
this.cancelTask(taskId).catch(() => {});
});
},
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, {
timeoutMs: TASK_STATUS_TIMEOUT_MS,
@@ -443,7 +518,20 @@ export const aiGenerationClient = {
subscribeTaskStatus(
taskId: string,
onUpdate: (task: Pick<AiTaskStatus, "taskId" | "status" | "progress" | "resultUrl" | "error">) => void,
onUpdate: (
task: Pick<
AiTaskStatus,
| "taskId"
| "status"
| "progress"
| "progressSource"
| "stage"
| "startedAt"
| "expectedDurationMs"
| "resultUrl"
| "error"
>,
) => void,
): () => void {
const url = buildApiUrl(`ai/tasks/${taskId}/stream`);
const controller = new AbortController();
+63
View File
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it } from "../test/testHarness";
import {
__resetGenerationConcurrencyForTests,
claimGenerationSlot,
getActiveGenerationTaskCount,
getEffectiveGenerationLimit,
getGenerationUserKey,
releaseGenerationSlot,
setUserMaxConcurrency,
} from "./generationConcurrency";
describe("generationConcurrency", () => {
afterEach(() => {
__resetGenerationConcurrencyForTests();
});
it("uses the default generation limit until the server provides a user-specific limit", () => {
expect(getEffectiveGenerationLimit()).toBe(3);
setUserMaxConcurrency(5);
expect(getEffectiveGenerationLimit()).toBe(5);
setUserMaxConcurrency(0);
expect(getEffectiveGenerationLimit()).toBe(3);
});
it("claims and releases local generation slots so the submit button can recover", () => {
const userKey = getGenerationUserKey("user-1");
const releaseFirst = claimGenerationSlot({
userKey,
kind: "image",
id: "slot-1",
});
claimGenerationSlot({ userKey, kind: "video", id: "slot-2" });
expect(getActiveGenerationTaskCount(userKey)).toBe(2);
releaseFirst();
expect(getActiveGenerationTaskCount(userKey)).toBe(1);
releaseGenerationSlot("slot-2");
expect(getActiveGenerationTaskCount(userKey)).toBe(0);
});
it("enforces per-user limits without blocking other users", () => {
setUserMaxConcurrency(1);
claimGenerationSlot({ userKey: "alice", kind: "image", id: "alice-slot" });
expect(() =>
claimGenerationSlot({
userKey: "alice",
kind: "video",
id: "alice-slot-2",
}),
).toThrow("最多生成 1 个图片/视频任务");
expect(() =>
claimGenerationSlot({ userKey: "bob", kind: "video", id: "bob-slot" }),
).not.toThrow();
});
});
+9
View File
@@ -21,6 +21,10 @@ function getEffectiveLimit(): number {
return userMaxConcurrency ?? DEFAULT_MAX_ACTIVE_GENERATION_TASKS;
}
export function getEffectiveGenerationLimit(): number {
return getEffectiveLimit();
}
export function getGenerationUserKey(userId?: string | number | null): string {
return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId);
}
@@ -71,3 +75,8 @@ export function releaseGenerationSlot(id: string | undefined | null): void {
if (!id) return;
activeSlots.delete(id);
}
export function __resetGenerationConcurrencyForTests(): void {
activeSlots.clear();
userMaxConcurrency = null;
}
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it } from "../test/testHarness";
import {
normalizeEnterpriseVideoPricingConfig,
normalizePublicModelPrice,
normalizePublicModelPrices,
normalizePublicPricingPayload,
} from "./publicPricingClient";
describe("publicPricingClient", () => {
it("normalizes camelCase public model price payloads", () => {
expect(
normalizePublicModelPrice({
id: 1,
modelKey: "gpt-4o",
displayName: "GPT-4o",
category: "text",
pricingType: "token",
inputPriceMills: 27,
outputPriceMills: 108,
flatPriceMills: null,
currency: "CNY",
enabled: true,
}),
).toEqual({
id: 1,
modelKey: "gpt-4o",
displayName: "GPT-4o",
category: "text",
pricingType: "token",
inputPriceMills: 27,
outputPriceMills: 108,
flatPriceMills: null,
currency: "CNY",
enabled: true,
createdAt: undefined,
updatedAt: undefined,
});
});
it("normalizes snake_case public model price payloads inside containers", () => {
expect(
normalizePublicModelPrices({
prices: [
{
model_key: "deepseek-chat",
display_name: "DeepSeek Chat",
pricing_type: "token",
input_price_mills: "2",
output_price_mills: "8",
flat_price_mills: "0",
enabled: 1,
},
{ display_name: "missing key" },
],
}),
).toEqual([
{
id: undefined,
modelKey: "deepseek-chat",
displayName: "DeepSeek Chat",
category: undefined,
pricingType: "token",
inputPriceMills: 2,
outputPriceMills: 8,
flatPriceMills: 0,
currency: "CNY",
enabled: true,
createdAt: undefined,
updatedAt: undefined,
},
]);
});
it("normalizes public pricing payloads with model prices and enterprise video pricing", () => {
expect(
normalizePublicPricingPayload({
modelPrices: [
{
modelKey: "qwen-turbo",
pricingType: "token",
inputPriceMills: 2,
outputPriceMills: 6,
},
],
enterpriseVideoPricing: {
currency: "CNY",
creditsPerCny: 100,
billingUnit: "per_second",
defaultResolution: "1080P",
resolutions: ["720P", "1080P"],
rules: [
{
id: "happyhorse",
modelIncludes: ["happyhorse"],
rates: { "720P": 0.72, "1080P": 1.28 },
},
],
},
}),
).toEqual({
modelPrices: [
{
id: undefined,
modelKey: "qwen-turbo",
displayName: undefined,
category: undefined,
pricingType: "token",
inputPriceMills: 2,
outputPriceMills: 6,
flatPriceMills: null,
currency: "CNY",
enabled: true,
createdAt: undefined,
updatedAt: undefined,
},
],
enterpriseVideoPricing: {
currency: "CNY",
creditsPerCny: 100,
billingUnit: "per_second",
defaultResolution: "1080P",
resolutions: ["720P", "1080P"],
rules: [
{
id: "happyhorse",
modelIncludes: ["happyhorse"],
rates: { "720P": 0.72, "1080P": 1.28 },
},
],
},
});
});
it("rejects malformed enterprise video pricing configs", () => {
expect(
normalizeEnterpriseVideoPricingConfig({
rules: [{ id: "broken", modelIncludes: [], rates: {} }],
}),
).toEqual(null);
});
});
+236
View File
@@ -0,0 +1,236 @@
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
import { isRecord, serverRequest } from "./serverConnection";
import type { EnterpriseVideoPricingConfig, EnterpriseVideoPricingRule } from "../utils/enterpriseVideoPolicy";
export interface PublicModelPrice {
id?: number | string;
modelKey: string;
displayName?: string;
category?: string;
pricingType?: string;
inputPriceMills: number | null;
outputPriceMills: number | null;
flatPriceMills: number | null;
currency: string;
enabled: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface PublicPricingPayload {
modelPrices: PublicModelPrice[];
enterpriseVideoPricing: EnterpriseVideoPricingConfig | null;
}
function readString(
record: Record<string, unknown>,
keys: string[],
): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
function readNumber(
record: Record<string, unknown>,
keys: string[],
): number | null {
for (const key of keys) {
const value = record[key];
const parsed =
typeof value === "number"
? value
: typeof value === "string"
? Number(value)
: NaN;
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function readBoolean(
record: Record<string, unknown>,
keys: string[],
fallback: boolean,
): boolean {
for (const key of keys) {
const value = record[key];
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "enabled"].includes(normalized)) return true;
if (["0", "false", "no", "disabled"].includes(normalized)) return false;
}
}
return fallback;
}
function readStringArray(record: Record<string, unknown>, keys: string[]): string[] {
for (const key of keys) {
const value = record[key];
if (!Array.isArray(value)) continue;
return value
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean);
}
return [];
}
function normalizeRateMap(raw: unknown): Record<string, number> | null {
if (!isRecord(raw)) return null;
const result: Record<string, number> = {};
for (const [key, value] of Object.entries(raw)) {
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
if (Number.isFinite(parsed) && parsed >= 0) result[key] = parsed;
}
return Object.keys(result).length ? result : null;
}
function normalizeEnterpriseVideoPricingRule(raw: unknown): EnterpriseVideoPricingRule | null {
if (!isRecord(raw)) return null;
const id = readString(raw, ["id", "key", "name"]);
const modelIncludes = readStringArray(raw, ["modelIncludes", "model_includes", "modelPatterns", "model_patterns"]);
const rates = normalizeRateMap(raw.rates);
if (!id || modelIncludes.length === 0 || !rates) return null;
const when = isRecord(raw.when)
? {
...(typeof raw.when.muted === "boolean" ? { muted: raw.when.muted } : {}),
...(typeof raw.when.hasReferenceVideo === "boolean"
? { hasReferenceVideo: raw.when.hasReferenceVideo }
: {}),
}
: undefined;
return {
id,
modelIncludes,
...(when && Object.keys(when).length ? { when } : {}),
rates,
};
}
export function normalizePublicModelPrice(
raw: unknown,
): PublicModelPrice | null {
if (!isRecord(raw)) return null;
const modelKey = readString(raw, ["modelKey", "model_key", "key", "model"]);
if (!modelKey) return null;
const displayName = readString(raw, ["displayName", "display_name", "name"]);
const category = readString(raw, ["category", "type"]);
const pricingType = readString(raw, ["pricingType", "pricing_type"]);
const currency = readString(raw, ["currency"]) || "CNY";
const createdAt = readString(raw, ["createdAt", "created_at"]);
const updatedAt = readString(raw, ["updatedAt", "updated_at"]);
const idValue = raw.id;
return {
id:
typeof idValue === "number" || typeof idValue === "string"
? idValue
: undefined,
modelKey,
displayName,
category,
pricingType,
inputPriceMills: readNumber(raw, ["inputPriceMills", "input_price_mills"]),
outputPriceMills: readNumber(raw, [
"outputPriceMills",
"output_price_mills",
]),
flatPriceMills: readNumber(raw, ["flatPriceMills", "flat_price_mills"]),
currency,
enabled: readBoolean(raw, ["enabled", "is_enabled"], true),
createdAt,
updatedAt,
};
}
export function normalizePublicModelPrices(
payload: unknown,
): PublicModelPrice[] {
const rawPrices = Array.isArray(payload)
? payload
: isRecord(payload) && Array.isArray(payload.prices)
? payload.prices
: isRecord(payload) && Array.isArray(payload.modelPrices)
? payload.modelPrices
: isRecord(payload) && Array.isArray(payload.model_prices)
? payload.model_prices
: isRecord(payload) && Array.isArray(payload.models)
? payload.models
: [];
return rawPrices
.map((item) => normalizePublicModelPrice(item))
.filter((item): item is PublicModelPrice => Boolean(item));
}
export function normalizeEnterpriseVideoPricingConfig(raw: unknown): EnterpriseVideoPricingConfig | null {
if (!isRecord(raw)) return null;
const rules = Array.isArray(raw.rules)
? raw.rules
.map((item) => normalizeEnterpriseVideoPricingRule(item))
.filter((item): item is EnterpriseVideoPricingRule => Boolean(item))
: [];
if (rules.length === 0) return null;
const creditsPerCny = readNumber(raw, ["creditsPerCny", "credits_per_cny"]);
const defaultResolution = readString(raw, ["defaultResolution", "default_resolution"]);
const billingUnit = readString(raw, ["billingUnit", "billing_unit"]);
const currency = readString(raw, ["currency"]);
const resolutions = readStringArray(raw, ["resolutions", "supportedResolutions", "supported_resolutions"]);
return {
...(currency ? { currency } : {}),
...(creditsPerCny !== null ? { creditsPerCny } : {}),
...(billingUnit ? { billingUnit } : {}),
...(defaultResolution ? { defaultResolution } : {}),
...(resolutions.length ? { resolutions } : {}),
rules,
};
}
export function normalizePublicPricingPayload(payload: unknown): PublicPricingPayload {
const enterpriseVideoPricingRaw =
isRecord(payload) && (payload.enterpriseVideoPricing ?? payload.enterprise_video_pricing);
return {
modelPrices: normalizePublicModelPrices(payload),
enterpriseVideoPricing: normalizeEnterpriseVideoPricingConfig(enterpriseVideoPricingRaw),
};
}
let cachedPricing: PublicPricingPayload | null = null;
let pricesRouteMissing = false;
export const publicPricingClient = {
async getPricing(): Promise<PublicPricingPayload> {
if (cachedPricing) return cachedPricing;
if (pricesRouteMissing) return { modelPrices: [], enterpriseVideoPricing: null };
try {
const payload = await serverRequest<unknown>("prices", {
fallbackMessage: "Model prices request failed",
});
cachedPricing = normalizePublicPricingPayload(payload);
return cachedPricing;
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
pricesRouteMissing = true;
return { modelPrices: [], enterpriseVideoPricing: null };
}
throw error;
}
},
async getPrices(): Promise<PublicModelPrice[]> {
const pricing = await publicPricingClient.getPricing();
return pricing.modelPrices;
},
};
+19 -3
View File
@@ -9,6 +9,10 @@ export interface TaskProgressEvent {
taskId: string;
status: string;
progress: number;
progressSource?: "real" | "estimated" | string | null;
stage?: string | null;
startedAt?: string | null;
expectedDurationMs?: number | null;
resultUrl?: string | null;
error?: string | null;
}
@@ -37,7 +41,8 @@ export function waitForTask(
operation: options.operation,
});
const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs;
const noProgressTimeoutMs = options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
const noProgressTimeoutMs =
options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
const startedAt = options.startedAt ?? Date.now();
return new Promise((resolve, reject) => {
@@ -58,7 +63,10 @@ export function waitForTask(
};
timeoutId = setTimeout(
() => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))),
() =>
settle(() =>
reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))),
),
timeoutMs,
);
@@ -105,7 +113,11 @@ export function waitForTask(
policy: { ...timeoutPolicy, noProgressTimeoutMs },
});
if (timeoutReason) {
settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))));
settle(() =>
reject(
new Error(buildLocalTimeoutMessage(options.kind || "video")),
),
);
return;
}
try {
@@ -114,6 +126,10 @@ export function waitForTask(
taskId,
status: task.status,
progress: task.progress || 0,
progressSource: task.progressSource,
stage: task.stage,
startedAt: task.startedAt,
expectedDurationMs: task.expectedDurationMs,
resultUrl: task.resultUrl,
error: task.error,
});
-3
View File
@@ -10,7 +10,6 @@ import NotificationCenter from "./NotificationCenter";
import BetaApplicationModal from "./BetaApplicationModal";
import { AnimatedPanel } from "./AnimatedPanel";
import AdminMonitor from "./AdminMonitor";
import CookieConsentBanner from "./CookieConsentBanner";
import { loadRechargeModal, type RechargeModalComponent } from "./RechargeModal/loadRechargeModal";
import { ShellIcon } from "./ShellIcon";
import { loadDarkGreenTheme } from "../styles/loadDarkGreenTheme";
@@ -76,7 +75,6 @@ function AppShell({
session,
usage,
notifications,
backendHealth,
workspaceExpanded,
onSelectView,
onLogout,
@@ -85,7 +83,6 @@ function AppShell({
onMarkAllNotificationsRead,
children,
}: AppShellProps) {
const activePackage = session?.user.activePackages?.[0];
const profileRef = useRef<HTMLDivElement>(null);
const submenuHideTimerRef = useRef<number | null>(null);
const [profileOpen, setProfileOpen] = useState(false);
+6 -1
View File
@@ -8,6 +8,7 @@ interface BeforeAfterCompareProps {
sourceAlt?: string;
resultAlt?: string;
className?: string;
aspectRatio?: string;
onSourceLoad?: (width: number, height: number) => void;
}
@@ -26,6 +27,7 @@ export default function BeforeAfterCompare({
sourceAlt = "原图",
resultAlt = "结果",
className = "",
aspectRatio,
onSourceLoad,
}: BeforeAfterCompareProps) {
const stageRef = useRef<HTMLDivElement>(null);
@@ -43,7 +45,10 @@ export default function BeforeAfterCompare({
<div
ref={stageRef}
className={`before-after-compare ${className}`}
style={{ "--compare-position": `${position}%` } as CSSProperties}
style={{
"--compare-position": `${position}%`,
...(aspectRatio ? { "--compare-aspect-ratio": aspectRatio } : {}),
} as CSSProperties}
aria-label="前后对比"
>
<div className="before-after-compare__layer before-after-compare__layer--source">
-1
View File
@@ -1,5 +1,4 @@
import { HomeOutlined } from "@ant-design/icons";
import { useCallback } from "react";
import "../styles/pages/not-found.css";
interface NotFoundPageProps {
+1 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { CloseOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons";
import "../styles/components/onboarding.css";
+1 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
export type ToastType = "success" | "error" | "info";
+2 -2
View File
@@ -10,7 +10,7 @@ import {
SearchOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type JSX } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactElement } from "react";
import "../../styles/pages/assets.css";
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
import { aiGenerationClient } from "../../api/aiGenerationClient";
@@ -36,7 +36,7 @@ interface AssetsPageProps {
onOpenLogin: () => void;
}
const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: JSX.Element | null }> = [
const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: ReactElement | null }> = [
{ key: "all", label: "全部", icon: null },
{ key: "character", label: "人物", icon: <UserOutlined /> },
{ key: "scene", label: "场景", icon: <FileImageOutlined /> },
+50 -58
View File
@@ -12,15 +12,11 @@
FileImageOutlined,
FileTextOutlined,
FolderOpenOutlined,
MutedOutlined,
PauseCircleOutlined,
PictureOutlined,
PlayCircleOutlined,
ReloadOutlined,
SaveOutlined,
SearchOutlined,
SendOutlined,
SoundOutlined,
ThunderboltOutlined,
UploadOutlined,
VideoCameraOutlined,
@@ -37,10 +33,11 @@ import { communityClient } from "../../api/communityClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import type {
WebCanvasWorkflow,
WebCanvasWorkflowNodePackage,
} from "../../types";
import {
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS,
} from "../../hooks/useSmoothedProgress";
import type { WebCanvasWorkflow } from "../../types";
import type { AssetLibraryCategory } from "../assets/localAssetStore";
import {
buildCanvasCommunityCaseInput,
@@ -64,7 +61,6 @@ import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolic
import { filterImageModelOptionsForSession } from "../../utils/imageModelVisibility";
import { translateTaskError } from "../../utils/translateTaskError";
import type {
CanvasAlignGuide,
CanvasAssetSaveSource,
CanvasCopiedNode,
CanvasConnectorDrag,
@@ -72,37 +68,25 @@ import type {
CanvasFloatingMenuPosition,
CanvasImageFocusDrag,
CanvasImageFocusSelection,
CanvasImageGenerationState,
CanvasImageNode,
CanvasImageNodeDrag,
CanvasImageReferenceItem,
CanvasManualLink,
CanvasNodeBounds,
CanvasNodeKind,
CanvasNodePackage,
CanvasNodePackageDrag,
CanvasNodePort,
CanvasNodeResizeDrag,
CanvasNodeSize,
CanvasOption,
CanvasPageProps,
CanvasPanDrag,
CanvasPoint,
CanvasProjectSaveState,
CanvasSelectedNode,
CanvasSelectionDrag,
CanvasStyleCase,
CanvasStylePickerTab,
CanvasStyleReference,
CanvasTextGenerationState,
CanvasPromptMentionOption,
CanvasPromptMentionState,
CanvasTextNode,
CanvasTextNodeDrag,
CanvasVideoGenerationState,
CanvasVideoMode,
CanvasVideoNode,
CanvasVideoNodeDrag,
CanvasViewport,
} from "./canvasTypes";
import {
@@ -110,7 +94,6 @@ import {
canvasAutoSaveDebounceMs,
canvasAutoSaveIdleTimeoutMs,
canvasNodeClickMoveThreshold,
canvasNodeDefaultSizes,
canvasStylePickerCategories,
canvasStylePickerTabs,
connectorAnchorOutset,
@@ -120,22 +103,17 @@ import {
defaultImageModel,
defaultTextModelId,
defaultVideoModel,
image4kCapableModels,
imageFocusRatioOptions,
imageModelOptions as fallbackCanvasImageModelOptions,
imageRatioOptions,
textModelOptions,
videoDurationOptions,
videoRatioOptions,
} from "./canvasConstants";
import {
applyImageFocusRatioFromTopLeft,
blobToDataUrl,
buildCanvasStyleKeywords,
buildCopyTitle,
clampCanvasPercent,
buildReversePromptFromAsset,
canvasGenerationProgressStyle,
clampCanvasNodeSize,
clampCanvasViewportZoom,
communityCaseToCanvasStyleCase,
@@ -150,15 +128,8 @@ import {
getDefaultImageQuality,
getDefaultVideoQuality,
getImageQualityOptions,
getOptionLabel,
getVideoQualityOptions,
getWorkflowImageNodeFileName,
getWorkflowImageNodePrompt,
getWorkflowNodeFocusSelection,
getWorkflowNodeMetadataString,
getWorkflowNodeStyleReference,
hasCanvasOptionValue,
moveCanvasNodesForPackageDrag,
normalizeCanvasGenerationProgress,
normalizeCanvasLinkPorts,
normalizeCanvasSelectionRect,
@@ -166,10 +137,6 @@ import {
positionFloatingMenu,
resolveImageQuality,
resolveVideoQuality,
resolveWorkflowImageModel,
resolveWorkflowRatio,
resolveWorkflowVideoMode,
resolveWorkflowVideoModel,
waitForImageTaskResult,
waitForVideoTaskResult,
} from "./canvasUtils";
@@ -181,11 +148,9 @@ import {
createVideoNodesFromWorkflow,
createWorkflowPackagesFromCanvasPackages,
formatCanvasProjectUpdatedAt,
formatCanvasVideoTime,
resolveAssetCategory,
} 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";
@@ -396,8 +361,6 @@ function CanvasPage({
const canvasUploadInputRef = useRef<HTMLInputElement>(null);
const imageNodeInputRef = useRef<HTMLInputElement>(null);
const canvasRef = useRef<HTMLElement>(null);
const videoGenerationInFlightRef = useRef(new Set<string>());
const canvasReferenceUploadPromisesRef = useRef(new Map<string, Promise<string | null>>());
const canvasDragCounterRef = useRef(0);
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
const suppressNextPaneClickRef = useRef(false);
@@ -412,12 +375,19 @@ function CanvasPage({
const textNodeIdRef = useRef(9);
const imageNodeIdRef = useRef(1);
const videoNodeIdRef = useRef(1);
const objectUrlsRef = useRef(new Set<string>());
const trackObjectUrl = (file: Blob) => {
const url = URL.createObjectURL(file);
objectUrlsRef.current.add(url);
return url;
};
const { pushSnapshot, undo, redo, canUndo, canRedo } = useCanvasHistory();
const { pushSnapshot, undo, redo } = useCanvasHistory();
const {
textGenerationState, imageGenerationState, videoGenerationState,
generationToast, setGenerationToast,
imageGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
imageGenerationAbortRef, videoGenerationAbortRef,
canvasGenKeepaliveRestoredRef,
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
restoreKeepaliveTasks, resetGenerationState,
@@ -568,6 +538,7 @@ function CanvasPage({
const autoSaveStatusTimerRef = useRef<number | null>(null);
useEffect(() => {
const objectUrls = objectUrlsRef.current;
return () => {
if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current);
if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
@@ -576,6 +547,8 @@ function CanvasPage({
if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
}
objectUrls.forEach((url) => URL.revokeObjectURL(url));
objectUrls.clear();
};
}, []);
@@ -1732,12 +1705,15 @@ function CanvasPage({
const quality = resolveImageQuality(model, imageNode.imageSize || "");
imageGenerationInFlightRef.current.add(nodeId);
const abortRef = { current: false };
imageGenerationAbortRef.current.set(nodeId, abortRef);
setImageGenerationStatus(nodeId, { status: "submitting", message: "正在提交生成", progress: 8 });
setGenerationToast("图片正在生成");
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
try {
const referenceUrls = await resolveConnectedImageReferenceUrls("image", nodeId, imageNode);
if (abortRef.current) return;
const taskInput: CreatePreviewTaskInput = {
title: imageNode.title || "图片节点生成",
type: "image",
@@ -1773,7 +1749,8 @@ function CanvasPage({
? "图片生成完成"
: "图片生成失败";
setImageGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
}));
}, abortRef));
if (abortRef.current || !outputUrl) return;
setImageGenerationStatus(nodeId, { status: "success", message: "生成完成", progress: 100 });
removeCanvasGenKeepalive(task.id);
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
@@ -1835,13 +1812,15 @@ function CanvasPage({
);
}
} catch (error) {
if (abortRef.current) return;
setImageGenerationStatus(nodeId, {
status: "error",
message: error instanceof Error ? error.message : "图片生成失败",
});
} finally {
imageGenerationInFlightRef.current.delete(nodeId);
if (task?.id) removeCanvasGenKeepalive(task.id);
imageGenerationAbortRef.current.delete(nodeId);
if (task?.id && !abortRef.current) removeCanvasGenKeepalive(task.id);
}
};
@@ -1884,16 +1863,20 @@ function CanvasPage({
const duration = Number(videoNode.duration) || 4;
videoGenerationInFlightRef.current.add(nodeId);
const abortRef = { current: false };
videoGenerationAbortRef.current.set(nodeId, abortRef);
setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 });
setGenerationToast("视频正在生成");
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
try {
const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId);
if (abortRef.current) return;
if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) {
throw new Error("图生视频需要先连接至少一个可用的图片节点");
}
let requestModel = resolveVideoRequestModel({ model, referenceUrls });
const task = await onCreateTask({
const requestModel = resolveVideoRequestModel({ model, referenceUrls });
task = await onCreateTask({
title: videoNode.title || "视频节点生成",
type: "video",
prompt: prompt || "根据参考图片生成视频",
@@ -1916,10 +1899,12 @@ function CanvasPage({
if (task.status === "completed" && !task.outputUrl) {
throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
}
const taskId = task.id;
addCanvasGenKeepalive(taskId, nodeId, "video", projectId || "");
setVideoGenerationStatus(nodeId, { status: "running", message: "视频生成中", progress: Math.max(18, Number(task.progress || 0)) });
const outputUrl =
task.outputUrl ||
(await waitForImageTaskResult(task.id, (status) => {
(await waitForVideoTaskResult(taskId, (status) => {
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
const statusLabel =
status.status === "pending"
@@ -1930,13 +1915,15 @@ function CanvasPage({
? "视频生成完成"
: "视频生成失败";
setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
}));
}, abortRef));
if (abortRef.current || !outputUrl) return;
setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 });
removeCanvasGenKeepalive(taskId);
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
url: outputUrl,
mediaType: "video/mp4",
resultType: "video",
taskId: task.id,
taskId,
originalUrl: outputUrl,
});
setVideoNodes((currentNodes) =>
@@ -1947,7 +1934,7 @@ function CanvasPage({
videoUrl: outputUrl,
assetRef: immediateAssetRef,
taskRef: {
taskId: task.id,
taskId,
status: "completed",
resultUrl: outputUrl,
updatedAt: new Date().toISOString(),
@@ -1961,7 +1948,7 @@ function CanvasPage({
url: outputUrl,
mediaType: "video/mp4",
resultType: "video",
taskId: task.id,
taskId,
originalUrl: outputUrl,
});
await delay(420);
@@ -1974,7 +1961,7 @@ function CanvasPage({
videoUrl: assetRef.url,
assetRef,
taskRef: {
taskId: task.id,
taskId,
status: "completed",
resultUrl: assetRef.url,
updatedAt: new Date().toISOString(),
@@ -1985,12 +1972,15 @@ function CanvasPage({
);
}
} catch (error) {
if (abortRef.current) return;
setVideoGenerationStatus(nodeId, {
status: "error",
message: error instanceof Error ? error.message : "视频生成失败",
});
} finally {
videoGenerationInFlightRef.current.delete(nodeId);
videoGenerationAbortRef.current.delete(nodeId);
if (task?.id && !abortRef.current) removeCanvasGenKeepalive(task.id);
}
};
@@ -2001,7 +1991,7 @@ function CanvasPage({
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const imageUrl = URL.createObjectURL(file);
const imageUrl = trackObjectUrl(file);
if (pendingImageToImageNodeId) {
const sourceNode = imageNodes.find((node) => node.id === pendingImageToImageNodeId);
if (sourceNode) {
@@ -2083,7 +2073,7 @@ function CanvasPage({
let offsetX = 0;
let offsetY = 0;
for (const file of files) {
const imageUrl = URL.createObjectURL(file);
const imageUrl = trackObjectUrl(file);
addImageNode(imageUrl, file.name, {
x: dropPosition.x + offsetX,
y: dropPosition.y + offsetY,
@@ -2139,7 +2129,7 @@ function CanvasPage({
let offsetX = 0;
let offsetY = 0;
for (const file of files) {
const imageUrl = URL.createObjectURL(file);
const imageUrl = trackObjectUrl(file);
addImageNode(imageUrl, file.name, {
x: sourceNode.position.x + sourceNode.size.width + 40 + offsetX,
y: sourceNode.position.y + offsetY,
@@ -4506,6 +4496,7 @@ function CanvasPage({
progress={imageNodeProgress}
status={imageTaskState?.status || "running"}
message={imageTaskState?.message || "图片生成中"}
expectedDurationMs={DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS}
/>
) : null}
{imageNodeFocusActive && imageFocusSelectionReady ? (
@@ -4880,6 +4871,7 @@ function CanvasPage({
progress={videoNodeProgress}
status={videoTaskState?.status || "running"}
message={videoTaskState?.message || "视频生成中"}
expectedDurationMs={DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS}
/>
) : null}
{renderConnectorButton({ kind: "video", nodeId: videoNode.id, side: "left", slot: "center" }, "studio-canvas-video-node__connector")}
@@ -5315,7 +5307,7 @@ function CanvasPage({
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
setAssetCoverUrl(URL.createObjectURL(file));
setAssetCoverUrl(trackObjectUrl(file));
setCoverSourceOpen(false);
}}
/>
@@ -1,4 +1,8 @@
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
import {
DEFAULT_GENERATION_EXPECTED_DURATION_MS,
useSmoothedProgress,
type ProgressSource,
} from "../../hooks/useSmoothedProgress";
import { canvasGenerationProgressStyle } from "./canvasUtils";
type NodeGenStatus = "submitting" | "running" | "success" | "error";
@@ -7,10 +11,24 @@ interface CanvasSmoothedProgressRingProps {
progress: number;
status: NodeGenStatus;
message?: string;
progressSource?: ProgressSource;
startedAt?: number | string | Date | null;
expectedDurationMs?: number | null;
}
export function CanvasSmoothedProgressRing({ progress, status, message }: CanvasSmoothedProgressRingProps) {
const smoothed = useSmoothedProgress(progress, status);
export function CanvasSmoothedProgressRing({
progress,
status,
message,
progressSource = "estimated",
startedAt,
expectedDurationMs = DEFAULT_GENERATION_EXPECTED_DURATION_MS,
}: CanvasSmoothedProgressRingProps) {
const smoothed = useSmoothedProgress(progress, status, {
progressSource,
startedAt,
expectedDurationMs,
});
return (
<div
className="studio-canvas-node-generation-progress"
@@ -18,7 +36,10 @@ export function CanvasSmoothedProgressRing({ progress, status, message }: Canvas
aria-live="polite"
style={canvasGenerationProgressStyle(smoothed)}
>
<span className="studio-canvas-node-generation-progress__ring" aria-hidden="true" />
<span
className="studio-canvas-node-generation-progress__ring"
aria-hidden="true"
/>
<strong>{message}</strong>
<em>{smoothed}%</em>
</div>
@@ -106,7 +106,11 @@ function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Unable to read canvas result"));
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Unable to read canvas result"));
}
};
reader.onerror = () => reject(reader.error || new Error("Unable to read canvas result"));
reader.readAsDataURL(blob);
-7
View File
@@ -1,17 +1,10 @@
import {
CopyOutlined,
DeleteOutlined,
DownloadOutlined,
DownOutlined,
ExpandOutlined,
MutedOutlined,
PauseCircleOutlined,
PictureOutlined,
PlayCircleOutlined,
ReloadOutlined,
SaveOutlined,
SoundOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type MouseEvent } from "react";
import type { CanvasOption } from "./canvasTypes";
-21
View File
@@ -146,26 +146,6 @@ export function CanvasInpaintPanel({ imageUrl, onComplete }: CanvasToolPanelProp
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("请输入重绘提示词");
@@ -174,7 +154,6 @@ export function CanvasInpaintPanel({ imageUrl, onComplete }: CanvasToolPanelProp
setLoading(true);
cancelRef.current = false;
try {
const maskDataUrl = getMaskDataUrl();
const { taskId } = await aiGenerationClient.createImageEditTask({
imageUrl,
function: "inpaint",
+47 -8
View File
@@ -1,5 +1,5 @@
import type { CSSProperties } from "react";
import { aiGenerationClient, type AiTaskStatus } from "../../api/aiGenerationClient";
import type { AiTaskStatus } from "../../api/aiGenerationClient";
import type { ServerCommunityCase } from "../../api/communityClient";
import { waitForTask } from "../../api/taskSubscription";
import type { WebCanvasWorkflow } from "../../types";
@@ -22,7 +22,6 @@ import type {
CanvasVideoMode,
} from "./canvasTypes";
import {
assetLibraryCategories,
assetTypePromptLabel,
canvasNodeDefaultSizes,
canvasNodeMaxSizes,
@@ -194,7 +193,7 @@ export function resolveWorkflowImageModel(node: WebCanvasWorkflow["nodes"][numbe
return defaultImageModel;
}
export function resolveWorkflowVideoModel(node: WebCanvasWorkflow["nodes"][number], workflowModel: string) {
export function resolveWorkflowVideoModel(node: WebCanvasWorkflow["nodes"][number], _workflowModel: string) {
const raw = getWorkflowNodeMetadataString(node, "model");
const storedModel = toPixverseDisplayModel(toViduDisplayModel(toHappyHorseDisplayModel(raw)));
if (hasCanvasOptionValue(videoModelOptions, storedModel)) return storedModel;
@@ -242,31 +241,71 @@ export function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Unable to read canvas image"));
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Unable to read canvas image"));
}
};
reader.onerror = () => reject(reader.error || new Error("Unable to read canvas image"));
reader.readAsDataURL(blob);
});
}
export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
export async function waitForImageTaskResult(
taskId: string,
onStatus?: (status: AiTaskStatus) => void,
abortRef?: { current: boolean },
) {
const resultUrl = await waitForTask(taskId, {
kind: "image",
abortRef,
onProgress: (e) => {
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
if (onStatus) {
onStatus({
taskId,
status: e.status,
progress: e.progress,
progressSource: e.progressSource,
stage: e.stage,
startedAt: e.startedAt,
expectedDurationMs: e.expectedDurationMs,
resultUrl: e.resultUrl ?? undefined,
error: e.error ?? undefined,
} as AiTaskStatus);
}
},
});
if (abortRef?.current) return "";
if (!resultUrl) throw new Error("生成任务已完成,但服务器没有返回结果地址,请稍后重试");
return resultUrl;
}
export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
export async function waitForVideoTaskResult(
taskId: string,
onStatus?: (status: AiTaskStatus) => void,
abortRef?: { current: boolean },
) {
const resultUrl = await waitForTask(taskId, {
kind: "video",
abortRef,
onProgress: (e) => {
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
if (onStatus) {
onStatus({
taskId,
status: e.status,
progress: e.progress,
progressSource: e.progressSource,
stage: e.stage,
startedAt: e.startedAt,
expectedDurationMs: e.expectedDurationMs,
resultUrl: e.resultUrl ?? undefined,
error: e.error ?? undefined,
} as AiTaskStatus);
}
},
});
if (abortRef?.current) return "";
if (!resultUrl) throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
return resultUrl;
}
@@ -90,7 +90,7 @@ export function buildCanvasVideoTaskInput(workflow: WebCanvasWorkflow, nodeId: s
const params = context.node.params || {};
const referenceUrls = context.imageReferences.map((item) => item.url);
const displayModel = toHappyHorseDisplayModel(String(params.model || workflow.settings.model || "happyhorse-1.0"));
let model = resolveVideoRequestModel({ model: displayModel, referenceUrls });
const model = resolveVideoRequestModel({ model: displayModel, referenceUrls });
return {
title: context.node.label || "视频节点生成",
type: "video",
+71 -7
View File
@@ -1,4 +1,4 @@
import { type Dispatch, type MutableRefObject, type SetStateAction, useEffect, useRef, useState } from "react";
import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from "react";
import type {
CanvasImageGenerationState,
CanvasImageNode,
@@ -6,6 +6,7 @@ import type {
CanvasVideoGenerationState,
CanvasVideoNode,
} from "./canvasTypes";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence";
import { waitForImageTaskResult, waitForVideoTaskResult } from "./canvasUtils";
@@ -41,6 +42,13 @@ export function removeCanvasGenKeepalive(taskId: string): void {
saveCanvasGenKeepalive(loadCanvasGenKeepalive().filter((e) => e.taskId !== taskId));
}
export function cancelCanvasGenKeepaliveOnUnload(): void {
const entries = loadCanvasGenKeepalive();
if (!entries.length) return;
entries.forEach((entry) => aiGenerationClient.cancelTaskOnUnload(entry.taskId));
saveCanvasGenKeepalive([]);
}
export interface UseCanvasGenerationParams {
setImageNodes: Dispatch<SetStateAction<CanvasImageNode[]>>;
setVideoNodes: Dispatch<SetStateAction<CanvasVideoNode[]>>;
@@ -55,8 +63,11 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
const [generationToast, setGenerationToast] = useState<string | null>(null);
const imageGenerationInFlightRef = useRef(new Set<string>());
const videoGenerationInFlightRef = useRef(new Set<string>());
const textGenerationInFlightRef = useRef(new Set<string>());
const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>());
const imageGenerationAbortRef = useRef(new Map<string, { current: boolean }>());
const videoGenerationAbortRef = useRef(new Map<string, { current: boolean }>());
const canvasGenKeepaliveRestoredRef = useRef(false);
const setTextGenerationStatus = (nodeId: string, state: CanvasTextGenerationState) => {
@@ -71,6 +82,15 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
setVideoGenerationState((current) => ({ ...current, [nodeId]: state }));
};
const abortAllGenerationPollers = useCallback(() => {
textGenerationAbortControllersRef.current.forEach((c) => c.abort());
textGenerationAbortControllersRef.current.clear();
imageGenerationAbortRef.current.forEach((ref) => { ref.current = true; });
imageGenerationAbortRef.current.clear();
videoGenerationAbortRef.current.forEach((ref) => { ref.current = true; });
videoGenerationAbortRef.current.clear();
}, []);
// Toast auto-dismiss
useEffect(() => {
if (!generationToast) return undefined;
@@ -94,11 +114,14 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
}
if (entry.nodeKind === "image") {
imageGenerationInFlightRef.current.add(entry.nodeId);
const abortRef = { current: false };
imageGenerationAbortRef.current.set(entry.nodeId, abortRef);
setImageGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复图片生成", progress: 20 });
void waitForImageTaskResult(entry.taskId, (status) => {
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
setImageGenerationStatus(entry.nodeId, { status: "running", message: "图片生成中", progress });
}).then(async (outputUrl) => {
}, abortRef).then(async (outputUrl) => {
if (abortRef.current || !outputUrl) return;
removeCanvasGenKeepalive(entry.taskId);
setImageGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
const ref = createCanvasAssetRefFromGeneratedResult({
@@ -119,18 +142,23 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
));
}
}).catch(() => {
if (abortRef.current) return;
removeCanvasGenKeepalive(entry.taskId);
setImageGenerationStatus(entry.nodeId, { status: "error", message: "图片生成失败" });
}).finally(() => {
imageGenerationInFlightRef.current.delete(entry.nodeId);
imageGenerationAbortRef.current.delete(entry.nodeId);
});
} else if (entry.nodeKind === "video") {
imageGenerationInFlightRef.current.add(entry.nodeId);
videoGenerationInFlightRef.current.add(entry.nodeId);
const abortRef = { current: false };
videoGenerationAbortRef.current.set(entry.nodeId, abortRef);
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 });
void waitForVideoTaskResult(entry.taskId, (status) => {
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "视频生成中", progress });
}).then(async (outputUrl) => {
}, abortRef).then(async (outputUrl) => {
if (abortRef.current || !outputUrl) return;
removeCanvasGenKeepalive(entry.taskId);
setVideoGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
const ref = createCanvasAssetRefFromGeneratedResult({
@@ -151,25 +179,58 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
));
}
}).catch(() => {
if (abortRef.current) return;
removeCanvasGenKeepalive(entry.taskId);
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
}).finally(() => {
imageGenerationInFlightRef.current.delete(entry.nodeId);
videoGenerationInFlightRef.current.delete(entry.nodeId);
videoGenerationAbortRef.current.delete(entry.nodeId);
});
}
}
};
const resetGenerationState = () => {
textGenerationAbortControllersRef.current.forEach((c) => c.abort());
textGenerationAbortControllersRef.current.clear();
abortAllGenerationPollers();
textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear();
videoGenerationInFlightRef.current.clear();
setTextGenerationState({});
setImageGenerationState({});
setVideoGenerationState({});
};
// Stop all in-flight front-end polling/setState when the canvas unmounts (route change).
// Keepalive records are intentionally preserved so restoreKeepaliveTasks can resume on return.
useEffect(() => {
return () => {
abortAllGenerationPollers();
};
}, [abortAllGenerationPollers]);
useEffect(() => {
const handlePageHide = () => {
cancelCanvasGenKeepaliveOnUnload();
abortAllGenerationPollers();
textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear();
videoGenerationInFlightRef.current.clear();
setTextGenerationState({});
setImageGenerationState({});
setVideoGenerationState({});
};
const handleOnline = () => {
aiGenerationClient.flushPendingTaskCancellations();
};
window.addEventListener("pagehide", handlePageHide);
window.addEventListener("online", handleOnline);
aiGenerationClient.flushPendingTaskCancellations();
return () => {
window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("online", handleOnline);
};
}, [abortAllGenerationPollers]);
return {
textGenerationState,
imageGenerationState,
@@ -177,8 +238,11 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
generationToast,
setGenerationToast,
imageGenerationInFlightRef,
videoGenerationInFlightRef,
textGenerationInFlightRef,
textGenerationAbortControllersRef,
imageGenerationAbortRef,
videoGenerationAbortRef,
canvasGenKeepaliveRestoredRef,
setTextGenerationStatus,
setImageGenerationStatus,
+1 -1
View File
@@ -1,4 +1,4 @@
import { type Dispatch, type MouseEvent, type MutableRefObject, type SetStateAction, useEffect, useRef, useState } from "react";
import { type Dispatch, type MouseEvent, type MutableRefObject, type SetStateAction, useEffect, useState } from "react";
import { canvasNodeClickMoveThreshold } from "./canvasConstants";
import type {
CanvasAlignGuide,
+119 -75
View File
@@ -13,7 +13,6 @@ import {
RightOutlined,
ScissorOutlined,
SwapOutlined,
ThunderboltOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
@@ -282,6 +281,94 @@ function CharacterMixPage({
}
};
const clearCharacterAsset = () => {
if (characterPreview) URL.revokeObjectURL(characterPreview);
setCharacterFile("");
setCharacterPreview("");
setCharacterDataUrl("");
setFaceHint(null);
if (characterInputRef.current) characterInputRef.current.value = "";
setNotice("已移除人物图");
};
const clearReferenceVideo = () => {
if (videoPreview) URL.revokeObjectURL(videoPreview);
setVideoFile("");
setVideoPreview("");
setVideoDataUrl("");
if (videoInputRef.current) videoInputRef.current.value = "";
setNotice("已移除参考视频");
};
const characterMixSettingsPanel = (
<div className="studio-panel__section character-mix-settings-panel">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
</div>
<div className="studio-panel__section-body">
<div className="character-mix-prompt-field">
<div className="studio-label"></div>
<textarea
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
placeholder="保持角色原有服装,动作流畅自然"
rows={3}
maxLength={1000}
/>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${checkImage ? " is-on" : ""}`} onClick={() => setCheckImage(!checkImage)}>
<span className="studio-toggle__thumb" />
</button>
</div>
{checkImage && characterPreview && faceHint && (
<div className={`character-mix-face-hint character-mix-face-hint--${faceHint}`}>
{faceHint === "analyzing" ? (
<>
<InfoCircleOutlined />
<span>...</span>
</>
) : (
<>
<CheckCircleOutlined />
<span></span>
</>
)}
</div>
)}
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !characterDataUrl || !videoDataUrl}>
{isCreating ? <LoadingOutlined /> : <PlayCircleOutlined />}
{isCreating ? "生成中..." : "开始迁移"}
</button>
{resultUrl && (
<div className="studio-result-actions">
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
<DownloadOutlined />
{isDownloadingResult ? "保存中" : "保存本地"}
</button>
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
<InboxOutlined />
{isSavingResultAsset ? "加入中" : "加入资产库"}
</button>
</div>
)}
</div>
</div>
);
return (
<section className="image-workbench-page character-mix-page" aria-label="角色迁移">
<header className="image-workbench-topbar">
@@ -340,8 +427,10 @@ function CharacterMixPage({
<StudioToolLayout
noTop
noRight
leftPanel={
<div
className="character-mix-source-panel"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -390,6 +479,20 @@ function CharacterMixPage({
<strong>{characterFile || "上传人物图"}</strong>
<small></small>
</span>
{characterPreview ? (
<button
type="button"
className="studio-upload-slot__remove"
aria-label="移除人物图"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearCharacterAsset();
}}
>
<DeleteOutlined />
</button>
) : null}
</label>
</div>
</div>
@@ -428,9 +531,24 @@ function CharacterMixPage({
<strong>{videoFile || "上传参考视频"}</strong>
<small>MP4 / MOV / AVI</small>
</span>
{videoPreview ? (
<button
type="button"
className="studio-upload-slot__remove"
aria-label="移除参考视频"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearReferenceVideo();
}}
>
<DeleteOutlined />
</button>
) : null}
</label>
</div>
</div>
{characterMixSettingsPanel}
</div>
}
canvas={
@@ -481,80 +599,6 @@ function CharacterMixPage({
</div>
)
}
rightPanel={
<div className="studio-panel__section">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
</div>
<div className="studio-panel__section-body">
<div style={{ marginBottom: 10 }}>
<div className="studio-label" style={{ fontSize: 11, color: "var(--fg-muted, #999)", marginBottom: 4 }}></div>
<textarea
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
placeholder="保持角色原有服装,动作流畅自然"
rows={3}
maxLength={1000}
style={{
width: "100%", resize: "vertical", background: "var(--bg-elevated, #1a1a1a)",
border: "1px solid var(--border-subtle, #333)", borderRadius: 6,
padding: "6px 8px", fontSize: 12, color: "var(--fg-body, #eee)",
fontFamily: "inherit", outline: "none", boxSizing: "border-box",
}}
/>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${checkImage ? " is-on" : ""}`} onClick={() => setCheckImage(!checkImage)}>
<span className="studio-toggle__thumb" />
</button>
</div>
{checkImage && characterPreview && faceHint && (
<div className={`character-mix-face-hint character-mix-face-hint--${faceHint}`}>
{faceHint === "analyzing" ? (
<>
<InfoCircleOutlined />
<span>...</span>
</>
) : (
<>
<CheckCircleOutlined />
<span></span>
</>
)}
</div>
)}
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !characterDataUrl || !videoDataUrl}>
{isCreating ? <LoadingOutlined /> : <PlayCircleOutlined />}
{isCreating ? "生成中..." : "开始迁移"}
</button>
{resultUrl && (
<div className="studio-result-actions">
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
<DownloadOutlined />
{isDownloadingResult ? "保存中" : "保存本地"}
</button>
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
<InboxOutlined />
{isSavingResultAsset ? "加入中" : "加入资产库"}
</button>
</div>
)}
</div>
</div>
}
statusBar={
<>
<span className="studio-status-bar__badge studio-status-bar__badge--idle"></span>
+210 -59
View File
@@ -7,61 +7,213 @@ interface CompliancePageProps {
kind: ComplianceKind;
}
const companyName = "OmniAI";
const companyName = "南京万物可爱文化传媒有限公司";
const platformName = "Omniai平台";
const contactEmail = "system@omniai.net.cn";
const contactPhone = "15155073618";
const address = "江苏省南京市江北新区扬子江数字视听产业园9栋A楼501";
const address = "江苏省南京市江北新区扬子江数字视听产业园9栋A楼9A-501";
const icpRecord = "苏ICP备2026021747号-1";
const effectiveDate = "2026年06月8日";
const agreementSections = [
{
title: "服务范围",
body: "平台提供 AI 图片、视频、脚本、数字人及相关创作辅助服务。具体功能、模型能力、消耗规则以页面展示和平台公告为准。",
},
{
title: "账号与使用",
body: "用户应保证注册信息真实有效,妥善保管账号与登录凭证,不得出租、转让账号或以自动化方式恶意占用平台资源。",
},
{
title: "内容合规",
body: "用户不得上传、生成、发布违法违规、侵权、涉政敏感、暴恐、色情、赌博、诈骗或侵犯他人合法权益的内容。平台有权对违规内容采取删除、限制功能、封禁账号等措施。",
},
{
title: "积分与付费",
body: "积分仅限平台内消费,不支持提现、转让或折现。充值、套餐、赠送积分的有效期、消耗顺序和退费规则以充值页面展示为准。",
},
{
title: "责任限制",
body: "AI 生成结果可能存在偏差,用户应自行审核输出内容并承担使用后果。因不可抗力、第三方服务异常、网络故障造成的服务中断,平台将在合理范围内修复。",
},
];
const privacyPolicyText = `
隐私政策
更新日期:2026年06月8日
生效日期:2026年06月8日
欢迎您使用本平台服务!
南京万物可爱文化传媒有限公司(以下简称“我们”或“平台”)是Omniai平台的运营者。我们非常重视用户的隐私和个人信息保护。您在使用我们的产品与/或服务时,我们可能会收集和使用您的相关信息。我们希望通过本《隐私政策》(以下简称“本政策”)向您说明,在您使用我们的产品与/或服务时,我们如何收集、使用、保存、共享和转让您的个人信息,以及您所享有的个人信息权利。
本政策与您所使用的我们的产品与/或服务息息相关,请您务必仔细阅读并确认您已经充分理解本政策的内容。一旦您开始使用我们的产品与/或服务,即表示您已充分理解并同意本政策。
本政策将帮助您了解以下内容:
一、本政策的适用范围
二、我们如何收集和使用您的个人信息
三、我们如何使用Cookie和同类技术
四、我们如何共享、转让、公开披露您的个人信息
五、我们如何保护和存储您的个人信息
六、您如何管理您的个人信息
七、我们如何保护未成年人的个人信息
八、本政策如何更新
九、其他声明与责任限制
十、如何联系我们
一、本政策的适用范围
1. 本政策适用于您通过我们的网站、客户端应用程序、小程序以及其他技术形态(包括但不限于SDK、API等方式)访问和使用我们的产品与/或服务。
2. 本政策不适用于以下情况:
(1)我们的产品与/或服务中包含的或链接至第三方提供的信息与/或服务(包括任何第三方应用、网站、产品、服务等)。这些服务由第三方负责运营,您使用该服务适用第三方另行向您说明的个人信息处理规则。
(2)其他非我们向您提供的产品与/或服务。
(3)特别提示:用户间交易。您通过平台社区与其他用户进行作品、素材、版权等交易时,您主动向交易对方提供的个人信息(如联系方式、交付地址等)不适用本政策。我们建议您在交易过程中谨慎保护您的个人信息,并充分了解交易对方的信用状况。
二、我们如何收集和使用您的个人信息
我们会遵循合法、正当、必要、诚信的原则,基于本政策所述的目的,收集和使用您的个人信息。如果我们将您的个人信息用于本政策未载明的其他用途,我们将以合理方式另行向您告知并征得您的同意。
(一)注册、登录与认证
1. 必要信息:当您注册平台账号时,您需要提供您的手机号码及验证码/登录密码。手机号码是履行国家法律法规对网络实名制(真实身份信息认证)要求所需的必要信息,若您不提供,您将无法完成注册,仅能使用浏览、搜索等基本功能。
2. 企业用户:如您以企业身份注册,您还需提供企业名称、营业执照、法定代表人/联系人信息等,以便我们核验您的企业身份,为您提供企业级服务。
3. 第三方账号登录:您可以使用我们认可的第三方账号(如微信、QQ等)进行登录。我们会收集您在该第三方账号下的唯一标识、昵称、头像等信息,用于创建您在本平台的账号。
4. 账号信息完善:您可以选择填写或修改您的昵称、头像、个人简介、所在地区等信息。此类信息非必要,但有助于提升您的社区交互体验。
(二)浏览、搜索与内容发布
1. 内容浏览:当您浏览平台上的模型、素材、作品、文章等内容时,我们会收集您的设备信息、日志信息,包括IP地址、浏览器类型、操作系统版本、访问时间、点击记录、浏览记录、下载记录等。此类信息用于为您提供内容展示、优化推荐算法以及保障服务安全稳定运行。
2. 搜索功能:当您使用搜索服务时,我们会收集您的搜索关键字信息,用于向您展示搜索结果。
3. 内容发布与社区交互:当您在平台社区上传模型、发布作品、发表评论、点赞、收藏、分享时,我们会收集您主动发布的内容(包括但不限于文字、图片、视频、模型文件等)。您发布的内容中会展示您的昵称、头像等信息。
(三)SaaS软件服务
1. 使用SaaS工具:当您使用我们提供的SaaS软件工具(如图片编辑、模型训练、设计工具等)时,您主动上传、输入或导入的内容及指令将被收集,以便为您提供服务。
2. 生成内容:您通过SaaS工具生成的内容,其所有权归您所有。我们会保存该生成内容的记录,以便您进行查看、下载、管理或再次编辑。
3. 模型优化:在经过安全加密技术处理、严格去标识化且无法重新识别特定个人的前提下,我们可能会将您使用SaaS软件过程中产生的脱敏数据用于模型优化和改进服务。您可以通过平台设置选择是否允许我们将您的内容用于此目的。
(四)付费订阅与交易服务
1. 会员订阅:当您购买会员服务(软件订阅费)或充值积分时,我们会收集您的订单信息(包括商品/服务名称、金额、交易时间)和支付信息。支付信息(如银行卡号、第三方支付账号)由第三方支付机构直接收集,我们不会获取您完整的支付敏感信息。
2. 积分消耗:当您使用积分消耗大模型算力或兑换服务时,我们会记录您的积分变动情况。
3. 用户间交易:
(1)当您作为卖方(发布作品/素材进行售卖)时,我们需要收集您的实名认证信息(个人或企业)、收款账户信息(银行卡号或第三方支付账号),以便向您结算交易款项。
(2)当您作为买方(购买作品/素材)时,我们需要收集您的订单信息,并可能向卖方提供您的平台账号信息,以便卖方完成交付。您的真实联系方式(如手机号、地址)不会直接提供给卖方,除非您主动通过平台沟通工具披露。
(五)运营与安全保障
为了维护相关产品或服务的正常稳定运行,保护您或其他用户或公众的安全及合法利益,我们会收集如下必要信息:
1. 设备信息:包括设备型号、操作系统版本、唯一设备标识符、IP地址、MAC地址、WLAN接入点、蓝牙、基站、软件版本号、网络接入方式/类型/状态等。
2. 日志信息:包括您的操作日志、服务日志,例如您对平台功能的点击、使用情况、崩溃数据、异常信息等。
3. 应用信息:用于预防恶意程序、保障运营质量,我们会收集安装的应用列表、软件列表或正在运行的进程信息。
4. IP归属地:根据相关法律法规要求,我们可能会在您的个人主页、作品发布等页面展示您的IP地址归属地信息。
(六)系统权限
为实现特定功能,我们可能会向您申请相机/摄像头、相册/存储、麦克风、通知、剪贴板等系统权限。您可以在设备设置中自主选择开启或关闭这些权限。关闭权限将可能导致对应功能无法使用,但不影响其他功能。
(七)征得授权同意的例外
根据相关法律法规,以下情形中收集您的个人信息无需征得您的授权同意:为订立、履行您作为一方当事人的合同所必需;为履行法定职责或者法定义务所必需;为应对突发公共卫生事件,或者紧急情况下为保护自然人的生命健康和财产安全所必需;为公共利益实施新闻报道、舆论监督等行为,在合理的范围内处理个人信息;依照法律规定在合理的范围内处理您自行公开或者其他已经合法公开的个人信息;法律、行政法规规定的其他情形。
三、我们如何使用Cookie和同类技术
1. Cookie:为使您获得更轻松的访问体验,我们可能会使用Cookie技术收集和存储您的登录状态、浏览偏好、使用习惯等信息。您可以根据自己的偏好管理或删除Cookie,但请注意,禁用Cookie可能会导致您无法正常使用平台的某些功能。
2. 同类技术:我们可能会使用网站信标、像素标签等其他同类技术,用于分析您对我们服务的使用情况、评估广告效果等。
四、我们如何共享、转让、公开披露您的个人信息
(一)共享
我们不会与任何公司、组织和个人共享您的个人信息,但获得您的明确同意、履行法定义务、与授权合作伙伴共享、用户间交易、关联公司共享等情况除外。授权合作伙伴包括支付服务提供商、实名认证服务商、云服务提供商、推送服务提供商、安全与风控服务商等,我们仅共享实现目的所必要的信息。
(二)转让
我们不会将您的个人信息转让给任何公司、组织和个人,但获得您的明确同意后,或在涉及合并、收购、破产清算等情形时除外。如涉及个人信息转让,我们会要求新的持有您个人信息的公司、组织继续受本政策约束,否则将要求其重新向您征求授权同意。
(三)公开披露
我们不会公开披露您的个人信息,但获得您的明确同意后、基于法律司法或行政程序要求、对违规账号或侵权行为进行必要公示等情况除外。
(四)共享、转让、公开披露的例外
在为订立或履行合同所必需、履行法定义务所必需、应对紧急情况、公共利益新闻报道或舆论监督、处理您自行公开或其他已合法公开信息、法律行政法规规定的其他情形中,共享、转让、公开披露您的个人信息无需事先征得您的授权同意。
五、我们如何保护和存储您的个人信息
1. 存储地点:我们在中华人民共和国境内运营中收集和产生的个人信息,将存储在中华人民共和国境内。我们不会将您的个人信息传输至境外。
2. 存储期限:我们仅在为实现本政策所述目的所必需的最短期限内保留您的个人信息,除非法律法规有更强的存留要求。超出存储期限后,我们将对您的个人信息进行删除或匿名化处理。
3. 保护措施:我们采用SSL加密、数据脱敏、访问控制、入侵检测等符合行业标准的安全技术措施,保护您的个人信息免遭泄露、篡改、毁损或未经授权的访问。我们建立数据安全管理制度,与员工签署保密协议,定期开展安全审计。一旦发生个人信息安全事件,我们将依法及时告知您,并向监管部门报告。
六、您如何管理您的个人信息
您可以通过平台账号功能或联系我们,访问、更正、补充、删除您的个人信息,撤回同意或申请注销账号。账号注销后,我们将删除或匿名化处理您的个人信息(法律法规另有规定的除外),您将无法再使用该账号登录平台。对于合理的请求,我们原则上不收取费用;对于无端重复、超出合理限度的请求,我们可能会拒绝或收取合理成本费用。
七、我们如何保护未成年人的个人信息
1. 平台主要面向成年人。如您为18周岁以下的未成年人,应在您的父母或其他监护人监护、指导下共同阅读并同意本政策。
2. 如您为14周岁以下的儿童,请务必在监护人明确同意的前提下使用我们的服务。我们将只会在法律法规允许、监护人明确同意或保护儿童所必要的情况下收集、使用儿童的个人信息。
3. 如我们发现未经监护人同意收集了儿童个人信息,我们将尽快删除相关数据。
八、本政策如何更新
我们可能会根据法律法规变化、产品功能调整或业务发展需要适时修订本政策。本政策更新后,我们会在平台显著位置发布更新版本,并在生效前通过公告、站内信、弹窗等方式通知您。如您继续使用我们的服务,即表示您同意受修订后的本政策约束。
九、其他声明与责任限制
为提供更好的服务,我们的产品中可能包含第三方SDK。第三方SDK可能会收集您的设备信息、网络信息等,具体请查阅我们在平台公示的《第三方信息共享清单》。本政策适用中华人民共和国大陆地区法律。因本政策引起的任何争议,双方应友好协商解决;协商不成的,任何一方均有权将争议提交至本平台运营主体所在地有管辖权的人民法院诉讼解决。
十、如何联系我们
如您对本政策有任何疑问、意见或建议,或需要进行投诉、举报,您可以通过以下方式与我们联系:
客服邮箱:system@omniai.net.cn
客服电话:15155073618
邮寄地址:江苏省南京市江北新区扬子江数字视听产业园9栋A楼9A-501
我们将在15个工作日内回复您的请求。
`.trim();
const privacySections = [
{
title: "收集的信息",
body: "我们会收集账号信息、登录状态、联系方式、创作输入、生成结果、用量记录、设备与网络日志,用于提供服务、安全审计和问题排查。",
},
{
title: "Cookie 与本地存储",
body: "我们使用 Cookie、localStorage 和 sessionStorage 保存登录状态、偏好设置、Cookie 同意状态、创作草稿和断点续传数据。",
},
{
title: "信息使用",
body: "信息用于身份验证、生成任务处理、资产管理、积分计费、客服支持、风控合规、服务优化和法律法规要求的备案审计。",
},
{
title: "第三方处理",
body: "为完成 AI 生成、对象存储、短信邮件、支付或错误监控,我们可能向必要的第三方服务提供最小范围数据,并要求其按约定保护数据安全。",
},
{
title: "用户权利",
body: "你可以通过平台账号功能或联系方式申请访问、更正、删除个人信息,或撤回非必要授权。法律法规另有要求的记录可能需按规定保留。",
},
];
const agreementText = `
Omniai平台用户协议
更新日期:2026年06月8日
生效日期:2026年06月8日
欢迎您使用本平台服务。
本平台向您提供SaaS软件服务、社区交流、素材及作品交易等相关服务。欢迎您与南京万物可爱文化传媒有限公司(以下简称“我们”或“平台”)共同签署本《用户服务协议》(下称“本协议”),并使用Omniai平台服务!
在您注册成为平台用户前,请您务必审慎阅读、充分理解本协议的全部内容,特别是免除或限制责任、法律适用和争议解决条款。如您对本协议内容有任何疑问,可通过平台客服进行咨询。如您未满18周岁,请在法定监护人陪同下仔细阅读并充分理解本协议,并征得监护人的同意后使用本平台服务。
当您点击“同意”本协议、完成注册程序,或实际开始使用平台服务时,即表示您已充分阅读、理解并接受本协议的全部内容,并与我们达成一致,本协议即对您产生法律约束力。如您不同意本协议的任何条款,请立即停止注册或使用行为。
我们可能不时修改本协议及相关平台规则,并通过网站公告、站内信等方式进行通知。若您在本协议修改后继续使用服务,即视为您已接受修改后的协议。
第一章 定义
1. 本平台:指由南京万物可爱文化传媒有限公司拥有并运营的,向用户提供SaaS软件工具、社区互动、内容上传、展示、分享、付费下载及交易等功能的网站、客户端应用程序及其他技术服务平台。
2. SaaS服务:指我们基于软件即服务模式,向您提供的在线软件工具及相关技术服务,您可能需要支付软件订阅费(会员费)后方可使用全部或部分功能。
3. 社区服务:指我们在平台上为用户提供的,用于发布、展示、交流、分享及交易作品、素材、版权等内容的空间与功能。
4. 平台规则:指我们已经发布或后续可能发布、修改的与本平台相关的所有协议、政策、活动规则、公告、说明、站内信通知等,以及《隐私政策》等,均构成本协议不可分割的组成部分。
5. 用户:指使用本平台服务的任何自然人或组织,包括企业用户与个人用户,合称“您”。
6. 内容:指用户通过本平台上传、发布、生成、展示、交易的全部信息,包括但不限于模型、软件、素材、图片、视频、音频、文字、代码、设计图、评论等。
第二章 账号的注册、使用与管理
1. 账号注册
(1)您在使用本平台服务前,需通过实名手机号或我们认可的第三方账号进行注册。企业用户还应提供真实、有效的企业营业执照等信息。您在注册或使用Omniai平台服务时可能需要提供一些必要的信息,为保证您享用的平台服务安全有效且不断优化,您同意授权我们对您的必要个人信息进行验证和合理使用。您须保证所填写及提供的资料真实、准确、完整、合法有效。
(2)您注册成功的账号仅限您本人/本企业自身正当使用,禁止以任何形式赠与、借用、出租、转让、售卖或授权他人使用该账号。
(3)特别提示:禁止恶意注册。您不得恶意批量注册账号,不得利用多个账号或其他技术手段实施干扰平台运营、规避平台规则、获取不当利益的行为。一经发现,我们有权立即冻结或收回相关账号,并追究您的违约责任。
(4)如您提供任何违法、不实或我们认为不适合的资料,或我们有理由怀疑您的行为属于恶意操作,我们有权暂停或终止您的账号。
(5)我们及Omniai平台无须对任何用户的任何登记资料承担任何责任,包括但不限于鉴别、核实任何登记资料的真实性、正确性、完整性、适用性及是否为最新资料的责任。
2. 账号安全
(1)您应对账号及密码/验证码的安全性负完全责任,并对该账号下进行的所有活动承担责任。您应高度重视对账号与验证码的保密,在任何情况下不向他人透露账号及验证码。您的账号遭到未获授权的使用,或者发生其它任何安全问题时,您应立即通知我们。
(2)我们发现或合理认为使用者并非账号初始注册人,为保障账号安全,有权立即暂停或终止提供服务,并永久禁用该账号。
3. 账号注销与回收
(1)您可以按照平台公示的方式申请注销账号。账号注销后,我们将对账号内信息进行删除或匿名化处理,法律法规另有规定的除外。
(2)如您的账号连续六个月以上未登录,我们有权冻结、收回或者注销该账号。
第三章 平台服务与使用规范
1. 服务内容
(1)SaaS软件服务:我们向您提供在线SaaS软件工具,您可按需选择免费版或付费版(会员费)。使用部分高级功能或消耗大模型算力时,可能需要消耗积分,积分可通过充值等方式获得。
(2)社区服务:平台提供作品、素材、版权的上传、展示、付费下载、互动交流等服务。用户间可通过平台进行相关版权或素材的交易。
(3)交易手续费:为维持平台运营,对于用户间通过平台达成的交易,我们可能会向卖方或买方收取一定比例的交易手续费,具体费率以平台页面公示为准。
2. 用户行为规范:严禁侵权与违法内容
(1)您承诺,您在使用平台SaaS软件创作、或上传、发布、交易的所有内容,均由您原创或已获得合法、完整的授权,不存在侵犯任何第三方知识产权、肖像权、名誉权、隐私权等合法权益的情形。
(2)高风险警示:您不得使用本平台创作、上传、发布、传播任何违反中华人民共和国法律法规、社会主义制度、国家利益、社会公序良俗或包含淫秽、色情、暴力、赌博、恐怖、侮辱、诽谤、虚假信息、扰乱社会秩序等任何不良内容的信息或作品。
(3)您不得利用本平台进行任何危害网络安全的行为,包括但不限于使用插件、外挂、爬虫、病毒、反向工程、干扰系统正常运行等。
(4)您不得利用本平台实施洗钱、套现、诈骗、赌博等违法活动。
3. 禁止恶意“薅羊毛”与不正当竞争
您不得通过批量注册账号、使用外挂程序、虚构交易、虚假评价、套取平台补贴或奖励等方式,获取不当商业利益或损害平台及其他用户的权益。一经发现,平台有权扣划不当得利、限制或封禁账号、追究违约责任。
AI内容生成特别提示:您理解并同意,本平台提供的SaaS软件中可能包含基于人工智能技术的内容生成功能。鉴于AI技术的局限性,生成内容可能存在不确定性或不准确性。您应自行对生成内容进行审核和判断,并对其使用承担全部责任。我们不对生成内容的合法性、准确性、完整性、不侵权性作任何保证。
第四章 知识产权
1. 平台的知识产权
本平台软件、代码、界面设计、商标、标识等知识产权归我们所有,未经书面许可,您不得进行复制、修改、反向编译或用于任何商业目的。
2. 您的内容的知识产权
(1)权利归属:您通过本平台上传、发布的内容及使用SaaS软件独立创作生成的内容,其知识产权归您或原权利人所有。您对您的内容承担全部责任。
(2)授权许可:为了使您的内容能够在平台上进行展示、传播、交易,并为了我们能够持续改进SaaS软件和社区服务,您授予我们一项全球范围内、免费的、非排他性的、可分许可的权利,允许我们在平台及相关业务中使用、复制、修改、改编、分发您公开发布的内容。如果您不希望我们使用您的内容用于模型训练或改进,您可以通过平台设置或联系客服关闭此选项。对于您设置为非公开或仅用于交易的内容,我们将采取更严格的保护措施。
(3)交易授权:您通过平台社区进行作品、素材、版权的付费下载或交易,即授予购买方一项按照交易约定范围使用该内容的许可。您作为卖方,应清晰标识授权范围,并对授权内容的真实性和合法性承担全部责任。
3. 侵权内容处理
我们尊重他人知识产权,并已建立侵权投诉处理机制。任何第三方认为平台上的内容侵犯其合法权益的,可按照平台公示的投诉渠道提交书面通知及初步侵权证据。我们将在收到合格通知后,依法采取删除、屏蔽、断开链接等必要措施,并通知相关用户。对于重复侵权、恶意侵权或情节严重的用户,我们有权直接终止向其提供服务,并永久封禁其账号。
第五章 社区交易与责任限制
1. 交易主体与责任承担
(1)您充分理解并同意,本平台仅为用户提供作品、素材、版权的信息发布、展示、下载、支付结算等交易技术支持服务。平台不是交易合同的任何一方当事人,也并非作品的卖方或买方。
(2)核心免责声明:平台对于用户发布、展示、交易的内容的合法性、真实性、准确性、完整性、安全性、质量、是否侵权以及交易的履行等,不承担任何事先审查或担保责任。您与其他用户之间因内容交易产生的任何纠纷,均由交易双方自行协商解决,或通过司法、行政途径解决。
(3)如我们收到司法机关或行政机关的有效法律文书,或认为确有必要时,我们可以采取临时性措施以维护各方权益。
2. 社区内容的管理权利
我们有权依据法律法规、本协议及平台规则,对平台社区内的内容进行主动巡查。如发现或收到举报证明内容涉嫌侵权、违法或违反本协议,我们有权在不事先通知的情况下,直接删除、屏蔽相关内容,并对相关用户采取警示、限制功能、暂停服务或封禁账号等措施。
第六章 费用与支付
1. 软件订阅费(会员费):您购买会员服务时,应按照平台页面公示的价格和期限支付费用。会员服务为虚拟产品,一经开通,除法律另有规定或平台规则另有说明外,原则上不予退款。
2. 积分充值:积分用于消耗平台算力或兑换特定服务。充值后积分有效期以平台公示为准。平台有权根据运营情况调整积分获取或消耗规则,但会提前公告。
3. 交易手续费:平台有权就用户间通过平台达成的交易收取手续费,费率在交易前明确告知。平台有权根据市场情况调整手续费率,通过公告方式通知后生效。
4. 税费:您使用本平台所获得的收入(如售卖素材所得),根据中国法律规定,您应自行申报并缴纳相关税费。平台按照法律规定履行代扣代缴义务(如适用)。
第七章 违约与处理
1. 违约认定:发生违反本协议或平台规则、侵犯第三方权益、恶意批量注册、虚假交易、套取利益或其他违反法律法规并造成损害的行为,视为您违约。
2. 处理措施:我们有权独立判断并采取警告、拒绝发布、删除内容、限制使用功能、暂停服务、扣划不当得利、冻结或永久封禁账号、追究赔偿等一项或多项措施。
3. 赔偿责任:如因您的行为导致我们或第三方遭受任何损失,您应足额赔偿。
第八章 免责声明与责任限制
1. 服务现状:我们的服务按“现状”和“可得到”的状态提供,我们不作出任何明示或暗示的保证,包括但不限于服务无间断、无错误、安全可靠、适用于特定目的等。
2. AI生成内容免责:鉴于人工智能技术的局限性,我们无法保证通过SaaS软件生成内容的真实性、准确性、独创性及不侵权性。您应当对生成内容自行加以判断和验证,并承担使用该等内容所产生的全部风险与责任。
3. 不可抗力及第三方原因:因自然灾害、战争、政府行为、电力或通讯故障、第三方攻击等不可抗力或非我们故意或重大过失的原因导致的损失,我们不承担责任。
4. 服务中断与变更:我们可能会对平台进行升级、维护或修改,由此导致的服务中断或功能变更,我们将提前通过合理方式通知您。但我们不对因上述必要维护导致的任何损失承担责任,法律另有强制性规定的除外。
5. 第三方链接与内容:平台可能包含指向第三方网站或资源的链接。我们不对这些第三方网站或资源的可用性、内容、产品或服务承担任何责任。您访问第三方网站或使用第三方服务所产生的一切风险由您自行承担。
6. 责任上限:在法律允许的最大范围内,我们对于您因使用平台服务而遭受的任何间接、附带、特殊、惩罚性损失,即使已被告知可能发生该等损失,也不承担任何责任。我们的全部赔偿责任总额,不超过您在过去十二个月内向平台支付的费用总额。
第九章 个人信息保护
1. 我们非常重视您的个人信息保护。我们将按照平台公布的《隐私政策》收集、使用、存储、共享和保护您的个人信息。请您务必仔细阅读《隐私政策》。
2. 您同意我们根据《隐私政策》以及相关法律法规的要求处理您的个人信息。
3. 您知悉并同意,为方便您使用Omniai相关服务,我们有权在遵守法律法规的前提下,以明示的方式获取、使用、储存和分享您的个人信息。我们不会在未经您授权时,公开、编辑或透露您的个人信息及您保存在Omniai的非公开内容。
4. 您知悉并同意,我们有权通过cookie等技术收集您的产品或服务使用、行为数据,并在经过数据脱敏使之不再指向或关联到您个人身份信息时,自由使用脱敏后的纯商业数据。
第十章 协议的修改与终止
1. 协议的修改:我们有权根据法律法规变化或业务需要修改本协议。修改后的协议将在平台公示或以其他方式通知您。如您不接受修改后的协议,您应停止使用平台服务;如您继续使用,则视为同意接受修改后的协议。
2. 协议的终止:您可以申请注销账号终止本协议。我们有权在您违约或出现其他法定、约定情形时终止本协议。协议终止后,您仍需对协议终止前的行为承担责任。
第十一章 法律适用与争议解决
1. 本协议的订立、生效、履行、解释及争议解决,均适用中华人民共和国大陆地区法律。
2. 凡因本协议引起的或与本协议有关的任何争议,双方应首先友好协商解决;协商不成的,任何一方均有权将争议提交至本平台运营主体所在地南京市浦口区有管辖权的人民法院通过诉讼解决。
第十二章 其他
1. 本协议条款标题仅为方便阅读而设,不影响条款含义的解释。
2. 本协议任何条款被认定为无效或不可执行,不影响其他条款的效力。
3. 如您对本协议有任何疑问、意见或建议,或需要投诉举报,您可以通过system@omniai.net.cn与我们联系。
4. 兜底免责声明:在法律法规允许的最大范围内,本协议未明确列明的、或因超出我们合理预见或控制范围所产生的任何直接或间接损失、责任或风险,我们均不承担责任,除非相关法律另有强制性规定。您理解并同意,使用本平台服务的风险由您自行承担,我们仅以普通注意义务对平台进行管理,不对任何未明确约定的商业成果或安全性作出保证。
(以下无正文)
`.trim();
function getDocumentLines(text: string) {
return text.split(/\n+/).map((line) => line.trim()).filter(Boolean);
}
function getLineClassName(line: string, index: number) {
if (index === 0) return "compliance-document__title";
if (/^(第[一二三四五六七八九十]+章|[一二三四五六七八九十]+、)/.test(line)) return "compliance-document__heading";
if (/^[一二三四五六七八九十]+/.test(line)) return "compliance-document__subheading";
if (/^[0-9]+\./.test(line) || /^[0-9]+/.test(line) || /^·/.test(line)) return "compliance-document__clause";
return "compliance-document__paragraph";
}
export default function CompliancePage({ kind }: CompliancePageProps) {
const isPrivacy = kind === "privacy";
const sections = isPrivacy ? privacySections : agreementSections;
const title = isPrivacy ? "隐私政策" : "用户协议";
const Icon = isPrivacy ? SafetyOutlined : FileTextOutlined;
const lines = getDocumentLines(isPrivacy ? privacyPolicyText : agreementText);
return (
<section className="compliance-page">
@@ -71,27 +223,26 @@ export default function CompliancePage({ kind }: CompliancePageProps) {
<div>
<span className="compliance-hero__eyebrow"></span>
<h1>{title}</h1>
<p>{companyName} 2026 6 3 </p>
<p>{companyName}{platformName}{effectiveDate}</p>
</div>
</header>
<div className="compliance-card">
{sections.map((section, index) => (
<article key={section.title} className="compliance-section">
<span>{String(index + 1).padStart(2, "0")}</span>
<div>
<h2>{section.title}</h2>
<p>{section.body}</p>
</div>
<article className="compliance-card compliance-document">
{lines.map((line, index) => {
const className = getLineClassName(line, index);
if (className === "compliance-document__title") return <h2 key={`${index}-${line}`} className={className}>{line}</h2>;
if (className === "compliance-document__heading") return <h3 key={`${index}-${line}`} className={className}>{line}</h3>;
if (className === "compliance-document__subheading") return <h4 key={`${index}-${line}`} className={className}>{line}</h4>;
return <p key={`${index}-${line}`} className={className}>{line}</p>;
})}
</article>
))}
</div>
<footer className="compliance-contact">
<strong></strong>
<span>{contactEmail}</span>
<span>{address}</span>
<span>{contactPhone}</span>
<span>ICP备2026021747号-1</span>
<span>{icpRecord}</span>
</footer>
</div>
</section>
+120 -79
View File
@@ -14,7 +14,6 @@ import {
RightOutlined,
ScissorOutlined,
SwapOutlined,
ThunderboltOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
@@ -100,7 +99,6 @@ function DigitalHumanPage({
const [isDragging, setIsDragging] = useState(false);
const imageInputRef = useRef<HTMLInputElement | null>(null);
const audioInputRef = useRef<HTMLInputElement | null>(null);
const canvasDragCounterRef = useRef(0);
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
useEffect(() => {
@@ -208,6 +206,24 @@ function DigitalHumanPage({
}
};
const clearImageAsset = () => {
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImageName("");
setImageFile(null);
setImagePreview("");
if (imageInputRef.current) imageInputRef.current.value = "";
setNotice("已移除参考人像");
};
const clearAudioAsset = () => {
if (audioPreview) URL.revokeObjectURL(audioPreview);
setAudioName("");
setAudioFile(null);
setAudioPreview("");
if (audioInputRef.current) audioInputRef.current.value = "";
setNotice("已移除音频源");
};
const handleDownloadResult = async () => {
if (!resultVideoUrl || isDownloadingResult) return;
setIsDownloadingResult(true);
@@ -420,6 +436,76 @@ function DigitalHumanPage({
}
};
const digitalHumanSettingsPanel = (
<div className="studio-panel__section digital-human-settings-panel">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
</div>
<div className="studio-panel__section-body">
<div className="digital-human-prompt-field">
<div className="studio-label"></div>
<textarea
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
placeholder="例如:自然微笑,边说边轻微点头"
rows={3}
maxLength={2500}
/>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${!watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${keepOriginalAudio ? " is-on" : ""}`} onClick={() => setKeepOriginalAudio(!keepOriginalAudio)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !imageFile || !audioFile}>
<PlayCircleOutlined />
{isCreating ? "生成中..." : "开始生成"}
</button>
{isCreating && (
<button type="button" className="studio-generate-btn digital-human-cancel-btn" onClick={handleCancel} aria-label="取消生成任务">
<CloseCircleOutlined />
</button>
)}
{resultVideoUrl && (
<button type="button" className="studio-generate-btn" onClick={() => {
setResultVideoUrl("");
setActiveTaskId("");
setTaskProgress(0);
setNotice("已清空工作区");
}}>
</button>
)}
{resultVideoUrl && (
<div className="studio-result-actions">
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
<DownloadOutlined />
{isDownloadingResult ? "保存中" : "保存本地"}
</button>
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
<InboxOutlined />
{isSavingResultAsset ? "加入中" : "加入资产库"}
</button>
</div>
)}
</div>
</div>
);
return (
<section className="image-workbench-page digital-human-page" aria-label="数字人">
<header className="image-workbench-topbar">
@@ -478,8 +564,10 @@ function DigitalHumanPage({
<StudioToolLayout
noTop
noRight
leftPanel={
<div
className="digital-human-source-panel"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -525,6 +613,20 @@ function DigitalHumanPage({
<strong>{imageName || "上传参考图"}</strong>
<small>PNG / JPG / WEBP</small>
</span>
{imagePreview ? (
<button
type="button"
className="studio-upload-slot__remove"
aria-label="移除参考人像"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearImageAsset();
}}
>
<DeleteOutlined />
</button>
) : null}
</label>
</div>
</div>
@@ -560,10 +662,26 @@ function DigitalHumanPage({
<strong>{audioName || "上传音频"}</strong>
<small>MP3 / WAV / M4A 5 </small>
</span>
{audioPreview ? (
<button
type="button"
className="studio-upload-slot__remove"
aria-label="移除音频源"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearAudioAsset();
}}
>
<DeleteOutlined />
</button>
) : null}
</label>
{audioPreview ? <audio src={audioPreview} controls className="studio-audio-preview" /> : null}
</div>
</div>
{digitalHumanSettingsPanel}
</div>
}
canvas={
@@ -598,83 +716,6 @@ function DigitalHumanPage({
</div>
)
}
rightPanel={
<>
<div className="studio-panel__section">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
</div>
<div className="studio-panel__section-body">
<div style={{ marginBottom: 8 }}>
<div className="studio-label" style={{ fontSize: 11, color: "var(--fg-muted, #999)", marginBottom: 4 }}></div>
<textarea
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
placeholder="例如:自然微笑,边说边轻微点头"
rows={3}
maxLength={2500}
style={{
width: "100%", resize: "vertical", background: "var(--bg-elevated, #1a1a1a)",
border: "1px solid var(--border-subtle, #333)", borderRadius: 6,
padding: "6px 8px", fontSize: 12, color: "var(--fg-body, #eee)",
fontFamily: "inherit", outline: "none", boxSizing: "border-box",
}}
/>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${!watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${keepOriginalAudio ? " is-on" : ""}`} onClick={() => setKeepOriginalAudio(!keepOriginalAudio)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !imageFile || !audioFile}>
<PlayCircleOutlined />
{isCreating ? "生成中..." : "开始生成"}
</button>
{isCreating && (
<button type="button" className="studio-generate-btn digital-human-cancel-btn" onClick={handleCancel} aria-label="取消生成任务">
<CloseCircleOutlined />
</button>
)}
{resultVideoUrl && (
<button type="button" className="studio-generate-btn" onClick={() => {
setResultVideoUrl("");
setActiveTaskId("");
setTaskProgress(0);
setNotice("已清空工作区");
}}>
</button>
)}
{resultVideoUrl && (
<div className="studio-result-actions">
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
<DownloadOutlined />
{isDownloadingResult ? "保存中" : "保存本地"}
</button>
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
<InboxOutlined />
{isSavingResultAsset ? "加入中" : "加入资产库"}
</button>
</div>
)}
</div>
</div>
</>
}
statusBar={
<>
<span className="studio-status-bar__badge studio-status-bar__badge--running"></span>
+2 -18
View File
@@ -1,6 +1,5 @@
import {
AppstoreOutlined,
CloudUploadOutlined,
CloseOutlined,
FileImageOutlined,
FrownOutlined,
@@ -9,7 +8,6 @@ import {
MenuUnfoldOutlined,
QuestionCircleOutlined,
ReloadOutlined,
SettingOutlined,
SkinOutlined,
} from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
@@ -624,11 +622,6 @@ const tryOnModelOptions = {
ethnicity: ["欧美白人", "亚洲人", "拉美裔", "非洲裔"],
body: ["标准", "高挑", "微胖", "运动"],
};
const sampleResults = [
ossAssets.ecommerce.slides.slide4,
ossAssets.ecommerce.generated,
ossAssets.ecommerce.slides.slide5,
];
const productSetAssets = ossAssets.ecommerce.productSet;
const productSetPreviewCards = [
{ id: "main", label: "01 主图 (白底/合规)", src: productSetAssets.main },
@@ -701,21 +694,13 @@ function readImageDimensions(src: string): Promise<{ width: number; height: numb
});
}
const blobToDataUrl = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsDataURL(blob);
});
async function createUploadedImageItems(files: File[], limit: number, prefix: string): Promise<CloneImageItem[]> {
const selectedFiles = Array.from(files).slice(0, limit);
const stamp = Date.now();
const items = await Promise.all(selectedFiles.map(async (file, index) => {
const localPreviewUrl = URL.createObjectURL(file);
let dimensions: { width?: number; height?: number } = {};
let dimensions: { width?: number; height?: number };
try {
dimensions = await readImageDimensions(localPreviewUrl);
} catch {
@@ -1702,7 +1687,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
}
const generatedUrls: string[] = [];
const stamp = Date.now();
for (const countKey of cloneSetCountKeys) {
if (imageAbortRef.current.current) break;
@@ -2039,7 +2023,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
);
};
const resetTask = () => {
const _resetTask = () => {
setSetImages([]);
setProductSetRequirement("");
setProductSetOutput("video");
@@ -1,4 +1,9 @@
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
import { useRef } from "react";
import {
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
formatEstimatedRemainingLabel,
useSmoothedProgress,
} from "../../hooks/useSmoothedProgress";
interface EcommerceProgressBarProps {
status: "idle" | "generating" | "done" | "failed" | string;
@@ -12,17 +17,40 @@ function mapStatus(status: string): "running" | "completed" | "failed" {
return "running";
}
export function EcommerceProgressBar({ status, label }: EcommerceProgressBarProps) {
const progress = mapStatus(status) === "running" ? 50 : 100;
const smoothed = useSmoothedProgress(progress, mapStatus(status));
export function EcommerceProgressBar({
status,
label,
}: EcommerceProgressBarProps) {
const startedAtRef = useRef(Date.now());
const mappedStatus = mapStatus(status);
const progress = mappedStatus === "running" ? 50 : 100;
const smoothed = useSmoothedProgress(progress, mappedStatus, {
progressSource: mappedStatus === "running" ? "estimated" : "real",
startedAt: startedAtRef.current,
expectedDurationMs: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
});
const remainingLabel =
mappedStatus === "running"
? formatEstimatedRemainingLabel({
nowMs: Date.now(),
startedAt: startedAtRef.current,
expectedDurationMs: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
})
: null;
if (status === "idle") return null;
return (
<div className="ecommerce-progress-bar">
<span className="ecommerce-progress-bar__label">{label || "AI 正在生成"}</span>
<span className="ecommerce-progress-bar__label">
{label || "AI 正在生成"}
{remainingLabel ? ` / ${remainingLabel}` : ""}
</span>
<div className="ecommerce-progress-bar__track">
<div className="ecommerce-progress-bar__fill" style={{ width: `${smoothed}%` }} />
<div
className="ecommerce-progress-bar__fill"
style={{ width: `${smoothed}%` }}
/>
</div>
<span className="ecommerce-progress-bar__value">{smoothed}%</span>
</div>
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import "../../styles/pages/ecommerce-video.css";
import {
CloseOutlined,
@@ -14,7 +14,6 @@ import {
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks, saveVideoHistory } from "./ecommerceVideoService";
import {
PLAN_STEP_LABELS,
PLAN_STEPS_DISPLAY,
type EcommerceVideoStage,
type EcommerceVideoSceneTask,
type EcommerceVideoPlanProgress,
@@ -29,7 +28,6 @@ import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import {
saveEcommerceVideoState,
loadEcommerceVideoState,
clearEcommerceVideoState,
} from "./ecommerceVideoKeepalive";
interface EcommerceVideoWorkspaceProps {
@@ -298,7 +296,7 @@ export default function EcommerceVideoWorkspace({
}, 3000);
};
const handleDownload = async (url: string) => {
const _handleDownload = async (url: string) => {
try {
await saveToolResultToLocal({
url, name: `ecommerce-video-${Date.now()}`, type: "video",
@@ -310,7 +308,7 @@ export default function EcommerceVideoWorkspace({
}
};
const handleSaveAsset = async (url: string) => {
const _handleSaveAsset = async (url: string) => {
try {
const result = await addToolResultToAssetLibrary({
url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频生成结果",
@@ -596,9 +594,6 @@ export default function EcommerceVideoWorkspace({
const sourceImage = sourceImageUrls[0] || planResult?.imageUrls[0] || productImageDataUrls[0] || "";
const flowHasStarted = stage !== "idle" || completedSteps.length > 0;
const flowMeta = `${platform} / ${aspectRatio} / ${durationSeconds}s / ${resolution}`;
const hasImaging = stage === "imaging" || stage === "imaged" || stage === "rendering" || stage === "completed" || stage === "partial_failed";
const hasRendering = stage === "rendering" || stage === "completed" || stage === "partial_failed";
const visiblePlanSteps = PLAN_STEPS_DISPLAY.filter((s) => completedSteps.includes(s));
return (
<div className="ecom-video-workspace" data-stage={stage}>
+1 -1
View File
@@ -19,7 +19,7 @@ const [
ecommerceCarouselImage2,
ecommerceCarouselImage3,
ecommerceCarouselImage4,
ecommerceCarouselImage5,
,
ecommerceCarouselImage6,
] = ossAssets.ecommerce.templateCases;
const ecommerceCarouselGenerated = ossAssets.ecommerce.generated;
+4 -29
View File
@@ -1,6 +1,5 @@
import {
ArrowRightOutlined,
DashboardOutlined,
FileSearchOutlined,
PlayCircleOutlined,
PlusOutlined,
@@ -9,28 +8,16 @@ import {
} from "@ant-design/icons";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
import { useScrollEntrance } from "../../hooks/useScrollEntrance";
import { ossAssets } from "../../data/ossAssets";
import "../../styles/pages/home.css";
import WelcomeSplash from "./WelcomeSplash";
import ToolboxSection from "./ToolboxSection";
import ScriptReviewShowcase from "./ScriptReviewShowcase";
import ModelGenerationShowcase from "./ModelGenerationShowcase";
function ScrollEntrance({ children, className, ...rest }: { children: React.ReactNode; className?: string } & React.HTMLAttributes<HTMLElement>) {
const { ref, isVisible } = useScrollEntrance<HTMLElement>();
return (
<section ref={ref} className={`${className ?? ""} scroll-entrance${isVisible ? " is-visible" : ""}`} {...rest}>
{children}
</section>
);
}
const [heroImage1, heroImage2, heroImage3] = ossAssets.home.heroSlides;
const {
ecommerce: featureEcommerceImage,
script: featureScriptImage,
token: featureTokenImage,
} = ossAssets.home.features;
interface HomePageProps {
@@ -53,16 +40,6 @@ const HOME_CAROUSEL_IMAGES = [
];
const HOME_FEATURES = [
{
key: "model",
eyebrow: "AI Generation",
title: "模型生成",
description: "通过AI模型生成文本、图片、视频,三种模式覆盖全内容类型,Agent对话式交互智能产出。",
imageUrl: featureTokenImage,
actionLabel: "开始生成",
icon: <ThunderboltOutlined />,
stats: ["文本生成", "图片生成", "视频生成"],
},
{
key: "ecommerce",
eyebrow: "AI Commerce",
@@ -477,7 +454,7 @@ function EcommerceFeatureShowcase() {
);
}
function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcommerce, onOpenScriptReview, onOpenTokenMonitor, onSelectView, onOpenImageTool }: HomePageProps) {
function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcommerce, onOpenScriptReview, onSelectView, onOpenImageTool }: HomePageProps) {
const [splashDismissed, setSplashDismissed] = useState(() => sessionStorage.getItem("omniai:splash-seen") === "1");
const [activeSlideIndex, setActiveSlideIndex] = useState(0);
const [carouselMotion, setCarouselMotion] = useState<HomeCarouselMotion | null>(null);
@@ -657,7 +634,7 @@ function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcomm
<main className="omni-home__feature-pages" aria-label="OmniAI 功能介绍">
{HOME_FEATURES.map((feature, index) => (
<section key={feature.key} className={`omni-home__feature-page is-${feature.key}${index % 2 ? " is-alt" : ""}`}>
{feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce" ? (
{feature.key !== "script" && feature.key !== "ecommerce" ? (
<div className="omni-home__feature-copy">
<span>
{feature.icon}
@@ -671,18 +648,16 @@ function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcomm
</button>
</div>
) : null}
<div className="omni-home__feature-visual" aria-hidden={feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce"}>
<div className="omni-home__feature-visual" aria-hidden={feature.key !== "script" && feature.key !== "ecommerce"}>
{feature.key === "script" ? (
<ScriptReviewShowcase />
) : feature.key === "model" ? (
<ModelGenerationShowcase />
) : feature.key === "ecommerce" ? (
<EcommerceFeatureShowcase />
) : (
<img src={feature.imageUrl} alt="" />
)}
</div>
{feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce" ? (
{feature.key !== "script" && feature.key !== "ecommerce" ? (
<div className="omni-home__feature-stats" aria-hidden="true">
{feature.stats.map((item) => (
<span key={item}>{item}</span>
-1
View File
@@ -14,7 +14,6 @@ function ScriptReviewVisual() {
const [animated, setAnimated] = useState(false);
const [activeDim, setActiveDim] = useState<number | null>(null);
const [score, setScore] = useState(0);
const scoreRef = useRef<number>(0);
const frameRef = useRef<number | null>(null);
useEffect(() => {
+1 -1
View File
@@ -145,7 +145,7 @@ const CARDS = [
},
];
function ToolboxSection({ onSelectView, onOpenImageTool }: ToolboxSectionProps) {
function ToolboxSection({ onSelectView }: ToolboxSectionProps) {
const handleCardClick = (targetView: WebViewKey) => {
onSelectView(targetView);
};
@@ -21,7 +21,6 @@ import {
ScissorOutlined,
SwapOutlined,
TableOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import "../../styles/pages/more-tools.css";
@@ -30,7 +29,6 @@ import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { translateTaskError } from "../../utils/translateTaskError";
import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions";
import { useCanvasDrawing } from "./useCanvasDrawing";
import CameraViewport3D from "./CameraViewport3D";
@@ -40,7 +38,6 @@ type OutputSize = "9:16" | "16:9" | "4:3" | "3:4" | "1:1";
type OutputCount = 1 | 2 | 3 | 4;
const OUTPUT_SIZE_OPTIONS: OutputSize[] = ["9:16", "16:9", "4:3", "3:4", "1:1"];
const OUTPUT_COUNT_OPTIONS: OutputCount[] = [1, 2, 3, 4];
const SIZE_TO_RATIO: Record<OutputSize, string> = {
"9:16": "9:16",
@@ -612,6 +609,20 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</div>
);
const handleRemoveWorkbenchResult = (index: number) => {
setResultImages((current) => {
const next = current.filter((_, imageIndex) => imageIndex !== index);
if (next.length) {
saveToolTaskState("imagewb", { taskId: taskIdRef.current || "", resultUrl: next[0], status: "完成", progress: 100 });
setStatus(`已移除生成图,剩余 ${next.length}`);
} else {
clearToolTaskState("imagewb");
setStatus("已移除生成图");
}
return next;
});
};
const handleGenerate = async () => {
if (!referenceImages.length && !prompt.trim()) {
setStatus("请先上传参考图或输入提示词");
@@ -800,7 +811,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
aria-label="删除局部重绘素材"
onClick={handleRemoveInpaintImage}
>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -833,7 +844,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</label>
</section>
<section className="image-workbench-control-card">
<section className="image-workbench-control-card image-workbench-inpaint-params-card">
<h3></h3>
<span className="image-workbench-field-label"></span>
<div className="image-workbench-segmented">
@@ -845,7 +856,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</div>
</section>
<section className="image-workbench-control-card">
<section className="image-workbench-control-card image-workbench-inpaint-prompt-card">
<h3></h3>
<textarea
className="image-workbench-prompt"
@@ -970,7 +981,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
) : activeTool === "camera" ? (
<main className="image-workbench-layout image-workbench-layout--camera">
<aside className="image-workbench-panel image-workbench-panel--left">
<section className="image-workbench-control-card">
<section className="image-workbench-control-card image-workbench-camera-material">
<div className="image-workbench-section-title">
<h3></h3>
<span>{cameraImage ? "已导入" : "待上传"}</span>
@@ -1000,7 +1011,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
aria-label="删除镜头参考图"
onClick={handleRemoveCameraImage}
>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -1251,7 +1262,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
aria-label={`删除参考图 ${index + 1}`}
onClick={() => handleRemoveReferenceImage(index)}
>
×
<DeleteOutlined />
</button>
</div>
))}
@@ -1285,7 +1296,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
aria-label="删除参考图"
onClick={() => handleRemoveReferenceImage(0)}
>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -1303,7 +1314,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</div>
</section>
<section className="image-workbench-control-card">
<section className="image-workbench-control-card image-workbench-output-card">
<h3></h3>
<span className="image-workbench-field-label"></span>
<div className="image-workbench-segmented">
@@ -1368,6 +1379,14 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
<div className="image-workbench-result-grid">
{resultImages.map((url, i) => (
<div key={url} className="image-workbench-result-card">
<button
type="button"
className="image-workbench-result-remove"
aria-label={`移除生成结果 ${i + 1}`}
onClick={() => handleRemoveWorkbenchResult(i)}
>
<DeleteOutlined />
</button>
<a href={url} target="_blank" rel="noopener noreferrer" className="image-workbench-result-item">
<img src={url} alt={`生成结果 ${i + 1}`} />
</a>
+2
View File
@@ -40,12 +40,14 @@ interface MoreTool {
}
const toolPreviewImages: Record<string, string> = {
workbench: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/toolbox/image-workbench-20260609132455.png",
inpaint: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%B1%80%E9%83%A8%E9%87%8D%E7%BB%98.PNG",
camera: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E9%95%9C%E5%A4%B4%E5%AE%9E%E9%AA%8C%E5%AE%A4.PNG",
upscale: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%88%86%E8%BE%A8%E7%8E%87%E6%8F%90%E5%8D%87.PNG",
watermarkRemoval: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%8E%BB%E6%B0%B4%E5%8D%B0.PNG",
dialogGenerator: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E4%BA%A4%E4%BA%92%E5%BC%8F%E5%AF%B9%E8%AF%9D%E6%A1%86%E7%94%9F%E6%88%90%E5%99%A8.PNG",
subtitleRemoval: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%AD%97%E5%B9%95%E5%8E%BB%E9%99%A4.PNG",
digitalHuman: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/toolbox/digital-human-20260609132455.png",
characterMix: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E8%A7%92%E8%89%B2%E8%BF%81%E7%A7%BB.PNG",
avatarConsole: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E6%95%B0%E5%AD%97%E4%BA%BA%E6%8E%A7%E5%88%B6%E5%8F%B0.PNG",
};
+26
View File
@@ -1488,6 +1488,32 @@ function ProfilePage({
</span>
) : null}
</label>
{mode === "register" ? (
<label className={`auth-page__field${fieldErrors.emailCode ? " auth-page__field--error" : ""}`}>
<span>
<SafetyOutlined />
</span>
<div className="auth-page__sms-row">
<input
value={emailCode}
onChange={(event) => { setEmailCode(event.target.value); clearFieldError("emailCode"); }}
onBlur={() => handleFieldBlur("emailCode", emailCode)}
placeholder="输入 6 位验证码"
maxLength={6}
autoComplete="one-time-code"
/>
<button
type="button"
className="auth-page__sms-btn"
disabled={emailCooldown > 0 || !email.trim() || isSendingEmail || !betaCode.trim()}
onClick={() => void handleSendEmailCode("register")}
>
{isSendingEmail ? "发送中" : emailCooldown > 0 ? `${emailCooldown}s` : "获取验证码"}
</button>
</div>
{fieldErrors.emailCode ? <span className="auth-page__field-error">{fieldErrors.emailCode}</span> : null}
</label>
) : null}
</>
) : null}
@@ -21,9 +21,8 @@ import "../../styles/pages/image-workbench.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { translateTaskError } from "../../utils/translateTaskError";
import { getServerBaseUrl, isServerRequestError } from "../../api/serverConnection";
import { summarizeUrl, formatFileSize, fileToDataUrl, wait } from "../../utils/toolPageUtils";
import { summarizeUrl, formatFileSize, fileToDataUrl } from "../../utils/toolPageUtils";
import TaskStatusBar from "../../components/TaskStatusBar";
import BeforeAfterCompare from "../../components/BeforeAfterCompare";
import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions";
@@ -440,7 +439,7 @@ function ResolutionUpscalePage({
</button>
{sourcePreview ? (
<button type="button" className="image-workbench-upload-remove" aria-label="删除素材" onClick={clearSource}>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -460,13 +459,20 @@ function ResolutionUpscalePage({
<section className="image-workbench-control-card">
<h3></h3>
{mode === "image" ? (
<label className="image-workbench-select">
<span></span>
<select value={imageScale} onChange={(event) => setImageScale(event.target.value as ImageScale)}>
<option value="2x">2x</option>
<option value="4x">4x</option>
</select>
</label>
<div className="resolution-upscale-scale-options" role="radiogroup" aria-label="放大倍数">
{(["2x", "4x"] as ImageScale[]).map((scale) => (
<button
key={scale}
type="button"
className={`resolution-upscale-scale-option${imageScale === scale ? " is-active" : ""}`}
aria-pressed={imageScale === scale}
onClick={() => setImageScale(scale)}
>
<strong>{scale}</strong>
<span>{scale === "2x" ? "日常清晰增强" : "高倍细节修复"}</span>
</button>
))}
</div>
) : (
<>
<div className="resolution-upscale-style-chips">
@@ -549,6 +555,7 @@ function ResolutionUpscalePage({
resultLabel={resultPreview ? resultSizeText : "等待结果"}
sourceAlt="原图预览"
resultAlt="超分结果预览"
aspectRatio={sourceDimensions ? `${sourceDimensions.width} / ${sourceDimensions.height}` : undefined}
onSourceLoad={(width, height) => setSourceDimensions({ width, height })}
/>
{resultPreview && (
@@ -1,12 +1,6 @@
import {
BarChartOutlined,
CheckCircleFilled,
CloseOutlined,
CopyOutlined,
DownloadOutlined,
FileTextOutlined,
LoadingOutlined,
ThunderboltOutlined,
UploadOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent } from "react";
@@ -264,7 +258,7 @@ function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[]
return normalizeEvidenceItems(evidence, 3);
}
function formatReportMarkdown(result: EvalResult, script: string): string {
function formatReportMarkdown(result: EvalResult): string {
const lines: string[] = [];
lines.push(`# 剧本评测报告`);
lines.push("");
@@ -438,7 +432,7 @@ function ScriptTokensPage() {
const handleCopyReport = async () => {
if (!result) return;
const text = formatReportMarkdown(result, script);
const text = formatReportMarkdown(result);
try {
await navigator.clipboard.writeText(text);
setCopied(true);
@@ -459,7 +453,7 @@ function ScriptTokensPage() {
const handleExportMarkdown = () => {
if (!result) return;
const md = formatReportMarkdown(result, script);
const md = formatReportMarkdown(result);
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -130,8 +130,6 @@ function TokenUsagePage({
loadEnterpriseUsage,
loadPersonalUsage,
onOpenMore,
onOpenImageTool,
onSelectView,
}: TokenUsagePageProps) {
const [enterpriseUsage, setEnterpriseUsage] = useState<WebEnterpriseUsageSummary | null>(null);
const [enterpriseUsageLoading, setEnterpriseUsageLoading] = useState(false);
@@ -947,7 +947,6 @@ function SizeTemplatePage({ onOpenEcommerce }: SizeTemplatePageProps) {
);
const selectedPreset =
filteredTemplates.find((item) => item.title === activePresetTitle) ?? filteredTemplates[0] ?? sizeTemplatePresets[0]!;
const activeGroupLabel = sizeTemplateGroups.find((item) => item.key === selectedPreset.group)?.label ?? "尺寸模板";
const platformOptions =
activeGroup === "socialCn"
? socialContentPlatformOptions
@@ -360,7 +360,7 @@ function SubtitleRemovalPage({
</button>
{sourcePreview ? (
<button type="button" className="image-workbench-upload-remove" aria-label="删除素材" onClick={clearSource}>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -339,7 +339,7 @@ function WatermarkRemovalPage({
</button>
{sourcePreview ? (
<button type="button" className="image-workbench-upload-remove" aria-label="删除素材" onClick={clearSource}>
×
<DeleteOutlined />
</button>
) : null}
</div>
+14 -1
View File
@@ -1,5 +1,4 @@
import {
CloseOutlined,
DeleteOutlined,
EditOutlined,
MenuFoldOutlined,
@@ -22,8 +21,22 @@ interface ConversationSidebarProps {
}
function formatRelativeTime(dateStr: string): string {
const relativeMatch = dateStr.trim().match(/^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks|mo|month|months|y|yr|year|years)\s+ago$/i);
if (relativeMatch) {
const value = Number(relativeMatch[1]);
const unit = relativeMatch[2].toLowerCase();
if (unit.startsWith("s")) return "刚刚";
if (unit === "m" || unit.startsWith("min")) return `${value} 分钟前`;
if (unit === "h" || unit.startsWith("hr") || unit.startsWith("hour")) return `${value} 小时前`;
if (unit === "d" || unit.startsWith("day")) return `${value} 天前`;
if (unit === "w" || unit.startsWith("week")) return `${value} 周前`;
if (unit === "mo" || unit.startsWith("month")) return `${value} 个月前`;
if (unit === "y" || unit.startsWith("yr") || unit.startsWith("year")) return `${value} 年前`;
}
const now = Date.now();
const then = new Date(dateStr).getTime();
if (!Number.isFinite(then)) return dateStr;
const diff = now - then;
if (diff < 60_000) return "刚刚";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
+18 -5
View File
@@ -25,13 +25,26 @@ interface ProjectSidebarProps {
}
function formatRelativeTime(dateStr: string): string {
const relativeMatch = dateStr.trim().match(/^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks|mo|month|months|y|yr|year|years)\s+ago$/i);
if (relativeMatch) {
const value = Number(relativeMatch[1]);
const unit = relativeMatch[2].toLowerCase();
if (unit.startsWith("s")) return "刚刚";
if (unit === "m" || unit.startsWith("min")) return `${value} 分钟前`;
if (unit === "h" || unit.startsWith("hr") || unit.startsWith("hour")) return `${value} 小时前`;
if (unit === "d" || unit.startsWith("day")) return `${value} 天前`;
if (unit === "w" || unit.startsWith("week")) return `${value} 周前`;
if (unit === "mo" || unit.startsWith("month")) return `${value} 个月前`;
if (unit === "y" || unit.startsWith("yr") || unit.startsWith("year")) return `${value} 年前`;
}
const then = new Date(dateStr).getTime();
if (!Number.isFinite(then)) return "";
if (!Number.isFinite(then)) return dateStr;
const diff = Date.now() - then;
if (diff < 60_000) return "just now";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} min ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} h ago`;
if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)} d ago`;
if (diff < 60_000) return "刚刚";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`;
if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)} 天前`;
return new Date(dateStr).toLocaleDateString("zh-CN");
}
+21 -3
View File
@@ -1,4 +1,8 @@
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
import {
DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
useSmoothedProgress,
type ProgressSource,
} from "../../hooks/useSmoothedProgress";
type MessageStatus = "thinking" | "completed" | "failed" | string;
@@ -6,6 +10,9 @@ interface SmoothedProgressBarProps {
progress: number;
status: MessageStatus;
label?: string;
progressSource?: ProgressSource;
startedAt?: number | string | Date | null;
expectedDurationMs?: number | null;
}
function mapMessageStatus(status: MessageStatus) {
@@ -15,8 +22,19 @@ function mapMessageStatus(status: MessageStatus) {
return "running" as const;
}
export function SmoothedProgressBar({ progress, status, label }: SmoothedProgressBarProps) {
const smoothed = useSmoothedProgress(progress, mapMessageStatus(status));
export function SmoothedProgressBar({
progress,
status,
label,
progressSource = "estimated",
startedAt,
expectedDurationMs = DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
}: SmoothedProgressBarProps) {
const smoothed = useSmoothedProgress(progress, mapMessageStatus(status), {
progressSource,
startedAt,
expectedDurationMs,
});
return (
<>
<span>{label || "超分处理中..."}</span>
+156 -56
View File
@@ -2,24 +2,18 @@ import {
AppstoreOutlined,
ArrowDownOutlined,
ArrowUpOutlined,
CaretRightOutlined,
ClockCircleOutlined,
CloseOutlined,
CopyOutlined,
DeleteOutlined,
DownloadOutlined,
FullscreenOutlined,
LoadingOutlined,
MessageOutlined,
MutedOutlined,
PictureOutlined,
PauseOutlined,
PlusOutlined,
ReloadOutlined,
SendOutlined,
SettingOutlined,
StopOutlined,
ThunderboltOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import {
@@ -35,10 +29,10 @@ import {
type SyntheticEvent,
} from "react";
import "../../styles/pages/workbench.css";
import type { WebGenerationPreviewTask, WebUserSession } from "../../types";
import type { WebUserSession } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { claimGenerationSlot, getActiveGenerationTaskCount, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUploadService";
import { claimGenerationSlot, getActiveGenerationTaskCount, getEffectiveGenerationLimit, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
import { preUploadReference } from "../../api/referenceUploadService";
import { assetClient } from "../../api/assetClient";
import { communityClient } from "../../api/communityClient";
import { loadRechargeModal, type RechargeModalComponent } from "../../components/RechargeModal/loadRechargeModal";
@@ -46,7 +40,7 @@ import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
import { buildApiUrl, buildAuthHeaders } from "../../api/serverConnection";
import { publicPricingClient, type PublicModelPrice } from "../../api/publicPricingClient";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import type { WebProjectSummary } from "../../types";
import {
@@ -62,11 +56,11 @@ import {
MarkdownMessage,
ResultCard,
} from "./components/WorkbenchChatCards";
import { renderMarkdownBlocks } from "./markdownRenderer";
import { downloadResultAsset } from "./workbenchDownload";
import { translateTaskError } from "../../utils/translateTaskError";
import {
buildLocalTimeoutMessage,
FALLBACK_TEXT_TOKEN_CREDIT_RATE,
formatTextTokenCreditRule,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../../utils/taskLifecycle";
@@ -78,9 +72,13 @@ import {
import { isViduModel } from "../../utils/viduRouting";
import { isPixverseModel } from "../../utils/pixverseRouting";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
import {
getImageQualityOptions,
calculateEnterpriseVideoCredits,
ENTERPRISE_DEFAULT_VIDEO_MODEL,
type EnterpriseVideoPricingConfig,
} from "../../utils/enterpriseVideoPolicy";
import { resolveTextTokenCreditRate } from "../../utils/modelPricing";
import {
getImageQualityOptionsForContext,
getDefaultImageQuality,
getDefaultImageQualityForContext,
@@ -90,8 +88,6 @@ import {
} from "../../utils/modelOptions";
import { filterImageModelOptionsForSession } from "../../utils/imageModelVisibility";
import { persistWorkbenchResultAsset, type PersistedWorkbenchResultAsset } from "./workbenchResultPersistence";
import { SmoothedProgressBar } from "./SmoothedProgressBar";
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
import {
type WorkbenchMode,
type ToolbarMenuId,
@@ -99,8 +95,6 @@ import {
type WorkbenchOption,
type WorkbenchFieldGroup,
type ReferenceItem,
type PromptMentionItem,
type PromptMentionTokenRange,
type ChatAttachment,
type ChatMessage,
type DeleteDialogState,
@@ -117,33 +111,18 @@ import {
GRID_SUPPORTED_MODELS,
VIDEO_FRAME_OPTIONS,
VIDEO_DURATION_OPTIONS,
MESSAGE_STORAGE_KEY,
ACTIVE_CONVERSATION_STORAGE_KEY,
PROMPT_HISTORY_STORAGE_KEY,
TASK_KEEPALIVE_STORAGE_KEY,
WORKBENCH_TASK_STALE_MS,
WORKBENCH_TASK_MAX_POLL_FAILURES,
REFERENCE_IMAGE_COMPRESS_THRESHOLD,
REFERENCE_IMAGE_MAX_DIMENSION,
REFERENCE_IMAGE_INITIAL_QUALITY,
REFERENCE_IMAGE_MIN_QUALITY,
CHAT_MODEL,
CHAT_NATURAL_SYSTEM_PROMPT,
CHAT_TURN_STYLE_REMINDER,
NON_CONVERSATIONAL_ASSISTANT_TEXT,
getCachedRole,
getSessionUserId,
userKey,
createId,
formatWorkbenchTimestamp,
parseWorkbenchTimestampValue,
buildChatAttachments,
buildNaturalChatHistoryMessages,
getErrorText,
isAuthFailure,
isInsufficientBalance,
isInsufficientBalanceMessage,
isTransientMessage,
getPersistableMessages,
shouldPersistPatch,
buildAssistantResult,
@@ -156,25 +135,18 @@ import {
persistMessages,
clearWorkbenchLocalState,
persistPromptHistory,
buildRecoverableTaskFromMessage,
readStoredKeepaliveTasks,
persistKeepaliveTasks,
} from "./workbenchStorage";
import {
getRatioOptionClassName,
getSettingsGridColumnsClassName,
getReferenceAccept,
getReferenceUploadLabel,
getReferenceLimit,
getReferenceKindLabel,
getReferenceEmptyCopy,
hexToRgbTriplet,
inferReferenceKind,
disposeReferencePreview,
fileToDataUrl,
bytesToHex,
buildReferenceFingerprint,
canCompressReferenceImage,
compressReferenceImageIfNeeded,
buildReferenceToken,
resolveReferenceUrls,
@@ -188,7 +160,6 @@ import {
import {
findPromptMentionRangeInside,
findPromptMentionRangeOverlap,
ReferenceInlinePreview,
ReferencePreview,
PromptPreviewLayer,
} from "./WorkbenchPromptPreview";
@@ -315,6 +286,7 @@ function WorkbenchPage({
const [messagePreviewAttachment, setMessagePreviewAttachment] = useState<ChatAttachment | null>(null);
const [selectedPromptCase, setSelectedPromptCase] = useState<PromptCaseViewModel | null>(null);
const [serverPromptCases, setServerPromptCases] = useState<PromptCaseViewModel[]>([]);
const [promptCaseStatus, setPromptCaseStatus] = useState<"loading" | "ready" | "error">("loading");
const [promptCaseMeasuredRatios, setPromptCaseMeasuredRatios] = useState<Record<string, number>>({});
const [mentionPanelPlacement, setMentionPanelPlacement] = useState<"above" | "below">("above");
const [isGenerating, setIsGenerating] = useState(false);
@@ -440,9 +412,32 @@ function WorkbenchPage({
const [videoQuality, setVideoQuality] = useState(() => getDefaultVideoQuality(VIDEO_MODEL_OPTIONS[0].value));
const [chatModel, setChatModel] = useState(CHAT_MODEL_OPTIONS[0].value);
const [modelPrices, setModelPrices] = useState<PublicModelPrice[]>([]);
const [enterpriseVideoPricing, setEnterpriseVideoPricing] = useState<EnterpriseVideoPricingConfig | null>(null);
const [thinkingSpeed, setThinkingSpeed] = useState(THINKING_SPEED_OPTIONS[0].value);
const [thinkingDepth, setThinkingDepth] = useState(THINKING_DEPTH_OPTIONS[0].value);
useEffect(() => {
let cancelled = false;
publicPricingClient
.getPricing()
.then((pricing) => {
if (cancelled) return;
setModelPrices(pricing.modelPrices);
setEnterpriseVideoPricing(pricing.enterpriseVideoPricing);
})
.catch(() => {
if (cancelled) return;
setModelPrices([]);
setEnterpriseVideoPricing(null);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
@@ -495,7 +490,6 @@ function WorkbenchPage({
const workbenchAccent = "#00ff88";
const hasConversationRecords = activeConversationId !== null || messages.length > 0;
const hasActivatedWorkspace = !effectiveOnboarding && (workspaceStarted || isGenerating || hasConversationRecords);
const referenceCount = referenceItems.length;
const activeVideoModelValue = toHappyHorseDisplayModel(videoModel);
const activeModelValue =
activeMode === "image" ? imageModel : activeMode === "video" ? activeVideoModelValue : chatModel;
@@ -522,7 +516,6 @@ function WorkbenchPage({
[conversations],
);
const hasSidebarRecords = conversationRecords.length > 0;
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
const activeConversationTitle = useMemo(() => {
if (!activeConversationId) return "";
@@ -563,6 +556,10 @@ function WorkbenchPage({
const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality);
const imageSettingsSummary = `${imageRatio} / ${imageQuality}`;
const selectedChatTokenRate = useMemo(
() => resolveTextTokenCreditRate(modelPrices, chatModel) || FALLBACK_TEXT_TOKEN_CREDIT_RATE,
[chatModel, modelPrices],
);
const billingEstimate = useMemo(() => {
if (activeMode === "image") {
return {
@@ -579,7 +576,7 @@ function WorkbenchPage({
durationSeconds,
muted: false,
hasReferenceVideo: referenceItems.some((item) => item.kind === "video"),
});
}, enterpriseVideoPricing || undefined);
return {
label: `预计 ${formatCreditValue(credits)} 积分`,
title: `${activeModel}${videoQualityLabel}${durationSeconds} 秒,预计 ${formatCreditValue(credits)} 积分`,
@@ -591,16 +588,20 @@ function WorkbenchPage({
};
}
}
const textBillingPrefix =
selectedChatTokenRate.source === "server" ? "文本计费" : "服务端价格暂不可用,按默认预估";
return {
label: "按 Token 结算",
title: "文本对话按输入、输出 Token 实际用量结算,完成后显示本次积分",
title: `${textBillingPrefix}${activeModel}${formatTextTokenCreditRule(selectedChatTokenRate)}`,
};
}, [
activeMode,
activeModel,
activeModelValue,
imageSettingsSummary,
enterpriseVideoPricing,
referenceItems,
selectedChatTokenRate,
videoDuration,
videoQuality,
videoQualityLabel,
@@ -757,6 +758,7 @@ function WorkbenchPage({
useEffect(() => {
let cancelled = false;
setPromptCaseStatus("loading");
communityClient
.listApprovedCases({ limit: 100, tag: "生成页面社区", sort: "latest" })
.then((items) => {
@@ -766,10 +768,12 @@ function WorkbenchPage({
.map(communityCaseToPromptCase)
.filter((item): item is PromptCaseViewModel => Boolean(item)),
);
setPromptCaseStatus("ready");
})
.catch(() => {
if (!cancelled) {
setServerPromptCases([]);
setPromptCaseStatus("error");
}
});
@@ -1008,7 +1012,7 @@ function WorkbenchPage({
const patchConversationMessage = useCallback(
async (conversationId: number, messageId: string, patch: Partial<ChatMessage>) => {
let sourceMessages =
const sourceMessages =
activeConversationIdRef.current === conversationId
? messagesRef.current
: conversationMessagesCacheRef.current.get(conversationId);
@@ -1056,6 +1060,54 @@ function WorkbenchPage({
persistKeepaliveTasks(rest);
};
const releaseKeepaliveTaskLocally = useCallback((taskId: string, options?: { cancelServer?: boolean }) => {
const task = keepaliveTasksRef.current[taskId];
taskAbortControllersRef.current.get(taskId)?.abort();
taskAbortControllersRef.current.delete(taskId);
removeKeepaliveTask(taskId);
if (task && options?.cancelServer) {
aiGenerationClient.cancelTask(task.taskId).catch(() => {});
}
syncActiveGenerationUi();
}, [syncActiveGenerationUi]);
const releaseKeepaliveTaskAfterNetworkLoss = useCallback((task: WorkbenchKeepaliveTask, progress: number) => {
const latestTask = {
...task,
progress,
statusLabel: "网络中断,已释放提交按钮",
};
void patchConversationMessage(task.conversationId, task.assistantMessageId, {
status: "failed",
taskProgress: Math.max(progress, 100),
taskStatusLabel: "网络中断",
body: "网络中断,当前任务已停止等待并释放提交按钮。请确认网络恢复后重新提交任务。",
});
upsertKeepaliveTask(latestTask);
releaseKeepaliveTaskLocally(task.taskId, { cancelServer: true });
if (activeConversationIdRef.current === task.conversationId) {
setIsGenerating(false);
setGenerationStatus("网络中断,已释放提交按钮");
setGenerationProgress(0);
}
}, [patchConversationMessage, releaseKeepaliveTaskLocally]);
const cancelActiveKeepaliveTasksOnPageExit = useCallback(() => {
const tasks = Object.values(keepaliveTasksRef.current);
if (!tasks.length) return;
tasks.forEach((task) => {
taskAbortControllersRef.current.get(task.taskId)?.abort();
taskAbortControllersRef.current.delete(task.taskId);
releaseGenerationSlot(task.concurrencySlotId);
aiGenerationClient.cancelTaskOnUnload(task.taskId);
});
keepaliveTasksRef.current = {};
persistKeepaliveTasks({});
setIsGenerating(false);
setGenerationStatus("已释放未完成任务");
setGenerationProgress(0);
}, []);
const runKeepalivePoll = useCallback(
(task: WorkbenchKeepaliveTask) => {
if (taskAbortControllersRef.current.has(task.taskId)) return;
@@ -1082,6 +1134,10 @@ function WorkbenchPage({
if (abortController.signal.aborted) return;
if (attempt > 0) await sleep(3000);
if (abortController.signal.aborted) return;
if (typeof navigator !== "undefined" && navigator.onLine === false) {
releaseKeepaliveTaskAfterNetworkLoss(task, lastKnownProgress);
return;
}
let status;
try {
@@ -1095,7 +1151,8 @@ function WorkbenchPage({
taskProgress: 100,
taskStatusLabel: "任务异常",
});
removeKeepaliveTask(task.taskId);
releaseKeepaliveTaskLocally(task.taskId, { cancelServer: true });
onRefreshUsage?.();
return;
}
continue;
@@ -1323,6 +1380,24 @@ function WorkbenchPage({
};
}, [runKeepalivePoll]);
useEffect(() => {
const handlePageHide = () => {
cancelActiveKeepaliveTasksOnPageExit();
};
const handleOnline = () => {
Object.values(keepaliveTasksRef.current).forEach((task) => runKeepalivePoll(task));
syncActiveGenerationUi();
};
window.addEventListener("pagehide", handlePageHide);
window.addEventListener("online", handleOnline);
aiGenerationClient.flushPendingTaskCancellations();
return () => {
window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("online", handleOnline);
};
}, [cancelActiveKeepaliveTasksOnPageExit, runKeepalivePoll, syncActiveGenerationUi]);
useEffect(() => {
persistPromptHistory(promptHistory);
}, [promptHistory]);
@@ -1987,7 +2062,7 @@ function WorkbenchPage({
const trimmedPrompt = (promptOverride ?? inputValue).trim();
if (!trimmedPrompt) return;
const userKey = getGenerationUserKey(session?.user.id);
if (activeMode !== "chat" && getActiveGenerationTaskCount(userKey) >= 3) return;
if (activeMode !== "chat" && getActiveGenerationTaskCount(userKey) >= getEffectiveGenerationLimit()) return;
setReferencePreviewOpen(false);
let conversationId = activeConversationIdRef.current ?? activeConversationId;
@@ -2166,7 +2241,7 @@ function WorkbenchPage({
taskId = result.taskId;
genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "image", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId });
} else {
let requestModel = resolveVideoRequestModel({
const requestModel = resolveVideoRequestModel({
model: taskInput.params?.model || ENTERPRISE_DEFAULT_VIDEO_MODEL,
referenceUrls: refUrls,
});
@@ -2466,8 +2541,11 @@ function WorkbenchPage({
setProjectError("仅支持对视频结果进行超分");
return;
}
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
const userKey = getGenerationUserKey(session?.user.id);
const activeCount = getActiveGenerationTaskCount(userKey);
const limit = getEffectiveGenerationLimit();
if (activeCount >= limit) {
setProjectError(`当前已有 ${activeCount} 个任务进行中(上限${limit}个),请等待任一任务完成后再提交新任务`);
return;
}
if (!isAuthenticated) {
@@ -2588,8 +2666,11 @@ function WorkbenchPage({
setProjectError("仅支持对图片结果进行超分");
return;
}
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
const userKey = getGenerationUserKey(session?.user.id);
const activeCount = getActiveGenerationTaskCount(userKey);
const limit = getEffectiveGenerationLimit();
if (activeCount >= limit) {
setProjectError(`当前已有 ${activeCount} 个任务进行中(上限${limit}个),请等待任一任务完成后再提交新任务`);
return;
}
if (!isAuthenticated) {
@@ -2762,13 +2843,14 @@ function WorkbenchPage({
};
const activeGenerationCount = getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id));
const generationLimitReached = activeMode !== "chat" && activeGenerationCount >= 3;
const activeGenerationLimit = getEffectiveGenerationLimit();
const generationLimitReached = activeMode !== "chat" && activeGenerationCount >= activeGenerationLimit;
const promptIsEmpty = !inputValue.trim();
const sendDisabled = promptIsEmpty || generationLimitReached;
const sendButtonTitle = promptIsEmpty
? "输入内容后可发送"
: generationLimitReached
? `当前已有 ${activeGenerationCount} 个任务进行中,请等待任一任务完成`
? `当前已有 ${activeGenerationCount} 个任务进行中(上限 ${activeGenerationLimit} 个),请等待任一任务完成`
: billingEstimate.title;
const suggestedPrompts = [
@@ -2954,7 +3036,7 @@ function WorkbenchPage({
</div>
);
const renderComposerToolbar = (disabled = false, showStop = false) => (
const renderComposerToolbar = (disabled = false, _showStop = false) => (
<div className="wb-composer__toolbar">
<div className="wb-composer__toolbar-left">
<span data-onboarding={obTarget({ chat: "onboarding-mode-selector", image: "onboarding-mode-selector" })}>
@@ -3332,6 +3414,23 @@ function WorkbenchPage({
<div className="wb-showcase__header">
<h2></h2>
</div>
{promptCaseStatus === "loading" ? (
<div className="wb-prompt-cases__grid wb-prompt-cases__grid--skeleton" aria-label="图片提示词案例加载中">
{Array.from({ length: 8 }, (_, index) => (
<span key={index} className={`wb-prompt-case-skeleton wb-prompt-case-skeleton--${index % 4}`} />
))}
</div>
) : promptCaseStatus === "error" ? (
<div className="wb-prompt-cases__state">
<strong></strong>
<span></span>
</div>
) : promptCaseDisplayItems.length === 0 ? (
<div className="wb-prompt-cases__state">
<strong></strong>
<span></span>
</div>
) : (
<div className="wb-prompt-cases__grid">
{promptCaseDisplayItems.map((item, index) => {
const measuredRatio = promptCaseMeasuredRatios[item.id];
@@ -3356,6 +3455,7 @@ function WorkbenchPage({
);
})}
</div>
)}
</section>
</div>
@@ -19,7 +19,13 @@ import { assetClient } from "../../../api/assetClient";
import { communityClient } from "../../../api/communityClient";
import { saveAssetToLocalLibrary } from "../../assets/localAssetStore";
import { SmoothedProgressBar } from "../SmoothedProgressBar";
import { useSmoothedProgress } from "../../../hooks/useSmoothedProgress";
import {
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS,
formatEstimatedRemainingLabel,
useSmoothedProgress,
} from "../../../hooks/useSmoothedProgress";
import { renderMarkdownBlocks } from "../markdownRenderer";
import { downloadResultAsset } from "../workbenchDownload";
import type { WorkbenchChatAttachment, WorkbenchChatMessage, WorkbenchResultActionPayload } from "../workbenchChatTypes";
@@ -456,6 +462,8 @@ export const ResultCard = memo(function ResultCard({
progress={message.taskProgress ?? 18}
status={message.status || "thinking"}
label={message.taskStatusLabel || "超分处理中..."}
progressSource="estimated"
expectedDurationMs={DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS}
/>
</div>
) : null}
@@ -575,7 +583,23 @@ export const GenerationPendingCard = memo(function GenerationPendingCard({
const specs = message.result?.specs || [];
const prompt = message.prompt || message.body;
const isVideo = message.mode === "video";
const smoothed = useSmoothedProgress(message.taskProgress ?? 5, message.status === "thinking" ? "running" : "completed");
const expectedDurationMs = isVideo
? DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS
: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS;
const progressStatus = message.status === "thinking" ? "running" : "completed";
const smoothed = useSmoothedProgress(message.taskProgress ?? 5, progressStatus, {
progressSource: progressStatus === "running" ? "estimated" : "real",
startedAt: message.createdAt,
expectedDurationMs,
});
const remainingLabel = progressStatus === "running"
? formatEstimatedRemainingLabel({
nowMs: Date.now(),
startedAt: message.createdAt,
expectedDurationMs,
})
: null;
const statusLabel = message.taskStatusLabel || (isVideo ? "视频生成中" : "图片生成中");
return (
<div className={`ai-generation-pending-card${isVideo ? " is-video" : " is-image"}`}>
@@ -590,7 +614,7 @@ export const GenerationPendingCard = memo(function GenerationPendingCard({
</div>
<div className="ai-generation-pending-card__meta">
<div>
<strong>{message.taskStatusLabel || "Generating..."}</strong>
<strong>{remainingLabel ? `${statusLabel} / ${remainingLabel}` : statusLabel}</strong>
<span>{prompt}</span>
</div>
{specs.length > 0 && (
@@ -6,7 +6,6 @@ import {
type WorkbenchMode,
type ReferenceKind,
type ReferenceItem,
type WorkbenchOption,
} from "./workbenchConstants";
import { resolvePreUploadedUrl } from "../../api/referenceUploadService";
@@ -82,7 +81,11 @@ export function fileToDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Unable to read reference file"));
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Unable to read reference file"));
}
};
reader.onerror = () => reject(reader.error || new Error("Unable to read reference file"));
reader.readAsDataURL(file);
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "../test/testHarness";
import {
calculateEstimatedProgress,
formatEstimatedRemainingLabel,
resolveProgressStartedAt,
} from "./useSmoothedProgress";
describe("useSmoothedProgress helpers", () => {
it("calculates estimated progress from elapsed time with an easing curve", () => {
expect(
Math.round(
calculateEstimatedProgress({
nowMs: 220_000,
startedAtMs: 100_000,
expectedDurationMs: 120_000,
}),
),
).toBe(85);
});
it("caps estimated progress below completion", () => {
expect(
Math.round(
calculateEstimatedProgress({
nowMs: 900_000,
startedAtMs: 0,
expectedDurationMs: 120_000,
}),
),
).toBe(92);
});
it("parses local workbench timestamps", () => {
expect(resolveProgressStartedAt("2026-06-10 09:30")).toBe(
new Date(2026, 5, 10, 9, 30).getTime(),
);
});
it("formats remaining time for estimated tasks", () => {
expect(
formatEstimatedRemainingLabel({
nowMs: new Date(2026, 5, 10, 9, 30).getTime(),
startedAt: "2026-06-10 09:29",
expectedDurationMs: 120_000,
}),
).toBe("预计还需 1 分钟");
});
});
+168 -5
View File
@@ -1,6 +1,14 @@
import { useEffect, useRef, useState } from "react";
type ProgressStatus = "queued" | "running" | "submitting" | "completed" | "failed" | "success" | "error";
type ProgressStatus =
| "queued"
| "running"
| "submitting"
| "completed"
| "failed"
| "success"
| "error";
export type ProgressSource = "real" | "estimated";
interface SmoothedProgressOptions {
creepSpeed?: number;
@@ -8,6 +16,11 @@ interface SmoothedProgressOptions {
creepCeiling?: number;
creepAhead?: number;
completionDuration?: number;
progressSource?: ProgressSource;
startedAt?: number | string | Date | null;
expectedDurationMs?: number | null;
estimatedFloor?: number;
estimatedCeiling?: number;
}
const DEFAULT_CREEP_SPEED = 0.5;
@@ -15,15 +28,109 @@ const DEFAULT_CHASE_RATE = 0.09;
const DEFAULT_CREEP_CEILING = 97;
const DEFAULT_CREEP_AHEAD = 25;
const DEFAULT_COMPLETION_DURATION = 450;
export const DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS = 120_000;
export const DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS = 240_000;
export const DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS = 180_000;
export const DEFAULT_GENERATION_EXPECTED_DURATION_MS =
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS;
const DEFAULT_ESTIMATED_FLOOR = 12;
const DEFAULT_ESTIMATED_CEILING = 92;
const ESTIMATED_PROGRESS_CURVE = 2.4;
const MIN_EXPECTED_DURATION_MS = 1_000;
function isTerminal(status: ProgressStatus): boolean {
return status === "completed" || status === "success" || status === "failed" || status === "error";
return (
status === "completed" ||
status === "success" ||
status === "failed" ||
status === "error"
);
}
function isSuccess(status: ProgressStatus): boolean {
return status === "completed" || status === "success";
}
export function resolveProgressStartedAt(
value: number | string | Date | null | undefined,
): number | null {
if (value instanceof Date) {
const time = value.getTime();
return Number.isFinite(time) ? time : null;
}
if (typeof value === "number") {
return Number.isFinite(value) && value > 0 ? value : null;
}
if (typeof value !== "string" || !value.trim()) return null;
const normalized = value.trim();
const localMatch = normalized.match(
/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?/,
);
if (localMatch) {
const [, year, month, day, hours, minutes, seconds] = localMatch;
const time = new Date(
Number(year),
Number(month) - 1,
Number(day),
Number(hours),
Number(minutes),
Number(seconds || 0),
).getTime();
return Number.isFinite(time) ? time : null;
}
const parsed = Date.parse(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
export function calculateEstimatedProgress(input: {
nowMs: number;
startedAtMs: number;
expectedDurationMs?: number | null;
floor?: number;
ceiling?: number;
}): number {
const floor = Math.max(
0,
Math.min(99, input.floor ?? DEFAULT_ESTIMATED_FLOOR),
);
const ceiling = Math.max(
floor,
Math.min(99, input.ceiling ?? DEFAULT_ESTIMATED_CEILING),
);
const duration = Math.max(
MIN_EXPECTED_DURATION_MS,
Number(input.expectedDurationMs) || DEFAULT_GENERATION_EXPECTED_DURATION_MS,
);
const elapsed = Math.max(0, input.nowMs - input.startedAtMs);
const ratio = elapsed / duration;
const eased = 1 - Math.exp(-ratio * ESTIMATED_PROGRESS_CURVE);
return Math.min(ceiling, floor + eased * (ceiling - floor));
}
export function formatEstimatedRemainingLabel(input: {
nowMs: number;
startedAt: number | string | Date | null | undefined;
expectedDurationMs?: number | null;
}): string | null {
const startedAtMs = resolveProgressStartedAt(input.startedAt);
if (!startedAtMs) return null;
const duration = Math.max(
MIN_EXPECTED_DURATION_MS,
Number(input.expectedDurationMs) || DEFAULT_GENERATION_EXPECTED_DURATION_MS,
);
const remainingSeconds = Math.max(
0,
Math.ceil((startedAtMs + duration - input.nowMs) / 1000),
);
if (remainingSeconds <= 0) return "即将完成";
if (remainingSeconds < 60) return `预计还需 ${remainingSeconds}`;
return `预计还需 ${Math.ceil(remainingSeconds / 60)} 分钟`;
}
/**
* Smoothly interpolates between coarse server-reported progress values.
* On completion, animates quickly to 100% instead of jumping.
@@ -37,24 +144,59 @@ export function useSmoothedProgress(
const chaseRate = options?.chaseRate ?? DEFAULT_CHASE_RATE;
const creepCeiling = options?.creepCeiling ?? DEFAULT_CREEP_CEILING;
const creepAhead = options?.creepAhead ?? DEFAULT_CREEP_AHEAD;
const completionDuration = options?.completionDuration ?? DEFAULT_COMPLETION_DURATION;
const completionDuration =
options?.completionDuration ?? DEFAULT_COMPLETION_DURATION;
const progressSource = options?.progressSource ?? "real";
const startedAt = options?.startedAt;
const expectedDurationMs =
options?.expectedDurationMs ?? DEFAULT_GENERATION_EXPECTED_DURATION_MS;
const estimatedFloor = options?.estimatedFloor ?? DEFAULT_ESTIMATED_FLOOR;
const estimatedCeiling =
options?.estimatedCeiling ?? DEFAULT_ESTIMATED_CEILING;
const [displayed, setDisplayed] = useState(0);
const rafRef = useRef(0);
const targetRef = useRef(realProgress);
const statusRef = useRef(status);
const progressSourceRef = useRef(progressSource);
const expectedDurationMsRef = useRef(expectedDurationMs);
const estimatedFloorRef = useRef(estimatedFloor);
const estimatedCeilingRef = useRef(estimatedCeiling);
const estimatedStartedAtRef = useRef<number | null>(null);
const completionStartRef = useRef<number | null>(null);
const completionBaseRef = useRef(0);
useEffect(() => {
targetRef.current = realProgress;
statusRef.current = status;
progressSourceRef.current = progressSource;
expectedDurationMsRef.current = expectedDurationMs;
estimatedFloorRef.current = estimatedFloor;
estimatedCeilingRef.current = estimatedCeiling;
if (progressSource === "estimated" && !isTerminal(status)) {
estimatedStartedAtRef.current =
resolveProgressStartedAt(startedAt) ??
estimatedStartedAtRef.current ??
Date.now();
}
if (isSuccess(status) && completionStartRef.current === null) {
completionStartRef.current = performance.now();
completionBaseRef.current = displayed;
} else if (!isSuccess(status)) {
completionStartRef.current = null;
}
}, [realProgress, status]);
}, [
displayed,
estimatedCeiling,
estimatedFloor,
expectedDurationMs,
progressSource,
realProgress,
startedAt,
status,
]);
useEffect(() => {
if (status === "failed" || status === "error") {
@@ -82,6 +224,20 @@ export function useSmoothedProgress(
if (isTerminal(currentStatus)) return current;
if (progressSourceRef.current === "estimated") {
const target = calculateEstimatedProgress({
nowMs: Date.now(),
startedAtMs: estimatedStartedAtRef.current ?? Date.now(),
expectedDurationMs: expectedDurationMsRef.current,
floor: estimatedFloorRef.current,
ceiling: estimatedCeilingRef.current,
});
if (current >= target) return current;
const gap = target - current;
const step = (gap * chaseRate + 0.2) * dt * 60;
return Math.min(current + step, target);
}
const target = targetRef.current;
if (current >= target) {
@@ -100,7 +256,14 @@ export function useSmoothedProgress(
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, [status, chaseRate, creepSpeed, creepCeiling, creepAhead, completionDuration]);
}, [
status,
chaseRate,
creepSpeed,
creepCeiling,
creepAhead,
completionDuration,
]);
if (status === "failed" || status === "error") return Math.round(displayed);
if (isSuccess(status) && displayed >= 99.5) return 100;
+84
View File
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it } from "../test/testHarness";
import { useGenerationStore } from "./useGenerationStore";
import type { WebGenerationPreviewTask } from "../types";
function previewTask(id: string, status: WebGenerationPreviewTask["status"] = "running"): WebGenerationPreviewTask {
return {
id,
title: "Task",
type: "image",
status,
progress: status === "completed" ? 100 : 10,
prompt: "prompt",
createdAt: "2026-06-10T08:00:00.000Z",
source: "server",
};
}
describe("useGenerationStore task state", () => {
afterEach(() => {
useGenerationStore.setState({ queue: [], tasks: [] });
});
it("merges server preview tasks without duplicating local rows", () => {
const store = useGenerationStore.getState();
store.appendTask(previewTask("server-1"));
store.mergeServerTasks([previewTask("server-1", "completed"), previewTask("server-2")]);
const tasks = useGenerationStore.getState().tasks;
expect(tasks.map((task) => task.id)).toEqual(["server-1", "server-2"]);
expect(tasks[0].status).toBe("completed");
});
it("syncs running queue updates into matching preview tasks", () => {
const store = useGenerationStore.getState();
store.addTask({
id: "local-task-1",
taskId: "server-task-1",
title: "Image",
type: "image",
status: "running",
progress: 5,
prompt: "prompt",
createdAt: Date.now(),
sourceView: "workbench",
});
expect(useGenerationStore.getState().tasks[0].id).toBe("server-task-1");
expect(useGenerationStore.getState().tasks[0].status).toBe("running");
store.updateTask("local-task-1", {
status: "completed",
progress: 100,
resultUrl: "https://oss.example/result.png",
});
const task = useGenerationStore.getState().tasks[0];
expect(task.status).toBe("completed");
expect(task.progress).toBe(100);
expect(task.outputUrl).toBe("https://oss.example/result.png");
});
it("clears preview tasks and running queue together", () => {
const store = useGenerationStore.getState();
store.appendTask(previewTask("server-task-1"));
store.addTask({
id: "local-task-1",
title: "Image",
type: "image",
status: "running",
progress: 5,
prompt: "prompt",
createdAt: Date.now(),
sourceView: "workbench",
});
store.clearTasks();
expect(useGenerationStore.getState().tasks).toEqual([]);
expect(useGenerationStore.getState().queue).toEqual([]);
});
});
+110 -12
View File
@@ -18,6 +18,8 @@ export interface GenerationQueueItem {
params?: Record<string, unknown>;
}
type PreviewTaskPatch = Partial<WebGenerationPreviewTask>;
interface PersistedQueueSnapshot {
version: 1;
items: GenerationQueueItem[];
@@ -54,36 +56,103 @@ function persistQueue(items: GenerationQueueItem[]): void {
interface GenerationStoreState {
queue: GenerationQueueItem[];
tasks: WebGenerationPreviewTask[];
addTask: (item: GenerationQueueItem) => void;
updateTask: (id: string, patch: Partial<GenerationQueueItem>) => void;
removeTask: (id: string) => void;
setTasks: (tasks: WebGenerationPreviewTask[]) => void;
appendTask: (task: WebGenerationPreviewTask) => void;
mergeServerTasks: (serverTasks: WebGenerationPreviewTask[]) => void;
clearTasks: () => void;
getRunningTasks: () => GenerationQueueItem[];
getPendingTasks: () => GenerationQueueItem[];
getTasksByView: (sourceView: string) => GenerationQueueItem[];
clearTerminal: () => void;
}
function hashUserId(): string {
try {
const raw = localStorage.getItem("omniai-web-session");
if (!raw) return "anon";
const parsed = JSON.parse(raw) as { user?: { id?: number | string } };
return String(parsed?.user?.id || "anon");
} catch {
return "anon";
}
const initialQueue = loadPersistedQueue();
function trimTasks(tasks: WebGenerationPreviewTask[]): WebGenerationPreviewTask[] {
return tasks.slice(0, MAX_ITEMS);
}
const initialQueue = loadPersistedQueue();
function mergePreviewTaskById(
tasks: WebGenerationPreviewTask[],
taskId: string | undefined,
patch: PreviewTaskPatch,
): WebGenerationPreviewTask[] {
if (!taskId) return tasks;
let changed = false;
const next = tasks.map((task) => {
if (task.id !== taskId) return task;
changed = true;
return { ...task, ...patch };
});
return changed ? next : tasks;
}
function toPreviewTaskStatus(status: GenerationQueueItem["status"]): WebGenerationPreviewTask["status"] {
if (status === "pending") return "queued";
if (status === "cancelled") return "failed";
return status;
}
function toPreviewTaskPatch(item: GenerationQueueItem): PreviewTaskPatch {
const status = toPreviewTaskStatus(item.status);
return {
status,
progress: item.status === "completed" ? 100 : item.progress,
outputUrl: item.resultUrl || undefined,
errorMessage: item.error || undefined,
};
}
function toPreviewTask(item: GenerationQueueItem): WebGenerationPreviewTask | null {
if (item.type === "ecommerce-video") return null;
const type = item.type;
const createdAt = Number.isFinite(item.createdAt)
? new Date(item.createdAt).toISOString()
: new Date().toISOString();
return {
id: item.taskId || item.id,
title: item.title,
type,
status: toPreviewTaskStatus(item.status),
progress: item.status === "completed" ? 100 : item.progress,
prompt: item.prompt,
createdAt,
projectId:
typeof item.params?.projectId === "string" ? item.params.projectId : undefined,
outputUrl: item.resultUrl || undefined,
source: "preview",
errorMessage: item.error || undefined,
};
}
function upsertPreviewTask(
tasks: WebGenerationPreviewTask[],
task: WebGenerationPreviewTask | null,
): WebGenerationPreviewTask[] {
if (!task) return tasks;
return trimTasks([task, ...tasks.filter((item) => item.id !== task.id)]);
}
function previewTaskIdsForItem(item: GenerationQueueItem): string[] {
return Array.from(new Set([item.taskId, item.id].filter(Boolean) as string[]));
}
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
queue: initialQueue,
tasks: [],
addTask: (item) => {
set((state) => {
const next = [item, ...state.queue].slice(0, MAX_ITEMS);
const previewTasks = upsertPreviewTask(state.tasks, toPreviewTask(item));
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
return { queue: next };
return { queue: next, tasks: previewTasks };
});
},
@@ -92,8 +161,16 @@ export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
const next = state.queue.map((item) =>
item.id === id ? { ...item, ...patch } : item,
);
const updated = next.find((item) => item.id === id);
let previewTasks = state.tasks;
if (updated) {
const previewPatch = toPreviewTaskPatch(updated);
for (const previewTaskId of previewTaskIdsForItem(updated)) {
previewTasks = mergePreviewTaskById(previewTasks, previewTaskId, previewPatch);
}
}
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
return { queue: next };
return { queue: next, tasks: previewTasks };
});
},
@@ -105,6 +182,27 @@ export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
});
},
setTasks: (tasks) => set({ tasks: trimTasks(tasks) }),
appendTask: (task) => set((state) => ({
tasks: trimTasks([task, ...state.tasks.filter((item) => item.id !== task.id)]),
})),
mergeServerTasks: (serverTasks) => set((state) => {
const serverIds = new Set(serverTasks.map((task) => task.id));
return {
tasks: trimTasks([
...serverTasks,
...state.tasks.filter((task) => !serverIds.has(task.id)),
]),
};
}),
clearTasks: () => {
persistQueue([]);
set({ tasks: [], queue: [] });
},
getRunningTasks: () => get().queue.filter((i) => i.status === "running" || i.status === "pending"),
getPendingTasks: () => get().queue.filter((i) => i.status === "pending"),
getTasksByView: (sourceView) => get().queue.filter((i) => i.sourceView === sourceView),
+1 -36
View File
@@ -1,36 +1 @@
import { create } from 'zustand';
import type { WebGenerationPreviewTask } from '../types';
interface TaskState {
tasks: WebGenerationPreviewTask[];
}
interface TaskActions {
setTasks: (tasks: WebGenerationPreviewTask[]) => void;
appendTask: (task: WebGenerationPreviewTask) => void;
mergeServerTasks: (serverTasks: WebGenerationPreviewTask[]) => void;
clearTasks: () => void;
}
const initialState: TaskState = {
tasks: [],
};
export const useTaskStore = create<TaskState & TaskActions>((set) => ({
...initialState,
setTasks: (tasks) => set({ tasks }),
appendTask: (task) => set((state) => ({
tasks: [task, ...state.tasks],
})),
mergeServerTasks: (serverTasks) => set((state) => {
const serverIds = new Set(serverTasks.map((task) => task.id));
return {
tasks: [...serverTasks, ...state.tasks.filter((task) => !serverIds.has(task.id))].slice(0, 80),
};
}),
clearTasks: () => set({ tasks: [] }),
}));
export { useGenerationStore as useTaskStore } from "./useGenerationStore";
+67
View File
@@ -788,6 +788,65 @@
overflow: hidden;
}
.compliance-document {
gap: 0;
padding: 30px 34px 34px;
overflow: visible;
}
.compliance-document__title,
.compliance-document__heading,
.compliance-document__subheading,
.compliance-document__paragraph,
.compliance-document__clause {
margin: 0;
max-width: 100%;
color: var(--fg-body);
letter-spacing: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.compliance-document__title {
margin-bottom: 18px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-subtle);
font-size: clamp(24px, 3vw, 32px);
line-height: 1.22;
}
.compliance-document__heading {
margin: 26px 0 12px;
padding-top: 4px;
color: var(--accent);
font-size: 20px;
line-height: 1.35;
}
.compliance-document__subheading {
margin: 18px 0 8px;
font-size: 16px;
line-height: 1.5;
}
.compliance-document__paragraph,
.compliance-document__clause {
color: var(--fg-muted);
font-size: 14px;
line-height: 1.85;
}
.compliance-document__paragraph + .compliance-document__paragraph,
.compliance-document__clause + .compliance-document__clause,
.compliance-document__paragraph + .compliance-document__clause,
.compliance-document__clause + .compliance-document__paragraph {
margin-top: 8px;
}
.compliance-document__clause {
padding-left: 12px;
}
.compliance-section {
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
@@ -892,4 +951,12 @@
.compliance-section {
grid-template-columns: 1fr;
}
.compliance-document {
padding: 22px 18px 26px;
}
.compliance-document__clause {
padding-left: 0;
}
}
File diff suppressed because it is too large Load Diff
+46 -22
View File
@@ -2,50 +2,74 @@
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
margin-top: 0;
}
.subtitle-removal-preset {
display: flex;
flex-direction: column;
display: grid;
grid-template-rows: auto auto 1fr;
justify-items: center;
align-items: center;
gap: 4px;
gap: 6px;
min-width: 0;
min-height: 92px;
padding: 10px 8px;
border: 1.5px solid var(--border-weak, #e5e5e5);
border-radius: 10px;
background: var(--bg-surface, #fff);
border: 1px solid color-mix(in srgb, var(--border-subtle, #333) 82%, white 10%);
border-radius: var(--radius-xs, 8px);
background:
linear-gradient(180deg, rgb(255 255 255 / 0.018), transparent),
var(--bg-inset, #111);
color: var(--fg-muted, #aaa);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, transform 0.16s ease;
}
.subtitle-removal-preset:hover {
border-color: var(--accent, #0d9488);
background: var(--bg-elevated, #f8f8f8);
border-color: color-mix(in srgb, var(--accent, #0d9488) 42%, var(--border-subtle, #333));
background:
linear-gradient(180deg, color-mix(in srgb, var(--accent, #0d9488) 8%, transparent), transparent),
var(--bg-hover, #171717);
color: var(--fg-body, #eee);
transform: translateY(-1px);
}
.subtitle-removal-preset.is-active {
border-color: var(--accent, #0d9488);
background: color-mix(in srgb, var(--accent, #0d9488) 6%, transparent);
border-color: color-mix(in srgb, var(--accent, #0d9488) 72%, transparent);
background: color-mix(in srgb, var(--accent, #0d9488) 13%, var(--bg-inset, #111));
color: var(--accent, #0d9488);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #0d9488) 8%, transparent) inset;
}
.subtitle-removal-preset strong {
max-width: 100%;
color: var(--fg-body, #eee);
font-size: 12px;
font-weight: 600;
font-weight: 800;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle-removal-preset span {
max-width: 100%;
color: var(--fg-muted, #aaa);
font-size: 11px;
opacity: 0.55;
line-height: 1.35;
opacity: 0.82;
text-align: center;
}
.subtitle-removal-preset__visual {
position: relative;
width: 48px;
height: 32px;
border-radius: 4px;
background: var(--bg-page, #f0f0f0);
border: 1px solid var(--border-weak, #e0e0e0);
width: 54px;
height: 34px;
border: 1px solid color-mix(in srgb, var(--border-subtle, #333) 78%, white 10%);
border-radius: 6px;
background:
linear-gradient(180deg, rgb(255 255 255 / 0.026), transparent),
color-mix(in srgb, var(--bg-elevated, #161616) 92%, black 8%);
box-shadow: 0 1px 0 rgb(255 255 255 / 0.035) inset;
overflow: hidden;
}
@@ -55,9 +79,9 @@
right: 0;
top: var(--region-top);
height: var(--region-height);
background: color-mix(in srgb, var(--accent, #0d9488) 30%, transparent);
border-top: 1.5px dashed var(--accent, #0d9488);
border-bottom: 1.5px dashed var(--accent, #0d9488);
background: color-mix(in srgb, var(--accent, #0d9488) 24%, transparent);
border-top: 1px dashed var(--accent, #0d9488);
border-bottom: 1px dashed var(--accent, #0d9488);
}
.subtitle-removal-preview {
+666
View File
@@ -2319,3 +2319,669 @@
display: none;
}
}
/* Workbench new-conversation commercial polish. */
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page {
--wb-elevated-line: rgba(210, 255, 232, 0.12);
--wb-elevated-line-active: rgba(var(--accent-rgb), 0.38);
--wb-elevated-fill: rgba(18, 22, 21, 0.94);
--wb-control-fill: rgba(255, 255, 255, 0.052);
--wb-control-fill-hover: rgba(var(--accent-rgb), 0.105);
--wb-copy-dim: rgba(202, 215, 207, 0.68);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home {
gap: 20px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__title {
color: rgba(246, 250, 247, 0.96);
font-weight: 760;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__content {
border-color: var(--wb-elevated-line);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.072), rgba(255, 255, 255, 0.024)),
var(--wb-elevated-fill);
box-shadow:
0 20px 46px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.065);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__content:focus-within {
border-color: var(--wb-elevated-line-active);
box-shadow:
0 0 0 1px rgba(var(--accent-rgb), 0.12),
0 24px 56px rgba(0, 0, 0, 0.32),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__textarea,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__highlight {
color: rgba(246, 250, 247, 0.95);
font-weight: 450;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar {
align-items: center;
gap: 10px;
border-top-color: rgba(210, 255, 232, 0.095);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar-left {
row-gap: 8px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-inline-chip__trigger,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-mode-switch__button,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-select-chip__trigger {
border-color: rgba(210, 255, 232, 0.105);
background: var(--wb-control-fill);
color: rgba(246, 250, 247, 0.86);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-inline-chip__trigger:hover:not(:disabled),
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-select-chip__trigger:hover,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-mode-switch__button:hover {
border-color: rgba(var(--accent-rgb), 0.34);
background: var(--wb-control-fill-hover);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__billing-estimate {
color: var(--wb-copy-dim);
font-size: 11px;
font-weight: 580;
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__send-primary {
box-shadow:
0 12px 26px rgba(var(--accent-rgb), 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.26);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__send-primary.is-loading {
cursor: progress;
opacity: 0.92;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-upload {
border-color: rgba(var(--accent-rgb), 0.36);
background:
linear-gradient(180deg, rgba(var(--accent-rgb), 0.11), rgba(var(--accent-rgb), 0.045)),
rgba(255, 255, 255, 0.02);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-label,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-count {
color: rgba(210, 255, 232, 0.84);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__suggestions {
align-items: center;
margin-top: -2px;
min-height: 34px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-suggestion-chip {
border-color: rgba(210, 255, 232, 0.105);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.052), rgba(255, 255, 255, 0.022)),
rgba(255, 255, 255, 0.018);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-suggestion-chip__icon {
color: rgba(var(--accent-rgb), 0.88);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases {
padding-bottom: 8px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases .wb-showcase__header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 24px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-showcase__header h2 {
color: rgba(224, 236, 229, 0.72);
font-size: 12px;
font-weight: 680;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card {
border-color: rgba(210, 255, 232, 0.085);
background: #090e0d;
box-shadow:
0 14px 28px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.035);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card:hover,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card:focus-visible {
border-color: rgba(var(--accent-rgb), 0.32);
box-shadow:
0 18px 36px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(var(--accent-rgb), 0.08);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card > div {
gap: 5px;
background:
linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.54) 24%, rgba(0, 0, 0, 0.9));
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card strong {
color: rgba(255, 255, 255, 0.95);
line-height: 1.38;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card em {
color: rgba(210, 224, 216, 0.68);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases__state {
display: grid;
place-items: center;
align-content: center;
gap: 6px;
min-height: 188px;
padding: 26px;
border: 1px dashed rgba(210, 255, 232, 0.13);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.014)),
rgba(255, 255, 255, 0.018);
color: rgba(246, 250, 247, 0.9);
text-align: center;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases__state strong {
font-size: 14px;
font-weight: 720;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases__state span {
color: var(--wb-copy-dim);
font-size: 12px;
font-weight: 520;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-skeleton {
display: block;
min-height: 160px;
grid-row: span 16;
overflow: hidden;
border: 1px solid rgba(210, 255, 232, 0.07);
border-radius: 14px;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.035)),
rgba(255, 255, 255, 0.025);
background-size: 220% 100%;
animation: wb-prompt-case-loading 1.15s ease-in-out infinite;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-skeleton--1,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-skeleton--3 {
grid-row: span 22;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-active .ai-chat-message-list > .conversation-sidebar__empty {
border-color: rgba(210, 255, 232, 0.14);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.018)),
rgba(255, 255, 255, 0.018);
color: rgba(210, 224, 216, 0.78);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar {
border-left-color: rgba(210, 255, 232, 0.095);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent 40%),
rgba(12, 15, 15, 0.97);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__header {
border-bottom-color: rgba(210, 255, 232, 0.095);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__new {
border-color: rgba(var(--accent-rgb), 0.38);
background:
linear-gradient(180deg, rgba(var(--accent-rgb), 0.17), rgba(var(--accent-rgb), 0.095)),
rgba(255, 255, 255, 0.02);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__item {
transition:
border-color var(--transition-fast),
background var(--transition-fast),
transform var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__item:hover {
transform: translateX(-1px);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__item-title {
line-height: 1.35;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__empty {
border-color: rgba(210, 255, 232, 0.12);
background: rgba(255, 255, 255, 0.022);
}
@keyframes wb-prompt-case-loading {
0% {
background-position: 120% 0;
}
100% {
background-position: -120% 0;
}
}
@media (max-width: 720px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .ai-workbench-shell > .conversation-sidebar.is-collapsed {
inset: calc(56px + var(--dg-mobile-nav-space, 70px) + 16px) 8px auto auto !important;
transform: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home {
gap: 16px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__composer {
width: 100%;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__content,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__content {
padding: 13px;
border-radius: 18px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs {
width: 60px;
min-width: 60px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-upload {
width: 58px;
height: 58px;
transform: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-upload:hover:not(:disabled) {
transform: translateY(-1px);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__textarea,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__highlight,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__textarea,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__highlight {
min-height: 88px;
padding-right: 0;
font-size: 15px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
padding-top: 9px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar-left {
max-width: none;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar-left::-webkit-scrollbar,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__suggestions::-webkit-scrollbar {
display: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar-right {
align-self: stretch;
display: grid;
grid-template-rows: 1fr auto;
justify-items: end;
gap: 6px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__billing-estimate {
max-width: 112px;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases {
margin-top: 0;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases__state {
min-height: 156px;
border-radius: 14px;
}
}
@media (max-width: 560px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home {
padding-right: 12px;
padding-left: 12px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__title {
font-size: 23px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__input-row {
gap: 9px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-inline-chip__trigger,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-mode-switch__button,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-select-chip__trigger {
height: 32px;
max-width: 132px;
border-radius: 10px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-suggestion-chip {
min-height: 32px;
height: 32px;
padding: 0 12px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases__grid {
gap: 7px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card {
border-radius: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-skeleton {
animation: none;
}
}
/* Browser feedback: scale the launch composer with large canvases and keep reference previews intact. */
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__composer {
width: min(100%, clamp(920px, 72vw, 1160px));
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__content {
padding: clamp(18px, 1.25vw, 24px);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__textarea,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__highlight {
min-height: clamp(78px, 8svh, 112px);
max-height: clamp(180px, 24svh, 260px);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__suggestions {
max-width: min(100%, clamp(920px, 72vw, 1160px));
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-card {
isolation: isolate;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-zoom {
z-index: 12;
width: min(280px, calc(100vw - 48px));
height: auto;
min-height: 188px;
max-height: min(340px, calc(100svh - 180px));
aspect-ratio: 1 / 1;
padding: 8px;
border-color: rgba(210, 255, 232, 0.18);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
rgba(5, 8, 8, 0.96);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-zoom img,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-zoom video {
border-radius: 11px;
object-fit: contain;
}
@media (min-width: 1600px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__composer,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__suggestions {
width: min(100%, clamp(1040px, 64vw, 1240px));
max-width: none;
}
}
@media (max-width: 980px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__composer,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__suggestions {
width: 100%;
max-width: 920px;
}
}
@media (max-width: 720px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__content {
padding: 13px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__textarea,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__highlight {
min-height: 88px;
max-height: 190px;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-zoom {
width: min(230px, calc(100vw - 32px));
min-height: 168px;
}
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-stack {
top: calc(100% + 12px);
bottom: auto;
isolation: isolate;
z-index: 120;
width: min(320px, calc(100vw - 64px));
max-width: calc(100vw - 64px);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-zoom {
top: 0;
transform: translateY(0) scale(0.98);
transform-origin: left top;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__ref-card:hover .wb-composer__ref-zoom,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
transform: translateY(0) scale(1);
}
/* Keep reference previews above the feed, and open lower-row thumbnails upward. */
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__composer {
position: relative;
z-index: 30;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__suggestions {
position: relative;
z-index: 10;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases {
position: relative;
z-index: 1;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs.has-items.is-open {
z-index: 220;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-stack {
z-index: 240;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5) .wb-composer__ref-zoom {
top: auto;
bottom: calc(100% + 12px);
transform: translateY(-6px) scale(0.98);
transform-origin: left bottom;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5):hover .wb-composer__ref-zoom,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5) .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
transform: translateY(0) scale(1);
}
@media (max-width: 720px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 4) .wb-composer__ref-zoom {
top: auto;
bottom: calc(100% + 10px);
transform: translateY(-6px) scale(0.98);
transform-origin: left bottom;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 4):hover .wb-composer__ref-zoom,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 4) .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
transform: translateY(0) scale(1);
}
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-stack {
isolation: isolate;
z-index: 120;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card {
isolation: auto;
z-index: 2;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-add-more {
position: relative;
z-index: 1;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:hover,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:focus-within {
z-index: 180;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-zoom {
left: 0;
top: calc(100% + 12px);
z-index: 80;
transform: translateY(6px) scale(0.98);
transform-origin: left top;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:hover .wb-composer__ref-zoom,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
transform: translateY(0) scale(1);
}
@media (max-width: 720px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-stack {
top: calc(100% + 8px);
width: min(230px, calc(100vw - 24px));
max-width: calc(100vw - 24px);
}
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-card:hover,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-card:focus-within {
z-index: 140;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-card:hover .wb-composer__ref-preview,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-card:focus-within .wb-composer__ref-preview {
border-color: rgba(var(--accent-rgb), 0.48);
box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.18);
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card {
isolation: auto;
z-index: 2;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-add-more {
position: relative;
z-index: 1;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card:hover,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card:focus-within {
z-index: 180;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-zoom {
left: 0;
top: calc(100% + 12px);
z-index: 80;
transform: translateY(6px) scale(0.98);
transform-origin: left top;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__ref-card:hover .wb-composer__ref-zoom,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
transform: translateY(0) scale(1);
}
/* Final override for multi-row reference stacks on the launch composer. */
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5) .wb-composer__ref-zoom {
top: auto;
bottom: calc(100% + 12px);
transform: translateY(-6px) scale(0.98);
transform-origin: left bottom;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5):hover .wb-composer__ref-zoom,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5) .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
transform: translateY(0) scale(1);
}
/* Keep the reference stack balanced in the active bottom composer. */
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-stack {
grid-template-columns: repeat(4, 58px);
width: min(286px, calc(100vw - 40px));
}
@media (max-width: 560px) {
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-stack {
grid-template-columns: repeat(3, 58px);
width: min(218px, calc(100vw - 24px));
}
}
/* The active bottom composer sits below the upload stack, so previews should open upward. */
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-active .wb-composer__refs .wb-composer__ref-card .wb-composer__ref-zoom {
top: auto;
bottom: calc(100% + 12px);
transform: translateY(-6px) scale(0.98);
transform-origin: left bottom;
}
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-active .wb-composer__refs .wb-composer__ref-card:hover .wb-composer__ref-zoom,
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-active .wb-composer__refs .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
transform: translateY(0) scale(1);
}
+150
View File
@@ -0,0 +1,150 @@
type TestFn = () => unknown | Promise<unknown>;
interface TestCase {
name: string;
run: TestFn;
afterEachFns: TestFn[];
}
const tests: TestCase[] = [];
const suiteStack: string[] = [];
const afterEachStack: TestFn[][] = [[]];
function formatValue(value: unknown): string {
return typeof value === "string" ? `"${value}"` : JSON.stringify(value);
}
function fail(message: string): never {
throw new Error(message);
}
function assertThrows(
actual: unknown,
expectedMessage?: string,
inverted = false,
): void {
if (typeof actual !== "function") {
fail("Expected value to be a function");
}
let thrown: unknown;
try {
actual();
} catch (error) {
thrown = error;
}
if (inverted) {
if (thrown)
fail(
`Expected function not to throw, but it threw ${thrown instanceof Error ? thrown.message : String(thrown)}`,
);
return;
}
if (!thrown) fail("Expected function to throw");
if (expectedMessage) {
const message = thrown instanceof Error ? thrown.message : String(thrown);
if (!message.includes(expectedMessage)) {
fail(
`Expected thrown message to include "${expectedMessage}", got "${message}"`,
);
}
}
}
export function describe(name: string, run: () => void): void {
suiteStack.push(name);
afterEachStack.push([]);
try {
run();
} finally {
afterEachStack.pop();
suiteStack.pop();
}
}
export function it(name: string, run: TestFn): void {
tests.push({
name: [...suiteStack, name].join(" > "),
run,
afterEachFns: afterEachStack.flat(),
});
}
export function afterEach(run: TestFn): void {
afterEachStack[afterEachStack.length - 1].push(run);
}
export function expect(actual: unknown) {
return {
toBe(expected: unknown): void {
if (!Object.is(actual, expected)) {
fail(`Expected ${formatValue(actual)} to be ${formatValue(expected)}`);
}
},
toEqual(expected: unknown): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
fail(`Expected ${actualJson} to equal ${expectedJson}`);
}
},
toThrow(expectedMessage?: string): void {
assertThrows(actual, expectedMessage);
},
not: {
toThrow(): void {
assertThrows(actual, undefined, true);
},
},
};
}
export async function runRegisteredTests(): Promise<{
passed: number;
failed: number;
total: number;
}> {
let passed = 0;
let failed = 0;
for (const test of tests) {
let failure: unknown;
try {
await test.run();
} catch (error) {
failure = error;
}
for (const hook of [...test.afterEachFns].reverse()) {
try {
await hook();
} catch (error) {
failure = failure || error;
}
}
if (failure) {
failed += 1;
console.error(`FAIL ${test.name}`);
console.error(
failure instanceof Error
? ` ${failure.message}`
: ` ${String(failure)}`,
);
} else {
passed += 1;
console.log(`PASS ${test.name}`);
}
}
const total = passed + failed;
if (total === 0) {
console.error("No unit tests were registered.");
return { passed, failed: 1, total };
}
console.log(`\n${passed} passed, ${failed} failed`);
return { passed, failed, total };
}
+1
View File
@@ -0,0 +1 @@
export * from "../testHarness";
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "../test/testHarness";
import {
calculateEnterpriseVideoCredits,
type EnterpriseVideoPricingConfig,
getEnterpriseVideoCreditRate,
normalizeEnterpriseResolution,
} from "./enterpriseVideoPolicy";
describe("enterpriseVideoPolicy", () => {
it("keeps video billing at 1 CNY to 100 credits", () => {
expect(
calculateEnterpriseVideoCredits({
model: "happyhorse-1.0",
resolution: "1080P",
durationSeconds: 5,
}),
).toBe(640);
expect(
calculateEnterpriseVideoCredits({
model: "wan2.7-i2v",
resolution: "720P",
durationSeconds: 5,
}),
).toBe(300);
});
it("rounds duration up to the next second before billing", () => {
expect(
calculateEnterpriseVideoCredits({
model: "vidu-q3-turbo",
resolution: "1080P",
durationSeconds: 5.2,
}),
).toBe(600);
});
it("normalizes unsupported resolutions to 1080P", () => {
expect(normalizeEnterpriseResolution("4K")).toBe("1080P");
expect(
getEnterpriseVideoCreditRate({
model: "pixverse-c1",
resolution: "4K",
durationSeconds: 5,
}),
).toBe(1);
});
it("uses server-provided pricing config before fallback pricing", () => {
const serverPricing: EnterpriseVideoPricingConfig = {
creditsPerCny: 100,
defaultResolution: "1080P",
rules: [
{
id: "happyhorse-server",
modelIncludes: ["happyhorse"],
rates: { "720P": 2, "1080P": 3 },
},
],
};
expect(
getEnterpriseVideoCreditRate(
{
model: "happyhorse-1.0",
resolution: "1080P",
durationSeconds: 5,
},
serverPricing,
),
).toBe(3);
expect(
calculateEnterpriseVideoCredits(
{
model: "happyhorse-1.0",
resolution: "1080P",
durationSeconds: 5,
},
serverPricing,
),
).toBe(1500);
});
});
+102 -33
View File
@@ -50,50 +50,119 @@ export interface EnterpriseVideoPricingInput {
hasReferenceVideo?: boolean;
}
export interface EnterpriseVideoPricingRule {
id: string;
modelIncludes: string[];
when?: {
muted?: boolean;
hasReferenceVideo?: boolean;
};
rates: Record<string, number>;
}
export interface EnterpriseVideoPricingConfig {
currency?: string;
creditsPerCny?: number;
billingUnit?: "per_second" | string;
defaultResolution?: string;
resolutions?: string[];
rules: EnterpriseVideoPricingRule[];
}
export const FALLBACK_ENTERPRISE_VIDEO_PRICING_CONFIG: EnterpriseVideoPricingConfig = {
currency: "CNY",
creditsPerCny: CREDITS_PER_CNY,
billingUnit: "per_second",
defaultResolution: ENTERPRISE_DEFAULT_VIDEO_RESOLUTION,
resolutions: ["720P", "1080P"],
rules: [
{
id: "happyhorse",
modelIncludes: ["happyhorse"],
rates: { "720P": 0.72, "1080P": 1.28 },
},
{
id: "wanxiang-i2v",
modelIncludes: ["wan2.7-i2v", "wanxiang"],
rates: { "720P": 0.6, "1080P": 1 },
},
{
id: "wan-animate-s2v",
modelIncludes: ["animate-mix", "s2v"],
rates: { "720P": 0.6, "1080P": 1 },
},
{
id: "kling-muted-reference",
modelIncludes: ["kling"],
when: { muted: true, hasReferenceVideo: true },
rates: { "720P": 0.9, "1080P": 1.2 },
},
{
id: "kling-muted",
modelIncludes: ["kling"],
when: { muted: true, hasReferenceVideo: false },
rates: { "720P": 0.6, "1080P": 0.8 },
},
{
id: "kling-default",
modelIncludes: ["kling"],
rates: { "720P": 0.9, "1080P": 1.2 },
},
{
id: "vidu",
modelIncludes: ["vidu"],
rates: { "720P": 0.6, "1080P": 1 },
},
{
id: "pixverse",
modelIncludes: ["pixverse"],
rates: { "720P": 0.6, "1080P": 1 },
},
],
};
export function normalizeEnterpriseResolution(value: string): "720P" | "1080P" {
return String(value || "").toUpperCase() === "720P" ? "720P" : "1080P";
}
export function getEnterpriseVideoCreditRate(input: EnterpriseVideoPricingInput): number {
function enterpriseVideoPricingRuleMatches(
rule: EnterpriseVideoPricingRule,
input: EnterpriseVideoPricingInput,
model: string,
): boolean {
if (!rule.modelIncludes.some((pattern) => model.includes(String(pattern || "").toLowerCase()))) return false;
if (!rule.when) return true;
if ("muted" in rule.when && Boolean(input.muted) !== rule.when.muted) return false;
if ("hasReferenceVideo" in rule.when && Boolean(input.hasReferenceVideo) !== rule.when.hasReferenceVideo) {
return false;
}
return true;
}
export function getEnterpriseVideoCreditRate(
input: EnterpriseVideoPricingInput,
config: EnterpriseVideoPricingConfig = FALLBACK_ENTERPRISE_VIDEO_PRICING_CONFIG,
): number {
const resolution = normalizeEnterpriseResolution(input.resolution);
const model = String(input.model || "").toLowerCase();
const fallbackResolution = normalizeEnterpriseResolution(
config.defaultResolution || ENTERPRISE_DEFAULT_VIDEO_RESOLUTION,
);
const rule = config.rules.find((candidate) => enterpriseVideoPricingRuleMatches(candidate, input, model));
if (model.includes("happyhorse")) {
return resolution === "720P" ? 0.72 : 1.28;
}
if (model.includes("wan2.7-i2v") || model.includes("wanxiang")) {
return resolution === "720P" ? 0.6 : 1;
}
if (model.includes("animate-mix")) {
return resolution === "720P" ? 0.6 : 1;
}
if (model.includes("s2v")) {
return resolution === "720P" ? 0.6 : 1;
}
if (model.includes("vidu")) {
return resolution === "720P" ? 0.6 : 1.0;
}
if (model.includes("pixverse")) {
return resolution === "720P" ? 0.6 : 1.0;
}
if (model.includes("kling")) {
if (input.muted) {
if (input.hasReferenceVideo) return resolution === "720P" ? 0.9 : 1.2;
return resolution === "720P" ? 0.6 : 0.8;
}
return resolution === "720P" ? 0.9 : 1.2;
if (rule) {
const rate = rule.rates[resolution] ?? rule.rates[fallbackResolution];
if (Number.isFinite(rate) && rate >= 0) return rate;
}
throw new Error(`Unsupported enterprise video model: ${input.model}`);
}
export function calculateEnterpriseVideoCredits(input: EnterpriseVideoPricingInput): number {
export function calculateEnterpriseVideoCredits(
input: EnterpriseVideoPricingInput,
config: EnterpriseVideoPricingConfig = FALLBACK_ENTERPRISE_VIDEO_PRICING_CONFIG,
): number {
const duration = Math.max(1, Math.ceil(Number(input.durationSeconds) || 1));
return Number((getEnterpriseVideoCreditRate(input) * duration * CREDITS_PER_CNY).toFixed(2));
const creditsPerCny = Number(config.creditsPerCny || CREDITS_PER_CNY);
return Number((getEnterpriseVideoCreditRate(input, config) * duration * creditsPerCny).toFixed(2));
}
+1 -1
View File
@@ -10,7 +10,7 @@ interface ErrorReport {
sessionId?: string;
}
let reportQueue: ErrorReport[] = [];
const reportQueue: ErrorReport[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
function getSessionId(): string | undefined {
+1 -1
View File
@@ -38,7 +38,7 @@ export function detectMentionTrigger(textBeforeCursor: string): MentionTriggerMa
const query = textBeforeCursor.slice(atIdx + 1);
// Query must not contain spaces or punctuation that would break a mention token
if (/[\s,。、;:!??!.,;:(){}\[\]<>""''《》【】@]/.test(query)) {
if (/[\s,。、;:!??!.,;:(){}[\]<>""''《》【】@]/.test(query)) {
return null;
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "../test/testHarness";
import {
millsPerThousandTokensToCreditsPerMillion,
modelPriceToTextTokenCreditRate,
resolveTextTokenCreditRate,
} from "./modelPricing";
describe("modelPricing", () => {
it("converts backend mills per thousand tokens to credits per million tokens", () => {
expect(millsPerThousandTokensToCreditsPerMillion(27)).toBe(2_700);
expect(millsPerThousandTokensToCreditsPerMillion(108)).toBe(10_800);
});
it("converts a token model price row to a text token credit rate", () => {
expect(
modelPriceToTextTokenCreditRate({
modelKey: "gpt-4o",
inputPriceMills: 27,
outputPriceMills: 108,
flatPriceMills: null,
currency: "CNY",
enabled: true,
}),
).toEqual({
inputCreditsPerMillion: 2_700,
outputCreditsPerMillion: 10_800,
source: "server",
modelKey: "gpt-4o",
});
});
it("resolves token pricing by exact or fuzzy model key without accepting flat prices", () => {
const prices = [
{
modelKey: "gemini-3-pro-image",
inputPriceMills: null,
outputPriceMills: null,
flatPriceMills: 200,
currency: "CNY",
enabled: true,
},
{
modelKey: "gemini-3.1-pro",
inputPriceMills: 12,
outputPriceMills: 48,
flatPriceMills: null,
currency: "CNY",
enabled: true,
},
];
expect(resolveTextTokenCreditRate(prices, "gemini")).toEqual({
inputCreditsPerMillion: 1_200,
outputCreditsPerMillion: 4_800,
source: "server",
modelKey: "gemini-3.1-pro",
});
});
});
+104
View File
@@ -0,0 +1,104 @@
import type { PublicModelPrice } from "../api/publicPricingClient";
import type { TextTokenCreditRate } from "./taskLifecycle";
const TOKENS_PER_MILLION = 1_000_000;
const BACKEND_TOKEN_PRICE_UNIT = 1_000;
const CREDITS_PER_CNY = 100;
const MILLS_PER_CNY = 1_000;
const CREDITS_PER_MILL = CREDITS_PER_CNY / MILLS_PER_CNY;
function isUsablePrice(value: number | null | undefined): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function normalizeModelKey(value: string): string {
return value.trim().toLowerCase();
}
function compactModelKey(value: string): string {
return normalizeModelKey(value).replace(/[^a-z0-9]+/g, "");
}
function addCandidate(
candidates: PublicModelPrice[],
seen: Set<string>,
price: PublicModelPrice,
): void {
const key = normalizeModelKey(price.modelKey);
if (seen.has(key)) return;
seen.add(key);
candidates.push(price);
}
export function millsPerThousandTokensToCreditsPerMillion(
priceMills: number,
): number {
if (!isUsablePrice(priceMills)) return 0;
return (
priceMills *
(TOKENS_PER_MILLION / BACKEND_TOKEN_PRICE_UNIT) *
CREDITS_PER_MILL
);
}
export function modelPriceToTextTokenCreditRate(
price: PublicModelPrice,
): TextTokenCreditRate | null {
if (
!isUsablePrice(price.inputPriceMills) ||
!isUsablePrice(price.outputPriceMills)
)
return null;
return {
inputCreditsPerMillion: millsPerThousandTokensToCreditsPerMillion(
price.inputPriceMills,
),
outputCreditsPerMillion: millsPerThousandTokensToCreditsPerMillion(
price.outputPriceMills,
),
source: "server",
modelKey: price.modelKey,
};
}
export function resolveTextTokenCreditRate(
prices: PublicModelPrice[],
modelKey: string | null | undefined,
): TextTokenCreditRate | null {
const normalizedTarget = normalizeModelKey(modelKey || "");
if (!normalizedTarget) return null;
const compactTarget = compactModelKey(normalizedTarget);
const candidates: PublicModelPrice[] = [];
const seen = new Set<string>();
for (const price of prices) {
if (normalizeModelKey(price.modelKey) === normalizedTarget) {
addCandidate(candidates, seen, price);
}
}
for (const price of prices) {
if (compactModelKey(price.modelKey) === compactTarget) {
addCandidate(candidates, seen, price);
}
}
for (const price of prices) {
const compactPriceKey = compactModelKey(price.modelKey);
if (
compactPriceKey.includes(compactTarget) ||
compactTarget.includes(compactPriceKey)
) {
addCandidate(candidates, seen, price);
}
}
for (const price of candidates) {
const rate = modelPriceToTextTokenCreditRate(price);
if (rate) return rate;
}
return null;
}
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "../test/testHarness";
import {
TEXT_INPUT_CREDITS_PER_MILLION,
TEXT_OUTPUT_CREDITS_PER_MILLION,
estimateTextTokenCredits,
formatTextTokenCreditRule,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "./taskLifecycle";
describe("taskLifecycle", () => {
it("keeps fallback text token billing at 1 CNY to 100 credits", () => {
expect(TEXT_INPUT_CREDITS_PER_MILLION).toBe(200);
expect(TEXT_OUTPUT_CREDITS_PER_MILLION).toBe(500);
expect(
estimateTextTokenCredits({
promptTokens: 1_000_000,
completionTokens: 1_000_000,
}),
).toBe(700);
});
it("estimates text billing from dynamic server pricing rates", () => {
expect(
estimateTextTokenCredits(
{
promptTokens: 1_000_000,
completionTokens: 1_000_000,
},
{
inputCreditsPerMillion: 2_700,
outputCreditsPerMillion: 10_800,
source: "server",
modelKey: "gpt-4o",
},
),
).toBe(13_500);
});
it("ignores negative token counts when estimating text billing", () => {
expect(
estimateTextTokenCredits({
promptTokens: -100,
completionTokens: 500_000,
}),
).toBe(250);
});
it("formats text billing rules from the selected rate", () => {
expect(
formatTextTokenCreditRule({
inputCreditsPerMillion: 2_700,
outputCreditsPerMillion: 10_800,
}),
).toBe(
"输入 Token 每百万 2,700 积分,输出 Token 每百万 10,800 积分,实际以服务端结算为准。",
);
});
it("marks unstarted tasks locally timed out after submit timeout", () => {
const policy = getTaskTimeoutPolicy({ kind: "image" });
expect(
isTaskLocallyTimedOut({
startedAt: 1_000,
lastProgressAt: 1_000,
now: 1_000 + policy.submitTimeoutMs,
policy,
progress: 0,
}),
).toBe("no_progress");
});
it("marks running tasks locally timed out when progress stops", () => {
const policy = getTaskTimeoutPolicy({ kind: "video", model: "wan2.7-i2v" });
expect(
isTaskLocallyTimedOut({
startedAt: 1_000,
lastProgressAt: 2_000,
now: 2_000 + policy.noProgressTimeoutMs,
policy,
progress: 40,
}),
).toBe("no_progress");
});
});
+45 -8
View File
@@ -32,11 +32,24 @@ export interface TextTokenUsage {
totalTokens?: number;
}
export interface TextTokenCreditRate {
inputCreditsPerMillion: number;
outputCreditsPerMillion: number;
source?: "server" | "fallback";
modelKey?: string;
}
const CREDITS_PER_CNY = 100;
export const TEXT_INPUT_CREDITS_PER_MILLION = 2 * CREDITS_PER_CNY;
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5 * CREDITS_PER_CNY;
export const FALLBACK_TEXT_TOKEN_CREDIT_RATE: TextTokenCreditRate = {
inputCreditsPerMillion: TEXT_INPUT_CREDITS_PER_MILLION,
outputCreditsPerMillion: TEXT_OUTPUT_CREDITS_PER_MILLION,
source: "fallback",
};
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 90_000,
noProgressTimeoutMs: 120_000,
@@ -145,18 +158,42 @@ export function getRefundHint(status: TaskRefundStatus): string {
}
}
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;
function sanitizeCreditRate(value: number): number {
return Number.isFinite(value) && value >= 0 ? value : 0;
}
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
const rule = "文本计费规则:输入 Token 每百万 200 积分,输出 Token 每百万 500 积分,实际以服务端结算为准。";
function formatCreditRate(value: number): string {
const safeValue = sanitizeCreditRate(value);
if (safeValue >= 100) return Math.round(safeValue).toLocaleString("zh-CN");
return Number(safeValue.toFixed(4)).toString();
}
export function formatTextTokenCreditRule(
rate: TextTokenCreditRate = FALLBACK_TEXT_TOKEN_CREDIT_RATE,
): string {
return `输入 Token 每百万 ${formatCreditRate(rate.inputCreditsPerMillion)} 积分,输出 Token 每百万 ${formatCreditRate(rate.outputCreditsPerMillion)} 积分,实际以服务端结算为准。`;
}
export function estimateTextTokenCredits(
usage: TextTokenUsage,
rate: TextTokenCreditRate = FALLBACK_TEXT_TOKEN_CREDIT_RATE,
): number {
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
return (
(promptTokens / 1_000_000) * sanitizeCreditRate(rate.inputCreditsPerMillion) +
(completionTokens / 1_000_000) * sanitizeCreditRate(rate.outputCreditsPerMillion)
);
}
export function formatTextTokenUsage(
usage?: TextTokenUsage | null,
rate: TextTokenCreditRate = FALLBACK_TEXT_TOKEN_CREDIT_RATE,
): string {
const rule = `文本计费规则:${formatTextTokenCreditRule(rate)}`;
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 });
const estimatedCredits = estimateTextTokenCredits({ promptTokens, completionTokens }, rate);
return `本次 Token:输入 ${promptTokens},输出 ${completionTokens},预估 ${estimatedCredits.toFixed(4)} 积分。\n${rule}`;
}