commit bedee3ba8d853a20a37a447e593622fa41a1a29f Author: Stringadmin Date: Tue Jun 2 12:38:01 2026 +0800 Initial commit: OmniAI Web Frontend Co-Authored-By: Claude Opus 4.7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39ebdc3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +dist/ +node_modules/ +*.env +*.env.local +*.env.*.local +.env.example +*.log +*.tmp +.DS_Store +Thumbs.db +.vscode/ +.idea/ +*.swp +*.swo +coverage/ \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..bcaa1ac --- /dev/null +++ b/index.html @@ -0,0 +1,41 @@ + + + + + + + + + OmniAI 创作中心 — AI 图像 / 视频生成 · 剧本测评 · 电商素材一站式平台 + + + + + + + + + + + + + + + + + + + +
+
+
+
加载中...
+
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..509d998 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2767 @@ +{ + "name": "omniai-web-preview", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "omniai-web-preview", + "version": "0.1.0", + "dependencies": { + "@ant-design/icons": "^5.3.0", + "@xyflow/react": "^12.10.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.1", + "playwright": "^1.60.0", + "sharp": "^0.34.5", + "typescript": "^5.3.3", + "vite": "^5.1.0", + "vite-plugin-compression2": "2.5.3" + } + }, + "node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmmirror.com/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmmirror.com/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.353", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmmirror.com/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-mini": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/tar-mini/-/tar-mini-0.2.0.tgz", + "integrity": "sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-plugin-compression2": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/vite-plugin-compression2/-/vite-plugin-compression2-2.5.3.tgz", + "integrity": "sha512-ItPgqQWkcnBbVw7is9OKwiZ8v6+ju9rYROl5Lp6QfQDEx/d55AwJQb/KLpsQqsU9HoigYBsZ8tK6I02UwJNvEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "tar-mini": "^0.2.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..efb8eb3 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "omniai-web-preview", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "preview": "vite preview --host 127.0.0.1", + "type-check": "tsc -p tsconfig.json --noEmit", + "style:check": "node scripts/check-style-governance.mjs", + "smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs" + }, + "dependencies": { + "@ant-design/icons": "5.3.0", + "@xyflow/react": "12.10.2", + "react": "18.2.0", + "react-dom": "18.2.0", + "zustand": "5.0.13" + }, + "devDependencies": { + "@types/react": "18.2.0", + "@types/react-dom": "18.2.0", + "@vitejs/plugin-react": "4.2.1", + "playwright": "1.60.0", + "sharp": "0.34.5", + "typescript": "5.3.3", + "vite": "5.1.0", + "vite-plugin-compression2": "2.5.3" + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..4f20bfc --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,1356 @@ +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 } from "react"; +import ErrorBoundary from "./components/ErrorBoundary"; +import PageTransition from "./components/PageTransition"; +import ToastContainer from "./components/toast/ToastContainer"; +import { aiGenerationClient } from "./api/aiGenerationClient"; +import { keyServerClient } from "./api/keyServerClient"; +import { notificationClient } from "./api/notificationClient"; +import { + SERVER_SESSION_REPLACED_EVENT, + SERVER_SESSION_EXPIRED_EVENT, + checkServerHealth, + getErrorMessage, + type ServerSessionReplacedDetail, +} from "./api/serverConnection"; +import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway"; +import { translateTaskError } from "./utils/translateTaskError"; +import AppShell from "./components/AppShell"; +import { cloneWorkflow, createBlankWorkflow } from "./data/workflows"; +const AgentPage = lazy(() => import("./features/agent/AgentPage")); +const AssetsPage = lazy(() => import("./features/assets/AssetsPage")); +const CanvasPage = lazy(() => import("./features/canvas/CanvasPage")); +const CharacterMixPage = lazy(() => import("./features/character-mix/CharacterMixPage")); +const CommunityPage = lazy(() => import("./features/community/CommunityPage")); +const CommunityCaseAddPage = lazy(() => import("./features/community-review/CommunityCaseAddPage")); +const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage")); +const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage")); +const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage")); +const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage")); +const EcommerceTemplatesPage = lazy(() => import("./features/ecommerce/EcommerceTemplatesPage")); +import type { TemplateCase } from "./features/ecommerce/ecommerceTemplates"; +const HomePage = lazy(() => import("./features/home/HomePage")); +const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage")); +const MorePage = lazy(() => import("./features/more/MorePage")); +const ReportPage = lazy(() => import("./features/report/ReportPage")); +const ProfilePage = lazy(() => import("./features/profile/ProfilePage")); +const ProviderHealthPage = lazy(() => import("./features/provider-health/ProviderHealthPage")); +const ResolutionUpscalePage = lazy(() => import("./features/resolution-upscale/ResolutionUpscalePage")); +const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/WatermarkRemovalPage")); +const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage")); +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 SettingsPage = lazy(() => import("./features/settings/SettingsPage")); +const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage")); +import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage"; +import { + useSessionStore, + useProjectStore, + useTaskStore, + useAppStore, + type PendingAction, +} from "./stores"; +import type { + WebCanvasWorkflow, + WebGenerationPreviewTask, + WebImageWorkbenchTool, + WebNavItem, + WebNotification, + WebProjectSummary, + WebUsageSummary, + WebUserSession, + WebViewKey, +} from "./types"; + +type SaveCanvasWorkflowOptions = { + silent?: boolean; + reason?: "manual" | "autosave" | "publish"; +}; + +const emptyUsageSummary: WebUsageSummary = { + balanceCents: 0, + imageUsed: 0, + videoUsed: 0, + textUsed: 0, + source: "preview", +}; + +const VIEW_KEYS = new Set([ + "home", + "workbench", + "community", + "login", + "agent", + "canvas", + "assets", + "ecommerceHub", + "ecommerce", + "ecommerceTemplates", + "scriptTokens", + "tokenUsage", + "settings", + "imageWorkbench", + "resolutionUpscale", + "watermarkRemoval", + "subtitleRemoval", + "digitalHuman", + "avatarConsole", + "characterMix", + "more", + "sizeTemplate", + "communityReview", + "communityCaseAdd", + "report", + "providerHealth", +]); + +const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "ecommerceTemplates", "sizeTemplate"]); + +function normalizeViewKey(rawView: string): WebViewKey { + const normalized = + rawView === "profile" || rawView === "auth" + ? "login" + : rawView === "ecommerceHub" + ? "ecommerce" + : rawView === "community-review" + ? "communityReview" + : rawView === "community-case-add" + ? "communityCaseAdd" + : rawView; + return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "home"; +} + +function readViewFromHash(): WebViewKey { + return normalizeViewKey(window.location.hash.replace(/^#\/?/, "")); +} + +function isWorkspaceView(view: WebViewKey): boolean { + return ( + view !== "workbench" && + view !== "home" && + view !== "community" && + view !== "assets" && + view !== "ecommerceHub" && + view !== "ecommerce" && + view !== "scriptTokens" && + view !== "login" + ); +} + +function isAdminAccount(session: WebUserSession | null): boolean { + if (!session) return false; + const role = String(session.user.role || "").trim().toLowerCase(); + const enterpriseRole = String(session.user.enterpriseRole || "").trim().toLowerCase(); + return role === "admin" || enterpriseRole === "admin"; +} + +function createWorkflowFromResult(payload: WorkbenchResultActionPayload): WebCanvasWorkflow { + const now = Date.now(); + const resultNodeKind = payload.resultType === "video" ? "video" : "image"; + + return { + id: `workflow-result-${now}`, + version: 1, + title: `继续编辑:${payload.title}`, + description: payload.prompt || "从生成结果进入画布继续创作。", + source: "blank", + settings: { + model: payload.resultType === "video" ? "Seedance 2.0" : "Nano Banana Pro", + ratio: payload.resultType === "video" ? "16:9" : "1:1", + duration: payload.resultType === "video" ? "6s" : "0s", + resolution: payload.resultType === "video" ? "720p" : "2K", + }, + nodes: [ + { + id: "prompt", + kind: "prompt", + label: "原始提示词", + detail: payload.prompt || "继续补充提示词", + position: { x: 80, y: 120 }, + }, + { + id: "generated-result", + kind: resultNodeKind, + label: payload.resultType === "video" ? "生成视频" : "生成图片", + detail: payload.title, + previewUrl: payload.resultUrl, + position: { x: 410, y: 80 }, + }, + { + id: "next-edit", + kind: "model", + label: "二次编辑", + detail: "可继续扩图、图生图、视频化或拆分镜头", + position: { x: 760, y: 160 }, + }, + { + id: "output", + kind: "output", + label: "新版输出", + detail: "保存资产或提交社区", + position: { x: 1080, y: 110 }, + }, + ], + edges: [ + { id: "prompt-generated-result", source: "prompt", target: "generated-result", label: "生成", animated: true }, + { id: "generated-result-next-edit", source: "generated-result", target: "next-edit", label: "继续", animated: true }, + { id: "next-edit-output", source: "next-edit", target: "output", label: "输出", animated: true }, + ], + }; +} + +function App() { + const initialView = readViewFromHash(); + const lastNonAuthViewRef = useRef(initialView === "login" ? "workbench" : initialView); + const canvasAutoOpenedRecentRef = useRef(false); + + // Session store + const session = useSessionStore((s) => s.session); + const loginPromptOpen = useSessionStore((s) => s.loginPromptOpen); + const pendingAction = useSessionStore((s) => s.pendingAction); + const sessionReplacedOpen = useSessionStore((s) => s.sessionReplacedOpen); + const sessionReplacedMessage = useSessionStore((s) => s.sessionReplacedMessage); + const setSession = useSessionStore((s) => s.setSession); + const openLoginPrompt = useSessionStore((s) => s.openLoginPrompt); + const closeLoginPrompt = useSessionStore((s) => s.closeLoginPrompt); + const showSessionReplaced = useSessionStore((s) => s.showSessionReplaced); + const hideSessionReplaced = useSessionStore((s) => s.hideSessionReplaced); + const clearSessionState = useSessionStore((s) => s.clearSession); + + // Project store + const projects = useProjectStore((s) => s.projects); + const projectsLoaded = useProjectStore((s) => s.projectsLoaded); + const canvasWorkflow = useProjectStore((s) => s.canvasWorkflow); + const currentCanvasProjectId = useProjectStore((s) => s.currentCanvasProjectId); + const pendingDeleteProject = useProjectStore((s) => s.pendingDeleteProject); + const deleteProjectSubmitting = useProjectStore((s) => s.deleteProjectSubmitting); + const setProjects = useProjectStore((s) => s.setProjects); + const setProjectsLoaded = useProjectStore((s) => s.setProjectsLoaded); + const setCanvasWorkflow = useProjectStore((s) => s.setCanvasWorkflow); + const setCurrentCanvasProjectId = useProjectStore((s) => s.setCurrentCanvasProjectId); + const openDeleteProjectModal = useProjectStore((s) => s.openDeleteProject); + const closeDeleteProjectModal = useProjectStore((s) => s.closeDeleteProject); + const setDeleteProjectSubmitting = useProjectStore((s) => s.setDeleteProjectSubmitting); + const clearProjectState = useProjectStore((s) => s.clearProjectState); + + // Task store + const tasks = useTaskStore((s) => s.tasks); + const appendTask = useTaskStore((s) => s.appendTask); + const mergeServerTasks = useTaskStore((s) => s.mergeServerTasks); + const clearTasks = useTaskStore((s) => s.clearTasks); + + // App store + const usage = useAppStore((s) => s.usage); + const runtimeNotifications = useAppStore((s) => s.runtimeNotifications); + const serverNotifications = useAppStore((s) => s.serverNotifications); + const activeView = useAppStore((s) => s.activeView); + const workspaceExpanded = useAppStore((s) => s.workspaceExpanded); + const imageWorkbenchTool = useAppStore((s) => s.imageWorkbenchTool); + const pendingEcommerceTemplate = useAppStore((s) => s.pendingEcommerceTemplate); + const backendHealth = useAppStore((s) => s.backendHealth); + const setUsage = useAppStore((s) => s.setUsage); + const pushNotification = useAppStore((s) => s.pushNotification); + const setRuntimeNotifications = useAppStore((s) => s.setRuntimeNotifications); + const setServerNotifications = useAppStore((s) => s.setServerNotifications); + const setView = useAppStore((s) => s.setView); + const setWorkspaceExpanded = useAppStore((s) => s.setWorkspaceExpanded); + const setImageWorkbenchTool = useAppStore((s) => s.setImageWorkbenchTool); + const setPendingEcommerceTemplate = useAppStore((s) => s.setPendingEcommerceTemplate); + const setBackendHealth = useAppStore((s) => s.setBackendHealth); + const markNotificationRead = useAppStore((s) => s.markNotificationRead); + const markAllNotificationsRead = useAppStore((s) => s.markAllNotificationsRead); + const clearAppState = useAppStore((s) => s.clearAppState); + + // Dismiss boot splash after first render + useEffect(() => { + const splash = document.getElementById("app-boot-splash"); + if (splash) { + splash.style.opacity = "0"; + const timer = setTimeout(() => splash.remove(), 350); + return () => clearTimeout(timer); + } + }, []); + + // Initialize canvasWorkflow if null + useEffect(() => { + if (!canvasWorkflow) { + setCanvasWorkflow(createBlankWorkflow()); + } + }, [canvasWorkflow, setCanvasWorkflow]); + + // Initialize activeView from hash + useEffect(() => { + setView(initialView); + if (isWorkspaceView(initialView)) { + setWorkspaceExpanded(true); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const navItems = useMemo( + () => [ + { key: "home", label: "首页", hint: "项目入口", icon: }, + { key: "workbench", label: "生成", hint: "对话生成页面", icon: }, + { + key: "ecommerce", + label: "电商生成", + hint: "AI创作与海报生成", + icon: , + }, + { + key: "sizeTemplate", + label: "示例模板", + hint: "平台比例与导出尺寸", + icon: , + }, + { key: "canvas", label: "画布", hint: "进入自由画布编排", icon: }, + { key: "community", label: "社区", hint: "案例分享与导入", icon: }, + { key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: }, + { key: "tokenUsage", label: "Token消耗", hint: "成员、服务与调用记录", icon: }, + { key: "providerHealth", label: "服务商健康", hint: "AI 服务商状态与监控", icon: }, + { key: "assets", label: "资产库", hint: "角色、场景、道具", icon: }, + { key: "agent", label: "Agent", hint: "拆解与规划", icon: }, + { key: "digitalHuman", label: "数字人", hint: "口播与人像生成", icon: }, + { key: "characterMix", label: "角色迁移", hint: "人物视频迁移", icon: }, + { key: "more", label: "工具盒", hint: "图像与镜头工具", icon: }, + ], + [], + ); + + const handleSetView = useCallback((view: WebViewKey) => { + window.location.hash = `/${view}`; + setView(view); + if (view !== "login") { + lastNonAuthViewRef.current = view; + } + if (isWorkspaceView(view)) { + setWorkspaceExpanded(true); + } + }, [setView, setWorkspaceExpanded]); + + const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => { + keyServerClient.clearSession(); + clearSessionState(); + setProjects([]); + setProjectsLoaded(true); + setUsage(emptyUsageSummary); + clearTasks(); + setRuntimeNotifications([]); + setServerNotifications([]); + setCanvasWorkflow(createBlankWorkflow()); + setCurrentCanvasProjectId(null); + canvasAutoOpenedRecentRef.current = false; + setWorkspaceExpanded(false); + if (options?.resetView) { + handleSetView("workbench"); + } + }, [clearSessionState, setProjects, setProjectsLoaded, setUsage, clearTasks, setRuntimeNotifications, setServerNotifications, setCanvasWorkflow, setCurrentCanvasProjectId, setWorkspaceExpanded, handleSetView]); + + const showSessionReplacedModal = useCallback((message?: string) => { + clearAuthenticatedState(); + showSessionReplaced(message); + }, [clearAuthenticatedState, showSessionReplaced]); + + useEffect(() => { + const handleSessionReplaced = (event: Event) => { + const detail = (event as CustomEvent).detail; + showSessionReplacedModal(detail?.message); + }; + + window.addEventListener(SERVER_SESSION_REPLACED_EVENT, handleSessionReplaced); + window.addEventListener(SERVER_SESSION_EXPIRED_EVENT, handleSessionReplaced); + return () => { + window.removeEventListener(SERVER_SESSION_REPLACED_EVENT, handleSessionReplaced); + window.removeEventListener(SERVER_SESSION_EXPIRED_EVENT, handleSessionReplaced); + }; + }, [showSessionReplacedModal]); + + const handleOpenEcommerceTemplate = useCallback((template: TemplateCase) => { + setPendingEcommerceTemplate(template); + handleSetView("ecommerce"); + }, [setPendingEcommerceTemplate, handleSetView]); + + const hydrateAccountData = useCallback(async (nextSession: WebUserSession | null) => { + setProjectsLoaded(false); + if (!nextSession) { + setProjects([]); + setUsage(emptyUsageSummary); + clearTasks(); + setProjectsLoaded(true); + return; + } + + const [projectResult, usageResult, taskResult] = await Promise.allSettled([ + keyServerClient.listProjects(), + keyServerClient.getUsageSummary(), + aiGenerationClient.listTasks({ limit: 100 }), + ]); + const loadedProjects = projectResult.status === "fulfilled" ? projectResult.value : []; + setProjects(loadedProjects); + setProjectsLoaded(projectResult.status === "fulfilled"); + setUsage( + usageResult.status === "fulfilled" + ? usageResult.value + : { + ...emptyUsageSummary, + source: "server", + errorMessage: usageResult.reason instanceof Error ? usageResult.reason.message : "用量服务暂时不可用", + }, + ); + + if (taskResult.status === "fulfilled") { + mergeServerTasks(taskResult.value); + setRuntimeNotifications(useAppStore.getState().runtimeNotifications.filter((item: WebNotification) => item.id !== "server-task-history-sync-error")); + } else { + setRuntimeNotifications([ + { + id: "server-task-history-sync-error", + type: "info" as const, + title: "任务历史同步失败", + description: getErrorMessage(taskResult.reason), + createdAt: new Date().toISOString(), + isRead: false, + targetView: "login" as const, + }, + ...useAppStore.getState().runtimeNotifications.filter((item: WebNotification) => item.id !== "server-task-history-sync-error"), + ].slice(0, 30)); + } + }, [setProjects, setProjectsLoaded, setUsage, clearTasks, mergeServerTasks, setRuntimeNotifications]); + + useEffect(() => { + if (!window.location.hash) { + window.history.replaceState(null, "", "#/home"); + } + + const handleHashChange = () => { + const nextView = readViewFromHash(); + setView(nextView); + if (nextView !== "login") { + lastNonAuthViewRef.current = nextView; + } + if (isWorkspaceView(nextView)) { + setWorkspaceExpanded(true); + } + }; + + window.addEventListener("hashchange", handleHashChange); + return () => window.removeEventListener("hashchange", handleHashChange); + }, [setView, setWorkspaceExpanded]); + + useEffect(() => { + let cancelled = false; + + const loadSession = async () => { + const nextSession = await keyServerClient.getCurrentSession(); + if (cancelled) return; + setSession(nextSession); + await hydrateAccountData(nextSession); + }; + + void loadSession(); + + return () => { + cancelled = true; + }; + }, [hydrateAccountData, setSession]); + + const refreshUsage = useCallback(async () => { + try { + const fresh = await keyServerClient.getUsageSummary(); + setUsage(fresh); + } catch { + // silent — balance display will update on next refreshUsage call + } + }, [setUsage]); + + useEffect(() => { + if (!session || sessionReplacedOpen) return undefined; + let cancelled = false; + let checking = false; + + const verifySession = async () => { + if (checking) return; + checking = true; + try { + const nextSession = await keyServerClient.getCurrentSession(); + if (cancelled) return; + if (nextSession) { + setSession(nextSession); + } else { + clearAuthenticatedState(); + } + } finally { + checking = false; + } + }; + + const handleFocusCheck = () => void verifySession(); + const handleVisibilityCheck = () => { + if (document.visibilityState === "visible") { + void verifySession(); + } + }; + + const intervalId = window.setInterval(() => void verifySession(), 60_000); + window.addEventListener("focus", handleFocusCheck); + document.addEventListener("visibilitychange", handleVisibilityCheck); + return () => { + cancelled = true; + window.clearInterval(intervalId); + window.removeEventListener("focus", handleFocusCheck); + document.removeEventListener("visibilitychange", handleVisibilityCheck); + }; + }, [clearAuthenticatedState, session, sessionReplacedOpen, setSession]); + + useEffect(() => { + let cancelled = false; + + const refreshBackendHealth = async () => { + const health = await checkServerHealth(); + if (!cancelled) { + setBackendHealth(health); + } + }; + + void refreshBackendHealth(); + const intervalId = window.setInterval(() => void refreshBackendHealth(), 60_000); + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [setBackendHealth]); + + const refreshServerNotifications = useCallback(async () => { + if (!keyServerClient.getStoredSession()) { + setServerNotifications([]); + return; + } + + try { + const items = await notificationClient.list(); + setServerNotifications(items); + } catch { + setServerNotifications([]); + } + }, [setServerNotifications]); + + useEffect(() => { + if (!session) { + setServerNotifications([]); + return; + } + + void refreshServerNotifications(); + const intervalId = window.setInterval(() => void refreshServerNotifications(), 45_000); + return () => window.clearInterval(intervalId); + }, [refreshServerNotifications, session, setServerNotifications]); + + const queueLoginGate = useCallback((action: PendingAction) => { + openLoginPrompt(action); + }, [openLoginPrompt]); + + const openWorkflowProject = useCallback( + async (workflow: WebCanvasWorkflow) => { + const nextWorkflow = cloneWorkflow(workflow); + setCanvasWorkflow(nextWorkflow); + const project = await keyServerClient.createProjectSpace(nextWorkflow); + let savedProject = project; + try { + savedProject = await keyServerClient.saveProjectContent(project.id, nextWorkflow); + } catch (error) { + pushNotification({ + type: "info", + title: "项目已创建,内容暂未保存", + description: error instanceof Error ? error.message : String(error), + targetView: "canvas", + targetId: project.id, + }); + } + setCurrentCanvasProjectId(project.id); + setProjects([savedProject, ...useProjectStore.getState().projects.filter((item: WebProjectSummary) => item.id !== savedProject.id)]); + setWorkspaceExpanded(true); + handleSetView("canvas"); + }, + [pushNotification, handleSetView, setCanvasWorkflow, setCurrentCanvasProjectId, setProjects, setWorkspaceExpanded], + ); + + const appendPreviewTask = useCallback(async (input: CreatePreviewTaskInput): Promise => { + const task = await webGenerationGateway.createPreviewTask(input); + appendTask(task); + return task; + }, [appendTask]); + + const handleStartCreate = useCallback(() => { + const workflow = createBlankWorkflow("新建项目"); + if (!session) { + queueLoginGate({ + kind: "project", + label: "新建项目", + description: "登录或注册后,我们会为你创建专属项目空间,并以画布方式打开。", + workflow, + }); + return; + } + + void openWorkflowProject(workflow); + }, [openWorkflowProject, queueLoginGate, session]); + + const handleStartTemplateCanvasCreate = useCallback(() => { + const workflow = createBlankWorkflow("新建项目"); + canvasAutoOpenedRecentRef.current = true; + setCanvasWorkflow(cloneWorkflow(workflow)); + setCurrentCanvasProjectId(null); + setWorkspaceExpanded(true); + handleSetView("canvas"); + }, [handleSetView, setCanvasWorkflow, setCurrentCanvasProjectId, setWorkspaceExpanded]); + + const handleImportWorkflow = useCallback( + (workflow: WebCanvasWorkflow) => { + const nextWorkflow = cloneWorkflow(workflow); + if (!session) { + queueLoginGate({ + kind: "project", + label: "导入社区案例", + description: "登录或注册后,案例会写入你的项目空间,并以画布方式打开。", + workflow: nextWorkflow, + }); + return; + } + + void openWorkflowProject(nextWorkflow); + }, + [openWorkflowProject, queueLoginGate, session], + ); + + const handleOpenProject = useCallback( + async (project: WebProjectSummary) => { + if (!session) { + openLoginPrompt(); + return; + } + + try { + const workflow = await keyServerClient.getProjectContent(project.id); + setCanvasWorkflow(workflow); + setCurrentCanvasProjectId(project.id); + setProjects([project, ...useProjectStore.getState().projects.filter((item: WebProjectSummary) => item.id !== project.id)]); + setWorkspaceExpanded(true); + handleSetView("canvas"); + } catch (error) { + pushNotification({ + type: "info", + title: "项目打开失败", + description: error instanceof Error ? error.message : String(error), + targetView: "ecommerce", + targetId: project.id, + }); + } + }, + [pushNotification, session, handleSetView, openLoginPrompt, setCanvasWorkflow, setCurrentCanvasProjectId, setProjects, setWorkspaceExpanded], + ); + + useEffect(() => { + const shouldAutoOpenRecentProject = + activeView === "canvas" && + Boolean(session) && + projectsLoaded && + projects.length > 0 && + !currentCanvasProjectId && + canvasWorkflow && + canvasWorkflow.source === "blank" && + canvasWorkflow.nodes.length === 0 && + !canvasAutoOpenedRecentRef.current; + + if (!shouldAutoOpenRecentProject) { + return; + } + + canvasAutoOpenedRecentRef.current = true; + void handleOpenProject(projects[0]); + }, [ + activeView, + canvasWorkflow?.nodes.length, + canvasWorkflow?.source, + currentCanvasProjectId, + handleOpenProject, + projects, + projectsLoaded, + session, + ]); + + const handleSaveCanvasWorkflow = useCallback( + async (workflow: WebCanvasWorkflow, options?: SaveCanvasWorkflowOptions) => { + if (!session) { + openLoginPrompt(); + throw new Error("请先登录后再保存画布。"); + } + + const nextWorkflow = cloneWorkflow(workflow); + let projectId = currentCanvasProjectId; + + if (!projectId) { + const project = await keyServerClient.createProjectSpace(nextWorkflow); + projectId = project.id; + setCurrentCanvasProjectId(projectId); + } + + const savedProject = await keyServerClient.saveProjectContent(projectId, nextWorkflow); + setProjects([savedProject, ...useProjectStore.getState().projects.filter((item: WebProjectSummary) => item.id !== projectId)]); + if (options?.reason !== "autosave") { + setCanvasWorkflow(nextWorkflow); + } + if (!options?.silent) { + pushNotification({ + type: "info", + title: "画布已保存", + description: savedProject.name || nextWorkflow.title, + targetView: "canvas", + targetId: projectId, + }); + } + return savedProject; + }, + [currentCanvasProjectId, pushNotification, session, openLoginPrompt, setCurrentCanvasProjectId, setProjects, setCanvasWorkflow], + ); + + const handleDeleteProject = useCallback( + (project: WebProjectSummary) => { + if (!session) { + openLoginPrompt(); + return; + } + openDeleteProjectModal(project); + }, + [session, openLoginPrompt, openDeleteProjectModal], + ); + + const closeDeleteProject = useCallback(() => { + if (deleteProjectSubmitting) return; + closeDeleteProjectModal(); + }, [deleteProjectSubmitting, closeDeleteProjectModal]); + + const confirmDeleteProject = useCallback(async () => { + if (!pendingDeleteProject || deleteProjectSubmitting) return; + const project = pendingDeleteProject; + setDeleteProjectSubmitting(true); + try { + await keyServerClient.deleteProject(project.id, { cleanupUserData: true }); + setProjects(useProjectStore.getState().projects.filter((item: WebProjectSummary) => item.id !== project.id)); + if (currentCanvasProjectId === project.id) { + setCurrentCanvasProjectId(null); + } + pushNotification({ + type: "info", + title: "项目已删除", + description: project.name, + targetView: "community", + targetId: project.id, + }); + closeDeleteProjectModal(); + } catch (error) { + pushNotification({ + type: "info", + title: "项目删除失败", + description: error instanceof Error ? error.message : String(error), + targetView: "community", + targetId: project.id, + }); + } finally { + setDeleteProjectSubmitting(false); + } + }, [currentCanvasProjectId, deleteProjectSubmitting, pendingDeleteProject, pushNotification, setCurrentCanvasProjectId, setProjects, setDeleteProjectSubmitting, closeDeleteProjectModal]); + + const handleCreateTask = useCallback( + async (input: CreatePreviewTaskInput) => { + if (!session) { + queueLoginGate({ + kind: "task", + label: input.title || "创建生成任务", + description: "登录或注册后,将继续发送当前输入并同步生成任务记录。", + input, + }); + throw new Error("需要先登录后继续"); + } + + const task = await appendPreviewTask(input); + if (task.status === "failed") { + throw new Error(translateTaskError(task.errorMessage)); + } + return task; + }, + [appendPreviewTask, queueLoginGate, session], + ); + + const handleRequireTaskLogin = useCallback( + (input: CreatePreviewTaskInput) => { + queueLoginGate({ + kind: "task", + label: input.title || "创建生成任务", + description: "登录或注册后,将继续发送当前输入并同步生成任务记录。", + input, + }); + }, + [queueLoginGate], + ); + + const executePendingAction = useCallback( + async (action: PendingAction) => { + if (action.kind === "project") { + await openWorkflowProject(action.workflow!); + return; + } + + await appendPreviewTask(action.input!); + handleSetView(lastNonAuthViewRef.current || "workbench"); + }, + [appendPreviewTask, openWorkflowProject, handleSetView], + ); + + const completeAuth = useCallback( + async (nextSession: WebUserSession) => { + hideSessionReplaced(); + setSession(nextSession); + await hydrateAccountData(nextSession); + + const action = pendingAction; + closeLoginPrompt(); + if (action) { + await executePendingAction(action); + return; + } + + const redirectTarget = sessionStorage.getItem("omniai:redirect-after-login") as WebViewKey | null; + sessionStorage.removeItem("omniai:redirect-after-login"); + handleSetView(redirectTarget || lastNonAuthViewRef.current || "workbench"); + }, + [executePendingAction, hydrateAccountData, pendingAction, handleSetView, hideSessionReplaced, setSession, closeLoginPrompt], + ); + + const handleLogin = useCallback( + async (username: string, password: string) => { + const nextSession = await keyServerClient.login({ username, password }); + await completeAuth(nextSession); + }, + [completeAuth], + ); + + const handleRegister = useCallback( + async (username: string, password: string, betaCode: string) => { + const nextSession = await keyServerClient.register({ username, password, betaCode }); + await completeAuth(nextSession); + }, + [completeAuth], + ); + + const handleLogout = useCallback(() => { + hideSessionReplaced(); + clearAuthenticatedState({ resetView: true }); + }, [clearAuthenticatedState, hideSessionReplaced]); + + const handleOpenResultInCanvas = useCallback( + async (payload: WorkbenchResultActionPayload) => { + const recentProject = projects?.[0]; + + // If user has recent projects, append result to the latest one + if (recentProject && session) { + try { + const workflow = await keyServerClient.getProjectContent(recentProject.id); + const nodeId = `img-${Date.now()}`; + const maxY = (workflow.nodes || []).reduce((m, n) => Math.max(m, n.position?.y || 0), 0); + + workflow.nodes = [ + ...(workflow.nodes || []), + { + id: nodeId, + kind: payload.resultType === "video" ? "video" : "image", + label: payload.title || "生成结果", + detail: payload.prompt || "", + position: { x: 280, y: maxY + 60 }, + previewUrl: payload.resultUrl, + params: payload.resultType === "video" + ? { model: "Kling V3 Omni", aspectRatio: "16:9", resolution: "720p", duration: "6s", videoMode: "text-to-video" } + : { model: "Nano Banana Pro", aspectRatio: "1:1", imageSize: "2K" }, + assetRef: payload.resultOssKey ? { url: payload.resultUrl, ossKey: payload.resultOssKey, mediaType: payload.resultType === "video" ? "video/mp4" : "image/png", sourceTaskId: payload.taskId } : undefined, + }, + ]; + + await keyServerClient.saveProjectContent(recentProject.id, workflow); + setCanvasWorkflow(workflow); + setCurrentCanvasProjectId(recentProject.id); + setProjects([recentProject, ...useProjectStore.getState().projects.filter((p: WebProjectSummary) => p.id !== recentProject.id)]); + setWorkspaceExpanded(true); + handleSetView("canvas"); + return; + } catch (err) { + console.warn("[App] failed to append result to recent project, creating new:", err instanceof Error ? err.message : err); + } + } + + // Fallback: create new canvas workflow + setCanvasWorkflow(createWorkflowFromResult(payload)); + setCurrentCanvasProjectId(null); + setWorkspaceExpanded(true); + handleSetView("canvas"); + }, + [projects, session, handleSetView, setCanvasWorkflow, setCurrentCanvasProjectId, setProjects, setWorkspaceExpanded], + ); + + const notifications = useMemo(() => { + const creditsNotification: WebNotification[] = + session && usage.balanceCents > 0 && usage.balanceCents < 10_000 + ? [ + { + id: "credits-low", + type: "credits_low", + title: "积分余额偏低", + description: `当前剩余 ${(usage.balanceCents / 100).toFixed(2)} 积分,请留意生成消耗。`, + createdAt: new Date().toISOString(), + isRead: false, + targetView: "login", + }, + ] + : []; + + return [...runtimeNotifications, ...serverNotifications, ...creditsNotification].slice(0, 30); + }, [runtimeNotifications, serverNotifications, session, usage.balanceCents]); + + const handleMarkNotificationRead = useCallback((id: string, isRead = true) => { + markNotificationRead(id, isRead); + notificationClient.markRead(id, isRead).catch((err) => { + console.warn("[notification] markRead failed:", err?.message || err); + }); + }, [markNotificationRead]); + + const handleMarkAllNotificationsRead = useCallback(() => { + markAllNotificationsRead(); + notificationClient.markAllRead().catch((err) => { + console.warn("[notification] markAllRead failed:", err?.message || err); + }); + }, [markAllNotificationsRead]); + + const handleOpenLogin = useCallback(() => { + closeLoginPrompt(); + handleSetView("login"); + }, [handleSetView, closeLoginPrompt]); + + const handleOpenImageWorkbenchTool = useCallback( + (tool: WebImageWorkbenchTool) => { + setImageWorkbenchTool(tool); + handleSetView("imageWorkbench"); + }, + [handleSetView, setImageWorkbenchTool], + ); + + const renderAdminOnlyPage = useCallback( + (content: React.ReactNode) => { + if (isAdminAccount(session)) return content; + + return ( +
+ +
+
+ 功能内测中 +

暂未开放

+

敬请期待,该功能还在开发中。

+
+
+
+ ); + }, + [session], + ); + + const PUBLIC_VIEWS = PUBLIC_VIEW_SET; + + useEffect(() => { + if (!session && !PUBLIC_VIEWS.has(activeView)) { + sessionStorage.setItem("omniai:redirect-after-login", activeView); + } + }, [activeView, session]); // eslint-disable-line react-hooks/exhaustive-deps + + const activePage = (() => { + if (!session && !PUBLIC_VIEWS.has(activeView)) { + return ( + handleSetView("workbench")} + onOpenCommunity={() => handleSetView("community")} + onDeleteProject={handleDeleteProject} + /> + ); + } + switch (activeView) { + case "login": + return ( + handleSetView("workbench")} + onOpenCommunity={() => handleSetView("community")} + onDeleteProject={handleDeleteProject} + /> + ); + case "community": + return ( + + ); + case "agent": + return ( + + ); + case "canvas": + return ( + handleSetView("community")} + onOpenProject={handleOpenProject} + onStartCreate={handleStartCreate} + isAuthenticated={Boolean(session)} + session={session} + onOpenLogin={handleOpenLogin} + onSaveWorkflow={handleSaveCanvasWorkflow} + onCreateTask={handleCreateTask} + /> + ); + case "assets": + return ; + case "ecommerce": + case "ecommerceHub": + return ( + setPendingEcommerceTemplate(null)} + /> + ); + case "ecommerceTemplates": + return ( + handleSetView("more")} + onOpenEcommerce={() => handleSetView("ecommerce")} + onSelectTemplate={handleOpenEcommerceTemplate} + onStartCreate={handleStartTemplateCanvasCreate} + onOpenProject={handleOpenProject} + onDeleteProject={handleDeleteProject} + /> + ); + case "digitalHuman": + return ( + handleSetView("more")} + onOpenImageTool={handleOpenImageWorkbenchTool} + onSelectView={handleSetView} + /> + ); + case "avatarConsole": + return handleSetView("more")} onSelectView={handleSetView} />; + case "characterMix": + return ( + handleSetView("more")} + onOpenImageTool={handleOpenImageWorkbenchTool} + onSelectView={handleSetView} + /> + ); + case "more": + return ; + case "sizeTemplate": + return ( + handleSetView("more")} + onOpenEcommerce={() => handleSetView("ecommerce")} + onSelectView={handleSetView} + /> + ); + case "scriptTokens": + return ; + case "tokenUsage": + return ( + keyServerClient.getEnterpriseUsageSummary()} + loadPersonalUsage={() => keyServerClient.getPersonalUsageSummary()} + onOpenMore={() => handleSetView("more")} + onOpenImageTool={handleOpenImageWorkbenchTool} + onSelectView={handleSetView} + /> + ); + case "settings": + return ; + case "imageWorkbench": + return ( + handleSetView("more")} + onSelectView={handleSetView} + /> + ); + case "resolutionUpscale": + return ( + handleSetView("more")} + onOpenImageTool={handleOpenImageWorkbenchTool} + onSelectView={handleSetView} + /> + ); + case "watermarkRemoval": + return ( + handleSetView("more")} + onOpenImageTool={handleOpenImageWorkbenchTool} + onSelectView={handleSetView} + /> + ); + case "subtitleRemoval": + return ( + handleSetView("more")} + onOpenImageTool={handleOpenImageWorkbenchTool} + onSelectView={handleSetView} + /> + ); + case "report": + return ; + case "providerHealth": + return ; + case "communityReview": + return ( + handleSetView("report")} + onOpenCaseAdd={() => handleSetView("communityCaseAdd")} + /> + ); + case "communityCaseAdd": + return ( + handleSetView("communityReview")} + /> + ); + case "workbench": + return ( + + ); + case "home": + default: + return ( + handleSetView("workbench")} + onOpenCanvas={() => handleSetView("canvas")} + onOpenEcommerce={() => handleSetView("ecommerce")} + onOpenScriptReview={() => handleSetView("scriptTokens")} + onOpenTokenMonitor={() => handleSetView("tokenUsage")} + /> + ); + } + })(); + + return ( + + + +
+ 加载中... +
+ }> + + {activePage} + +
+
+ + {loginPromptOpen && pendingAction ? ( +
+ + +
+ + + ) : null} + + {sessionReplacedOpen ? ( +
+
+
+ 账号安全提醒 +

用户已在别处登录

+

{sessionReplacedMessage}

+
+ + +
+
+
+ ) : null} + + {pendingDeleteProject ? ( +
+ + +
+ +
+ ) : null} + +
+ ); +} + +export default App; diff --git a/src/api/adVideoPlanClient.ts b/src/api/adVideoPlanClient.ts new file mode 100644 index 0000000..ae700ea --- /dev/null +++ b/src/api/adVideoPlanClient.ts @@ -0,0 +1,284 @@ +import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; + +const TEXT_MODEL = "qwen-max"; +const VISION_MODEL = "qwen3.6-plus"; + +export interface AdVideoUserConfig { + platform: string; + aspectRatio: string; + durationSeconds: number; + style: string; + language: string; + market: string; + needVoiceover: boolean; + needSubtitle: boolean; + conversionFocus: "conversion" | "brand"; +} + +export interface ProductSummary { + product_name: string; + category: string; + appearance: string; + materials: string[]; + colors: string[]; + core_features: string[]; + target_users: string[]; + usage_scenarios: string[]; + selling_points: string[]; + risk_notes: string[]; +} + +export interface SellingPoint { + point: string; + evidence: string; + ad_expression: string; +} + +export interface SellingPointResult { + primary_selling_points: SellingPoint[]; + secondary_selling_points: SellingPoint[]; + unsupported_claims: string[]; + compliance_warnings: string[]; +} + +export interface CreativeOption { + creative_id: string; + creative_type: string; + hook: string; + target_user: string; + main_message: string; + emotional_tone: string; + recommended_platform: string; + reason: string; +} + +export interface StoryboardScene { + scene_id: number; + duration: string; + scene_goal: string; + visual_description: string; + product_focus: string; + camera_movement: string; + background: string; + lighting: string; + subtitle: string; + voiceover: string; + transition: string; +} + +export interface Storyboard { + video_title: string; + duration: string; + aspect_ratio: string; + target_platform: string; + language: string; + scenes: StoryboardScene[]; +} + +export interface VideoPrompt { + scene_id: number; + positive_prompt: string; + negative_prompt: string; + reference_requirements: string; + consistency_rules: string; + text_overlay: string; +} + +export interface ComplianceCheck { + risk_level: "low" | "medium" | "high"; + issues: Array<{ field: string; problem: string; suggestion: string }>; + allow_video_generation: boolean; +} + +function extractJson(text: string): unknown { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/); + const raw = fenced ? fenced[1].trim() : text.trim(); + const start = raw.search(/[[{]/); + const slice = start >= 0 ? raw.slice(start) : raw; + try { + return JSON.parse(slice); + } catch { + throw new Error("AI 返回内容不是有效的 JSON"); + } +} + +interface ChatMessage { + role: "system" | "user"; + content: string; +} + +async function chat( + systemPrompt: string, + userContent: string, + options?: { model?: string; signal?: AbortSignal }, +): Promise { + const messages: ChatMessage[] = [ + { role: "system", content: systemPrompt }, + { role: "user", content: userContent }, + ]; + const timeoutSignal = AbortSignal.timeout(60000); + const combinedSignal = options?.signal + ? AbortSignal.any([options.signal, timeoutSignal]) + : timeoutSignal; + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ + model: options?.model ?? TEXT_MODEL, + messages, + stream: false, + temperature: 0.4, + }), + signal: combinedSignal, + }); + if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`); + const payload = await res.json(); + const content: string = + payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; + if (!content) throw new Error("模型未返回有效内容"); + return content; +} + +async function visionChat( + systemPrompt: string, + text: string, + imageUrls: string[], + signal?: AbortSignal, +): Promise { + const content = [ + ...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })), + { type: "text", text }, + ]; + const timeoutSignal = AbortSignal.timeout(60000); + const combinedSignal = signal + ? AbortSignal.any([signal, timeoutSignal]) + : timeoutSignal; + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ + model: VISION_MODEL, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content }, + ], + stream: false, + temperature: 0.3, + }), + signal: combinedSignal, + }); + if (!res.ok) throw new Error(`图片理解调用失败 (${res.status})`); + const payload = await res.json(); + const out: string = + payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; + if (!out) throw new Error("图片理解未返回有效内容"); + return out; +} + +const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`; + +export async function analyzeProductImages( + imageUrls: string[], + signal?: AbortSignal, +): Promise { + if (imageUrls.length === 0) return ""; + return visionChat(IMAGE_UNDERSTANDING_PROMPT, "请分析这些产品图片的视觉特征。", imageUrls, signal); +} + +const PRODUCT_SUMMARY_PROMPT = `你是商品信息理解专家。根据产品图片理解结果和说明书文本,输出结构化的商品信息。严格按以下 JSON 格式返回,不要任何额外解释: +{"product_name":"","category":"","appearance":"","materials":[],"colors":[],"core_features":[],"target_users":[],"usage_scenarios":[],"selling_points":[],"risk_notes":[]} +要求:只描述资料中真实存在的信息,不要编造说明书或图片中不存在的功能。risk_notes 列出可能涉及夸大、医疗功效、绝对化用语等风险点。`; + +export async function buildProductSummary( + imageDescription: string, + manualText: string, + signal?: AbortSignal, +): Promise { + const userContent = `【产品图片理解结果】\n${imageDescription || "(无图片)"}\n\n【产品说明书/详情文本】\n${manualText || "(无文本)"}`; + const text = await chat(PRODUCT_SUMMARY_PROMPT, userContent, { signal }); + return extractJson(text) as ProductSummary; +} + +const SELLING_POINT_PROMPT = `你是电商卖点提炼专家。将商品信息拆分为不同层级卖点。严格按以下 JSON 格式返回,不要任何额外解释: +{"primary_selling_points":[{"point":"","evidence":"","ad_expression":""}],"secondary_selling_points":[{"point":"","evidence":"","ad_expression":""}],"unsupported_claims":[],"compliance_warnings":[]} +要求:每个卖点必须有来源依据(evidence),依据来自输入的商品信息。不得凭空增加功能。无依据的卖点放入 unsupported_claims。涉及夸大、医疗、绝对化用语的放入 compliance_warnings。`; + +export async function extractSellingPoints( + summary: ProductSummary, + signal?: AbortSignal, +): Promise { + const text = await chat(SELLING_POINT_PROMPT, `【商品结构化信息】\n${JSON.stringify(summary, null, 2)}`, { signal }); + return extractJson(text) as SellingPointResult; +} + +function configBlock(config: AdVideoUserConfig): string { + return `【用户配置】\n目标平台:${config.platform}\n视频比例:${config.aspectRatio}\n时长:${config.durationSeconds}秒\n广告风格:${config.style}\n语言:${config.language}\n目标市场:${config.market}\n旁白:${config.needVoiceover ? "需要" : "不需要"}\n字幕:${config.needSubtitle ? "需要" : "不需要"}\n侧重:${config.conversionFocus === "conversion" ? "强转化" : "品牌展示"}`; +} + +const CREATIVE_PROMPT = `你是电商广告创意专家。根据商品卖点和用户配置,生成至少 3 个差异化的广告创意方向。严格按以下 JSON 格式返回,不要任何额外解释: +{"creative_options":[{"creative_id":"A","creative_type":"","hook":"","target_user":"","main_message":"","emotional_tone":"","recommended_platform":"","reason":""}]} +要求:每个方向围绕真实卖点,有清晰广告逻辑,方向之间有明显差异。`; + +export async function generateCreativeOptions( + selling: SellingPointResult, + config: AdVideoUserConfig, + signal?: AbortSignal, +): Promise { + const userContent = `【卖点】\n${JSON.stringify(selling.primary_selling_points, null, 2)}\n\n${configBlock(config)}`; + const text = await chat(CREATIVE_PROMPT, userContent, { signal }); + const parsed = extractJson(text) as { creative_options?: CreativeOption[] }; + return Array.isArray(parsed.creative_options) ? parsed.creative_options : []; +} + +const STORYBOARD_PROMPT = `你是电商短视频分镜师。根据选定的广告创意方向、商品信息和用户配置,输出结构化视频分镜。严格按以下 JSON 格式返回,不要任何额外解释: +{"video_title":"","duration":"","aspect_ratio":"","target_platform":"","language":"","scenes":[{"scene_id":1,"duration":"3s","scene_goal":"","visual_description":"","product_focus":"","camera_movement":"","background":"","lighting":"","subtitle":"","voiceover":"","transition":""}]} +要求:开头3秒有吸引点,中段展示核心卖点,结尾有行动号召。各镜头时长之和等于配置总时长。不要出现说明书中不存在的功能,不要设计视频模型难以稳定生成的复杂动作。`; + +export async function generateStoryboard( + creative: CreativeOption, + summary: ProductSummary, + config: AdVideoUserConfig, + signal?: AbortSignal, +): Promise { + const userContent = `【选定创意方向】\n${JSON.stringify(creative, null, 2)}\n\n【商品信息】\n${JSON.stringify(summary, null, 2)}\n\n${configBlock(config)}`; + const text = await chat(STORYBOARD_PROMPT, userContent, { signal }); + return extractJson(text) as Storyboard; +} + +const VIDEO_PROMPT_PROMPT = `你是 AI 视频模型提示词工程师。为每个分镜生成视频模型提示词。严格按以下 JSON 格式返回一个数组,不要任何额外解释: +[{"scene_id":1,"positive_prompt":"","negative_prompt":"","reference_requirements":"","consistency_rules":"","text_overlay":""}] +正向提示词需包含:产品主体、外观、颜色、材质、使用场景、镜头构图、镜头运动、光线风格、背景环境、广告质感、画面节奏。 +负面提示词需包含:不改变产品外观/颜色、不添加不存在的部件、不生成错误Logo、不生成模糊文字、不生成虚假功能演示、不生成畸形手部、不生成夸张功效、不生成医学暗示。 +字幕和文字建议后期叠加(text_overlay),不要让视频模型直接生成文字。`; + +export async function generateVideoPrompts( + storyboard: Storyboard, + summary: ProductSummary, + signal?: AbortSignal, +): Promise { + const userContent = `【分镜脚本】\n${JSON.stringify(storyboard.scenes, null, 2)}\n\n【产品外观特征(一致性参考)】\n外观:${summary.appearance}\n颜色:${summary.colors.join("、")}\n材质:${summary.materials.join("、")}`; + const text = await chat(VIDEO_PROMPT_PROMPT, userContent, { signal }); + const parsed = extractJson(text); + return Array.isArray(parsed) ? (parsed as VideoPrompt[]) : []; +} + +const COMPLIANCE_PROMPT = `你是电商广告合规质检专家。检查文案和卖点是否存在虚假宣传、绝对化用语(如"最""第一""100%")、医疗功效暗示、高风险品类违规表达。严格按以下 JSON 格式返回,不要任何额外解释: +{"risk_level":"low","issues":[{"field":"","problem":"","suggestion":""}],"allow_video_generation":true} +risk_level 取值 low/medium/high。存在高风险违规时 allow_video_generation 设为 false。`; + +export async function checkCompliance( + summary: ProductSummary, + selling: SellingPointResult, + storyboard: Storyboard, + signal?: AbortSignal, +): Promise { + const userContent = `【卖点】\n${JSON.stringify(selling, null, 2)}\n\n【分镜文案/旁白/字幕】\n${JSON.stringify(storyboard.scenes.map((s) => ({ subtitle: s.subtitle, voiceover: s.voiceover })), null, 2)}\n\n【风险点】\n${summary.risk_notes.join("、")}`; + const text = await chat(COMPLIANCE_PROMPT, userContent, { signal }); + return extractJson(text) as ComplianceCheck; +} + + + + + + diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts new file mode 100644 index 0000000..f580b5a --- /dev/null +++ b/src/api/aiGenerationClient.ts @@ -0,0 +1,516 @@ +import { + buildApiUrl, + buildAuthHeaders, + isRecord, + readJsonResponse, + throwResponseError, +} from "./serverConnection"; +import { isOptionalApiRouteMissing } from "./apiErrorUtils"; +import type { WebGenerationPreviewTask } from "../types"; + +export interface ImageGenInput { + projectId?: string; + conversationId?: number; + model: string; + prompt: string; + ratio?: string; + quality?: string; + gridMode?: string; + referenceUrls?: string[]; +} + +export interface ImageProviderDebug { + requestedModel?: string; + effectiveModel?: string; + primaryProvider?: string; + fallbackProviders?: string[]; + route?: string[]; + candidates?: Array<{ + provider?: string; + transport?: string; + model?: string; + requestedModel?: string; + billingProvider?: string; + fallbackOf?: string; + }>; +} + +export interface ImageTaskCreateResponse { + taskId: string; + providerDebug?: ImageProviderDebug; +} + +type ImageRouteDebugEntry = Record & { + at: string; + label: string; +}; + +export interface VideoGenInput { + projectId?: string; + conversationId?: number; + model: string; + prompt: string; + ratio?: string; + duration?: number; + quality?: string; + resolution?: string; + frameMode?: string; + referenceUrls?: string[]; + imageUrl?: string; + audioUrl?: string; + muted?: boolean; + hasReferenceVideo?: boolean; + style?: "speech" | "sing" | "performance" | string; +} + +export interface VideoSuperResolveInput { + projectId?: string; + conversationId?: number; + videoUrl: string; + bitRate?: number; + provider?: string; + style?: number; + videoFps?: number; + minLen?: 540 | 720; + useSR?: boolean; + animateEmotion?: boolean; +} + +export interface EraseSubtitlesInput { + videoUrl: string; + bx?: number; + by?: number; + bw?: number; + bh?: number; +} + +export interface ImageEditInput { + imageUrl: string; + function: string; + prompt?: string; + n?: number; +} + +export interface ImageSuperResolveInput { + projectId?: string; + conversationId?: number; + imageUrl: string; + scale?: "2x" | "4x" | number; +} + +export interface UploadAssetInput { + dataUrl: string; + name?: string; + mimeType?: string; + scope?: "profile-avatar" | "profile-background" | string; +} + +export interface UploadAssetByUrlInput { + sourceUrl: string; + name?: string; + mimeType?: string; + scope?: string; +} + +export type ChatMessageContent = + | string + | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>; + +export interface ChatInput { + model: string; + messages: Array<{ role: string; content: ChatMessageContent }>; + stream?: boolean; + temperature?: number; +} + +export interface AiTaskStatus { + taskId: string; + projectId?: string; + conversationId?: number | null; + clientQueueId?: string | null; + type: "image" | "video"; + status: "pending" | "running" | "completed" | "failed" | "cancelled"; + progress: number; + resultUrl: string | null; + error: string | null; + params?: Record; + createdAt: string; + updatedAt: string; + completedAt?: string | null; +} + +function normalizeTaskStatus(status: AiTaskStatus["status"]): WebGenerationPreviewTask["status"] { + if (status === "running" || status === "completed" || status === "failed") return status; + if (status === "cancelled") return "failed"; + return "queued"; +} + +function taskTitle(task: AiTaskStatus): string { + const prompt = typeof task.params?.prompt === "string" ? task.params.prompt.trim() : ""; + if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt; + return task.type === "video" ? "视频生成任务" : "图像生成任务"; +} + +function toPreviewTask(task: AiTaskStatus): WebGenerationPreviewTask { + return { + id: task.taskId, + title: taskTitle(task), + type: task.type, + status: normalizeTaskStatus(task.status), + progress: Math.max(0, Math.min(100, Math.trunc(task.progress || 0))), + prompt: typeof task.params?.prompt === "string" ? task.params.prompt : taskTitle(task), + createdAt: task.createdAt, + projectId: task.projectId || undefined, + outputUrl: task.resultUrl || undefined, + source: "server", + errorMessage: task.error || undefined, + }; +} + +function parseContentDispositionFilename(value: string | null): string | undefined { + if (!value) return undefined; + const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1].trim()); + } catch { + return utf8Match[1].trim(); + } + } + const plainMatch = value.match(/filename="?([^";]+)"?/i); + return plainMatch?.[1]?.trim() || undefined; +} + +function extractTaskList(payload: unknown): AiTaskStatus[] { + if (Array.isArray(payload)) return payload as AiTaskStatus[]; + if (!isRecord(payload)) return []; + const rows = payload.tasks ?? payload.items; + return Array.isArray(rows) ? (rows as AiTaskStatus[]) : []; +} + +function getStoredSessionRole(): string { + try { + if (typeof window === "undefined") return ""; + const raw = window.localStorage.getItem("omniai-web-session"); + if (!raw) return ""; + const session = JSON.parse(raw); + return String(session?.user?.role || "").trim().toLowerCase(); + } catch { + return ""; + } +} + +function emitImageRouteDebug(label: string, payload: Record): void { + // Only emit console logs for admin users — hides enterprise routing details + if (getStoredSessionRole() === "admin") { + const entry: ImageRouteDebugEntry = { + at: new Date().toISOString(), + label, + ...payload, + }; + try { + console.log(`${label} ${JSON.stringify(entry)}`); + } catch { + console.log(label, entry); + } + } + + if (typeof window === "undefined") return; + const debugWindow = window as Window & { __OMNIAI_IMAGE_ROUTE_DEBUG__?: ImageRouteDebugEntry[] }; + const previousEntries = Array.isArray(debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__) + ? debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__ + : []; + const entry: ImageRouteDebugEntry = { at: new Date().toISOString(), label, ...payload }; + debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__ = [...previousEntries.slice(-19), entry]; +} + +let taskHistoryRouteMissing = false; + +export const aiGenerationClient = { + async createImageTask(input: ImageGenInput): Promise { + const requestUrl = buildApiUrl("ai/image"); + emitImageRouteDebug("[ai/image-request]", { + url: requestUrl, + model: input.model, + ratio: input.ratio, + quality: input.quality, + gridMode: input.gridMode, + referenceCount: input.referenceUrls?.length || 0, + projectId: input.projectId, + conversationId: input.conversationId, + }); + const res = await fetch(requestUrl, { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify(input), + }); + if (!res.ok) { + await throwResponseError(res, "Image generation request failed"); + } + const payload = await readJsonResponse(res, "Image generation response failed"); + if (payload.providerDebug) { + emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record); + } + return payload; + }, + + async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> { + const res = await fetch(buildApiUrl("ai/video"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify(input), + }); + if (!res.ok) { + await throwResponseError(res, "Video generation request failed"); + } + return readJsonResponse<{ taskId: string }>(res, "Video generation response failed"); + }, + + async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> { + const res = await fetch(buildApiUrl("ai/video/super-resolve"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify(input), + }); + if (!res.ok) { + await throwResponseError(res, "Video super-resolution request failed"); + } + return readJsonResponse<{ taskId: string }>(res, "Video super-resolution response failed"); + }, + + async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> { + const res = await fetch(buildApiUrl("ai/video/erase-subtitles"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify(input), + }); + if (!res.ok) { + await throwResponseError(res, "Subtitle removal request failed"); + } + return readJsonResponse<{ taskId: string }>(res, "Subtitle removal response failed"); + }, + + async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> { + const res = await fetch(buildApiUrl("ai/image/super-resolve"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify(input), + }); + if (!res.ok) { + await throwResponseError(res, "Image super-resolution request failed"); + } + return readJsonResponse<{ taskId: string }>(res, "Image super-resolution response failed"); + }, + + async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> { + const res = await fetch(buildApiUrl("ai/image/edit"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify(input), + }); + if (!res.ok) { + await throwResponseError(res, "Image edit request failed"); + } + return readJsonResponse<{ taskId: string }>(res, "Image edit response failed"); + }, + + async cancelTask(taskId: string): Promise { + const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/cancel`), { + method: "PATCH", + headers: buildAuthHeaders(), + }); + if (!res.ok && res.status !== 404) { + await throwResponseError(res, "Task cancel failed"); + } + }, + + async getTaskStatus(taskId: string): Promise { + const res = await fetch(buildApiUrl(`ai/tasks/${taskId}`), { + method: "GET", + headers: buildAuthHeaders(), + }); + if (!res.ok) { + await throwResponseError(res, "Task status request failed"); + } + return readJsonResponse(res, "Task status response failed"); + }, + + async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> { + const res = await fetch(buildApiUrl(`ai/tasks/${encodeURIComponent(taskId)}/download`), { + method: "GET", + headers: buildAuthHeaders(), + }); + if (!res.ok) { + await throwResponseError(res, "Task result download failed"); + } + const blob = await res.blob(); + return { + blob, + filename: parseContentDispositionFilename(res.headers.get("content-disposition")), + contentType: res.headers.get("content-type") || blob.type || undefined, + }; + }, + + async listTasks(params?: { limit?: number; status?: string; type?: string; projectId?: string }): Promise { + if (taskHistoryRouteMissing) return []; + const search = new URLSearchParams(); + if (params?.limit) search.set("limit", String(params.limit)); + if (params?.status) search.set("status", params.status); + if (params?.type) search.set("type", params.type); + if (params?.projectId) search.set("projectId", params.projectId); + const res = await fetch(buildApiUrl(`ai/tasks${search.toString() ? `?${search}` : ""}`), { + method: "GET", + headers: buildAuthHeaders(), + }); + if (!res.ok) { + try { + await throwResponseError(res, "Task history request failed"); + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + taskHistoryRouteMissing = true; + return []; + } + throw error; + } + } + const payload = await readJsonResponse(res, "Task history response failed"); + return extractTaskList(payload).map(toPreviewTask); + }, + + async bindTaskToConversation(taskId: string, conversationId: number): Promise { + const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/conversation`), { + method: "PATCH", + headers: buildAuthHeaders(), + body: JSON.stringify({ conversationId }), + }); + if (res.status === 404) { + return; + } + if (!res.ok) { + await throwResponseError(res, "Task conversation binding failed"); + } + }, + + async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { + const res = await fetch(buildApiUrl("oss/upload"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify(input), + }); + if (!res.ok) { + await throwResponseError(res, "Asset upload failed"); + } + return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload response failed"); + }, + + async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { + const res = await fetch(buildApiUrl("oss/upload-by-url"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify(input), + }); + 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( + taskId: string, + onUpdate: (task: Pick) => void, + ): () => void { + const url = buildApiUrl(`ai/tasks/${taskId}/stream`); + const controller = new AbortController(); + + (async () => { + try { + const res = await fetch(url, { + headers: { ...buildAuthHeaders(), Accept: "text/event-stream" }, + signal: controller.signal, + }); + if (!res.ok || !res.body) return; + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + try { + const data = JSON.parse(line.slice(6)); + onUpdate(data); + } catch { /* ignore */ } + } + } + } catch { /* aborted or network error */ } + })(); + + return () => controller.abort(); + }, + + async streamChat( + input: ChatInput, + onChunk: (text: string) => void, + signal?: AbortSignal, + ): Promise { + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ ...input, stream: true }), + signal, + }); + if (!res.ok) { + await throwResponseError(res, "Chat request failed"); + } + + const reader = res.body?.getReader(); + if (!reader) throw new Error("无法读取响应流"); + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6).trim(); + if (!payload) continue; + try { + const chunk = JSON.parse(payload) as { delta?: string; done?: boolean; error?: string }; + if (chunk.error) throw new Error(chunk.error); + if (chunk.delta) onChunk(chunk.delta); + if (chunk.done) return; + } catch (e) { + if (e instanceof SyntaxError) continue; + throw e; + } + } + } + }, + + async chatCompletion(input: ChatInput, signal?: AbortSignal): Promise { + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ ...input, stream: false }), + signal, + }); + if (!res.ok) { + await throwResponseError(res, "Chat completion failed"); + } + const json = await readJsonResponse<{ content?: string }>(res, "Chat completion response failed"); + return (json as { content?: string }).content || ""; + }, +}; diff --git a/src/api/apiErrorUtils.ts b/src/api/apiErrorUtils.ts new file mode 100644 index 0000000..c7adf58 --- /dev/null +++ b/src/api/apiErrorUtils.ts @@ -0,0 +1,3 @@ +export function isOptionalApiRouteMissing(error: unknown): boolean { + return typeof error === "object" && error !== null && "status" in error && Number(error.status) === 404; +} diff --git a/src/api/assetClient.ts b/src/api/assetClient.ts new file mode 100644 index 0000000..049fd1f --- /dev/null +++ b/src/api/assetClient.ts @@ -0,0 +1,123 @@ +import type { WebAssetItem } from "../types"; +import { isRecord, serverRequest } from "./serverConnection"; + +export interface ServerAssetItem extends WebAssetItem { + id: string; + url?: string | null; + ossKey?: string | null; + tags: string[]; + sourceTaskId?: string | null; + sourceProjectId?: string | null; + metadata?: Record; + createdAt?: string; +} + +export interface CreateAssetInput { + type: WebAssetItem["type"] | "image" | "asset" | "other"; + name: string; + description?: string; + url?: string; + imageUrl?: string; + ossKey?: string; + tags?: string[]; + status?: WebAssetItem["status"] | "pending" | "failed"; + sourceTaskId?: string; + sourceProjectId?: string; + metadata?: Record; +} + +export interface DeleteAssetOptions { + cleanupUserData?: boolean; +} + +function toStringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") return value.trim() || fallback; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return fallback; +} + +function normalizeAssetType(value: unknown): WebAssetItem["type"] { + const type = toStringValue(value); + if ( + type === "character" || + type === "scene" || + type === "prop" || + type === "video" || + type === "image" || + type === "asset" || + type === "other" + ) { + return type; + } + return "other"; +} + +function normalizeAssetStatus(value: unknown): WebAssetItem["status"] { + const status = toStringValue(value); + if ( + status === "ready" || + status === "draft" || + status === "reviewing" || + status === "pending" || + status === "failed" + ) { + return status; + } + return "ready"; +} + +function normalizeTags(value: unknown): string[] { + return Array.isArray(value) ? value.map((item) => toStringValue(item)).filter(Boolean) : []; +} + +function normalizeAsset(raw: unknown): ServerAssetItem { + const item = isRecord(raw) ? raw : {}; + const url = toStringValue(item.url ?? item.imageUrl) || null; + return { + id: toStringValue(item.id, `asset-${Date.now()}`), + type: normalizeAssetType(item.type), + name: toStringValue(item.name, "未命名素材"), + description: toStringValue(item.description, "从服务器资产库同步的素材。"), + imageUrl: url || "", + url, + ossKey: toStringValue(item.ossKey ?? item.oss_key) || null, + tags: normalizeTags(item.tags), + status: normalizeAssetStatus(item.status), + sourceTaskId: toStringValue(item.sourceTaskId ?? item.source_task_id) || null, + sourceProjectId: toStringValue(item.sourceProjectId ?? item.source_project_id) || null, + metadata: isRecord(item.metadata) ? item.metadata : {}, + createdAt: toStringValue(item.createdAt ?? item.created_at), + updatedAt: toStringValue(item.updatedAt ?? item.updated_at, "刚刚"), + }; +} + +function extractAssets(payload: unknown): ServerAssetItem[] { + if (Array.isArray(payload)) return payload.map(normalizeAsset); + if (!isRecord(payload)) return []; + const rows = payload.assets ?? payload.items; + return Array.isArray(rows) ? rows.map(normalizeAsset) : []; +} + +export const assetClient = { + async list(params?: { type?: string; q?: string; status?: string }): Promise { + const search = new URLSearchParams(); + if (params?.type && params.type !== "all") search.set("type", params.type); + if (params?.q) search.set("q", params.q); + if (params?.status) search.set("status", params.status); + const query = search.toString(); + return extractAssets(await serverRequest(`assets${query ? `?${query}` : ""}`)); + }, + + async create(input: CreateAssetInput): Promise { + const payload = await serverRequest<{ asset: unknown }>("assets", { + method: "POST", + body: input, + }); + return normalizeAsset(payload.asset ?? payload); + }, + + async delete(id: string, options?: DeleteAssetOptions): Promise { + const path = options?.cleanupUserData ? `assets/${encodeURIComponent(id)}?cleanupUserData=1` : `assets/${encodeURIComponent(id)}`; + await serverRequest(path, { method: "DELETE" }); + }, +}; diff --git a/src/api/communityClient.ts b/src/api/communityClient.ts new file mode 100644 index 0000000..0000bf2 --- /dev/null +++ b/src/api/communityClient.ts @@ -0,0 +1,203 @@ +import { isRecord, serverRequest } from "./serverConnection"; + +export interface ServerCommunityAsset { + id?: number; + assetType: "image" | "video" | "project" | "workflow" | "asset" | "cover" | "other"; + title?: string | null; + url?: string | null; + ossKey?: string | null; + metadata?: Record; + sortOrder?: number; +} + +export interface ServerCommunityCase { + id: number; + userId?: number; + username?: string | null; + projectId?: string | null; + title: string; + description?: string | null; + coverUrl?: string | null; + tags: string[]; + metadata: Record; + status: "pending" | "approved" | "rejected"; + reviewNote?: string | null; + publishedAt?: string | null; + copyCount: number; + favoriteCount: number; + likeCount: number; + isFavorited: boolean; + isLiked: boolean; + createdAt: string; + updatedAt: string; + assets: ServerCommunityAsset[]; +} + +export interface PublishCommunityCaseInput { + projectId?: string | null; + title: string; + description?: string | null; + coverUrl?: string | null; + tags?: string[]; + metadata?: Record; + assets?: Array<{ + assetType?: ServerCommunityAsset["assetType"]; + title?: string; + url?: string; + ossKey?: string; + metadata?: Record; + sortOrder?: number; + }>; +} + +function toNumber(value: unknown, fallback = 0): number { + const numeric = typeof value === "number" ? value : Number(value); + return Number.isFinite(numeric) ? numeric : fallback; +} + +function toStringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") return value.trim() || fallback; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return fallback; +} + +function toStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.map((item) => toStringValue(item)).filter(Boolean) + : []; +} + +function toMetadata(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function normalizeAsset(raw: unknown): ServerCommunityAsset { + const asset = isRecord(raw) ? raw : {}; + const assetType = toStringValue(asset.assetType ?? asset.asset_type ?? asset.type, "other"); + return { + id: Number.isFinite(Number(asset.id)) ? Number(asset.id) : undefined, + assetType: + assetType === "image" || + assetType === "video" || + assetType === "image" || + assetType === "project" || + assetType === "workflow" || + assetType === "asset" || + assetType === "cover" + ? assetType + : "other", + title: toStringValue(asset.title) || null, + url: toStringValue(asset.url) || null, + ossKey: toStringValue(asset.ossKey ?? asset.oss_key) || null, + metadata: toMetadata(asset.metadata), + sortOrder: toNumber(asset.sortOrder ?? asset.sort_order), + }; +} + +function normalizeCase(raw: unknown): ServerCommunityCase { + const item = isRecord(raw) ? raw : {}; + const status = toStringValue(item.status, "pending"); + return { + id: toNumber(item.id), + userId: Number.isFinite(Number(item.userId ?? item.user_id)) ? Number(item.userId ?? item.user_id) : undefined, + username: toStringValue(item.username) || null, + projectId: toStringValue(item.projectId ?? item.project_id) || null, + title: toStringValue(item.title, "未命名案例"), + description: toStringValue(item.description) || null, + coverUrl: toStringValue(item.coverUrl ?? item.cover_url) || null, + tags: toStringArray(item.tags), + metadata: toMetadata(item.metadata), + status: status === "approved" || status === "rejected" ? status : "pending", + reviewNote: toStringValue(item.reviewNote ?? item.review_note) || null, + publishedAt: toStringValue(item.publishedAt ?? item.published_at) || null, + copyCount: toNumber(item.copyCount ?? item.copy_count), + favoriteCount: toNumber(item.favoriteCount ?? item.favorite_count), + likeCount: toNumber(item.likeCount ?? item.like_count), + isFavorited: Boolean(item.isFavorited ?? item.is_favorited), + isLiked: Boolean(item.isLiked ?? item.is_liked), + createdAt: toStringValue(item.createdAt ?? item.created_at, new Date().toISOString()), + updatedAt: toStringValue(item.updatedAt ?? item.updated_at, new Date().toISOString()), + assets: Array.isArray(item.assets) ? item.assets.map(normalizeAsset) : [], + }; +} + +function extractCases(payload: unknown): ServerCommunityCase[] { + if (Array.isArray(payload)) return payload.map(normalizeCase); + if (!isRecord(payload)) return []; + const rows = payload.cases ?? payload.items; + return Array.isArray(rows) ? rows.map(normalizeCase) : []; +} + +export const communityClient = { + async listApprovedCases( + params: number | { limit?: number; q?: string; category?: string; tag?: string; sort?: string } = 30, + ): Promise { + const search = new URLSearchParams(); + if (typeof params === "number") { + search.set("limit", String(params)); + } else { + search.set("limit", String(params.limit ?? 30)); + if (params.q) search.set("q", params.q); + if (params.category && params.category !== "全部") search.set("category", params.category); + if (params.tag) search.set("tag", params.tag); + if (params.sort) search.set("sort", params.sort); + } + return extractCases(await serverRequest(`community/cases?${search.toString()}`)); + }, + + async listMyCases(): Promise { + return extractCases(await serverRequest("community/me/cases")); + }, + + async listCasesForReview(status: "" | ServerCommunityCase["status"] = "pending"): Promise { + const search = new URLSearchParams(); + if (status) search.set("status", status); + const query = search.toString(); + return extractCases(await serverRequest(`admin/community/cases${query ? `?${query}` : ""}`)); + }, + + async publishCase(input: PublishCommunityCaseInput): Promise { + const payload = await serverRequest<{ case: unknown }>("community/cases", { + method: "POST", + body: input, + }); + return normalizeCase(payload.case); + }, + + async updateReviewStatus( + caseId: number | string, + status: Exclude, + reviewNote: string, + ): Promise { + const payload = await serverRequest<{ case: unknown }>(`admin/community/cases/${encodeURIComponent(String(caseId))}/status`, { + method: "PATCH", + body: { status, reviewNote, review_note: reviewNote }, + }); + return normalizeCase(payload.case); + }, + + async copyCase(caseId: number, input?: { projectId?: string; name?: string; ossKey?: string }): Promise { + await serverRequest(`community/cases/${caseId}/copy`, { + method: "POST", + body: input || {}, + }); + }, + + async setReaction( + caseId: number, + reactionType: "favorite" | "like", + active: boolean, + ): Promise<{ favoriteCount: number; likeCount: number; isFavorited: boolean; isLiked: boolean }> { + const payload = await serverRequest<{ stats: unknown }>(`community/cases/${caseId}/reactions`, { + method: "POST", + body: { reactionType, active }, + }); + const stats = isRecord(payload.stats) ? payload.stats : {}; + return { + favoriteCount: toNumber(stats.favoriteCount), + likeCount: toNumber(stats.likeCount), + isFavorited: Boolean(stats.isFavorited), + isLiked: Boolean(stats.isLiked), + }; + }, +}; diff --git a/src/api/conversationClient.ts b/src/api/conversationClient.ts new file mode 100644 index 0000000..562ebc8 --- /dev/null +++ b/src/api/conversationClient.ts @@ -0,0 +1,67 @@ +import { serverRequest } from "./serverConnection"; + +export interface ConversationSummary { + id: number; + title: string; + mode: string; + createdAt: string; + updatedAt: string; +} + +export interface ConversationDetail extends ConversationSummary { + messages: ConversationMessage[]; +} + +export interface ConversationMessage { + id: string; + role: "user" | "assistant"; + author: string; + mode: string; + body: string; + createdAt: string; + status?: string; + taskId?: string; + conversationId?: number; + taskProgress?: number; + taskStatusLabel?: string; + attachments?: Array<{ kind: string; name: string; token: string; previewUrl?: string; remoteUrl?: string }>; + resultUrl?: string; + resultType?: string; + resultOriginalUrl?: string; + resultOssKey?: string; + resultMimeType?: string; + result?: { title: string; summary: string; specs: string[] }; +} + +export interface DeleteConversationOptions { + cleanupUserData?: boolean; +} + +export const conversationClient = { + async list(): Promise { + const data = await serverRequest<{ conversations: ConversationSummary[] }>("conversations"); + return data.conversations || []; + }, + + async create(title: string, mode: string, messages?: ConversationMessage[]): Promise { + const data = await serverRequest<{ conversation: ConversationSummary }>("conversations", { + method: "POST", + body: { title, mode, messages }, + }); + return data.conversation; + }, + + async get(id: number): Promise { + const data = await serverRequest<{ conversation: ConversationDetail }>(`conversations/${id}`); + return data.conversation; + }, + + async update(id: number, data: { title?: string; messages?: ConversationMessage[] }): Promise { + await serverRequest(`conversations/${id}`, { method: "PUT", body: data }); + }, + + async delete(id: number, options?: DeleteConversationOptions): Promise { + const path = options?.cleanupUserData ? `conversations/${id}?cleanupUserData=1` : `conversations/${id}`; + await serverRequest(path, { method: "DELETE" }); + }, +}; diff --git a/src/api/draftClient.ts b/src/api/draftClient.ts new file mode 100644 index 0000000..c5c72e0 --- /dev/null +++ b/src/api/draftClient.ts @@ -0,0 +1,53 @@ +import { isRecord, serverRequest } from "./serverConnection"; + +export interface WebDraft { + id: string; + scope: string; + targetId: string; + payload: TPayload; + createdAt: string; + updatedAt: string; +} + +function toStringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") return value.trim() || fallback; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return fallback; +} + +function normalizeDraft(raw: unknown): WebDraft { + const item = isRecord(raw) ? raw : {}; + return { + id: toStringValue(item.id, `draft-${Date.now()}`), + scope: toStringValue(item.scope), + targetId: toStringValue(item.targetId ?? item.target_id, "default"), + payload: item.payload, + createdAt: toStringValue(item.createdAt ?? item.created_at), + updatedAt: toStringValue(item.updatedAt ?? item.updated_at), + }; +} + +function extractDrafts(payload: unknown): WebDraft[] { + if (Array.isArray(payload)) return payload.map(normalizeDraft); + if (!isRecord(payload)) return []; + const rows = payload.drafts ?? payload.items; + return Array.isArray(rows) ? rows.map(normalizeDraft) : []; +} + +export const draftClient = { + async list(params?: { scope?: string; targetId?: string }): Promise { + const search = new URLSearchParams(); + if (params?.scope) search.set("scope", params.scope); + if (params?.targetId) search.set("targetId", params.targetId); + const query = search.toString(); + return extractDrafts(await serverRequest(`drafts${query ? `?${query}` : ""}`)); + }, + + async save(input: { scope: string; targetId?: string; payload: TPayload }): Promise> { + const payload = await serverRequest<{ draft: unknown }>("drafts", { + method: "PUT", + body: input, + }); + return normalizeDraft(payload.draft ?? payload) as WebDraft; + }, +}; diff --git a/src/api/generationConcurrency.ts b/src/api/generationConcurrency.ts new file mode 100644 index 0000000..ed9df37 --- /dev/null +++ b/src/api/generationConcurrency.ts @@ -0,0 +1,62 @@ +type GenerationKind = "image" | "video"; + +interface GenerationSlot { + id: string; + userKey: string; + kind: GenerationKind; + createdAt: number; +} + +const MAX_ACTIVE_GENERATION_TASKS = 3; +const STALE_SLOT_MS = 6 * 60 * 60 * 1000; +const activeSlots = new Map(); + +export function getGenerationUserKey(userId?: string | number | null): string { + return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId); +} + +function pruneStaleSlots(now = Date.now()): void { + activeSlots.forEach((slot, id) => { + if (now - slot.createdAt > STALE_SLOT_MS) { + activeSlots.delete(id); + } + }); +} + +export function getActiveGenerationTaskCount(userKey: string): number { + pruneStaleSlots(); + let count = 0; + activeSlots.forEach((slot) => { + if (slot.userKey === userKey) count += 1; + }); + return count; +} + +export function claimGenerationSlot(input: { + userKey: string; + kind: GenerationKind; + id?: string; +}): () => void { + pruneStaleSlots(); + const activeCount = getActiveGenerationTaskCount(input.userKey); + if (activeCount >= MAX_ACTIVE_GENERATION_TASKS) { + throw new Error("当前账号同时最多生成 3 个图片/视频任务,请等待已有任务完成后再提交。"); + } + + const id = input.id || `generation-slot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + activeSlots.set(id, { + id, + userKey: input.userKey, + kind: input.kind, + createdAt: Date.now(), + }); + + return () => { + activeSlots.delete(id); + }; +} + +export function releaseGenerationSlot(id: string | undefined | null): void { + if (!id) return; + activeSlots.delete(id); +} diff --git a/src/api/keyServerClient.ts b/src/api/keyServerClient.ts new file mode 100644 index 0000000..ea51e68 --- /dev/null +++ b/src/api/keyServerClient.ts @@ -0,0 +1,931 @@ +import type { + WebCanvasWorkflow, + WebCanvasWorkflowNode, + WebEnterpriseUsageSummary, + WebProjectSummary, + WebUsageSummary, + WebUserSession, +} from "../types"; +import { + getErrorMessage, + getServerBaseUrl, + isRecord, + isServerRequestError, + readStoredSession, + serverRequest, + unwrapApiPayload, + writeStoredSession, +} from "./serverConnection"; + +interface LoginInput { + username: string; + password: string; +} + +interface RegisterInput extends LoginInput { + betaCode: string; +} + +interface EmailAuthInput { + email: string; + password: string; + username?: string; + betaCode?: string; +} + +interface PhoneAuthInput { + phone: string; + code: string; + password?: string; + betaCode?: string; +} + +interface UpdateProfileInput { + avatarUrl?: string | null; + avatarOssKey?: string | null; + bio?: string | null; + backgroundUrl?: string | null; + profileBackgroundUrl?: string | null; +} + +interface DeleteProjectOptions { + cleanupUserData?: boolean; +} + +export interface WechatLoginTicket { + configured: boolean; + url?: string; + state?: string; + message?: string; +} + +export interface WechatLoginSessionStatus { + status: "pending" | "completed" | "failed" | "expired" | "missing" | "consumed" | string; + error?: string; + session?: WebUserSession; +} + +const getBaseUrl = getServerBaseUrl; +const request = serverRequest; +const isHttpError = isServerRequestError; +const PROJECT_CONTENT_ENRICH_CONCURRENCY = 1; +let projectContentEnrichmentDisabled = false; + +function toNumber(value: unknown, fallback = 0): number { + const numberValue = typeof value === "number" ? value : Number(value); + return Number.isFinite(numberValue) ? numberValue : fallback; +} + +function toStringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || fallback; + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + return fallback; +} + +function toNullableString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed || null; +} + +function toNullableId(value: unknown): number | string | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + return toNullableString(value); +} + +function toIdValue(value: unknown, fallback: string): number | string { + if (typeof value === "number" && Number.isFinite(value)) return value; + return toStringValue(value, fallback); +} + +function isPlaceholderProjectText(value: string | null | undefined): boolean { + if (!value) return true; + return /^(新建项目|新建创作|未命名项目|Untitled project)$/i.test(value.trim()); +} + +function isPlaceholderProjectDescription(value: string | null | undefined): boolean { + if (!value) return true; + return /^(从空白画布开始|从空白画布开始,直接进入节点式创作。|最近更新的项目)$/i.test(value.trim()); +} + +function chooseProjectText(contentText: string | null, currentText: string | null, fallback: string): string { + if (contentText && (!isPlaceholderProjectText(contentText) || isPlaceholderProjectText(currentText))) { + return contentText; + } + return currentText || contentText || fallback; +} + +function chooseProjectDescription(contentText: string | null, currentText: string | null): string | null { + if (contentText && (!isPlaceholderProjectDescription(contentText) || isPlaceholderProjectDescription(currentText))) { + return contentText; + } + return currentText || contentText || null; +} + +function pickFirstString(...values: unknown[]): string | null { + for (const value of values) { + const next = toNullableString(value); + if (next) return next; + } + return null; +} + +function toProjectSummary( + raw: Record, + source: WebProjectSummary["source"], +): WebProjectSummary { + return { + id: toStringValue(raw.id), + name: toStringValue(raw.name, "未命名项目"), + description: toNullableString(raw.description), + thumbnailUrl: toNullableString(raw.thumbnail_url ?? raw.thumbnailUrl), + updatedAt: toStringValue(raw.updated_at ?? raw.updatedAt, "刚刚"), + storyboardCount: toNumber(raw.storyboard_count ?? raw.storyboardCount), + imageCount: toNumber(raw.image_count ?? raw.imageCount), + videoCount: toNumber(raw.video_count ?? raw.videoCount), + source, + }; +} + +function buildProjectSummaryFromWorkflow( + workflow: WebCanvasWorkflow, + source: WebProjectSummary["source"], + errorMessage?: string, +): WebProjectSummary { + const previewNode = workflow.nodes.find((node) => node.previewUrl); + return { + id: workflow.id, + name: workflow.title, + description: workflow.description, + thumbnailUrl: previewNode?.previewUrl ?? null, + updatedAt: "刚刚", + storyboardCount: workflow.nodes.length, + imageCount: workflow.nodes.filter((node) => node.kind === "image").length, + videoCount: workflow.nodes.filter((node) => node.kind === "video").length, + source, + ...(errorMessage ? { errorMessage } : {}), + }; +} + +function unwrapProjectContentPayload(payload: unknown): unknown { + const unwrapped = unwrapApiPayload(payload); + return isRecord(unwrapped) && "content" in unwrapped ? unwrapped.content : unwrapped; +} + +function getContentWorkflowRecord(content: unknown): Record | null { + if (!isRecord(content)) return null; + const workflow = content.workflowData ?? content.workflow ?? content.workflow_data; + return isRecord(workflow) ? workflow : null; +} + +function getContentNodes(content: unknown): Record[] { + const workflow = getContentWorkflowRecord(content); + const nodeSource = + (workflow && Array.isArray(workflow.nodes) ? workflow.nodes : null) ?? + (isRecord(content) && Array.isArray(content.nodes) ? content.nodes : null); + return Array.isArray(nodeSource) ? nodeSource.filter(isRecord) : []; +} + +function getContentArray(content: unknown, key: string): Record[] { + if (!isRecord(content)) return []; + const value = content[key]; + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function countNodesByKind(nodes: Record[], kind: string): number { + return nodes.filter((node) => node.kind === kind || node.type === kind).length; +} + +function pickProjectPreviewUrl(content: unknown): string | null { + if (!isRecord(content)) return null; + const workflow = getContentWorkflowRecord(content); + const nodes = getContentNodes(content); + const storyboards = getContentArray(content, "storyboards"); + const videos = getContentArray(content, "videos"); + + return ( + pickFirstString( + content.thumbnailUrl, + content.thumbnail_url, + content.coverUrl, + content.cover_url, + workflow?.thumbnailUrl, + workflow?.thumbnail_url, + nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url)) + ?.previewUrl, + nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url)) + ?.preview_url, + nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url)) + ?.imageUrl, + nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url)) + ?.image_url, + storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)) + ?.imageUrl, + storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)) + ?.image_url, + storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)) + ?.coverUrl, + storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)) + ?.cover_url, + videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url)) + ?.coverUrl, + videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url)) + ?.cover_url, + videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url)) + ?.thumbnailUrl, + videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url)) + ?.thumbnail_url, + ) || null + ); +} + +function mergeProjectSummaryWithContent(project: WebProjectSummary, payload: unknown): WebProjectSummary { + const content = unwrapProjectContentPayload(payload); + if (!isRecord(content)) return project; + + const workflow = getContentWorkflowRecord(content); + const nodes = getContentNodes(content); + const storyboards = getContentArray(content, "storyboards"); + const videos = getContentArray(content, "videos"); + const contentTitle = pickFirstString(content.name, content.projectName, content.title, workflow?.title); + const contentDescription = pickFirstString( + content.description, + content.projectDescription, + content.summary, + workflow?.description, + ); + const imageCount = + countNodesByKind(nodes, "image") || + storyboards.filter((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)).length; + const videoCount = + countNodesByKind(nodes, "video") || + videos.length || + storyboards.filter((item) => pickFirstString(item.videoUrl, item.video_url)).length; + const storyboardCount = nodes.length || storyboards.length; + + return { + ...project, + name: chooseProjectText(contentTitle, project.name, project.name || "未命名项目"), + description: chooseProjectDescription(contentDescription, project.description ?? null), + thumbnailUrl: pickProjectPreviewUrl(content) || project.thumbnailUrl || null, + storyboardCount: storyboardCount || project.storyboardCount, + imageCount: imageCount || project.imageCount, + videoCount: videoCount || project.videoCount, + }; +} + +async function enrichProjectSummariesWithContent(projects: WebProjectSummary[]): Promise { + if (projectContentEnrichmentDisabled) return projects; + + const enriched = [...projects]; + + for (let index = 0; index < projects.length; index += PROJECT_CONTENT_ENRICH_CONCURRENCY) { + if (projectContentEnrichmentDisabled) break; + const batch = projects.slice(index, index + PROJECT_CONTENT_ENRICH_CONCURRENCY); + const results = await Promise.allSettled( + batch.map(async (project) => { + const payload = await request(`/projects/${encodeURIComponent(project.id)}/content?resolveMedia=1`); + return mergeProjectSummaryWithContent(project, payload); + }), + ); + + results.forEach((result, offset) => { + const targetIndex = index + offset; + if (result.status === "fulfilled") { + enriched[targetIndex] = result.value; + } else { + const status = isHttpError(result.reason) ? result.reason.status : undefined; + if (status === 404 || (typeof status === "number" && status >= 500)) { + projectContentEnrichmentDisabled = true; + } + enriched[targetIndex] = { + ...enriched[targetIndex], + errorMessage: getErrorMessage(result.reason), + }; + } + }); + } + + return enriched; +} + +function createWorkflowFingerprint(workflow: WebCanvasWorkflow): string { + const payload = JSON.stringify({ + title: workflow.title, + description: workflow.description, + settings: workflow.settings, + nodes: workflow.nodes.map((node) => ({ + id: node.id, + kind: node.kind, + label: node.label, + detail: node.detail, + previewUrl: node.previewUrl || "", + })), + edges: workflow.edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + label: edge.label || "", + })), + }); + + let hash = 0x811c9dc5; + for (let i = 0; i < payload.length; i += 1) { + hash ^= payload.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + + return `wf-${(hash >>> 0).toString(16).padStart(8, "0")}`; +} + +function toActivePackages(value: unknown): WebUserSession["user"]["activePackages"] { + if (!Array.isArray(value)) return undefined; + + return value.filter(isRecord).map((entry) => ({ + name: toStringValue(entry.name, "Preview package"), + expiresAt: toStringValue(entry.expiresAt ?? entry.expires_at, ""), + remainingImage: toNumber(entry.remainingImage ?? entry.remaining_image), + remainingVideo: toNumber(entry.remainingVideo ?? entry.remaining_video), + remainingText: toNumber(entry.remainingText ?? entry.remaining_text), + })); +} + +function normalizeUser(raw: unknown): WebUserSession["user"] | null { + const payload = unwrapApiPayload(raw); + const candidate = isRecord(payload) && isRecord(payload.user) ? payload.user : payload; + if (!isRecord(candidate)) return null; + + const id = candidate.id ?? candidate.userId ?? candidate.user_id; + const username = toStringValue(candidate.username ?? candidate.name, "预览用户"); + if (id === undefined || !username) return null; + + return { + id: typeof id === "number" && Number.isFinite(id) ? id : toStringValue(id), + username, + displayName: toNullableString(candidate.displayName ?? candidate.display_name ?? candidate.nickname ?? candidate.name), + bio: toNullableString(candidate.bio ?? candidate.profileBio ?? candidate.profile_bio ?? candidate.description ?? candidate.intro), + avatarUrl: toNullableString(candidate.avatarUrl ?? candidate.avatar_url), + backgroundUrl: toNullableString( + candidate.profileBackgroundUrl ?? + candidate.profile_background_url ?? + candidate.backgroundUrl ?? + candidate.background_url ?? + candidate.coverUrl ?? + candidate.cover_url, + ), + email: toNullableString(candidate.email), + emailVerified: candidate.emailVerified === true || candidate.email_verified === true || candidate.email_verified === 1, + phone: toNullableString(candidate.phone), + authProvider: toNullableString(candidate.authProvider ?? candidate.auth_provider), + sessionId: toNullableString(candidate.sessionId ?? candidate.session_id), + sessionStartedAt: toNullableString(candidate.sessionStartedAt ?? candidate.session_started_at), + role: toNullableString(candidate.role) ?? undefined, + accountType: toNullableString(candidate.accountType ?? candidate.account_type) ?? undefined, + enterpriseId: toNullableString(candidate.enterpriseId ?? candidate.enterprise_id), + enterpriseName: toNullableString(candidate.enterpriseName ?? candidate.enterprise_name), + enterpriseRole: toNullableString(candidate.enterpriseRole ?? candidate.enterprise_role) ?? undefined, + enterpriseAdminUserId: toNullableId(candidate.enterpriseAdminUserId ?? candidate.enterprise_admin_user_id), + balanceCents: toNumber( + candidate.balanceCents ?? candidate.balance_cents ?? candidate.userBalanceCents ?? candidate.user_balance_cents, + ), + enterpriseBalanceCents: toNumber( + candidate.enterpriseBalanceCents ?? + candidate.enterprise_balance_cents ?? + candidate.enterpriseBalance ?? + candidate.enterprise_balance, + ), + activePackages: toActivePackages(candidate.activePackages ?? candidate.active_packages), + }; +} + +function extractProjectRows(payload: unknown): Record[] { + const unwrapped = unwrapApiPayload(payload); + if (Array.isArray(unwrapped)) { + return unwrapped.filter(isRecord); + } + if (!isRecord(unwrapped)) return []; + + const rows = unwrapped.projects ?? unwrapped.items; + return Array.isArray(rows) ? rows.filter(isRecord) : []; +} + +function isCanvasWorkflow(value: unknown): value is WebCanvasWorkflow { + if (!isRecord(value)) return false; + return ( + typeof value.id === "string" && + typeof value.title === "string" && + typeof value.description === "string" && + typeof value.version === "number" && value.version >= 1 && + isRecord(value.settings) && + Array.isArray(value.nodes) && + Array.isArray(value.edges) + ); +} + +function isLegacyWorkflowData(value: unknown): value is Record { + if (!isRecord(value)) return false; + return ( + typeof value.version === "number" && + Array.isArray(value.nodes) && + Array.isArray(value.edges) + ); +} + +function migrateLegacyWorkflowData(old: Record, wrapper: Record, projectId: string): WebCanvasWorkflow { + const viewport = isRecord(old.viewport) ? old.viewport : {}; + return { + id: old.id as string || projectId, + version: 1, + title: String(wrapper.name || wrapper.title || "未命名项目"), + description: String(wrapper.description || ""), + source: (wrapper.source as WebCanvasWorkflow["source"]) || "blank", + settings: { + model: String(isRecord(old.settings) ? old.settings.model || "Nano Banana Pro" : "Nano Banana Pro"), + ratio: String(isRecord(old.settings) ? old.settings.ratio || "1:1" : "1:1"), + duration: String(isRecord(old.settings) ? old.settings.duration || "0s" : "0s"), + resolution: String(isRecord(old.settings) ? old.settings.resolution || "2K" : "2K"), + }, + viewport: { + x: Number(viewport.x || 0), + y: Number(viewport.y || 0), + zoom: Number(viewport.zoom || viewport.scale || 1), + }, + nodes: (Array.isArray(old.nodes) ? old.nodes : []).map((n: unknown) => isRecord(n) ? { ...n, position: isRecord(n.position) ? { ...n.position } : { x: 0, y: 0 } } : n) as WebCanvasWorkflowNode[], + edges: Array.isArray(old.edges) ? old.edges : [], + packages: Array.isArray(old.packages) ? old.packages : [], + }; +} + +function cloneWorkflow(workflow: WebCanvasWorkflow): WebCanvasWorkflow { + return { + ...workflow, + settings: { ...workflow.settings }, + nodes: workflow.nodes.map((node) => ({ ...node, position: { ...node.position } })), + edges: workflow.edges.map((edge) => ({ ...edge })), + }; +} + +function normalizeProjectContent(payload: unknown, projectId: string): WebCanvasWorkflow { + const unwrapped = unwrapApiPayload(payload); + const content = isRecord(unwrapped) && "content" in unwrapped ? unwrapped.content : unwrapped; + + // New format: content.workflowData is a full WebCanvasWorkflow + if (isRecord(content) && isCanvasWorkflow(content.workflowData)) { + return cloneWorkflow({ ...content.workflowData, id: content.workflowData.id || projectId }); + } + + // New format: content.workflow is a full WebCanvasWorkflow + if (isRecord(content) && isCanvasWorkflow(content.workflow)) { + return cloneWorkflow({ ...content.workflow, id: content.workflow.id || projectId }); + } + + // Content itself is a WebCanvasWorkflow + if (isCanvasWorkflow(content)) { + return cloneWorkflow({ ...content, id: content.id || projectId }); + } + + // Legacy format: wrapper has name/description, workflowData has nodes/edges/viewport + if (isRecord(content) && isLegacyWorkflowData(content.workflowData)) { + return migrateLegacyWorkflowData(content.workflowData, content, projectId); + } + + throw new Error("Project content did not include a canvas workflow"); +} + +function normalizeLoginResult(payload: unknown): WebUserSession | null { + const unwrapped = unwrapApiPayload(payload); + if (!isRecord(unwrapped) || typeof unwrapped.token !== "string") return null; + + const user = normalizeUser(unwrapped.user ?? unwrapped); + if (!user) return null; + + return { + token: unwrapped.token, + user, + source: "server", + }; +} + +function updateStoredSessionUser(user: WebUserSession["user"]): WebUserSession | null { + const stored = readStoredSession(); + if (!stored) return null; + + const session: WebUserSession = { + ...stored, + user: { + ...stored.user, + ...user, + }, + }; + writeStoredSession(session); + return session; +} + +function normalizeUsageSummary(payload: unknown): WebUsageSummary { + const unwrapped = unwrapApiPayload(payload); + const raw = isRecord(unwrapped) ? unwrapped : {}; + + return { + balanceCents: toNumber( + raw.balanceCents ?? raw.balance_cents ?? raw.currentBalanceCents ?? raw.current_balance_cents, + ), + enterpriseBalanceCents: + toOptionalNumber( + raw.enterpriseBalanceCents ?? + raw.enterprise_balance_cents ?? + raw.enterpriseBalance ?? + raw.enterprise_balance, + ) ?? undefined, + imageUsed: toNumber(raw.imageUsed ?? raw.image_used ?? raw.imageCount ?? raw.image_count), + videoUsed: toNumber(raw.videoUsed ?? raw.video_used ?? raw.videoCount ?? raw.video_count), + textUsed: toNumber( + raw.textUsed ?? raw.text_used ?? raw.textTokens ?? raw.text_tokens ?? raw.total_prompt_tokens, + ), + source: "server", + }; +} + +function toOptionalNumber(value: unknown): number | null { + if (value === undefined || value === null || value === "") return null; + const numberValue = typeof value === "number" ? value : Number(value); + return Number.isFinite(numberValue) ? numberValue : null; +} + +function normalizeEnterpriseUsageSummary(payload: unknown): WebEnterpriseUsageSummary { + const unwrapped = unwrapApiPayload(payload); + const raw = isRecord(unwrapped) ? unwrapped : {}; + const memberRows = Array.isArray(raw.members) ? raw.members.filter(isRecord) : []; + const modelRows = Array.isArray(raw.modelBreakdown) + ? raw.modelBreakdown.filter(isRecord) + : Array.isArray(raw.model_breakdown) + ? raw.model_breakdown.filter(isRecord) + : []; + const recordRows = Array.isArray(raw.records) + ? raw.records.filter(isRecord) + : Array.isArray(raw.items) + ? raw.items.filter(isRecord) + : []; + const trendRows = Array.isArray(raw.dailyTrend) + ? raw.dailyTrend.filter(isRecord) + : Array.isArray(raw.daily_trend) + ? raw.daily_trend.filter(isRecord) + : []; + + return { + enterpriseId: toStringValue(raw.enterpriseId ?? raw.enterprise_id), + enterpriseName: toStringValue(raw.enterpriseName ?? raw.enterprise_name, "Enterprise"), + balanceCents: toNumber(raw.balanceCents ?? raw.balance_cents ?? raw.enterpriseBalanceCents ?? raw.enterprise_balance_cents), + totalUsedCents: toNumber(raw.totalUsedCents ?? raw.total_used_cents ?? raw.usedCents ?? raw.used_cents), + members: memberRows.map((member, index) => ({ + userId: toIdValue(member.userId ?? member.user_id ?? member.id, `member-${index + 1}`), + username: toStringValue(member.username ?? member.userName ?? member.user_name ?? member.name, "employee"), + displayName: toNullableString(member.displayName ?? member.display_name ?? member.nickname ?? member.name), + role: toStringValue(member.role ?? member.enterpriseRole ?? member.enterprise_role, "employee"), + usedCents: toNumber( + member.usedCents ?? + member.used_cents ?? + member.amountCents ?? + member.amount_cents ?? + member.totalUsedCents ?? + member.total_used_cents, + ), + taskCount: toNumber(member.taskCount ?? member.task_count ?? member.calls ?? member.count), + lastUsedAt: toNullableString(member.lastUsedAt ?? member.last_used_at ?? member.updatedAt ?? member.updated_at), + })), + modelBreakdown: modelRows.map((model) => ({ + model: toStringValue(model.model ?? model.modelId ?? model.model_id, "unknown"), + usedCents: toNumber(model.usedCents ?? model.used_cents ?? model.amountCents ?? model.amount_cents), + taskCount: toNumber(model.taskCount ?? model.task_count ?? model.calls ?? model.count), + })), + dailyTrend: trendRows.map((row) => ({ + date: toStringValue(row.date ?? row.day), + usedCents: toNumber(row.usedCents ?? row.used_cents ?? row.amountCents ?? row.amount_cents), + taskCount: toNumber(row.taskCount ?? row.task_count ?? row.count), + })), + records: recordRows.map((record, index) => ({ + id: toStringValue(record.id ?? record.ledgerId ?? record.ledger_id ?? record.taskId ?? record.task_id, `record-${index + 1}`), + userId: toIdValue(record.userId ?? record.user_id ?? record.memberId ?? record.member_id, "unknown"), + username: toStringValue(record.username ?? record.userName ?? record.user_name ?? record.name, "employee"), + model: toStringValue(record.model ?? record.modelId ?? record.model_id, "unknown"), + taskType: toStringValue(record.taskType ?? record.task_type ?? record.type, "image"), + resolution: toNullableString(record.resolution ?? record.quality), + durationSeconds: toOptionalNumber(record.durationSeconds ?? record.duration_seconds ?? record.duration), + amountCents: toNumber(record.amountCents ?? record.amount_cents ?? record.usedCents ?? record.used_cents), + prompt: toNullableString(record.prompt), + status: toStringValue(record.status, "completed"), + createdAt: toStringValue(record.createdAt ?? record.created_at ?? record.updatedAt ?? record.updated_at), + })), + source: "server", + }; +} + +function buildProjectUpsertPayload(workflow: WebCanvasWorkflow, session: WebUserSession): Record { + const userId = String(session.user.id).replace(/[^a-zA-Z0-9_-]/g, ""); + const projectId = workflow.id.trim(); + const ossKey = `users/${userId}/projects/${projectId}/current/project.json`; + + return { + id: projectId, + name: workflow.title.trim() || "未命名项目", + description: workflow.description.trim() || null, + ossKey, + thumbnailUrl: workflow.nodes.find((node) => node.previewUrl)?.previewUrl || null, + storyboardCount: workflow.nodes.length, + imageCount: workflow.nodes.filter((node) => node.kind === "image").length, + videoCount: workflow.nodes.filter((node) => node.kind === "video").length, + fileSize: JSON.stringify(workflow).length, + fingerprint: createWorkflowFingerprint(workflow), + deviceId: "web", + baseRevision: null, + forceOverwrite: true, + saveReason: "create", + }; +} + +export const keyServerClient = { + getBaseUrl, + getStoredSession: readStoredSession, + updateStoredSessionUser, + clearSession() { + writeStoredSession(null); + }, + async login(input: LoginInput): Promise { + const session = normalizeLoginResult( + await request("/auth/login", { + method: "POST", + body: { + username: input.username.trim(), + password: input.password, + }, + }), + ); + if (!session) { + throw new Error("Login response did not include a token and user"); + } + + writeStoredSession(session); + return session; + }, + async register(input: RegisterInput): Promise { + const session = normalizeLoginResult( + await request("/auth/register", { + method: "POST", + body: { + username: input.username.trim(), + password: input.password, + betaCode: input.betaCode.trim(), + }, + }), + ); + if (!session) { + throw new Error("Register response did not include a token and user"); + } + + writeStoredSession(session); + return session; + }, + async loginEmail(input: EmailAuthInput): Promise { + const session = normalizeLoginResult( + await request("/auth/login-email", { + method: "POST", + body: { + email: input.email.trim(), + password: input.password, + }, + }), + ); + if (!session) { + throw new Error("Email login response did not include a token and user"); + } + + writeStoredSession(session); + return session; + }, + async registerEmail(input: EmailAuthInput): Promise { + const session = normalizeLoginResult( + await request("/auth/register-email", { + method: "POST", + body: { + email: input.email.trim(), + username: input.username?.trim() || undefined, + password: input.password, + betaCode: input.betaCode?.trim() || undefined, + }, + }), + ); + if (!session) { + throw new Error("Email register response did not include a token and user"); + } + + writeStoredSession(session); + return session; + }, + async sendSmsCode(phone: string, purpose: "login" | "register", betaCode?: string): Promise<{ cooldownSeconds?: number; ttlSeconds?: number }> { + return request<{ cooldownSeconds?: number; ttlSeconds?: number }>("/auth/sms/send", { + method: "POST", + body: { phone: phone.trim(), purpose, betaCode: betaCode?.trim() || undefined }, + }); + }, + async loginPhone(input: PhoneAuthInput): Promise { + const session = normalizeLoginResult( + await request("/auth/login-phone", { + method: "POST", + body: { + phone: input.phone.trim(), + code: input.code.trim(), + }, + }), + ); + if (!session) { + throw new Error("Phone login response did not include a token and user"); + } + + writeStoredSession(session); + return session; + }, + async registerPhone(input: PhoneAuthInput): Promise { + const session = normalizeLoginResult( + await request("/auth/register-phone", { + method: "POST", + body: { + phone: input.phone.trim(), + code: input.code.trim(), + password: input.password || "", + betaCode: input.betaCode?.trim() || undefined, + }, + }), + ); + if (!session) { + throw new Error("Phone register response did not include a token and user"); + } + + writeStoredSession(session); + return session; + }, + async getWechatLoginTicket(): Promise { + const browserCrypto = typeof globalThis !== "undefined" ? globalThis.crypto : undefined; + const state = + browserCrypto && "randomUUID" in browserCrypto + ? browserCrypto.randomUUID().replace(/-/g, "") + : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; + return request(`/auth/wechat/login-url?state=${encodeURIComponent(state)}`); + }, + async getWechatLoginSession(state: string): Promise { + const response = await request(`/auth/wechat/session?state=${encodeURIComponent(state)}`); + const raw = isRecord(response) ? response : {}; + const session = normalizeLoginResult(raw); + if (session) { + writeStoredSession(session); + return { status: "completed", session }; + } + + const status = toStringValue(raw.status, "pending"); + return { + status, + error: toNullableString(raw.error) ?? undefined, + }; + }, + async updateProfile(input: UpdateProfileInput): Promise { + const user = normalizeUser( + await request("/auth/profile", { + method: "PUT", + body: { + ...input, + profileBackgroundUrl: input.profileBackgroundUrl ?? input.backgroundUrl ?? undefined, + }, + }), + ); + if (!user) { + throw new Error("Profile response did not include a user"); + } + + updateStoredSessionUser(user); + return user; + }, + async getCurrentSession(): Promise { + const stored = readStoredSession(); + if (!stored) { + return null; + } + + try { + const user = normalizeUser(await request("/auth/me", { token: stored.token })); + if (!user) { + throw new Error("Current-user response did not include a user"); + } + + const session: WebUserSession = { ...stored, user, source: "server", errorMessage: undefined }; + writeStoredSession(session); + return session; + } catch (error) { + if (isHttpError(error) && (error.status === 401 || error.status === 403)) { + writeStoredSession(null); + return null; + } + return { + ...stored, + source: "server", + errorMessage: getErrorMessage(error), + }; + } + }, + async listProjects(): Promise { + const summaries = extractProjectRows(await request("/projects")).map((project) => + toProjectSummary(project, "server"), + ); + return enrichProjectSummariesWithContent(summaries); + }, + async getProjectContent(projectId: string): Promise { + const stored = readStoredSession(); + if (!stored) { + throw new Error("闇€瑕佸厛鐧诲綍"); + } + + const safeProjectId = encodeURIComponent(projectId.trim()); + if (!safeProjectId) { + throw new Error("Project id is required"); + } + + const response = await request(`/projects/${safeProjectId}/content?resolveMedia=1`); + return normalizeProjectContent(response, projectId); + }, + async getUsageSummary(): Promise { + return normalizeUsageSummary(await request("/user/usage/summary")); + }, + async getEnterpriseUsageSummary(): Promise { + return normalizeEnterpriseUsageSummary(await request("/enterprise/usage/summary")); + }, + async getPersonalUsageSummary(): Promise { + return normalizeEnterpriseUsageSummary(await request("/user/usage/credits")); + }, + async createProjectSpace(workflow: WebCanvasWorkflow): Promise { + const stored = readStoredSession(); + if (!stored) { + const error = new Error("需要先登录"); + throw error; + } + + const payload = buildProjectUpsertPayload(workflow, stored); + const response = await request("/projects/upsert", { + method: "POST", + body: payload, + }); + const projectPayload = isRecord(response) ? response.project ?? response : response; + if (!isRecord(projectPayload)) { + throw new Error("Project response did not include a project"); + } + + return toProjectSummary(projectPayload, "server"); + }, + async saveProjectContent(projectId: string, workflow: WebCanvasWorkflow): Promise { + const stored = readStoredSession(); + if (!stored) { + throw new Error("需要先登录"); + } + + const response = await request(`projects/${encodeURIComponent(projectId)}/content`, { + method: "PUT", + body: { + content: { + name: workflow.title, + description: workflow.description, + workflowData: workflow, + nodes: workflow.nodes, + edges: workflow.edges, + }, + meta: { + name: workflow.title, + description: workflow.description, + thumbnailUrl: workflow.nodes.find((node) => node.previewUrl)?.previewUrl || null, + storyboardCount: workflow.nodes.length, + imageCount: workflow.nodes.filter((node) => node.kind === "image").length, + videoCount: workflow.nodes.filter((node) => node.kind === "video").length, + }, + saveReason: "web-create", + deviceId: "web", + forceOverwrite: true, + }, + }); + const rawProject = isRecord(response) && isRecord(response.project) ? response.project : response; + if (!isRecord(rawProject)) { + return buildProjectSummaryFromWorkflow(workflow, "server"); + } + return toProjectSummary(rawProject, "server"); + }, + async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise { + const stored = readStoredSession(); + if (!stored) { + throw new Error("闇€瑕佸厛鐧诲綍"); + } + + const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`; + await request(path, { + method: "DELETE", + }); + }, +}; diff --git a/src/api/modelCapabilitiesClient.ts b/src/api/modelCapabilitiesClient.ts new file mode 100644 index 0000000..0ce1f8a --- /dev/null +++ b/src/api/modelCapabilitiesClient.ts @@ -0,0 +1,96 @@ +import { isOptionalApiRouteMissing } from "./apiErrorUtils"; +import { isRecord, serverRequest } from "./serverConnection"; + +export interface ModelCapabilityOption { + value: string; + label: string; + description?: string; + badge?: string; + enabled?: boolean; + status?: "available" | "maintenance" | "disabled" | string; +} + +export interface WebModelCapabilities { + source: "server" | "fallback"; + imageModels: ModelCapabilityOption[]; + videoModels: ModelCapabilityOption[]; + chatModels: ModelCapabilityOption[]; + updatedAt?: string; +} + +function toStringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") return value.trim() || fallback; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return fallback; +} + +function normalizeModelOption(raw: unknown): ModelCapabilityOption | null { + if (typeof raw === "string") { + const value = raw.trim(); + return value ? { value, label: value } : null; + } + if (!isRecord(raw)) return null; + + const value = toStringValue(raw.value ?? raw.id ?? raw.model ?? raw.modelKey ?? raw.model_key); + if (!value) return null; + + const status = toStringValue(raw.status); + const enabled = raw.enabled === undefined ? status !== "maintenance" && status !== "disabled" : Boolean(raw.enabled); + if (!enabled) return null; + + return { + value, + label: toStringValue(raw.label ?? raw.displayName ?? raw.display_name ?? raw.name, value), + description: toStringValue(raw.description) || undefined, + badge: toStringValue(raw.badge) || undefined, + enabled, + status: status || "available", + }; +} + +function normalizeModelList(value: unknown): ModelCapabilityOption[] { + return Array.isArray(value) + ? value.map(normalizeModelOption).filter((item): item is ModelCapabilityOption => Boolean(item)) + : []; +} + +function createFallbackCapabilities(): WebModelCapabilities { + return { + source: "fallback", + imageModels: [], + videoModels: [], + chatModels: [], + }; +} + +let modelCapabilitiesRouteMissing = false; + +export const modelCapabilitiesClient = { + async get(name = "web-model-capabilities"): Promise { + if (import.meta.env.DEV && name === "web-model-capabilities") return createFallbackCapabilities(); + if (modelCapabilitiesRouteMissing) return createFallbackCapabilities(); + + let payload: unknown; + try { + payload = await serverRequest(`config/profile?name=${encodeURIComponent(name)}`); + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + modelCapabilitiesRouteMissing = true; + return createFallbackCapabilities(); + } + throw error; + } + + const raw = isRecord(payload) && isRecord(payload.config) ? payload.config : payload; + const config = isRecord(raw) ? raw : {}; + const models = isRecord(config.models) ? config.models : {}; + + return { + source: "server", + imageModels: normalizeModelList(config.imageModels ?? config.image_models ?? models.image), + videoModels: normalizeModelList(config.videoModels ?? config.video_models ?? models.video), + chatModels: normalizeModelList(config.chatModels ?? config.chat_models ?? models.chat ?? models.agent ?? models.text), + updatedAt: toStringValue((payload as { updatedAt?: unknown; updated_at?: unknown })?.updatedAt ?? (payload as { updated_at?: unknown })?.updated_at), + }; + }, +}; diff --git a/src/api/notificationClient.ts b/src/api/notificationClient.ts new file mode 100644 index 0000000..b50afb4 --- /dev/null +++ b/src/api/notificationClient.ts @@ -0,0 +1,173 @@ +import type { WebNotification, WebNotificationType, WebViewKey } from "../types"; +import { isOptionalApiRouteMissing } from "./apiErrorUtils"; +import { isRecord, isServerRequestError, serverRequest, writeStoredSession } from "./serverConnection"; + +interface CreateNotificationInput { + type: WebNotificationType; + title: string; + description?: string; + targetType?: string; + targetId?: string; + metadata?: Record; +} + +const NOTIFICATION_VIEW_BY_TARGET: Record = { + task: "workbench", + generation_task: "workbench", + community_case: "login", + asset: "assets", + project: "canvas", + draft: "workbench", +}; + +let notificationsRouteMissing = false; +let notificationsUnauthorized = false; + +function toStringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") return value.trim() || fallback; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return fallback; +} + +function normalizeType(value: unknown): WebNotificationType { + const type = toStringValue(value); + if ( + type === "task_completed" || + type === "task_failed" || + type === "review_pending" || + type === "review_passed" || + type === "review_rejected" || + type === "credits_low" || + type === "session_expired" + ) { + return type; + } + return "info"; +} + +function normalizeNotification(raw: unknown): WebNotification { + const item = isRecord(raw) ? raw : {}; + const targetType = toStringValue(item.targetType ?? item.target_type) || null; + const targetId = toStringValue(item.targetId ?? item.target_id) || undefined; + const readAt = toStringValue(item.readAt ?? item.read_at) || null; + return { + id: toStringValue(item.id, `notice-${Date.now()}`), + type: normalizeType(item.type), + title: toStringValue(item.title, "通知"), + description: toStringValue(item.description), + createdAt: toStringValue(item.createdAt ?? item.created_at, new Date().toISOString()), + isRead: Boolean(item.isRead ?? item.is_read ?? readAt), + targetType, + targetId, + targetView: targetType ? NOTIFICATION_VIEW_BY_TARGET[targetType] : undefined, + readAt, + metadata: isRecord(item.metadata) ? item.metadata : {}, + }; +} + +function extractNotifications(payload: unknown): WebNotification[] { + if (Array.isArray(payload)) return payload.map(normalizeNotification); + if (!isRecord(payload)) return []; + const rows = payload.notifications ?? payload.items; + return Array.isArray(rows) ? rows.map(normalizeNotification) : []; +} + +function isUnauthorized(error: unknown): boolean { + return isServerRequestError(error) && (error.status === 401 || error.status === 403); +} + +function handleUnauthorizedNotifications(): void { + notificationsUnauthorized = true; + writeStoredSession(null); +} + +export const notificationClient = { + async list(): Promise { + if (notificationsRouteMissing || notificationsUnauthorized) return []; + try { + return extractNotifications(await serverRequest("notifications")); + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + notificationsRouteMissing = true; + return []; + } + if (isUnauthorized(error)) { + handleUnauthorizedNotifications(); + return []; + } + throw error; + } + }, + + async create(input: CreateNotificationInput): Promise { + if (notificationsRouteMissing || notificationsUnauthorized) { + return normalizeNotification({ + ...input, + id: `local-notice-${Date.now()}`, + createdAt: new Date().toISOString(), + }); + } + try { + const payload = await serverRequest<{ notification: unknown }>("notifications", { + method: "POST", + body: input, + }); + return normalizeNotification(payload.notification ?? payload); + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + notificationsRouteMissing = true; + return normalizeNotification({ + ...input, + id: `local-notice-${Date.now()}`, + createdAt: new Date().toISOString(), + }); + } + if (isUnauthorized(error)) { + handleUnauthorizedNotifications(); + return normalizeNotification({ + ...input, + id: `local-notice-${Date.now()}`, + createdAt: new Date().toISOString(), + }); + } + throw error; + } + }, + + async markRead(id: string, isRead = true): Promise { + if (notificationsRouteMissing || notificationsUnauthorized) return; + try { + await serverRequest(`notifications/${id}/read`, { + method: "PATCH", + body: { isRead }, + }); + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + notificationsRouteMissing = true; + return; + } + if (isUnauthorized(error)) { + handleUnauthorizedNotifications(); + return; + } + throw error; + } + }, + + async markAllRead(): Promise { + if (notificationsRouteMissing || notificationsUnauthorized) return; + try { + await serverRequest("notifications/read-all", { method: "POST" }); + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + notificationsRouteMissing = true; + return; + } + if (isUnauthorized(error)) { + handleUnauthorizedNotifications(); + return; + } + throw error; + } + }, +}; diff --git a/src/api/projectTaskClient.ts b/src/api/projectTaskClient.ts new file mode 100644 index 0000000..efe56e3 --- /dev/null +++ b/src/api/projectTaskClient.ts @@ -0,0 +1,141 @@ +import type { WebGenerationPreviewTask } from "../types"; +import { isRecord, serverRequest } from "./serverConnection"; + +type ServerTaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled"; + +interface ServerProjectTask { + id: string; + projectId?: string | null; + clientQueueId?: string | null; + type: "image" | "video"; + status: ServerTaskStatus; + params?: Record; + resultUrl?: string | null; + progress?: number; + error?: string | null; + createdAt?: string; + updatedAt?: string; +} + +export interface ProjectTaskUpsertInput { + clientQueueId: string; + type: "image" | "video"; + status: ServerTaskStatus; + params?: Record; + providerTaskId?: string | null; + resultUrl?: string | null; + progress?: number; + error?: string | null; + dedupeKey?: string | null; + sourceDeviceId?: string | null; + createdAt?: string | null; + completedAt?: string | null; +} + +function toStringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") return value.trim() || fallback; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return fallback; +} + +function toNumber(value: unknown, fallback = 0): number { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : fallback; +} + +function normalizeStatus(value: unknown): WebGenerationPreviewTask["status"] { + const status = toStringValue(value); + if (status === "running" || status === "completed" || status === "failed") return status; + if (status === "cancelled") return "failed"; + return "queued"; +} + +function normalizeTask(raw: unknown): ServerProjectTask | null { + if (!isRecord(raw)) return null; + const type = toStringValue(raw.type); + if (type !== "image" && type !== "video") return null; + + return { + id: toStringValue(raw.id), + projectId: toStringValue(raw.projectId ?? raw.project_id) || null, + clientQueueId: toStringValue(raw.clientQueueId ?? raw.client_queue_id) || null, + type, + status: toStringValue(raw.status, "pending") as ServerTaskStatus, + params: isRecord(raw.params) ? raw.params : {}, + resultUrl: toStringValue(raw.resultUrl ?? raw.result_url) || null, + progress: toNumber(raw.progress), + error: toStringValue(raw.error) || null, + createdAt: toStringValue(raw.createdAt ?? raw.created_at), + updatedAt: toStringValue(raw.updatedAt ?? raw.updated_at), + }; +} + +function extractTasks(payload: unknown): ServerProjectTask[] { + if (Array.isArray(payload)) return payload.map(normalizeTask).filter(Boolean) as ServerProjectTask[]; + if (!isRecord(payload)) return []; + const rows = payload.tasks ?? payload.items; + return Array.isArray(rows) ? (rows.map(normalizeTask).filter(Boolean) as ServerProjectTask[]) : []; +} + +function taskTitle(task: ServerProjectTask): string { + const prompt = toStringValue(task.params?.prompt); + if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt; + return task.type === "video" ? "视频生成任务" : "图像生成任务"; +} + +function toPreviewTask(task: ServerProjectTask): WebGenerationPreviewTask { + return { + id: task.clientQueueId || task.id, + title: taskTitle(task), + type: task.type, + status: normalizeStatus(task.status), + progress: Math.max(0, Math.min(100, Math.trunc(task.progress || 0))), + prompt: toStringValue(task.params?.prompt, taskTitle(task)), + createdAt: task.createdAt || task.updatedAt || "", + projectId: task.projectId || undefined, + outputUrl: task.resultUrl || undefined, + source: "server", + errorMessage: task.error || undefined, + }; +} + +async function listProjectTasks(projectId: string): Promise { + const payload = await serverRequest(`projects/${encodeURIComponent(projectId)}/tasks`); + return extractTasks(payload).map(toPreviewTask); +} + +export const projectTaskClient = { + async list(projectId: string): Promise { + return listProjectTasks(projectId); + }, + + async listForProjects(projectIds: string[]): Promise { + const uniqueIds = Array.from(new Set(projectIds.map((id) => id.trim()).filter(Boolean))); + const results = await Promise.all(uniqueIds.map((id) => listProjectTasks(id))); + return results.flat(); + }, + + async upsert(projectId: string, input: ProjectTaskUpsertInput): Promise { + const payload = await serverRequest<{ task: unknown }>( + `projects/${encodeURIComponent(projectId)}/tasks/upsert`, + { + method: "POST", + body: input, + }, + ); + const task = normalizeTask(payload.task ?? payload); + if (!task) throw new Error("Project task response did not include a task"); + return toPreviewTask(task); + }, + + async batchUpsert(projectId: string, tasks: ProjectTaskUpsertInput[]): Promise { + const payload = await serverRequest<{ tasks: unknown }>( + `projects/${encodeURIComponent(projectId)}/tasks/batch-upsert`, + { + method: "POST", + body: { tasks }, + }, + ); + return extractTasks(payload).map(toPreviewTask); + }, +}; diff --git a/src/api/providerHealthClient.ts b/src/api/providerHealthClient.ts new file mode 100644 index 0000000..11c48b4 --- /dev/null +++ b/src/api/providerHealthClient.ts @@ -0,0 +1,44 @@ +import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; + +export interface ProviderHealthEntry { + status: string; + lastCheck: string | null; + lastError: string | null; + details: Record | null; +} + +export interface CallStatRow { + provider: string; + model: string; + status: string; + count: string; + avg_ms: string | null; + total_cost: string | null; +} + +export interface KeyStatRow { + provider: string; + total_keys: string; + active_keys: string; + current_load: string; +} + +export interface ProviderHealthResponse { + health: Record; + callStats: CallStatRow[]; + keyStats: KeyStatRow[]; + checkedAt: string; +} + +export const providerHealthClient = { + async getStatus(): Promise { + const res = await fetch(buildApiUrl("admin/providers/status"), { + method: "GET", + headers: buildAuthHeaders(), + }); + if (!res.ok) { + throw new Error(`Provider health request failed (${res.status})`); + } + return res.json() as Promise; + }, +}; \ No newline at end of file diff --git a/src/api/referenceUploadService.ts b/src/api/referenceUploadService.ts new file mode 100644 index 0000000..89f60ff --- /dev/null +++ b/src/api/referenceUploadService.ts @@ -0,0 +1,84 @@ +import { aiGenerationClient } from "./aiGenerationClient"; + +interface UploadEntry { + promise: Promise; + url: string | null; + status: "pending" | "done" | "failed"; +} + +const uploadCache = new Map(); + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("文件读取失败")); + reader.readAsDataURL(file); + }); +} + +function buildCacheKey(file: File, fingerprint?: string): string { + if (fingerprint) return fingerprint; + return `${file.name}__${file.size}__${file.lastModified}`; +} + +export function preUploadReference( + file: File, + name: string, + fingerprint?: string, +): Promise { + const key = buildCacheKey(file, fingerprint); + const cached = uploadCache.get(key); + if (cached) return cached.promise; + + const scope = file.type.startsWith("video/") ? "reference-video" : "reference-image"; + + const promise = (async () => { + try { + const dataUrl = await fileToDataUrl(file); + const uploaded = await aiGenerationClient.uploadAsset({ + dataUrl, + name, + mimeType: file.type, + scope, + }); + const entry = uploadCache.get(key); + if (entry) { + entry.url = uploaded.url; + entry.status = "done"; + } + return uploaded.url; + } catch (error) { + const entry = uploadCache.get(key); + if (entry) entry.status = "failed"; + console.warn("[referenceUpload] pre-upload failed:", error); + return null; + } + })(); + + uploadCache.set(key, { promise, url: null, status: "pending" }); + return promise; +} + +export function getPreUploadedUrl( + file: File, + fingerprint?: string, +): string | null { + const key = buildCacheKey(file, fingerprint); + return uploadCache.get(key)?.url ?? null; +} + +export async function resolvePreUploadedUrl( + file: File, + name: string, + fingerprint?: string, +): Promise { + const key = buildCacheKey(file, fingerprint); + const cached = uploadCache.get(key); + if (cached) return cached.promise; + return preUploadReference(file, name, fingerprint); +} + +export function clearUploadCache(): void { + uploadCache.clear(); +} diff --git a/src/api/reportClient.ts b/src/api/reportClient.ts new file mode 100644 index 0000000..12a02bb --- /dev/null +++ b/src/api/reportClient.ts @@ -0,0 +1,71 @@ +import { serverRequest } from "./serverConnection"; + +export interface ReportInput { + reportType: string; + targetType?: string; + targetId?: string; + contactName?: string; + contactEmail?: string; + contactPhone?: string; + title: string; + description: string; + pageUrl?: string; +} + +export interface AdminReportItem { + id: number; + userId?: number | null; + username?: string | null; + reportType?: string | null; + targetType?: string | null; + targetId?: string | null; + contactName?: string | null; + contactEmail?: string | null; + contactPhone?: string | null; + title: string; + description: string; + pageUrl?: string | null; + status: string; + ipAddress?: string | null; + userAgent?: string | null; + createdAt: string; + updatedAt?: string | null; +} + +function normalizeReport(raw: unknown): AdminReportItem { + const item = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; + return { + id: Number(item.id) || 0, + userId: item.userId == null ? null : Number(item.userId), + username: typeof item.username === "string" ? item.username : null, + reportType: typeof item.reportType === "string" ? item.reportType : null, + targetType: typeof item.targetType === "string" ? item.targetType : null, + targetId: typeof item.targetId === "string" ? item.targetId : null, + contactName: typeof item.contactName === "string" ? item.contactName : null, + contactEmail: typeof item.contactEmail === "string" ? item.contactEmail : null, + contactPhone: typeof item.contactPhone === "string" ? item.contactPhone : null, + title: typeof item.title === "string" && item.title.trim() ? item.title : "未命名举报", + description: typeof item.description === "string" ? item.description : "", + pageUrl: typeof item.pageUrl === "string" ? item.pageUrl : null, + status: typeof item.status === "string" && item.status.trim() ? item.status : "pending", + ipAddress: typeof item.ipAddress === "string" ? item.ipAddress : null, + userAgent: typeof item.userAgent === "string" ? item.userAgent : null, + createdAt: typeof item.createdAt === "string" ? item.createdAt : "", + updatedAt: typeof item.updatedAt === "string" ? item.updatedAt : null, + }; +} + +export const reportClient = { + async submit(input: ReportInput): Promise<{ id: number; status: string; createdAt: string }> { + const payload = await serverRequest<{ report: { id: number; status: string; createdAt: string } }>("reports", { + method: "POST", + body: input, + }); + return payload.report; + }, + + async listAdminReports(): Promise { + const payload = await serverRequest<{ reports?: unknown[] }>("admin/reports"); + return Array.isArray(payload.reports) ? payload.reports.map(normalizeReport) : []; + }, +}; diff --git a/src/api/scriptEvalClient.ts b/src/api/scriptEvalClient.ts new file mode 100644 index 0000000..fe64beb --- /dev/null +++ b/src/api/scriptEvalClient.ts @@ -0,0 +1,108 @@ +import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; + +export interface ScriptEvalResult { + totalScore: number; + grade: string; + dimensionScores: Record; + summary: string; + issues: string[]; + highlights: string[]; + suggestions: string[]; +} + +const EVAL_SYSTEM_PROMPT = `你是一位专业的剧本评测专家。请对用户提供的剧本进行六维评分分析,并以严格的 JSON 格式返回结果。 + +六个评分维度: +1. hook(钩子设计,满分20):开篇吸引力、悬念设置、黄金三秒法则 +2. character(角色塑造,满分15):人物立体度、动机合理性、弧光设计 +3. plot(剧情结构,满分20):起承转合、节奏把控、冲突设计 +4. dialogue(台词对白,满分15):语言质感、角色差异化、潜台词 +5. visual(画面表现,满分15):镜头感、空间层次、视觉冲击力 +6. content(内容深度,满分15):主题表达、情感共鸣、思想内核 + +请严格按以下 JSON 格式返回(不要包含任何其他文字): +{ + "dimensionScores": { "hook": 数字, "character": 数字, "plot": 数字, "dialogue": 数字, "visual": 数字, "content": 数字 }, + "summary": "一句话总结评价", + "issues": ["问题1", "问题2", ...], + "highlights": ["亮点1", "亮点2", ...], + "suggestions": ["建议1", "建议2", ...] +}`; + +const DIMENSION_WEIGHTS: Record = { + hook: { maxScore: 20, weight: 0.2 }, + character: { maxScore: 15, weight: 0.15 }, + plot: { maxScore: 20, weight: 0.2 }, + dialogue: { maxScore: 15, weight: 0.15 }, + visual: { maxScore: 15, weight: 0.15 }, + content: { maxScore: 15, weight: 0.15 }, +}; + +function computeTotalAndGrade(scores: Record): { totalScore: number; grade: string } { + const totalScore = Math.round( + Object.entries(DIMENSION_WEIGHTS).reduce((sum, [key, dim]) => { + const score = Math.max(0, Math.min(dim.maxScore, scores[key] ?? 0)); + return sum + (score / dim.maxScore) * 100 * dim.weight; + }, 0), + ); + const grade = totalScore >= 90 ? "S" : totalScore >= 80 ? "A" : totalScore >= 70 ? "B" : totalScore >= 60 ? "C" : "D"; + return { totalScore, grade }; +} + +function extractJson(text: string): unknown { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/); + const raw = fenced ? fenced[1].trim() : text.trim(); + return JSON.parse(raw); +} + +export async function evaluateScript(script: string, signal?: AbortSignal): Promise { + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ + model: "qwen3.7-max", + messages: [ + { role: "system", content: EVAL_SYSTEM_PROMPT }, + { role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` }, + ], + stream: false, + temperature: 0.3, + }), + signal, + }); + + if (!res.ok) { + throw new Error(`评测请求失败 (${res.status})`); + } + + const payload = await res.json(); + const content: string = payload?.choices?.[0]?.message?.content + ?? payload?.result?.content + ?? payload?.content + ?? payload?.text + ?? (typeof payload === "string" ? payload : ""); + + if (!content) throw new Error("模型未返回有效内容"); + + const parsed = extractJson(content) as Record; + const dimensionScores: Record = {}; + const rawScores = parsed.dimensionScores as Record | undefined; + if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常"); + + for (const key of Object.keys(DIMENSION_WEIGHTS)) { + const val = Number(rawScores[key] ?? 0); + dimensionScores[key] = Math.max(0, Math.min(DIMENSION_WEIGHTS[key].maxScore, val)); + } + + const { totalScore, grade } = computeTotalAndGrade(dimensionScores); + + return { + totalScore, + grade, + dimensionScores, + summary: String(parsed.summary || ""), + issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [], + highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [], + suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.map(String) : [], + }; +} diff --git a/src/api/serverConnection.ts b/src/api/serverConnection.ts new file mode 100644 index 0000000..bf8d9be --- /dev/null +++ b/src/api/serverConnection.ts @@ -0,0 +1,380 @@ +import type { WebUserSession } from "../types"; + +export const DEFAULT_SERVER_BASE_URL = import.meta.env.VITE_API_BASE_URL || ""; +export const SERVER_SESSION_STORAGE_KEY = "omniai-web-session"; +export const SERVER_SESSION_REPLACED_EVENT = "omniai:session-replaced"; +export const SERVER_SESSION_EXPIRED_EVENT = "omniai:session-expired"; + +export type ServerConnectionState = "checking" | "connected" | "degraded"; + +export interface ServerConnectionHealth { + state: ServerConnectionState; + baseUrl: string; + checkedAt: string; + errorMessage?: string; +} + +export interface ServerRequestOptions { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + body?: unknown; + token?: string; + headers?: Record; + raw?: boolean; + signal?: AbortSignal; + /** Per-request timeout in ms. Defaults to DEFAULT_REQUEST_TIMEOUT_MS. Pass 0 to disable. */ + timeoutMs?: number; +} + +export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; + +export interface ServerSessionReplacedDetail { + status?: number; + code?: string; + message: string; +} + +export class ServerRequestError extends Error { + status?: number; + code?: string; + payload?: unknown; + + constructor(message: string, status?: number, payload?: unknown) { + super(message); + this.name = "ServerRequestError"; + this.status = status; + this.payload = payload; + + if (isRecord(payload) && typeof payload.code === "string") { + this.code = payload.code; + } + } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function compactMessage(value: string): string { + return value.replace(/\s+/g, " ").trim().slice(0, 240); +} + +export function getServerBaseUrl(): string { + const envBaseUrl = String( + import.meta.env.VITE_KEY_SERVER_URL || + import.meta.env.VITE_SERVER_BASE_URL || + import.meta.env.VITE_API_BASE_URL || + "", + ).trim(); + const shouldUseSameOriginApi = + typeof window !== "undefined" && + (window.location.protocol === "https:" || + window.location.hostname === "omniai.net.cn" || + window.location.hostname === "www.omniai.net.cn"); + const rawBaseUrl = envBaseUrl || (shouldUseSameOriginApi ? "" : DEFAULT_SERVER_BASE_URL); + if (!rawBaseUrl || rawBaseUrl.replace(/\/+$/, "").toLowerCase() === "/api") { + return ""; + } + return rawBaseUrl.replace(/\/+$/, "").replace(/\/api$/i, ""); +} + +export function buildApiUrl(path: string): string { + const cleanPath = path.replace(/^\/+/, ""); + const baseUrl = getServerBaseUrl(); + if (!baseUrl) return `/api/${cleanPath}`; + + try { + return new URL(`api/${cleanPath}`, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString(); + } catch { + return `${baseUrl}/api/${cleanPath}`; + } +} + +export function canUseSessionStorage(): boolean { + return typeof window !== "undefined" && typeof window.sessionStorage !== "undefined"; +} + +function canUseLocalStorage(): boolean { + return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; +} + +function parseStoredSession(raw: string | null): WebUserSession | null { + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as unknown; + return isRecord(parsed) && typeof parsed.token === "string" && isRecord(parsed.user) + ? (parsed as unknown as WebUserSession) + : null; + } catch { + return null; + } +} + +export function readStoredSession(): WebUserSession | null { + let fallbackSession: WebUserSession | null = null; + + try { + if (canUseLocalStorage()) { + const localSession = parseStoredSession(window.localStorage.getItem(SERVER_SESSION_STORAGE_KEY)); + if (localSession) return localSession; + } + } catch { + // Fall through to the legacy session-scoped copy. + } + + try { + if (canUseSessionStorage()) { + fallbackSession = parseStoredSession(window.sessionStorage.getItem(SERVER_SESSION_STORAGE_KEY)); + } + } catch { + fallbackSession = null; + } + + if (fallbackSession && canUseLocalStorage()) { + try { + window.localStorage.setItem(SERVER_SESSION_STORAGE_KEY, JSON.stringify(fallbackSession)); + } catch { + // Migrating the legacy session is best-effort. + } + } + + return fallbackSession; +} + +export function writeStoredSession(session: WebUserSession | null): void { + try { + if (canUseLocalStorage()) { + if (session) { + window.localStorage.setItem(SERVER_SESSION_STORAGE_KEY, JSON.stringify(session)); + } else { + window.localStorage.removeItem(SERVER_SESSION_STORAGE_KEY); + } + } + } catch { + // Browser persistence is a convenience layer, not a hard dependency. + } + + try { + if (canUseSessionStorage()) { + if (session) { + window.sessionStorage.setItem(SERVER_SESSION_STORAGE_KEY, JSON.stringify(session)); + } else { + window.sessionStorage.removeItem(SERVER_SESSION_STORAGE_KEY); + } + } + } catch { + // Keep the local copy as the primary persistence layer. + } +} + +export function getStoredToken(): string | null { + return readStoredSession()?.token ?? null; +} + +export function buildAuthHeaders(tokenOverride?: string, extraHeaders?: Record): Record { + const token = (tokenOverride ?? getStoredToken() ?? "").trim(); + return { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(extraHeaders || {}), + }; +} + +export function parseResponseBody(text: string): unknown { + const trimmed = text.trim(); + if (!trimmed) return null; + + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } +} + +export function unwrapApiPayload(payload: unknown): unknown { + if (!isRecord(payload)) return payload; + + const nested = payload.data ?? payload.result ?? payload.payload; + return nested === undefined ? payload : nested; +} + +export function getPayloadMessage(payload: unknown): string | null { + if (typeof payload === "string") { + const message = compactMessage(payload); + if (/^]|]|]|^<\?xml|]/i.test(message)) { + return null; + } + return message || null; + } + if (!isRecord(payload)) return null; + + const message = payload.error ?? payload.message ?? payload.errorMessage; + if (typeof message !== "string") return null; + + const compacted = compactMessage(message); + if (/^]|]|]|^<\?xml|]/i.test(compacted)) { + return null; + } + return compacted || null; +} + +function getPayloadCode(payload: unknown): string | undefined { + return isRecord(payload) && typeof payload.code === "string" ? payload.code : undefined; +} + +let lastSessionReplacedEventAt = 0; + +let lastSessionExpiredEventAt = 0; + +function notifySessionExpired(status: number, response: Response, payload: unknown): void { + if (status !== 401 && status !== 403) return; + if (typeof window === "undefined") return; + // Auth endpoints (login/register/me) surface their own errors — a wrong + // password must not be mistaken for an expired session. + if (/\/auth\//i.test(response.url)) return; + // SESSION_REPLACED has its own dedicated handling/modal. + if (getPayloadCode(payload) === "SESSION_REPLACED") return; + + const now = Date.now(); + if (now - lastSessionExpiredEventAt < 1500) return; + lastSessionExpiredEventAt = now; + window.dispatchEvent( + new CustomEvent(SERVER_SESSION_EXPIRED_EVENT, { + detail: { status, code: getPayloadCode(payload), message: "登录状态已失效,请重新登录。" }, + }), + ); +} + +function notifySessionReplaced(status: number, payload: unknown, fallbackMessage: string): void { + const code = getPayloadCode(payload); + const message = getPayloadMessage(payload) || fallbackMessage || "您已在别处登录"; + const isSessionReplaced = code === "SESSION_REPLACED" || message.includes("您已在别处登录"); + if (!isSessionReplaced || typeof window === "undefined") return; + + const now = Date.now(); + if (now - lastSessionReplacedEventAt < 1500) return; + lastSessionReplacedEventAt = now; + window.dispatchEvent( + new CustomEvent(SERVER_SESSION_REPLACED_EVENT, { + detail: { status, code, message }, + }), + ); +} + +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error || "Unknown API error"); +} + +export function isServerRequestError(error: unknown): error is ServerRequestError { + return error instanceof ServerRequestError; +} + +export async function readJsonResponse(response: Response, fallbackMessage: string): Promise { + const payload = parseResponseBody(await response.text().catch(() => "")); + if (!response.ok) { + const message = + getPayloadMessage(payload) || + compactMessage(response.statusText) || + `${fallbackMessage} (${response.status})`; + notifySessionReplaced(response.status, payload, message); + notifySessionExpired(response.status, response, payload); + throw new ServerRequestError(message, response.status, payload); + } + + return unwrapApiPayload(payload) as T; +} + +export async function throwResponseError(response: Response, fallbackMessage: string): Promise { + const payload = parseResponseBody(await response.text().catch(() => "")); + const message = + getPayloadMessage(payload) || + compactMessage(response.statusText) || + `${fallbackMessage} (${response.status})`; + notifySessionReplaced(response.status, payload, message); + notifySessionExpired(response.status, response, payload); + throw new ServerRequestError(message, response.status, payload); +} + +function isRetryable(error: unknown): boolean { + if (error instanceof ServerRequestError) { + const s = error.status; + return s === 429 || (s !== undefined && s >= 500); + } + return error instanceof TypeError || (error instanceof DOMException && error.name !== "AbortError"); +} + +function getRetryDelay(attempt: number, error: unknown): number { + if (error instanceof ServerRequestError && error.status === 429) { + return Math.min(5000, 2000 * attempt); + } + return Math.min(4000, 300 * 2 ** attempt); +} + +const MAX_RETRIES = 2; + +export async function serverRequest(path: string, options?: ServerRequestOptions): Promise { + let lastError: unknown; + const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const controller = timeoutMs > 0 ? new AbortController() : null; + const timeoutId = + controller && typeof window !== "undefined" + ? window.setTimeout(() => controller.abort(new DOMException("Request timed out", "TimeoutError")), timeoutMs) + : null; + const onCallerAbort = () => controller?.abort((options?.signal as AbortSignal)?.reason); + if (controller && options?.signal) { + if (options.signal.aborted) controller.abort(options.signal.reason); + else options.signal.addEventListener("abort", onCallerAbort, { once: true }); + } + + try { + const headers = buildAuthHeaders(options?.token, options?.headers); + const response = await fetch(buildApiUrl(path), { + method: options?.method || "GET", + headers, + body: options?.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller ? controller.signal : options?.signal, + }); + + const payload = await readJsonResponse(response, "Request failed"); + return (options?.raw ? payload : unwrapApiPayload(payload)) as T; + } catch (error) { + lastError = error; + if (attempt < MAX_RETRIES && isRetryable(error) && !options?.signal?.aborted) { + await new Promise((r) => setTimeout(r, getRetryDelay(attempt, error))); + continue; + } + throw error; + } finally { + if (timeoutId !== null) window.clearTimeout(timeoutId); + options?.signal?.removeEventListener("abort", onCallerAbort); + } + } + + throw lastError; +} + +export async function checkServerHealth(timeoutMs = 4500): Promise { + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); + + try { + await serverRequest("/health", { signal: controller.signal }); + return { + state: "connected", + baseUrl: getServerBaseUrl(), + checkedAt: new Date().toISOString(), + }; + } catch (error) { + return { + state: "degraded", + baseUrl: getServerBaseUrl(), + checkedAt: new Date().toISOString(), + errorMessage: getErrorMessage(error), + }; + } finally { + window.clearTimeout(timeoutId); + } +} diff --git a/src/api/taskSubscription.ts b/src/api/taskSubscription.ts new file mode 100644 index 0000000..7c1fa33 --- /dev/null +++ b/src/api/taskSubscription.ts @@ -0,0 +1,95 @@ +import { aiGenerationClient } from "./aiGenerationClient"; + +export interface TaskProgressEvent { + taskId: string; + status: string; + progress: number; + resultUrl?: string | null; + error?: string | null; +} + +export interface WaitForTaskOptions { + onProgress?: (event: TaskProgressEvent) => void; + abortRef?: { current: boolean }; + timeoutMs?: number; +} + +const POLL_INTERVAL = 3000; +const DEFAULT_TIMEOUT = 30 * 60 * 1000; + +export function waitForTask( + taskId: string, + options: WaitForTaskOptions = {}, +): Promise { + const { onProgress, abortRef, timeoutMs = DEFAULT_TIMEOUT } = options; + + return new Promise((resolve, reject) => { + let settled = false; + let cleanup: (() => void) | null = null; + let timeoutId: ReturnType | null = null; + let sseConnected = false; + let fallbackTimerId: ReturnType | null = null; + + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + if (timeoutId) clearTimeout(timeoutId); + if (fallbackTimerId) clearTimeout(fallbackTimerId); + if (cleanup) cleanup(); + fn(); + }; + + timeoutId = setTimeout( + () => settle(() => reject(new Error("等待任务结果超时,请稍后在任务历史中查看"))), + timeoutMs, + ); + + const handleUpdate = (event: TaskProgressEvent) => { + if (settled) return; + if (abortRef?.current) { + settle(() => resolve(null)); + return; + } + onProgress?.(event); + if (event.status === "completed") { + settle(() => resolve(event.resultUrl || null)); + } else if (event.status === "failed" || event.status === "cancelled") { + settle(() => reject(new Error(event.error || "任务失败"))); + } + }; + + // Try SSE first + cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate); + sseConnected = true; + + // Fallback: if SSE doesn't deliver any event within 5s, switch to polling + fallbackTimerId = setTimeout(() => { + if (settled || !sseConnected) return; + if (cleanup) cleanup(); + startPolling(); + }, 5000); + + function startPolling() { + const poll = async () => { + while (!settled) { + if (abortRef?.current) { settle(() => resolve(null)); return; } + await new Promise((r) => setTimeout(r, POLL_INTERVAL)); + if (settled || abortRef?.current) return; + try { + const task = await aiGenerationClient.getTaskStatus(taskId); + handleUpdate({ + taskId, + status: task.status, + progress: task.progress || 0, + resultUrl: task.resultUrl, + error: task.error, + }); + } catch (e) { + if (!settled) settle(() => reject(e)); + } + } + }; + poll(); + } + }); +} diff --git a/src/api/uploadWithProgress.ts b/src/api/uploadWithProgress.ts new file mode 100644 index 0000000..149420a --- /dev/null +++ b/src/api/uploadWithProgress.ts @@ -0,0 +1,57 @@ +import { buildApiUrl, buildAuthHeaders, readJsonResponse, throwResponseError } from "./serverConnection"; + +export interface UploadProgressOptions { + onProgress?: (percent: number) => void; + signal?: AbortSignal; +} + +export async function uploadAssetWithProgress( + input: { dataUrl: string; name?: string; mimeType?: string; scope?: string }, + options?: UploadProgressOptions, +): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { + const { onProgress, signal } = options || {}; + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const url = buildApiUrl("oss/upload"); + const headers = buildAuthHeaders(); + + xhr.open("POST", url); + Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v)); + + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable && onProgress) { + onProgress(Math.round((e.loaded / e.total) * 100)); + } + }); + + xhr.addEventListener("load", async () => { + const fakeResponse = new Response(xhr.responseText, { + status: xhr.status, + statusText: xhr.statusText, + headers: { "Content-Type": "application/json" }, + }); + try { + if (!fakeResponse.ok) { + await throwResponseError(fakeResponse, "Asset upload failed"); + } + const result = await readJsonResponse<{ url: string; ossKey?: string }>( + fakeResponse.clone(), + "Asset upload response failed", + ); + resolve(result); + } catch (e) { + reject(e); + } + }); + + xhr.addEventListener("error", () => reject(new Error("上传失败,请检查网络连接"))); + xhr.addEventListener("abort", () => reject(new Error("上传已取消"))); + + if (signal) { + signal.addEventListener("abort", () => xhr.abort()); + } + + xhr.send(JSON.stringify(input)); + }); +} diff --git a/src/api/webGenerationGateway.ts b/src/api/webGenerationGateway.ts new file mode 100644 index 0000000..2944a15 --- /dev/null +++ b/src/api/webGenerationGateway.ts @@ -0,0 +1,110 @@ +import type { WebGenerationPreviewTask } from "../types"; +import { aiGenerationClient } from "./aiGenerationClient"; +import { resolveVideoRequestModel } from "../utils/resolveVideoModel"; +import { ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../utils/enterpriseVideoPolicy"; + +function formatPreviewTaskTimestamp(date = new Date()): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day} ${hours}:${minutes}`; +} + +export interface CreatePreviewTaskInput { + title: string; + type: WebGenerationPreviewTask["type"]; + prompt: string; + params?: { + existingTaskId?: string; + projectId?: string; + conversationId?: number; + model?: string; + ratio?: string; + quality?: string; + resolution?: string; + gridMode?: string; + duration?: number; + frameMode?: string; + referenceUrls?: string[]; + audioUrl?: string; + muted?: boolean; + hasReferenceVideo?: boolean; + }; +} + +export const webGenerationGateway = { + async createPreviewTask(input: CreatePreviewTaskInput): Promise { + const { type, params } = input; + const prompt = input.prompt.trim(); + const title = input.title.trim() || "未命名任务"; + const createdAt = formatPreviewTaskTimestamp(); + + try { + let taskId: string; + + if (params?.existingTaskId) { + taskId = params.existingTaskId; + } else if (type === "image") { + const result = await aiGenerationClient.createImageTask({ + projectId: params?.projectId, + conversationId: params?.conversationId, + model: params?.model || "nano-banana-pro", + prompt, + ratio: params?.ratio || "16:9", + quality: params?.quality || "1K", + gridMode: params?.gridMode || "single", + referenceUrls: params?.referenceUrls, + }); + taskId = result.taskId; + } else if (type === "video") { + const refs = params?.referenceUrls; + let model: string = params?.model || ENTERPRISE_DEFAULT_VIDEO_MODEL; + model = resolveVideoRequestModel({ model, referenceUrls: refs }); + const result = await aiGenerationClient.createVideoTask({ + projectId: params?.projectId, + conversationId: params?.conversationId, + model, + prompt, + ratio: params?.ratio || "16:9", + duration: params?.duration || 5, + quality: params?.quality || params?.resolution || "1080P", + resolution: params?.resolution || params?.quality || "1080P", + frameMode: params?.frameMode || "start-end", + referenceUrls: params?.referenceUrls, + audioUrl: params?.audioUrl, + muted: params?.muted ?? false, + hasReferenceVideo: params?.hasReferenceVideo ?? false, + }); + taskId = result.taskId; + } else { + taskId = `web-task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + } + + return { + id: taskId, + title, + type, + status: "queued", + progress: 5, + prompt, + createdAt, + source: "server", + projectId: params?.projectId, + }; + } catch (err) { + return { + id: `web-task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + title, + type, + status: "failed", + progress: 0, + prompt, + createdAt, + source: "server", + errorMessage: err instanceof Error ? err.message : "请求失败", + }; + } + }, +}; diff --git a/src/assets/ecommerce-carousel-generated.png b/src/assets/ecommerce-carousel-generated.png new file mode 100644 index 0000000..e5d107d Binary files /dev/null and b/src/assets/ecommerce-carousel-generated.png differ diff --git a/src/assets/ecommerce-hero-carousel/slide-1.webp b/src/assets/ecommerce-hero-carousel/slide-1.webp new file mode 100644 index 0000000..3af02be Binary files /dev/null and b/src/assets/ecommerce-hero-carousel/slide-1.webp differ diff --git a/src/assets/ecommerce-hero-carousel/slide-2.webp b/src/assets/ecommerce-hero-carousel/slide-2.webp new file mode 100644 index 0000000..869d351 Binary files /dev/null and b/src/assets/ecommerce-hero-carousel/slide-2.webp differ diff --git a/src/assets/ecommerce-hero-carousel/slide-3.webp b/src/assets/ecommerce-hero-carousel/slide-3.webp new file mode 100644 index 0000000..9f9f010 Binary files /dev/null and b/src/assets/ecommerce-hero-carousel/slide-3.webp differ diff --git a/src/assets/ecommerce-hero-carousel/slide-4.png b/src/assets/ecommerce-hero-carousel/slide-4.png new file mode 100644 index 0000000..192dd55 Binary files /dev/null and b/src/assets/ecommerce-hero-carousel/slide-4.png differ diff --git a/src/assets/ecommerce-hero-carousel/slide-4.webp b/src/assets/ecommerce-hero-carousel/slide-4.webp new file mode 100644 index 0000000..78eb0b7 Binary files /dev/null and b/src/assets/ecommerce-hero-carousel/slide-4.webp differ diff --git a/src/assets/ecommerce-hero-carousel/slide-5.png b/src/assets/ecommerce-hero-carousel/slide-5.png new file mode 100644 index 0000000..a9c55ac Binary files /dev/null and b/src/assets/ecommerce-hero-carousel/slide-5.png differ diff --git a/src/assets/ecommerce-hero-carousel/slide-5.webp b/src/assets/ecommerce-hero-carousel/slide-5.webp new file mode 100644 index 0000000..03cbff6 Binary files /dev/null and b/src/assets/ecommerce-hero-carousel/slide-5.webp differ diff --git a/src/assets/home-features/feature-ecommerce.jpg b/src/assets/home-features/feature-ecommerce.jpg new file mode 100644 index 0000000..70ce725 Binary files /dev/null and b/src/assets/home-features/feature-ecommerce.jpg differ diff --git a/src/assets/home-features/feature-script.jpg b/src/assets/home-features/feature-script.jpg new file mode 100644 index 0000000..1fe454a Binary files /dev/null and b/src/assets/home-features/feature-script.jpg differ diff --git a/src/assets/home-features/feature-token.jpg b/src/assets/home-features/feature-token.jpg new file mode 100644 index 0000000..b1ca5f7 Binary files /dev/null and b/src/assets/home-features/feature-token.jpg differ diff --git a/src/assets/logo.png b/src/assets/logo.png new file mode 100644 index 0000000..dc4eea8 Binary files /dev/null and b/src/assets/logo.png differ diff --git a/src/assets/more-template-carousel/slide-1.jpg b/src/assets/more-template-carousel/slide-1.jpg new file mode 100644 index 0000000..76679ec Binary files /dev/null and b/src/assets/more-template-carousel/slide-1.jpg differ diff --git a/src/assets/more-template-carousel/slide-2.jpg b/src/assets/more-template-carousel/slide-2.jpg new file mode 100644 index 0000000..231aab8 Binary files /dev/null and b/src/assets/more-template-carousel/slide-2.jpg differ diff --git a/src/assets/more-template-carousel/slide-3.jpg b/src/assets/more-template-carousel/slide-3.jpg new file mode 100644 index 0000000..920fbae Binary files /dev/null and b/src/assets/more-template-carousel/slide-3.jpg differ diff --git a/src/assets/more-template-carousel/slide-4.png b/src/assets/more-template-carousel/slide-4.png new file mode 100644 index 0000000..a0d78dc Binary files /dev/null and b/src/assets/more-template-carousel/slide-4.png differ diff --git a/src/assets/more-template-carousel/slide-5.gif b/src/assets/more-template-carousel/slide-5.gif new file mode 100644 index 0000000..e64151c Binary files /dev/null and b/src/assets/more-template-carousel/slide-5.gif differ diff --git a/src/assets/screenshot-white.png b/src/assets/screenshot-white.png new file mode 100644 index 0000000..cbf4dd7 Binary files /dev/null and b/src/assets/screenshot-white.png differ diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx new file mode 100644 index 0000000..b1bdc16 --- /dev/null +++ b/src/components/AppShell.tsx @@ -0,0 +1,427 @@ +import { + ArrowDownOutlined, + ArrowUpOutlined, + CheckCircleOutlined, + FlagOutlined, + LoginOutlined, + LogoutOutlined, + PlusCircleOutlined, + UserOutlined, + WalletOutlined, +} from "@ant-design/icons"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import type { ServerConnectionHealth } from "../api/serverConnection"; +import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions"; +import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types"; +import NotificationCenter from "./NotificationCenter"; +import { RechargeModal } from "./RechargeModal/RechargeModal"; + +interface AppShellProps { + activeView: WebViewKey; + navItems: WebNavItem[]; + session: WebUserSession | null; + usage: WebUsageSummary; + notifications: WebNotification[]; + backendHealth: ServerConnectionHealth; + workspaceExpanded: boolean; + onSelectView: (view: WebViewKey) => void; + onLogout: () => void; + onOpenLogin: () => void; + onMarkNotificationRead?: (id: string, isRead?: boolean) => void; + onMarkAllNotificationsRead?: () => void; + children: ReactNode; +} + +const BRAND_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png"; + +function formatBalance(cents: number): string { + const value = Math.max(0, cents) / 100; + return `${value.toFixed(2)} 积分`; +} + +function AppShell({ + activeView, + navItems, + session, + usage, + notifications, + backendHealth, + workspaceExpanded, + onSelectView, + onLogout, + onOpenLogin, + onMarkNotificationRead, + onMarkAllNotificationsRead, + children, +}: AppShellProps) { + const activePackage = session?.user.activePackages?.[0]; + const profileRef = useRef(null); + const submenuHideTimerRef = useRef(null); + const [profileOpen, setProfileOpen] = useState(false); + const [rechargeOpen, setRechargeOpen] = useState(false); + const [openSubmenuKey, setOpenSubmenuKey] = useState(null); + const isAuthView = activeView === "login"; + const isImmersiveView = activeView === "agent" || activeView === "avatarConsole"; + const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home"; + const toolSurfaceViews = [ + "workbench", + "canvas", + "more", + "scriptTokens", + "tokenUsage", + "ecommerceTemplates", + "sizeTemplate", + "imageWorkbench", + "resolutionUpscale", + "digitalHuman", + "avatarConsole", + "characterMix", + ] as WebViewKey[]; + const showPageScrollActions = showFloatingNav && !toolSurfaceViews.includes(activeView); + + const visibleNavItems = useMemo( + () => { + const orderedKeys: WebViewKey[] = [ + "workbench", + "ecommerce", + "sizeTemplate", + "canvas", + "scriptTokens", + "tokenUsage", + "community", + "assets", + "more", + ]; + return orderedKeys + .map((key) => navItems.find((item) => item.key === key)) + .filter((item): item is WebNavItem => Boolean(item)); + }, + [navItems], + ); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + + document.documentElement.dataset.theme = "dark"; + document.documentElement.dataset.uiTheme = "dark-green"; + document.documentElement.style.colorScheme = "dark"; + + const metaThemeColor = document.querySelector("meta[name='theme-color']"); + if (metaThemeColor) { + metaThemeColor.content = "#0d0d0f"; + } + }, []); + + useEffect(() => { + if (!profileOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + if (!profileRef.current?.contains(event.target as Node)) { + setProfileOpen(false); + } + }; + + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, [profileOpen]); + + useEffect(() => { + if (!session) { + setProfileOpen(false); + } + }, [session]); + + useEffect(() => { + return () => { + if (submenuHideTimerRef.current) { + window.clearTimeout(submenuHideTimerRef.current); + } + }; + }, []); + + const showSubmenu = (key: WebViewKey) => { + if (submenuHideTimerRef.current) { + window.clearTimeout(submenuHideTimerRef.current); + submenuHideTimerRef.current = null; + } + setOpenSubmenuKey(key); + }; + + const scheduleHideSubmenu = () => { + if (submenuHideTimerRef.current) { + window.clearTimeout(submenuHideTimerRef.current); + } + submenuHideTimerRef.current = window.setTimeout(() => { + setOpenSubmenuKey(null); + submenuHideTimerRef.current = null; + }, 1500); + }; + + const scrollActivePage = (direction: "top" | "bottom") => { + if (typeof document === "undefined") return; + + const targets = [ + ...Array.from(document.querySelectorAll(".web-shell__page")), + ...Array.from( + document.querySelectorAll( + ".workbench-landing-page, .ecommerce-landing-page, .workspace-page-shell__content, .community-page", + ), + ), + document.scrollingElement as HTMLElement | null, + ].filter((target): target is HTMLElement => Boolean(target)); + + targets.forEach((target) => { + const top = direction === "top" ? 0 : target.scrollHeight; + target.scrollTo({ top, behavior: "smooth" }); + }); + }; + + const displayName = session?.user.displayName || session?.user.username || "预览创作者"; + const avatarLabel = displayName.trim().slice(0, 1).toUpperCase() || "创"; + const avatarUrl = session?.user.avatarUrl || null; + const isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise"); + const displayedBalanceCents = + session && isEnterpriseAccount + ? (usage.enterpriseBalanceCents ?? session.user.enterpriseBalanceCents ?? usage.balanceCents) + : usage.balanceCents; + const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分"; + const isPreviewSession = session?.source === "mock-fallback"; + const showCommunityReview = canReviewCommunity(session); + const showCommunityCaseAdd = canManageCommunityCases(session); + + return ( +
+
+ {showFloatingNav ? ( + + ) : null} + {showPageScrollActions ? ( +
+ + +
+ ) : null} + +
+ {!isImmersiveView ? ( +
+ +
+ {session && ( + onSelectView(view)} + onMarkRead={onMarkNotificationRead} + onMarkAllRead={onMarkAllNotificationsRead} + /> + )} + +
+ + {session && profileOpen ? ( +
+
+ + {avatarUrl ? {displayName} : avatarLabel} + +
+ {displayName} + {session ? session.user.role || "已登录" : "预览模式"} +
+
+
+
UID
+
{session?.user.id || "preview"}
+
{isEnterpriseAccount ? "企业积分" : "积分"}
+
{displayedBalanceLabel}
+
图片
+
{usage.imageUsed}
+
视频
+
{usage.videoUsed}
+
+
+ {import.meta.env.VITE_KEY_SERVER_URL || "使用预览数据"} + +
+ + + {showCommunityReview ? ( + <> + + + ) : null} + {showCommunityCaseAdd ? ( + <> + + + ) : null} +
+ ) : null} +
+
+
+ ) : null} +
{children}
+
+
+ setRechargeOpen(false)} currentBalance={displayedBalanceCents} /> +
+ ); +} + +export default AppShell; diff --git a/src/components/BeforeAfterCompare.tsx b/src/components/BeforeAfterCompare.tsx new file mode 100644 index 0000000..0a67142 --- /dev/null +++ b/src/components/BeforeAfterCompare.tsx @@ -0,0 +1,108 @@ +import { useRef, useState, type CSSProperties } from "react"; + +interface BeforeAfterCompareProps { + sourceSrc: string; + resultSrc: string; + sourceLabel?: string; + resultLabel?: string; + sourceAlt?: string; + resultAlt?: string; + className?: string; + onSourceLoad?: (width: number, height: number) => void; +} + +const MIN_POSITION = 5; +const MAX_POSITION = 95; + +function clamp(value: number) { + return Math.min(MAX_POSITION, Math.max(MIN_POSITION, value)); +} + +export default function BeforeAfterCompare({ + sourceSrc, + resultSrc, + sourceLabel, + resultLabel, + sourceAlt = "原图", + resultAlt = "结果", + className = "", + onSourceLoad, +}: BeforeAfterCompareProps) { + const stageRef = useRef(null); + const [position, setPosition] = useState(50); + + const updatePosition = (clientX: number) => { + const stage = stageRef.current; + if (!stage) return; + const rect = stage.getBoundingClientRect(); + if (!rect.width) return; + setPosition(clamp(((clientX - rect.left) / rect.width) * 100)); + }; + + return ( +
+
+ {sourceAlt} { + onSourceLoad?.(event.currentTarget.naturalWidth, event.currentTarget.naturalHeight); + }} + /> +
+
+ {resultAlt} +
+ {sourceLabel && ( +
{sourceLabel}
+ )} + {resultLabel && ( +
{resultLabel}
+ )} +
{ + if (event.key === "ArrowLeft") { + event.preventDefault(); + setPosition((current) => clamp(current - 2)); + } + if (event.key === "ArrowRight") { + event.preventDefault(); + setPosition((current) => clamp(current + 2)); + } + }} + onPointerDown={(event) => { + event.currentTarget.setPointerCapture(event.pointerId); + updatePosition(event.clientX); + }} + onPointerMove={(event) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return; + updatePosition(event.clientX); + }} + onPointerUp={(event) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }} + onPointerCancel={(event) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }} + > + +
+
+ ); +} diff --git a/src/components/DropZone.tsx b/src/components/DropZone.tsx new file mode 100644 index 0000000..2657238 --- /dev/null +++ b/src/components/DropZone.tsx @@ -0,0 +1,98 @@ +import { useCallback, useRef, useState, type ReactNode } from "react"; + +interface DropZoneProps { + accept?: string; + multiple?: boolean; + onFiles: (files: File[]) => void; + children?: ReactNode; + className?: string; + label?: string; + hint?: string; + disabled?: boolean; +} + +export default function DropZone({ + accept = "image/*", + multiple = false, + onFiles, + children, + className = "", + label = "拖入文件或点击上传", + hint, + disabled = false, +}: DropZoneProps) { + const [isDragging, setIsDragging] = useState(false); + const inputRef = useRef(null); + const dragCounter = useRef(0); + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + dragCounter.current += 1; + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + dragCounter.current -= 1; + if (dragCounter.current <= 0) { + dragCounter.current = 0; + setIsDragging(false); + } + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + dragCounter.current = 0; + setIsDragging(false); + if (disabled) return; + const acceptTypes = accept.split(",").map((t) => t.trim()); + const files = Array.from(e.dataTransfer.files).filter((f) => + acceptTypes.some((t) => { + if (t.endsWith("/*")) return f.type.startsWith(t.replace("/*", "/")); + return f.type === t || f.name.endsWith(t); + }), + ); + if (files.length) onFiles(multiple ? files : files.slice(0, 1)); + }, + [accept, disabled, multiple, onFiles], + ); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length) onFiles(multiple ? files : files.slice(0, 1)); + e.target.value = ""; + }, + [multiple, onFiles], + ); + + return ( +
e.preventDefault()} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + onClick={() => !disabled && inputRef.current?.click()} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); }} + > + + {children || ( + <> + {label} + {hint && {hint}} + + )} +
+ ); +} diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx new file mode 100644 index 0000000..7610ceb --- /dev/null +++ b/src/components/EmptyState.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from "react"; + +interface EmptyStateProps { + icon?: ReactNode; + title: string; + description?: string; + actionLabel?: string; + onAction?: () => void; +} + +export function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) { + return ( +
+
+ {icon || ( + + )} +
+ {title} + {description ?

{description}

: null} + {actionLabel && onAction ? ( + + ) : null} +
+ ); +} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..07d61f5 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,152 @@ +import { Component, type ReactNode } from "react"; +import { reportError } from "../utils/errorReporting"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo) { + console.error("[ErrorBoundary] Uncaught error:", error, info.componentStack); + reportError(error, "boundary"); + } + + handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + handleReload = () => { + window.location.reload(); + }; + + render() { + if (!this.state.hasError) { + return this.props.children; + } + + return ( +
+
+
⚠️
+

+ 页面出错了 +

+

+ 应用遇到了意外错误,请尝试刷新页面。 +
+ 如果问题持续出现,请联系技术支持。 +

+ {this.state.error && ( +
+ + 错误详情 + +
+                {this.state.error.message}
+              
+
+ )} +
+ + +
+
+
+ ); + } +} + +export default ErrorBoundary; diff --git a/src/components/NotificationCenter.tsx b/src/components/NotificationCenter.tsx new file mode 100644 index 0000000..db9b9a0 --- /dev/null +++ b/src/components/NotificationCenter.tsx @@ -0,0 +1,167 @@ +import { + BellOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + DeleteOutlined, + DislikeOutlined, + ExclamationCircleOutlined, + LikeOutlined, + LockOutlined, +} from "@ant-design/icons"; +import { useEffect, useRef, useState } from "react"; +import type { WebNotification, WebNotificationType, WebViewKey } from "../types"; + +const NOTIFICATION_ICONS: Record = { + task_completed: , + task_failed: , + review_pending: , + review_passed: , + review_rejected: , + credits_low: , + session_expired: , + info: , +}; + +function parseTimestamp(dateStr: string): number { + if (!dateStr) return Date.now(); + let ts = new Date(dateStr).getTime(); + if (Number.isNaN(ts)) { + ts = new Date(dateStr.replace(" ", "T") + "Z").getTime(); + } + if (Number.isNaN(ts)) return Date.now(); + return ts; +} + +function timeAgo(dateStr: string, now: number): string { + const ts = parseTimestamp(dateStr); + const diff = now - ts; + if (diff < 0) return "刚刚"; + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return "刚刚"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}分钟前`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}小时前`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}天前`; + const date = new Date(ts); + return `${date.getMonth() + 1}月${date.getDate()}日`; +} + +interface NotificationCenterProps { + items?: WebNotification[]; + onNavigate: (view: WebViewKey, targetId?: string) => void; + onMarkRead?: (id: string, isRead?: boolean) => void; + onMarkAllRead?: () => void; + onClear?: () => void; +} + +function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onClear }: NotificationCenterProps) { + const [readIds, setReadIds] = useState([]); + const [open, setOpen] = useState(false); + const [now, setNow] = useState(Date.now); + const containerRef = useRef(null); + const notifications = items ?? []; + const unreadCount = notifications.filter((n) => !readIds.includes(n.id) && !n.isRead).length; + + useEffect(() => { + if (!open) return; + const timer = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(timer); + }, [open]); + + useEffect(() => { + if (items && items.length === 0) { + setReadIds([]); + } + }, [items]); + + useEffect(() => { + if (!open) return; + const handlePointerDown = (e: PointerEvent) => { + if (!containerRef.current?.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, [open]); + + const markAllRead = () => { + setReadIds((prev) => Array.from(new Set([...prev, ...notifications.map((n) => n.id)]))); + onMarkAllRead?.(); + }; + + const handleClickNotification = (n: WebNotification) => { + setReadIds((prev) => (prev.includes(n.id) ? prev : [...prev, n.id])); + onMarkRead?.(n.id, true); + setOpen(false); + if (n.targetView) { + onNavigate(n.targetView, n.targetId); + } + }; + + return ( +
+ + {open && ( +
+
+ 通知中心 +
+ {unreadCount > 0 && ( + + )} + {notifications.length > 0 && onClear && ( + + )} +
+
+
+ {notifications.length === 0 ? ( +
+ + 暂无通知 +
+ ) : ( + notifications.map((n) => ( + + )) + )} +
+
+ )} +
+ ); +} + +export default NotificationCenter; diff --git a/src/components/OptimizedImage.tsx b/src/components/OptimizedImage.tsx new file mode 100644 index 0000000..aac744a --- /dev/null +++ b/src/components/OptimizedImage.tsx @@ -0,0 +1,55 @@ +import { useState, useCallback, type CSSProperties, type ImgHTMLAttributes } from "react"; + +const FALLBACK_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='80' viewBox='0 0 80 80'%3E%3Crect fill='%23222' width='80' height='80' rx='8'/%3E%3Cpath d='M28 52l8-12 6 8 12-16 10 20H28z' fill='%23444'/%3E%3Ccircle cx='32' cy='32' r='5' fill='%23444'/%3E%3C/svg%3E"; + +const baseStyle: CSSProperties = { + transition: "opacity 0.3s ease", +}; + +interface OptimizedImageProps extends ImgHTMLAttributes { + fallbackSrc?: string; +} + +export default function OptimizedImage({ + src, + alt = "", + fallbackSrc = FALLBACK_SRC, + style, + onLoad, + onError, + ...rest +}: OptimizedImageProps) { + const [loaded, setLoaded] = useState(false); + const [errored, setErrored] = useState(false); + + const handleLoad = useCallback( + (e: React.SyntheticEvent) => { + setLoaded(true); + onLoad?.(e); + }, + [onLoad], + ); + + const handleError = useCallback( + (e: React.SyntheticEvent) => { + if (!errored) { + setErrored(true); + (e.target as HTMLImageElement).src = fallbackSrc; + } + onError?.(e); + }, + [errored, fallbackSrc, onError], + ); + + return ( + {alt} + ); +} diff --git a/src/components/PageTransition.tsx b/src/components/PageTransition.tsx new file mode 100644 index 0000000..b008505 --- /dev/null +++ b/src/components/PageTransition.tsx @@ -0,0 +1,35 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; + +interface PageTransitionProps { + viewKey: string; + children: ReactNode; +} + +const EXIT_DURATION_MS = 180; + +export default function PageTransition({ viewKey, children }: PageTransitionProps) { + const [displayedChildren, setDisplayedChildren] = useState(children); + const [phase, setPhase] = useState<"idle" | "exit">("idle"); + const prevKeyRef = useRef(viewKey); + const timerRef = useRef>(); + + useEffect(() => { + if (viewKey === prevKeyRef.current) { + setDisplayedChildren(children); + return; + } + prevKeyRef.current = viewKey; + setPhase("exit"); + timerRef.current = setTimeout(() => { + setDisplayedChildren(children); + setPhase("idle"); + }, EXIT_DURATION_MS); + return () => clearTimeout(timerRef.current); + }, [viewKey, children]); + + return ( +
+ {displayedChildren} +
+ ); +} diff --git a/src/components/RechargeModal/RechargeModal.tsx b/src/components/RechargeModal/RechargeModal.tsx new file mode 100644 index 0000000..2254028 --- /dev/null +++ b/src/components/RechargeModal/RechargeModal.tsx @@ -0,0 +1,230 @@ +import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons"; +import { useMemo, useState, type ReactNode } from "react"; + +type RechargeAudience = "personal" | "enterprise"; + +interface MembershipPlan { + id: string; + audience: RechargeAudience; + name: string; + subtitle: string; + period: string; + price: string; + grant: string; + comparisonLabel: string; + badge?: string; + icon: ReactNode; + benefits: string[]; +} + +const membershipPlans: MembershipPlan[] = [ + { + id: "pro-month", + audience: "personal", + name: "专业版", + subtitle: "Pro", + period: "月付", + price: "299 元 / 月", + grant: "每月赠送 10000 积分,30 天有效", + comparisonLabel: "专业版基础权益", + icon: , + benefits: ["通用大模型全解锁", "积分与 API 消耗 9 折", "并发提升到 3 个", "去水印、插队加速、专属客服"], + }, + { + id: "pro-quarter", + audience: "personal", + name: "专业版", + subtitle: "Pro", + period: "季付", + price: "897 元 / 季", + grant: "连续 3 个月按月发放 Pro 积分", + comparisonLabel: "相比月付新增", + badge: "季度", + icon: , + benefits: ["一次覆盖 3 个月使用周期", "每月延续 Pro 权益", "适合短期项目排期"], + }, + { + id: "pro-year", + audience: "personal", + name: "专业版", + subtitle: "Pro", + period: "年付", + price: "1990 元 / 年", + grant: "全年合计 140000 积分,默认按月分摊", + comparisonLabel: "相比季付新增", + badge: "年费优惠", + icon: , + benefits: ["折合 10 个月费用", "前 100 名额外赠 20000 积分", "适合全年持续高频使用"], + }, + { + id: "enterprise-month", + audience: "enterprise", + name: "企业版", + subtitle: "Enterprise", + period: "月付", + price: "499 元 / 月", + grant: "每月赠送 2000 积分,30 天有效", + comparisonLabel: "企业版基础权益", + icon: , + benefits: ["企业私有模型与高性能模型", "默认 10 并发,可申请提升", "积分与 API 消耗 8 折", "用量报表与正式 API 权限"], + }, + { + id: "enterprise-quarter", + audience: "enterprise", + name: "企业版", + subtitle: "Enterprise", + period: "季付", + price: "1497 元 / 季", + grant: "连续 3 个月按月发放企业版积分", + comparisonLabel: "相比月付新增", + badge: "季度", + icon: , + benefits: ["一次覆盖季度项目周期", "延续企业资源池与高并发", "适合阶段性团队投放"], + }, + { + id: "enterprise-year", + audience: "enterprise", + name: "企业版", + subtitle: "Enterprise", + period: "年付", + price: "4990 元 / 年", + grant: "全年合计 340000 积分,默认按月分摊", + comparisonLabel: "相比季付新增", + badge: "企业年费", + icon: , + benefits: ["折合 10 个月费用", "前 100 名额外赠 100000 积分", "支持对公充值与子账户额度分配"], + }, +]; + +const defaultSelectedPlanIds: Record = { + personal: "pro-month", + enterprise: "enterprise-month", +}; + +const rechargeRules = [ + "充值比例:固定 1 元 = 100 积分,平台可限时活动额外赠送积分", + "有效期:充值积分到账起有效期 12 个月,系统按先进先出自动消耗", + "退费规则:充值积分到账后不支持退换、折现,仅限平台内消费", +]; + +interface RechargeModalProps { + open: boolean; + onClose: () => void; + currentBalance?: number; +} + +export function RechargeModal({ open, onClose, currentBalance }: RechargeModalProps) { + const [activeAudience, setActiveAudience] = useState("personal"); + const [selectedPlanIds, setSelectedPlanIds] = useState>(defaultSelectedPlanIds); + const visiblePlans = useMemo(() => membershipPlans.filter((plan) => plan.audience === activeAudience), [activeAudience]); + const selectedPlanId = selectedPlanIds[activeAudience]; + + const handlePlanSelect = (plan: MembershipPlan) => { + setSelectedPlanIds((current) => ({ + ...current, + [plan.audience]: plan.id, + })); + }; + + if (!open) return null; + + return ( +
+ + + +
+ + +
+ +
+ {visiblePlans.map((plan) => { + const isSelected = plan.id === selectedPlanId; + + return ( +
+ {plan.badge ? {plan.badge} : null} +
+ {plan.icon} +
+

{plan.name}

+ {plan.subtitle} +
+
+ {plan.period} +
+ {plan.price} +
+

{plan.grant}

+ {plan.comparisonLabel} +
    + {plan.benefits.map((benefit) => ( +
  • + + {benefit} +
  • + ))} +
+ +
+ ); + })} +
+ +
+

积分充值规则

+
    + {rechargeRules.map((rule) => ( +
  1. {rule}
  2. + ))} +
+
+ +
+ ); +} diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx new file mode 100644 index 0000000..491a942 --- /dev/null +++ b/src/components/Skeleton.tsx @@ -0,0 +1,41 @@ +import type { CSSProperties } from "react"; + +interface SkeletonProps { + width?: string | number; + height?: string | number; + borderRadius?: string | number; + className?: string; + style?: CSSProperties; +} + +export function Skeleton({ width, height = 16, borderRadius = 6, className = "", style }: SkeletonProps) { + return ( +