From 1adacea3eb4da4746fcc87db7f746e1fc532869c Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:03 +0800 Subject: [PATCH 01/29] build(frontend): scaffold vite react typescript workspace The MS2 frontend needs a reproducible workspace before any module code can land. Add the Vite entry, the pinned dependency manifest and lockfile, the three referenced TypeScript configs, and the ignore rules. The workspace installs and builds from a clean checkout. --- .gitignore | 4 + frontend/.gitignore | 28 + frontend/index.html | 12 + frontend/package-lock.json | 3630 +++++++++++++++++++++++++++++++++++ frontend/package.json | 36 + frontend/tsconfig.app.json | 31 + frontend/tsconfig.json | 4 + frontend/tsconfig.node.json | 23 + frontend/vite.config.ts | 16 + 9 files changed, 3784 insertions(+) create mode 100644 .gitignore create mode 100644 frontend/.gitignore create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2603d1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# macOS +.DS_Store + +# 依赖与构建产物由各子项目的 .gitignore 负责 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..00baad0 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Vercel 本地项目链接元数据,不提交账号和项目 ID +.vercel + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.env* diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..cd1df0c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Windup · 2D 角色资产生成 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d513d30 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3630 @@ +{ + "name": "windup-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "windup-frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "^8.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.2", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "jsdom": "^29.1.1", + "oxfmt": "^0.61.0", + "oxlint": "^1.71.0", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.2", + "vite": "^8.1.1", + "vitest": "^4.1.10" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.61.0.tgz", + "integrity": "sha512-BaS+1OVvg9sr+Xav0+KdWedQRcAzrdoEcwMZeqoc2F6ieC1s/t5eM35YQoRPQ7vAqkZ+p3tbQb1r9I9mrV5oGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.61.0.tgz", + "integrity": "sha512-of8atAV0M1egGcVOMbgZCvc10sFOP3ayQBNQV5h5G3fNq8gACdEswfFk9bzGrdbM23rtg0Coxi7np7oPLcueNw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.61.0.tgz", + "integrity": "sha512-7l8+5ov4BGwtAcmpzvEik/TG3bciwyw/S3e6j5GKH7pcQqcgCVxD3AuJeP6upto+SOTBKQ4wrrdbMt0gq8fHSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.61.0.tgz", + "integrity": "sha512-Fnz4dDDXBb7udk+DmwelNjxbD6yptyxwCqwCH2ebo4RVLxVsRfFsn/AHJC49KIltPrVokamGv4SSOsiV50DTxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.61.0.tgz", + "integrity": "sha512-mddOebKNCP+AucmzfNsk3jgbr681qAUvgMqi865GW5gWLJ/AnzXbvjQRrny0e++NAN8aphav/aRSrfFxNsNjpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.61.0.tgz", + "integrity": "sha512-svx59iYL+DbaZGZUIoice4W0CjRXGExnbz7Re+awIb60rVxBS2KrU7Hnlx+nZYanLGLpjneUEgo/VFEKkSZAyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.61.0.tgz", + "integrity": "sha512-BYK9MPJPCf6d+fLKMTruThmEyCtHzQ1zLcsrTlUVkmnoXIaHAbfpeLYQwX1tkjs7W11dyzoi6HFvKcdnvX1zNg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.61.0.tgz", + "integrity": "sha512-QUaCNLq2/EC6G5ljOuFanl9Lgw6ZWp4co7rs4+KOMUzbGfA4Lq58FHRjjF9sVIG+93XSbo343MxFATrOU1qctA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.61.0.tgz", + "integrity": "sha512-S6uvJ6MXnRXl+zTs0CARNDvkE+cymj0EVWEKKsyKnlLlqTyQJBjw5s4D2pSIOZc+S46cy4STefzcr/sm0VzVPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.61.0.tgz", + "integrity": "sha512-6VDlRcytvZG6UlSIdAFKDLbppo9tvPxrWzle6vHldYFMeuDPQEfMKrkwezp7FaBq1wik9ra554ZZeRPsyIkFpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.61.0.tgz", + "integrity": "sha512-KkBTYbzExpbmn15XjKPLu2fRV2PVlq+KWt+brad5rwIa03vdYoaDRWiS7raHII/dCTR6Ro4UpYUCH4t6lif4WQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.61.0.tgz", + "integrity": "sha512-69tzIq7sJLVB9dxYYtvMzcSSsnZHSO+U2U19O2RqDqgj6+Q4O7HjSXdaszbcgqzhsUwzSH7z5kWvk8nmf6BHTg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.61.0.tgz", + "integrity": "sha512-Oqi/N0OvtOVXsPKAOOhKgGH3msRYF8BLJaNBbWiupRiKoKVyc8JRKPCfarkQJC+RgP9U8raUKLe+bNwd0HUMiA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.61.0.tgz", + "integrity": "sha512-3TKwv/ed4uwJSemAA8P9XcoqETpjQI4waquF9UilhA9Mn/dhr1PdUEXWlL74mtc6ZNfmKPA9+NEJm01nRF8CVA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.61.0.tgz", + "integrity": "sha512-uFso4u4nLkVSlMCpgjyvWV60Gt7GvDQHnk1mmRxHIkZTMB0ljpUKwCD9FYGgN9H97x2wYl0UwEjgRZaPIuhEhw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.61.0.tgz", + "integrity": "sha512-keGLkzeOvkMpNmPp4hffXWpfoSsY6e1K8++KXD4mSSfxdvM8q9QUDsYY689TB1k6Co832DZn1MnaaVx6cIBMWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.61.0.tgz", + "integrity": "sha512-VzsAISkFxmNhJ5LBDEL9VuH6tJsVJMtqYit2LyIUf/HLnsCe4Pg9SMOjjVQzGWt0bnpyfJ94CrqTqcpNZzK+ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.61.0.tgz", + "integrity": "sha512-xv4t7yzwJoYaLB6Zv28B3W+j7brEjsyv50rLTAQgmxJzddce9fAMCxed8dSAkbWES0zz2J29nYK5FaTuD2YBHg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.61.0.tgz", + "integrity": "sha512-6EZXFkqOwxdDYjIn3TSNnPk3ST5E5GiYd4FiM6UF/mCL/LZSfr6D6UygTfW3R1PCQP2quCKpCEGRlij8E3VYbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.75.0.tgz", + "integrity": "sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.75.0.tgz", + "integrity": "sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.75.0.tgz", + "integrity": "sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.75.0.tgz", + "integrity": "sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.75.0.tgz", + "integrity": "sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.75.0.tgz", + "integrity": "sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.75.0.tgz", + "integrity": "sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.75.0.tgz", + "integrity": "sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.75.0.tgz", + "integrity": "sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.75.0.tgz", + "integrity": "sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.75.0.tgz", + "integrity": "sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.75.0.tgz", + "integrity": "sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.75.0.tgz", + "integrity": "sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.75.0.tgz", + "integrity": "sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.75.0.tgz", + "integrity": "sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.75.0.tgz", + "integrity": "sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.75.0.tgz", + "integrity": "sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.75.0.tgz", + "integrity": "sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.75.0.tgz", + "integrity": "sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "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/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "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/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/oxfmt": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.61.0.tgz", + "integrity": "sha512-DxdHBEMYpcEnHoUHjjOigUqV2TYKsvxLwUPXnVYBjgFdqrcQ/91OtwubtZ2PUodCs3sStI8R5Qw3fKNGK4e8wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.61.0", + "@oxfmt/binding-android-arm64": "0.61.0", + "@oxfmt/binding-darwin-arm64": "0.61.0", + "@oxfmt/binding-darwin-x64": "0.61.0", + "@oxfmt/binding-freebsd-x64": "0.61.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.61.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.61.0", + "@oxfmt/binding-linux-arm64-gnu": "0.61.0", + "@oxfmt/binding-linux-arm64-musl": "0.61.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.61.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.61.0", + "@oxfmt/binding-linux-riscv64-musl": "0.61.0", + "@oxfmt/binding-linux-s390x-gnu": "0.61.0", + "@oxfmt/binding-linux-x64-gnu": "0.61.0", + "@oxfmt/binding-linux-x64-musl": "0.61.0", + "@oxfmt/binding-openharmony-arm64": "0.61.0", + "@oxfmt/binding-win32-arm64-msvc": "0.61.0", + "@oxfmt/binding-win32-ia32-msvc": "0.61.0", + "@oxfmt/binding-win32-x64-msvc": "0.61.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.75.0.tgz", + "integrity": "sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.75.0", + "@oxlint/binding-android-arm64": "1.75.0", + "@oxlint/binding-darwin-arm64": "1.75.0", + "@oxlint/binding-darwin-x64": "1.75.0", + "@oxlint/binding-freebsd-x64": "1.75.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.75.0", + "@oxlint/binding-linux-arm-musleabihf": "1.75.0", + "@oxlint/binding-linux-arm64-gnu": "1.75.0", + "@oxlint/binding-linux-arm64-musl": "1.75.0", + "@oxlint/binding-linux-ppc64-gnu": "1.75.0", + "@oxlint/binding-linux-riscv64-gnu": "1.75.0", + "@oxlint/binding-linux-riscv64-musl": "1.75.0", + "@oxlint/binding-linux-s390x-gnu": "1.75.0", + "@oxlint/binding-linux-x64-gnu": "1.75.0", + "@oxlint/binding-linux-x64-musl": "1.75.0", + "@oxlint/binding-openharmony-arm64": "1.75.0", + "@oxlint/binding-win32-arm64-msvc": "1.75.0", + "@oxlint/binding-win32-ia32-msvc": "1.75.0", + "@oxlint/binding-win32-x64-msvc": "1.75.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "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.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-router": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz", + "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "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": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0a845f7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "windup-frontend", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "format": "oxfmt", + "format:check": "oxfmt --check", + "preview": "vite preview", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -b" + }, + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "^8.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.2", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "jsdom": "^29.1.1", + "oxfmt": "^0.61.0", + "oxlint": "^1.71.0", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.2", + "vite": "^8.1.1", + "vitest": "^4.1.10" + } +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..474b9e6 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* 路径别名:@/xxx -> src/xxx,需与 vite.config.ts 的 resolve.alias 保持一致 */ + "paths": { + "@/*": ["./src/*"] + }, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..b928223 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { fileURLToPath, URL } from 'node:url' + +import tailwindcss from '@tailwindcss/vite' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + // 全项目统一用 @/xxx 引用 src 下的模块,避免 ../../.. 相对路径 + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, +}) From 059d3759313875c725418bc6733d26bc5198092f Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:03 +0800 Subject: [PATCH 02/29] build(frontend): add oxlint and oxfmt configuration Style and lint rules should be fixed before contributors start writing modules. Add the oxlint rule set and the oxfmt formatting profile for the frontend workspace. Formatting and lint results are identical on every machine and in CI. --- frontend/.oxfmtrc.json | 9 +++++++++ frontend/.oxlintrc.json | 8 ++++++++ 2 files changed, 17 insertions(+) create mode 100644 frontend/.oxfmtrc.json create mode 100644 frontend/.oxlintrc.json diff --git a/frontend/.oxfmtrc.json b/frontend/.oxfmtrc.json new file mode 100644 index 0000000..293ef6a --- /dev/null +++ b/frontend/.oxfmtrc.json @@ -0,0 +1,9 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "endOfLine": "lf", + "printWidth": 100, + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "ignorePatterns": ["**/*.md"] +} diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} From 980d5112407a036ff860f00b000970877c50fdd2 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:03 +0800 Subject: [PATCH 03/29] build(frontend): add vitest configuration The skeleton carries no test suite yet, but the test runner has to be wired for later work. Add the vitest config so it resolves the same path aliases as the application build. Tests can be added to any module without further setup. --- frontend/vitest.config.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 frontend/vitest.config.ts diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..3a40db6 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,21 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + // 纯逻辑测试跑 node;需要 DOM 的用文件顶部 @vitest-environment jsdom 单独声明 + environment: 'node', + include: [ + 'src/**/*.test.ts', + 'src/**/*.test.tsx', + '../tests/**/*.test.ts', + '../tests/**/*.test.tsx', + ], + }, +}) From fd4424a5bcfbdd727f9d620b6dde5c9c9966f858 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:24 +0800 Subject: [PATCH 04/29] feat(shared): add shared layer with pagination contract Every upper layer needs a place for cross-module primitives that belong to no single domain. Add the shared layer with its pagination contract and a README stating what may live here. Modules have one agreed home for shared primitives instead of duplicating them. --- frontend/src/shared/README.md | 28 +++++++++++++++++++++++++ frontend/src/shared/pagination/index.ts | 13 ++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 frontend/src/shared/README.md create mode 100644 frontend/src/shared/pagination/index.ts diff --git a/frontend/src/shared/README.md b/frontend/src/shared/README.md new file mode 100644 index 0000000..b35fb60 --- /dev/null +++ b/frontend/src/shared/README.md @@ -0,0 +1,28 @@ +# shared + +与 Windup 业务无关、可被任意上层模块复用的基础代码。它是依赖方向的最底层,**不能反向依赖 `entities`、`features`、`pages` 或 `app`**。 + +## 判断标准 + +**如果一段代码需要理解 Windup 的业务词汇,它就不属于 shared。** + +## 现有内容 + +- `pagination/` —— 与传输协议无关的分页请求与结果形状。 + +## 后续允许放入 + +- `ui/` —— 按钮、弹窗、加载状态等不含业务含义的展示组件 +- `hooks/` —— 通用浏览器或 React 行为,例如媒体查询、键盘快捷键 +- `utils/` —— 纯函数工具,例如日期格式化、文件大小显示 +- `config/` —— 前端通用常量与运行时配置读取 + +**这些目录只在出现真实代码时创建,不为占位提前建空文件。** + +## 不允许放入 + +- Project、Character、Generation、Task、WorkflowRun 等业务数据 +- `ProjectApis`、`CharacterApis` 这类业务接口集合 +- 流程的推进、重启、中断和 Revision 规则 +- 为开发与生产各维护一套实现的切换机制 +- 只被单个页面或模块使用、却以「复用」名义提前抽出的代码 diff --git a/frontend/src/shared/pagination/index.ts b/frontend/src/shared/pagination/index.ts new file mode 100644 index 0000000..d5e2c27 --- /dev/null +++ b/frontend/src/shared/pagination/index.ts @@ -0,0 +1,13 @@ +/** 与传输协议无关的分页请求形状。 */ +export interface PageQuery { + page?: number + pageSize?: number +} + +/** 与传输协议无关的分页结果形状。 */ +export interface Paged { + items: T[] + total: number + page: number + pageSize: number +} From 7e22b51e16ad9744ccbf6072907170147de5c24c Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:24 +0800 Subject: [PATCH 05/29] feat(entities): add data layer public interfaces The frontend has to state which business objects it owns and how each one is reached. Add one contract file per entity for character, project, task, generation, media, action template, playtest inspection and workflow run, plus the aggregate export. Upper layers depend on declared entity interfaces rather than on transport details. --- .../src/entities/action-template/index.ts | 14 ++ frontend/src/entities/character/index.ts | 138 ++++++++++++++++ frontend/src/entities/generation/index.ts | 108 ++++++++++++ frontend/src/entities/index.ts | 86 ++++++++++ frontend/src/entities/media/index.ts | 9 + .../src/entities/playtest-inspection/index.ts | 29 ++++ frontend/src/entities/project/index.ts | 78 +++++++++ frontend/src/entities/task/index.ts | 45 +++++ frontend/src/entities/workflow-run/index.ts | 155 ++++++++++++++++++ 9 files changed, 662 insertions(+) create mode 100644 frontend/src/entities/action-template/index.ts create mode 100644 frontend/src/entities/character/index.ts create mode 100644 frontend/src/entities/generation/index.ts create mode 100644 frontend/src/entities/index.ts create mode 100644 frontend/src/entities/media/index.ts create mode 100644 frontend/src/entities/playtest-inspection/index.ts create mode 100644 frontend/src/entities/project/index.ts create mode 100644 frontend/src/entities/task/index.ts create mode 100644 frontend/src/entities/workflow-run/index.ts diff --git a/frontend/src/entities/action-template/index.ts b/frontend/src/entities/action-template/index.ts new file mode 100644 index 0000000..2dafa39 --- /dev/null +++ b/frontend/src/entities/action-template/index.ts @@ -0,0 +1,14 @@ +interface ActionTemplateBase { + id: string + name: string + prompt: string +} + +/** 系统内置模板没有项目归属;项目自定义模板必须携带所属 Project ID。 */ +export type ActionTemplate = ActionTemplateBase & + ({ scope: 'system'; projectId: null } | { scope: 'project'; projectId: string }) + +/** ActionTemplate 对应的一组后端接口。 */ +export interface ActionTemplateApis { + listAvailable(projectId: string): Promise +} diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts new file mode 100644 index 0000000..db8315b --- /dev/null +++ b/frontend/src/entities/character/index.ts @@ -0,0 +1,138 @@ +/** + * 动作「如何被定义」的来源维度:preset 复用预设定义,custom 由用户自定义。 + * 它与动作做什么的 ActionType 相互独立,例如 custom + walk 和 preset + custom 都是合法组合。 + */ +export type ActionKind = 'preset' | 'custom' + +/** + * 动作「做什么」的业务语义维度;custom 表示不属于当前内置语义枚举。 + * 它不表示定义来源:custom 来源仍可描述 walk,preset 来源也可承载 custom 业务语义。 + */ +export type ActionType = 'walk' | 'idle' | 'attack' | 'jump' | 'custom' + +/** 动作在生成流水线上的位置。 */ +export type ActionStatus = + /** 已加入工作流但还没开始生成 */ + | 'planned' + /** 后端任务进行中 */ + | 'generating' + /** 生成完成,是候选,还没被用户确认 */ + | 'candidate' + /** 用户确认后成为正式资产 */ + | 'confirmed' + /** 生成失败 */ + | 'failed' + +/** 系统质检结论。系统通过不等于人工通过,分开记。 */ +export type FrameQcResult = 'pending' | 'passed' | 'failed' + +/** 单帧相对动作首帧的根位移,单位为像素。 */ +export interface FrameRootMotion { + dx: number + /** 正值表示向上。 */ + dy: number +} + +/** 动作序列中的一张有序画面;帧序号由其在 Action.frames 中的位置决定。 */ +export interface Frame { + imageUrl: string + /** + * 此帧的显示时长,单位毫秒。 + * null 表示该帧没有独立时长,读取方才使用所属 Action.fps 计算等时长回退值。 + */ + durationMs: number | null + /** null 表示不提供根位移,Playtest 与 Export 不应据此施加任何位移。 */ + rootMotion: FrameRootMotion | null + /** 后端自动质检结论,与人工 rejected 状态相互独立。 */ + qc: FrameQcResult + /** 人工退回。不设「通过此帧」,故是布尔量而非三态。 */ + rejected: boolean +} + +/** 一张母版候选;attemptId 用于区分候选所属的生成尝试。 */ +export interface CharacterTemplateCandidate { + id: string + imageUrl: string + attemptId: string +} + +/** 确认母版候选时同时命名造型与候选,避免两个字符串 ID 在调用处颠倒。 */ +export interface ConfirmCharacterTemplateInput { + outfitId: string + candidateId: string +} + +/** 造型的基础参考帧;方向与质检结构等待真实资产契约后再扩展。 */ +export interface BaseFrame { + readonly imageUrl: string +} + +/** 某个角色造型下的一段动画动作。 */ +export interface Action { + id: string + outfitId: Outfit['id'] + name: string + /** 定义来源方式;与 type 正交,不用于推断动作业务语义。 */ + kind: ActionKind + /** 动作业务语义;与 kind 的 preset/custom 来源维度相互独立。 */ + type: ActionType + status: ActionStatus + /** + * 每秒播放帧数。仅当某帧 durationMs 为 null 时用于等时长回退; + * Playtest 与 Export 不得用前端全局常量替代,也不得覆盖帧自己的 durationMs。 + */ + fps: number + /** + * 攻击触点、跳跃顶点等关键时刻在 frames 中的零基下标;null 表示没有明确关键帧。 + * 非 null 值必须指向当前 frames 数组内的成员。 + */ + keyFrameIndex: number | null + /** 按播放顺序排列的帧;数组下标就是零基帧序号。 */ + frames: Frame[] +} + +/** 同一角色的一套独立造型;MVP UI 只展示第一套,但数据结构不折叠该层。 */ +export interface Outfit { + id: string + characterId: string + name: string + /** 母版生成阶段返回的候选;生成完成前可以为空数组。 */ + candidateCharacterTemplates: CharacterTemplateCandidate[] + /** 用户从候选图中选定的角色母版 URL;尚未选定时为 null。 */ + characterTemplateUrl: string | null + /** 供后续动作生成使用的只读基础帧入口。 */ + readonly baseFrames: readonly BaseFrame[] + /** 每个 Action.outfitId 必须等于本造型 ID。 */ + actions: Action[] +} + +/** 项目下的角色资产;造型拥有各自的母版和动作帧。 */ +export interface Character { + id: string + projectId: string + name: string + /** 角色的全部独立造型;MVP 页面至少保留这一层,即使当前只有一个成员。 */ + outfits: Outfit[] + createdAt: string + updatedAt: string +} + +/** 创建角色并发起母版生成所需的入参。 */ +export interface CreateCharacterInput { + projectId: string + name: string + /** 交给模型生成母版。 */ + description: string + referenceImageUrl?: string | null +} + +/** + * Character 对应的一组后端接口。 + * 造型、动作和帧是 Character 内的完整树,不通过独立粒度方法写入;每次确认后整棵更新。 + */ +export interface CharacterApis { + get(id: Character['id']): Promise + listByProject(projectId: string): Promise + create(input: CreateCharacterInput): Promise + update(character: Character): Promise +} diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts new file mode 100644 index 0000000..7c932fc --- /dev/null +++ b/frontend/src/entities/generation/index.ts @@ -0,0 +1,108 @@ +import type { ActionType } from '../character' +import type { MediaReference } from '../media' +import type { TaskStatus } from '../task' + +/** + * Generation 是业务数据,不是「调用图片生成能力」。 + * 前端只创建 generation 并订阅它的状态;真正调用模型的是后端,前端不接触那一层。 + */ + +/** 生成对应的三个前端可见异步步骤。 */ +export type GenerationType = 'character_template' | 'first_frame' | 'complete_animation' + +interface GenerationInputBase { + projectId: string + /** 可选参考媒体;没有参考图时传空数组。 */ + referenceMedia: readonly MediaReference[] +} + +/** 角色母版候选生成。 */ +export interface CharacterTemplateGenerationInput extends GenerationInputBase { + type: 'character_template' + /** 已由手动输入或 Quick Start 整理好的角色提示词。 */ + prompt: string +} + +/** 指定角色造型下的动作首帧生成;不能只绑定 Character。 */ +export interface FirstFrameGenerationInput extends GenerationInputBase { + type: 'first_frame' + characterId: string + outfitId: string + actionType: ActionType + /** 自定义动作或额外动作要求;没有时为 null。 */ + prompt: string | null +} + +/** 以已确认首帧为起点生成完整动画。 */ +export interface CompleteAnimationGenerationInput extends GenerationInputBase { + type: 'complete_animation' + characterId: string + outfitId: string + actionType: ActionType + /** 已确认的生成首帧 URL。 */ + firstFrameUrl: string + prompt: string | null +} + +export type GenerationInput = + | CharacterTemplateGenerationInput + | FirstFrameGenerationInput + | CompleteAnimationGenerationInput + +/** 后端当前能交付给前端的最小图片结果。 */ +export interface GeneratedImage { + url: string +} + +/** 结果按 type 分别定义,不共用一个 urls 数组。 */ +export interface CharacterTemplateGenerationResult { + type: 'character_template' + images: readonly GeneratedImage[] +} + +export interface FirstFrameGenerationResult { + type: 'first_frame' + image: GeneratedImage +} + +/** 帧顺序由数组位置表达。 */ +export interface CompleteAnimationGenerationResult { + type: 'complete_animation' + frames: readonly GeneratedImage[] +} + +export type GenerationResult = + | CharacterTemplateGenerationResult + | FirstFrameGenerationResult + | CompleteAnimationGenerationResult + +export type GenerationResultFor = + T extends CharacterTemplateGenerationInput + ? CharacterTemplateGenerationResult + : T extends FirstFrameGenerationInput + ? FirstFrameGenerationResult + : CompleteAnimationGenerationResult + +/** + * 一次生成任务的完整快照。 + * 它是服务端的资源,不是一次「调用能力」——前端创建它,然后订阅或轮询它的状态。 + */ +export interface Generation { + id: string + projectId: string + /** 与创建时的输入判别字段保持同一字面量类型。 */ + type: TType + status: TaskStatus + /** 完成前为 null;完成后形状由 type 决定。 */ + result: GenerationResult | null + /** status 为 failed 时有值。 */ + error: string | null +} + +/** Generation 对应的一组后端接口。 */ +export interface GenerationApis { + /** 创建一次生成任务。 */ + create(input: T): Promise> + /** 读取生成任务的最新快照。 */ + get(id: Generation['id']): Promise +} diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts new file mode 100644 index 0000000..804de8b --- /dev/null +++ b/frontend/src/entities/index.ts @@ -0,0 +1,86 @@ +/** + * entities 唯一公开入口。外部不得绕过本文件访问内部文件。 + * 本次只提交类型与接口,不提交实现。 + */ + +/* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ +export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, SPRITE_SIZES } from './project' +export type { + CharacterPerspective, + CreateProjectInput, + DirectionalMovement, + Project, + ProjectApis, + UpdateProjectInput, +} from './project' + +/* 角色 —— 资产本体;造型、动作、帧都在这棵树里 */ +export type { + Action, + ActionKind, + ActionStatus, + ActionType, + BaseFrame, + Character, + CharacterApis, + CharacterTemplateCandidate, + ConfirmCharacterTemplateInput, + CreateCharacterInput, + Frame, + FrameQcResult, + FrameRootMotion, + Outfit, +} from './character' + +/* 动作模板 —— 能跨角色复用的配方 */ +export type { ActionTemplate, ActionTemplateApis } from './action-template' + +/* 生成 —— 业务数据,不是「调用生成能力」 */ +export type { + CharacterTemplateGenerationInput, + CharacterTemplateGenerationResult, + CompleteAnimationGenerationInput, + CompleteAnimationGenerationResult, + FirstFrameGenerationInput, + FirstFrameGenerationResult, + GeneratedImage, + Generation, + GenerationApis, + GenerationInput, + GenerationResult, + GenerationResultFor, + GenerationType, +} from './generation' + +/* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ +export type { MediaReference } from './media' + +/* 后端异步任务 —— 与工作流节点是两回事 */ +export type { Task, TaskApis, TaskEvent, TaskStatus, TaskType } from './task' + +/* 工作流 —— 节点由前端推进,运行记录由后端持久化 */ +export { WORKFLOW_STEP_ORDER } from './workflow-run' +export type { + CreateWorkflowRunInput, + ExportStatus, + GenerationStatus, + WorkflowDriver, + WorkflowStep, + WorkflowStepStatus, + WorkflowStepType, + WorkflowRevision, + WorkflowRevisionStatus, + WorkflowRun, + WorkflowRunApis, + WorkflowRunPurpose, + WorkflowRunStatus, + WorkflowTaskLink, +} from './workflow-run' + +/* Playtest 核验记录 —— 独立于 Character 与 WorkflowRun */ +export type { + PlaytestInspection, + PlaytestInspectionApis, + PlaytestInspectionStatus, + RecordPlaytestInspectionInput, +} from './playtest-inspection' diff --git a/frontend/src/entities/media/index.ts b/frontend/src/entities/media/index.ts new file mode 100644 index 0000000..347e626 --- /dev/null +++ b/frontend/src/entities/media/index.ts @@ -0,0 +1,9 @@ +declare const mediaReferenceBrand: unique symbol + +/** + * 已上传媒体的不透明引用。 + * 当前不承诺运行时字符串代表 URL、media_id 或其他后端标识。 + */ +export type MediaReference = string & { + readonly [mediaReferenceBrand]: 'MediaReference' +} diff --git a/frontend/src/entities/playtest-inspection/index.ts b/frontend/src/entities/playtest-inspection/index.ts new file mode 100644 index 0000000..e984111 --- /dev/null +++ b/frontend/src/entities/playtest-inspection/index.ts @@ -0,0 +1,29 @@ +/** 可持久化的 Playtest 核验结论;没有记录即表示尚未核验。 */ +export type PlaytestInspectionStatus = 'passed' | 'issues_found' + +/** 一次独立核验记录,不属于 Character 或 WorkflowRevision 的内部字段。 */ +export interface PlaytestInspection { + id: string + characterId: string + outfitId: string + /** 仅从 Workflow 进入时保留返回定位;独立入口为 null。 */ + source: { runId: string; revisionId: string } | null + status: PlaytestInspectionStatus + createdAt: string + updatedAt: string +} + +/** 创建或更新指定核验目标结论所需的业务输入。 */ +export interface RecordPlaytestInspectionInput { + characterId: string + outfitId: string + /** 可选的 Workflow 来源,不是播放和核验的前置条件。 */ + source?: { runId: string; revisionId: string } | null + status: PlaytestInspectionStatus +} + +/** PlaytestInspection 对应的一组后端接口。 */ +export interface PlaytestInspectionApis { + getLatest(target: { characterId: string; outfitId: string }): Promise + record(input: RecordPlaytestInspectionInput): Promise +} diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts new file mode 100644 index 0000000..778acf8 --- /dev/null +++ b/frontend/src/entities/project/index.ts @@ -0,0 +1,78 @@ +import type { Paged, PageQuery } from '@/shared/pagination' + +/** Project 前端领域形状;字段只表达当前页面需要,不对应任何已确认后端 DTO。 */ +export interface Project { + id: string + /** Project 所属用户 ID;认证来源尚未冻结。 */ + ownerId: string + name: string + /** 游戏视角,见 CHARACTER_PERSPECTIVE。 */ + perspective: CharacterPerspective + /** 移动方向,见 DIRECTIONAL_MOVEMENT。 */ + directionalMovement: DirectionalMovement + /** 当前页面使用建议档位,后端范围尚未确认。 */ + spriteSize: { width: number; height: number } + /** 项目级画风描述,作为本项目所有角色和动作生成的视觉约束。 */ + gameStyle: string | null + /** + * 项目级画风参考图,本项目所有角色和动作都遵循它的视觉风格。 + * 它不决定某个角色具体长什么样;角色自身参考图由 CreateCharacterInput.referenceImageUrl 表达。 + */ + sampleImageUrl: string | null + /** ISO 8601 字符串。 */ + createdAt: string + /** ISO 8601 字符串。 */ + updatedAt: string +} + +/** 新建项目的入参。 */ +export interface CreateProjectInput { + name: string + perspective: CharacterPerspective + directionalMovement: DirectionalMovement + spriteSize: { width: number; height: number } + gameStyle?: string | null + sampleImageUrl?: string | null +} + +/** 更新项目设置的入参;未提供的字段保持不变。 */ +export interface UpdateProjectInput { + name?: string + perspective?: CharacterPerspective + directionalMovement?: DirectionalMovement + spriteSize?: { width: number; height: number } + gameStyle?: string | null + sampleImageUrl?: string | null +} + +/** 前端使用的游戏视角枚举;后端映射尚未冻结。 */ +export type CharacterPerspective = 'side' | 'top-down' | 'isometric' + +/** 前端使用的移动方向枚举;后端映射尚未冻结。 */ +export type DirectionalMovement = 'single' | 'four-way' | 'eight-way' + +/** 游戏视角的页面文案。 */ +export const CHARACTER_PERSPECTIVE: Record = { + side: '横版视角', + 'top-down': '俯视', + isometric: '2.5D', +} + +/** 移动方向,决定一个动作要生成几套朝向的帧。 */ +export const DIRECTIONAL_MOVEMENT: Record = { + single: '单向', + 'four-way': '四向', + 'eight-way': '八向', +} + +/** UI 使用的建议尺寸档位;不代表后端约束。 */ +export const SPRITE_SIZES = [32, 64, 128, 256, 512, 1024, 2048] as const + +/** Project 对应的一组后端接口。 */ +export interface ProjectApis { + list(query?: PageQuery): Promise> + get(id: Project['id']): Promise + create(input: CreateProjectInput): Promise + update(id: Project['id'], input: UpdateProjectInput): Promise + remove(id: Project['id']): Promise +} diff --git a/frontend/src/entities/task/index.ts b/frontend/src/entities/task/index.ts new file mode 100644 index 0000000..eaccba1 --- /dev/null +++ b/frontend/src/entities/task/index.ts @@ -0,0 +1,45 @@ +/** + * 异步任务实体,与 WorkflowRun 的页面节点是两回事。 + * + * 任务粒度 = 前端可见的一个异步步骤,而不是内部某次模型调用。 + * 任务生命周期不等于工作流节点状态,WorkflowRun 只通过 WorkflowTaskLink 记录两者关系。 + */ + +/** 后端异步任务状态;pending 表示已提交但尚未执行。 */ +export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' + +/** + * MS2 前端需要展示和恢复的三类异步步骤。 + * 完整动画内部可包含视频生成、截帧和多次图像处理,但对前端仍是一个 Task。 + */ +export type TaskType = 'character_template' | 'first_frame' | 'complete_animation' + +/** + * 创建、查询和断线恢复都使用的完整任务快照。 + * TType 在调用边界已知时保留精确任务类型;按 ID 恢复时使用默认值,等待运行时解析后再收窄。 + */ +export interface Task { + /** 后端生成的任务 ID,是查询状态和订阅事件的唯一标识。 */ + id: string + /** 后端 task_type 对应的领域判别字段,不接受任意字符串。 */ + type: TType + /** 后端任务当前状态;它不会直接改变工作流节点状态。 */ + status: TaskStatus + /** 失败原因,status 为 failed 时有值。completed 不表示工作流节点已通过。 */ + error: string | null + /** 任务产出;契约冻结前保持 unknown,避免调用方依赖猜测结构。 */ + result: unknown +} + +/** 每条事件携带同类型 Task 的完整状态,taskId 对应 Task.id。 */ +export interface TaskEvent extends Omit, 'id'> { + taskId: Task['id'] +} + +/** Task 对应的一组后端接口。服务端没有取消能力,因此这里不声明 cancel。 */ +export interface TaskApis { + /** 按后端 Task ID 读取最新快照;实现必须在运行时校验 task_type 后才返回。 */ + get(taskId: Task['id']): Promise + /** 订阅任务状态变化,返回取消订阅函数。 */ + subscribe(taskId: Task['id'], onEvent: (event: TaskEvent) => void): () => void +} diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts new file mode 100644 index 0000000..04ec0e2 --- /dev/null +++ b/frontend/src/entities/workflow-run/index.ts @@ -0,0 +1,155 @@ +import type { Task } from '../task' + +/** Quick Start 与手动工作流只改变输入方式,共用同一种运行模型。 */ +export type WorkflowDriver = 'ai' | 'manual' + +/** 创建 WorkflowRun 时要完成的用户意图。 */ +export type WorkflowRunPurpose = 'create_character' | 'add_action' + +/** + * 流程步骤类型的唯一标准顺序;它不是后端 Workflow 或 Execution 定义。 + * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.nodes 的数组位置表达。 + */ +export const WORKFLOW_STEP_ORDER = [ + 'character-setup', + 'character-template', + 'template-candidate', + 'action-setup', + 'first-frame', + 'complete-animation', + 'review', + 'export', +] as const + +/** 前端流程步骤类型,与 WORKFLOW_STEP_ORDER 的成员保持一致。 */ +export type WorkflowStepType = (typeof WORKFLOW_STEP_ORDER)[number] + +/** + * 步骤的可用性和执行结果;不直接复用后端任务状态。 + * locked/available 表示尚未执行,active 表示当前页面阶段,passed/failed 表示结果。 + */ +export type WorkflowStepStatus = 'locked' | 'available' | 'active' | 'passed' | 'failed' + +/** + * 单个版本的生命周期。 + * abandoned 表示停止沿用但仍保留为历史。 + */ +export type WorkflowRevisionStatus = 'active' | 'completed' | 'failed' | 'abandoned' + +/** + * 整次流程的汇总状态。 + * interrupted 只表示用户主动停止自动推进:历史仍保留且可只读查看,它不等于 failed 或 completed。 + * 后端 Task 是否真正停止是独立问题;从历史重启成功后可重新进入 active。 + */ +export type WorkflowRunStatus = 'active' | 'interrupted' | 'completed' | 'failed' + +/** 当前版本在生成阶段的汇总状态;素材准备期间为 not_started。 */ +export type GenerationStatus = 'not_started' | 'in_progress' | 'completed' | 'failed' + +/** 当前版本在导出阶段的汇总状态。 */ +export type ExportStatus = 'not_exported' | 'exporting' | 'exported' | 'failed' + +/** + * 一个 Revision 中已经进入执行线的流程步骤。 + * 步骤自身不重复保存顺序;其在 nodes 中的数组位置就是该版本的执行顺序。 + */ +export interface WorkflowStep { + /** 只用于编排和页面定位,不作为业务 ID 发送给后端。 */ + id: string + type: WorkflowStepType + status: WorkflowStepStatus + /** 进入步骤时保存的输入快照。 */ + input: unknown + /** 步骤完成后的结果或引用;尚无结果时为 null。 */ + output: unknown + /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ + referenceStepIds: string[] +} + +/** 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 */ +export interface WorkflowRevision { + id: string + /** 首次创建的版本没有来源,因此为 null。 */ + basedOnRevisionId: string | null + /** 在来源版本中选择的重启步骤 ID;非重启创建的版本为 null。 */ + restartStepId: string | null + status: WorkflowRevisionStatus + /** + * 已进入当前执行线的步骤;数组位置是该版本步骤顺序的唯一来源。 + * 尚未推进到的后续步骤可以不存在;完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 + */ + steps: WorkflowStep[] + generationStatus: GenerationStatus + exportStatus: ExportStatus + createdAt: string +} + +/** + * 一次由前端推进的页面流程。 + * 步骤怎么走由前端决定;WorkflowRun 本身由后端持久化,前端通过 WorkflowRunApis 读写。 + */ +export interface WorkflowRun { + id: string + projectId: string + /** 已关联的 Character ID;角色尚未创建或确认时为 null。 */ + characterId: string | null + /** 已有角色加动作时的目标造型;新建角色时为 null。 */ + outfitId: string | null + purpose: WorkflowRunPurpose + driver: WorkflowDriver + status: WorkflowRunStatus + /** 当前可编辑版本 ID;必须能在 revisions 中找到。 */ + currentRevisionId: string + /** 按创建顺序保存的全部版本;历史版本保留用于只读查看和重启。 */ + revisions: WorkflowRevision[] + /** Quick Start 的规范化提示词;空白输入或手动模式无提示词时为 null。 */ + prompt: string | null +} + +/** 两种入口共享的创建字段。 */ +interface CreateWorkflowRunInputBase { + projectId: string + driver: WorkflowDriver + /** Quick Start 的自然语言需求;提交时去除首尾空白,空字符串按 null 保存。 */ + prompt?: string +} + +/** + * 创建 WorkflowRun 的输入。 + * add_action 分支把已有角色、造型、母版和基准帧设为必填,避免创建无法恢复的半成品运行。 + */ +export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & + ( + | { + purpose: 'create_character' + characterId?: never + outfitId?: never + characterTemplateUrl?: never + baseFrameUrls?: never + } + | { + purpose: 'add_action' + characterId: string + outfitId: string + characterTemplateUrl: string + baseFrameUrls: readonly string[] + } + ) + +/** 前端编排关联;后端 Task 不需要认识 WorkflowRun、Revision 或流程步骤。 */ +export interface WorkflowTaskLink { + taskId: Task['id'] + runId: WorkflowRun['id'] + /** 发起任务时所在的版本 ID,避免结果写入后来创建的新版本。 */ + revisionId: WorkflowRevision['id'] + /** 发起任务的步骤 ID,用于把结果映射回正确页面阶段。 */ + stepId: WorkflowStep['id'] +} + +/** WorkflowRun 对应的一组后端接口;只负责存取,不负责推进。 */ +export interface WorkflowRunApis { + get(runId: WorkflowRun['id']): Promise + create(input: CreateWorkflowRunInput): Promise + /** 保存已由 WorkflowController 完成合法状态转换的流程快照。 */ + save(run: WorkflowRun): Promise +} From 73d7b981c3011eafe8947e388146536ba9053686 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:24 +0800 Subject: [PATCH 06/29] feat(features): add use case layer contracts The workflow steps the mentor reviewed need an explicit home separate from both entities and pages. Add contracts for character setup, generation, review, export and the workflow controller. Each use case has a named boundary that pages call instead of reaching into entities. --- .../src/features/character-setup/index.ts | 7 ++ frontend/src/features/export/index.ts | 6 ++ frontend/src/features/generation/index.ts | 6 ++ frontend/src/features/review/index.ts | 7 ++ .../src/features/workflow-controller/index.ts | 67 +++++++++++++++++++ 5 files changed, 93 insertions(+) create mode 100644 frontend/src/features/character-setup/index.ts create mode 100644 frontend/src/features/export/index.ts create mode 100644 frontend/src/features/generation/index.ts create mode 100644 frontend/src/features/review/index.ts create mode 100644 frontend/src/features/workflow-controller/index.ts diff --git a/frontend/src/features/character-setup/index.ts b/frontend/src/features/character-setup/index.ts new file mode 100644 index 0000000..44c9a7b --- /dev/null +++ b/frontend/src/features/character-setup/index.ts @@ -0,0 +1,7 @@ +import type { CreateCharacterInput } from '@/entities' + +/** 填写角色资料并提交母版生成。 */ +export interface CharacterSetupProps { + projectId: string + onSubmit(input: CreateCharacterInput): void +} diff --git a/frontend/src/features/export/index.ts b/frontend/src/features/export/index.ts new file mode 100644 index 0000000..adfc5aa --- /dev/null +++ b/frontend/src/features/export/index.ts @@ -0,0 +1,6 @@ +/** 把确认后的造型与动作导出到资产库。 */ +export interface ExportProps { + runId: string + characterId: string + outfitId: string +} diff --git a/frontend/src/features/generation/index.ts b/frontend/src/features/generation/index.ts new file mode 100644 index 0000000..47f5997 --- /dev/null +++ b/frontend/src/features/generation/index.ts @@ -0,0 +1,6 @@ +/** 触发并展示一次生成;不感知后端调用的是哪个模型。 */ +export interface GenerationProps { + runId: string + /** 生成母版等非动作任务可以省略。 */ + actionId?: string +} diff --git a/frontend/src/features/review/index.ts b/frontend/src/features/review/index.ts new file mode 100644 index 0000000..8a897e8 --- /dev/null +++ b/frontend/src/features/review/index.ts @@ -0,0 +1,7 @@ +/** 逐帧审核:打回有问题的帧。不设「通过此帧」,默认通过。 */ +export interface ReviewProps { + actionId: string + frameIndex: number + onSelectFrame(index: number): void + onRejectFrame(index: number): void +} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts new file mode 100644 index 0000000..3ccff25 --- /dev/null +++ b/frontend/src/features/workflow-controller/index.ts @@ -0,0 +1,67 @@ +import type { + CreateWorkflowRunInput, + WorkflowRevision, + WorkflowRun, + WorkflowStep, +} from '@/entities' + +/** 更新当前 Revision 中某个步骤的业务数据。 */ +export interface UpdateWorkflowStepInput { + stepId: WorkflowStep['id'] + data: unknown +} + +/** 从指定 Revision 的指定步骤建立新的执行版本。 */ +export interface RestartWorkflowFromStepInput { + revisionId: WorkflowRevision['id'] + stepId: WorkflowStep['id'] +} + +/** 把某次服务端调用的结果写回目标步骤。 */ +export interface ApplyServerResultInput { + /** 发起请求时所属的 Revision,防止旧的异步结果污染重启后的新版本。 */ + revisionId: WorkflowRevision['id'] + stepId: WorkflowStep['id'] + result: unknown +} + +/** + * Quick Start 与手动工作流共用的流程推进边界,不含界面。 + * 两套界面共享同一套流程:手动模式一次推进一步,Quick Start 连续推进到终点。 + * + * Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份 + * 步骤数据,不拆成互不共享状态的独立模块。 + * + * 步骤怎么走由前端决定,服务端只负责持久化 WorkflowRun 和提供生成能力。 + */ +export interface WorkflowController { + /** 初始化一条创建角色或增加动作的流程。 */ + create(input: CreateWorkflowRunInput): Promise + + /** 读取当前维护的完整流程快照。 */ + getWorkflow(): WorkflowRun + + /** 按前端规则完成当前步骤并进入下一步;需要服务端时创建对应的 generation。 */ + nextStep(): Promise + + /** 连续推进到终点,Quick Start 使用。 */ + runToCompletion(): Promise + + /** 更新指定步骤的数据;页面不绕过 Controller 直接改流程状态。 */ + updateStep(input: UpdateWorkflowStepInput): Promise + + /** + * 把服务端返回的结果写回目标步骤。 + * 目标 Revision 已被重启取代时丢弃该结果,不写入新的执行线。 + */ + applyServerResult(input: ApplyServerResultInput): Promise + + /** + * 从历史步骤开出新的执行线。 + * 旧 Revision 保留为只读历史,不会被改写成失败或完成。 + */ + restartFromStep(input: RestartWorkflowFromStepInput): Promise + + /** 用户主动停止自动推进;历史保留,不等于失败或完成。 */ + interrupt(): Promise +} From 6b21545783682ec3dcbcc6274129c261a8aadb7c Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:53 +0800 Subject: [PATCH 07/29] feat(pages): add page modules Routing targets have to exist before the application shell can wire them together. Add the home, projects, project detail, quick start, workflow editor, asset library, playtest and not found page modules. Every route in the product design maps to exactly one page module. --- frontend/src/pages/asset-library/index.tsx | 9 +++++++++ frontend/src/pages/home/index.tsx | 13 +++++++++++++ frontend/src/pages/not-found/index.tsx | 9 +++++++++ frontend/src/pages/playtest/index.tsx | 9 +++++++++ frontend/src/pages/project-detail/index.tsx | 9 +++++++++ frontend/src/pages/projects/index.tsx | 9 +++++++++ frontend/src/pages/quick-start/index.tsx | 9 +++++++++ frontend/src/pages/workflow-editor/index.tsx | 9 +++++++++ 8 files changed, 76 insertions(+) create mode 100644 frontend/src/pages/asset-library/index.tsx create mode 100644 frontend/src/pages/home/index.tsx create mode 100644 frontend/src/pages/not-found/index.tsx create mode 100644 frontend/src/pages/playtest/index.tsx create mode 100644 frontend/src/pages/project-detail/index.tsx create mode 100644 frontend/src/pages/projects/index.tsx create mode 100644 frontend/src/pages/quick-start/index.tsx create mode 100644 frontend/src/pages/workflow-editor/index.tsx diff --git a/frontend/src/pages/asset-library/index.tsx b/frontend/src/pages/asset-library/index.tsx new file mode 100644 index 0000000..016328b --- /dev/null +++ b/frontend/src/pages/asset-library/index.tsx @@ -0,0 +1,9 @@ +/** 资产库。 */ +export function AssetLibraryPage() { + return ( +
+

资产库

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx new file mode 100644 index 0000000..7a153ad --- /dev/null +++ b/frontend/src/pages/home/index.tsx @@ -0,0 +1,13 @@ +/** 首页:入口与项目概览。 */ +export function HomePage() { + return +} + +function PagePlaceholder({ title }: { title: string }) { + return ( +
+

{title}

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/not-found/index.tsx b/frontend/src/pages/not-found/index.tsx new file mode 100644 index 0000000..d35181b --- /dev/null +++ b/frontend/src/pages/not-found/index.tsx @@ -0,0 +1,9 @@ +/** 页面不存在。 */ +export function NotFoundPage() { + return ( +
+

页面不存在

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx new file mode 100644 index 0000000..9796e86 --- /dev/null +++ b/frontend/src/pages/playtest/index.tsx @@ -0,0 +1,9 @@ +/** 核验台。 */ +export function PlaytestPage() { + return ( +
+

核验台

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/project-detail/index.tsx b/frontend/src/pages/project-detail/index.tsx new file mode 100644 index 0000000..cc6ff5c --- /dev/null +++ b/frontend/src/pages/project-detail/index.tsx @@ -0,0 +1,9 @@ +/** 项目详情。 */ +export function ProjectDetailPage() { + return ( +
+

项目详情

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx new file mode 100644 index 0000000..8f807f7 --- /dev/null +++ b/frontend/src/pages/projects/index.tsx @@ -0,0 +1,9 @@ +/** 项目列表。 */ +export function ProjectsPage() { + return ( +
+

项目列表

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx new file mode 100644 index 0000000..1ffe9fe --- /dev/null +++ b/frontend/src/pages/quick-start/index.tsx @@ -0,0 +1,9 @@ +/** 快速开始。 */ +export function QuickStartPage() { + return ( +
+

快速开始

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx new file mode 100644 index 0000000..59df779 --- /dev/null +++ b/frontend/src/pages/workflow-editor/index.tsx @@ -0,0 +1,9 @@ +/** 工作流画布。 */ +export function WorkflowEditorPage() { + return ( +
+

工作流画布

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} From e5de44093ada07325cc040ab5013234e61a7bb40 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:53 +0800 Subject: [PATCH 08/29] feat(app): add application shell and entry point The page modules need a composition root that owns routing and layout. Add the app shell with its layout and route table, the browser entry point, and the base stylesheet. The application runs end to end and every page is reachable. --- frontend/src/app/app.tsx | 36 +++++++++++++++++++++++++++++++ frontend/src/app/index.ts | 1 + frontend/src/app/layout/index.tsx | 32 +++++++++++++++++++++++++++ frontend/src/index.css | 8 +++++++ frontend/src/main.tsx | 11 ++++++++++ 5 files changed, 88 insertions(+) create mode 100644 frontend/src/app/app.tsx create mode 100644 frontend/src/app/index.ts create mode 100644 frontend/src/app/layout/index.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx new file mode 100644 index 0000000..b46ae87 --- /dev/null +++ b/frontend/src/app/app.tsx @@ -0,0 +1,36 @@ +import { BrowserRouter, Route, Routes } from 'react-router' + +import { AssetLibraryPage } from '@/pages/asset-library' +import { HomePage } from '@/pages/home' +import { NotFoundPage } from '@/pages/not-found' +import { PlaytestPage } from '@/pages/playtest' +import { ProjectDetailPage } from '@/pages/project-detail' +import { ProjectsPage } from '@/pages/projects' +import { QuickStartPage } from '@/pages/quick-start' +import { WorkflowEditorPage } from '@/pages/workflow-editor' +import { AppShell } from './layout' + +/** + * 路由表与全局外壳。 + * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。 + */ +export function App() { + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} diff --git a/frontend/src/app/index.ts b/frontend/src/app/index.ts new file mode 100644 index 0000000..f1c81c7 --- /dev/null +++ b/frontend/src/app/index.ts @@ -0,0 +1 @@ +export { App } from './app' diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx new file mode 100644 index 0000000..c69669a --- /dev/null +++ b/frontend/src/app/layout/index.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react' +import { Link } from 'react-router' + +/** + * 模块五:跨页面常驻导航。不属于任何单个页面,故上提到 app 层。 + * 页内头部 PageHeader 在 shared/ui —— pages 不能 import app 层。 + */ + +export interface AppShellProps { + /** 渲染在全局导航下方的当前路由页面。 */ + children: ReactNode +} + +/** 全站外壳,全局导航常驻。 */ +export function AppShell({ children }: AppShellProps) { + return ( +
+ +
{children}
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..f80172a --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,8 @@ +@import 'tailwindcss'; + +/* 全局只放这一点点:其余样式一律走 Tailwind 工具类,避免样式散落各处。 */ +html, +body, +#root { + height: 100%; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..ff3b910 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' + +import { App } from '@/app' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + , +) From 0a13193592df74e89d4c4ffa16cae87f9c16bfb2 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:53 +0800 Subject: [PATCH 09/29] ci(frontend): add quality gate and preview deployment Reviewers need the branch checked automatically and a running preview to look at. Add the frontend workflow running lint, typecheck, test and build, and scope Vercel previews to frontend changes. Every pull request reports its own build status and preview URL. --- .github/workflows/frontend-ci.yml | 56 +++++++++++++++++++++++++++++++ frontend/vercel.json | 13 +++++++ 2 files changed, 69 insertions(+) create mode 100644 .github/workflows/frontend-ci.yml create mode 100644 frontend/vercel.json diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 0000000..fa48364 --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,56 @@ +name: Frontend CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: frontend-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + frontend-checks: + name: Frontend checks + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + CI: 'true' + + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Check formatting + run: npm run format:check + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm run test + + - name: Build + run: npm run build diff --git a/frontend/vercel.json b/frontend/vercel.json new file mode 100644 index 0000000..c99f398 --- /dev/null +++ b/frontend/vercel.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "installCommand": "npm ci", + "buildCommand": "npm run build", + "outputDirectory": "dist", + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +} From 33b6fa32595bc096b8726a157713fe3b66694a47 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:53 +0800 Subject: [PATCH 10/29] docs(frontend): document architecture and backend contract The module boundaries and the interfaces expected from the backend must be reviewable without reading the code. Add the architecture document, the frontend README, and the API contract listing every endpoint the frontend calls. Backend and frontend can align on one written list of interfaces. --- frontend-architecture-v3.md | 101 ++++++++++++++++++++++++++++++++ frontend/API_CONTRACT.md | 114 ++++++++++++++++++++++++++++++++++++ frontend/README.md | 30 ++++++++++ 3 files changed, 245 insertions(+) create mode 100644 frontend-architecture-v3.md create mode 100644 frontend/API_CONTRACT.md create mode 100644 frontend/README.md diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md new file mode 100644 index 0000000..01b8926 --- /dev/null +++ b/frontend-architecture-v3.md @@ -0,0 +1,101 @@ +# Windup 前端架构 + +本文记录当前前端的模块划分与依赖规则。2026-07-30 按当日评审意见重写:本阶段只提交模块边界与接口,实现进后续 PR。 + +--- + +## 1. 模块划分 + +业务模块都在 `src/entities/` 下: + +| 模块 | 职责 | +|---|---| +| `project` | 项目级全局约束:视角、朝向数、精灵尺寸、画风 | +| `character` | 角色资产。造型、动作、帧是它内部的一棵树 | +| `action-template` | 能跨角色复用的动作配方 | +| `generation` | 一次生成任务这份业务数据 | +| `media` | 已上传媒体的不透明引用 | +| `task` | 后端异步步骤的状态 | +| `workflow-run` | 制作流程的运行记录 | +| `playtest-inspection` | 核验结论,独立于角色与流程 | + +**模块判据:这个东西能不能被单独取到。** + +能单独取,说明它需要自己的一套取数逻辑,才值得一个模块;取不到的,它只是别人身上的一个字段。 + +按这条判据,`Outfit`、`Action`、`Frame` 没有独立模块——它们不能脱离 `Character` 被取到,所以是 `character` 内部的类型。`ActionTemplate` 有独立模块,因为它能被不同角色复用。 + +--- + +## 2. 层次 + +```text +pages -> features -> entities -> shared +``` + +| 层 | 内容 | +|---|---| +| `pages` | 八个路由页面 | +| `features` | 用户操作:角色设置、生成、审核、导出;以及流程推进 `workflow-controller` | +| `entities` | 上表业务模块 | +| `shared` | 无业务语义的形状,目前只有分页 | + +`app` 只做启动和路由,不构造服务、不向下注入。 + +### 依赖规则 + +1. 只能向下依赖,不允许反向。 +2. 同层模块之间不互相导入。要共用就往下沉。 +3. 跨模块只从模块目录的 `index.ts` 进入;`entities` 统一从 `@/entities` 使用。 +4. `entities` 内部模块之间可以互相导入,对外仍是一个门。 + +--- + +## 3. 接口命名 + +每个模块暴露一组后端接口,统一叫 `XxxApis`: + +```text +ProjectApis CharacterApis ActionTemplateApis GenerationApis +TaskApis WorkflowRunApis PlaytestInspectionApis +``` + +**不使用 `Repository` / `Port` / `Adapter` 这些叫法**,也不做接口与实现的分离——实现跟着接口放在同一个模块里。 + +--- + +## 4. 流程推进 + +`features/workflow-controller` 是快速开始与手动工作流共用的推进边界,不含界面。 + +Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份步骤数据,不拆成互不共享状态的独立模块。 + +步骤顺序固定八步: + +```text +角色资料 → 角色图 → 候选选择 → 动作资料 → 首帧 → 完整动画 → 审核 → 导出 +``` + +**步骤怎么走由前端决定。** `WorkflowRunApis` 只负责存取运行记录,不负责推进。 + +从历史步骤重开会追加一个新 Revision,旧 Revision 保留为只读历史,不会被改写成失败或完成。 + +快速开始与手动模式共用同一份推进逻辑,区别只是前者连续调用、后者一次一步。隐藏步骤不等于跳过步骤——门禁写在流程模型里,不在界面里。 + +--- + +## 5. 本次不包含 + +- 任何实现代码(真实请求、假数据、组件内部逻辑) +- 测试文件 +- 图片上传模块(体量太小,本次不单独体现) +- 穿戴道具相关(产品侧未设计) +- 第三方登录 + +页面当前是占位外壳,只声明路由与模块边界。 + +--- + +## 6. 未与后端对齐的部分 + +明细见 `frontend/API_CONTRACT.md`。 diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md new file mode 100644 index 0000000..f5a3206 --- /dev/null +++ b/frontend/API_CONTRACT.md @@ -0,0 +1,114 @@ +# 前后端接口对齐清单 + +前端各模块的 `XxxApis` 与后端 PR #64(head `10dd958`)逐条比对结果。 + +后端现有四个相关模块:`project`、`character`、`generation`、`media`。`asset` 与 `wearable` 已按 07-30 评审要求删除。 + +--- + +## 一、前端预期有、后端目前没有 + +**这几条需要后端明确做或不做;不做的前端删掉。** + +| 前端接口 | 后端情况 | +|---|---| +| `WorkflowRunApis`(`get` / `create` / `save`) | 没有 workflow 模块 | +| `ActionTemplateApis.listAvailable` | 没有 action template 模块 | +| `PlaytestInspectionApis`(`getLatest` / `record`) | 没有对应模块 | +| 独立的 `task` 模块 | 后端 Task 不独立,是 `generation` 内的 `GenerationTask` | + +前端已按服务端现状去掉 `TaskApis.cancel`——`GenerationService` 没有取消能力,不声明前端用不到的接口。 + +--- + +## 二、形状不一致 + +### 生成步骤:后端一步,前端两步 + +| 后端 `GenerationType` | 前端 `GenerationType` | +|---|---| +| `character_image` | `character_template` | +| `character_action` | `first_frame` → `complete_animation` | + +前端设计是先出动作首帧、用户确认后再生成完整动画。后端 `generate_character_action` 是一次到底。 + +**这不是命名差异,是交互差异。需要确认首帧确认这一步保不保。** + +### 其他 + +| 项 | 后端 | 前端 | +|---|---|---| +| 角色列表 | `list_characters` 分页,返回 `(list, total)` | `listByProject` 无分页 | +| 更新角色 | `update_character(character_id, **fields)` 部分更新 | `update(character)` 整棵树替换 | +| 查任务 | `get_task(project_id, task_id)` 需要 `project_id` | `get(taskId)` 只传 taskId | +| 动作类型 | `walk` `idle` `attack` `custom` | 多一个 `jump` | +| 删除项目 | 返回 `bool`(是否找到) | 返回 `void` | + +ID 类型后端为 `int`、前端为 `string`,由前端转换层处理,不需要后端改动。 + +--- + +## 三、后端有、前端没接 + +| 后端 | 说明 | +|---|---| +| `project_name_exists(user_id, project_name)` | 建项目时的重名校验,前端未接 | +| `delete_character` | 前端 `CharacterApis` 没有删除 | +| `Character.description` | 后端存在实体上;前端只在创建入参里,创建完查不到 | +| `Character.reference_image_url` | 后端存在实体上;前端 `Character` 类型没有这个字段 | +| `MediaService.upload` | 前端本次未提交上传模块 | + +--- + +## 四、审核数据无处保存 + +后端 `character_data` 的嵌套结构(见 `character/model.py`): + +```text +outfits[] → id / name / preview_url / actions[] +actions[] → id / type / name / loop / fps / frame_count / frames[] +frames[] → index / image_url / duration_ms +``` + +前端以下字段在后端结构里没有落点: + +- `Frame.qc`(系统质检结论) +- `Frame.rejected`(人工打回) +- `Action.kind`(preset / custom 来源) +- `Action.status`(planned / generating / candidate / confirmed / failed) +- `Action.keyFrameIndex` +- `Frame.rootMotion` +- `Outfit.candidateCharacterTemplates`(母版候选列表) +- `Outfit.baseFrames` + +**其中 `qc` 与 `rejected` 是审核台的全部依据。后端不存这两个字段,审核结果就落不了库。** + +`candidateCharacterTemplates` 同理:前端设计是生成多张候选让用户选,后端 `CharacterImageInput.num_images` 默认 1,`character_data` 里也没有候选列表。 + +--- + +## 五、概念不一致 + +后端 `character/model.py` 字段说明: + +> `reference_image_url`: 角色参考图,即旧概念中的 Character Template + +前端把这两者当成不同的东西: + +- 用户上传的参考图 —— 创建角色时的输入 +- AI 生成后用户选定的角色图(母版)—— `Outfit.characterTemplateUrl` + +**后端合成了一个字段。** 07-30 评审也提到「模板」这个叫法容易与 action template 混淆,暂改称「角色图」。三方对这里是几个概念的理解需要统一。 + +--- + +## 待确认 + +- [ ] 第一节四条:后端做还是不做 +- [ ] 动作生成一步还是两步 +- [ ] `Frame.qc` / `Frame.rejected` 落不落库 +- [ ] 母版候选几张 +- [ ] 参考图与角色图是一个字段还是两个 +- [ ] `Character.description` 前端要不要跟着存 +- [ ] `jump` 动作类型后端加不加 +- [ ] 上传模块何时提交 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..6e01fa3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,30 @@ +# Windup 前端 + +React + Vite + TypeScript。 + +## 开发 + +```bash +npm ci +npm run dev +``` + +## 检查 + +```bash +npm run format:check # 格式 +npm run lint # 静态检查 +npm run typecheck # 类型 +npm run test # 测试(本阶段无测试文件) +npm run build # 构建 +``` + +CI 按上面顺序全跑一遍。 + +## 结构 + +模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。 + +**本阶段只提交模块边界与接口,不含实现。** 页面是占位外壳,各模块只有类型与 `XxxApis` 接口。实现按模块拆成后续 PR。 + +与后端尚未对齐的接口见 `API_CONTRACT.md`。 From 01b0e0a1ffca078486a13850f4b3ae9a44fad4a2 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 17:10:30 +0800 Subject: [PATCH 11/29] fix(workflow): persist the in-flight task id on the step A run reopened after a refresh could not tell that a generation was still running, so the page had to either resubmit it or stall. Record the in-flight task id on WorkflowStep so it is saved with the run, and drop WorkflowTaskLink, which no structure ever held. Reopening a run recovers the pending task from its step instead of starting a second generation. --- frontend/src/entities/index.ts | 1 - frontend/src/entities/task/index.ts | 3 ++- frontend/src/entities/workflow-run/index.ts | 16 ++++++---------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 804de8b..113ddaa 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -74,7 +74,6 @@ export type { WorkflowRunApis, WorkflowRunPurpose, WorkflowRunStatus, - WorkflowTaskLink, } from './workflow-run' /* Playtest 核验记录 —— 独立于 Character 与 WorkflowRun */ diff --git a/frontend/src/entities/task/index.ts b/frontend/src/entities/task/index.ts index eaccba1..bd64883 100644 --- a/frontend/src/entities/task/index.ts +++ b/frontend/src/entities/task/index.ts @@ -2,7 +2,8 @@ * 异步任务实体,与 WorkflowRun 的页面节点是两回事。 * * 任务粒度 = 前端可见的一个异步步骤,而不是内部某次模型调用。 - * 任务生命周期不等于工作流节点状态,WorkflowRun 只通过 WorkflowTaskLink 记录两者关系。 + * 任务生命周期不等于工作流节点状态,两者只由 WorkflowStep.taskId 单向关联: + * 步骤记得自己发起过哪个任务,任务不认识步骤。 */ /** 后端异步任务状态;pending 表示已提交但尚未执行。 */ diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 04ec0e2..c4a715d 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -62,6 +62,12 @@ export interface WorkflowStep { input: unknown /** 步骤完成后的结果或引用;尚无结果时为 null。 */ output: unknown + /** + * 本步骤已提交、结果尚未写回 output 的生成任务 ID;没有在途任务时为 null。 + * 它随 WorkflowRun 一起持久化,页面重开后据此查回在途任务的状态, + * 因而不会重复发起同一次生成。任务本身不认识步骤,反向关联不存在。 + */ + taskId: Task['id'] | null /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ referenceStepIds: string[] } @@ -136,16 +142,6 @@ export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & } ) -/** 前端编排关联;后端 Task 不需要认识 WorkflowRun、Revision 或流程步骤。 */ -export interface WorkflowTaskLink { - taskId: Task['id'] - runId: WorkflowRun['id'] - /** 发起任务时所在的版本 ID,避免结果写入后来创建的新版本。 */ - revisionId: WorkflowRevision['id'] - /** 发起任务的步骤 ID,用于把结果映射回正确页面阶段。 */ - stepId: WorkflowStep['id'] -} - /** WorkflowRun 对应的一组后端接口;只负责存取,不负责推进。 */ export interface WorkflowRunApis { get(runId: WorkflowRun['id']): Promise From 1f0ddcae55fd46b5b7b6635b8ded4fd768809809 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 17:27:26 +0800 Subject: [PATCH 12/29] build(frontend): typecheck the vitest config The vitest config sat outside every tsconfig include, so a type error in it would only surface when the runner started. Add it to the node project alongside the vite config. Both build-side configs are now covered by tsc -b. --- frontend/tsconfig.node.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 8455dcb..d3daab6 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -19,5 +19,5 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts"] } From 2be18e3ad535cef96acf91a459a7af57ded4cc1f Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 17:27:26 +0800 Subject: [PATCH 13/29] refactor(entities): keep only confirmed content in the asset tree A character is written when the user exports to the asset library, so it cannot hold work-in-progress state; the backend also returns no QC verdict and the product has no per-frame reject. Drop the action generation status, the frame qc and rejected fields, and the standalone playtest inspection entity; review now only reads frames. The asset tree describes confirmed assets only, and review results no longer need a second home. --- frontend/src/entities/character/index.ts | 49 ++++++++++--------- frontend/src/entities/index.ts | 10 ---- .../src/entities/playtest-inspection/index.ts | 29 ----------- frontend/src/features/review/index.ts | 9 +++- 4 files changed, 32 insertions(+), 65 deletions(-) delete mode 100644 frontend/src/entities/playtest-inspection/index.ts diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index db8315b..eecd8a1 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -10,22 +10,6 @@ export type ActionKind = 'preset' | 'custom' */ export type ActionType = 'walk' | 'idle' | 'attack' | 'jump' | 'custom' -/** 动作在生成流水线上的位置。 */ -export type ActionStatus = - /** 已加入工作流但还没开始生成 */ - | 'planned' - /** 后端任务进行中 */ - | 'generating' - /** 生成完成,是候选,还没被用户确认 */ - | 'candidate' - /** 用户确认后成为正式资产 */ - | 'confirmed' - /** 生成失败 */ - | 'failed' - -/** 系统质检结论。系统通过不等于人工通过,分开记。 */ -export type FrameQcResult = 'pending' | 'passed' | 'failed' - /** 单帧相对动作首帧的根位移,单位为像素。 */ export interface FrameRootMotion { dx: number @@ -33,7 +17,12 @@ export interface FrameRootMotion { dy: number } -/** 动作序列中的一张有序画面;帧序号由其在 Action.frames 中的位置决定。 */ +/** + * 动作序列中的一张有序画面;帧序号由其在 Action.frames 中的位置决定。 + * + * 不带任何审核字段:服务端只交付生成好的帧,不返回质检结论;用户侧的审核也只是查看, + * 没有打回。前端若要做自动质检,那是读取帧之后在本地算出来的临时结论,不属于资产数据。 + */ export interface Frame { imageUrl: string /** @@ -43,10 +32,6 @@ export interface Frame { durationMs: number | null /** null 表示不提供根位移,Playtest 与 Export 不应据此施加任何位移。 */ rootMotion: FrameRootMotion | null - /** 后端自动质检结论,与人工 rejected 状态相互独立。 */ - qc: FrameQcResult - /** 人工退回。不设「通过此帧」,故是布尔量而非三态。 */ - rejected: boolean } /** 一张母版候选;attemptId 用于区分候选所属的生成尝试。 */ @@ -69,6 +54,10 @@ export interface BaseFrame { /** 某个角色造型下的一段动画动作。 */ export interface Action { + /** + * 仅在所属 Outfit 内唯一。动作没有自己的表,整棵树存在 character 记录里, + * 因此不存在全局唯一的动作 ID:任何按 ID 定位动作的地方都必须同时带上造型。 + */ id: string outfitId: Outfit['id'] name: string @@ -76,7 +65,6 @@ export interface Action { kind: ActionKind /** 动作业务语义;与 kind 的 preset/custom 来源维度相互独立。 */ type: ActionType - status: ActionStatus /** * 每秒播放帧数。仅当某帧 durationMs 为 null 时用于等时长回退; * Playtest 与 Export 不得用前端全局常量替代,也不得覆盖帧自己的 durationMs。 @@ -87,12 +75,20 @@ export interface Action { * 非 null 值必须指向当前 frames 数组内的成员。 */ keyFrameIndex: number | null - /** 按播放顺序排列的帧;数组下标就是零基帧序号。 */ + /** + * 按播放顺序排列的帧;数组下标就是零基帧序号。 + * 当前只表达单朝向。Project.directionalMovement 的四向/八向要如何落到这里 + * (本层再分组,还是一个朝向一条 Action)尚未有产品定义,不要凭猜先定结构。 + */ frames: Frame[] } /** 同一角色的一套独立造型;MVP UI 只展示第一套,但数据结构不折叠该层。 */ export interface Outfit { + /** + * 仅在所属 Character 内唯一。造型没有自己的表,与动作一起存在 character 记录里, + * 因此不存在全局唯一的造型 ID:任何按 ID 定位造型的地方都必须同时带上角色。 + */ id: string characterId: string name: string @@ -106,7 +102,12 @@ export interface Outfit { actions: Action[] } -/** 项目下的角色资产;造型拥有各自的母版和动作帧。 */ +/** + * 项目下的角色资产;造型拥有各自的母版和动作帧。 + * + * 这棵树只承载已导出到资产库的内容,因此其中的动作一律是已确认的,不带生成过程状态。 + * 工作流运行期间的造型、动作和帧活在 WorkflowRun 的步骤里,直到用户确认导出才整体写入。 + */ export interface Character { id: string projectId: string diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 113ddaa..6a4ce9c 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -18,7 +18,6 @@ export type { export type { Action, ActionKind, - ActionStatus, ActionType, BaseFrame, Character, @@ -27,7 +26,6 @@ export type { ConfirmCharacterTemplateInput, CreateCharacterInput, Frame, - FrameQcResult, FrameRootMotion, Outfit, } from './character' @@ -75,11 +73,3 @@ export type { WorkflowRunPurpose, WorkflowRunStatus, } from './workflow-run' - -/* Playtest 核验记录 —— 独立于 Character 与 WorkflowRun */ -export type { - PlaytestInspection, - PlaytestInspectionApis, - PlaytestInspectionStatus, - RecordPlaytestInspectionInput, -} from './playtest-inspection' diff --git a/frontend/src/entities/playtest-inspection/index.ts b/frontend/src/entities/playtest-inspection/index.ts deleted file mode 100644 index e984111..0000000 --- a/frontend/src/entities/playtest-inspection/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** 可持久化的 Playtest 核验结论;没有记录即表示尚未核验。 */ -export type PlaytestInspectionStatus = 'passed' | 'issues_found' - -/** 一次独立核验记录,不属于 Character 或 WorkflowRevision 的内部字段。 */ -export interface PlaytestInspection { - id: string - characterId: string - outfitId: string - /** 仅从 Workflow 进入时保留返回定位;独立入口为 null。 */ - source: { runId: string; revisionId: string } | null - status: PlaytestInspectionStatus - createdAt: string - updatedAt: string -} - -/** 创建或更新指定核验目标结论所需的业务输入。 */ -export interface RecordPlaytestInspectionInput { - characterId: string - outfitId: string - /** 可选的 Workflow 来源,不是播放和核验的前置条件。 */ - source?: { runId: string; revisionId: string } | null - status: PlaytestInspectionStatus -} - -/** PlaytestInspection 对应的一组后端接口。 */ -export interface PlaytestInspectionApis { - getLatest(target: { characterId: string; outfitId: string }): Promise - record(input: RecordPlaytestInspectionInput): Promise -} diff --git a/frontend/src/features/review/index.ts b/frontend/src/features/review/index.ts index 8a897e8..3a48b66 100644 --- a/frontend/src/features/review/index.ts +++ b/frontend/src/features/review/index.ts @@ -1,7 +1,12 @@ -/** 逐帧审核:打回有问题的帧。不设「通过此帧」,默认通过。 */ +/** + * 逐帧查看已生成的动作,供用户在导出前过一遍。 + * + * 只看不改:服务端不返回质检结论,产品上也不设「打回此帧」, + * 所以这里没有任何写操作,帧数据不会因为查看而改变。 + */ export interface ReviewProps { + /** 动作 ID 只在造型内唯一,调用方须自行持有所属造型,不能拿它跨角色定位。 */ actionId: string frameIndex: number onSelectFrame(index: number): void - onRejectFrame(index: number): void } From f9776f9b385d093b2270a086df05b821189c48da Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 17:27:26 +0800 Subject: [PATCH 14/29] docs(entities): mark what the product has not defined yet Three structures carried assumptions a reader could not distinguish from settled decisions. State that multi-direction frame storage is undefined, that the MVP runs a single revision, and that an action id is unique only inside its outfit. Open questions are visible in the contracts instead of being rediscovered later. --- frontend/src/entities/project/index.ts | 6 +++++- frontend/src/entities/workflow-run/index.ts | 8 +++++++- frontend/src/features/generation/index.ts | 6 +++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts index 778acf8..4c71164 100644 --- a/frontend/src/entities/project/index.ts +++ b/frontend/src/entities/project/index.ts @@ -58,7 +58,11 @@ export const CHARACTER_PERSPECTIVE: Record = { isometric: '2.5D', } -/** 移动方向,决定一个动作要生成几套朝向的帧。 */ +/** + * 移动方向,决定一个动作要生成几套朝向的帧。 + * 多朝向在 Action 上如何存放尚未定义,当前 Action.frames 只表达单朝向; + * 选了四向/八向的项目,生成侧还接不上,见 Action.frames 的说明。 + */ export const DIRECTIONAL_MOVEMENT: Record = { single: '单向', 'four-way': '四向', diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index c4a715d..6998d27 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -72,7 +72,13 @@ export interface WorkflowStep { referenceStepIds: string[] } -/** 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 */ +/** + * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 + * + * MVP 只走单条执行线:revisions 恒为一个成员,basedOnRevisionId 与 restartStepId 恒为 null。 + * 「从历史步骤重开并保留旧版本」尚未进入产品定义,结构先留出位置但不实现, + * 避免真要做时改动波及 WorkflowRun 的持久化形状。 + */ export interface WorkflowRevision { id: string /** 首次创建的版本没有来源,因此为 null。 */ diff --git a/frontend/src/features/generation/index.ts b/frontend/src/features/generation/index.ts index 47f5997..2015752 100644 --- a/frontend/src/features/generation/index.ts +++ b/frontend/src/features/generation/index.ts @@ -1,6 +1,10 @@ /** 触发并展示一次生成;不感知后端调用的是哪个模型。 */ export interface GenerationProps { runId: string - /** 生成母版等非动作任务可以省略。 */ + /** + * 目标动作,生成母版等非动作任务可以省略。 + * 动作 ID 只在造型内唯一,所以造型由 runId 对应的 WorkflowRun.outfitId 决定, + * 不能脱离 run 单独使用这个字段。 + */ actionId?: string } From a86d23b99b8e6db82db5cd1da7ad673aa92c4f57 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 18:24:18 +0800 Subject: [PATCH 15/29] docs(frontend): remove stale skeleton references The reduced module skeleton no longer includes playtest inspection or shared UI. Remove obsolete API and entity references and align the layout comment with app ownership. Documentation now matches the contracts and directories present in this branch. --- frontend-architecture-v3.md | 3 +-- frontend/API_CONTRACT.md | 1 - frontend/src/app/layout/index.tsx | 5 +---- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index 01b8926..1488dfb 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -17,7 +17,6 @@ | `media` | 已上传媒体的不透明引用 | | `task` | 后端异步步骤的状态 | | `workflow-run` | 制作流程的运行记录 | -| `playtest-inspection` | 核验结论,独立于角色与流程 | **模块判据:这个东西能不能被单独取到。** @@ -57,7 +56,7 @@ pages -> features -> entities -> shared ```text ProjectApis CharacterApis ActionTemplateApis GenerationApis -TaskApis WorkflowRunApis PlaytestInspectionApis +TaskApis WorkflowRunApis ``` **不使用 `Repository` / `Port` / `Adapter` 这些叫法**,也不做接口与实现的分离——实现跟着接口放在同一个模块里。 diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index f5a3206..0774b9e 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -14,7 +14,6 @@ |---|---| | `WorkflowRunApis`(`get` / `create` / `save`) | 没有 workflow 模块 | | `ActionTemplateApis.listAvailable` | 没有 action template 模块 | -| `PlaytestInspectionApis`(`getLatest` / `record`) | 没有对应模块 | | 独立的 `task` 模块 | 后端 Task 不独立,是 `generation` 内的 `GenerationTask` | 前端已按服务端现状去掉 `TaskApis.cancel`——`GenerationService` 没有取消能力,不声明前端用不到的接口。 diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx index c69669a..aa21b82 100644 --- a/frontend/src/app/layout/index.tsx +++ b/frontend/src/app/layout/index.tsx @@ -1,10 +1,7 @@ import type { ReactNode } from 'react' import { Link } from 'react-router' -/** - * 模块五:跨页面常驻导航。不属于任何单个页面,故上提到 app 层。 - * 页内头部 PageHeader 在 shared/ui —— pages 不能 import app 层。 - */ +/** 跨页面常驻导航属于应用外壳,由 app 层统一承载。 */ export interface AppShellProps { /** 渲染在全局导航下方的当前路由页面。 */ From 77fca50ae47a010ffb38e39342f96768ece4964e Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 19:48:32 +0800 Subject: [PATCH 16/29] refactor(character): remove unsupported character name The backend character contract has no standalone name field. Remove name from Character and CreateCharacterInput. The frontend now matches the confirmed character payload boundary. --- frontend/src/entities/character/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index eecd8a1..616b5db 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -111,7 +111,6 @@ export interface Outfit { export interface Character { id: string projectId: string - name: string /** 角色的全部独立造型;MVP 页面至少保留这一层,即使当前只有一个成员。 */ outfits: Outfit[] createdAt: string @@ -121,7 +120,6 @@ export interface Character { /** 创建角色并发起母版生成所需的入参。 */ export interface CreateCharacterInput { projectId: string - name: string /** 交给模型生成母版。 */ description: string referenceImageUrl?: string | null From b03250c23184737cf099d133fe815d7a285897dc Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 19:48:59 +0800 Subject: [PATCH 17/29] refactor(workflow): keep workflow run frontend-owned The fixed workflow is not a backend resource. Remove WorkflowRunApis and update the public and controller contracts. Backend responsibilities now stop at generation and confirmed asset storage. --- frontend/src/entities/index.ts | 3 +-- frontend/src/entities/workflow-run/index.ts | 16 +++++----------- .../src/features/workflow-controller/index.ts | 2 +- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 6a4ce9c..ac64b4f 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -56,7 +56,7 @@ export type { MediaReference } from './media' /* 后端异步任务 —— 与工作流节点是两回事 */ export type { Task, TaskApis, TaskEvent, TaskStatus, TaskType } from './task' -/* 工作流 —— 节点由前端推进,运行记录由后端持久化 */ +/* 工作流 —— 节点与运行状态都由前端管理 */ export { WORKFLOW_STEP_ORDER } from './workflow-run' export type { CreateWorkflowRunInput, @@ -69,7 +69,6 @@ export type { WorkflowRevision, WorkflowRevisionStatus, WorkflowRun, - WorkflowRunApis, WorkflowRunPurpose, WorkflowRunStatus, } from './workflow-run' diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 6998d27..a2a3aef 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -64,8 +64,9 @@ export interface WorkflowStep { output: unknown /** * 本步骤已提交、结果尚未写回 output 的生成任务 ID;没有在途任务时为 null。 - * 它随 WorkflowRun 一起持久化,页面重开后据此查回在途任务的状态, - * 因而不会重复发起同一次生成。任务本身不认识步骤,反向关联不存在。 + * 它由前端随 WorkflowRun 一起维护,据此查回在途任务的状态,因而不会在同一次 + * 前端运行中重复发起生成。是否写入浏览器存储属于前端实现,不形成后端契约。 + * 任务本身不认识步骤,反向关联不存在。 */ taskId: Task['id'] | null /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ @@ -98,7 +99,8 @@ export interface WorkflowRevision { /** * 一次由前端推进的页面流程。 - * 步骤怎么走由前端决定;WorkflowRun 本身由后端持久化,前端通过 WorkflowRunApis 读写。 + * 步骤推进和运行状态都由前端管理;后端不读取、不推进、也不持久化 WorkflowRun。 + * 后端只处理生成任务,并在用户最终确认时持久化角色与动作资产。 */ export interface WorkflowRun { id: string @@ -147,11 +149,3 @@ export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & baseFrameUrls: readonly string[] } ) - -/** WorkflowRun 对应的一组后端接口;只负责存取,不负责推进。 */ -export interface WorkflowRunApis { - get(runId: WorkflowRun['id']): Promise - create(input: CreateWorkflowRunInput): Promise - /** 保存已由 WorkflowController 完成合法状态转换的流程快照。 */ - save(run: WorkflowRun): Promise -} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index 3ccff25..f8ce879 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -32,7 +32,7 @@ export interface ApplyServerResultInput { * Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份 * 步骤数据,不拆成互不共享状态的独立模块。 * - * 步骤怎么走由前端决定,服务端只负责持久化 WorkflowRun 和提供生成能力。 + * 步骤和运行状态由前端管理;服务端只提供生成能力,并持久化最终确认的资产。 */ export interface WorkflowController { /** 初始化一条创建角色或增加动作的流程。 */ From 378fee028fbf9f7bdd314120db2a2450bc529feb Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 19:49:21 +0800 Subject: [PATCH 18/29] docs(frontend): record confirmed backend boundaries The API notes still described stale workflow and generation assumptions. Document frontend-owned workflow state, generation mapping, and assigned follow-ups. The architecture now reflects the latest backend reference and team decisions. --- frontend-architecture-v3.md | 8 ++-- frontend/API_CONTRACT.md | 73 ++++++++++++++++++++----------------- 2 files changed, 45 insertions(+), 36 deletions(-) diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index 1488dfb..5c06229 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -52,15 +52,17 @@ pages -> features -> entities -> shared ## 3. 接口命名 -每个模块暴露一组后端接口,统一叫 `XxxApis`: +需要访问后端资源的模块暴露一组接口,统一叫 `XxxApis`: ```text ProjectApis CharacterApis ActionTemplateApis GenerationApis -TaskApis WorkflowRunApis +TaskApis ``` **不使用 `Repository` / `Port` / `Adapter` 这些叫法**,也不做接口与实现的分离——实现跟着接口放在同一个模块里。 +`WorkflowRun` 是前端运行态,不声明后端接口。后端不读取、不推进、也不持久化它。 + --- ## 4. 流程推进 @@ -75,7 +77,7 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断 角色资料 → 角色图 → 候选选择 → 动作资料 → 首帧 → 完整动画 → 审核 → 导出 ``` -**步骤怎么走由前端决定。** `WorkflowRunApis` 只负责存取运行记录,不负责推进。 +**步骤怎么走、运行状态如何保存都由前端决定。** 后端不参与 WorkflowRun,只接收各节点发起的生成请求,并在最终确认时持久化角色与动作资产。 从历史步骤重开会追加一个新 Revision,旧 Revision 保留为只读历史,不会被改写成失败或完成。 diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index 0774b9e..e0cd7d2 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -1,57 +1,63 @@ # 前后端接口对齐清单 -前端各模块的 `XxxApis` 与后端 PR #64(head `10dd958`)逐条比对结果。 +前端各模块的 `XxxApis` 与后端 2026-07-30 接口文档逐条比对结果。 后端现有四个相关模块:`project`、`character`、`generation`、`media`。`asset` 与 `wearable` 已按 07-30 评审要求删除。 --- -## 一、前端预期有、后端目前没有 +## 一、已经确认的边界 -**这几条需要后端明确做或不做;不做的前端删掉。** +- `WorkflowRun` 是前端固定工作流的运行态。后端不读取、不推进、也不持久化,前端不声明 `WorkflowRunApis`。 +- `Character` 不使用独立 `name` 字段;前端已删除。 +- 前端保留 `jump` 动作类型,由后端补充对应枚举。 +- 前端工作流节点不与后端 `GenerationType` 一一对应,按下表调用: -| 前端接口 | 后端情况 | -|---|---| -| `WorkflowRunApis`(`get` / `create` / `save`) | 没有 workflow 模块 | -| `ActionTemplateApis.listAvailable` | 没有 action template 模块 | -| 独立的 `task` 模块 | 后端 Task 不独立,是 `generation` 内的 `GenerationTask` | +| 前端工作流节点 | 后端接口 | 后端任务类型 | +|---|---|---| +| `character_template` | `POST /generation/image` | `character_image` | +| `first_frame` | `POST /generation/image`,以上一步角色图作为参考图 | `character_image` | +| `complete_animation` | `POST /generation/action`,以已确认动作首帧作为参考图 | `character_action` | -前端已按服务端现状去掉 `TaskApis.cancel`——`GenerationService` 没有取消能力,不声明前端用不到的接口。 +图片生成和动作生成只返回任务及结果,不自动修改 WorkflowRun 或角色资产。用户最终确认后,前端再通过角色更新接口保存角色图和完整动作数据。 --- -## 二、形状不一致 +## 二、前端预期有、后端目前没有 -### 生成步骤:后端一步,前端两步 +**这些接口仍需要确定由后端提供,还是改为前端本地能力。** -| 后端 `GenerationType` | 前端 `GenerationType` | +| 前端接口 | 后端情况 | |---|---| -| `character_image` | `character_template` | -| `character_action` | `first_frame` → `complete_animation` | +| `ActionTemplateApis.listAvailable` | 没有 action template 模块 | +| `ProjectApis.update` | 没有 `PATCH /projects/{project_id}` | -前端设计是先出动作首帧、用户确认后再生成完整动画。后端 `generate_character_action` 是一次到底。 +前端已按服务端现状去掉 `TaskApis.cancel`——后端没有取消能力,不声明前端用不到的接口。 -**这不是命名差异,是交互差异。需要确认首帧确认这一步保不保。** +--- + +## 三、形状不一致 -### 其他 +这些差异可以在前端接口层转换,不要求领域类型与后端 DTO 使用相同命名。 | 项 | 后端 | 前端 | |---|---|---| | 角色列表 | `list_characters` 分页,返回 `(list, total)` | `listByProject` 无分页 | | 更新角色 | `update_character(character_id, **fields)` 部分更新 | `update(character)` 整棵树替换 | | 查任务 | `get_task(project_id, task_id)` 需要 `project_id` | `get(taskId)` 只传 taskId | -| 动作类型 | `walk` `idle` `attack` `custom` | 多一个 `jump` | -| 删除项目 | 返回 `bool`(是否找到) | 返回 `void` | +| 等待任务完成 | 提供 `GET /generation/tasks/{task_id}` 轮询 | `TaskApis.subscribe`,实现时可封装轮询 | +| 图片生成数量 | 入参有 `num_images`,结果只有一个 `image_url` | 角色图候选结果是 `images[]` | +| 动作类型 | `walk` `idle` `attack` `custom`;待增加 `jump` | `walk` `idle` `attack` `jump` `custom` | +| 角色视角 | `character_perspective` 为 `1~3`,文档中 2、3 都写成“正面” | `side` `top-down` `isometric` | ID 类型后端为 `int`、前端为 `string`,由前端转换层处理,不需要后端改动。 --- -## 三、后端有、前端没接 +## 四、后端有、前端没接 | 后端 | 说明 | |---|---| -| `project_name_exists(user_id, project_name)` | 建项目时的重名校验,前端未接 | | `delete_character` | 前端 `CharacterApis` 没有删除 | | `Character.description` | 后端存在实体上;前端只在创建入参里,创建完查不到 | | `Character.reference_image_url` | 后端存在实体上;前端 `Character` 类型没有这个字段 | @@ -59,7 +65,7 @@ ID 类型后端为 `int`、前端为 `string`,由前端转换层处理,不 --- -## 四、审核数据无处保存 +## 五、前端资产字段在后端没有落点 后端 `character_data` 的嵌套结构(见 `character/model.py`): @@ -71,22 +77,18 @@ frames[] → index / image_url / duration_ms 前端以下字段在后端结构里没有落点: -- `Frame.qc`(系统质检结论) -- `Frame.rejected`(人工打回) - `Action.kind`(preset / custom 来源) -- `Action.status`(planned / generating / candidate / confirmed / failed) - `Action.keyFrameIndex` - `Frame.rootMotion` - `Outfit.candidateCharacterTemplates`(母版候选列表) +- `Outfit.characterTemplateUrl`(每套造型的已确认角色图) - `Outfit.baseFrames` -**其中 `qc` 与 `rejected` 是审核台的全部依据。后端不存这两个字段,审核结果就落不了库。** - -`candidateCharacterTemplates` 同理:前端设计是生成多张候选让用户选,后端 `CharacterImageInput.num_images` 默认 1,`character_data` 里也没有候选列表。 +`candidateCharacterTemplates` 属于生成过程数据;若只在当前 WorkflowRun 中使用,可以留在前端。其余字段若要随最终资产恢复,需要后端增加字段,或者前端在 MVP 中删除。 --- -## 五、概念不一致 +## 六、概念不一致 后端 `character/model.py` 字段说明: @@ -103,11 +105,16 @@ frames[] → index / image_url / duration_ms ## 待确认 -- [ ] 第一节四条:后端做还是不做 -- [ ] 动作生成一步还是两步 -- [ ] `Frame.qc` / `Frame.rejected` 落不落库 +- [ ] `ActionTemplateApis` 由后端提供还是前端内置 +- [ ] 查询生成任务统一使用 `taskId`,还是 `projectId + taskId` - [ ] 母版候选几张 - [ ] 参考图与角色图是一个字段还是两个 - [ ] `Character.description` 前端要不要跟着存 -- [ ] `jump` 动作类型后端加不加 +- [ ] `Action.kind` / `Action.keyFrameIndex` / `Frame.rootMotion` 是否进入最终资产 - [ ] 上传模块何时提交 + +## 已分工 + +- [x] 前端删除 `WorkflowRunApis`,WorkflowRun 全程由前端管理 +- [x] 前端删除 `Character.name` +- [ ] 后端增加 `jump` 动作类型 From 869827b61576837244ad76ac55c688235cfdd7d1 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 21:15:32 +0800 Subject: [PATCH 19/29] fix(tasks): require project context for task lookup The backend scopes generation-task queries by both project and task identifiers. Add projectId to generation reads and task reads and subscriptions. Callers can now map directly to the documented task query endpoint. --- frontend/src/entities/generation/index.ts | 4 ++-- frontend/src/entities/task/index.ts | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index 7c932fc..a9d34a1 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -103,6 +103,6 @@ export interface Generation { export interface GenerationApis { /** 创建一次生成任务。 */ create(input: T): Promise> - /** 读取生成任务的最新快照。 */ - get(id: Generation['id']): Promise + /** 按所属项目和任务 ID 读取生成任务的最新快照。 */ + get(projectId: Generation['projectId'], id: Generation['id']): Promise } diff --git a/frontend/src/entities/task/index.ts b/frontend/src/entities/task/index.ts index bd64883..469d263 100644 --- a/frontend/src/entities/task/index.ts +++ b/frontend/src/entities/task/index.ts @@ -39,8 +39,11 @@ export interface TaskEvent extends Omit + /** + * 按所属项目和后端 Task ID 读取最新快照。 + * projectId 不能从 taskId 推导;后端查询接口要求两者同时传入。 + */ + get(projectId: string, taskId: Task['id']): Promise /** 订阅任务状态变化,返回取消订阅函数。 */ - subscribe(taskId: Task['id'], onEvent: (event: TaskEvent) => void): () => void + subscribe(projectId: string, taskId: Task['id'], onEvent: (event: TaskEvent) => void): () => void } From 7a6515edc3a1f514f0c999bb9cba1ba88c878078 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 21:15:56 +0800 Subject: [PATCH 20/29] docs(api): record project-scoped task lookup Task lookup ownership is now confirmed against the backend API. Mark projectId plus taskId as resolved and remove the stale mismatch and question. Reviewers can see the current task query boundary without reopening the decision. --- frontend/API_CONTRACT.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index e0cd7d2..45e7189 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -11,6 +11,7 @@ - `WorkflowRun` 是前端固定工作流的运行态。后端不读取、不推进、也不持久化,前端不声明 `WorkflowRunApis`。 - `Character` 不使用独立 `name` 字段;前端已删除。 - 前端保留 `jump` 动作类型,由后端补充对应枚举。 +- 查询生成任务统一携带 `projectId + taskId`。 - 前端工作流节点不与后端 `GenerationType` 一一对应,按下表调用: | 前端工作流节点 | 后端接口 | 后端任务类型 | @@ -44,7 +45,6 @@ |---|---|---| | 角色列表 | `list_characters` 分页,返回 `(list, total)` | `listByProject` 无分页 | | 更新角色 | `update_character(character_id, **fields)` 部分更新 | `update(character)` 整棵树替换 | -| 查任务 | `get_task(project_id, task_id)` 需要 `project_id` | `get(taskId)` 只传 taskId | | 等待任务完成 | 提供 `GET /generation/tasks/{task_id}` 轮询 | `TaskApis.subscribe`,实现时可封装轮询 | | 图片生成数量 | 入参有 `num_images`,结果只有一个 `image_url` | 角色图候选结果是 `images[]` | | 动作类型 | `walk` `idle` `attack` `custom`;待增加 `jump` | `walk` `idle` `attack` `jump` `custom` | @@ -106,7 +106,6 @@ frames[] → index / image_url / duration_ms ## 待确认 - [ ] `ActionTemplateApis` 由后端提供还是前端内置 -- [ ] 查询生成任务统一使用 `taskId`,还是 `projectId + taskId` - [ ] 母版候选几张 - [ ] 参考图与角色图是一个字段还是两个 - [ ] `Character.description` 前端要不要跟着存 From 43e4809755d924daef7fe90373d9f3025714fd15 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 01:34:11 +0800 Subject: [PATCH 21/29] feat(generation): define task recovery contract Workflow execution needs a stable boundary between generation records and backend tasks. Parse character-template results and bind generation IDs to task IDs. Require subscriptions to replay a current snapshot before later updates. --- frontend/src/entities/generation/index.ts | 24 +++++++++++++++++++++-- frontend/src/entities/task/index.ts | 5 ++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index a9d34a1..7ca1889 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -1,6 +1,6 @@ import type { ActionType } from '../character' import type { MediaReference } from '../media' -import type { TaskStatus } from '../task' +import type { Task, TaskStatus } from '../task' /** * Generation 是业务数据,不是「调用图片生成能力」。 @@ -60,6 +60,21 @@ export interface CharacterTemplateGenerationResult { images: readonly GeneratedImage[] } +/** Task.result 来自运行时边界,写回 WorkflowRun 前必须按生成类型收窄。 */ +export function parseCharacterTemplateGenerationResult( + value: unknown, +): CharacterTemplateGenerationResult | null { + if (!isRecord(value) || value.type !== 'character_template' || !Array.isArray(value.images)) { + return null + } + const images = value.images.filter( + (image): image is GeneratedImage => + isRecord(image) && typeof image.url === 'string' && image.url.length > 0, + ) + if (images.length === 0 || images.length !== value.images.length) return null + return { type: 'character_template', images } +} + export interface FirstFrameGenerationResult { type: 'first_frame' image: GeneratedImage @@ -88,7 +103,8 @@ export type GenerationResultFor = * 它是服务端的资源,不是一次「调用能力」——前端创建它,然后订阅或轮询它的状态。 */ export interface Generation { - id: string + /** 创建接口返回的后端 Task ID;后续查询与订阅必须把它交给 TaskApis。 */ + id: Task['id'] projectId: string /** 与创建时的输入判别字段保持同一字面量类型。 */ type: TType @@ -106,3 +122,7 @@ export interface GenerationApis { /** 按所属项目和任务 ID 读取生成任务的最新快照。 */ get(projectId: Generation['projectId'], id: Generation['id']): Promise } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/frontend/src/entities/task/index.ts b/frontend/src/entities/task/index.ts index 469d263..37e0950 100644 --- a/frontend/src/entities/task/index.ts +++ b/frontend/src/entities/task/index.ts @@ -44,6 +44,9 @@ export interface TaskApis { * projectId 不能从 taskId 推导;后端查询接口要求两者同时传入。 */ get(projectId: string, taskId: Task['id']): Promise - /** 订阅任务状态变化,返回取消订阅函数。 */ + /** + * 订阅后必须立即发送一次最新完整快照,随后再发送状态变化;任务已经终止也必须发送。 + * 该语义关闭 get/create 与开始监听之间的终态竞态。 + */ subscribe(projectId: string, taskId: Task['id'], onEvent: (event: TaskEvent) => void): () => void } From e8b68ba850839145d339fb68bd7d6c3b454b8319 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 01:34:36 +0800 Subject: [PATCH 22/29] feat(workflow-run): add local state store Workflow execution needs a frontend-owned snapshot that can survive page refreshes. Add the fixed step model, versioned local storage, and runtime hydration validation. Keep memory authoritative when persistence fails or stored data is invalid. --- .../src/entities/workflow-run/constants.ts | 19 ++ frontend/src/entities/workflow-run/index.ts | 116 +++++--- frontend/src/entities/workflow-run/store.ts | 265 ++++++++++++++++++ 3 files changed, 365 insertions(+), 35 deletions(-) create mode 100644 frontend/src/entities/workflow-run/constants.ts create mode 100644 frontend/src/entities/workflow-run/store.ts diff --git a/frontend/src/entities/workflow-run/constants.ts b/frontend/src/entities/workflow-run/constants.ts new file mode 100644 index 0000000..16714ea --- /dev/null +++ b/frontend/src/entities/workflow-run/constants.ts @@ -0,0 +1,19 @@ +export const WORKFLOW_DRIVERS = ['ai', 'manual'] as const +export const WORKFLOW_PURPOSES = ['create_character', 'add_action'] as const +export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const +export const WORKFLOW_REVISION_STATUSES = ['active', 'completed', 'failed', 'abandoned'] as const +export const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const +export const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const +export const WORKFLOW_STEP_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const + +/** 当前产品工作流的固定八步,也是存储校验与进度 UI 的唯一顺序来源。 */ +export const WORKFLOW_STEP_ORDER = [ + 'character-setup', + 'character-template', + 'template-candidate', + 'action-setup', + 'first-frame', + 'complete-animation', + 'review', + 'export', +] as const diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index a2a3aef..f6bd2a1 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,26 +1,32 @@ +import type { + CharacterTemplateGenerationInput, + CharacterTemplateGenerationResult, +} from '../generation' +import type { MediaReference } from '../media' import type { Task } from '../task' +import { + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDER, + WORKFLOW_STEP_STATUSES, +} from './constants' + +export { WORKFLOW_STEP_ORDER } from './constants' /** Quick Start 与手动工作流只改变输入方式,共用同一种运行模型。 */ -export type WorkflowDriver = 'ai' | 'manual' +export type WorkflowDriver = (typeof WORKFLOW_DRIVERS)[number] /** 创建 WorkflowRun 时要完成的用户意图。 */ -export type WorkflowRunPurpose = 'create_character' | 'add_action' +export type WorkflowRunPurpose = (typeof WORKFLOW_PURPOSES)[number] /** * 流程步骤类型的唯一标准顺序;它不是后端 Workflow 或 Execution 定义。 - * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.nodes 的数组位置表达。 + * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.steps 的数组位置表达。 */ -export const WORKFLOW_STEP_ORDER = [ - 'character-setup', - 'character-template', - 'template-candidate', - 'action-setup', - 'first-frame', - 'complete-animation', - 'review', - 'export', -] as const - /** 前端流程步骤类型,与 WORKFLOW_STEP_ORDER 的成员保持一致。 */ export type WorkflowStepType = (typeof WORKFLOW_STEP_ORDER)[number] @@ -28,40 +34,31 @@ export type WorkflowStepType = (typeof WORKFLOW_STEP_ORDER)[number] * 步骤的可用性和执行结果;不直接复用后端任务状态。 * locked/available 表示尚未执行,active 表示当前页面阶段,passed/failed 表示结果。 */ -export type WorkflowStepStatus = 'locked' | 'available' | 'active' | 'passed' | 'failed' +export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] /** * 单个版本的生命周期。 * abandoned 表示停止沿用但仍保留为历史。 */ -export type WorkflowRevisionStatus = 'active' | 'completed' | 'failed' | 'abandoned' +export type WorkflowRevisionStatus = (typeof WORKFLOW_REVISION_STATUSES)[number] /** * 整次流程的汇总状态。 * interrupted 只表示用户主动停止自动推进:历史仍保留且可只读查看,它不等于 failed 或 completed。 - * 后端 Task 是否真正停止是独立问题;从历史重启成功后可重新进入 active。 + * 后端 Task 是否真正停止是独立问题;当前纵切不提供重启操作。 */ -export type WorkflowRunStatus = 'active' | 'interrupted' | 'completed' | 'failed' +export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] /** 当前版本在生成阶段的汇总状态;素材准备期间为 not_started。 */ -export type GenerationStatus = 'not_started' | 'in_progress' | 'completed' | 'failed' +export type GenerationStatus = (typeof GENERATION_STATUSES)[number] /** 当前版本在导出阶段的汇总状态。 */ -export type ExportStatus = 'not_exported' | 'exporting' | 'exported' | 'failed' +export type ExportStatus = (typeof EXPORT_STATUSES)[number] -/** - * 一个 Revision 中已经进入执行线的流程步骤。 - * 步骤自身不重复保存顺序;其在 nodes 中的数组位置就是该版本的执行顺序。 - */ -export interface WorkflowStep { +interface WorkflowStepBase { /** 只用于编排和页面定位,不作为业务 ID 发送给后端。 */ id: string - type: WorkflowStepType status: WorkflowStepStatus - /** 进入步骤时保存的输入快照。 */ - input: unknown - /** 步骤完成后的结果或引用;尚无结果时为 null。 */ - output: unknown /** * 本步骤已提交、结果尚未写回 output 的生成任务 ID;没有在途任务时为 null。 * 它由前端随 WorkflowRun 一起维护,据此查回在途任务的状态,因而不会在同一次 @@ -69,16 +66,62 @@ export interface WorkflowStep { * 任务本身不认识步骤,反向关联不存在。 */ taskId: Task['id'] | null + /** + * 前端开始提交、但后端 taskId 尚未返回时的本地尝试标识。 + * 它非 null 而 taskId 为 null 时不能重复提交;若页面在这个窗口刷新, + * Controller 会把本地 Run 标为失败。它不是后端字段,也不冒充幂等键。 + */ + submissionId: string | null + /** 步骤失败后供页面解释原因;未失败时必须为 null。 */ + error: string | null /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ referenceStepIds: string[] } +/** 角色资料步骤保存的输入;参考媒体为空表示仅使用文字描述。 */ +export interface CharacterSetupStepInput { + description: string + referenceMedia: readonly MediaReference[] +} + +export interface CharacterSetupWorkflowStep extends WorkflowStepBase { + type: 'character-setup' + input: CharacterSetupStepInput | null + output: null +} + +export interface CharacterTemplateWorkflowStep extends WorkflowStepBase { + type: 'character-template' + /** 发起任务前为 null;提交时保存实际发送给 GenerationApis 的输入快照。 */ + input: CharacterTemplateGenerationInput | null + output: CharacterTemplateGenerationResult | null +} + +type RemainingWorkflowStepType = Exclude + +interface RemainingWorkflowStep extends WorkflowStepBase { + type: RemainingWorkflowStepType + /** 候选选择及后五步尚未实现,输入输出等对应纵切开始时再收窄。 */ + input: unknown + output: unknown +} + +/** + * 一个 Revision 中已经进入执行线的流程步骤。 + * 前两个执行步骤已冻结输入输出;候选选择及后五步进入对应纵切时再收窄, + * 不提前猜页面尚未产生的数据形状。 + */ +export type WorkflowStep = + | CharacterSetupWorkflowStep + | CharacterTemplateWorkflowStep + | RemainingWorkflowStep + /** * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 * - * MVP 只走单条执行线:revisions 恒为一个成员,basedOnRevisionId 与 restartStepId 恒为 null。 - * 「从历史步骤重开并保留旧版本」尚未进入产品定义,结构先留出位置但不实现, - * 避免真要做时改动波及 WorkflowRun 的持久化形状。 + * 当前本地存储版本只接受单条执行线:revisions 恒为一个成员, + * basedOnRevisionId 与 restartStepId 恒为 null。「从历史步骤重开并保留旧版本」 + * 尚未进入产品定义;实现时需要同步升级存储版本和迁移规则。 */ export interface WorkflowRevision { id: string @@ -88,8 +131,8 @@ export interface WorkflowRevision { restartStepId: string | null status: WorkflowRevisionStatus /** - * 已进入当前执行线的步骤;数组位置是该版本步骤顺序的唯一来源。 - * 尚未推进到的后续步骤可以不存在;完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 + * 当前版本固定保存全部八步;数组位置是步骤顺序的唯一来源。 + * 完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 */ steps: WorkflowStep[] generationStatus: GenerationStatus @@ -149,3 +192,6 @@ export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & baseFrameUrls: readonly string[] } ) + +export { createWorkflowRunStore } from './store' +export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './store' diff --git a/frontend/src/entities/workflow-run/store.ts b/frontend/src/entities/workflow-run/store.ts new file mode 100644 index 0000000..1a2f94f --- /dev/null +++ b/frontend/src/entities/workflow-run/store.ts @@ -0,0 +1,265 @@ +import type { WorkflowRun } from './index' +import { parseCharacterTemplateGenerationResult } from '../generation' +import { + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDER, + WORKFLOW_STEP_STATUSES, +} from './constants' + +export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' +export const WORKFLOW_RUN_STORAGE_VERSION = 1 + +type WorkflowRunListener = (run: WorkflowRun) => void + +interface WorkflowRunStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +export interface WorkflowRunStore { + get(runId: WorkflowRun['id']): WorkflowRun | null + save(run: WorkflowRun): void + subscribe(runId: WorkflowRun['id'], listener: WorkflowRunListener): () => void +} + +export interface CreateWorkflowRunStoreOptions { + /** + * 传 null 可显式创建仅内存存储;不传时在浏览器中使用 localStorage。 + * 该入口也让纯逻辑测试无需模拟完整 DOM。 + */ + storage?: WorkflowRunStorage | null +} + +interface PersistedWorkflowRuns { + version: typeof WORKFLOW_RUN_STORAGE_VERSION + runs: WorkflowRun[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isNullableString(value: unknown): value is string | null { + return typeof value === 'string' || value === null +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string') +} + +function isMember(value: unknown, members: readonly T[]): value is T { + return typeof value === 'string' && members.includes(value as T) +} + +function isWorkflowStep(value: unknown): boolean { + if (!isRecord(value)) return false + + const commonFieldsAreValid = + typeof value.id === 'string' && + isMember(value.type, WORKFLOW_STEP_ORDER) && + isMember(value.status, WORKFLOW_STEP_STATUSES) && + isNullableString(value.taskId) && + isNullableString(value.submissionId) && + isStringArray(value.referenceStepIds) && + 'input' in value && + 'output' in value + if (!commonFieldsAreValid) return false + const error = value.error + if (!isNullableString(error)) return false + if ( + (value.status === 'failed' && (error === null || error.trim().length === 0)) || + (value.status !== 'failed' && error !== null) + ) { + return false + } + + if (value.type === 'character-setup') { + return ( + value.output === null && + (value.input === null || + (isRecord(value.input) && + typeof value.input.description === 'string' && + isStringArray(value.input.referenceMedia))) + ) + } + if (value.type === 'character-template') { + return ( + (value.input === null || + (isRecord(value.input) && + value.input.type === 'character_template' && + typeof value.input.projectId === 'string' && + typeof value.input.prompt === 'string' && + isStringArray(value.input.referenceMedia))) && + (value.output === null || parseCharacterTemplateGenerationResult(value.output) !== null) + ) + } + return true +} + +function isWorkflowRevision(value: unknown): boolean { + if (!isRecord(value)) return false + + return ( + typeof value.id === 'string' && + isNullableString(value.basedOnRevisionId) && + isNullableString(value.restartStepId) && + isMember(value.status, WORKFLOW_REVISION_STATUSES) && + Array.isArray(value.steps) && + value.steps.length === WORKFLOW_STEP_ORDER.length && + value.steps.every( + (step, index) => + isWorkflowStep(step) && isRecord(step) && step.type === WORKFLOW_STEP_ORDER[index], + ) && + isMember(value.generationStatus, GENERATION_STATUSES) && + isMember(value.exportStatus, EXPORT_STATUSES) && + typeof value.createdAt === 'string' + ) +} + +function isWorkflowRun(value: unknown): value is WorkflowRun { + if (!isRecord(value) || !Array.isArray(value.revisions)) return false + + const fieldsAreValid = + typeof value.id === 'string' && + typeof value.projectId === 'string' && + isNullableString(value.characterId) && + isNullableString(value.outfitId) && + isMember(value.purpose, WORKFLOW_PURPOSES) && + isMember(value.driver, WORKFLOW_DRIVERS) && + isMember(value.status, WORKFLOW_RUN_STATUSES) && + typeof value.currentRevisionId === 'string' && + value.revisions.length === 1 && + value.revisions.every(isWorkflowRevision) && + value.revisions.some( + (revision) => isRecord(revision) && revision.id === value.currentRevisionId, + ) && + isNullableString(value.prompt) + if (!fieldsAreValid) return false + + const currentRevision = value.revisions.find( + (revision) => isRecord(revision) && revision.id === value.currentRevisionId, + ) + if (!isRecord(currentRevision) || !Array.isArray(currentRevision.steps)) return false + if (currentRevision.basedOnRevisionId !== null || currentRevision.restartStepId !== null) { + return false + } + + const expectedRevisionStatus = + value.status === 'failed' ? 'failed' : value.status === 'completed' ? 'completed' : 'active' + if (currentRevision.status !== expectedRevisionStatus) return false + + const activeStepCount = currentRevision.steps.filter( + (step) => isRecord(step) && step.status === 'active', + ).length + if ( + ((value.status === 'active' || value.status === 'interrupted') && activeStepCount !== 1) || + ((value.status === 'failed' || value.status === 'completed') && activeStepCount !== 0) + ) { + return false + } + + return value.revisions.every( + (revision) => + isRecord(revision) && + Array.isArray(revision.steps) && + revision.steps.every((step) => { + if (!isRecord(step)) return false + const taskId = step.taskId + const submissionId = step.submissionId + if (taskId !== null && submissionId !== null) return false + if (taskId === null && submissionId === null) return true + return step.type === 'character-template' && step.status === 'active' + }), + ) +} + +function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRun[] { + if (storage === null) return [] + + try { + const serialized = storage.getItem(WORKFLOW_RUN_STORAGE_KEY) + if (serialized === null) return [] + + const persisted: unknown = JSON.parse(serialized) + if ( + !isRecord(persisted) || + persisted.version !== WORKFLOW_RUN_STORAGE_VERSION || + !Array.isArray(persisted.runs) + ) { + return [] + } + + return persisted.runs.filter(isWorkflowRun).map((run) => structuredClone(run)) + } catch { + return [] + } +} + +function resolveBrowserStorage(): WorkflowRunStorage | null { + if (typeof window === 'undefined') return null + + try { + return window.localStorage + } catch { + return null + } +} + +/** + * WorkflowRun 的内存快照是当前会话的权威状态,localStorage 只负责刷新恢复。 + * 因此 save 先更新内存;浏览器拒绝写入时,本次运行仍能继续读取和订阅。 + */ +export function createWorkflowRunStore( + options: CreateWorkflowRunStoreOptions = {}, +): WorkflowRunStore { + const storage = options.storage === undefined ? resolveBrowserStorage() : options.storage + const runs = new Map(readPersistedRuns(storage).map((run) => [run.id, run] as const)) + const listeners = new Map>() + + return { + get(runId) { + const run = runs.get(runId) + return run === undefined ? null : structuredClone(run) + }, + + save(run) { + const savedRun = structuredClone(run) + runs.set(savedRun.id, savedRun) + + const persisted: PersistedWorkflowRuns = { + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [...runs.values()], + } + + try { + storage?.setItem(WORKFLOW_RUN_STORAGE_KEY, JSON.stringify(persisted)) + } catch { + // 内存已成功更新;持久化失败不能中断当前会话中的工作流。 + } + + for (const listener of listeners.get(savedRun.id) ?? []) { + try { + listener(structuredClone(savedRun)) + } catch { + // 订阅方渲染失败不能撤销已经保存的运行状态,也不能阻断其他订阅方。 + } + } + }, + + subscribe(runId, listener) { + const runListeners = listeners.get(runId) ?? new Set() + runListeners.add(listener) + listeners.set(runId, runListeners) + + return () => { + runListeners.delete(listener) + if (runListeners.size === 0) listeners.delete(runId) + } + }, + } +} From 244dd552c02d6ac6ec1ac61fd191443208ef92f8 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 01:34:59 +0800 Subject: [PATCH 23/29] feat(workflow-controller): run character template generation Quick Start and the editor need one frontend-owned progression boundary. Add character setup updates, generation submission, task recovery, and interruption handling. Advance valid results to candidate selection without exposing unfinished steps. --- frontend/src/entities/index.ts | 10 +- .../workflow-controller/controller.ts | 718 ++++++++++++++++++ .../src/features/workflow-controller/index.ts | 73 +- 3 files changed, 732 insertions(+), 69 deletions(-) create mode 100644 frontend/src/features/workflow-controller/controller.ts diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index ac64b4f..33b1ff8 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -1,6 +1,6 @@ /** * entities 唯一公开入口。外部不得绕过本文件访问内部文件。 - * 本次只提交类型与接口,不提交实现。 + * 外部只从这里使用实体契约与已经落地的实体能力。 */ /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ @@ -34,6 +34,7 @@ export type { export type { ActionTemplate, ActionTemplateApis } from './action-template' /* 生成 —— 业务数据,不是「调用生成能力」 */ +export { parseCharacterTemplateGenerationResult } from './generation' export type { CharacterTemplateGenerationInput, CharacterTemplateGenerationResult, @@ -57,8 +58,12 @@ export type { MediaReference } from './media' export type { Task, TaskApis, TaskEvent, TaskStatus, TaskType } from './task' /* 工作流 —— 节点与运行状态都由前端管理 */ -export { WORKFLOW_STEP_ORDER } from './workflow-run' +export { createWorkflowRunStore, WORKFLOW_STEP_ORDER } from './workflow-run' export type { + CharacterSetupStepInput, + CharacterSetupWorkflowStep, + CharacterTemplateWorkflowStep, + CreateWorkflowRunStoreOptions, CreateWorkflowRunInput, ExportStatus, GenerationStatus, @@ -69,6 +74,7 @@ export type { WorkflowRevision, WorkflowRevisionStatus, WorkflowRun, + WorkflowRunStore, WorkflowRunPurpose, WorkflowRunStatus, } from './workflow-run' diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts new file mode 100644 index 0000000..21cd581 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.ts @@ -0,0 +1,718 @@ +import { + parseCharacterTemplateGenerationResult, + WORKFLOW_STEP_ORDER, + type CharacterSetupStepInput, + type CharacterTemplateGenerationInput, + type CreateWorkflowRunInput, + type GenerationApis, + type Task, + type TaskApis, + type TaskEvent, + type WorkflowRevision, + type WorkflowRun, + type WorkflowRunStore, + type WorkflowStep, + type WorkflowStepStatus, + type WorkflowStepType, +} from '@/entities' + +interface ApplyServerResultInput { + revisionId: WorkflowRevision['id'] + stepId: WorkflowStep['id'] + /** 结果必须仍属于步骤当前记录的任务;重试前的旧结果会被忽略。 */ + taskId: string + result: unknown +} + +/** 首个纵切只开放创建角色;增加动作进入对应步骤实现时再加入 Controller。 */ +export type CreateWorkflowControllerInput = Extract< + CreateWorkflowRunInput, + { purpose: 'create_character' } +> + +export interface WorkflowController { + /** 创建并保存一条纯前端运行记录。 */ + create(input: CreateWorkflowControllerInput): WorkflowRun + + /** 按路由中的 runId 读取快照;不存在时返回 null。 */ + getWorkflow(runId: WorkflowRun['id']): WorkflowRun | null + + /** 订阅指定运行记录的本地变化。 */ + subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void): () => void + + /** 修改当前角色资料步骤,页面无需知道步骤内部 ID。 */ + updateCharacterSetup(runId: WorkflowRun['id'], input: CharacterSetupStepInput): WorkflowRun + + /** + * 推进一个步骤。当前纵切只实现角色资料到角色图生成; + * 后续步骤进入各自实现 PR 后再扩展,不在这里伪造完成。 + */ + nextStep(runId: WorkflowRun['id']): Promise + + /** 页面恢复时先读取任务终态;仍在运行时再恢复订阅。 */ + resume(runId: WorkflowRun['id']): Promise + + /** 只停止前端自动推进和任务订阅;后端当前没有取消任务能力。 */ + interrupt(runId: WorkflowRun['id']): WorkflowRun +} + +export interface CreateWorkflowControllerOptions { + store: WorkflowRunStore + generationApis: Pick + taskApis: TaskApis + /** 测试可注入确定性 ID;生产默认使用浏览器随机 UUID。 */ + createId?: (scope: 'run' | 'revision' | 'submission') => string + /** 测试可注入确定性时间。 */ + now?: () => string +} + +interface ActiveSubscription { + runId: WorkflowRun['id'] + stop: () => void +} + +/** + * WorkflowRun 的唯一推进实现。 + * + * Controller 管前端状态和后端任务的关联;Generation 与 Task 只认识自己的 ID, + * 不读取 WorkflowRun、Revision 或 Step。 + * submissions 与 subscriptions 是实例内锁;生产接入必须复用同一个实例, + * 不能在组件渲染期间重复创建。 + */ +export function createWorkflowController({ + store, + generationApis, + taskApis, + createId = createRuntimeId, + now = () => new Date().toISOString(), +}: CreateWorkflowControllerOptions): WorkflowController { + const submissions = new Map>() + const subscriptions = new Map() + + function getWorkflow(runId: WorkflowRun['id']) { + return store.get(runId) + } + + function requireWorkflow(runId: WorkflowRun['id']) { + const run = getWorkflow(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run + } + + function save(run: WorkflowRun) { + store.save(run) + return run + } + + function getCurrentRevision(run: WorkflowRun) { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision) throw new Error(`WorkflowRun ${run.id} 的 currentRevisionId 无效`) + return revision + } + + function getActiveStep(revision: WorkflowRevision) { + return revision.steps.find((step) => step.status === 'active') ?? null + } + + function replaceStep( + run: WorkflowRun, + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + update: (step: WorkflowStep) => WorkflowStep, + revisionUpdate?: (revision: WorkflowRevision) => WorkflowRevision, + ) { + return { + ...run, + revisions: run.revisions.map((revision) => { + if (revision.id !== revisionId) return revision + const nextRevision = { + ...revision, + steps: revision.steps.map((step) => (step.id === stepId ? update(step) : step)), + } + return revisionUpdate ? revisionUpdate(nextRevision) : nextRevision + }), + } + } + + function create(input: CreateWorkflowControllerInput): WorkflowRun { + const prompt = input.prompt?.trim() || null + const runId = createId('run') + const revisionId = createId('revision') + const steps = WORKFLOW_STEP_ORDER.map((type, index) => + createInitialStep(type, revisionId, index, prompt), + ) + const run: WorkflowRun = { + id: runId, + projectId: input.projectId, + characterId: null, + outfitId: null, + purpose: input.purpose, + driver: input.driver, + status: 'active', + currentRevisionId: revisionId, + revisions: [ + { + id: revisionId, + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps, + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt: now(), + }, + ], + prompt, + } + save(run) + return run + } + + function subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void) { + return store.subscribe(runId, listener) + } + + function updateCharacterSetup( + runId: WorkflowRun['id'], + input: CharacterSetupStepInput, + ): WorkflowRun { + const run = requireActiveWorkflow(runId, requireWorkflow) + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.type === 'character-setup') + if (!step || step.type !== 'character-setup' || step.status !== 'active') { + throw new Error('当前只能更新处于 active 状态的角色资料步骤') + } + + const description = input.description.trim() + if (!description) throw new Error('角色描述不能为空') + + const updated = replaceStep(run, revision.id, step.id, (current) => { + if (current.type !== 'character-setup') return current + return { + ...current, + input: { + description, + referenceMedia: [...input.referenceMedia], + }, + } + }) + return save(updated) + } + + async function nextStep(runId: WorkflowRun['id']): Promise { + const run = requireActiveWorkflow(runId, requireWorkflow) + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (!activeStep) throw new Error('当前 WorkflowRun 没有 active 步骤') + + if (activeStep.type === 'character-template') { + if (activeStep.taskId) { + ensureTaskSubscription(run, revision.id, activeStep.id, activeStep.taskId) + return requireWorkflow(runId) + } + if (!activeStep.input) throw new Error('角色图生成步骤缺少输入快照') + return submitCharacterTemplate(runId, revision.id, activeStep.id) + } + + if (activeStep.type !== 'character-setup') { + throw new Error(`步骤 ${activeStep.type} 尚未进入本轮实现`) + } + if (!activeStep.input) throw new Error('请先填写角色资料') + + const templateStep = revision.steps.find((step) => step.type === 'character-template') + if (!templateStep) throw new Error('WorkflowRun 缺少 character-template 步骤') + + const generationInput: CharacterTemplateGenerationInput = { + type: 'character_template', + projectId: run.projectId, + prompt: activeStep.input.description, + referenceMedia: activeStep.input.referenceMedia, + } + + const transitioned: WorkflowRun = { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + generationStatus: 'in_progress' as const, + steps: item.steps.map((step) => { + if (step.id === activeStep.id) return { ...step, status: 'passed' as const } + if (step.id !== templateStep.id || step.type !== 'character-template') return step + return { + ...step, + status: 'active' as const, + input: generationInput, + } + }), + } + }), + } + save(transitioned) + return submitCharacterTemplate(runId, revision.id, templateStep.id) + } + + function submitCharacterTemplate( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + ) { + const key = submissionKey(runId, revisionId, stepId) + const pending = submissions.get(key) + if (pending) return pending + + const submission = performCharacterTemplateSubmission(runId, revisionId, stepId).finally(() => { + submissions.delete(key) + }) + submissions.set(key, submission) + return submission + } + + async function performCharacterTemplateSubmission( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + ): Promise { + const before = requireWorkflow(runId) + const beforeRevision = getCurrentRevision(before) + const beforeStep = beforeRevision.steps.find((step) => step.id === stepId) + if ( + before.status !== 'active' || + beforeRevision.id !== revisionId || + !beforeStep || + beforeStep.type !== 'character-template' || + beforeStep.status !== 'active' || + !beforeStep.input + ) { + return before + } + if (beforeStep.taskId) { + ensureTaskSubscription(before, revisionId, stepId, beforeStep.taskId) + return before + } + if (beforeStep.submissionId) { + throw new Error('角色图生成请求仍在等待后端确认,不能重复提交') + } + + const submissionId = createId('submission') + const submitting = replaceStep(before, revisionId, stepId, (current) => { + if (current.type !== 'character-template') return current + return { ...current, submissionId } + }) + save(submitting) + try { + const generation = await generationApis.create(beforeStep.input) + const latest = requireWorkflow(runId) + const latestRevision = getCurrentRevision(latest) + const latestStep = latestRevision.steps.find((step) => step.id === stepId) + if ( + (latest.status !== 'active' && latest.status !== 'interrupted') || + latestRevision.id !== revisionId || + !latestStep || + latestStep.type !== 'character-template' || + latestStep.status !== 'active' || + latestStep.taskId || + latestStep.submissionId !== submissionId + ) { + return latest + } + if (generation.type !== 'character_template' || generation.projectId !== latest.projectId) { + throw new Error('生成任务返回的类型或项目与当前 WorkflowRun 不匹配') + } + + const withTask = replaceStep(latest, revisionId, stepId, (current) => { + if (current.type !== 'character-template') return current + return { ...current, taskId: generation.id, submissionId: null } + }) + save(withTask) + + if (latest.status === 'interrupted') return withTask + if (generation.status === 'failed') { + return markGenerationFailed( + runId, + revisionId, + stepId, + generation.id, + null, + generation.error?.trim() || '角色图生成任务失败', + ) + } + if (generation.status === 'completed') { + return applyServerResult(runId, { + revisionId, + stepId, + taskId: generation.id, + result: generation.result, + }) + } + + ensureTaskSubscription(withTask, revisionId, stepId, generation.id) + return requireWorkflow(runId) + } catch (cause) { + markGenerationFailed( + runId, + revisionId, + stepId, + null, + submissionId, + errorMessage(cause, '角色图生成请求失败'), + ) + throw cause instanceof Error ? cause : new Error(String(cause)) + } + } + + function ensureTaskSubscription( + run: WorkflowRun, + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + taskId: string, + ) { + const key = subscriptionKey(run.id, revisionId, stepId, taskId) + if (subscriptions.has(key)) return + + subscriptions.set(key, { runId: run.id, stop: () => undefined }) + try { + const stop = taskApis.subscribe(run.projectId, taskId, (event) => { + handleTaskEvent(run.id, revisionId, stepId, taskId, event) + }) + const active = subscriptions.get(key) + if (active) subscriptions.set(key, { ...active, stop }) + else stop() + } catch (cause) { + subscriptions.delete(key) + throw cause + } + } + + function handleTaskEvent( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + taskId: string, + event: TaskEvent, + ) { + if (event.taskId !== taskId) return + if (event.status === 'pending' || event.status === 'running') return + if (event.status === 'failed') { + markGenerationFailed( + runId, + revisionId, + stepId, + taskId, + null, + event.error?.trim() || '角色图生成任务失败', + ) + return + } + if (event.type !== 'character_template') { + markGenerationFailed( + runId, + revisionId, + stepId, + taskId, + null, + '任务结果类型与角色图生成步骤不匹配', + ) + return + } + applyServerResult(runId, { + revisionId, + stepId, + taskId, + result: event.result, + }) + } + + async function resume(runId: WorkflowRun['id']): Promise { + const run = getWorkflow(runId) + if (!run || run.status !== 'active') return run + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (activeStep?.type !== 'character-template' || activeStep.status !== 'active') { + return run + } + if (activeStep.submissionId && !activeStep.taskId) { + if (submissions.has(submissionKey(run.id, revision.id, activeStep.id))) { + return run + } + return markGenerationFailed( + run.id, + revision.id, + activeStep.id, + null, + activeStep.submissionId, + '页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交', + ) + } + if (activeStep.taskId) { + const task = await taskApis.get(run.projectId, activeStep.taskId) + const latest = getWorkflow(run.id) + if (!latest || latest.status !== 'active' || latest.currentRevisionId !== revision.id) { + return latest + } + const latestRevision = getCurrentRevision(latest) + const latestStep = latestRevision.steps.find((step) => step.id === activeStep.id) + if ( + latestStep?.type !== 'character-template' || + latestStep.status !== 'active' || + latestStep.taskId !== activeStep.taskId + ) { + return latest + } + if (task.id !== latestStep.taskId) { + throw new Error('任务查询结果与 WorkflowRun 记录的 taskId 不匹配') + } + if (task.type !== 'character_template') { + return markGenerationFailed( + latest.id, + latestRevision.id, + latestStep.id, + latestStep.taskId, + null, + '任务查询结果类型与角色图生成步骤不匹配', + ) + } + if (task.status === 'pending' || task.status === 'running') { + ensureTaskSubscription(latest, latestRevision.id, latestStep.id, latestStep.taskId) + } else { + handleTaskEvent( + latest.id, + latestRevision.id, + latestStep.id, + latestStep.taskId, + taskEvent(task), + ) + } + } + return getWorkflow(runId) + } + + function applyServerResult(runId: WorkflowRun['id'], input: ApplyServerResultInput): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active' || run.currentRevisionId !== input.revisionId) { + return run + } + + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === input.stepId) + if ( + !step || + step.type !== 'character-template' || + step.status !== 'active' || + step.taskId !== input.taskId + ) { + return run + } + + const result = parseCharacterTemplateGenerationResult(input.result) + if (!result) { + return markGenerationFailed( + runId, + revision.id, + step.id, + input.taskId, + null, + '角色图生成任务返回了无法识别的结果', + ) + } + const candidateStep = revision.steps.find((item) => item.type === 'template-candidate') + if (!candidateStep) throw new Error('WorkflowRun 缺少 template-candidate 步骤') + + const updated: WorkflowRun = { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + steps: item.steps.map((current) => { + if (current.id === step.id && current.type === 'character-template') { + return { + ...current, + status: 'passed' as const, + output: result, + taskId: null, + submissionId: null, + } + } + if (current.id === candidateStep.id && current.type === 'template-candidate') { + return { ...current, status: 'active' as const } + } + return current + }), + } + }), + } + stopSubscription(subscriptionKey(run.id, revision.id, step.id, input.taskId)) + return save(updated) + } + + function markGenerationFailed( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + expectedTaskId: string | null, + expectedSubmissionId: string | null, + error: string, + ) { + const run = requireWorkflow(runId) + if (run.status !== 'active' || run.currentRevisionId !== revisionId) return run + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === stepId) + if ( + !step || + step.type !== 'character-template' || + step.status !== 'active' || + (expectedTaskId !== null && step.taskId !== expectedTaskId) || + (expectedSubmissionId !== null && step.submissionId !== expectedSubmissionId) + ) { + return run + } + + const failureMessage = error.trim() || '角色图生成失败' + const failed: WorkflowRun = { + ...replaceStep( + run, + revisionId, + stepId, + (current) => ({ + ...current, + status: 'failed', + taskId: null, + submissionId: null, + error: failureMessage, + }), + (current) => ({ + ...current, + status: 'failed', + generationStatus: 'failed', + }), + ), + status: 'failed', + } + if (step.taskId) { + stopSubscription(subscriptionKey(run.id, revisionId, stepId, step.taskId)) + } + return save(failed) + } + + function stopSubscription(key: string) { + const subscription = subscriptions.get(key) + subscriptions.delete(key) + try { + subscription?.stop() + } catch { + // 取消轮询失败不能反向破坏已经落盘的 WorkflowRun 状态。 + } + } + + function interrupt(runId: WorkflowRun['id']): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active') return run + for (const [key, subscription] of subscriptions) { + if (subscription.runId === runId) stopSubscription(key) + } + const latest = requireWorkflow(runId) + if (latest.status !== 'active') return latest + return save({ ...latest, status: 'interrupted' }) + } + + return { + create, + getWorkflow, + subscribe, + updateCharacterSetup, + nextStep, + resume, + interrupt, + } +} + +function createInitialStep( + type: WorkflowStepType, + revisionId: string, + index: number, + prompt: string | null, +): WorkflowStep { + const status: WorkflowStepStatus = index === 0 ? 'active' : 'locked' + const base: { + id: string + status: WorkflowStepStatus + taskId: null + submissionId: null + error: null + referenceStepIds: string[] + } = { + id: `${revisionId}:${type}`, + status, + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [], + } + + if (type === 'character-setup') { + return { + ...base, + type, + input: prompt ? { description: prompt, referenceMedia: [] } : null, + output: null, + } + } + if (type === 'character-template') { + return { + ...base, + type, + input: null, + output: null, + } + } + return { ...base, type, input: null, output: null } as WorkflowStep +} + +function requireActiveWorkflow( + runId: WorkflowRun['id'], + getWorkflow: (runId: WorkflowRun['id']) => WorkflowRun, +) { + const run = getWorkflow(runId) + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) + return run +} + +function taskEvent(task: Task): TaskEvent { + return { + taskId: task.id, + type: task.type, + status: task.status, + error: task.error, + result: task.result, + } +} + +function errorMessage(cause: unknown, fallback: string) { + return cause instanceof Error && cause.message.trim() ? cause.message.trim() : fallback +} + +function subscriptionKey( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + taskId: string, +) { + return `${runId}:${revisionId}:${stepId}:${taskId}` +} + +function submissionKey( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], +) { + return `${runId}:${revisionId}:${stepId}` +} + +function createRuntimeId(scope: 'run' | 'revision' | 'submission') { + const suffix = + typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}` + return `${scope}-${suffix}` +} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index f8ce879..fcb6978 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -1,67 +1,6 @@ -import type { - CreateWorkflowRunInput, - WorkflowRevision, - WorkflowRun, - WorkflowStep, -} from '@/entities' - -/** 更新当前 Revision 中某个步骤的业务数据。 */ -export interface UpdateWorkflowStepInput { - stepId: WorkflowStep['id'] - data: unknown -} - -/** 从指定 Revision 的指定步骤建立新的执行版本。 */ -export interface RestartWorkflowFromStepInput { - revisionId: WorkflowRevision['id'] - stepId: WorkflowStep['id'] -} - -/** 把某次服务端调用的结果写回目标步骤。 */ -export interface ApplyServerResultInput { - /** 发起请求时所属的 Revision,防止旧的异步结果污染重启后的新版本。 */ - revisionId: WorkflowRevision['id'] - stepId: WorkflowStep['id'] - result: unknown -} - -/** - * Quick Start 与手动工作流共用的流程推进边界,不含界面。 - * 两套界面共享同一套流程:手动模式一次推进一步,Quick Start 连续推进到终点。 - * - * Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份 - * 步骤数据,不拆成互不共享状态的独立模块。 - * - * 步骤和运行状态由前端管理;服务端只提供生成能力,并持久化最终确认的资产。 - */ -export interface WorkflowController { - /** 初始化一条创建角色或增加动作的流程。 */ - create(input: CreateWorkflowRunInput): Promise - - /** 读取当前维护的完整流程快照。 */ - getWorkflow(): WorkflowRun - - /** 按前端规则完成当前步骤并进入下一步;需要服务端时创建对应的 generation。 */ - nextStep(): Promise - - /** 连续推进到终点,Quick Start 使用。 */ - runToCompletion(): Promise - - /** 更新指定步骤的数据;页面不绕过 Controller 直接改流程状态。 */ - updateStep(input: UpdateWorkflowStepInput): Promise - - /** - * 把服务端返回的结果写回目标步骤。 - * 目标 Revision 已被重启取代时丢弃该结果,不写入新的执行线。 - */ - applyServerResult(input: ApplyServerResultInput): Promise - - /** - * 从历史步骤开出新的执行线。 - * 旧 Revision 保留为只读历史,不会被改写成失败或完成。 - */ - restartFromStep(input: RestartWorkflowFromStepInput): Promise - - /** 用户主动停止自动推进;历史保留,不等于失败或完成。 */ - interrupt(): Promise -} +export { createWorkflowController } from './controller' +export type { + CreateWorkflowControllerInput, + CreateWorkflowControllerOptions, + WorkflowController, +} from './controller' From 2aef23af8433e4a289184d2cd7f3ae1273c8d29b Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 01:35:23 +0800 Subject: [PATCH 24/29] test(workflow-run): cover local execution lifecycle Workflow state and async recovery need regression coverage before page integration. Test persistence, submission deduplication, task replay, and interruption races. Verify the first generation slice through the real store and controller. --- .../src/entities/workflow-run/store.test.ts | 238 +++++++ .../workflow-controller/controller.test.ts | 607 ++++++++++++++++++ .../workflow-run.integration.test.ts | 106 +++ 3 files changed, 951 insertions(+) create mode 100644 frontend/src/entities/workflow-run/store.test.ts create mode 100644 frontend/src/features/workflow-controller/controller.test.ts create mode 100644 frontend/src/features/workflow-controller/workflow-run.integration.test.ts diff --git a/frontend/src/entities/workflow-run/store.test.ts b/frontend/src/entities/workflow-run/store.test.ts new file mode 100644 index 0000000..c165a4e --- /dev/null +++ b/frontend/src/entities/workflow-run/store.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it, vi } from 'vitest' + +import { WORKFLOW_STEP_ORDER } from './constants' +import type { WorkflowRun, WorkflowStep } from './index' +import { + createWorkflowRunStore, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, +} from './store' + +class TestStorage { + value: string | null + failOnSet = false + + constructor(value: string | null = null) { + this.value = value + } + + getItem(): string | null { + return this.value + } + + setItem(_key: string, value: string): void { + if (this.failOnSet) throw new Error('storage unavailable') + this.value = value + } +} + +function createSteps(): WorkflowStep[] { + return WORKFLOW_STEP_ORDER.map((type, index) => { + const common = { + id: `revision-1:${type}`, + status: index === 0 ? ('active' as const) : ('locked' as const), + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [], + } + if (type === 'character-setup') { + return { + ...common, + type, + input: { description: 'slime', referenceMedia: [] }, + output: null, + } + } + if (type === 'character-template') { + return { ...common, type, input: null, output: null } + } + return { ...common, type, input: null, output: null } as WorkflowStep + }) +} + +function createRun(id = 'run-1'): WorkflowRun { + return { + id, + projectId: 'project-1', + characterId: null, + outfitId: null, + purpose: 'create_character', + driver: 'ai', + status: 'active', + currentRevisionId: 'revision-1', + revisions: [ + { + id: 'revision-1', + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps: createSteps(), + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt: '2026-07-30T12:00:00.000Z', + }, + ], + prompt: 'Create a slime', + } +} + +describe('createWorkflowRunStore', () => { + it('stores a versioned snapshot and returns defensive clones', () => { + const storage = new TestStorage() + const store = createWorkflowRunStore({ storage }) + const source = createRun() + + store.save(source) + source.prompt = 'mutated outside' + + const firstRead = store.get(source.id) + expect(firstRead?.prompt).toBe('Create a slime') + + firstRead!.revisions[0].steps[0].status = 'failed' + expect(store.get(source.id)?.revisions[0].steps[0].status).toBe('active') + + expect(JSON.parse(storage.value!)).toEqual({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [createRun()], + }) + }) + + it('hydrates valid runs from localStorage', () => { + const run = createRun() + const storage = new TestStorage( + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [run], + }), + ) + + const store = createWorkflowRunStore({ storage }) + + expect(store.get(run.id)).toEqual(run) + }) + + it.each([ + ['invalid JSON', '{'], + ['unknown version', JSON.stringify({ version: 2, runs: [createRun()] })], + ['invalid payload', JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: {} })], + [ + 'invalid run', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [{ ...createRun(), currentRevisionId: 'missing-revision' }], + }), + ], + [ + 'inconsistent run status', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [{ ...createRun(), status: 'failed' }], + }), + ], + [ + 'multiple revisions in storage version 1', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRun(), + revisions: [ + ...createRun().revisions, + { + ...createRun().revisions[0], + id: 'revision-2', + status: 'abandoned', + }, + ], + }, + ], + }), + ], + [ + 'restart metadata in storage version 1', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRun(), + revisions: [ + { + ...createRun().revisions[0], + restartStepId: 'revision-1:character-setup', + }, + ], + }, + ], + }), + ], + ])('ignores %s in localStorage', (_label, serialized) => { + const store = createWorkflowRunStore({ storage: new TestStorage(serialized) }) + + expect(store.get('run-1')).toBeNull() + }) + + it('keeps the memory snapshot and notifies subscribers when persistence fails', () => { + const storage = new TestStorage() + storage.failOnSet = true + const store = createWorkflowRunStore({ storage }) + const listener = vi.fn() + const run = createRun() + + store.subscribe(run.id, listener) + + expect(() => store.save(run)).not.toThrow() + expect(store.get(run.id)).toEqual(run) + expect(listener).toHaveBeenCalledWith(run) + }) + + it('isolates subscriber values and stops notifications after unsubscribe', () => { + const store = createWorkflowRunStore({ storage: null }) + const run = createRun() + const secondListener = vi.fn() + const unsubscribeFirst = store.subscribe(run.id, (savedRun) => { + savedRun.prompt = 'mutated by first listener' + }) + const unsubscribeSecond = store.subscribe(run.id, secondListener) + + store.save(run) + + expect(secondListener).toHaveBeenLastCalledWith(run) + expect(store.get(run.id)).toEqual(run) + + unsubscribeFirst() + unsubscribeSecond() + store.save({ ...run, prompt: 'new prompt' }) + + expect(secondListener).toHaveBeenCalledTimes(1) + }) + + it('does not let one failing subscriber block the saved state or other subscribers', () => { + const store = createWorkflowRunStore({ storage: null }) + const run = createRun() + const secondListener = vi.fn() + + store.subscribe(run.id, () => { + throw new Error('render failed') + }) + store.subscribe(run.id, secondListener) + + expect(() => store.save(run)).not.toThrow() + expect(store.get(run.id)).toEqual(run) + expect(secondListener).toHaveBeenCalledWith(run) + }) + + it('uses the stable storage key by default', () => { + const setItem = vi.fn() + const store = createWorkflowRunStore({ + storage: { + getItem: vi.fn(() => null), + setItem, + }, + }) + + store.save(createRun()) + + expect(setItem).toHaveBeenCalledWith(WORKFLOW_RUN_STORAGE_KEY, expect.any(String)) + }) +}) diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts new file mode 100644 index 0000000..b2acd99 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -0,0 +1,607 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + WORKFLOW_STEP_ORDER, + type Generation, + type GenerationApis, + type GenerationInput, + type Task, + type TaskApis, + type TaskEvent, + type WorkflowRevision, + type WorkflowRun, + type WorkflowStep, + type WorkflowStepType, +} from '@/entities' +import { createWorkflowController } from '.' + +const NOW = '2026-07-30T12:00:00.000Z' + +type RunListener = (run: WorkflowRun) => void + +function cloneRun(run: WorkflowRun): WorkflowRun { + return structuredClone(run) +} + +function createMemoryStore() { + const runs = new Map() + const listeners = new Map>() + + const get = vi.fn((runId: string): WorkflowRun | null => { + const run = runs.get(runId) + return run ? cloneRun(run) : null + }) + + const save = vi.fn((run: WorkflowRun): void => { + const snapshot = cloneRun(run) + runs.set(run.id, snapshot) + + for (const listener of listeners.get(run.id) ?? []) { + listener(cloneRun(snapshot)) + } + }) + + const subscribe = vi.fn((runId: string, listener: RunListener): (() => void) => { + const runListeners = listeners.get(runId) ?? new Set() + runListeners.add(listener) + listeners.set(runId, runListeners) + + return () => { + runListeners.delete(listener) + } + }) + + return { get, save, subscribe } +} + +function createIdFactory() { + let nextId = 0 + return vi.fn(() => `id-${++nextId}`) +} + +function deferNextGeneration(harness: ReturnType) { + let resolve!: (generation: Generation<'character_template'>) => void + const promise = new Promise>((resolvePromise) => { + resolve = resolvePromise + }) + vi.mocked(harness.generationApis.create).mockImplementationOnce( + async () => (await promise) as Generation, + ) + return { resolve } +} + +function pendingCharacterTemplateGeneration(): Generation<'character_template'> { + return { + id: 'task-1', + projectId: 'project-1', + type: 'character_template', + status: 'pending', + result: null, + error: null, + } +} + +function createHarness() { + const store = createMemoryStore() + const taskListeners = new Map void>() + + const createGeneration: GenerationApis['create'] = async (input: T) => + ({ + id: 'task-1', + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + }) as Generation + + const generationApis: Pick = { + create: vi.fn(createGeneration), + } + + const subscribeTask = vi.fn( + (projectId: string, taskId: string, onEvent: (event: TaskEvent) => void) => { + taskListeners.set(`${projectId}:${taskId}`, onEvent) + onEvent({ + taskId, + type: 'character_template', + status: 'pending', + error: null, + result: null, + }) + return () => { + taskListeners.delete(`${projectId}:${taskId}`) + } + }, + ) + + const taskApis: TaskApis = { + get: vi.fn(async () => { + throw new Error('TaskApis.get is not used until a run is resumed') + }), + subscribe: subscribeTask, + } + + const controller = createWorkflowController({ + store, + generationApis, + taskApis, + createId: createIdFactory(), + now: () => NOW, + }) + + return { + controller, + generationApis, + subscribeTask, + store, + taskApis, + getTaskListener(projectId: string, taskId: string) { + return taskListeners.get(`${projectId}:${taskId}`) ?? null + }, + emitTask(projectId: string, taskId: string, event: TaskEvent) { + const listener = taskListeners.get(`${projectId}:${taskId}`) + expect(listener, `missing task subscription for ${projectId}:${taskId}`).toBeTypeOf( + 'function', + ) + listener?.(event) + }, + } +} + +function currentRevision(run: WorkflowRun): WorkflowRevision { + const revision = run.revisions.find(({ id }) => id === run.currentRevisionId) + if (!revision) { + throw new Error(`Current revision ${run.currentRevisionId} is missing`) + } + return revision +} + +function step(run: WorkflowRun, type: WorkflowStepType): WorkflowStep { + const workflowStep = currentRevision(run).steps.find((item) => item.type === type) + if (!workflowStep) { + throw new Error(`Workflow step ${type} is missing`) + } + return workflowStep +} + +async function createAiRun(harness: ReturnType) { + return harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: ' pixel knight ', + }) +} + +async function startCharacterTemplate(harness: ReturnType) { + const run = await createAiRun(harness) + await harness.controller.nextStep(run.id) + return run +} + +describe('createWorkflowController', () => { + it('creates one revision with the fixed eight steps and seeds AI input from the prompt', async () => { + const harness = createHarness() + + expect(harness.controller).toEqual( + expect.objectContaining({ + create: expect.any(Function), + getWorkflow: expect.any(Function), + subscribe: expect.any(Function), + updateCharacterSetup: expect.any(Function), + nextStep: expect.any(Function), + resume: expect.any(Function), + interrupt: expect.any(Function), + }), + ) + + const run = await createAiRun(harness) + const revision = currentRevision(run) + + expect(run.prompt).toBe('pixel knight') + expect(run.projectId).toBe('project-1') + expect(run.revisions).toHaveLength(1) + expect(run.currentRevisionId).toBe(revision.id) + expect(revision.basedOnRevisionId).toBeNull() + expect(revision.restartStepId).toBeNull() + expect(revision.createdAt).toBe(NOW) + expect(revision.steps.map(({ type }) => type)).toEqual(WORKFLOW_STEP_ORDER) + expect(revision.steps.map(({ status }) => status)).toEqual([ + 'active', + 'locked', + 'locked', + 'locked', + 'locked', + 'locked', + 'locked', + 'locked', + ]) + expect(step(run, 'character-setup').input).toEqual({ + description: 'pixel knight', + referenceMedia: [], + }) + + const allIds = [run.id, revision.id, ...revision.steps.map(({ id }) => id)] + expect(new Set(allIds).size).toBe(allIds.length) + expect(harness.store.save).toHaveBeenCalledWith(run) + }) + + it('persists the task id before subscribing and never submits the active generation twice', async () => { + const harness = createHarness() + + await startCharacterTemplate(harness) + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + expect(harness.generationApis.create).toHaveBeenCalledWith({ + type: 'character_template', + projectId: 'project-1', + prompt: 'pixel knight', + referenceMedia: [], + }) + expect(harness.subscribeTask).toHaveBeenCalledWith('project-1', 'task-1', expect.any(Function)) + + const taskSaveIndex = harness.store.save.mock.calls.findIndex(([savedRun]) => { + return step(savedRun, 'character-template').taskId === 'task-1' + }) + expect(taskSaveIndex).toBeGreaterThanOrEqual(0) + expect(harness.store.save.mock.invocationCallOrder[taskSaveIndex]).toBeLessThan( + harness.subscribeTask.mock.invocationCallOrder[0], + ) + + const createdRun = harness.store.save.mock.calls[0]?.[0] + if (!createdRun) throw new Error('Expected the created WorkflowRun to be saved') + const activeRun = harness.controller.getWorkflow(createdRun.id) + if (!activeRun) throw new Error('Expected the WorkflowRun to remain available') + expect(step(activeRun, 'character-setup').status).toBe('passed') + expect(step(activeRun, 'character-template')).toMatchObject({ + status: 'active', + taskId: 'task-1', + }) + + await harness.controller.nextStep(activeRun.id) + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + }) + + it('shares one submission when nextStep is called concurrently', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const deferred = deferNextGeneration(harness) + + const first = harness.controller.nextStep(run.id) + const second = harness.controller.nextStep(run.id) + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + deferred.resolve(pendingCharacterTemplateGeneration()) + await Promise.all([first, second]) + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + expect(step(harness.controller.getWorkflow(run.id)!, 'character-template').taskId).toBe( + 'task-1', + ) + }) + + it('keeps an active submission alive when resume uses the same controller', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const deferred = deferNextGeneration(harness) + + const submission = harness.controller.nextStep(run.id) + const resumed = await harness.controller.resume(run.id) + + expect(resumed?.status).toBe('active') + expect(step(resumed!, 'character-template')).toMatchObject({ + status: 'active', + taskId: null, + submissionId: expect.any(String), + }) + + deferred.resolve(pendingCharacterTemplateGeneration()) + await submission + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + expect(step(harness.controller.getWorkflow(run.id)!, 'character-template').taskId).toBe( + 'task-1', + ) + }) + + it('handles a terminal snapshot emitted synchronously when subscribing', async () => { + const harness = createHarness() + const stop = vi.fn() + harness.subscribeTask.mockImplementationOnce((_projectId, _taskId, onEvent) => { + onEvent({ + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/synchronous.png' }], + }, + }) + return stop + }) + + const run = await createAiRun(harness) + await harness.controller.nextStep(run.id) + + expect(step(harness.controller.getWorkflow(run.id)!, 'character-template')).toMatchObject({ + status: 'passed', + taskId: null, + }) + expect(step(harness.controller.getWorkflow(run.id)!, 'template-candidate').status).toBe( + 'active', + ) + expect(stop).toHaveBeenCalledOnce() + }) + + it('ignores another task result and advances only when the matching task completes', async () => { + const harness = createHarness() + + const run = await startCharacterTemplate(harness) + + harness.emitTask('project-1', 'task-1', { + taskId: 'another-task', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/wrong.png' }], + }, + }) + + const unchangedRun = harness.controller.getWorkflow(run.id) + if (!unchangedRun) throw new Error('Expected the WorkflowRun to remain available') + expect(step(unchangedRun, 'character-template')).toMatchObject({ + status: 'active', + output: null, + taskId: 'task-1', + }) + expect(step(unchangedRun, 'template-candidate').status).toBe('locked') + + const result = { + type: 'character_template' as const, + images: [{ url: 'https://example.com/knight.png' }], + } + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result, + }) + + const completedRun = harness.controller.getWorkflow(run.id) + if (!completedRun) throw new Error('Expected the WorkflowRun to remain available') + expect(step(completedRun, 'character-template')).toMatchObject({ + status: 'passed', + output: result, + taskId: null, + }) + expect(step(completedRun, 'template-candidate').status).toBe('active') + }) + + it('marks the step, revision, generation, and run as failed when the task fails', async () => { + const harness = createHarness() + + const run = await startCharacterTemplate(harness) + + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'failed', + error: 'model unavailable', + result: null, + }) + + const failedRun = harness.controller.getWorkflow(run.id) + if (!failedRun) throw new Error('Expected the WorkflowRun to remain available') + const revision = currentRevision(failedRun) + expect(step(failedRun, 'character-template')).toMatchObject({ + status: 'failed', + taskId: null, + submissionId: null, + error: 'model unavailable', + }) + expect(revision.status).toBe('failed') + expect(revision.generationStatus).toBe('failed') + expect(failedRun.status).toBe('failed') + }) + + it('resumes a persisted in-flight task without creating another generation', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + vi.mocked(harness.taskApis.get).mockResolvedValueOnce({ + id: 'task-1', + type: 'character_template', + status: 'running', + error: null, + result: null, + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: TaskEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: harness.generationApis, + taskApis: { + get: harness.taskApis.get, + subscribe: resumeSubscribe, + }, + }) + + const resumed = await resumedController.resume(run.id) + + expect(resumed?.id).toBe(run.id) + expect(harness.taskApis.get).toHaveBeenCalledWith('project-1', 'task-1') + expect(resumeSubscribe).toHaveBeenCalledWith('project-1', 'task-1', expect.any(Function)) + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + }) + + it('applies a completed task found during refresh before subscribing again', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + vi.mocked(harness.taskApis.get).mockResolvedValueOnce({ + id: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/recovered.png' }], + }, + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: TaskEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: harness.generationApis, + taskApis: { + get: harness.taskApis.get, + subscribe: resumeSubscribe, + }, + }) + + const resumed = await resumedController.resume(run.id) + + expect(step(resumed!, 'character-template')).toMatchObject({ + status: 'passed', + taskId: null, + }) + expect(step(resumed!, 'template-candidate').status).toBe('active') + expect(resumeSubscribe).not.toHaveBeenCalled() + }) + + it('does not subscribe after interrupting while resume waits for the task query', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + let resolveTask!: (task: Task) => void + const pendingTask = new Promise((resolve) => { + resolveTask = resolve + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: TaskEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: harness.generationApis, + taskApis: { + get: vi.fn(() => pendingTask), + subscribe: resumeSubscribe, + }, + }) + + const resuming = resumedController.resume(run.id) + resumedController.interrupt(run.id) + resolveTask({ + id: 'task-1', + type: 'character_template', + status: 'running', + error: null, + result: null, + }) + const resumed = await resuming + + expect(resumed?.status).toBe('interrupted') + expect(resumeSubscribe).not.toHaveBeenCalled() + }) + + it('fails safely after refresh when the request was sent before taskId arrived', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + const uncertainSnapshot = harness.store.save.mock.calls + .map(([savedRun]) => savedRun) + .find((savedRun) => { + const template = step(savedRun, 'character-template') + return template.submissionId !== null && template.taskId === null + }) + if (!uncertainSnapshot) throw new Error('expected the submitting snapshot to be persisted') + harness.store.save(uncertainSnapshot) + vi.mocked(harness.generationApis.create).mockClear() + + const restoredController = createWorkflowController({ + store: harness.store, + generationApis: harness.generationApis, + taskApis: harness.taskApis, + }) + + const restored = await restoredController.resume(run.id) + + expect(restored?.status).toBe('failed') + expect(step(restored!, 'character-template')).toMatchObject({ + status: 'failed', + taskId: null, + submissionId: null, + error: '页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交', + }) + expect(harness.generationApis.create).not.toHaveBeenCalled() + }) + + it('records a task id that returns after the run was interrupted', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const deferred = deferNextGeneration(harness) + + const submission = harness.controller.nextStep(run.id) + await harness.controller.interrupt(run.id) + deferred.resolve(pendingCharacterTemplateGeneration()) + await submission + + const interrupted = harness.controller.getWorkflow(run.id) + expect(interrupted?.status).toBe('interrupted') + expect(step(interrupted!, 'character-template')).toMatchObject({ + status: 'active', + taskId: 'task-1', + submissionId: null, + }) + expect(harness.subscribeTask).not.toHaveBeenCalled() + }) + + it('keeps an interrupted run interrupted when a queued failure arrives late', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + const queuedListener = harness.getTaskListener('project-1', 'task-1') + if (!queuedListener) throw new Error('expected an active task subscription') + + await harness.controller.interrupt(run.id) + queuedListener({ + taskId: 'task-1', + type: 'character_template', + status: 'failed', + error: 'late failure', + result: null, + }) + + expect(harness.controller.getWorkflow(run.id)?.status).toBe('interrupted') + }) + + it('publishes saved updates and can interrupt the current run', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const listener = vi.fn() + const unsubscribe = harness.controller.subscribe(run.id, listener) + + const updated = await harness.controller.updateCharacterSetup(run.id, { + description: 'revised knight', + referenceMedia: [], + }) + expect(step(updated, 'character-setup').input).toEqual({ + description: 'revised knight', + referenceMedia: [], + }) + + const interrupted = await harness.controller.interrupt(run.id) + + expect(interrupted.status).toBe('interrupted') + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ id: run.id, status: 'interrupted' }), + ) + + unsubscribe() + }) +}) diff --git a/frontend/src/features/workflow-controller/workflow-run.integration.test.ts b/frontend/src/features/workflow-controller/workflow-run.integration.test.ts new file mode 100644 index 0000000..ca4cc9a --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-run.integration.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + createWorkflowRunStore, + type Generation, + type GenerationApis, + type GenerationInput, + type TaskApis, + type TaskEvent, +} from '@/entities' +import { createWorkflowController } from '.' + +describe('WorkflowRun first vertical slice', () => { + it('runs character setup through a completed character-template task', async () => { + const store = createWorkflowRunStore({ storage: null }) + const taskChannel: { listener?: (event: TaskEvent) => void } = {} + + const createGeneration: GenerationApis['create'] = async ( + input: T, + ) => + ({ + id: 'task-character-template-1', + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + }) as Generation + + const generationApis: Pick = { + create: vi.fn(createGeneration), + } + const taskApis: TaskApis = { + get: vi.fn(async () => { + throw new Error('not used in this slice') + }), + subscribe: vi.fn((_projectId, taskId, onEvent) => { + taskChannel.listener = onEvent + onEvent({ + taskId, + type: 'character_template', + status: 'pending', + error: null, + result: null, + }) + return () => { + delete taskChannel.listener + } + }), + } + const ids = ['run-1', 'revision-1'] + const controller = createWorkflowController({ + store, + generationApis, + taskApis, + createId: () => ids.shift() ?? 'unexpected-id', + now: () => '2026-07-30T12:00:00.000Z', + }) + + const created = await controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + + await controller.nextStep(created.id) + + const inFlight = store.get(created.id) + expect( + inFlight?.revisions[0].steps.find((step) => step.type === 'character-template'), + ).toMatchObject({ + status: 'active', + taskId: 'task-character-template-1', + }) + + const taskListener = taskChannel.listener + if (!taskListener) throw new Error('expected the task subscription to be active') + taskListener({ + taskId: 'task-character-template-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/knight.png' }], + }, + }) + await Promise.resolve() + + const completed = store.get(created.id) + expect( + completed?.revisions[0].steps.find((step) => step.type === 'character-template'), + ).toMatchObject({ + status: 'passed', + taskId: null, + output: { + type: 'character_template', + images: [{ url: 'https://example.com/knight.png' }], + }, + }) + expect( + completed?.revisions[0].steps.find((step) => step.type === 'template-candidate'), + ).toMatchObject({ status: 'active' }) + }) +}) From 339edbad18eb12f7e87d0da8a6e9035a293515c3 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 01:35:43 +0800 Subject: [PATCH 25/29] docs(frontend): document workflow runtime boundaries The skeleton documentation no longer reflects the implemented workflow slice. Record controller scope, recovery behavior, task replay, and local persistence limits. Keep editor, Quick Start automation, and later steps explicitly out of scope. --- frontend-architecture-v3.md | 37 +++++++++++++++++++++++++------------ frontend/API_CONTRACT.md | 2 +- frontend/README.md | 6 ++++-- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index 5c06229..0c326e6 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -1,6 +1,6 @@ # Windup 前端架构 -本文记录当前前端的模块划分与依赖规则。2026-07-30 按当日评审意见重写:本阶段只提交模块边界与接口,实现进后续 PR。 +本文记录当前前端的模块划分、依赖规则和已经落地的首个工作流纵切。 --- @@ -69,7 +69,8 @@ TaskApis `features/workflow-controller` 是快速开始与手动工作流共用的推进边界,不含界面。 -Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份步骤数据,不拆成互不共享状态的独立模块。 +Controller 围绕同一份 WorkflowRun 提供创建、读取、订阅、当前步骤更新、推进、 +任务恢复、结果写回和中断。这些操作依赖同一份步骤数据,不拆成互不共享状态的独立模块。 步骤顺序固定八步: @@ -77,23 +78,35 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断 角色资料 → 角色图 → 候选选择 → 动作资料 → 首帧 → 完整动画 → 审核 → 导出 ``` -**步骤怎么走、运行状态如何保存都由前端决定。** 后端不参与 WorkflowRun,只接收各节点发起的生成请求,并在最终确认时持久化角色与动作资产。 +**步骤怎么走、运行状态如何保存都由前端决定。** 后端不参与 WorkflowRun, +只接收各节点发起的生成请求,并在最终确认时持久化角色与动作资产。固定八步是当前 +产品流程,不是为了通用编排而写的可配置工作流。 -从历史步骤重开会追加一个新 Revision,旧 Revision 保留为只读历史,不会被改写成失败或完成。 +当前存储版本只支持一个 Revision。从历史步骤重开尚未进入产品定义,Controller +不提前暴露该操作;实现时必须同步升级本地存储版本和迁移规则。 -快速开始与手动模式共用同一份推进逻辑,区别只是前者连续调用、后者一次一步。隐藏步骤不等于跳过步骤——门禁写在流程模型里,不在界面里。 +快速开始与手动模式将共用同一份推进逻辑,但连续自动推进属于 Quick Start 页面接入范围, +当前 Controller 只实现一次推进一个步骤。 + +Controller 的提交锁和任务订阅属于实例状态。页面接入时必须复用同一个 Feature 实例, +不能在组件渲染或路由切换时重复创建。 --- -## 5. 本次不包含 +## 5. 当前实现范围 + +- `WorkflowRun` 的内存状态、版本化 localStorage 镜像和刷新校验 +- `角色资料 → 角色图生成 → 候选选择` 的 Controller 纵切 +- Store、Controller 和纵向流程测试 + +页面、Workflow Editor、Quick Start 自动推进、后五步和真实后端适配器仍未实现。 -- 任何实现代码(真实请求、假数据、组件内部逻辑) -- 测试文件 -- 图片上传模块(体量太小,本次不单独体现) -- 穿戴道具相关(产品侧未设计) -- 第三方登录 +### 恢复边界 -页面当前是占位外壳,只声明路由与模块边界。 +- 已取得 `taskId`:刷新后先查询任务当前状态,未结束才重新订阅。 +- 请求已经发出但尚未取得 `taskId`:后端没有幂等键或按请求标识查询的能力, + 前端将本地 Run 标为失败,不自动重提,避免静默创建重复任务。 +- localStorage 写入失败时当前会话继续使用内存快照;页面提示与重新持久化策略在 UI 接入时补充。 --- diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index 45e7189..427b6eb 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -45,7 +45,7 @@ |---|---|---| | 角色列表 | `list_characters` 分页,返回 `(list, total)` | `listByProject` 无分页 | | 更新角色 | `update_character(character_id, **fields)` 部分更新 | `update(character)` 整棵树替换 | -| 等待任务完成 | 提供 `GET /generation/tasks/{task_id}` 轮询 | `TaskApis.subscribe`,实现时可封装轮询 | +| 等待任务完成 | 提供 `GET /generation/tasks/{task_id}` 轮询 | `TaskApis.subscribe`;适配器先立即回放当前快照,再继续轮询 | | 图片生成数量 | 入参有 `num_images`,结果只有一个 `image_url` | 角色图候选结果是 `images[]` | | 动作类型 | `walk` `idle` `attack` `custom`;待增加 `jump` | `walk` `idle` `attack` `jump` `custom` | | 角色视角 | `character_perspective` 为 `1~3`,文档中 2、3 都写成“正面” | `side` `top-down` `isometric` | diff --git a/frontend/README.md b/frontend/README.md index 6e01fa3..a7878ed 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -15,7 +15,7 @@ npm run dev npm run format:check # 格式 npm run lint # 静态检查 npm run typecheck # 类型 -npm run test # 测试(本阶段无测试文件) +npm run test # 单元与纵向集成测试 npm run build # 构建 ``` @@ -25,6 +25,8 @@ CI 按上面顺序全跑一遍。 模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。 -**本阶段只提交模块边界与接口,不含实现。** 页面是占位外壳,各模块只有类型与 `XxxApis` 接口。实现按模块拆成后续 PR。 +当前已实现纯前端 `WorkflowRun` 存储,以及 +`角色资料 → 角色图生成 → 候选选择` 的首个 Controller 纵切。页面仍是占位外壳, +后五步和真实 `XxxApis` 实现按模块拆成后续 PR。 与后端尚未对齐的接口见 `API_CONTRACT.md`。 From b72262fc373de9b2a06a436ded2defeb7d002d16 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 03:03:39 +0800 Subject: [PATCH 26/29] refactor(workflow-controller): separate state and task runtime The workflow controller mixed process coordination, state transitions, and asynchronous task recovery in one file. Extract pure WorkflowRun transitions and the character-template task lifecycle behind the existing controller facade. Keep the public API and runtime behavior unchanged while making later workflow steps easier to add. --- .../character-template-task.ts | 457 +++++++++++++ .../workflow-controller/controller.ts | 644 ++---------------- .../workflow-controller/workflow-state.ts | 217 ++++++ 3 files changed, 721 insertions(+), 597 deletions(-) create mode 100644 frontend/src/features/workflow-controller/character-template-task.ts create mode 100644 frontend/src/features/workflow-controller/workflow-state.ts diff --git a/frontend/src/features/workflow-controller/character-template-task.ts b/frontend/src/features/workflow-controller/character-template-task.ts new file mode 100644 index 0000000..d0656b7 --- /dev/null +++ b/frontend/src/features/workflow-controller/character-template-task.ts @@ -0,0 +1,457 @@ +import { + parseCharacterTemplateGenerationResult, + type GenerationApis, + type Task, + type TaskApis, + type TaskEvent, + type WorkflowRevision, + type WorkflowRun, + type WorkflowRunStore, + type WorkflowStep, +} from '@/entities' +import { + getActiveStep, + getCurrentRevision, + replaceWorkflowStep, + type WorkflowStepTarget, +} from './workflow-state' + +interface ApplyServerResultInput extends WorkflowStepTarget { + /** 结果必须仍属于步骤当前记录的任务;重试前的旧结果会被忽略。 */ + taskId: string + result: unknown +} + +interface ActiveSubscription { + runId: WorkflowRun['id'] + stop: () => void +} + +export interface CharacterTemplateTask { + /** 启动或继续目标角色图步骤;同一实例内的重复调用共享一次提交。 */ + start(runId: WorkflowRun['id'], target: WorkflowStepTarget): Promise + + /** 页面恢复时先读取任务终态;仍在运行时再恢复订阅。 */ + resume(runId: WorkflowRun['id']): Promise + + /** 停止指定运行记录的前端任务订阅,不改变 WorkflowRun 状态。 */ + stop(runId: WorkflowRun['id']): void +} + +interface CreateCharacterTemplateTaskOptions { + store: WorkflowRunStore + generationApis: Pick + taskApis: TaskApis + createSubmissionId: () => string +} + +/** + * 角色图异步任务的生命周期。 + * + * 它只处理当前角色图步骤与后端 Task 的关联,不决定整个工作流下一步走什么。 + * submissions 与 subscriptions 属于实例锁;生产环境必须复用同一个实例。 + */ +export function createCharacterTemplateTask({ + store, + generationApis, + taskApis, + createSubmissionId, +}: CreateCharacterTemplateTaskOptions): CharacterTemplateTask { + const submissions = new Map>() + const subscriptions = new Map() + + function getWorkflow(runId: WorkflowRun['id']) { + return store.get(runId) + } + + function requireWorkflow(runId: WorkflowRun['id']) { + const run = getWorkflow(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run + } + + function save(run: WorkflowRun) { + store.save(run) + return run + } + + function start(runId: WorkflowRun['id'], target: WorkflowStepTarget): Promise { + const run = requireWorkflow(runId) + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === target.stepId) + if ( + revision.id !== target.revisionId || + !step || + step.type !== 'character-template' || + step.status !== 'active' + ) { + return Promise.resolve(run) + } + if (step.taskId) { + ensureTaskSubscription(run, target.revisionId, target.stepId, step.taskId) + return Promise.resolve(requireWorkflow(runId)) + } + if (!step.input) throw new Error('角色图生成步骤缺少输入快照') + return submit(runId, target) + } + + function submit(runId: WorkflowRun['id'], target: WorkflowStepTarget) { + const key = submissionKey(runId, target.revisionId, target.stepId) + const pending = submissions.get(key) + if (pending) return pending + + const submission = performSubmission(runId, target).finally(() => { + submissions.delete(key) + }) + submissions.set(key, submission) + return submission + } + + async function performSubmission( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + ): Promise { + const before = requireWorkflow(runId) + const beforeRevision = getCurrentRevision(before) + const beforeStep = beforeRevision.steps.find((step) => step.id === target.stepId) + if ( + before.status !== 'active' || + beforeRevision.id !== target.revisionId || + !beforeStep || + beforeStep.type !== 'character-template' || + beforeStep.status !== 'active' || + !beforeStep.input + ) { + return before + } + if (beforeStep.taskId) { + ensureTaskSubscription(before, target.revisionId, target.stepId, beforeStep.taskId) + return before + } + if (beforeStep.submissionId) { + throw new Error('角色图生成请求仍在等待后端确认,不能重复提交') + } + + const submissionId = createSubmissionId() + const submitting = replaceWorkflowStep(before, target.revisionId, target.stepId, (current) => { + if (current.type !== 'character-template') return current + return { ...current, submissionId } + }) + save(submitting) + + try { + const generation = await generationApis.create(beforeStep.input) + const latest = requireWorkflow(runId) + const latestRevision = getCurrentRevision(latest) + const latestStep = latestRevision.steps.find((step) => step.id === target.stepId) + if ( + (latest.status !== 'active' && latest.status !== 'interrupted') || + latestRevision.id !== target.revisionId || + !latestStep || + latestStep.type !== 'character-template' || + latestStep.status !== 'active' || + latestStep.taskId || + latestStep.submissionId !== submissionId + ) { + return latest + } + if (generation.type !== 'character_template' || generation.projectId !== latest.projectId) { + throw new Error('生成任务返回的类型或项目与当前 WorkflowRun 不匹配') + } + + const withTask = replaceWorkflowStep(latest, target.revisionId, target.stepId, (current) => { + if (current.type !== 'character-template') return current + return { ...current, taskId: generation.id, submissionId: null } + }) + save(withTask) + + if (latest.status === 'interrupted') return withTask + if (generation.status === 'failed') { + return markFailed( + runId, + target, + generation.id, + null, + generation.error?.trim() || '角色图生成任务失败', + ) + } + if (generation.status === 'completed') { + return applyServerResult(runId, { + ...target, + taskId: generation.id, + result: generation.result, + }) + } + + ensureTaskSubscription(withTask, target.revisionId, target.stepId, generation.id) + return requireWorkflow(runId) + } catch (cause) { + markFailed(runId, target, null, submissionId, errorMessage(cause, '角色图生成请求失败')) + throw cause instanceof Error ? cause : new Error(String(cause)) + } + } + + function ensureTaskSubscription( + run: WorkflowRun, + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + taskId: string, + ) { + const key = subscriptionKey(run.id, revisionId, stepId, taskId) + if (subscriptions.has(key)) return + + subscriptions.set(key, { runId: run.id, stop: () => undefined }) + try { + const stop = taskApis.subscribe(run.projectId, taskId, (event) => { + handleTaskEvent(run.id, { revisionId, stepId }, taskId, event) + }) + const active = subscriptions.get(key) + if (active) subscriptions.set(key, { ...active, stop }) + else stop() + } catch (cause) { + subscriptions.delete(key) + throw cause + } + } + + function handleTaskEvent( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + taskId: string, + event: TaskEvent, + ) { + if (event.taskId !== taskId) return + if (event.status === 'pending' || event.status === 'running') return + if (event.status === 'failed') { + markFailed(runId, target, taskId, null, event.error?.trim() || '角色图生成任务失败') + return + } + if (event.type !== 'character_template') { + markFailed(runId, target, taskId, null, '任务结果类型与角色图生成步骤不匹配') + return + } + applyServerResult(runId, { + ...target, + taskId, + result: event.result, + }) + } + + async function resume(runId: WorkflowRun['id']): Promise { + const run = getWorkflow(runId) + if (!run || run.status !== 'active') return run + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (activeStep?.type !== 'character-template' || activeStep.status !== 'active') { + return run + } + const target = { revisionId: revision.id, stepId: activeStep.id } + + if (activeStep.submissionId && !activeStep.taskId) { + if (submissions.has(submissionKey(run.id, revision.id, activeStep.id))) { + return run + } + return markFailed( + run.id, + target, + null, + activeStep.submissionId, + '页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交', + ) + } + if (activeStep.taskId) { + const task = await taskApis.get(run.projectId, activeStep.taskId) + const latest = getWorkflow(run.id) + if (!latest || latest.status !== 'active' || latest.currentRevisionId !== revision.id) { + return latest + } + const latestRevision = getCurrentRevision(latest) + const latestStep = latestRevision.steps.find((step) => step.id === activeStep.id) + if ( + latestStep?.type !== 'character-template' || + latestStep.status !== 'active' || + latestStep.taskId !== activeStep.taskId + ) { + return latest + } + if (task.id !== latestStep.taskId) { + throw new Error('任务查询结果与 WorkflowRun 记录的 taskId 不匹配') + } + if (task.type !== 'character_template') { + return markFailed( + latest.id, + { revisionId: latestRevision.id, stepId: latestStep.id }, + latestStep.taskId, + null, + '任务查询结果类型与角色图生成步骤不匹配', + ) + } + if (task.status === 'pending' || task.status === 'running') { + ensureTaskSubscription(latest, latestRevision.id, latestStep.id, latestStep.taskId) + } else { + handleTaskEvent( + latest.id, + { revisionId: latestRevision.id, stepId: latestStep.id }, + latestStep.taskId, + taskEvent(task), + ) + } + } + return getWorkflow(runId) + } + + function applyServerResult(runId: WorkflowRun['id'], input: ApplyServerResultInput): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active' || run.currentRevisionId !== input.revisionId) { + return run + } + + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === input.stepId) + if ( + !step || + step.type !== 'character-template' || + step.status !== 'active' || + step.taskId !== input.taskId + ) { + return run + } + + const result = parseCharacterTemplateGenerationResult(input.result) + if (!result) { + return markFailed( + runId, + { revisionId: revision.id, stepId: step.id }, + input.taskId, + null, + '角色图生成任务返回了无法识别的结果', + ) + } + const candidateStep = revision.steps.find((item) => item.type === 'template-candidate') + if (!candidateStep) throw new Error('WorkflowRun 缺少 template-candidate 步骤') + + const updated: WorkflowRun = { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + steps: item.steps.map((current) => { + if (current.id === step.id && current.type === 'character-template') { + return { + ...current, + status: 'passed' as const, + output: result, + taskId: null, + submissionId: null, + } + } + if (current.id === candidateStep.id && current.type === 'template-candidate') { + return { ...current, status: 'active' as const } + } + return current + }), + } + }), + } + stopSubscription(subscriptionKey(run.id, revision.id, step.id, input.taskId)) + return save(updated) + } + + function markFailed( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + expectedTaskId: string | null, + expectedSubmissionId: string | null, + error: string, + ) { + const run = requireWorkflow(runId) + if (run.status !== 'active' || run.currentRevisionId !== target.revisionId) return run + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === target.stepId) + if ( + !step || + step.type !== 'character-template' || + step.status !== 'active' || + (expectedTaskId !== null && step.taskId !== expectedTaskId) || + (expectedSubmissionId !== null && step.submissionId !== expectedSubmissionId) + ) { + return run + } + + const failureMessage = error.trim() || '角色图生成失败' + const failed: WorkflowRun = { + ...replaceWorkflowStep( + run, + target.revisionId, + target.stepId, + (current) => ({ + ...current, + status: 'failed', + taskId: null, + submissionId: null, + error: failureMessage, + }), + (current) => ({ + ...current, + status: 'failed', + generationStatus: 'failed', + }), + ), + status: 'failed', + } + if (step.taskId) { + stopSubscription(subscriptionKey(run.id, revision.id, step.id, step.taskId)) + } + return save(failed) + } + + function stopSubscription(key: string) { + const subscription = subscriptions.get(key) + subscriptions.delete(key) + try { + subscription?.stop() + } catch { + // 取消轮询失败不能反向破坏已经落盘的 WorkflowRun 状态。 + } + } + + function stop(runId: WorkflowRun['id']) { + for (const [key, subscription] of subscriptions) { + if (subscription.runId === runId) stopSubscription(key) + } + } + + return { start, resume, stop } +} + +function taskEvent(task: Task): TaskEvent { + return { + taskId: task.id, + type: task.type, + status: task.status, + error: task.error, + result: task.result, + } +} + +function errorMessage(cause: unknown, fallback: string) { + return cause instanceof Error && cause.message.trim() ? cause.message.trim() : fallback +} + +function subscriptionKey( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + taskId: string, +) { + return `${runId}:${revisionId}:${stepId}:${taskId}` +} + +function submissionKey( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], +) { + return `${runId}:${revisionId}:${stepId}` +} diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 21cd581..58194dc 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -1,34 +1,24 @@ -import { - parseCharacterTemplateGenerationResult, - WORKFLOW_STEP_ORDER, - type CharacterSetupStepInput, - type CharacterTemplateGenerationInput, - type CreateWorkflowRunInput, - type GenerationApis, - type Task, - type TaskApis, - type TaskEvent, - type WorkflowRevision, - type WorkflowRun, - type WorkflowRunStore, - type WorkflowStep, - type WorkflowStepStatus, - type WorkflowStepType, +import type { + CharacterSetupStepInput, + GenerationApis, + TaskApis, + WorkflowRun, + WorkflowRunStore, } from '@/entities' - -interface ApplyServerResultInput { - revisionId: WorkflowRevision['id'] - stepId: WorkflowStep['id'] - /** 结果必须仍属于步骤当前记录的任务;重试前的旧结果会被忽略。 */ - taskId: string - result: unknown -} +import { createCharacterTemplateTask } from './character-template-task' +import { + advanceCharacterSetupState, + createWorkflowRunState, + getActiveStep, + getCurrentRevision, + interruptWorkflowRunState, + requireActiveWorkflow, + updateCharacterSetupState, + type CreateWorkflowRunStateInput, +} from './workflow-state' /** 首个纵切只开放创建角色;增加动作进入对应步骤实现时再加入 Controller。 */ -export type CreateWorkflowControllerInput = Extract< - CreateWorkflowRunInput, - { purpose: 'create_character' } -> +export type CreateWorkflowControllerInput = CreateWorkflowRunStateInput export interface WorkflowController { /** 创建并保存一条纯前端运行记录。 */ @@ -66,17 +56,11 @@ export interface CreateWorkflowControllerOptions { now?: () => string } -interface ActiveSubscription { - runId: WorkflowRun['id'] - stop: () => void -} - /** - * WorkflowRun 的唯一推进实现。 + * Quick Start 与手动工作流共用的流程协调器。 * - * Controller 管前端状态和后端任务的关联;Generation 与 Task 只认识自己的 ID, - * 不读取 WorkflowRun、Revision 或 Step。 - * submissions 与 subscriptions 是实例内锁;生产接入必须复用同一个实例, + * Controller 只负责读取当前步骤、保存状态并委派角色图任务;纯状态转换和异步任务 + * 生命周期分别留在本 Feature 的内部模块。生产接入必须复用同一个 Controller 实例, * 不能在组件渲染期间重复创建。 */ export function createWorkflowController({ @@ -86,8 +70,12 @@ export function createWorkflowController({ createId = createRuntimeId, now = () => new Date().toISOString(), }: CreateWorkflowControllerOptions): WorkflowController { - const submissions = new Map>() - const subscriptions = new Map() + const characterTemplateTask = createCharacterTemplateTask({ + store, + generationApis, + taskApis, + createSubmissionId: () => createId('submission'), + }) function getWorkflow(runId: WorkflowRun['id']) { return store.get(runId) @@ -104,68 +92,14 @@ export function createWorkflowController({ return run } - function getCurrentRevision(run: WorkflowRun) { - const revision = run.revisions.find((item) => item.id === run.currentRevisionId) - if (!revision) throw new Error(`WorkflowRun ${run.id} 的 currentRevisionId 无效`) - return revision - } - - function getActiveStep(revision: WorkflowRevision) { - return revision.steps.find((step) => step.status === 'active') ?? null - } - - function replaceStep( - run: WorkflowRun, - revisionId: WorkflowRevision['id'], - stepId: WorkflowStep['id'], - update: (step: WorkflowStep) => WorkflowStep, - revisionUpdate?: (revision: WorkflowRevision) => WorkflowRevision, - ) { - return { - ...run, - revisions: run.revisions.map((revision) => { - if (revision.id !== revisionId) return revision - const nextRevision = { - ...revision, - steps: revision.steps.map((step) => (step.id === stepId ? update(step) : step)), - } - return revisionUpdate ? revisionUpdate(nextRevision) : nextRevision - }), - } - } - function create(input: CreateWorkflowControllerInput): WorkflowRun { - const prompt = input.prompt?.trim() || null - const runId = createId('run') - const revisionId = createId('revision') - const steps = WORKFLOW_STEP_ORDER.map((type, index) => - createInitialStep(type, revisionId, index, prompt), + return save( + createWorkflowRunState(input, { + runId: createId('run'), + revisionId: createId('revision'), + createdAt: now(), + }), ) - const run: WorkflowRun = { - id: runId, - projectId: input.projectId, - characterId: null, - outfitId: null, - purpose: input.purpose, - driver: input.driver, - status: 'active', - currentRevisionId: revisionId, - revisions: [ - { - id: revisionId, - basedOnRevisionId: null, - restartStepId: null, - status: 'active', - steps, - generationStatus: 'not_started', - exportStatus: 'not_exported', - createdAt: now(), - }, - ], - prompt, - } - save(run) - return run } function subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void) { @@ -176,444 +110,42 @@ export function createWorkflowController({ runId: WorkflowRun['id'], input: CharacterSetupStepInput, ): WorkflowRun { - const run = requireActiveWorkflow(runId, requireWorkflow) - const revision = getCurrentRevision(run) - const step = revision.steps.find((item) => item.type === 'character-setup') - if (!step || step.type !== 'character-setup' || step.status !== 'active') { - throw new Error('当前只能更新处于 active 状态的角色资料步骤') - } - - const description = input.description.trim() - if (!description) throw new Error('角色描述不能为空') - - const updated = replaceStep(run, revision.id, step.id, (current) => { - if (current.type !== 'character-setup') return current - return { - ...current, - input: { - description, - referenceMedia: [...input.referenceMedia], - }, - } - }) - return save(updated) + return save(updateCharacterSetupState(requireWorkflow(runId), input)) } async function nextStep(runId: WorkflowRun['id']): Promise { - const run = requireActiveWorkflow(runId, requireWorkflow) + const run = requireActiveWorkflow(requireWorkflow(runId)) const revision = getCurrentRevision(run) const activeStep = getActiveStep(revision) if (!activeStep) throw new Error('当前 WorkflowRun 没有 active 步骤') if (activeStep.type === 'character-template') { - if (activeStep.taskId) { - ensureTaskSubscription(run, revision.id, activeStep.id, activeStep.taskId) - return requireWorkflow(runId) - } - if (!activeStep.input) throw new Error('角色图生成步骤缺少输入快照') - return submitCharacterTemplate(runId, revision.id, activeStep.id) + return characterTemplateTask.start(runId, { + revisionId: revision.id, + stepId: activeStep.id, + }) } - if (activeStep.type !== 'character-setup') { throw new Error(`步骤 ${activeStep.type} 尚未进入本轮实现`) } - if (!activeStep.input) throw new Error('请先填写角色资料') - - const templateStep = revision.steps.find((step) => step.type === 'character-template') - if (!templateStep) throw new Error('WorkflowRun 缺少 character-template 步骤') - - const generationInput: CharacterTemplateGenerationInput = { - type: 'character_template', - projectId: run.projectId, - prompt: activeStep.input.description, - referenceMedia: activeStep.input.referenceMedia, - } - - const transitioned: WorkflowRun = { - ...run, - revisions: run.revisions.map((item) => { - if (item.id !== revision.id) return item - return { - ...item, - generationStatus: 'in_progress' as const, - steps: item.steps.map((step) => { - if (step.id === activeStep.id) return { ...step, status: 'passed' as const } - if (step.id !== templateStep.id || step.type !== 'character-template') return step - return { - ...step, - status: 'active' as const, - input: generationInput, - } - }), - } - }), - } - save(transitioned) - return submitCharacterTemplate(runId, revision.id, templateStep.id) - } - - function submitCharacterTemplate( - runId: WorkflowRun['id'], - revisionId: WorkflowRevision['id'], - stepId: WorkflowStep['id'], - ) { - const key = submissionKey(runId, revisionId, stepId) - const pending = submissions.get(key) - if (pending) return pending - - const submission = performCharacterTemplateSubmission(runId, revisionId, stepId).finally(() => { - submissions.delete(key) - }) - submissions.set(key, submission) - return submission - } - - async function performCharacterTemplateSubmission( - runId: WorkflowRun['id'], - revisionId: WorkflowRevision['id'], - stepId: WorkflowStep['id'], - ): Promise { - const before = requireWorkflow(runId) - const beforeRevision = getCurrentRevision(before) - const beforeStep = beforeRevision.steps.find((step) => step.id === stepId) - if ( - before.status !== 'active' || - beforeRevision.id !== revisionId || - !beforeStep || - beforeStep.type !== 'character-template' || - beforeStep.status !== 'active' || - !beforeStep.input - ) { - return before - } - if (beforeStep.taskId) { - ensureTaskSubscription(before, revisionId, stepId, beforeStep.taskId) - return before - } - if (beforeStep.submissionId) { - throw new Error('角色图生成请求仍在等待后端确认,不能重复提交') - } - - const submissionId = createId('submission') - const submitting = replaceStep(before, revisionId, stepId, (current) => { - if (current.type !== 'character-template') return current - return { ...current, submissionId } - }) - save(submitting) - try { - const generation = await generationApis.create(beforeStep.input) - const latest = requireWorkflow(runId) - const latestRevision = getCurrentRevision(latest) - const latestStep = latestRevision.steps.find((step) => step.id === stepId) - if ( - (latest.status !== 'active' && latest.status !== 'interrupted') || - latestRevision.id !== revisionId || - !latestStep || - latestStep.type !== 'character-template' || - latestStep.status !== 'active' || - latestStep.taskId || - latestStep.submissionId !== submissionId - ) { - return latest - } - if (generation.type !== 'character_template' || generation.projectId !== latest.projectId) { - throw new Error('生成任务返回的类型或项目与当前 WorkflowRun 不匹配') - } - - const withTask = replaceStep(latest, revisionId, stepId, (current) => { - if (current.type !== 'character-template') return current - return { ...current, taskId: generation.id, submissionId: null } - }) - save(withTask) - - if (latest.status === 'interrupted') return withTask - if (generation.status === 'failed') { - return markGenerationFailed( - runId, - revisionId, - stepId, - generation.id, - null, - generation.error?.trim() || '角色图生成任务失败', - ) - } - if (generation.status === 'completed') { - return applyServerResult(runId, { - revisionId, - stepId, - taskId: generation.id, - result: generation.result, - }) - } - - ensureTaskSubscription(withTask, revisionId, stepId, generation.id) - return requireWorkflow(runId) - } catch (cause) { - markGenerationFailed( - runId, - revisionId, - stepId, - null, - submissionId, - errorMessage(cause, '角色图生成请求失败'), - ) - throw cause instanceof Error ? cause : new Error(String(cause)) - } - } - - function ensureTaskSubscription( - run: WorkflowRun, - revisionId: WorkflowRevision['id'], - stepId: WorkflowStep['id'], - taskId: string, - ) { - const key = subscriptionKey(run.id, revisionId, stepId, taskId) - if (subscriptions.has(key)) return - - subscriptions.set(key, { runId: run.id, stop: () => undefined }) - try { - const stop = taskApis.subscribe(run.projectId, taskId, (event) => { - handleTaskEvent(run.id, revisionId, stepId, taskId, event) - }) - const active = subscriptions.get(key) - if (active) subscriptions.set(key, { ...active, stop }) - else stop() - } catch (cause) { - subscriptions.delete(key) - throw cause - } - } - - function handleTaskEvent( - runId: WorkflowRun['id'], - revisionId: WorkflowRevision['id'], - stepId: WorkflowStep['id'], - taskId: string, - event: TaskEvent, - ) { - if (event.taskId !== taskId) return - if (event.status === 'pending' || event.status === 'running') return - if (event.status === 'failed') { - markGenerationFailed( - runId, - revisionId, - stepId, - taskId, - null, - event.error?.trim() || '角色图生成任务失败', - ) - return - } - if (event.type !== 'character_template') { - markGenerationFailed( - runId, - revisionId, - stepId, - taskId, - null, - '任务结果类型与角色图生成步骤不匹配', - ) - return - } - applyServerResult(runId, { - revisionId, - stepId, - taskId, - result: event.result, - }) - } - async function resume(runId: WorkflowRun['id']): Promise { - const run = getWorkflow(runId) - if (!run || run.status !== 'active') return run - const revision = getCurrentRevision(run) - const activeStep = getActiveStep(revision) - if (activeStep?.type !== 'character-template' || activeStep.status !== 'active') { - return run - } - if (activeStep.submissionId && !activeStep.taskId) { - if (submissions.has(submissionKey(run.id, revision.id, activeStep.id))) { - return run - } - return markGenerationFailed( - run.id, - revision.id, - activeStep.id, - null, - activeStep.submissionId, - '页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交', - ) - } - if (activeStep.taskId) { - const task = await taskApis.get(run.projectId, activeStep.taskId) - const latest = getWorkflow(run.id) - if (!latest || latest.status !== 'active' || latest.currentRevisionId !== revision.id) { - return latest - } - const latestRevision = getCurrentRevision(latest) - const latestStep = latestRevision.steps.find((step) => step.id === activeStep.id) - if ( - latestStep?.type !== 'character-template' || - latestStep.status !== 'active' || - latestStep.taskId !== activeStep.taskId - ) { - return latest - } - if (task.id !== latestStep.taskId) { - throw new Error('任务查询结果与 WorkflowRun 记录的 taskId 不匹配') - } - if (task.type !== 'character_template') { - return markGenerationFailed( - latest.id, - latestRevision.id, - latestStep.id, - latestStep.taskId, - null, - '任务查询结果类型与角色图生成步骤不匹配', - ) - } - if (task.status === 'pending' || task.status === 'running') { - ensureTaskSubscription(latest, latestRevision.id, latestStep.id, latestStep.taskId) - } else { - handleTaskEvent( - latest.id, - latestRevision.id, - latestStep.id, - latestStep.taskId, - taskEvent(task), - ) - } - } - return getWorkflow(runId) + const transitioned = advanceCharacterSetupState(run) + save(transitioned.run) + return characterTemplateTask.start(runId, transitioned.target) } - function applyServerResult(runId: WorkflowRun['id'], input: ApplyServerResultInput): WorkflowRun { - const run = requireWorkflow(runId) - if (run.status !== 'active' || run.currentRevisionId !== input.revisionId) { - return run - } - - const revision = getCurrentRevision(run) - const step = revision.steps.find((item) => item.id === input.stepId) - if ( - !step || - step.type !== 'character-template' || - step.status !== 'active' || - step.taskId !== input.taskId - ) { - return run - } - - const result = parseCharacterTemplateGenerationResult(input.result) - if (!result) { - return markGenerationFailed( - runId, - revision.id, - step.id, - input.taskId, - null, - '角色图生成任务返回了无法识别的结果', - ) - } - const candidateStep = revision.steps.find((item) => item.type === 'template-candidate') - if (!candidateStep) throw new Error('WorkflowRun 缺少 template-candidate 步骤') - - const updated: WorkflowRun = { - ...run, - revisions: run.revisions.map((item) => { - if (item.id !== revision.id) return item - return { - ...item, - steps: item.steps.map((current) => { - if (current.id === step.id && current.type === 'character-template') { - return { - ...current, - status: 'passed' as const, - output: result, - taskId: null, - submissionId: null, - } - } - if (current.id === candidateStep.id && current.type === 'template-candidate') { - return { ...current, status: 'active' as const } - } - return current - }), - } - }), - } - stopSubscription(subscriptionKey(run.id, revision.id, step.id, input.taskId)) - return save(updated) - } - - function markGenerationFailed( - runId: WorkflowRun['id'], - revisionId: WorkflowRevision['id'], - stepId: WorkflowStep['id'], - expectedTaskId: string | null, - expectedSubmissionId: string | null, - error: string, - ) { - const run = requireWorkflow(runId) - if (run.status !== 'active' || run.currentRevisionId !== revisionId) return run - const revision = getCurrentRevision(run) - const step = revision.steps.find((item) => item.id === stepId) - if ( - !step || - step.type !== 'character-template' || - step.status !== 'active' || - (expectedTaskId !== null && step.taskId !== expectedTaskId) || - (expectedSubmissionId !== null && step.submissionId !== expectedSubmissionId) - ) { - return run - } - - const failureMessage = error.trim() || '角色图生成失败' - const failed: WorkflowRun = { - ...replaceStep( - run, - revisionId, - stepId, - (current) => ({ - ...current, - status: 'failed', - taskId: null, - submissionId: null, - error: failureMessage, - }), - (current) => ({ - ...current, - status: 'failed', - generationStatus: 'failed', - }), - ), - status: 'failed', - } - if (step.taskId) { - stopSubscription(subscriptionKey(run.id, revisionId, stepId, step.taskId)) - } - return save(failed) - } - - function stopSubscription(key: string) { - const subscription = subscriptions.get(key) - subscriptions.delete(key) - try { - subscription?.stop() - } catch { - // 取消轮询失败不能反向破坏已经落盘的 WorkflowRun 状态。 - } + function resume(runId: WorkflowRun['id']) { + return characterTemplateTask.resume(runId) } function interrupt(runId: WorkflowRun['id']): WorkflowRun { const run = requireWorkflow(runId) if (run.status !== 'active') return run - for (const [key, subscription] of subscriptions) { - if (subscription.runId === runId) stopSubscription(key) - } + + characterTemplateTask.stop(runId) const latest = requireWorkflow(runId) if (latest.status !== 'active') return latest - return save({ ...latest, status: 'interrupted' }) + return save(interruptWorkflowRunState(latest)) } return { @@ -627,88 +159,6 @@ export function createWorkflowController({ } } -function createInitialStep( - type: WorkflowStepType, - revisionId: string, - index: number, - prompt: string | null, -): WorkflowStep { - const status: WorkflowStepStatus = index === 0 ? 'active' : 'locked' - const base: { - id: string - status: WorkflowStepStatus - taskId: null - submissionId: null - error: null - referenceStepIds: string[] - } = { - id: `${revisionId}:${type}`, - status, - taskId: null, - submissionId: null, - error: null, - referenceStepIds: [], - } - - if (type === 'character-setup') { - return { - ...base, - type, - input: prompt ? { description: prompt, referenceMedia: [] } : null, - output: null, - } - } - if (type === 'character-template') { - return { - ...base, - type, - input: null, - output: null, - } - } - return { ...base, type, input: null, output: null } as WorkflowStep -} - -function requireActiveWorkflow( - runId: WorkflowRun['id'], - getWorkflow: (runId: WorkflowRun['id']) => WorkflowRun, -) { - const run = getWorkflow(runId) - if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) - return run -} - -function taskEvent(task: Task): TaskEvent { - return { - taskId: task.id, - type: task.type, - status: task.status, - error: task.error, - result: task.result, - } -} - -function errorMessage(cause: unknown, fallback: string) { - return cause instanceof Error && cause.message.trim() ? cause.message.trim() : fallback -} - -function subscriptionKey( - runId: WorkflowRun['id'], - revisionId: WorkflowRevision['id'], - stepId: WorkflowStep['id'], - taskId: string, -) { - return `${runId}:${revisionId}:${stepId}:${taskId}` -} - -function submissionKey( - runId: WorkflowRun['id'], - revisionId: WorkflowRevision['id'], - stepId: WorkflowStep['id'], -) { - return `${runId}:${revisionId}:${stepId}` -} - function createRuntimeId(scope: 'run' | 'revision' | 'submission') { const suffix = typeof globalThis.crypto?.randomUUID === 'function' diff --git a/frontend/src/features/workflow-controller/workflow-state.ts b/frontend/src/features/workflow-controller/workflow-state.ts new file mode 100644 index 0000000..47c608a --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.ts @@ -0,0 +1,217 @@ +import { + WORKFLOW_STEP_ORDER, + type CharacterSetupStepInput, + type CharacterTemplateGenerationInput, + type CreateWorkflowRunInput, + type WorkflowRevision, + type WorkflowRun, + type WorkflowStep, + type WorkflowStepStatus, + type WorkflowStepType, +} from '@/entities' + +export type CreateWorkflowRunStateInput = Extract< + CreateWorkflowRunInput, + { purpose: 'create_character' } +> + +export interface CreateWorkflowRunStateOptions { + runId: WorkflowRun['id'] + revisionId: WorkflowRevision['id'] + createdAt: string +} + +export interface WorkflowStepTarget { + revisionId: WorkflowRevision['id'] + stepId: WorkflowStep['id'] +} + +export function createWorkflowRunState( + input: CreateWorkflowRunStateInput, + { runId, revisionId, createdAt }: CreateWorkflowRunStateOptions, +): WorkflowRun { + const prompt = input.prompt?.trim() || null + + return { + id: runId, + projectId: input.projectId, + characterId: null, + outfitId: null, + purpose: input.purpose, + driver: input.driver, + status: 'active', + currentRevisionId: revisionId, + revisions: [ + { + id: revisionId, + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps: WORKFLOW_STEP_ORDER.map((type, index) => + createInitialStep(type, revisionId, index, prompt), + ), + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt, + }, + ], + prompt, + } +} + +export function getCurrentRevision(run: WorkflowRun): WorkflowRevision { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision) throw new Error(`WorkflowRun ${run.id} 的 currentRevisionId 无效`) + return revision +} + +export function getActiveStep(revision: WorkflowRevision): WorkflowStep | null { + return revision.steps.find((step) => step.status === 'active') ?? null +} + +export function requireActiveWorkflow(run: WorkflowRun): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) + return run +} + +export function replaceWorkflowStep( + run: WorkflowRun, + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + update: (step: WorkflowStep) => WorkflowStep, + revisionUpdate?: (revision: WorkflowRevision) => WorkflowRevision, +): WorkflowRun { + return { + ...run, + revisions: run.revisions.map((revision) => { + if (revision.id !== revisionId) return revision + const nextRevision = { + ...revision, + steps: revision.steps.map((step) => (step.id === stepId ? update(step) : step)), + } + return revisionUpdate ? revisionUpdate(nextRevision) : nextRevision + }), + } +} + +export function updateCharacterSetupState( + workflow: WorkflowRun, + input: CharacterSetupStepInput, +): WorkflowRun { + const run = requireActiveWorkflow(workflow) + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.type === 'character-setup') + if (!step || step.type !== 'character-setup' || step.status !== 'active') { + throw new Error('当前只能更新处于 active 状态的角色资料步骤') + } + + const description = input.description.trim() + if (!description) throw new Error('角色描述不能为空') + + return replaceWorkflowStep(run, revision.id, step.id, (current) => { + if (current.type !== 'character-setup') return current + return { + ...current, + input: { + description, + referenceMedia: [...input.referenceMedia], + }, + } + }) +} + +export function advanceCharacterSetupState(workflow: WorkflowRun): { + run: WorkflowRun + target: WorkflowStepTarget +} { + const run = requireActiveWorkflow(workflow) + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (!activeStep) throw new Error('当前 WorkflowRun 没有 active 步骤') + if (activeStep.type !== 'character-setup') { + throw new Error(`当前步骤不是角色资料:${activeStep.type}`) + } + if (!activeStep.input) throw new Error('请先填写角色资料') + + const templateStep = revision.steps.find((step) => step.type === 'character-template') + if (!templateStep) throw new Error('WorkflowRun 缺少 character-template 步骤') + + const generationInput: CharacterTemplateGenerationInput = { + type: 'character_template', + projectId: run.projectId, + prompt: activeStep.input.description, + referenceMedia: activeStep.input.referenceMedia, + } + + return { + run: { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + generationStatus: 'in_progress' as const, + steps: item.steps.map((step) => { + if (step.id === activeStep.id) return { ...step, status: 'passed' as const } + if (step.id !== templateStep.id || step.type !== 'character-template') return step + return { + ...step, + status: 'active' as const, + input: generationInput, + } + }), + } + }), + }, + target: { + revisionId: revision.id, + stepId: templateStep.id, + }, + } +} + +export function interruptWorkflowRunState(run: WorkflowRun): WorkflowRun { + return run.status === 'active' ? { ...run, status: 'interrupted' } : run +} + +function createInitialStep( + type: WorkflowStepType, + revisionId: string, + index: number, + prompt: string | null, +): WorkflowStep { + const status: WorkflowStepStatus = index === 0 ? 'active' : 'locked' + const base: { + id: string + status: WorkflowStepStatus + taskId: null + submissionId: null + error: null + referenceStepIds: string[] + } = { + id: `${revisionId}:${type}`, + status, + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [], + } + + if (type === 'character-setup') { + return { + ...base, + type, + input: prompt ? { description: prompt, referenceMedia: [] } : null, + output: null, + } + } + if (type === 'character-template') { + return { + ...base, + type, + input: null, + output: null, + } + } + return { ...base, type, input: null, output: null } as WorkflowStep +} From 6d3f51c4136a88f906d8d0bcc84688e7b678551f Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 03:03:58 +0800 Subject: [PATCH 27/29] test(workflow-controller): cover extracted state transitions The new pure state boundary needs direct regression coverage alongside the existing controller tests. Cover fixed workflow creation, character setup normalization, and activation of the character-template step. Protect the extracted rules without changing production behavior. --- .../workflow-state.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 frontend/src/features/workflow-controller/workflow-state.test.ts diff --git a/frontend/src/features/workflow-controller/workflow-state.test.ts b/frontend/src/features/workflow-controller/workflow-state.test.ts new file mode 100644 index 0000000..fc68371 --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' + +import { + advanceCharacterSetupState, + createWorkflowRunState, + updateCharacterSetupState, +} from './workflow-state' + +const CREATED_AT = '2026-07-31T02:40:00.000Z' + +function createRun() { + return createWorkflowRunState( + { + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: ' pixel knight ', + }, + { + runId: 'run-1', + revisionId: 'revision-1', + createdAt: CREATED_AT, + }, + ) +} + +describe('workflow state transitions', () => { + it('creates the fixed workflow with normalized Quick Start input', () => { + const run = createRun() + + expect(run).toMatchObject({ + id: 'run-1', + projectId: 'project-1', + status: 'active', + prompt: 'pixel knight', + currentRevisionId: 'revision-1', + }) + expect(run.revisions[0]?.createdAt).toBe(CREATED_AT) + expect(run.revisions[0]?.steps.map(({ type, status }) => ({ type, status }))).toEqual([ + { type: 'character-setup', status: 'active' }, + { type: 'character-template', status: 'locked' }, + { type: 'template-candidate', status: 'locked' }, + { type: 'action-setup', status: 'locked' }, + { type: 'first-frame', status: 'locked' }, + { type: 'complete-animation', status: 'locked' }, + { type: 'review', status: 'locked' }, + { type: 'export', status: 'locked' }, + ]) + expect(run.revisions[0]?.steps[0]?.input).toEqual({ + description: 'pixel knight', + referenceMedia: [], + }) + }) + + it('normalizes character setup input before storing it', () => { + const updated = updateCharacterSetupState(createRun(), { + description: ' revised knight ', + referenceMedia: [], + }) + + expect(updated.revisions[0]?.steps[0]?.input).toEqual({ + description: 'revised knight', + referenceMedia: [], + }) + }) + + it('activates character-template with its generation input snapshot', () => { + const run = updateCharacterSetupState(createRun(), { + description: 'revised knight', + referenceMedia: [], + }) + + const transitioned = advanceCharacterSetupState(run) + + expect(transitioned.target).toEqual({ + revisionId: 'revision-1', + stepId: 'revision-1:character-template', + }) + expect(transitioned.run.revisions[0]?.steps.slice(0, 3)).toMatchObject([ + { type: 'character-setup', status: 'passed' }, + { + type: 'character-template', + status: 'active', + input: { + type: 'character_template', + projectId: 'project-1', + prompt: 'revised knight', + referenceMedia: [], + }, + }, + { type: 'template-candidate', status: 'locked' }, + ]) + }) +}) From 7e4fff2554ff6e9bd6a38b9579eb76d857865f85 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 03:04:17 +0800 Subject: [PATCH 28/29] fix(character-setup): accept workflow step input The Character Setup feature exposed the asset creation DTO while the workflow controller expects step-local input. Change the submit callback to accept CharacterSetupStepInput. Allow Quick Start and Workflow Editor to connect without translating through an unrelated asset contract. --- frontend/src/features/character-setup/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/character-setup/index.ts b/frontend/src/features/character-setup/index.ts index 44c9a7b..13827b1 100644 --- a/frontend/src/features/character-setup/index.ts +++ b/frontend/src/features/character-setup/index.ts @@ -1,7 +1,7 @@ -import type { CreateCharacterInput } from '@/entities' +import type { CharacterSetupStepInput } from '@/entities' /** 填写角色资料并提交母版生成。 */ export interface CharacterSetupProps { projectId: string - onSubmit(input: CreateCharacterInput): void + onSubmit(input: CharacterSetupStepInput): void } From f251dddeff4e0efb95cf58e78f43e34460e837fa Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 03:04:42 +0800 Subject: [PATCH 29/29] test(character-setup): lock workflow input contract The Character Setup boundary previously drifted to an unrelated asset DTO. Add a type assertion for the submit callback parameter. Prevent the page-to-workflow contract from regressing. --- frontend/src/features/character-setup/index.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 frontend/src/features/character-setup/index.test.ts diff --git a/frontend/src/features/character-setup/index.test.ts b/frontend/src/features/character-setup/index.test.ts new file mode 100644 index 0000000..60a05ec --- /dev/null +++ b/frontend/src/features/character-setup/index.test.ts @@ -0,0 +1,10 @@ +import { expectTypeOf, it } from 'vitest' + +import type { CharacterSetupStepInput } from '@/entities' +import type { CharacterSetupProps } from '.' + +it('submits WorkflowRun character setup input', () => { + expectTypeOf() + .parameter(0) + .toEqualTypeOf() +})