Compare commits

..

1 Commits

Author SHA1 Message Date
OmniAI Developer 56ed94bf43 fix: remove duplicate workspace activation flag 2026-06-10 10:39:14 +08:00
53 changed files with 435 additions and 2472 deletions
-30
View File
@@ -1,30 +0,0 @@
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
@@ -1,10 +0,0 @@
dist
node_modules
coverage
tmp
.codex-tmp
.codex-logs
screenshots
*.log
*.tmp
package-lock.json
-77
View File
@@ -1,77 +0,0 @@
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",
},
},
);
+102 -1367
View File
File diff suppressed because it is too large Load Diff
+1 -10
View File
@@ -7,11 +7,7 @@
"dev": "vite --host 127.0.0.1", "dev": "vite --host 127.0.0.1",
"build": "vite build", "build": "vite build",
"preview": "vite preview --host 127.0.0.1", "preview": "vite preview --host 127.0.0.1",
"test": "node scripts/run-unit-tests.mjs",
"type-check": "tsc -p tsconfig.json --noEmit", "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", "governance:check": "node scripts/check-governance.mjs",
"style:check": "node scripts/check-style-governance.mjs", "style:check": "node scripts/check-style-governance.mjs",
"smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs" "smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs"
@@ -24,18 +20,13 @@
"zustand": "5.0.13" "zustand": "5.0.13"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "18.2.0", "@types/react": "18.2.0",
"@types/react-dom": "18.2.0", "@types/react-dom": "18.2.0",
"@vitejs/plugin-react": "4.2.1", "@vitejs/plugin-react": "4.2.1",
"eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1",
"playwright": "1.60.0", "playwright": "1.60.0",
"prettier": "^3.8.3",
"sharp": "0.34.5", "sharp": "0.34.5",
"typescript": "5.3.3", "typescript": "5.3.3",
"typescript-eslint": "^8.60.1", "vite": "5.1.0",
"vite": "5.4.21",
"vite-plugin-compression2": "2.5.3" "vite-plugin-compression2": "2.5.3"
} }
} }
+3 -8
View File
@@ -27,6 +27,7 @@ console.log(' 1. MODULE DEPENDENCY GRAPH ANALYSIS');
console.log('═══════════════════════════════════════════════'); console.log('═══════════════════════════════════════════════');
const importMap = new Map(); // file -> [imports] const importMap = new Map(); // file -> [imports]
const importedBy = new Map(); // file -> [importers]
for (const r of results) { for (const r of results) {
const imports = []; const imports = [];
@@ -70,13 +71,10 @@ function findCircular(file, visited = new Set(), path = []) {
} }
} }
} }
for (const file of importMap.keys()) {
findCircular(file);
}
// Check high-fanin files (imported by many) // Check high-fanin files (imported by many)
const fanIn = new Map(); const fanIn = new Map();
for (const imports of importMap.values()) { for (const [file, imports] of importMap) {
for (const imp of imports) { for (const imp of imports) {
const key = imp.replace(/\[dynamic\]/, ''); const key = imp.replace(/\[dynamic\]/, '');
fanIn.set(key, (fanIn.get(key) || 0) + 1); fanIn.set(key, (fanIn.get(key) || 0) + 1);
@@ -103,7 +101,7 @@ for (const [file, count] of sortedFanOut) {
// Dynamic imports analysis (lazy loading effectiveness) // Dynamic imports analysis (lazy loading effectiveness)
console.log('\n--- Lazy Loading (Dynamic Imports) ---'); console.log('\n--- Lazy Loading (Dynamic Imports) ---');
let dynamicImports = 0, staticImports = 0; let dynamicImports = 0, staticImports = 0;
for (const imports of importMap.values()) { for (const [file, imports] of importMap) {
for (const imp of imports) { for (const imp of imports) {
if (imp.startsWith('[dynamic]')) dynamicImports++; if (imp.startsWith('[dynamic]')) dynamicImports++;
else staticImports++; else staticImports++;
@@ -179,9 +177,6 @@ for (const r of results) {
if (noDeps > 0) { if (noDeps > 0) {
console.log(` [RENDER-COST] ${r.file}: ${noDeps} useEffect(s) run EVERY render`); 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 ─── // ─── 4. Zustand Store Analysis ───
+3 -3
View File
@@ -3,7 +3,7 @@
* Measures: page load, bundle sizes, memory, rendering, network. * Measures: page load, bundle sizes, memory, rendering, network.
*/ */
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { readdirSync, statSync } from 'fs'; import { readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
const DIST = join(import.meta.dirname, '..', 'dist'); const DIST = join(import.meta.dirname, '..', 'dist');
@@ -187,7 +187,7 @@ async function runtimeAnalysis() {
const allElements = document.querySelectorAll('*'); const allElements = document.querySelectorAll('*');
const tagCounts = {}; const tagCounts = {};
let maxDepth = 0; let maxDepth = 0;
const totalNodes = allElements.length; let totalNodes = allElements.length;
allElements.forEach(el => { allElements.forEach(el => {
const tag = el.tagName.toLowerCase(); const tag = el.tagName.toLowerCase();
@@ -297,7 +297,7 @@ console.log('╔═════════════════════
console.log('║ OmniAI Web Preview - Performance Analysis ║'); console.log('║ OmniAI Web Preview - Performance Analysis ║');
console.log('╚═══════════════════════════════════════════════╝'); console.log('╚═══════════════════════════════════════════════╝');
analyzeBundles(); const bundleResult = analyzeBundles();
await runtimeAnalysis(); await runtimeAnalysis();
console.log('\n═══════════════════════════════════════════════'); console.log('\n═══════════════════════════════════════════════');
-55
View File
@@ -1,55 +0,0 @@
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();
}
+2 -1
View File
@@ -1,4 +1,4 @@
import { readdirSync, readFileSync } from 'fs'; import { readdirSync, readFileSync, statSync } from 'fs';
import { join, relative } from 'path'; import { join, relative } from 'path';
const SRC = join(import.meta.dirname, '..', 'src'); const SRC = join(import.meta.dirname, '..', 'src');
@@ -114,6 +114,7 @@ for (const r of results) {
console.log('\n=== HIGH COMPLEXITY: Deep nesting ==='); console.log('\n=== HIGH COMPLEXITY: Deep nesting ===');
for (const r of results) { for (const r of results) {
const lines = r.content.split('\n'); const lines = r.content.split('\n');
let maxIndent = 0;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const line = lines[i];
if (line.trim() === '') continue; if (line.trim() === '') continue;
+26
View File
@@ -295,6 +295,7 @@ function App() {
openDeleteProject: openDeleteProjectModal, openDeleteProject: openDeleteProjectModal,
closeDeleteProject: closeDeleteProjectModal, closeDeleteProject: closeDeleteProjectModal,
setDeleteProjectSubmitting, setDeleteProjectSubmitting,
clearProjectState,
} = useProjectStore(useShallow((s) => ({ } = useProjectStore(useShallow((s) => ({
projects: s.projects, projects: s.projects,
projectsLoaded: s.projectsLoaded, projectsLoaded: s.projectsLoaded,
@@ -309,6 +310,7 @@ function App() {
openDeleteProject: s.openDeleteProject, openDeleteProject: s.openDeleteProject,
closeDeleteProject: s.closeDeleteProject, closeDeleteProject: s.closeDeleteProject,
setDeleteProjectSubmitting: s.setDeleteProjectSubmitting, setDeleteProjectSubmitting: s.setDeleteProjectSubmitting,
clearProjectState: s.clearProjectState,
}))); })));
// Task store // Task store
@@ -347,6 +349,7 @@ function App() {
setBackendHealth, setBackendHealth,
markNotificationRead, markNotificationRead,
markAllNotificationsRead, markAllNotificationsRead,
clearAppState,
} = useAppStore(useShallow((s) => ({ } = useAppStore(useShallow((s) => ({
usage: s.usage, usage: s.usage,
runtimeNotifications: s.runtimeNotifications, runtimeNotifications: s.runtimeNotifications,
@@ -367,6 +370,7 @@ function App() {
setBackendHealth: s.setBackendHealth, setBackendHealth: s.setBackendHealth,
markNotificationRead: s.markNotificationRead, markNotificationRead: s.markNotificationRead,
markAllNotificationsRead: s.markAllNotificationsRead, markAllNotificationsRead: s.markAllNotificationsRead,
clearAppState: s.clearAppState,
}))); })));
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false); const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
@@ -1100,6 +1104,28 @@ function App() {
[handleSetView, setImageWorkbenchTool], [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; const PUBLIC_VIEWS = PUBLIC_VIEW_SET;
useEffect(() => { useEffect(() => {
+6 -15
View File
@@ -3,19 +3,6 @@ import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"]; const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"]; 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 { export interface AdVideoUserConfig {
platform: string; platform: string;
aspectRatio: string; aspectRatio: string;
@@ -175,7 +162,9 @@ async function chat(
{ role: "user", content: userContent }, { role: "user", content: userContent },
]; ];
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = combineAbortSignals(options?.signal, timeoutSignal); const combinedSignal = options?.signal
? AbortSignal.any([options.signal, timeoutSignal])
: timeoutSignal;
const res = await fetch(buildApiUrl("ai/chat"), { const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST", method: "POST",
headers: buildAuthHeaders(), headers: buildAuthHeaders(),
@@ -221,7 +210,9 @@ async function visionChat(
let lastError: Error | null = null; let lastError: Error | null = null;
for (const model of VISION_MODELS) { for (const model of VISION_MODELS) {
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = combineAbortSignals(signal, timeoutSignal); const combinedSignal = signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal;
try { try {
const out = await retryOnTransient(async () => { const out = await retryOnTransient(async () => {
const res = await fetch(buildApiUrl("ai/chat"), { const res = await fetch(buildApiUrl("ai/chat"), {
+1 -72
View File
@@ -4,7 +4,6 @@ import {
isRecord, isRecord,
readJsonResponse, readJsonResponse,
serverRequest, serverRequest,
isServerRequestError,
throwResponseError, throwResponseError,
} from "./serverConnection"; } from "./serverConnection";
import { isOptionalApiRouteMissing } from "./apiErrorUtils"; import { isOptionalApiRouteMissing } from "./apiErrorUtils";
@@ -248,46 +247,6 @@ let taskHistoryRouteMissing = false;
const TASK_SUBMIT_TIMEOUT_MS = 90_000; const TASK_SUBMIT_TIMEOUT_MS = 90_000;
const TASK_STATUS_TIMEOUT_MS = 20_000; const TASK_STATUS_TIMEOUT_MS = 20_000;
const NON_RETRYING_REQUEST = { maxRetries: 0 }; 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 = { export const aiGenerationClient = {
async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> { async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> {
@@ -376,48 +335,18 @@ export const aiGenerationClient = {
}, },
async cancelTask(taskId: string): Promise<void> { async cancelTask(taskId: string): Promise<void> {
markTaskCancelPending(taskId);
try { try {
await serverRequest<void>(`ai/tasks/${taskId}/cancel`, { await serverRequest<void>(`ai/tasks/${taskId}/cancel`, {
method: "PATCH", method: "PATCH",
maxRetries: NON_RETRYING_REQUEST.maxRetries, maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Task cancel failed", fallbackMessage: "Task cancel failed",
}); });
clearPendingTaskCancel(taskId);
} catch (error) { } catch (error) {
if (isOptionalApiRouteMissing(error) || !shouldRetryTaskCancel(error)) { if (isOptionalApiRouteMissing(error)) return;
clearPendingTaskCancel(taskId);
return;
}
throw error; 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> { async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, { return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, {
timeoutMs: TASK_STATUS_TIMEOUT_MS, timeoutMs: TASK_STATUS_TIMEOUT_MS,
-63
View File
@@ -1,63 +0,0 @@
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,10 +21,6 @@ function getEffectiveLimit(): number {
return userMaxConcurrency ?? DEFAULT_MAX_ACTIVE_GENERATION_TASKS; return userMaxConcurrency ?? DEFAULT_MAX_ACTIVE_GENERATION_TASKS;
} }
export function getEffectiveGenerationLimit(): number {
return getEffectiveLimit();
}
export function getGenerationUserKey(userId?: string | number | null): string { export function getGenerationUserKey(userId?: string | number | null): string {
return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId); return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId);
} }
@@ -75,8 +71,3 @@ export function releaseGenerationSlot(id: string | undefined | null): void {
if (!id) return; if (!id) return;
activeSlots.delete(id); activeSlots.delete(id);
} }
export function __resetGenerationConcurrencyForTests(): void {
activeSlots.clear();
userMaxConcurrency = null;
}
+3
View File
@@ -10,6 +10,7 @@ import NotificationCenter from "./NotificationCenter";
import BetaApplicationModal from "./BetaApplicationModal"; import BetaApplicationModal from "./BetaApplicationModal";
import { AnimatedPanel } from "./AnimatedPanel"; import { AnimatedPanel } from "./AnimatedPanel";
import AdminMonitor from "./AdminMonitor"; import AdminMonitor from "./AdminMonitor";
import CookieConsentBanner from "./CookieConsentBanner";
import { loadRechargeModal, type RechargeModalComponent } from "./RechargeModal/loadRechargeModal"; import { loadRechargeModal, type RechargeModalComponent } from "./RechargeModal/loadRechargeModal";
import { ShellIcon } from "./ShellIcon"; import { ShellIcon } from "./ShellIcon";
import { loadDarkGreenTheme } from "../styles/loadDarkGreenTheme"; import { loadDarkGreenTheme } from "../styles/loadDarkGreenTheme";
@@ -75,6 +76,7 @@ function AppShell({
session, session,
usage, usage,
notifications, notifications,
backendHealth,
workspaceExpanded, workspaceExpanded,
onSelectView, onSelectView,
onLogout, onLogout,
@@ -83,6 +85,7 @@ function AppShell({
onMarkAllNotificationsRead, onMarkAllNotificationsRead,
children, children,
}: AppShellProps) { }: AppShellProps) {
const activePackage = session?.user.activePackages?.[0];
const profileRef = useRef<HTMLDivElement>(null); const profileRef = useRef<HTMLDivElement>(null);
const submenuHideTimerRef = useRef<number | null>(null); const submenuHideTimerRef = useRef<number | null>(null);
const [profileOpen, setProfileOpen] = useState(false); const [profileOpen, setProfileOpen] = useState(false);
+1
View File
@@ -1,4 +1,5 @@
import { HomeOutlined } from "@ant-design/icons"; import { HomeOutlined } from "@ant-design/icons";
import { useCallback } from "react";
import "../styles/pages/not-found.css"; import "../styles/pages/not-found.css";
interface NotFoundPageProps { interface NotFoundPageProps {
+1 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { CloseOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons"; import { CloseOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons";
import "../styles/components/onboarding.css"; import "../styles/components/onboarding.css";
+1 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
export type ToastType = "success" | "error" | "info"; export type ToastType = "success" | "error" | "info";
+2 -2
View File
@@ -10,7 +10,7 @@ import {
SearchOutlined, SearchOutlined,
UserOutlined, UserOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactElement } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type JSX } from "react";
import "../../styles/pages/assets.css"; import "../../styles/pages/assets.css";
import { assetClient, type ServerAssetItem } from "../../api/assetClient"; import { assetClient, type ServerAssetItem } from "../../api/assetClient";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
@@ -36,7 +36,7 @@ interface AssetsPageProps {
onOpenLogin: () => void; onOpenLogin: () => void;
} }
const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: ReactElement | null }> = [ const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: JSX.Element | null }> = [
{ key: "all", label: "全部", icon: null }, { key: "all", label: "全部", icon: null },
{ key: "character", label: "人物", icon: <UserOutlined /> }, { key: "character", label: "人物", icon: <UserOutlined /> },
{ key: "scene", label: "场景", icon: <FileImageOutlined /> }, { key: "scene", label: "场景", icon: <FileImageOutlined /> },
+51 -15
View File
@@ -12,11 +12,15 @@
FileImageOutlined, FileImageOutlined,
FileTextOutlined, FileTextOutlined,
FolderOpenOutlined, FolderOpenOutlined,
MutedOutlined,
PauseCircleOutlined,
PictureOutlined, PictureOutlined,
PlayCircleOutlined,
ReloadOutlined, ReloadOutlined,
SaveOutlined, SaveOutlined,
SearchOutlined, SearchOutlined,
SendOutlined, SendOutlined,
SoundOutlined,
ThunderboltOutlined, ThunderboltOutlined,
UploadOutlined, UploadOutlined,
VideoCameraOutlined, VideoCameraOutlined,
@@ -33,7 +37,10 @@ import { communityClient } from "../../api/communityClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient"; import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway"; import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import WorkspacePageShell from "../../components/WorkspacePageShell"; import WorkspacePageShell from "../../components/WorkspacePageShell";
import type { WebCanvasWorkflow } from "../../types"; import type {
WebCanvasWorkflow,
WebCanvasWorkflowNodePackage,
} from "../../types";
import type { AssetLibraryCategory } from "../assets/localAssetStore"; import type { AssetLibraryCategory } from "../assets/localAssetStore";
import { import {
buildCanvasCommunityCaseInput, buildCanvasCommunityCaseInput,
@@ -57,6 +64,7 @@ import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolic
import { filterImageModelOptionsForSession } from "../../utils/imageModelVisibility"; import { filterImageModelOptionsForSession } from "../../utils/imageModelVisibility";
import { translateTaskError } from "../../utils/translateTaskError"; import { translateTaskError } from "../../utils/translateTaskError";
import type { import type {
CanvasAlignGuide,
CanvasAssetSaveSource, CanvasAssetSaveSource,
CanvasCopiedNode, CanvasCopiedNode,
CanvasConnectorDrag, CanvasConnectorDrag,
@@ -64,25 +72,37 @@ import type {
CanvasFloatingMenuPosition, CanvasFloatingMenuPosition,
CanvasImageFocusDrag, CanvasImageFocusDrag,
CanvasImageFocusSelection, CanvasImageFocusSelection,
CanvasImageGenerationState,
CanvasImageNode, CanvasImageNode,
CanvasImageNodeDrag,
CanvasImageReferenceItem, CanvasImageReferenceItem,
CanvasManualLink, CanvasManualLink,
CanvasNodeBounds, CanvasNodeBounds,
CanvasNodeKind, CanvasNodeKind,
CanvasNodePackage, CanvasNodePackage,
CanvasNodePackageDrag,
CanvasNodePort, CanvasNodePort,
CanvasNodeResizeDrag,
CanvasNodeSize, CanvasNodeSize,
CanvasOption, CanvasOption,
CanvasPageProps, CanvasPageProps,
CanvasPanDrag,
CanvasPoint, CanvasPoint,
CanvasProjectSaveState, CanvasProjectSaveState,
CanvasSelectedNode, CanvasSelectedNode,
CanvasSelectionDrag,
CanvasStyleCase, CanvasStyleCase,
CanvasStylePickerTab, CanvasStylePickerTab,
CanvasStyleReference,
CanvasTextGenerationState,
CanvasPromptMentionOption, CanvasPromptMentionOption,
CanvasPromptMentionState, CanvasPromptMentionState,
CanvasTextNode, CanvasTextNode,
CanvasTextNodeDrag,
CanvasVideoGenerationState,
CanvasVideoMode,
CanvasVideoNode, CanvasVideoNode,
CanvasVideoNodeDrag,
CanvasViewport, CanvasViewport,
} from "./canvasTypes"; } from "./canvasTypes";
import { import {
@@ -90,6 +110,7 @@ import {
canvasAutoSaveDebounceMs, canvasAutoSaveDebounceMs,
canvasAutoSaveIdleTimeoutMs, canvasAutoSaveIdleTimeoutMs,
canvasNodeClickMoveThreshold, canvasNodeClickMoveThreshold,
canvasNodeDefaultSizes,
canvasStylePickerCategories, canvasStylePickerCategories,
canvasStylePickerTabs, canvasStylePickerTabs,
connectorAnchorOutset, connectorAnchorOutset,
@@ -99,17 +120,22 @@ import {
defaultImageModel, defaultImageModel,
defaultTextModelId, defaultTextModelId,
defaultVideoModel, defaultVideoModel,
image4kCapableModels,
imageFocusRatioOptions, imageFocusRatioOptions,
imageModelOptions as fallbackCanvasImageModelOptions, imageModelOptions as fallbackCanvasImageModelOptions,
imageRatioOptions, imageRatioOptions,
textModelOptions,
videoDurationOptions, videoDurationOptions,
videoRatioOptions, videoRatioOptions,
} from "./canvasConstants"; } from "./canvasConstants";
import { import {
applyImageFocusRatioFromTopLeft, applyImageFocusRatioFromTopLeft,
blobToDataUrl, blobToDataUrl,
buildCanvasStyleKeywords,
buildCopyTitle, buildCopyTitle,
clampCanvasPercent, clampCanvasPercent,
buildReversePromptFromAsset,
canvasGenerationProgressStyle,
clampCanvasNodeSize, clampCanvasNodeSize,
clampCanvasViewportZoom, clampCanvasViewportZoom,
communityCaseToCanvasStyleCase, communityCaseToCanvasStyleCase,
@@ -124,8 +150,15 @@ import {
getDefaultImageQuality, getDefaultImageQuality,
getDefaultVideoQuality, getDefaultVideoQuality,
getImageQualityOptions, getImageQualityOptions,
getOptionLabel,
getVideoQualityOptions, getVideoQualityOptions,
getWorkflowImageNodeFileName,
getWorkflowImageNodePrompt,
getWorkflowNodeFocusSelection,
getWorkflowNodeMetadataString,
getWorkflowNodeStyleReference,
hasCanvasOptionValue, hasCanvasOptionValue,
moveCanvasNodesForPackageDrag,
normalizeCanvasGenerationProgress, normalizeCanvasGenerationProgress,
normalizeCanvasLinkPorts, normalizeCanvasLinkPorts,
normalizeCanvasSelectionRect, normalizeCanvasSelectionRect,
@@ -133,6 +166,10 @@ import {
positionFloatingMenu, positionFloatingMenu,
resolveImageQuality, resolveImageQuality,
resolveVideoQuality, resolveVideoQuality,
resolveWorkflowImageModel,
resolveWorkflowRatio,
resolveWorkflowVideoMode,
resolveWorkflowVideoModel,
waitForImageTaskResult, waitForImageTaskResult,
waitForVideoTaskResult, waitForVideoTaskResult,
} from "./canvasUtils"; } from "./canvasUtils";
@@ -144,9 +181,11 @@ import {
createVideoNodesFromWorkflow, createVideoNodesFromWorkflow,
createWorkflowPackagesFromCanvasPackages, createWorkflowPackagesFromCanvasPackages,
formatCanvasProjectUpdatedAt, formatCanvasProjectUpdatedAt,
formatCanvasVideoTime,
resolveAssetCategory, resolveAssetCategory,
} from "./canvasWorkflowDeserialize"; } from "./canvasWorkflowDeserialize";
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents"; import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
import type { CanvasNodeToolbarAction } from "./canvasComponents";
import { CanvasMarkingPopover } from "./CanvasMarkingPopover"; import { CanvasMarkingPopover } from "./CanvasMarkingPopover";
import { CanvasPromptMentionTextarea, CanvasTextPromptComposer } from "./CanvasTextPromptComposer"; import { CanvasPromptMentionTextarea, CanvasTextPromptComposer } from "./CanvasTextPromptComposer";
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels"; import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
@@ -357,6 +396,8 @@ function CanvasPage({
const canvasUploadInputRef = useRef<HTMLInputElement>(null); const canvasUploadInputRef = useRef<HTMLInputElement>(null);
const imageNodeInputRef = useRef<HTMLInputElement>(null); const imageNodeInputRef = useRef<HTMLInputElement>(null);
const canvasRef = useRef<HTMLElement>(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 canvasDragCounterRef = useRef(0);
const [isCanvasDragging, setIsCanvasDragging] = useState(false); const [isCanvasDragging, setIsCanvasDragging] = useState(false);
const suppressNextPaneClickRef = useRef(false); const suppressNextPaneClickRef = useRef(false);
@@ -372,11 +413,11 @@ function CanvasPage({
const imageNodeIdRef = useRef(1); const imageNodeIdRef = useRef(1);
const videoNodeIdRef = useRef(1); const videoNodeIdRef = useRef(1);
const { pushSnapshot, undo, redo } = useCanvasHistory(); const { pushSnapshot, undo, redo, canUndo, canRedo } = useCanvasHistory();
const { const {
textGenerationState, imageGenerationState, videoGenerationState, textGenerationState, imageGenerationState, videoGenerationState,
generationToast, setGenerationToast, generationToast, setGenerationToast,
imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef, imageGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
canvasGenKeepaliveRestoredRef, canvasGenKeepaliveRestoredRef,
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus, setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
restoreKeepaliveTasks, resetGenerationState, restoreKeepaliveTasks, resetGenerationState,
@@ -1846,14 +1887,13 @@ function CanvasPage({
setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 }); setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 });
setGenerationToast("视频正在生成"); setGenerationToast("视频正在生成");
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
try { try {
const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId); const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId);
if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) { if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) {
throw new Error("图生视频需要先连接至少一个可用的图片节点"); throw new Error("图生视频需要先连接至少一个可用的图片节点");
} }
const requestModel = resolveVideoRequestModel({ model, referenceUrls }); let requestModel = resolveVideoRequestModel({ model, referenceUrls });
task = await onCreateTask({ const task = await onCreateTask({
title: videoNode.title || "视频节点生成", title: videoNode.title || "视频节点生成",
type: "video", type: "video",
prompt: prompt || "根据参考图片生成视频", prompt: prompt || "根据参考图片生成视频",
@@ -1876,12 +1916,10 @@ function CanvasPage({
if (task.status === "completed" && !task.outputUrl) { if (task.status === "completed" && !task.outputUrl) {
throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试"); 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)) }); setVideoGenerationStatus(nodeId, { status: "running", message: "视频生成中", progress: Math.max(18, Number(task.progress || 0)) });
const outputUrl = const outputUrl =
task.outputUrl || task.outputUrl ||
(await waitForVideoTaskResult(taskId, (status) => { (await waitForImageTaskResult(task.id, (status) => {
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0))); const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
const statusLabel = const statusLabel =
status.status === "pending" status.status === "pending"
@@ -1894,12 +1932,11 @@ function CanvasPage({
setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress }); setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
})); }));
setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 }); setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 });
removeCanvasGenKeepalive(taskId);
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({ const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
url: outputUrl, url: outputUrl,
mediaType: "video/mp4", mediaType: "video/mp4",
resultType: "video", resultType: "video",
taskId, taskId: task.id,
originalUrl: outputUrl, originalUrl: outputUrl,
}); });
setVideoNodes((currentNodes) => setVideoNodes((currentNodes) =>
@@ -1910,7 +1947,7 @@ function CanvasPage({
videoUrl: outputUrl, videoUrl: outputUrl,
assetRef: immediateAssetRef, assetRef: immediateAssetRef,
taskRef: { taskRef: {
taskId, taskId: task.id,
status: "completed", status: "completed",
resultUrl: outputUrl, resultUrl: outputUrl,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
@@ -1924,7 +1961,7 @@ function CanvasPage({
url: outputUrl, url: outputUrl,
mediaType: "video/mp4", mediaType: "video/mp4",
resultType: "video", resultType: "video",
taskId, taskId: task.id,
originalUrl: outputUrl, originalUrl: outputUrl,
}); });
await delay(420); await delay(420);
@@ -1937,7 +1974,7 @@ function CanvasPage({
videoUrl: assetRef.url, videoUrl: assetRef.url,
assetRef, assetRef,
taskRef: { taskRef: {
taskId, taskId: task.id,
status: "completed", status: "completed",
resultUrl: assetRef.url, resultUrl: assetRef.url,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
@@ -1954,7 +1991,6 @@ function CanvasPage({
}); });
} finally { } finally {
videoGenerationInFlightRef.current.delete(nodeId); videoGenerationInFlightRef.current.delete(nodeId);
if (task?.id) removeCanvasGenKeepalive(task.id);
} }
}; };
@@ -106,11 +106,7 @@ function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
if (typeof reader.result === "string") { typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Unable to read canvas result"));
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.onerror = () => reject(reader.error || new Error("Unable to read canvas result"));
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
+7
View File
@@ -1,10 +1,17 @@
import { import {
CopyOutlined,
DeleteOutlined,
DownloadOutlined,
DownOutlined, DownOutlined,
ExpandOutlined, ExpandOutlined,
MutedOutlined, MutedOutlined,
PauseCircleOutlined, PauseCircleOutlined,
PictureOutlined,
PlayCircleOutlined, PlayCircleOutlined,
ReloadOutlined,
SaveOutlined,
SoundOutlined, SoundOutlined,
ThunderboltOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type MouseEvent } from "react"; import { useEffect, useRef, useState, type ChangeEvent, type MouseEvent } from "react";
import type { CanvasOption } from "./canvasTypes"; import type { CanvasOption } from "./canvasTypes";
+21
View File
@@ -146,6 +146,26 @@ export function CanvasInpaintPanel({ imageUrl, onComplete }: CanvasToolPanelProp
ctx.fill(); 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 () => { const handleInpaint = useCallback(async () => {
if (!imageUrl || !prompt) { if (!imageUrl || !prompt) {
toast.error("请输入重绘提示词"); toast.error("请输入重绘提示词");
@@ -154,6 +174,7 @@ export function CanvasInpaintPanel({ imageUrl, onComplete }: CanvasToolPanelProp
setLoading(true); setLoading(true);
cancelRef.current = false; cancelRef.current = false;
try { try {
const maskDataUrl = getMaskDataUrl();
const { taskId } = await aiGenerationClient.createImageEditTask({ const { taskId } = await aiGenerationClient.createImageEditTask({
imageUrl, imageUrl,
function: "inpaint", function: "inpaint",
+6 -13
View File
@@ -1,5 +1,5 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import type { AiTaskStatus } from "../../api/aiGenerationClient"; import { aiGenerationClient, type AiTaskStatus } from "../../api/aiGenerationClient";
import type { ServerCommunityCase } from "../../api/communityClient"; import type { ServerCommunityCase } from "../../api/communityClient";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import type { WebCanvasWorkflow } from "../../types"; import type { WebCanvasWorkflow } from "../../types";
@@ -22,6 +22,7 @@ import type {
CanvasVideoMode, CanvasVideoMode,
} from "./canvasTypes"; } from "./canvasTypes";
import { import {
assetLibraryCategories,
assetTypePromptLabel, assetTypePromptLabel,
canvasNodeDefaultSizes, canvasNodeDefaultSizes,
canvasNodeMaxSizes, canvasNodeMaxSizes,
@@ -193,7 +194,7 @@ export function resolveWorkflowImageModel(node: WebCanvasWorkflow["nodes"][numbe
return defaultImageModel; 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 raw = getWorkflowNodeMetadataString(node, "model");
const storedModel = toPixverseDisplayModel(toViduDisplayModel(toHappyHorseDisplayModel(raw))); const storedModel = toPixverseDisplayModel(toViduDisplayModel(toHappyHorseDisplayModel(raw)));
if (hasCanvasOptionValue(videoModelOptions, storedModel)) return storedModel; if (hasCanvasOptionValue(videoModelOptions, storedModel)) return storedModel;
@@ -241,11 +242,7 @@ export function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
if (typeof reader.result === "string") { typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Unable to read canvas image"));
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.onerror = () => reject(reader.error || new Error("Unable to read canvas image"));
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
@@ -256,9 +253,7 @@ export async function waitForImageTaskResult(taskId: string, onStatus?: (status:
const resultUrl = await waitForTask(taskId, { const resultUrl = await waitForTask(taskId, {
kind: "image", kind: "image",
onProgress: (e) => { onProgress: (e) => {
if (onStatus) { onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
}
}, },
}); });
if (!resultUrl) throw new Error("生成任务已完成,但服务器没有返回结果地址,请稍后重试"); if (!resultUrl) throw new Error("生成任务已完成,但服务器没有返回结果地址,请稍后重试");
@@ -269,9 +264,7 @@ export async function waitForVideoTaskResult(taskId: string, onStatus?: (status:
const resultUrl = await waitForTask(taskId, { const resultUrl = await waitForTask(taskId, {
kind: "video", kind: "video",
onProgress: (e) => { onProgress: (e) => {
if (onStatus) { onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
}
}, },
}); });
if (!resultUrl) throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试"); if (!resultUrl) throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
@@ -90,7 +90,7 @@ export function buildCanvasVideoTaskInput(workflow: WebCanvasWorkflow, nodeId: s
const params = context.node.params || {}; const params = context.node.params || {};
const referenceUrls = context.imageReferences.map((item) => item.url); const referenceUrls = context.imageReferences.map((item) => item.url);
const displayModel = toHappyHorseDisplayModel(String(params.model || workflow.settings.model || "happyhorse-1.0")); const displayModel = toHappyHorseDisplayModel(String(params.model || workflow.settings.model || "happyhorse-1.0"));
const model = resolveVideoRequestModel({ model: displayModel, referenceUrls }); let model = resolveVideoRequestModel({ model: displayModel, referenceUrls });
return { return {
title: context.node.label || "视频节点生成", title: context.node.label || "视频节点生成",
type: "video", type: "video",
+3 -38
View File
@@ -1,4 +1,4 @@
import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from "react"; import { type Dispatch, type MutableRefObject, type SetStateAction, useEffect, useRef, useState } from "react";
import type { import type {
CanvasImageGenerationState, CanvasImageGenerationState,
CanvasImageNode, CanvasImageNode,
@@ -6,7 +6,6 @@ import type {
CanvasVideoGenerationState, CanvasVideoGenerationState,
CanvasVideoNode, CanvasVideoNode,
} from "./canvasTypes"; } from "./canvasTypes";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence"; import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence";
import { waitForImageTaskResult, waitForVideoTaskResult } from "./canvasUtils"; import { waitForImageTaskResult, waitForVideoTaskResult } from "./canvasUtils";
@@ -42,13 +41,6 @@ export function removeCanvasGenKeepalive(taskId: string): void {
saveCanvasGenKeepalive(loadCanvasGenKeepalive().filter((e) => e.taskId !== taskId)); 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 { export interface UseCanvasGenerationParams {
setImageNodes: Dispatch<SetStateAction<CanvasImageNode[]>>; setImageNodes: Dispatch<SetStateAction<CanvasImageNode[]>>;
setVideoNodes: Dispatch<SetStateAction<CanvasVideoNode[]>>; setVideoNodes: Dispatch<SetStateAction<CanvasVideoNode[]>>;
@@ -63,7 +55,6 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
const [generationToast, setGenerationToast] = useState<string | null>(null); const [generationToast, setGenerationToast] = useState<string | null>(null);
const imageGenerationInFlightRef = useRef(new Set<string>()); const imageGenerationInFlightRef = useRef(new Set<string>());
const videoGenerationInFlightRef = useRef(new Set<string>());
const textGenerationInFlightRef = useRef(new Set<string>()); const textGenerationInFlightRef = useRef(new Set<string>());
const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>()); const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>());
const canvasGenKeepaliveRestoredRef = useRef(false); const canvasGenKeepaliveRestoredRef = useRef(false);
@@ -134,7 +125,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
imageGenerationInFlightRef.current.delete(entry.nodeId); imageGenerationInFlightRef.current.delete(entry.nodeId);
}); });
} else if (entry.nodeKind === "video") { } else if (entry.nodeKind === "video") {
videoGenerationInFlightRef.current.add(entry.nodeId); imageGenerationInFlightRef.current.add(entry.nodeId);
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 }); setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 });
void waitForVideoTaskResult(entry.taskId, (status) => { void waitForVideoTaskResult(entry.taskId, (status) => {
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0))); const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
@@ -163,7 +154,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
removeCanvasGenKeepalive(entry.taskId); removeCanvasGenKeepalive(entry.taskId);
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" }); setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
}).finally(() => { }).finally(() => {
videoGenerationInFlightRef.current.delete(entry.nodeId); imageGenerationInFlightRef.current.delete(entry.nodeId);
}); });
} }
} }
@@ -174,36 +165,11 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
textGenerationAbortControllersRef.current.clear(); textGenerationAbortControllersRef.current.clear();
textGenerationInFlightRef.current.clear(); textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear(); imageGenerationInFlightRef.current.clear();
videoGenerationInFlightRef.current.clear();
setTextGenerationState({}); setTextGenerationState({});
setImageGenerationState({}); setImageGenerationState({});
setVideoGenerationState({}); 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 { return {
textGenerationState, textGenerationState,
imageGenerationState, imageGenerationState,
@@ -211,7 +177,6 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
generationToast, generationToast,
setGenerationToast, setGenerationToast,
imageGenerationInFlightRef, imageGenerationInFlightRef,
videoGenerationInFlightRef,
textGenerationInFlightRef, textGenerationInFlightRef,
textGenerationAbortControllersRef, textGenerationAbortControllersRef,
canvasGenKeepaliveRestoredRef, canvasGenKeepaliveRestoredRef,
+1 -1
View File
@@ -1,4 +1,4 @@
import { type Dispatch, type MouseEvent, type MutableRefObject, type SetStateAction, useEffect, useState } from "react"; import { type Dispatch, type MouseEvent, type MutableRefObject, type SetStateAction, useEffect, useRef, useState } from "react";
import { canvasNodeClickMoveThreshold } from "./canvasConstants"; import { canvasNodeClickMoveThreshold } from "./canvasConstants";
import type { import type {
CanvasAlignGuide, CanvasAlignGuide,
@@ -13,6 +13,7 @@ import {
RightOutlined, RightOutlined,
ScissorOutlined, ScissorOutlined,
SwapOutlined, SwapOutlined,
ThunderboltOutlined,
VideoCameraOutlined, VideoCameraOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react"; import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
+60 -211
View File
@@ -7,213 +7,61 @@ interface CompliancePageProps {
kind: ComplianceKind; kind: ComplianceKind;
} }
const companyName = "南京万物可爱文化传媒有限公司"; const companyName = "OmniAI";
const platformName = "Omniai平台";
const contactEmail = "system@omniai.net.cn";
const contactPhone = "15155073618"; const contactPhone = "15155073618";
const address = "江苏省南京市江北新区扬子江数字视听产业园9栋A楼9A-501"; const address = "江苏省南京市江北新区扬子江数字视听产业园9栋A楼501";
const icpRecord = "苏ICP备2026021747号-1";
const effectiveDate = "2026年06月8日";
const privacyPolicyText = ` const agreementSections = [
{
2026068 title: "服务范围",
2026068 body: "平台提供 AI 图片、视频、脚本、数字人及相关创作辅助服务。具体功能、模型能力、消耗规则以页面展示和平台公告为准。",
使 },
Omniai平台的运营者使/使使/使 {
使/使/ title: "账号与使用",
body: "用户应保证注册信息真实有效,妥善保管账号与登录凭证,不得出租、转让账号或以自动化方式恶意占用平台资源。",
},
使 {
使Cookie和同类技术 title: "内容合规",
body: "用户不得上传、生成、发布违法违规、侵权、涉政敏感、暴恐、色情、赌博、诈骗或侵犯他人合法权益的内容。平台有权对违规内容采取删除、限制功能、封禁账号等措施。",
},
{
title: "积分与付费",
body: "积分仅限平台内消费,不支持提现、转让或折现。充值、套餐、赠送积分的有效期、消耗顺序和退费规则以充值页面展示为准。",
},
{
title: "责任限制",
1. SDKAPI等方式访使/ body: "AI 生成结果可能存在偏差,用户应自行审核输出内容并承担使用后果。因不可抗力、第三方服务异常、网络故障造成的服务中断,平台将在合理范围内修复。",
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 agreementText = ` const privacySections = [
Omniai平台用户协议 {
2026068 title: "收集的信息",
2026068 body: "我们会收集账号信息、登录状态、联系方式、创作输入、生成结果、用量记录、设备与网络日志,用于提供服务、安全审计和问题排查。",
使 },
SaaS软件服务使Omniai平台服务 {
18使 title: "Cookie 与本地存储",
使使 body: "我们使用 Cookie、localStorage 和 sessionStorage 保存登录状态、偏好设置、Cookie 同意状态、创作草稿和断点续传数据。",
使 },
{
1. SaaS软件工具 title: "信息使用",
2. SaaS服务线使 body: "信息用于身份验证、生成任务处理、资产管理、积分计费、客服支持、风控合规、服务优化和法律法规要求的备案审计。",
3. },
4. {
5. 使 title: "第三方处理",
6. body: "为完成 AI 生成、对象存储、短信邮件、支付或错误监控,我们可能向必要的第三方服务提供最小范围数据,并要求其按约定保护数据安全。",
使 },
1. {
1使使Omniai平台服务时可能需要提供一些必要的信息使 title: "用户权利",
2/使使 body: "你可以通过平台账号功能或联系方式申请访问、更正、删除个人信息,或撤回非必要授权。法律法规另有要求的记录可能需按规定保留。",
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) { export default function CompliancePage({ kind }: CompliancePageProps) {
const isPrivacy = kind === "privacy"; const isPrivacy = kind === "privacy";
const sections = isPrivacy ? privacySections : agreementSections;
const title = isPrivacy ? "隐私政策" : "用户协议"; const title = isPrivacy ? "隐私政策" : "用户协议";
const Icon = isPrivacy ? SafetyOutlined : FileTextOutlined; const Icon = isPrivacy ? SafetyOutlined : FileTextOutlined;
const lines = getDocumentLines(isPrivacy ? privacyPolicyText : agreementText);
return ( return (
<section className="compliance-page"> <section className="compliance-page">
@@ -223,26 +71,27 @@ export default function CompliancePage({ kind }: CompliancePageProps) {
<div> <div>
<span className="compliance-hero__eyebrow"></span> <span className="compliance-hero__eyebrow"></span>
<h1>{title}</h1> <h1>{title}</h1>
<p>{companyName}{platformName}{effectiveDate}</p> <p>{companyName} 2026 6 3 </p>
</div> </div>
</header> </header>
<article className="compliance-card compliance-document"> <div className="compliance-card">
{lines.map((line, index) => { {sections.map((section, index) => (
const className = getLineClassName(line, index); <article key={section.title} className="compliance-section">
if (className === "compliance-document__title") return <h2 key={`${index}-${line}`} className={className}>{line}</h2>; <span>{String(index + 1).padStart(2, "0")}</span>
if (className === "compliance-document__heading") return <h3 key={`${index}-${line}`} className={className}>{line}</h3>; <div>
if (className === "compliance-document__subheading") return <h4 key={`${index}-${line}`} className={className}>{line}</h4>; <h2>{section.title}</h2>
return <p key={`${index}-${line}`} className={className}>{line}</p>; <p>{section.body}</p>
})} </div>
</article> </article>
))}
</div>
<footer className="compliance-contact"> <footer className="compliance-contact">
<strong></strong> <strong></strong>
<span>{contactEmail}</span>
<span>{address}</span> <span>{address}</span>
<span>{contactPhone}</span> <span>{contactPhone}</span>
<span>{icpRecord}</span> <span>ICP备2026021747号-1</span>
</footer> </footer>
</div> </div>
</section> </section>
@@ -14,6 +14,7 @@ import {
RightOutlined, RightOutlined,
ScissorOutlined, ScissorOutlined,
SwapOutlined, SwapOutlined,
ThunderboltOutlined,
UserOutlined, UserOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react"; import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
@@ -99,6 +100,7 @@ function DigitalHumanPage({
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const imageInputRef = useRef<HTMLInputElement | null>(null); const imageInputRef = useRef<HTMLInputElement | null>(null);
const audioInputRef = useRef<HTMLInputElement | null>(null); const audioInputRef = useRef<HTMLInputElement | null>(null);
const canvasDragCounterRef = useRef(0);
const [isCanvasDragging, setIsCanvasDragging] = useState(false); const [isCanvasDragging, setIsCanvasDragging] = useState(false);
useEffect(() => { useEffect(() => {
+18 -2
View File
@@ -1,5 +1,6 @@
import { import {
AppstoreOutlined, AppstoreOutlined,
CloudUploadOutlined,
CloseOutlined, CloseOutlined,
FileImageOutlined, FileImageOutlined,
FrownOutlined, FrownOutlined,
@@ -8,6 +9,7 @@ import {
MenuUnfoldOutlined, MenuUnfoldOutlined,
QuestionCircleOutlined, QuestionCircleOutlined,
ReloadOutlined, ReloadOutlined,
SettingOutlined,
SkinOutlined, SkinOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react"; import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
@@ -622,6 +624,11 @@ const tryOnModelOptions = {
ethnicity: ["欧美白人", "亚洲人", "拉美裔", "非洲裔"], ethnicity: ["欧美白人", "亚洲人", "拉美裔", "非洲裔"],
body: ["标准", "高挑", "微胖", "运动"], body: ["标准", "高挑", "微胖", "运动"],
}; };
const sampleResults = [
ossAssets.ecommerce.slides.slide4,
ossAssets.ecommerce.generated,
ossAssets.ecommerce.slides.slide5,
];
const productSetAssets = ossAssets.ecommerce.productSet; const productSetAssets = ossAssets.ecommerce.productSet;
const productSetPreviewCards = [ const productSetPreviewCards = [
{ id: "main", label: "01 主图 (白底/合规)", src: productSetAssets.main }, { id: "main", label: "01 主图 (白底/合规)", src: productSetAssets.main },
@@ -694,13 +701,21 @@ 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[]> { async function createUploadedImageItems(files: File[], limit: number, prefix: string): Promise<CloneImageItem[]> {
const selectedFiles = Array.from(files).slice(0, limit); const selectedFiles = Array.from(files).slice(0, limit);
const stamp = Date.now(); const stamp = Date.now();
const items = await Promise.all(selectedFiles.map(async (file, index) => { const items = await Promise.all(selectedFiles.map(async (file, index) => {
const localPreviewUrl = URL.createObjectURL(file); const localPreviewUrl = URL.createObjectURL(file);
let dimensions: { width?: number; height?: number }; let dimensions: { width?: number; height?: number } = {};
try { try {
dimensions = await readImageDimensions(localPreviewUrl); dimensions = await readImageDimensions(localPreviewUrl);
} catch { } catch {
@@ -1687,6 +1702,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
} }
const generatedUrls: string[] = []; const generatedUrls: string[] = [];
const stamp = Date.now();
for (const countKey of cloneSetCountKeys) { for (const countKey of cloneSetCountKeys) {
if (imageAbortRef.current.current) break; if (imageAbortRef.current.current) break;
@@ -2023,7 +2039,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
); );
}; };
const _resetTask = () => { const resetTask = () => {
setSetImages([]); setSetImages([]);
setProductSetRequirement(""); setProductSetRequirement("");
setProductSetOutput("video"); setProductSetOutput("video");
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import "../../styles/pages/ecommerce-video.css"; import "../../styles/pages/ecommerce-video.css";
import { import {
CloseOutlined, CloseOutlined,
@@ -14,6 +14,7 @@ import {
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks, saveVideoHistory } from "./ecommerceVideoService"; import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks, saveVideoHistory } from "./ecommerceVideoService";
import { import {
PLAN_STEP_LABELS, PLAN_STEP_LABELS,
PLAN_STEPS_DISPLAY,
type EcommerceVideoStage, type EcommerceVideoStage,
type EcommerceVideoSceneTask, type EcommerceVideoSceneTask,
type EcommerceVideoPlanProgress, type EcommerceVideoPlanProgress,
@@ -28,6 +29,7 @@ import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import { import {
saveEcommerceVideoState, saveEcommerceVideoState,
loadEcommerceVideoState, loadEcommerceVideoState,
clearEcommerceVideoState,
} from "./ecommerceVideoKeepalive"; } from "./ecommerceVideoKeepalive";
interface EcommerceVideoWorkspaceProps { interface EcommerceVideoWorkspaceProps {
@@ -296,7 +298,7 @@ export default function EcommerceVideoWorkspace({
}, 3000); }, 3000);
}; };
const _handleDownload = async (url: string) => { const handleDownload = async (url: string) => {
try { try {
await saveToolResultToLocal({ await saveToolResultToLocal({
url, name: `ecommerce-video-${Date.now()}`, type: "video", url, name: `ecommerce-video-${Date.now()}`, type: "video",
@@ -308,7 +310,7 @@ export default function EcommerceVideoWorkspace({
} }
}; };
const _handleSaveAsset = async (url: string) => { const handleSaveAsset = async (url: string) => {
try { try {
const result = await addToolResultToAssetLibrary({ const result = await addToolResultToAssetLibrary({
url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频生成结果", url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频生成结果",
@@ -594,6 +596,9 @@ export default function EcommerceVideoWorkspace({
const sourceImage = sourceImageUrls[0] || planResult?.imageUrls[0] || productImageDataUrls[0] || ""; const sourceImage = sourceImageUrls[0] || planResult?.imageUrls[0] || productImageDataUrls[0] || "";
const flowHasStarted = stage !== "idle" || completedSteps.length > 0; const flowHasStarted = stage !== "idle" || completedSteps.length > 0;
const flowMeta = `${platform} / ${aspectRatio} / ${durationSeconds}s / ${resolution}`; 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 ( return (
<div className="ecom-video-workspace" data-stage={stage}> <div className="ecom-video-workspace" data-stage={stage}>
+1 -1
View File
@@ -19,7 +19,7 @@ const [
ecommerceCarouselImage2, ecommerceCarouselImage2,
ecommerceCarouselImage3, ecommerceCarouselImage3,
ecommerceCarouselImage4, ecommerceCarouselImage4,
, ecommerceCarouselImage5,
ecommerceCarouselImage6, ecommerceCarouselImage6,
] = ossAssets.ecommerce.templateCases; ] = ossAssets.ecommerce.templateCases;
const ecommerceCarouselGenerated = ossAssets.ecommerce.generated; const ecommerceCarouselGenerated = ossAssets.ecommerce.generated;
+12 -1
View File
@@ -1,5 +1,6 @@
import { import {
ArrowRightOutlined, ArrowRightOutlined,
DashboardOutlined,
FileSearchOutlined, FileSearchOutlined,
PlayCircleOutlined, PlayCircleOutlined,
PlusOutlined, PlusOutlined,
@@ -8,6 +9,7 @@ import {
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import type { WebViewKey, WebImageWorkbenchTool } from "../../types"; import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
import { useScrollEntrance } from "../../hooks/useScrollEntrance";
import { ossAssets } from "../../data/ossAssets"; import { ossAssets } from "../../data/ossAssets";
import "../../styles/pages/home.css"; import "../../styles/pages/home.css";
import WelcomeSplash from "./WelcomeSplash"; import WelcomeSplash from "./WelcomeSplash";
@@ -15,6 +17,15 @@ import ToolboxSection from "./ToolboxSection";
import ScriptReviewShowcase from "./ScriptReviewShowcase"; import ScriptReviewShowcase from "./ScriptReviewShowcase";
import ModelGenerationShowcase from "./ModelGenerationShowcase"; 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 [heroImage1, heroImage2, heroImage3] = ossAssets.home.heroSlides;
const { const {
ecommerce: featureEcommerceImage, ecommerce: featureEcommerceImage,
@@ -466,7 +477,7 @@ function EcommerceFeatureShowcase() {
); );
} }
function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcommerce, onOpenScriptReview, onSelectView, onOpenImageTool }: HomePageProps) { function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcommerce, onOpenScriptReview, onOpenTokenMonitor, onSelectView, onOpenImageTool }: HomePageProps) {
const [splashDismissed, setSplashDismissed] = useState(() => sessionStorage.getItem("omniai:splash-seen") === "1"); const [splashDismissed, setSplashDismissed] = useState(() => sessionStorage.getItem("omniai:splash-seen") === "1");
const [activeSlideIndex, setActiveSlideIndex] = useState(0); const [activeSlideIndex, setActiveSlideIndex] = useState(0);
const [carouselMotion, setCarouselMotion] = useState<HomeCarouselMotion | null>(null); const [carouselMotion, setCarouselMotion] = useState<HomeCarouselMotion | null>(null);
+1
View File
@@ -14,6 +14,7 @@ function ScriptReviewVisual() {
const [animated, setAnimated] = useState(false); const [animated, setAnimated] = useState(false);
const [activeDim, setActiveDim] = useState<number | null>(null); const [activeDim, setActiveDim] = useState<number | null>(null);
const [score, setScore] = useState(0); const [score, setScore] = useState(0);
const scoreRef = useRef<number>(0);
const frameRef = useRef<number | null>(null); const frameRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
+1 -1
View File
@@ -145,7 +145,7 @@ const CARDS = [
}, },
]; ];
function ToolboxSection({ onSelectView }: ToolboxSectionProps) { function ToolboxSection({ onSelectView, onOpenImageTool }: ToolboxSectionProps) {
const handleCardClick = (targetView: WebViewKey) => { const handleCardClick = (targetView: WebViewKey) => {
onSelectView(targetView); onSelectView(targetView);
}; };
@@ -21,6 +21,7 @@ import {
ScissorOutlined, ScissorOutlined,
SwapOutlined, SwapOutlined,
TableOutlined, TableOutlined,
ThunderboltOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react"; import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import "../../styles/pages/more-tools.css"; import "../../styles/pages/more-tools.css";
@@ -29,6 +30,7 @@ import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive"; import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { translateTaskError } from "../../utils/translateTaskError";
import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions"; import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions";
import { useCanvasDrawing } from "./useCanvasDrawing"; import { useCanvasDrawing } from "./useCanvasDrawing";
import CameraViewport3D from "./CameraViewport3D"; import CameraViewport3D from "./CameraViewport3D";
@@ -38,6 +40,7 @@ type OutputSize = "9:16" | "16:9" | "4:3" | "3:4" | "1:1";
type OutputCount = 1 | 2 | 3 | 4; type OutputCount = 1 | 2 | 3 | 4;
const OUTPUT_SIZE_OPTIONS: OutputSize[] = ["9:16", "16:9", "4:3", "3:4", "1:1"]; 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> = { const SIZE_TO_RATIO: Record<OutputSize, string> = {
"9:16": "9:16", "9:16": "9:16",
-26
View File
@@ -1488,32 +1488,6 @@ function ProfilePage({
</span> </span>
) : null} ) : null}
</label> </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} ) : null}
@@ -21,8 +21,9 @@ import "../../styles/pages/image-workbench.css";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive"; import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { translateTaskError } from "../../utils/translateTaskError";
import { getServerBaseUrl, isServerRequestError } from "../../api/serverConnection"; import { getServerBaseUrl, isServerRequestError } from "../../api/serverConnection";
import { summarizeUrl, formatFileSize, fileToDataUrl } from "../../utils/toolPageUtils"; import { summarizeUrl, formatFileSize, fileToDataUrl, wait } from "../../utils/toolPageUtils";
import TaskStatusBar from "../../components/TaskStatusBar"; import TaskStatusBar from "../../components/TaskStatusBar";
import BeforeAfterCompare from "../../components/BeforeAfterCompare"; import BeforeAfterCompare from "../../components/BeforeAfterCompare";
import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions"; import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions";
@@ -1,6 +1,12 @@
import { import {
BarChartOutlined,
CheckCircleFilled, CheckCircleFilled,
CloseOutlined, CloseOutlined,
CopyOutlined,
DownloadOutlined,
FileTextOutlined,
LoadingOutlined,
ThunderboltOutlined,
UploadOutlined, UploadOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent } from "react"; import { useEffect, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent } from "react";
@@ -258,7 +264,7 @@ function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[]
return normalizeEvidenceItems(evidence, 3); return normalizeEvidenceItems(evidence, 3);
} }
function formatReportMarkdown(result: EvalResult): string { function formatReportMarkdown(result: EvalResult, script: string): string {
const lines: string[] = []; const lines: string[] = [];
lines.push(`# 剧本评测报告`); lines.push(`# 剧本评测报告`);
lines.push(""); lines.push("");
@@ -432,7 +438,7 @@ function ScriptTokensPage() {
const handleCopyReport = async () => { const handleCopyReport = async () => {
if (!result) return; if (!result) return;
const text = formatReportMarkdown(result); const text = formatReportMarkdown(result, script);
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
setCopied(true); setCopied(true);
@@ -453,7 +459,7 @@ function ScriptTokensPage() {
const handleExportMarkdown = () => { const handleExportMarkdown = () => {
if (!result) return; if (!result) return;
const md = formatReportMarkdown(result); const md = formatReportMarkdown(result, script);
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" }); const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
@@ -130,6 +130,8 @@ function TokenUsagePage({
loadEnterpriseUsage, loadEnterpriseUsage,
loadPersonalUsage, loadPersonalUsage,
onOpenMore, onOpenMore,
onOpenImageTool,
onSelectView,
}: TokenUsagePageProps) { }: TokenUsagePageProps) {
const [enterpriseUsage, setEnterpriseUsage] = useState<WebEnterpriseUsageSummary | null>(null); const [enterpriseUsage, setEnterpriseUsage] = useState<WebEnterpriseUsageSummary | null>(null);
const [enterpriseUsageLoading, setEnterpriseUsageLoading] = useState(false); const [enterpriseUsageLoading, setEnterpriseUsageLoading] = useState(false);
@@ -947,6 +947,7 @@ function SizeTemplatePage({ onOpenEcommerce }: SizeTemplatePageProps) {
); );
const selectedPreset = const selectedPreset =
filteredTemplates.find((item) => item.title === activePresetTitle) ?? filteredTemplates[0] ?? sizeTemplatePresets[0]!; filteredTemplates.find((item) => item.title === activePresetTitle) ?? filteredTemplates[0] ?? sizeTemplatePresets[0]!;
const activeGroupLabel = sizeTemplateGroups.find((item) => item.key === selectedPreset.group)?.label ?? "尺寸模板";
const platformOptions = const platformOptions =
activeGroup === "socialCn" activeGroup === "socialCn"
? socialContentPlatformOptions ? socialContentPlatformOptions
@@ -1,4 +1,5 @@
import { import {
CloseOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
MenuFoldOutlined, MenuFoldOutlined,
+52 -92
View File
@@ -2,18 +2,24 @@ import {
AppstoreOutlined, AppstoreOutlined,
ArrowDownOutlined, ArrowDownOutlined,
ArrowUpOutlined, ArrowUpOutlined,
CaretRightOutlined,
ClockCircleOutlined, ClockCircleOutlined,
CloseOutlined, CloseOutlined,
CopyOutlined, CopyOutlined,
DeleteOutlined, DeleteOutlined,
DownloadOutlined,
FullscreenOutlined,
LoadingOutlined, LoadingOutlined,
MessageOutlined, MessageOutlined,
MutedOutlined,
PictureOutlined, PictureOutlined,
PauseOutlined,
PlusOutlined, PlusOutlined,
ReloadOutlined, ReloadOutlined,
SendOutlined, SendOutlined,
SettingOutlined, SettingOutlined,
StopOutlined, StopOutlined,
ThunderboltOutlined,
VideoCameraOutlined, VideoCameraOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { import {
@@ -29,10 +35,10 @@ import {
type SyntheticEvent, type SyntheticEvent,
} from "react"; } from "react";
import "../../styles/pages/workbench.css"; import "../../styles/pages/workbench.css";
import type { WebUserSession } from "../../types"; import type { WebGenerationPreviewTask, WebUserSession } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { claimGenerationSlot, getActiveGenerationTaskCount, getEffectiveGenerationLimit, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency"; import { claimGenerationSlot, getActiveGenerationTaskCount, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
import { preUploadReference } from "../../api/referenceUploadService"; import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUploadService";
import { assetClient } from "../../api/assetClient"; import { assetClient } from "../../api/assetClient";
import { communityClient } from "../../api/communityClient"; import { communityClient } from "../../api/communityClient";
import { loadRechargeModal, type RechargeModalComponent } from "../../components/RechargeModal/loadRechargeModal"; import { loadRechargeModal, type RechargeModalComponent } from "../../components/RechargeModal/loadRechargeModal";
@@ -40,6 +46,7 @@ import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import { conversationClient, type ConversationSummary } from "../../api/conversationClient"; import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient"; import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
import { buildApiUrl, buildAuthHeaders } from "../../api/serverConnection";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway"; import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import type { WebProjectSummary } from "../../types"; import type { WebProjectSummary } from "../../types";
import { import {
@@ -55,6 +62,8 @@ import {
MarkdownMessage, MarkdownMessage,
ResultCard, ResultCard,
} from "./components/WorkbenchChatCards"; } from "./components/WorkbenchChatCards";
import { renderMarkdownBlocks } from "./markdownRenderer";
import { downloadResultAsset } from "./workbenchDownload";
import { translateTaskError } from "../../utils/translateTaskError"; import { translateTaskError } from "../../utils/translateTaskError";
import { import {
buildLocalTimeoutMessage, buildLocalTimeoutMessage,
@@ -71,6 +80,7 @@ import { isPixverseModel } from "../../utils/pixverseRouting";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy"; import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
import { import {
getImageQualityOptions,
getImageQualityOptionsForContext, getImageQualityOptionsForContext,
getDefaultImageQuality, getDefaultImageQuality,
getDefaultImageQualityForContext, getDefaultImageQualityForContext,
@@ -80,6 +90,8 @@ import {
} from "../../utils/modelOptions"; } from "../../utils/modelOptions";
import { filterImageModelOptionsForSession } from "../../utils/imageModelVisibility"; import { filterImageModelOptionsForSession } from "../../utils/imageModelVisibility";
import { persistWorkbenchResultAsset, type PersistedWorkbenchResultAsset } from "./workbenchResultPersistence"; import { persistWorkbenchResultAsset, type PersistedWorkbenchResultAsset } from "./workbenchResultPersistence";
import { SmoothedProgressBar } from "./SmoothedProgressBar";
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
import { import {
type WorkbenchMode, type WorkbenchMode,
type ToolbarMenuId, type ToolbarMenuId,
@@ -87,6 +99,8 @@ import {
type WorkbenchOption, type WorkbenchOption,
type WorkbenchFieldGroup, type WorkbenchFieldGroup,
type ReferenceItem, type ReferenceItem,
type PromptMentionItem,
type PromptMentionTokenRange,
type ChatAttachment, type ChatAttachment,
type ChatMessage, type ChatMessage,
type DeleteDialogState, type DeleteDialogState,
@@ -103,18 +117,33 @@ import {
GRID_SUPPORTED_MODELS, GRID_SUPPORTED_MODELS,
VIDEO_FRAME_OPTIONS, VIDEO_FRAME_OPTIONS,
VIDEO_DURATION_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, 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_NATURAL_SYSTEM_PROMPT,
CHAT_TURN_STYLE_REMINDER, CHAT_TURN_STYLE_REMINDER,
NON_CONVERSATIONAL_ASSISTANT_TEXT,
getCachedRole, getCachedRole,
getSessionUserId,
userKey,
createId, createId,
formatWorkbenchTimestamp, formatWorkbenchTimestamp,
parseWorkbenchTimestampValue,
buildChatAttachments, buildChatAttachments,
buildNaturalChatHistoryMessages, buildNaturalChatHistoryMessages,
getErrorText, getErrorText,
isAuthFailure, isAuthFailure,
isInsufficientBalance, isInsufficientBalance,
isInsufficientBalanceMessage, isInsufficientBalanceMessage,
isTransientMessage,
getPersistableMessages, getPersistableMessages,
shouldPersistPatch, shouldPersistPatch,
buildAssistantResult, buildAssistantResult,
@@ -127,18 +156,25 @@ import {
persistMessages, persistMessages,
clearWorkbenchLocalState, clearWorkbenchLocalState,
persistPromptHistory, persistPromptHistory,
buildRecoverableTaskFromMessage,
readStoredKeepaliveTasks, readStoredKeepaliveTasks,
persistKeepaliveTasks, persistKeepaliveTasks,
} from "./workbenchStorage"; } from "./workbenchStorage";
import { import {
getRatioOptionClassName,
getSettingsGridColumnsClassName,
getReferenceAccept, getReferenceAccept,
getReferenceUploadLabel, getReferenceUploadLabel,
getReferenceLimit, getReferenceLimit,
getReferenceKindLabel, getReferenceKindLabel,
getReferenceEmptyCopy,
hexToRgbTriplet, hexToRgbTriplet,
inferReferenceKind, inferReferenceKind,
disposeReferencePreview, disposeReferencePreview,
fileToDataUrl,
bytesToHex,
buildReferenceFingerprint, buildReferenceFingerprint,
canCompressReferenceImage,
compressReferenceImageIfNeeded, compressReferenceImageIfNeeded,
buildReferenceToken, buildReferenceToken,
resolveReferenceUrls, resolveReferenceUrls,
@@ -152,6 +188,7 @@ import {
import { import {
findPromptMentionRangeInside, findPromptMentionRangeInside,
findPromptMentionRangeOverlap, findPromptMentionRangeOverlap,
ReferenceInlinePreview,
ReferencePreview, ReferencePreview,
PromptPreviewLayer, PromptPreviewLayer,
} from "./WorkbenchPromptPreview"; } from "./WorkbenchPromptPreview";
@@ -458,6 +495,7 @@ function WorkbenchPage({
const workbenchAccent = "#00ff88"; const workbenchAccent = "#00ff88";
const hasConversationRecords = activeConversationId !== null || messages.length > 0; const hasConversationRecords = activeConversationId !== null || messages.length > 0;
const hasActivatedWorkspace = !effectiveOnboarding && (workspaceStarted || isGenerating || hasConversationRecords); const hasActivatedWorkspace = !effectiveOnboarding && (workspaceStarted || isGenerating || hasConversationRecords);
const referenceCount = referenceItems.length;
const activeVideoModelValue = toHappyHorseDisplayModel(videoModel); const activeVideoModelValue = toHappyHorseDisplayModel(videoModel);
const activeModelValue = const activeModelValue =
activeMode === "image" ? imageModel : activeMode === "video" ? activeVideoModelValue : chatModel; activeMode === "image" ? imageModel : activeMode === "video" ? activeVideoModelValue : chatModel;
@@ -969,7 +1007,7 @@ function WorkbenchPage({
const patchConversationMessage = useCallback( const patchConversationMessage = useCallback(
async (conversationId: number, messageId: string, patch: Partial<ChatMessage>) => { async (conversationId: number, messageId: string, patch: Partial<ChatMessage>) => {
const sourceMessages = let sourceMessages =
activeConversationIdRef.current === conversationId activeConversationIdRef.current === conversationId
? messagesRef.current ? messagesRef.current
: conversationMessagesCacheRef.current.get(conversationId); : conversationMessagesCacheRef.current.get(conversationId);
@@ -1017,54 +1055,6 @@ function WorkbenchPage({
persistKeepaliveTasks(rest); 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( const runKeepalivePoll = useCallback(
(task: WorkbenchKeepaliveTask) => { (task: WorkbenchKeepaliveTask) => {
if (taskAbortControllersRef.current.has(task.taskId)) return; if (taskAbortControllersRef.current.has(task.taskId)) return;
@@ -1091,10 +1081,6 @@ function WorkbenchPage({
if (abortController.signal.aborted) return; if (abortController.signal.aborted) return;
if (attempt > 0) await sleep(3000); if (attempt > 0) await sleep(3000);
if (abortController.signal.aborted) return; if (abortController.signal.aborted) return;
if (typeof navigator !== "undefined" && navigator.onLine === false) {
releaseKeepaliveTaskAfterNetworkLoss(task, lastKnownProgress);
return;
}
let status; let status;
try { try {
@@ -1108,8 +1094,7 @@ function WorkbenchPage({
taskProgress: 100, taskProgress: 100,
taskStatusLabel: "任务异常", taskStatusLabel: "任务异常",
}); });
releaseKeepaliveTaskLocally(task.taskId, { cancelServer: true }); removeKeepaliveTask(task.taskId);
onRefreshUsage?.();
return; return;
} }
continue; continue;
@@ -1337,24 +1322,6 @@ function WorkbenchPage({
}; };
}, [runKeepalivePoll]); }, [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(() => { useEffect(() => {
persistPromptHistory(promptHistory); persistPromptHistory(promptHistory);
}, [promptHistory]); }, [promptHistory]);
@@ -2019,7 +1986,7 @@ function WorkbenchPage({
const trimmedPrompt = (promptOverride ?? inputValue).trim(); const trimmedPrompt = (promptOverride ?? inputValue).trim();
if (!trimmedPrompt) return; if (!trimmedPrompt) return;
const userKey = getGenerationUserKey(session?.user.id); const userKey = getGenerationUserKey(session?.user.id);
if (activeMode !== "chat" && getActiveGenerationTaskCount(userKey) >= getEffectiveGenerationLimit()) return; if (activeMode !== "chat" && getActiveGenerationTaskCount(userKey) >= 3) return;
setReferencePreviewOpen(false); setReferencePreviewOpen(false);
let conversationId = activeConversationIdRef.current ?? activeConversationId; let conversationId = activeConversationIdRef.current ?? activeConversationId;
@@ -2198,7 +2165,7 @@ function WorkbenchPage({
taskId = result.taskId; taskId = result.taskId;
genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "image", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId }); genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "image", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId });
} else { } else {
const requestModel = resolveVideoRequestModel({ let requestModel = resolveVideoRequestModel({
model: taskInput.params?.model || ENTERPRISE_DEFAULT_VIDEO_MODEL, model: taskInput.params?.model || ENTERPRISE_DEFAULT_VIDEO_MODEL,
referenceUrls: refUrls, referenceUrls: refUrls,
}); });
@@ -2498,11 +2465,8 @@ function WorkbenchPage({
setProjectError("仅支持对视频结果进行超分"); setProjectError("仅支持对视频结果进行超分");
return; return;
} }
const userKey = getGenerationUserKey(session?.user.id); if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
const activeCount = getActiveGenerationTaskCount(userKey); setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
const limit = getEffectiveGenerationLimit();
if (activeCount >= limit) {
setProjectError(`当前已有 ${activeCount} 个任务进行中(上限${limit}个),请等待任一任务完成后再提交新任务`);
return; return;
} }
if (!isAuthenticated) { if (!isAuthenticated) {
@@ -2623,11 +2587,8 @@ function WorkbenchPage({
setProjectError("仅支持对图片结果进行超分"); setProjectError("仅支持对图片结果进行超分");
return; return;
} }
const userKey = getGenerationUserKey(session?.user.id); if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
const activeCount = getActiveGenerationTaskCount(userKey); setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
const limit = getEffectiveGenerationLimit();
if (activeCount >= limit) {
setProjectError(`当前已有 ${activeCount} 个任务进行中(上限${limit}个),请等待任一任务完成后再提交新任务`);
return; return;
} }
if (!isAuthenticated) { if (!isAuthenticated) {
@@ -2800,14 +2761,13 @@ function WorkbenchPage({
}; };
const activeGenerationCount = getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)); const activeGenerationCount = getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id));
const activeGenerationLimit = getEffectiveGenerationLimit(); const generationLimitReached = activeMode !== "chat" && activeGenerationCount >= 3;
const generationLimitReached = activeMode !== "chat" && activeGenerationCount >= activeGenerationLimit;
const promptIsEmpty = !inputValue.trim(); const promptIsEmpty = !inputValue.trim();
const sendDisabled = promptIsEmpty || generationLimitReached; const sendDisabled = promptIsEmpty || generationLimitReached;
const sendButtonTitle = promptIsEmpty const sendButtonTitle = promptIsEmpty
? "输入内容后可发送" ? "输入内容后可发送"
: generationLimitReached : generationLimitReached
? `当前已有 ${activeGenerationCount} 个任务进行中(上限 ${activeGenerationLimit} 个),请等待任一任务完成` ? `当前已有 ${activeGenerationCount} 个任务进行中,请等待任一任务完成`
: billingEstimate.title; : billingEstimate.title;
const suggestedPrompts = [ const suggestedPrompts = [
@@ -2993,7 +2953,7 @@ function WorkbenchPage({
</div> </div>
); );
const renderComposerToolbar = (disabled = false, _showStop = false) => ( const renderComposerToolbar = (disabled = false, showStop = false) => (
<div className="wb-composer__toolbar"> <div className="wb-composer__toolbar">
<div className="wb-composer__toolbar-left"> <div className="wb-composer__toolbar-left">
<span data-onboarding={obTarget({ chat: "onboarding-mode-selector", image: "onboarding-mode-selector" })}> <span data-onboarding={obTarget({ chat: "onboarding-mode-selector", image: "onboarding-mode-selector" })}>
@@ -6,6 +6,7 @@ import {
type WorkbenchMode, type WorkbenchMode,
type ReferenceKind, type ReferenceKind,
type ReferenceItem, type ReferenceItem,
type WorkbenchOption,
} from "./workbenchConstants"; } from "./workbenchConstants";
import { resolvePreUploadedUrl } from "../../api/referenceUploadService"; import { resolvePreUploadedUrl } from "../../api/referenceUploadService";
@@ -81,11 +82,7 @@ export function fileToDataUrl(file: File) {
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
if (typeof reader.result === "string") { typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Unable to read reference file"));
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.onerror = () => reject(reader.error || new Error("Unable to read reference file"));
reader.readAsDataURL(file); reader.readAsDataURL(file);
+12
View File
@@ -1,4 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import type { WebGenerationPreviewTask } from "../types";
export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled"; export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
@@ -62,6 +63,17 @@ interface GenerationStoreState {
clearTerminal: () => void; 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(); const initialQueue = loadPersistedQueue();
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({ export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
-67
View File
@@ -788,65 +788,6 @@
overflow: hidden; 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 { .compliance-section {
display: grid; display: grid;
grid-template-columns: 52px minmax(0, 1fr); grid-template-columns: 52px minmax(0, 1fr);
@@ -951,12 +892,4 @@
.compliance-section { .compliance-section {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.compliance-document {
padding: 22px 18px 26px;
}
.compliance-document__clause {
padding-left: 0;
}
} }
-150
View File
@@ -1,150 +0,0 @@
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
@@ -1 +0,0 @@
export * from "../testHarness";
-48
View File
@@ -1,48 +0,0 @@
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; sessionId?: string;
} }
const reportQueue: ErrorReport[] = []; let reportQueue: ErrorReport[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null; let flushTimer: ReturnType<typeof setTimeout> | null = null;
function getSessionId(): string | undefined { function getSessionId(): string | undefined {
+1 -1
View File
@@ -38,7 +38,7 @@ export function detectMentionTrigger(textBeforeCursor: string): MentionTriggerMa
const query = textBeforeCursor.slice(atIdx + 1); const query = textBeforeCursor.slice(atIdx + 1);
// Query must not contain spaces or punctuation that would break a mention token // Query must not contain spaces or punctuation that would break a mention token
if (/[\s,。、;:!??!.,;:(){}[\]<>""''《》【】@]/.test(query)) { if (/[\s,。、;:!??!.,;:(){}\[\]<>""''《》【】@]/.test(query)) {
return null; return null;
} }
-59
View File
@@ -1,59 +0,0 @@
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");
});
});