Merge origin/master into feat/dialog-generator-cancel-generation
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 525 KiB |
|
After Width: | Height: | Size: 674 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 473 KiB |
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 685 KiB |
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* Dynamic analysis without Playwright - uses Node.js to analyze module structure,
|
||||||
|
* dependency graph, import patterns, and potential runtime costs.
|
||||||
|
*/
|
||||||
|
import { readFileSync, readdirSync, statSync } from 'fs';
|
||||||
|
import { join, relative, basename } from 'path';
|
||||||
|
|
||||||
|
const SRC = join(import.meta.dirname, '..', 'src');
|
||||||
|
const DIST = join(import.meta.dirname, '..', 'dist');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function walk(dir) {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory() && entry.name !== 'node_modules') walk(full);
|
||||||
|
else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
|
||||||
|
const content = readFileSync(full, 'utf-8');
|
||||||
|
results.push({ file: relative(join(SRC, '..'), full), content, lines: content.split('\n').length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(SRC);
|
||||||
|
|
||||||
|
// ─── 1. Dependency Graph Analysis ───
|
||||||
|
console.log('═══════════════════════════════════════════════');
|
||||||
|
console.log(' 1. MODULE DEPENDENCY GRAPH ANALYSIS');
|
||||||
|
console.log('═══════════════════════════════════════════════');
|
||||||
|
|
||||||
|
const importMap = new Map(); // file -> [imports]
|
||||||
|
const importedBy = new Map(); // file -> [importers]
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
const imports = [];
|
||||||
|
// Match import statements
|
||||||
|
const importRe = /import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
||||||
|
let m;
|
||||||
|
while ((m = importRe.exec(r.content)) !== null) {
|
||||||
|
imports.push(m[1]);
|
||||||
|
}
|
||||||
|
// Match dynamic imports
|
||||||
|
const dynRe = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||||
|
while ((m = dynRe.exec(r.content)) !== null) {
|
||||||
|
imports.push(`[dynamic]${m[1]}`);
|
||||||
|
}
|
||||||
|
importMap.set(r.file, imports);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find circular dependencies
|
||||||
|
console.log('\n--- Circular Dependency Detection ---');
|
||||||
|
function findCircular(file, visited = new Set(), path = []) {
|
||||||
|
if (visited.has(file)) {
|
||||||
|
if (path.includes(file)) {
|
||||||
|
console.log(` [CIRCULAR] ${path.slice(path.indexOf(file)).join(' -> ')} -> ${file}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visited.add(file);
|
||||||
|
path.push(file);
|
||||||
|
const deps = importMap.get(file) || [];
|
||||||
|
for (const dep of deps) {
|
||||||
|
if (dep.startsWith('.') || dep.startsWith('/')) {
|
||||||
|
// Resolve relative path
|
||||||
|
const dir = file.split('/').slice(0, -1).join('/');
|
||||||
|
const resolved = dep.replace(/^\.\//, dir + '/').replace(/^\.\.\//, '');
|
||||||
|
// Find matching file
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.file.includes(resolved) || r.file.includes(basename(resolved))) {
|
||||||
|
findCircular(r.file, new Set(visited), [...path]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check high-fanin files (imported by many)
|
||||||
|
const fanIn = new Map();
|
||||||
|
for (const [file, imports] of importMap) {
|
||||||
|
for (const imp of imports) {
|
||||||
|
const key = imp.replace(/\[dynamic\]/, '');
|
||||||
|
fanIn.set(key, (fanIn.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- High Fan-In Modules (most imported) ---');
|
||||||
|
const sortedFanIn = [...fanIn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15);
|
||||||
|
for (const [mod, count] of sortedFanIn) {
|
||||||
|
const bar = '█'.repeat(Math.min(30, count));
|
||||||
|
console.log(` ${mod.padEnd(50)} ${String(count).padStart(3)}x ${bar}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check high-fanout files (import many)
|
||||||
|
console.log('\n--- High Fan-Out Modules (import most) ---');
|
||||||
|
const sortedFanOut = [...importMap.entries()]
|
||||||
|
.map(([f, imps]) => [f, imps.length])
|
||||||
|
.sort((a, b) => b[1] - a[1]).slice(0, 15);
|
||||||
|
for (const [file, count] of sortedFanOut) {
|
||||||
|
const bar = '█'.repeat(Math.min(30, count));
|
||||||
|
console.log(` ${file.padEnd(50)} ${String(count).padStart(3)} deps ${bar}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic imports analysis (lazy loading effectiveness)
|
||||||
|
console.log('\n--- Lazy Loading (Dynamic Imports) ---');
|
||||||
|
let dynamicImports = 0, staticImports = 0;
|
||||||
|
for (const [file, imports] of importMap) {
|
||||||
|
for (const imp of imports) {
|
||||||
|
if (imp.startsWith('[dynamic]')) dynamicImports++;
|
||||||
|
else staticImports++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` Static imports: ${staticImports}`);
|
||||||
|
console.log(` Dynamic imports: ${dynamicImports}`);
|
||||||
|
console.log(` Lazy load ratio: ${((dynamicImports / (staticImports + dynamicImports)) * 100).toFixed(1)}%`);
|
||||||
|
|
||||||
|
// Find files that should be lazy loaded but aren't
|
||||||
|
const largePages = results.filter(r => r.lines > 500 && r.file.includes('Page'));
|
||||||
|
for (const r of largePages) {
|
||||||
|
const isLazyImported = [...importMap.values()].some(imps =>
|
||||||
|
imps.some(i => i.startsWith('[dynamic]') && i.includes(basename(r.file, '.tsx')))
|
||||||
|
);
|
||||||
|
if (!isLazyImported && !r.file.includes('App')) {
|
||||||
|
// Check if it's referenced in App.tsx
|
||||||
|
const appContent = results.find(x => x.file === 'src/App.tsx')?.content || '';
|
||||||
|
if (appContent.includes(basename(r.file, '.tsx'))) {
|
||||||
|
console.log(` [INFO] ${r.file} (${r.lines} lines) - loaded via App.tsx`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2. React Rendering Cost Analysis ───
|
||||||
|
console.log('\n═══════════════════════════════════════════════');
|
||||||
|
console.log(' 2. REACT RENDERING COST ANALYSIS');
|
||||||
|
console.log('═══════════════════════════════════════════════');
|
||||||
|
|
||||||
|
// Count useState/useReducer per component (state update triggers re-render)
|
||||||
|
console.log('\n--- State Hook Density ---');
|
||||||
|
for (const r of results) {
|
||||||
|
if (!r.file.endsWith('.tsx')) continue;
|
||||||
|
const stateHooks = (r.content.match(/useState\s*[<(]/g) || []).length;
|
||||||
|
const reducers = (r.content.match(/useReducer\s*[<(]/g) || []).length;
|
||||||
|
const effects = (r.content.match(/useEffect\s*\(/g) || []).length;
|
||||||
|
const memos = (r.content.match(/useMemo\s*\(/g) || []).length;
|
||||||
|
const callbacks = (r.content.match(/useCallback\s*[<(]/g) || []).length;
|
||||||
|
const refs = (r.content.match(/useRef\s*[<(]/g) || []).length;
|
||||||
|
|
||||||
|
const totalHooks = stateHooks + reducers + effects + memos + callbacks + refs;
|
||||||
|
if (totalHooks > 15) {
|
||||||
|
const risk = totalHooks > 30 ? '🔴 HIGH' : totalHooks > 20 ? '🟡 MEDIUM' : '🟢 LOW';
|
||||||
|
console.log(` ${risk} ${r.file}`);
|
||||||
|
console.log(` useState:${stateHooks} useReducer:${reducers} useEffect:${effects} useMemo:${memos} useCallback:${callbacks} useRef:${refs} (total:${totalHooks})`);
|
||||||
|
|
||||||
|
// Check if there are many state updates that could be batched
|
||||||
|
const setters = r.content.match(/set\w+\(/g) || [];
|
||||||
|
if (setters.length > 20) {
|
||||||
|
console.log(` ⚠️ ${setters.length} state setter calls — potential for excessive re-renders`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for missing useMemo on expensive computations
|
||||||
|
const expensiveInRender = (r.content.match(/\.map\(|\.filter\(|\.reduce\(|\.sort\(/g) || []).length;
|
||||||
|
if (expensiveInRender > 5 && memos === 0) {
|
||||||
|
console.log(` ⚠️ ${expensiveInRender} array operations in render body with 0 useMemo`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 3. useEffect Dependency Analysis ───
|
||||||
|
console.log('\n═══════════════════════════════════════════════');
|
||||||
|
console.log(' 3. useEffect DEPENDENCY ANALYSIS');
|
||||||
|
console.log('═══════════════════════════════════════════════');
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
if (!r.file.endsWith('.tsx')) continue;
|
||||||
|
// Find useEffect with no dependency array (runs every render)
|
||||||
|
const noDeps = (r.content.match(/useEffect\s*\(\s*\(\)\s*=>\s*\{[\s\S]*?\}\s*\)/g) || []).length;
|
||||||
|
// Find useEffect with empty deps
|
||||||
|
const emptyDeps = (r.content.match(/useEffect\s*\(\s*\(\)\s*=>\s*\{[\s\S]*?\}\s*,\s*\[\s*\]\s*\)/g) || []).length;
|
||||||
|
|
||||||
|
if (noDeps > 0) {
|
||||||
|
console.log(` [RENDER-COST] ${r.file}: ${noDeps} useEffect(s) run EVERY render`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 4. Zustand Store Analysis ───
|
||||||
|
console.log('\n═══════════════════════════════════════════════');
|
||||||
|
console.log(' 4. ZUSTAND STORE ANALYSIS');
|
||||||
|
console.log('═══════════════════════════════════════════════');
|
||||||
|
|
||||||
|
const storeFiles = results.filter(r => r.file.includes('store') || r.file.includes('Store'));
|
||||||
|
for (const r of storeFiles) {
|
||||||
|
const stateFields = (r.content.match(/^\s+\w+:/gm) || []).length;
|
||||||
|
const actions = (r.content.match(/^\s+\w+\s*:\s*(\(|function|\w+\s*=>)/gm) || []).length;
|
||||||
|
const subscribers = (r.content.match(/subscribe\s*\(/g) || []).length;
|
||||||
|
|
||||||
|
console.log(`\n ${r.file}`);
|
||||||
|
console.log(` State fields: ~${stateFields}`);
|
||||||
|
console.log(` Actions: ~${actions}`);
|
||||||
|
console.log(` Subscribers: ${subscribers}`);
|
||||||
|
|
||||||
|
// Check for selector usage patterns
|
||||||
|
if (r.content.includes('set(') && !r.content.includes('useShallow')) {
|
||||||
|
console.log(` ⚠️ Uses set() without useShallow — may cause unnecessary re-renders`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 5. Bundle Composition Analysis ───
|
||||||
|
console.log('\n═══════════════════════════════════════════════');
|
||||||
|
console.log(' 5. BUNDLE COMPOSITION ANALYSIS');
|
||||||
|
console.log('═══════════════════════════════════════════════');
|
||||||
|
|
||||||
|
if (readdirSync(DIST).includes('assets')) {
|
||||||
|
const assets = readdirSync(join(DIST, 'assets'));
|
||||||
|
const jsFiles = assets.filter(f => f.endsWith('.js') && !f.endsWith('.br'));
|
||||||
|
const cssFiles = assets.filter(f => f.endsWith('.css') && !f.endsWith('.br'));
|
||||||
|
|
||||||
|
let totalJs = 0, totalCss = 0;
|
||||||
|
const chunks = [];
|
||||||
|
for (const f of jsFiles) {
|
||||||
|
const size = statSync(join(DIST, 'assets', f)).size;
|
||||||
|
totalJs += size;
|
||||||
|
chunks.push({ name: f, sizeKB: size / 1024, type: 'js' });
|
||||||
|
}
|
||||||
|
for (const f of cssFiles) {
|
||||||
|
const size = statSync(join(DIST, 'assets', f)).size;
|
||||||
|
totalCss += size;
|
||||||
|
chunks.push({ name: f, sizeKB: size / 1024, type: 'css' });
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.sort((a, b) => b.sizeKB - a.sizeKB);
|
||||||
|
|
||||||
|
console.log(`\n Total JS: ${(totalJs / 1024).toFixed(1)} KB (${(totalJs / 1024 / 1024).toFixed(2)} MB)`);
|
||||||
|
console.log(` Total CSS: ${(totalCss / 1024).toFixed(1)} KB (${(totalCss / 1024 / 1024).toFixed(2)} MB)`);
|
||||||
|
console.log(` Total: ${((totalJs + totalCss) / 1024).toFixed(1)} KB`);
|
||||||
|
|
||||||
|
// CSS is suspiciously large
|
||||||
|
const cssRatio = totalCss / totalJs;
|
||||||
|
if (cssRatio > 0.5) {
|
||||||
|
console.log(`\n ⚠️ CSS/JS ratio: ${(cssRatio * 100).toFixed(0)}% — CSS bundle may contain unused styles`);
|
||||||
|
console.log(` Consider: PurgeCSS, CSS Modules, or extracting to separate loads`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check initial load budget
|
||||||
|
const initialChunks = chunks.filter(c =>
|
||||||
|
c.name.includes('index') || c.name.includes('vendor-react') || c.name.includes('vendor-antd')
|
||||||
|
);
|
||||||
|
const initialLoad = initialChunks.reduce((s, c) => s + c.sizeKB, 0);
|
||||||
|
console.log(`\n Critical path (initial load):`);
|
||||||
|
for (const c of initialChunks) {
|
||||||
|
console.log(` ${c.name.padEnd(45)} ${c.sizeKB.toFixed(1).padStart(8)} KB`);
|
||||||
|
}
|
||||||
|
console.log(` ${'TOTAL'.padEnd(45)} ${initialLoad.toFixed(1).padStart(8)} KB`);
|
||||||
|
|
||||||
|
if (initialLoad > 300) {
|
||||||
|
console.log(`\n ⚠️ Initial load > 300KB — consider code splitting or tree-shaking`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 6. API Client Efficiency ───
|
||||||
|
console.log('\n═══════════════════════════════════════════════');
|
||||||
|
console.log(' 6. API CLIENT EFFICIENCY');
|
||||||
|
console.log('═══════════════════════════════════════════════');
|
||||||
|
|
||||||
|
const apiFiles = results.filter(r => r.file.includes('src/api/'));
|
||||||
|
for (const r of apiFiles) {
|
||||||
|
const fetchCalls = (r.content.match(/fetch\s*\(/g) || []).length;
|
||||||
|
const retries = (r.content.match(/retry|Retry|RETRY/g) || []).length;
|
||||||
|
const abortSignals = (r.content.match(/AbortSignal|AbortController/g) || []).length;
|
||||||
|
const timeouts = (r.content.match(/timeout|Timeout|setTimeout/g) || []).length;
|
||||||
|
|
||||||
|
if (fetchCalls > 0) {
|
||||||
|
console.log(`\n ${r.file}`);
|
||||||
|
console.log(` fetch calls: ${fetchCalls}`);
|
||||||
|
console.log(` retry logic: ${retries > 0 ? '✅' : '❌ none'}`);
|
||||||
|
console.log(` abort signals: ${abortSignals > 0 ? '✅' : '❌ none'}`);
|
||||||
|
console.log(` timeouts: ${timeouts}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 7. TypeScript Compilation Metrics ───
|
||||||
|
console.log('\n═══════════════════════════════════════════════');
|
||||||
|
console.log(' 7. TYPE COMPLEXITY');
|
||||||
|
console.log('═══════════════════════════════════════════════');
|
||||||
|
|
||||||
|
let totalTypes = 0, totalInterfaces = 0, totalEnums = 0, totalGenerics = 0;
|
||||||
|
for (const r of results) {
|
||||||
|
totalTypes += (r.content.match(/^export\s+type\s+/gm) || []).length;
|
||||||
|
totalTypes += (r.content.match(/^type\s+/gm) || []).length;
|
||||||
|
totalInterfaces += (r.content.match(/^export\s+interface\s+/gm) || []).length;
|
||||||
|
totalInterfaces += (r.content.match(/^interface\s+/gm) || []).length;
|
||||||
|
totalEnums += (r.content.match(/enum\s+\w+/g) || []).length;
|
||||||
|
totalGenerics += (r.content.match(/<\w+(?:\s*,\s*\w+)*>/g) || []).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Type aliases: ${totalTypes}`);
|
||||||
|
console.log(` Interfaces: ${totalInterfaces}`);
|
||||||
|
console.log(` Enums: ${totalEnums}`);
|
||||||
|
console.log(` Generic usages: ${totalGenerics}`);
|
||||||
|
console.log(` Total TS files: ${results.length}`);
|
||||||
|
|
||||||
|
// ─── Final Summary ───
|
||||||
|
console.log('\n═══════════════════════════════════════════════');
|
||||||
|
console.log(' ANALYSIS COMPLETE');
|
||||||
|
console.log('═══════════════════════════════════════════════');
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
/**
|
||||||
|
* Dynamic performance analysis using Playwright.
|
||||||
|
* Measures: page load, bundle sizes, memory, rendering, network.
|
||||||
|
*/
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { readFileSync, readdirSync, statSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
const DIST = join(import.meta.dirname, '..', 'dist');
|
||||||
|
const PORT = 4174;
|
||||||
|
|
||||||
|
// ─── Bundle analysis from dist ───
|
||||||
|
function analyzeBundles() {
|
||||||
|
const assets = readdirSync(join(DIST, 'assets'));
|
||||||
|
const jsFiles = assets.filter(f => f.endsWith('.js'));
|
||||||
|
const cssFiles = assets.filter(f => f.endsWith('.css'));
|
||||||
|
const brFiles = assets.filter(f => f.endsWith('.js.br'));
|
||||||
|
|
||||||
|
let totalJsSize = 0, totalCssSize = 0, totalBrSize = 0;
|
||||||
|
const bundles = [];
|
||||||
|
|
||||||
|
for (const f of jsFiles) {
|
||||||
|
const size = statSync(join(DIST, 'assets', f)).size;
|
||||||
|
totalJsSize += size;
|
||||||
|
bundles.push({ name: f, sizeKB: (size / 1024).toFixed(2), type: 'js' });
|
||||||
|
}
|
||||||
|
for (const f of cssFiles) {
|
||||||
|
const size = statSync(join(DIST, 'assets', f)).size;
|
||||||
|
totalCssSize += size;
|
||||||
|
bundles.push({ name: f, sizeKB: (size / 1024).toFixed(2), type: 'css' });
|
||||||
|
}
|
||||||
|
for (const f of brFiles) {
|
||||||
|
const size = statSync(join(DIST, 'assets', f)).size;
|
||||||
|
totalBrSize += size;
|
||||||
|
}
|
||||||
|
|
||||||
|
bundles.sort((a, b) => parseFloat(b.sizeKB) - parseFloat(a.sizeKB));
|
||||||
|
|
||||||
|
console.log('\n═══════════════════════════════════════════════');
|
||||||
|
console.log(' BUNDLE SIZE ANALYSIS');
|
||||||
|
console.log('═══════════════════════════════════════════════');
|
||||||
|
console.log(`\nTotal JS (raw): ${(totalJsSize / 1024).toFixed(1)} KB`);
|
||||||
|
console.log(`Total CSS (raw): ${(totalCssSize / 1024).toFixed(1)} KB`);
|
||||||
|
console.log(`Total JS (brotli): ${(totalBrSize / 1024).toFixed(1)} KB`);
|
||||||
|
console.log(`\nTop 15 bundles by raw size:`);
|
||||||
|
for (const b of bundles.slice(0, 15)) {
|
||||||
|
const bar = '█'.repeat(Math.min(40, Math.round(parseFloat(b.sizeKB) / 5)));
|
||||||
|
console.log(` ${b.name.padEnd(45)} ${String(b.sizeKB).padStart(8)} KB ${bar}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify oversized chunks
|
||||||
|
console.log('\n⚠️ Oversized chunks (>50KB raw):');
|
||||||
|
for (const b of bundles.filter(b => parseFloat(b.sizeKB) > 50)) {
|
||||||
|
console.log(` [WARN] ${b.name} = ${b.sizeKB} KB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { totalJsSize, totalCssSize, totalBrSize, bundles };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Runtime performance with Playwright ───
|
||||||
|
async function runtimeAnalysis() {
|
||||||
|
console.log('\n═══════════════════════════════════════════════');
|
||||||
|
console.log(' RUNTIME PERFORMANCE ANALYSIS');
|
||||||
|
console.log('═══════════════════════════════════════════════');
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
// Collect performance metrics
|
||||||
|
const perfEntries = [];
|
||||||
|
page.on('console', msg => {
|
||||||
|
if (msg.type() === 'info' && msg.text().startsWith('[PERF]')) {
|
||||||
|
perfEntries.push(msg.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track network requests
|
||||||
|
const networkRequests = [];
|
||||||
|
page.on('request', req => {
|
||||||
|
networkRequests.push({
|
||||||
|
url: req.url(),
|
||||||
|
method: req.method(),
|
||||||
|
startTime: Date.now()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const networkResponses = [];
|
||||||
|
page.on('response', res => {
|
||||||
|
networkResponses.push({
|
||||||
|
url: res.url(),
|
||||||
|
status: res.status(),
|
||||||
|
size: res.headers()['content-length'] || '0',
|
||||||
|
endTime: Date.now()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Measure page load
|
||||||
|
console.log('\n--- Page Load Performance ---');
|
||||||
|
const navStart = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await page.goto(`http://127.0.0.1:${PORT}/`, {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
const loadTime = Date.now() - navStart;
|
||||||
|
console.log(` Initial page load: ${loadTime}ms`);
|
||||||
|
console.log(` HTTP status: ${response.status()}`);
|
||||||
|
|
||||||
|
// Get navigation timing from the browser
|
||||||
|
const navTiming = await page.evaluate(() => {
|
||||||
|
const perf = performance.getEntriesByType('navigation')[0];
|
||||||
|
if (!perf) return null;
|
||||||
|
return {
|
||||||
|
dns: Math.round(perf.domainLookupEnd - perf.domainLookupStart),
|
||||||
|
tcp: Math.round(perf.connectEnd - perf.connectStart),
|
||||||
|
ttfb: Math.round(perf.responseStart - perf.requestStart),
|
||||||
|
download: Math.round(perf.responseEnd - perf.responseStart),
|
||||||
|
domInteractive: Math.round(perf.domInteractive),
|
||||||
|
domComplete: Math.round(perf.domComplete),
|
||||||
|
loadEvent: Math.round(perf.loadEventEnd),
|
||||||
|
transferSize: perf.transferSize,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (navTiming) {
|
||||||
|
console.log(`\n Navigation Timing:`);
|
||||||
|
console.log(` DNS lookup: ${navTiming.dns}ms`);
|
||||||
|
console.log(` TCP connect: ${navTiming.tcp}ms`);
|
||||||
|
console.log(` TTFB: ${navTiming.ttfb}ms`);
|
||||||
|
console.log(` Download: ${navTiming.download}ms`);
|
||||||
|
console.log(` DOM interactive: ${navTiming.domInteractive}ms`);
|
||||||
|
console.log(` DOM complete: ${navTiming.domComplete}ms`);
|
||||||
|
console.log(` Load event end: ${navTiming.loadEvent}ms`);
|
||||||
|
console.log(` Transfer size: ${(navTiming.transferSize / 1024).toFixed(1)} KB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource timing
|
||||||
|
const resources = await page.evaluate(() => {
|
||||||
|
return performance.getEntriesByType('resource').map(r => ({
|
||||||
|
name: r.name.split('/').pop(),
|
||||||
|
type: r.initiatorType,
|
||||||
|
duration: Math.round(r.duration),
|
||||||
|
size: r.transferSize,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n--- Resource Loading ---`);
|
||||||
|
console.log(` Total resources: ${resources.length}`);
|
||||||
|
const totalTransfer = resources.reduce((s, r) => s + r.size, 0);
|
||||||
|
console.log(` Total transfer: ${(totalTransfer / 1024).toFixed(1)} KB`);
|
||||||
|
|
||||||
|
const slowResources = resources.filter(r => r.duration > 100).sort((a, b) => b.duration - a.duration);
|
||||||
|
if (slowResources.length > 0) {
|
||||||
|
console.log(`\n Slow resources (>100ms):`);
|
||||||
|
for (const r of slowResources.slice(0, 10)) {
|
||||||
|
console.log(` [SLOW] ${r.name.padEnd(40)} ${r.duration}ms (${(r.size/1024).toFixed(1)}KB)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory analysis
|
||||||
|
console.log(`\n--- Memory Analysis ---`);
|
||||||
|
const memory = await page.evaluate(() => {
|
||||||
|
if (performance.memory) {
|
||||||
|
return {
|
||||||
|
usedJSHeap: performance.memory.usedJSHeapSize,
|
||||||
|
totalJSHeap: performance.memory.totalJSHeapSize,
|
||||||
|
heapLimit: performance.memory.jsHeapSizeLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (memory) {
|
||||||
|
console.log(` Used JS heap: ${(memory.usedJSHeap / 1024 / 1024).toFixed(1)} MB`);
|
||||||
|
console.log(` Total JS heap: ${(memory.totalJSHeap / 1024 / 1024).toFixed(1)} MB`);
|
||||||
|
console.log(` Heap limit: ${(memory.heapLimit / 1024 / 1024).toFixed(1)} MB`);
|
||||||
|
console.log(` Heap utilization: ${((memory.usedJSHeap / memory.heapLimit) * 100).toFixed(1)}%`);
|
||||||
|
} else {
|
||||||
|
console.log(' Memory API not available (Chromium flag needed: --enable-precise-memory-info)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM complexity
|
||||||
|
console.log(`\n--- DOM Complexity ---`);
|
||||||
|
const domStats = await page.evaluate(() => {
|
||||||
|
const allElements = document.querySelectorAll('*');
|
||||||
|
const tagCounts = {};
|
||||||
|
let maxDepth = 0;
|
||||||
|
let totalNodes = allElements.length;
|
||||||
|
|
||||||
|
allElements.forEach(el => {
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
||||||
|
let depth = 0, parent = el.parentElement;
|
||||||
|
while (parent) { depth++; parent = parent.parentElement; }
|
||||||
|
if (depth > maxDepth) maxDepth = depth;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { totalNodes, maxDepth, tagCounts };
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` Total DOM nodes: ${domStats.totalNodes}`);
|
||||||
|
console.log(` Max DOM depth: ${domStats.maxDepth}`);
|
||||||
|
console.log(` Top 10 tags:`);
|
||||||
|
const sortedTags = Object.entries(domStats.tagCounts).sort((a, b) => b[1] - a[1]);
|
||||||
|
for (const [tag, count] of sortedTags.slice(0, 10)) {
|
||||||
|
console.log(` <${tag}>: ${count}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM warnings
|
||||||
|
if (domStats.totalNodes > 1500) {
|
||||||
|
console.log(` ⚠️ DOM nodes > 1500 — may cause sluggish rendering`);
|
||||||
|
}
|
||||||
|
if (domStats.maxDepth > 15) {
|
||||||
|
console.log(` ⚠️ DOM depth > 15 — may slow style/layout calculations`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// React-specific analysis: check for unnecessary re-renders
|
||||||
|
console.log(`\n--- Render Performance ---`);
|
||||||
|
const renderMetrics = await page.evaluate(() => {
|
||||||
|
// Check if React DevTools hook exists
|
||||||
|
const hasReact = !!window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||||
|
return {
|
||||||
|
hasReact,
|
||||||
|
eventListeners: typeof getEventListeners !== 'undefined' ? 'available' : 'not-in-page-context',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log(` React DevTools: ${renderMetrics.hasReact ? 'detected' : 'not detected'}`);
|
||||||
|
|
||||||
|
// Measure interaction performance - simulate scroll
|
||||||
|
console.log(`\n--- Interaction Performance ---`);
|
||||||
|
const scrollStart = Date.now();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const container = document.querySelector('.app-shell-content') || document.documentElement;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
container.scrollTop = i * 100;
|
||||||
|
}
|
||||||
|
container.scrollTop = 0;
|
||||||
|
});
|
||||||
|
const scrollTime = Date.now() - scrollStart;
|
||||||
|
console.log(` 10x scroll ops: ${scrollTime}ms`);
|
||||||
|
|
||||||
|
// Navigate to different routes to test lazy loading
|
||||||
|
const routes = ['#/', '#/canvas', '#/workbench', '#/ecommerce', '#/image-workbench', '#/home'];
|
||||||
|
console.log(`\n--- Route Navigation (Lazy Loading) ---`);
|
||||||
|
for (const route of routes) {
|
||||||
|
const routeStart = Date.now();
|
||||||
|
await page.goto(`http://127.0.0.1:${PORT}/${route}`, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||||
|
const routeTime = Date.now() - routeStart;
|
||||||
|
console.log(` ${route.padEnd(30)} ${routeTime}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory after navigation
|
||||||
|
const memoryAfter = await page.evaluate(() => {
|
||||||
|
if (performance.memory) {
|
||||||
|
return {
|
||||||
|
usedJSHeap: performance.memory.usedJSHeapSize,
|
||||||
|
totalJSHeap: performance.memory.totalJSHeapSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (memoryAfter) {
|
||||||
|
console.log(`\n--- Memory After Route Navigation ---`);
|
||||||
|
console.log(` Used JS heap: ${(memoryAfter.usedJSHeap / 1024 / 1024).toFixed(1)} MB`);
|
||||||
|
console.log(` Total JS heap: ${(memoryAfter.totalJSHeap / 1024 / 1024).toFixed(1)} MB`);
|
||||||
|
if (memory) {
|
||||||
|
const delta = memoryAfter.usedJSHeap - memory.usedJSHeap;
|
||||||
|
console.log(` Heap delta: ${(delta > 0 ? '+' : '')}${(delta / 1024 / 1024).toFixed(1)} MB`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network summary
|
||||||
|
console.log(`\n--- Network Summary ---`);
|
||||||
|
console.log(` Total requests: ${networkResponses.length}`);
|
||||||
|
const totalNetworkSize = networkResponses.reduce((s, r) => s + parseInt(r.size || '0'), 0);
|
||||||
|
console.log(` Total response: ${(totalNetworkSize / 1024).toFixed(1)} KB`);
|
||||||
|
const failedRequests = networkResponses.filter(r => r.status >= 400);
|
||||||
|
if (failedRequests.length > 0) {
|
||||||
|
console.log(` Failed requests: ${failedRequests.length}`);
|
||||||
|
for (const r of failedRequests) {
|
||||||
|
console.log(` [${r.status}] ${r.url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`\n ❌ Error during runtime analysis: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ───
|
||||||
|
console.log('╔═══════════════════════════════════════════════╗');
|
||||||
|
console.log('║ OmniAI Web Preview - Performance Analysis ║');
|
||||||
|
console.log('╚═══════════════════════════════════════════════╝');
|
||||||
|
|
||||||
|
const bundleResult = analyzeBundles();
|
||||||
|
await runtimeAnalysis();
|
||||||
|
|
||||||
|
console.log('\n═══════════════════════════════════════════════');
|
||||||
|
console.log(' ANALYSIS COMPLETE');
|
||||||
|
console.log('═══════════════════════════════════════════════');
|
||||||
@@ -42,9 +42,9 @@ assertNoMatch(
|
|||||||
/dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i,
|
/dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i,
|
||||||
);
|
);
|
||||||
assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/);
|
assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/);
|
||||||
assertMatch("video generation must go through the app API", generationClient, /buildApiUrl\("ai\/video"\)/);
|
assertMatch("video generation must go through the app API", generationClient, /serverRequest<\{ taskId: string \}>\("ai\/video"/);
|
||||||
assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/);
|
assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/);
|
||||||
assertMatch("URL uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-by-url"\)/);
|
assertMatch("URL uploads must go through the app OSS API", generationClient, /serverRequest<\{ url: string; signedUrl\?: string; ossKey\?: string \}>\("oss\/upload-by-url"/);
|
||||||
assertMatch(
|
assertMatch(
|
||||||
"ecommerce video history must durable-copy media before saving",
|
"ecommerce video history must durable-copy media before saving",
|
||||||
ecommerceVideoService,
|
ecommerceVideoService,
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { readdirSync, readFileSync, statSync } from 'fs';
|
||||||
|
import { join, relative } from 'path';
|
||||||
|
|
||||||
|
const SRC = join(import.meta.dirname, '..', 'src');
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory() && entry.name !== 'node_modules') walk(full);
|
||||||
|
else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
|
||||||
|
const content = readFileSync(full, 'utf-8');
|
||||||
|
const lines = content.split('\n').length;
|
||||||
|
results.push({ file: relative(join(SRC, '..'), full), lines, content });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(SRC);
|
||||||
|
results.sort((a, b) => b.lines - a.lines);
|
||||||
|
|
||||||
|
console.log('=== TOP 30 FILES BY LINE COUNT ===');
|
||||||
|
for (const r of results.slice(0, 30)) {
|
||||||
|
console.log(`${String(r.lines).padStart(5)} ${r.file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect nested loops (3+ levels)
|
||||||
|
console.log('\n=== NESTED LOOP DETECTION (3+ levels) ===');
|
||||||
|
const loopPatterns = [
|
||||||
|
/for\s*\(/g, /while\s*\(/g, /\.forEach\s*\(/g, /\.map\s*\(/g,
|
||||||
|
/\.filter\s*\(/g, /\.reduce\s*\(/g, /\.some\s*\(/g, /\.every\s*\(/g,
|
||||||
|
/\.flatMap\s*\(/g, /\.find\s*\(/g
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
const lines = r.content.split('\n');
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
let loopCount = 0;
|
||||||
|
for (const p of loopPatterns) {
|
||||||
|
p.lastIndex = 0;
|
||||||
|
if (p.test(line)) loopCount++;
|
||||||
|
}
|
||||||
|
// Check surrounding context for nesting
|
||||||
|
if (loopCount > 0 || /for\s*\(/.test(line) || /\.map\(/.test(line) || /\.forEach\(/.test(line) || /\.filter\(/.test(line) || /\.reduce\(/.test(line)) {
|
||||||
|
// Count loop keywords on this single line
|
||||||
|
let singleLineLoops = 0;
|
||||||
|
for (const p of loopPatterns) {
|
||||||
|
p.lastIndex = 0;
|
||||||
|
const matches = line.match(new RegExp(p.source, 'g'));
|
||||||
|
if (matches) singleLineLoops += matches.length;
|
||||||
|
}
|
||||||
|
if (singleLineLoops >= 2) {
|
||||||
|
console.log(` [NESTED] ${r.file}:${i + 1} (${singleLineLoops} loops on one line)`);
|
||||||
|
console.log(` ${line.trim().substring(0, 120)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect missing cleanup in useEffect
|
||||||
|
console.log('\n=== MEMORY LEAK RISK: useEffect without cleanup ===');
|
||||||
|
for (const r of results) {
|
||||||
|
if (!r.file.endsWith('.tsx') && !r.file.endsWith('.ts')) continue;
|
||||||
|
const content = r.content;
|
||||||
|
// Find useEffect blocks that contain setInterval/setTimeout/addEventListener but no return
|
||||||
|
const useEffectRegex = /useEffect\s*\(\s*\(\)\s*=>\s*\{([\s\S]*?)\}\s*,/g;
|
||||||
|
let match;
|
||||||
|
while ((match = useEffectRegex.exec(content)) !== null) {
|
||||||
|
const body = match[1];
|
||||||
|
const hasTimer = /setInterval|setTimeout/.test(body);
|
||||||
|
const hasListener = /addEventListener/.test(body);
|
||||||
|
const hasSubscribe = /\.subscribe\(/.test(body);
|
||||||
|
const hasCleanup = /return\s*\(\)\s*=>|return\s*function|return\s*\(\{/.test(body);
|
||||||
|
|
||||||
|
if ((hasTimer || hasListener || hasSubscribe) && !hasCleanup) {
|
||||||
|
const lineNum = content.substring(0, match.index).split('\n').length;
|
||||||
|
console.log(` [RISK] ${r.file}:${lineNum}`);
|
||||||
|
if (hasTimer) console.log(` - Has setInterval/setTimeout without cleanup`);
|
||||||
|
if (hasListener) console.log(` - Has addEventListener without cleanup`);
|
||||||
|
if (hasSubscribe) console.log(` - Has subscribe without cleanup`);
|
||||||
|
console.log(` ${body.trim().substring(0, 200)}`);
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect objects/arrays/functions created in render body (not memoized)
|
||||||
|
console.log('\n=== REDUNDANT COMPUTATION: Non-memoized values in components ===');
|
||||||
|
for (const r of results) {
|
||||||
|
if (!r.file.endsWith('.tsx')) continue;
|
||||||
|
const lines = r.content.split('\n');
|
||||||
|
// Look for const x = [...], const x = {...}, const x = (...) => patterns outside useMemo
|
||||||
|
let inUseMemo = 0;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (/useMemo\s*\(/.test(lines[i])) inUseMemo++;
|
||||||
|
if (inUseMemo > 0 && /\)/.test(lines[i])) {
|
||||||
|
// Rough heuristic - not perfect
|
||||||
|
}
|
||||||
|
if (inUseMemo === 0) {
|
||||||
|
// Expensive operations in render
|
||||||
|
if (/\.map\s*\(.*\.map\s*\(/.test(lines[i])) {
|
||||||
|
console.log(` [PERF] ${r.file}:${i + 1} - Chained .map calls in render`);
|
||||||
|
console.log(` ${lines[i].trim().substring(0, 120)}`);
|
||||||
|
}
|
||||||
|
if (/\.filter\s*\(.*\.map\s*\(/.test(lines[i]) || /\.map\s*\(.*\.filter\s*\(/.test(lines[i])) {
|
||||||
|
console.log(` [PERF] ${r.file}:${i + 1} - filter+map chain in render`);
|
||||||
|
console.log(` ${lines[i].trim().substring(0, 120)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect deeply nested conditionals (4+ levels)
|
||||||
|
console.log('\n=== HIGH COMPLEXITY: Deep nesting ===');
|
||||||
|
for (const r of results) {
|
||||||
|
const lines = r.content.split('\n');
|
||||||
|
let maxIndent = 0;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.trim() === '') continue;
|
||||||
|
const indent = line.match(/^(\s*)/)[1].length;
|
||||||
|
// Only flag if inside if/else/switch/ternary
|
||||||
|
if (indent >= 16 && /if\s*\(|else|switch\s*\(|case\s+/.test(line.trim())) {
|
||||||
|
console.log(` [DEEP] ${r.file}:${i + 1} (indent=${indent})`);
|
||||||
|
console.log(` ${line.trim().substring(0, 120)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect inline style objects in JSX (recreated every render)
|
||||||
|
console.log('\n=== REDUNDANT: Inline style objects in JSX ===');
|
||||||
|
for (const r of results) {
|
||||||
|
if (!r.file.endsWith('.tsx')) continue;
|
||||||
|
const lines = r.content.split('\n');
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (/style\s*=\s*\{\s*\{/.test(lines[i]) && !/useMemo/.test(lines[i])) {
|
||||||
|
console.log(` [INLINE] ${r.file}:${i + 1}`);
|
||||||
|
console.log(` ${lines[i].trim().substring(0, 120)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total stats
|
||||||
|
console.log('\n=== SUMMARY ===');
|
||||||
|
console.log(`Total files: ${results.length}`);
|
||||||
|
console.log(`Total lines: ${results.reduce((s, r) => s + r.lines, 0)}`);
|
||||||
|
console.log(`Files > 500 lines: ${results.filter(r => r.lines > 500).length}`);
|
||||||
|
console.log(`Files > 1000 lines: ${results.filter(r => r.lines > 1000).length}`);
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
import {
|
|
||||||
BarChartOutlined,
|
|
||||||
BranchesOutlined,
|
|
||||||
CustomerServiceOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
FolderOpenOutlined,
|
|
||||||
GlobalOutlined,
|
|
||||||
HeartOutlined,
|
|
||||||
HomeOutlined,
|
|
||||||
LayoutOutlined,
|
|
||||||
RobotOutlined,
|
|
||||||
ShoppingOutlined,
|
|
||||||
SwapOutlined,
|
|
||||||
ToolOutlined,
|
|
||||||
WalletOutlined,
|
|
||||||
} from "@ant-design/icons";
|
|
||||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { reportError } from "./utils/errorReporting";
|
import { reportError } from "./utils/errorReporting";
|
||||||
import { initNotificationPermission } from "./utils/generationNotifier";
|
import { initNotificationPermission } from "./utils/generationNotifier";
|
||||||
@@ -36,6 +21,7 @@ import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGene
|
|||||||
import { translateTaskError } from "./utils/translateTaskError";
|
import { translateTaskError } from "./utils/translateTaskError";
|
||||||
import { recoverAndResumeTasks } from "./services/backgroundTaskRunner";
|
import { recoverAndResumeTasks } from "./services/backgroundTaskRunner";
|
||||||
import AppShell from "./components/AppShell";
|
import AppShell from "./components/AppShell";
|
||||||
|
import { ShellIcon } from "./components/ShellIcon";
|
||||||
const NotFoundPage = lazy(() => import("./components/NotFoundPage"));
|
const NotFoundPage = lazy(() => import("./components/NotFoundPage"));
|
||||||
const CompliancePage = lazy(() => import("./features/compliance/CompliancePage"));
|
const CompliancePage = lazy(() => import("./features/compliance/CompliancePage"));
|
||||||
import { cloneWorkflow, createBlankWorkflow } from "./data/workflows";
|
import { cloneWorkflow, createBlankWorkflow } from "./data/workflows";
|
||||||
@@ -50,6 +36,7 @@ const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarCons
|
|||||||
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
|
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
|
||||||
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
|
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
|
||||||
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
|
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
|
||||||
|
const EcommerceTemplatesPage = lazy(() => import("./features/ecommerce/EcommerceTemplatesPage"));
|
||||||
const HomePage = lazy(() => import("./features/home/HomePage"));
|
const HomePage = lazy(() => import("./features/home/HomePage"));
|
||||||
const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage"));
|
const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage"));
|
||||||
const MorePage = lazy(() => import("./features/more/MorePage"));
|
const MorePage = lazy(() => import("./features/more/MorePage"));
|
||||||
@@ -60,6 +47,7 @@ const ResolutionUpscalePage = lazy(() => import("./features/resolution-upscale/R
|
|||||||
const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/WatermarkRemovalPage"));
|
const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/WatermarkRemovalPage"));
|
||||||
const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage"));
|
const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage"));
|
||||||
const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage"));
|
const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage"));
|
||||||
|
const SizeTemplatePage = lazy(() => import("./features/size-template/SizeTemplatePage"));
|
||||||
const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage"));
|
const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage"));
|
||||||
const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage"));
|
const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage"));
|
||||||
import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage";
|
import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage";
|
||||||
@@ -105,6 +93,8 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
|||||||
"assets",
|
"assets",
|
||||||
"ecommerceHub",
|
"ecommerceHub",
|
||||||
"ecommerce",
|
"ecommerce",
|
||||||
|
"ecommerceTemplates",
|
||||||
|
"sizeTemplate",
|
||||||
"scriptTokens",
|
"scriptTokens",
|
||||||
"tokenUsage",
|
"tokenUsage",
|
||||||
"imageWorkbench",
|
"imageWorkbench",
|
||||||
@@ -126,6 +116,29 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]);
|
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]);
|
||||||
|
const LEGACY_PAGE_STYLE_VIEWS = new Set<WebViewKey>([
|
||||||
|
"login",
|
||||||
|
"workbench",
|
||||||
|
"canvas",
|
||||||
|
"community",
|
||||||
|
"communityReview",
|
||||||
|
"communityCaseAdd",
|
||||||
|
"assets",
|
||||||
|
"ecommerce",
|
||||||
|
"ecommerceHub",
|
||||||
|
"ecommerceTemplates",
|
||||||
|
"sizeTemplate",
|
||||||
|
"digitalHuman",
|
||||||
|
"characterMix",
|
||||||
|
"more",
|
||||||
|
]);
|
||||||
|
|
||||||
|
let legacyPageStylesPromise: Promise<unknown> | null = null;
|
||||||
|
|
||||||
|
function loadLegacyPageStyles(): Promise<unknown> {
|
||||||
|
legacyPageStylesPromise ??= import("./styles/pages/legacy-pages.css");
|
||||||
|
return legacyPageStylesPromise;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeViewKey(rawView: string): WebViewKey {
|
function normalizeViewKey(rawView: string): WebViewKey {
|
||||||
const normalized =
|
const normalized =
|
||||||
@@ -133,6 +146,8 @@ function normalizeViewKey(rawView: string): WebViewKey {
|
|||||||
? "login"
|
? "login"
|
||||||
: rawView === "ecommerceHub"
|
: rawView === "ecommerceHub"
|
||||||
? "ecommerce"
|
? "ecommerce"
|
||||||
|
: rawView === "bug-feedback" || rawView === "feedback"
|
||||||
|
? "report"
|
||||||
: rawView === "terms" || rawView === "agreement" || rawView === "user-agreement"
|
: rawView === "terms" || rawView === "agreement" || rawView === "user-agreement"
|
||||||
? "userAgreement"
|
? "userAgreement"
|
||||||
: rawView === "privacy" || rawView === "privacy-policy"
|
: rawView === "privacy" || rawView === "privacy-policy"
|
||||||
@@ -233,62 +248,124 @@ function App() {
|
|||||||
const canvasAutoOpenedRecentRef = useRef(false);
|
const canvasAutoOpenedRecentRef = useRef(false);
|
||||||
|
|
||||||
// Session store
|
// Session store
|
||||||
const session = useSessionStore((s) => s.session);
|
const {
|
||||||
const loginPromptOpen = useSessionStore((s) => s.loginPromptOpen);
|
session,
|
||||||
const pendingAction = useSessionStore((s) => s.pendingAction);
|
loginPromptOpen,
|
||||||
const sessionReplacedOpen = useSessionStore((s) => s.sessionReplacedOpen);
|
pendingAction,
|
||||||
const sessionReplacedMessage = useSessionStore((s) => s.sessionReplacedMessage);
|
sessionReplacedOpen,
|
||||||
const setSession = useSessionStore((s) => s.setSession);
|
sessionReplacedMessage,
|
||||||
const openLoginPrompt = useSessionStore((s) => s.openLoginPrompt);
|
setSession,
|
||||||
const closeLoginPrompt = useSessionStore((s) => s.closeLoginPrompt);
|
openLoginPrompt,
|
||||||
const showSessionReplaced = useSessionStore((s) => s.showSessionReplaced);
|
closeLoginPrompt,
|
||||||
const hideSessionReplaced = useSessionStore((s) => s.hideSessionReplaced);
|
showSessionReplaced,
|
||||||
const clearSessionState = useSessionStore((s) => s.clearSession);
|
hideSessionReplaced,
|
||||||
|
clearSession: clearSessionState,
|
||||||
|
} = useSessionStore(useShallow((s) => ({
|
||||||
|
session: s.session,
|
||||||
|
loginPromptOpen: s.loginPromptOpen,
|
||||||
|
pendingAction: s.pendingAction,
|
||||||
|
sessionReplacedOpen: s.sessionReplacedOpen,
|
||||||
|
sessionReplacedMessage: s.sessionReplacedMessage,
|
||||||
|
setSession: s.setSession,
|
||||||
|
openLoginPrompt: s.openLoginPrompt,
|
||||||
|
closeLoginPrompt: s.closeLoginPrompt,
|
||||||
|
showSessionReplaced: s.showSessionReplaced,
|
||||||
|
hideSessionReplaced: s.hideSessionReplaced,
|
||||||
|
clearSession: s.clearSession,
|
||||||
|
})));
|
||||||
|
|
||||||
// Project store
|
// Project store
|
||||||
const projects = useProjectStore((s) => s.projects);
|
const {
|
||||||
const projectsLoaded = useProjectStore((s) => s.projectsLoaded);
|
projects,
|
||||||
const canvasWorkflow = useProjectStore((s) => s.canvasWorkflow);
|
projectsLoaded,
|
||||||
const currentCanvasProjectId = useProjectStore((s) => s.currentCanvasProjectId);
|
canvasWorkflow,
|
||||||
const pendingDeleteProject = useProjectStore((s) => s.pendingDeleteProject);
|
currentCanvasProjectId,
|
||||||
const deleteProjectSubmitting = useProjectStore((s) => s.deleteProjectSubmitting);
|
pendingDeleteProject,
|
||||||
const setProjects = useProjectStore((s) => s.setProjects);
|
deleteProjectSubmitting,
|
||||||
const setProjectsLoaded = useProjectStore((s) => s.setProjectsLoaded);
|
setProjects,
|
||||||
const setCanvasWorkflow = useProjectStore((s) => s.setCanvasWorkflow);
|
setProjectsLoaded,
|
||||||
const setCurrentCanvasProjectId = useProjectStore((s) => s.setCurrentCanvasProjectId);
|
setCanvasWorkflow,
|
||||||
const openDeleteProjectModal = useProjectStore((s) => s.openDeleteProject);
|
setCurrentCanvasProjectId,
|
||||||
const closeDeleteProjectModal = useProjectStore((s) => s.closeDeleteProject);
|
openDeleteProject: openDeleteProjectModal,
|
||||||
const setDeleteProjectSubmitting = useProjectStore((s) => s.setDeleteProjectSubmitting);
|
closeDeleteProject: closeDeleteProjectModal,
|
||||||
const clearProjectState = useProjectStore((s) => s.clearProjectState);
|
setDeleteProjectSubmitting,
|
||||||
|
clearProjectState,
|
||||||
|
} = useProjectStore(useShallow((s) => ({
|
||||||
|
projects: s.projects,
|
||||||
|
projectsLoaded: s.projectsLoaded,
|
||||||
|
canvasWorkflow: s.canvasWorkflow,
|
||||||
|
currentCanvasProjectId: s.currentCanvasProjectId,
|
||||||
|
pendingDeleteProject: s.pendingDeleteProject,
|
||||||
|
deleteProjectSubmitting: s.deleteProjectSubmitting,
|
||||||
|
setProjects: s.setProjects,
|
||||||
|
setProjectsLoaded: s.setProjectsLoaded,
|
||||||
|
setCanvasWorkflow: s.setCanvasWorkflow,
|
||||||
|
setCurrentCanvasProjectId: s.setCurrentCanvasProjectId,
|
||||||
|
openDeleteProject: s.openDeleteProject,
|
||||||
|
closeDeleteProject: s.closeDeleteProject,
|
||||||
|
setDeleteProjectSubmitting: s.setDeleteProjectSubmitting,
|
||||||
|
clearProjectState: s.clearProjectState,
|
||||||
|
})));
|
||||||
|
|
||||||
// Task store
|
// Task store
|
||||||
const tasks = useTaskStore((s) => s.tasks);
|
const {
|
||||||
const setTasks = useTaskStore((s) => s.setTasks);
|
tasks,
|
||||||
const appendTask = useTaskStore((s) => s.appendTask);
|
setTasks,
|
||||||
const mergeServerTasks = useTaskStore((s) => s.mergeServerTasks);
|
appendTask,
|
||||||
const clearTasks = useTaskStore((s) => s.clearTasks);
|
mergeServerTasks,
|
||||||
|
clearTasks,
|
||||||
|
} = useTaskStore(useShallow((s) => ({
|
||||||
|
tasks: s.tasks,
|
||||||
|
setTasks: s.setTasks,
|
||||||
|
appendTask: s.appendTask,
|
||||||
|
mergeServerTasks: s.mergeServerTasks,
|
||||||
|
clearTasks: s.clearTasks,
|
||||||
|
})));
|
||||||
|
|
||||||
// App store
|
// App store
|
||||||
const usage = useAppStore((s) => s.usage);
|
const {
|
||||||
const runtimeNotifications = useAppStore((s) => s.runtimeNotifications);
|
usage,
|
||||||
const serverNotifications = useAppStore((s) => s.serverNotifications);
|
runtimeNotifications,
|
||||||
const activeView = useAppStore((s) => s.activeView);
|
serverNotifications,
|
||||||
const workspaceExpanded = useAppStore((s) => s.workspaceExpanded);
|
activeView,
|
||||||
const imageWorkbenchTool = useAppStore((s) => s.imageWorkbenchTool);
|
workspaceExpanded,
|
||||||
const pendingEcommerceTemplate = useAppStore((s) => s.pendingEcommerceTemplate);
|
imageWorkbenchTool,
|
||||||
const backendHealth = useAppStore((s) => s.backendHealth);
|
pendingEcommerceTemplate,
|
||||||
const setUsage = useAppStore((s) => s.setUsage);
|
backendHealth,
|
||||||
const pushNotification = useAppStore((s) => s.pushNotification);
|
setUsage,
|
||||||
const setRuntimeNotifications = useAppStore((s) => s.setRuntimeNotifications);
|
pushNotification,
|
||||||
const setServerNotifications = useAppStore((s) => s.setServerNotifications);
|
setRuntimeNotifications,
|
||||||
const setView = useAppStore((s) => s.setView);
|
setServerNotifications,
|
||||||
const setWorkspaceExpanded = useAppStore((s) => s.setWorkspaceExpanded);
|
setView,
|
||||||
const setImageWorkbenchTool = useAppStore((s) => s.setImageWorkbenchTool);
|
setWorkspaceExpanded,
|
||||||
const setPendingEcommerceTemplate = useAppStore((s) => s.setPendingEcommerceTemplate);
|
setImageWorkbenchTool,
|
||||||
const setBackendHealth = useAppStore((s) => s.setBackendHealth);
|
setPendingEcommerceTemplate,
|
||||||
const markNotificationRead = useAppStore((s) => s.markNotificationRead);
|
setBackendHealth,
|
||||||
const markAllNotificationsRead = useAppStore((s) => s.markAllNotificationsRead);
|
markNotificationRead,
|
||||||
const clearAppState = useAppStore((s) => s.clearAppState);
|
markAllNotificationsRead,
|
||||||
|
clearAppState,
|
||||||
|
} = useAppStore(useShallow((s) => ({
|
||||||
|
usage: s.usage,
|
||||||
|
runtimeNotifications: s.runtimeNotifications,
|
||||||
|
serverNotifications: s.serverNotifications,
|
||||||
|
activeView: s.activeView,
|
||||||
|
workspaceExpanded: s.workspaceExpanded,
|
||||||
|
imageWorkbenchTool: s.imageWorkbenchTool,
|
||||||
|
pendingEcommerceTemplate: s.pendingEcommerceTemplate,
|
||||||
|
backendHealth: s.backendHealth,
|
||||||
|
setUsage: s.setUsage,
|
||||||
|
pushNotification: s.pushNotification,
|
||||||
|
setRuntimeNotifications: s.setRuntimeNotifications,
|
||||||
|
setServerNotifications: s.setServerNotifications,
|
||||||
|
setView: s.setView,
|
||||||
|
setWorkspaceExpanded: s.setWorkspaceExpanded,
|
||||||
|
setImageWorkbenchTool: s.setImageWorkbenchTool,
|
||||||
|
setPendingEcommerceTemplate: s.setPendingEcommerceTemplate,
|
||||||
|
setBackendHealth: s.setBackendHealth,
|
||||||
|
markNotificationRead: s.markNotificationRead,
|
||||||
|
markAllNotificationsRead: s.markAllNotificationsRead,
|
||||||
|
clearAppState: s.clearAppState,
|
||||||
|
})));
|
||||||
|
|
||||||
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
|
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
|
||||||
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
|
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
|
||||||
@@ -296,6 +373,12 @@ function App() {
|
|||||||
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
|
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
|
||||||
}, [isEcommerceActive]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [isEcommerceActive]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (LEGACY_PAGE_STYLE_VIEWS.has(activeView) || ecommerceEverMounted) {
|
||||||
|
void loadLegacyPageStyles();
|
||||||
|
}
|
||||||
|
}, [activeView, ecommerceEverMounted]);
|
||||||
|
|
||||||
// Dismiss boot splash after first render
|
// Dismiss boot splash after first render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const splash = document.getElementById("app-boot-splash");
|
const splash = document.getElementById("app-boot-splash");
|
||||||
@@ -347,24 +430,24 @@ function App() {
|
|||||||
|
|
||||||
const navItems = useMemo<WebNavItem[]>(
|
const navItems = useMemo<WebNavItem[]>(
|
||||||
() => [
|
() => [
|
||||||
{ key: "home", label: "首页", hint: "项目入口", icon: <HomeOutlined /> },
|
{ key: "home", label: "首页", hint: "项目入口", icon: <ShellIcon name="home" /> },
|
||||||
{ key: "workbench", label: "生成", hint: "对话生成页面", icon: <RobotOutlined /> },
|
{ key: "workbench", label: "生成", hint: "对话生成页面", icon: <ShellIcon name="robot" /> },
|
||||||
{
|
{
|
||||||
key: "ecommerce",
|
key: "ecommerce",
|
||||||
label: "电商生成",
|
label: "电商生成",
|
||||||
hint: "AI创作与海报生成",
|
hint: "AI创作与海报生成",
|
||||||
icon: <ShoppingOutlined />,
|
icon: <ShellIcon name="shopping" />,
|
||||||
},
|
},
|
||||||
{ key: "canvas", label: "画布", hint: "进入自由画布编排", icon: <BranchesOutlined /> },
|
{ key: "canvas", label: "画布", hint: "进入自由画布编排", icon: <ShellIcon name="branches" /> },
|
||||||
{ key: "community", label: "社区", hint: "案例分享与导入", icon: <GlobalOutlined /> },
|
{ key: "community", label: "社区", hint: "案例分享与导入", icon: <ShellIcon name="global" /> },
|
||||||
{ key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: <BarChartOutlined /> },
|
{ key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: <ShellIcon name="bar-chart" /> },
|
||||||
{ key: "tokenUsage", label: "Token消耗", hint: "成员、服务与调用记录", icon: <WalletOutlined /> },
|
{ key: "tokenUsage", label: "Token消耗", hint: "成员、服务与调用记录", icon: <ShellIcon name="wallet" /> },
|
||||||
{ key: "providerHealth", label: "服务商健康", hint: "AI 服务商状态与监控", icon: <HeartOutlined /> },
|
{ key: "providerHealth", label: "服务商健康", hint: "AI 服务商状态与监控", icon: <ShellIcon name="heart" /> },
|
||||||
{ key: "assets", label: "资产库", hint: "角色、场景、道具", icon: <FolderOpenOutlined /> },
|
{ key: "assets", label: "资产库", hint: "角色、场景、道具", icon: <ShellIcon name="folder" /> },
|
||||||
{ key: "agent", label: "Agent", hint: "拆解与规划", icon: <RobotOutlined /> },
|
{ key: "agent", label: "Agent", hint: "拆解与规划", icon: <ShellIcon name="robot" /> },
|
||||||
{ key: "digitalHuman", label: "数字人", hint: "口播与人像生成", icon: <CustomerServiceOutlined /> },
|
{ key: "digitalHuman", label: "数字人", hint: "口播与人像生成", icon: <ShellIcon name="customer-service" /> },
|
||||||
{ key: "characterMix", label: "角色迁移", hint: "人物视频迁移", icon: <SwapOutlined /> },
|
{ key: "characterMix", label: "角色迁移", hint: "人物视频迁移", icon: <ShellIcon name="swap" /> },
|
||||||
{ key: "more", label: "工具盒", hint: "图像与镜头工具", icon: <ToolOutlined /> },
|
{ key: "more", label: "工具盒", hint: "图像与镜头工具", icon: <ShellIcon name="tool" /> },
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -1090,6 +1173,30 @@ function App() {
|
|||||||
case "ecommerce":
|
case "ecommerce":
|
||||||
case "ecommerceHub":
|
case "ecommerceHub":
|
||||||
return null;
|
return null;
|
||||||
|
case "ecommerceTemplates":
|
||||||
|
return (
|
||||||
|
<EcommerceTemplatesPage
|
||||||
|
projects={projects}
|
||||||
|
onOpenMore={() => handleSetView("more")}
|
||||||
|
onOpenEcommerce={() => handleSetView("ecommerce")}
|
||||||
|
onSelectTemplate={(template) => {
|
||||||
|
setPendingEcommerceTemplate(template);
|
||||||
|
handleSetView("ecommerce");
|
||||||
|
}}
|
||||||
|
onStartCreate={handleStartTemplateCanvasCreate}
|
||||||
|
onOpenProject={handleOpenProject}
|
||||||
|
onDeleteProject={handleDeleteProject}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "sizeTemplate":
|
||||||
|
return (
|
||||||
|
<SizeTemplatePage
|
||||||
|
isAuthenticated={Boolean(session)}
|
||||||
|
onOpenMore={() => handleSetView("more")}
|
||||||
|
onOpenEcommerce={() => handleSetView("ecommerce")}
|
||||||
|
onSelectView={handleSetView}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "digitalHuman":
|
case "digitalHuman":
|
||||||
return (
|
return (
|
||||||
<DigitalHumanPage
|
<DigitalHumanPage
|
||||||
@@ -1339,7 +1446,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<section className="project-delete-modal__panel">
|
<section className="project-delete-modal__panel">
|
||||||
<span className="project-delete-modal__icon">
|
<span className="project-delete-modal__icon">
|
||||||
<DeleteOutlined />
|
<ShellIcon name="delete" />
|
||||||
</span>
|
</span>
|
||||||
<h2 id="project-delete-title">删除项目</h2>
|
<h2 id="project-delete-title">删除项目</h2>
|
||||||
<p>确认删除项目「{pendingDeleteProject.name}」?删除后将从服务器项目列表移除。</p>
|
<p>确认删除项目「{pendingDeleteProject.name}」?删除后将从服务器项目列表移除。</p>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
buildAuthHeaders,
|
buildAuthHeaders,
|
||||||
isRecord,
|
isRecord,
|
||||||
readJsonResponse,
|
readJsonResponse,
|
||||||
|
serverRequest,
|
||||||
throwResponseError,
|
throwResponseError,
|
||||||
} from "./serverConnection";
|
} from "./serverConnection";
|
||||||
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
||||||
@@ -243,6 +244,10 @@ function emitImageRouteDebug(label: string, payload: Record<string, unknown>): v
|
|||||||
|
|
||||||
let taskHistoryRouteMissing = false;
|
let taskHistoryRouteMissing = false;
|
||||||
|
|
||||||
|
const TASK_SUBMIT_TIMEOUT_MS = 90_000;
|
||||||
|
const TASK_STATUS_TIMEOUT_MS = 20_000;
|
||||||
|
const NON_RETRYING_REQUEST = { maxRetries: 0 };
|
||||||
|
|
||||||
export const aiGenerationClient = {
|
export const aiGenerationClient = {
|
||||||
async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> {
|
async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> {
|
||||||
const requestUrl = buildApiUrl("ai/image");
|
const requestUrl = buildApiUrl("ai/image");
|
||||||
@@ -256,15 +261,13 @@ export const aiGenerationClient = {
|
|||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
conversationId: input.conversationId,
|
conversationId: input.conversationId,
|
||||||
});
|
});
|
||||||
const res = await fetch(requestUrl, {
|
const payload = await serverRequest<ImageTaskCreateResponse>("ai/image", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Image generation request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Image generation request failed");
|
|
||||||
}
|
|
||||||
const payload = await readJsonResponse<ImageTaskCreateResponse>(res, "Image generation response failed");
|
|
||||||
if (payload.providerDebug) {
|
if (payload.providerDebug) {
|
||||||
emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record<string, unknown>);
|
emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
@@ -272,96 +275,83 @@ export const aiGenerationClient = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> {
|
async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> {
|
||||||
const res = await fetch(buildApiUrl("ai/video"), {
|
return serverRequest<{ taskId: string }>("ai/video", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Video generation request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Video generation request failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ taskId: string }>(res, "Video generation response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> {
|
async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> {
|
||||||
const res = await fetch(buildApiUrl("ai/video/super-resolve"), {
|
return serverRequest<{ taskId: string }>("ai/video/super-resolve", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Video super-resolution request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Video super-resolution request failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ taskId: string }>(res, "Video super-resolution response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> {
|
async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> {
|
||||||
const res = await fetch(buildApiUrl("ai/video/erase-subtitles"), {
|
return serverRequest<{ taskId: string }>("ai/video/erase-subtitles", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Subtitle removal request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Subtitle removal request failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ taskId: string }>(res, "Subtitle removal response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> {
|
async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> {
|
||||||
const res = await fetch(buildApiUrl("ai/video/edit"), {
|
return serverRequest<{ taskId: string }>("ai/video/edit", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: { ...input, model: input.model || "happyhorse-1.0-video-edit" },
|
||||||
body: JSON.stringify({ ...input, model: input.model || "happyhorse-1.0-video-edit" }),
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Video edit request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Video edit request failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ taskId: string }>(res, "Video edit response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
|
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
|
||||||
const res = await fetch(buildApiUrl("ai/image/super-resolve"), {
|
return serverRequest<{ taskId: string }>("ai/image/super-resolve", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Image super-resolution request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Image super-resolution request failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ taskId: string }>(res, "Image super-resolution response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> {
|
async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> {
|
||||||
const res = await fetch(buildApiUrl("ai/image/edit"), {
|
return serverRequest<{ taskId: string }>("ai/image/edit", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Image edit request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Image edit request failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ taskId: string }>(res, "Image edit response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async cancelTask(taskId: string): Promise<void> {
|
async cancelTask(taskId: string): Promise<void> {
|
||||||
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/cancel`), {
|
try {
|
||||||
|
await serverRequest<void>(`ai/tasks/${taskId}/cancel`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: buildAuthHeaders(),
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Task cancel failed",
|
||||||
});
|
});
|
||||||
if (!res.ok && res.status !== 404) {
|
} catch (error) {
|
||||||
await throwResponseError(res, "Task cancel failed");
|
if (isOptionalApiRouteMissing(error)) return;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
|
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
|
||||||
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}`), {
|
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, {
|
||||||
method: "GET",
|
timeoutMs: TASK_STATUS_TIMEOUT_MS,
|
||||||
headers: buildAuthHeaders(),
|
fallbackMessage: "Task status request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Task status request failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<AiTaskStatus>(res, "Task status response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> {
|
async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> {
|
||||||
@@ -387,13 +377,11 @@ export const aiGenerationClient = {
|
|||||||
if (params?.status) search.set("status", params.status);
|
if (params?.status) search.set("status", params.status);
|
||||||
if (params?.type) search.set("type", params.type);
|
if (params?.type) search.set("type", params.type);
|
||||||
if (params?.projectId) search.set("projectId", params.projectId);
|
if (params?.projectId) search.set("projectId", params.projectId);
|
||||||
const res = await fetch(buildApiUrl(`ai/tasks${search.toString() ? `?${search}` : ""}`), {
|
|
||||||
method: "GET",
|
|
||||||
headers: buildAuthHeaders(),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
try {
|
try {
|
||||||
await throwResponseError(res, "Task history request failed");
|
const payload = await serverRequest<unknown>(`ai/tasks${search.toString() ? `?${search}` : ""}`, {
|
||||||
|
fallbackMessage: "Task history request failed",
|
||||||
|
});
|
||||||
|
return extractTaskList(payload).map(toPreviewTask);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isOptionalApiRouteMissing(error)) {
|
if (isOptionalApiRouteMissing(error)) {
|
||||||
taskHistoryRouteMissing = true;
|
taskHistoryRouteMissing = true;
|
||||||
@@ -401,35 +389,29 @@ export const aiGenerationClient = {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const payload = await readJsonResponse<unknown>(res, "Task history response failed");
|
|
||||||
return extractTaskList(payload).map(toPreviewTask);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async bindTaskToConversation(taskId: string, conversationId: number): Promise<void> {
|
async bindTaskToConversation(taskId: string, conversationId: number): Promise<void> {
|
||||||
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/conversation`), {
|
try {
|
||||||
|
await serverRequest<void>(`ai/tasks/${taskId}/conversation`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: buildAuthHeaders(),
|
body: { conversationId },
|
||||||
body: JSON.stringify({ conversationId }),
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Task conversation binding failed",
|
||||||
});
|
});
|
||||||
if (res.status === 404) {
|
} catch (error) {
|
||||||
return;
|
if (isOptionalApiRouteMissing(error)) return;
|
||||||
}
|
throw error;
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Task conversation binding failed");
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
||||||
const res = await fetch(buildApiUrl("oss/upload"), {
|
return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Asset upload failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Asset upload failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async uploadAssetBinary(blob: Blob, options?: { name?: string; mimeType?: string; scope?: string }): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
async uploadAssetBinary(blob: Blob, options?: { name?: string; mimeType?: string; scope?: string }): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
||||||
@@ -451,15 +433,12 @@ export const aiGenerationClient = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
||||||
const res = await fetch(buildApiUrl("oss/upload-by-url"), {
|
return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload-by-url", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Asset upload by URL failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Asset upload by URL failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload by URL response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
subscribeTaskStatus(
|
subscribeTaskStatus(
|
||||||
|
|||||||
@@ -67,7 +67,13 @@ function normalizeAssetStatus(value: unknown): WebAssetItem["status"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTags(value: unknown): string[] {
|
function normalizeTags(value: unknown): string[] {
|
||||||
return Array.isArray(value) ? value.map((item) => toStringValue(item)).filter(Boolean) : [];
|
if (!Array.isArray(value)) return [];
|
||||||
|
const tags: string[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
const tag = toStringValue(item);
|
||||||
|
if (tag) tags.push(tag);
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAsset(raw: unknown): ServerAssetItem {
|
function normalizeAsset(raw: unknown): ServerAssetItem {
|
||||||
|
|||||||
@@ -62,9 +62,13 @@ function toStringValue(value: unknown, fallback = ""): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toStringArray(value: unknown): string[] {
|
function toStringArray(value: unknown): string[] {
|
||||||
return Array.isArray(value)
|
if (!Array.isArray(value)) return [];
|
||||||
? value.map((item) => toStringValue(item)).filter(Boolean)
|
const result: string[] = [];
|
||||||
: [];
|
for (const item of value) {
|
||||||
|
const text = toStringValue(item);
|
||||||
|
if (text) result.push(text);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toMetadata(value: unknown): Record<string, unknown> {
|
function toMetadata(value: unknown): Record<string, unknown> {
|
||||||
|
|||||||
@@ -376,13 +376,18 @@ function createWorkflowFingerprint(workflow: WebCanvasWorkflow): string {
|
|||||||
function toActivePackages(value: unknown): WebUserSession["user"]["activePackages"] {
|
function toActivePackages(value: unknown): WebUserSession["user"]["activePackages"] {
|
||||||
if (!Array.isArray(value)) return undefined;
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
|
||||||
return value.filter(isRecord).map((entry) => ({
|
const packages: NonNullable<WebUserSession["user"]["activePackages"]> = [];
|
||||||
|
for (const entry of value) {
|
||||||
|
if (!isRecord(entry)) continue;
|
||||||
|
packages.push({
|
||||||
name: toStringValue(entry.name, "Preview package"),
|
name: toStringValue(entry.name, "Preview package"),
|
||||||
expiresAt: toStringValue(entry.expiresAt ?? entry.expires_at, ""),
|
expiresAt: toStringValue(entry.expiresAt ?? entry.expires_at, ""),
|
||||||
remainingImage: toNumber(entry.remainingImage ?? entry.remaining_image),
|
remainingImage: toNumber(entry.remainingImage ?? entry.remaining_image),
|
||||||
remainingVideo: toNumber(entry.remainingVideo ?? entry.remaining_video),
|
remainingVideo: toNumber(entry.remainingVideo ?? entry.remaining_video),
|
||||||
remainingText: toNumber(entry.remainingText ?? entry.remaining_text),
|
remainingText: toNumber(entry.remainingText ?? entry.remaining_text),
|
||||||
}));
|
});
|
||||||
|
}
|
||||||
|
return packages;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUser(raw: unknown): WebUserSession["user"] | null {
|
function normalizeUser(raw: unknown): WebUserSession["user"] | null {
|
||||||
|
|||||||
@@ -49,9 +49,13 @@ function normalizeModelOption(raw: unknown): ModelCapabilityOption | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeModelList(value: unknown): ModelCapabilityOption[] {
|
function normalizeModelList(value: unknown): ModelCapabilityOption[] {
|
||||||
return Array.isArray(value)
|
if (!Array.isArray(value)) return [];
|
||||||
? value.map(normalizeModelOption).filter((item): item is ModelCapabilityOption => Boolean(item))
|
const options: ModelCapabilityOption[] = [];
|
||||||
: [];
|
for (const item of value) {
|
||||||
|
const option = normalizeModelOption(item);
|
||||||
|
if (option) options.push(option);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFallbackCapabilities(): WebModelCapabilities {
|
function createFallbackCapabilities(): WebModelCapabilities {
|
||||||
|
|||||||
@@ -71,10 +71,19 @@ function normalizeTask(raw: unknown): ServerProjectTask | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractTasks(payload: unknown): ServerProjectTask[] {
|
function extractTasks(payload: unknown): ServerProjectTask[] {
|
||||||
if (Array.isArray(payload)) return payload.map(normalizeTask).filter(Boolean) as ServerProjectTask[];
|
const normalizeTasks = (rows: unknown[]): ServerProjectTask[] => {
|
||||||
|
const tasks: ServerProjectTask[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const task = normalizeTask(row);
|
||||||
|
if (task) tasks.push(task);
|
||||||
|
}
|
||||||
|
return tasks;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(payload)) return normalizeTasks(payload);
|
||||||
if (!isRecord(payload)) return [];
|
if (!isRecord(payload)) return [];
|
||||||
const rows = payload.tasks ?? payload.items;
|
const rows = payload.tasks ?? payload.items;
|
||||||
return Array.isArray(rows) ? (rows.map(normalizeTask).filter(Boolean) as ServerProjectTask[]) : [];
|
return Array.isArray(rows) ? normalizeTasks(rows) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskTitle(task: ServerProjectTask): string {
|
function taskTitle(task: ServerProjectTask): string {
|
||||||
@@ -110,8 +119,12 @@ export const projectTaskClient = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async listForProjects(projectIds: string[]): Promise<WebGenerationPreviewTask[]> {
|
async listForProjects(projectIds: string[]): Promise<WebGenerationPreviewTask[]> {
|
||||||
const uniqueIds = Array.from(new Set(projectIds.map((id) => id.trim()).filter(Boolean)));
|
const uniqueIds = new Set<string>();
|
||||||
const results = await Promise.all(uniqueIds.map((id) => listProjectTasks(id)));
|
for (const projectId of projectIds) {
|
||||||
|
const id = projectId.trim();
|
||||||
|
if (id) uniqueIds.add(id);
|
||||||
|
}
|
||||||
|
const results = await Promise.all(Array.from(uniqueIds, (id) => listProjectTasks(id)));
|
||||||
return results.flat();
|
return results.flat();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
import { serverRequest } from "./serverConnection";
|
||||||
|
|
||||||
export interface ProviderHealthEntry {
|
export interface ProviderHealthEntry {
|
||||||
status: string;
|
status: string;
|
||||||
@@ -32,13 +32,8 @@ export interface ProviderHealthResponse {
|
|||||||
|
|
||||||
export const providerHealthClient = {
|
export const providerHealthClient = {
|
||||||
async getStatus(): Promise<ProviderHealthResponse> {
|
async getStatus(): Promise<ProviderHealthResponse> {
|
||||||
const res = await fetch(buildApiUrl("admin/providers/status"), {
|
return serverRequest<ProviderHealthResponse>("admin/providers/status", {
|
||||||
method: "GET",
|
fallbackMessage: "Provider health request failed",
|
||||||
headers: buildAuthHeaders(),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Provider health request failed (${res.status})`);
|
|
||||||
}
|
|
||||||
return res.json() as Promise<ProviderHealthResponse>;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
import { serverRequest } from "./serverConnection";
|
||||||
|
|
||||||
export interface ScriptEvalResult {
|
export interface ScriptEvalResult {
|
||||||
totalScore: number;
|
totalScore: number;
|
||||||
@@ -107,6 +107,17 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEvidenceItems(source: unknown[], limit: number): string[] {
|
||||||
|
const items: string[] = [];
|
||||||
|
for (const item of source) {
|
||||||
|
const value = String(item).trim();
|
||||||
|
if (!value) continue;
|
||||||
|
items.push(value);
|
||||||
|
if (items.length >= limit) break;
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeNestedScores(value: unknown): Record<string, Record<string, number>> {
|
function normalizeNestedScores(value: unknown): Record<string, Record<string, number>> {
|
||||||
if (!isRecord(value)) return {};
|
if (!isRecord(value)) return {};
|
||||||
|
|
||||||
@@ -132,7 +143,7 @@ function normalizeEvidence(value: unknown): Record<string, string[]> {
|
|||||||
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
|
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
|
||||||
if (!Array.isArray(source)) continue;
|
if (!Array.isArray(source)) continue;
|
||||||
|
|
||||||
const items = source.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3);
|
const items = normalizeEvidenceItems(source, 3);
|
||||||
if (items.length > 0) normalized[dimensionKey] = items;
|
if (items.length > 0) normalized[dimensionKey] = items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,10 +151,13 @@ function normalizeEvidence(value: unknown): Record<string, string[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
|
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
|
||||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
const payload = await serverRequest<{
|
||||||
|
content?: string;
|
||||||
|
choices?: Array<{ message?: { content?: string } }>;
|
||||||
|
text?: string;
|
||||||
|
}>("ai/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: {
|
||||||
body: JSON.stringify({
|
|
||||||
model: MODEL,
|
model: MODEL,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: EVAL_SYSTEM_PROMPT },
|
{ role: "system", content: EVAL_SYSTEM_PROMPT },
|
||||||
@@ -153,16 +167,13 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
|
|||||||
stream: false,
|
stream: false,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
}),
|
},
|
||||||
signal,
|
signal,
|
||||||
|
timeoutMs: 180_000,
|
||||||
|
maxRetries: 0,
|
||||||
|
fallbackMessage: "评测请求失败",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const errText = await res.text().catch(() => "");
|
|
||||||
throw new Error(`评测请求失败 (${res.status}): ${errText.slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await res.json();
|
|
||||||
const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
||||||
|
|
||||||
if (!content) throw new Error("模型未返回有效内容");
|
if (!content) throw new Error("模型未返回有效内容");
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export interface ServerRequestOptions {
|
|||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
/** Per-request timeout in ms. Defaults to DEFAULT_REQUEST_TIMEOUT_MS. Pass 0 to disable. */
|
/** Per-request timeout in ms. Defaults to DEFAULT_REQUEST_TIMEOUT_MS. Pass 0 to disable. */
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
/** Defaults to 2. Use 0 for non-idempotent task submission endpoints. */
|
||||||
|
maxRetries?: number;
|
||||||
|
fallbackMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
||||||
@@ -343,8 +346,10 @@ const MAX_RETRIES = 2;
|
|||||||
export async function serverRequest<T>(path: string, options?: ServerRequestOptions): Promise<T> {
|
export async function serverRequest<T>(path: string, options?: ServerRequestOptions): Promise<T> {
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
const maxRetries = options?.maxRetries ?? MAX_RETRIES;
|
||||||
|
const fallbackMessage = options?.fallbackMessage || "Request failed";
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
const controller = timeoutMs > 0 ? new AbortController() : null;
|
const controller = timeoutMs > 0 ? new AbortController() : null;
|
||||||
const timeoutId =
|
const timeoutId =
|
||||||
controller && typeof window !== "undefined"
|
controller && typeof window !== "undefined"
|
||||||
@@ -366,11 +371,11 @@ export async function serverRequest<T>(path: string, options?: ServerRequestOpti
|
|||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = await readJsonResponse<unknown>(response, "Request failed");
|
const payload = await readJsonResponse<unknown>(response, fallbackMessage);
|
||||||
return (options?.raw ? payload : unwrapApiPayload(payload)) as T;
|
return (options?.raw ? payload : unwrapApiPayload(payload)) as T;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (attempt < MAX_RETRIES && isRetryable(error) && !options?.signal?.aborted) {
|
if (attempt < maxRetries && isRetryable(error) && !options?.signal?.aborted) {
|
||||||
await new Promise((r) => setTimeout(r, getRetryDelay(attempt, error)));
|
await new Promise((r) => setTimeout(r, getRetryDelay(attempt, error)));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,3 @@
|
|||||||
import {
|
|
||||||
ArrowDownOutlined,
|
|
||||||
ArrowUpOutlined,
|
|
||||||
CheckCircleOutlined,
|
|
||||||
FlagOutlined,
|
|
||||||
InfoCircleOutlined,
|
|
||||||
LoginOutlined,
|
|
||||||
LogoutOutlined,
|
|
||||||
PlusCircleOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
WalletOutlined,
|
|
||||||
} from "@ant-design/icons";
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient";
|
import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient";
|
||||||
@@ -20,10 +8,12 @@ import { canManageCommunityCases, canReviewCommunity } from "../features/communi
|
|||||||
import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types";
|
import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types";
|
||||||
import NotificationCenter from "./NotificationCenter";
|
import NotificationCenter from "./NotificationCenter";
|
||||||
import BetaApplicationModal from "./BetaApplicationModal";
|
import BetaApplicationModal from "./BetaApplicationModal";
|
||||||
import { RechargeModal } from "./RechargeModal/RechargeModal";
|
|
||||||
import { AnimatedPanel } from "./AnimatedPanel";
|
import { AnimatedPanel } from "./AnimatedPanel";
|
||||||
import AdminMonitor from "./AdminMonitor";
|
import AdminMonitor from "./AdminMonitor";
|
||||||
import CookieConsentBanner from "./CookieConsentBanner";
|
import CookieConsentBanner from "./CookieConsentBanner";
|
||||||
|
import { loadRechargeModal, type RechargeModalComponent } from "./RechargeModal/loadRechargeModal";
|
||||||
|
import { ShellIcon } from "./ShellIcon";
|
||||||
|
import { loadDarkGreenTheme } from "../styles/loadDarkGreenTheme";
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
activeView: WebViewKey;
|
activeView: WebViewKey;
|
||||||
@@ -42,6 +32,32 @@ interface AppShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BRAND_LOGO_URL = ossAssets.brand.logo;
|
const BRAND_LOGO_URL = ossAssets.brand.logo;
|
||||||
|
const TOOL_SURFACE_VIEW_SET = new Set<WebViewKey>([
|
||||||
|
"workbench",
|
||||||
|
"canvas",
|
||||||
|
"more",
|
||||||
|
"scriptTokens",
|
||||||
|
"tokenUsage",
|
||||||
|
"ecommerceTemplates",
|
||||||
|
"sizeTemplate",
|
||||||
|
"imageWorkbench",
|
||||||
|
"resolutionUpscale",
|
||||||
|
"digitalHuman",
|
||||||
|
"dialogGenerator",
|
||||||
|
"avatarConsole",
|
||||||
|
"characterMix",
|
||||||
|
] as WebViewKey[]);
|
||||||
|
const PRIMARY_NAV_ORDER: WebViewKey[] = [
|
||||||
|
"workbench",
|
||||||
|
"ecommerce",
|
||||||
|
"sizeTemplate",
|
||||||
|
"canvas",
|
||||||
|
"scriptTokens",
|
||||||
|
"tokenUsage",
|
||||||
|
"more",
|
||||||
|
"assets",
|
||||||
|
"community",
|
||||||
|
];
|
||||||
|
|
||||||
function formatBalance(cents: number): string {
|
function formatBalance(cents: number): string {
|
||||||
const value = Math.max(0, cents) / 100;
|
const value = Math.max(0, cents) / 100;
|
||||||
@@ -68,6 +84,7 @@ function AppShell({
|
|||||||
const submenuHideTimerRef = useRef<number | null>(null);
|
const submenuHideTimerRef = useRef<number | null>(null);
|
||||||
const [profileOpen, setProfileOpen] = useState(false);
|
const [profileOpen, setProfileOpen] = useState(false);
|
||||||
const [rechargeOpen, setRechargeOpen] = useState(false);
|
const [rechargeOpen, setRechargeOpen] = useState(false);
|
||||||
|
const [RechargeModal, setRechargeModal] = useState<RechargeModalComponent | null>(null);
|
||||||
const [infoOpen, setInfoOpen] = useState(false);
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
const [betaOpen, setBetaOpen] = useState(false);
|
const [betaOpen, setBetaOpen] = useState(false);
|
||||||
const infoRef = useRef<HTMLDivElement>(null);
|
const infoRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -78,38 +95,13 @@ function AppShell({
|
|||||||
const isAuthView = activeView === "login";
|
const isAuthView = activeView === "login";
|
||||||
const isImmersiveView = activeView === "agent" || activeView === "avatarConsole";
|
const isImmersiveView = activeView === "agent" || activeView === "avatarConsole";
|
||||||
const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home";
|
const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home";
|
||||||
const toolSurfaceViews = [
|
const showPageScrollActions = showFloatingNav && !TOOL_SURFACE_VIEW_SET.has(activeView);
|
||||||
"workbench",
|
|
||||||
"canvas",
|
|
||||||
"more",
|
|
||||||
"scriptTokens",
|
|
||||||
"tokenUsage",
|
|
||||||
"ecommerceTemplates",
|
|
||||||
"sizeTemplate",
|
|
||||||
"imageWorkbench",
|
|
||||||
"resolutionUpscale",
|
|
||||||
"digitalHuman",
|
|
||||||
"dialogGenerator",
|
|
||||||
"avatarConsole",
|
|
||||||
"characterMix",
|
|
||||||
] as WebViewKey[];
|
|
||||||
const showPageScrollActions = showFloatingNav && !toolSurfaceViews.includes(activeView);
|
|
||||||
|
|
||||||
const visibleNavItems = useMemo(
|
const visibleNavItems = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const orderedKeys: WebViewKey[] = [
|
const navItemByKey = new Map(navItems.map((item) => [item.key, item]));
|
||||||
"workbench",
|
return PRIMARY_NAV_ORDER
|
||||||
"ecommerce",
|
.map((key) => navItemByKey.get(key))
|
||||||
"sizeTemplate",
|
|
||||||
"canvas",
|
|
||||||
"scriptTokens",
|
|
||||||
"tokenUsage",
|
|
||||||
"more",
|
|
||||||
"assets",
|
|
||||||
"community",
|
|
||||||
];
|
|
||||||
return orderedKeys
|
|
||||||
.map((key) => navItems.find((item) => item.key === key))
|
|
||||||
.filter((item): item is WebNavItem => Boolean(item));
|
.filter((item): item is WebNavItem => Boolean(item));
|
||||||
},
|
},
|
||||||
[navItems],
|
[navItems],
|
||||||
@@ -129,6 +121,7 @@ function AppShell({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void loadDarkGreenTheme();
|
||||||
document.documentElement.dataset.theme = "dark";
|
document.documentElement.dataset.theme = "dark";
|
||||||
document.documentElement.dataset.uiTheme = "dark-green";
|
document.documentElement.dataset.uiTheme = "dark-green";
|
||||||
document.documentElement.style.colorScheme = "dark";
|
document.documentElement.style.colorScheme = "dark";
|
||||||
@@ -193,6 +186,21 @@ function AppShell({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rechargeOpen || RechargeModal) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void loadRechargeModal().then((component) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setRechargeModal(() => component);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [RechargeModal, rechargeOpen]);
|
||||||
|
|
||||||
const showSubmenu = (key: WebViewKey) => {
|
const showSubmenu = (key: WebViewKey) => {
|
||||||
if (submenuHideTimerRef.current) {
|
if (submenuHideTimerRef.current) {
|
||||||
window.clearTimeout(submenuHideTimerRef.current);
|
window.clearTimeout(submenuHideTimerRef.current);
|
||||||
@@ -313,7 +321,7 @@ function AppShell({
|
|||||||
aria-label="返回页面顶部"
|
aria-label="返回页面顶部"
|
||||||
onClick={() => scrollActivePage("top")}
|
onClick={() => scrollActivePage("top")}
|
||||||
>
|
>
|
||||||
<ArrowUpOutlined />
|
<ShellIcon name="arrow-up" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -322,7 +330,7 @@ function AppShell({
|
|||||||
aria-label="到达页面底部"
|
aria-label="到达页面底部"
|
||||||
onClick={() => scrollActivePage("bottom")}
|
onClick={() => scrollActivePage("bottom")}
|
||||||
>
|
>
|
||||||
<ArrowDownOutlined />
|
<ShellIcon name="arrow-down" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -361,7 +369,7 @@ function AppShell({
|
|||||||
aria-label="网站信息"
|
aria-label="网站信息"
|
||||||
onClick={() => setInfoOpen((c) => !c)}
|
onClick={() => setInfoOpen((c) => !c)}
|
||||||
>
|
>
|
||||||
<InfoCircleOutlined />
|
<ShellIcon name="info-circle" />
|
||||||
</button>
|
</button>
|
||||||
<AnimatedPanel open={infoOpen} className="info-popover panel-surface">
|
<AnimatedPanel open={infoOpen} className="info-popover panel-surface">
|
||||||
<dl>
|
<dl>
|
||||||
@@ -373,6 +381,7 @@ function AppShell({
|
|||||||
<dd>{publicConfig.contactPhone || "由服务器配置"}</dd>
|
<dd>{publicConfig.contactPhone || "由服务器配置"}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div className="info-popover__links">
|
<div className="info-popover__links">
|
||||||
|
<a href="#/bug-feedback" onClick={() => setInfoOpen(false)}>Bug 反馈</a>
|
||||||
<a href="#/userAgreement" onClick={() => setInfoOpen(false)}>用户协议</a>
|
<a href="#/userAgreement" onClick={() => setInfoOpen(false)}>用户协议</a>
|
||||||
<a href="#/privacyPolicy" onClick={() => setInfoOpen(false)}>隐私政策</a>
|
<a href="#/privacyPolicy" onClick={() => setInfoOpen(false)}>隐私政策</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -384,7 +393,7 @@ function AppShell({
|
|||||||
aria-label={`积分余额 ${displayedBalanceLabel}`}
|
aria-label={`积分余额 ${displayedBalanceLabel}`}
|
||||||
onClick={() => toast.info("充值功能即将开放,敬请期待")}
|
onClick={() => toast.info("充值功能即将开放,敬请期待")}
|
||||||
>
|
>
|
||||||
<WalletOutlined />
|
<ShellIcon name="wallet" />
|
||||||
<span className="member-button__label">{displayedBalanceLabel}</span>
|
<span className="member-button__label">{displayedBalanceLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="profile-popover-anchor" ref={profileRef}>
|
<div className="profile-popover-anchor" ref={profileRef}>
|
||||||
@@ -408,7 +417,7 @@ function AppShell({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<LoginOutlined />
|
<ShellIcon name="login" />
|
||||||
<span>登录 / 注册</span>
|
<span>登录 / 注册</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -436,7 +445,7 @@ function AppShell({
|
|||||||
<div className="profile-popover__footer">
|
<div className="profile-popover__footer">
|
||||||
<span>{session?.source === "server" ? "服务器会话" : "预览会话"}</span>
|
<span>{session?.source === "server" ? "服务器会话" : "预览会话"}</span>
|
||||||
<button type="button" onClick={onLogout}>
|
<button type="button" onClick={onLogout}>
|
||||||
<LogoutOutlined />
|
<ShellIcon name="logout" />
|
||||||
退出
|
退出
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -448,7 +457,7 @@ function AppShell({
|
|||||||
onSelectView("login");
|
onSelectView("login");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UserOutlined />
|
<ShellIcon name="user" />
|
||||||
个人中心
|
个人中心
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -459,8 +468,8 @@ function AppShell({
|
|||||||
onSelectView("report");
|
onSelectView("report");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FlagOutlined />
|
<ShellIcon name="flag" />
|
||||||
投诉举报
|
Bug 反馈
|
||||||
</button>
|
</button>
|
||||||
{showCommunityReview ? (
|
{showCommunityReview ? (
|
||||||
<>
|
<>
|
||||||
@@ -472,7 +481,7 @@ function AppShell({
|
|||||||
onSelectView("communityReview");
|
onSelectView("communityReview");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CheckCircleOutlined />
|
<ShellIcon name="check-circle" />
|
||||||
社区审核
|
社区审核
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -487,7 +496,7 @@ function AppShell({
|
|||||||
onSelectView("communityCaseAdd");
|
onSelectView("communityCaseAdd");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlusCircleOutlined />
|
<ShellIcon name="plus-circle" />
|
||||||
添加案例
|
添加案例
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -501,9 +510,10 @@ function AppShell({
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{session?.user.role === "admin" ? <AdminMonitor /> : null}
|
{session?.user.role === "admin" ? <AdminMonitor /> : null}
|
||||||
|
{rechargeOpen && RechargeModal ? (
|
||||||
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
||||||
|
) : null}
|
||||||
<BetaApplicationModal open={betaOpen} onClose={() => setBetaOpen(false)} />
|
<BetaApplicationModal open={betaOpen} onClose={() => setBetaOpen(false)} />
|
||||||
<CookieConsentBanner />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||||||
|
import "../styles/components/dropzone.css";
|
||||||
|
|
||||||
interface DropZoneProps {
|
interface DropZoneProps {
|
||||||
accept?: string;
|
accept?: string;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import "../styles/components/empty-state.css";
|
||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { HomeOutlined } from "@ant-design/icons";
|
import { HomeOutlined } from "@ant-design/icons";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import "../styles/pages/not-found.css";
|
||||||
|
|
||||||
interface NotFoundPageProps {
|
interface NotFoundPageProps {
|
||||||
onGoHome: () => void;
|
onGoHome: () => void;
|
||||||
|
|||||||
@@ -1,26 +1,17 @@
|
|||||||
import {
|
|
||||||
BellOutlined,
|
|
||||||
CheckCircleOutlined,
|
|
||||||
CloseCircleOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
DislikeOutlined,
|
|
||||||
ExclamationCircleOutlined,
|
|
||||||
LikeOutlined,
|
|
||||||
LockOutlined,
|
|
||||||
} from "@ant-design/icons";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { WebNotification, WebNotificationType, WebViewKey } from "../types";
|
import type { WebNotification, WebNotificationType, WebViewKey } from "../types";
|
||||||
import { AnimatedPanel } from "./AnimatedPanel";
|
import { AnimatedPanel } from "./AnimatedPanel";
|
||||||
|
import { ShellIcon } from "./ShellIcon";
|
||||||
|
|
||||||
const NOTIFICATION_ICONS: Record<WebNotificationType, React.ReactNode> = {
|
const NOTIFICATION_ICONS: Record<WebNotificationType, React.ReactNode> = {
|
||||||
task_completed: <CheckCircleOutlined style={{ color: "#10b981" }} />,
|
task_completed: <ShellIcon name="check-circle" style={{ color: "#10b981" }} />,
|
||||||
task_failed: <CloseCircleOutlined style={{ color: "#ef4444" }} />,
|
task_failed: <ShellIcon name="close-circle" style={{ color: "#ef4444" }} />,
|
||||||
review_pending: <ExclamationCircleOutlined style={{ color: "#f59e0b" }} />,
|
review_pending: <ShellIcon name="exclamation-circle" style={{ color: "#f59e0b" }} />,
|
||||||
review_passed: <LikeOutlined style={{ color: "#10b981" }} />,
|
review_passed: <ShellIcon name="like" style={{ color: "#10b981" }} />,
|
||||||
review_rejected: <DislikeOutlined style={{ color: "#f59e0b" }} />,
|
review_rejected: <ShellIcon name="dislike" style={{ color: "#f59e0b" }} />,
|
||||||
credits_low: <ExclamationCircleOutlined style={{ color: "#f59e0b" }} />,
|
credits_low: <ShellIcon name="exclamation-circle" style={{ color: "#f59e0b" }} />,
|
||||||
session_expired: <LockOutlined style={{ color: "#ef4444" }} />,
|
session_expired: <ShellIcon name="lock" style={{ color: "#ef4444" }} />,
|
||||||
info: <BellOutlined style={{ color: "#2563eb" }} />,
|
info: <ShellIcon name="bell" style={{ color: "#2563eb" }} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseTimestamp(dateStr: string): number {
|
function parseTimestamp(dateStr: string): number {
|
||||||
@@ -111,7 +102,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl
|
|||||||
aria-label={`通知中心${unreadCount > 0 ? `,${unreadCount}条未读` : ""}`}
|
aria-label={`通知中心${unreadCount > 0 ? `,${unreadCount}条未读` : ""}`}
|
||||||
onClick={() => { setOpen((v) => !v); setNow(Date.now()); }}
|
onClick={() => { setOpen((v) => !v); setNow(Date.now()); }}
|
||||||
>
|
>
|
||||||
<BellOutlined />
|
<ShellIcon name="bell" />
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className="notification-center__badge">{unreadCount > 99 ? "99+" : unreadCount}</span>
|
<span className="notification-center__badge">{unreadCount > 99 ? "99+" : unreadCount}</span>
|
||||||
)}
|
)}
|
||||||
@@ -127,7 +118,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl
|
|||||||
)}
|
)}
|
||||||
{notifications.length > 0 && onClear && (
|
{notifications.length > 0 && onClear && (
|
||||||
<button className="notification-center__clear" type="button" onClick={() => { onClear(); setOpen(false); }}>
|
<button className="notification-center__clear" type="button" onClick={() => { onClear(); setOpen(false); }}>
|
||||||
<DeleteOutlined /> 清空
|
<ShellIcon name="delete" /> 清空
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +126,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl
|
|||||||
<div className="notification-center__list">
|
<div className="notification-center__list">
|
||||||
{notifications.length === 0 ? (
|
{notifications.length === 0 ? (
|
||||||
<div className="notification-center__empty">
|
<div className="notification-center__empty">
|
||||||
<BellOutlined style={{ fontSize: 28, opacity: 0.3 }} />
|
<ShellIcon name="bell" style={{ fontSize: 28, opacity: 0.3 }} />
|
||||||
<span>暂无通知</span>
|
<span>暂无通知</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons";
|
import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons";
|
||||||
import { useMemo, useState, type ReactNode } from "react";
|
import { useMemo, useState, type ReactNode } from "react";
|
||||||
|
import "../../styles/components/recharge-modal.css";
|
||||||
import { keyServerClient, type RechargeOrderResult } from "../../api/keyServerClient";
|
import { keyServerClient, type RechargeOrderResult } from "../../api/keyServerClient";
|
||||||
import { toast } from "../toast/toastStore";
|
import { toast } from "../toast/toastStore";
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ const paymentMethods: Array<{ id: PaymentMethod; label: string; hint: string }>
|
|||||||
{ id: "bank", label: "对公转账", hint: "企业客户可联系客服确认" },
|
{ id: "bank", label: "对公转账", hint: "企业客户可联系客服确认" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface RechargeModalProps {
|
export interface RechargeModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
currentBalance?: number;
|
currentBalance?: number;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
import type { RechargeModalProps } from "./RechargeModal";
|
||||||
|
|
||||||
|
export type RechargeModalComponent = ComponentType<RechargeModalProps>;
|
||||||
|
|
||||||
|
let rechargeModalPromise: Promise<RechargeModalComponent> | null = null;
|
||||||
|
|
||||||
|
export function loadRechargeModal(): Promise<RechargeModalComponent> {
|
||||||
|
if (!rechargeModalPromise) {
|
||||||
|
rechargeModalPromise = import("./RechargeModal").then((module) => module.RechargeModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rechargeModalPromise;
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
export type ShellIconName =
|
||||||
|
| "arrow-down"
|
||||||
|
| "arrow-left"
|
||||||
|
| "arrow-up"
|
||||||
|
| "bar-chart"
|
||||||
|
| "bell"
|
||||||
|
| "branches"
|
||||||
|
| "check-circle"
|
||||||
|
| "chevron-left"
|
||||||
|
| "chevron-right"
|
||||||
|
| "close-circle"
|
||||||
|
| "copy"
|
||||||
|
| "customer-service"
|
||||||
|
| "delete"
|
||||||
|
| "dislike"
|
||||||
|
| "download"
|
||||||
|
| "exclamation-circle"
|
||||||
|
| "flag"
|
||||||
|
| "file-text"
|
||||||
|
| "folder"
|
||||||
|
| "global"
|
||||||
|
| "heart"
|
||||||
|
| "home"
|
||||||
|
| "info-circle"
|
||||||
|
| "like"
|
||||||
|
| "line-chart"
|
||||||
|
| "lock"
|
||||||
|
| "login"
|
||||||
|
| "logout"
|
||||||
|
| "loading"
|
||||||
|
| "plus-circle"
|
||||||
|
| "reload"
|
||||||
|
| "robot"
|
||||||
|
| "shopping"
|
||||||
|
| "swap"
|
||||||
|
| "team"
|
||||||
|
| "thunderbolt"
|
||||||
|
| "tool"
|
||||||
|
| "upload"
|
||||||
|
| "user"
|
||||||
|
| "wallet"
|
||||||
|
| "warning";
|
||||||
|
|
||||||
|
interface ShellIconProps {
|
||||||
|
name: ShellIconName;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIcon(name: ShellIconName) {
|
||||||
|
switch (name) {
|
||||||
|
case "arrow-down":
|
||||||
|
return <path d="M12 5v14m0 0 6-6m-6 6-6-6" />;
|
||||||
|
case "arrow-left":
|
||||||
|
return <path d="M19 12H5m0 0 6-6m-6 6 6 6" />;
|
||||||
|
case "arrow-up":
|
||||||
|
return <path d="M12 19V5m0 0 6 6m-6-6-6 6" />;
|
||||||
|
case "bar-chart":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M4 19V5" />
|
||||||
|
<path d="M4 19h16" />
|
||||||
|
<path d="M8 16v-5" />
|
||||||
|
<path d="M12 16V8" />
|
||||||
|
<path d="M16 16v-9" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "bell":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M18 9a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
|
||||||
|
<path d="M10 21h4" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "branches":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx="6" cy="6" r="2" />
|
||||||
|
<circle cx="18" cy="6" r="2" />
|
||||||
|
<circle cx="12" cy="18" r="2" />
|
||||||
|
<path d="M8 7.5 12 12l4-4.5" />
|
||||||
|
<path d="M12 12v4" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "check-circle":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="m8 12 2.5 2.5L16 9" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "chevron-left":
|
||||||
|
return <path d="m15 18-6-6 6-6" />;
|
||||||
|
case "chevron-right":
|
||||||
|
return <path d="m9 18 6-6-6-6" />;
|
||||||
|
case "close-circle":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="m9 9 6 6m0-6-6 6" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "copy":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<rect x="8" y="8" width="11" height="11" rx="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "customer-service":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M4 13a8 8 0 0 1 16 0" />
|
||||||
|
<path d="M5 13h3v5H5a2 2 0 0 1-2-2v-1a2 2 0 0 1 2-2Z" />
|
||||||
|
<path d="M16 13h3a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2h-3v-5Z" />
|
||||||
|
<path d="M18 18c0 2-2 3-6 3" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "delete":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M4 7h16" />
|
||||||
|
<path d="M10 11v6" />
|
||||||
|
<path d="M14 11v6" />
|
||||||
|
<path d="M6 7l1 14h10l1-14" />
|
||||||
|
<path d="M9 7V4h6v3" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "download":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M12 4v11" />
|
||||||
|
<path d="m7 10 5 5 5-5" />
|
||||||
|
<path d="M5 20h14" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "dislike":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M7 3v12" />
|
||||||
|
<path d="M7 15h9l-1 5a2 2 0 0 1-3 1l-3-6H5a2 2 0 0 1-2-2V6a3 3 0 0 1 3-3h1" />
|
||||||
|
<path d="M17 3h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-3" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "exclamation-circle":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 7v6" />
|
||||||
|
<path d="M12 17h.01" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "flag":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M5 21V4" />
|
||||||
|
<path d="M5 5h11l-1.5 4L16 13H5" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "file-text":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9Z" />
|
||||||
|
<path d="M14 3v6h6" />
|
||||||
|
<path d="M8 13h8" />
|
||||||
|
<path d="M8 17h6" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "folder":
|
||||||
|
return <path d="M3 7h7l2 2h9v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />;
|
||||||
|
case "global":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M3 12h18" />
|
||||||
|
<path d="M12 3c3 3 3 15 0 18" />
|
||||||
|
<path d="M12 3c-3 3-3 15 0 18" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "heart":
|
||||||
|
return <path d="M20 8.5c0 5-8 10.5-8 10.5S4 13.5 4 8.5A4.5 4.5 0 0 1 12 6a4.5 4.5 0 0 1 8 2.5Z" />;
|
||||||
|
case "home":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M3 11 12 4l9 7" />
|
||||||
|
<path d="M5 10v10h14V10" />
|
||||||
|
<path d="M10 20v-6h4v6" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "info-circle":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 11v6" />
|
||||||
|
<path d="M12 7h.01" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "like":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M7 21V9" />
|
||||||
|
<path d="M7 9h3l3-6a2 2 0 0 1 3 1l-1 5h4a2 2 0 0 1 2 2l-2 8a3 3 0 0 1-3 2H7" />
|
||||||
|
<path d="M3 10h4v10H3z" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "line-chart":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M4 19V5" />
|
||||||
|
<path d="M4 19h16" />
|
||||||
|
<path d="m7 15 4-4 3 3 5-7" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "lock":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<rect x="5" y="10" width="14" height="10" rx="2" />
|
||||||
|
<path d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "login":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M14 4h5v16h-5" />
|
||||||
|
<path d="M4 12h10" />
|
||||||
|
<path d="m10 8 4 4-4 4" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "logout":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M10 4H5v16h5" />
|
||||||
|
<path d="M20 12H10" />
|
||||||
|
<path d="m14 8-4 4 4 4" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "loading":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M12 3a9 9 0 1 1-8 5" />
|
||||||
|
<path d="M4 3v5h5" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "plus-circle":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 8v8" />
|
||||||
|
<path d="M8 12h8" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "reload":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M20 12a8 8 0 1 1-2.3-5.7" />
|
||||||
|
<path d="M20 4v6h-6" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "robot":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<rect x="5" y="8" width="14" height="11" rx="3" />
|
||||||
|
<path d="M12 8V4" />
|
||||||
|
<path d="M8 13h.01" />
|
||||||
|
<path d="M16 13h.01" />
|
||||||
|
<path d="M9 17h6" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "shopping":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M6 7h15l-2 8H8L6 7Z" />
|
||||||
|
<path d="M6 7 5 4H2" />
|
||||||
|
<circle cx="9" cy="20" r="1.5" />
|
||||||
|
<circle cx="18" cy="20" r="1.5" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "swap":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M7 7h13m0 0-4-4m4 4-4 4" />
|
||||||
|
<path d="M17 17H4m0 0 4-4m-4 4 4 4" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "team":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx="9" cy="8" r="3" />
|
||||||
|
<path d="M3 20a6 6 0 0 1 12 0" />
|
||||||
|
<path d="M16 11a3 3 0 1 0-1-5.8" />
|
||||||
|
<path d="M17 20a5 5 0 0 0-3-4.6" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "thunderbolt":
|
||||||
|
return <path d="M13 2 4 14h7l-1 8 10-13h-7l1-7Z" />;
|
||||||
|
case "tool":
|
||||||
|
return <path d="M14.5 5.5a5 5 0 0 0 4 6.5L9 21l-6-6 9-9.5a5 5 0 0 0 2.5 0Z" />;
|
||||||
|
case "upload":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M12 20V9" />
|
||||||
|
<path d="m7 14 5-5 5 5" />
|
||||||
|
<path d="M5 4h14" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "user":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="8" r="4" />
|
||||||
|
<path d="M4 21a8 8 0 0 1 16 0" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "wallet":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M4 7h15a2 2 0 0 1 2 2v10H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h12" />
|
||||||
|
<path d="M16 13h5" />
|
||||||
|
<path d="M17 16h.01" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "warning":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M12 3 2 20h20L12 3Z" />
|
||||||
|
<path d="M12 9v5" />
|
||||||
|
<path d="M12 17h.01" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <circle cx="12" cy="12" r="8" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShellIcon({ name, className, style }: ShellIconProps) {
|
||||||
|
return (
|
||||||
|
<span className={["anticon", "shell-icon", className].filter(Boolean).join(" ")} style={style} aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{renderIcon(name)}
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
import "../styles/components/skeleton.css";
|
||||||
|
|
||||||
interface SkeletonProps {
|
interface SkeletonProps {
|
||||||
width?: string | number;
|
width?: string | number;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import "../styles/pages/studio-layout.css";
|
||||||
|
|
||||||
interface StudioToolLayoutProps {
|
interface StudioToolLayoutProps {
|
||||||
toolstrip?: ReactNode;
|
toolstrip?: ReactNode;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import "../../styles/pages/agent.css";
|
||||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||||
import type { WebGenerationPreviewTask } from "../../types";
|
import type { WebGenerationPreviewTask } from "../../types";
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
UserOutlined,
|
UserOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react";
|
||||||
|
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";
|
||||||
import { useDebounce } from "../../hooks/useDebounce";
|
import { useDebounce } from "../../hooks/useDebounce";
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
interface CanvasMarkingPopoverProps {
|
||||||
|
value?: string;
|
||||||
|
placeholder: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanvasMarkingPopover({
|
||||||
|
value,
|
||||||
|
placeholder,
|
||||||
|
onChange,
|
||||||
|
onClear,
|
||||||
|
onDone,
|
||||||
|
}: CanvasMarkingPopoverProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="studio-canvas-marking-popover"
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className="studio-canvas-marking-input"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="studio-canvas-marking-actions">
|
||||||
|
{value ? (
|
||||||
|
<button type="button" className="studio-canvas-marking-clear" onClick={onClear}>
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button type="button" className="studio-canvas-marking-done" onClick={onDone}>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,10 +28,13 @@
|
|||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
|
import "@xyflow/react/dist/style.css";
|
||||||
|
import "../../styles/pages/canvas.css";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react";
|
||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
|
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
|
||||||
import { communityClient } from "../../api/communityClient";
|
import { communityClient } from "../../api/communityClient";
|
||||||
|
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 {
|
import type {
|
||||||
@@ -52,6 +55,7 @@ import { useCanvasHistory, type CanvasHistorySnapshot } from "./useCanvasHistory
|
|||||||
import { useCanvasKeyboard } from "./useCanvasKeyboard";
|
import { useCanvasKeyboard } from "./useCanvasKeyboard";
|
||||||
import { useCanvasNodeDrag } from "./useCanvasNodeDrag";
|
import { useCanvasNodeDrag } from "./useCanvasNodeDrag";
|
||||||
import { useCanvasGeneration, addCanvasGenKeepalive, removeCanvasGenKeepalive } from "./useCanvasGeneration";
|
import { useCanvasGeneration, addCanvasGenKeepalive, removeCanvasGenKeepalive } from "./useCanvasGeneration";
|
||||||
|
import { useCanvasAssetSummary, useCanvasVisibleNodes } from "./useCanvasDerivedState";
|
||||||
import {
|
import {
|
||||||
toHappyHorseDisplayModel,
|
toHappyHorseDisplayModel,
|
||||||
} from "../../utils/happyHorseRouting";
|
} from "../../utils/happyHorseRouting";
|
||||||
@@ -118,7 +122,7 @@ import {
|
|||||||
defaultVideoModel,
|
defaultVideoModel,
|
||||||
image4kCapableModels,
|
image4kCapableModels,
|
||||||
imageFocusRatioOptions,
|
imageFocusRatioOptions,
|
||||||
imageModelOptions,
|
imageModelOptions as fallbackCanvasImageModelOptions,
|
||||||
imageRatioOptions,
|
imageRatioOptions,
|
||||||
textModelOptions,
|
textModelOptions,
|
||||||
videoDurationOptions,
|
videoDurationOptions,
|
||||||
@@ -182,6 +186,8 @@ import {
|
|||||||
} from "./canvasWorkflowDeserialize";
|
} from "./canvasWorkflowDeserialize";
|
||||||
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
|
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
|
||||||
import type { CanvasNodeToolbarAction } from "./canvasComponents";
|
import type { CanvasNodeToolbarAction } from "./canvasComponents";
|
||||||
|
import { CanvasMarkingPopover } from "./CanvasMarkingPopover";
|
||||||
|
import { CanvasPromptMentionTextarea, CanvasTextPromptComposer } from "./CanvasTextPromptComposer";
|
||||||
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
|
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
|
||||||
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";
|
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";
|
||||||
|
|
||||||
@@ -193,7 +199,6 @@ const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL
|
|||||||
// --- Canvas generation keep-alive (survives page refresh / view switch) ---
|
// --- Canvas generation keep-alive (survives page refresh / view switch) ---
|
||||||
|
|
||||||
const MENTION_TOKEN_RE = /@(?:图片|视频|文本)\d+/g;
|
const MENTION_TOKEN_RE = /@(?:图片|视频|文本)\d+/g;
|
||||||
const MENTION_BOUNDARY_RE = /\s|[,。、;:!??(){}[\]<>]/;
|
|
||||||
|
|
||||||
function buildNodeMentionOptions(
|
function buildNodeMentionOptions(
|
||||||
kind: CanvasNodeKind,
|
kind: CanvasNodeKind,
|
||||||
@@ -354,6 +359,8 @@ function CanvasPage({
|
|||||||
const [projectNameEditing, setProjectNameEditing] = useState(false);
|
const [projectNameEditing, setProjectNameEditing] = useState(false);
|
||||||
const [videoNodeMenu, setVideoNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
|
const [videoNodeMenu, setVideoNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
|
||||||
const [videoNodes, setVideoNodes] = useState<CanvasVideoNode[]>([]);
|
const [videoNodes, setVideoNodes] = useState<CanvasVideoNode[]>([]);
|
||||||
|
const [canvasImageModelOptions, setCanvasImageModelOptions] = useState<CanvasOption[]>(fallbackCanvasImageModelOptions);
|
||||||
|
const [canvasVideoModelOptions, setCanvasVideoModelOptions] = useState<CanvasOption[]>(canvasEnterpriseVideoModelOptions);
|
||||||
const [selectedNode, setSelectedNode] = useState<CanvasSelectedNode | null>(null);
|
const [selectedNode, setSelectedNode] = useState<CanvasSelectedNode | null>(null);
|
||||||
const [selectedNodes, setSelectedNodes] = useState<CanvasSelectedNode[]>([]);
|
const [selectedNodes, setSelectedNodes] = useState<CanvasSelectedNode[]>([]);
|
||||||
const [selectionContextMenu, setSelectionContextMenu] = useState<CanvasFloatingMenuPosition | null>(null);
|
const [selectionContextMenu, setSelectionContextMenu] = useState<CanvasFloatingMenuPosition | null>(null);
|
||||||
@@ -396,10 +403,12 @@ function CanvasPage({
|
|||||||
const suppressNextPaneClickRef = useRef(false);
|
const suppressNextPaneClickRef = useRef(false);
|
||||||
const canvasAutoSaveTimerRef = useRef<number | null>(null);
|
const canvasAutoSaveTimerRef = useRef<number | null>(null);
|
||||||
const canvasAutoSaveIdleHandleRef = useRef<number | null>(null);
|
const canvasAutoSaveIdleHandleRef = useRef<number | null>(null);
|
||||||
|
const canvasAutoSaveRetryTimerRef = useRef<number | null>(null);
|
||||||
const canvasAutoSaveInFlightRef = useRef(false);
|
const canvasAutoSaveInFlightRef = useRef(false);
|
||||||
const canvasAutoSavePendingRef = useRef(false);
|
const canvasAutoSavePendingRef = useRef(false);
|
||||||
const lastAutoSavedWorkflowFingerprintRef = useRef("");
|
const lastAutoSavedWorkflowFingerprintRef = useRef("");
|
||||||
const canvasAutoSaveHydrationRef = useRef(true);
|
const canvasAutoSaveHydrationRef = useRef(true);
|
||||||
|
const textNodeMentionFocusTimerRef = useRef<number | null>(null);
|
||||||
const textNodeIdRef = useRef(9);
|
const textNodeIdRef = useRef(9);
|
||||||
const imageNodeIdRef = useRef(1);
|
const imageNodeIdRef = useRef(1);
|
||||||
const videoNodeIdRef = useRef(1);
|
const videoNodeIdRef = useRef(1);
|
||||||
@@ -460,9 +469,39 @@ function CanvasPage({
|
|||||||
callbacksRef: dragCallbacksRef,
|
callbacksRef: dragCallbacksRef,
|
||||||
suppressNextPaneClickRef,
|
suppressNextPaneClickRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setCanvasImageModelOptions(fallbackCanvasImageModelOptions);
|
||||||
|
setCanvasVideoModelOptions(canvasEnterpriseVideoModelOptions);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
modelCapabilitiesClient
|
||||||
|
.get()
|
||||||
|
.then((capabilities) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setCanvasImageModelOptions(capabilities.imageModels.length ? capabilities.imageModels : fallbackCanvasImageModelOptions);
|
||||||
|
setCanvasVideoModelOptions(capabilities.videoModels.length ? capabilities.videoModels : canvasEnterpriseVideoModelOptions);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setCanvasImageModelOptions(fallbackCanvasImageModelOptions);
|
||||||
|
setCanvasVideoModelOptions(canvasEnterpriseVideoModelOptions);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
const visibleImageModelOptions = useMemo(
|
const visibleImageModelOptions = useMemo(
|
||||||
() => filterImageModelOptionsForSession(imageModelOptions, session),
|
() => filterImageModelOptionsForSession(canvasImageModelOptions, session),
|
||||||
[session],
|
[canvasImageModelOptions, session],
|
||||||
);
|
);
|
||||||
const fallbackVisibleImageModel = visibleImageModelOptions[0]?.value || defaultImageModel;
|
const fallbackVisibleImageModel = visibleImageModelOptions[0]?.value || defaultImageModel;
|
||||||
const resolveVisibleImageModel = useCallback(
|
const resolveVisibleImageModel = useCallback(
|
||||||
@@ -488,7 +527,11 @@ function CanvasPage({
|
|||||||
else if (kind === "video") updateVideoNodePrompt(nodeId, nextValue);
|
else if (kind === "video") updateVideoNodePrompt(nodeId, nextValue);
|
||||||
else updateTextNodePrompt(nodeId, nextValue);
|
else updateTextNodePrompt(nodeId, nextValue);
|
||||||
closeTextNodeMention(nodeId);
|
closeTextNodeMention(nodeId);
|
||||||
setTimeout(() => {
|
if (textNodeMentionFocusTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(textNodeMentionFocusTimerRef.current);
|
||||||
|
}
|
||||||
|
textNodeMentionFocusTimerRef.current = window.setTimeout(() => {
|
||||||
|
textNodeMentionFocusTimerRef.current = null;
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
textarea.setSelectionRange(nextCaret, nextCaret);
|
textarea.setSelectionRange(nextCaret, nextCaret);
|
||||||
@@ -524,10 +567,22 @@ function CanvasPage({
|
|||||||
const [autoSaveStatus, setAutoSaveStatus] = useState<"saved" | "saving" | "error" | "idle">("idle");
|
const [autoSaveStatus, setAutoSaveStatus] = useState<"saved" | "saving" | "error" | "idle">("idle");
|
||||||
const autoSaveStatusTimerRef = useRef<number | null>(null);
|
const autoSaveStatusTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current);
|
||||||
|
if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
|
||||||
|
if (autoSaveStatusTimerRef.current !== null) window.clearTimeout(autoSaveStatusTimerRef.current);
|
||||||
|
if (textNodeMentionFocusTimerRef.current !== null) window.clearTimeout(textNodeMentionFocusTimerRef.current);
|
||||||
|
if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
|
||||||
|
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Save immediately when user leaves page or switches tab (placed after runCanvasAutoSave definition)
|
// Save immediately when user leaves page or switches tab (placed after runCanvasAutoSave definition)
|
||||||
// — see useEffect below near runCanvasAutoSave
|
// — see useEffect below near runCanvasAutoSave
|
||||||
|
|
||||||
const canvasAssets = serverAssets.filter((asset) => asset.imageUrl);
|
const { canvasAssets, assetCountsByCategory } = useCanvasAssetSummary(serverAssets);
|
||||||
const shouldShowEmptyProjectState =
|
const shouldShowEmptyProjectState =
|
||||||
projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0;
|
projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0;
|
||||||
const isWaitingForProjects = isAuthenticated && !projectsLoaded;
|
const isWaitingForProjects = isAuthenticated && !projectsLoaded;
|
||||||
@@ -2704,13 +2759,17 @@ function CanvasPage({
|
|||||||
setConnectorDrag(null);
|
setConnectorDrag(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const collapsedPackageNodeKeys = new Set(
|
const {
|
||||||
nodePackages.flatMap((nodePackage) =>
|
isNodeCollapsedInPackage,
|
||||||
nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : []
|
visibleTextNodes,
|
||||||
)
|
visibleImageNodes,
|
||||||
);
|
visibleVideoNodes,
|
||||||
const isNodeCollapsedInPackage = (kind: CanvasNodeKind, id: string) =>
|
} = useCanvasVisibleNodes({
|
||||||
collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind, id }));
|
textNodes,
|
||||||
|
imageNodes,
|
||||||
|
videoNodes,
|
||||||
|
nodePackages,
|
||||||
|
});
|
||||||
const isLinkCollapsedInPackage = (link: { sourceKind: CanvasNodeKind; sourceNodeId: string; targetKind: CanvasNodeKind; targetNodeId: string }) =>
|
const isLinkCollapsedInPackage = (link: { sourceKind: CanvasNodeKind; sourceNodeId: string; targetKind: CanvasNodeKind; targetNodeId: string }) =>
|
||||||
isNodeCollapsedInPackage(link.sourceKind, link.sourceNodeId) ||
|
isNodeCollapsedInPackage(link.sourceKind, link.sourceNodeId) ||
|
||||||
isNodeCollapsedInPackage(link.targetKind, link.targetNodeId);
|
isNodeCollapsedInPackage(link.targetKind, link.targetNodeId);
|
||||||
@@ -3243,7 +3302,13 @@ function CanvasPage({
|
|||||||
canvasAutoSaveInFlightRef.current = false;
|
canvasAutoSaveInFlightRef.current = false;
|
||||||
if (canvasAutoSavePendingRef.current) {
|
if (canvasAutoSavePendingRef.current) {
|
||||||
canvasAutoSavePendingRef.current = false;
|
canvasAutoSavePendingRef.current = false;
|
||||||
window.setTimeout(() => void runCanvasAutoSave(), canvasAutoSaveIdleTimeoutMs);
|
if (canvasAutoSaveRetryTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
|
||||||
|
}
|
||||||
|
canvasAutoSaveRetryTimerRef.current = window.setTimeout(() => {
|
||||||
|
canvasAutoSaveRetryTimerRef.current = null;
|
||||||
|
void runCanvasAutoSave();
|
||||||
|
}, canvasAutoSaveIdleTimeoutMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -3312,7 +3377,13 @@ function CanvasPage({
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.setTimeout(() => void runCanvasAutoSave(), canvasAutoSaveIdleTimeoutMs);
|
if (canvasAutoSaveRetryTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
|
||||||
|
}
|
||||||
|
canvasAutoSaveRetryTimerRef.current = window.setTimeout(() => {
|
||||||
|
canvasAutoSaveRetryTimerRef.current = null;
|
||||||
|
void runCanvasAutoSave();
|
||||||
|
}, canvasAutoSaveIdleTimeoutMs);
|
||||||
}, canvasAutoSaveDebounceMs);
|
}, canvasAutoSaveDebounceMs);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -3324,6 +3395,10 @@ function CanvasPage({
|
|||||||
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
|
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
|
||||||
canvasAutoSaveIdleHandleRef.current = null;
|
canvasAutoSaveIdleHandleRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (canvasAutoSaveRetryTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
|
||||||
|
canvasAutoSaveRetryTimerRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
@@ -3571,9 +3646,14 @@ function CanvasPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const toDelete = selectedNode ? [selectedNode] : selectedNodes;
|
const toDelete = selectedNode ? [selectedNode] : selectedNodes;
|
||||||
const textIds = new Set(toDelete.filter((n) => n.kind === "text").map((n) => n.id));
|
const textIds = new Set<string>();
|
||||||
const imageIds = new Set(toDelete.filter((n) => n.kind === "image").map((n) => n.id));
|
const imageIds = new Set<string>();
|
||||||
const videoIds = new Set(toDelete.filter((n) => n.kind === "video").map((n) => n.id));
|
const videoIds = new Set<string>();
|
||||||
|
for (const node of toDelete) {
|
||||||
|
if (node.kind === "text") textIds.add(node.id);
|
||||||
|
else if (node.kind === "image") imageIds.add(node.id);
|
||||||
|
else if (node.kind === "video") videoIds.add(node.id);
|
||||||
|
}
|
||||||
if (textIds.size) setTextNodes((nodes) => nodes.filter((n) => !textIds.has(n.id)));
|
if (textIds.size) setTextNodes((nodes) => nodes.filter((n) => !textIds.has(n.id)));
|
||||||
if (imageIds.size) setImageNodes((nodes) => nodes.filter((n) => !imageIds.has(n.id)));
|
if (imageIds.size) setImageNodes((nodes) => nodes.filter((n) => !imageIds.has(n.id)));
|
||||||
if (videoIds.size) setVideoNodes((nodes) => nodes.filter((n) => !videoIds.has(n.id)));
|
if (videoIds.size) setVideoNodes((nodes) => nodes.filter((n) => !videoIds.has(n.id)));
|
||||||
@@ -4054,7 +4134,7 @@ function CanvasPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</svg>
|
</svg>
|
||||||
) : null}
|
) : null}
|
||||||
{textNodes.filter((textNode) => !isNodeCollapsedInPackage("text", textNode.id)).map((textNode) => {
|
{visibleTextNodes.map((textNode) => {
|
||||||
const textNodeSelected = isSelectedNode("text", textNode.id);
|
const textNodeSelected = isSelectedNode("text", textNode.id);
|
||||||
const textNodeActive = isActiveSelectedNode("text", textNode.id);
|
const textNodeActive = isActiveSelectedNode("text", textNode.id);
|
||||||
const textNodeResizing = nodeResizeDrag?.kind === "text" && nodeResizeDrag.nodeId === textNode.id;
|
const textNodeResizing = nodeResizeDrag?.kind === "text" && nodeResizeDrag.nodeId === textNode.id;
|
||||||
@@ -4203,132 +4283,26 @@ function CanvasPage({
|
|||||||
onMouseDown={(event) => handleNodeResizeStart(event, "text", textNode.id, textNode.size)}
|
onMouseDown={(event) => handleNodeResizeStart(event, "text", textNode.id, textNode.size)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{textNodeActive && !isCanvasNodeMoving ? (() => {
|
{textNodeActive && !isCanvasNodeMoving ? (
|
||||||
const mentionOptions = buildNodeMentionOptions("text", textNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
|
<CanvasTextPromptComposer
|
||||||
const mentionState = textNodeMentionStates[textNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
|
nodeId={textNode.id}
|
||||||
const filteredMentions = mentionState.open
|
prompt={textNode.prompt}
|
||||||
? mentionOptions.filter((o) => !mentionState.query || o.searchText.includes(mentionState.query.toLowerCase()))
|
canGenerate={textNodeCanGenerate}
|
||||||
: [];
|
isGenerating={textNodeGenerating}
|
||||||
|
mentionOptions={buildNodeMentionOptions("text", textNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
|
||||||
const handlePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
mentionState={textNodeMentionStates[textNode.id]}
|
||||||
const value = e.target.value;
|
onPromptChange={updateTextNodePrompt}
|
||||||
const caret = e.target.selectionStart || 0;
|
onMentionStateChange={setTextNodeMentionStates}
|
||||||
updateTextNodePrompt(textNode.id, value);
|
onCloseMention={closeTextNodeMention}
|
||||||
|
onInsertMention={insertTextNodeMention}
|
||||||
// Detect @-mention trigger
|
onGenerate={handleGenerateTextNode}
|
||||||
const beforeCaret = value.slice(0, caret);
|
|
||||||
const atIdx = beforeCaret.lastIndexOf("@");
|
|
||||||
if (atIdx >= 0) {
|
|
||||||
const query = beforeCaret.slice(atIdx + 1);
|
|
||||||
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
|
|
||||||
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
closeTextNodeMention(textNode.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (!mentionState.open || filteredMentions.length === 0) return;
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { ...mentionState, activeIndex: (mentionState.activeIndex + 1) % filteredMentions.length } }));
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { ...mentionState, activeIndex: (mentionState.activeIndex - 1 + filteredMentions.length) % filteredMentions.length } }));
|
|
||||||
} else if (e.key === "Enter" || e.key === "Tab") {
|
|
||||||
e.preventDefault();
|
|
||||||
const opt = filteredMentions[mentionState.activeIndex];
|
|
||||||
if (opt) {
|
|
||||||
const ta = e.currentTarget;
|
|
||||||
insertTextNodeMention(textNode.id, opt, ta);
|
|
||||||
}
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
closeTextNodeMention(textNode.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePromptSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
|
||||||
const ta = e.currentTarget;
|
|
||||||
const caret = ta.selectionStart || 0;
|
|
||||||
setTextNodeMentionStates((prev) => {
|
|
||||||
const cur = prev[textNode.id];
|
|
||||||
if (!cur?.open) return prev;
|
|
||||||
return { ...prev, [textNode.id]: { ...cur, caret } };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`studio-canvas-text-composer${textComposerDragNodeId === textNode.id ? " is-drag-over" : ""}`}
|
|
||||||
onDragEnter={(e) => handleTextComposerDragEnter(e, textNode.id)}
|
|
||||||
onDragOver={handleTextComposerDragOver}
|
|
||||||
onDragLeave={handleTextComposerDragLeave}
|
|
||||||
onDrop={(e) => handleTextComposerDrop(e, textNode)}
|
|
||||||
>
|
|
||||||
<div className="studio-canvas-text-composer__input-wrap">
|
|
||||||
<textarea
|
|
||||||
value={textNode.prompt}
|
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
|
||||||
onChange={handlePromptChange}
|
|
||||||
onKeyDown={handlePromptKeyDown}
|
|
||||||
onSelect={handlePromptSelect}
|
|
||||||
placeholder="写下你想讲的故事、场景或角色设定。@引用连接的节点"
|
|
||||||
/>
|
/>
|
||||||
{mentionState.open ? (
|
|
||||||
<div className="studio-canvas-mention-panel">
|
|
||||||
{filteredMentions.length > 0 ? filteredMentions.map((opt, idx) => (
|
|
||||||
<button
|
|
||||||
key={opt.token}
|
|
||||||
type="button"
|
|
||||||
className={`studio-canvas-mention-item${idx === mentionState.activeIndex ? " is-active" : ""}`}
|
|
||||||
onMouseDown={(e) => { e.preventDefault(); const ta = e.currentTarget.closest(".studio-canvas-text-composer")?.querySelector("textarea") || null; insertTextNodeMention(textNode.id, opt, ta as HTMLTextAreaElement | null); }}
|
|
||||||
>
|
|
||||||
<span className="studio-canvas-mention-thumb">
|
|
||||||
{opt.kind === "image" && opt.previewUrl ? <img src={opt.previewUrl} alt="" /> : opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}
|
|
||||||
</span>
|
|
||||||
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
|
|
||||||
<span className="studio-canvas-mention-token">{opt.token}</span>
|
|
||||||
</button>
|
|
||||||
)) : (
|
|
||||||
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}>
|
|
||||||
<span className="studio-canvas-mention-label">没有可引用的连接节点</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-canvas-text-composer__footer">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`studio-canvas-text-composer__send studio-canvas-generate-button${textNodeCanGenerate && !textNodeGenerating ? " is-ready" : ""}`}
|
|
||||||
title={textNodeGenerating ? "生成中" : "生成"}
|
|
||||||
disabled={textNodeGenerating || !textNodeCanGenerate}
|
|
||||||
aria-busy={textNodeGenerating}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (!textNodeGenerating && textNodeCanGenerate) {
|
|
||||||
void handleGenerateTextNode(textNode.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SendOutlined />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})() : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{imageNodes.filter((imageNode) => !isNodeCollapsedInPackage("image", imageNode.id)).map((imageNode) => {
|
{visibleImageNodes.map((imageNode) => {
|
||||||
const imageNodeSelected = isSelectedNode("image", imageNode.id);
|
const imageNodeSelected = isSelectedNode("image", imageNode.id);
|
||||||
const imageNodeActive = isActiveSelectedNode("image", imageNode.id);
|
const imageNodeActive = isActiveSelectedNode("image", imageNode.id);
|
||||||
const imageNodeResizing = nodeResizeDrag?.kind === "image" && nodeResizeDrag.nodeId === imageNode.id;
|
const imageNodeResizing = nodeResizeDrag?.kind === "image" && nodeResizeDrag.nodeId === imageNode.id;
|
||||||
@@ -4586,38 +4560,7 @@ function CanvasPage({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{imageNodeActive && !isCanvasNodeMoving && !imageNodeFocusActive ? (() => {
|
{imageNodeActive && !isCanvasNodeMoving && !imageNodeFocusActive ? (
|
||||||
const imgMentionOptions = buildNodeMentionOptions("image", imageNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
|
|
||||||
const imgMentionState = textNodeMentionStates[imageNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
|
|
||||||
const imgFilteredMentions = imgMentionState.open
|
|
||||||
? imgMentionOptions.filter((o) => !imgMentionState.query || o.searchText.includes(imgMentionState.query.toLowerCase()))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const handleImagePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
const caret = e.target.selectionStart || 0;
|
|
||||||
updateImageNodePrompt(imageNode.id, value);
|
|
||||||
const beforeCaret = value.slice(0, caret);
|
|
||||||
const atIdx = beforeCaret.lastIndexOf("@");
|
|
||||||
if (atIdx >= 0) {
|
|
||||||
const query = beforeCaret.slice(atIdx + 1);
|
|
||||||
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
|
|
||||||
setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
closeTextNodeMention(imageNode.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImagePromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (!imgMentionState.open || imgFilteredMentions.length === 0) return;
|
|
||||||
if (e.key === "ArrowDown") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { ...imgMentionState, activeIndex: (imgMentionState.activeIndex + 1) % imgFilteredMentions.length } })); }
|
|
||||||
else if (e.key === "ArrowUp") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { ...imgMentionState, activeIndex: (imgMentionState.activeIndex - 1 + imgFilteredMentions.length) % imgFilteredMentions.length } })); }
|
|
||||||
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const opt = imgFilteredMentions[imgMentionState.activeIndex]; if (opt) insertTextNodeMention(imageNode.id, opt, e.currentTarget, "image"); }
|
|
||||||
else if (e.key === "Escape") { e.preventDefault(); closeTextNodeMention(imageNode.id); }
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="studio-canvas-image-composer">
|
<div className="studio-canvas-image-composer">
|
||||||
<div className="studio-canvas-image-composer__tools">
|
<div className="studio-canvas-image-composer__tools">
|
||||||
<button
|
<button
|
||||||
@@ -4656,47 +4599,23 @@ function CanvasPage({
|
|||||||
>
|
>
|
||||||
<FileImageOutlined /><span>标记</span>
|
<FileImageOutlined /><span>标记</span>
|
||||||
</button>
|
</button>
|
||||||
{markingPopoverNodeId === imageNode.id && (
|
{markingPopoverNodeId === imageNode.id ? (
|
||||||
<div
|
<CanvasMarkingPopover
|
||||||
className="studio-canvas-marking-popover"
|
value={imageNode.marking}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
className="studio-canvas-marking-input"
|
|
||||||
placeholder="描述标记内容,如:主角站在桥上,远处是城市天际线"
|
placeholder="描述标记内容,如:主角站在桥上,远处是城市天际线"
|
||||||
value={imageNode.marking || ""}
|
onChange={(value) => {
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
setImageNodes((nodes) =>
|
setImageNodes((nodes) =>
|
||||||
nodes.map((n) => (n.id === imageNode.id ? { ...n, marking: val } : n)),
|
nodes.map((node) => (node.id === imageNode.id ? { ...node, marking: value } : node)),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
setImageNodes((nodes) =>
|
||||||
|
nodes.map((node) => (node.id === imageNode.id ? { ...node, marking: "" } : node)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onDone={() => setMarkingPopoverNodeId(null)}
|
||||||
/>
|
/>
|
||||||
<div className="studio-canvas-marking-actions">
|
) : null}
|
||||||
{imageNode.marking && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="studio-canvas-marking-clear"
|
|
||||||
onClick={() => {
|
|
||||||
setImageNodes((nodes) =>
|
|
||||||
nodes.map((n) => (n.id === imageNode.id ? { ...n, marking: "" } : n)),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
清除
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="studio-canvas-marking-done"
|
|
||||||
onClick={() => setMarkingPopoverNodeId(null)}
|
|
||||||
>
|
|
||||||
完成
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="多宫格生成"
|
title="多宫格生成"
|
||||||
@@ -4738,28 +4657,18 @@ function CanvasPage({
|
|||||||
</button>
|
</button>
|
||||||
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开">↗</button>
|
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开">↗</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-canvas-text-composer__input-wrap">
|
<CanvasPromptMentionTextarea
|
||||||
<textarea
|
nodeId={imageNode.id}
|
||||||
value={imageNode.prompt}
|
value={imageNode.prompt}
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
|
||||||
onChange={handleImagePromptChange}
|
|
||||||
onKeyDown={handleImagePromptKeyDown}
|
|
||||||
placeholder="描述你想要生成的画面内容,按/呼出指令,@引用素材"
|
placeholder="描述你想要生成的画面内容,按/呼出指令,@引用素材"
|
||||||
|
mentionOptions={buildNodeMentionOptions("image", imageNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
|
||||||
|
mentionState={textNodeMentionStates[imageNode.id]}
|
||||||
|
mentionKind="image"
|
||||||
|
onPromptChange={updateImageNodePrompt}
|
||||||
|
onMentionStateChange={setTextNodeMentionStates}
|
||||||
|
onCloseMention={closeTextNodeMention}
|
||||||
|
onInsertMention={insertTextNodeMention}
|
||||||
/>
|
/>
|
||||||
{imgMentionState.open && (
|
|
||||||
<div className="studio-canvas-mention-panel">
|
|
||||||
{imgFilteredMentions.length > 0 ? imgFilteredMentions.map((opt, idx) => (
|
|
||||||
<button key={opt.token} type="button" className={`studio-canvas-mention-item${idx === imgMentionState.activeIndex ? " is-active" : ""}`} onMouseDown={(e) => { e.preventDefault(); insertTextNodeMention(imageNode.id, opt, e.currentTarget.closest(".studio-canvas-image-composer")?.querySelector("textarea") || null, "image"); }}>
|
|
||||||
<span className="studio-canvas-mention-thumb">{opt.kind === "image" && opt.previewUrl ? <img src={opt.previewUrl} alt="" /> : opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}</span>
|
|
||||||
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
|
|
||||||
<span className="studio-canvas-mention-token">{opt.token}</span>
|
|
||||||
</button>
|
|
||||||
)) : (
|
|
||||||
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}><span className="studio-canvas-mention-label">没有可引用的连接节点</span></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="studio-canvas-image-composer__footer">
|
<div className="studio-canvas-image-composer__footer">
|
||||||
<CanvasSelectChip
|
<CanvasSelectChip
|
||||||
ariaLabel="选择生图模型"
|
ariaLabel="选择生图模型"
|
||||||
@@ -4827,12 +4736,12 @@ function CanvasPage({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
); })() : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{videoNodes.filter((videoNode) => !isNodeCollapsedInPackage("video", videoNode.id)).map((videoNode) => {
|
{visibleVideoNodes.map((videoNode) => {
|
||||||
const videoNodeSelected = isSelectedNode("video", videoNode.id);
|
const videoNodeSelected = isSelectedNode("video", videoNode.id);
|
||||||
const videoNodeActive = isActiveSelectedNode("video", videoNode.id);
|
const videoNodeActive = isActiveSelectedNode("video", videoNode.id);
|
||||||
const videoNodeResizing = nodeResizeDrag?.kind === "video" && nodeResizeDrag.nodeId === videoNode.id;
|
const videoNodeResizing = nodeResizeDrag?.kind === "video" && nodeResizeDrag.nodeId === videoNode.id;
|
||||||
@@ -4982,38 +4891,7 @@ function CanvasPage({
|
|||||||
onMouseDown={(event) => handleNodeResizeStart(event, "video", videoNode.id, videoNode.size)}
|
onMouseDown={(event) => handleNodeResizeStart(event, "video", videoNode.id, videoNode.size)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{videoNodeActive && !isCanvasNodeMoving ? (() => {
|
{videoNodeActive && !isCanvasNodeMoving ? (
|
||||||
const vidMentionOptions = buildNodeMentionOptions("video", videoNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
|
|
||||||
const vidMentionState = textNodeMentionStates[videoNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
|
|
||||||
const vidFilteredMentions = vidMentionState.open
|
|
||||||
? vidMentionOptions.filter((o) => !vidMentionState.query || o.searchText.includes(vidMentionState.query.toLowerCase()))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const handleVideoPromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
const caret = e.target.selectionStart || 0;
|
|
||||||
updateVideoNodePrompt(videoNode.id, value);
|
|
||||||
const beforeCaret = value.slice(0, caret);
|
|
||||||
const atIdx = beforeCaret.lastIndexOf("@");
|
|
||||||
if (atIdx >= 0) {
|
|
||||||
const query = beforeCaret.slice(atIdx + 1);
|
|
||||||
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
|
|
||||||
setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
closeTextNodeMention(videoNode.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVideoPromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (!vidMentionState.open || vidFilteredMentions.length === 0) return;
|
|
||||||
if (e.key === "ArrowDown") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { ...vidMentionState, activeIndex: (vidMentionState.activeIndex + 1) % vidFilteredMentions.length } })); }
|
|
||||||
else if (e.key === "ArrowUp") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { ...vidMentionState, activeIndex: (vidMentionState.activeIndex - 1 + vidFilteredMentions.length) % vidFilteredMentions.length } })); }
|
|
||||||
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const opt = vidFilteredMentions[vidMentionState.activeIndex]; if (opt) insertTextNodeMention(videoNode.id, opt, e.currentTarget, "video"); }
|
|
||||||
else if (e.key === "Escape") { e.preventDefault(); closeTextNodeMention(videoNode.id); }
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="studio-canvas-video-composer">
|
<div className="studio-canvas-video-composer">
|
||||||
<div className="studio-canvas-video-composer__tabs studio-canvas-video-composer__mode-tabs">
|
<div className="studio-canvas-video-composer__tabs studio-canvas-video-composer__mode-tabs">
|
||||||
<button
|
<button
|
||||||
@@ -5068,47 +4946,23 @@ function CanvasPage({
|
|||||||
>
|
>
|
||||||
运镜{videoNode.cameraMotion ? ` ${CAMERA_MOTION_PRESETS.find((p) => p.value === videoNode.cameraMotion)?.label || ""}` : ""}
|
运镜{videoNode.cameraMotion ? ` ${CAMERA_MOTION_PRESETS.find((p) => p.value === videoNode.cameraMotion)?.label || ""}` : ""}
|
||||||
</button>
|
</button>
|
||||||
{markingPopoverNodeId === videoNode.id && (
|
{markingPopoverNodeId === videoNode.id ? (
|
||||||
<div
|
<CanvasMarkingPopover
|
||||||
className="studio-canvas-marking-popover"
|
value={videoNode.marking}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
className="studio-canvas-marking-input"
|
|
||||||
placeholder="描述标记内容,如:主角在城市街头行走"
|
placeholder="描述标记内容,如:主角在城市街头行走"
|
||||||
value={videoNode.marking || ""}
|
onChange={(value) => {
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
setVideoNodes((nodes) =>
|
setVideoNodes((nodes) =>
|
||||||
nodes.map((n) => (n.id === videoNode.id ? { ...n, marking: val } : n)),
|
nodes.map((node) => (node.id === videoNode.id ? { ...node, marking: value } : node)),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
setVideoNodes((nodes) =>
|
||||||
|
nodes.map((node) => (node.id === videoNode.id ? { ...node, marking: "" } : node)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onDone={() => setMarkingPopoverNodeId(null)}
|
||||||
/>
|
/>
|
||||||
<div className="studio-canvas-marking-actions">
|
) : null}
|
||||||
{videoNode.marking && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="studio-canvas-marking-clear"
|
|
||||||
onClick={() => {
|
|
||||||
setVideoNodes((nodes) =>
|
|
||||||
nodes.map((n) => (n.id === videoNode.id ? { ...n, marking: "" } : n)),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
清除
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="studio-canvas-marking-done"
|
|
||||||
onClick={() => setMarkingPopoverNodeId(null)}
|
|
||||||
>
|
|
||||||
完成
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{cameraMotionDropdownNodeId === videoNode.id && (
|
{cameraMotionDropdownNodeId === videoNode.id && (
|
||||||
<div
|
<div
|
||||||
className="studio-canvas-camera-dropdown"
|
className="studio-canvas-camera-dropdown"
|
||||||
@@ -5135,43 +4989,24 @@ function CanvasPage({
|
|||||||
<button type="button">角色库</button>
|
<button type="button">角色库</button>
|
||||||
<button type="button" className="is-active">文本</button>
|
<button type="button" className="is-active">文本</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-canvas-text-composer__input-wrap">
|
<CanvasPromptMentionTextarea
|
||||||
<textarea
|
nodeId={videoNode.id}
|
||||||
value={videoNode.prompt}
|
value={videoNode.prompt}
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
|
||||||
onChange={handleVideoPromptChange}
|
|
||||||
onKeyDown={handleVideoPromptKeyDown}
|
|
||||||
placeholder="根据文字描述生成视频。"
|
placeholder="根据文字描述生成视频。"
|
||||||
|
mentionOptions={buildNodeMentionOptions("video", videoNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
|
||||||
|
mentionState={textNodeMentionStates[videoNode.id]}
|
||||||
|
mentionKind="video"
|
||||||
|
onPromptChange={updateVideoNodePrompt}
|
||||||
|
onMentionStateChange={setTextNodeMentionStates}
|
||||||
|
onCloseMention={closeTextNodeMention}
|
||||||
|
onInsertMention={insertTextNodeMention}
|
||||||
/>
|
/>
|
||||||
{vidMentionState.open ? (
|
|
||||||
<div className="studio-canvas-mention-panel">
|
|
||||||
{vidFilteredMentions.length > 0 ? vidFilteredMentions.map((opt, idx) => (
|
|
||||||
<button
|
|
||||||
key={opt.token}
|
|
||||||
type="button"
|
|
||||||
className={`studio-canvas-mention-item${idx === vidMentionState.activeIndex ? " is-active" : ""}`}
|
|
||||||
onMouseDown={(ev) => { ev.preventDefault(); insertTextNodeMention(videoNode.id, opt, ev.currentTarget.closest(".studio-canvas-text-composer__input-wrap")?.querySelector("textarea")!, "video"); }}
|
|
||||||
>
|
|
||||||
<span className={`studio-canvas-mention-icon studio-canvas-mention-icon--${opt.kind}`}>
|
|
||||||
{opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}
|
|
||||||
</span>
|
|
||||||
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
|
|
||||||
<span className="studio-canvas-mention-token">{opt.token}</span>
|
|
||||||
</button>
|
|
||||||
)) : (
|
|
||||||
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}>
|
|
||||||
<span className="studio-canvas-mention-label">没有可引用的连接节点</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="studio-canvas-video-composer__footer studio-canvas-video-composer__settings">
|
<div className="studio-canvas-video-composer__footer studio-canvas-video-composer__settings">
|
||||||
<CanvasSelectChip
|
<CanvasSelectChip
|
||||||
ariaLabel="选择视频模型"
|
ariaLabel="选择视频模型"
|
||||||
className="canvas-select-chip--model studio-canvas-composer-chip"
|
className="canvas-select-chip--model studio-canvas-composer-chip"
|
||||||
value={toHappyHorseDisplayModel(videoNode.model || defaultVideoModel)}
|
value={toHappyHorseDisplayModel(videoNode.model || defaultVideoModel)}
|
||||||
options={canvasEnterpriseVideoModelOptions}
|
options={canvasVideoModelOptions}
|
||||||
open={canvasSelectMenu === `${videoNode.id}:video-model`}
|
open={canvasSelectMenu === `${videoNode.id}:video-model`}
|
||||||
onToggle={() =>
|
onToggle={() =>
|
||||||
setCanvasSelectMenu((current) =>
|
setCanvasSelectMenu((current) =>
|
||||||
@@ -5249,7 +5084,7 @@ function CanvasPage({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
); })() : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -5543,7 +5378,7 @@ function CanvasPage({
|
|||||||
onClick={() => setSelectedExistingCategory(category.key)}
|
onClick={() => setSelectedExistingCategory(category.key)}
|
||||||
>
|
>
|
||||||
{category.label}
|
{category.label}
|
||||||
<span>{serverAssets.filter((asset) => asset.type === category.key).length} 个素材</span>
|
<span>{assetCountsByCategory.get(category.key) ?? 0} 个素材</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { SendOutlined } from "@ant-design/icons";
|
||||||
|
import { useRef, type CSSProperties, type Dispatch, type SetStateAction } from "react";
|
||||||
|
import type { CanvasNodeKind, CanvasPromptMentionOption, CanvasPromptMentionState } from "./canvasTypes";
|
||||||
|
|
||||||
|
const MENTION_BOUNDARY_RE = /\s|[,。、;:!??(){}[\]<>]/;
|
||||||
|
const EMPTY_MENTION_STYLE: CSSProperties = { opacity: 0.5, pointerEvents: "none" };
|
||||||
|
const DEFAULT_MENTION_STATE: CanvasPromptMentionState = {
|
||||||
|
open: false,
|
||||||
|
query: "",
|
||||||
|
start: 0,
|
||||||
|
caret: 0,
|
||||||
|
activeIndex: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CanvasPromptMentionTextareaProps {
|
||||||
|
nodeId: string;
|
||||||
|
value: string;
|
||||||
|
placeholder: string;
|
||||||
|
mentionOptions: CanvasPromptMentionOption[];
|
||||||
|
mentionState?: CanvasPromptMentionState;
|
||||||
|
onPromptChange: (nodeId: string, prompt: string) => void;
|
||||||
|
onMentionStateChange: Dispatch<SetStateAction<Record<string, CanvasPromptMentionState>>>;
|
||||||
|
onCloseMention: (nodeId: string) => void;
|
||||||
|
onInsertMention: (
|
||||||
|
nodeId: string,
|
||||||
|
option: CanvasPromptMentionOption,
|
||||||
|
textarea: HTMLTextAreaElement | null,
|
||||||
|
kind?: CanvasNodeKind,
|
||||||
|
) => void;
|
||||||
|
mentionKind?: CanvasNodeKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanvasPromptMentionTextarea({
|
||||||
|
nodeId,
|
||||||
|
value,
|
||||||
|
placeholder,
|
||||||
|
mentionOptions,
|
||||||
|
mentionState = DEFAULT_MENTION_STATE,
|
||||||
|
onPromptChange,
|
||||||
|
onMentionStateChange,
|
||||||
|
onCloseMention,
|
||||||
|
onInsertMention,
|
||||||
|
mentionKind,
|
||||||
|
}: CanvasPromptMentionTextareaProps) {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const filteredMentions = mentionState.open
|
||||||
|
? mentionOptions.filter((option) => !mentionState.query || option.searchText.includes(mentionState.query.toLowerCase()))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handlePromptChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
const caret = event.target.selectionStart || 0;
|
||||||
|
onPromptChange(nodeId, value);
|
||||||
|
|
||||||
|
const beforeCaret = value.slice(0, caret);
|
||||||
|
const atIndex = beforeCaret.lastIndexOf("@");
|
||||||
|
if (atIndex >= 0) {
|
||||||
|
const query = beforeCaret.slice(atIndex + 1);
|
||||||
|
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
|
||||||
|
onMentionStateChange((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[nodeId]: { open: true, query, start: atIndex, caret, activeIndex: 0 },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onCloseMention(nodeId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePromptKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (!mentionState.open || filteredMentions.length === 0) return;
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
onMentionStateChange((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[nodeId]: { ...mentionState, activeIndex: (mentionState.activeIndex + 1) % filteredMentions.length },
|
||||||
|
}));
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
onMentionStateChange((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[nodeId]: {
|
||||||
|
...mentionState,
|
||||||
|
activeIndex: (mentionState.activeIndex - 1 + filteredMentions.length) % filteredMentions.length,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} else if (event.key === "Enter" || event.key === "Tab") {
|
||||||
|
event.preventDefault();
|
||||||
|
const option = filteredMentions[mentionState.activeIndex];
|
||||||
|
if (option) {
|
||||||
|
onInsertMention(nodeId, option, event.currentTarget, mentionKind);
|
||||||
|
}
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
onCloseMention(nodeId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePromptSelect = (event: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
||||||
|
const caret = event.currentTarget.selectionStart || 0;
|
||||||
|
onMentionStateChange((prev) => {
|
||||||
|
const current = prev[nodeId];
|
||||||
|
if (!current?.open) return prev;
|
||||||
|
return { ...prev, [nodeId]: { ...current, caret } };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="studio-canvas-text-composer__input-wrap">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
onChange={handlePromptChange}
|
||||||
|
onKeyDown={handlePromptKeyDown}
|
||||||
|
onSelect={handlePromptSelect}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
{mentionState.open ? (
|
||||||
|
<div className="studio-canvas-mention-panel">
|
||||||
|
{filteredMentions.length > 0 ? filteredMentions.map((option, index) => (
|
||||||
|
<button
|
||||||
|
key={option.token}
|
||||||
|
type="button"
|
||||||
|
className={`studio-canvas-mention-item${index === mentionState.activeIndex ? " is-active" : ""}`}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onInsertMention(nodeId, option, textareaRef.current, mentionKind);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="studio-canvas-mention-thumb">
|
||||||
|
{option.kind === "image" && option.previewUrl ? (
|
||||||
|
<img src={option.previewUrl} alt="" />
|
||||||
|
) : option.kind === "image" ? "🖼" : option.kind === "video" ? "🎬" : "📝"}
|
||||||
|
</span>
|
||||||
|
<span className="studio-canvas-mention-label">{option.nodeTitle}</span>
|
||||||
|
<span className="studio-canvas-mention-token">{option.token}</span>
|
||||||
|
</button>
|
||||||
|
)) : (
|
||||||
|
<div className="studio-canvas-mention-item" style={EMPTY_MENTION_STYLE}>
|
||||||
|
<span className="studio-canvas-mention-label">没有可引用的连接节点</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CanvasTextPromptComposerProps {
|
||||||
|
nodeId: string;
|
||||||
|
prompt: string;
|
||||||
|
canGenerate: boolean;
|
||||||
|
isGenerating: boolean;
|
||||||
|
mentionOptions: CanvasPromptMentionOption[];
|
||||||
|
mentionState?: CanvasPromptMentionState;
|
||||||
|
onPromptChange: (nodeId: string, prompt: string) => void;
|
||||||
|
onMentionStateChange: Dispatch<SetStateAction<Record<string, CanvasPromptMentionState>>>;
|
||||||
|
onCloseMention: (nodeId: string) => void;
|
||||||
|
onInsertMention: (
|
||||||
|
nodeId: string,
|
||||||
|
option: CanvasPromptMentionOption,
|
||||||
|
textarea: HTMLTextAreaElement | null,
|
||||||
|
kind?: CanvasNodeKind,
|
||||||
|
) => void;
|
||||||
|
onGenerate: (nodeId: string) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanvasTextPromptComposer({
|
||||||
|
nodeId,
|
||||||
|
prompt,
|
||||||
|
canGenerate,
|
||||||
|
isGenerating,
|
||||||
|
mentionOptions,
|
||||||
|
mentionState,
|
||||||
|
onPromptChange,
|
||||||
|
onMentionStateChange,
|
||||||
|
onCloseMention,
|
||||||
|
onInsertMention,
|
||||||
|
onGenerate,
|
||||||
|
}: CanvasTextPromptComposerProps) {
|
||||||
|
return (
|
||||||
|
<div className="studio-canvas-text-composer">
|
||||||
|
<CanvasPromptMentionTextarea
|
||||||
|
nodeId={nodeId}
|
||||||
|
value={prompt}
|
||||||
|
placeholder="写下你想讲的故事、场景或角色设定。@引用连接的节点"
|
||||||
|
mentionOptions={mentionOptions}
|
||||||
|
mentionState={mentionState}
|
||||||
|
onPromptChange={onPromptChange}
|
||||||
|
onMentionStateChange={onMentionStateChange}
|
||||||
|
onCloseMention={onCloseMention}
|
||||||
|
onInsertMention={onInsertMention}
|
||||||
|
/>
|
||||||
|
<div className="studio-canvas-text-composer__footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`studio-canvas-text-composer__send studio-canvas-generate-button${canGenerate && !isGenerating ? " is-ready" : ""}`}
|
||||||
|
title={isGenerating ? "生成中" : "生成"}
|
||||||
|
disabled={isGenerating || !canGenerate}
|
||||||
|
aria-busy={isGenerating}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!isGenerating && canGenerate) {
|
||||||
|
void onGenerate(nodeId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SendOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import type { ServerAssetItem } from "../../api/assetClient";
|
||||||
|
import type {
|
||||||
|
CanvasImageNode,
|
||||||
|
CanvasNodeKind,
|
||||||
|
CanvasNodePackage,
|
||||||
|
CanvasTextNode,
|
||||||
|
CanvasVideoNode,
|
||||||
|
} from "./canvasTypes";
|
||||||
|
import { getCanvasSelectionKey } from "./canvasUtils";
|
||||||
|
|
||||||
|
export function useCanvasAssetSummary(serverAssets: ServerAssetItem[]) {
|
||||||
|
return useMemo(() => {
|
||||||
|
const canvasAssets: ServerAssetItem[] = [];
|
||||||
|
const assetCountsByCategory = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const asset of serverAssets) {
|
||||||
|
if (asset.imageUrl) {
|
||||||
|
canvasAssets.push(asset);
|
||||||
|
}
|
||||||
|
assetCountsByCategory.set(asset.type, (assetCountsByCategory.get(asset.type) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { canvasAssets, assetCountsByCategory };
|
||||||
|
}, [serverAssets]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCanvasVisibleNodes({
|
||||||
|
textNodes,
|
||||||
|
imageNodes,
|
||||||
|
videoNodes,
|
||||||
|
nodePackages,
|
||||||
|
}: {
|
||||||
|
textNodes: CanvasTextNode[];
|
||||||
|
imageNodes: CanvasImageNode[];
|
||||||
|
videoNodes: CanvasVideoNode[];
|
||||||
|
nodePackages: CanvasNodePackage[];
|
||||||
|
}) {
|
||||||
|
const collapsedPackageNodeKeys = useMemo(
|
||||||
|
() => new Set(
|
||||||
|
nodePackages.flatMap((nodePackage) =>
|
||||||
|
nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : []
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[nodePackages],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isNodeCollapsedInPackage = useCallback(
|
||||||
|
(kind: CanvasNodeKind, id: string) =>
|
||||||
|
collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind, id })),
|
||||||
|
[collapsedPackageNodeKeys],
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleTextNodes = useMemo(
|
||||||
|
() => textNodes.filter((textNode) => !collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind: "text", id: textNode.id }))),
|
||||||
|
[collapsedPackageNodeKeys, textNodes],
|
||||||
|
);
|
||||||
|
const visibleImageNodes = useMemo(
|
||||||
|
() => imageNodes.filter((imageNode) => !collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind: "image", id: imageNode.id }))),
|
||||||
|
[collapsedPackageNodeKeys, imageNodes],
|
||||||
|
);
|
||||||
|
const visibleVideoNodes = useMemo(
|
||||||
|
() => videoNodes.filter((videoNode) => !collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind: "video", id: videoNode.id }))),
|
||||||
|
[collapsedPackageNodeKeys, videoNodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
collapsedPackageNodeKeys,
|
||||||
|
isNodeCollapsedInPackage,
|
||||||
|
visibleTextNodes,
|
||||||
|
visibleImageNodes,
|
||||||
|
visibleVideoNodes,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -82,11 +82,16 @@ export function useCanvasNodeDrag(params: UseCanvasNodeDragParams) {
|
|||||||
const cy = pos.y + size.height / 2;
|
const cy = pos.y + size.height / 2;
|
||||||
const right = pos.x + size.width;
|
const right = pos.x + size.width;
|
||||||
const bottom = pos.y + size.height;
|
const bottom = pos.y + size.height;
|
||||||
const others = [
|
const others: Array<{ pos: CanvasPoint; size: CanvasNodeSize }> = [];
|
||||||
...textNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
|
for (const node of textNodesRef.current) {
|
||||||
...imageNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
|
if (node.id !== draggedId) others.push({ pos: node.position, size: node.size });
|
||||||
...videoNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
|
}
|
||||||
];
|
for (const node of imageNodesRef.current) {
|
||||||
|
if (node.id !== draggedId) others.push({ pos: node.position, size: node.size });
|
||||||
|
}
|
||||||
|
for (const node of videoNodesRef.current) {
|
||||||
|
if (node.id !== draggedId) others.push({ pos: node.position, size: node.size });
|
||||||
|
}
|
||||||
for (const other of others) {
|
for (const other of others) {
|
||||||
const ocx = other.pos.x + other.size.width / 2;
|
const ocx = other.pos.x + other.size.width / 2;
|
||||||
const ocy = other.pos.y + other.size.height / 2;
|
const ocy = other.pos.y + other.size.height / 2;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
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";
|
||||||
|
import "../../styles/pages/more-tools.css";
|
||||||
|
import "../../styles/pages/image-workbench.css";
|
||||||
import StudioToolLayout from "../../components/StudioToolLayout";
|
import StudioToolLayout from "../../components/StudioToolLayout";
|
||||||
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
|
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
|
||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import "../../styles/pages/community.css";
|
||||||
import { useDebounce } from "../../hooks/useDebounce";
|
import { useDebounce } from "../../hooks/useDebounce";
|
||||||
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
||||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||||
|
|||||||
@@ -91,7 +91,11 @@ export function getCommunityCaseSurface(item: Pick<ServerCommunityCase, "metadat
|
|||||||
);
|
);
|
||||||
if (explicitSurface !== "unknown") return explicitSurface;
|
if (explicitSurface !== "unknown") return explicitSurface;
|
||||||
|
|
||||||
const tags = item.tags.map((tag) => tag.trim()).filter(Boolean);
|
const tags: string[] = [];
|
||||||
|
for (const rawTag of item.tags) {
|
||||||
|
const tag = rawTag.trim();
|
||||||
|
if (tag) tags.push(tag);
|
||||||
|
}
|
||||||
if (tags.some((tag) => tag.includes("生成页面社区") || tag === "Web生成")) return "generation";
|
if (tags.some((tag) => tag.includes("生成页面社区") || tag === "Web生成")) return "generation";
|
||||||
if (tags.some((tag) => tag.includes("画布页面社区") || tag.includes("工作流"))) return "canvas";
|
if (tags.some((tag) => tag.includes("画布页面社区") || tag.includes("工作流"))) return "canvas";
|
||||||
if (getWorkflowFromCase(item)) return "canvas";
|
if (getWorkflowFromCase(item)) return "canvas";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FileTextOutlined, SafetyOutlined } from "@ant-design/icons";
|
import { FileTextOutlined, SafetyOutlined } from "@ant-design/icons";
|
||||||
|
import "../../styles/pages/compliance.css";
|
||||||
|
|
||||||
type ComplianceKind = "agreement" | "privacy";
|
type ComplianceKind = "agreement" | "privacy";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
|
import { useCallback, useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
|
||||||
import { ApartmentOutlined, DownOutlined, RobotOutlined, ThunderboltOutlined } from "@ant-design/icons";
|
import { ApartmentOutlined, DownOutlined, RobotOutlined, ThunderboltOutlined } from "@ant-design/icons";
|
||||||
|
import "../../styles/pages/dialog-generator.css";
|
||||||
|
|
||||||
type DialogStyle = "style1" | "style2" | "style3" | "style4";
|
type DialogStyle = "style1" | "style2" | "style3" | "style4";
|
||||||
type GenerationMode = "dialog" | "video";
|
type GenerationMode = "dialog" | "video";
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode } from "react";
|
import { useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode } from "react";
|
||||||
|
import "../../styles/pages/avatar-console.css";
|
||||||
import type { WebViewKey } from "../../types";
|
import type { WebViewKey } from "../../types";
|
||||||
import {
|
import {
|
||||||
bringAvatarEditorLayerForward,
|
bringAvatarEditorLayerForward,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
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";
|
||||||
|
import "../../styles/pages/more-tools.css";
|
||||||
|
import "../../styles/pages/image-workbench.css";
|
||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
|
import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
|
||||||
import { waitForTask } from "../../api/taskSubscription";
|
import { waitForTask } from "../../api/taskSubscription";
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
SkinOutlined,
|
SkinOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
|
||||||
|
import "../../styles/pages/ecommerce.css";
|
||||||
|
import "../../styles/pages/local-theme-parity.css";
|
||||||
import { ossAssets } from "../../data/ossAssets";
|
import { ossAssets } from "../../data/ossAssets";
|
||||||
import { EcommerceProgressBar } from "./EcommerceProgressBar";
|
import { EcommerceProgressBar } from "./EcommerceProgressBar";
|
||||||
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
|
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
|
||||||
@@ -587,6 +589,7 @@ const cloneSetCountOptions: Array<{
|
|||||||
{ key: "white", title: "白底图", desc: "白底主图,多角度呈现商品细节" },
|
{ key: "white", title: "白底图", desc: "白底主图,多角度呈现商品细节" },
|
||||||
{ key: "scene", title: "场景图", desc: "展示商品的生活使用场景和人物搭配" },
|
{ key: "scene", title: "场景图", desc: "展示商品的生活使用场景和人物搭配" },
|
||||||
];
|
];
|
||||||
|
const cloneSetCountKeys = cloneSetCountOptions.map((option) => option.key);
|
||||||
const defaultCloneSetCounts: Record<CloneSetCountKey, number> = {
|
const defaultCloneSetCounts: Record<CloneSetCountKey, number> = {
|
||||||
selling: 3,
|
selling: 3,
|
||||||
white: 1,
|
white: 1,
|
||||||
@@ -938,25 +941,58 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const [selectedDetailModules, setSelectedDetailModules] = useState<string[]>(defaultDetailModuleIds);
|
const [selectedDetailModules, setSelectedDetailModules] = useState<string[]>(defaultDetailModuleIds);
|
||||||
const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle");
|
const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle");
|
||||||
const [detailResultUrl, setDetailResultUrl] = useState<string | null>(null);
|
const [detailResultUrl, setDetailResultUrl] = useState<string | null>(null);
|
||||||
const productSetRatioOptions = getPlatformRatioOptions(productSetPlatform, productSetOutput);
|
const productSetRatioOptions = useMemo(
|
||||||
const hotUploadedRatioOption = cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null;
|
() => getPlatformRatioOptions(productSetPlatform, productSetOutput),
|
||||||
const baseCloneRatioOptions = getPlatformRatioOptions(platform, cloneOutput);
|
[productSetOutput, productSetPlatform],
|
||||||
const cloneRatioOptions = hotUploadedRatioOption
|
);
|
||||||
|
const hotUploadedRatioOption = useMemo(
|
||||||
|
() => cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null,
|
||||||
|
[cloneOutput, cloneReferenceImages],
|
||||||
|
);
|
||||||
|
const baseCloneRatioOptions = useMemo(
|
||||||
|
() => getPlatformRatioOptions(platform, cloneOutput),
|
||||||
|
[cloneOutput, platform],
|
||||||
|
);
|
||||||
|
const cloneRatioOptions = useMemo(
|
||||||
|
() => hotUploadedRatioOption
|
||||||
? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption])
|
? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption])
|
||||||
: baseCloneRatioOptions;
|
: baseCloneRatioOptions,
|
||||||
const productSetLanguageOptions = getPlatformLanguageOptions(productSetPlatform, productSetMarket);
|
[baseCloneRatioOptions, hotUploadedRatioOption],
|
||||||
const cloneLanguageOptions = getPlatformLanguageOptions(platform, market);
|
);
|
||||||
const detailLanguageOptions = getPlatformLanguageOptions(detailPlatform, detailMarket);
|
const productSetLanguageOptions = useMemo(
|
||||||
|
() => getPlatformLanguageOptions(productSetPlatform, productSetMarket),
|
||||||
|
[productSetMarket, productSetPlatform],
|
||||||
|
);
|
||||||
|
const cloneLanguageOptions = useMemo(
|
||||||
|
() => getPlatformLanguageOptions(platform, market),
|
||||||
|
[market, platform],
|
||||||
|
);
|
||||||
|
const detailLanguageOptions = useMemo(
|
||||||
|
() => getPlatformLanguageOptions(detailPlatform, detailMarket),
|
||||||
|
[detailMarket, detailPlatform],
|
||||||
|
);
|
||||||
const ecommerceMentionImages: MentionImageOption[] = [
|
const ecommerceMentionImages: MentionImageOption[] = [
|
||||||
...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })),
|
...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })),
|
||||||
...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })),
|
...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ecommerceVideoImageDataUrls = useMemo(
|
||||||
|
() => productImages.map((img) => img.src),
|
||||||
|
[productImages],
|
||||||
|
);
|
||||||
|
const ecommerceVideoImageFiles = useMemo(
|
||||||
|
() => productImages.map((img) => img.file),
|
||||||
|
[productImages],
|
||||||
|
);
|
||||||
|
|
||||||
const selectedProductSetOutput =
|
const selectedProductSetOutput =
|
||||||
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
|
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
|
||||||
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
|
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
|
||||||
const productSetPreviewReady = productSetStatus === "done";
|
const productSetPreviewReady = productSetStatus === "done";
|
||||||
const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0);
|
const cloneSetTotal = useMemo(
|
||||||
|
() => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0),
|
||||||
|
[cloneSetCounts],
|
||||||
|
);
|
||||||
const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating";
|
const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating";
|
||||||
const canGenerate = (cloneOutput === "video-outfit"
|
const canGenerate = (cloneOutput === "video-outfit"
|
||||||
? Boolean(videoOutfitVideoFile && videoOutfitRefFile)
|
? Boolean(videoOutfitVideoFile && videoOutfitRefFile)
|
||||||
@@ -965,9 +1001,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating";
|
const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating";
|
||||||
const cloneVideoDurationProgress =
|
const cloneVideoDurationProgress =
|
||||||
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
|
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
|
||||||
const cloneVideoDurationStyle: CSSProperties = {
|
const cloneVideoDurationStyle: CSSProperties = useMemo(
|
||||||
|
() => ({
|
||||||
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
|
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
|
||||||
} as CSSProperties;
|
}) as CSSProperties,
|
||||||
|
[cloneVideoDurationProgress],
|
||||||
|
);
|
||||||
|
|
||||||
const trackEcommerceTask = (taskId: string) => {
|
const trackEcommerceTask = (taskId: string) => {
|
||||||
activeEcommerceTaskIdsRef.current.add(taskId);
|
activeEcommerceTaskIdsRef.current.add(taskId);
|
||||||
@@ -1162,6 +1201,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearCloneSetCountHold = () => {
|
const clearCloneSetCountHold = () => {
|
||||||
|
window.removeEventListener("pointerup", clearCloneSetCountHold);
|
||||||
|
window.removeEventListener("pointercancel", clearCloneSetCountHold);
|
||||||
if (countHoldTimeoutRef.current !== null) {
|
if (countHoldTimeoutRef.current !== null) {
|
||||||
window.clearTimeout(countHoldTimeoutRef.current);
|
window.clearTimeout(countHoldTimeoutRef.current);
|
||||||
countHoldTimeoutRef.current = null;
|
countHoldTimeoutRef.current = null;
|
||||||
@@ -1276,6 +1317,34 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
requirement,
|
requirement,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const latestCloneSettingSnapshot = useMemo(
|
||||||
|
() => createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest"),
|
||||||
|
[
|
||||||
|
cloneOutput,
|
||||||
|
platform,
|
||||||
|
market,
|
||||||
|
language,
|
||||||
|
ratio,
|
||||||
|
cloneSetCounts,
|
||||||
|
selectedCloneDetailModules,
|
||||||
|
cloneModelPanelTab,
|
||||||
|
selectedCloneModelScenes,
|
||||||
|
cloneModelCustomScene,
|
||||||
|
cloneModelGender,
|
||||||
|
cloneModelAge,
|
||||||
|
cloneModelEthnicity,
|
||||||
|
cloneModelBody,
|
||||||
|
cloneModelAppearance,
|
||||||
|
cloneVideoQuality,
|
||||||
|
cloneVideoDuration,
|
||||||
|
cloneVideoSmart,
|
||||||
|
cloneReferenceMode,
|
||||||
|
cloneReplicateLevel,
|
||||||
|
requirement,
|
||||||
|
cloneSettingName,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const persistLatestCloneSetting = () => {
|
const persistLatestCloneSetting = () => {
|
||||||
const snapshot = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
|
const snapshot = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
|
||||||
latestCloneSettingRef.current = snapshot;
|
latestCloneSettingRef.current = snapshot;
|
||||||
@@ -1323,8 +1392,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
latestCloneSettingRef.current = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
|
latestCloneSettingRef.current = latestCloneSettingSnapshot;
|
||||||
});
|
}, [latestCloneSettingSnapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const latestSetting = readCloneLatestSetting();
|
const latestSetting = readCloneLatestSetting();
|
||||||
@@ -1631,7 +1700,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const generatedUrls: string[] = [];
|
const generatedUrls: string[] = [];
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
|
|
||||||
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
|
for (const countKey of cloneSetCountKeys) {
|
||||||
if (imageAbortRef.current.current) break;
|
if (imageAbortRef.current.current) break;
|
||||||
const count = counts[countKey];
|
const count = counts[countKey];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -1908,7 +1977,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
platform, ratio, language, market,
|
platform, ratio, language, market,
|
||||||
{ gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene },
|
{ gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene },
|
||||||
(s) => setTryOnStatus(s as TryOnStatus),
|
(s) => setTryOnStatus(s as TryOnStatus),
|
||||||
(res) => setTryOnResultImages(res.map((r) => r.src).filter(Boolean)),
|
(res) => {
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (const item of res) {
|
||||||
|
if (item.src) urls.push(item.src);
|
||||||
|
}
|
||||||
|
setTryOnResultImages(urls);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
lastFailedActionRef.current = () => handleTryOnGenerate();
|
lastFailedActionRef.current = () => handleTryOnGenerate();
|
||||||
};
|
};
|
||||||
@@ -2028,7 +2103,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
productImages.length === 0 ? "请先上传商品原图" : status === "generating" ? "生成中..." : `生成${selectedCloneOutput.label}`;
|
productImages.length === 0 ? "请先上传商品原图" : status === "generating" ? "生成中..." : `生成${selectedCloneOutput.label}`;
|
||||||
const setPreviewCards: CloneResult[] = [];
|
const setPreviewCards: CloneResult[] = [];
|
||||||
let setIndex = 0;
|
let setIndex = 0;
|
||||||
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
|
for (const countKey of cloneSetCountKeys) {
|
||||||
const count = cloneSetCounts[countKey];
|
const count = cloneSetCounts[countKey];
|
||||||
const info = setCountLabels[countKey];
|
const info = setCountLabels[countKey];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -2043,7 +2118,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
|
|
||||||
const clonePreviewCards: CloneResult[] = [];
|
const clonePreviewCards: CloneResult[] = [];
|
||||||
let cloneIndex = 0;
|
let cloneIndex = 0;
|
||||||
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
|
for (const countKey of cloneSetCountKeys) {
|
||||||
const count = cloneSetCounts[countKey];
|
const count = cloneSetCounts[countKey];
|
||||||
const info = setCountLabels[countKey];
|
const info = setCountLabels[countKey];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -2055,6 +2130,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
cloneIndex++;
|
cloneIndex++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const detailSourcePreviewImages = detailProductImages.length
|
||||||
|
? detailProductImages.reduce<string[]>((urls, item) => {
|
||||||
|
urls.push(item.src);
|
||||||
|
return urls;
|
||||||
|
}, [])
|
||||||
|
: detailProductSamples;
|
||||||
const cloneBasicSelects: Array<{
|
const cloneBasicSelects: Array<{
|
||||||
key: CloneBasicSelectKey;
|
key: CloneBasicSelectKey;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -2564,7 +2645,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
|
|
||||||
<section className="product-detail-demo-board">
|
<section className="product-detail-demo-board">
|
||||||
<div className="product-detail-source-stack">
|
<div className="product-detail-source-stack">
|
||||||
{(detailProductImages.length ? detailProductImages.map((item) => item.src) : detailProductSamples).map((src, index) => (
|
{detailSourcePreviewImages.map((src, index) => (
|
||||||
<figure key={`${src}-${index}`}>
|
<figure key={`${src}-${index}`}>
|
||||||
<img src={src} alt={`商品原图 ${index + 1}`} />
|
<img src={src} alt={`商品原图 ${index + 1}`} />
|
||||||
</figure>
|
</figure>
|
||||||
@@ -2692,8 +2773,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
<main className="product-clone-preview product-clone-preview--video" style={{ padding: 0 }}>
|
<main className="product-clone-preview product-clone-preview--video" style={{ padding: 0 }}>
|
||||||
<EcommerceVideoWorkspace
|
<EcommerceVideoWorkspace
|
||||||
isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)}
|
isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)}
|
||||||
productImageDataUrls={productImages.map((img) => img.src)}
|
productImageDataUrls={ecommerceVideoImageDataUrls}
|
||||||
productImageFiles={productImages.map((img) => img.file)}
|
productImageFiles={ecommerceVideoImageFiles}
|
||||||
requirement={requirement}
|
requirement={requirement}
|
||||||
platform={platform}
|
platform={platform}
|
||||||
aspectRatio={ratio.includes("9:16") || ratio.includes("9:16") ? "9:16" : ratio.includes("16:9") || ratio.includes("16:9") ? "16:9" : ratio.includes("3:4") || ratio.includes("3:4") ? "3:4" : "9:16"}
|
aspectRatio={ratio.includes("9:16") || ratio.includes("9:16") ? "9:16" : ratio.includes("16:9") || ratio.includes("16:9") ? "16:9" : ratio.includes("3:4") || ratio.includes("3:4") ? "3:4" : "9:16"}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
TagsOutlined,
|
TagsOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||||
|
import "../../styles/pages/more-tools.css";
|
||||||
|
import "../../styles/pages/image-workbench.css";
|
||||||
|
import "../../styles/pages/ecommerce.css";
|
||||||
|
import "../../styles/pages/local-theme-parity.css";
|
||||||
import type { WebProjectSummary } from "../../types";
|
import type { WebProjectSummary } from "../../types";
|
||||||
import { useDebounce } from "../../hooks/useDebounce";
|
import { useDebounce } from "../../hooks/useDebounce";
|
||||||
import { templateCarouselCases, templateCases, templateCategories, type TemplateCase } from "./ecommerceTemplates";
|
import { templateCarouselCases, templateCases, templateCategories, type TemplateCase } from "./ecommerceTemplates";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import "../../styles/pages/ecommerce-video.css";
|
||||||
import {
|
import {
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
@@ -122,6 +123,7 @@ export default function EcommerceVideoWorkspace({
|
|||||||
const [flowZoom, setFlowZoom] = useState(1);
|
const [flowZoom, setFlowZoom] = useState(1);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const renderAbortRef = useRef({ current: false });
|
const renderAbortRef = useRef({ current: false });
|
||||||
|
const actionNoticeTimerRef = useRef<number | null>(null);
|
||||||
const setView = useAppStore((s) => s.setView);
|
const setView = useAppStore((s) => s.setView);
|
||||||
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
|
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
|
||||||
const keepalivePollingStartedRef = useRef(false);
|
const keepalivePollingStartedRef = useRef(false);
|
||||||
@@ -277,9 +279,23 @@ export default function EcommerceVideoWorkspace({
|
|||||||
// Note: keep-alive is NOT cleared on completion — results persist across page switches.
|
// Note: keep-alive is NOT cleared on completion — results persist across page switches.
|
||||||
// Only cleared when user explicitly starts a new plan via handlePlan.
|
// Only cleared when user explicitly starts a new plan via handlePlan.
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (actionNoticeTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(actionNoticeTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const showNotice = (msg: string) => {
|
const showNotice = (msg: string) => {
|
||||||
setActionNotice(msg);
|
setActionNotice(msg);
|
||||||
setTimeout(() => setActionNotice(null), 3000);
|
if (actionNoticeTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(actionNoticeTimerRef.current);
|
||||||
|
}
|
||||||
|
actionNoticeTimerRef.current = window.setTimeout(() => {
|
||||||
|
actionNoticeTimerRef.current = null;
|
||||||
|
setActionNotice(null);
|
||||||
|
}, 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async (url: string) => {
|
const handleDownload = async (url: string) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type AdVideoUserConfig,
|
type AdVideoUserConfig,
|
||||||
} from "../../api/adVideoPlanClient";
|
} from "../../api/adVideoPlanClient";
|
||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
|
import { serverRequest } from "../../api/serverConnection";
|
||||||
import { waitForTask } from "../../api/taskSubscription";
|
import { waitForTask } from "../../api/taskSubscription";
|
||||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||||
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
|
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
|
||||||
@@ -430,15 +431,6 @@ export interface VideoHistoryListResponse {
|
|||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { getStoredToken } from "../../api/serverConnection";
|
|
||||||
|
|
||||||
const API_BASE = "/api/ai/ecommerce/video-history";
|
|
||||||
|
|
||||||
function getAuthHeaders(): Record<string, string> {
|
|
||||||
const token = getStoredToken();
|
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
|
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
|
||||||
const uploadAssetByUrl = payload.uploadAssetByUrl;
|
const uploadAssetByUrl = payload.uploadAssetByUrl;
|
||||||
const scenes = await Promise.all(
|
const scenes = await Promise.all(
|
||||||
@@ -486,13 +478,12 @@ export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryP
|
|||||||
|
|
||||||
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
|
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
|
||||||
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
|
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
|
||||||
const res = await fetch(API_BASE, {
|
return serverRequest<{ id: number; createdAt: string }>("ai/ecommerce/video-history", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
body: historyPayload,
|
||||||
body: JSON.stringify(historyPayload),
|
maxRetries: 0,
|
||||||
|
fallbackMessage: "Failed to save video history",
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to save video history");
|
|
||||||
return res.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
|
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
|
||||||
@@ -511,12 +502,10 @@ export async function fetchVideoHistory(
|
|||||||
limit = 20,
|
limit = 20,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
): Promise<VideoHistoryListResponse> {
|
): Promise<VideoHistoryListResponse> {
|
||||||
const res = await fetch(
|
const search = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||||
`${API_BASE}?limit=${limit}&offset=${offset}`,
|
const history = await serverRequest<VideoHistoryListResponse>(`ai/ecommerce/video-history?${search}`, {
|
||||||
{ headers: getAuthHeaders() },
|
fallbackMessage: "Failed to fetch video history",
|
||||||
);
|
});
|
||||||
if (!res.ok) throw new Error("Failed to fetch video history");
|
|
||||||
const history = (await res.json()) as VideoHistoryListResponse;
|
|
||||||
return {
|
return {
|
||||||
...history,
|
...history,
|
||||||
items: history.items.map(removeTemporaryHistoryUrls),
|
items: history.items.map(removeTemporaryHistoryUrls),
|
||||||
@@ -524,9 +513,9 @@ export async function fetchVideoHistory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteVideoHistory(id: number): Promise<void> {
|
export async function deleteVideoHistory(id: number): Promise<void> {
|
||||||
const res = await fetch(`${API_BASE}/${id}`, {
|
await serverRequest<void>(`ai/ecommerce/video-history/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: getAuthHeaders(),
|
maxRetries: 0,
|
||||||
|
fallbackMessage: "Failed to delete video history",
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to delete video history");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSPr
|
|||||||
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
|
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
|
||||||
import { useScrollEntrance } from "../../hooks/useScrollEntrance";
|
import { useScrollEntrance } from "../../hooks/useScrollEntrance";
|
||||||
import { ossAssets } from "../../data/ossAssets";
|
import { ossAssets } from "../../data/ossAssets";
|
||||||
|
import "../../styles/pages/home.css";
|
||||||
import WelcomeSplash from "./WelcomeSplash";
|
import WelcomeSplash from "./WelcomeSplash";
|
||||||
import ToolboxSection from "./ToolboxSection";
|
import ToolboxSection from "./ToolboxSection";
|
||||||
import ScriptReviewShowcase from "./ScriptReviewShowcase";
|
import ScriptReviewShowcase from "./ScriptReviewShowcase";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import "../../styles/pages/model-generation-showcase.css";
|
||||||
|
|
||||||
type ShowMode = "agent" | "image" | "video";
|
type ShowMode = "agent" | "image" | "video";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import "../../styles/pages/script-review-showcase.css";
|
||||||
|
|
||||||
const DIMS = [
|
const DIMS = [
|
||||||
{ name: "钩子设计", score: 16, max: 20, hue: 145, desc: "吸引力·悬念·黄金三秒", isPerfect: false, isLow: false },
|
{ name: "钩子设计", score: 16, max: 20, hue: 145, desc: "吸引力·悬念·黄金三秒", isPerfect: false, isLow: false },
|
||||||
@@ -50,6 +51,12 @@ function ScriptReviewShowcase() {
|
|||||||
const scoreRef = useRef<HTMLSpanElement>(null);
|
const scoreRef = useRef<HTMLSpanElement>(null);
|
||||||
const barRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const barRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const scoreValRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
const scoreValRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||||
|
const animationTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||||
|
|
||||||
|
const clearAnimationTimers = () => {
|
||||||
|
animationTimersRef.current.forEach((timer) => clearTimeout(timer));
|
||||||
|
animationTimersRef.current = [];
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = document.getElementById("script-review-showcase");
|
const el = document.getElementById("script-review-showcase");
|
||||||
@@ -69,18 +76,23 @@ function ScriptReviewShowcase() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!animated) return;
|
if (!animated) return;
|
||||||
const timer = setTimeout(() => {
|
clearAnimationTimers();
|
||||||
|
const scheduleAnimation = (callback: () => void, delay: number) => {
|
||||||
|
const timer = setTimeout(callback, delay);
|
||||||
|
animationTimersRef.current.push(timer);
|
||||||
|
};
|
||||||
|
scheduleAnimation(() => {
|
||||||
animateNumber(scoreRef.current, 77, 1400);
|
animateNumber(scoreRef.current, 77, 1400);
|
||||||
barRefs.current.forEach((bar, i) => {
|
barRefs.current.forEach((bar, i) => {
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
const pct = parseFloat(bar.dataset.pct ?? "0");
|
const pct = parseFloat(bar.dataset.pct ?? "0");
|
||||||
setTimeout(() => { bar.style.height = `${pct}%`; }, i * 100 + 400);
|
scheduleAnimation(() => { bar.style.height = `${pct}%`; }, i * 100 + 400);
|
||||||
});
|
});
|
||||||
scoreValRefs.current.forEach((el, i) => {
|
scoreValRefs.current.forEach((el, i) => {
|
||||||
setTimeout(() => animateNumber(el, parseInt(el?.dataset.target ?? "0"), 800), i * 100 + 400);
|
scheduleAnimation(() => animateNumber(el, parseInt(el?.dataset.target ?? "0"), 800), i * 100 + 400);
|
||||||
});
|
});
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearTimeout(timer);
|
return clearAnimationTimers;
|
||||||
}, [animated]);
|
}, [animated]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import "../../styles/pages/script-review-visual.css";
|
||||||
|
|
||||||
const DIMS = [
|
const DIMS = [
|
||||||
{ name: "钩子设计", score: 19, max: 20, hue: 145 },
|
{ name: "钩子设计", score: 19, max: 20, hue: 145 },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ToolOutlined } from "@ant-design/icons";
|
import { ToolOutlined } from "@ant-design/icons";
|
||||||
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
|
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
|
||||||
import { ossAssets } from "../../data/ossAssets";
|
import { ossAssets } from "../../data/ossAssets";
|
||||||
|
import "../../styles/pages/toolbox.css";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
imageBefore: toolImageBefore,
|
imageBefore: toolImageBefore,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import "../../styles/pages/welcome-splash.css";
|
||||||
|
|
||||||
interface WelcomeSplashProps {
|
interface WelcomeSplashProps {
|
||||||
onEnter: () => void;
|
onEnter: () => void;
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
ThunderboltOutlined,
|
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/image-workbench.css";
|
||||||
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
|
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";
|
||||||
@@ -37,6 +39,9 @@ type WorkMode = "single" | "blend";
|
|||||||
type OutputSize = "9:16" | "16:9" | "4:3" | "3:4" | "1:1";
|
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_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",
|
||||||
"16:9": "16:9",
|
"16:9": "16:9",
|
||||||
@@ -80,6 +85,20 @@ const CAMERA_EFFECT_PRESETS = [
|
|||||||
{ key: "hdr", label: "HDR", prompt: "HDR高动态范围,明暗细节丰富,色彩饱和" },
|
{ key: "hdr", label: "HDR", prompt: "HDR高动态范围,明暗细节丰富,色彩饱和" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const CAMERA_EFFECT_PROMPT_BY_KEY = new Map<string, string>(
|
||||||
|
CAMERA_EFFECT_PRESETS.map((effect) => [effect.key, effect.prompt]),
|
||||||
|
);
|
||||||
|
|
||||||
|
function getCameraEffectsPrompt(effectKeys: Set<string>): string {
|
||||||
|
if (effectKeys.size === 0) return "";
|
||||||
|
const prompts: string[] = [];
|
||||||
|
for (const key of effectKeys) {
|
||||||
|
const prompt = CAMERA_EFFECT_PROMPT_BY_KEY.get(key);
|
||||||
|
if (prompt) prompts.push(prompt);
|
||||||
|
}
|
||||||
|
return prompts.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
function shotScaleToZoom(shotScale: number): number {
|
function shotScaleToZoom(shotScale: number): number {
|
||||||
const map: Record<number, number> = { 1: 24, 2: 28, 3: 32, 4: 35, 5: 40, 6: 50, 7: 60, 8: 75, 9: 85, 10: 100 };
|
const map: Record<number, number> = { 1: 24, 2: 28, 3: 32, 4: 35, 5: 40, 6: 50, 7: 60, 8: 75, 9: 85, 10: 100 };
|
||||||
return map[Math.round(Math.max(1, Math.min(10, shotScale)))] || 40;
|
return map[Math.round(Math.max(1, Math.min(10, shotScale)))] || 40;
|
||||||
@@ -154,6 +173,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
|||||||
abortRef.current = false;
|
abortRef.current = false;
|
||||||
taskIdRef.current = saved.taskId;
|
taskIdRef.current = saved.taskId;
|
||||||
void waitForTask(saved.taskId, {
|
void waitForTask(saved.taskId, {
|
||||||
|
kind: "image",
|
||||||
onProgress: (e) => {
|
onProgress: (e) => {
|
||||||
setStatus(`${e.status} / ${e.progress}%`);
|
setStatus(`${e.status} / ${e.progress}%`);
|
||||||
if (e.status === "completed" && e.resultUrl) {
|
if (e.status === "completed" && e.resultUrl) {
|
||||||
@@ -296,9 +316,9 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
|||||||
|
|
||||||
const [isInpaintDragging, setIsInpaintDragging] = useState(false);
|
const [isInpaintDragging, setIsInpaintDragging] = useState(false);
|
||||||
|
|
||||||
const handleInpaintDragOver = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsInpaintDragging(true); };
|
const handleInpaintDragOver = (e: DragEvent<HTMLElement>) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsInpaintDragging(true); };
|
||||||
const handleInpaintDragLeave = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsInpaintDragging(false); };
|
const handleInpaintDragLeave = (e: DragEvent<HTMLElement>) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsInpaintDragging(false); };
|
||||||
const handleInpaintDrop = (e: DragEvent<HTMLDivElement>) => {
|
const handleInpaintDrop = (e: DragEvent<HTMLElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsInpaintDragging(false);
|
setIsInpaintDragging(false);
|
||||||
@@ -464,9 +484,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
|||||||
const refUrls = await uploadReferenceImages([cameraImage]);
|
const refUrls = await uploadReferenceImages([cameraImage]);
|
||||||
const model = "wan2.7-image-pro";
|
const model = "wan2.7-image-pro";
|
||||||
const cameraDesc = `镜头预设: ${cameraPreset}, 方向: ${cameraDirection}, 水平: ${cameraHorizontal}°, 垂直: ${cameraVertical}°, 倾斜: ${cameraRoll}°, 焦距: ${cameraZoom}mm`;
|
const cameraDesc = `镜头预设: ${cameraPreset}, 方向: ${cameraDirection}, 水平: ${cameraHorizontal}°, 垂直: ${cameraVertical}°, 倾斜: ${cameraRoll}°, 焦距: ${cameraZoom}mm`;
|
||||||
const effectsDesc = cameraEffects.size > 0
|
const effectsDesc = getCameraEffectsPrompt(cameraEffects);
|
||||||
? Array.from(cameraEffects).map((key) => CAMERA_EFFECT_PRESETS.find((e) => e.key === key)?.prompt).filter(Boolean).join(",")
|
|
||||||
: "";
|
|
||||||
const fullPrompt = cameraPromptEnabled && cameraPrompt.trim()
|
const fullPrompt = cameraPromptEnabled && cameraPrompt.trim()
|
||||||
? `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}。${cameraPrompt}`
|
? `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}。${cameraPrompt}`
|
||||||
: `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}。保持人物和场景一致,按照镜头参数重新构图。`;
|
: `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}。保持人物和场景一致,按照镜头参数重新构图。`;
|
||||||
@@ -512,6 +530,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
|||||||
|
|
||||||
const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => {
|
const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => {
|
||||||
return waitForTask(taskId, {
|
return waitForTask(taskId, {
|
||||||
|
kind: "image",
|
||||||
abortRef,
|
abortRef,
|
||||||
onProgress: (e) => setGenerationProgress(e.progress || 0),
|
onProgress: (e) => setGenerationProgress(e.progress || 0),
|
||||||
});
|
});
|
||||||
@@ -818,7 +837,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
|||||||
<h3>参数</h3>
|
<h3>参数</h3>
|
||||||
<span className="image-workbench-field-label">输出分辨率</span>
|
<span className="image-workbench-field-label">输出分辨率</span>
|
||||||
<div className="image-workbench-segmented">
|
<div className="image-workbench-segmented">
|
||||||
{(["9:16", "16:9", "4:3", "3:4", "1:1"] as OutputSize[]).map((s) => (
|
{OUTPUT_SIZE_OPTIONS.map((s) => (
|
||||||
<button key={s} type="button" className={outputSize === s ? "is-active" : ""} onClick={() => setOutputSize(s)}>
|
<button key={s} type="button" className={outputSize === s ? "is-active" : ""} onClick={() => setOutputSize(s)}>
|
||||||
{s}
|
{s}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import "../../styles/pages/more.css";
|
||||||
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
|
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
|
||||||
|
|
||||||
interface MorePageProps {
|
interface MorePageProps {
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import {
|
|||||||
ShareAltOutlined,
|
ShareAltOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import "../../styles/pages/profile.css";
|
||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
import { assetClient } from "../../api/assetClient";
|
import { assetClient } from "../../api/assetClient";
|
||||||
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
||||||
@@ -268,8 +269,14 @@ function ProfilePage({
|
|||||||
const [isDeletingDetail, setIsDeletingDetail] = useState(false);
|
const [isDeletingDetail, setIsDeletingDetail] = useState(false);
|
||||||
const [isDownloadingDetail, setIsDownloadingDetail] = useState(false);
|
const [isDownloadingDetail, setIsDownloadingDetail] = useState(false);
|
||||||
|
|
||||||
const completedTasks = tasks.filter((task) => task.status === "completed");
|
const completedTasks = useMemo(
|
||||||
const visibleWorks = completedTasks.length ? completedTasks : tasks.slice(0, 6);
|
() => tasks.filter((task) => task.status === "completed"),
|
||||||
|
[tasks],
|
||||||
|
);
|
||||||
|
const visibleWorks = useMemo(
|
||||||
|
() => (completedTasks.length ? completedTasks : tasks.slice(0, 6)),
|
||||||
|
[completedTasks, tasks],
|
||||||
|
);
|
||||||
const totalBalance = usage.balanceCents + (session?.user.enterpriseBalanceCents || 0);
|
const totalBalance = usage.balanceCents + (session?.user.enterpriseBalanceCents || 0);
|
||||||
const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分";
|
const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分";
|
||||||
const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null;
|
const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null;
|
||||||
@@ -697,17 +704,21 @@ function ProfilePage({
|
|||||||
const handleDownloadSelectedDetail = async () => {
|
const handleDownloadSelectedDetail = async () => {
|
||||||
if (!detailSelection || isDownloadingDetail) return;
|
if (!detailSelection || isDownloadingDetail) return;
|
||||||
|
|
||||||
const isWork = detailSelection.kind === "work";
|
const url =
|
||||||
const item = detailSelection.item;
|
detailSelection.kind === "work"
|
||||||
const url = isWork ? item.outputUrl : item.imageUrl || item.url || "";
|
? detailSelection.item.outputUrl
|
||||||
|
: detailSelection.item.imageUrl || detailSelection.item.url || "";
|
||||||
if (!url) {
|
if (!url) {
|
||||||
setDetailNotice("暂无可下载的媒体文件");
|
setDetailNotice("暂无可下载的媒体文件");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isVideo = isWork ? item.type === "video" : item.type === "video" || /\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(url);
|
const isVideo =
|
||||||
const taskId = isWork ? item.id : item.sourceTaskId || undefined;
|
detailSelection.kind === "work"
|
||||||
const name = isWork ? item.title : item.name;
|
? detailSelection.item.type === "video"
|
||||||
|
: detailSelection.item.type === "video" || /\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(url);
|
||||||
|
const taskId = detailSelection.kind === "work" ? detailSelection.item.id : detailSelection.item.sourceTaskId || undefined;
|
||||||
|
const name = detailSelection.kind === "work" ? detailSelection.item.title : detailSelection.item.name;
|
||||||
|
|
||||||
setIsDownloadingDetail(true);
|
setIsDownloadingDetail(true);
|
||||||
setDetailNotice("正在准备下载...");
|
setDetailNotice("正在准备下载...");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import "../../styles/pages/provider-health.css";
|
||||||
import {
|
import {
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
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";
|
||||||
|
import "../../styles/pages/more-tools.css";
|
||||||
|
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";
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import {
|
|||||||
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";
|
||||||
|
import "../../styles/pages/script-tokens-v5.css";
|
||||||
|
import "../../styles/pages/script-tokens.css";
|
||||||
import { evaluateScript } from "../../api/scriptEvalClient";
|
import { evaluateScript } from "../../api/scriptEvalClient";
|
||||||
import { buildApiUrl, getStoredToken } from "../../api/serverConnection";
|
import { buildApiUrl, getStoredToken } from "../../api/serverConnection";
|
||||||
|
import { ShellIcon } from "../../components/ShellIcon";
|
||||||
import { useSessionStore } from "../../stores";
|
import { useSessionStore } from "../../stores";
|
||||||
|
|
||||||
interface ScoreDimension {
|
interface ScoreDimension {
|
||||||
@@ -244,9 +247,21 @@ function getDimensionSubScores(result: EvalResult, dim: ScoreDimension): Array<[
|
|||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEvidenceItems(evidence: unknown[] | undefined, limit: number): string[] {
|
||||||
|
if (!Array.isArray(evidence)) return [];
|
||||||
|
const items: string[] = [];
|
||||||
|
for (const item of evidence) {
|
||||||
|
const value = String(item).trim();
|
||||||
|
if (!value) continue;
|
||||||
|
items.push(value);
|
||||||
|
if (items.length >= limit) break;
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[] {
|
function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[] {
|
||||||
const evidence = result.evidence?.[dim.key] ?? (dim.key === "logic" ? result.evidence?.dialogue : undefined);
|
const evidence = result.evidence?.[dim.key] ?? (dim.key === "logic" ? result.evidence?.dialogue : undefined);
|
||||||
return Array.isArray(evidence) ? evidence.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3) : [];
|
return normalizeEvidenceItems(evidence, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatReportMarkdown(result: EvalResult, script: string): string {
|
function formatReportMarkdown(result: EvalResult, script: string): string {
|
||||||
@@ -538,10 +553,10 @@ function ScriptTokensPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="script-eval-v5-upload-icon"><UploadOutlined /></div>
|
<div className="script-eval-v5-upload-icon"><ShellIcon name="upload" /></div>
|
||||||
<div className="script-eval-v5-upload-text">拖拽或点击上传</div>
|
<div className="script-eval-v5-upload-text">拖拽或点击上传</div>
|
||||||
<button type="button" className="script-eval-v5-upload-btn" onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}>
|
<button type="button" className="script-eval-v5-upload-btn" onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}>
|
||||||
<UploadOutlined /> 选择剧本
|
<ShellIcon name="upload" /> 选择剧本
|
||||||
</button>
|
</button>
|
||||||
<div className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div>
|
<div className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div>
|
||||||
</>
|
</>
|
||||||
@@ -614,11 +629,11 @@ function ScriptTokensPage() {
|
|||||||
disabled={loading || !hasContent}
|
disabled={loading || !hasContent}
|
||||||
onClick={() => void handleEvaluate()}
|
onClick={() => void handleEvaluate()}
|
||||||
>
|
>
|
||||||
{loading ? <LoadingOutlined /> : <ThunderboltOutlined />}
|
{loading ? <ShellIcon name="loading" /> : <ShellIcon name="thunderbolt" />}
|
||||||
<span>{loading ? "评测中..." : "开始评测"}</span>
|
<span>{loading ? "评测中..." : "开始评测"}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}>
|
<button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}>
|
||||||
<DownloadOutlined />
|
<ShellIcon name="download" />
|
||||||
<span>导出评测报告</span>
|
<span>导出评测报告</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -636,10 +651,10 @@ function ScriptTokensPage() {
|
|||||||
{result && (
|
{result && (
|
||||||
<>
|
<>
|
||||||
<button type="button" className="script-eval-v5-action-btn" onClick={() => void handleCopyReport()}>
|
<button type="button" className="script-eval-v5-action-btn" onClick={() => void handleCopyReport()}>
|
||||||
<CopyOutlined />{copied ? "已复制" : "复制"}
|
<ShellIcon name="copy" />{copied ? "已复制" : "复制"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="script-eval-v5-action-btn" onClick={handleExportMarkdown}>
|
<button type="button" className="script-eval-v5-action-btn" onClick={handleExportMarkdown}>
|
||||||
<DownloadOutlined />导出
|
<ShellIcon name="download" />导出
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -673,7 +688,7 @@ function ScriptTokensPage() {
|
|||||||
onKeyDown={uploadKeyDown}
|
onKeyDown={uploadKeyDown}
|
||||||
>
|
>
|
||||||
<div className="script-eval-v5-upload-card-icon">
|
<div className="script-eval-v5-upload-card-icon">
|
||||||
<FileTextOutlined />
|
<ShellIcon name="file-text" />
|
||||||
</div>
|
</div>
|
||||||
<div className="script-eval-v5-upload-card-title">
|
<div className="script-eval-v5-upload-card-title">
|
||||||
{uploadedFile ? "剧本已导入" : "上传剧本文件"}
|
{uploadedFile ? "剧本已导入" : "上传剧本文件"}
|
||||||
@@ -777,7 +792,7 @@ function ScriptTokensPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="script-eval-report__chart-note">
|
<div className="script-eval-report__chart-note">
|
||||||
<BarChartOutlined />
|
<ShellIcon name="bar-chart" />
|
||||||
<span>
|
<span>
|
||||||
{activeDim === null
|
{activeDim === null
|
||||||
? "悬停维度可查看当前分项表现,优先从低分项制定改稿计划。"
|
? "悬停维度可查看当前分项表现,优先从低分项制定改稿计划。"
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
import {
|
|
||||||
ArrowLeftOutlined,
|
|
||||||
BarChartOutlined,
|
|
||||||
CheckCircleOutlined,
|
|
||||||
LeftOutlined,
|
|
||||||
LineChartOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
RightOutlined,
|
|
||||||
TeamOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
WarningOutlined,
|
|
||||||
} from "@ant-design/icons";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ShellIcon } from "../../components/ShellIcon";
|
||||||
|
import "../../styles/pages/more-tools.css";
|
||||||
|
import "../../styles/pages/script-tokens-v5.css";
|
||||||
|
import "../../styles/pages/script-tokens.css";
|
||||||
import type {
|
import type {
|
||||||
WebEnterpriseUsageMember,
|
WebEnterpriseUsageMember,
|
||||||
WebEnterpriseUsageRecord,
|
WebEnterpriseUsageRecord,
|
||||||
@@ -243,7 +235,7 @@ function TokenUsagePage({
|
|||||||
<header className="management-center-toolbar" aria-label="管理中心操作">
|
<header className="management-center-toolbar" aria-label="管理中心操作">
|
||||||
<div className="management-center-toolbar__title">
|
<div className="management-center-toolbar__title">
|
||||||
<button type="button" className="management-center-toolbar__back" aria-label="返回工具盒" onClick={onOpenMore}>
|
<button type="button" className="management-center-toolbar__back" aria-label="返回工具盒" onClick={onOpenMore}>
|
||||||
<ArrowLeftOutlined />
|
<ShellIcon name="arrow-left" />
|
||||||
</button>
|
</button>
|
||||||
<span>
|
<span>
|
||||||
<strong>管理中心</strong>
|
<strong>管理中心</strong>
|
||||||
@@ -254,18 +246,18 @@ function TokenUsagePage({
|
|||||||
{enterpriseUsageLoading ? "正在同步企业用量" : enterpriseUsageError || "服务器已连接"}
|
{enterpriseUsageLoading ? "正在同步企业用量" : enterpriseUsageError || "服务器已连接"}
|
||||||
</span>
|
</span>
|
||||||
<button type="button" onClick={refreshEnterpriseUsage} disabled={enterpriseUsageLoading}>
|
<button type="button" onClick={refreshEnterpriseUsage} disabled={enterpriseUsageLoading}>
|
||||||
<ReloadOutlined />
|
<ShellIcon name="reload" />
|
||||||
刷新数据
|
刷新数据
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="is-muted-action">
|
<button type="button" className="is-muted-action">
|
||||||
<UserOutlined />
|
<ShellIcon name="user" />
|
||||||
成员管理
|
成员管理
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{isLowBalance ? (
|
{isLowBalance ? (
|
||||||
<div className="management-balance-alert" role="alert">
|
<div className="management-balance-alert" role="alert">
|
||||||
<WarningOutlined />
|
<ShellIcon name="warning" />
|
||||||
<span>当前余额 {formatCredits(availableBalanceCents)},可能不足以完成下一次生成,请及时充值。</span>
|
<span>当前余额 {formatCredits(availableBalanceCents)},可能不足以完成下一次生成,请及时充值。</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -284,7 +276,7 @@ function TokenUsagePage({
|
|||||||
<article className="management-card management-card--chart">
|
<article className="management-card management-card--chart">
|
||||||
<div className="management-card__head">
|
<div className="management-card__head">
|
||||||
<h2>
|
<h2>
|
||||||
<BarChartOutlined />
|
<ShellIcon name="bar-chart" />
|
||||||
模型消耗分布
|
模型消耗分布
|
||||||
</h2>
|
</h2>
|
||||||
<span>{enterpriseUsageLoading ? "SYNC" : modelBreakdown.length ? `${modelBreakdown.length} 个模型` : "LIVE"}</span>
|
<span>{enterpriseUsageLoading ? "SYNC" : modelBreakdown.length ? `${modelBreakdown.length} 个模型` : "LIVE"}</span>
|
||||||
@@ -306,7 +298,7 @@ function TokenUsagePage({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="management-empty-chart">
|
<div className="management-empty-chart">
|
||||||
<BarChartOutlined />
|
<ShellIcon name="bar-chart" />
|
||||||
<span>暂无模型用量数据</span>
|
<span>暂无模型用量数据</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -315,7 +307,7 @@ function TokenUsagePage({
|
|||||||
<article className="management-card management-status-card">
|
<article className="management-card management-status-card">
|
||||||
<div className="management-card__head">
|
<div className="management-card__head">
|
||||||
<h2>
|
<h2>
|
||||||
<LineChartOutlined />
|
<ShellIcon name="line-chart" />
|
||||||
系统状态
|
系统状态
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -344,7 +336,7 @@ function TokenUsagePage({
|
|||||||
<section className="management-card management-members">
|
<section className="management-card management-members">
|
||||||
<div className="management-card__head">
|
<div className="management-card__head">
|
||||||
<h2>
|
<h2>
|
||||||
<TeamOutlined />
|
<ShellIcon name="team" />
|
||||||
团队成员 ({members.length})
|
团队成员 ({members.length})
|
||||||
</h2>
|
</h2>
|
||||||
<button type="button">{isEnterpriseAdmin ? "企业管理员" : "当前账号"}</button>
|
<button type="button">{isEnterpriseAdmin ? "企业管理员" : "当前账号"}</button>
|
||||||
@@ -363,7 +355,7 @@ function TokenUsagePage({
|
|||||||
<b>{member.taskCount} 调用</b>
|
<b>{member.taskCount} 调用</b>
|
||||||
<b>{formatDateTime(member.lastUsedAt)}</b>
|
<b>{formatDateTime(member.lastUsedAt)}</b>
|
||||||
</span>
|
</span>
|
||||||
<CheckCircleOutlined />
|
<ShellIcon name="check-circle" />
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -372,7 +364,7 @@ function TokenUsagePage({
|
|||||||
<section className="management-card management-records">
|
<section className="management-card management-records">
|
||||||
<div className="management-card__head">
|
<div className="management-card__head">
|
||||||
<h2>
|
<h2>
|
||||||
<BarChartOutlined />
|
<ShellIcon name="bar-chart" />
|
||||||
调用记录
|
调用记录
|
||||||
</h2>
|
</h2>
|
||||||
<span>{records.length} 条记录</span>
|
<span>{records.length} 条记录</span>
|
||||||
@@ -408,11 +400,11 @@ function TokenUsagePage({
|
|||||||
{records.length > pageSize && (
|
{records.length > pageSize && (
|
||||||
<div className="management-record-pagination">
|
<div className="management-record-pagination">
|
||||||
<button type="button" disabled={recordPage === 0} onClick={() => setRecordPage((p) => p - 1)}>
|
<button type="button" disabled={recordPage === 0} onClick={() => setRecordPage((p) => p - 1)}>
|
||||||
<LeftOutlined />
|
<ShellIcon name="chevron-left" />
|
||||||
</button>
|
</button>
|
||||||
<span>{recordPage + 1} / {totalPages}</span>
|
<span>{recordPage + 1} / {totalPages}</span>
|
||||||
<button type="button" disabled={recordPage >= totalPages - 1} onClick={() => setRecordPage((p) => p + 1)}>
|
<button type="button" disabled={recordPage >= totalPages - 1} onClick={() => setRecordPage((p) => p + 1)}>
|
||||||
<RightOutlined />
|
<ShellIcon name="chevron-right" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||||
import type { WebViewKey } from "../../types";
|
import type { WebViewKey } from "../../types";
|
||||||
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "../ecommerce/ImageMentionMenu";
|
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "../ecommerce/ImageMentionMenu";
|
||||||
|
import "../../styles/pages/ecommerce.css";
|
||||||
|
import "../../styles/pages/size-template.css";
|
||||||
|
import "../../styles/pages/local-theme-parity.css";
|
||||||
|
|
||||||
interface SizeTemplatePageProps {
|
interface SizeTemplatePageProps {
|
||||||
isAuthenticated?: boolean;
|
isAuthenticated?: boolean;
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useCallback, useEffect, useRef, useState, type CSSProperties, type DragEvent } from "react";
|
import { useCallback, useEffect, useRef, useState, type CSSProperties, type DragEvent } from "react";
|
||||||
|
import "../../styles/pages/more-tools.css";
|
||||||
|
import "../../styles/pages/image-workbench.css";
|
||||||
|
import "../../styles/pages/subtitle-removal.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";
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
SwapOutlined,
|
SwapOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import "../../styles/pages/more-tools.css";
|
||||||
|
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";
|
||||||
|
|||||||
@@ -34,13 +34,14 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
type SyntheticEvent,
|
type SyntheticEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import "../../styles/pages/workbench.css";
|
||||||
import type { WebGenerationPreviewTask, WebUserSession } from "../../types";
|
import type { WebGenerationPreviewTask, WebUserSession } from "../../types";
|
||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
import { claimGenerationSlot, getActiveGenerationTaskCount, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
|
import { claimGenerationSlot, getActiveGenerationTaskCount, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
|
||||||
import { preUploadReference, resolvePreUploadedUrl } 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 { RechargeModal } from "../../components/RechargeModal/RechargeModal";
|
import { loadRechargeModal, type RechargeModalComponent } from "../../components/RechargeModal/loadRechargeModal";
|
||||||
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
||||||
|
|
||||||
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
|
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
|
||||||
@@ -270,6 +271,7 @@ function WorkbenchPage({
|
|||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [generationStatus, setGenerationStatus] = useState("准备就绪");
|
const [generationStatus, setGenerationStatus] = useState("准备就绪");
|
||||||
const [showRechargeModal, setShowRechargeModal] = useState(false);
|
const [showRechargeModal, setShowRechargeModal] = useState(false);
|
||||||
|
const [RechargeModal, setRechargeModal] = useState<RechargeModalComponent | null>(null);
|
||||||
const [savedAssetMentionItems, setSavedAssetMentionItems] = useState<
|
const [savedAssetMentionItems, setSavedAssetMentionItems] = useState<
|
||||||
Pick<ReferenceItem, "id" | "kind" | "name" | "previewUrl" | "remoteUrl" | "token">[]
|
Pick<ReferenceItem, "id" | "kind" | "name" | "previewUrl" | "remoteUrl" | "token">[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -293,6 +295,21 @@ function WorkbenchPage({
|
|||||||
activeConversationIdRef.current = activeConversationId;
|
activeConversationIdRef.current = activeConversationId;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showRechargeModal || RechargeModal) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void loadRechargeModal().then((component) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setRechargeModal(() => component);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [RechargeModal, showRechargeModal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -376,7 +393,7 @@ function WorkbenchPage({
|
|||||||
.get()
|
.get()
|
||||||
.then((capabilities) => {
|
.then((capabilities) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const nextVideoModels = VIDEO_MODEL_OPTIONS;
|
const nextVideoModels = capabilities.videoModels.length ? capabilities.videoModels : VIDEO_MODEL_OPTIONS;
|
||||||
|
|
||||||
applyImageModels(capabilities.imageModels);
|
applyImageModels(capabilities.imageModels);
|
||||||
setVideoModelOptions(nextVideoModels);
|
setVideoModelOptions(nextVideoModels);
|
||||||
@@ -3237,7 +3254,9 @@ function WorkbenchPage({
|
|||||||
|
|
||||||
{renderMessagePreviewOverlay()}
|
{renderMessagePreviewOverlay()}
|
||||||
{renderDeleteDialog()}
|
{renderDeleteDialog()}
|
||||||
|
{showRechargeModal && RechargeModal ? (
|
||||||
<RechargeModal open={showRechargeModal} onClose={() => setShowRechargeModal(false)} currentBalance={session?.user?.balanceCents} />
|
<RechargeModal open={showRechargeModal} onClose={() => setShowRechargeModal(false)} currentBalance={session?.user?.balanceCents} />
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* Persists task state to localStorage so in-progress tasks survive page switches.
|
* Persists task state to localStorage so in-progress tasks survive page switches.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { waitForTask } from "../../api/taskSubscription";
|
||||||
|
|
||||||
const KEEPALIVE_PREFIX = "omniai:tool-task:";
|
const KEEPALIVE_PREFIX = "omniai:tool-task:";
|
||||||
|
|
||||||
interface ToolTaskKeepalive {
|
interface ToolTaskKeepalive {
|
||||||
@@ -59,38 +61,19 @@ export function clearToolTaskState(key: string): void {
|
|||||||
try { window.localStorage.removeItem(KEEPALIVE_PREFIX + key); } catch { /* ignore */ }
|
try { window.localStorage.removeItem(KEEPALIVE_PREFIX + key); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const TASK_POLL_INTERVAL = 3000;
|
|
||||||
const TASK_POLL_TIMEOUT = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
export async function pollTaskUntilDone(
|
export async function pollTaskUntilDone(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
onProgress?: (progress: number) => void,
|
onProgress?: (progress: number) => void,
|
||||||
abortRef?: { current: boolean },
|
abortRef?: { current: boolean },
|
||||||
|
kind: "image" | "video" = "video",
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const startTime = Date.now();
|
|
||||||
const { aiGenerationClient } = await import("../../api/aiGenerationClient");
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
if (abortRef?.current) return null;
|
|
||||||
if (Date.now() - startTime > TASK_POLL_TIMEOUT) return null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const task = await aiGenerationClient.getTaskStatus(taskId);
|
return await waitForTask(taskId, {
|
||||||
if (!task) return null;
|
kind,
|
||||||
|
abortRef,
|
||||||
const progress = Math.min(99, task.progress || 0);
|
onProgress: (event) => onProgress?.(Math.min(99, Number(event.progress || 0))),
|
||||||
onProgress?.(progress);
|
});
|
||||||
|
} catch {
|
||||||
if (task.status === "completed") {
|
|
||||||
return task.resultUrl || null;
|
|
||||||
}
|
|
||||||
if (task.status === "failed" || task.status === "cancelled") {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// retry on next poll
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, TASK_POLL_INTERVAL));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useCallback } from "react";
|
import { useEffect, useMemo, useRef, useCallback } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import type { GenerationQueueItem } from "../stores/useGenerationStore";
|
import type { GenerationQueueItem } from "../stores/useGenerationStore";
|
||||||
import { useGenerationStore } from "../stores/useGenerationStore";
|
import { useGenerationStore } from "../stores/useGenerationStore";
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +14,17 @@ interface UseGenerationTasksOptions {
|
|||||||
|
|
||||||
export function useGenerationTasks(options: UseGenerationTasksOptions) {
|
export function useGenerationTasks(options: UseGenerationTasksOptions) {
|
||||||
const { sourceView, autoResume = true } = options;
|
const { sourceView, autoResume = true } = options;
|
||||||
const store = useGenerationStore();
|
const {
|
||||||
|
queue,
|
||||||
|
addTask,
|
||||||
|
updateTask: updateStoredTask,
|
||||||
|
getRunningTasks,
|
||||||
|
} = useGenerationStore(useShallow((s) => ({
|
||||||
|
queue: s.queue,
|
||||||
|
addTask: s.addTask,
|
||||||
|
updateTask: s.updateTask,
|
||||||
|
getRunningTasks: s.getRunningTasks,
|
||||||
|
})));
|
||||||
const pollingStartedRef = useRef(false);
|
const pollingStartedRef = useRef(false);
|
||||||
|
|
||||||
// ── Auto-resume: re-subscribe to running tasks on mount ────
|
// ── Auto-resume: re-subscribe to running tasks on mount ────
|
||||||
@@ -21,7 +32,7 @@ export function useGenerationTasks(options: UseGenerationTasksOptions) {
|
|||||||
if (!autoResume || pollingStartedRef.current) return;
|
if (!autoResume || pollingStartedRef.current) return;
|
||||||
pollingStartedRef.current = true;
|
pollingStartedRef.current = true;
|
||||||
|
|
||||||
const active = store.getRunningTasks().filter((t) => t.sourceView === sourceView);
|
const active = getRunningTasks().filter((t) => t.sourceView === sourceView);
|
||||||
if (active.length > 0) {
|
if (active.length > 0) {
|
||||||
startBackgroundPolling();
|
startBackgroundPolling();
|
||||||
}
|
}
|
||||||
@@ -29,19 +40,19 @@ export function useGenerationTasks(options: UseGenerationTasksOptions) {
|
|||||||
return () => {
|
return () => {
|
||||||
pollingStartedRef.current = false;
|
pollingStartedRef.current = false;
|
||||||
};
|
};
|
||||||
}, [autoResume, sourceView, store]);
|
}, [autoResume, sourceView, getRunningTasks]);
|
||||||
|
|
||||||
// ── Subscribe to live updates ───────────────────────────
|
// ── Subscribe to live updates ───────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return subscribeToTaskUpdates((updated) => {
|
return subscribeToTaskUpdates((updated) => {
|
||||||
store.updateTask(updated.id, updated);
|
updateStoredTask(updated.id, updated);
|
||||||
});
|
});
|
||||||
}, [store]);
|
}, [updateStoredTask]);
|
||||||
|
|
||||||
// ── View-scoped computed lists ──────────────────────────
|
// ── View-scoped computed lists ──────────────────────────
|
||||||
const myTasks = useMemo(
|
const myTasks = useMemo(
|
||||||
() => store.queue.filter((t) => t.sourceView === sourceView),
|
() => queue.filter((t) => t.sourceView === sourceView),
|
||||||
[store.queue, sourceView],
|
[queue, sourceView],
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeTasks = useMemo(
|
const activeTasks = useMemo(
|
||||||
@@ -63,41 +74,41 @@ export function useGenerationTasks(options: UseGenerationTasksOptions) {
|
|||||||
const submitTask = useCallback(
|
const submitTask = useCallback(
|
||||||
(task: Omit<GenerationQueueItem, "id" | "createdAt">) => {
|
(task: Omit<GenerationQueueItem, "id" | "createdAt">) => {
|
||||||
const id = `gen-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const id = `gen-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
store.addTask({ ...task, id, createdAt: Date.now() });
|
addTask({ ...task, id, createdAt: Date.now() });
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
[store],
|
[addTask],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateTask = useCallback(
|
const updateTask = useCallback(
|
||||||
(id: string, patch: Partial<GenerationQueueItem>) => {
|
(id: string, patch: Partial<GenerationQueueItem>) => {
|
||||||
store.updateTask(id, patch);
|
updateStoredTask(id, patch);
|
||||||
},
|
},
|
||||||
[store],
|
[updateStoredTask],
|
||||||
);
|
);
|
||||||
|
|
||||||
const markCompleted = useCallback(
|
const markCompleted = useCallback(
|
||||||
(id: string, resultUrl: string) => {
|
(id: string, resultUrl: string) => {
|
||||||
store.updateTask(id, { status: "completed", progress: 100, resultUrl });
|
updateStoredTask(id, { status: "completed", progress: 100, resultUrl });
|
||||||
},
|
},
|
||||||
[store],
|
[updateStoredTask],
|
||||||
);
|
);
|
||||||
|
|
||||||
const markFailed = useCallback(
|
const markFailed = useCallback(
|
||||||
(id: string, error: string) => {
|
(id: string, error: string) => {
|
||||||
store.updateTask(id, { status: "failed", error });
|
updateStoredTask(id, { status: "failed", error });
|
||||||
},
|
},
|
||||||
[store],
|
[updateStoredTask],
|
||||||
);
|
);
|
||||||
|
|
||||||
const retryTask = useCallback(
|
const retryTask = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const task = store.queue.find((t) => t.id === id);
|
const task = queue.find((t) => t.id === id);
|
||||||
if (task) {
|
if (task) {
|
||||||
store.updateTask(id, { status: "pending", progress: 0, error: null });
|
updateStoredTask(id, { status: "pending", progress: 0, error: null });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[store],
|
[queue, updateStoredTask],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import "@xyflow/react/dist/style.css";
|
|
||||||
import "./styles/index.css";
|
import "./styles/index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { reportError } from "./utils/errorReporting";
|
import { reportError } from "./utils/errorReporting";
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
|
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
|
||||||
import { aiGenerationClient } from "../api/aiGenerationClient";
|
import { waitForTask, type TaskProgressEvent } from "../api/taskSubscription";
|
||||||
import {
|
import { buildTaskFailureInfo } from "../utils/taskLifecycle";
|
||||||
buildLocalTimeoutMessage,
|
|
||||||
buildTaskFailureInfo,
|
|
||||||
getTaskTimeoutPolicy,
|
|
||||||
isTaskLocallyTimedOut,
|
|
||||||
} from "../utils/taskLifecycle";
|
|
||||||
|
|
||||||
type PollCallback = (item: GenerationQueueItem) => void;
|
type PollCallback = (item: GenerationQueueItem) => void;
|
||||||
|
|
||||||
const activePollers = new Map<string, ReturnType<typeof setInterval>>();
|
const activePollers = new Map<string, { current: boolean }>();
|
||||||
const pollCallbacks = new Set<PollCallback>();
|
const pollCallbacks = new Set<PollCallback>();
|
||||||
|
|
||||||
const POLL_INTERVAL = 3000;
|
|
||||||
const MAX_POLL_ATTEMPTS = 200; // Keep the previous 10-minute guard as a fallback.
|
|
||||||
|
|
||||||
export function subscribeToTaskUpdates(callback: PollCallback): () => void {
|
export function subscribeToTaskUpdates(callback: PollCallback): () => void {
|
||||||
pollCallbacks.add(callback);
|
pollCallbacks.add(callback);
|
||||||
return () => { pollCallbacks.delete(callback); };
|
return () => { pollCallbacks.delete(callback); };
|
||||||
@@ -34,109 +26,109 @@ function getQueueItemModel(item: GenerationQueueItem): string | undefined {
|
|||||||
return typeof item.params?.model === "string" ? item.params.model : undefined;
|
return typeof item.params?.model === "string" ? item.params.model : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void {
|
function updateTaskAndNotify(id: string, patch: Partial<GenerationQueueItem>): GenerationQueueItem | null {
|
||||||
|
const current = useGenerationStore.getState().queue.find((i) => i.id === id);
|
||||||
|
if (!current) return null;
|
||||||
|
const next = { ...current, ...patch };
|
||||||
|
useGenerationStore.getState().updateTask(id, patch);
|
||||||
|
notifyCallbacks(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalStatus(status: GenerationQueueItem["status"]): boolean {
|
||||||
|
return status === "completed" || status === "failed" || status === "cancelled";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollTask(item: GenerationQueueItem): void {
|
||||||
const key = `poll-${item.id}`;
|
const key = `poll-${item.id}`;
|
||||||
if (activePollers.has(key)) return;
|
if (activePollers.has(key) || !item.taskId) return;
|
||||||
|
|
||||||
const kind = getQueueItemKind(item);
|
const kind = getQueueItemKind(item);
|
||||||
const timeoutPolicy = getTaskTimeoutPolicy({ kind, model: getQueueItemModel(item) });
|
const abortRef = { current: false };
|
||||||
let lastProgress = Math.max(0, Number(item.progress || 0));
|
activePollers.set(key, abortRef);
|
||||||
let lastProgressAt = Date.now();
|
|
||||||
|
|
||||||
const interval = setInterval(async () => {
|
const applyProgress = (event: TaskProgressEvent) => {
|
||||||
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
|
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
|
||||||
if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") {
|
if (!current || isTerminalStatus(current.status)) {
|
||||||
cleanupPoll(key);
|
abortRef.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
attemptsRef.current++;
|
|
||||||
const timeoutReason = isTaskLocallyTimedOut({
|
|
||||||
startedAt: current.createdAt || item.createdAt || Date.now(),
|
|
||||||
lastProgressAt,
|
|
||||||
progress: lastProgress,
|
|
||||||
policy: timeoutPolicy,
|
|
||||||
});
|
|
||||||
if (timeoutReason || attemptsRef.current > MAX_POLL_ATTEMPTS) {
|
|
||||||
const error = buildLocalTimeoutMessage(kind);
|
|
||||||
useGenerationStore.getState().updateTask(item.id, {
|
|
||||||
status: "failed",
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
notifyCallbacks({ ...item, status: "failed", error });
|
|
||||||
cleanupPoll(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || "");
|
|
||||||
const nextProgress = Number(status.progress || 0);
|
|
||||||
if (nextProgress > lastProgress || status.status === "completed") {
|
|
||||||
lastProgress = Math.max(lastProgress, nextProgress);
|
|
||||||
lastProgressAt = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
const patch: Partial<GenerationQueueItem> = {
|
const patch: Partial<GenerationQueueItem> = {
|
||||||
progress: status.progress,
|
progress: Number(event.progress || 0),
|
||||||
resultUrl: status.resultUrl || current.resultUrl,
|
resultUrl: event.resultUrl || current.resultUrl,
|
||||||
error: status.error || current.error,
|
error: event.error || current.error,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status.status === "completed") {
|
if (event.status === "completed") {
|
||||||
patch.status = "completed";
|
patch.status = "completed";
|
||||||
useGenerationStore.getState().updateTask(item.id, patch);
|
patch.progress = 100;
|
||||||
notifyCallbacks({ ...item, ...patch, status: "completed" });
|
} else if (event.status === "failed" || event.status === "cancelled") {
|
||||||
cleanupPoll(key);
|
|
||||||
} else if (status.status === "failed" || status.status === "cancelled") {
|
|
||||||
patch.status = "failed";
|
patch.status = "failed";
|
||||||
patch.error = buildTaskFailureInfo(status.error).message;
|
patch.error = buildTaskFailureInfo(event.error).message;
|
||||||
useGenerationStore.getState().updateTask(item.id, patch);
|
|
||||||
notifyCallbacks({ ...item, ...patch, status: "failed" });
|
|
||||||
cleanupPoll(key);
|
|
||||||
} else {
|
} else {
|
||||||
patch.status = "running";
|
patch.status = "running";
|
||||||
useGenerationStore.getState().updateTask(item.id, patch);
|
|
||||||
notifyCallbacks({ ...item, ...patch, status: "running" });
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Network errors during polling are retried until the lifecycle guard trips.
|
|
||||||
}
|
|
||||||
}, POLL_INTERVAL);
|
|
||||||
|
|
||||||
activePollers.set(key, interval);
|
updateTaskAndNotify(item.id, patch);
|
||||||
|
};
|
||||||
|
|
||||||
|
void waitForTask(item.taskId, {
|
||||||
|
kind,
|
||||||
|
model: getQueueItemModel(item),
|
||||||
|
startedAt: item.createdAt || Date.now(),
|
||||||
|
abortRef,
|
||||||
|
onProgress: applyProgress,
|
||||||
|
})
|
||||||
|
.then((resultUrl) => {
|
||||||
|
if (abortRef.current) return;
|
||||||
|
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
|
||||||
|
if (!current || isTerminalStatus(current.status)) return;
|
||||||
|
updateTaskAndNotify(item.id, {
|
||||||
|
status: "completed",
|
||||||
|
progress: 100,
|
||||||
|
resultUrl: resultUrl || current.resultUrl,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (abortRef.current) return;
|
||||||
|
const failure = buildTaskFailureInfo(error instanceof Error ? error.message : String(error));
|
||||||
|
updateTaskAndNotify(item.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: failure.message,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
cleanupPoll(key, abortRef);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupPoll(key: string): void {
|
function cleanupPoll(key: string, abortRef: { current: boolean }): void {
|
||||||
const interval = activePollers.get(key);
|
if (activePollers.get(key) !== abortRef) return;
|
||||||
if (interval) {
|
|
||||||
clearInterval(interval);
|
|
||||||
activePollers.delete(key);
|
activePollers.delete(key);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startBackgroundPolling(): void {
|
export function startBackgroundPolling(): void {
|
||||||
const tasks = useGenerationStore.getState().getRunningTasks();
|
const tasks = useGenerationStore.getState().getRunningTasks();
|
||||||
const attemptsMap = new Map<string, { current: number }>();
|
|
||||||
|
|
||||||
tasks.forEach((task) => {
|
tasks.forEach((task) => {
|
||||||
if (task.taskId) {
|
if (task.taskId) {
|
||||||
if (!attemptsMap.has(task.id)) {
|
pollTask(task);
|
||||||
attemptsMap.set(task.id, { current: 0 });
|
|
||||||
}
|
|
||||||
pollTask(task, attemptsMap.get(task.id)!);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resumeTaskPolling(taskId: string, storeId: string): void {
|
export function resumeTaskPolling(taskId: string, storeId: string): void {
|
||||||
const task = useGenerationStore.getState().queue.find((i) => i.id === storeId);
|
const task = useGenerationStore.getState().queue.find((i) => i.id === storeId);
|
||||||
if (task && task.status !== "completed" && task.status !== "failed") {
|
if (task && !isTerminalStatus(task.status)) {
|
||||||
pollTask(task, { current: 0 });
|
pollTask({ ...task, taskId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopAllPolling(): void {
|
export function stopAllPolling(): void {
|
||||||
activePollers.forEach((interval) => clearInterval(interval));
|
activePollers.forEach((abortRef) => {
|
||||||
|
abortRef.current = true;
|
||||||
|
});
|
||||||
activePollers.clear();
|
activePollers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,41 +3,10 @@
|
|||||||
@import "./shell/app-shell.css";
|
@import "./shell/app-shell.css";
|
||||||
@import "./components/primitives.css";
|
@import "./components/primitives.css";
|
||||||
@import "./components/legacy-components.css";
|
@import "./components/legacy-components.css";
|
||||||
@import "./pages/home.css";
|
|
||||||
@import "./pages/welcome-splash.css";
|
|
||||||
@import "./pages/toolbox.css";
|
|
||||||
@import "./pages/script-review-visual.css";
|
|
||||||
@import "./pages/script-review-showcase.css";
|
|
||||||
@import "./pages/model-generation-showcase.css";
|
|
||||||
@import "./pages/workbench.css";
|
|
||||||
@import "./pages/ecommerce.css";
|
|
||||||
@import "./pages/ecommerce-video.css";
|
|
||||||
@import "./pages/community.css";
|
|
||||||
@import "./pages/assets.css";
|
|
||||||
@import "./pages/more.css";
|
|
||||||
@import "./pages/avatar-console.css";
|
|
||||||
@import "./pages/more-tools.css";
|
|
||||||
@import "./pages/studio-layout.css";
|
|
||||||
@import "./pages/image-workbench.css";
|
|
||||||
@import "./pages/subtitle-removal.css";
|
|
||||||
@import "./pages/dialog-generator.css";
|
|
||||||
@import "./pages/size-template.css";
|
|
||||||
@import "./pages/script-tokens-v5.css";
|
|
||||||
@import "./pages/script-tokens.css";
|
|
||||||
@import "./pages/profile.css";
|
|
||||||
@import "./pages/canvas.css";
|
|
||||||
@import "./pages/agent.css";
|
|
||||||
@import "./pages/compliance.css";
|
|
||||||
@import "./pages/provider-health.css";
|
|
||||||
@import "./pages/legacy-pages.css";
|
|
||||||
@import "./pages/not-found.css";
|
|
||||||
@import "./components/recharge-modal.css";
|
@import "./components/recharge-modal.css";
|
||||||
@import "./components/beta-application-modal.css";
|
@import "./components/beta-application-modal.css";
|
||||||
@import "./components/dropzone.css";
|
@import "./components/dropzone.css";
|
||||||
@import "./components/skeleton.css";
|
@import "./components/skeleton.css";
|
||||||
@import "./components/toast.css";
|
@import "./components/toast.css";
|
||||||
@import "./components/empty-state.css";
|
|
||||||
@import "./components/page-transition.css";
|
@import "./components/page-transition.css";
|
||||||
@import "./components/motion.css";
|
@import "./components/motion.css";
|
||||||
@import "./themes/dark-green.css";
|
|
||||||
@import "./pages/local-theme-parity.css";
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
let darkGreenThemePromise: Promise<unknown> | null = null;
|
||||||
|
|
||||||
|
export function loadDarkGreenTheme(): Promise<unknown> {
|
||||||
|
darkGreenThemePromise ??= import("./themes/dark-green.css");
|
||||||
|
return darkGreenThemePromise;
|
||||||
|
}
|
||||||
@@ -189,6 +189,18 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shell-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-icon svg {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
.creator-button,
|
.creator-button,
|
||||||
.member-button,
|
.member-button,
|
||||||
.profile-button,
|
.profile-button,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* Falls back gracefully when Notification API is unavailable.
|
* Falls back gracefully when Notification API is unavailable.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { toast } from "../components/toast/toastStore";
|
||||||
|
|
||||||
let permissionGranted = false;
|
let permissionGranted = false;
|
||||||
|
|
||||||
async function requestPermission(): Promise<boolean> {
|
async function requestPermission(): Promise<boolean> {
|
||||||
@@ -35,9 +37,7 @@ export function notifyTaskCompleted(label: string, mode: "image" | "video" = "im
|
|||||||
|
|
||||||
// Use the existing toast system for in-app notifications
|
// Use the existing toast system for in-app notifications
|
||||||
function dispatchGenToast(msg: string) {
|
function dispatchGenToast(msg: string) {
|
||||||
try {
|
toast(msg, "success");
|
||||||
import("../components/toast/toastStore").then((m) => m.toast(msg, "success"));
|
|
||||||
} catch { /* toast system not loaded */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Call once on app init to pre-warm permission. */
|
/** Call once on app init to pre-warm permission. */
|
||||||
|
|||||||