Compare commits

...

5 Commits

Author SHA1 Message Date
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
53 changed files with 2472 additions and 436 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
+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"), {
+72 -1
View File
@@ -4,6 +4,7 @@ import {
isRecord,
readJsonResponse,
serverRequest,
isServerRequestError,
throwResponseError,
} from "./serverConnection";
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
@@ -247,6 +248,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 +376,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,
+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;
}
-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);
-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 /> },
+15 -51
View File
@@ -12,15 +12,11 @@
FileImageOutlined,
FileTextOutlined,
FolderOpenOutlined,
MutedOutlined,
PauseCircleOutlined,
PictureOutlined,
PlayCircleOutlined,
ReloadOutlined,
SaveOutlined,
SearchOutlined,
SendOutlined,
SoundOutlined,
ThunderboltOutlined,
UploadOutlined,
VideoCameraOutlined,
@@ -37,10 +33,7 @@ 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 type { WebCanvasWorkflow } from "../../types";
import type { AssetLibraryCategory } from "../assets/localAssetStore";
import {
buildCanvasCommunityCaseInput,
@@ -64,7 +57,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 +64,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 +90,6 @@ import {
canvasAutoSaveDebounceMs,
canvasAutoSaveIdleTimeoutMs,
canvasNodeClickMoveThreshold,
canvasNodeDefaultSizes,
canvasStylePickerCategories,
canvasStylePickerTabs,
connectorAnchorOutset,
@@ -120,22 +99,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 +124,8 @@ import {
getDefaultImageQuality,
getDefaultVideoQuality,
getImageQualityOptions,
getOptionLabel,
getVideoQualityOptions,
getWorkflowImageNodeFileName,
getWorkflowImageNodePrompt,
getWorkflowNodeFocusSelection,
getWorkflowNodeMetadataString,
getWorkflowNodeStyleReference,
hasCanvasOptionValue,
moveCanvasNodesForPackageDrag,
normalizeCanvasGenerationProgress,
normalizeCanvasLinkPorts,
normalizeCanvasSelectionRect,
@@ -166,10 +133,6 @@ import {
positionFloatingMenu,
resolveImageQuality,
resolveVideoQuality,
resolveWorkflowImageModel,
resolveWorkflowRatio,
resolveWorkflowVideoMode,
resolveWorkflowVideoModel,
waitForImageTaskResult,
waitForVideoTaskResult,
} from "./canvasUtils";
@@ -181,11 +144,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 +357,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);
@@ -413,11 +372,11 @@ function CanvasPage({
const imageNodeIdRef = useRef(1);
const videoNodeIdRef = useRef(1);
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,
canvasGenKeepaliveRestoredRef,
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
restoreKeepaliveTasks, resetGenerationState,
@@ -1887,13 +1846,14 @@ function CanvasPage({
setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 });
setGenerationToast("视频正在生成");
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
try {
const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId);
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 +1876,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"
@@ -1932,11 +1894,12 @@ function CanvasPage({
setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
}));
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 +1910,7 @@ function CanvasPage({
videoUrl: outputUrl,
assetRef: immediateAssetRef,
taskRef: {
taskId: task.id,
taskId,
status: "completed",
resultUrl: outputUrl,
updatedAt: new Date().toISOString(),
@@ -1961,7 +1924,7 @@ function CanvasPage({
url: outputUrl,
mediaType: "video/mp4",
resultType: "video",
taskId: task.id,
taskId,
originalUrl: outputUrl,
});
await delay(420);
@@ -1974,7 +1937,7 @@ function CanvasPage({
videoUrl: assetRef.url,
assetRef,
taskRef: {
taskId: task.id,
taskId,
status: "completed",
resultUrl: assetRef.url,
updatedAt: new Date().toISOString(),
@@ -1991,6 +1954,7 @@ function CanvasPage({
});
} finally {
videoGenerationInFlightRef.current.delete(nodeId);
if (task?.id) removeCanvasGenKeepalive(task.id);
}
};
@@ -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",
+13 -6
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,7 +241,11 @@ 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);
@@ -253,7 +256,9 @@ export async function waitForImageTaskResult(taskId: string, onStatus?: (status:
const resultUrl = await waitForTask(taskId, {
kind: "image",
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, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
}
},
});
if (!resultUrl) throw new Error("生成任务已完成,但服务器没有返回结果地址,请稍后重试");
@@ -264,7 +269,9 @@ export async function waitForVideoTaskResult(taskId: string, onStatus?: (status:
const resultUrl = await waitForTask(taskId, {
kind: "video",
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, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
}
},
});
if (!resultUrl) throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
@@ -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",
+38 -3
View File
@@ -1,4 +1,4 @@
import { type Dispatch, type MutableRefObject, type SetStateAction, useEffect, useRef, useState } from "react";
import { type Dispatch, type SetStateAction, 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,6 +63,7 @@ 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 canvasGenKeepaliveRestoredRef = useRef(false);
@@ -125,7 +134,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
imageGenerationInFlightRef.current.delete(entry.nodeId);
});
} else if (entry.nodeKind === "video") {
imageGenerationInFlightRef.current.add(entry.nodeId);
videoGenerationInFlightRef.current.add(entry.nodeId);
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)));
@@ -154,7 +163,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
removeCanvasGenKeepalive(entry.taskId);
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
}).finally(() => {
imageGenerationInFlightRef.current.delete(entry.nodeId);
videoGenerationInFlightRef.current.delete(entry.nodeId);
});
}
}
@@ -165,11 +174,36 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
textGenerationAbortControllersRef.current.clear();
textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear();
videoGenerationInFlightRef.current.clear();
setTextGenerationState({});
setImageGenerationState({});
setVideoGenerationState({});
};
useEffect(() => {
const handlePageHide = () => {
cancelCanvasGenKeepaliveOnUnload();
textGenerationAbortControllersRef.current.forEach((controller) => controller.abort());
textGenerationAbortControllersRef.current.clear();
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);
};
}, []);
return {
textGenerationState,
imageGenerationState,
@@ -177,6 +211,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
generationToast,
setGenerationToast,
imageGenerationInFlightRef,
videoGenerationInFlightRef,
textGenerationInFlightRef,
textGenerationAbortControllersRef,
canvasGenKeepaliveRestoredRef,
+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,
@@ -13,7 +13,6 @@ import {
RightOutlined,
ScissorOutlined,
SwapOutlined,
ThunderboltOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
+211 -60
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 = `
2026068
2026068
使
Omniai平台的运营者使/使使/使
使/使/
使
使Cookie和同类技术
1. SDKAPI等方式访使/
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技术收集和存储您的登录状态使CookieCookie可能会导致您无法正常使用平台的某些功能
2. 使使广
1.
2.
3. SSL加密访访
访使
1. 18
2. 14使使
3.
使
SDKSDK可能会收集您的设备信息
system@omniai.net.cn
15155073618
9A楼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平台用户协议
2026068
2026068
使
SaaS软件服务使Omniai平台服务
18使
使使
使
1. SaaS软件工具
2. SaaS服务线使
3.
4.
5. 使
6.
使
1.
1使使Omniai平台服务时可能需要提供一些必要的信息使
2/使使
3
4怀
5Omniai平台无须对任何用户的任何登记资料承担任何责任
2.
1/使
2使
3.
1
2
使
1.
1SaaS软件服务线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>
))}
</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>
<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>
@@ -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(() => {
+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,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;
+1 -12
View File
@@ -1,6 +1,5 @@
import {
ArrowRightOutlined,
DashboardOutlined,
FileSearchOutlined,
PlayCircleOutlined,
PlusOutlined,
@@ -9,7 +8,6 @@ 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";
@@ -17,15 +15,6 @@ 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,
@@ -469,7 +458,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);
-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",
+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";
@@ -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
@@ -1,5 +1,4 @@
import {
CloseOutlined,
DeleteOutlined,
EditOutlined,
MenuFoldOutlined,
+92 -53
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,6 @@ import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
import { buildApiUrl, buildAuthHeaders } from "../../api/serverConnection";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import type { WebProjectSummary } from "../../types";
import {
@@ -62,8 +55,6 @@ import {
MarkdownMessage,
ResultCard,
} from "./components/WorkbenchChatCards";
import { renderMarkdownBlocks } from "./markdownRenderer";
import { downloadResultAsset } from "./workbenchDownload";
import { translateTaskError } from "../../utils/translateTaskError";
import {
buildLocalTimeoutMessage,
@@ -80,7 +71,6 @@ import { isPixverseModel } from "../../utils/pixverseRouting";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
import {
getImageQualityOptions,
getImageQualityOptionsForContext,
getDefaultImageQuality,
getDefaultImageQualityForContext,
@@ -90,8 +80,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 +87,6 @@ import {
type WorkbenchOption,
type WorkbenchFieldGroup,
type ReferenceItem,
type PromptMentionItem,
type PromptMentionTokenRange,
type ChatAttachment,
type ChatMessage,
type DeleteDialogState,
@@ -117,33 +103,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 +127,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 +152,6 @@ import {
import {
findPromptMentionRangeInside,
findPromptMentionRangeOverlap,
ReferenceInlinePreview,
ReferencePreview,
PromptPreviewLayer,
} from "./WorkbenchPromptPreview";
@@ -495,7 +458,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 +484,6 @@ function WorkbenchPage({
[conversations],
);
const hasSidebarRecords = conversationRecords.length > 0;
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
const activeConversationTitle = useMemo(() => {
if (!activeConversationId) return "";
@@ -1008,7 +969,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 +1017,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 +1091,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 +1108,8 @@ function WorkbenchPage({
taskProgress: 100,
taskStatusLabel: "任务异常",
});
removeKeepaliveTask(task.taskId);
releaseKeepaliveTaskLocally(task.taskId, { cancelServer: true });
onRefreshUsage?.();
return;
}
continue;
@@ -1323,6 +1337,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 +2019,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 +2198,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 +2498,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 +2623,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 +2800,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 +2993,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" })}>
@@ -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);
-12
View File
@@ -1,5 +1,4 @@
import { create } from "zustand";
import type { WebGenerationPreviewTask } from "../types";
export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
@@ -63,17 +62,6 @@ interface GenerationStoreState {
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();
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
+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;
}
}
+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";
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "../test/testHarness";
import {
calculateEnterpriseVideoCredits,
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);
});
});
+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;
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "../test/testHarness";
import {
TEXT_INPUT_CREDITS_PER_MILLION,
TEXT_OUTPUT_CREDITS_PER_MILLION,
estimateTextTokenCredits,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "./taskLifecycle";
describe("taskLifecycle", () => {
it("keeps 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("ignores negative token counts when estimating text billing", () => {
expect(
estimateTextTokenCredits({
promptTokens: -100,
completionTokens: 500_000,
}),
).toBe(250);
});
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");
});
});