refactor(*): 稳定期治理 - 收敛核心架构、错误语义并修复跨项目数据竞态
This commit is contained in:
176
web/bun.lock
176
web/bun.lock
@@ -68,6 +68,9 @@
|
||||
"zustand": "^5.0.14",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.29.7",
|
||||
"@babel/eslint-parser": "7.28.6",
|
||||
"@babel/preset-typescript": "7.28.5",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^20.19.43",
|
||||
@@ -75,6 +78,7 @@
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/three": "0.185.4",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"eslint": "9.39.5",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.5",
|
||||
@@ -150,6 +154,8 @@
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.7", "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
||||
|
||||
"@babel/eslint-parser": ["@babel/eslint-parser@7.28.6", "", { "dependencies": { "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", "eslint-visitor-keys": "^2.1.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.11.0", "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" } }, "sha512-QGmsKi2PBO/MHSQk+AAgA9R6OHQr+VqnniFE0eMWZcVcfBZoA2dKn2hUsl3Csg/Plt9opRUWdY7//VXsrIlEiA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.7", "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.7.tgz", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
|
||||
|
||||
"@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "https://registry.npmmirror.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="],
|
||||
@@ -288,6 +294,24 @@
|
||||
|
||||
"@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "https://registry.npmmirror.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||
|
||||
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.7", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.3.2", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@9.39.5", "", {}, "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||
|
||||
"@excalidraw/excalidraw": ["@excalidraw/excalidraw@0.18.1", "https://registry.npmmirror.com/@excalidraw/excalidraw/-/excalidraw-0.18.1.tgz", { "dependencies": { "@braintree/sanitize-url": "6.0.2", "@excalidraw/laser-pointer": "1.3.1", "@excalidraw/mermaid-to-excalidraw": "2.2.2", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.1.6", "@radix-ui/react-tabs": "1.0.2", "browser-fs-access": "0.29.1", "canvas-roundrect-polyfill": "0.0.1", "clsx": "1.1.1", "cross-env": "7.0.3", "es6-promise-pool": "2.5.0", "fractional-indexing": "3.2.0", "fuzzy": "0.1.3", "image-blob-reduce": "3.0.1", "jotai": "2.11.0", "jotai-scope": "0.7.2", "lodash.debounce": "4.0.8", "lodash.throttle": "4.1.1", "nanoid": "3.3.3", "open-color": "1.9.1", "pako": "2.0.3", "perfect-freehand": "1.2.0", "pica": "7.1.1", "png-chunk-text": "1.0.0", "png-chunks-encode": "1.0.0", "png-chunks-extract": "1.0.0", "points-on-curve": "1.0.1", "pwacompat": "2.0.17", "roughjs": "4.6.4", "sass": "1.51.0", "tunnel-rat": "0.1.2" }, "peerDependencies": { "react": "^17.0.2 || ^18.2.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0" } }, "sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw=="],
|
||||
|
||||
"@excalidraw/laser-pointer": ["@excalidraw/laser-pointer@1.3.1", "https://registry.npmmirror.com/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz", {}, "sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g=="],
|
||||
@@ -324,6 +348,16 @@
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.14", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.14.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
|
||||
|
||||
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@iconify/types": ["@iconify/types@2.0.0", "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
|
||||
"@iconify/utils": ["@iconify/utils@3.1.4", "https://registry.npmmirror.com/@iconify/utils/-/utils-3.1.4.tgz", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw=="],
|
||||
@@ -476,6 +510,8 @@
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
||||
|
||||
"@nicolo-ribaudo/eslint-scope-5-internals": ["@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1", "", { "dependencies": { "eslint-scope": "5.1.1" } }, "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@1.3.0", "https://registry.npmmirror.com/@noble/ciphers/-/ciphers-1.3.0.tgz", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
||||
|
||||
"@noble/curves": ["@noble/curves@1.9.7", "https://registry.npmmirror.com/@noble/curves/-/curves-1.9.7.tgz", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="],
|
||||
@@ -984,6 +1020,8 @@
|
||||
|
||||
"@types/js-cookie": ["@types/js-cookie@3.0.6", "https://registry.npmmirror.com/@types/js-cookie/-/js-cookie-3.0.6.tgz", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/katex": ["@types/katex@0.16.8", "https://registry.npmmirror.com/@types/katex/-/katex-0.16.8.tgz", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
@@ -1088,12 +1126,14 @@
|
||||
|
||||
"ahooks": ["ahooks@3.9.7", "https://registry.npmmirror.com/ahooks/-/ahooks-3.9.7.tgz", { "dependencies": { "@babel/runtime": "^7.21.0", "@types/js-cookie": "^3.0.6", "dayjs": "^1.9.1", "intersection-observer": "^0.12.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", "react-fast-compare": "^3.2.2", "resize-observer-polyfill": "^1.5.1", "screenfull": "^5.0.0", "tslib": "^2.4.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-S0lvzhbdlhK36RFBkGv+RbOM/dbbweym+BIHM/bwwuWVSVN5TuVErHPMWo4w0t1NDYg5KPp2iEf7Y7E5LASYiw=="],
|
||||
|
||||
"ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"antd": ["antd@6.5.1", "https://registry.npmmirror.com/antd/-/antd-6.5.1.tgz", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/cssinjs": "^2.1.2", "@ant-design/cssinjs-utils": "^2.1.2", "@ant-design/fast-color": "^3.0.1", "@ant-design/icons": "^6.3.2", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.29.2", "@rc-component/cascader": "~1.17.0", "@rc-component/checkbox": "~2.0.0", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.1.1", "@rc-component/dialog": "~1.10.0", "@rc-component/drawer": "~1.4.2", "@rc-component/dropdown": "~1.0.3", "@rc-component/form": "~1.8.5", "@rc-component/image": "~1.9.0", "@rc-component/input": "~1.3.1", "@rc-component/input-number": "~1.6.2", "@rc-component/mentions": "~1.10.0", "@rc-component/menu": "~1.4.1", "@rc-component/motion": "^1.3.3", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~2.0.7", "@rc-component/pagination": "~1.4.0", "@rc-component/picker": "~1.11.0", "@rc-component/progress": "~1.0.2", "@rc-component/qrcode": "~2.0.0", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/segmented": "~1.3.0", "@rc-component/select": "~1.8.2", "@rc-component/slider": "~1.1.1", "@rc-component/steps": "~1.2.2", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.10.4", "@rc-component/tabs": "~1.11.0", "@rc-component/tooltip": "~1.4.0", "@rc-component/tour": "~2.4.0", "@rc-component/tree": "~1.3.2", "@rc-component/tree-select": "~1.11.0", "@rc-component/trigger": "^3.10.0", "@rc-component/upload": "~1.1.1", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-VZVVF9zYI6S0NHqboVhCoY9Iiqj6dphW1NPB+sEaAf2HuIQ0haXWXj7ZvAXTRDzusktV6+cvvrSZEdRi4twATg=="],
|
||||
|
||||
"antd-style": ["antd-style@4.1.0", "https://registry.npmmirror.com/antd-style/-/antd-style-4.1.0.tgz", { "dependencies": { "@ant-design/cssinjs": "^2.0.0", "@babel/runtime": "^7.24.1", "@emotion/cache": "^11.11.0", "@emotion/css": "^11.11.2", "@emotion/react": "^11.11.4", "@emotion/serialize": "^1.1.3", "@emotion/utils": "^1.2.1", "use-merge-value": "^1.2.0" }, "peerDependencies": { "antd": ">=6.0.0", "react": ">=18" } }, "sha512-vnPBGg0OVlSz90KRYZhxd89aZiOImTiesF+9MQqN8jsLGZUQTjbP04X9jTdEfsztKUuMbBWg/RmB/wHTakbtMQ=="],
|
||||
@@ -1120,7 +1160,7 @@
|
||||
|
||||
"bail": ["bail@2.0.2", "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
@@ -1136,7 +1176,7 @@
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.6", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
"brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
@@ -1164,7 +1204,7 @@
|
||||
|
||||
"ccount": ["ccount@2.0.1", "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
@@ -1200,6 +1240,10 @@
|
||||
|
||||
"collapse-white-space": ["collapse-white-space@2.1.0", "https://registry.npmmirror.com/collapse-white-space/-/collapse-white-space-2.1.0.tgz", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"colord": ["colord@2.9.3", "https://registry.npmmirror.com/colord/-/colord-2.9.3.tgz", {}, "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
@@ -1210,6 +1254,8 @@
|
||||
|
||||
"compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.1.0", "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
@@ -1328,6 +1374,8 @@
|
||||
|
||||
"dedent": ["dedent@1.7.2", "https://registry.npmmirror.com/dedent/-/dedent-1.7.2.tgz", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"deepmerge": ["deepmerge@4.3.1", "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"default-browser": ["default-browser@5.5.0", "https://registry.npmmirror.com/default-browser/-/default-browser-5.5.0.tgz", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="],
|
||||
@@ -1410,8 +1458,22 @@
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@2.1.0", "", {}, "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw=="],
|
||||
|
||||
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "https://registry.npmmirror.com/esprima/-/esprima-4.0.1.tgz", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "https://registry.npmmirror.com/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="],
|
||||
|
||||
"estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "https://registry.npmmirror.com/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="],
|
||||
@@ -1426,6 +1488,8 @@
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.4", "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||
@@ -1450,6 +1514,10 @@
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.2", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.2.tgz", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||
|
||||
"fastq": ["fastq@1.20.1", "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
@@ -1462,6 +1530,8 @@
|
||||
|
||||
"figures": ["figures@6.1.0", "https://registry.npmmirror.com/figures/-/figures-6.1.0.tgz", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"file-saver": ["file-saver@2.0.5", "https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz", {}, "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="],
|
||||
|
||||
"file-selector": ["file-selector@0.5.0", "https://registry.npmmirror.com/file-selector/-/file-selector-0.5.0.tgz", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA=="],
|
||||
@@ -1474,6 +1544,12 @@
|
||||
|
||||
"find-root": ["find-root@1.1.0", "https://registry.npmmirror.com/find-root/-/find-root-1.1.0.tgz", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.16.0", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
||||
|
||||
"for-in": ["for-in@1.0.2", "https://registry.npmmirror.com/for-in/-/for-in-1.0.2.tgz", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="],
|
||||
@@ -1518,7 +1594,9 @@
|
||||
|
||||
"giscus": ["giscus@1.6.0", "https://registry.npmmirror.com/giscus/-/giscus-1.6.0.tgz", { "dependencies": { "lit": "^3.2.1" } }, "sha512-Zrsi8r4t1LVW950keaWcsURuZUQwUaMKjvJgTCY125vkW6OiEBkatE7ScJDbpqKHdZwb///7FVC21SE3iFK3PQ=="],
|
||||
|
||||
"glob-parent": ["glob-parent@5.1.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
|
||||
"glsl-noise": ["glsl-noise@0.0.0", "https://registry.npmmirror.com/glsl-noise/-/glsl-noise-0.0.0.tgz", {}, "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w=="],
|
||||
|
||||
@@ -1530,6 +1608,8 @@
|
||||
|
||||
"hachure-fill": ["hachure-fill@0.5.2", "https://registry.npmmirror.com/hachure-fill/-/hachure-fill-0.5.2.tgz", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
@@ -1606,6 +1686,8 @@
|
||||
|
||||
"import-meta-resolve": ["import-meta-resolve@4.2.0", "https://registry.npmmirror.com/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
@@ -1668,7 +1750,7 @@
|
||||
|
||||
"isarray": ["isarray@1.0.0", "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"isexe": ["isexe@3.1.5", "https://registry.npmmirror.com/isexe/-/isexe-3.1.5.tgz", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
"isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"isobject": ["isobject@3.0.1", "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="],
|
||||
|
||||
@@ -1690,14 +1772,18 @@
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-canonicalize": ["json-canonicalize@2.0.0", "https://registry.npmmirror.com/json-canonicalize/-/json-canonicalize-2.0.0.tgz", {}, "sha512-yyrnK/mEm6Na3ChbJUWueXdapueW0p380RUyTW87XGb1ww8l8hU0pRrGC3vSWHe9CxrbPHX2fGUOZpNiHR0IIg=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"json2mq": ["json2mq@0.2.0", "https://registry.npmmirror.com/json2mq/-/json2mq-0.2.0.tgz", { "dependencies": { "string-convert": "^0.2.0" } }, "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
@@ -1708,6 +1794,8 @@
|
||||
|
||||
"katex": ["katex@0.16.47", "https://registry.npmmirror.com/katex/-/katex-0.16.47.tgz", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"khroma": ["khroma@2.1.0", "https://registry.npmmirror.com/khroma/-/khroma-2.1.0.tgz", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
@@ -1720,6 +1808,8 @@
|
||||
|
||||
"leva": ["leva@0.10.1", "https://registry.npmmirror.com/leva/-/leva-0.10.1.tgz", { "dependencies": { "@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", "@stitches/react": "^1.2.8", "@use-gesture/react": "^10.2.5", "colord": "^2.9.2", "dequal": "^2.0.2", "merge-value": "^1.0.0", "react-colorful": "^5.5.1", "react-dropzone": "^12.0.0", "v8n": "^1.3.3", "zustand": "^3.6.9" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lie": ["lie@3.1.1", "https://registry.npmmirror.com/lie/-/lie-3.1.1.tgz", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "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" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
@@ -1758,6 +1848,8 @@
|
||||
|
||||
"localforage": ["localforage@1.10.0", "https://registry.npmmirror.com/localforage/-/localforage-1.10.0.tgz", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
@@ -1768,6 +1860,8 @@
|
||||
|
||||
"lodash.isequalwith": ["lodash.isequalwith@4.4.0", "https://registry.npmmirror.com/lodash.isequalwith/-/lodash.isequalwith-4.4.0.tgz", {}, "sha512-dcZON0IalGBpRmJBmMkaoV7d3I80R2O+FrzsZyHdNSFrANq/cgDqKQNmAHE8UEj4+QYWwwhkQOVdLHiAopzlsQ=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"lodash.throttle": ["lodash.throttle@4.1.1", "https://registry.npmmirror.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="],
|
||||
|
||||
"lodash.uniq": ["lodash.uniq@4.5.0", "https://registry.npmmirror.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="],
|
||||
@@ -1946,7 +2040,7 @@
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
@@ -1964,6 +2058,8 @@
|
||||
|
||||
"nanoid": ["nanoid@6.0.0", "https://registry.npmmirror.com/nanoid/-/nanoid-6.0.0.tgz", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-mkUH+rPkwU2qPadJ0oJZOjeZ5Mxn8Q1UhevwkTRWNuUZzyia3h4rhzK39hxaHTk0o2OxB8W2SQ6A8k23ZDi1pQ=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.44", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.44.tgz", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="],
|
||||
@@ -1998,10 +2094,16 @@
|
||||
|
||||
"option": ["option@0.2.4", "https://registry.npmmirror.com/option/-/option-0.2.4.tgz", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"ora": ["ora@8.2.0", "https://registry.npmmirror.com/ora/-/ora-8.2.0.tgz", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
|
||||
|
||||
"orderedmap": ["orderedmap@2.1.1", "https://registry.npmmirror.com/orderedmap/-/orderedmap-2.1.1.tgz", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@1.8.0", "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.8.0.tgz", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="],
|
||||
|
||||
"pako": ["pako@2.0.3", "https://registry.npmmirror.com/pako/-/pako-2.0.3.tgz", {}, "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw=="],
|
||||
@@ -2022,6 +2124,8 @@
|
||||
|
||||
"path-data-parser": ["path-data-parser@0.1.0", "https://registry.npmmirror.com/path-data-parser/-/path-data-parser-0.1.0.tgz", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-is-absolute": ["path-is-absolute@1.0.1", "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
@@ -2062,6 +2166,8 @@
|
||||
|
||||
"powershell-utils": ["powershell-utils@0.1.0", "https://registry.npmmirror.com/powershell-utils/-/powershell-utils-0.1.0.tgz", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["prettier@3.9.5", "https://registry.npmmirror.com/prettier/-/prettier-3.9.5.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "https://registry.npmmirror.com/pretty-ms/-/pretty-ms-9.3.0.tgz", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
@@ -2108,6 +2214,8 @@
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@2.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"pwacompat": ["pwacompat@2.0.17", "https://registry.npmmirror.com/pwacompat/-/pwacompat-2.0.17.tgz", {}, "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w=="],
|
||||
|
||||
"qs": ["qs@6.15.3", "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
|
||||
@@ -2374,6 +2482,8 @@
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@4.0.0", "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"style-mod": ["style-mod@4.1.3", "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.21", "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||
@@ -2382,6 +2492,8 @@
|
||||
|
||||
"stylis": ["stylis@4.4.0", "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"suspend-react": ["suspend-react@0.1.3", "https://registry.npmmirror.com/suspend-react/-/suspend-react-0.1.3.tgz", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="],
|
||||
@@ -2442,6 +2554,8 @@
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "https://registry.npmmirror.com/tw-animate-css/-/tw-animate-css-1.4.0.tgz", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"type-is": ["type-is@2.1.0", "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"typescript": ["typescript@7.0.2", "https://registry.npmmirror.com/typescript/-/typescript-7.0.2.tgz", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
||||
@@ -2478,6 +2592,8 @@
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"url-join": ["url-join@5.0.0", "https://registry.npmmirror.com/url-join/-/url-join-5.0.0.tgz", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
@@ -2534,7 +2650,9 @@
|
||||
|
||||
"webworkify": ["webworkify@1.5.0", "https://registry.npmmirror.com/webworkify/-/webworkify-1.5.0.tgz", {}, "sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g=="],
|
||||
|
||||
"which": ["which@4.0.0", "https://registry.npmmirror.com/which/-/which-4.0.0.tgz", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||
"which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
@@ -2546,6 +2664,8 @@
|
||||
|
||||
"yaml": ["yaml@1.10.3", "https://registry.npmmirror.com/yaml/-/yaml-1.10.3.tgz", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"yocto-spinner": ["yocto-spinner@1.2.0", "https://registry.npmmirror.com/yocto-spinner/-/yocto-spinner-1.2.0.tgz", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw=="],
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "https://registry.npmmirror.com/yoctocolors/-/yoctocolors-2.1.2.tgz", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
@@ -2600,6 +2720,8 @@
|
||||
|
||||
"@dotenvx/dotenvx/picomatch": ["picomatch@4.0.4", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"@dotenvx/dotenvx/which": ["which@4.0.0", "https://registry.npmmirror.com/which/-/which-4.0.0.tgz", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||
|
||||
"@emotion/babel-plugin/@emotion/hash": ["@emotion/hash@0.9.2", "https://registry.npmmirror.com/@emotion/hash/-/hash-0.9.2.tgz", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="],
|
||||
|
||||
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-1.9.0.tgz", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
|
||||
@@ -2614,6 +2736,8 @@
|
||||
|
||||
"@emotion/serialize/@emotion/unitless": ["@emotion/unitless@0.10.0", "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.10.0.tgz", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@excalidraw/excalidraw/clsx": ["clsx@1.1.1", "https://registry.npmmirror.com/clsx/-/clsx-1.1.1.tgz", {}, "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA=="],
|
||||
|
||||
"@excalidraw/excalidraw/nanoid": ["nanoid@3.3.3", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.3.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w=="],
|
||||
@@ -2640,6 +2764,10 @@
|
||||
|
||||
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "https://registry.npmmirror.com/source-map/-/source-map-0.7.6.tgz", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"@nicolo-ribaudo/eslint-scope-5-internals/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="],
|
||||
|
||||
"@pierre/diffs/diff": ["diff@9.0.0", "https://registry.npmmirror.com/diff/-/diff-9.0.0.tgz", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
|
||||
"@pierre/diffs/shiki": ["shiki@4.4.3", "https://registry.npmmirror.com/shiki/-/shiki-4.4.3.tgz", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="],
|
||||
@@ -3196,10 +3324,14 @@
|
||||
|
||||
"@tldraw/editor/idb": ["idb@7.1.1", "https://registry.npmmirror.com/idb/-/idb-7.1.1.tgz", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="],
|
||||
|
||||
"@ts-morph/common/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"@types/mdast/@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"accepts/mime-types": ["mime-types@3.0.2", "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"ajv-formats/ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"antd/@rc-component/util": ["@rc-component/util@1.12.0", "https://registry.npmmirror.com/@rc-component/util/-/util-1.12.0.tgz", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^19.2.7" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.2", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
@@ -3210,7 +3342,7 @@
|
||||
|
||||
"chevrotain/lodash-es": ["lodash-es@4.17.21", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"cytoscape-fcose/cose-base": ["cose-base@2.2.0", "https://registry.npmmirror.com/cose-base/-/cose-base-2.2.0.tgz", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="],
|
||||
|
||||
@@ -3224,6 +3356,10 @@
|
||||
|
||||
"es-set-tostringtag/hasown": ["hasown@2.0.3", "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
|
||||
|
||||
"eslint/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"estree-util-to-js/source-map": ["source-map@0.7.6", "https://registry.npmmirror.com/source-map/-/source-map-0.7.6.tgz", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"estree-util-visit/@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
@@ -3232,6 +3368,8 @@
|
||||
|
||||
"extend-shallow/is-extendable": ["is-extendable@0.1.1", "https://registry.npmmirror.com/is-extendable/-/is-extendable-0.1.1.tgz", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"get-intrinsic/hasown": ["hasown@2.0.3", "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
|
||||
|
||||
"hast-util-from-parse5/@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
@@ -3262,6 +3400,8 @@
|
||||
|
||||
"leva/zustand": ["zustand@3.7.2", "https://registry.npmmirror.com/zustand/-/zustand-3.7.2.tgz", { "peerDependencies": { "react": ">=16.8" }, "optionalPeers": ["react"] }, "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA=="],
|
||||
|
||||
"log-symbols/chalk": ["chalk@5.6.2", "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
@@ -3284,6 +3424,8 @@
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "https://registry.npmmirror.com/path-key/-/path-key-4.0.0.tgz", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"ora/chalk": ["chalk@5.6.2", "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"parse-json/@babel/code-frame": ["@babel/code-frame@7.29.0", "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"parse5/entities": ["entities@6.0.1", "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
@@ -3474,6 +3616,8 @@
|
||||
|
||||
"@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "https://registry.npmmirror.com/isexe/-/isexe-3.1.5.tgz", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"@floating-ui/react/@floating-ui/react-dom/@floating-ui/dom": ["@floating-ui/dom@1.8.0", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
|
||||
|
||||
"@lobehub/ui/@radix-ui/react-slot/@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.7.tgz", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="],
|
||||
@@ -3492,6 +3636,10 @@
|
||||
|
||||
"@lobehub/ui/shiki/@shikijs/types": ["@shikijs/types@4.4.3", "https://registry.npmmirror.com/@shikijs/types/-/types-4.4.3.tgz", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"@nicolo-ribaudo/eslint-scope-5-internals/eslint-scope/estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
|
||||
|
||||
"@pierre/diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "https://registry.npmmirror.com/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="],
|
||||
|
||||
"@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.4.3", "https://registry.npmmirror.com/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w=="],
|
||||
@@ -3652,11 +3800,13 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@5.0.6", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.54.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"antd/@rc-component/util/react-is": ["react-is@19.2.7", "https://registry.npmmirror.com/react-is/-/react-is-19.2.7.tgz", {}, "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A=="],
|
||||
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
"antd/@rc-component/util/react-is": ["react-is@19.2.7", "https://registry.npmmirror.com/react-is/-/react-is-19.2.7.tgz", {}, "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A=="],
|
||||
|
||||
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "https://registry.npmmirror.com/layout-base/-/layout-base-2.0.1.tgz", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
|
||||
|
||||
@@ -3756,6 +3906,8 @@
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.4", "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.4.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pWJo6lQAfR6uy1n7ii7PaCc9dLPwTXDYbQpORZU5B548Aqvl2pP1SM1vJGKyxIFqZMHRopRO4CQYX2iXAIB5jA=="],
|
||||
|
||||
"@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"leva/@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.2", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.3.2.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-nLBh0hpzgQA16ioKAWTSQvGjRj7QcI3pTpngtYZ12b4RP9N8eGSj7ziceHyHe04zrd0bAcLyMijO0ws0DLmDWw=="],
|
||||
|
||||
"@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
65
web/eslint.config.js
Normal file
65
web/eslint.config.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import babelParser from "@babel/eslint-parser";
|
||||
|
||||
// 这不是风格检查。只锁已经退场的 AntD 用法,防止 Empty / 静态 Modal.confirm 再长回来。
|
||||
// 用 Babel 解析 TS/TSX:typescript-eslint 8 在 TypeScript 7 上会直接拒绝启动。
|
||||
const emptyMessage = "列表和面板空态用 @/components/ui/product/empty-state 的 EmptyState,不要再引入 antd Empty。";
|
||||
const confirmMessage = "命令式确认必须走 App.useApp().modal.confirm,静态 Modal.confirm 拿不到主题和 App 上下文。";
|
||||
|
||||
// 源码里还有 react-hooks/exhaustive-deps 的 disable 注释。本闸门不启用 hooks 规则,
|
||||
// 只挂一个空规则,避免 ESLint 把未知规则名当成错误。
|
||||
const commentCompatPlugin = {
|
||||
rules: {
|
||||
"exhaustive-deps": {
|
||||
meta: { type: "problem" },
|
||||
create() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**", "coverage/**"],
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx,mjs}"],
|
||||
plugins: {
|
||||
"react-hooks": commentCompatPlugin,
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: "off",
|
||||
},
|
||||
languageOptions: {
|
||||
parser: babelParser,
|
||||
parserOptions: {
|
||||
requireConfigFile: false,
|
||||
sourceType: "module",
|
||||
babelOptions: {
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
presets: [["@babel/preset-typescript", { allExtensions: true, isTSX: true, allowDeclareFields: true }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{ name: "antd", importNames: ["Empty"], message: emptyMessage },
|
||||
{ name: "antd/es/empty", message: emptyMessage },
|
||||
{ name: "antd/lib/empty", message: emptyMessage },
|
||||
],
|
||||
},
|
||||
],
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector: "CallExpression[callee.object.name='Modal'][callee.property.name='confirm']",
|
||||
message: confirmMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -10,11 +10,12 @@
|
||||
"pretest": "bun test test/tiptap-dependency-versions.test.ts test/admin-ui-regressions.test.ts test/announcement-prompt-regressions.test.ts test/password-reset.test.ts test/oss-settings.test.ts test/channel-model-editor-form.test.ts test/channel-model-price-tier-form.test.ts test/admin-logical-models.test.ts && bun test test/canvas-grid-viewport.test.tsx",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:canvas": "bun test test/canvas-media-performance.test.ts test/canvas-node-title-interaction.test.ts test/canvas-spatial-index.test.ts",
|
||||
"test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-pricing.test.ts test/model-protocols.test.ts test/model-capabilities.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-generation-mentions.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-frame.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-clay-materials.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
|
||||
"test": "bun test",
|
||||
"test:director:e2e": "bun scripts/director-p0-chrome-e2e.mjs",
|
||||
"start": "vite preview --host 0.0.0.0 --port 3000",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint src test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
@@ -80,6 +81,9 @@
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.29.7",
|
||||
"@babel/eslint-parser": "7.28.6",
|
||||
"@babel/preset-typescript": "7.28.5",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^20.19.43",
|
||||
@@ -87,6 +91,7 @@
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/three": "0.185.4",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"eslint": "9.39.5",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.5"
|
||||
@@ -133,42 +138,5 @@
|
||||
"@tiptap/react": "3.28.0",
|
||||
"@tiptap/starter-kit": "3.28.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@tiptap/core": "3.28.0",
|
||||
"@tiptap/extension-blockquote": "3.28.0",
|
||||
"@tiptap/extension-bold": "3.28.0",
|
||||
"@tiptap/extension-bubble-menu": "3.28.0",
|
||||
"@tiptap/extension-bullet-list": "3.28.0",
|
||||
"@tiptap/extension-character-count": "3.28.0",
|
||||
"@tiptap/extension-code": "3.28.0",
|
||||
"@tiptap/extension-code-block": "3.28.0",
|
||||
"@tiptap/extension-color": "3.28.0",
|
||||
"@tiptap/extension-document": "3.28.0",
|
||||
"@tiptap/extension-dropcursor": "3.28.0",
|
||||
"@tiptap/extension-floating-menu": "3.28.0",
|
||||
"@tiptap/extension-gapcursor": "3.28.0",
|
||||
"@tiptap/extension-hard-break": "3.28.0",
|
||||
"@tiptap/extension-heading": "3.28.0",
|
||||
"@tiptap/extension-highlight": "3.28.0",
|
||||
"@tiptap/extension-horizontal-rule": "3.28.0",
|
||||
"@tiptap/extension-italic": "3.28.0",
|
||||
"@tiptap/extension-link": "3.28.0",
|
||||
"@tiptap/extension-list": "3.28.0",
|
||||
"@tiptap/extension-list-item": "3.28.0",
|
||||
"@tiptap/extension-list-keymap": "3.28.0",
|
||||
"@tiptap/extension-ordered-list": "3.28.0",
|
||||
"@tiptap/extension-paragraph": "3.28.0",
|
||||
"@tiptap/extension-placeholder": "3.28.0",
|
||||
"@tiptap/extension-strike": "3.28.0",
|
||||
"@tiptap/extension-text": "3.28.0",
|
||||
"@tiptap/extension-text-align": "3.28.0",
|
||||
"@tiptap/extension-text-style": "3.28.0",
|
||||
"@tiptap/extension-underline": "3.28.0",
|
||||
"@tiptap/extensions": "3.28.0",
|
||||
"@tiptap/pm": "3.28.0",
|
||||
"@tiptap/react": "3.28.0",
|
||||
"@tiptap/starter-kit": "3.28.0"
|
||||
}
|
||||
}
|
||||
"packageManager": "bun@1.3.9"
|
||||
}
|
||||
|
||||
14084
web/pnpm-lock.yaml
generated
14084
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { App, Button, Dropdown, Modal, Popconfirm } from "antd";
|
||||
import { App, Button, Dropdown, Popconfirm } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { Check, ChevronDown, FileText, FolderOpen, HardDrive, Image as ImageIcon, LoaderCircle, Music2, Puzzle, RotateCcw, Search, Trash2, Upload, UserRound, Video } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -12,7 +13,7 @@ import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
|
||||
import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { deleteAssetWithRemoteSync, loadAssetLibraryPage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { deleteAssetWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
|
||||
export type AssetPickerMediaKind = "image" | "video" | "audio" | "text";
|
||||
|
||||
@@ -271,8 +272,8 @@ export function AssetLibraryPickerModal({
|
||||
setSelected(new Set());
|
||||
message.success(`已还原 ${archivedSelectedIds.length} 个素材至素材库`);
|
||||
setCategory("all");
|
||||
} catch {
|
||||
message.warning("已在本地还原,稍后自动同步至云端");
|
||||
} catch (error) {
|
||||
message.warning(localSavedRemotePendingMessage("已在本地还原", error));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
if (remoteEnabled) void remoteQuery.refetch();
|
||||
@@ -380,7 +381,7 @@ export function AssetLibraryPickerModal({
|
||||
const uploading = uploadingCount > 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<AppModal
|
||||
centered
|
||||
open={open}
|
||||
footer={null}
|
||||
@@ -393,7 +394,7 @@ export function AssetLibraryPickerModal({
|
||||
if (!working) onClose();
|
||||
}}
|
||||
className="workspace-modal workspace-modal-wide asset-library-picker-modal"
|
||||
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
|
||||
flush
|
||||
>
|
||||
<div className="asset-picker-shell">
|
||||
<header className="asset-picker-toolbar">
|
||||
@@ -559,7 +560,7 @@ export function AssetLibraryPickerModal({
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button, Modal, Tag } from "antd";
|
||||
import { Button, Tag } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { EmptyState } from "@/components/ui/product/empty-state";
|
||||
import { ArrowLeft, ArrowRight, CheckCircle2, ChevronDown, ChevronRight, Copy, FileText, Image as ImageIcon, LoaderCircle, RefreshCw, Sparkles, Target, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -256,7 +257,7 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
|
||||
const modelInlineLabel = selectedCritiqueModel || visibleState.report?.modelLabel ? `${modelStatusLabel} · ${displayedModelLabel}` : "请到设置中选择支持图片理解的文本模型";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<AppModal
|
||||
open={open}
|
||||
title={null}
|
||||
closable={false}
|
||||
@@ -266,7 +267,7 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
|
||||
onCancel={close}
|
||||
footer={null}
|
||||
className="art-critique-modal"
|
||||
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}
|
||||
flush
|
||||
>
|
||||
<div className="flex h-[min(820px,calc(100dvh-32px))] max-h-[calc(100dvh-32px)] min-h-0 flex-col overflow-hidden rounded-[var(--r-lg)]" style={{ background: theme.canvas.background, color: theme.node.text }}>
|
||||
<header className="flex shrink-0 items-center gap-3 border-b px-5 py-3.5" style={{ borderColor: theme.node.edge }}>
|
||||
@@ -465,7 +466,7 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -340,7 +340,7 @@ export function AgentChatComposer({
|
||||
};
|
||||
|
||||
const applySlashSkill = (skill: Skill) => {
|
||||
const token = `@[skill:${skill.skill_id}] `;
|
||||
const token = `@[skill:${skill.skillId}] `;
|
||||
const next = slash ? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + slash.query.length)}` : prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token;
|
||||
setSlash(null);
|
||||
setSlashIndex(0);
|
||||
@@ -463,7 +463,7 @@ export function AgentChatComposer({
|
||||
>
|
||||
{availableSlashSkills.map((skill, index) => (
|
||||
<button
|
||||
key={skill.skill_id}
|
||||
key={skill.skillId}
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs"
|
||||
style={{ background: index === activeSlashIndex ? theme.toolbar.itemHover : "transparent", color: theme.node.text }}
|
||||
@@ -471,7 +471,7 @@ export function AgentChatComposer({
|
||||
onClick={() => applySlashSkill(skill)}
|
||||
>
|
||||
<Sparkles className="size-3.5 shrink-0 opacity-70" />
|
||||
<span className="min-w-0 truncate font-medium">{skill.skill_name}</span>
|
||||
<span className="min-w-0 truncate font-medium">{skill.skillName}</span>
|
||||
{skill.description ? <span className="min-w-0 flex-1 truncate opacity-50">{skill.description}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
|
||||
573
web/src/components/canvas/canvas-assistant-online-tools.ts
Normal file
573
web/src/components/canvas/canvas-assistant-online-tools.ts
Normal file
@@ -0,0 +1,573 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { type ResponseInputMessage, type ResponseToolCall } from "@/services/api/image";
|
||||
import { runBackendToolGenerationTask } from "@/services/api/generation-task";
|
||||
import { imageToDataUrl } from "@/services/image-storage";
|
||||
import { normalizeModelOptionValue, resolveModelRequestConfig, selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store";
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "@/types/canvas";
|
||||
import { previewCanvasAgentOps, type CanvasAgentOp, type CanvasAgentOperationImpact, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { isWritableToolCall, buildCanvasOnlineAgentSystemContent } from "@/lib/canvas/canvas-agent-protocol";
|
||||
import { CANVAS_ONLINE_AGENT_TOOLS } from "@/lib/canvas/canvas-agent-tools";
|
||||
import { buildOrderedCanvasResourceReferences, canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references";
|
||||
import { buildCanvasWorkflowOps, looksLikeWorkflowRequest, type CanvasWorkflowInput } from "@/lib/canvas/canvas-agent-workflow";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
|
||||
export type OnlineToolResult = { ok: true; message: string; data?: unknown } | { ok: false; message: string };
|
||||
|
||||
export function objectDetail(value: unknown) {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export function describeCanvasSnapshot(snapshot: CanvasAgentSnapshot) {
|
||||
const counts = snapshot.nodes.reduce<Record<string, number>>((acc, node) => {
|
||||
acc[node.type] = (acc[node.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
return `当前画布有 ${snapshot.nodes.length} 个节点、${snapshot.connections.length} 条连线。背板 ${counts[CanvasNodeType.Frame] || 0} 个,文本 ${counts[CanvasNodeType.Text] || 0} 个,绘图 ${counts[CanvasNodeType.Drawing] || 0} 个,分镜脚本 ${counts[CanvasNodeType.Script] || 0} 个,技能 ${counts[CanvasNodeType.Skill] || 0} 个,图片 ${counts[CanvasNodeType.Image] || 0} 个,生成配置 ${counts[CanvasNodeType.Config] || 0} 个,视频 ${counts[CanvasNodeType.Video] || 0} 个,音频 ${counts[CanvasNodeType.Audio] || 0} 个。`;
|
||||
}
|
||||
|
||||
export function parseToolArguments(value: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(value || "{}");
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("工具参数必须是 JSON 对象");
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error("工具参数不是合法 JSON 对象");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把在线 Agent 的工具调用转换为可审计的画布操作,不在这里直接修改 store。
|
||||
* 只读工具不会进入该函数;写操作先由上层确认,并以 snapshot 生成的 revision/hash 做并发保护,执行失败时保留原始工具消息和错误。
|
||||
*/
|
||||
|
||||
export function onlineToolToOps(name: string, input: Record<string, unknown>, snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOp[] {
|
||||
if (name === "canvas_apply_ops") return requireOps(input.ops);
|
||||
if (name === "canvas_create_workflow") return buildCanvasWorkflowOps(input as unknown as CanvasWorkflowInput, snapshot, config);
|
||||
if (name === "canvas_create_node") {
|
||||
const nodeType = requireNodeType(input.nodeType);
|
||||
const x = numberOr(input.x, nextCanvasX(snapshot));
|
||||
const y = numberOr(input.y, 0);
|
||||
return [{ type: "add_node", nodeType, title: stringOptional(input.title), position: { x, y }, width: numberOptional(input.width), height: numberOptional(input.height), metadata: recordOptional(input.metadata) as CanvasNodeData["metadata"] }];
|
||||
}
|
||||
if (name === "canvas_create_text_node") return [textNodeOp(input, numberOr(input.x, nextCanvasX(snapshot)), numberOr(input.y, 0))];
|
||||
if (name === "canvas_create_text_nodes") {
|
||||
const items = requireRecordArray(input.items, "items");
|
||||
const textBatch = items.map((item) => `${String(item.title || "")} ${String(item.text || "")}`).join(" ");
|
||||
if (looksLikeWorkflowRequest(textBatch)) throw new Error("检测到流水线/工作流意图,请使用 canvas_create_workflow 创建真实类型节点和连线。" );
|
||||
const x = numberOr(input.x, nextCanvasX(snapshot));
|
||||
const y = numberOr(input.y, 0);
|
||||
const gap = numberOr(input.gap, 40);
|
||||
const direction = input.direction === "row" ? "row" : "column";
|
||||
return items.map((item, index) =>
|
||||
textNodeOp(
|
||||
{ ...item, text: requireString(item.text, "text") },
|
||||
numberOr(item.x, direction === "row" ? x + index * (NODE_DEFAULT_SIZE[CanvasNodeType.Text].width + gap) : x),
|
||||
numberOr(item.y, direction === "row" ? y : y + index * (NODE_DEFAULT_SIZE[CanvasNodeType.Text].height + gap)),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (name === "canvas_create_image_prompt_flow") return generationFlowOps({ ...input, mode: "image" }, snapshot, config);
|
||||
if (name === "canvas_create_generation_flow") return generationFlowOps(input, snapshot, config);
|
||||
if (name === "canvas_generate_text") return generationFlowOps({ ...input, mode: "text", autoRun: true }, snapshot, config);
|
||||
if (name === "canvas_generate_image") return generationFlowOps({ ...input, mode: "image", autoRun: true }, snapshot, config);
|
||||
if (name === "canvas_generate_video") return generationFlowOps({ ...input, mode: "video", autoRun: true }, snapshot, config);
|
||||
if (name === "canvas_generate_audio") return generationFlowOps({ ...input, mode: "audio", autoRun: true }, snapshot, config);
|
||||
if (name === "canvas_update_node") return [{ type: "update_node", id: requireString(input.id, "id"), patch: recordOptional(input.patch) as Partial<CanvasNodeData> | undefined, metadata: recordOptional(input.metadata) as CanvasNodeData["metadata"] }];
|
||||
if (name === "canvas_update_node_text")
|
||||
return [{ type: "update_node", id: requireString(input.id, "id"), patch: stringOptional(input.title) ? { title: stringOptional(input.title) } : undefined, metadata: { content: requireString(input.text, "text"), status: "success" } }];
|
||||
if (name === "canvas_move_nodes") {
|
||||
return requireRecordArray(input.items, "items").map((item) => {
|
||||
const id = requireString(item.id, "id");
|
||||
const current = snapshot.nodes.find((node) => node.id === id);
|
||||
return { type: "update_node", id, patch: { position: { x: numberOr(item.x, (current?.position.x || 0) + numberOr(item.dx, 0)), y: numberOr(item.y, (current?.position.y || 0) + numberOr(item.dy, 0)) } } };
|
||||
});
|
||||
}
|
||||
if (name === "canvas_resize_node")
|
||||
return [
|
||||
{
|
||||
type: "update_node",
|
||||
id: requireString(input.id, "id"),
|
||||
patch: { width: requireNumber(input.width, "width"), height: requireNumber(input.height, "height") },
|
||||
metadata: typeof input.freeResize === "boolean" ? { freeResize: input.freeResize } : undefined,
|
||||
},
|
||||
];
|
||||
if (name === "canvas_delete_nodes") return [{ type: "delete_node", ids: requireStringArray(input.ids, "ids") }];
|
||||
if (name === "canvas_connect_nodes")
|
||||
return requireRecordArray(input.connections, "connections").map((connection) => ({ type: "connect_nodes", fromNodeId: requireString(connection.fromNodeId, "fromNodeId"), toNodeId: requireString(connection.toNodeId, "toNodeId") }));
|
||||
if (name === "canvas_select_nodes") return [{ type: "select_nodes", ids: requireStringArray(input.ids, "ids") }];
|
||||
if (name === "canvas_set_viewport") return [{ type: "set_viewport", viewport: requireViewport(input.viewport) }];
|
||||
if (name === "canvas_run_generation") return [runGenerationOp(requireString(input.nodeId, "nodeId"), generationMode(input.mode), stringOptional(input.prompt), input.retry === true)];
|
||||
throw new Error(`不支持的工具:${name}`);
|
||||
}
|
||||
|
||||
function generationFlowOps(input: Record<string, unknown>, snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOp[] {
|
||||
const mode = generationMode(input.mode);
|
||||
const prompt = requireString(input.prompt, "prompt");
|
||||
const x = numberOr(input.x, nextCanvasX(snapshot));
|
||||
const y = numberOr(input.y, 0);
|
||||
const textId = `text-${nanoid()}`;
|
||||
const targetId = `${mode}-${nanoid()}`;
|
||||
const referenceNodeIds = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds.filter((id): id is string => typeof id === "string") : [];
|
||||
const promptNode: CanvasNodeData = {
|
||||
id: textId,
|
||||
type: CanvasNodeType.Text,
|
||||
title: stringOptional(input.title) || "提示词",
|
||||
position: { x, y },
|
||||
width: NODE_DEFAULT_SIZE[CanvasNodeType.Text].width,
|
||||
height: NODE_DEFAULT_SIZE[CanvasNodeType.Text].height,
|
||||
metadata: { content: prompt },
|
||||
};
|
||||
const referenceNodes = referenceNodeIds.flatMap((id) => {
|
||||
const node = snapshot.nodes.find((candidate) => candidate.id === id);
|
||||
return node ? [node] : [];
|
||||
});
|
||||
const tokens = buildOrderedCanvasResourceReferences([promptNode, ...referenceNodes]).map(canvasResourceMentionToken);
|
||||
return [
|
||||
textNodeOp({ id: textId, text: prompt, title: stringOptional(input.title) || "提示词" }, x, y),
|
||||
generationTargetNodeOp(targetId, { ...input, prompt: tokens.join("\n") }, x + NODE_DEFAULT_SIZE[CanvasNodeType.Text].width + 80, y, config),
|
||||
{ type: "connect_nodes", fromNodeId: textId, toNodeId: targetId },
|
||||
...referenceNodeIds.map((fromNodeId) => ({ type: "connect_nodes" as const, fromNodeId, toNodeId: targetId })),
|
||||
{ type: "select_nodes", ids: [targetId] },
|
||||
...(input.autoRun ? [runGenerationOp(targetId, mode, tokens.join("\n"))] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function textNodeOp(input: Record<string, unknown>, x: number, y: number): CanvasAgentOp {
|
||||
return {
|
||||
type: "add_node",
|
||||
id: stringOptional(input.id),
|
||||
nodeType: CanvasNodeType.Text,
|
||||
title: stringOptional(input.title),
|
||||
position: { x, y },
|
||||
width: numberOptional(input.width),
|
||||
height: numberOptional(input.height),
|
||||
metadata: { content: stringOptional(input.text), status: "success", fontSize: 14 },
|
||||
};
|
||||
}
|
||||
|
||||
function generationTargetNodeOp(id: string, input: Record<string, unknown>, x: number, y: number, config: AiConfig): CanvasAgentOp {
|
||||
const mode = generationMode(input.mode);
|
||||
const prompt = stringOptional(input.prompt);
|
||||
const nodeType = generationNodeType(mode);
|
||||
return {
|
||||
type: "add_node",
|
||||
id,
|
||||
nodeType,
|
||||
title: stringOptional(input.title) || generationTitle(mode),
|
||||
position: { x, y },
|
||||
width: numberOptional(input.width),
|
||||
height: numberOptional(input.height),
|
||||
metadata: cleanRecord({
|
||||
content: "",
|
||||
fontSize: nodeType === CanvasNodeType.Text ? 14 : undefined,
|
||||
generationMode: mode,
|
||||
composerContent: prompt,
|
||||
prompt,
|
||||
status: "idle",
|
||||
model: resolveGenerationModel(config, mode, stringOptional(input.model)),
|
||||
size: stringOptional(input.size) || config.size,
|
||||
quality: stringOptional(input.quality) || config.quality,
|
||||
transparentBackground: stringOptional(input.transparentBackground) || config.transparentBackground,
|
||||
count: numberOptional(input.count) ?? generationCount(mode === "image" ? config.canvasImageCount || config.count : config.count),
|
||||
seconds: stringOptional(input.seconds) || config.videoSeconds,
|
||||
vquality: stringOptional(input.vquality) || config.vquality,
|
||||
generateAudio: stringOptional(input.generateAudio) || config.videoGenerateAudio,
|
||||
watermark: stringOptional(input.watermark) || config.videoWatermark,
|
||||
audioVoice: stringOptional(input.audioVoice) || config.audioVoice,
|
||||
audioFormat: stringOptional(input.audioFormat) || config.audioFormat,
|
||||
audioSpeed: stringOptional(input.audioSpeed) || config.audioSpeed,
|
||||
audioInstructions: stringOptional(input.audioInstructions) || config.audioInstructions,
|
||||
}) as CanvasNodeData["metadata"],
|
||||
};
|
||||
}
|
||||
|
||||
function generationNodeType(mode: "text" | "image" | "video" | "audio") {
|
||||
if (mode === "text") return CanvasNodeType.Text;
|
||||
if (mode === "video") return CanvasNodeType.Video;
|
||||
if (mode === "audio") return CanvasNodeType.Audio;
|
||||
return CanvasNodeType.Image;
|
||||
}
|
||||
|
||||
function runGenerationOp(nodeId: string, mode: "text" | "image" | "video" | "audio", prompt?: string, retry?: boolean): CanvasAgentOp {
|
||||
return { type: "run_generation", nodeId, mode, prompt, ...(retry ? { retry: true } : {}) };
|
||||
}
|
||||
|
||||
export function toolCallsFromDetail(detail: Record<string, unknown>): ResponseToolCall[] {
|
||||
return Array.isArray(detail.toolCalls) ? (detail.toolCalls.filter(isResponseToolCall) as ResponseToolCall[]) : [];
|
||||
}
|
||||
|
||||
function isResponseToolCall(value: unknown): value is ResponseToolCall {
|
||||
const item = objectDetail(value);
|
||||
const fn = objectDetail(item.function);
|
||||
return typeof item.id === "string" && item.type === "function" && typeof fn.name === "string" && typeof fn.arguments === "string";
|
||||
}
|
||||
|
||||
export function toolCallToResponseInput(call: ResponseToolCall): ResponseInputMessage {
|
||||
return { type: "function_call", call_id: call.id, name: call.function.name, arguments: call.function.arguments, ...(call.thoughtSignature ? { thoughtSignature: call.thoughtSignature } : {}) };
|
||||
}
|
||||
|
||||
// 上游兼容兜底:DeepSeek 等 OpenAI 兼容渠道的思考/推理模式不允许
|
||||
// tool_choice=required(后端归类文案见 providerPayloadErrorCategory)。
|
||||
// required 只用于对话首步把智能体拖入工具循环;遇到该类拒绝时降级为 auto
|
||||
// 重试一次——auto 在思考模式下可用,对话与工具调用均不受影响。
|
||||
// 语义与 image.ts 对非 auto tool_choice 的兼容降级策略一致。
|
||||
const THINKING_MODE_TOOL_CHOICE_REJECTION = "不支持强制工具调用";
|
||||
|
||||
export async function requestOnlineAgentModel(config: AiConfig, messages: ResponseInputMessage[], toolChoice: "auto" | "required", prompt: string, onDelta: (text: string) => void) {
|
||||
const submit = (choice: "auto" | "required") => runBackendToolGenerationTask({ prompt, config, messages, tools: CANVAS_ONLINE_AGENT_TOOLS, toolChoice: choice, onDelta });
|
||||
try {
|
||||
return await submit(toolChoice);
|
||||
} catch (error) {
|
||||
if (toolChoice !== "required" || !(error instanceof Error) || !error.message.includes(THINKING_MODE_TOOL_CHOICE_REJECTION)) {
|
||||
throw error;
|
||||
}
|
||||
return submit("auto");
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeToolCalls(calls: ResponseToolCall[]) {
|
||||
return calls.map((call) => toolCallLabel(call.function.name)).join(",") || "工具调用";
|
||||
}
|
||||
|
||||
export function previewOnlineToolCalls(calls: ResponseToolCall[], snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOperationImpact {
|
||||
const ops: CanvasAgentOp[] = [];
|
||||
let deferredCinematicCount = 0;
|
||||
calls.filter(isWritableToolCall).forEach((call) => {
|
||||
if (call.function.name === "canvas_create_cinematic_session") {
|
||||
deferredCinematicCount += 1;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ops.push(...onlineToolToOps(call.function.name, parseToolArguments(call.function.arguments), snapshot, config));
|
||||
} catch {
|
||||
// 参数错误会在真正执行时显式失败;预览阶段只展示可确定的影响。
|
||||
}
|
||||
});
|
||||
const impact = previewCanvasAgentOps(ops, snapshot);
|
||||
if (!deferredCinematicCount) return impact;
|
||||
return {
|
||||
...impact,
|
||||
operationCount: impact.operationCount + deferredCinematicCount,
|
||||
items: [...impact.items, "启动影视 Agent,会话完成后将剧本、分镜和生成节点写回当前画布"].slice(0, 8),
|
||||
warning: [impact.warning, "影视 Agent 的具体写回范围将在后端完成拆解后确定。"].filter(Boolean).join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
function toolCallLabel(name: string) {
|
||||
if (name === "canvas_list_skills") return "列出技能";
|
||||
if (name === "canvas_get_skill") return "读取技能入口";
|
||||
if (name === "canvas_list_skill_files") return "列出技能文件";
|
||||
if (name === "canvas_read_skill_file") return "读取技能文件";
|
||||
if (name === "canvas_search_skill_files") return "搜索技能文件";
|
||||
if (name === "canvas_apply_ops") return "画布操作";
|
||||
if (name === "canvas_get_state") return "读取画布";
|
||||
if (name === "canvas_get_context") return "读取上下文";
|
||||
if (name === "canvas_find_nodes") return "检索节点";
|
||||
if (name === "canvas_get_node") return "读取节点";
|
||||
if (name === "canvas_get_connection") return "读取连线";
|
||||
if (name === "canvas_get_generation_tasks") return "读取生成任务";
|
||||
if (name === "canvas_get_resources") return "读取资源";
|
||||
if (name === "canvas_validate_ops") return "校验操作";
|
||||
if (name === "canvas_get_selection") return "读取选区";
|
||||
if (name === "canvas_export_snapshot") return "导出快照";
|
||||
if (name === "canvas_create_cinematic_session") return "创建影视项目";
|
||||
if (name === "canvas_create_workflow") return "创建工作流";
|
||||
if (name === "canvas_create_node") return "创建节点";
|
||||
if (name === "canvas_create_text_node") return "创建文本";
|
||||
if (name === "canvas_create_text_nodes") return "批量创建文本";
|
||||
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
|
||||
if (name === "canvas_create_generation_flow") return "创建生成流程";
|
||||
if (name === "canvas_generate_text") return "生成文本";
|
||||
if (name === "canvas_generate_image") return "生成图片";
|
||||
if (name === "canvas_generate_video") return "生成视频";
|
||||
if (name === "canvas_generate_audio") return "生成音频";
|
||||
if (name === "canvas_update_node") return "更新节点";
|
||||
if (name === "canvas_update_node_text") return "更新文本";
|
||||
if (name === "canvas_move_nodes") return "移动节点";
|
||||
if (name === "canvas_resize_node") return "调整节点尺寸";
|
||||
if (name === "canvas_delete_nodes") return "删除节点";
|
||||
if (name === "canvas_connect_nodes") return "连接节点";
|
||||
if (name === "canvas_select_nodes") return "选择节点";
|
||||
if (name === "canvas_set_viewport") return "调整视口";
|
||||
if (name === "canvas_run_generation") return "触发生成";
|
||||
return name;
|
||||
}
|
||||
|
||||
export function toolResultText(result: OnlineToolResult) {
|
||||
return result.message;
|
||||
}
|
||||
|
||||
function requireStringArray(value: unknown, field: string): string[] {
|
||||
if (!Array.isArray(value)) throw new Error(`${field} 必须是字符串数组`);
|
||||
if (!value.every((item) => typeof item === "string" && Boolean(item))) throw new Error(`${field} 必须只包含非空字符串`);
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
export function requireOps(value: unknown): CanvasAgentOp[] {
|
||||
if (!Array.isArray(value)) throw new Error("ops 必须是数组");
|
||||
return value.map(toCanvasAgentOp);
|
||||
}
|
||||
|
||||
function toCanvasAgentOp(value: unknown): CanvasAgentOp {
|
||||
const item = objectDetail(value);
|
||||
const type = item.type;
|
||||
if (type === "add_node") {
|
||||
return {
|
||||
type,
|
||||
id: stringOptional(item.id),
|
||||
nodeType: item.nodeType ? requireNodeType(item.nodeType) : undefined,
|
||||
title: stringOptional(item.title),
|
||||
position: recordOptional(item.position) ? { x: requireNumber(objectDetail(item.position).x, "position.x"), y: requireNumber(objectDetail(item.position).y, "position.y") } : undefined,
|
||||
x: numberOptional(item.x),
|
||||
y: numberOptional(item.y),
|
||||
width: numberOptional(item.width),
|
||||
height: numberOptional(item.height),
|
||||
metadata: recordOptional(item.metadata) as CanvasNodeData["metadata"],
|
||||
};
|
||||
}
|
||||
if (type === "update_node") return { type, id: requireString(item.id, "id"), patch: recordOptional(item.patch) as Partial<CanvasNodeData> | undefined, metadata: recordOptional(item.metadata) as CanvasNodeData["metadata"] };
|
||||
if (type === "delete_node") return { type, id: stringOptional(item.id), ids: Array.isArray(item.ids) ? requireStringArray(item.ids, "ids") : undefined };
|
||||
if (type === "delete_connections") return { type, id: stringOptional(item.id), ids: Array.isArray(item.ids) ? requireStringArray(item.ids, "ids") : undefined, all: typeof item.all === "boolean" ? item.all : undefined };
|
||||
if (type === "connect_nodes") return { type, id: stringOptional(item.id), fromNodeId: requireString(item.fromNodeId, "fromNodeId"), toNodeId: requireString(item.toNodeId, "toNodeId") };
|
||||
if (type === "set_viewport") return { type, viewport: requireViewport(item.viewport) };
|
||||
if (type === "select_nodes") return { type, ids: requireStringArray(item.ids, "ids") };
|
||||
if (type === "run_generation") return { type, nodeId: requireString(item.nodeId, "nodeId"), mode: generationMode(item.mode), prompt: stringOptional(item.prompt), ...(item.retry === true ? { retry: true } : {}) };
|
||||
throw new Error("不支持的画布操作类型");
|
||||
}
|
||||
|
||||
function requireRecordArray(value: unknown, field: string): Record<string, unknown>[] {
|
||||
if (!Array.isArray(value)) throw new Error(`${field} 必须是数组`);
|
||||
return value.map((item) => {
|
||||
const record = objectDetail(item);
|
||||
if (!Object.keys(record).length) throw new Error(`${field} 必须只包含对象`);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
export function requireString(value: unknown, field: string) {
|
||||
if (typeof value !== "string" || !value) throw new Error(`${field} 必须是非空字符串`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireNumber(value: unknown, field: string) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${field} 必须是数字`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireNodeType(value: unknown): CanvasNodeType {
|
||||
if (Object.values(CanvasNodeType).includes(value as CanvasNodeType)) return value as CanvasNodeType;
|
||||
throw new Error("节点类型必须是 text、image、config、video 或 audio");
|
||||
}
|
||||
|
||||
function requireViewport(value: unknown) {
|
||||
const item = objectDetail(value);
|
||||
return { x: requireNumber(item.x, "viewport.x"), y: requireNumber(item.y, "viewport.y"), k: requireNumber(item.k, "viewport.k") };
|
||||
}
|
||||
|
||||
function recordOptional(value: unknown) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function stringOptional(value: unknown) {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function numberOptional(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function numberOr(value: unknown, fallback: number) {
|
||||
return numberOptional(value) ?? fallback;
|
||||
}
|
||||
|
||||
function nextCanvasX(snapshot: CanvasAgentSnapshot) {
|
||||
return snapshot.nodes.length ? Math.max(...snapshot.nodes.map((node) => node.position.x + node.width)) + 80 : 0;
|
||||
}
|
||||
|
||||
function generationMode(value: unknown): "text" | "image" | "video" | "audio" {
|
||||
return value === "text" || value === "video" || value === "audio" ? value : "image";
|
||||
}
|
||||
|
||||
function generationTitle(mode: "text" | "image" | "video" | "audio") {
|
||||
if (mode === "text") return "文本生成";
|
||||
if (mode === "video") return "视频生成";
|
||||
if (mode === "audio") return "音频生成";
|
||||
return "图片生成";
|
||||
}
|
||||
|
||||
function defaultGenerationModel(config: AiConfig, mode: "text" | "image" | "video" | "audio") {
|
||||
if (mode === "image") return config.imageModel || config.model;
|
||||
if (mode === "video") return config.videoModel || config.model;
|
||||
if (mode === "audio") return config.audioModel || config.model;
|
||||
return config.textModel || config.model;
|
||||
}
|
||||
|
||||
function resolveGenerationModel(config: AiConfig, mode: "text" | "image" | "video" | "audio", model?: string) {
|
||||
const normalized = normalizeModelOptionValue(model, config.channels);
|
||||
return normalized && selectableModelsByCapability(config, mode).includes(normalized) ? normalized : defaultGenerationModel(config, mode);
|
||||
}
|
||||
|
||||
function generationCount(value: string) {
|
||||
return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1)));
|
||||
}
|
||||
|
||||
function cleanRecord(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== ""));
|
||||
}
|
||||
|
||||
export function snapshotSignature(snapshot: CanvasAgentSnapshot) {
|
||||
return JSON.stringify({ nodes: snapshot.nodes, connections: snapshot.connections, selectedNodeIds: snapshot.selectedNodeIds, viewport: snapshot.viewport });
|
||||
}
|
||||
|
||||
export function explainNoop(ops: CanvasAgentOp[], snapshot: CanvasAgentSnapshot) {
|
||||
if (!ops.length) return "模型没有返回可执行的画布操作。";
|
||||
const nodeIds = new Set(snapshot.nodes.map((node) => node.id));
|
||||
const connectionIds = new Set(snapshot.connections.map((conn) => conn.id));
|
||||
const deleteConnectionOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "delete_connections" }> => op.type === "delete_connections");
|
||||
const connectOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "connect_nodes" }> => op.type === "connect_nodes");
|
||||
const deleteNodeOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "delete_node" }> => op.type === "delete_node");
|
||||
const updateOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "update_node" }> => op.type === "update_node");
|
||||
const selectOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "select_nodes" }> => op.type === "select_nodes");
|
||||
const generationOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation");
|
||||
if (deleteConnectionOps.length && !snapshot.connections.length) return "画布当前没有连线可删除。";
|
||||
if (deleteConnectionOps.length && deleteConnectionOps.every((op) => !op.all && [...(op.ids || []), ...(op.id ? [op.id] : [])].every((id) => !connectionIds.has(id)))) return "没有找到要删除的连线。";
|
||||
if (connectOps.length && connectOps.every((op) => snapshot.connections.some((conn) => conn.fromNodeId === op.fromNodeId && conn.toNodeId === op.toNodeId))) return "这些节点已经存在对应连线,无需重复连接。";
|
||||
if (connectOps.length && connectOps.every((op) => !nodeIds.has(op.fromNodeId) || !nodeIds.has(op.toNodeId))) return "没有找到要连接的节点。";
|
||||
if (deleteNodeOps.length && deleteNodeOps.every((op) => op.nodeType === CanvasNodeType.Config) && !snapshot.nodes.some((node) => node.type === CanvasNodeType.Config)) return "画布当前没有生成配置节点可删除。";
|
||||
if (deleteNodeOps.length && deleteNodeOps.every((op) => [...(op.ids || []), ...(op.id ? [op.id] : [])].every((id) => !nodeIds.has(id)))) return "没有找到要删除的节点。";
|
||||
if (updateOps.length && updateOps.every((op) => !nodeIds.has(op.id))) return "没有找到要更新的节点。";
|
||||
if (selectOps.length && selectOps.every((op) => !(op.ids || []).some((id) => nodeIds.has(id)))) return "没有找到要选择的节点。";
|
||||
if (generationOps.length && generationOps.every((op) => !nodeIds.has(op.nodeId))) return "没有找到要触发生成的节点。";
|
||||
if (ops.every((op) => op.type === "set_viewport")) return "视图已经是目标状态。";
|
||||
if (selectOps.length && selectOps.every((op) => JSON.stringify(op.ids || []) === JSON.stringify(snapshot.selectedNodeIds))) return "选区已经是目标状态。";
|
||||
return "工具已执行,但画布状态没有变化;请在日志 tab 查看工具参数和执行前后状态。";
|
||||
}
|
||||
|
||||
export function nodeToReference(node: CanvasNodeData): CanvasAssistantReference | null {
|
||||
if (node.type === CanvasNodeType.Image && (node.metadata?.content || node.metadata?.storageKey)) {
|
||||
return { id: node.id, type: node.type, title: node.title, dataUrl: node.metadata.content, storageKey: node.metadata.storageKey };
|
||||
}
|
||||
if (node.type === CanvasNodeType.Text && node.metadata?.content) {
|
||||
return { id: node.id, type: node.type, title: node.title, text: node.metadata.content };
|
||||
}
|
||||
if (node.type === CanvasNodeType.Skill && node.metadata?.skillSnapshot) {
|
||||
return { id: node.id, type: node.type, title: node.title, text: [node.metadata.skillSnapshot.name, node.metadata.skillSnapshot.template, node.metadata.skillSnapshot.outputContract].filter(Boolean).join("\n\n") };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildAssistantReferences(nodes: CanvasNodeData[], selectedNodeIds: Set<string>) {
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
return Array.from(selectedNodeIds)
|
||||
.map((id) => nodeById.get(id))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node))
|
||||
.map(nodeToReference)
|
||||
.filter((item): item is CanvasAssistantReference => Boolean(item));
|
||||
}
|
||||
|
||||
export async function buildToolAgentMessages(snapshot: CanvasAgentSnapshot, history: CanvasAssistantMessage[], userMessage: CanvasAssistantMessage, skills: Skill[] = []): Promise<ResponseInputMessage[]> {
|
||||
const refs = userMessage.references || [];
|
||||
const skillCatalog = skills
|
||||
.filter((skill) => skill.isAdded)
|
||||
.slice(0, 40)
|
||||
.map((skill) => `- ${skill.skillName}(${skill.skillId},v${skill.version || "1"},${skill.fileCount || 1} 文件):${skill.description}`)
|
||||
.join("\n");
|
||||
const systemContent = buildCanvasOnlineAgentSystemContent(skillCatalog);
|
||||
return [
|
||||
{ role: "system", content: systemContent },
|
||||
...history
|
||||
.filter((message) => message.role === "user" || message.role === "assistant" || message.role === "system")
|
||||
.slice(-8)
|
||||
.map((message): ResponseInputMessage => ({ role: message.role as "system" | "user" | "assistant", content: message.text })),
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
...refs.flatMap((item) => (item.text ? [{ type: "text" as const, text: `选中节点 ${item.title}:${item.text}` }] : [])),
|
||||
{ type: "text", text: `当前画布:${JSON.stringify(compactSnapshot(snapshot))}\n\n用户需求:${userMessage.text}` },
|
||||
...(await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ type: "image_url" as const, image_url: { url: await imageToDataUrl(item) } })))),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function compactSnapshot(snapshot: CanvasAgentSnapshot) {
|
||||
return {
|
||||
title: snapshot.title,
|
||||
viewport: snapshot.viewport,
|
||||
selectedNodeIds: snapshot.selectedNodeIds,
|
||||
nodes: snapshot.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
title: node.title,
|
||||
position: node.position,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
metadata: compactMetadata(node.metadata || {}),
|
||||
})),
|
||||
connections: snapshot.connections,
|
||||
};
|
||||
}
|
||||
|
||||
function compactMetadata(metadata: CanvasNodeData["metadata"]) {
|
||||
return {
|
||||
content: String(metadata?.content || "").slice(0, 500),
|
||||
prompt: String(metadata?.prompt || metadata?.composerContent || "").slice(0, 500),
|
||||
status: metadata?.status,
|
||||
skillName: metadata?.skillSnapshot?.name,
|
||||
skillVersion: metadata?.skillSnapshot?.version,
|
||||
generationMode: metadata?.generationMode,
|
||||
model: metadata?.model,
|
||||
size: metadata?.size,
|
||||
assetTags: metadata?.assetTags,
|
||||
workflowKind: metadata?.workflowKind,
|
||||
workflowTitle: metadata?.workflowTitle,
|
||||
workflowDescription: metadata?.workflowDescription,
|
||||
characterName: metadata?.characterName,
|
||||
characterAssetId: metadata?.characterAssetId,
|
||||
characterVersionId: metadata?.characterVersionId,
|
||||
chapterId: metadata?.chapterId,
|
||||
chapterTitle: metadata?.chapterTitle,
|
||||
shotIndex: metadata?.shotIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function backendAgentProviderConfig(config: ReturnType<typeof resolveModelRequestConfig>) {
|
||||
return {
|
||||
channelId: config.channelId,
|
||||
apiFormat: config.apiFormat,
|
||||
interfaceType: config.interfaceType,
|
||||
baseUrl: config.baseUrl,
|
||||
allowLocalChannel: config.allowLocalChannel === true,
|
||||
apiKey: config.apiKey,
|
||||
secretKey: config.secretKey,
|
||||
model: config.model,
|
||||
size: config.size,
|
||||
quality: config.quality,
|
||||
transparentBackground: config.transparentBackground,
|
||||
count: config.count,
|
||||
videoSeconds: config.videoSeconds,
|
||||
vquality: config.vquality,
|
||||
videoGenerateAudio: config.videoGenerateAudio,
|
||||
videoWatermark: config.videoWatermark,
|
||||
audioVoice: config.audioVoice,
|
||||
audioFormat: config.audioFormat,
|
||||
audioSpeed: config.audioSpeed,
|
||||
audioInstructions: config.audioInstructions,
|
||||
systemPrompt: config.systemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export function cinematicSessionMessageId(backendSessionId: string) {
|
||||
return `cinematic-session:${backendSessionId}`;
|
||||
}
|
||||
|
||||
export function upsertAssistantMessage(messages: CanvasAssistantMessage[], message: CanvasAssistantMessage) {
|
||||
const exists = messages.some((item) => item.id === message.id);
|
||||
return exists ? messages.map((item) => (item.id === message.id ? { ...item, ...message } : item)) : [...messages, message];
|
||||
}
|
||||
|
||||
export function createSession(): CanvasAssistantSession {
|
||||
const now = new Date().toISOString();
|
||||
return { id: nanoid(), title: "新对话", messages: [], createdAt: now, updatedAt: now };
|
||||
}
|
||||
251
web/src/components/canvas/canvas-assistant-panel-views.tsx
Normal file
251
web/src/components/canvas/canvas-assistant-panel-views.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { Button, Select } from "antd";
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { useMemo, useState } from "react";
|
||||
import { AtSign, Cpu, Trash2, X } from "lucide-react";
|
||||
|
||||
import { modelDisplayName, modelIcon, resolveModelChannel, selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { AgentImagePreview, type CanvasAgentChatMessage } from "./canvas-agent-chat-ui";
|
||||
import { ModelLogo } from "@/components/model-logo";
|
||||
import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references";
|
||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||
import { type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantReference, type CanvasAssistantSession } from "@/types/canvas";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
|
||||
export function AgentTextModelPicker({ config, value, onChange }: { config: AiConfig; value: string; onChange: (model: string) => void }) {
|
||||
const options = useMemo(() => Array.from(new Set([value, ...selectableModelsByCapability(config, "text")].filter(Boolean))), [config, value]);
|
||||
const current = value || "";
|
||||
return (
|
||||
<div className="min-w-0 max-w-[240px]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<Select<string>
|
||||
size="small"
|
||||
variant="borderless"
|
||||
value={current || undefined}
|
||||
className="agent-text-model-select w-full"
|
||||
popupMatchSelectWidth={288}
|
||||
options={options.map((model) => ({ value: model, label: agentModelLabel(config, model) }))}
|
||||
notFoundContent={<span className="block py-2 text-center text-xs text-foreground/48">暂无文本模型</span>}
|
||||
optionRender={(option) => {
|
||||
const model = String(option.value);
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<AgentModelIcon config={config} model={model} />
|
||||
<span className="min-w-0 flex-1 truncate">{modelDisplayName(config, model)}</span>
|
||||
{agentModelSource(config, model) ? <span className="shrink-0 text-xs opacity-55">{agentModelSource(config, model)}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
labelRender={() => (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<AgentModelIcon config={config} model={current} />
|
||||
<span className="min-w-0 truncate">{current ? modelDisplayName(config, current) : "选择文本模型"}</span>
|
||||
{current && agentModelSource(config, current) ? <span className="shrink-0 opacity-55">{agentModelSource(config, current)}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
onChange={onChange}
|
||||
aria-label="选择 Agent 文本模型"
|
||||
title={current ? agentModelLabel(config, current) : "选择文本模型"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function agentModelSource(config: AiConfig, model: string) {
|
||||
const channel = resolveModelChannel(config, model);
|
||||
return channel.scope === "system" ? "" : channel.name;
|
||||
}
|
||||
|
||||
export function agentModelLabel(config: AiConfig, model: string) {
|
||||
const source = agentModelSource(config, model);
|
||||
return source ? `${modelDisplayName(config, model)} · ${source}` : modelDisplayName(config, model);
|
||||
}
|
||||
|
||||
export function AgentModelIcon({ config, model }: { config: AiConfig; model: string }) {
|
||||
const icon = modelIcon(config, model);
|
||||
return icon ? <span className="inline-flex size-4 shrink-0 items-center justify-center"><ModelLogo icon={icon} size={16} /></span> : <Cpu className="size-4 shrink-0 opacity-70" />;
|
||||
}
|
||||
|
||||
export function AssistantHistory({ sessions, activeSession, onOpen, onDelete }: { sessions: CanvasAssistantSession[]; activeSession: CanvasAssistantSession | null; onOpen: (id: string) => void; onDelete: (id: string) => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm" style={{ color: theme.node.muted }}>
|
||||
{sessions.length ? `${sessions.length} 条历史` : "暂无历史"}
|
||||
</div>
|
||||
{sessions.map((session) => (
|
||||
<div key={session.id} className="rounded-md px-2.5 py-2 transition-colors" style={{ background: session.id === activeSession?.id ? theme.accent.primarySoft : "transparent", color: theme.node.text }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{session.id === activeSession?.id ? (
|
||||
<span className="shrink-0 text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.text }}>
|
||||
当前
|
||||
</span>
|
||||
) : null}
|
||||
<div className="truncate text-sm font-medium leading-5">{session.title}</div>
|
||||
</div>
|
||||
<div className="truncate text-[var(--fs-label)] leading-4 opacity-65">{sessionPreview(session)}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span className="text-[var(--fs-tiny)] opacity-55">{formatSessionTime(session.updatedAt || session.createdAt)}</span>
|
||||
<Button size="small" className="!h-6 !px-2" onClick={() => onOpen(session.id)}>
|
||||
进入
|
||||
</Button>
|
||||
<Tooltip title="删除记录">
|
||||
<Button size="small" danger type="text" className="!h-6 !w-6 !min-w-6" icon={<Trash2 className="size-3.5" />} onClick={() => onDelete(session.id)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!sessions.length ? (
|
||||
<div className="px-3 py-8 text-center text-sm" style={{ color: theme.node.muted }}>
|
||||
网站 Agent 的对话记录会显示在这里
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageReferences({ message }: { message: CanvasAssistantMessage }) {
|
||||
return (
|
||||
<div className={`flex max-w-[88%] flex-wrap gap-2 ${message.role === "user" ? "ml-auto justify-end" : "ml-11 justify-start"}`}>
|
||||
{message.references?.map((item, index, references) => (
|
||||
<AssistantReferenceChip key={item.id} item={item} label={assistantImageReferenceLabel(references, index)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function promptAlreadyHasMention(prompt: string, label: string) {
|
||||
const token = `@${label}`;
|
||||
let from = 0;
|
||||
while (from <= prompt.length) {
|
||||
const index = prompt.indexOf(token, from);
|
||||
if (index < 0) return false;
|
||||
const next = prompt[index + token.length];
|
||||
if (!next || /\s|[,.!?;:,。!?;:、)\]}】)]/.test(next)) return true;
|
||||
from = index + 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function AssistantReferenceChip({ item, label, previewUrl, onRemove, onInsertMention }: { item: CanvasAssistantReference; label?: string; previewUrl?: string; onRemove?: () => void; onInsertMention?: (label: string) => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const mentionReference = useMemo(() => assistantReferencesToMentionReferences([item]), [item]);
|
||||
const resolvedReference = useResolvedCanvasResourceReferences(mentionReference)[0];
|
||||
const text = (item.text || item.title).replace(/\s+/g, " ").trim().slice(0, 1) || "文";
|
||||
const imageUrl = previewUrl || resolvedReference?.previewUrl || item.dataUrl;
|
||||
const hasImage = Boolean(imageUrl || item.storageKey);
|
||||
const chipStyle = { background: theme.spatial.surface, color: theme.node.text };
|
||||
const actionStyle = { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text };
|
||||
|
||||
if (hasImage) {
|
||||
return (
|
||||
<>
|
||||
<span className="group/chip relative inline-flex h-12 shrink-0 items-center overflow-visible rounded-md" style={chipStyle}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-12 shrink-0 overflow-hidden rounded-l-md border-0 p-0"
|
||||
style={{ background: theme.toolbar.itemHover }}
|
||||
title="点击放大预览"
|
||||
aria-label={`预览 ${label || item.title}`}
|
||||
disabled={!imageUrl}
|
||||
onClick={() => imageUrl && setPreviewOpen(true)}
|
||||
onDoubleClick={() => imageUrl && setPreviewOpen(true)}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt="" className="size-full object-cover" />
|
||||
) : (
|
||||
<span className="text-[var(--fs-micro)] opacity-50">…</span>
|
||||
)}
|
||||
</button>
|
||||
{label && onInsertMention ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-12 max-w-[112px] min-w-0 items-center gap-1 border-0 bg-transparent px-2.5 text-[var(--fs-tiny)] opacity-80 hover:opacity-100"
|
||||
title={`插入 @${label}`}
|
||||
onClick={() => onInsertMention(label)}
|
||||
>
|
||||
<AtSign className="size-2.5 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
) : label ? (
|
||||
<span className="inline-flex h-12 max-w-[112px] min-w-0 items-center gap-1 px-2.5 text-[var(--fs-tiny)] opacity-80">
|
||||
<AtSign className="size-2.5 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100 group-focus-within/chip:opacity-100"
|
||||
style={actionStyle}
|
||||
onClick={onRemove}
|
||||
aria-label="移除引用"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
{previewOpen && imageUrl ? <AgentImagePreview attachment={{ id: item.id, url: imageUrl, name: item.title || label || "图片" }} onClose={() => setPreviewOpen(false)} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="group/chip relative inline-flex h-12 max-w-[168px] shrink-0 items-center gap-2 overflow-hidden rounded-md px-2.5 text-sm" style={chipStyle}>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md text-sm font-medium" style={{ background: theme.toolbar.itemHover }}>
|
||||
{text}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[var(--fs-tiny)] opacity-80">{item.title}</span>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100"
|
||||
style={actionStyle}
|
||||
onClick={onRemove}
|
||||
aria-label="移除引用"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function assistantReferencesToMentionReferences(references: CanvasAssistantReference[]): CanvasResourceReference[] {
|
||||
return references.flatMap((item, index): CanvasResourceReference[] => {
|
||||
if (item.dataUrl || item.storageKey) {
|
||||
const label = assistantImageReferenceLabel(references, index) || item.title;
|
||||
return [{ id: item.id, nodeId: item.id, kind: "image", label, title: item.title, previewUrl: item.dataUrl, storageKey: item.storageKey, active: true }];
|
||||
}
|
||||
if (item.text) {
|
||||
const label = item.type === CanvasNodeType.Skill ? `技能${index + 1}` : `文本${index + 1}`;
|
||||
return [{ id: item.id, nodeId: item.id, kind: item.type === CanvasNodeType.Skill ? "skill" : "text", label, title: item.title, text: item.text, active: true }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function assistantImageReferenceLabel(references: CanvasAssistantReference[], index: number) {
|
||||
if (!references[index]?.dataUrl && !references[index]?.storageKey) return undefined;
|
||||
const imageIndex = references.slice(0, index + 1).filter((item) => item.dataUrl || item.storageKey).length - 1;
|
||||
return imageIndex >= 0 ? imageReferenceLabel(imageIndex) : undefined;
|
||||
}
|
||||
|
||||
export function assistantMessageToChatMessage(message: CanvasAssistantMessage): CanvasAgentChatMessage {
|
||||
return { id: message.id, role: message.role, title: message.title, text: message.text, meta: message.meta, detail: message.detail };
|
||||
}
|
||||
|
||||
export function formatSessionTime(value?: string) {
|
||||
return value ? new Date(value).toLocaleString() : "";
|
||||
}
|
||||
|
||||
export function sessionPreview(session: CanvasAssistantSession) {
|
||||
return session.messages.at(-1)?.text || `${session.messages.length} 条消息`;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { Modal } from "antd";
|
||||
|
||||
import { AudioLines, BadgeCheck, Image as ImageIcon, UserRound, Volume2 } from "lucide-react";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
@@ -15,14 +16,14 @@ export function CanvasCharacterReferenceModal({ node, open, onClose }: { node: C
|
||||
const voiceProfile = metadata?.characterVoiceProfile;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<AppModal
|
||||
open={open}
|
||||
title={null}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width="min(1180px, calc(100vw - 32px))"
|
||||
onCancel={onClose}
|
||||
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}
|
||||
flush
|
||||
>
|
||||
<div className="grid h-[min(720px,calc(100dvh-48px))] min-h-0 grid-rows-[minmax(240px,42vh)_minmax(0,1fr)] overflow-hidden bg-background text-foreground md:grid-cols-[minmax(0,1.55fr)_minmax(360px,.85fr)] md:grid-rows-1">
|
||||
<section className="relative min-h-0 overflow-hidden border-b border-border bg-foreground/[.035] md:border-b-0 md:border-r" aria-label="角色三视图">
|
||||
@@ -91,7 +92,7 @@ export function CanvasCharacterReferenceModal({ node, open, onClose }: { node: C
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
71
web/src/components/canvas/canvas-cinematic-continuation.ts
Normal file
71
web/src/components/canvas/canvas-cinematic-continuation.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { isCanvasGenerationDurableAckError, persistCanvasCinematicSessionContinuationEffect } from "@/services/canvas-generation-consumer";
|
||||
import { isAgentSessionPollingAbort } from "@/lib/canvas/canvas-agent-session";
|
||||
import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
import type { CanvasAssistantSession } from "@/types/canvas";
|
||||
|
||||
export type CinematicContinuationFailureDisposition = "abort" | "durable-ack" | "provider-failed";
|
||||
|
||||
export type CinematicContinuationLiveSessionState = { sessions: CanvasAssistantSession[]; activeChatId: string | null };
|
||||
|
||||
type CanvasCinematicContinuationBoundaryInput<T> = {
|
||||
projectId: string;
|
||||
effectKey?: string;
|
||||
signal?: AbortSignal;
|
||||
readSnapshot: () => CanvasAgentSnapshot;
|
||||
executeOps: () => Promise<T>;
|
||||
completeSession: (effectKey?: string) => CanvasAssistantSession[];
|
||||
readLiveSessionState: () => CinematicContinuationLiveSessionState;
|
||||
restoreLiveSessions: (sessions: CanvasAssistantSession[], activeChatId: string | null) => void;
|
||||
restoreLiveSnapshot: (state: Pick<CanvasAgentSnapshot, "nodes" | "connections">) => void;
|
||||
failProvider: (error: unknown) => void;
|
||||
onFailureDisposition?: (disposition: CinematicContinuationFailureDisposition, error: unknown) => void;
|
||||
persistContinuation?: typeof persistCanvasCinematicSessionContinuationEffect;
|
||||
};
|
||||
|
||||
export function handleCinematicContinuationFailure(error: unknown, failProvider: (error: unknown) => void): CinematicContinuationFailureDisposition {
|
||||
if (isAgentSessionPollingAbort(error)) return "abort";
|
||||
if (isCanvasGenerationDurableAckError(error)) return "durable-ack";
|
||||
failProvider(error);
|
||||
return "provider-failed";
|
||||
}
|
||||
|
||||
export async function runCanvasCinematicContinuationBoundary<T>(input: CanvasCinematicContinuationBoundaryInput<T>) {
|
||||
const previousSnapshot = input.readSnapshot();
|
||||
const previousSessionState = input.readLiveSessionState();
|
||||
try {
|
||||
if (input.signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
|
||||
const result = await input.executeOps();
|
||||
const attemptedSnapshot = input.readSnapshot();
|
||||
input.completeSession(input.effectKey);
|
||||
const attemptedSessionState = input.readLiveSessionState();
|
||||
if (input.effectKey) {
|
||||
await (input.persistContinuation ?? persistCanvasCinematicSessionContinuationEffect)({
|
||||
projectId: input.projectId,
|
||||
effectKey: input.effectKey,
|
||||
previousNodes: previousSnapshot.nodes,
|
||||
nodes: attemptedSnapshot.nodes,
|
||||
previousConnections: previousSnapshot.connections,
|
||||
connections: attemptedSnapshot.connections,
|
||||
previousChatSessions: previousSessionState.sessions,
|
||||
chatSessions: attemptedSessionState.sessions,
|
||||
previousActiveChatId: previousSessionState.activeChatId,
|
||||
activeChatId: attemptedSessionState.activeChatId,
|
||||
signal: input.signal,
|
||||
readLiveSessionState: input.readLiveSessionState,
|
||||
restoreLiveSessions: input.restoreLiveSessions,
|
||||
restoreLiveSnapshot: input.restoreLiveSnapshot,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const disposition = handleCinematicContinuationFailure(error, input.failProvider);
|
||||
input.onFailureDisposition?.(disposition, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const canvasCinematicContinuationEntryAdapters = {
|
||||
"online-tool": runCanvasCinematicContinuationBoundary,
|
||||
"submit-cinematic": runCanvasCinematicContinuationBoundary,
|
||||
"resume-cinematic": runCanvasCinematicContinuationBoundary,
|
||||
} as const;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { App, Modal } from "antd";
|
||||
import { App } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { Check, Maximize2, X } from "lucide-react";
|
||||
|
||||
import type { CanvasDrawingEditorHandle } from "@/components/canvas/canvas-drawing-editor-types";
|
||||
@@ -80,7 +81,7 @@ export function CanvasDrawingEditorModal({ open, projectId, node, onClose, onSav
|
||||
|
||||
const unavailable = !isDrawingEngineAvailable(engine, tldrawLicenseKey);
|
||||
return (
|
||||
<Modal open={open} onCancel={() => void handleClose()} footer={null} closable={false} destroyOnHidden width="100vw" centered styles={{ body: { padding: 0 }, container: { padding: 0, overflow: "hidden" } }} className="canvas-drawing-editor-modal">
|
||||
<AppModal flush open={open} onCancel={() => void handleClose()} footer={null} closable={false} destroyOnHidden width="100vw" centered className="canvas-drawing-editor-modal">
|
||||
<div className="flex h-[min(92dvh,980px)] flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4" style={{ background: "var(--background)", borderColor: "var(--border)" }}>
|
||||
<div className="flex min-w-0 items-center gap-2"><Maximize2 className="size-4 opacity-55" /><span className="truncate text-sm font-semibold">{node?.title || "绘图"}</span><span className="text-[var(--fs-label)] opacity-45">{drawingEngineLabel(engine)} · {ready ? "已加载" : "正在加载"}</span></div>
|
||||
@@ -99,7 +100,7 @@ export function CanvasDrawingEditorModal({ open, projectId, node, onClose, onSav
|
||||
) : <EditorState title="正在准备绘图画布" />}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/stores/canvas/use-canvas-agent-store";
|
||||
import { canvasAgentPostconditionMessage, hashCanvasAgentSnapshot, previewCanvasAgentOps, summarizeCanvasAgentOps, verifyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context";
|
||||
import { isCanvasReadTool } from "@/lib/canvas/canvas-agent-protocol";
|
||||
import { buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references";
|
||||
import { buildLocalAgentSetupCommands, detectLocalAgentSetupPlatform, type LocalAgentSetupPlatform } from "@/lib/canvas/local-agent-setup";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
@@ -1318,7 +1319,7 @@ function toolName(name: string) {
|
||||
}
|
||||
|
||||
function isReadTool(name: string) {
|
||||
return name === "canvas_get_state" || name === "canvas_get_context" || name === "canvas_find_nodes" || name === "canvas_get_node" || name === "canvas_get_connection" || name === "canvas_get_generation_tasks" || name === "canvas_get_resources" || name === "canvas_validate_ops" || name === "canvas_get_selection" || name === "canvas_export_snapshot" || isProjectAgentReadTool(name);
|
||||
return isCanvasReadTool(name) || isProjectAgentReadTool(name);
|
||||
}
|
||||
|
||||
function isMcpToolItem(item?: AgentEventItem) {
|
||||
|
||||
@@ -3,69 +3,12 @@ import { Input, Popover } from "antd";
|
||||
import { LayoutTemplate, Search, WandSparkles } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { CANVAS_BUILTIN_PRESETS, type CanvasPromptPreset } from "@/lib/prompts";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { CanvasGenerationMode } from "@/types/canvas";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
|
||||
export type CanvasPromptPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
modes: CanvasGenerationMode[];
|
||||
source: "builtin" | "skill";
|
||||
};
|
||||
|
||||
const BUILTIN_PRESETS: CanvasPromptPreset[] = [
|
||||
{
|
||||
id: "character-sheet",
|
||||
name: "角色设定图",
|
||||
description: "正面、侧面、背面与表情参考,锁定角色一致性",
|
||||
prompt: "生成角色设定图:保持同一角色身份、五官、发型、服装和体态一致,包含正面、侧面、背面和关键表情参考,背景简洁,便于后续镜头复用。",
|
||||
modes: ["image"],
|
||||
source: "builtin",
|
||||
},
|
||||
{
|
||||
id: "multi-angle",
|
||||
name: "多机位视角",
|
||||
description: "围绕同一主体生成连续、可衔接的机位变化",
|
||||
prompt: "围绕同一主体设计多机位画面,保持人物、服装、场景和光线一致,分别给出远景、全景、中景、近景、特写、侧面、背面和俯拍视角,镜头之间具有连续性。",
|
||||
modes: ["image", "video"],
|
||||
source: "builtin",
|
||||
},
|
||||
{
|
||||
id: "next-shot",
|
||||
name: "画面推演",
|
||||
description: "推演当前画面的前后动作与镜头衔接",
|
||||
prompt: "基于当前画面推演下一个连续镜头:保持角色和场景一致,明确主体接下来的动作、视线、环境变化、镜头运动和自然衔接方式,不要跳变构图或身份。",
|
||||
modes: ["image", "video"],
|
||||
source: "builtin",
|
||||
},
|
||||
{
|
||||
id: "story-beats",
|
||||
name: "连续镜头",
|
||||
description: "将短剧情拆成可生成的连续镜头节拍",
|
||||
prompt: "把这段内容拆成连续镜头节拍。每个镜头写清主体动作、景别、构图、机位、运镜、光线、情绪和与前后镜头的衔接,并保持角色、场景和道具一致。",
|
||||
modes: ["text", "image", "video"],
|
||||
source: "builtin",
|
||||
},
|
||||
{
|
||||
id: "cinematic-light",
|
||||
name: "电影光影优化",
|
||||
description: "保留内容,优化真实光线、层次和融合感",
|
||||
prompt: "保留主体身份、动作和原始构图,优化为真实电影摄影光线:明确主光方向、环境反射、阴影层次、肤色和背景融合,降低塑料感与过度锐化,不改变画面内容。",
|
||||
modes: ["image", "video"],
|
||||
source: "builtin",
|
||||
},
|
||||
{
|
||||
id: "video-prompt",
|
||||
name: "视频提示词优化",
|
||||
description: "整理为模型更容易执行的时序化镜头指令",
|
||||
prompt: "将当前要求改写为结构化视频提示词,按时间顺序描述开场画面、主体动作、镜头运动、环境变化、声音和结束画面;消除冲突指令,保留所有关键约束。",
|
||||
modes: ["text", "video"],
|
||||
source: "builtin",
|
||||
},
|
||||
];
|
||||
export type { CanvasPromptPreset };
|
||||
|
||||
export function CanvasPresetPicker({
|
||||
mode,
|
||||
@@ -100,17 +43,17 @@ export function CanvasPresetPicker({
|
||||
if (!reference.skill) return [];
|
||||
return [
|
||||
{
|
||||
id: `skill:${reference.skill.skill_id}`,
|
||||
name: reference.skill.skill_name,
|
||||
id: `skill:${reference.skill.skillId}`,
|
||||
name: reference.skill.skillName,
|
||||
description: reference.skill.description || reference.skill.instruction || "已加入技能",
|
||||
prompt: `@${reference.skill.skill_name} `,
|
||||
prompt: `@${reference.skill.skillName} `,
|
||||
modes: ["text", "image", "video", "audio"],
|
||||
source: "skill",
|
||||
},
|
||||
];
|
||||
});
|
||||
const normalized = query.trim().toLowerCase();
|
||||
return [...BUILTIN_PRESETS.filter((preset) => preset.modes.includes(mode)), ...skills].filter((preset) => !normalized || `${preset.name} ${preset.description}`.toLowerCase().includes(normalized));
|
||||
return [...CANVAS_BUILTIN_PRESETS.filter((preset) => preset.modes.includes(mode)), ...skills].filter((preset) => !normalized || `${preset.name} ${preset.description}`.toLowerCase().includes(normalized));
|
||||
}, [mode, query, skillReferences]);
|
||||
|
||||
const content = (
|
||||
|
||||
@@ -724,7 +724,7 @@ function MentionReferenceList({ references, activeReferenceId, onSelect }: { ref
|
||||
<span className="canvas-resource-mention-copy">
|
||||
<span className="canvas-resource-mention-title-row"><strong title={reference.label}>{reference.label}</strong>{reference.kind === "skill" ? <em>技能</em> : null}</span>
|
||||
{reference.kind === "skill" ? (
|
||||
<span className="canvas-resource-mention-meta"><span>{reference.skill?.description || reference.text || "工作流技能"}</span><small>{reference.skill?.version ? `v${reference.skill.version}` : ""}{reference.skill?.file_count ? ` · ${reference.skill.file_count} 文件` : ""}</small></span>
|
||||
<span className="canvas-resource-mention-meta"><span>{reference.skill?.description || reference.text || "工作流技能"}</span><small>{reference.skill?.version ? `v${reference.skill.version}` : ""}{reference.skill?.fileCount ? ` · ${reference.skill.fileCount} 文件` : ""}</small></span>
|
||||
) : reference.text && reference.text !== reference.title ? <span className="canvas-resource-mention-meta"><span>{reference.text}</span></span> : null}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Clock3, Copy, Eye, Palette, Pencil, Plus, Search, SlidersHorizontal, Star, Trash2, UserRound } from "lucide-react";
|
||||
import { App, Button, Input, Modal } from "antd";
|
||||
import { App, Button, Input } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { StyleProfileEditorModal } from "@/components/canvas/style-profile-editor-modal";
|
||||
@@ -536,7 +537,7 @@ export function CanvasStylePickerModal({ open, value, currentProfile, startInEdi
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal rootClassName="canvas-style-picker-modal" open={open} title={null} footer={null} centered width="min(1240px, calc(100vw - 24px))" onCancel={onClose} styles={{ container: { padding: 0 }, body: { padding: 0 } }}>
|
||||
<AppModal rootClassName="canvas-style-picker-modal" open={open} title={null} footer={null} centered width="min(1240px, calc(100vw - 24px))" onCancel={onClose} flush>
|
||||
<div className="canvas-style-center-shell flex min-h-0 flex-col overflow-hidden" style={{ color: theme.node.text, background: theme.node.panel }}>
|
||||
<header className="flex min-h-16 flex-col gap-3 border-b px-4 py-3 pr-12 sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:pr-14" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="min-w-0">
|
||||
@@ -592,7 +593,7 @@ export function CanvasStylePickerModal({ open, value, currentProfile, startInEdi
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
<CanvasStyleDetailModal open={Boolean(detailPreset)} preset={detailPreset} selected={detailPreset?.id === value} onClose={() => setDetailPreset(null)} onSelect={(preset) => { setDetailPreset(null); const item = [...systemItems, ...userItems].find((candidate) => candidate.preset.id === preset.id); if (item) selectItem(item); }} />
|
||||
<StyleProfileEditorModal open={Boolean(editor)} initialProfile={editor?.profile || null} saving={saveMutation.isPending} onClose={() => setEditor(null)} onSave={(profile, apply) => saveMutation.mutate({ profile, apply })} />
|
||||
</>
|
||||
@@ -672,7 +673,7 @@ export function CanvasStyleDetailModal({ open, preset, selected = false, onClose
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const sections = preset ? parseStyleSections(preset.prompt) : [];
|
||||
return (
|
||||
<Modal rootClassName="canvas-style-detail-modal" open={open} title={null} footer={null} centered destroyOnHidden width="min(820px, calc(100vw - 24px))" onCancel={onClose} styles={{ container: { padding: 0 }, body: { padding: 0 } }}>
|
||||
<AppModal rootClassName="canvas-style-detail-modal" open={open} title={null} footer={null} centered destroyOnHidden width="min(820px, calc(100vw - 24px))" onCancel={onClose} flush>
|
||||
{preset ? <div className="canvas-style-detail-shell flex flex-col overflow-hidden" style={{ color: theme.node.text, background: theme.node.panel }}>
|
||||
<div className="flex h-44 shrink-0 items-center justify-center overflow-hidden border-b sm:h-52" style={{ borderColor: theme.node.stroke, background: theme.canvas.background }}>
|
||||
<img src={preset.imageUrl} width="960" height="540" alt={`${preset.title}画风示意`} className="h-full w-full object-contain" style={preset.id === "black-white-noir" ? { filter: "grayscale(1) contrast(1.08)" } : undefined} />
|
||||
@@ -694,7 +695,7 @@ export function CanvasStyleDetailModal({ open, preset, selected = false, onClose
|
||||
</div>
|
||||
<footer className="flex shrink-0 flex-wrap justify-end gap-2 border-t px-4 py-3 sm:px-5" style={{ borderColor: theme.node.stroke }}><Button onClick={onClose}>关闭</Button>{onSelect ? <Button type="primary" disabled={selected} icon={selected ? <Check className="size-3.5" /> : <Palette className="size-3.5" />} onClick={() => onSelect(preset)}>{selected ? "当前画风" : "选择该画风"}</Button> : null}</footer>
|
||||
</div> : null}
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { App, Button, ColorPicker, Input, InputNumber, Modal, Progress, Segmented } from "antd";
|
||||
import { App, Button, ColorPicker, Input, InputNumber, Progress, Segmented } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { Switch } from "@/components/ui/base/switch";
|
||||
import { Captions, FileDown, FileUp, ListPlus, LoaderCircle, Plus, Scissors, Sparkles, Trash2 } from "lucide-react";
|
||||
import { saveAs } from "file-saver";
|
||||
@@ -501,7 +502,7 @@ export function CanvasSubtitleDialog({ node, open, projectId, config, onClose, o
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal className="canvas-subtitle-dialog" title={title} open={open} centered footer={null} width={1120} destroyOnHidden onCancel={onClose} styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}>
|
||||
<AppModal className="canvas-subtitle-dialog" title={title} open={open} centered footer={null} width={1120} destroyOnHidden onCancel={onClose} flush>
|
||||
<div className="flex h-[min(72vh,680px)] min-h-[420px] flex-col text-sm" style={{ color: theme.node.text }}>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
|
||||
<input
|
||||
@@ -612,6 +613,6 @@ export function CanvasSubtitleDialog({ node, open, projectId, config, onClose, o
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { App, Button, ColorPicker, Dropdown, Input, Modal, Popover } from "antd";
|
||||
import { App, Button, ColorPicker, Dropdown, Input, Popover } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
@@ -109,7 +110,7 @@ export function CanvasTextEditorModal({ node, open, onClose, onSave }: CanvasTex
|
||||
}, [dirty, editor, node?.id, open, saving, title]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<AppModal
|
||||
className="canvas-text-editor-modal"
|
||||
open={open && Boolean(node)}
|
||||
title={null}
|
||||
@@ -119,7 +120,7 @@ export function CanvasTextEditorModal({ node, open, onClose, onSave }: CanvasTex
|
||||
destroyOnHidden
|
||||
width="min(1180px, calc(100vw - 24px))"
|
||||
onCancel={close}
|
||||
styles={{ container: { padding: 0, overflow: "hidden", borderRadius: 8 }, body: { padding: 0 } }}
|
||||
flush styles={{ container: { borderRadius: 8 } }}
|
||||
>
|
||||
<section className="flex h-[min(88dvh,840px)] flex-col overflow-hidden" style={{ background: theme.node.panel, color: theme.node.text }}>
|
||||
<header className="flex h-13 shrink-0 items-center gap-3 border-b px-3" style={{ borderColor: theme.node.stroke }}>
|
||||
@@ -154,7 +155,7 @@ export function CanvasTextEditorModal({ node, open, onClose, onSave }: CanvasTex
|
||||
<span className="ml-auto">Ctrl/⌘S 保存</span>
|
||||
</footer>
|
||||
</section>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { App, Button, Dropdown, Input, InputNumber, Modal, Progress } from "antd";
|
||||
import { App, Button, Dropdown, Input, InputNumber, Progress } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
// 二期:多轨时间线编辑弹窗。
|
||||
// 数据源是项目级 TimelineProject:视频/音频节点自动入轨,字幕条目转字幕片段。
|
||||
@@ -634,7 +635,7 @@ export function CanvasTimelineDialog({
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<AppModal
|
||||
className="canvas-timeline-dialog"
|
||||
title={title}
|
||||
open={open}
|
||||
@@ -646,7 +647,7 @@ export function CanvasTimelineDialog({
|
||||
afterOpenChange={(visible) => {
|
||||
if (visible) ensureToolbarObserved();
|
||||
}}
|
||||
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}
|
||||
flush
|
||||
>
|
||||
<div className="flex h-[min(76vh,760px)] min-h-[420px] flex-col text-sm" style={{ color: theme.node.text }}>
|
||||
<div ref={toolbarRef} className="flex flex-nowrap items-center gap-2 overflow-hidden border-b px-4 py-3" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
|
||||
@@ -919,6 +920,6 @@ export function CanvasTimelineDialog({
|
||||
<span className="ml-auto truncate text-xs opacity-45">拖拽片段移动,左右边缘裁剪,字幕片段来自视频节点的字幕数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { App, Button, Modal, Upload, type UploadFile } from "antd";
|
||||
import { App, Button, Upload, type UploadFile } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { FileImage, FileText, Film, Music2, UploadCloud, X } from "lucide-react";
|
||||
|
||||
import { isAudioFile } from "@/lib/canvas/canvas-project-generation";
|
||||
@@ -40,7 +41,7 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<AppModal
|
||||
open={open}
|
||||
title={null}
|
||||
footer={null}
|
||||
@@ -50,7 +51,7 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
|
||||
keyboard={!uploading}
|
||||
mask={{ closable: !uploading }}
|
||||
onCancel={onClose}
|
||||
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}
|
||||
flush
|
||||
>
|
||||
<div className="flex min-h-96 flex-col overflow-hidden">
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border py-0 pl-5 pr-12">
|
||||
@@ -128,7 +129,7 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "@/lib/canvas/director/director-onboarding";
|
||||
|
||||
/**
|
||||
* 导演台首次上手引导浮层(P2)。
|
||||
* 导演台首次上手引导浮层。
|
||||
*
|
||||
* 硬约束:
|
||||
* - 非阻塞。不是 dialog,没有遮罩,不抢焦点,不做焦点陷阱:引导旁边就是要动手的工作台,
|
||||
|
||||
@@ -24,7 +24,7 @@ import { createDirectorActor, createDirectorBillboard, createDirectorCamera, cre
|
||||
import { describeDirectorSaveStatus, resolveDirectorCloseOutcome, shouldBlockDirectorUnload, shouldOfferDirectorDraftRecovery } from "@/lib/canvas/director/director-save-wiring";
|
||||
import { useDirectorSaveCoordinator } from "@/components/canvas/director/use-director-save-coordinator";
|
||||
import { uploadMediaFile } from "@/services/file-storage";
|
||||
import { saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store";
|
||||
import { useDirectorWorkbenchStore } from "@/stores/canvas/use-director-workbench-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -420,10 +420,14 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco
|
||||
if (!file || !/\.(glb|gltf)$/i.test(file.name)) return;
|
||||
const uploaded = await uploadMediaFile(file, "model");
|
||||
const assetId = addAsset({ kind: "model", title: file.name.replace(/\.(glb|gltf)$/i, ""), coverUrl: "", tags: ["3D模型"], source: "导演台", data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: file.name }, metadata: { source: "director" } });
|
||||
await saveRemoteUserDataNow();
|
||||
const asset = useAssetStore.getState().assets.find((item): item is ModelAsset => item.id === assetId && item.kind === "model");
|
||||
if (asset) addModelAsset(asset);
|
||||
message.success("3D 模型已加入场景和素材库");
|
||||
try {
|
||||
await saveRemoteUserDataNow();
|
||||
message.success("3D 模型已加入场景和素材库");
|
||||
} catch (error) {
|
||||
message.warning(localSavedRemotePendingMessage("3D 模型已加入场景和本机素材库", error));
|
||||
}
|
||||
};
|
||||
|
||||
const addBillboard = (node: CanvasNodeData) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button, InputNumber, Modal, Popconfirm, Segmented, Select, Tag } from "antd";
|
||||
import { Button, InputNumber, Popconfirm, Segmented, Select, Tag } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { Switch } from "@/components/ui/base/switch";
|
||||
import { Callout } from "@/components/ui/product/callout";
|
||||
import { StatusBadge } from "@/components/ui/base/badges";
|
||||
@@ -11,7 +12,7 @@ import { portraitVisionModelError, portraitVisionModelLabel, portraitVisionModel
|
||||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||||
import { useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { cancelPortraitTask, createPortraitClearanceTask, deletePortraitTask, downloadPortraitReport, imageNodeDataUrl, installPortraitClearanceModels, listPortraitTasks, portraitOwnerScopeHash, readPortraitRuntimeStatus, readPortraitTaskResult, retryPortraitTask, type PortraitRuntimeStatus, type PortraitRuntimeTask } from "@/services/portrait-clearance-runtime";
|
||||
import { cancelPortraitTask, createPortraitClearanceTask, deletePortraitTask, downloadPortraitReport, imageNodeDataUrl, installPortraitClearanceModels, listPortraitTasks, portraitOwnerScopeHash, readPortraitRuntimeStatus, readPortraitTaskImage, readPortraitTaskResult, retryPortraitTask, type PortraitRuntimeStatus, type PortraitRuntimeTask } from "@/services/portrait-clearance-runtime";
|
||||
import type { PortraitFeatureKey, PortraitPairResult, PortraitRiskLevel } from "@/lib/portrait-clearance/contracts";
|
||||
|
||||
type PortraitClearanceModalProps = {
|
||||
@@ -249,7 +250,7 @@ export function PortraitClearanceModal({ projectId, node, upstreamNodes, open, o
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<AppModal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
@@ -259,7 +260,7 @@ export function PortraitClearanceModal({ projectId, node, upstreamNodes, open, o
|
||||
centered
|
||||
destroyOnHidden={false}
|
||||
className="portrait-clearance-modal"
|
||||
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}
|
||||
flush
|
||||
>
|
||||
<div className="flex max-h-[calc(100dvh-32px)] min-h-[min(680px,calc(100dvh-32px))] flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b px-5 py-4" style={{ borderColor: "var(--border)" }}>
|
||||
@@ -334,7 +335,7 @@ export function PortraitClearanceModal({ projectId, node, upstreamNodes, open, o
|
||||
<div className="flex flex-wrap items-center justify-end gap-2"><Select size="small" value={reportFormat} disabled={!state.lastResult || Boolean(busyAction)} options={[{ value: "html", label: "HTML 报告" }, { value: "md", label: "Markdown 报告" }, { value: "docx", label: "DOCX 报告" }, { value: "json", label: "JSON 结果" }]} onChange={(value) => setReportFormat(value as "json" | "md" | "html" | "docx")} /><Button icon={<Download className="size-3.5" />} loading={busyAction === "export"} disabled={!state.lastResult} onClick={() => void exportReport()}>导出</Button>{state.activeTaskId && state.task?.status !== "failed" && state.task?.status !== "cancelled" ? <Popconfirm title="停止当前排查?" description="已下载的候选和本机任务会保留,可稍后查看或重试。" onConfirm={() => void stopTask()}><Button danger icon={<Pause className="size-3.5" />} loading={busyAction === "stop"}>停止</Button></Popconfirm> : null}{state.task?.status === "failed" || state.task?.status === "cancelled" ? <Button icon={<Play className="size-3.5" />} loading={busyAction === "retry"} onClick={() => void retryTask()}>重试</Button> : null}<Button type="primary" icon={<Play className="size-3.5" />} loading={starting} disabled={!canStart} onClick={() => void startTask()}>开始排查</Button></div>
|
||||
</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -411,9 +412,15 @@ const PORTRAIT_FEATURE_SIMILARITY_LABELS = { high: "高度相似", medium: "中
|
||||
function ResultPairCard({ taskId, pair, candidate, onAddCandidate }: { taskId: string; pair: PortraitPairResult; candidate?: PortraitResultView["candidates"][number]; onAddCandidate?: PortraitClearanceModalProps["onAddCandidate"] }) {
|
||||
const [image, setImage] = useState("");
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
void import("@/services/portrait-clearance-runtime").then(({ readPortraitTaskImage }) => readPortraitTaskImage(taskId, pair.comparisonImageId)).then((value) => { if (!disposed) setImage(value.dataUrl); }).catch(() => undefined);
|
||||
return () => { disposed = true; };
|
||||
const controller = new AbortController();
|
||||
void readPortraitTaskImage(taskId, pair.comparisonImageId, controller.signal)
|
||||
.then((value) => setImage(value.dataUrl))
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
// 结果正文仍可阅读,但图片读取失败必须留下任务与图片上下文,不能静默伪装成“尚未加载”。
|
||||
console.warn("读取肖像对比图片失败", { taskId, imageId: pair.comparisonImageId, error });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [pair.comparisonImageId, taskId]);
|
||||
const vision = pair.visionComparison;
|
||||
return <article className="overflow-hidden rounded-lg border" style={{ borderColor: "var(--border)" }}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Button, Input, InputNumber, Modal, Select } from "antd";
|
||||
import { Button, Input, InputNumber, Select } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { Switch } from "@/components/ui/base/switch";
|
||||
import { AlertTriangle, Box, Check, FileImage, Link2, Plus, Save, SlidersHorizontal, Trash2 } from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
@@ -77,7 +78,7 @@ export function StyleAssetBindingModal({ open, profile, onClose, onApply }: Styl
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<AppModal
|
||||
rootClassName="style-asset-binding-modal"
|
||||
open={open}
|
||||
title={null}
|
||||
@@ -85,7 +86,7 @@ export function StyleAssetBindingModal({ open, profile, onClose, onApply }: Styl
|
||||
centered
|
||||
width="min(980px, calc(100vw - 24px))"
|
||||
onCancel={onClose}
|
||||
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
|
||||
flush
|
||||
>
|
||||
<div className="flex max-h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex items-start justify-between gap-3 border-b border-border px-4 py-3 pr-12 sm:items-center sm:px-5">
|
||||
@@ -169,7 +170,7 @@ export function StyleAssetBindingModal({ open, profile, onClose, onApply }: Styl
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { App, Button, Input, Modal, Segmented, Select } from "antd";
|
||||
import { App, Button, Input, Segmented, Select } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { Braces, Image, Layers3, Save, Sparkles } from "lucide-react";
|
||||
|
||||
import { StyleAssetBindingModal } from "@/components/canvas/style-asset-binding-modal";
|
||||
@@ -52,7 +53,7 @@ export function StyleProfileEditorModal({ open, initialProfile, saving = false,
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
<AppModal
|
||||
rootClassName="style-profile-editor-modal"
|
||||
open={open}
|
||||
title={null}
|
||||
@@ -60,7 +61,7 @@ export function StyleProfileEditorModal({ open, initialProfile, saving = false,
|
||||
centered
|
||||
width="min(1120px, calc(100vw - 24px))"
|
||||
onCancel={requestClose}
|
||||
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
|
||||
flush
|
||||
>
|
||||
<div className="flex max-h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex min-h-16 items-center border-b border-border px-4 pr-12 sm:px-5 sm:pr-14">
|
||||
@@ -99,7 +100,7 @@ export function StyleProfileEditorModal({ open, initialProfile, saving = false,
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
<StyleAssetBindingModal
|
||||
open={assetsOpen}
|
||||
profile={draft}
|
||||
|
||||
@@ -13,7 +13,7 @@ type EditorStoreHook = UseBoundStore<StoreApi<EditorStore>>;
|
||||
|
||||
export type EditorHostContextValue = {
|
||||
projectId: string;
|
||||
/** 项目资产列表(M3.4 asset-ingest 消费;由宿主在进入编辑器时获取)。 */
|
||||
/** 当前用户、当前项目的资产快照;由编辑器宿主获取,素材导入插件只消费和触发刷新。 */
|
||||
assets: ProjectAsset[];
|
||||
/** 重新拉取项目资产(导入媒体后刷新列表)。 */
|
||||
refreshAssets: () => Promise<ProjectAsset[] | null>;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Modal } from "antd";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { ScrollText } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { AppModal } from "@/components/ui/product/app-modal/app-modal";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
|
||||
export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
@@ -10,7 +10,8 @@ export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose:
|
||||
const version = `v${__APP_VERSION__.replace(/^v/, "")}`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<AppModal
|
||||
flush
|
||||
rootClassName="app-spatial-modal app-changelog-modal"
|
||||
title={
|
||||
<div className="app-changelog-heading">
|
||||
@@ -29,7 +30,6 @@ export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose:
|
||||
footer={null}
|
||||
centered
|
||||
onCancel={onClose}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
modalRender={(node) => (
|
||||
<motion.div initial={reducedMotion ? false : { opacity: 0, y: 14, scale: 0.975 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}>
|
||||
{node}
|
||||
@@ -61,6 +61,6 @@ export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose:
|
||||
{__APP_CHANGELOG__}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Modal, Tabs } from "antd";
|
||||
import { Tabs } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import type { ReactNode } from "react";
|
||||
import "./model-editor-modal.css";
|
||||
|
||||
@@ -31,7 +32,7 @@ export function ModelEditorModal({
|
||||
const content = typeof children === "function" ? children(tabs) : children || tabs;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<AppModal
|
||||
open={open}
|
||||
centered
|
||||
width={1120}
|
||||
@@ -48,9 +49,9 @@ export function ModelEditorModal({
|
||||
closable={!busy}
|
||||
onCancel={onClose}
|
||||
footer={footer}
|
||||
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0, minHeight: 0, flex: 1 }, header: { margin: 0 }, footer: { margin: 0 } }}
|
||||
flush styles={{ body: { minHeight: 0, flex: 1 }, header: { margin: 0 }, footer: { margin: 0 } }}
|
||||
>
|
||||
{content}
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function useSkillRuntimeCatalog() {
|
||||
let cancelled = false;
|
||||
listAddedSkills()
|
||||
.then((result) => {
|
||||
if (!cancelled) setSkills(result.skills.filter((skill) => skill.is_added));
|
||||
if (!cancelled) setSkills(result.skills.filter((skill) => skill.isAdded));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSkills([]);
|
||||
@@ -30,7 +30,7 @@ export function useSkillRuntimeCatalog() {
|
||||
|
||||
export function SkillRuntimePicker({ skills, loading, value, onChange, placeholder = "选择本次生成使用的技能", profile = "canvas" }: { skills: Skill[]; loading?: boolean; value: string[]; onChange: (skillIds: string[]) => void; placeholder?: string; profile?: SkillRuntimeProfile }) {
|
||||
const options = useMemo(
|
||||
() => skills.map((skill) => ({ value: skill.skill_id, label: skill.skill_name, title: skill.description })),
|
||||
() => skills.map((skill) => ({ value: skill.skillId, label: skill.skillName, title: skill.description })),
|
||||
[skills],
|
||||
);
|
||||
const maxSkills = SKILL_RUNTIME_PROFILES[profile].maxSkills;
|
||||
|
||||
@@ -41,8 +41,7 @@
|
||||
- `base/tooltip/` — Tooltip(RAC 浮层,AntD 8 向 placement 映射,title 空不渲染)
|
||||
- `base/switch/` — Switch(button role=switch + data-state,checkedChildren 文本轨、loading、Form.Item 注入)
|
||||
- `product/empty-state/` — EmptyState(空态占位)
|
||||
- `product/callout/` — Callout(规划中,P0)
|
||||
- `base/badges/` — StatusBadge(状态点徽章;NodeTypeBadge 规划中)
|
||||
- `product/empty-state/` — EmptyState(空态占位)
|
||||
- `product/callout/` — Callout(规划中,P0)
|
||||
- `product/callout/` — Callout
|
||||
- `product/app-modal/` — AppModal(产品弹窗壳,`flush` 去掉 AntD 默认内边距)
|
||||
- `product/app-drawer/` — AppDrawer(产品侧栏壳)
|
||||
- `base/buttons/` — IconButton / ToolButton / ButtonGroup(规划中,P0)
|
||||
|
||||
22
web/src/components/ui/product/app-drawer/app-drawer.tsx
Normal file
22
web/src/components/ui/product/app-drawer/app-drawer.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Drawer, type DrawerProps } from "antd";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
// 用途:产品侧栏壳。消费 token 由内容自己负责。默认 destroyOnHidden,可选 flush 去内边距。
|
||||
export type AppDrawerProps = DrawerProps & {
|
||||
flush?: boolean;
|
||||
};
|
||||
|
||||
export function AppDrawer({ flush = false, destroyOnHidden = true, styles, className, rootClassName, ...props }: AppDrawerProps) {
|
||||
const mergeResolvedStyles = (resolved: unknown) => {
|
||||
const base = resolved && typeof resolved === "object" ? (resolved as Record<string, unknown>) : {};
|
||||
if (!flush) return base;
|
||||
const body = base.body && typeof base.body === "object" ? (base.body as CSSProperties) : {};
|
||||
return { ...base, body: { ...body, padding: 0 } };
|
||||
};
|
||||
const mergedStyles =
|
||||
typeof styles === "function"
|
||||
? (info: { props: DrawerProps }) => mergeResolvedStyles(styles(info))
|
||||
: mergeResolvedStyles(styles);
|
||||
|
||||
return <Drawer destroyOnHidden={destroyOnHidden} className={className} rootClassName={rootClassName} {...props} styles={mergedStyles as DrawerProps["styles"]} />;
|
||||
}
|
||||
1
web/src/components/ui/product/app-drawer/index.ts
Normal file
1
web/src/components/ui/product/app-drawer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AppDrawer, type AppDrawerProps } from "./app-drawer";
|
||||
33
web/src/components/ui/product/app-modal/app-modal.tsx
Normal file
33
web/src/components/ui/product/app-modal/app-modal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Modal, type ModalProps } from "antd";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
// 用途:产品弹窗壳。消费 token 由内容自己负责。接管 AntD Modal 的重复 flush 内边距。
|
||||
const FLUSH_STYLES = {
|
||||
container: { padding: 0, overflow: "hidden" },
|
||||
body: { padding: 0 },
|
||||
} as const;
|
||||
|
||||
export type AppModalProps = ModalProps & {
|
||||
/** 内容自带外壳时去掉 AntD 默认内边距,避免每个业务弹窗复制同一套 styles。 */
|
||||
flush?: boolean;
|
||||
};
|
||||
|
||||
export function AppModal({ flush = false, destroyOnHidden = true, styles, ...props }: AppModalProps) {
|
||||
const mergeResolvedStyles = (resolved: unknown) => {
|
||||
const base = resolved && typeof resolved === "object" ? (resolved as Record<string, unknown>) : {};
|
||||
if (!flush) return base;
|
||||
const container = base.container && typeof base.container === "object" ? (base.container as CSSProperties) : {};
|
||||
const body = base.body && typeof base.body === "object" ? (base.body as CSSProperties) : {};
|
||||
return {
|
||||
...base,
|
||||
container: { ...container, ...FLUSH_STYLES.container },
|
||||
body: { ...body, ...FLUSH_STYLES.body },
|
||||
};
|
||||
};
|
||||
const mergedStyles =
|
||||
typeof styles === "function"
|
||||
? (info: { props: ModalProps }) => mergeResolvedStyles(styles(info))
|
||||
: mergeResolvedStyles(styles);
|
||||
|
||||
return <Modal destroyOnHidden={destroyOnHidden} {...props} styles={mergedStyles as ModalProps["styles"]} />;
|
||||
}
|
||||
1
web/src/components/ui/product/app-modal/index.ts
Normal file
1
web/src/components/ui/product/app-modal/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AppModal, type AppModalProps } from "./app-modal";
|
||||
@@ -29,6 +29,15 @@ export function normalizeAssetCategory(value: unknown, fallback: AssetCategory =
|
||||
return LEGACY_ASSET_CATEGORY_MAP[normalized] || fallback;
|
||||
}
|
||||
|
||||
export function parseAssetCategory(value: unknown): AssetCategory {
|
||||
if (typeof value !== "string") throw new Error("素材 category 必须是字符串");
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if ((ASSET_CATEGORIES as readonly string[]).includes(normalized)) return normalized as AssetCategory;
|
||||
const mapped = LEGACY_ASSET_CATEGORY_MAP[normalized];
|
||||
if (!mapped) throw new Error("素材 category 无效");
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export function defaultAssetCategoryForKind(kind: string): AssetCategory {
|
||||
if (kind === "entity") return "character";
|
||||
if (kind === "image" || kind === "video" || kind === "audio" || kind === "model") return "material";
|
||||
|
||||
214
web/src/lib/asset-record.ts
Normal file
214
web/src/lib/asset-record.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import type { Asset, AssetKind, AssetStatus, AudioAsset, EntityAsset, ImageAsset, ModelAsset, TextAsset, VideoAsset } from "@/stores/use-asset-store";
|
||||
import { parseAssetCategory } from "@/lib/asset-category";
|
||||
|
||||
const ASSET_KINDS: readonly AssetKind[] = ["text", "image", "video", "audio", "model", "entity"];
|
||||
const ASSET_STATUSES: readonly AssetStatus[] = ["draft", "review", "confirmed", "archived"];
|
||||
|
||||
/**
|
||||
* 素材持久化合同的运行时边界。它只接受能被后续业务安全消费的完整记录,绝不通过空 URL、通配 MIME 或零尺寸伪造默认值。
|
||||
* 展示层可以隔离历史坏记录,但新增、更新和写回前必须经过这里的严格校验。
|
||||
*/
|
||||
|
||||
export function parseAssetRecord(value: unknown): Asset {
|
||||
if (!isRecord(value)) throw new Error("素材记录不是对象");
|
||||
const id = requireTrimmedString(value, "id");
|
||||
const kind = requireTrimmedString(value, "kind");
|
||||
if (!ASSET_KINDS.includes(kind as AssetKind)) throw new Error(`不支持的素材类型 ${kind}`);
|
||||
const title = requireString(value, "title");
|
||||
const coverUrl = requireString(value, "coverUrl");
|
||||
const tags = requireStringArray(value.tags, "tags");
|
||||
const createdAt = requireString(value, "createdAt");
|
||||
const updatedAt = requireString(value, "updatedAt");
|
||||
const folderId = optionalTrimmedString(value, "folderId");
|
||||
const status = optionalAssetStatus(value.status);
|
||||
const primaryVersionId = optionalTrimmedString(value, "primaryVersionId");
|
||||
const source = optionalString(value, "source");
|
||||
const note = optionalString(value, "note");
|
||||
const metadata = optionalRecord(value.metadata, "metadata");
|
||||
const category = value.category === undefined ? undefined : parseAssetCategory(value.category);
|
||||
const data = requireRecord(value.data, "data");
|
||||
const base = {
|
||||
id,
|
||||
title,
|
||||
coverUrl,
|
||||
tags,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
...(folderId ? { folderId } : {}),
|
||||
...(category ? { category } : {}),
|
||||
...(status ? { status } : {}),
|
||||
...(primaryVersionId ? { primaryVersionId } : {}),
|
||||
...(source !== undefined ? { source } : {}),
|
||||
...(note !== undefined ? { note } : {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
|
||||
if (kind === "text") {
|
||||
return { ...base, kind, data: { content: requireString(data, "content") } } satisfies TextAsset;
|
||||
}
|
||||
if (kind === "image") {
|
||||
const parsed = {
|
||||
dataUrl: requireString(data, "dataUrl"),
|
||||
storageKey: optionalString(data, "storageKey"),
|
||||
width: requirePositiveNumber(data, "width"),
|
||||
height: requirePositiveNumber(data, "height"),
|
||||
bytes: requireNonNegativeNumber(data, "bytes"),
|
||||
mimeType: requireMediaMimeType(data, "image"),
|
||||
};
|
||||
requireMediaLocator(parsed.dataUrl, parsed.storageKey, "图片", "dataUrl");
|
||||
return { ...base, kind, data: parsed } satisfies ImageAsset;
|
||||
}
|
||||
if (kind === "video") {
|
||||
const parsed = {
|
||||
url: requireString(data, "url"),
|
||||
storageKey: optionalString(data, "storageKey"),
|
||||
width: requirePositiveNumber(data, "width"),
|
||||
height: requirePositiveNumber(data, "height"),
|
||||
durationMs: optionalNonNegativeNumber(data, "durationMs"),
|
||||
hasAudio: optionalBoolean(data, "hasAudio"),
|
||||
bytes: requireNonNegativeNumber(data, "bytes"),
|
||||
mimeType: requireMediaMimeType(data, "video"),
|
||||
};
|
||||
requireMediaLocator(parsed.url, parsed.storageKey, "视频", "url");
|
||||
return { ...base, kind, data: parsed } satisfies VideoAsset;
|
||||
}
|
||||
if (kind === "audio") {
|
||||
const parsed = {
|
||||
url: requireString(data, "url"),
|
||||
storageKey: optionalString(data, "storageKey"),
|
||||
durationMs: optionalNonNegativeNumber(data, "durationMs"),
|
||||
bytes: requireNonNegativeNumber(data, "bytes"),
|
||||
mimeType: requireMediaMimeType(data, "audio"),
|
||||
};
|
||||
requireMediaLocator(parsed.url, parsed.storageKey, "音频", "url");
|
||||
return { ...base, kind, data: parsed } satisfies AudioAsset;
|
||||
}
|
||||
if (kind === "model") {
|
||||
const parsed = {
|
||||
url: requireString(data, "url"),
|
||||
storageKey: optionalString(data, "storageKey"),
|
||||
bytes: requireNonNegativeNumber(data, "bytes"),
|
||||
mimeType: requireConcreteMimeType(data),
|
||||
fileName: requireTrimmedString(data, "fileName"),
|
||||
};
|
||||
requireMediaLocator(parsed.url, parsed.storageKey, "模型", "url");
|
||||
return { ...base, kind, data: parsed } satisfies ModelAsset;
|
||||
}
|
||||
if (kind === "entity") {
|
||||
return { ...base, kind, data: { definition: requireRecord(data.definition, "definition") } } satisfies EntityAsset;
|
||||
}
|
||||
throw new Error(`不支持的素材类型 ${kind}`);
|
||||
}
|
||||
|
||||
export function parseAssetRecordList(values: unknown): Asset[] {
|
||||
if (!Array.isArray(values)) throw new Error("素材列表无效");
|
||||
return values.map((item, index) => {
|
||||
try {
|
||||
return parseAssetRecord(item);
|
||||
} catch (error) {
|
||||
const id = isRecord(item) && typeof item.id === "string" && item.id.trim() ? item.id : `#${index}`;
|
||||
throw new Error(`素材 ${id} 合同无效:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function requireMediaLocator(primary: string, storageKey: string | undefined, label: string, primaryKey: string) {
|
||||
if (!primary.trim() && !storageKey?.trim()) {
|
||||
throw new Error(`${label}素材缺少 ${primaryKey} 或 storageKey`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireString(record: Record<string, unknown>, key: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string") throw new Error(`素材字段 ${key} 必须是字符串`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireTrimmedString(record: Record<string, unknown>, key: string): string {
|
||||
const value = requireString(record, key).trim();
|
||||
if (!value) throw new Error(`素材字段 ${key} 不能为空`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(record: Record<string, unknown>, key: string): string | undefined {
|
||||
if (!Object.prototype.hasOwnProperty.call(record, key) || record[key] === undefined) return undefined;
|
||||
if (typeof record[key] !== "string") throw new Error(`素材字段 ${key} 必须是字符串`);
|
||||
return record[key] as string;
|
||||
}
|
||||
|
||||
function optionalTrimmedString(record: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = optionalString(record, key)?.trim();
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
function requireStringArray(value: unknown, key: string): string[] {
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
||||
throw new Error(`素材字段 ${key} 必须是字符串数组`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, key: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new Error(`素材字段 ${key} 必须是对象`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown, key: string): Record<string, unknown> | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return requireRecord(value, key);
|
||||
}
|
||||
|
||||
function requireNonNegativeNumber(record: Record<string, unknown>, key: string): number {
|
||||
const value = record[key];
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`素材字段 ${key} 必须是非负数字`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePositiveNumber(record: Record<string, unknown>, key: string): number {
|
||||
const value = requireNonNegativeNumber(record, key);
|
||||
if (value === 0) throw new Error(`素材字段 ${key} 必须大于 0`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireConcreteMimeType(record: Record<string, unknown>): string {
|
||||
const mimeType = requireTrimmedString(record, "mimeType").toLowerCase();
|
||||
if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mimeType) || mimeType.endsWith("/*")) {
|
||||
throw new Error("素材字段 mimeType 必须是具体 MIME 类型");
|
||||
}
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
function requireMediaMimeType(record: Record<string, unknown>, kind: "image" | "video" | "audio"): string {
|
||||
const mimeType = requireConcreteMimeType(record);
|
||||
// application/octet-stream 是诚实的“类型未知”;除此之外必须与素材种类一致,
|
||||
// 防止图片记录携带 audio/* 等跨类型元数据后在下载、转码阶段才暴露错误。
|
||||
if (mimeType !== "application/octet-stream" && !mimeType.startsWith(`${kind}/`)) {
|
||||
throw new Error(`素材字段 mimeType 与 ${kind} 类型不匹配`);
|
||||
}
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
function optionalNonNegativeNumber(record: Record<string, unknown>, key: string): number | undefined {
|
||||
if (!Object.prototype.hasOwnProperty.call(record, key) || record[key] === undefined) return undefined;
|
||||
return requireNonNegativeNumber(record, key);
|
||||
}
|
||||
|
||||
function optionalBoolean(record: Record<string, unknown>, key: string): boolean | undefined {
|
||||
if (!Object.prototype.hasOwnProperty.call(record, key) || record[key] === undefined) return undefined;
|
||||
if (typeof record[key] !== "boolean") throw new Error(`素材字段 ${key} 必须是布尔值`);
|
||||
return record[key] as boolean;
|
||||
}
|
||||
|
||||
function optionalAssetStatus(value: unknown): AssetStatus | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string" || !ASSET_STATUSES.includes(value as AssetStatus)) {
|
||||
throw new Error("素材 status 无效");
|
||||
}
|
||||
return value as AssetStatus;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// @ts-expect-error -- Node 原生 TypeScript 测试运行器需要保留扩展名。
|
||||
import { normalizeAssetRecord } from "./asset-storage-revision.ts";
|
||||
import { parseAssetRecord } from "./asset-record.ts";
|
||||
|
||||
test("缺失图片尺寸的历史素材会补齐可渲染数据", () => {
|
||||
const asset = normalizeAssetRecord({
|
||||
test("缺失图片数据的历史素材会被拒绝,而不是补齐可渲染字段", () => {
|
||||
assert.throws(() => parseAssetRecord({
|
||||
id: "legacy-image",
|
||||
kind: "image",
|
||||
title: "历史图片",
|
||||
@@ -14,10 +14,5 @@ test("缺失图片尺寸的历史素材会补齐可渲染数据", () => {
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
data: undefined,
|
||||
} as never);
|
||||
|
||||
assert.equal(asset.kind, "image");
|
||||
assert.equal(asset.data.width, 0);
|
||||
assert.equal(asset.data.height, 0);
|
||||
assert.equal(asset.data.dataUrl, "https://example.com/image.png");
|
||||
}), /data/);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import { defaultAssetCategoryForKind, normalizeAssetCategory } from "@/lib/asset-category";
|
||||
import { parseAssetRecord, parseAssetRecordList } from "@/lib/asset-record";
|
||||
|
||||
export type AssetStorageDocument = {
|
||||
state: { assets: Asset[] };
|
||||
@@ -8,51 +8,10 @@ export type AssetStorageDocument = {
|
||||
tombstones: { assets: Record<string, number> };
|
||||
};
|
||||
|
||||
export function normalizeAssetRecord(asset: Asset): Asset {
|
||||
const category = normalizeAssetCategory(asset.category, defaultAssetCategoryForKind(asset.kind));
|
||||
const folderId = typeof asset.folderId === "string" ? asset.folderId.trim() : "";
|
||||
const normalizedData = normalizeAssetData(asset);
|
||||
if (Array.isArray(asset.tags) && asset.tags.every((tag) => typeof tag === "string") && asset.category === category && (asset.folderId || "") === folderId && normalizedData === asset.data) return asset;
|
||||
return {
|
||||
...asset,
|
||||
tags: Array.isArray(asset.tags) ? asset.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
||||
folderId: folderId || undefined,
|
||||
category,
|
||||
data: normalizedData,
|
||||
} as Asset;
|
||||
}
|
||||
|
||||
function normalizeAssetData(asset: Asset): Asset["data"] {
|
||||
const raw = asset.data && typeof asset.data === "object" ? asset.data as Record<string, unknown> : {};
|
||||
const numberValue = (value: unknown) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
if (asset.kind === "text") return typeof raw.content === "string" ? asset.data : { content: "" };
|
||||
if (asset.kind === "image") {
|
||||
if (typeof raw.dataUrl === "string" && typeof raw.width === "number" && typeof raw.height === "number") return asset.data;
|
||||
return { dataUrl: typeof raw.dataUrl === "string" ? raw.dataUrl : asset.coverUrl || "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, width: numberValue(raw.width), height: numberValue(raw.height), bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "image/*" };
|
||||
}
|
||||
if (asset.kind === "video") {
|
||||
if (typeof raw.url === "string" && typeof raw.width === "number" && typeof raw.height === "number") return asset.data;
|
||||
return { url: typeof raw.url === "string" ? raw.url : asset.coverUrl || "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, width: numberValue(raw.width), height: numberValue(raw.height), durationMs: numberValue(raw.durationMs), hasAudio: raw.hasAudio === true, bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "video/*" };
|
||||
}
|
||||
if (asset.kind === "audio") {
|
||||
if (typeof raw.url === "string") return asset.data;
|
||||
return { url: "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, durationMs: numberValue(raw.durationMs), bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "audio/*" };
|
||||
}
|
||||
if (asset.kind === "model") {
|
||||
if (typeof raw.url === "string" && typeof raw.fileName === "string") return asset.data;
|
||||
return { url: "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "model/*", fileName: typeof raw.fileName === "string" ? raw.fileName : asset.title || "未命名模型" };
|
||||
}
|
||||
return asset.data;
|
||||
}
|
||||
|
||||
function normalizeAssets(assets: Asset[]) {
|
||||
return assets.map(normalizeAssetRecord);
|
||||
}
|
||||
|
||||
export function parseAssetStorageDocument(value: string | null, fallback: Asset[] = []): AssetStorageDocument {
|
||||
if (!value) {
|
||||
return {
|
||||
state: { assets: normalizeAssets(fallback) },
|
||||
state: { assets: parseAssetRecordList(fallback) },
|
||||
version: 0,
|
||||
storageRevision: 0,
|
||||
tombstones: { assets: {} },
|
||||
@@ -69,13 +28,77 @@ export function parseAssetStorageDocument(value: string | null, fallback: Asset[
|
||||
const tombstones =
|
||||
rawTombstones && typeof rawTombstones === "object" && !Array.isArray(rawTombstones) ? Object.fromEntries(Object.entries(rawTombstones).filter((entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1]))) : {};
|
||||
return {
|
||||
state: { assets: normalizeAssets(parsed.state.assets as Asset[]) },
|
||||
state: { assets: parseAssetRecordList(parsed.state.assets) },
|
||||
version: typeof parsed.version === "number" ? parsed.version : 0,
|
||||
storageRevision: typeof parsed.storageRevision === "number" && Number.isFinite(parsed.storageRevision) ? parsed.storageRevision : 0,
|
||||
tombstones: { assets: tombstones },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export type InvalidAssetRecord = {
|
||||
index: number;
|
||||
id?: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type AssetStorageRecovery = {
|
||||
document: AssetStorageDocument;
|
||||
invalid: InvalidAssetRecord[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 仅用于读取历史本地缓存:保留仍符合合同的素材,并把坏记录隔离到诊断结果。
|
||||
*
|
||||
* 这里不能把坏记录改成空 URL、通配 MIME 或其他“看起来可用”的默认值;
|
||||
* 读路径允许恢复可用部分,但写路径仍由 parseAssetRecord 严格阻断损坏状态。
|
||||
*/
|
||||
export function parseAssetStorageDocumentRecovering(value: string | null, fallback: Asset[] = []): AssetStorageRecovery {
|
||||
if (!value) {
|
||||
return {
|
||||
document: parseAssetStorageDocument(value, fallback),
|
||||
invalid: [],
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(value) as {
|
||||
state?: { assets?: unknown };
|
||||
version?: unknown;
|
||||
storageRevision?: unknown;
|
||||
tombstones?: { assets?: unknown };
|
||||
};
|
||||
if (!Array.isArray(parsed.state?.assets)) throw new Error("素材持久状态无效");
|
||||
|
||||
const invalid: InvalidAssetRecord[] = [];
|
||||
const assets: Asset[] = [];
|
||||
parsed.state.assets.forEach((item, index) => {
|
||||
try {
|
||||
assets.push(parseAssetRecord(item));
|
||||
} catch (error) {
|
||||
const id = isRecord(item) && typeof item.id === "string" && item.id.trim() ? item.id : undefined;
|
||||
invalid.push({
|
||||
index,
|
||||
...(id ? { id } : {}),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const rawTombstones = parsed.tombstones?.assets;
|
||||
const tombstones =
|
||||
rawTombstones && typeof rawTombstones === "object" && !Array.isArray(rawTombstones) ? Object.fromEntries(Object.entries(rawTombstones).filter((entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1]))) : {};
|
||||
|
||||
return {
|
||||
document: {
|
||||
state: { assets },
|
||||
version: typeof parsed.version === "number" ? parsed.version : 0,
|
||||
storageRevision: typeof parsed.storageRevision === "number" && Number.isFinite(parsed.storageRevision) ? parsed.storageRevision : 0,
|
||||
tombstones: { assets: tombstones },
|
||||
},
|
||||
invalid,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeAssetStorageDocument(document: AssetStorageDocument) {
|
||||
return JSON.stringify(document);
|
||||
}
|
||||
@@ -124,6 +147,11 @@ function mergeRecord(base: Record<string, unknown>, local: Record<string, unknow
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将本地内存快照合并到最新 durable 文档:base 是读取时看到的版本,local 是当前用户修改,durable 是另一写入者已落盘的版本。
|
||||
* 删除通过 tombstone 记录 revision,避免旧标签页把已删除素材重新写回来;字段冲突按三方变更而不是最后写入时间粗暴覆盖。
|
||||
*/
|
||||
|
||||
export function rebaseAssetSnapshot(input: { document: AssetStorageDocument; baseAssets: Asset[]; localAssets: Asset[]; baseRevision: number }) {
|
||||
const nextRevision = input.document.storageRevision + 1;
|
||||
const tombstones = { assets: { ...input.document.tombstones.assets } };
|
||||
|
||||
32
web/src/lib/canvas/canvas-agent-protocol.ts
Normal file
32
web/src/lib/canvas/canvas-agent-protocol.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { SKILL_RUNTIME_AGENT_GUIDANCE, skillRuntime } from "@/services/skill-runtime";
|
||||
import type { ResponseToolCall } from "@/services/api/image";
|
||||
|
||||
export const CANVAS_ONLINE_AGENT_PROMPT =
|
||||
`你是当前创作工作台内置的在线画布助手。首轮必须先调用 canvas_get_context;涉及已有节点时用 canvas_find_nodes 获取真实 id,涉及媒体参考时用 canvas_get_resources。流水线、工作流、管线、节点图或用户要求连线时,必须使用 canvas_create_workflow:把需求拆成有语义的节点类型、真实内容/提示词、边和布局,禁止把业务阶段退化成几个空文本卡片;工具会自动分配 id、布局并建立连线。复杂写操作先 canvas_validate_ops,再执行 canvas_apply_ops。任何写入后都必须检查工具返回的真实节点类型、connectionCount、overlapWarnings 和 verification;没有真实连线时绝不能说已连线,没有生成资源时绝不能说已完成。不要输出 JSON ops、不要猜 id、不要把未就绪资源当作可用素材、不要编造执行结果。需要用户选择时,给出可点击的短选项,不要只让用户输入 1、2、3。${SKILL_RUNTIME_AGENT_GUIDANCE}`;
|
||||
|
||||
export const CANVAS_READ_TOOL_NAMES = [
|
||||
"canvas_get_state",
|
||||
"canvas_get_context",
|
||||
"canvas_find_nodes",
|
||||
"canvas_get_node",
|
||||
"canvas_get_connection",
|
||||
"canvas_get_generation_tasks",
|
||||
"canvas_get_resources",
|
||||
"canvas_validate_ops",
|
||||
"canvas_get_selection",
|
||||
"canvas_export_snapshot",
|
||||
] as const;
|
||||
|
||||
const CANVAS_READ_TOOL_NAME_SET = new Set<string>(CANVAS_READ_TOOL_NAMES);
|
||||
|
||||
export function isCanvasReadTool(name: string) {
|
||||
return CANVAS_READ_TOOL_NAME_SET.has(name) || skillRuntime.agentToolNames("onlineAgent").has(name);
|
||||
}
|
||||
|
||||
export function isWritableToolCall(call: ResponseToolCall) {
|
||||
return !isCanvasReadTool(call.function.name);
|
||||
}
|
||||
|
||||
export function buildCanvasOnlineAgentSystemContent(skillCatalog: string) {
|
||||
return [CANVAS_ONLINE_AGENT_PROMPT, skillCatalog ? `当前可按需加载的技能(仅元数据):\n${skillCatalog}` : ""].filter(Boolean).join("\n\n");
|
||||
}
|
||||
195
web/src/lib/canvas/canvas-agent-tools.ts
Normal file
195
web/src/lib/canvas/canvas-agent-tools.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { type ResponseFunctionTool } from "@/services/api/image";
|
||||
import { skillRuntime } from "@/services/skill-runtime";
|
||||
|
||||
const JSON_RECORD_SCHEMA = { type: "object", additionalProperties: true };
|
||||
const POSITION_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"], additionalProperties: false };
|
||||
const VIEWPORT_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" }, k: { type: "number" } }, required: ["x", "y", "k"], additionalProperties: false };
|
||||
const NODE_TYPE_SCHEMA = { type: "string", enum: ["image", "text", "skill", "video", "audio"] };
|
||||
const WORKFLOW_NODE_KIND_SCHEMA = { type: "string", enum: ["text", "script", "image", "video", "audio", "character_cards", "character_three_view", "storyboard_video"] };
|
||||
const GENERATION_MODE_SCHEMA = { type: "string", enum: ["text", "image", "video", "audio"] };
|
||||
const GENERATION_OPTION_PROPERTIES = {
|
||||
model: { type: "string" },
|
||||
size: { type: "string" },
|
||||
quality: { type: "string" },
|
||||
transparentBackground: { type: "string", enum: ["true", "false"] },
|
||||
count: { type: "number" },
|
||||
seconds: { type: "string" },
|
||||
vquality: { type: "string" },
|
||||
generateAudio: { type: "string" },
|
||||
watermark: { type: "string" },
|
||||
audioVoice: { type: "string" },
|
||||
audioFormat: { type: "string" },
|
||||
audioSpeed: { type: "string" },
|
||||
audioInstructions: { type: "string" },
|
||||
};
|
||||
const CANVAS_OP_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", enum: ["add_node", "update_node", "delete_node", "delete_connections", "connect_nodes", "set_viewport", "select_nodes", "run_generation"] },
|
||||
id: { type: "string" },
|
||||
ids: { type: "array", items: { type: "string" } },
|
||||
nodeType: NODE_TYPE_SCHEMA,
|
||||
title: { type: "string" },
|
||||
x: { type: "number" },
|
||||
y: { type: "number" },
|
||||
width: { type: "number" },
|
||||
height: { type: "number" },
|
||||
position: POSITION_SCHEMA,
|
||||
metadata: JSON_RECORD_SCHEMA,
|
||||
patch: JSON_RECORD_SCHEMA,
|
||||
all: { type: "boolean" },
|
||||
fromNodeId: { type: "string" },
|
||||
toNodeId: { type: "string" },
|
||||
viewport: VIEWPORT_SCHEMA,
|
||||
nodeId: { type: "string" },
|
||||
mode: GENERATION_MODE_SCHEMA,
|
||||
prompt: { type: "string" },
|
||||
retry: { type: "boolean" },
|
||||
},
|
||||
required: ["type"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
function toolDefinition(name: string, description: string, properties: Record<string, unknown>, required: string[] = [], strict = false): ResponseFunctionTool {
|
||||
return { type: "function", function: { name, description, parameters: { type: "object", properties, required, additionalProperties: false }, strict } };
|
||||
}
|
||||
|
||||
function generationToolDefinition(name: string, description: string, mode?: "text" | "image" | "video" | "audio") {
|
||||
return toolDefinition(
|
||||
name,
|
||||
description,
|
||||
{
|
||||
prompt: { type: "string" },
|
||||
title: { type: "string" },
|
||||
x: { type: "number" },
|
||||
y: { type: "number" },
|
||||
referenceNodeIds: { type: "array", items: { type: "string" } },
|
||||
...(mode ? {} : { mode: GENERATION_MODE_SCHEMA }),
|
||||
autoRun: { type: "boolean" },
|
||||
...GENERATION_OPTION_PROPERTIES,
|
||||
},
|
||||
["prompt"],
|
||||
);
|
||||
}
|
||||
|
||||
export const CANVAS_ONLINE_AGENT_TOOLS: ResponseFunctionTool[] = [
|
||||
...skillRuntime.agentTools("onlineAgent"),
|
||||
toolDefinition("canvas_get_state", "读取当前网页画布的节点、连线、选区和视口。", {}),
|
||||
toolDefinition("canvas_get_context", "读取语义化画布上下文、真实节点 id、连接关系、资源就绪状态和状态哈希。", {}),
|
||||
toolDefinition("canvas_find_nodes", "按标题、内容、提示词、类型、状态或资产检索真实节点。", { query: { type: "string" }, ids: { type: "array", items: { type: "string" } }, types: { type: "array", items: { type: "string" } }, statuses: { type: "array", items: { type: "string" } }, resourceOnly: { type: "boolean" }, limit: { type: "number" } }),
|
||||
toolDefinition("canvas_get_node", "按真实节点 id 精确读取单个节点、资源状态和关联连线。", { id: { type: "string" } }, ["id"]),
|
||||
toolDefinition("canvas_get_connection", "按真实连线 id 精确读取端点节点和 handle 信息。", { id: { type: "string" } }, ["id"]),
|
||||
toolDefinition("canvas_get_generation_tasks", "读取当前画布绑定的生成任务观察状态,不主动轮询上游。", { status: { type: "string" }, nodeIds: { type: "array", items: { type: "string" } }, limit: { type: "number" } }),
|
||||
toolDefinition("canvas_get_resources", "读取画布媒体资源引用、类型、尺寸、大小、时长和就绪状态,不返回媒体 URL。", { nodeIds: { type: "array", items: { type: "string" } }, status: { type: "string" }, limit: { type: "number" } }),
|
||||
toolDefinition("canvas_validate_ops", "在写入前校验节点 id、连接关系和批量操作参数。", { ops: { type: "array", items: CANVAS_OP_SCHEMA } }, ["ops"]),
|
||||
toolDefinition("canvas_get_selection", "读取当前网页画布选中的节点。", {}),
|
||||
toolDefinition("canvas_export_snapshot", "导出当前画布快照,用于理解布局。", {}),
|
||||
toolDefinition(
|
||||
"canvas_apply_ops",
|
||||
"批量操作当前网页画布。复杂写操作应先 canvas_validate_ops;可传 canvas_get_context 返回的 expectedStateHash 防止基于过期状态写入。",
|
||||
{ ops: { type: "array", items: CANVAS_OP_SCHEMA }, expectedRevision: { type: "number" }, expectedStateHash: { type: "string" } },
|
||||
["ops"],
|
||||
false,
|
||||
),
|
||||
toolDefinition(
|
||||
"canvas_create_workflow",
|
||||
"创建语义化工作流/流水线:节点使用真实的文本、脚本、图片、视频或音频类型;character_cards=角色拆分图片卡片,character_three_view=角色三视图,storyboard_video=分镜剧情视频。工具会自动生成唯一 id、按节点实际尺寸布局、创建 edges/referenceRefs/referenceNodeIds 连线、选择新节点并复核重叠。媒体节点必须提供有意义的 prompt 或 content;已有素材先 canvas_find_nodes/canvas_get_resources,再把真实 node id 放入 referenceNodeIds。不要用 canvas_create_text_nodes 代替工作流。",
|
||||
{
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
nodes: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ref: { type: "string" },
|
||||
kind: WORKFLOW_NODE_KIND_SCHEMA,
|
||||
title: { type: "string" },
|
||||
content: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
description: { type: "string" },
|
||||
referenceRefs: { type: "array", items: { type: "string" } },
|
||||
referenceNodeIds: { type: "array", items: { type: "string" } },
|
||||
runGeneration: { type: "boolean" },
|
||||
width: { type: "number" },
|
||||
height: { type: "number" },
|
||||
},
|
||||
required: ["ref", "kind", "title"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
edges: { type: "array", items: { type: "object", properties: { from: { type: "string" }, to: { type: "string" } }, required: ["from", "to"], additionalProperties: false } },
|
||||
direction: { type: "string", enum: ["horizontal", "vertical"] },
|
||||
start: POSITION_SCHEMA,
|
||||
gap: { type: "number" },
|
||||
autoRun: { type: "boolean" },
|
||||
},
|
||||
["nodes"],
|
||||
),
|
||||
toolDefinition(
|
||||
"canvas_create_node",
|
||||
"创建任意类型节点:text、image、video、audio。适合创建文本、媒体占位或自定义 metadata 节点。",
|
||||
{ nodeType: NODE_TYPE_SCHEMA, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, width: { type: "number" }, height: { type: "number" }, metadata: JSON_RECORD_SCHEMA },
|
||||
["nodeType"],
|
||||
),
|
||||
toolDefinition("canvas_create_text_node", "在当前画布创建单个文本节点。", { text: { type: "string" }, x: { type: "number" }, y: { type: "number" }, title: { type: "string" }, width: { type: "number" }, height: { type: "number" } }),
|
||||
toolDefinition(
|
||||
"canvas_create_text_nodes",
|
||||
"批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。",
|
||||
{
|
||||
items: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { text: { type: "string" }, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, width: { type: "number" }, height: { type: "number" } },
|
||||
required: ["text"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
x: { type: "number" },
|
||||
y: { type: "number" },
|
||||
gap: { type: "number" },
|
||||
direction: { type: "string", enum: ["row", "column"] },
|
||||
},
|
||||
["items"],
|
||||
),
|
||||
toolDefinition("canvas_create_cinematic_session", "把自然语言创作指令提交给后端影视 Agent 会话,后端拆解为剧本、场景、分镜、镜头和成片节点,并返回可写回画布的操作。", { prompt: { type: "string" } }, ["prompt"]),
|
||||
toolDefinition(
|
||||
"canvas_create_image_prompt_flow",
|
||||
"创建提示词文本节点和图片目标节点并自动连线,可选择立即触发生图。",
|
||||
{ prompt: { type: "string" }, x: { type: "number" }, y: { type: "number" }, autoRun: { type: "boolean" }, ...GENERATION_OPTION_PROPERTIES },
|
||||
["prompt"],
|
||||
),
|
||||
generationToolDefinition("canvas_create_generation_flow", "创建通用生成流程:提示词文本节点、对应类型的生成目标节点和参考节点连线,可用于文案、生图、视频或音频。"),
|
||||
generationToolDefinition("canvas_generate_text", "创建通用文本生成流程并立即触发生成。", "text"),
|
||||
generationToolDefinition("canvas_generate_image", "创建通用图片生成流程并立即触发生成。", "image"),
|
||||
generationToolDefinition("canvas_generate_video", "创建通用视频生成流程并立即触发生成。", "video"),
|
||||
generationToolDefinition("canvas_generate_audio", "创建通用音频生成流程并立即触发生成。", "audio"),
|
||||
toolDefinition("canvas_update_node", "更新节点基础字段或 metadata。", { id: { type: "string" }, patch: JSON_RECORD_SCHEMA, metadata: JSON_RECORD_SCHEMA }, ["id"]),
|
||||
toolDefinition("canvas_update_node_text", "更新文本节点内容和标题。", { id: { type: "string" }, text: { type: "string" }, title: { type: "string" } }, ["id", "text"]),
|
||||
toolDefinition(
|
||||
"canvas_move_nodes",
|
||||
"移动一个或多个节点,支持绝对坐标或 dx/dy 偏移。",
|
||||
{
|
||||
items: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
items: { type: "object", properties: { id: { type: "string" }, x: { type: "number" }, y: { type: "number" }, dx: { type: "number" }, dy: { type: "number" } }, required: ["id"], additionalProperties: false },
|
||||
},
|
||||
},
|
||||
["items"],
|
||||
),
|
||||
toolDefinition("canvas_resize_node", "调整节点尺寸。", { id: { type: "string" }, width: { type: "number" }, height: { type: "number" }, freeResize: { type: "boolean" } }, ["id", "width", "height"]),
|
||||
toolDefinition("canvas_delete_nodes", "删除指定节点及相关连线。", { ids: { type: "array", items: { type: "string" }, minItems: 1 } }, ["ids"]),
|
||||
toolDefinition(
|
||||
"canvas_connect_nodes",
|
||||
"批量连接节点。",
|
||||
{ connections: { type: "array", minItems: 1, items: { type: "object", properties: { fromNodeId: { type: "string" }, toNodeId: { type: "string" } }, required: ["fromNodeId", "toNodeId"], additionalProperties: false } } },
|
||||
["connections"],
|
||||
),
|
||||
toolDefinition("canvas_select_nodes", "设置当前选中节点。", { ids: { type: "array", items: { type: "string" } } }, ["ids"]),
|
||||
toolDefinition("canvas_set_viewport", "调整画布视口。", { viewport: VIEWPORT_SCHEMA }, ["viewport"]),
|
||||
toolDefinition("canvas_run_generation", "触发指定节点生成;对已有生成任务明确重试时传 retry=true。", { nodeId: { type: "string" }, mode: GENERATION_MODE_SCHEMA, prompt: { type: "string" }, retry: { type: "boolean" } }, ["nodeId"]),
|
||||
];
|
||||
@@ -1,6 +1,7 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { workflowStarterPrompt } from "@/lib/prompts";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import type { CanvasAgentOp, CanvasAgentSnapshot } from "./canvas-agent-ops";
|
||||
@@ -143,9 +144,9 @@ export function buildCanvasWorkflowOps(input: CanvasWorkflowInput, snapshot: Can
|
||||
|
||||
function workflowPrompt(kind: CanvasWorkflowNodeKind, title: string, input: CanvasWorkflowInput) {
|
||||
const workflowTitle = (input.title || input.description || "当前创作项目").trim();
|
||||
if (kind === "character_cards") return `请基于「${workflowTitle}」拆分主要角色,并为每个角色生成可用于后续创作的角色图片卡片:外观、服饰、身份、性格和视觉辨识点。`;
|
||||
if (kind === "character_three_view") return `请基于上游角色卡片生成「${title}」:同一角色的正面、侧面、背面三视图,保持服饰、发型、道具和比例一致。`;
|
||||
if (kind === "storyboard_video") return `请基于上游角色三视图,为「${workflowTitle}」制作分镜剧情视频方案:包含镜头顺序、景别、动作、节奏和画面连续性。`;
|
||||
if (kind === "character_cards" || kind === "character_three_view" || kind === "storyboard_video") {
|
||||
return workflowStarterPrompt(kind, title, workflowTitle);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,15 @@ type CanvasNodeAssetOptions = {
|
||||
};
|
||||
|
||||
export function canvasNodeToAsset(node: CanvasNodeData, options: CanvasNodeAssetOptions): NewAsset | null {
|
||||
const content = node.metadata?.content?.trim();
|
||||
if (!content) return null;
|
||||
const content = node.metadata?.content?.trim() || "";
|
||||
const storageKey = node.metadata?.storageKey?.trim() || undefined;
|
||||
const isMedia = node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio;
|
||||
|
||||
// 文本素材必须直接携带正文;媒体则允许只有持久 storageKey。后者常见于页面重载后:
|
||||
// blob/object URL 已失效,但资源定位符仍可从后端重新换取可访问地址,不能因此丢掉素材记录。
|
||||
if (node.type === CanvasNodeType.Text && !content) return null;
|
||||
if (isMedia && !content && !storageKey) return null;
|
||||
if (!isMedia && node.type !== CanvasNodeType.Text) return null;
|
||||
const title = node.metadata?.prompt?.slice(0, 24) || node.title || canvasAssetFallbackTitle(node.type);
|
||||
const metadata = {
|
||||
source: options.source,
|
||||
@@ -21,7 +28,7 @@ export function canvasNodeToAsset(node: CanvasNodeData, options: CanvasNodeAsset
|
||||
nodeId: node.id,
|
||||
taskId: options.taskId || node.metadata?.taskId,
|
||||
prompt: node.metadata?.prompt,
|
||||
resourceKey: node.metadata?.storageKey,
|
||||
resourceKey: storageKey,
|
||||
};
|
||||
const base = {
|
||||
title,
|
||||
@@ -37,13 +44,13 @@ export function canvasNodeToAsset(node: CanvasNodeData, options: CanvasNodeAsset
|
||||
return { ...base, kind: "text", data: { content } };
|
||||
}
|
||||
if (node.type === CanvasNodeType.Image) {
|
||||
const dataUrl = node.metadata?.storageKey ? "" : content;
|
||||
const dataUrl = storageKey ? "" : content;
|
||||
return {
|
||||
...base,
|
||||
kind: "image",
|
||||
data: {
|
||||
dataUrl,
|
||||
storageKey: node.metadata?.storageKey,
|
||||
storageKey,
|
||||
width: node.metadata?.naturalWidth || node.width,
|
||||
height: node.metadata?.naturalHeight || node.height,
|
||||
bytes: node.metadata?.bytes || getDataUrlByteSize(dataUrl),
|
||||
@@ -57,7 +64,7 @@ export function canvasNodeToAsset(node: CanvasNodeData, options: CanvasNodeAsset
|
||||
kind: "video",
|
||||
data: {
|
||||
url: content,
|
||||
storageKey: node.metadata?.storageKey,
|
||||
storageKey,
|
||||
width: node.metadata?.naturalWidth || node.width,
|
||||
height: node.metadata?.naturalHeight || node.height,
|
||||
durationMs: node.metadata?.durationMs,
|
||||
@@ -73,7 +80,7 @@ export function canvasNodeToAsset(node: CanvasNodeData, options: CanvasNodeAsset
|
||||
kind: "audio",
|
||||
data: {
|
||||
url: content,
|
||||
storageKey: node.metadata?.storageKey,
|
||||
storageKey,
|
||||
durationMs: node.metadata?.durationMs,
|
||||
bytes: node.metadata?.bytes || 0,
|
||||
mimeType: node.metadata?.mimeType || "audio/mpeg",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NODE_DEFAULT_SIZE, getNodeSpec } from "@/constant/canvas";
|
||||
import { PromptTemplateOperation } from "@/lib/prompts";
|
||||
import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
|
||||
import { normalizeStoryboardAssetBindings } from "@/lib/canvas/canvas-storyboard-assets";
|
||||
import { bindingForConnectedNode, storyboardComposerContent, storyboardRowReferenceNodeIds } from "@/lib/canvas/canvas-storyboard-materializer";
|
||||
@@ -93,7 +94,7 @@ export function createStoryboardRow(shotNumber: number, patch: Partial<Storyboar
|
||||
export function storyboardPromptTemplateMetadata(row: StoryboardRow, kind: "image" | "video"): Pick<CanvasNodeMetadata, "promptTemplateOperation" | "promptTemplateVariables"> {
|
||||
const variables = kind === "image" ? row.imagePromptTemplateVariables : row.videoPromptTemplateVariables;
|
||||
return variables
|
||||
? { promptTemplateOperation: kind === "image" ? "storyboard_first_frame" : "storyboard_video", promptTemplateVariables: variables }
|
||||
? { promptTemplateOperation: kind === "image" ? PromptTemplateOperation.StoryboardFirstFrame : PromptTemplateOperation.StoryboardVideo, promptTemplateVariables: variables }
|
||||
: { promptTemplateOperation: undefined, promptTemplateVariables: undefined };
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export function canvasNodeMentionToken(nodeId: string) {
|
||||
|
||||
export function canvasResourceMentionToken(reference: CanvasResourceReference) {
|
||||
if (reference.mentionToken) return reference.mentionToken;
|
||||
if (reference.kind === "skill" && reference.skill?.skill_id) return canvasSkillMentionToken(reference.skill.skill_id);
|
||||
if (reference.kind === "skill" && reference.skill?.skillId) return canvasSkillMentionToken(reference.skill.skillId);
|
||||
if (reference.assetId) return `@[asset:${reference.assetId}]`;
|
||||
return `@${reference.label}`;
|
||||
}
|
||||
@@ -239,7 +239,10 @@ export function getMentionResourceNodes(nodeId: string, nodes: CanvasNodeData[],
|
||||
if (configInputs.length) return configInputs;
|
||||
const ownInputs = getContextResourceNodes(nodeId, nodes, connections);
|
||||
if (ownInputs.length) return ownInputs;
|
||||
return [];
|
||||
// 没有入边时,资源节点可以把自身当作 @图片1 / @视频1,用于图生图、视频再编辑。
|
||||
// 有入边时仍只暴露上游,避免自身把槽位序号挤掉。
|
||||
const self = nodes.find((node) => node.id === nodeId);
|
||||
return self && isResourceNode(self) ? [self] : [];
|
||||
}
|
||||
|
||||
export function getGenerationResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { localForageStorageForScope } from "@/lib/localforage-storage";
|
||||
import type { DirectorMode } from "@/lib/canvas/director/director-modes";
|
||||
|
||||
/**
|
||||
* 导演台首次上手引导(P2)。
|
||||
* 导演台首次上手引导。
|
||||
*
|
||||
* 为什么存在:导演台把模板、四个一级模式、对象操作、保存/导出堆在同一个工作台里,
|
||||
* 第一次进来的人没有任何线索该先做什么。引导给出一条确定的最短路径。
|
||||
@@ -44,7 +44,7 @@ export type DirectorOnboardingStep = {
|
||||
mode?: DirectorMode;
|
||||
};
|
||||
|
||||
/** 顺序与 Issue #305 的首次任务一致:添加演员 → 移动 → 调姿 → 轨迹 → CAM → 应用。 */
|
||||
/** 引导步骤按首次使用的最短操作路径排列:添加演员 → 移动 → 调姿 → 轨迹 → CAM → 应用。 */
|
||||
export const DIRECTOR_ONBOARDING_STEPS: DirectorOnboardingStep[] = [
|
||||
{ id: "actor", title: "添加并选中演员", detail: "在左侧「快速添加」点演员;模板已有演员时,直接在场景树或视口选中它。", mode: "layout" },
|
||||
{ id: "move", title: "把演员移动到表演位置", detail: "保持摆场模式,在底部 dock 选移动工具(W),拖动轴手柄;右栏可输入精确坐标。", mode: "layout" },
|
||||
@@ -207,9 +207,8 @@ export async function advanceDirectorOnboarding(scope: string, progress: Directo
|
||||
}
|
||||
|
||||
/**
|
||||
* 独立的重启 API。不依赖调用方持有当前内存进度 —— 未来工作台设置里的
|
||||
* 「重新开始引导」按钮不必先加载一次旧进度才能重置,直接落盘初始状态即可,
|
||||
* 对已 dismissed/completed 的账号同样生效。
|
||||
* 独立的重启 API。不依赖调用方持有当前内存进度;设置页的「重新开始引导」
|
||||
* 按钮可以直接落盘初始状态,对已 dismissed/completed 的账号同样生效。
|
||||
*/
|
||||
export async function resetDirectorOnboardingProgress(scope: string, storage?: DirectorOnboardingStorage): Promise<DirectorOnboardingProgress> {
|
||||
const normalizedScope = requireScope(scope);
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
import { buildGenerationConfig } from "@/lib/canvas/canvas-project-generation";
|
||||
import { PromptTemplateOperation, promptTemplateTaskPlaceholder } from "@/lib/prompts";
|
||||
import { runBackendGenerationTask } from "@/services/api/generation-task";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
export type SkillDraft = {
|
||||
skill_name: string;
|
||||
skillName: string;
|
||||
description: string;
|
||||
instruction: string;
|
||||
tag?: string;
|
||||
};
|
||||
|
||||
const SKILL_DRAFT_PROMPT = `你是一位技能编写助手。根据用户的想法,为一个「可复用的创作技能」生成一份草稿。
|
||||
只输出一个 JSON 对象,不要输出任何其他文字、解释或 Markdown 代码块。JSON 字段:
|
||||
- "skill_name": 技能名称,简短(不超过 20 个字)
|
||||
- "tag": 分类,从以下值中选一个字符串:drama(短剧影视)、ecommerce(电商营销)、creative(创意设计)、social(社媒内容)、others(其他)
|
||||
- "description": 技能简介(不超过 120 字),说明适用场景、输入条件和最终产出
|
||||
- "instruction": 技能指令(Markdown 格式,至少 300 字),写给后续在画布中使用该技能的 AI 模型阅读,必须包含:角色设定、输入与约束、分步执行流程、检查清单、输出格式
|
||||
|
||||
用户的想法:
|
||||
{{{IDEA}}}`;
|
||||
|
||||
export function buildSkillDraftPrompt(idea: string): string {
|
||||
return SKILL_DRAFT_PROMPT.replace("{{{IDEA}}}", idea.trim());
|
||||
}
|
||||
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_DESCRIPTION_LENGTH = 500;
|
||||
|
||||
@@ -36,7 +23,7 @@ export function parseSkillDraft(text: string): Partial<SkillDraft> {
|
||||
const raw = JSON.parse(trimmed.slice(start, end + 1)) as Record<string, unknown>;
|
||||
if (raw && typeof raw === "object") {
|
||||
return {
|
||||
skill_name: String(raw.skill_name ?? "").trim().slice(0, MAX_NAME_LENGTH),
|
||||
skillName: String(raw.skillName ?? raw.skill_name ?? "").trim().slice(0, MAX_NAME_LENGTH),
|
||||
description: String(raw.description ?? "").trim().slice(0, MAX_DESCRIPTION_LENGTH),
|
||||
instruction: String(raw.instruction ?? raw.instructions ?? "").trim(),
|
||||
tag: typeof raw.tag === "string" && raw.tag.trim() ? raw.tag.trim() : undefined,
|
||||
@@ -49,7 +36,7 @@ export function parseSkillDraft(text: string): Partial<SkillDraft> {
|
||||
// 回退:首行作为名称,正文作为简介与指令,用户随后可自由编辑
|
||||
const firstLine = trimmed.split(/\r?\n/)[0]?.trim().slice(0, 30) || "未命名技能";
|
||||
return {
|
||||
skill_name: firstLine,
|
||||
skillName: firstLine,
|
||||
description: trimmed.slice(0, MAX_DESCRIPTION_LENGTH),
|
||||
instruction: trimmed,
|
||||
};
|
||||
@@ -59,8 +46,13 @@ export async function generateSkillDraft(idea: string, config: AiConfig, signal?
|
||||
const generationConfig = buildGenerationConfig(config, undefined, "text");
|
||||
const result = await runBackendGenerationTask({
|
||||
mode: "text",
|
||||
prompt: buildSkillDraftPrompt(idea),
|
||||
prompt: promptTemplateTaskPlaceholder("技能草稿"),
|
||||
config: generationConfig,
|
||||
metadata: {
|
||||
source: "skill-draft",
|
||||
promptTemplateOperation: PromptTemplateOperation.SkillDraft,
|
||||
promptTemplateVariables: { 用户想法: idea.trim() },
|
||||
},
|
||||
signal,
|
||||
});
|
||||
return parseSkillDraft(result.text || "");
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { appendTaskTextDelta, completeTextReplayTask, createGenerationTask } from "@/services/api/task-center";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
/**
|
||||
* text-replay 前端发布器:为前端直连模型生成的文本,在后端维护一个
|
||||
* text_replay 任务作持久化存档 + 回放。
|
||||
*
|
||||
* - start():创建后端 text_replay 任务(不排队、不计费),拿到 taskId;
|
||||
* - publish(fullText):按阈值节流地把新增片段通过 appendTaskTextDelta 上报
|
||||
* (生成中途刷新页面也能读到已生成部分);
|
||||
* - finish(finalText):把最终正文写入任务并置为 succeeded,queryTaskTextReplay
|
||||
* 即可读到 finalText。
|
||||
*
|
||||
* 全部失败静默降级:文本直连生成是主流程,后端持久化只是增强,失败不阻塞生成。
|
||||
*/
|
||||
export function createTextReplayPublisher(config: AiConfig, prompt: string) {
|
||||
let taskId: string | null = null;
|
||||
let lastLen = 0;
|
||||
let buffered = "";
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let closed = false;
|
||||
|
||||
// 增量累积到该阈值才落一次库,避免逐 token 高频写。
|
||||
const FLUSH_THRESHOLD = 2048;
|
||||
const flush = (force = false) => {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
if (closed || !taskId) return;
|
||||
if (buffered.length >= FLUSH_THRESHOLD || force) {
|
||||
const chunk = buffered;
|
||||
buffered = "";
|
||||
if (chunk.trim()) void appendTaskTextDelta(taskId, chunk).catch(() => {});
|
||||
} else if (buffered) {
|
||||
flushTimer = setTimeout(() => flush(true), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
const task = await createGenerationTask({
|
||||
type: "text",
|
||||
prompt: prompt.trim() || "文本创作",
|
||||
model: config.model,
|
||||
input: {
|
||||
mode: "text",
|
||||
replay: true,
|
||||
prompt: prompt.trim() || "文本创作",
|
||||
config: {
|
||||
model: config.model,
|
||||
baseUrl: config.baseUrl,
|
||||
apiKey: config.apiKey,
|
||||
apiFormat: config.apiFormat,
|
||||
},
|
||||
},
|
||||
});
|
||||
taskId = task?.id || null;
|
||||
} catch {
|
||||
taskId = null; // 降级:无持久化,不影响主流程
|
||||
}
|
||||
};
|
||||
|
||||
const publish = (fullText: string) => {
|
||||
if (!taskId) return;
|
||||
if (fullText.length <= lastLen) return;
|
||||
buffered += fullText.slice(lastLen);
|
||||
lastLen = fullText.length;
|
||||
flush();
|
||||
};
|
||||
|
||||
const finish = (finalText: string) => {
|
||||
closed = true;
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
if (!taskId) return;
|
||||
void appendTaskTextDelta(taskId, buffered).catch(() => {});
|
||||
if (finalText && finalText.trim()) {
|
||||
void completeTextReplayTask(taskId, finalText).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
return { start, publish, finish };
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
// 媒体时长探测(纯前端工具,M3.4)。
|
||||
// 上传视频/音频前用 <video>/<audio> 元数据读取真实时长(毫秒),
|
||||
// 随上传 meta 传给后端存 Resource.DurationMs → 资产 summary 透出 → 时间线片段真实时长。
|
||||
// 解码能力仅用于读取 duration,不需要能播放编码。
|
||||
// 媒体时长探测工具:上传前通过 <video>/<audio> 元数据读取真实时长(毫秒)。
|
||||
// 结果随上传 meta 传给后端,供资源摘要和时间线片段使用;这里不负责验证完整播放能力。
|
||||
|
||||
const PROBE_TIMEOUT_MS = 8000;
|
||||
|
||||
@@ -9,31 +7,37 @@ function isProbeable(file: File): boolean {
|
||||
return /^video\//.test(file.type) || /^audio\//.test(file.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回可确认的媒体时长;非音视频、浏览器不支持解码、元数据错误或超时都返回 undefined。
|
||||
* 这是上传附加信息而不是写入前置条件,调用方必须允许缺少时长,但不能编造默认时长。
|
||||
*/
|
||||
export async function probeMediaDurationMs(file: File): Promise<number | undefined> {
|
||||
if (!isProbeable(file)) return undefined;
|
||||
const url = URL.createObjectURL(file);
|
||||
const el: HTMLVideoElement | HTMLAudioElement = /^video\//.test(file.type)
|
||||
? document.createElement("video")
|
||||
: document.createElement("audio");
|
||||
let timeoutId: number | undefined;
|
||||
el.preload = "metadata";
|
||||
el.muted = true; // video 自动加载元数据在某些浏览器需要非 autoplay;muted 仅避免噪音
|
||||
// 某些浏览器只有在静音时才允许视频元素主动加载元数据;这里只禁止声音,不触发自动播放。
|
||||
el.muted = true;
|
||||
try {
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
window.setTimeout(() => reject(new Error("probe timeout")), PROBE_TIMEOUT_MS);
|
||||
});
|
||||
const meta = new Promise<number>((resolve) => {
|
||||
const durationMs = await new Promise<number>((resolve, reject) => {
|
||||
timeoutId = window.setTimeout(() => reject(new Error("媒体元数据读取超时")), PROBE_TIMEOUT_MS);
|
||||
el.onloadedmetadata = () => {
|
||||
const d = el.duration;
|
||||
resolve(Number.isFinite(d) && d > 0 ? Math.round(d * 1000) : 0);
|
||||
const durationSeconds = el.duration;
|
||||
resolve(Number.isFinite(durationSeconds) && durationSeconds > 0 ? Math.round(durationSeconds * 1000) : 0);
|
||||
};
|
||||
el.onerror = () => resolve(0);
|
||||
el.src = url;
|
||||
});
|
||||
el.src = url;
|
||||
const durationMs = await Promise.race([meta, timeout]);
|
||||
return durationMs > 0 ? durationMs : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
|
||||
el.onloadedmetadata = null;
|
||||
el.onerror = null;
|
||||
el.removeAttribute("src");
|
||||
el.load();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
// AI 编辑助手(editor-shell 预设插件贡献 ai-assistant 插槽,M3.8 注册;入口在编辑器顶部工具按钮浮层)。
|
||||
// M6.3:对话面板 —— 时间线摘要注入 system prompt,LLM 输出受 ai-command-schema 约束;
|
||||
// ≤3 条命令直执行,>3 条先预览确认(ADR-0007:AI 编辑交互=预设插件且命令受约束)。
|
||||
// 命令逐条 dispatch;宿主 fail-closed:无效命令被 registry 拒绝并写入 saveError(顶部提示条),
|
||||
// 面板汇报为中性“已提交”,执行结果以时间线实际变化为准。
|
||||
// 模型显式选择:不再盲信 config.model 默认值(历史默认可能是图像模型导致文本请求失败),
|
||||
// 面板顶部提供文本能力模型下拉,请求时同时覆写 config.model/textModel,避免默认模型错误。
|
||||
// 时间线摘要作为上下文发送给文本模型,模型输出必须通过 ai-command-schema 校验。
|
||||
// 小批命令直接进入统一 dispatch,大批命令先由用户确认;宿主拒绝无效命令并显示 saveError。
|
||||
// 请求显式携带用户选择的文本模型,不能沿用可能指向图片模型的全局默认值。
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Bot, Loader2, RotateCcw, Send, ShieldAlert } from "lucide-react";
|
||||
import { requestImageQuestion } from "@/services/api/image";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// 资产导入(editor-shell 预设插件贡献 asset-ingest 插槽,M3.4)。
|
||||
// 展示项目资产库,按来源区分「本地上传」与「项目素材」;点击资产 → makeClipFromAsset
|
||||
// → dispatch addClip 加入时间线(添加到匹配 kind 的轨道末尾)。资产是
|
||||
// "仅时间线作用域"直连媒体(nodeId=asset:<id>)。导入链路:uploadResourceFile →
|
||||
@@ -111,7 +110,7 @@ function AssetThumb({ asset }: { asset: ProjectAsset }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 尝试用 <video> 抓取视频首帧作为封面 dataURL;失败返回 null(保持图标占位)。 */
|
||||
/** 尝试用 <video> 抓取视频首帧作为封面 dataURL;失败时保留媒体类型图标。 */
|
||||
function useVideoPoster(url: string | null): string | null {
|
||||
const [poster, setPoster] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// 导出(editor-shell 预设插件贡献 export-renderer 插槽,M3.7)。
|
||||
// M4.2:默认提交后端渲染任务(POST /timeline/renders,ffmpeg 服务端合成,
|
||||
// 产物落资源存储,ResultJSON 返回 resourceId 供内嵌预览/下载);
|
||||
// ffmpeg.wasm 本地导出保留为离线/降级路径。
|
||||
// §3.1 引用契约:缺失 nodeId 源(节点已删除)按计划跳过,成片不因悬空引用失败。
|
||||
// 默认由后端 ffmpeg 渲染并把产物写入资源存储;浏览器 ffmpeg.wasm 只承担离线降级。
|
||||
// 构建渲染计划时跳过已失去媒体来源的片段,避免悬空 nodeId 阻断其余有效片段导出。
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Download, Loader2, PackageOpen, Server } from "lucide-react";
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// 片段检查器(editor-shell 预设插件贡献 inspector 插槽,M3.3)。
|
||||
// 读取选中片段(store.selectedClipId),属性编辑经 setClipProperty 命令入队
|
||||
// (可撤销、可回放、进黄金文件语义);无选中时显示项目概览。
|
||||
// 视觉参照 Concat inspector:分组小标题 + 紧凑控件 + sunken 滑块,token 随主题联动。
|
||||
// 并进入统一撤销历史;检查器不直接修改 project,避免绕过命令校验和持久化。
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// 预览监视器(editor-shell 预设插件贡献 preview-renderer 插槽,M3.2)。
|
||||
// 浏览器内近似预览:真实媒体帧(storageKey → resolveMediaUrl)+ 播放头 + 时间码。
|
||||
// 近似渲染与导出(M3.7 的 buildTimelineRenderPlan)共享同一条"片段→媒体"解析路径,
|
||||
// 但这里只做时序呈现,不承诺像素级预览——像素级交给导出任务(M4)。
|
||||
// 预览与 buildTimelineRenderPlan 共享“片段→媒体”解析规则,但浏览器预览只保证时序一致;
|
||||
// 最终画面、编码和滤镜结果以后端导出任务为准。
|
||||
//
|
||||
// 播放模型:时间线 transport(store.transportMs)与监视器共享。
|
||||
// - 本地 playbackRef 以 rAF 逐帧推进(60fps 时间码),节流回写 store(~80ms),
|
||||
@@ -461,7 +460,7 @@ export function EditorPreviewMonitor() {
|
||||
<div className="max-w-xs text-xs leading-relaxed text-white/70">
|
||||
{project && project.clips.length > 0
|
||||
? activeClip
|
||||
? "当前时间点片段无媒体源(占位/字幕/音频)"
|
||||
? "当前时间点没有可显示画面(字幕或音频片段)"
|
||||
: "播放头位于空白处"
|
||||
: "时间线暂无片段,导入素材后在此预览"}
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,9 @@ import { EditorAiAssistant } from "./editor-ai-assistant";
|
||||
const manifest: PluginManifestV2 = {
|
||||
apiVersion: "yingce.plugin/v2",
|
||||
id: "editor-shell",
|
||||
name: "剪辑工作台(壳)",
|
||||
name: "剪辑工作台",
|
||||
version: "0.1.0",
|
||||
description: "编辑器预设插件垂直切片:注册时间线面板插槽,验证 v2 插件插槽注册→渲染链路。",
|
||||
description: "注册时间线、预览、检查器、素材、字幕、转写、导出和 AI 编辑八个工作台插槽。",
|
||||
author: "影策团队",
|
||||
surfaces: ["fullscreen"],
|
||||
permissions: ["timeline.read", "timeline.command", "export.run"],
|
||||
@@ -42,55 +42,47 @@ export const editorShellPlugin: RegisteredPlugin = {
|
||||
|
||||
registerPlugin(editorShellPlugin);
|
||||
|
||||
// M2.4:插槽渲染函数 = 真实时间线面板(命令状态机 + 手势)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "timeline-panel",
|
||||
render: () => <EditorTimelinePanel />,
|
||||
});
|
||||
|
||||
// M3.2:插槽渲染函数 = 预览监视器(浏览器内近似预览 + 播放头)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "preview-renderer",
|
||||
render: () => <EditorPreviewMonitor />,
|
||||
});
|
||||
// M3.3:插槽渲染函数 = 片段检查器(属性编辑入队可撤销)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "inspector",
|
||||
render: () => <EditorInspector />,
|
||||
});
|
||||
|
||||
// M3.4:插槽渲染函数 = 素材库(点击资产 → addClip 入轨)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "asset-ingest",
|
||||
render: () => <EditorAssetIngest />,
|
||||
});
|
||||
|
||||
// M3.5:插槽渲染函数 = 字幕工具(SRT 导入导出 + 从节点重建字幕快照)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "subtitle-tool",
|
||||
render: () => <EditorSubtitleTools />,
|
||||
});
|
||||
|
||||
// M3.6:转写(mock ASR → 字幕轨道,M4 接后端任务客户端)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "transcription-provider",
|
||||
render: () => <EditorTranscription />,
|
||||
});
|
||||
|
||||
// M3.7:导出(渲染计划 + ffmpeg.wasm 降级,M4 接后端任务)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "export-renderer",
|
||||
render: () => <EditorExport />,
|
||||
});
|
||||
|
||||
// M3.8:AI 助手占位(M6 补实现 ai-command-schema + timeline-summary)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "ai-assistant",
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
// 字幕工具(editor-shell 预设插件贡献 subtitle-tool 插槽,M3.5)。
|
||||
// SRT 导入导出(复用 srt-parser)+ 「从节点重建字幕片段」命令(§3.1 快照契约:
|
||||
// SRT 导入导出(复用 srt-parser)+ 「从节点重建字幕片段」命令(快照契约:
|
||||
// 节点 subtitleEntries 权威 → 重建时间线字幕快照,替换过期快照,单向显式同步)。
|
||||
// AI 字幕高亮与关键词标注已在 M6 落地,入口在画布「字幕」弹窗
|
||||
//(canvas-subtitle-dialog.tsx,走 ai.text 权限门禁):对权威节点字幕条目生成
|
||||
// 高亮/关键词,并随字幕条目渲染。此处(时间线字幕工具)字幕片段只是快照,
|
||||
// 编辑器预览尚无字幕文本渲染层,高亮视觉无处挂载,故不在此重复接 AI;
|
||||
// 待 M4 数据源接入、预览可渲染字幕文本后,再将高亮打通到时间线片段。
|
||||
// 时间线字幕片段是节点字幕的只读快照;AI 高亮只写权威节点字幕。
|
||||
// 当前预览不渲染字幕高亮,因此本工具不重复发起 AI 请求,避免产生两份互相漂移的结果。
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// 时间线面板(editor-shell 预设插件贡献,M2.4 手势 echo 最小件)。
|
||||
// 渲染链路:store.project(当前命令状态)→ 轨道/片段绝对定位 → 拖拽手势
|
||||
// (moveClip / trimClip)经 previewGesture 逐帧预览、commitGesture 一次性入历史。
|
||||
// 手势数学与渲染同源(同一 pxPerMs),保证拖拽所见即所得。
|
||||
// 撤销/重做与保存状态移入宿主顶栏(Concat 主菜单区),本面板保留缩放与片段统计。
|
||||
// 撤销、重做和保存由编辑器宿主统一处理,本面板只负责时间线交互和可视状态。
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Eye, EyeOff, Film, Magnet, Maximize, MousePointer2, Music2, Plus, Scissors, Slice, Subtitles, Trash2, Volume2, VolumeX, X, ZoomIn, ZoomOut } from "lucide-react";
|
||||
@@ -42,7 +41,7 @@ const TOOL_FG_ACTIVE = "bg-[var(--director-dock-active-surface)] text-[var(--dir
|
||||
|
||||
type GestureMode = "move" | "trim-start" | "trim-end" | null;
|
||||
|
||||
// 与时间线相关的场景:playheadMs 是标尺交互的临时坐标,随缩放同步换算。
|
||||
// 工具模式只影响指针手势解释;时间线数据修改仍必须通过命令分发。
|
||||
type TimelineTool = "select" | "razor";
|
||||
|
||||
type GestureState = {
|
||||
@@ -300,7 +299,7 @@ export function EditorTimelinePanel() {
|
||||
{project.clips.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-[var(--director-dock-fg)]">
|
||||
<Scissors className="size-8" />
|
||||
<p className="text-sm">时间线暂无片段,从素材库拖入或后续接入画布节点</p>
|
||||
<p className="text-sm">从素材库加入素材后即可开始剪辑</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative min-w-full" style={{ width: contentWidth }}>
|
||||
@@ -399,7 +398,7 @@ function TimelineRuler({
|
||||
};
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex h-6 shrink-0 w-full items-end border-b border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)]">
|
||||
{/* 左列占位与 TrackRow 轨道标签列(w-48)同宽,sticky 跟随横向滚动,保证 0ms 刻度与片段区起点对齐 */}
|
||||
{/* 左侧空白列与轨道标签列同宽,保证 0ms 刻度和片段绘制区域使用同一坐标原点。 */}
|
||||
<div className="sticky left-0 z-10 w-48 shrink-0 self-stretch border-r border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)]">
|
||||
{/* 当前播放头时码,跟随拖动实时刷新 */}
|
||||
<span className="absolute bottom-1 right-2 text-[10px] tabular-nums text-[var(--director-danger)]">{formatTimelineTime(playheadMs)}</span>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// 字幕转写(editor-shell 预设插件贡献 transcription-provider 插槽,M3.6)。
|
||||
// M4 后端转写任务(whisper.cpp 本地执行):创建 POST /timeline/transcriptions
|
||||
// → waitForGenerationTask 轮询进度 → 结果 segments 按 ADR-0004 协议以
|
||||
// SrtEntry[] 落字幕轨道(rebuildSubtitleClips 重建快照),UI 契约与 M3.6 mock 一致。
|
||||
// 转写由后端 whisper.cpp 任务执行;前端只提交资源 ID、观察任务状态,
|
||||
// 并将成功结果转换为 SrtEntry[],通过 rebuildSubtitleClips 原子替换字幕轨道快照。
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AudioLines, Loader2 } from "lucide-react";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// 编辑器预设插件集合入口(M3.8)。
|
||||
// editor-shell 单个预设插件贡献全部 8 种 editorSlots;此处聚合导出,
|
||||
// builtin/index.ts 的静态注册经 editor-shell 模块副作用完成。
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// 编辑器插件执行期权限校验(M5,fail-closed)。
|
||||
// v2 插件贡献 editorSlots / 代发 timeline 命令时,其 manifest.permissions 必须覆盖
|
||||
// 对应能力域;缺失即拒绝渲染/执行。v1 插件不贡献 editorSlots,天然不受此域约束。
|
||||
// 本模块只做纯判定,接入点在插槽渲染(SlotStack)与宿主命令代发通道。
|
||||
// 编辑器插件执行期权限校验,默认拒绝未知插件和缺失权限。
|
||||
// v2 插件贡献 editorSlots 或代发 timeline 命令时,manifest.permissions 必须覆盖对应能力域;
|
||||
// v1 插件不贡献 editorSlots,因此不经过这条插槽权限路径。本模块只做纯判定,
|
||||
// 接入点分别位于插槽渲染(SlotStack)和宿主命令代发通道。
|
||||
|
||||
import { getRegisteredPlugin } from "@/lib/plugins/plugin-registry";
|
||||
import type { EditorPluginPermission, EditorSlotKind } from "@/lib/plugins/plugin-types";
|
||||
|
||||
/** 每个编辑器插槽所需的最小权限域(M5.2 表驱动:新插槽必须补表,否则 fail-closed 拒渲染)。 */
|
||||
/** 每个编辑器插槽所需的最小权限域;新增插槽必须补表,否则会因找不到权限而拒绝渲染。 */
|
||||
export const EDITOR_SLOT_REQUIRED_PERMISSION: Record<EditorSlotKind, EditorPluginPermission> = {
|
||||
"timeline-panel": "timeline.command",
|
||||
"preview-renderer": "timeline.read",
|
||||
@@ -51,7 +51,7 @@ export function pluginMayRenderEditorSlot(pluginId: string, slot: EditorSlotKind
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/** 宿主命令代发通道的权限断言:插件未声明 permission 时抛错(M6 AI 命令、第三方代发复用)。 */
|
||||
/** 宿主命令代发通道的权限断言:未注册插件或未声明目标权限时抛错,调用方必须阻止命令执行。 */
|
||||
export function assertEditorPermission(pluginId: string, permission: EditorPluginPermission): void {
|
||||
const permissions = pluginPermissions(pluginId);
|
||||
if (permissions === null) {
|
||||
|
||||
127
web/src/lib/prompts/catalog.ts
Normal file
127
web/src/lib/prompts/catalog.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { CanvasGenerationMode } from "@/types/canvas";
|
||||
import type { SrtEntry } from "@/types/timeline";
|
||||
|
||||
export type CanvasPromptPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
modes: CanvasGenerationMode[];
|
||||
source: "builtin" | "skill";
|
||||
};
|
||||
|
||||
export const CANVAS_BUILTIN_PRESETS: CanvasPromptPreset[] = [
|
||||
{
|
||||
id: "character-sheet",
|
||||
name: "角色设定图",
|
||||
description: "正面、侧面、背面与表情参考,锁定角色一致性",
|
||||
prompt: "生成角色设定图:保持同一角色身份、五官、发型、服装和体态一致,包含正面、侧面、背面和关键表情参考,背景简洁,便于后续镜头复用。",
|
||||
modes: ["image"],
|
||||
source: "builtin",
|
||||
},
|
||||
{
|
||||
id: "multi-angle",
|
||||
name: "多机位视角",
|
||||
description: "围绕同一主体生成连续、可衔接的机位变化",
|
||||
prompt: "围绕同一主体设计多机位画面,保持人物、服装、场景和光线一致,分别给出远景、全景、中景、近景、特写、侧面、背面和俯拍视角,镜头之间具有连续性。",
|
||||
modes: ["image", "video"],
|
||||
source: "builtin",
|
||||
},
|
||||
{
|
||||
id: "next-shot",
|
||||
name: "画面推演",
|
||||
description: "推演当前画面的前后动作与镜头衔接",
|
||||
prompt: "基于当前画面推演下一个连续镜头:保持角色和场景一致,明确主体接下来的动作、视线、环境变化、镜头运动和自然衔接方式,不要跳变构图或身份。",
|
||||
modes: ["image", "video"],
|
||||
source: "builtin",
|
||||
},
|
||||
{
|
||||
id: "story-beats",
|
||||
name: "连续镜头",
|
||||
description: "将短剧情拆成可生成的连续镜头节拍",
|
||||
prompt: "把这段内容拆成连续镜头节拍。每个镜头写清主体动作、景别、构图、机位、运镜、光线、情绪和与前后镜头的衔接,并保持角色、场景和道具一致。",
|
||||
modes: ["text", "image", "video"],
|
||||
source: "builtin",
|
||||
},
|
||||
{
|
||||
id: "cinematic-light",
|
||||
name: "电影光影优化",
|
||||
description: "保留内容,优化真实光线、层次和融合感",
|
||||
prompt: "保留主体身份、动作和原始构图,优化为真实电影摄影光线:明确主光方向、环境反射、阴影层次、肤色和背景融合,降低塑料感与过度锐化,不改变画面内容。",
|
||||
modes: ["image", "video"],
|
||||
source: "builtin",
|
||||
},
|
||||
{
|
||||
id: "video-prompt",
|
||||
name: "视频提示词优化",
|
||||
description: "整理为模型更容易执行的时序化镜头指令",
|
||||
prompt: "将当前要求改写为结构化视频提示词,按时间顺序描述开场画面、主体动作、镜头运动、环境变化、声音和结束画面;消除冲突指令,保留所有关键约束。",
|
||||
modes: ["text", "video"],
|
||||
source: "builtin",
|
||||
},
|
||||
];
|
||||
|
||||
export const IMAGE_PROMPT_REVERSE = `请根据参考图片反推一段适合用于 AI 生图的提示词。
|
||||
|
||||
要求:
|
||||
1. 只输出提示词正文,不要解释。
|
||||
2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。
|
||||
3. 尽量写成可直接用于生图模型的完整提示词。`;
|
||||
|
||||
export function workflowStarterPrompt(kind: "character_cards" | "character_three_view" | "storyboard_video", title: string, workflowTitle: string) {
|
||||
if (kind === "character_cards") return `请基于「${workflowTitle}」拆分主要角色,并为每个角色生成可用于后续创作的角色图片卡片:外观、服饰、身份、性格和视觉辨识点。`;
|
||||
if (kind === "character_three_view") return `请基于上游角色卡片生成「${title}」:同一角色的正面、侧面、背面三视图,保持服饰、发型、道具和比例一致。`;
|
||||
return `请基于上游角色三视图,为「${workflowTitle}」制作分镜剧情视频方案:包含镜头顺序、景别、动作、节奏和画面连续性。`;
|
||||
}
|
||||
|
||||
export function buildTextRewritePrompt(sourceText: string, instruction: string) {
|
||||
return `请根据要求修改以下文本。\n\n原文:\n${sourceText}\n\n修改要求:\n${instruction}`;
|
||||
}
|
||||
|
||||
export function buildVideoOperationPrompt(operation: string, prompt: string, operationLabel: string) {
|
||||
if (operation === "compare_versions") return `请对以下视频结果版本做对比分析,输出推荐版本、差异点和修改建议:\n${prompt}`;
|
||||
return `视频编辑任务:${operationLabel}\n创作要求:${prompt}`;
|
||||
}
|
||||
|
||||
export function buildSubtitleHighlightSystemPrompt() {
|
||||
return `你是一个视频字幕关键词高亮助手。请输出严格 JSON。
|
||||
|
||||
任务目标:
|
||||
- 为每条字幕判断是否需要高亮
|
||||
- 每条字幕最多高亮 1 段
|
||||
- 只返回最值得高亮的一个关键词或短语
|
||||
- start 和 end 使用 JavaScript 字符串下标
|
||||
- end 为 exclusive
|
||||
- 没有明确重点时返回 shouldHighlight=false
|
||||
|
||||
高亮标准:
|
||||
- 优先高亮结论词、数字、身份词、首次/突破词、强反差词
|
||||
- 不要高亮虚词、口头语、整句长短语
|
||||
|
||||
边界约束:
|
||||
- 不负责颜色、字号、位置、动效
|
||||
- 只返回 highlights 数组
|
||||
- highlightText 必须严格等于原字幕文本切片结果`;
|
||||
}
|
||||
|
||||
export function buildSubtitleHighlightUserMessage(entries: SrtEntry[]) {
|
||||
const lines = entries.map((entry) => {
|
||||
return `entryIndex=${entry.index} | startMs=${entry.startMs} | endMs=${entry.endMs} | text=${entry.text}`;
|
||||
});
|
||||
return `请根据以下字幕和上下文,为每条字幕判断是否需要高亮,并返回 JSON:
|
||||
|
||||
{
|
||||
"highlights": [
|
||||
{
|
||||
"entryIndex": 1,
|
||||
"shouldHighlight": true,
|
||||
"highlightText": "示例",
|
||||
"start": 0,
|
||||
"end": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
字幕列表:
|
||||
${lines.join("\n")}`;
|
||||
}
|
||||
22
web/src/lib/prompts/index.ts
Normal file
22
web/src/lib/prompts/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export {
|
||||
PromptTemplateOperation,
|
||||
promptTemplateTaskPlaceholder,
|
||||
type PromptTemplateOperationId,
|
||||
} from "./operations";
|
||||
export {
|
||||
CANVAS_BUILTIN_PRESETS,
|
||||
IMAGE_PROMPT_REVERSE,
|
||||
buildSubtitleHighlightSystemPrompt,
|
||||
buildSubtitleHighlightUserMessage,
|
||||
buildTextRewritePrompt,
|
||||
buildVideoOperationPrompt,
|
||||
workflowStarterPrompt,
|
||||
type CanvasPromptPreset,
|
||||
} from "./catalog";
|
||||
export {
|
||||
parseGeneratedStory,
|
||||
shortDramaOutlineVariables,
|
||||
storyTitleFromAnswer,
|
||||
type GeneratedShortDramaChapter,
|
||||
type GeneratedShortDramaOutline,
|
||||
} from "./short-drama-outline";
|
||||
17
web/src/lib/prompts/operations.ts
Normal file
17
web/src/lib/prompts/operations.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const PromptTemplateOperation = {
|
||||
ChapterAssetsExtract: "chapter_assets_extract",
|
||||
CharacterExtract: "character_extract",
|
||||
CharacterTurnaround: "character_turnaround",
|
||||
StoryboardPlan: "storyboard_plan",
|
||||
StoryboardRepair: "storyboard_repair",
|
||||
StoryboardFirstFrame: "storyboard_first_frame",
|
||||
StoryboardVideo: "storyboard_video",
|
||||
ShortDramaOutline: "short_drama_outline",
|
||||
SkillDraft: "skill_draft",
|
||||
} as const;
|
||||
|
||||
export type PromptTemplateOperationId = (typeof PromptTemplateOperation)[keyof typeof PromptTemplateOperation];
|
||||
|
||||
export function promptTemplateTaskPlaceholder(label: string) {
|
||||
return `使用当前启用的${label}模板。`;
|
||||
}
|
||||
57
web/src/lib/prompts/short-drama-outline.ts
Normal file
57
web/src/lib/prompts/short-drama-outline.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export type GeneratedShortDramaChapter = {
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type GeneratedShortDramaOutline = {
|
||||
title: string;
|
||||
synopsis: string;
|
||||
chapters: GeneratedShortDramaChapter[];
|
||||
};
|
||||
|
||||
export function parseGeneratedStory(answer: string): GeneratedShortDramaOutline {
|
||||
const cleaned = answer
|
||||
.replace(/```json/gi, "")
|
||||
.replace(/```/g, "")
|
||||
.trim();
|
||||
const match = cleaned.match(/\{[\s\S]*\}/);
|
||||
const payload = match ? JSON.parse(match[0]) as Record<string, unknown> : {};
|
||||
const title = String(payload.title || "").trim();
|
||||
const synopsis = String(payload.synopsis || "").trim();
|
||||
const chapters = Array.isArray(payload.chapters)
|
||||
? payload.chapters
|
||||
.map((chapter: unknown) => {
|
||||
const item = typeof chapter === "object" && chapter ? chapter as Record<string, unknown> : {};
|
||||
return { title: String(item.title || "").trim(), content: String(item.content || "").trim() };
|
||||
})
|
||||
.filter((chapter: GeneratedShortDramaChapter) => chapter.title && chapter.content)
|
||||
: [];
|
||||
return { title: title || storyTitleFromAnswer(answer), synopsis, chapters };
|
||||
}
|
||||
|
||||
export function storyTitleFromAnswer(answer: string) {
|
||||
const line = answer.split(/\r?\n/).find((item) => item.trim());
|
||||
return line ? line.trim().slice(0, 24) : "AI 生成短剧";
|
||||
}
|
||||
|
||||
export function shortDramaOutlineVariables(input: {
|
||||
story: string;
|
||||
chapterCount: string;
|
||||
structure: string;
|
||||
wordCount: string;
|
||||
perspective: string;
|
||||
tone: string;
|
||||
characterScale: string;
|
||||
chapterLength: string;
|
||||
}) {
|
||||
return {
|
||||
用户故事: input.story,
|
||||
章节数量: input.chapterCount,
|
||||
叙事结构: input.structure,
|
||||
每章字数: input.wordCount,
|
||||
叙事视角: input.perspective,
|
||||
整体基调: input.tone,
|
||||
角色规模: input.characterScale,
|
||||
章节篇幅: input.chapterLength,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// AI 编辑命令契约(ADR-0007 / Runbook M6.1):宿主校验 + LLM 提示词约束的双契约。
|
||||
// 本文件不复制 handler 约束——命令合法性硬守门在 M2.1 命令注册表(fail-closed),
|
||||
// 这里只做:(1) LLM 输出 JSON 的结构解析与批级预检(dry-run 走同一注册表,零重复),
|
||||
// (2) 生成喂给 LLM 的 op 契约(op 集合与注册表黄金同步,见测试)。
|
||||
// AI 编辑命令的边界层:同一份注册表同时约束模型输出和宿主执行,避免提示词与真实能力列表分叉。
|
||||
// 本文件只负责 JSON 结构解析、批量预检和提示词契约生成;命令的参数合法性仍由编辑命令注册表统一判定。
|
||||
// 预检使用与正式执行相同的纯函数注册表,保证“模型看见的能力”和“宿主实际允许的能力”一致。
|
||||
|
||||
import { getEditorCommandRegistry, type EditCommand } from "@/lib/timeline/editor-commands";
|
||||
import type { TimelineProject } from "@/types/timeline";
|
||||
@@ -79,8 +78,8 @@ function assertPlanShape(raw: unknown): asserts raw is AiCommandPlan {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 LLM 文本输出为 AiCommandPlan;结构非法时抛 AiCommandPlanError(供 UI 展示并回填模型)。
|
||||
* commands 为空数组表示「只读问答 / 无法用现有命令完成」,面板直接展示 reasoning 作为回答。
|
||||
* 解析模型文本为 AiCommandPlan;结构非法时抛 AiCommandPlanError,调用方可将明确原因展示给用户或回传模型修正。
|
||||
* commands 为空数组表示只读问答或当前能力无法完成,调用方不应把它当成“执行成功的空修改”。
|
||||
*/
|
||||
export function parseAiCommandPlan(text: string): AiCommandPlan {
|
||||
let raw: unknown;
|
||||
@@ -96,8 +95,8 @@ export function parseAiCommandPlan(text: string): AiCommandPlan {
|
||||
|
||||
/**
|
||||
* 批级预检:把整批命令按序 dry-run 到当前时间线(immutable 纯函数,无副作用)。
|
||||
* 任何一条触发注册表 fail-closed(未知 op / 非法 payload / 引用不存在)即整批拒绝,
|
||||
* 返回首个出错命令的下标与消息——ADR-0007「任一非法→整批拒绝,错误回填 LLM 修正」。
|
||||
* 任何一条触发注册表的拒绝规则(未知 op、非法 payload 或引用不存在)即整批拒绝,
|
||||
* 返回首个失败命令的位置和原因;正式执行不得绕过这一步或只提交部分命令。
|
||||
*/
|
||||
export function validateAiCommandBatch(project: TimelineProject, plan: AiCommandPlan): AiCommandBatchVerdict {
|
||||
const registry = getEditorCommandRegistry();
|
||||
@@ -114,13 +113,13 @@ export function validateAiCommandBatch(project: TimelineProject, plan: AiCommand
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM 提示词契约(ADR-0007:schema 同时用于宿主校验与模型输出约束)
|
||||
// 模型提示词契约:schema 同时用于宿主校验与模型输出约束,具体交互决策见 ADR-0007。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** AI 命令 schema 版本(Runbook M6.1):提示词契约/解析规则变更时递增,供 golden 与缓存失效对齐。 */
|
||||
/** AI 命令 schema 版本;提示词契约或解析规则变化时递增,用于 golden 测试和缓存失效对齐。 */
|
||||
export const AI_COMMAND_SCHEMA_VERSION = 1;
|
||||
|
||||
/** 单次 AI 编辑最多允许的命令条数(ADR-0007:防止模型一次输出失控编辑)。 */
|
||||
/** 单次 AI 编辑最多允许的命令条数,防止一次模型输出造成不可控的大范围变更。 */
|
||||
export const AI_EDITING_MAX_COMMANDS = 8;
|
||||
|
||||
/** 12 个黄金 op 的 LLM 可读 payload 契约;op 集合与注册表黄金同步(见测试)。 */
|
||||
@@ -203,7 +202,7 @@ export const AI_EDITING_OUTPUT_FORMAT = `输出要求:
|
||||
const AI_EDITING_OUTPUT_EXAMPLE = `示例:
|
||||
{ "reasoning": "把第一个视频片段移到第 5 秒", "commands": [ { "op": "moveClip", "payload": { "id": "clip-a", "startMs": 5000 } } ] }`;
|
||||
|
||||
/** 组装 AI 编辑系统提示词:时间线摘要 + op 契约 + 输出约束(M6.3 面板喂给模型)。 */
|
||||
/** 组装 AI 编辑系统提示词:时间线摘要、可用 op 契约和输出约束必须来自同一份注册表。 */
|
||||
export function buildAiEditingSystemPrompt(timelineSummary: string): string {
|
||||
const catalogLines = AI_EDITING_OP_CATALOG.map(
|
||||
(entry) => `- ${entry.op}:${entry.desc}。payload:${entry.payload}`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 资产 → 时间线片段 的纯转换(M3.4 asset-ingest)。
|
||||
// 资产到时间线片段的纯转换:只做类型映射和结构组装,不读取网络或修改编辑器状态。
|
||||
// 资产是"仅时间线作用域"的直连媒体:nodeId 用 `asset:<id>` 前缀(不指向画布节点),
|
||||
// 预览/导出从 directMedia 解析媒体,不回画布查节点。
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export type EditorCommandRegistry = {
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 黄金命令集合 payload 类型(首版一期能力,对应 Runbook M2.1)
|
||||
// 内置命令的 payload 类型;注册表、AI 命令契约和黄金测试必须共享同一组 op。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AddClipPayload = { clip: TimelineClip };
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// Mock 转写(M3.6):M4 后端转写任务未就绪前,用确定性伪转写模拟 ASR 结果。
|
||||
// 同一 title + durationMs 永远生成相同的 SrtEntry[],便于测试与回归;
|
||||
// M4 接真实任务客户端后本模块移除,UI 契约(SrtEntry[] 落字幕轨道)不变。
|
||||
|
||||
import type { SrtEntry } from "@/types/timeline";
|
||||
|
||||
const MOCK_CHUNK_MS = 1800;
|
||||
const MOCK_GAP_MS = 200;
|
||||
|
||||
/** 从标题确定性生成一组模拟字幕条目(基于字符串 hash 稳定切分)。 */
|
||||
export function mockTranscriptionEntries(title: string, durationMs: number): SrtEntry[] {
|
||||
const text = title.trim() || "未命名素材";
|
||||
const chunks = splitStable(text, 6);
|
||||
const usable = Math.max(1, Math.floor(durationMs / (MOCK_CHUNK_MS + MOCK_GAP_MS)));
|
||||
const count = Math.min(chunks.length, usable);
|
||||
const step = durationMs / count;
|
||||
|
||||
const entries: SrtEntry[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const startMs = Math.round(i * step);
|
||||
const endMs = Math.min(durationMs, Math.round(startMs + MOCK_CHUNK_MS));
|
||||
if (endMs <= startMs) break;
|
||||
entries.push({ index: i, startMs, endMs, text: chunks[i % chunks.length] });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** 稳定切分:按可打印字符逐段取满 maxLen,确定性无随机。 */
|
||||
function splitStable(text: string, maxLen: number): string[] {
|
||||
const chars = Array.from(text);
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < chars.length; i += maxLen) {
|
||||
chunks.push(chars.slice(i, i + maxLen).join(""));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { SrtEntry } from "@/types/timeline";
|
||||
|
||||
export interface SubtitleHighlightLLMResult {
|
||||
entryIndex: number;
|
||||
shouldHighlight: boolean;
|
||||
highlightText: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export function buildSubtitleHighlightSystemPrompt(): string {
|
||||
return `你是一个视频字幕关键词高亮助手。请输出严格 JSON。
|
||||
|
||||
任务目标:
|
||||
- 为每条字幕判断是否需要高亮
|
||||
- 每条字幕最多高亮 1 段
|
||||
- 只返回最值得高亮的一个关键词或短语
|
||||
- start 和 end 使用 JavaScript 字符串下标
|
||||
- end 为 exclusive
|
||||
- 没有明确重点时返回 shouldHighlight=false
|
||||
|
||||
高亮标准:
|
||||
- 优先高亮结论词、数字、身份词、首次/突破词、强反差词
|
||||
- 不要高亮虚词、口头语、整句长短语
|
||||
|
||||
边界约束:
|
||||
- 不负责颜色、字号、位置、动效
|
||||
- 只返回 highlights 数组
|
||||
- highlightText 必须严格等于原字幕文本切片结果`;
|
||||
}
|
||||
|
||||
export function buildSubtitleHighlightUserMessage(entries: SrtEntry[]): string {
|
||||
const lines = entries.map((entry) => {
|
||||
return `entryIndex=${entry.index} | startMs=${entry.startMs} | endMs=${entry.endMs} | text=${entry.text}`;
|
||||
});
|
||||
|
||||
return `请根据以下字幕和上下文,为每条字幕判断是否需要高亮,并返回 JSON:
|
||||
|
||||
{
|
||||
"highlights": [
|
||||
{
|
||||
"entryIndex": 1,
|
||||
"shouldHighlight": true,
|
||||
"highlightText": "示例",
|
||||
"start": 0,
|
||||
"end": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
字幕列表:
|
||||
${lines.join("\n")}`;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import type { SrtEntry, SubtitleHighlight } from "@/types/timeline";
|
||||
import { buildSubtitleHighlightSystemPrompt, buildSubtitleHighlightUserMessage } from "./subtitle-highlight-ai";
|
||||
import { buildSubtitleHighlightSystemPrompt, buildSubtitleHighlightUserMessage } from "@/lib/prompts";
|
||||
import { parseSubtitleHighlightResponse } from "./subtitle-highlight-service";
|
||||
|
||||
export interface SubtitleHighlightProgress {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { SrtEntry, SubtitleHighlight } from "@/types/timeline";
|
||||
import { filterValidSubtitleHighlights } from "./subtitle-highlights";
|
||||
import type { SubtitleHighlightLLMResult } from "./subtitle-highlight-ai";
|
||||
interface SubtitleHighlightLLMResult {
|
||||
entryIndex: number;
|
||||
shouldHighlight: boolean;
|
||||
highlightText: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function isSubtitleHighlightLLMResult(value: unknown): value is SubtitleHighlightLLMResult {
|
||||
if (!value || typeof value !== "object") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 时间线确定性摘要(Runbook M6.2):把 TimelineProject v2 压缩成喂给 LLM 的中文上下文。
|
||||
// 时间线确定性摘要:把 TimelineProject 压缩成稳定、可控长度的中文上下文,供 AI 编辑和诊断使用。
|
||||
// 同一输入永远产出同一输出(无时间戳/无随机),供命令引用 id 与 AI 面板上下文注入。
|
||||
|
||||
import type { TimelineClip, TimelineProject, TimelineTrack } from "@/types/timeline";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getFeatureAvailability, type AuthSessionPayload } from "@/services/api/auth";
|
||||
import { getModelCatalog, listLogicalModels, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog, type PublicLogicalModel } from "@/services/api/logical-models";
|
||||
import { getModelCatalog, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog, type PublicLogicalModel } from "@/services/api/logical-models";
|
||||
import { localForageStorage } from "@/lib/localforage-storage";
|
||||
import { appQueryClient } from "@/lib/query-client";
|
||||
import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope";
|
||||
@@ -61,17 +61,10 @@ export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
if (!persistedCreationPreferences) useCreationPreferencesStore.setState({ preferences: {} });
|
||||
if (!persistedConfig) {
|
||||
// 只有首次配置缺失时才生成能力推荐;已有配置中的空数组代表用户明确清空。
|
||||
// 使用统一模型目录接口
|
||||
const catalog = await getModelCatalog();
|
||||
let channels: ModelChannel[] = [];
|
||||
if (catalog.source === "frontend" && catalog.models) {
|
||||
channels = managedModelChannels(catalog.models);
|
||||
} else if (catalog.source === "system" && catalog.channels) {
|
||||
channels = systemChannelModelChannels(catalog.channels);
|
||||
}
|
||||
const initialSystemConfig = {
|
||||
...defaultConfig,
|
||||
channels,
|
||||
channels: modelCatalogChannels(catalog),
|
||||
imageModels: undefined,
|
||||
videoModels: undefined,
|
||||
textModels: undefined,
|
||||
@@ -79,13 +72,8 @@ export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
};
|
||||
useConfigStore.getState().replaceConfig(normalizeConfigSnapshot({ config: initialSystemConfig }).config);
|
||||
} else {
|
||||
// 已有配置时也需要合并最新的系统渠道
|
||||
const catalog = await getModelCatalog();
|
||||
if (catalog.source === "frontend" && catalog.models) {
|
||||
useConfigStore.getState().mergeSystemChannels(managedModelChannels(catalog.models));
|
||||
} else if (catalog.source === "system" && catalog.channels) {
|
||||
useConfigStore.getState().mergeSystemChannels(systemChannelModelChannels(catalog.channels));
|
||||
}
|
||||
useConfigStore.getState().mergeSystemChannels(modelCatalogChannels(catalog));
|
||||
}
|
||||
installRemoteUserDataAutoSync();
|
||||
if (payload.user?.id) {
|
||||
@@ -97,16 +85,21 @@ export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
}
|
||||
|
||||
export async function refreshSystemChannels() {
|
||||
// 使用统一模型目录接口,根据 frontendModelsEnabled 自动返回前台模型或系统渠道模型
|
||||
const catalog = await getModelCatalog();
|
||||
useConfigStore.getState().mergeSystemChannels(modelCatalogChannels(catalog));
|
||||
}
|
||||
|
||||
if (catalog.source === "frontend" && catalog.models) {
|
||||
// 前台模型模式
|
||||
useConfigStore.getState().mergeSystemChannels(managedModelChannels(catalog.models));
|
||||
} else if (catalog.source === "system" && catalog.channels) {
|
||||
// 系统渠道模式
|
||||
useConfigStore.getState().mergeSystemChannels(systemChannelModelChannels(catalog.channels));
|
||||
// 模型目录来源决定数据形状;这里统一做运行时收口,避免畸形响应被当成“空目录”写入用户配置。
|
||||
function modelCatalogChannels(catalog: ModelCatalogResponse): ModelChannel[] {
|
||||
if (catalog.source === "frontend") {
|
||||
if (!Array.isArray(catalog.models)) throw new Error("模型目录响应缺少前台模型列表");
|
||||
return managedModelChannels(catalog.models);
|
||||
}
|
||||
if (catalog.source === "system") {
|
||||
if (!Array.isArray(catalog.channels)) throw new Error("模型目录响应缺少系统渠道列表");
|
||||
return systemChannelModelChannels(catalog.channels);
|
||||
}
|
||||
throw new Error("模型目录响应来源无效");
|
||||
}
|
||||
|
||||
function managedModelChannels(models: PublicLogicalModel[]) {
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function ChannelsPage() {
|
||||
const sequence = ++requestSequence.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listAdminChannels({ keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, limit: pageSize });
|
||||
const result = await listAdminChannels({ keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, pageSize });
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setChannels(result.channels);
|
||||
setTotal(result.total);
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function AdminAnnouncementsPanel({
|
||||
keyword: queryKeyword || undefined,
|
||||
status: queryStatus === "all" ? undefined : queryStatus,
|
||||
page: targetPage,
|
||||
limit: targetPageSize,
|
||||
pageSize: targetPageSize,
|
||||
}),
|
||||
targetPage,
|
||||
targetPageSize,
|
||||
@@ -1024,13 +1024,13 @@ function assertAnnouncementMutationResult(
|
||||
}
|
||||
}
|
||||
|
||||
function assertAnnouncementListResult(result: unknown, expectedPage: number, expectedLimit: number) {
|
||||
if (!isRecord(result) || !Array.isArray(result.announcements) || !Number.isInteger(result.total) || (result.total as number) < 0 || result.page !== expectedPage || result.limit !== expectedLimit) {
|
||||
function assertAnnouncementListResult(result: unknown, expectedPage: number, expectedPageSize: number) {
|
||||
if (!isRecord(result) || !Array.isArray(result.announcements) || !Number.isInteger(result.total) || (result.total as number) < 0 || result.page !== expectedPage || result.pageSize !== expectedPageSize) {
|
||||
throw new Error("公告列表返回格式不完整");
|
||||
}
|
||||
const announcements = result.announcements.map((value) => normalizeAnnouncementListItem(value));
|
||||
if ((result.total as number) < announcements.length) throw new Error("公告列表总数与当前页数据不一致");
|
||||
return { announcements, total: result.total as number, page: expectedPage, limit: expectedLimit };
|
||||
return { announcements, total: result.total as number, page: expectedPage, pageSize: expectedPageSize };
|
||||
}
|
||||
|
||||
function normalizeAnnouncementListItem(value: unknown): SystemAnnouncement {
|
||||
@@ -1076,9 +1076,9 @@ async function inspectPendingReview(review: AnnouncementPendingReview) {
|
||||
for (const title of queryTitles) {
|
||||
let targetPage = 1;
|
||||
while (targetPage <= 50) {
|
||||
const data = assertAnnouncementListResult(await listAdminAnnouncements({ keyword: title, page: targetPage, limit: 100 }), targetPage, 100);
|
||||
const data = assertAnnouncementListResult(await listAdminAnnouncements({ keyword: title, page: targetPage, pageSize: 100 }), targetPage, 100);
|
||||
data.announcements.forEach((announcement) => candidates.set(announcement.id, announcement));
|
||||
if (targetPage >= Math.max(1, Math.ceil(data.total / data.limit))) break;
|
||||
if (targetPage >= Math.max(1, Math.ceil(data.total / data.pageSize))) break;
|
||||
if (targetPage === 50) throw new Error("同名匹配记录过多,无法安全定位目标;请稍后重试。");
|
||||
targetPage += 1;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { App, Button, Descriptions, Drawer, Progress, Skeleton, Tabs } from "antd";
|
||||
import { App, Button, Descriptions, Progress, Skeleton, Tabs } from "antd";
|
||||
import { AppDrawer } from "@/components/ui/product/app-drawer";
|
||||
import { IconButton } from "@/components/ui/base/buttons";
|
||||
import { EmptyState } from "@/components/ui/product/empty-state";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
@@ -46,7 +47,7 @@ export function AdminUserDetailDrawer({ userId, onClose, previousUserId, nextUse
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
let active = true;
|
||||
void listAdminUserLedger(userId, { page: ledgerPage, limit: 20 })
|
||||
void listAdminUserLedger(userId, { page: ledgerPage, pageSize: 20 })
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
setLedger(result.entries);
|
||||
@@ -61,7 +62,7 @@ export function AdminUserDetailDrawer({ userId, onClose, previousUserId, nextUse
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
let active = true;
|
||||
void listAdminUserTasks(userId, { page: taskPage, limit: 20 })
|
||||
void listAdminUserTasks(userId, { page: taskPage, pageSize: 20 })
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
setTasks(result.tasks);
|
||||
@@ -76,7 +77,7 @@ export function AdminUserDetailDrawer({ userId, onClose, previousUserId, nextUse
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
let active = true;
|
||||
void listAdminUserAuditEvents(userId, { page: auditPage, limit: 20 })
|
||||
void listAdminUserAuditEvents(userId, { page: auditPage, pageSize: 20 })
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
setEvents(result.events);
|
||||
@@ -90,12 +91,11 @@ export function AdminUserDetailDrawer({ userId, onClose, previousUserId, nextUse
|
||||
}, [auditPage, message, userId]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
<AppDrawer
|
||||
title={detail ? `${detail.user.displayName || detail.user.username} · 用户详情` : "用户详情"}
|
||||
open={Boolean(userId)}
|
||||
onClose={onClose}
|
||||
size="min(920px, 100vw)"
|
||||
destroyOnHidden
|
||||
rootClassName="admin-drawer"
|
||||
extra={onNavigate ? (
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -229,7 +229,7 @@ export function AdminUserDetailDrawer({ userId, onClose, previousUserId, nextUse
|
||||
) : (
|
||||
<EmptyState size="compact" title="没有用户详情" />
|
||||
)}
|
||||
</Drawer>
|
||||
</AppDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
const searchUsers = async (keyword: string) => {
|
||||
setSearchingUsers(true);
|
||||
try {
|
||||
const result = await listAdminUsers({ keyword: keyword.trim() || undefined, page: 1, limit: 50 });
|
||||
const result = await listAdminUsers({ keyword: keyword.trim() || undefined, page: 1, pageSize: 50 });
|
||||
setUserOptions(result.users);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "搜索用户失败");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Alert, App, Button, Form, Input, Modal, Segmented, Switch, Tabs } from "antd";
|
||||
import { Alert, App, Button, Form, Input, Segmented, Switch, Tabs } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { FlaskConical, Plus } from "lucide-react";
|
||||
import { ModelIconPicker } from "@/components/model-logo";
|
||||
import { ModelIcon } from "@/components/model-picker";
|
||||
@@ -166,7 +167,7 @@ export function ChannelModelEditor({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<AppModal
|
||||
open
|
||||
title={
|
||||
<div className="admin-model-editor-title">
|
||||
@@ -183,7 +184,7 @@ export function ChannelModelEditor({
|
||||
keyboard={!busy}
|
||||
closable={!busy}
|
||||
onCancel={requestClose}
|
||||
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0, minHeight: 0, flex: 1 }, header: { margin: 0 }, footer: { margin: 0 } }}
|
||||
flush styles={{ body: { minHeight: 0, flex: 1 }, header: { margin: 0 }, footer: { margin: 0 } }}
|
||||
footer={
|
||||
<div className="admin-model-editor-footer-actions">
|
||||
<div className="admin-model-editor-footer-status">
|
||||
@@ -396,7 +397,7 @@ export function ChannelModelEditor({
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { App, Button, Empty, Modal, Spin } from "antd";
|
||||
import { App, Button, Modal, Spin } from "antd";
|
||||
import { ArrowDown, ArrowUp, GripVertical, ListOrdered } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { EmptyState } from "@/components/ui/product/empty-state";
|
||||
import { getChannelOrder, saveChannelOrder, type ChannelOrderItem } from "@/services/api/channel-order";
|
||||
|
||||
export function moveOrderItem<T extends { id: string }>(items: T[], id: string, target: number): T[] {
|
||||
@@ -86,7 +87,7 @@ export function ChannelOrderDialog({ channelId, onSaved }: { channelId?: string;
|
||||
<Spin spinning={loading}>
|
||||
<div className="max-h-[55vh] overflow-y-auto space-y-2" role="list" aria-label="展示顺序">
|
||||
{!loading && !items.length ? (
|
||||
<Empty description="暂无可排序条目" />
|
||||
<EmptyState size="compact" title="暂无可排序条目" />
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<div
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function CreditOperationsPanel({ users, activeOperation, onOperat
|
||||
keyword: debouncedKeyword || undefined,
|
||||
status: orderStatus,
|
||||
page: targetPage,
|
||||
limit: targetPageSize,
|
||||
pageSize: targetPageSize,
|
||||
});
|
||||
if (requestId !== ordersRequestRef.current) return;
|
||||
if (targetPage > 1 && result.total > 0 && result.orders.length === 0) {
|
||||
@@ -134,7 +134,7 @@ export default function CreditOperationsPanel({ users, activeOperation, onOperat
|
||||
if (activeOperation !== "adjustment") return;
|
||||
const requestId = ++userSearchRequestRef.current;
|
||||
setSearchingUsers(true);
|
||||
void listAdminUsers({ keyword: debouncedAdjustmentSearch.trim() || undefined, page: 1, limit: 50 })
|
||||
void listAdminUsers({ keyword: debouncedAdjustmentSearch.trim() || undefined, page: 1, pageSize: 50 })
|
||||
.then((result) => {
|
||||
if (requestId !== userSearchRequestRef.current) return;
|
||||
const selectedId = adjustmentForm.getFieldValue("userId");
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function RedemptionCodesPanel({ createOpen, onCreateOpenChange, o
|
||||
keyword: queryKeyword || undefined,
|
||||
validity: queryValidity === "all" ? undefined : queryValidity,
|
||||
page: targetPage,
|
||||
limit: targetPageSize,
|
||||
pageSize: targetPageSize,
|
||||
});
|
||||
if (requestId !== listRequestRef.current) return false;
|
||||
const lastPage = Math.max(1, Math.ceil(result.total / targetPageSize));
|
||||
@@ -641,7 +641,7 @@ function RedeemBatchCodesModal({ batch, onClose, onBatchChanged }: { batch: Rede
|
||||
setCodes([]);
|
||||
setTotal(0);
|
||||
try {
|
||||
const result = await listAdminRedeemBatchCodes(batch.id, { status: targetStatus === "all" ? undefined : targetStatus, page: targetPage, limit: targetPageSize });
|
||||
const result = await listAdminRedeemBatchCodes(batch.id, { status: targetStatus === "all" ? undefined : targetStatus, page: targetPage, pageSize: targetPageSize });
|
||||
if (requestId !== requestRef.current) return false;
|
||||
const lastPage = Math.max(1, Math.ceil(result.total / targetPageSize));
|
||||
if (targetPage > lastPage) {
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function StorageResourcesPanel() {
|
||||
provider: provider === "all" ? undefined : provider,
|
||||
userId: debouncedUserId || undefined,
|
||||
page,
|
||||
limit: pageSize,
|
||||
pageSize: pageSize,
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
@@ -93,8 +93,8 @@ export default function LogicalModelsPage() {
|
||||
const reload = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [modelResult, firstChannelPage] = await Promise.all([listAdminLogicalModels(), listAdminChannels({ page: 1, limit: 100 })]);
|
||||
const remainingChannelPages = await Promise.all(Array.from({ length: Math.max(0, Math.ceil(firstChannelPage.total / firstChannelPage.limit) - 1) }, (_, index) => listAdminChannels({ page: index + 2, limit: firstChannelPage.limit })));
|
||||
const [modelResult, firstChannelPage] = await Promise.all([listAdminLogicalModels(), listAdminChannels({ page: 1, pageSize: 100 })]);
|
||||
const remainingChannelPages = await Promise.all(Array.from({ length: Math.max(0, Math.ceil(firstChannelPage.total / firstChannelPage.pageSize) - 1) }, (_, index) => listAdminChannels({ page: index + 2, pageSize: firstChannelPage.pageSize })));
|
||||
const channels = [firstChannelPage, ...remainingChannelPages].flatMap((result) => result.channels);
|
||||
const channelModelResults = await Promise.all(channels.map((channel) => listAdminChannelModels(channel.id)));
|
||||
setModels(modelResult.models);
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function LogsPage() {
|
||||
useEffect(() => {
|
||||
const sequence = ++requestSequence.current;
|
||||
setLoading(true);
|
||||
void listAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, limit: pageSize })
|
||||
void listAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, pageSize })
|
||||
.then((result) => {
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setLogs(result.logs);
|
||||
|
||||
@@ -121,14 +121,14 @@ export default function AdminPaymentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadOrders = async (page = orderPage, limit = orderPageSize) => {
|
||||
const loadOrders = async (page = orderPage, pageSize = orderPageSize) => {
|
||||
setOrdersLoading(true);
|
||||
try {
|
||||
const result = await listAdminPaymentOrders({ status: orderStatusFilter === "all" ? undefined : orderStatusFilter, keyword: orderKeyword.trim() || undefined, page, limit });
|
||||
const result = await listAdminPaymentOrders({ status: orderStatusFilter === "all" ? undefined : orderStatusFilter, keyword: orderKeyword.trim() || undefined, page, pageSize });
|
||||
setOrders(result.orders);
|
||||
setOrderTotal(result.total);
|
||||
setOrderPage(result.page);
|
||||
setOrderPageSize(result.limit);
|
||||
setOrderPageSize(result.pageSize);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取支付订单失败");
|
||||
} finally {
|
||||
@@ -136,14 +136,14 @@ export default function AdminPaymentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadRuns = async (page = runPage, limit = runPageSize) => {
|
||||
const loadRuns = async (page = runPage, pageSize = runPageSize) => {
|
||||
setRunsLoading(true);
|
||||
try {
|
||||
const result = await listAdminPaymentReconciliations({ providerId: runProviderFilter === "all" ? undefined : runProviderFilter, page, limit });
|
||||
const result = await listAdminPaymentReconciliations({ providerId: runProviderFilter === "all" ? undefined : runProviderFilter, page, pageSize });
|
||||
setRuns(result.runs);
|
||||
setRunTotal(result.total);
|
||||
setRunPage(result.page);
|
||||
setRunPageSize(result.limit);
|
||||
setRunPageSize(result.pageSize);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取对账记录失败");
|
||||
} finally {
|
||||
@@ -287,16 +287,16 @@ export default function AdminPaymentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openRunDetails = async (run: PaymentReconciliationRun, page = 1, limit = detailPageSize, result = detailResult) => {
|
||||
const openRunDetails = async (run: PaymentReconciliationRun, page = 1, pageSize = detailPageSize, result = detailResult) => {
|
||||
setDetailRun(run);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const response = await listAdminPaymentReconciliationItems(run.id, { result: result === "all" ? undefined : result, page, limit });
|
||||
const response = await listAdminPaymentReconciliationItems(run.id, { result: result === "all" ? undefined : result, page, pageSize });
|
||||
setDetailRun(response.run);
|
||||
setDetailItems(response.items);
|
||||
setDetailTotal(response.total);
|
||||
setDetailPage(response.page);
|
||||
setDetailPageSize(response.limit);
|
||||
setDetailPageSize(response.pageSize);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取对账明细失败");
|
||||
} finally {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { App, Button, Drawer, Form, Input, Popconfirm, Select, Tabs } from "antd";
|
||||
import { App, Button, Form, Input, Popconfirm, Select, Tabs } from "antd";
|
||||
import { StatusBadge } from "@/components/ui/base/badges";
|
||||
import { Callout } from "@/components/ui/product/callout";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
@@ -7,6 +7,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import { AppDrawer } from "@/components/ui/product/app-drawer/app-drawer";
|
||||
import { PromptCodeEditor, type PromptCodeEditorHandle } from "@/components/prompt/prompt-code-editor";
|
||||
import {
|
||||
createAdminPromptTemplate,
|
||||
@@ -187,7 +188,8 @@ export default function StoryboardPromptsPage() {
|
||||
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={filtered.length} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
<AppDrawer
|
||||
flush
|
||||
title={baseTemplate ? `基于 v${baseTemplate.version} 新建版本` : "新建提示词版本"}
|
||||
open={drawerOpen}
|
||||
size="min(1180px, 100vw)"
|
||||
@@ -196,8 +198,6 @@ export default function StoryboardPromptsPage() {
|
||||
closable={false}
|
||||
mask={{ closable: false }}
|
||||
keyboard={false}
|
||||
destroyOnHidden
|
||||
styles={{ body: { padding: 0 } }}
|
||||
extra={<div className="flex gap-2"><Popconfirm disabled={!dirty} title="放弃模板修改?" description="尚未保存的新版本内容将丢失。" okText="放弃修改" cancelText="继续编辑" okButtonProps={{ danger: true }} onConfirm={closeDrawer}><Button disabled={saving} onClick={() => { if (!dirty) closeDrawer(); }}>关闭</Button></Popconfirm><Button type="primary" loading={saving} disabled={!draftOperation || !draftName.trim() || !editorContent.trim()} onClick={() => void save()}>保存版本</Button></div>}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false} className="flex min-h-full flex-col">
|
||||
@@ -253,7 +253,7 @@ export default function StoryboardPromptsPage() {
|
||||
</aside>
|
||||
</div>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</AppDrawer>
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L
|
||||
role: state.role === "all" ? undefined : state.role,
|
||||
status: state.status === "all" ? undefined : state.status,
|
||||
page: state.page,
|
||||
limit: state.pageSize,
|
||||
pageSize: state.pageSize,
|
||||
})
|
||||
.then((result) => {
|
||||
if (sequence !== requestSequence.current) return;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { FileImage, UploadCloud, X } from "lucide-react";
|
||||
import { ASSET_CATEGORY_OPTIONS, type AssetCategory } from "@/lib/asset-category";
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
|
||||
import type { AssetFolder } from "@/services/api/user-data";
|
||||
|
||||
@@ -58,8 +58,8 @@ export function AssetBatchUploadModal({ open, defaultFolderId, folders, onClose,
|
||||
await flushAssetStorePersistence();
|
||||
try {
|
||||
await saveRemoteUserDataNow();
|
||||
} catch {
|
||||
message.warning("部分素材已保存在本地,稍后自动同步至云端");
|
||||
} catch (error) {
|
||||
message.warning(localSavedRemotePendingMessage("部分素材已保存在本地", error));
|
||||
}
|
||||
setUploading(false);
|
||||
await onComplete();
|
||||
|
||||
@@ -11,7 +11,6 @@ import { AssetMediaPreview } from "@/components/asset-media-preview";
|
||||
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
|
||||
import { saveAs } from "file-saver";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizeAssetRecord } from "@/lib/asset-storage-revision";
|
||||
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
@@ -23,7 +22,7 @@ import { uploadMediaFile } from "@/services/file-storage";
|
||||
import { flushAssetStorePersistence, useAssetStore, type Asset, type AssetCategory, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
|
||||
import { exportAssets, readAssetPackage } from "./asset-transfer";
|
||||
import { AssetStorageUsage, assetStorageUsageQueryKey } from "./asset-storage-usage";
|
||||
import { deleteAssetWithRemoteSync, loadAssetLibraryPage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { deleteAssetWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { createAssetFolder, deleteAssetFolder, listAssetFolders, listRemoteAssetsPage, moveRemoteAssetsToFolder, updateAssetFolder, type AssetFolder } from "@/services/api/user-data";
|
||||
import { AssetBatchUploadModal } from "./asset-batch-upload-modal";
|
||||
@@ -124,7 +123,7 @@ export default function AssetsPage() {
|
||||
});
|
||||
const folders = foldersQuery.data?.folders || [];
|
||||
|
||||
const allLibraryAssets = useMemo(() => assets.map(normalizeAssetRecord).filter((asset): asset is LibraryAsset => asset.kind !== "entity"), [assets]);
|
||||
const allLibraryAssets = useMemo(() => assets.filter((asset): asset is LibraryAsset => asset.kind !== "entity"), [assets]);
|
||||
const activeAssets = useMemo(() => allLibraryAssets.filter((asset) => asset.status !== "archived"), [allLibraryAssets]);
|
||||
const trashAssets = useMemo(() => allLibraryAssets.filter((asset) => asset.status === "archived"), [allLibraryAssets]);
|
||||
const validAssets = viewMode === "trash" ? trashAssets : activeAssets;
|
||||
@@ -163,7 +162,7 @@ export default function AssetsPage() {
|
||||
return filteredAssets.slice(start, start + pageSize);
|
||||
}, [filteredAssets, page, pageSize]);
|
||||
const visibleAssets = useMemo(
|
||||
() => (assetPageQuery.data?.assets || localVisibleAssets).map(normalizeAssetRecord).filter((asset): asset is LibraryAsset => asset.kind !== "entity"),
|
||||
() => (assetPageQuery.data?.assets || localVisibleAssets).filter((asset): asset is LibraryAsset => asset.kind !== "entity"),
|
||||
[assetPageQuery.data?.assets, localVisibleAssets],
|
||||
);
|
||||
const visibleAssetIds = useMemo(() => visibleAssets.map((asset) => asset.id), [visibleAssets]);
|
||||
@@ -332,8 +331,8 @@ export default function AssetsPage() {
|
||||
await saveRemoteUserDataNow();
|
||||
await invalidateAssetLibrary();
|
||||
message.success(editingAsset ? "素材已更新" : "素材已保存");
|
||||
} catch {
|
||||
message.warning(editingAsset ? "素材已在本地更新,稍后自动同步至云端" : "素材已在本地保存,稍后自动同步至云端");
|
||||
} catch (error) {
|
||||
message.warning(localSavedRemotePendingMessage(editingAsset ? "素材已在本地更新" : "素材已在本地保存", error));
|
||||
}
|
||||
setIsAssetOpen(false);
|
||||
};
|
||||
@@ -372,7 +371,9 @@ export default function AssetsPage() {
|
||||
data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: file.name },
|
||||
metadata: { source: "manual" },
|
||||
});
|
||||
message.success("3D 模型已保存");
|
||||
// 直传失败时文件只落在本机,云端同步会重传;此时不能说成"已保存"。
|
||||
if (uploaded.pendingRemoteUpload) message.warning(`3D 模型已保存在本机,尚未上传到服务器${uploaded.remoteUploadError ? `:${uploaded.remoteUploadError}` : ""}`);
|
||||
else message.success("3D 模型已保存");
|
||||
};
|
||||
|
||||
const copyAssetText = async (asset: LibraryAsset) => {
|
||||
@@ -420,8 +421,8 @@ export default function AssetsPage() {
|
||||
try {
|
||||
await saveRemoteUserDataNow();
|
||||
message.success(`已还原素材「${asset.title}」`);
|
||||
} catch {
|
||||
message.warning("已在本地还原,稍后自动同步至云端");
|
||||
} catch (error) {
|
||||
message.warning(localSavedRemotePendingMessage("已在本地还原", error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -436,8 +437,8 @@ export default function AssetsPage() {
|
||||
try {
|
||||
await saveRemoteUserDataNow();
|
||||
message.success(`已还原 ${count} 个素材`);
|
||||
} catch {
|
||||
message.warning("已在本地还原,稍后自动同步至云端");
|
||||
} catch (error) {
|
||||
message.warning(localSavedRemotePendingMessage("已在本地还原", error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -447,8 +448,8 @@ export default function AssetsPage() {
|
||||
try {
|
||||
await saveRemoteUserDataNow();
|
||||
message.success(`已将「${asset.title}」移入回收站`);
|
||||
} catch {
|
||||
message.warning("已移入回收站,稍后自动同步至云端");
|
||||
} catch (error) {
|
||||
message.warning(localSavedRemotePendingMessage("已移入回收站", error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -463,8 +464,8 @@ export default function AssetsPage() {
|
||||
try {
|
||||
await saveRemoteUserDataNow();
|
||||
message.success(`已将 ${count} 个素材移入回收站`);
|
||||
} catch {
|
||||
message.warning("已移入回收站,稍后自动同步至云端");
|
||||
} catch (error) {
|
||||
message.warning(localSavedRemotePendingMessage("已移入回收站", error));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -30,7 +30,11 @@ export default function LoginPage() {
|
||||
useEffect(() => {
|
||||
void getAuthSettings()
|
||||
.then((settings) => setLinuxdoEnabled(settings.linuxdoEnabled))
|
||||
.catch(() => undefined);
|
||||
.catch((error) => {
|
||||
// 这是登录页的展示配置读取:失败时明确隐藏第三方入口,
|
||||
// 账号密码登录仍可用;不能无痕地把配置读取失败当成成功。
|
||||
console.warn("读取登录方式配置失败,已隐藏第三方登录入口", error);
|
||||
});
|
||||
const oauthError = params.get("oauth_error");
|
||||
if (oauthError) message.error(oauthError);
|
||||
}, [message, params]);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { generationTaskShowsProgress, generationTaskStageLabel } from "@/lib/gen
|
||||
import { formatTaskLog, type GenerationTask, type TaskLog } from "@/services/api/task-center";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { VideoPlayer } from "@/components/video-player";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { modelDisplayName, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
|
||||
type CanvasProjectStatusDialogsProps = {
|
||||
@@ -74,19 +75,20 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
|
||||
<div className="py-8 text-center text-base font-medium">暂未实现</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
<AppModal
|
||||
title="视频预览"
|
||||
open={Boolean(previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video)}
|
||||
centered
|
||||
onCancel={onClosePreview}
|
||||
footer={null}
|
||||
width="min(1200px, calc(100vw - 32px))"
|
||||
styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "84vh", overflow: "hidden", background: "#090909" } }}
|
||||
flush
|
||||
styles={{ body: { display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "84vh", overflow: "hidden", background: "var(--workspace-canvas-deep)" } }}
|
||||
>
|
||||
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video ? (
|
||||
<VideoPlayer src={previewNode.metadata.content} mimeType={previewNode.metadata.mimeType} title={previewNode.title || "视频预览"} hasAudio={typeof previewNode.metadata.hasAudio === "boolean" ? previewNode.metadata.hasAudio : undefined} className="max-h-[84vh] max-w-full bg-black" />
|
||||
) : null}
|
||||
</Modal>
|
||||
</AppModal>
|
||||
|
||||
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Image ? (
|
||||
<CanvasImagePreview
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { Command, Search } from "lucide-react";
|
||||
import { Kbd } from "@/components/ui/base/kbd";
|
||||
import { Modal } from "antd";
|
||||
|
||||
import {
|
||||
CANVAS_SHORTCUT_CATEGORIES,
|
||||
@@ -31,7 +31,7 @@ export function CanvasShortcutsModal({ open, onClose }: { open: boolean; onClose
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<AppModal
|
||||
className="workspace-modal workspace-modal-wide canvas-shortcuts-modal"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
@@ -40,7 +40,7 @@ export function CanvasShortcutsModal({ open, onClose }: { open: boolean; onClose
|
||||
centered
|
||||
keyboard
|
||||
width="min(860px, calc(100vw - 24px))"
|
||||
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
|
||||
flush
|
||||
afterOpenChange={(visible) => {
|
||||
if (visible) window.requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}}
|
||||
@@ -109,7 +109,7 @@ export function CanvasShortcutsModal({ open, onClose }: { open: boolean; onClose
|
||||
<span className="canvas-shortcuts-close-hint"><Kbd>Esc</Kbd> 关闭</span>
|
||||
</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ import { CanvasVideoFrameDialog } from "@/components/canvas/canvas-video-frame-d
|
||||
import { CanvasVideoSegmentDialog } from "@/components/canvas/canvas-video-segment-dialog";
|
||||
import { CanvasTimelineDialog } from "@/components/canvas/canvas-timeline-dialog";
|
||||
import { syncNodeSubtitlesToTimeline } from "@/lib/timeline/timeline-build";
|
||||
import type { TimelineDirectMedia } from "@/types/timeline";
|
||||
import { CanvasNodeAnglePanel } from "@/components/canvas/canvas-node-angle-dialog";
|
||||
import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-modal";
|
||||
import { CanvasNodeSearchModal } from "@/components/canvas/canvas-node-search-modal";
|
||||
@@ -60,7 +59,7 @@ import { Minimap } from "@/components/canvas/canvas-mini-map";
|
||||
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar";
|
||||
import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands";
|
||||
import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import { AssetPickerModal } from "@/components/canvas/asset-picker-modal";
|
||||
import { getProject } from "@/services/api/projects";
|
||||
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
|
||||
import { CanvasShareModal } from "@/components/canvas/canvas-share-modal";
|
||||
@@ -126,6 +125,7 @@ import { useCanvasSelectionController } from "./use-canvas-selection-controller"
|
||||
import { useCanvasShortDrama } from "./use-canvas-short-drama";
|
||||
import { useCanvasStoryboard } from "./use-canvas-storyboard";
|
||||
import { useCanvasUpload } from "./use-canvas-upload";
|
||||
import { useCanvasTimelineAssetInsert } from "./use-canvas-timeline-asset-insert";
|
||||
import { useCanvasViewportController } from "./use-canvas-viewport-controller";
|
||||
import { usePortraitClearanceCoordinator } from "./use-portrait-clearance-coordinator";
|
||||
import {
|
||||
@@ -205,7 +205,8 @@ export default function CanvasPage() {
|
||||
}
|
||||
|
||||
function InfiniteCanvasPage() {
|
||||
const { message } = App.useApp();
|
||||
// 命令式确认必须走 App.useApp().modal;静态 Modal.confirm 拿不到主题和 App 上下文。
|
||||
const { message, modal } = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const params = useParams<{ id: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -281,10 +282,6 @@ function InfiniteCanvasPage() {
|
||||
const [stylePickerOpen, setStylePickerOpen] = useState(false);
|
||||
// 新建导演台镜头必须先选模板:null 表示未在选择中,undefined position 表示用画布中心。
|
||||
const [directorTemplateRequest, setDirectorTemplateRequest] = useState<{ position?: Position } | null>(null);
|
||||
const [projectAssetOpen, setProjectAssetOpen] = useState(false);
|
||||
const [projectAssetInitialCategory, setProjectAssetInitialCategory] = useState("all");
|
||||
const [projectAssetInitialFolderId, setProjectAssetInitialFolderId] = useState("all");
|
||||
const [projectAssetInsertPosition, setProjectAssetInsertPosition] = useState<Position | undefined>();
|
||||
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
|
||||
const [subtitleNodeId, setSubtitleNodeId] = useState<string | null>(null);
|
||||
const [timelineNodeId, setTimelineNodeId] = useState<string | null>(null);
|
||||
@@ -523,7 +520,7 @@ function InfiniteCanvasPage() {
|
||||
message.warning("官方即梦 CLI 当前不支持可靠取消,请等待官方状态同步");
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: "取消生成任务?",
|
||||
content: "任务会立即停止本地执行;如果已经提交到上游,系统会继续核对取消结果和积分状态。",
|
||||
okText: "取消任务",
|
||||
@@ -543,7 +540,7 @@ function InfiniteCanvasPage() {
|
||||
},
|
||||
});
|
||||
},
|
||||
[bindGenerationTask, message, nodesRef, projectId, queryClient, setTaskDetail],
|
||||
[bindGenerationTask, message, modal, nodesRef, projectId, queryClient, setTaskDetail],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -703,6 +700,28 @@ function InfiniteCanvasPage() {
|
||||
setDialogNodeId,
|
||||
});
|
||||
const replaceCanvasNodeMedia = useCallback((node: CanvasNodeData) => handleUploadRequest(node.id), [handleUploadRequest]);
|
||||
const {
|
||||
timelineAddNodeRef,
|
||||
timelineMediaAddRef,
|
||||
assetInsertScope,
|
||||
projectAssetScope,
|
||||
projectAssetOpen,
|
||||
projectAssetInitialCategory,
|
||||
projectAssetInitialFolderId,
|
||||
projectAssetInsertPosition,
|
||||
handleLibraryAssetsInsert,
|
||||
handleTimelineProjectAssetsInsert,
|
||||
openProjectAssets,
|
||||
openCanvasAssetLibrary,
|
||||
openTimelineAssetLibrary,
|
||||
closeProjectAssets,
|
||||
} = useCanvasTimelineAssetInsert({
|
||||
linkedProjectId,
|
||||
refetchLinkedProject,
|
||||
handleAssetsInsert,
|
||||
handleProjectAssetsInsert,
|
||||
openAssetsAtPosition,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectLoaded || searchParams.get("mode") !== "handoff") return;
|
||||
@@ -745,103 +764,6 @@ function InfiniteCanvasPage() {
|
||||
});
|
||||
}, [assets, assetsHydrated, handleProjectAssetsInsert, message, nodesRef, projectId, projectLoaded, searchParams, setSearchParams, updateProject]);
|
||||
|
||||
// 时间线弹窗内新增素材的回填通道:素材库/上传创建节点后由弹窗通过 ref 加入草稿。
|
||||
const timelineAddNodeRef = useRef<((node: CanvasNodeData) => void) | null>(null);
|
||||
// 时间线作用域直连媒体入轨通道:素材库/项目资产/本地上传不落画布,仅加入时间线草稿。
|
||||
const timelineMediaAddRef = useRef<((media: TimelineDirectMedia) => void) | null>(null);
|
||||
// 素材库与项目资产弹窗的插入作用域:时间线弹窗内打开时为 timeline,其余为 canvas。
|
||||
const [assetInsertScope, setAssetInsertScope] = useState<"canvas" | "timeline">("canvas");
|
||||
const [projectAssetScope, setProjectAssetScope] = useState<"canvas" | "timeline">("canvas");
|
||||
|
||||
// InsertAssetPayload → 直连媒体:仅音视频支持直接入轨;图片/文本/角色返回 null(避免在画布重复出现)。
|
||||
const payloadToTimelineMedia = (payload: InsertAssetPayload): TimelineDirectMedia | null => {
|
||||
const randomSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
if (payload.kind === "video") {
|
||||
return {
|
||||
id: payload.assetId || `asset-${randomSuffix}`,
|
||||
kind: "video",
|
||||
title: payload.title,
|
||||
storageKey: payload.storageKey,
|
||||
url: payload.url,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
durationMs: payload.durationMs,
|
||||
bytes: payload.bytes,
|
||||
mimeType: payload.mimeType,
|
||||
};
|
||||
}
|
||||
if (payload.kind === "audio") {
|
||||
return { id: payload.assetId || `asset-${randomSuffix}`, kind: "audio", title: payload.title, storageKey: payload.storageKey, url: payload.url, durationMs: payload.durationMs, bytes: payload.bytes, mimeType: payload.mimeType };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleLibraryAssetsInsert = useCallback(
|
||||
async (payloads: InsertAssetPayload[]) => {
|
||||
if (assetInsertScope === "timeline") {
|
||||
const media = payloads.map(payloadToTimelineMedia).filter((item): item is TimelineDirectMedia => Boolean(item));
|
||||
if (media.length !== payloads.length) throw new Error("图片和文本素材暂不支持直接入轨,请先插入画布");
|
||||
media.forEach((item) => timelineMediaAddRef.current?.(item));
|
||||
return;
|
||||
}
|
||||
const created = await handleAssetsInsert(payloads);
|
||||
created.forEach((node) => timelineAddNodeRef.current?.(node));
|
||||
},
|
||||
[assetInsertScope, handleAssetsInsert],
|
||||
);
|
||||
|
||||
// 项目资产库引入到时间线:复用现有引入逻辑,把创建出的节点回填到弹窗草稿。
|
||||
const handleTimelineProjectAssetsInsert = useCallback(
|
||||
async (payloads: InsertAssetPayload[]) => {
|
||||
if (projectAssetScope === "timeline") {
|
||||
let inserted = 0;
|
||||
for (const payload of payloads) {
|
||||
const media = payloadToTimelineMedia(payload);
|
||||
if (media) {
|
||||
timelineMediaAddRef.current?.(media);
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
if (inserted < payloads.length) message.info("图片/文本/角色素材暂不支持直接入轨,仅音视频素材已加入时间线");
|
||||
return;
|
||||
}
|
||||
const created = await handleProjectAssetsInsert(payloads, projectAssetInsertPosition);
|
||||
created.forEach((node) => timelineAddNodeRef.current?.(node));
|
||||
},
|
||||
[handleProjectAssetsInsert, message, projectAssetInsertPosition, projectAssetScope],
|
||||
);
|
||||
|
||||
const openProjectAssets = useCallback(
|
||||
(initialCategory = "all", position?: Position, scope: "canvas" | "timeline" = "canvas", initialFolderId = "all") => {
|
||||
setProjectAssetScope(scope);
|
||||
setProjectAssetInitialCategory(initialCategory);
|
||||
setProjectAssetInitialFolderId(initialFolderId);
|
||||
setProjectAssetInsertPosition(position);
|
||||
setProjectAssetOpen(true);
|
||||
// 资产与项目实时同步:打开弹窗前刷新关联短剧项目资产,避免缓存导致资产列表空白/过期。
|
||||
if (linkedProjectId) void refetchLinkedProject();
|
||||
},
|
||||
[linkedProjectId, refetchLinkedProject],
|
||||
);
|
||||
|
||||
// 素材库打开入口:画布作用域(工具栏/空态/侧栏)与时间线作用域(时间线弹窗)分别标记插入目标。
|
||||
const openCanvasAssetLibrary = useCallback(
|
||||
(position?: Position) => {
|
||||
setAssetInsertScope("canvas");
|
||||
openAssetsAtPosition(position);
|
||||
},
|
||||
[openAssetsAtPosition],
|
||||
);
|
||||
const openTimelineAssetLibrary = useCallback(() => {
|
||||
setAssetInsertScope("timeline");
|
||||
openAssetsAtPosition();
|
||||
}, [openAssetsAtPosition]);
|
||||
const closeProjectAssets = useCallback(() => {
|
||||
setProjectAssetOpen(false);
|
||||
setProjectAssetInsertPosition(undefined);
|
||||
setProjectAssetInitialFolderId("all");
|
||||
}, []);
|
||||
|
||||
const {
|
||||
angleNodeId,
|
||||
emotionNodeId,
|
||||
@@ -1739,14 +1661,14 @@ function InfiniteCanvasPage() {
|
||||
}, [arkPrivateAssetUploadNodeId, currentProject?.projectId, handleConfigNodeChange, message, projectId]);
|
||||
|
||||
const confirmUploadNodeImageToArkPrivateAsset = useCallback((node: CanvasNodeData) => {
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: "上传到方舟素材库",
|
||||
content: "仅可上传你拥有肖像、版权或其他合法使用权的图片。方舟审核通过后,Seedance 会使用受控素材标识生成视频。",
|
||||
okText: "确认拥有使用权并上传",
|
||||
cancelText: "取消",
|
||||
onOk: () => uploadNodeImageToArkPrivateAsset(node),
|
||||
});
|
||||
}, [uploadNodeImageToArkPrivateAsset]);
|
||||
}, [modal, uploadNodeImageToArkPrivateAsset]);
|
||||
|
||||
const handleCanvasContextMenu = useCallback(
|
||||
(event: ReactMouseEvent) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { buildGenerationConfig, isGenerationCanceled } from "@/lib/canvas/canvas
|
||||
import { canvasGenerationPromptMetadata, canvasGenerationRequestFingerprint, runCanvasGenerationSubmissionOnce } from "@/lib/canvas/canvas-generation-submission";
|
||||
import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch";
|
||||
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
|
||||
import { buildTextRewritePrompt } from "@/lib/prompts";
|
||||
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
|
||||
import { generationErrorMessage, generationFailureMetadata } from "@/lib/generation-error";
|
||||
import { modelCompatibilityError, modelGroupReferenceLimits, modelPromptLengthError, modelRequestOptions, type ModelRequirements } from "@/lib/model-selection";
|
||||
@@ -143,7 +144,7 @@ export function useCanvasGenerationExecutor({
|
||||
nodeId,
|
||||
nodesRef.current,
|
||||
connectionsRef.current,
|
||||
editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt,
|
||||
editingTextNode ? buildTextRewritePrompt(sourceTextContent, prompt) : generationPrompt,
|
||||
assets,
|
||||
promptOnly,
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
|
||||
import { compositeEmotionImage, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion";
|
||||
import { DEFAULT_PORTRAIT_TEXTURE_SETTINGS } from "@/lib/canvas/canvas-portrait-texture";
|
||||
import { IMAGE_PROMPT_REVERSE } from "@/lib/prompts";
|
||||
import { createPortraitTextureNode } from "@/lib/canvas/canvas-image-source";
|
||||
import { captureVideoFrames } from "@/lib/canvas/canvas-video-frame";
|
||||
import { buildVideoFrameNodes } from "@/lib/canvas/canvas-video-frame-nodes";
|
||||
@@ -71,12 +72,6 @@ const NODE_STATUS_LOADING = "loading" as const;
|
||||
const NODE_STATUS_SUCCESS = "success" as const;
|
||||
const NODE_STATUS_ERROR = "error" as const;
|
||||
const NODE_STATUS_IDLE = "idle" as const;
|
||||
const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用于 AI 生图的提示词。
|
||||
|
||||
要求:
|
||||
1. 只输出提示词正文,不要解释。
|
||||
2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。
|
||||
3. 尽量写成可直接用于生图模型的完整提示词。`;
|
||||
|
||||
export function useCanvasMediaTools({
|
||||
projectId,
|
||||
@@ -160,7 +155,7 @@ export function useCanvasMediaTools({
|
||||
const resultSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Text];
|
||||
const centerY = node.position.y + node.height / 2;
|
||||
const textNode = {
|
||||
...createCanvasNode(CanvasNodeType.Text, { x: node.position.x + node.width + gap + textSpec.width / 2, y: centerY }, { content: IMAGE_PROMPT_REVERSE_PRESET, prompt: IMAGE_PROMPT_REVERSE_PRESET, status: NODE_STATUS_SUCCESS, fontSize: 14 }),
|
||||
...createCanvasNode(CanvasNodeType.Text, { x: node.position.x + node.width + gap + textSpec.width / 2, y: centerY }, { content: IMAGE_PROMPT_REVERSE, prompt: IMAGE_PROMPT_REVERSE, status: NODE_STATUS_SUCCESS, fontSize: 14 }),
|
||||
title: "反推提示词",
|
||||
};
|
||||
const resultNode = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
|
||||
import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
@@ -244,8 +244,8 @@ export function useCanvasProjectLifecycle({
|
||||
await saveRemoteUserDataNow();
|
||||
message.success("画布布局和位置已保存");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : "未知错误";
|
||||
message.warning(`本地画布布局已保存,云端同步失败:${detail}`);
|
||||
// 本地 IndexedDB 已写入;云端失败只排队重试,不能把导入方的画布状态回滚。
|
||||
message.warning(localSavedRemotePendingMessage("本地画布布局已保存", error));
|
||||
}
|
||||
return true;
|
||||
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
|
||||
|
||||
138
web/src/pages/canvas/use-canvas-timeline-asset-insert.ts
Normal file
138
web/src/pages/canvas/use-canvas-timeline-asset-insert.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { App } from "antd";
|
||||
|
||||
import type { InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import type { CanvasNodeData, Position } from "@/types/canvas";
|
||||
import type { TimelineDirectMedia } from "@/types/timeline";
|
||||
|
||||
type UseCanvasTimelineAssetInsertOptions = {
|
||||
linkedProjectId?: string;
|
||||
refetchLinkedProject: () => Promise<unknown>;
|
||||
handleAssetsInsert: (payloads: InsertAssetPayload[]) => Promise<CanvasNodeData[]>;
|
||||
handleProjectAssetsInsert: (payloads: InsertAssetPayload[], position?: Position) => Promise<CanvasNodeData[]>;
|
||||
openAssetsAtPosition: (position?: Position) => void;
|
||||
};
|
||||
|
||||
function payloadToTimelineMedia(payload: InsertAssetPayload): TimelineDirectMedia | null {
|
||||
const randomSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
if (payload.kind === "video") {
|
||||
return {
|
||||
id: payload.assetId || `asset-${randomSuffix}`,
|
||||
kind: "video",
|
||||
title: payload.title,
|
||||
storageKey: payload.storageKey,
|
||||
url: payload.url,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
durationMs: payload.durationMs,
|
||||
bytes: payload.bytes,
|
||||
mimeType: payload.mimeType,
|
||||
};
|
||||
}
|
||||
if (payload.kind === "audio") {
|
||||
return { id: payload.assetId || `asset-${randomSuffix}`, kind: "audio", title: payload.title, storageKey: payload.storageKey, url: payload.url, durationMs: payload.durationMs, bytes: payload.bytes, mimeType: payload.mimeType };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useCanvasTimelineAssetInsert({
|
||||
linkedProjectId,
|
||||
refetchLinkedProject,
|
||||
handleAssetsInsert,
|
||||
handleProjectAssetsInsert,
|
||||
openAssetsAtPosition,
|
||||
}: UseCanvasTimelineAssetInsertOptions) {
|
||||
const { message } = App.useApp();
|
||||
// 时间线弹窗内新增素材的回填通道:素材库/上传创建节点后由弹窗通过 ref 加入草稿。
|
||||
const timelineAddNodeRef = useRef<((node: CanvasNodeData) => void) | null>(null);
|
||||
// 时间线作用域直连媒体入轨通道:素材库/项目资产/本地上传不落画布,仅加入时间线草稿。
|
||||
const timelineMediaAddRef = useRef<((media: TimelineDirectMedia) => void) | null>(null);
|
||||
// 素材库与项目资产弹窗的插入作用域:时间线弹窗内打开时为 timeline,其余为 canvas。
|
||||
const [assetInsertScope, setAssetInsertScope] = useState<"canvas" | "timeline">("canvas");
|
||||
const [projectAssetScope, setProjectAssetScope] = useState<"canvas" | "timeline">("canvas");
|
||||
const [projectAssetOpen, setProjectAssetOpen] = useState(false);
|
||||
const [projectAssetInitialCategory, setProjectAssetInitialCategory] = useState("all");
|
||||
const [projectAssetInitialFolderId, setProjectAssetInitialFolderId] = useState("all");
|
||||
const [projectAssetInsertPosition, setProjectAssetInsertPosition] = useState<Position | undefined>();
|
||||
|
||||
const handleLibraryAssetsInsert = useCallback(
|
||||
async (payloads: InsertAssetPayload[]) => {
|
||||
if (assetInsertScope === "timeline") {
|
||||
const media = payloads.map(payloadToTimelineMedia).filter((item): item is TimelineDirectMedia => Boolean(item));
|
||||
if (media.length !== payloads.length) throw new Error("图片和文本素材暂不支持直接入轨,请先插入画布");
|
||||
media.forEach((item) => timelineMediaAddRef.current?.(item));
|
||||
return;
|
||||
}
|
||||
const created = await handleAssetsInsert(payloads);
|
||||
created.forEach((node) => timelineAddNodeRef.current?.(node));
|
||||
},
|
||||
[assetInsertScope, handleAssetsInsert],
|
||||
);
|
||||
|
||||
const handleTimelineProjectAssetsInsert = useCallback(
|
||||
async (payloads: InsertAssetPayload[]) => {
|
||||
if (projectAssetScope === "timeline") {
|
||||
let inserted = 0;
|
||||
for (const payload of payloads) {
|
||||
const media = payloadToTimelineMedia(payload);
|
||||
if (media) {
|
||||
timelineMediaAddRef.current?.(media);
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
if (inserted < payloads.length) message.info("图片/文本/角色素材暂不支持直接入轨,仅音视频素材已加入时间线");
|
||||
return;
|
||||
}
|
||||
const created = await handleProjectAssetsInsert(payloads, projectAssetInsertPosition);
|
||||
created.forEach((node) => timelineAddNodeRef.current?.(node));
|
||||
},
|
||||
[handleProjectAssetsInsert, message, projectAssetInsertPosition, projectAssetScope],
|
||||
);
|
||||
|
||||
const openProjectAssets = useCallback(
|
||||
(initialCategory = "all", position?: Position, scope: "canvas" | "timeline" = "canvas", initialFolderId = "all") => {
|
||||
setProjectAssetScope(scope);
|
||||
setProjectAssetInitialCategory(initialCategory);
|
||||
setProjectAssetInitialFolderId(initialFolderId);
|
||||
setProjectAssetInsertPosition(position);
|
||||
setProjectAssetOpen(true);
|
||||
// 资产与项目实时同步:打开弹窗前刷新关联短剧项目资产,避免缓存导致资产列表空白/过期。
|
||||
if (linkedProjectId) void refetchLinkedProject();
|
||||
},
|
||||
[linkedProjectId, refetchLinkedProject],
|
||||
);
|
||||
|
||||
const openCanvasAssetLibrary = useCallback(
|
||||
(position?: Position) => {
|
||||
setAssetInsertScope("canvas");
|
||||
openAssetsAtPosition(position);
|
||||
},
|
||||
[openAssetsAtPosition],
|
||||
);
|
||||
const openTimelineAssetLibrary = useCallback(() => {
|
||||
setAssetInsertScope("timeline");
|
||||
openAssetsAtPosition();
|
||||
}, [openAssetsAtPosition]);
|
||||
const closeProjectAssets = useCallback(() => {
|
||||
setProjectAssetOpen(false);
|
||||
setProjectAssetInsertPosition(undefined);
|
||||
setProjectAssetInitialFolderId("all");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
timelineAddNodeRef,
|
||||
timelineMediaAddRef,
|
||||
assetInsertScope,
|
||||
projectAssetScope,
|
||||
projectAssetOpen,
|
||||
projectAssetInitialCategory,
|
||||
projectAssetInitialFolderId,
|
||||
projectAssetInsertPosition,
|
||||
handleLibraryAssetsInsert,
|
||||
handleTimelineProjectAssetsInsert,
|
||||
openProjectAssets,
|
||||
openCanvasAssetLibrary,
|
||||
openTimelineAssetLibrary,
|
||||
closeProjectAssets,
|
||||
};
|
||||
}
|
||||
139
web/src/pages/create/creation-conversations.ts
Normal file
139
web/src/pages/create/creation-conversations.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
import type { BackendGenerationResult } from "@/services/api/generation-task";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { creationAttachmentKind, type CreationAttachment } from "./creation-assets";
|
||||
import type { CreationConversation, CreationMessage, CreationShotRailEntry } from "./creation-types";
|
||||
|
||||
type CreationRuntime = typeof import("./creation-runtime");
|
||||
type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationError?: string };
|
||||
|
||||
export function newConversation(): CreationConversation {
|
||||
return { id: createClientId(), title: "新创作", updatedAt: new Date().toISOString(), messages: [] };
|
||||
}
|
||||
|
||||
export function newMessage(role: CreationMessage["role"], content: string, extra: Partial<CreationMessage> = {}): CreationMessage {
|
||||
return { id: createClientId(), role, content, createdAt: new Date().toISOString(), ...extra };
|
||||
}
|
||||
|
||||
export function creationShotRail(messages: CreationMessage[]): CreationShotRailEntry[] {
|
||||
const rail: CreationShotRailEntry[] = [];
|
||||
let ordinal = 0;
|
||||
let lastIndex = -1;
|
||||
for (const message of messages) {
|
||||
const isVideo = (message.mode || "text") === "video";
|
||||
if (message.role === "user") {
|
||||
if (!isVideo) continue;
|
||||
ordinal += 1;
|
||||
rail.push({ key: message.id, ordinal, user: message });
|
||||
lastIndex = rail.length - 1;
|
||||
} else if (isVideo && lastIndex >= 0 && !rail[lastIndex].result) {
|
||||
rail[lastIndex].result = message;
|
||||
}
|
||||
}
|
||||
return rail;
|
||||
}
|
||||
|
||||
export function creationVideoShotOrdinal(shots: CreationShotRailEntry[], item: CreationMessage): number {
|
||||
const own = shots.find((entry) => item.role === "user" ? entry.user.id === item.id : entry.result?.id === item.id);
|
||||
return own?.ordinal || 0;
|
||||
}
|
||||
|
||||
export function completedCreationGenerationTask(runtime: CreationRuntime, input: { taskId: string; task?: GenerationTask; mode: "image" | "video"; prompt: string; result: BackendGenerationResult; conversationId: string; messageId: string; batchIndex?: number; batchCount?: number }): GenerationTask {
|
||||
const now = new Date().toISOString();
|
||||
const task = input.task ?? { id: input.taskId, type: input.mode, status: "succeeded" as const, prompt: input.prompt, attempts: 1, createdAt: now, updatedAt: now };
|
||||
return runtime.projectGenerationTaskResult({ ...task, status: "succeeded", prompt: input.prompt, clientContext: { conversationId: input.conversationId, messageId: input.messageId, ...(typeof input.batchIndex === "number" ? { batchIndex: input.batchIndex } : {}), ...(typeof input.batchCount === "number" ? { batchCount: input.batchCount } : {}) } }, input.result);
|
||||
}
|
||||
|
||||
export function isVideoAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { url: string } {
|
||||
return creationAttachmentKind(attachment) === "video";
|
||||
}
|
||||
|
||||
export function isImageAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { dataUrl: string; width?: number; height?: number } {
|
||||
return creationAttachmentKind(attachment) === "image";
|
||||
}
|
||||
|
||||
|
||||
export function attachCreationTaskContexts(tasks: GenerationTask[], conversations: CreationConversation[]) {
|
||||
const contexts = new Map<string, { prompt: string; clientContext: NonNullable<GenerationTask["clientContext"]> }>();
|
||||
for (const conversation of conversations) {
|
||||
for (const [messageIndex, message] of conversation.messages.entries()) {
|
||||
if (message.role !== "assistant" || !message.taskIds?.length) continue;
|
||||
const prompt = conversation.messages[messageIndex - 1]?.role === "user" ? conversation.messages[messageIndex - 1].content : "";
|
||||
for (const [batchIndex, taskId] of message.taskIds.entries()) contexts.set(taskId, { prompt, clientContext: { conversationId: conversation.id, messageId: message.id, batchIndex, batchCount: message.taskIds.length } });
|
||||
}
|
||||
}
|
||||
return tasks.map((task) => {
|
||||
const context = contexts.get(task.id);
|
||||
return context ? { ...task, prompt: context.prompt, clientContext: context.clientContext } : task;
|
||||
});
|
||||
}
|
||||
|
||||
export async function materializeCreationTaskResults(runtime: CreationRuntime, tasks: GenerationTask[], signal?: AbortSignal): Promise<PersistedCreationTask[]> {
|
||||
return Promise.all(tasks.map(async (task): Promise<PersistedCreationTask> => {
|
||||
// 文本正文保存在 resultJson,不进入媒体资源化链路。
|
||||
if (task.status !== "succeeded" || !task.clientContext || task.type === "canvas_text") return task;
|
||||
try {
|
||||
const materialized = await runtime.runGenerationConsumer(signal, (managedSignal: AbortSignal) => runtime.materializeGenerationTaskAssets(task, managedSignal));
|
||||
const creationResultUrls = runtime.generationTaskMaterializedUrls(materialized);
|
||||
return creationResultUrls.length ? { ...materialized, creationResultUrls } : materialized;
|
||||
} catch (error) {
|
||||
return { ...task, creationError: error instanceof Error ? error.message : "生成结果资源化失败" };
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export function reconcileCreationTaskMessages(runtime: CreationRuntime, conversations: CreationConversation[], tasks: PersistedCreationTask[]) {
|
||||
let changed = false;
|
||||
const next = conversations.map((conversation) => {
|
||||
let conversationChanged = false;
|
||||
let completedAt = conversation.updatedAt;
|
||||
const messages = conversation.messages.map((message) => {
|
||||
const taskIds = new Set(message.taskIds || []);
|
||||
const matches = tasks
|
||||
.filter((task) => taskIds.has(task.id) || (task.clientContext?.conversationId === conversation.id && task.clientContext.messageId === message.id))
|
||||
.sort((left, right) => (left.clientContext?.batchIndex || 0) - (right.clientContext?.batchIndex || 0));
|
||||
if (message.role === "assistant" && message.mode === "text") {
|
||||
const recovery = runtime.recoverCreationTextTask(message, matches);
|
||||
if (!recovery) return message;
|
||||
completedAt = matches.reduce((latest, task) => conversationTimestamp(task.updatedAt) > conversationTimestamp(latest) ? task.updatedAt : latest, completedAt);
|
||||
conversationChanged = true;
|
||||
changed = true;
|
||||
return { ...message, ...recovery };
|
||||
}
|
||||
if (message.role !== "assistant" || message.status !== "pending") return message;
|
||||
const expectedTaskCount = Math.max(0, ...matches.map((task) => task.clientContext?.batchCount || 0));
|
||||
if (!matches.length || (expectedTaskCount > 0 && matches.length < expectedTaskCount) || matches.some((task) => task.status === "queued" || task.status === "running")) return message;
|
||||
|
||||
const resultUrls = Array.from(new Set(matches.filter((task) => task.status === "succeeded").flatMap(creationTaskResultUrls)));
|
||||
const failedCount = matches.filter((task) => task.status !== "succeeded" || Boolean(task.creationError)).length;
|
||||
const nextTaskIds = Array.from(new Set([...(message.taskIds || []), ...matches.map((task) => task.id)]));
|
||||
completedAt = matches.reduce((latest, task) => conversationTimestamp(task.updatedAt) > conversationTimestamp(latest) ? task.updatedAt : latest, completedAt);
|
||||
conversationChanged = true;
|
||||
changed = true;
|
||||
|
||||
if (resultUrls.length) {
|
||||
const content = message.mode === "video" ? "视频已生成" : failedCount ? `${resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成";
|
||||
return { ...message, status: "done" as const, content, resultUrls, error: undefined, taskIds: nextTaskIds };
|
||||
}
|
||||
if (matches.every((task) => task.status === "cancelled")) {
|
||||
const localOnly = matches.find(runtime.isLocalDreaminaWaitStopped);
|
||||
return { ...message, status: "cancelled" as const, content: localOnly ? runtime.localDreaminaCancellationMessage(localOnly) : "已停止", error: undefined, taskIds: nextTaskIds };
|
||||
}
|
||||
const failed = matches.find((task) => task.status === "failed" || task.creationError);
|
||||
return { ...message, status: "error" as const, content: "生成失败", error: generationErrorMessage(failed?.creationError || failed?.error || "任务已结束,但生成结果暂时无法读取"), taskIds: nextTaskIds };
|
||||
});
|
||||
return conversationChanged ? { ...conversation, messages, updatedAt: completedAt } : conversation;
|
||||
});
|
||||
return changed ? next : conversations;
|
||||
}
|
||||
|
||||
function creationTaskResultUrls(task: PersistedCreationTask) {
|
||||
if (task.creationResultUrls?.length) return task.creationResultUrls;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function conversationTimestamp(value: string) {
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
63
web/src/pages/create/creation-types.ts
Normal file
63
web/src/pages/create/creation-types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { GenerationRetryContext } from "@/lib/canvas/canvas-project-generation";
|
||||
import { formatVideoResolutionLabel as videoResolutionLabel, VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
|
||||
import type { CreationAttachment, CreationMode } from "./creation-assets";
|
||||
import type { CreationReference } from "./creation-references";
|
||||
|
||||
export type { CreationMode };
|
||||
|
||||
export type CreationStatus = "streaming" | "pending" | "done" | "error" | "cancelled";
|
||||
export type CreationSettings = { ratio: string; seconds: string; quality: string; videoQuality: string; count: string };
|
||||
export type CreationRetryContext = GenerationRetryContext & { retryContextsByBatchIndex?: GenerationRetryContext[] };
|
||||
|
||||
export type CreationMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
mode?: CreationMode;
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
createdAt: string;
|
||||
status?: CreationStatus;
|
||||
model?: string;
|
||||
resultUrls?: string[];
|
||||
error?: string;
|
||||
generationErrorCode?: string;
|
||||
generationOperation?: string;
|
||||
attachments?: CreationAttachment[];
|
||||
references?: CreationReference[];
|
||||
settings?: CreationSettings;
|
||||
taskIds?: string[];
|
||||
clientOperationId?: string;
|
||||
retryOf?: string;
|
||||
attemptGroupId?: string;
|
||||
generationStage?: string;
|
||||
generationEffectKeys?: string[];
|
||||
};
|
||||
export type CreationConversation = { id: string; title: string; updatedAt: string; messages: CreationMessage[] };
|
||||
|
||||
export const modeLabels: Record<CreationMode, string> = { text: "文本", image: "图片", video: "视频" };
|
||||
export const shotScriptLabels: Record<CreationMode, string> = { text: "创作思路", image: "画面指令", video: "镜头脚本" };
|
||||
export const ratioOptions = [
|
||||
{ value: "1:1", label: "方形" },
|
||||
{ value: "16:9", label: "横屏" },
|
||||
{ value: "9:16", label: "竖屏" },
|
||||
{ value: "4:3", label: "标准横屏" },
|
||||
{ value: "3:4", label: "标准竖屏" },
|
||||
{ value: "21:9", label: "宽银幕" },
|
||||
];
|
||||
export const qualityOptions = [
|
||||
{ value: "auto", label: "自动", description: "由模型决定" },
|
||||
{ value: "low", label: "低", description: "更快生成" },
|
||||
{ value: "medium", label: "中", description: "均衡模式" },
|
||||
{ value: "high", label: "高", description: "优先细节" },
|
||||
// grok2api / xAI Imagine:quality 映射 resolution
|
||||
{ value: "1k", label: "1K", description: "标准清晰度" },
|
||||
{ value: "2k", label: "2K", description: "更高清晰度" },
|
||||
];
|
||||
export const resolutionOptions = VIDEO_RESOLUTION_OPTIONS.map((value) => ({ value: String(value), label: videoResolutionLabel(value) }));
|
||||
export const countOptions = ["1", "2", "3", "4"];
|
||||
export const conversationTimeFormatter = new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
export const messageTimeFormatter = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
|
||||
export const historyDayFormatter = new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric" });
|
||||
|
||||
export type CreationShotRailEntry = { key: string; ordinal: number; user: CreationMessage; result?: CreationMessage };
|
||||
831
web/src/pages/create/creation-workspace.tsx
Normal file
831
web/src/pages/create/creation-workspace.tsx
Normal file
@@ -0,0 +1,831 @@
|
||||
import { ImageSizePicker } from "@/components/image-size-picker";
|
||||
import { imageResolutionUsesQuality } from "@/lib/image-size-presets";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode, type RefObject } from "react";
|
||||
import { App, Button, Dropdown, Popover } from "antd";
|
||||
import { AppDrawer } from "@/components/ui/product/app-drawer";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { Reorder } from "motion/react";
|
||||
import { ArrowDown, ArrowUp, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, MoreHorizontal, Music2, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, UserRound, WandSparkles, Waves, X } from "lucide-react";
|
||||
|
||||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||||
import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card";
|
||||
import { WorkingDots, WorkingGlow } from "@/components/ai/working-indicator";
|
||||
import { MessageReasoning } from "@/components/ai/message-reasoning";
|
||||
import { Link } from "react-router";
|
||||
import { creationCanvasHandoffPath, creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
import { formatVideoResolutionLabel as videoResolutionLabel } from "@/lib/video-generation-options";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { CanvasImagePreview } from "@/components/canvas/canvas-image-preview";
|
||||
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
|
||||
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category";
|
||||
import { formatShotOrdinal } from "@/lib/shot-label";
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { buildImageResolutionOptions, formatImageResolutionSize, supportsImageResolutionPresets } from "@/lib/image-resolution-tiers";
|
||||
import { modelCapabilityConfigFor, normalizeVideoValue, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import { resolveResourceUrl } from "@/services/api/resources";
|
||||
import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { PromptOptimizerProvider } from "@/lib/plugins/plugin-types";
|
||||
import { displayCreationPrompt, type CreationReference } from "./creation-references";
|
||||
import { creationAttachmentKind, creationMediaAspectRatio, removeCreationAttachment, type CreationAttachment, type CreationMode } from "./creation-assets";
|
||||
import { conversationTimestamp, isImageAttachment, isVideoAttachment } from "./creation-conversations";
|
||||
import { conversationTimeFormatter, countOptions, historyDayFormatter, messageTimeFormatter, modeLabels, qualityOptions, ratioOptions, resolutionOptions, shotScriptLabels, type CreationConversation, type CreationMessage, type CreationShotRailEntry, type CreationStatus } from "./creation-types";
|
||||
|
||||
const CanvasPromptOptimizerDrawer = lazy(() => import("@/components/canvas/canvas-prompt-optimizer-drawer").then((module) => ({ default: module.CanvasPromptOptimizerDrawer })));
|
||||
|
||||
export const creationAssetCategoryLabels: Record<string, string> = { all: "全部素材", ...ASSET_CATEGORY_LABELS };
|
||||
|
||||
|
||||
function creationConversationBucket(updatedAt: string): "today" | "yesterday" | "week" | "earlier" {
|
||||
const at = new Date(updatedAt).getTime();
|
||||
if (!Number.isFinite(at)) return "earlier";
|
||||
const nowStart = new Date();
|
||||
nowStart.setHours(0, 0, 0, 0);
|
||||
const targetStart = new Date(at);
|
||||
targetStart.setHours(0, 0, 0, 0);
|
||||
const days = Math.round((nowStart.getTime() - targetStart.getTime()) / 86400000);
|
||||
if (days <= 0) return "today";
|
||||
if (days === 1) return "yesterday";
|
||||
if (days < 7) return "week";
|
||||
return "earlier";
|
||||
}
|
||||
|
||||
const creationBucketLabels: Record<"today" | "yesterday" | "week" | "earlier", string> = { today: "今天", yesterday: "昨天", week: "近 7 天", earlier: "更早" };
|
||||
export function CreationHistoryDrawer({ open, conversations, activeId, onNew, onClose, onSelect, onDelete, onRename }: { open: boolean; conversations: CreationConversation[]; activeId: string; onNew: () => void; onClose: () => void; onSelect: (conversation: CreationConversation) => void; onDelete: (conversation: CreationConversation) => void; onRename: (conversation: CreationConversation, title: string) => void }) {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const skipRenameCommitRef = useRef(false);
|
||||
|
||||
const assistantName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const exportUser = useUserStore((state) => state.user);
|
||||
|
||||
const { message: drawerToast } = App.useApp();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setKeyword("");
|
||||
setRenamingId(null);
|
||||
setMenuOpenId(null);
|
||||
}, [open]);
|
||||
|
||||
const commitRename = (conversation: CreationConversation) => {
|
||||
const shouldSkip = skipRenameCommitRef.current;
|
||||
skipRenameCommitRef.current = false;
|
||||
const value = renameInputRef.current?.value.trim() || "";
|
||||
setRenamingId(null);
|
||||
if (shouldSkip || !value) return;
|
||||
const original = conversation.title.trim() || "新创作";
|
||||
if (value === original) return;
|
||||
onRename(conversation, value);
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
skipRenameCommitRef.current = true;
|
||||
setRenamingId(null);
|
||||
};
|
||||
|
||||
const beginRename = (conversation: CreationConversation) => {
|
||||
skipRenameCommitRef.current = false;
|
||||
setMenuOpenId(null);
|
||||
setRenamingId(conversation.id);
|
||||
};
|
||||
|
||||
const visibleConversations = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
if (!query) return conversations;
|
||||
return conversations.filter((conversation) => {
|
||||
const latest = conversationPreviewMessage(conversation);
|
||||
const searchable = [
|
||||
conversation.title,
|
||||
...conversation.messages.flatMap((message) => [message.content, displayCreationPrompt(message.content, message.references || [])]),
|
||||
latest?.mode ? modeLabels[latest.mode] : "创作",
|
||||
formatConversationTime(conversation.updatedAt),
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
return searchable.includes(query);
|
||||
});
|
||||
}, [conversations, keyword]);
|
||||
|
||||
|
||||
return <AppDrawer flush open={open} onClose={onClose} placement="right" size="min(440px, 100vw)" closeIcon={<X className="size-4" />} className="creation-history-drawer" rootClassName="creation-history-drawer-root" title={<div className="creation-history-title"><span>历史对话</span><small>{conversations.length} 个对话</small></div>}>
|
||||
<div className="creation-history-content">
|
||||
<label className="creation-history-search">
|
||||
<Search aria-hidden="true" />
|
||||
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索对话标题或内容" aria-label="搜索历史对话" />
|
||||
</label>
|
||||
|
||||
<button type="button" className="creation-history-new" onClick={onNew}><span className="creation-history-new-icon"><Plus /></span><span className="creation-history-new-copy"><strong>新建创作</strong><small>开启一个新的创作对话</small></span></button>
|
||||
{visibleConversations.length ? <ul className="creation-history-list" aria-label="历史对话,按更新时间倒序排列">
|
||||
{visibleConversations.flatMap((conversation, index) => {
|
||||
const showGroupHead = !keyword.trim() && (index === 0 || creationConversationBucket(conversation.updatedAt) !== creationConversationBucket(visibleConversations[index - 1].updatedAt));
|
||||
const latest = conversationPreviewMessage(conversation);
|
||||
const active = conversation.id === activeId;
|
||||
const HistoryTypeIcon = latest?.mode === "video" ? Clapperboard : latest?.mode === "image" ? ImageIcon : latest?.mode === "text" ? MessageSquareText : Sparkles;
|
||||
return [
|
||||
showGroupHead ? <li key={`${conversation.id}-group`} className="creation-history-group-head"><h4>{creationBucketLabels[creationConversationBucket(conversation.updatedAt)]}</h4></li> : null,
|
||||
<li key={conversation.id} className={active ? "is-active" : undefined}>
|
||||
{renamingId === conversation.id ? (
|
||||
<div className="creation-history-rename">
|
||||
<input ref={renameInputRef} className="creation-history-rename-input" defaultValue={conversation.title.trim() || "新创作"} aria-label="重命名对话标题" autoFocus onFocus={(event) => event.currentTarget.select()} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); event.currentTarget.blur(); } else if (event.key === "Escape") { cancelRename(); } }} onBlur={() => commitRename(conversation)} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={menuOpenId === conversation.id ? "creation-history-row is-menu-open" : "creation-history-row"}>
|
||||
<button type="button" className="creation-history-item-main" aria-current={active ? "page" : undefined} onClick={() => { setMenuOpenId(null); onSelect(conversation); }}>
|
||||
<span className="creation-history-item-icon" aria-hidden="true"><HistoryTypeIcon /></span>
|
||||
<span className="creation-history-item-text">
|
||||
<strong className="creation-history-item-heading">{conversation.title.trim() || "新创作"}</strong>
|
||||
<span className="creation-history-snippet">{latest ? <><em>{latest.mode ? modeLabels[latest.mode] : "创作"}</em><span>{displayCreationPrompt(latest.content, latest.references || []).trim() || "还没有开始创作"}</span></> : <><em>创作</em><span>还没有开始创作</span></>}</span>
|
||||
</span>
|
||||
</button>
|
||||
<span className="creation-history-time-slot" aria-hidden={menuOpenId === conversation.id}><time dateTime={conversation.updatedAt}>{formatHistoryRelativeTime(conversation.updatedAt)}</time></span>
|
||||
<Dropdown trigger={["click"]} placement="bottomRight" open={menuOpenId === conversation.id} onOpenChange={(open) => setMenuOpenId(open ? conversation.id : null)} overlayClassName="creation-history-menu-overlay" menu={{ items: [{ key: "rename", label: "重命名", icon: <Pencil /> }, { key: "export", label: "导出对话", icon: <Download /> }, { key: "delete", label: "删除对话", danger: true, icon: <Trash2 /> }], onClick: ({ key }) => { setMenuOpenId(null); if (key === "rename") { beginRename(conversation); } else if (key === "export") { downloadCreationConversation(conversation, assistantName, exportUser?.displayName || "你"); drawerToast.success("对话已导出为 Markdown"); } else { onDelete(conversation); } } }}>
|
||||
<button type="button" className={menuOpenId === conversation.id ? "creation-history-more is-open" : "creation-history-more"} aria-label={`更多操作:${conversation.title.trim() || "新创作"}`} onClick={(event) => event.preventDefault()}><MoreHorizontal /></button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</li>,
|
||||
];
|
||||
})}
|
||||
</ul> : <div className="creation-history-empty">{keyword.trim() ? "没有找到匹配的对话" : "暂无历史对话"}</div>}
|
||||
</div>
|
||||
</AppDrawer>;
|
||||
}
|
||||
|
||||
export function CreationWorkspaceToolbar({ shots, onJumpToShot, onNewConversation, onOpenHistory }: { shots: CreationShotRailEntry[]; onJumpToShot: (shot: CreationShotRailEntry) => void; onNewConversation: () => void; onOpenHistory: () => void }) {
|
||||
const [railOpen, setRailOpen] = useState(false);
|
||||
const railRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!railOpen) return;
|
||||
const onPointerDown = (event: MouseEvent) => { if (railRef.current && !railRef.current.contains(event.target as Node)) setRailOpen(false); };
|
||||
window.addEventListener("mousedown", onPointerDown);
|
||||
return () => window.removeEventListener("mousedown", onPointerDown);
|
||||
}, [railOpen]);
|
||||
return <header className="creation-thread-toolbar">
|
||||
<div className="creation-toolbar-shots" ref={railRef}>
|
||||
<button type="button" className="creation-rail-trigger" aria-expanded={railOpen} aria-haspopup="listbox" onClick={() => setRailOpen((open) => !open)}><Clapperboard />镜头时间线{shots.length > 0 ? <em className="creation-rail-count">{shots.length}</em> : null}</button>
|
||||
{railOpen ? <div className="creation-rail-pop" role="listbox" aria-label="镜头时间线">
|
||||
<div className="creation-rail-pop-head"><span className="creation-rail-pop-title">镜头时间线<small>{shots.length ? `共 ${shots.length} 镜` : "空轨道"}</small></span><button type="button" className="creation-rail-pop-close" aria-label="关闭镜头列表" onClick={() => setRailOpen(false)}><X /></button></div>
|
||||
{shots.length ? <ol className="creation-rail-list">{shots.map((shot) => {
|
||||
const resultStatus = shot.result?.status;
|
||||
const statusLabel = resultStatus === "done" ? "完成" : resultStatus === "error" ? "生成失败" : resultStatus === "pending" ? "生成中" : resultStatus === "cancelled" ? "已停止" : "待生成";
|
||||
return <li key={shot.key}><button type="button" role="option" aria-selected="false" className="creation-rail-row" onClick={() => { setRailOpen(false); onJumpToShot(shot); }}>
|
||||
<span className="creation-rail-row-shot">{formatShotOrdinal(shot.ordinal - 1)}</span>
|
||||
<span className="creation-rail-row-prompt">{shot.user.content || "视频镜头"}</span>
|
||||
<span className={`creation-rail-row-state is-${resultStatus || "idle"}`}>{statusLabel}</span>
|
||||
</button></li>;
|
||||
})}</ol> : <p className="creation-rail-empty">在下方发送一条视频消息,就会自动成为第 1 镜。</p>}
|
||||
</div> : null}
|
||||
</div>
|
||||
<div className="creation-toolbar-actions">
|
||||
<Tooltip title="新建创作"><button type="button" aria-label="新建创作" className="creation-toolbar-action" onClick={onNewConversation}><Plus /></button></Tooltip>
|
||||
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" className="creation-toolbar-action" onClick={onOpenHistory}><History /></button></Tooltip>
|
||||
</div>
|
||||
</header>;
|
||||
}
|
||||
|
||||
export function CreationMessageView({ item, shotNumber, onRetryFailure, onCreateVariant, onEditUserMessage }: { item: CreationMessage; shotNumber: number; onRetryFailure: () => void; onCreateVariant: () => void; onEditUserMessage: (text: string) => void }) {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
if (item.role === "user") return <CreationUserMessage item={item} shotNumber={shotNumber} onEditUserMessage={onEditUserMessage} />;
|
||||
const mode = item.mode || "text";
|
||||
const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : "";
|
||||
const heading =
|
||||
mode !== "text" ? (
|
||||
<>{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}<span className="creation-message-mark"><Sparkles /></span><strong>{mode === "image" ? "图像生成" : "视频生成"}</strong>{item.status === "pending" ? <span className="creation-message-progress-copy">{brandName}正在生成{mode === "video" ? "视频" : "图像"}……</span> : item.status === "done" ? <span className="creation-message-progress-copy">你的{mode === "video" ? "视频" : "图像"}已创建</span> : null}{item.status === "done" ? <button type="button" className="creation-message-variant-action" onClick={onCreateVariant}><RefreshCw />生成同款</button> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</>
|
||||
) : (
|
||||
<>{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}<span className="creation-message-mark"><Sparkles /></span><strong>{brandName}</strong>{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</>
|
||||
);
|
||||
const toolStatus: GenerationToolStatus = item.status === "pending" ? "running" : item.status === "error" ? "error" : item.status === "cancelled" ? "cancelled" : "completed";
|
||||
return <article className={`creation-assistant-message is-${mode}`}>
|
||||
{mode === "text" ? <><div className="creation-message-heading">{heading}</div>{item.reasoning ? <div className="creation-message-reasoning-wrap"><MessageReasoning reasoning={item.reasoning} isStreaming={item.status === "streaming"} /></div> : null}<div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}><WorkingDots dotSize={5} gap={2} /><span>正在生成…</span></span>}</div></> : <GenerationToolCard status={toolStatus} heading={heading}><MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} /></GenerationToolCard>}
|
||||
{item.error && mode === "text" ? <div className="creation-message-error"><span>{generationErrorMessage(item.error)}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div> : null}
|
||||
</article>;
|
||||
}
|
||||
|
||||
function CreationUserMessage({ item, shotNumber, onEditUserMessage }: { item: CreationMessage; shotNumber: number; onEditUserMessage: (text: string) => void }) {
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const copyText = useCopyText();
|
||||
const visiblePrompt = displayCreationPrompt(item.content, item.references || []);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const userAvatarUrl = user?.avatarUrl?.trim();
|
||||
return <article className="creation-user-message">
|
||||
<div className="creation-user-message-meta">{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}<strong>{user?.displayName || "你"}</strong><span className="creation-user-avatar">{userAvatarUrl ? <img src={userAvatarUrl} alt="" referrerPolicy="no-referrer" loading="lazy" decoding="async" /> : <UserRound />}</span></div>
|
||||
<div className="creation-user-message-copy-wrap"><p>{visiblePrompt}</p></div>
|
||||
{item.references?.length ? <CreationMessageReferences references={item.references} /> : null}
|
||||
{item.attachments?.length ? <div className="creation-user-message-attachments">{item.attachments.map((attachment) => {
|
||||
const kind = creationAttachmentKind(attachment);
|
||||
const previewable = kind === "image" || kind === "video";
|
||||
const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || "";
|
||||
const imageUrl = kind === "image" ? resolveResourceUrl(attachment.storageKey, url) : "";
|
||||
const previewUrl = kind === "image" ? imageUrl : url;
|
||||
return <button key={attachment.id} type="button" className={!previewable ? "is-file" : undefined} onClick={() => { if (!previewable) return; setPreviewType(kind === "video" ? "video" : "image"); setPreviewUrl(kind === "video" ? attachment.url || "" : previewUrl); }} aria-label={previewable ? `预览 ${attachment.name || "附件"}` : attachment.name || "附件"} disabled={previewable && !previewUrl}>{kind === "video" ? <video src={attachment.url || ""} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : kind === "image" ? <CachedResourceImage storageKey={attachment.storageKey} src={imageUrl} alt={attachment.name || "附件"} width={44} height={44} loading="lazy" decoding="async" /> : kind === "audio" ? <Music2 /> : <FileText />}{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}</button>;
|
||||
})}</div> : null}
|
||||
<div className="creation-user-message-actions"><Tooltip title="复制提示词"><button type="button" className="creation-user-message-copy" aria-label="复制提示词" onClick={() => copyText(visiblePrompt, "提示词已复制")}><Copy /></button></Tooltip><Tooltip title="编辑并重新发送"><button type="button" className="creation-user-message-edit" aria-label="编辑提示词" onClick={() => onEditUserMessage(visiblePrompt)}><Pencil /></button></Tooltip></div>
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
</article>;
|
||||
}
|
||||
|
||||
function MediaResult({ item, onRetryFailure, onCreateVariant }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void }) {
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const resultUrls = item.resultUrls || [];
|
||||
const resultAssetIds = resultUrls.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls }) : [];
|
||||
const canvasHandoffPath = creationCanvasHandoffPath(resultAssetIds, resultUrls.length);
|
||||
const canvasPath = canvasHandoffPath || "/canvas";
|
||||
if (item.status === "pending") return <CreationMediaPending mode={item.mode || "image"} ratio={item.settings?.ratio} />;
|
||||
if ((item.status === "error" || item.status === "cancelled") && !resultUrls.length) return <div className="creation-media-error"><span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
|
||||
if (!resultUrls.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
|
||||
const isVideo = item.mode === "video";
|
||||
return <div className="creation-media-result">
|
||||
{isVideo ? <button type="button" className="creation-video-result" onClick={() => { setPreviewType("video"); setPreviewUrl(resultUrls[0]); }} aria-label="预览生成视频"><video muted preload="metadata" src={resultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{resultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => { setPreviewType("image"); setPreviewUrl(url); }} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
|
||||
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}</span><Link to={canvasPath}>{canvasHandoffPath ? "添加到画布" : "打开画布"}</Link>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function CreationMediaPending({ mode, ratio }: { mode: CreationMode; ratio?: string }) {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
return <div className={`creation-media-pending is-${mode}`} style={{ aspectRatio: creationMediaAspectRatio(ratio, mode) }} aria-live="polite"><span className="creation-media-pending-icon"><WorkingDots dotSize={7} gap={3} minOpacity={0.3} /></span><span className="sr-only">{brandName}正在生成{mode === "video" ? "视频" : "图像"}</span></div>;
|
||||
}
|
||||
|
||||
function CreationMessageReferences({ references }: { references: CreationReference[] }) {
|
||||
return <div className="creation-user-message-references" aria-label="本次引用">{references.map((reference) => {
|
||||
const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText;
|
||||
const imageUrl = reference.kind === "image" ? resolveResourceUrl(reference.storageKey, reference.previewUrl) : reference.previewUrl;
|
||||
return <span key={reference.id} className="creation-user-message-reference">{imageUrl && reference.kind === "video" ? <video src={imageUrl} muted playsInline preload="metadata" aria-label={reference.label} /> : imageUrl && reference.kind === "image" ? <CachedResourceImage storageKey={reference.storageKey} src={imageUrl} alt="" loading="lazy" decoding="async" /> : <Icon />}<span>{reference.label}</span></span>;
|
||||
})}</div>;
|
||||
}
|
||||
|
||||
function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: "image" | "video"; onClose: () => void }) {
|
||||
if (type === "image") return <CanvasImagePreview src={url} alt="媒体预览" onClose={onClose} />;
|
||||
|
||||
return <AppModal flush open={Boolean(url)} title={null} footer={null} centered destroyOnHidden width="min(1160px, calc(100vw - 32px))" onCancel={onClose} className="creation-media-preview-modal">
|
||||
{url ? <video controls autoPlay className="creation-media-preview-video" src={url} /> : null}
|
||||
</AppModal>;
|
||||
}
|
||||
|
||||
function CreationAttachmentThumbnail({ item, onPreview, onRemove }: {
|
||||
item: CreationAttachment;
|
||||
onPreview: (type: "image" | "video", url: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
const kind = creationAttachmentKind(item);
|
||||
const previewable = kind === "image" || kind === "video";
|
||||
const url = (kind === "video" ? item.url : item.previewUrl) || "";
|
||||
const imageUrl = kind === "image" ? resolveResourceUrl(item.storageKey, item.previewUrl) : "";
|
||||
const previewUrl = kind === "image" ? imageUrl : url;
|
||||
const content = kind === "video" ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : kind === "image" ? <CachedResourceImage storageKey={item.storageKey} src={imageUrl} alt={item.name} loading="lazy" decoding="async" fallback={<span className="creation-chat-file-icon"><ImageIcon /></span>} /> : <span className="creation-chat-file-icon">{kind === "audio" ? <Music2 /> : <FileText />}<em>{item.name}</em></span>;
|
||||
return <div className="creation-reference-card-content">
|
||||
{previewable ? <button type="button" className="creation-reference-card-preview" onClick={() => onPreview(kind === "video" ? "video" : "image", previewUrl)} aria-label={`放大预览 ${item.name}`} disabled={!previewUrl}>{content}<span aria-hidden="true"><Maximize2 /></span></button> : <div className="creation-reference-card-preview is-file" aria-label={item.name}>{content}</div>}
|
||||
<button type="button" className="creation-reference-card-remove" onPointerDownCapture={(event) => event.stopPropagation()} onMouseDownCapture={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onRemove(item.id); }} aria-label={`移除 ${item.name}`}><X /></button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
type ComposerProps = {
|
||||
variant: "empty" | "thread";
|
||||
mode: CreationMode;
|
||||
prompt: string;
|
||||
setPrompt: (value: string) => void;
|
||||
busy: boolean;
|
||||
generationActive: boolean;
|
||||
referenceReplacementBusy: boolean;
|
||||
attachments: CreationAttachment[];
|
||||
referenceImageSize?: { width: number; height: number };
|
||||
maxReferences: number;
|
||||
references: CreationReference[];
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
onClearAttachments: () => void;
|
||||
onClearComposer: () => void;
|
||||
onReorderAttachments: (attachments: CreationAttachment[]) => void;
|
||||
onReplaceAttachment: (targetAttachmentId: string, replacement: CreationAttachment) => void;
|
||||
onReplaceReferenceFiles: (targetAttachmentId: string, files: File[]) => void;
|
||||
onOpenLibrary: () => void;
|
||||
onModeChange: (mode: CreationMode) => void;
|
||||
model: string;
|
||||
modelRequirements: ModelRequirements;
|
||||
videoProfile: VideoCapabilityConfig;
|
||||
imageProfile: ImageCapabilityConfig;
|
||||
config: AiConfig;
|
||||
onModelChange: (value: string) => void;
|
||||
ratio: string;
|
||||
setRatio: (value: string) => void;
|
||||
seconds: string;
|
||||
setSeconds: (value: string) => void;
|
||||
quality: string;
|
||||
setQuality: (value: string) => void;
|
||||
videoQuality: string;
|
||||
setVideoQuality: (value: string) => void;
|
||||
count: string;
|
||||
setCount: (value: string) => void;
|
||||
textStreaming: boolean;
|
||||
setTextStreaming: (value: boolean) => void;
|
||||
textThinking: boolean;
|
||||
setTextThinking: (value: boolean) => void;
|
||||
promptOptimizerProvider: PromptOptimizerProvider | null;
|
||||
composerFocusRef: RefObject<HTMLTextAreaElement | null>;
|
||||
onPromptFocus: () => void;
|
||||
placeholderOverride?: string;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
type CreationReferenceFilter = "all" | "image" | "video" | "audio" | "file";
|
||||
|
||||
export function CreationComposer(props: ComposerProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false);
|
||||
const [referenceFilter, setReferenceFilter] = useState<CreationReferenceFilter>("all");
|
||||
const [canDragReferences, setCanDragReferences] = useState(false);
|
||||
const [dropTargetReferenceId, setDropTargetReferenceId] = useState<string | null>(null);
|
||||
const attachmentTrackRef = useRef<HTMLUListElement>(null);
|
||||
const cardDragRef = useRef<{ startX: number; startY: number; moved: boolean } | null>(null);
|
||||
const suppressAttachmentClickRef = useRef(false);
|
||||
const [trackState, setTrackState] = useState({ canScrollLeft: false, canScrollRight: false, isExpanded: true, isDragging: false });
|
||||
const previousAttachmentCountRef = useRef(0);
|
||||
const interactionBusy = props.busy || props.referenceReplacementBusy;
|
||||
const hasWork = interactionBusy || props.generationActive;
|
||||
const canSubmit = Boolean(props.prompt.trim()) && !interactionBusy;
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const priceChannel = resolveModelChannel(props.config, props.model);
|
||||
const canOptimizePrompt = Boolean(props.promptOptimizerProvider) && (props.mode === "image" || props.mode === "video");
|
||||
const optimizerReferences = props.references.filter((reference) => reference.active && reference.kind !== "skill");
|
||||
const credits = requestCreditCost({
|
||||
channelMode: priceChannel.scope === "system" ? "remote" : "local",
|
||||
modelCosts: priceChannel.modelCosts,
|
||||
model: modelOptionName(props.model),
|
||||
count: props.mode === "image" ? props.count : 1,
|
||||
seconds: props.mode === "video" ? props.seconds : 1,
|
||||
});
|
||||
const showCost = creditsEnabled && credits !== null;
|
||||
const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||||
const actionLabel = props.referenceReplacementBusy ? "正在替换参考图" : interactionBusy || (props.generationActive && !canSubmit) ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送";
|
||||
// Send-button working state must span the WHOLE generation (not just the
|
||||
// submit-lock window): spinner + glow stay while a message is pending and
|
||||
// the composer is empty; typing a next prompt returns the arrow so the
|
||||
// user knows a new send is possible.
|
||||
const showWorkingSpinner = interactionBusy || (props.generationActive && !canSubmit);
|
||||
const showWorkingGlow = props.generationActive && !canSubmit;
|
||||
const placeholder = props.mode === "text"
|
||||
? "描述你的故事、角色或想继续讨论的创意"
|
||||
: props.mode === "image"
|
||||
? "描述画面、人物、场景、构图与风格"
|
||||
: "描述镜头内容、运动、光线与节奏";
|
||||
const emptyPlaceholder = "输入你的镜头、画面或故事。也可以添加参考图开始创作";
|
||||
const imageReferencesSupported = props.imageProfile.references.maxImages > 0;
|
||||
const referencesSupported = props.mode === "image" ? imageReferencesSupported : props.mode !== "video" || props.videoProfile.operations.includes("image_to_video");
|
||||
const canAddMoreReferences = referencesSupported && props.attachments.length < props.maxReferences;
|
||||
const addReferenceLabel = interactionBusy ? (props.referenceReplacementBusy ? "正在替换参考图" : "生成中暂不能添加参考内容") : canAddMoreReferences ? "添加更多参考内容" : `已达到当前模型的参考内容上限(${props.maxReferences} 个)`;
|
||||
const referenceCounts = useMemo(() => props.attachments.reduce((counts, attachment) => {
|
||||
const kind = creationAttachmentKind(attachment);
|
||||
counts[kind] += 1;
|
||||
return counts;
|
||||
}, { image: 0, video: 0, audio: 0, file: 0 }), [props.attachments]);
|
||||
const visibleAttachments = useMemo(() => referenceFilter === "all"
|
||||
? props.attachments
|
||||
: props.attachments.filter((attachment) => creationAttachmentKind(attachment) === referenceFilter), [props.attachments, referenceFilter]);
|
||||
const imageSettingsSupported = props.imageProfile.size.parameter !== "none" || props.imageProfile.quality.supported || props.imageProfile.maxOutputs > 1;
|
||||
const updateTrackScrollState = useCallback(() => {
|
||||
const track = attachmentTrackRef.current;
|
||||
if (!track) return;
|
||||
setTrackState((current) => ({
|
||||
...current,
|
||||
canScrollLeft: track.scrollLeft > 1,
|
||||
canScrollRight: track.scrollLeft + track.clientWidth < track.scrollWidth - 1,
|
||||
}));
|
||||
}, []);
|
||||
const setReferencePanelExpanded = useCallback((isExpanded: boolean) => {
|
||||
setTrackState((current) => ({ ...current, isExpanded }));
|
||||
if (!isExpanded) setReferenceFilter("all");
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const hadAttachments = previousAttachmentCountRef.current > 0;
|
||||
if (!props.attachments.length) setReferencePanelExpanded(false);
|
||||
else if (!hadAttachments) setReferencePanelExpanded(true);
|
||||
previousAttachmentCountRef.current = props.attachments.length;
|
||||
updateTrackScrollState();
|
||||
}, [props.attachments.length, setReferencePanelExpanded, updateTrackScrollState]);
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
|
||||
const update = () => setCanDragReferences(query.matches);
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(updateTrackScrollState);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [referenceFilter, trackState.isExpanded, updateTrackScrollState, visibleAttachments.length]);
|
||||
const beginCardDrag = (event: PointerEvent<HTMLElement>) => {
|
||||
if (event.button !== 0 || interactionBusy || !trackState.isExpanded) return;
|
||||
if ((event.target as HTMLElement).closest(".creation-reference-card-remove")) return;
|
||||
cardDragRef.current = { startX: event.clientX, startY: event.clientY, moved: false };
|
||||
};
|
||||
const endCardDrag = (event: PointerEvent<HTMLElement>) => {
|
||||
const drag = cardDragRef.current;
|
||||
if (!drag) return;
|
||||
cardDragRef.current = null;
|
||||
if (drag.moved) {
|
||||
suppressAttachmentClickRef.current = true;
|
||||
window.setTimeout(() => { suppressAttachmentClickRef.current = false; }, 0);
|
||||
}
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
setTrackState((current) => ({ ...current, isDragging: false }));
|
||||
};
|
||||
const moveCardDrag = (event: PointerEvent<HTMLElement>) => {
|
||||
const drag = cardDragRef.current;
|
||||
if (!drag || drag.moved) return;
|
||||
if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) <= 4) return;
|
||||
drag.moved = true;
|
||||
setTrackState((current) => ({ ...current, isDragging: true, isExpanded: true }));
|
||||
};
|
||||
const previewAttachment = (type: "image" | "video", url: string) => {
|
||||
if (suppressAttachmentClickRef.current || cardDragRef.current?.moved) return;
|
||||
setPreviewType(type);
|
||||
setPreviewUrl(url);
|
||||
};
|
||||
const reorderVisibleAttachments = useCallback((next: CreationAttachment[]) => {
|
||||
if (referenceFilter === "all") {
|
||||
props.onReorderAttachments(next);
|
||||
return;
|
||||
}
|
||||
const visibleIds = new Set(visibleAttachments.map((attachment) => attachment.id));
|
||||
const reordered = [...next];
|
||||
props.onReorderAttachments(props.attachments.map((attachment) => visibleIds.has(attachment.id) ? reordered.shift() || attachment : attachment));
|
||||
}, [props.attachments, props.onReorderAttachments, referenceFilter, visibleAttachments]);
|
||||
useEffect(() => {
|
||||
if (!canOptimizePrompt) setPromptOptimizerOpen(false);
|
||||
}, [canOptimizePrompt]);
|
||||
|
||||
const scrollAttachmentTrack = (direction: -1 | 1) => {
|
||||
const track = attachmentTrackRef.current;
|
||||
if (!track) return;
|
||||
track.scrollBy({ left: direction * Math.max(track.clientWidth * 0.72, 120), behavior: "smooth" });
|
||||
window.setTimeout(updateTrackScrollState, 180);
|
||||
};
|
||||
const imageReferenceAtPoint = (x: number, y: number) => {
|
||||
for (const element of document.elementsFromPoint(x, y)) {
|
||||
const chip = element.closest<HTMLElement>("[data-mention-reference-id]");
|
||||
const referenceId = chip?.dataset.mentionReferenceId;
|
||||
const reference = referenceId ? props.references.find((item) => item.id === referenceId) : undefined;
|
||||
if (reference?.kind === "image" && reference.attachmentId) return reference;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const composer = <section className={`creation-chat-composer is-${props.variant}`}>
|
||||
<div className="creation-chat-writing-surface">
|
||||
<div className="creation-chat-editor">
|
||||
<CanvasResourceMentionTextarea ref={props.composerFocusRef} value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter={false} onFocus={props.onPromptFocus} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.placeholderOverride || (props.variant === "empty" ? emptyPlaceholder : placeholder)} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={interactionBusy} activeDropReferenceId={dropTargetReferenceId} onReferenceFilesDrop={(reference, files) => { const target = props.references.find((item) => item.id === reference.id); if (target?.attachmentId) props.onReplaceReferenceFiles(target.attachmentId, files); }} />
|
||||
{props.attachments.length || referencesSupported ? <div className={`creation-reference-panel${trackState.isExpanded ? " is-expanded" : ""}`} aria-busy={interactionBusy}>
|
||||
{trackState.isExpanded ? <div className="creation-reference-panel-header">
|
||||
<div className="creation-reference-filter-tabs" role="group" aria-label="筛选参考内容">
|
||||
{([
|
||||
{ id: "all", label: "全部", count: props.attachments.length },
|
||||
{ id: "image", label: "图片", count: referenceCounts.image },
|
||||
{ id: "video", label: "视频", count: referenceCounts.video },
|
||||
{ id: "audio", label: "音频", count: referenceCounts.audio },
|
||||
{ id: "file", label: "文件", count: referenceCounts.file },
|
||||
] as const).map((filter) => <button key={filter.id} type="button" aria-pressed={referenceFilter === filter.id} className={referenceFilter === filter.id ? "is-active" : undefined} onClick={() => setReferenceFilter(filter.id)}>{filter.label}{filter.count ? ` (${filter.count})` : ""}</button>)}
|
||||
</div>
|
||||
<div className="creation-reference-panel-actions">
|
||||
{props.attachments.length ? <button type="button" onClick={props.onClearAttachments} disabled={interactionBusy}>清空全部素材</button> : null}
|
||||
<Tooltip title="收起素材面板"><button type="button" className="creation-reference-panel-collapse" onClick={() => setReferencePanelExpanded(false)} aria-label="收起素材面板"><Minimize2 aria-hidden="true" /></button></Tooltip>
|
||||
</div>
|
||||
</div> : null}
|
||||
<div className="creation-reference-track-wrapper">
|
||||
<div className="creation-reference-stack-shell">
|
||||
{trackState.canScrollLeft ? <button type="button" className="creation-reference-track-button is-left" onClick={() => scrollAttachmentTrack(-1)} aria-label="向左浏览参考内容" title="向左浏览参考内容"><ChevronLeft aria-hidden="true" /></button> : null}
|
||||
<Reorder.Group<CreationAttachment[]>
|
||||
as="ul"
|
||||
ref={attachmentTrackRef}
|
||||
className={`creation-reference-track${trackState.isExpanded ? " is-expanded" : ""}${trackState.isDragging ? " is-dragging" : ""}${visibleAttachments.length ? "" : " is-empty"}`}
|
||||
axis="x"
|
||||
values={visibleAttachments}
|
||||
onReorder={reorderVisibleAttachments}
|
||||
layoutScroll
|
||||
role="list"
|
||||
aria-label="参考内容轨道"
|
||||
onScroll={updateTrackScrollState}
|
||||
>
|
||||
{visibleAttachments.map((item) => <Reorder.Item<CreationAttachment>
|
||||
key={item.id}
|
||||
value={item}
|
||||
layout="position"
|
||||
drag={trackState.isExpanded && canDragReferences && !interactionBusy}
|
||||
className="creation-reference-stack-card"
|
||||
onPointerDown={beginCardDrag}
|
||||
onPointerMove={moveCardDrag}
|
||||
onPointerUp={endCardDrag}
|
||||
onPointerCancel={endCardDrag}
|
||||
onDragStart={() => { setDropTargetReferenceId(null); setTrackState((current) => ({ ...current, isDragging: true, isExpanded: true })); }}
|
||||
onDrag={(_, info) => {
|
||||
if (creationAttachmentKind(item) !== "image") return;
|
||||
const target = imageReferenceAtPoint(info.point.x, info.point.y);
|
||||
setDropTargetReferenceId(target?.attachmentId !== item.id ? target?.id || null : null);
|
||||
}}
|
||||
onDragEnd={(_, info) => {
|
||||
const target = creationAttachmentKind(item) === "image" ? imageReferenceAtPoint(info.point.x, info.point.y) : undefined;
|
||||
setDropTargetReferenceId(null);
|
||||
setTrackState((current) => ({ ...current, isDragging: false, isExpanded: true }));
|
||||
if (target?.attachmentId && target.attachmentId !== item.id) props.onReplaceAttachment(target.attachmentId, item);
|
||||
}}
|
||||
>
|
||||
<CreationAttachmentThumbnail item={item} onPreview={previewAttachment} onRemove={props.onRemoveAttachment} />
|
||||
</Reorder.Item>)}
|
||||
{!visibleAttachments.length && props.attachments.length ? <li className="creation-reference-filter-empty">该类型暂无参考内容</li> : null}
|
||||
{referencesSupported ? <li className="creation-reference-add-slot"><Tooltip title={addReferenceLabel}><button type="button" className="creation-reference-add-button" onClick={props.onOpenLibrary} disabled={interactionBusy || !canAddMoreReferences} aria-label={addReferenceLabel}><Plus aria-hidden="true" /><span>参考内容</span></button></Tooltip></li> : null}
|
||||
</Reorder.Group>
|
||||
{trackState.canScrollRight ? <button type="button" className="creation-reference-track-button is-right" onClick={() => scrollAttachmentTrack(1)} aria-label="向右浏览参考内容" title="向右浏览参考内容"><ChevronRight aria-hidden="true" /></button> : null}
|
||||
{!trackState.isExpanded && props.attachments.length ? <Tooltip title="查看全部"><button type="button" className="creation-reference-panel-expand" onClick={() => setReferencePanelExpanded(true)} aria-label={`查看全部 ${props.attachments.length} 个参考内容`} aria-expanded="false"><Maximize2 aria-hidden="true" /></button></Tooltip> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="creation-chat-dock">
|
||||
<div className="creation-chat-controls">
|
||||
<VoiceRecordingButton
|
||||
disabled={interactionBusy}
|
||||
onTranscribed={(text) => props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)}
|
||||
/>
|
||||
<ModePicker mode={props.mode} onModeChange={props.onModeChange} />
|
||||
{canOptimizePrompt ? <Tooltip title="用 AI 优化提示词">
|
||||
<button
|
||||
type="button"
|
||||
className="creation-chat-control"
|
||||
onClick={() => setPromptOptimizerOpen(true)}
|
||||
aria-label="优化提示词"
|
||||
aria-expanded={promptOptimizerOpen}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<WandSparkles />
|
||||
<span>优化</span>
|
||||
</button>
|
||||
</Tooltip> : null}
|
||||
<ModelPicker config={props.config} value={props.model} onChange={props.onModelChange} capability={props.mode} requirements={props.modelRequirements} className="creation-model-picker" placeholder={`选择${modeLabels[props.mode]}模型`} showSelectedPrice={false} showOptionPrices variant="creation" />
|
||||
{props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? <GenerationSettingsMenu {...props} /> : null}
|
||||
{props.mode === "video" ? <DurationMenu profile={props.videoProfile} seconds={props.seconds} onChange={props.setSeconds} /> : null}
|
||||
{props.mode === "text" ? <>
|
||||
<Tooltip title={interactionBusy ? "生成中,此开关将在下次发送时生效" : (props.textStreaming ? "流式输出已开启" : "流式输出已关闭")}><button type="button" className="creation-chat-control" aria-pressed={props.textStreaming} disabled={interactionBusy} onClick={() => props.setTextStreaming(!props.textStreaming)}><Waves /><span>流式</span></button></Tooltip>
|
||||
<Tooltip title={interactionBusy ? "生成中,此开关将在下次发送时生效" : (props.textThinking ? "思考已开启,会展示模型返回的推理摘要" : "开启模型思考")}><button type="button" className="creation-chat-control" aria-pressed={props.textThinking} disabled={interactionBusy} onClick={() => props.setTextThinking(!props.textThinking)}><Brain /><span>思考</span></button></Tooltip>
|
||||
</> : null}
|
||||
{props.prompt.trim() || props.attachments.length || props.references.some((reference) => reference.active) ? <Tooltip title="清空提示词和参考内容"><button type="button" className="creation-chat-control is-clear" onClick={props.onClearComposer} disabled={interactionBusy} aria-label="清空提示词和参考内容"><Trash2 /><span>清空</span></button></Tooltip> : null}
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
className={`canvas-node-composer-submit ${showCost ? "has-cost" : ""}`}
|
||||
disabled={interactionBusy || !canSubmit}
|
||||
style={{
|
||||
position: "relative",
|
||||
color: !hasWork && !canSubmit ? "var(--creation-faint)" : "var(--creation-text)",
|
||||
"--canvas-composer-submit-action": !hasWork && !canSubmit ? "var(--creation-surface-hover)" : "var(--creation-text)",
|
||||
"--canvas-composer-submit-action-fg": !hasWork && !canSubmit ? "var(--creation-faint)" : "var(--creation-bg)",
|
||||
} as CSSProperties}
|
||||
onClick={interactionBusy ? undefined : props.onSubmit}
|
||||
aria-label={actionLabel}
|
||||
title={actionLabel}
|
||||
>
|
||||
{showWorkingGlow ? <WorkingGlow active color="var(--creation-text)" radius="999px" /> : null}
|
||||
{showCost ? <span className="canvas-node-composer-submit-cost"><CreditSymbol /><span>{formattedCredits}</span></span> : null}
|
||||
<span className="canvas-node-composer-submit-action" aria-hidden>{showWorkingSpinner ? <LoaderCircle className="size-3 animate-spin" /> : <ArrowUp className="size-3" />}</span>
|
||||
</Button>
|
||||
</footer>
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
</section>;
|
||||
|
||||
if (!promptOptimizerOpen) return composer;
|
||||
|
||||
return (
|
||||
<Suspense fallback={composer}><CanvasPromptOptimizerDrawer
|
||||
open={promptOptimizerOpen}
|
||||
prompt={props.prompt}
|
||||
generationMode={props.mode === "video" ? "video" : "image"}
|
||||
targetModel={modelOptionName(props.model) || props.model}
|
||||
targetProtocol={priceChannel.modelCosts?.find((item) => item.model === modelOptionName(props.model))?.protocol || priceChannel.interfaceType}
|
||||
config={props.config}
|
||||
optimizerModel={props.config.textModel}
|
||||
references={optimizerReferences}
|
||||
provider={props.promptOptimizerProvider}
|
||||
onClose={() => setPromptOptimizerOpen(false)}
|
||||
onApply={props.setPrompt}
|
||||
>
|
||||
{composer}
|
||||
</CanvasPromptOptimizerDrawer></Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const items: { mode: CreationMode; icon: ReactNode; label: string }[] = [
|
||||
{ mode: "video", icon: <Film />, label: "视频生成" },
|
||||
{ mode: "image", icon: <ImageIcon />, label: "图片生成" },
|
||||
{ mode: "text", icon: <MessageSquareText />, label: "文本创作" },
|
||||
];
|
||||
const current = items.find((item) => item.mode === mode) || items[0];
|
||||
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottomLeft" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={<div className="creation-mode-picker-menu" role="listbox" aria-label="选择生成类型">{items.map((item) => <button key={item.mode} type="button" role="option" aria-selected={item.mode === mode} className={item.mode === mode ? "is-selected" : ""} onClick={() => { onModeChange(item.mode); setOpen(false); }}><span className="creation-menu-icon">{item.icon}</span><span>{item.label}</span>{item.mode === mode ? <Check /> : null}</button>)}</div>}>
|
||||
<button type="button" className="creation-chat-control is-mode" aria-label={`生成类型:${current.label}`}>{current.icon}<span>{current.label}</span><ChevronDown className={open ? "is-open" : ""} /></button>
|
||||
</Popover>;
|
||||
}
|
||||
|
||||
function GenerationSettingsMenu(props: ComposerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const activeQualityOptions = props.imageProfile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value.toUpperCase(), description: "模型支持的质量/分辨率" });
|
||||
const qualityLabel = activeQualityOptions.find((item) => item.value === props.quality)?.label || qualityOptions.find((item) => item.value === props.quality)?.label || props.quality || "自动";
|
||||
// 尺寸/比例/分辨率选项取同显示名分组内全部模型的并集,路由模型只决定发送参数。
|
||||
const mergedProfile = mergedImageCapabilityConfig(props.config, props.model || props.config.imageModel);
|
||||
const usesImageResolutionPicker = props.mode === "image" && supportsImageResolutionPresets(mergedProfile.size);
|
||||
const imageResolutionOptions = usesImageResolutionPicker ? buildImageResolutionOptions(mergedProfile.size.values) : [];
|
||||
const ratios = props.videoProfile.ratios;
|
||||
const referenceImageSize = props.mode === "image" && mergedProfile.size.allowCustom ? props.referenceImageSize : undefined;
|
||||
const referenceImageSizeValue = referenceImageSize ? String(referenceImageSize.width) + "x" + String(referenceImageSize.height) : "";
|
||||
const referenceImageSizeLabel = referenceImageSize ? String(referenceImageSize.width) + " × " + String(referenceImageSize.height) : "";
|
||||
const referenceImageSizeSelected = Boolean(referenceImageSizeValue && props.ratio === referenceImageSizeValue);
|
||||
const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions;
|
||||
const selectReferenceImageSize = () => {
|
||||
if (!referenceImageSizeValue) return;
|
||||
props.setRatio(referenceImageSizeValue);
|
||||
};
|
||||
const videoResolutionSupported = props.mode === "video" && resolutions.length > 0;
|
||||
const imageSummary = [
|
||||
...(mergedProfile.size.parameter !== "none" ? [referenceImageSizeSelected ? referenceImageSizeLabel : usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []),
|
||||
...(props.imageProfile.quality.supported ? [qualityLabel] : []),
|
||||
...(props.imageProfile.maxOutputs > 1 ? [props.count] : []),
|
||||
].join(" · ");
|
||||
const videoRatioSupported = props.mode === "video" && ratios.length > 0;
|
||||
const summary = props.mode === "video" ? [...(videoRatioSupported ? [props.ratio] : []), ...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])].join(" · ") : imageSummary;
|
||||
const panel = <div className="creation-parameter-menu">
|
||||
{props.mode === "image" ? <ImageSizePicker profile={mergedProfile} size={props.ratio} quality={props.quality} onChange={(size, quality) => { props.setRatio(size); if (quality) props.setQuality(quality); }} /> : videoRatioSupported ? <SettingSection title="画幅" value={props.ratio}><div className="creation-choice-grid is-ratio">{ratios.map((value) => <button key={value} type="button" aria-pressed={value === props.ratio} className={value === props.ratio ? "is-selected" : ""} onClick={() => props.setRatio(value)}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>)}</div></SettingSection> : null}
|
||||
{props.mode === "image" && referenceImageSizeValue ? <button type="button" className="creation-custom-trigger" onClick={selectReferenceImageSize}>使用参考图尺寸 · {referenceImageSizeLabel}</button> : null}
|
||||
{props.mode === "video" ? (videoResolutionSupported ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : null) : <>
|
||||
|
||||
{props.imageProfile.quality.supported && !imageResolutionUsesQuality(mergedProfile) ? <SettingSection title={activeQualityOptions.some((item) => item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}><div className="creation-choice-grid is-quality">{activeQualityOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.quality} className={option.value === props.quality ? "is-selected" : ""} onClick={() => props.setQuality(option.value)}><span>{option.label}</span><small>{option.description}</small></button>)}</div></SettingSection> : null}
|
||||
{props.imageProfile.maxOutputs > 1 ? <SettingSection title="生成数量" value={`${props.count} 张`}><div className="creation-parameter-content"><div className="creation-choice-grid is-count">{countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).map((option) => <button key={option} type="button" aria-pressed={option === props.count} className={option === props.count ? "is-selected" : ""} onClick={() => props.setCount(option)}>{option}</button>)}</div><label className="creation-custom-value"><span>自定义</span><input inputMode="numeric" pattern="[0-9]*" value={props.count} onChange={(event) => props.setCount(String(Math.max(1, Math.min(props.imageProfile.maxOutputs, Number(event.target.value) || 1))))} aria-label={`生成数量,范围 1 到 ${props.imageProfile.maxOutputs}`} /><em>张</em></label></div></SettingSection> : null}
|
||||
</>}
|
||||
</div>;
|
||||
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottom" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={panel}>
|
||||
<button type="button" className="creation-chat-control" aria-label={`生成设置:${summary}`}><SlidersHorizontal /><span>{summary}</span><ChevronDown className={open ? "is-open" : ""} /></button>
|
||||
</Popover>;
|
||||
}
|
||||
|
||||
function SettingSection({ title, value, children }: { title: string; value?: string; children: ReactNode }) {
|
||||
return <section className="creation-parameter-section"><header><h3>{title}</h3>{value ? <span>{value}</span> : null}</header>{children}</section>;
|
||||
}
|
||||
|
||||
function DurationMenu({ profile, seconds, onChange }: { profile: VideoCapabilityConfig; seconds: string; onChange: (value: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const value = Number(normalizeVideoValue(profile, { seconds }).seconds);
|
||||
const presets = profile.duration.selection === "enum" ? videoDurationOptions(profile) : [];
|
||||
const fallbackPreset = presets.length ? presets : [profile.duration.default];
|
||||
const min = profile.duration.selection === "range" ? profile.duration.min || 1 : Math.min(...fallbackPreset);
|
||||
const max = profile.duration.selection === "range" ? Math.max(min, profile.duration.max || min) : Math.max(...fallbackPreset);
|
||||
const step = Math.max(1, profile.duration.step || 1);
|
||||
const durationControl = profile.duration.selection === "range" ? <>
|
||||
<input className="h-8 w-full" style={{ accentColor: "var(--creation-text)" }} type="range" min={min} max={max} step={step} value={value} aria-label="视频时长(秒)" onChange={(event) => onChange(event.target.value)} />
|
||||
<div className="flex justify-between px-0.5 text-[var(--fs-tiny)] text-[var(--creation-muted)]"><span>{min}s</span><span>{max}s</span></div>
|
||||
<label className="creation-custom-value is-duration"><span>自定义时长</span><span className="creation-duration-custom-field"><input type="number" min={min} max={max} step={step} inputMode="numeric" value={seconds} onFocus={(event) => event.currentTarget.select()} onBlur={() => onChange(String(value))} onChange={(event) => onChange(event.target.value)} aria-label="自定义视频时长,单位秒" /><em>秒</em></span></label>
|
||||
</> : <div className="creation-duration-choices">{presets.map((item) => <button key={item} type="button" className={item === value ? "is-selected" : ""} onClick={() => onChange(String(item))}>{item}s</button>)}</div>;
|
||||
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottom" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={<div className="creation-duration-menu"><div className="creation-duration-heading"><span>时长</span><strong>{value} 秒</strong></div>{durationControl}</div>}>
|
||||
<button type="button" className="creation-chat-control is-duration" aria-label={`视频时长:${value}秒`}><Clock3 /><span>{value}s</span><ChevronDown className={open ? "is-open" : ""} /></button>
|
||||
</Popover>;
|
||||
}
|
||||
|
||||
const creationEmptyBannerFrames = [
|
||||
{ src: "/short-drama-styles/cyberpunk-neon.jpg", caption: "镜头01 · 雨夜霓虹" },
|
||||
{ src: "/short-drama-styles/suspense-noir.jpg", caption: "镜头02 · 暗巷追逐" },
|
||||
{ src: "/short-drama-styles/retro-hong-kong.jpg", caption: "镜头03 · 天台重逢" },
|
||||
];
|
||||
|
||||
export function CreationEmptyBanner() {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
return <div className="creation-empty-art" aria-hidden="true">
|
||||
{creationEmptyBannerFrames.map((frame, index) => <figure key={frame.caption} className={`creation-empty-art-frame ${index === 1 ? "is-main" : index === 0 ? "is-back" : "is-front"}`}>
|
||||
<img src={frame.src} alt="" />
|
||||
<span>{frame.caption}</span>
|
||||
</figure>)}
|
||||
<span className="creation-empty-art-caption"><span>{brandName}</span>把每一帧,交给镜头导演</span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const creationEmptySuggestions: Array<{ mode: CreationMode; icon: typeof Clapperboard; title: string; hint: string; prompt: string; openLibrary?: boolean }> = [
|
||||
{ mode: "video", icon: Clapperboard, title: "生成第一个镜头", hint: "描述画面、镜头运动与光线", prompt: "雨夜天台,镜头缓缓推近霓虹灯牌下的主角,她回眸看向镜头,强对比电影感布光" },
|
||||
{ mode: "image", icon: ImageIcon, title: "从参考图开始", hint: "上传风格图,生成同风格画面", prompt: "", openLibrary: true },
|
||||
{ mode: "text", icon: FileText, title: "续写故事", hint: "和 AI 讨论剧情、角色与对白", prompt: "帮我续写一个短剧故事,先聊聊剧情走向:" },
|
||||
{ mode: "video", icon: Sparkles, title: "引用技能增强", hint: "@技能 调用分镜、配音等专业能力", prompt: "调用分镜技能,帮我规划这个镜头的拍摄方案:" },
|
||||
];
|
||||
|
||||
export function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStartPrompt: (mode: CreationMode, prompt: string) => void; onOpenLibrary: () => void }) {
|
||||
const [main, ...rest] = creationEmptySuggestions;
|
||||
const MainIcon = main.icon;
|
||||
return <div className="creation-empty-suggest">
|
||||
<button type="button" className="creation-hero-prompt" onClick={() => { if (main.openLibrary) onOpenLibrary(); else onStartPrompt(main.mode, main.prompt); }}>
|
||||
<span className="creation-hero-prompt-ray" aria-hidden="true" />
|
||||
<span className={`library-icon-tile suggest-icon is-${main.mode}`}><MainIcon size={16} strokeWidth={2} /></span>
|
||||
<span className="creation-hero-prompt-copy">
|
||||
<strong>{main.title}</strong>
|
||||
<span className="creation-hero-prompt-hint">{main.hint}</span>
|
||||
<em className="creation-hero-prompt-text">“{main.prompt}”</em>
|
||||
</span>
|
||||
<Sparkles className="creation-hero-prompt-spark" aria-hidden="true" />
|
||||
</button>
|
||||
<div className="creation-empty-suggest-grid">{rest.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return <button key={item.title} type="button" className="suggest-card" onClick={() => { if (item.openLibrary) onOpenLibrary(); else onStartPrompt(item.mode, item.prompt); }}>
|
||||
<span className={`library-icon-tile suggest-icon is-${item.mode}`}><Icon size={16} strokeWidth={2} /></span>
|
||||
<span className="suggest-copy"><strong>{item.title}</strong><span>{item.hint}</span></span>
|
||||
</button>;
|
||||
})}</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
type CreationThinking = { title: string; hint: string; steps: string[]; activity: string };
|
||||
|
||||
function thinkingFor(mode: CreationMode, brandName: string): CreationThinking {
|
||||
if (mode === "image") return { title: "正在为你画这一镜", hint: `${brandName}正在理解你的构图意图,并把画面交给模型出图。`, steps: ["理解构图", "定调画风", "生成画面"], activity: "正在理解构图并把画面交给模型出图" };
|
||||
if (mode === "text") return { title: "正在为你写这段", hint: `${brandName}正在梳理你的创作脉络,组织语言与结构。`, steps: ["梳理脉络", "组织语言", "输出段落"], activity: "正在梳理脉络并组织语言" };
|
||||
return { title: "正在为你拍这一镜", hint: `${brandName}正在拆解你的镜头脚本,设计运镜与光线,并交给模型渲染成片。`, steps: ["拆解镜头", "设计运镜", "定调布光", "渲染成片"], activity: "正在按导演思路拆解镜头并渲染" };
|
||||
}
|
||||
function formatMessageTime(value: string) {
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? conversationTimeFormatter.format(timestamp) : "";
|
||||
}
|
||||
|
||||
function buildConversationExportMarkdown(conversation: CreationConversation, assistantName: string, userName: string) {
|
||||
const lines: string[] = [`# ${conversation.title.trim() || "新创作"}`, ""];
|
||||
for (const message of conversation.messages) {
|
||||
const stamp = formatMessageTime(message.createdAt);
|
||||
const modeTag = message.mode && message.mode !== "text" ? (message.mode === "image" ? "[图像生成] " : "[视频生成] ") : "";
|
||||
const speaker = message.role === "user" ? (userName || "我") : assistantName;
|
||||
if (message.role === "user") {
|
||||
const prompt = displayCreationPrompt(message.content, message.references || []).trim();
|
||||
if (!prompt) continue;
|
||||
lines.push(`## ${speaker} · ${stamp}`, "", prompt, "");
|
||||
} else {
|
||||
const body = (message.content || "").trim();
|
||||
if (body) lines.push(`## ${speaker} · ${stamp}`, "", `${modeTag}${body}`, "");
|
||||
else if (message.resultUrls?.length) lines.push(`## ${speaker} · ${stamp}`, "", `${modeTag}已生成,素材保留在项目中。`, "");
|
||||
else continue;
|
||||
if (message.reasoning?.trim()) lines.push("> 思考过程:", message.reasoning.trim(), "");
|
||||
}
|
||||
}
|
||||
return lines.join("\n").trim() + "\n";
|
||||
}
|
||||
function downloadCreationConversation(conversation: CreationConversation, assistantName: string, userName: string) {
|
||||
const safeTitle = (conversation.title.trim() || "新创作").replace(/[\\/:*?"<>|]/g, "_").slice(0, 60) || "新创作";
|
||||
const blob = new Blob([buildConversationExportMarkdown(conversation, assistantName, userName)], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${safeTitle}.md`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
function conversationPreviewMessage(conversation: CreationConversation) {
|
||||
let fallback: CreationMessage | undefined;
|
||||
for (let index = conversation.messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = conversation.messages[index];
|
||||
if (!message.content.trim()) continue;
|
||||
fallback ||= message;
|
||||
if (message.role === "user") return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatHistoryRelativeTime(value: string) {
|
||||
const timestamp = conversationTimestamp(value);
|
||||
if (!timestamp) return "";
|
||||
const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
|
||||
if (seconds < 60) return "刚刚";
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.round(hours / 24);
|
||||
if (days < 7) return `${days} 天前`;
|
||||
return historyDayFormatter.format(timestamp);
|
||||
}
|
||||
|
||||
function formatConversationTime(value: string) {
|
||||
const timestamp = conversationTimestamp(value);
|
||||
if (!timestamp) return "时间未知";
|
||||
return conversationTimeFormatter.format(timestamp);
|
||||
}
|
||||
|
||||
function ratioPreviewStyle(value: string) {
|
||||
const [width, height] = value.replace("x", ":").split(":").map(Number);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return { width: 10, height: 10 };
|
||||
// 画幅容器的可用空间是 14×10;同时计算宽高,避免 CSS 的 max-width/max-height 把宽银幕比例压扁。
|
||||
const scale = Math.min(14 / width, 10 / height);
|
||||
return { width: Math.max(4, Math.round(width * scale)), height: Math.max(4, Math.round(height * scale)) };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user