mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
Merge branch 'dev'
# Conflicts: # .gitignore # CHANGELOG.md # web/bun.lock
This commit is contained in:
+24
-3
@@ -7,6 +7,7 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@ant-design/pro-components": "3.0.0-beta.3",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@uiw/react-codemirror": "^4.25.9",
|
||||
@@ -31,6 +32,7 @@
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4",
|
||||
"three": "^0.180.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.12",
|
||||
},
|
||||
@@ -40,6 +42,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"@types/three": "^0.180.0",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^5",
|
||||
@@ -138,6 +141,8 @@
|
||||
|
||||
"@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
|
||||
|
||||
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
|
||||
|
||||
"@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="],
|
||||
|
||||
"@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="],
|
||||
@@ -154,6 +159,8 @@
|
||||
|
||||
"@ctrl/tinycolor": ["@ctrl/tinycolor@4.2.0", "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", {}, "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A=="],
|
||||
|
||||
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
|
||||
|
||||
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||
|
||||
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||
@@ -278,6 +285,8 @@
|
||||
|
||||
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
|
||||
|
||||
"@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="],
|
||||
|
||||
"@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="],
|
||||
|
||||
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
|
||||
@@ -604,6 +613,8 @@
|
||||
|
||||
"@ts-morph/common": ["@ts-morph/common@0.27.0", "https://registry.npmmirror.com/@ts-morph/common/-/common-0.27.0.tgz", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
|
||||
|
||||
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
@@ -702,14 +713,20 @@
|
||||
|
||||
"@types/set-cookie-parser": ["@types/set-cookie-parser@2.4.10", "https://registry.npmmirror.com/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw=="],
|
||||
|
||||
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
|
||||
|
||||
"@types/statuses": ["@types/statuses@2.0.6", "https://registry.npmmirror.com/@types/statuses/-/statuses-2.0.6.tgz", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
|
||||
|
||||
"@types/three": ["@types/three@0.180.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/unist": ["@types/unist@2.0.11", "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "https://registry.npmmirror.com/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
|
||||
|
||||
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
|
||||
|
||||
"@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.9", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw=="],
|
||||
|
||||
"@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.9", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.9", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng=="],
|
||||
@@ -724,6 +741,8 @@
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
|
||||
|
||||
"@webgpu/types": ["@webgpu/types@0.1.71", "", {}, "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
@@ -1144,7 +1163,7 @@
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.1.1", "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz", {}, "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
"internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
|
||||
|
||||
"ip-address": ["ip-address@10.2.0", "https://registry.npmmirror.com/ip-address/-/ip-address-10.2.0.tgz", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||
|
||||
@@ -1324,6 +1343,8 @@
|
||||
|
||||
"mermaid": ["mermaid@11.16.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA=="],
|
||||
|
||||
"meshoptimizer": ["meshoptimizer@0.22.0", "", {}, "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
@@ -1686,6 +1707,8 @@
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"three": ["three@0.180.0", "", {}, "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w=="],
|
||||
|
||||
"throttle-debounce": ["throttle-debounce@5.0.2", "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
@@ -2070,8 +2093,6 @@
|
||||
|
||||
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
|
||||
|
||||
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
|
||||
|
||||
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
||||
|
||||
"express/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=="],
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@ant-design/pro-components": "3.0.0-beta.3",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@uiw/react-codemirror": "^4.25.9",
|
||||
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
|
||||
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||
return (
|
||||
<Modal title="选择素材" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
|
||||
<Modal title="选择资产" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
|
||||
<MyAssetsTab onInsert={onInsert} />
|
||||
</Modal>
|
||||
);
|
||||
@@ -90,7 +90,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
|
||||
className="w-56"
|
||||
size="small"
|
||||
prefix={<Search className="size-3.5 text-stone-400" />}
|
||||
placeholder="搜索素材"
|
||||
placeholder="搜索资产"
|
||||
value={keyword}
|
||||
allowClear
|
||||
onChange={(e) => {
|
||||
@@ -122,7 +122,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有资产" className="py-12" />
|
||||
)}
|
||||
|
||||
{filtered.length > PAGE_SIZE && (
|
||||
|
||||
@@ -116,7 +116,7 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<div className="shrink-0 text-xs font-semibold">组装提示词</div>
|
||||
<div className="truncate text-[11px] opacity-55">@ 引用已连接素材,发送前按当前连接重新编号</div>
|
||||
<div className="truncate text-[11px] opacity-55">@ 引用已连接资产,发送前按当前连接重新编号</div>
|
||||
</div>
|
||||
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
|
||||
</div>
|
||||
|
||||
@@ -146,9 +146,9 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
|
||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
const currentModel = node.metadata?.model;
|
||||
const model = currentModel && modelMatchesCapability(currentModel, mode)
|
||||
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
|
||||
? currentModel
|
||||
: defaultModel && modelMatchesCapability(defaultModel, mode)
|
||||
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
|
||||
? defaultModel
|
||||
: fallbackModel;
|
||||
return {
|
||||
|
||||
@@ -927,8 +927,8 @@ function siteToolSummary(name: string, result: unknown) {
|
||||
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
|
||||
if (name === "canvas_list_projects") return `共 ${numberField(data, "total")} 个画布`;
|
||||
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`;
|
||||
if (name === "assets_list") return `共 ${numberField(data, "total")} 个素材`;
|
||||
if (name === "assets_add") return "已加入我的素材";
|
||||
if (name === "assets_list") return `共 ${numberField(data, "total")} 个资产`;
|
||||
if (name === "assets_add") return "已加入我的资产";
|
||||
if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : "已在工作台执行";
|
||||
if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return "已读取工作台配置";
|
||||
return "已完成";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
|
||||
import { type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
|
||||
|
||||
export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { nodes: CanvasNodeData[]; viewport: ViewportTransform; viewportSize: { width: number; height: number }; onViewportChange: (viewport: ViewportTransform) => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
@@ -113,7 +114,7 @@ export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { n
|
||||
>
|
||||
{nodes.map((node) => {
|
||||
const pos = toMinimap(node.position.x, node.position.y);
|
||||
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : node.type === CanvasNodeType.Group ? "#94a3b8" : theme.node.muted;
|
||||
const color = getNodeDefinition(node.type)?.minimapColor || theme.node.muted;
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
|
||||
import type { CanvasNodeToolbarItem } from "@/types/canvas-plugin";
|
||||
import { ImageToolSettingsModal, type ImageToolbarSettingsTool } from "./canvas-image-toolbar-settings-modal";
|
||||
import { IMAGE_QUICK_TOOLS_STORAGE_KEY, buildImageToolbarTools, defaultImageQuickToolIds, readImageQuickToolsConfig, type ImageQuickToolId } from "./canvas-image-toolbar-tools";
|
||||
|
||||
@@ -35,6 +36,7 @@ type CanvasNodeHoverToolbarProps = {
|
||||
onRetry: (node: CanvasNodeData) => void;
|
||||
onToggleFreeResize: (node: CanvasNodeData) => void;
|
||||
onDelete: (node: CanvasNodeData) => void;
|
||||
extraTools?: CanvasNodeToolbarItem[];
|
||||
};
|
||||
|
||||
type ToolbarTool = {
|
||||
@@ -72,6 +74,7 @@ export function CanvasNodeHoverToolbar({
|
||||
onRetry,
|
||||
onToggleFreeResize,
|
||||
onDelete,
|
||||
extraTools = [],
|
||||
}: CanvasNodeHoverToolbarProps) {
|
||||
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
|
||||
const [showImageToolLabels, setShowImageToolLabels] = useState(true);
|
||||
@@ -137,7 +140,7 @@ export function CanvasNodeHoverToolbar({
|
||||
];
|
||||
const nodeToolbarTools: ToolbarTool[] = [
|
||||
...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
|
||||
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的素材", label: "存素材", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
|
||||
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的资产", label: "存资产", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
|
||||
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
|
||||
...(canOpenDialog ? [{ id: "edit", title: "编辑", label: "编辑", icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
|
||||
...(isText ? [{ id: "editText", title: "编辑文本", label: "编辑文字", icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
|
||||
@@ -150,7 +153,7 @@ export function CanvasNodeHoverToolbar({
|
||||
...(isAudio ? [{ id: "uploadAudio", title: hasAudio ? "替换音频" : "上传音频", label: hasAudio ? "替换音频" : "上传音频", icon: <Music2 className="size-4" />, onClick: () => onUpload(node) }] : []),
|
||||
...(hasImage ? imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, onClick: tool.onClick })) : []),
|
||||
];
|
||||
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools];
|
||||
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools, ...extraTools];
|
||||
const selectableImageToolbarTools = [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => tool.id !== "retry") as ImageToolbarSettingsTool[];
|
||||
|
||||
const closeImageToolSettings = () => {
|
||||
|
||||
@@ -134,9 +134,9 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
|
||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
const currentModel = node.metadata?.model;
|
||||
const model = currentModel && modelMatchesCapability(currentModel, mode)
|
||||
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
|
||||
? currentModel
|
||||
: defaultModel && modelMatchesCapability(defaultModel, mode)
|
||||
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
|
||||
? defaultModel
|
||||
: fallbackModel;
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronRight, Group, Image as ImageIcon, Music2, RefreshCw, Star, Video } from "lucide-react";
|
||||
import { ChevronRight, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { formatBytes } from "@/lib/image-utils";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
|
||||
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
|
||||
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
@@ -25,6 +28,8 @@ type CanvasNodeProps = {
|
||||
showImageInfo: boolean;
|
||||
resourceLabel?: CanvasResourceReference;
|
||||
mentionReferences?: CanvasResourceReference[];
|
||||
pluginHost?: CanvasPluginHost;
|
||||
registryVersion?: number;
|
||||
renderPanel?: (node: CanvasNodeData) => ReactNode;
|
||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||
batchCount?: number;
|
||||
@@ -61,6 +66,7 @@ type NodeContentRendererProps = {
|
||||
batchOpening: boolean;
|
||||
batchRecovering: boolean;
|
||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||
pluginContext?: CanvasNodeContext | null;
|
||||
onContentChange: (nodeId: string, content: string) => void;
|
||||
onStopEditing: () => void;
|
||||
mentionReferences: CanvasResourceReference[];
|
||||
@@ -84,6 +90,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
showImageInfo,
|
||||
resourceLabel,
|
||||
mentionReferences = [],
|
||||
pluginHost,
|
||||
renderPanel,
|
||||
renderNodeContent,
|
||||
batchCount = 0,
|
||||
@@ -110,6 +117,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
}: CanvasNodeProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const definition = getNodeDefinition(data.type);
|
||||
const pluginContext = useMemo<CanvasNodeContext | null>(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale) : null), [pluginHost, data, theme, scale]);
|
||||
const [isEditingContent, setIsEditingContent] = useState(false);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(data.title || "");
|
||||
@@ -259,7 +268,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
startTop: data.position.y,
|
||||
startWidth: data.width,
|
||||
startHeight: data.height,
|
||||
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video,
|
||||
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video || Boolean(definition?.keepAspectRatio?.(data)),
|
||||
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
|
||||
};
|
||||
window.addEventListener("mousemove", handleResizeMove);
|
||||
@@ -343,6 +352,10 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
onToggleBatch?.(data.id);
|
||||
return;
|
||||
}
|
||||
if (definition?.onDoubleClick && pluginContext) {
|
||||
if (definition.onDoubleClick(pluginContext)) event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (data.type === CanvasNodeType.Image && hasImageContent) {
|
||||
event.stopPropagation();
|
||||
onViewImage?.(data);
|
||||
@@ -377,6 +390,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
batchOpening={batchOpening}
|
||||
batchRecovering={batchRecovering}
|
||||
renderNodeContent={renderNodeContent}
|
||||
pluginContext={pluginContext}
|
||||
mentionReferences={mentionReferences}
|
||||
onContentChange={onContentChange}
|
||||
onStopEditing={() => setIsEditingContent(false)}
|
||||
@@ -400,7 +414,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
</div>
|
||||
|
||||
{!isGroup ? <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} /> : null}
|
||||
{!isGroup ? <ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
|
||||
{!isGroup ? <ConnectionHandleDot side="right" visible={(definition?.hasSourceHandle ?? true) && data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
|
||||
|
||||
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||
</div>
|
||||
@@ -413,8 +427,16 @@ function NodeContent(props: NodeContentRendererProps) {
|
||||
if (props.node.metadata?.status === "loading") return <LoadingContent theme={props.theme} />;
|
||||
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
|
||||
|
||||
const Renderer = nodeContentRenderers[props.node.type];
|
||||
return Renderer ? <Renderer {...props} /> : <UnknownNodeContent theme={props.theme} />;
|
||||
const Renderer = nodeContentRenderers[props.node.type as CanvasNodeType];
|
||||
if (Renderer) return <Renderer {...props} />;
|
||||
|
||||
// 插件节点:有注册渲染器则渲染,否则展示缺少插件占位
|
||||
const definition = getNodeDefinition(props.node.type);
|
||||
if (definition?.Content && props.pluginContext) {
|
||||
const PluginContent = definition.Content;
|
||||
return <PluginContent ctx={props.pluginContext} />;
|
||||
}
|
||||
return <MissingPluginContent theme={props.theme} type={props.node.type} />;
|
||||
}
|
||||
|
||||
const nodeContentRenderers = {
|
||||
@@ -473,10 +495,12 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
|
||||
);
|
||||
}
|
||||
|
||||
function UnknownNodeContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
|
||||
function MissingPluginContent({ theme, type }: Pick<NodeContentRendererProps, "theme"> & { type: string }) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm" style={{ color: theme.node.placeholder }}>
|
||||
未知节点
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.placeholder }}>
|
||||
<Puzzle className="size-7 opacity-40" />
|
||||
<span className="text-sm">缺少插件</span>
|
||||
<span className="text-[11px] opacity-70">节点类型 “{type}” 的插件未安装或未启用</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { App, Button, Input, Modal, Popconfirm, Switch, Tabs } from "antd";
|
||||
import { AlertTriangle, Download, Puzzle, RefreshCw, Trash2 } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { installPluginFromUrl, setPluginEnabled, uninstallPlugin, updatePlugin } from "@/lib/canvas/plugin-loader";
|
||||
import { fetchOfficialPlugins, type OfficialPluginEntry } from "@/lib/canvas/plugin-registry";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
|
||||
|
||||
export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { message } = App.useApp();
|
||||
const plugins = usePluginStore((state) => state.plugins);
|
||||
const [url, setUrl] = useState("");
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const [official, setOfficial] = useState<OfficialPluginEntry[]>([]);
|
||||
const [loadingOfficial, setLoadingOfficial] = useState(false);
|
||||
const [officialError, setOfficialError] = useState<string | null>(null);
|
||||
|
||||
const recordById = useMemo(() => new Map(plugins.map((item) => [item.id, item])), [plugins]);
|
||||
const localPlugins = useMemo(() => plugins.filter((item) => item.local), [plugins]);
|
||||
const thirdPartyPlugins = useMemo(() => plugins.filter((item) => !item.local && !item.official), [plugins]);
|
||||
|
||||
const loadOfficial = useCallback(async () => {
|
||||
setLoadingOfficial(true);
|
||||
setOfficialError(null);
|
||||
try {
|
||||
setOfficial(await fetchOfficialPlugins());
|
||||
} catch (error) {
|
||||
setOfficialError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setLoadingOfficial(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 打开面板时拉取官方清单(仅在尚未加载过时,避免重复请求)
|
||||
useEffect(() => {
|
||||
if (open && official.length === 0 && !loadingOfficial && !officialError) void loadOfficial();
|
||||
}, [open, official.length, loadingOfficial, officialError, loadOfficial]);
|
||||
|
||||
const handleInstallUrl = async () => {
|
||||
const target = url.trim();
|
||||
if (!target) return;
|
||||
setInstalling(true);
|
||||
try {
|
||||
const plugin = await installPluginFromUrl(target);
|
||||
message.success(`已安装插件 ${plugin.name}`);
|
||||
setUrl("");
|
||||
} catch (error) {
|
||||
message.error(`安装失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallOfficial = async (entry: OfficialPluginEntry) => {
|
||||
setBusyId(entry.id);
|
||||
try {
|
||||
const plugin = await installPluginFromUrl(entry.url, { official: true });
|
||||
message.success(`已安装 ${plugin.name}`);
|
||||
} catch (error) {
|
||||
message.error(`安装失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runOnPlugin = async (record: InstalledPlugin, action: () => Promise<void>, successText: string) => {
|
||||
setBusyId(record.id);
|
||||
try {
|
||||
await action();
|
||||
message.success(successText);
|
||||
} catch (error) {
|
||||
message.error(`${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 已安装插件的操作区:启用开关 +(非本地)更新/卸载
|
||||
const installedControls = (record: InstalledPlugin) => (
|
||||
<>
|
||||
<Switch size="small" checked={record.enabled} loading={busyId === record.id} onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), checked ? "已启用" : "已禁用")} />
|
||||
{!record.local && (
|
||||
<>
|
||||
<Button type="text" size="small" icon={<RefreshCw className="size-4" />} loading={busyId === record.id} title="从来源更新" onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")} />
|
||||
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
|
||||
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const versionTag = (version: string) => (
|
||||
<span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
|
||||
v{version}
|
||||
</span>
|
||||
);
|
||||
|
||||
const emptyHint = (text: string) => (
|
||||
<div className="py-10 text-center text-sm" style={{ color: theme.node.muted }}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 通用插件行:图标 + 标题(名称 + 版本)+ 描述 + 右侧操作
|
||||
const row = (key: string, icon: ReactNode, name: string, version: string, subtitle: string | undefined, right: ReactNode) => (
|
||||
<div key={key} className="flex items-center gap-3 rounded-xl border px-3 py-2.5" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}>
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-lg text-base" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm font-medium" style={{ color: theme.node.text }}>
|
||||
<span className="truncate">{name}</span>
|
||||
{versionTag(version)}
|
||||
</div>
|
||||
{subtitle ? (
|
||||
<div className="mt-0.5 truncate text-xs" style={{ color: theme.node.muted }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
|
||||
const officialTab = (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs" style={{ color: theme.node.muted }}>
|
||||
本项目官方插件,来自仓库注册表
|
||||
</div>
|
||||
<Button type="text" size="small" icon={<RefreshCw className={`size-4 ${loadingOfficial ? "animate-spin" : ""}`} />} onClick={loadOfficial} disabled={loadingOfficial}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
{officialError ? (
|
||||
<div className="rounded-lg border px-3 py-2 text-xs" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
|
||||
加载失败:{officialError}
|
||||
</div>
|
||||
) : loadingOfficial && official.length === 0 ? (
|
||||
emptyHint("正在获取官方插件…")
|
||||
) : official.length === 0 ? (
|
||||
emptyHint("暂无官方插件")
|
||||
) : (
|
||||
<div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto">
|
||||
{official.map((entry) => {
|
||||
const record = recordById.get(entry.id);
|
||||
return row(
|
||||
entry.id,
|
||||
entry.icon || <Puzzle className="size-4" />,
|
||||
entry.name,
|
||||
entry.version,
|
||||
entry.description,
|
||||
record ? (
|
||||
installedControls(record)
|
||||
) : (
|
||||
<Button type="primary" size="small" icon={<Download className="size-4" />} loading={busyId === entry.id} onClick={() => handleInstallOfficial(entry)}>
|
||||
安装
|
||||
</Button>
|
||||
),
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const localTab = <div className="thin-scrollbar max-h-[52vh] space-y-2 overflow-auto">{localPlugins.map((record) => row(record.id, <Puzzle className="size-4" />, record.name, record.version, record.description || record.url, installedControls(record)))}</div>;
|
||||
|
||||
const thirdPartyTab = (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="输入插件 JS 文件 URL,例如 https://.../plugin.js" value={url} onChange={(event) => setUrl(event.target.value)} onPressEnter={handleInstallUrl} allowClear />
|
||||
<Button type="primary" loading={installing} onClick={handleInstallUrl} icon={<Puzzle className="size-4" />}>
|
||||
安装
|
||||
</Button>
|
||||
</div>
|
||||
<div className="thin-scrollbar max-h-[42vh] space-y-2 overflow-auto">{thirdPartyPlugins.length === 0 ? emptyHint("还没有安装第三方插件") : thirdPartyPlugins.map((record) => row(record.id, <Puzzle className="size-4" />, record.name, record.version, record.description || record.url, installedControls(record)))}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
{ key: "official", label: "官方插件", children: officialTab },
|
||||
...(localPlugins.length > 0 ? [{ key: "local", label: "本地插件", children: localTab }] : []),
|
||||
{ key: "third", label: "第三方插件", children: thirdPartyTab },
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal title="节点插件" open={open} onCancel={onClose} footer={null} centered width={640}>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2 rounded-lg border px-3 py-2 text-xs leading-5" style={{ borderColor: "#f59e0b55", background: "#f59e0b14", color: theme.node.text }}>
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-500" />
|
||||
<span>插件代码会在当前页面内直接执行,可访问本地数据(包含 AI API Key)。请仅安装你信任来源的插件。</span>
|
||||
</div>
|
||||
<Tabs defaultActiveKey="official" items={tabs} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Empty, Input, Select, Tag } from "antd";
|
||||
import { ChevronRight, FileText, Image as ImageIcon, Music2, Search, Settings2, Square, Type, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
import type { InsertAssetPayload } from "./asset-picker-modal";
|
||||
|
||||
type PanelTab = "canvas" | "assets";
|
||||
|
||||
type Props = {
|
||||
nodes: CanvasNodeData[];
|
||||
selectedNodeIds: Set<string>;
|
||||
onFocusNode: (nodeId: string) => void;
|
||||
onInsertAsset: (payload: InsertAssetPayload) => void;
|
||||
};
|
||||
|
||||
const NODE_TYPE_ICON: Record<string, typeof Square> = {
|
||||
[CanvasNodeType.Image]: ImageIcon,
|
||||
[CanvasNodeType.Video]: Video,
|
||||
[CanvasNodeType.Audio]: Music2,
|
||||
[CanvasNodeType.Text]: Type,
|
||||
[CanvasNodeType.Config]: Settings2,
|
||||
[CanvasNodeType.Group]: Square,
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
success: "#22c55e",
|
||||
loading: "#f59e0b",
|
||||
error: "#ef4444",
|
||||
idle: "transparent",
|
||||
};
|
||||
|
||||
export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onInsertAsset }: Props) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [tab, setTab] = useState<PanelTab>("canvas");
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-[280px] shrink-0 flex-col overflow-hidden border-r" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} data-canvas-no-zoom>
|
||||
<div className="flex items-center gap-1 px-3 pt-3">
|
||||
<TabButton label="画布" active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} />
|
||||
<TabButton label="资产" active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} />
|
||||
</div>
|
||||
<div className="mt-2 min-h-0 flex-1 overflow-hidden">{tab === "canvas" ? <CanvasNodesTab nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={onFocusNode} theme={theme} /> : <CanvasAssetsTab onInsert={onInsertAsset} theme={theme} />}</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({ label, active, theme, onClick }: { label: string; active: boolean; theme: CanvasTheme; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={cn("rounded-lg px-3 py-1.5 text-sm font-semibold transition", active ? "" : "opacity-55 hover:opacity-90")} style={active ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { color: theme.node.text }}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 画布 Tab —— 列出节点,点击居中放大并选中
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NODE_FILTER_OPTIONS = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "图片", value: CanvasNodeType.Image },
|
||||
{ label: "视频", value: CanvasNodeType.Video },
|
||||
{ label: "文本", value: CanvasNodeType.Text },
|
||||
{ label: "音频", value: CanvasNodeType.Audio },
|
||||
{ label: "配置", value: CanvasNodeType.Config },
|
||||
{ label: "分组", value: CanvasNodeType.Group },
|
||||
];
|
||||
|
||||
function nodePreviewText(node: CanvasNodeData) {
|
||||
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || "";
|
||||
return getNodeDefinition(node.type)?.title || node.type;
|
||||
}
|
||||
|
||||
function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set<string>; onFocusNode: (nodeId: string) => void; theme: CanvasTheme }) {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return nodes.filter((node) => (typeFilter === "all" || node.type === typeFilter) && (!query || [node.title, node.metadata?.content, node.metadata?.prompt].filter(Boolean).join(" ").toLowerCase().includes(query)));
|
||||
}, [nodes, keyword, typeFilter]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 px-3 pb-2.5 pt-1">
|
||||
<span className="text-xs font-medium opacity-60">画布元素</span>
|
||||
{filtered.length ? <span className="text-xs opacity-35">{filtered.length}</span> : null}
|
||||
<Select size="small" variant="borderless" className="ml-auto w-20" value={typeFilter} onChange={setTypeFilter} options={NODE_FILTER_OPTIONS} />
|
||||
</div>
|
||||
<div className="px-3 pb-2.5">
|
||||
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索节点" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||
{filtered.length ? (
|
||||
<div className="space-y-1.5">
|
||||
{filtered.map((node) => {
|
||||
const Icon = NODE_TYPE_ICON[node.type] || FileText;
|
||||
const isImage = node.type === CanvasNodeType.Image && node.metadata?.content;
|
||||
const active = selectedNodeIds.has(node.id);
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
onClick={() => onFocusNode(node.id)}
|
||||
className={cn("flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition", active ? "" : "hover:bg-black/5 dark:hover:bg-white/5")}
|
||||
style={active ? { background: theme.toolbar.activeBg } : undefined}
|
||||
>
|
||||
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md" style={{ background: theme.node.fill }}>
|
||||
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-4.5 opacity-70" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 space-y-0.5">
|
||||
<span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || "未命名节点"}</span>
|
||||
<span className="block truncate text-xs leading-snug opacity-50">{nodePreviewText(node)}</span>
|
||||
</span>
|
||||
{node.metadata?.status && node.metadata.status !== "idle" ? <span className="size-1.5 shrink-0 rounded-full" style={{ background: STATUS_COLOR[node.metadata.status] || "transparent" }} /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-16 text-center text-sm opacity-40">画布暂无节点</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ASSET_GROUPS: { kind: AssetKind; label: string; icon: typeof Square }[] = [
|
||||
{ kind: "image", label: "图片", icon: ImageIcon },
|
||||
{ kind: "video", label: "视频", icon: Video },
|
||||
{ kind: "text", label: "文本", icon: FileText },
|
||||
];
|
||||
|
||||
function buildInsertPayload(asset: Asset): InsertAssetPayload {
|
||||
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title };
|
||||
if (asset.kind === "video") return { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height };
|
||||
return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title };
|
||||
}
|
||||
|
||||
function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [tagFilter, setTagFilter] = useState<string>("all");
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
|
||||
const allTags = useMemo(() => Array.from(new Set(assets.flatMap((asset) => asset.tags || []))).slice(0, 20), [assets]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return assets.filter((asset) => (tagFilter === "all" || (asset.tags || []).includes(tagFilter)) && (!query || [asset.title, ...(asset.tags || [])].join(" ").toLowerCase().includes(query)));
|
||||
}, [assets, keyword, tagFilter]);
|
||||
|
||||
const groups = useMemo(() => ASSET_GROUPS.map((group) => ({ ...group, items: filtered.filter((asset) => asset.kind === group.kind) })).filter((group) => group.items.length > 0), [filtered]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="px-3 pb-2">
|
||||
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索资产" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
|
||||
</div>
|
||||
{allTags.length ? (
|
||||
<div className="flex flex-wrap gap-1.5 px-3 pb-2">
|
||||
<Tag.CheckableTag checked={tagFilter === "all"} className={cn("prompt-filter-tag", tagFilter === "all" && "is-active")} onChange={() => setTagFilter("all")}>
|
||||
全部
|
||||
</Tag.CheckableTag>
|
||||
{allTags.map((tag) => (
|
||||
<Tag.CheckableTag key={tag} checked={tagFilter === tag} className={cn("prompt-filter-tag", tagFilter === tag && "is-active")} onChange={() => setTagFilter((prev) => (prev === tag ? "all" : tag))}>
|
||||
{tag}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||
{groups.length ? (
|
||||
<div className="space-y-1">
|
||||
{groups.map((group) => {
|
||||
const isCollapsed = collapsed[group.kind];
|
||||
return (
|
||||
<div key={group.kind}>
|
||||
<button type="button" onClick={() => setCollapsed((prev) => ({ ...prev, [group.kind]: !prev[group.kind] }))} className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs font-semibold opacity-75 transition hover:opacity-100">
|
||||
<ChevronRight className={cn("size-3.5 transition-transform", !isCollapsed && "rotate-90")} />
|
||||
<group.icon className="size-3.5" />
|
||||
<span>{group.label}</span>
|
||||
<span className="opacity-50">{group.items.length}</span>
|
||||
</button>
|
||||
{isCollapsed ? null : (
|
||||
<div className="grid grid-cols-2 gap-2 px-1 pb-2 pt-1">
|
||||
{group.items.map((asset) => (
|
||||
<AssetCard key={asset.id} asset={asset} theme={theme} onClick={() => onInsert(buildInsertPayload(asset))} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无资产" className="pt-16" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetCard({ asset, theme, onClick }: { asset: Asset; theme: CanvasTheme; onClick: () => void }) {
|
||||
const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="group relative overflow-hidden rounded-lg border text-left transition hover:shadow-md" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
|
||||
{cover ? <img src={cover} alt={asset.title} className="aspect-square w-full object-cover" /> : <div className="flex aspect-square items-center justify-center p-2 text-center text-[11px] leading-4 opacity-60">{asset.title}</div>}
|
||||
<div className="truncate px-2 py-1.5 text-[11px] font-medium">{asset.title}</div>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-xs font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100">插入</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Segmented, Switch } from "antd";
|
||||
import { CircleDot, Eraser, FolderOpen, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
|
||||
import { CircleDot, Eraser, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Puzzle, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { getNodePluginId, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||
|
||||
@@ -19,6 +20,7 @@ export function CanvasToolbar({
|
||||
onAddText,
|
||||
onAddConfig,
|
||||
onAddGroup,
|
||||
onAddExtensionNode,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onUpload,
|
||||
@@ -27,7 +29,6 @@ export function CanvasToolbar({
|
||||
onDeselect,
|
||||
onBackgroundModeChange,
|
||||
onShowImageInfoChange,
|
||||
onOpenMyAssets,
|
||||
}: {
|
||||
selectedCount: number;
|
||||
canUndo: boolean;
|
||||
@@ -40,6 +41,7 @@ export function CanvasToolbar({
|
||||
onAddText: () => void;
|
||||
onAddConfig: () => void;
|
||||
onAddGroup: () => void;
|
||||
onAddExtensionNode: (type: string) => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onUpload: () => void;
|
||||
@@ -48,9 +50,9 @@ export function CanvasToolbar({
|
||||
onDeselect: () => void;
|
||||
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
|
||||
onShowImageInfoChange: (show: boolean) => void;
|
||||
onOpenMyAssets: () => void;
|
||||
}) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const colorTheme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const theme = canvasThemes[colorTheme];
|
||||
@@ -58,13 +60,31 @@ export function CanvasToolbar({
|
||||
const [tipX, setTipX] = useState(0);
|
||||
const [appearanceOpen, setAppearanceOpen] = useState(false);
|
||||
const [panelX, setPanelX] = useState(0);
|
||||
const [extensionsOpen, setExtensionsOpen] = useState(false);
|
||||
const [extPanelX, setExtPanelX] = useState(0);
|
||||
// 扩展(插件)节点,随注册表变化实时更新
|
||||
useNodeRegistryVersion();
|
||||
const extensionDefs = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false && getNodePluginId(def.type) !== "builtin");
|
||||
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
|
||||
const hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText };
|
||||
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
|
||||
const tip = hovered ? toolLabel(hovered) : "";
|
||||
|
||||
// 点击工具栏(含弹出面板)以外的地方,关闭弹出的扩展节点/画布外观面板
|
||||
useEffect(() => {
|
||||
if (!extensionsOpen && !appearanceOpen) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
setExtensionsOpen(false);
|
||||
setAppearanceOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
}, [extensionsOpen, appearanceOpen]);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
|
||||
<div ref={rootRef} className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
|
||||
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
|
||||
<div ref={wrapRef} className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0" style={dockStyle}>
|
||||
<ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
|
||||
@@ -95,13 +115,30 @@ export function CanvasToolbar({
|
||||
<ToolbarButton id="tool-group" label="组" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddGroup}>
|
||||
<Group className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton id="tool-upload" label="上传素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
|
||||
{extensionDefs.length ? (
|
||||
<ToolbarButton
|
||||
id="tool-extensions"
|
||||
label="扩展节点"
|
||||
active={extensionsOpen}
|
||||
hovered={hovered}
|
||||
activeStyle={activeStyle}
|
||||
hoverStyle={hoverStyle}
|
||||
wrapRef={wrapRef}
|
||||
onTipX={setTipX}
|
||||
onHover={setHovered}
|
||||
onClick={(event) => {
|
||||
setExtPanelX(getTipX(wrapRef.current, event.currentTarget));
|
||||
setAppearanceOpen(false);
|
||||
setExtensionsOpen((value) => !value);
|
||||
}}
|
||||
>
|
||||
<Puzzle className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
) : null}
|
||||
<ToolbarButton id="tool-upload" label="上传资产" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
|
||||
<Upload className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<Divider theme={theme} />
|
||||
<ToolbarButton id="tool-assets" label="我的素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenMyAssets}>
|
||||
<FolderOpen className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
id="tool-style"
|
||||
label="画布外观"
|
||||
@@ -114,6 +151,7 @@ export function CanvasToolbar({
|
||||
onHover={setHovered}
|
||||
onClick={(event) => {
|
||||
setPanelX(getTipX(wrapRef.current, event.currentTarget));
|
||||
setExtensionsOpen(false);
|
||||
setAppearanceOpen((value) => !value);
|
||||
}}
|
||||
>
|
||||
@@ -133,6 +171,36 @@ export function CanvasToolbar({
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
|
||||
{extensionsOpen && extensionDefs.length ? (
|
||||
<div
|
||||
className="thin-scrollbar pointer-events-auto absolute bottom-[72px] z-30 max-h-[50vh] w-[240px] -translate-x-1/2 overflow-y-auto rounded-xl border p-2 shadow-xl backdrop-blur"
|
||||
style={{ left: extPanelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }}
|
||||
>
|
||||
<div className="px-1.5 pb-1.5 text-[11px] font-medium opacity-50">扩展节点</div>
|
||||
<div className="grid gap-0.5">
|
||||
{extensionDefs.map((def) => (
|
||||
<button
|
||||
key={def.type}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm transition"
|
||||
style={{ color: theme.toolbar.item }}
|
||||
onMouseEnter={(event) => (event.currentTarget.style.background = theme.toolbar.itemHover)}
|
||||
onMouseLeave={(event) => (event.currentTarget.style.background = "transparent")}
|
||||
onClick={() => {
|
||||
onAddExtensionNode(def.type);
|
||||
setExtensionsOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-md text-base" style={{ background: theme.toolbar.itemHover }}>
|
||||
{def.icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{def.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{appearanceOpen ? (
|
||||
<div
|
||||
className="pointer-events-auto absolute bottom-[72px] z-30 w-[248px] -translate-x-1/2 rounded-xl border p-2.5 shadow-xl backdrop-blur"
|
||||
@@ -287,8 +355,8 @@ function toolLabel(id: string) {
|
||||
if (id === "tool-audio") return "音频";
|
||||
if (id === "tool-config") return "生成配置";
|
||||
if (id === "tool-group") return "组";
|
||||
if (id === "tool-upload") return "上传素材";
|
||||
if (id === "tool-assets") return "我的素材";
|
||||
if (id === "tool-extensions") return "扩展节点";
|
||||
if (id === "tool-upload") return "上传资产";
|
||||
if (id === "tool-style") return "画布外观";
|
||||
if (id === "tool-delete") return "删除选中";
|
||||
if (id === "tool-clear") return "清空画布";
|
||||
|
||||
@@ -167,7 +167,12 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const preventWheelScroll = (event: WheelEvent) => event.preventDefault();
|
||||
// 阻止画布滚动导致页面滚动;但浮层(创建菜单/弹窗等)内允许原生滚动
|
||||
const preventWheelScroll = (event: WheelEvent) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (target?.closest("[data-canvas-no-zoom],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown")) return;
|
||||
event.preventDefault();
|
||||
};
|
||||
container.addEventListener("wheel", preventWheelScroll, { passive: false });
|
||||
return () => container.removeEventListener("wheel", preventWheelScroll);
|
||||
}, [containerRef]);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { FileText, Group, Image as ImageIcon, Music2, Settings2, Video } from "lucide-react";
|
||||
|
||||
import { NODE_SPECS } from "@/constant/canvas";
|
||||
import { registerNodeDefinitions } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import type { CanvasNodeDefinition, CanvasNodeResource } from "@/types/canvas-plugin";
|
||||
|
||||
// 内置节点的可扩展元数据(尺寸/初始 metadata 复用 NODE_SPECS)。
|
||||
// 渲染仍由 canvas-node 内部渲染器负责,故不提供 Content。
|
||||
function builtinResource(node: CanvasNodeData): CanvasNodeResource | null {
|
||||
if (node.type === CanvasNodeType.Image && node.metadata?.content) return { kind: "image", url: node.metadata.content };
|
||||
if (node.type === CanvasNodeType.Video && node.metadata?.content) return { kind: "video", url: node.metadata.content };
|
||||
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return { kind: "audio", url: node.metadata.content };
|
||||
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return { kind: "text", text: node.metadata.content || node.metadata.prompt };
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconClass = "size-5";
|
||||
|
||||
const BUILTIN_DEFINITIONS: CanvasNodeDefinition[] = [
|
||||
{ type: CanvasNodeType.Text, title: "文本", icon: <FileText className={iconClass} />, minimapColor: undefined, resource: builtinResource },
|
||||
{ type: CanvasNodeType.Image, title: "图片", icon: <ImageIcon className={iconClass} />, minimapColor: "#10b981", keepAspectRatio: (node: CanvasNodeData) => !node.metadata?.freeResize, resource: builtinResource },
|
||||
{ type: CanvasNodeType.Video, title: "视频", icon: <Video className={iconClass} />, minimapColor: "#f97316", keepAspectRatio: () => true, resource: builtinResource },
|
||||
{ type: CanvasNodeType.Audio, title: "音频", icon: <Music2 className={iconClass} />, minimapColor: "#a855f7", resource: builtinResource },
|
||||
{ type: CanvasNodeType.Config, title: "生成配置", icon: <Settings2 className={iconClass} />, minimapColor: "#60a5fa", hasSourceHandle: false },
|
||||
{ type: CanvasNodeType.Group, title: "组", icon: <Group className={iconClass} />, minimapColor: "#94a3b8" },
|
||||
].map((def) => {
|
||||
const spec = NODE_SPECS[def.type];
|
||||
return { ...def, title: spec.title, defaultSize: { width: spec.width, height: spec.height }, defaultMetadata: spec.metadata };
|
||||
});
|
||||
|
||||
let registered = false;
|
||||
export function registerBuiltinNodes() {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerNodeDefinitions(BUILTIN_DEFINITIONS, "builtin");
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd";
|
||||
import { CircleAlert, Cloud, Plus, RefreshCw, Trash2, Wifi } from "lucide-react";
|
||||
import { Cloud, Pencil, Plus, RefreshCw, Trash2, Wifi } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { fetchChannelModels } from "@/services/api/image";
|
||||
import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer";
|
||||
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
|
||||
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
|
||||
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
|
||||
import { createModelChannel, defaultBaseUrlForApiFormat, filterModelsByCapability, modelOptionLabel, modelOptionsFromChannels, normalizeModelOptionValue, useConfigStore, type AiConfig, type ApiCallFormat, type ConfigTabKey, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { createModelChannel, modelOptionsFromChannels, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore, type AiConfig, type ApiCallFormat, type ConfigTabKey, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
||||
|
||||
type ModelGroup = {
|
||||
capability: ModelCapability;
|
||||
modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel";
|
||||
modelsKey: "imageModels" | "videoModels" | "textModels" | "audioModels";
|
||||
defaultLabel: string;
|
||||
optionsLabel: string;
|
||||
};
|
||||
|
||||
type WebdavDomainProgress = {
|
||||
@@ -26,21 +24,16 @@ type WebdavDomainProgress = {
|
||||
};
|
||||
|
||||
const modelGroups: ModelGroup[] = [
|
||||
{ capability: "image", modelKey: "imageModel", modelsKey: "imageModels", defaultLabel: "默认生图模型", optionsLabel: "生图模型可选项" },
|
||||
{ capability: "video", modelKey: "videoModel", modelsKey: "videoModels", defaultLabel: "默认视频模型", optionsLabel: "视频模型可选项" },
|
||||
{ capability: "text", modelKey: "textModel", modelsKey: "textModels", defaultLabel: "默认文本模型", optionsLabel: "文本模型可选项" },
|
||||
{ capability: "audio", modelKey: "audioModel", modelsKey: "audioModels", defaultLabel: "默认音频模型", optionsLabel: "音频模型可选项" },
|
||||
];
|
||||
|
||||
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
|
||||
{ label: "OpenAI", value: "openai" },
|
||||
{ label: "Gemini", value: "gemini" },
|
||||
{ capability: "image", modelKey: "imageModel", defaultLabel: "默认生图模型" },
|
||||
{ capability: "video", modelKey: "videoModel", defaultLabel: "默认视频模型" },
|
||||
{ capability: "text", modelKey: "textModel", defaultLabel: "默认文本模型" },
|
||||
{ capability: "audio", modelKey: "audioModel", defaultLabel: "默认音频模型" },
|
||||
];
|
||||
|
||||
const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"];
|
||||
const webdavDomainLabels: Record<AppSyncDomainKey, string> = {
|
||||
canvas: "画布",
|
||||
assets: "我的素材",
|
||||
assets: "我的资产",
|
||||
"image-workbench": "生图工作台",
|
||||
"video-workbench": "视频创作台",
|
||||
};
|
||||
@@ -58,7 +51,7 @@ function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProg
|
||||
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
|
||||
const { message } = App.useApp();
|
||||
const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab);
|
||||
const [loadingChannelId, setLoadingChannelId] = useState("");
|
||||
const [editingChannelId, setEditingChannelId] = useState("");
|
||||
const [testingWebdav, setTestingWebdav] = useState(false);
|
||||
const [syncingWebdav, setSyncingWebdav] = useState(false);
|
||||
const [webdavSyncStatus, setWebdavSyncStatus] = useState("");
|
||||
@@ -70,8 +63,8 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
|
||||
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
|
||||
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
|
||||
const modelOptions = config.models.map((model) => ({ label: modelOptionLabel(config, model), value: model }));
|
||||
const webdavReady = Boolean(webdav.url.trim());
|
||||
const editingChannel = config.channels.find((channel) => channel.id === editingChannelId) || null;
|
||||
useEffect(() => setActiveTab(initialTab), [initialTab]);
|
||||
|
||||
const saveConfig = (nextConfig: AiConfig) => {
|
||||
@@ -86,22 +79,12 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
clearPromptContinue();
|
||||
};
|
||||
|
||||
const updateChannels = (channels: ModelChannel[]) => {
|
||||
const nextConfig = withChannels(config, channels);
|
||||
saveConfig(nextConfig);
|
||||
};
|
||||
|
||||
const updateChannel = (id: string, patch: Partial<ModelChannel>) => {
|
||||
updateChannels(config.channels.map((channel) => (channel.id === id ? { ...channel, ...patch, models: patch.models ? uniqueModels(patch.models) : channel.models } : channel)));
|
||||
};
|
||||
|
||||
const updateChannelApiFormat = (channel: ModelChannel, apiFormat: ApiCallFormat) => {
|
||||
const baseUrl = !channel.baseUrl.trim() || channel.baseUrl.trim() === defaultBaseUrlForApiFormat(channel.apiFormat) ? defaultBaseUrlForApiFormat(apiFormat) : channel.baseUrl;
|
||||
updateChannel(channel.id, { apiFormat, baseUrl });
|
||||
};
|
||||
const updateChannels = (channels: ModelChannel[]) => saveConfig(withChannels(config, channels));
|
||||
|
||||
const addChannel = () => {
|
||||
updateChannels([...config.channels, createModelChannel({ name: `渠道 ${config.channels.length + 1}` })]);
|
||||
const channel = createModelChannel({ name: `渠道 ${config.channels.length + 1}` });
|
||||
updateChannels([...config.channels, channel]);
|
||||
setEditingChannelId(channel.id);
|
||||
};
|
||||
|
||||
const deleteChannel = (id: string) => {
|
||||
@@ -112,46 +95,8 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
updateChannels(config.channels.filter((channel) => channel.id !== id));
|
||||
};
|
||||
|
||||
const refreshChannelModels = async (channel: ModelChannel) => {
|
||||
if (!channel.baseUrl.trim() || !channel.apiKey.trim()) {
|
||||
message.error("请先填写该渠道的 Base URL 和 API Key");
|
||||
return;
|
||||
}
|
||||
setLoadingChannelId(channel.id);
|
||||
try {
|
||||
const models = await fetchChannelModels(channel);
|
||||
updateChannels(config.channels.map((item) => (item.id === channel.id ? { ...item, models } : item)));
|
||||
message.success(`${channel.name} 模型列表已更新`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取模型失败");
|
||||
} finally {
|
||||
setLoadingChannelId("");
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAllModels = async () => {
|
||||
const runnable = config.channels.filter((channel) => channel.baseUrl.trim() && channel.apiKey.trim());
|
||||
if (!runnable.length) {
|
||||
message.error("请先填写至少一个渠道的 Base URL 和 API Key");
|
||||
return;
|
||||
}
|
||||
setLoadingChannelId("all");
|
||||
try {
|
||||
const entries = await Promise.all(runnable.map(async (channel) => [channel.id, await fetchChannelModels(channel)] as const));
|
||||
const modelMap = new Map(entries);
|
||||
updateChannels(config.channels.map((channel) => (modelMap.has(channel.id) ? { ...channel, models: modelMap.get(channel.id) || [] } : channel)));
|
||||
message.success("模型列表已更新");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取模型失败");
|
||||
} finally {
|
||||
setLoadingChannelId("");
|
||||
}
|
||||
};
|
||||
|
||||
const updateCapabilityModels = (group: ModelGroup, models: string[]) => {
|
||||
const next = uniqueModels(models.map((model) => normalizeModelOptionValue(model, config.channels)).filter(Boolean));
|
||||
updateConfig(group.modelsKey, next);
|
||||
if (!next.includes(config[group.modelKey])) updateConfig(group.modelKey, next[0] || "");
|
||||
const saveChannel = (channel: ModelChannel) => {
|
||||
updateChannels(config.channels.map((item) => (item.id === channel.id ? channel : item)));
|
||||
};
|
||||
|
||||
const testWebdav = async () => {
|
||||
@@ -196,7 +141,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
try {
|
||||
const result = await syncAppDataToWebdav(webdav, updateWebdavProgress);
|
||||
updateWebdavConfig("lastSyncedAt", result.syncedAt);
|
||||
message.success(`同步完成:${result.projects} 个画布,${result.assets} 个素材,${result.imageLogs + result.videoLogs} 条记录,本次上传 ${result.uploadedFiles} 个文件 ${formatBytes(result.uploadedBytes)}`);
|
||||
message.success(`同步完成:${result.projects} 个画布,${result.assets} 个资产,${result.imageLogs + result.videoLogs} 条记录,本次上传 ${result.uploadedFiles} 个文件 ${formatBytes(result.uploadedBytes)}`);
|
||||
} catch (error) {
|
||||
setWebdavSyncStatus(error instanceof Error ? error.message : "WebDAV 同步失败");
|
||||
message.error(error instanceof Error ? error.message : "WebDAV 同步失败");
|
||||
@@ -215,107 +160,48 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
key: "channels",
|
||||
label: "渠道",
|
||||
children: (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex w-fit max-w-full flex-wrap items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2.5 py-1.5 text-xs text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
<CircleAlert className="size-3.5 shrink-0" />
|
||||
<span className="font-semibold">重要:</span>
|
||||
<span>新增或拉取模型后,需要到“模型”Tab 选择可选项才会显示。</span>
|
||||
<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold text-amber-900 dark:text-amber-100" onClick={() => setActiveTab("models")}>
|
||||
去模型设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={Boolean(loadingChannelId)} onClick={() => void refreshAllModels()}>
|
||||
拉取全部
|
||||
</Button>
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
|
||||
新增渠道
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-xs text-stone-500">每个渠道选择一个协议并拉取模型,为每个模型指定能力(生图/视频/文本/音频),并可自定义调用脚本。</div>
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
|
||||
新增渠道
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{config.channels.map((channel) => (
|
||||
<section key={channel.id} className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div>
|
||||
<div className="mt-1 text-xs text-stone-500">
|
||||
{apiFormatLabel(channel.apiFormat)} · 已保存 {channel.models.length} 个模型
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button size="small" loading={loadingChannelId === channel.id} onClick={() => void refreshChannelModels(channel)}>
|
||||
拉取模型
|
||||
</Button>
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} />
|
||||
<div key={channel.id} className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div>
|
||||
<div className="mt-1 truncate text-xs text-stone-500">
|
||||
{apiFormatLabel(channel.apiFormat)} · {channel.models.length} 个模型 · {channel.baseUrl || "未填写接口地址"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Form.Item label="渠道名称" className="mb-0">
|
||||
<Input value={channel.name} onChange={(event) => updateChannel(channel.id, { name: event.target.value })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="调用格式" className="mb-0">
|
||||
<Select value={channel.apiFormat} options={apiFormatOptions} onChange={(value: ApiCallFormat) => updateChannelApiFormat(channel, value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Base URL" className="mb-0">
|
||||
<Input value={channel.baseUrl} onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="API Key" className="mb-0">
|
||||
<Input.Password value={channel.apiKey} onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="模型列表" className="mb-0 md:col-span-2">
|
||||
<Select mode="tags" showSearch allowClear maxTagCount="responsive" placeholder="输入模型名,或点击拉取模型" value={channel.models} onChange={(models) => updateChannel(channel.id, { models })} />
|
||||
</Form.Item>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingChannelId(channel.id)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "models",
|
||||
label: "模型",
|
||||
key: "preferences",
|
||||
label: "偏好设置",
|
||||
children: (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<div className="mb-4 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="text-sm font-semibold">默认模型和可选项</div>
|
||||
<div className="mt-1 text-xs leading-5 text-stone-500">可选项决定各处下拉框展示哪些模型;同名模型会以括号里的渠道名区分。</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{modelGroups.map((group) => (
|
||||
<Form.Item key={group.modelsKey} label={group.optionsLabel} className="mb-0">
|
||||
<Select
|
||||
mode="tags"
|
||||
showSearch
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
placeholder={config.models.length ? `请选择或输入${group.optionsLabel}` : "先到渠道里填写或拉取模型"}
|
||||
value={config[group.modelsKey]}
|
||||
options={modelOptions}
|
||||
onChange={(models) => updateCapabilityModels(group, models)}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="mb-2 text-sm font-semibold">默认模型</div>
|
||||
<div className="mb-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{modelGroups.map((group) => (
|
||||
<Form.Item key={group.modelKey} label={group.defaultLabel} className="mb-0">
|
||||
<ModelPicker config={config} value={config[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "preferences",
|
||||
label: "生成偏好",
|
||||
children: (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<div className="mb-2 text-sm font-semibold">生成偏好</div>
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
|
||||
<Input
|
||||
@@ -366,7 +252,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
<Cloud className="size-4" />
|
||||
WebDAV 同步
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-stone-500">同步画布、我的素材、生成记录和本地媒体文件,不包含 AI API Key;浏览器会直接连接 WebDAV 服务。</div>
|
||||
<div className="mt-1 text-xs text-stone-500">同步画布、我的资产、生成记录和本地媒体文件,不包含 AI API Key;浏览器会直接连接 WebDAV 服务。</div>
|
||||
</div>
|
||||
<div className="text-xs text-stone-500">{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}</div>
|
||||
</div>
|
||||
@@ -407,6 +293,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<ChannelEditorDrawer open={Boolean(editingChannel)} channel={editingChannel} onSave={saveChannel} onClose={() => setEditingChannelId("")} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -420,7 +307,7 @@ export function AppConfigModal() {
|
||||
title={
|
||||
<div>
|
||||
<div className="text-lg font-semibold">配置与用户偏好</div>
|
||||
<div className="mt-1 text-xs font-normal text-stone-500">渠道聚合、模型选择和同步偏好</div>
|
||||
<div className="mt-1 text-xs font-normal text-stone-500">渠道聚合、默认模型和同步偏好</div>
|
||||
</div>
|
||||
}
|
||||
open={isConfigOpen}
|
||||
@@ -436,48 +323,33 @@ export function AppConfigModal() {
|
||||
}
|
||||
|
||||
function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig {
|
||||
const models = modelOptionsFromChannels(channels);
|
||||
const imageModels = keepOrSuggest(config.imageModels, filterModelsByCapability(models, "image"), models);
|
||||
const videoModels = keepOrSuggest(config.videoModels, filterModelsByCapability(models, "video"), models);
|
||||
const textModels = keepOrSuggest(config.textModels, filterModelsByCapability(models, "text"), models);
|
||||
const audioModels = keepOrSuggest(config.audioModels, filterModelsByCapability(models, "audio"), models);
|
||||
return {
|
||||
const next: AiConfig = {
|
||||
...config,
|
||||
channels,
|
||||
models,
|
||||
models: modelOptionsFromChannels(channels),
|
||||
baseUrl: channels[0]?.baseUrl || config.baseUrl,
|
||||
apiKey: channels[0]?.apiKey || config.apiKey,
|
||||
apiFormat: channels[0]?.apiFormat || config.apiFormat,
|
||||
imageModels,
|
||||
videoModels,
|
||||
textModels,
|
||||
audioModels,
|
||||
imageModel: normalizeDefaultModel(config.imageModel, imageModels),
|
||||
videoModel: normalizeDefaultModel(config.videoModel, videoModels),
|
||||
textModel: normalizeDefaultModel(config.textModel, textModels),
|
||||
audioModel: normalizeDefaultModel(config.audioModel, audioModels),
|
||||
};
|
||||
return {
|
||||
...next,
|
||||
imageModel: pickDefaultModel(next, "image", config.imageModel),
|
||||
videoModel: pickDefaultModel(next, "video", config.videoModel),
|
||||
textModel: pickDefaultModel(next, "text", config.textModel),
|
||||
audioModel: pickDefaultModel(next, "audio", config.audioModel),
|
||||
};
|
||||
}
|
||||
|
||||
function keepOrSuggest(current: string[], suggested: string[], allModels: string[]) {
|
||||
const available = new Set(allModels);
|
||||
const kept = uniqueModels(current).filter((model) => available.has(model));
|
||||
return kept.length ? kept : suggested;
|
||||
}
|
||||
|
||||
function normalizeDefaultModel(value: string, options: string[]) {
|
||||
if (options.includes(value)) return value;
|
||||
return options[0] || value;
|
||||
function pickDefaultModel(config: AiConfig, capability: ModelCapability, current: string) {
|
||||
const options = selectableModelsByCapability(config, capability);
|
||||
const normalized = normalizeModelOptionValue(current, config.channels);
|
||||
return options.includes(normalized) ? normalized : options[0] || "";
|
||||
}
|
||||
|
||||
function normalizeImageCount(value: string) {
|
||||
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3))));
|
||||
}
|
||||
|
||||
function uniqueModels(models: string[]) {
|
||||
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function apiFormatLabel(apiFormat: ApiCallFormat) {
|
||||
return apiFormat === "gemini" ? "Gemini" : "OpenAI";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Button, Drawer, Input, Segmented, Select, Space } from "antd";
|
||||
import { ListPlus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { defaultBaseUrlForApiFormat, guessCapability, normalizeChannelModels, type ApiCallFormat, type ChannelModel, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { ModelScriptEditor } from "./model-script-editor";
|
||||
import { ModelSelectModal } from "./model-select-modal";
|
||||
|
||||
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
|
||||
{ label: "OpenAI", value: "openai" },
|
||||
{ label: "Gemini", value: "gemini" },
|
||||
];
|
||||
|
||||
const capabilityOptions: Array<{ label: string; value: ModelCapability }> = [
|
||||
{ label: "生图", value: "image" },
|
||||
{ label: "视频", value: "video" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "音频", value: "audio" },
|
||||
];
|
||||
|
||||
type ScriptTarget = { name: string; capability: ModelCapability; value: string };
|
||||
|
||||
export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open: boolean; channel: ModelChannel | null; onSave: (channel: ModelChannel) => void; onClose: () => void }) {
|
||||
const [draft, setDraft] = useState<ModelChannel | null>(channel);
|
||||
const [selectOpen, setSelectOpen] = useState(false);
|
||||
const [scriptTarget, setScriptTarget] = useState<ScriptTarget | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && channel) setDraft(channel);
|
||||
}, [open, channel]);
|
||||
|
||||
if (!draft) return null;
|
||||
|
||||
const patch = (value: Partial<ModelChannel>) => setDraft((current) => (current ? { ...current, ...value } : current));
|
||||
const setModels = (models: ChannelModel[]) => patch({ models });
|
||||
|
||||
const changeApiFormat = (apiFormat: ApiCallFormat) => {
|
||||
const baseUrl = !draft.baseUrl.trim() || draft.baseUrl.trim() === defaultBaseUrlForApiFormat(draft.apiFormat) ? defaultBaseUrlForApiFormat(apiFormat) : draft.baseUrl;
|
||||
patch({ apiFormat, baseUrl });
|
||||
};
|
||||
|
||||
const applySelection = (names: string[]) => {
|
||||
const map = new Map(draft.models.map((model) => [model.name, model]));
|
||||
setModels(names.map((name) => map.get(name) || { name, capability: guessCapability(name) }));
|
||||
};
|
||||
|
||||
const setCapability = (name: string, capability: ModelCapability) => setModels(draft.models.map((model) => (model.name === name ? { ...model, capability } : model)));
|
||||
const setScript = (name: string, script: string) => setModels(draft.models.map((model) => (model.name === name ? { ...model, script: script || undefined } : model)));
|
||||
const removeModel = (name: string) => setModels(draft.models.filter((model) => model.name !== name));
|
||||
|
||||
const save = () => {
|
||||
onSave({ ...draft, name: draft.name.trim() || "未命名渠道", models: normalizeChannelModels(draft.models) });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
width={640}
|
||||
title="编辑渠道"
|
||||
onClose={onClose}
|
||||
styles={{ body: { paddingTop: 16 } }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="primary" onClick={save}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium">渠道名称</span>
|
||||
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium">协议</span>
|
||||
<Select className="w-full" value={draft.apiFormat} options={apiFormatOptions} onChange={changeApiFormat} />
|
||||
</label>
|
||||
<label className="block md:col-span-2">
|
||||
<span className="mb-1 block text-sm font-medium">接口地址</span>
|
||||
<Input value={draft.baseUrl} onChange={(event) => patch({ baseUrl: event.target.value })} placeholder="https://api.example.com" />
|
||||
</label>
|
||||
<label className="block md:col-span-2">
|
||||
<span className="mb-1 block text-sm font-medium">API Key</span>
|
||||
<Input.Password value={draft.apiKey} onChange={(event) => patch({ apiKey: event.target.value })} placeholder="sk-..." />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">渠道模型</div>
|
||||
<div className="mt-0.5 text-xs text-stone-500">已选 {draft.models.length} 个;为每个模型指定能力并可自定义调用脚本。</div>
|
||||
</div>
|
||||
<Button type="primary" icon={<ListPlus className="size-4" />} onClick={() => setSelectOpen(true)}>
|
||||
选择模型
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-stone-200 p-2 dark:border-stone-800">
|
||||
{draft.models.length ? (
|
||||
draft.models.map((model) => (
|
||||
<div key={model.name} className="flex flex-wrap items-center gap-3 rounded-md px-2 py-1.5 hover:bg-stone-50 dark:hover:bg-stone-900/40">
|
||||
<span className="min-w-0 flex-1 truncate text-sm" title={model.name}>
|
||||
{model.name}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Segmented size="small" value={model.capability} options={capabilityOptions} onChange={(value) => setCapability(model.name, value as ModelCapability)} />
|
||||
<Button size="small" type={model.script ? "primary" : "default"} ghost={Boolean(model.script)} onClick={() => setScriptTarget({ name: model.name, capability: model.capability, value: model.script || "" })}>
|
||||
{model.script ? "脚本已设" : "调用脚本"}
|
||||
</Button>
|
||||
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} onClick={() => removeModel(model.name)} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="px-2 py-8 text-center text-sm text-stone-500">点击「选择模型」拉取或手动增加模型。</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModelSelectModal open={selectOpen} channel={draft} selectedNames={draft.models.map((model) => model.name)} onConfirm={applySelection} onClose={() => setSelectOpen(false)} />
|
||||
|
||||
<ModelScriptEditor
|
||||
open={Boolean(scriptTarget)}
|
||||
capability={scriptTarget?.capability || "text"}
|
||||
modelName={scriptTarget?.name || ""}
|
||||
value={scriptTarget?.value || ""}
|
||||
onSave={(script) => scriptTarget && setScript(scriptTarget.name, script)}
|
||||
onClose={() => setScriptTarget(null)}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { Button, Modal } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { PLUGIN_RETURNS, PLUGIN_TEMPLATES, PLUGIN_VARIABLES } from "@/services/api/model-plugin";
|
||||
import type { ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
const capabilityLabels: Record<ModelCapability, string> = { image: "生图", video: "视频", text: "文本", audio: "音频" };
|
||||
|
||||
function isDarkMode() {
|
||||
return typeof document !== "undefined" && document.documentElement.classList.contains("dark");
|
||||
}
|
||||
|
||||
export function ModelScriptEditor({ open, capability, modelName, value, onSave, onClose }: { open: boolean; capability: ModelCapability; modelName: string; value: string; onSave: (script: string) => void; onClose: () => void }) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
useEffect(() => {
|
||||
if (open) setDraft(value);
|
||||
}, [open, value]);
|
||||
|
||||
const variables = PLUGIN_VARIABLES.filter((variable) => !variable.capabilities || variable.capabilities.includes(capability));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={
|
||||
<div>
|
||||
<div className="text-base font-semibold">
|
||||
{capabilityLabels[capability]}
|
||||
{modelName ? ` - ${modelName}` : ""}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-normal text-stone-500">脚本是一段异步函数体,直接使用下方变量,最后 return 结果;留空则使用系统默认调用。</div>
|
||||
</div>
|
||||
}
|
||||
width={1080}
|
||||
centered
|
||||
onCancel={onClose}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
footer={
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{PLUGIN_TEMPLATES[capability].map((template) => (
|
||||
<Button key={template.label} size="small" onClick={() => setDraft(template.script)}>
|
||||
插入{template.label}模板
|
||||
</Button>
|
||||
))}
|
||||
<Button size="small" danger onClick={() => setDraft("")}>
|
||||
恢复默认调用
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
onSave(draft.trim());
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex h-[60vh] min-h-[420px] border-t border-stone-200 dark:border-stone-800">
|
||||
<aside className="flex w-[320px] shrink-0 flex-col overflow-y-auto border-r border-stone-200 bg-stone-50/80 dark:border-stone-800 dark:bg-stone-900/40">
|
||||
<div className="border-b border-stone-200/70 px-4 py-3 dark:border-stone-800/70">
|
||||
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-stone-400">返回要求</div>
|
||||
<div className="text-xs leading-6 text-stone-600 dark:text-stone-300">{PLUGIN_RETURNS[capability]}</div>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-stone-400">可用变量</span>
|
||||
<span className="text-[10px] text-stone-400">点击插入</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{variables.map((variable) => (
|
||||
<button
|
||||
key={variable.name}
|
||||
type="button"
|
||||
onClick={() => setDraft((current) => (current ? `${current}\n${variable.name}` : variable.name))}
|
||||
className="group block w-full rounded-lg border border-transparent px-2.5 py-2 text-left transition-colors hover:border-stone-200 hover:bg-white dark:hover:border-stone-700 dark:hover:bg-stone-800/60"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline gap-1.5">
|
||||
<code className="rounded bg-stone-200/80 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-stone-800 group-hover:bg-blue-100 group-hover:text-blue-700 dark:bg-stone-800 dark:text-stone-100 dark:group-hover:bg-blue-950 dark:group-hover:text-blue-300">
|
||||
{variable.name}
|
||||
</code>
|
||||
<span className="font-mono text-[10px] text-stone-400">{variable.type}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-stone-500 dark:text-stone-400">{variable.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="min-w-0 flex-1 overflow-hidden bg-white dark:bg-stone-950">
|
||||
<CodeMirror
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
height="100%"
|
||||
theme={isDarkMode() ? "dark" : "light"}
|
||||
extensions={[javascript()]}
|
||||
placeholder={"// 留空使用系统默认调用;点击右下角「插入模板」查看示例。"}
|
||||
style={{ height: "100%", fontSize: 13 }}
|
||||
className="h-full [&_.cm-editor]:h-full [&_.cm-gutters]:border-none [&_.cm-scroller]:overflow-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { App, Button, Checkbox, Input, Modal, Tabs } from "antd";
|
||||
import { RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { fetchChannelModels } from "@/services/api/image";
|
||||
import type { ModelChannel } from "@/stores/use-config-store";
|
||||
|
||||
// 选择渠道模型弹窗:拉取上游模型列表或手动增加,勾选后才会进入渠道模型列表。
|
||||
export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onClose }: { open: boolean; channel: ModelChannel | null; selectedNames: string[]; onConfirm: (names: string[]) => void; onClose: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [existing, setExisting] = useState<string[]>([]);
|
||||
const [fetched, setFetched] = useState<string[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [activeTab, setActiveTab] = useState("new");
|
||||
const [search, setSearch] = useState("");
|
||||
const [manual, setManual] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setExisting(selectedNames);
|
||||
setFetched([]);
|
||||
setSelected(new Set(selectedNames));
|
||||
setActiveTab(selectedNames.length ? "existing" : "new");
|
||||
setSearch("");
|
||||
setManual("");
|
||||
}, [open, selectedNames]);
|
||||
|
||||
const currentList = activeTab === "new" ? fetched : existing;
|
||||
const visibleList = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
return keyword ? currentList.filter((name) => name.toLowerCase().includes(keyword)) : currentList;
|
||||
}, [currentList, search]);
|
||||
const visibleSelectedCount = visibleList.filter((name) => selected.has(name)).length;
|
||||
|
||||
const toggle = (name: string, checked: boolean) =>
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(name);
|
||||
else next.delete(name);
|
||||
return next;
|
||||
});
|
||||
|
||||
const selectVisible = (checked: boolean) =>
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
visibleList.forEach((name) => (checked ? next.add(name) : next.delete(name)));
|
||||
return next;
|
||||
});
|
||||
|
||||
const addManual = () => {
|
||||
const name = manual.trim();
|
||||
if (!name) return;
|
||||
if (!fetched.includes(name) && !existing.includes(name)) setFetched((current) => [name, ...current]);
|
||||
setSelected((current) => new Set(current).add(name));
|
||||
setManual("");
|
||||
setActiveTab("new");
|
||||
};
|
||||
|
||||
const fetchModels = async () => {
|
||||
if (!channel) return;
|
||||
if (!channel.baseUrl.trim() || !channel.apiKey.trim()) {
|
||||
message.error("请先填写接口地址和 API Key");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const models = await fetchChannelModels(channel);
|
||||
setFetched(models);
|
||||
setActiveTab("new");
|
||||
message.success(`已拉取 ${models.length} 个模型`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "拉取模型失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
const ordered = [...existing, ...fetched].filter((name, index, list) => list.indexOf(name) === index).filter((name) => selected.has(name));
|
||||
onConfirm(ordered);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
width={880}
|
||||
centered
|
||||
onCancel={onClose}
|
||||
title={
|
||||
<span>
|
||||
选择渠道模型 <span className="ml-2 text-xs font-normal text-stone-500">已选择 {selected.size} / {new Set([...existing, ...fetched]).size}</span>
|
||||
</span>
|
||||
}
|
||||
styles={{ body: { maxHeight: "62vh", overflowY: "auto" } }}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button key="confirm" type="primary" onClick={confirm}>
|
||||
确定
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input className="min-w-[200px] flex-1" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索模型" prefix={<Search className="size-4 text-stone-400" />} allowClear />
|
||||
<Input className="min-w-[180px] flex-1" value={manual} onChange={(event) => setManual(event.target.value)} onPressEnter={addManual} placeholder="输入模型名称" />
|
||||
<Button onClick={addManual}>增加模型</Button>
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void fetchModels()}>
|
||||
拉取模型列表
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-stone-500">如果上游不提供 OpenAI /models 模型列表接口,请在这里手动增加模型名称。</div>
|
||||
|
||||
<Tabs
|
||||
className="mt-3"
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{ key: "new", label: `新获取的模型 (${fetched.length})` },
|
||||
{ key: "existing", label: `已有的模型 (${existing.length})` },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-stone-500">当前列表已选择 {visibleSelectedCount} / {visibleList.length}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" disabled={!visibleList.length} onClick={() => selectVisible(true)}>
|
||||
全选当前列表
|
||||
</Button>
|
||||
<Button size="small" disabled={!visibleSelectedCount} onClick={() => selectVisible(false)}>
|
||||
取消当前列表
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visibleList.length ? (
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-3 md:grid-cols-2">
|
||||
{visibleList.map((name) => (
|
||||
<Checkbox key={name} checked={selected.has(name)} onChange={(event) => toggle(name, event.target.checked)}>
|
||||
<span className="truncate" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-sm text-stone-500">{activeTab === "new" ? "点击「拉取模型列表」获取上游模型,或手动增加模型名称。" : "暂无已选择的模型。"}</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { BookOpen, Keyboard, Settings2 } from "lucide-react";
|
||||
import { BookOpen, Keyboard, Puzzle, Settings2 } from "lucide-react";
|
||||
|
||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||
import { GitHubLink } from "@/components/layout/github-link";
|
||||
@@ -14,9 +14,10 @@ type UserStatusActionsProps = {
|
||||
showConfig?: boolean;
|
||||
variant?: "default" | "canvas";
|
||||
onOpenShortcuts?: () => void;
|
||||
onOpenPlugins?: () => void;
|
||||
};
|
||||
|
||||
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts }: UserStatusActionsProps) {
|
||||
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts, onOpenPlugins }: UserStatusActionsProps) {
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
@@ -29,6 +30,11 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
|
||||
|
||||
return (
|
||||
<div className="inline-flex shrink-0 items-center gap-1">
|
||||
{onOpenPlugins ? (
|
||||
<button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenPlugins} aria-label="节点插件" title="节点插件">
|
||||
<Puzzle className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={naturalIconClass} style={iconStyle} aria-label="文档" title="文档">
|
||||
<BookOpen className="size-4" />
|
||||
</a>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
|
||||
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
||||
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : "";
|
||||
if (capability && config.models.length) return "请先在上方配置可选模型";
|
||||
if (capability && config.models.length) return `请先在渠道里为${label}指定模型`;
|
||||
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
import type { CanvasNodeMetadata } from "@/types/canvas";
|
||||
import { getNodeSpec as getRegistryNodeSpec } from "@/lib/canvas/node-registry";
|
||||
|
||||
type CanvasNodeSpec = {
|
||||
width: number;
|
||||
@@ -44,6 +45,9 @@ export const NODE_SPECS = {
|
||||
},
|
||||
} satisfies Record<CanvasNodeType, CanvasNodeSpec>;
|
||||
|
||||
export function getNodeSpec(type: CanvasNodeType) {
|
||||
return NODE_SPECS[type];
|
||||
// 内置类型返回内置 spec;插件类型从注册表解析
|
||||
export function getNodeSpec(type: string) {
|
||||
if ((Object.values(CanvasNodeType) as string[]).includes(type)) return NODE_SPECS[type as CanvasNodeType];
|
||||
const spec = getRegistryNodeSpec(type);
|
||||
return { width: spec.width, height: spec.height, title: spec.title, metadata: spec.metadata };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export const APP_VERSION = __APP_VERSION__ || "dev";
|
||||
|
||||
export const DOCS_URL = import.meta.env.VITE_DOC_URL || "https://docs.canvas.best";
|
||||
|
||||
// 官方插件清单地址:CI 发布到 plugins-dist 分支,经 jsDelivr 远程拉取;可用环境变量覆盖成自建来源
|
||||
export const PLUGIN_REGISTRY_URL = import.meta.env.VITE_PLUGIN_REGISTRY_URL || "https://cdn.jsdelivr.net/gh/basketikun/infinite-canvas@plugins-dist/official-plugins.json";
|
||||
|
||||
@@ -23,7 +23,7 @@ export const navigationTools = [
|
||||
},
|
||||
{
|
||||
slug: "assets",
|
||||
label: "我的素材",
|
||||
label: "我的资产",
|
||||
icon: Images,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,10 +6,10 @@ import { imageAspectOptions, imageQualityOptions } from "@/components/image-sett
|
||||
import { videoResolutionOptions, videoSecondOptions, videoSizeOptions } from "@/components/video-settings-panel";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, useConfigStore } from "@/stores/use-config-store";
|
||||
import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore } from "@/stores/use-config-store";
|
||||
import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store";
|
||||
|
||||
// 在网页端执行 Agent 的「站点级」工具(画布列表、工作台生成、提示词搜索、素材增删查等)。
|
||||
// 在网页端执行 Agent 的「站点级」工具(画布列表、工作台生成、提示词搜索、资产增删查等)。
|
||||
// 这些工具的数据都在浏览器本地(localforage / zustand),因此由本模块直接读写对应 store 后返回结果。
|
||||
|
||||
export const SITE_TOOL_NAMES = [
|
||||
@@ -36,8 +36,8 @@ export const SITE_TOOL_LABELS: Record<SiteToolName, string> = {
|
||||
workbench_video_get_config: "视频配置",
|
||||
workbench_video_generate: "视频创作台生成",
|
||||
prompts_search: "搜索提示词",
|
||||
assets_list: "素材列表",
|
||||
assets_add: "添加素材",
|
||||
assets_list: "资产列表",
|
||||
assets_add: "添加资产",
|
||||
};
|
||||
|
||||
type SiteToolInput = Record<string, unknown>;
|
||||
@@ -87,7 +87,7 @@ function getImageConfig() {
|
||||
const model = config.imageModel || config.model;
|
||||
return {
|
||||
current: { model, modelName: modelOptionName(model), quality: config.quality || "auto", size: config.size || "1:1", count: config.count || "1" },
|
||||
models: config.imageModels.map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
||||
models: selectableModelsByCapability(config, "image").map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
||||
qualityOptions: imageQualityOptions,
|
||||
sizeOptions: imageAspectOptions,
|
||||
countRange: { min: 1, max: 15 },
|
||||
@@ -135,7 +135,7 @@ function getVideoConfig() {
|
||||
generateAudio: config.videoGenerateAudio !== "false",
|
||||
watermark: config.videoWatermark === "true",
|
||||
},
|
||||
models: config.videoModels.map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
||||
models: selectableModelsByCapability(config, "video").map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
||||
sizeOptions: videoSizeOptions,
|
||||
secondsOptions: videoSecondOptions,
|
||||
resolutionOptions: videoResolutionOptions,
|
||||
@@ -194,7 +194,7 @@ async function searchPrompts(input: SiteToolInput) {
|
||||
|
||||
function listAssets(input: SiteToolInput) {
|
||||
const { assets, hydrated } = useAssetStore.getState();
|
||||
if (!hydrated) throw new Error("素材还在加载中,请稍后重试");
|
||||
if (!hydrated) throw new Error("资产还在加载中,请稍后重试");
|
||||
const kind = input.kind === "text" || input.kind === "image" || input.kind === "video" ? input.kind : "all";
|
||||
const keyword = String(input.keyword || "").trim().toLowerCase();
|
||||
const filtered = assets.filter((asset) => {
|
||||
@@ -221,7 +221,7 @@ function listAssets(input: SiteToolInput) {
|
||||
async function addAsset(input: SiteToolInput) {
|
||||
const kind = input.kind;
|
||||
const title = String(input.title || "").trim();
|
||||
if (!title) throw new Error("请提供素材标题 title");
|
||||
if (!title) throw new Error("请提供资产标题 title");
|
||||
const tags = Array.isArray(input.tags) ? input.tags.filter((tag): tag is string => typeof tag === "string") : [];
|
||||
const source = typeof input.source === "string" ? input.source : "Agent";
|
||||
const note = typeof input.note === "string" ? input.note : undefined;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { getNodeSpec } from "@/constant/canvas";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type ViewportTransform } from "@/types/canvas";
|
||||
import { getNodeSpec, isRegisteredNodeType } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ViewportTransform } from "@/types/canvas";
|
||||
|
||||
export type CanvasAgentOp =
|
||||
| { type: "add_node"; id?: string; nodeType?: CanvasNodeType; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata }
|
||||
| { type: "add_node"; id?: string; nodeType?: CanvasNodeTypeId; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata }
|
||||
| { type: "update_node"; id: string; patch?: Partial<CanvasNodeData>; metadata?: CanvasNodeMetadata }
|
||||
| { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeType }
|
||||
| { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeTypeId }
|
||||
| { type: "delete_connections"; id?: string; ids?: string[]; all?: boolean }
|
||||
| { type: "connect_nodes"; id?: string; fromNodeId: string; toNodeId: string }
|
||||
| { type: "set_viewport"; viewport: ViewportTransform }
|
||||
@@ -42,7 +42,7 @@ export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops?: CanvasA
|
||||
(Array.isArray(ops) ? ops : []).forEach((op, index) => {
|
||||
if (!op?.type) return;
|
||||
if (op.type === "add_node") {
|
||||
const nodeType = Object.values(CanvasNodeType).includes(op.nodeType as CanvasNodeType) ? op.nodeType! : CanvasNodeType.Text;
|
||||
const nodeType = op.nodeType && isRegisteredNodeType(op.nodeType) ? op.nodeType : CanvasNodeType.Text;
|
||||
const spec = getNodeSpec(nodeType);
|
||||
const node: CanvasNodeData = {
|
||||
id: op.id || `${nodeType}-${Date.now()}-${index}`,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import localforage from "localforage";
|
||||
|
||||
import type { PluginStorage } from "@/types/canvas-plugin";
|
||||
|
||||
// 画布内轻量事件总线,供节点/插件互相通信
|
||||
type Handler = (payload: unknown) => void;
|
||||
const handlers = new Map<string, Set<Handler>>();
|
||||
|
||||
export function emitCanvasEvent(event: string, payload?: unknown) {
|
||||
handlers.get(event)?.forEach((handler) => {
|
||||
try {
|
||||
handler(payload);
|
||||
} catch (error) {
|
||||
console.error(`[canvas-event] handler for "${event}" failed`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function onCanvasEvent(event: string, handler: Handler) {
|
||||
let set = handlers.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
handlers.set(event, set);
|
||||
}
|
||||
set.add(handler);
|
||||
return () => set!.delete(handler);
|
||||
}
|
||||
|
||||
// 插件私有存储,按 pluginId 命名空间隔离
|
||||
const stores = new Map<string, LocalForage>();
|
||||
|
||||
export function createPluginStorage(pluginId: string): PluginStorage {
|
||||
let store = stores.get(pluginId);
|
||||
if (!store) {
|
||||
store = localforage.createInstance({ name: "infinite-canvas-plugins", storeName: pluginId });
|
||||
stores.set(pluginId, store);
|
||||
}
|
||||
return {
|
||||
get: (key) => store!.getItem(key),
|
||||
set: async (key, value) => {
|
||||
await store!.setItem(key, value);
|
||||
},
|
||||
remove: async (key) => {
|
||||
await store!.removeItem(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export type CanvasResourceKind = "image" | "video" | "audio" | "text";
|
||||
@@ -71,7 +72,7 @@ function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
|
||||
label,
|
||||
title: node.title || label,
|
||||
previewUrl: node.metadata?.content,
|
||||
text: node.type === CanvasNodeType.Text ? node.metadata?.content || node.metadata?.prompt : undefined,
|
||||
text: resourceText(node),
|
||||
active,
|
||||
},
|
||||
];
|
||||
@@ -89,10 +90,17 @@ function isResourceNode(node: CanvasNodeData) {
|
||||
return Boolean(resourceKind(node));
|
||||
}
|
||||
|
||||
function resourceText(node: CanvasNodeData): string | undefined {
|
||||
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt;
|
||||
const resource = getNodeDefinition(node.type)?.resource?.(node);
|
||||
return resource?.kind === "text" ? resource.text : undefined;
|
||||
}
|
||||
|
||||
function resourceKind(node: CanvasNodeData): CanvasResourceKind | null {
|
||||
if (node.type === CanvasNodeType.Image && node.metadata?.content) return "image";
|
||||
if (node.type === CanvasNodeType.Video && node.metadata?.content) return "video";
|
||||
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return "audio";
|
||||
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return "text";
|
||||
return null;
|
||||
// 插件节点通过 definition.resource 声明可作为输入
|
||||
return getNodeDefinition(node.type)?.resource?.(node)?.kind || null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import type { CanvasNodeDefinition } from "@/types/canvas-plugin";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
const definitions = new Map<string, CanvasNodeDefinition>();
|
||||
const ownerByType = new Map<string, string>(); // type -> pluginId(内置为 "builtin")
|
||||
|
||||
// 注册表版本号,注册/卸载时自增,驱动创建菜单等 UI 重渲染
|
||||
export const useNodeRegistryVersion = create<{ version: number }>(() => ({ version: 0 }));
|
||||
function bump() {
|
||||
useNodeRegistryVersion.setState((state) => ({ version: state.version + 1 }));
|
||||
}
|
||||
|
||||
export function registerNodeDefinitions(defs: CanvasNodeDefinition[], pluginId = "builtin") {
|
||||
defs.forEach((def) => {
|
||||
definitions.set(def.type, def);
|
||||
ownerByType.set(def.type, pluginId);
|
||||
});
|
||||
bump();
|
||||
}
|
||||
|
||||
export function unregisterPluginNodes(pluginId: string) {
|
||||
for (const [type, owner] of ownerByType) {
|
||||
if (owner !== pluginId) continue;
|
||||
definitions.delete(type);
|
||||
ownerByType.delete(type);
|
||||
}
|
||||
bump();
|
||||
}
|
||||
|
||||
export function getNodeDefinition(type: string) {
|
||||
return definitions.get(type);
|
||||
}
|
||||
|
||||
export function getNodePluginId(type: string) {
|
||||
return ownerByType.get(type) || "builtin";
|
||||
}
|
||||
|
||||
export function listNodeDefinitions() {
|
||||
return Array.from(definitions.values());
|
||||
}
|
||||
|
||||
export function isRegisteredNodeType(type: string) {
|
||||
return definitions.has(type);
|
||||
}
|
||||
|
||||
const FALLBACK_SPEC = { width: 340, height: 240, title: "节点", metadata: {} as CanvasNodeDefinition["defaultMetadata"] };
|
||||
|
||||
// 提供默认尺寸/标题/初始 metadata,createCanvasNode 与 agent-ops 复用
|
||||
export function getNodeSpec(type: string) {
|
||||
const def = definitions.get(type);
|
||||
if (!def) return FALLBACK_SPEC;
|
||||
return { width: def.defaultSize.width, height: def.defaultSize.height, title: def.title, metadata: def.defaultMetadata };
|
||||
}
|
||||
|
||||
export function isBuiltinNodeType(type: string) {
|
||||
return (Object.values(CanvasNodeType) as string[]).includes(type);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { registerNodeDefinitions, unregisterPluginNodes } from "@/lib/canvas/node-registry";
|
||||
import { getPluginRuntime } from "@/lib/canvas/plugin-runtime";
|
||||
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
|
||||
import type { CanvasPlugin } from "@/types/canvas-plugin";
|
||||
|
||||
const cleanups = new Map<string, () => void>();
|
||||
|
||||
// 远程插件默认导出可以是 CanvasPlugin,或接收 runtime 返回 CanvasPlugin 的工厂
|
||||
// (工厂形式用 runtime.React,无需 bundle 自带 React)
|
||||
async function evaluatePluginSource(source: string): Promise<CanvasPlugin> {
|
||||
const blob = new Blob([source], { type: "text/javascript" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const mod = (await import(/* @vite-ignore */ url)) as { default?: unknown; plugin?: unknown };
|
||||
const exported = mod.default ?? mod.plugin;
|
||||
const plugin = typeof exported === "function" ? (exported as (runtime: unknown) => unknown)(getPluginRuntime()) : exported;
|
||||
assertPlugin(plugin);
|
||||
return plugin;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPlugin(plugin: unknown): asserts plugin is CanvasPlugin {
|
||||
const value = plugin as Partial<CanvasPlugin> | null;
|
||||
if (!value || typeof value !== "object") throw new Error("插件未导出有效对象");
|
||||
if (!value.id || !Array.isArray(value.nodes) || !value.nodes.length) throw new Error("插件缺少 id 或 nodes");
|
||||
}
|
||||
|
||||
export function activatePlugin(plugin: CanvasPlugin) {
|
||||
registerNodeDefinitions(plugin.nodes, plugin.id);
|
||||
const runtime = getPluginRuntime();
|
||||
const disposers: Array<() => void> = [];
|
||||
// 插件声明的样式:启用时注入,禁用/卸载时清理
|
||||
if (plugin.css) disposers.push(runtime.injectCSS(plugin.css, plugin.id));
|
||||
const cleanup = plugin.setup?.(runtime);
|
||||
if (typeof cleanup === "function") disposers.push(cleanup);
|
||||
if (disposers.length) cleanups.set(plugin.id, () => disposers.forEach((dispose) => dispose()));
|
||||
}
|
||||
|
||||
export function deactivatePlugin(pluginId: string) {
|
||||
cleanups.get(pluginId)?.();
|
||||
cleanups.delete(pluginId);
|
||||
unregisterPluginNodes(pluginId);
|
||||
}
|
||||
|
||||
async function fetchPluginSource(url: string) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`下载失败 (HTTP ${response.status})`);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
// 加缓存穿透参数,配合 watch 构建拿到最新产物
|
||||
function withCacheBust(url: string) {
|
||||
return `${url}${url.includes("?") ? "&" : "?"}t=${Date.now()}`;
|
||||
}
|
||||
|
||||
// 从 URL 安装(或覆盖更新)一个插件,成功后立即启用
|
||||
export async function installPluginFromUrl(url: string, opts?: { official?: boolean }) {
|
||||
const source = await fetchPluginSource(url);
|
||||
const plugin = await evaluatePluginSource(source);
|
||||
deactivatePlugin(plugin.id); // 覆盖旧版本
|
||||
usePluginStore.getState().upsert({ id: plugin.id, name: plugin.name || plugin.id, version: plugin.version || "0.0.0", description: plugin.description, url, source, enabled: true, official: opts?.official });
|
||||
activatePlugin(plugin);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
export async function updatePlugin(record: InstalledPlugin) {
|
||||
return installPluginFromUrl(record.url, { official: record.official });
|
||||
}
|
||||
|
||||
export async function setPluginEnabled(record: InstalledPlugin, enabled: boolean) {
|
||||
usePluginStore.getState().setEnabled(record.id, enabled);
|
||||
if (!enabled) {
|
||||
deactivatePlugin(record.id);
|
||||
return;
|
||||
}
|
||||
// 本地插件启用时按 url 重新拉取,拿到最新构建(缓存 source 可能已过期)
|
||||
const source = record.local ? await fetchPluginSource(withCacheBust(record.url)) : record.source;
|
||||
const plugin = await evaluatePluginSource(source);
|
||||
activatePlugin(plugin);
|
||||
}
|
||||
|
||||
export function uninstallPlugin(id: string) {
|
||||
deactivatePlugin(id);
|
||||
usePluginStore.getState().remove(id);
|
||||
}
|
||||
|
||||
let loaded = false;
|
||||
|
||||
// 应用启动时加载已安装且启用的插件
|
||||
export async function ensurePluginsLoaded() {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
await usePluginStore.persist.rehydrate();
|
||||
await loadLocalPlugins(); // 先发现本地插件(默认关闭),再统一按 enabled 激活
|
||||
const records = usePluginStore.getState().plugins.filter((record) => record.enabled);
|
||||
await Promise.all(
|
||||
records.map(async (record) => {
|
||||
try {
|
||||
// 本地插件用最新产物,其余用缓存的源码
|
||||
const source = record.local ? await fetchPluginSource(withCacheBust(record.url)) : record.source;
|
||||
activatePlugin(await evaluatePluginSource(source));
|
||||
} catch (error) {
|
||||
console.error(`[plugin] 加载失败: ${record.id}`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
await loadDevPlugins();
|
||||
}
|
||||
|
||||
// 自动发现 web/public/plugins 下的本地插件:加入列表但默认关闭,
|
||||
// 本地开发放好插件文件即可在管理器里看到并一键启用,无需手动填 URL。
|
||||
// 已在列表中的(用户装过/发现过)不覆盖,尊重其现有开关。
|
||||
async function loadLocalPlugins() {
|
||||
let urls: unknown;
|
||||
try {
|
||||
const response = await fetch("/plugins/index.json");
|
||||
if (!response.ok) return;
|
||||
urls = await response.json();
|
||||
} catch {
|
||||
return; // 无本地清单(如生产环境未构建插件)则跳过
|
||||
}
|
||||
if (!Array.isArray(urls) || !urls.length) return;
|
||||
const store = usePluginStore.getState();
|
||||
await Promise.all(
|
||||
urls.map(async (url: string) => {
|
||||
try {
|
||||
const source = await fetchPluginSource(withCacheBust(url));
|
||||
const plugin = await evaluatePluginSource(source);
|
||||
if (store.plugins.some((item) => item.id === plugin.id)) return;
|
||||
store.upsert({ id: plugin.id, name: plugin.name || plugin.id, version: plugin.version || "0.0.0", description: plugin.description, url, source, enabled: false, local: true });
|
||||
} catch (error) {
|
||||
console.error(`[plugin] 本地插件发现失败: ${url}`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 本地开发:VITE_DEV_PLUGINS 里的 URL 每次启动都重新拉取(不缓存、不落库),
|
||||
// 配合 watch 构建即可「改代码→刷新页面」看到最新插件,无需反复安装。
|
||||
async function loadDevPlugins() {
|
||||
const raw = import.meta.env.VITE_DEV_PLUGINS;
|
||||
if (!raw) return;
|
||||
const urls = raw.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
try {
|
||||
const source = await fetchPluginSource(withCacheBust(url));
|
||||
const plugin = await evaluatePluginSource(source);
|
||||
deactivatePlugin(plugin.id);
|
||||
activatePlugin(plugin);
|
||||
console.info(`[plugin] dev 插件已加载: ${plugin.id} (${url})`);
|
||||
} catch (error) {
|
||||
console.error(`[plugin] dev 插件加载失败: ${url}`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createPluginStorage, emitCanvasEvent, onCanvasEvent } from "@/lib/canvas/canvas-event-bus";
|
||||
import { getNodePluginId } from "@/lib/canvas/node-registry";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
|
||||
|
||||
// 把宿主能力 + 节点 + 主题/缩放,组装成注入给插件节点的上下文
|
||||
export function buildNodeContext(host: CanvasPluginHost, node: CanvasNodeData, theme: CanvasTheme, scale: number): CanvasNodeContext {
|
||||
const storage = createPluginStorage(getNodePluginId(node.type));
|
||||
return {
|
||||
node,
|
||||
theme,
|
||||
scale,
|
||||
updateMetadata: (patch) => host.updateMetadata(node.id, patch),
|
||||
updateNode: (patch) => host.updateNode(node.id, patch),
|
||||
getNode: (id) => host.getNode(id),
|
||||
getNodes: () => host.getNodes(),
|
||||
getConnections: () => host.getConnections(),
|
||||
getUpstream: () => host.getUpstream(node.id),
|
||||
getDownstream: () => host.getDownstream(node.id),
|
||||
applyOps: (ops) => host.applyOps(ops),
|
||||
emit: (event, payload) => emitCanvasEvent(event, payload),
|
||||
on: (event, handler) => onCanvasEvent(event, handler),
|
||||
storage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { PLUGIN_REGISTRY_URL } from "@/constant/env";
|
||||
|
||||
// 官方插件清单里的一条(entry 已解析成绝对 URL)
|
||||
export type OfficialPluginEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type RawEntry = { id?: string; name?: string; version?: string; description?: string; icon?: string; entry?: string; url?: string };
|
||||
type RawManifest = { plugins?: RawEntry[] };
|
||||
|
||||
// 拉取官方插件清单;entry(相对文件名)按清单地址解析成绝对 URL,再走既有 URL 安装流程
|
||||
export async function fetchOfficialPlugins(registryUrl: string = PLUGIN_REGISTRY_URL): Promise<OfficialPluginEntry[]> {
|
||||
const response = await fetch(registryUrl, { headers: { accept: "application/json" } });
|
||||
if (!response.ok) throw new Error(`获取官方插件列表失败 (HTTP ${response.status})`);
|
||||
const data = (await response.json()) as RawManifest;
|
||||
const list = Array.isArray(data?.plugins) ? data.plugins : [];
|
||||
return list
|
||||
.filter((item): item is RawEntry & { id: string } => Boolean(item && item.id && (item.entry || item.url)))
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name || item.id,
|
||||
version: item.version || "0.0.0",
|
||||
description: item.description,
|
||||
icon: item.icon,
|
||||
url: item.url ? item.url : new URL(item.entry as string, registryUrl).toString(),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
|
||||
import { emitCanvasEvent, onCanvasEvent } from "@/lib/canvas/canvas-event-bus";
|
||||
import type { CanvasPluginApp } from "@/types/canvas-plugin";
|
||||
|
||||
// 插件运行时:远程插件通过它拿到宿主的 React 实例,避免多份 React 实例
|
||||
export type PluginRuntime = CanvasPluginApp & {
|
||||
React: typeof React;
|
||||
jsx: typeof React.createElement;
|
||||
Fragment: typeof React.Fragment;
|
||||
injectCSS: (css: string, key?: string) => () => void;
|
||||
};
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
// 注入插件样式:同 key 覆盖旧样式,返回移除函数
|
||||
function injectCSS(css: string, key?: string) {
|
||||
const id = key ? `canvas-plugin-style-${key}` : undefined;
|
||||
if (id) document.getElementById(id)?.remove();
|
||||
const style = document.createElement("style");
|
||||
if (id) style.id = id;
|
||||
style.dataset.canvasPluginStyle = "true";
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
return () => style.remove();
|
||||
}
|
||||
|
||||
export function getPluginRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
runtime = {
|
||||
React,
|
||||
jsx: React.createElement,
|
||||
Fragment: React.Fragment,
|
||||
injectCSS,
|
||||
version: typeof __APP_VERSION__ === "string" ? __APP_VERSION__ : "dev",
|
||||
emit: emitCanvasEvent,
|
||||
on: onCanvasEvent,
|
||||
};
|
||||
(window as unknown as { InfiniteCanvasRuntime?: PluginRuntime }).InfiniteCanvasRuntime = runtime;
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -141,7 +141,7 @@ export function buildSeedancePromptText(prompt: string, images: ReferenceImage[]
|
||||
];
|
||||
const text = prompt.trim();
|
||||
if (!labels.length) return text;
|
||||
return `参考素材编号:${labels.join("、")}。请按这些编号理解提示词中的图片、视频和音频引用。\n\n${text}`;
|
||||
return `参考资产编号:${labels.join("、")}。请按这些编号理解提示词中的图片、视频和音频引用。\n\n${text}`;
|
||||
}
|
||||
|
||||
export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
|
||||
@@ -166,4 +166,4 @@ export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
|
||||
return "";
|
||||
}
|
||||
|
||||
export const seedanceVideoReferenceHint = "参考视频需为 mp4/mov,H.264/H.265,FPS 24-60;含真人人脸素材请使用火山授权 asset:// 素材。";
|
||||
export const seedanceVideoReferenceHint = "参考视频需为 mp4/mov,H.264/H.265,FPS 24-60;含真人人脸资产请使用火山授权 asset:// 资产。";
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function exportAssets(assets: Asset[]) {
|
||||
|
||||
const data: AssetExportFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), assets, files };
|
||||
const zip = await createZip([{ name: "assets.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
|
||||
saveAs(zip, "我的素材.zip");
|
||||
saveAs(zip, "我的资产.zip");
|
||||
}
|
||||
|
||||
export async function readAssetPackage(file: File) {
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function AssetsPage() {
|
||||
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
|
||||
}
|
||||
|
||||
message.success(editingAsset ? "素材已更新" : "素材已保存");
|
||||
message.success(editingAsset ? "资产已更新" : "资产已保存");
|
||||
setIsAssetOpen(false);
|
||||
};
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function AssetsPage() {
|
||||
|
||||
const exportAllAssets = async () => {
|
||||
if (!validAssets.length) {
|
||||
message.warning("暂无素材可导出");
|
||||
message.warning("暂无资产可导出");
|
||||
return;
|
||||
}
|
||||
await exportAssets(validAssets);
|
||||
@@ -170,9 +170,9 @@ export default function AssetsPage() {
|
||||
delete payload.updatedAt;
|
||||
addAsset(payload as Parameters<typeof addAsset>[0]);
|
||||
});
|
||||
message.success(`已导入 ${importedAssets.length} 个素材`);
|
||||
message.success(`已导入 ${importedAssets.length} 个资产`);
|
||||
} catch {
|
||||
message.error("导入失败,请选择有效的素材压缩包");
|
||||
message.error("导入失败,请选择有效的资产压缩包");
|
||||
} finally {
|
||||
if (assetInputRef.current) assetInputRef.current.value = "";
|
||||
}
|
||||
@@ -181,7 +181,7 @@ export default function AssetsPage() {
|
||||
const confirmDelete = () => {
|
||||
if (!deletingAsset) return;
|
||||
removeAsset(deletingAsset.id);
|
||||
message.success("素材已删除");
|
||||
message.success("资产已删除");
|
||||
setDeletingAsset(null);
|
||||
};
|
||||
|
||||
@@ -190,7 +190,7 @@ export default function AssetsPage() {
|
||||
<main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.14)_1px,transparent_1px)]">
|
||||
<div className="pb-8">
|
||||
<div className="mx-auto max-w-5xl text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">我的素材</h1>
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">我的资产</h1>
|
||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">收藏常用文本和图片,按类型、标题和标签快速查找。</p>
|
||||
</div>
|
||||
|
||||
@@ -239,21 +239,21 @@ export default function AssetsPage() {
|
||||
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
||||
onClick={() => void exportAllAssets()}
|
||||
>
|
||||
导出素材
|
||||
导出资产
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
||||
onClick={() => assetInputRef.current?.click()}
|
||||
>
|
||||
导入素材
|
||||
导入资产
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
||||
onClick={openCreate}
|
||||
>
|
||||
新增素材
|
||||
新增资产
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,7 +267,7 @@ export default function AssetsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
|
||||
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到资产" className="py-20" /> : null}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Pagination
|
||||
@@ -285,7 +285,7 @@ export default function AssetsPage() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Modal title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Modal title={editingAsset ? "编辑资产" : "新增资产"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
|
||||
<Form.Item name="kind" label="类型">
|
||||
@@ -298,7 +298,7 @@ export default function AssetsPage() {
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||
<Input size="large" placeholder="给素材起一个容易检索的名字" />
|
||||
<Input size="large" placeholder="给资产起一个容易检索的名字" />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL">
|
||||
<Space.Compact className="w-full">
|
||||
@@ -321,7 +321,7 @@ export default function AssetsPage() {
|
||||
</div>
|
||||
{formKind === "text" ? (
|
||||
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
|
||||
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本素材" />
|
||||
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本资产" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item label="图片内容" required>
|
||||
@@ -352,7 +352,7 @@ export default function AssetsPage() {
|
||||
)}
|
||||
<div className="p-4">
|
||||
<Typography.Text strong ellipsis className="block">
|
||||
{title || "未命名素材"}
|
||||
{title || "未命名资产"}
|
||||
</Typography.Text>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{tags.length ? (
|
||||
@@ -395,8 +395,8 @@ export default function AssetsPage() {
|
||||
|
||||
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
|
||||
|
||||
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。
|
||||
<Modal title="删除资产" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除「{deletingAsset?.title}」吗?删除后会从我的资产中移除。
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
@@ -474,7 +474,7 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
|
||||
function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | null; onClose: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void }) {
|
||||
const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : "";
|
||||
return (
|
||||
<Drawer title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
|
||||
<Drawer title="资产详情" open={Boolean(asset)} size="large" onClose={onClose}>
|
||||
{asset ? (
|
||||
<div className="space-y-5">
|
||||
{cover ? (
|
||||
|
||||
@@ -38,11 +38,18 @@ import { CanvasNode } from "@/components/canvas/canvas-node";
|
||||
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar";
|
||||
import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import { CanvasSidePanel } from "@/components/canvas/canvas-side-panel";
|
||||
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
|
||||
import { useAgentStore } from "@/stores/use-agent-store";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
|
||||
import { getNodeDefinition, isBuiltinNodeType as isBuiltinType, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
|
||||
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
|
||||
import { ensurePluginsLoaded } from "@/lib/canvas/plugin-loader";
|
||||
import { registerBuiltinNodes } from "@/components/canvas/nodes/builtin-nodes";
|
||||
import { CanvasPluginManagerModal } from "@/components/canvas/canvas-plugin-manager-modal";
|
||||
import type { CanvasPluginHost } from "@/types/canvas-plugin";
|
||||
import {
|
||||
CanvasNodeType,
|
||||
type CanvasAssistantImage,
|
||||
@@ -51,6 +58,7 @@ import {
|
||||
type CanvasImageGenerationType,
|
||||
type CanvasNodeData,
|
||||
type CanvasNodeMetadata,
|
||||
type CanvasNodeTypeId,
|
||||
type ConnectionHandle,
|
||||
type ContextMenuState,
|
||||
type Position,
|
||||
@@ -60,6 +68,9 @@ import {
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio } from "@/types/media";
|
||||
|
||||
// 内置节点注册到统一注册表(模块加载时执行一次)
|
||||
registerBuiltinNodes();
|
||||
|
||||
type CanvasClipboard = {
|
||||
nodes: CanvasNodeData[];
|
||||
connections: CanvasConnection[];
|
||||
@@ -104,7 +115,7 @@ const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用
|
||||
2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。
|
||||
3. 尽量写成可直接用于生图模型的完整提示词。`;
|
||||
|
||||
function createCanvasNode(type: CanvasNodeType, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
|
||||
function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
|
||||
const spec = getNodeSpec(type);
|
||||
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
@@ -212,11 +223,23 @@ function ConnectionCreateOption({ theme, icon, title, description, onClick }: {
|
||||
);
|
||||
}
|
||||
|
||||
function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: CanvasNodeType) => void; onClose: () => void }) {
|
||||
function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: string) => void; onClose: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
useNodeRegistryVersion();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const definitions = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false);
|
||||
// 点击菜单外的空白处自动关闭
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) onClose();
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
}, [onClose]);
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[120] w-[300px] rounded-[18px] border p-3 shadow-2xl backdrop-blur"
|
||||
ref={menuRef}
|
||||
className="absolute z-[120] max-h-[70vh] w-[300px] overflow-y-auto rounded-[18px] border p-3 shadow-2xl backdrop-blur thin-scrollbar"
|
||||
data-canvas-no-zoom
|
||||
style={{ left: position.x, top: position.y, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
@@ -228,12 +251,9 @@ function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; o
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title="文本" onClick={() => onCreate(CanvasNodeType.Text)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title="图片" onClick={() => onCreate(CanvasNodeType.Image)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Video className="size-5" />} title="视频" onClick={() => onCreate(CanvasNodeType.Video)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Music2 className="size-5" />} title="音频" onClick={() => onCreate(CanvasNodeType.Audio)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title="生成配置" onClick={() => onCreate(CanvasNodeType.Config)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Group className="size-5" />} title="组" onClick={() => onCreate(CanvasNodeType.Group)} />
|
||||
{definitions.map((def) => (
|
||||
<ConnectionCreateOption key={def.type} theme={theme} icon={def.icon} title={def.title} description={def.description} onClick={() => onCreate(def.type)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -241,6 +261,8 @@ function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; o
|
||||
|
||||
function InfiniteCanvasPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
// 订阅节点注册表版本,插件动态注册/卸载后驱动画布重渲染
|
||||
const nodeRegistryVersion = useNodeRegistryVersion((state) => state.version);
|
||||
const params = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -322,6 +344,7 @@ function InfiniteCanvasPage() {
|
||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
|
||||
const [editRequestNonce, setEditRequestNonce] = useState(0);
|
||||
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
|
||||
const [pluginManagerOpen, setPluginManagerOpen] = useState(false);
|
||||
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
|
||||
const [maskEditNodeId, setMaskEditNodeId] = useState<string | null>(null);
|
||||
const [splitNodeId, setSplitNodeId] = useState<string | null>(null);
|
||||
@@ -602,7 +625,7 @@ function InfiniteCanvasPage() {
|
||||
setConnections((prev) => [...prev, { id: nanoid(), ...connection }]);
|
||||
setSelectedNodeIds(new Set([newNode.id]));
|
||||
setSelectedConnectionId(null);
|
||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group) setDialogNodeId(newNode.id);
|
||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
|
||||
setPendingConnectionCreate(null);
|
||||
setConnecting(null);
|
||||
},
|
||||
@@ -791,8 +814,38 @@ function InfiniteCanvasPage() {
|
||||
setAgentCanvasContext({ snapshot: agentSnapshot, applyOps: applyAgentOps, undoOps: undoAgentOps, canUndo: Boolean(agentUndoSnapshot) });
|
||||
return () => setAgentCanvasContext(null);
|
||||
}, [agentSnapshot, applyAgentOps, agentUndoSnapshot, setAgentCanvasContext, undoAgentOps]);
|
||||
|
||||
// 提供给插件节点的宿主能力(节点无关,方法接收 nodeId)
|
||||
const pluginHost = useMemo<CanvasPluginHost>(
|
||||
() => ({
|
||||
getNode: (id) => nodesRef.current.find((node) => node.id === id) || null,
|
||||
getNodes: () => nodesRef.current,
|
||||
getConnections: () => connectionsRef.current,
|
||||
getUpstream: (nodeId) => connectionsRef.current.filter((conn) => conn.toNodeId === nodeId).map((conn) => nodesRef.current.find((node) => node.id === conn.fromNodeId)).filter((node): node is CanvasNodeData => Boolean(node)),
|
||||
getDownstream: (nodeId) => connectionsRef.current.filter((conn) => conn.fromNodeId === nodeId).map((conn) => nodesRef.current.find((node) => node.id === conn.toNodeId)).filter((node): node is CanvasNodeData => Boolean(node)),
|
||||
updateNode: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, ...patch } : node))),
|
||||
updateMetadata: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))),
|
||||
applyOps: (ops) => applyAgentOps(ops),
|
||||
}),
|
||||
[applyAgentOps],
|
||||
);
|
||||
|
||||
const renderPluginPanel = useCallback(
|
||||
(panelNode: CanvasNodeData) => {
|
||||
const Panel = getNodeDefinition(panelNode.type)?.Panel;
|
||||
if (!Panel) return null;
|
||||
const ctx = buildNodeContext(pluginHost, panelNode, theme, viewportRef.current.k);
|
||||
return <Panel ctx={ctx} onClose={() => setDialogNodeId(null)} />;
|
||||
},
|
||||
[pluginHost, theme],
|
||||
);
|
||||
|
||||
// 启动时加载已安装的远程插件
|
||||
useEffect(() => {
|
||||
void ensurePluginsLoaded();
|
||||
}, []);
|
||||
const createNode = useCallback(
|
||||
(type: CanvasNodeType, position?: Position) => {
|
||||
(type: CanvasNodeTypeId, position?: Position) => {
|
||||
const targetPosition = position || getCanvasCenter();
|
||||
const configMetadata =
|
||||
type === CanvasNodeType.Config
|
||||
@@ -807,7 +860,8 @@ function InfiniteCanvasPage() {
|
||||
setNodes((prev) => [...prev, newNode]);
|
||||
setSelectedNodeIds(new Set([newNode.id]));
|
||||
setSelectedConnectionId(null);
|
||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group) setDialogNodeId(newNode.id);
|
||||
const definition = getNodeDefinition(type);
|
||||
if (definition?.Panel || (isBuiltinType(type) && type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group)) setDialogNodeId(newNode.id);
|
||||
},
|
||||
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
|
||||
);
|
||||
@@ -996,6 +1050,21 @@ function InfiniteCanvasPage() {
|
||||
setContextMenu(null);
|
||||
}, [size.height, size.width]);
|
||||
|
||||
const focusNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||
if (!node) return;
|
||||
const worldX = node.position.x + node.width / 2;
|
||||
const worldY = node.position.y + node.height / 2;
|
||||
const k = Math.min(Math.max(Math.min((size.width * 0.6) / node.width, (size.height * 0.6) / node.height), 0.05), 1.5);
|
||||
setViewport({ x: size.width / 2 - worldX * k, y: size.height / 2 - worldY * k, k });
|
||||
setSelectedNodeIds(new Set([nodeId]));
|
||||
setSelectedConnectionId(null);
|
||||
setContextMenu(null);
|
||||
},
|
||||
[size.height, size.width],
|
||||
);
|
||||
|
||||
const setZoomScale = useCallback(
|
||||
(scale: number) => {
|
||||
const nextScale = Math.min(Math.max(scale, 0.05), 5);
|
||||
@@ -1598,13 +1667,13 @@ function InfiniteCanvasPage() {
|
||||
const content = node.metadata?.content?.trim();
|
||||
if (!content) return message.error("没有可保存的文本");
|
||||
addAsset({ kind: "text", title: node.metadata?.prompt?.slice(0, 24) || "画布文本", coverUrl: "", tags: [], source: "Canvas", data: { content }, metadata: { source: "canvas", nodeId: node.id } });
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
return;
|
||||
}
|
||||
if (node.type === CanvasNodeType.Video) {
|
||||
if (!node.metadata?.content) return message.error("没有可保存的视频");
|
||||
addAsset({ kind: "video", title: node.metadata?.prompt?.slice(0, 24) || "画布视频", coverUrl: "", tags: [], source: "Canvas", data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.width, height: node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt } });
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
return;
|
||||
}
|
||||
if (!node.metadata?.content) return message.error("没有可保存的图片");
|
||||
@@ -1625,7 +1694,7 @@ function InfiniteCanvasPage() {
|
||||
},
|
||||
metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt },
|
||||
});
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
},
|
||||
[addAsset, message],
|
||||
);
|
||||
@@ -2502,6 +2571,7 @@ function InfiniteCanvasPage() {
|
||||
|
||||
return (
|
||||
<main className="flex h-full min-h-0 overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
|
||||
<CanvasSidePanel nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={focusNode} onInsertAsset={handleAssetInsert} />
|
||||
<section className="relative min-w-0 flex-1 overflow-hidden">
|
||||
<CanvasTopBar
|
||||
title={currentProject?.title || "未命名画布"}
|
||||
@@ -2518,6 +2588,7 @@ function InfiniteCanvasPage() {
|
||||
onCreateProject={createAndOpenProject}
|
||||
onDeleteProject={deleteCurrentProject}
|
||||
onImportImage={() => handleUploadRequest()}
|
||||
onOpenPlugins={() => setPluginManagerOpen(true)}
|
||||
onUndo={undoCanvas}
|
||||
onRedo={redoCanvas}
|
||||
agentOpen={agentPanelOpen}
|
||||
@@ -2600,8 +2671,12 @@ function InfiniteCanvasPage() {
|
||||
showImageInfo={showImageInfo}
|
||||
resourceLabel={resourceReferenceByNodeId.get(node.id)}
|
||||
mentionReferences={mentionReferencesByNodeId.get(node.id) || []}
|
||||
pluginHost={pluginHost}
|
||||
registryVersion={nodeRegistryVersion}
|
||||
renderPanel={(panelNode) =>
|
||||
panelNode.type === CanvasNodeType.Config ? (
|
||||
getNodeDefinition(panelNode.type)?.Panel ? (
|
||||
renderPluginPanel(panelNode)
|
||||
) : panelNode.type === CanvasNodeType.Config ? (
|
||||
<CanvasConfigComposer
|
||||
value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""}
|
||||
inputs={configInputsById.get(panelNode.id) || []}
|
||||
@@ -2692,6 +2767,7 @@ function InfiniteCanvasPage() {
|
||||
<CanvasNodeHoverToolbar
|
||||
node={isNodeDragging || nodeImageSettingsOpen ? null : toolbarNode}
|
||||
viewport={viewport}
|
||||
extraTools={toolbarNode ? getNodeDefinition(toolbarNode.type)?.toolbar?.(buildNodeContext(pluginHost, toolbarNode, theme, viewport.k)) : undefined}
|
||||
onKeep={keepNodeToolbar}
|
||||
onLeave={hideNodeToolbar}
|
||||
onInfo={(node) => setInfoNodeId(node.id)}
|
||||
@@ -2728,6 +2804,7 @@ function InfiniteCanvasPage() {
|
||||
onAddText={() => createNode(CanvasNodeType.Text)}
|
||||
onAddConfig={() => createNode(CanvasNodeType.Config)}
|
||||
onAddGroup={() => createNode(CanvasNodeType.Group)}
|
||||
onAddExtensionNode={(type) => createNode(type)}
|
||||
onUndo={undoCanvas}
|
||||
onRedo={redoCanvas}
|
||||
onUpload={() => handleUploadRequest()}
|
||||
@@ -2736,9 +2813,6 @@ function InfiniteCanvasPage() {
|
||||
onDeselect={deselectCanvas}
|
||||
onBackgroundModeChange={setBackgroundMode}
|
||||
onShowImageInfoChange={setShowImageInfo}
|
||||
onOpenMyAssets={() => {
|
||||
setAssetPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{isMiniMapOpen ? <Minimap nodes={nodes} viewport={viewport} viewportSize={size} onViewportChange={setViewport} /> : null}
|
||||
@@ -2768,6 +2842,7 @@ function InfiniteCanvasPage() {
|
||||
<input ref={imageInputRef} type="file" accept="image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav" className="hidden" onChange={handleImageInputChange} />
|
||||
|
||||
<CanvasNodeInfoModal node={infoNode} open={Boolean(infoNode)} onClose={() => setInfoNodeId(null)} />
|
||||
<CanvasPluginManagerModal open={pluginManagerOpen} onClose={() => setPluginManagerOpen(false)} />
|
||||
|
||||
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open={Boolean(cropNode)} onClose={() => setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null}
|
||||
|
||||
@@ -2839,6 +2914,7 @@ function CanvasTopBar({
|
||||
onCreateProject,
|
||||
onDeleteProject,
|
||||
onImportImage,
|
||||
onOpenPlugins,
|
||||
onUndo,
|
||||
onRedo,
|
||||
agentOpen,
|
||||
@@ -2859,6 +2935,7 @@ function CanvasTopBar({
|
||||
onCreateProject: () => void;
|
||||
onDeleteProject: () => void;
|
||||
onImportImage: () => void;
|
||||
onOpenPlugins: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
agentOpen: boolean;
|
||||
@@ -2894,7 +2971,7 @@ function CanvasTopBar({
|
||||
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
|
||||
{ type: "divider" },
|
||||
{ key: "import", icon: <Upload className="size-4" />, label: "导入素材", onClick: onImportImage },
|
||||
{ key: "import", icon: <Upload className="size-4" />, label: "导入资产", onClick: onImportImage },
|
||||
{ type: "divider" },
|
||||
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
|
||||
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
|
||||
@@ -2938,6 +3015,7 @@ function CanvasTopBar({
|
||||
<UserStatusActions
|
||||
variant="canvas"
|
||||
onOpenShortcuts={() => setShortcutsOpen(true)}
|
||||
onOpenPlugins={onOpenPlugins}
|
||||
/>
|
||||
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
|
||||
<Button
|
||||
|
||||
@@ -232,7 +232,7 @@ export default function ImagePage() {
|
||||
data: { dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType },
|
||||
metadata: { source: "image-page", prompt },
|
||||
});
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
};
|
||||
|
||||
const insertPickedAsset = async (payload: InsertAssetPayload) => {
|
||||
@@ -242,7 +242,7 @@ export default function ImagePage() {
|
||||
const stored = await uploadImage(payload.dataUrl);
|
||||
setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]);
|
||||
} else {
|
||||
message.warning("生图工作台只能使用文本或图片素材");
|
||||
message.warning("生图工作台只能使用文本或图片资产");
|
||||
}
|
||||
setAssetPickerOpen(false);
|
||||
};
|
||||
@@ -388,7 +388,7 @@ export default function ImagePage() {
|
||||
查看提示词库
|
||||
</Button>
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>
|
||||
查看我的素材
|
||||
查看我的资产
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -560,9 +560,9 @@ function ResultImageCard({
|
||||
<span>{formatDuration(image.durationMs)}</span>
|
||||
</div>
|
||||
<div className="grid min-w-0 grid-cols-3 gap-2">
|
||||
<Tooltip title="添加到素材">
|
||||
<Tooltip title="添加到资产">
|
||||
<Button className={RESULT_ACTION_BUTTON_CLASS} size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}>
|
||||
添加到素材
|
||||
添加到资产
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="加入参考图">
|
||||
|
||||
@@ -32,7 +32,7 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p
|
||||
</Button>
|
||||
{onSaveAsset ? (
|
||||
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
|
||||
加入我的素材
|
||||
加入我的资产
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function PromptsPage() {
|
||||
|
||||
const savePromptAsset = (item: Prompt) => {
|
||||
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
};
|
||||
|
||||
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
@@ -106,7 +106,7 @@ export default function PromptsPage() {
|
||||
onCopy={() => copyText(item.prompt, "提示词已复制")}
|
||||
extraAction={
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>
|
||||
加入我的素材
|
||||
加入我的资产
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function VideoPage() {
|
||||
const addReferences = async (files?: FileList | null) => {
|
||||
const selectedFiles = Array.from(files || []);
|
||||
const unsupported = selectedFiles.filter((file) => !file.type.startsWith("image/") && !file.type.startsWith("video/") && !isSupportedAudioFile(file));
|
||||
if (unsupported.length) message.warning("已忽略不支持的参考素材,请使用图片、mp4/mov 视频或 mp3/wav 音频");
|
||||
if (unsupported.length) message.warning("已忽略不支持的参考资产,请使用图片、mp4/mov 视频或 mp3/wav 音频");
|
||||
const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/") && file.size <= SEEDANCE_REFERENCE_LIMITS.imageMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.images - references.length);
|
||||
const videoFiles = selectedFiles.filter((file) => file.type.startsWith("video/") && file.size <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.videos - videoReferences.length);
|
||||
const audioFiles = selectedFiles.filter((file) => isSupportedAudioFile(file) && file.size <= SEEDANCE_REFERENCE_LIMITS.audioMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.audios - audioReferences.length);
|
||||
@@ -244,7 +244,7 @@ export default function VideoPage() {
|
||||
data: { url: video.url, storageKey: video.storageKey, width: video.width, height: video.height, bytes: video.bytes, mimeType: video.mimeType },
|
||||
metadata: { source: "video-page", prompt },
|
||||
});
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
};
|
||||
|
||||
const insertPickedAsset = async (payload: InsertAssetPayload) => {
|
||||
@@ -394,7 +394,7 @@ export default function VideoPage() {
|
||||
查看提示词库
|
||||
</Button>
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>
|
||||
查看我的素材
|
||||
查看我的资产
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -573,7 +573,7 @@ function ResultVideoCard({ video, onDownload, onSaveAsset }: { video: GeneratedV
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => onSaveAsset(video)}>
|
||||
添加到素材
|
||||
添加到资产
|
||||
</Button>
|
||||
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(video)}>
|
||||
下载
|
||||
|
||||
@@ -2,7 +2,8 @@ import axios from "axios";
|
||||
|
||||
import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
|
||||
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
|
||||
import { buildApiUrl, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { buildApiUrl, resolveModelRequestConfig, resolveModelScript, type AiConfig } from "@/stores/use-config-store";
|
||||
import { runModelPlugin } from "./model-plugin";
|
||||
|
||||
type RequestOptions = { signal?: AbortSignal };
|
||||
|
||||
@@ -20,8 +21,27 @@ function aiHeaders(config: AiConfig) {
|
||||
export async function requestAudioGeneration(config: AiConfig, prompt: string, options?: RequestOptions): Promise<Blob> {
|
||||
const requestConfig = resolveModelRequestConfig(config, config.model || config.audioModel);
|
||||
const model = requestConfig.model.trim();
|
||||
assertAudioConfig(requestConfig, model);
|
||||
const format = normalizeAudioFormatValue(config.audioFormat);
|
||||
const script = resolveModelScript(config, config.model || config.audioModel);
|
||||
if (script) {
|
||||
if (!model) throw new Error("请先配置音频模型");
|
||||
if (!requestConfig.baseUrl.trim()) throw new Error("请先配置 Base URL");
|
||||
if (!requestConfig.apiKey.trim()) throw new Error("请先配置 API Key");
|
||||
try {
|
||||
const result = await runModelPlugin({
|
||||
capability: "audio",
|
||||
script,
|
||||
config: requestConfig,
|
||||
prompt,
|
||||
params: { voice: normalizeAudioVoiceValue(config.audioVoice), format, speed: normalizeAudioSpeedValue(config.audioSpeed), instructions: config.audioInstructions.trim() },
|
||||
signal: options?.signal,
|
||||
});
|
||||
return await audioPluginBlob(result, format);
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "音频生成失败"));
|
||||
}
|
||||
}
|
||||
assertAudioConfig(requestConfig, model);
|
||||
const instructions = config.audioInstructions.trim();
|
||||
|
||||
try {
|
||||
@@ -44,6 +64,20 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string, o
|
||||
}
|
||||
}
|
||||
|
||||
async function audioPluginBlob(result: unknown, format: string): Promise<Blob> {
|
||||
if (result instanceof Blob) return result.type.startsWith("audio/") ? result : new Blob([result], { type: audioMimeType(format) });
|
||||
let source = "";
|
||||
if (typeof result === "string") source = result;
|
||||
else if (result && typeof result === "object") {
|
||||
const record = result as Record<string, unknown>;
|
||||
source = typeof record.b64_json === "string" ? record.b64_json : typeof record.data === "string" ? record.data : typeof record.url === "string" ? record.url : "";
|
||||
}
|
||||
if (!source) throw new Error("模型调用脚本没有返回音频");
|
||||
const url = source.startsWith("data:") || /^https?:/i.test(source) ? source : `data:${audioMimeType(format)};base64,${source}`;
|
||||
const blob = await (await fetch(url)).blob();
|
||||
return blob.type.startsWith("audio/") ? blob : new Blob([blob], { type: audioMimeType(format) });
|
||||
}
|
||||
|
||||
export async function storeGeneratedAudio(blob: Blob, format = "mp3"): Promise<UploadedFile> {
|
||||
const audio = blob.type.startsWith("audio/") ? blob : new Blob([blob], { type: audioMimeType(format) });
|
||||
return uploadMediaFile(audio, "audio");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { buildApiUrl, resolveModelRequestConfig, type AiConfig, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { buildApiUrl, resolveModelRequestConfig, resolveModelScript, type AiConfig, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { normalizePluginImages, runModelPlugin } from "./model-plugin";
|
||||
import { nanoid } from "nanoid";
|
||||
import { dataUrlToFile } from "@/lib/image-utils";
|
||||
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
|
||||
@@ -656,6 +657,25 @@ function parseGeminiImagePayload(payload: GeminiPayload) {
|
||||
export async function requestGeneration(config: AiConfig, prompt: string, options?: RequestOptions) {
|
||||
const requestConfig = resolveModelRequestConfig(config, config.model || config.imageModel);
|
||||
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const script = resolveModelScript(config, config.model || config.imageModel);
|
||||
if (script) {
|
||||
const quality = normalizeQuality(config.quality);
|
||||
const requestSize = resolveRequestSize(quality, config.size);
|
||||
try {
|
||||
const result = await runModelPlugin({
|
||||
capability: "image",
|
||||
script,
|
||||
config: requestConfig,
|
||||
prompt: withSystemPrompt(requestConfig, prompt),
|
||||
images: [],
|
||||
params: { size: requestSize, quality, count: n },
|
||||
signal: options?.signal,
|
||||
});
|
||||
return normalizePluginImages(result).map((dataUrl) => ({ id: nanoid(), dataUrl }));
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "请求失败"));
|
||||
}
|
||||
}
|
||||
if (requestConfig.apiFormat === "gemini") {
|
||||
try {
|
||||
return await requestGeminiImages(requestConfig, prompt, [], n, options);
|
||||
@@ -693,6 +713,26 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
const requestConfig = resolveModelRequestConfig(config, config.model || config.imageModel);
|
||||
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const requestPrompt = buildImageReferencePromptText(prompt, references);
|
||||
const script = resolveModelScript(config, config.model || config.imageModel);
|
||||
if (script) {
|
||||
const quality = normalizeQuality(config.quality);
|
||||
const requestSize = resolveRequestSize(quality, config.size);
|
||||
const refs = await Promise.all(references.map((image) => imageToDataUrl(image)));
|
||||
try {
|
||||
const result = await runModelPlugin({
|
||||
capability: "image",
|
||||
script,
|
||||
config: requestConfig,
|
||||
prompt: withSystemPrompt(requestConfig, requestPrompt),
|
||||
images: refs,
|
||||
params: { size: requestSize, quality, count: n },
|
||||
signal: options?.signal,
|
||||
});
|
||||
return normalizePluginImages(result).map((dataUrl) => ({ id: nanoid(), dataUrl }));
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "请求失败"));
|
||||
}
|
||||
}
|
||||
if (requestConfig.apiFormat === "gemini") {
|
||||
if (mask) throw new Error("Gemini 调用格式暂不支持蒙版编辑");
|
||||
try {
|
||||
@@ -730,6 +770,24 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
|
||||
export async function requestImageQuestion(config: AiConfig, messages: AiTextMessage[], onDelta: (text: string) => void, options?: RequestOptions) {
|
||||
const requestConfig = resolveModelRequestConfig(config, config.model || config.textModel);
|
||||
const script = resolveModelScript(config, config.model || config.textModel);
|
||||
if (script) {
|
||||
try {
|
||||
const answer = await runModelPlugin<string>({
|
||||
capability: "text",
|
||||
script,
|
||||
config: requestConfig,
|
||||
messages: withSystemMessage(requestConfig, messages),
|
||||
signal: options?.signal,
|
||||
onDelta,
|
||||
});
|
||||
const text = String(answer ?? "").trim() || "没有返回内容";
|
||||
if (text === "没有返回内容") onDelta(text);
|
||||
return text;
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "请求失败"));
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (requestConfig.apiFormat === "gemini") {
|
||||
const answer = (await requestGeminiStreamingResponse(requestConfig, toGeminiBody(requestConfig, messages), onDelta, options)).content || "没有返回内容";
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
|
||||
import { buildApiUrl, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
type RequestOptions = { signal?: AbortSignal };
|
||||
|
||||
export type PluginHttpOptions = {
|
||||
headers?: Record<string, string>;
|
||||
params?: Record<string, unknown>;
|
||||
responseType?: "json" | "blob" | "text" | "arraybuffer";
|
||||
};
|
||||
|
||||
export type PluginHttp = {
|
||||
url: (path: string) => string;
|
||||
post: (path: string, body?: unknown, options?: PluginHttpOptions) => Promise<unknown>;
|
||||
get: (path: string, options?: PluginHttpOptions) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export type PluginPollOptions = { intervalMs?: number; timeoutMs?: number };
|
||||
|
||||
export type RunPluginArgs = {
|
||||
capability: ModelCapability;
|
||||
script: string;
|
||||
config: AiConfig;
|
||||
prompt?: string;
|
||||
images?: string[];
|
||||
messages?: unknown[];
|
||||
params?: Record<string, unknown>;
|
||||
signal?: AbortSignal;
|
||||
onDelta?: (text: string) => void;
|
||||
};
|
||||
|
||||
function pluginHeaders(extra?: Record<string, string>, hasJsonBody = false): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (hasJsonBody) headers["Content-Type"] = "application/json";
|
||||
return { ...headers, ...extra };
|
||||
}
|
||||
|
||||
function pluginUrl(config: AiConfig, path: string) {
|
||||
if (/^https?:/i.test(path)) return path;
|
||||
return buildApiUrl(config.baseUrl, path.startsWith("/") ? path : `/${path}`);
|
||||
}
|
||||
|
||||
function createPluginHttp(config: AiConfig, options?: RequestOptions): PluginHttp {
|
||||
const run = async (method: "get" | "post", path: string, body: unknown, opts?: PluginHttpOptions) => {
|
||||
const isForm = typeof FormData !== "undefined" && body instanceof FormData;
|
||||
const response = await axios.request({
|
||||
method,
|
||||
url: pluginUrl(config, path),
|
||||
data: method === "post" ? body : undefined,
|
||||
params: opts?.params,
|
||||
headers: pluginHeaders({ Authorization: `Bearer ${config.apiKey}`, ...opts?.headers }, method === "post" && !isForm && body !== undefined),
|
||||
responseType: opts?.responseType || "json",
|
||||
signal: options?.signal,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
return {
|
||||
url: (path) => pluginUrl(config, path),
|
||||
post: (path, body, opts) => run("post", path, body, opts),
|
||||
get: (path, opts) => run("get", path, undefined, opts),
|
||||
};
|
||||
}
|
||||
|
||||
/** Raw request with no automatic auth header — the script controls method, url, headers, body entirely. */
|
||||
function createPluginRequest(config: AiConfig, options?: RequestOptions) {
|
||||
return async (requestConfig: AxiosRequestConfig & { url: string }) => {
|
||||
const response = await axios.request({ ...requestConfig, url: pluginUrl(config, requestConfig.url), signal: options?.signal });
|
||||
return response.data;
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createPoll(signal?: AbortSignal) {
|
||||
return async function poll<T, R>(request: () => Promise<T>, extract: (value: T) => R | null | undefined | false, options?: PluginPollOptions): Promise<R> {
|
||||
const intervalMs = options?.intervalMs ?? 2500;
|
||||
const timeoutMs = options?.timeoutMs ?? 300000;
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
for (;;) {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
const result = extract(await request());
|
||||
if (result !== null && result !== undefined && result !== false) return result;
|
||||
if (performance.now() >= deadline) throw new Error("插件轮询超时,请检查调用脚本或稍后重试");
|
||||
await sleep(intervalMs, signal);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a user-authored model call script as an async function body with flat locals (see PLUGIN_VARIABLES):
|
||||
* prompt / images / messages / params —— 本次请求的输入
|
||||
* model / baseUrl / apiKey / systemPrompt —— 当前渠道信息
|
||||
* http / request / poll / sleep / signal / onDelta —— 调用辅助
|
||||
* The script must `return` the result; each caller normalizes it to its capability's shape.
|
||||
*/
|
||||
export async function runModelPlugin<T = unknown>(args: RunPluginArgs): Promise<T> {
|
||||
const { config } = args;
|
||||
const http = createPluginHttp(config, { signal: args.signal });
|
||||
const request = createPluginRequest(config, { signal: args.signal });
|
||||
const poll = createPoll(args.signal);
|
||||
const runner = new Function(
|
||||
"prompt",
|
||||
"images",
|
||||
"messages",
|
||||
"params",
|
||||
"model",
|
||||
"baseUrl",
|
||||
"apiKey",
|
||||
"systemPrompt",
|
||||
"http",
|
||||
"request",
|
||||
"poll",
|
||||
"sleep",
|
||||
"signal",
|
||||
"onDelta",
|
||||
`"use strict"; return (async () => {\n${args.script}\n})();`,
|
||||
) as (...fnArgs: unknown[]) => Promise<T>;
|
||||
try {
|
||||
return await runner(
|
||||
args.prompt || "",
|
||||
args.images || [],
|
||||
args.messages || [],
|
||||
args.params || {},
|
||||
config.model,
|
||||
config.baseUrl,
|
||||
config.apiKey,
|
||||
config.systemPrompt || "",
|
||||
http,
|
||||
request,
|
||||
poll,
|
||||
(ms: number) => sleep(ms, args.signal),
|
||||
args.signal,
|
||||
args.onDelta,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
if (axios.isCancel(error)) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`模型调用脚本执行失败:${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginVariable = { name: string; type: string; desc: string; capabilities?: ModelCapability[] };
|
||||
|
||||
/** Documentation surface shown in the script editor. */
|
||||
export const PLUGIN_VARIABLES: PluginVariable[] = [
|
||||
{ name: "prompt", type: "string", desc: "用户输入的提示词(已拼接系统提示词)", capabilities: ["image", "video", "audio"] },
|
||||
{ name: "images", type: "string[]", desc: "参考图,dataURL 数组(改图 / 图生视频时有值)", capabilities: ["image", "video"] },
|
||||
{ name: "messages", type: "{ role, content }[]", desc: "对话消息数组,含系统消息", capabilities: ["text"] },
|
||||
{ name: "params", type: "object", desc: "生成参数:生图 {size,quality,count}、视频 {seconds,size,resolution,ratio,generateAudio,watermark}、音频 {voice,format,speed,instructions}" },
|
||||
{ name: "model", type: "string", desc: "模型名称(不含渠道前缀)" },
|
||||
{ name: "baseUrl", type: "string", desc: "渠道接口地址(原样,未拼 /v1)" },
|
||||
{ name: "apiKey", type: "string", desc: "渠道 API Key,请求头里自己带上" },
|
||||
{ name: "systemPrompt", type: "string", desc: "系统提示词原文" },
|
||||
{ name: "http", type: "object", desc: "便捷请求:http.post(path, body, {headers,params,responseType})、http.get(path, opts)、http.url(path);默认带 Authorization: Bearer apiKey,可用 headers 覆盖;path 相对时按 baseUrl 拼 /v1" },
|
||||
{ name: "request", type: "function", desc: "原始请求 request({ method, url, headers, params, data, responseType }),不加任何默认头,鉴权头自己写;url 相对时按 baseUrl 拼接(不加 /v1)" },
|
||||
{ name: "poll", type: "function", desc: "轮询 poll(request, extract, {intervalMs,timeoutMs}),extract 返回真值即结束" },
|
||||
{ name: "sleep", type: "function", desc: "sleep(ms) 延时" },
|
||||
{ name: "signal", type: "AbortSignal", desc: "取消信号,可透传给 http/request" },
|
||||
{ name: "onDelta", type: "function", desc: "onDelta(text) 推送流式文本(文本模型)", capabilities: ["text"] },
|
||||
];
|
||||
|
||||
export const PLUGIN_RETURNS: Record<ModelCapability, string> = {
|
||||
image: "文生图(images 为空)和图生图(images 有参考图)接口不同,脚本需自行区分;返回图片 URL 或 dataURL 字符串,也可返回它们的数组,或 [{ dataUrl }] / [{ url }] / [{ b64_json }]",
|
||||
video: "脚本内部完成轮询,返回 { url } 或 { blob } 或视频 URL 字符串",
|
||||
audio: "返回 Blob,或 base64 / dataURL 字符串,或 { b64_json } / { data } / { url }",
|
||||
text: "用 onDelta(text) 推送流式,最终 return 完整文本字符串",
|
||||
};
|
||||
|
||||
export type PluginTemplate = { label: string; script: string };
|
||||
|
||||
export const PLUGIN_TEMPLATES: Record<ModelCapability, PluginTemplate[]> = {
|
||||
image: [
|
||||
{
|
||||
label: "OpenAI 规范",
|
||||
script: `// 生图 / 改图:两者接口不同,用 images 是否为空来区分。
|
||||
// 可用:prompt、images(dataURL[])、params{size,quality,count}、model、baseUrl、apiKey
|
||||
if (images.length === 0) {
|
||||
// 文生图:/images/generations(JSON)
|
||||
const data = await request({
|
||||
method: "post",
|
||||
url: \`\${baseUrl}/v1/images/generations\`,
|
||||
headers: { "Content-Type": "application/json", Authorization: \`Bearer \${apiKey}\` },
|
||||
data: { model, prompt, n: params.count, size: params.size, response_format: "b64_json" },
|
||||
});
|
||||
return (data.data || []).map((item) => item.b64_json ? \`data:image/png;base64,\${item.b64_json}\` : item.url);
|
||||
}
|
||||
|
||||
// 图生图:/images/edits(multipart/form-data,参考图作为文件上传)
|
||||
const form = new FormData();
|
||||
form.set("model", model);
|
||||
form.set("prompt", prompt);
|
||||
form.set("n", String(params.count));
|
||||
form.set("response_format", "b64_json");
|
||||
for (const dataUrl of images) {
|
||||
form.append("image", await (await fetch(dataUrl)).blob(), "ref.png");
|
||||
}
|
||||
const edited = await request({
|
||||
method: "post",
|
||||
url: \`\${baseUrl}/v1/images/edits\`,
|
||||
headers: { Authorization: \`Bearer \${apiKey}\` }, // 不要手动设 Content-Type,交给浏览器带 boundary
|
||||
data: form,
|
||||
});
|
||||
return (edited.data || []).map((item) => item.b64_json ? \`data:image/png;base64,\${item.b64_json}\` : item.url);`,
|
||||
},
|
||||
{
|
||||
label: "Gemini 规范",
|
||||
script: `// Gemini 文生图 / 图生图:都走 generateContent,参考图放进 parts 的 inline_data。
|
||||
// 可用:prompt、images(dataURL[])、model、baseUrl、apiKey
|
||||
const parts = [{ text: prompt }];
|
||||
for (const dataUrl of images) {
|
||||
const match = dataUrl.match(/^data:([^;]+);base64,(.*)$/);
|
||||
if (match) parts.push({ inline_data: { mime_type: match[1], data: match[2] } });
|
||||
}
|
||||
const data = await request({
|
||||
method: "post",
|
||||
url: \`\${baseUrl}/v1beta/models/\${model}:generateContent\`,
|
||||
headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
|
||||
data: { contents: [{ role: "user", parts }], generationConfig: { responseModalities: ["IMAGE"] } },
|
||||
});
|
||||
return (data.candidates || [])
|
||||
.flatMap((c) => c.content?.parts || [])
|
||||
.map((p) => p.inlineData || p.inline_data)
|
||||
.filter(Boolean)
|
||||
.map((img) => \`data:\${img.mimeType || img.mime_type || "image/png"};base64,\${img.data}\`);`,
|
||||
},
|
||||
],
|
||||
video: [
|
||||
{
|
||||
label: "OpenAI 规范",
|
||||
script: `// 视频(脚本内部自行轮询)。可用:prompt、images(dataURL[])、params{seconds,size,resolution,ratio}
|
||||
const headers = { "Content-Type": "application/json", Authorization: \`Bearer \${apiKey}\` };
|
||||
const task = await request({
|
||||
method: "post",
|
||||
url: \`\${baseUrl}/v1/videos\`,
|
||||
headers,
|
||||
data: { model, prompt, seconds: params.seconds },
|
||||
});
|
||||
return await poll(
|
||||
() => request({ method: "get", url: \`\${baseUrl}/v1/videos/\${task.id}\`, headers }),
|
||||
(state) => state.status === "completed" ? { url: state.video_url || state.url } : null,
|
||||
{ intervalMs: 2500, timeoutMs: 300000 },
|
||||
);`,
|
||||
},
|
||||
{
|
||||
label: "Gemini 规范",
|
||||
script: `// Gemini(Veo) 视频:predictLongRunning 提交,轮询 operation 拿视频 URI。
|
||||
// 可用:prompt、images(dataURL[])、params、model、baseUrl、apiKey
|
||||
const headers = { "Content-Type": "application/json", "x-goog-api-key": apiKey };
|
||||
const instance = { prompt };
|
||||
const first = images[0] && images[0].match(/^data:([^;]+);base64,(.*)$/);
|
||||
if (first) instance.image = { bytesBase64Encoded: first[2], mimeType: first[1] };
|
||||
const op = await request({
|
||||
method: "post",
|
||||
url: \`\${baseUrl}/v1beta/models/\${model}:predictLongRunning\`,
|
||||
headers,
|
||||
data: { instances: [instance], parameters: { aspectRatio: params.ratio } },
|
||||
});
|
||||
return await poll(
|
||||
() => request({ method: "get", url: \`\${baseUrl}/v1beta/\${op.name}\`, headers }),
|
||||
(state) => {
|
||||
if (!state.done) return null;
|
||||
const uri = state.response?.generateVideoResponse?.generatedSamples?.[0]?.video?.uri;
|
||||
if (!uri) throw new Error("Gemini 未返回视频 URI");
|
||||
return { url: uri.includes("key=") ? uri : \`\${uri}\${uri.includes("?") ? "&" : "?"}key=\${apiKey}\` };
|
||||
},
|
||||
{ intervalMs: 5000, timeoutMs: 300000 },
|
||||
);`,
|
||||
},
|
||||
],
|
||||
audio: [
|
||||
{
|
||||
label: "OpenAI 规范",
|
||||
script: `// 音频 TTS。可用:prompt、params{voice,format,speed,instructions}、model
|
||||
return await request({
|
||||
method: "post",
|
||||
url: \`\${baseUrl}/v1/audio/speech\`,
|
||||
headers: { "Content-Type": "application/json", Authorization: \`Bearer \${apiKey}\` },
|
||||
responseType: "blob",
|
||||
data: { model, input: prompt, voice: params.voice, response_format: params.format, speed: Number(params.speed) },
|
||||
});`,
|
||||
},
|
||||
{
|
||||
label: "Gemini 规范",
|
||||
script: `// Gemini TTS:generateContent + AUDIO 模态,返回 base64 PCM(音频数据在 inlineData.data)。
|
||||
// 可用:prompt、params{voice}、model、baseUrl、apiKey
|
||||
const data = await request({
|
||||
method: "post",
|
||||
url: \`\${baseUrl}/v1beta/models/\${model}:generateContent\`,
|
||||
headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
|
||||
data: {
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
generationConfig: {
|
||||
responseModalities: ["AUDIO"],
|
||||
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: params.voice } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const audio = data.candidates?.[0]?.content?.parts?.map((p) => p.inlineData || p.inline_data).find(Boolean);
|
||||
if (!audio?.data) throw new Error("Gemini 未返回音频");
|
||||
return { data: audio.data };`,
|
||||
},
|
||||
],
|
||||
text: [
|
||||
{
|
||||
label: "OpenAI 规范",
|
||||
script: `// 文本对话(OpenAI Responses 接口)。可用:messages([{role,content}])、systemPrompt、model
|
||||
const data = await request({
|
||||
method: "post",
|
||||
url: \`\${baseUrl}/v1/responses\`,
|
||||
headers: { "Content-Type": "application/json", Authorization: \`Bearer \${apiKey}\` },
|
||||
data: { model, input: messages },
|
||||
});
|
||||
const text = data.output_text
|
||||
|| (data.output || []).flatMap((o) => o.content || []).map((c) => c.text || "").join("")
|
||||
|| "";
|
||||
onDelta(text);
|
||||
return text;`,
|
||||
},
|
||||
{
|
||||
label: "Gemini 规范",
|
||||
script: `// Gemini 文本:generateContent,system 消息放 systemInstruction。
|
||||
// 可用:messages([{role,content}])、systemPrompt、model、baseUrl、apiKey
|
||||
const contents = messages
|
||||
.filter((m) => m.role !== "system")
|
||||
.map((m) => ({ role: m.role === "assistant" ? "model" : "user", parts: [{ text: m.content }] }));
|
||||
const data = await request({
|
||||
method: "post",
|
||||
url: \`\${baseUrl}/v1beta/models/\${model}:generateContent\`,
|
||||
headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
|
||||
data: { contents, ...(systemPrompt ? { systemInstruction: { parts: [{ text: systemPrompt }] } } : {}) },
|
||||
});
|
||||
const text = data.candidates?.[0]?.content?.parts?.map((p) => p.text || "").join("") || "";
|
||||
onDelta(text);
|
||||
return text;`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Normalize whatever an image script returns into the app's generated-image shape. */
|
||||
export function normalizePluginImages(result: unknown): string[] {
|
||||
const items = Array.isArray(result) ? result : [result];
|
||||
const urls = items
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (item && typeof item === "object") {
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.dataUrl === "string") return record.dataUrl;
|
||||
if (typeof record.url === "string") return record.url;
|
||||
if (typeof record.b64_json === "string") return `data:image/png;base64,${record.b64_json}`;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (!urls.length) throw new Error("模型调用脚本没有返回图片");
|
||||
return urls;
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import axios from "axios";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { dataUrlToFile } from "@/lib/image-utils";
|
||||
import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/file-storage";
|
||||
import { imageToDataUrl } from "@/services/image-storage";
|
||||
import { boolConfig, buildSeedancePromptText, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
|
||||
import { buildApiUrl, modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { buildApiUrl, modelOptionName, resolveModelRequestConfig, resolveModelScript, type AiConfig } from "@/stores/use-config-store";
|
||||
import { runModelPlugin } from "./model-plugin";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
|
||||
@@ -23,9 +25,12 @@ type ApiEnvelope<T> = T | { code?: number | string; data?: T | null; msg?: strin
|
||||
type RequestOptions = { signal?: AbortSignal };
|
||||
|
||||
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
|
||||
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance"; model: string };
|
||||
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance" | "plugin"; model: string };
|
||||
export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string };
|
||||
|
||||
/** Results for scripted (plugin) video models, which run their own create+poll in one shot at task creation. */
|
||||
const pluginVideoResults = new Map<string, VideoGenerationResult>();
|
||||
|
||||
function aiApiUrl(config: AiConfig, path: string) {
|
||||
return buildApiUrl(config.baseUrl, path);
|
||||
}
|
||||
@@ -54,22 +59,67 @@ export async function requestVideoGeneration(config: AiConfig, prompt: string, r
|
||||
export async function createVideoGenerationTask(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = [], options?: RequestOptions): Promise<VideoGenerationTask> {
|
||||
const selectedModel = (config.model || config.videoModel).trim();
|
||||
const requestConfig = resolveModelRequestConfig(config, selectedModel);
|
||||
const script = resolveModelScript(config, selectedModel);
|
||||
if (script) return createPluginVideoTask(requestConfig, selectedModel, script, prompt, references, options);
|
||||
assertVideoConfig(requestConfig, requestConfig.model);
|
||||
if (isSeedanceVideoConfig(requestConfig)) {
|
||||
return createSeedanceTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
|
||||
}
|
||||
if (videoReferences.length || audioReferences.length) {
|
||||
throw new Error("当前视频接口不支持参考视频或参考音频,请切换到 Seedance 2.0 / 火山 Agent Plan 模型,或移除参考素材");
|
||||
throw new Error("当前视频接口不支持参考视频或参考音频,请切换到 Seedance 2.0 / 火山 Agent Plan 模型,或移除参考资产");
|
||||
}
|
||||
return createOpenAIVideoTask(requestConfig, selectedModel, prompt, references, options);
|
||||
}
|
||||
|
||||
export async function pollVideoGenerationTask(config: AiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
|
||||
if (task.provider === "plugin") {
|
||||
const result = pluginVideoResults.get(task.id);
|
||||
return result ? { status: "completed", result } : { status: "failed", error: "插件视频任务已失效,请重新生成" };
|
||||
}
|
||||
const requestConfig = resolveModelRequestConfig(config, task.model);
|
||||
assertVideoConfig(requestConfig, requestConfig.model);
|
||||
return task.provider === "seedance" ? pollSeedanceTask(requestConfig, task, options) : pollOpenAIVideoTask(requestConfig, task, options);
|
||||
}
|
||||
|
||||
async function createPluginVideoTask(config: AiConfig, model: string, script: string, prompt: string, references: ReferenceImage[], options?: RequestOptions): Promise<VideoGenerationTask> {
|
||||
if (!config.baseUrl.trim()) throw new Error("请先配置 Base URL");
|
||||
if (!config.apiKey.trim()) throw new Error("请先配置 API Key");
|
||||
const refs = await Promise.all(references.map((image) => imageToDataUrl(image)));
|
||||
const result = videoPluginResult(
|
||||
await runModelPlugin({
|
||||
capability: "video",
|
||||
script,
|
||||
config,
|
||||
prompt,
|
||||
images: refs,
|
||||
params: {
|
||||
seconds: normalizeVideoSeconds(config.videoSeconds),
|
||||
size: normalizeVideoSize(config.size),
|
||||
resolution: normalizeVideoResolution(config.vquality),
|
||||
ratio: config.size,
|
||||
generateAudio: boolConfig(config.videoGenerateAudio, true),
|
||||
watermark: boolConfig(config.videoWatermark, false),
|
||||
},
|
||||
signal: options?.signal,
|
||||
}),
|
||||
);
|
||||
const id = nanoid();
|
||||
pluginVideoResults.set(id, result);
|
||||
return { id, provider: "plugin", model };
|
||||
}
|
||||
|
||||
function videoPluginResult(result: unknown): VideoGenerationResult {
|
||||
if (result instanceof Blob) return { blob: result };
|
||||
if (typeof result === "string") return { url: result, mimeType: "video/mp4" };
|
||||
if (result && typeof result === "object") {
|
||||
const record = result as Record<string, unknown>;
|
||||
if (record.blob instanceof Blob) return { blob: record.blob };
|
||||
const url = [record.url, record.video_url, record.result_url].find((value) => typeof value === "string" && value) as string | undefined;
|
||||
if (url) return { url, mimeType: "video/mp4" };
|
||||
}
|
||||
throw new Error("模型调用脚本没有返回视频");
|
||||
}
|
||||
|
||||
export async function storeGeneratedVideo(result: VideoGenerationResult): Promise<UploadedFile> {
|
||||
if (result.blob) return uploadMediaFile(result.blob, "video");
|
||||
if (result.url) {
|
||||
@@ -213,7 +263,7 @@ async function resolveSeedanceVideoUrl(video: ReferenceVideo) {
|
||||
let blob: Blob | null = null;
|
||||
if (video.storageKey) blob = await getMediaBlob(video.storageKey);
|
||||
if (!blob && video.url?.startsWith("blob:")) blob = await (await fetch(video.url)).blob();
|
||||
if (!blob) throw new Error("参考视频必须是公网 URL、素材 ID,或本地已保存的视频");
|
||||
if (!blob) throw new Error("参考视频必须是公网 URL、资产 ID,或本地已保存的视频");
|
||||
return blobToDataUrl(blob);
|
||||
}
|
||||
|
||||
@@ -222,7 +272,7 @@ async function resolveSeedanceAudioUrl(audio: ReferenceAudio) {
|
||||
let blob: Blob | null = null;
|
||||
if (audio.storageKey) blob = await getMediaBlob(audio.storageKey);
|
||||
if (!blob && audio.url?.startsWith("blob:")) blob = await (await fetch(audio.url)).blob();
|
||||
if (!blob) throw new Error("参考音频必须是公网 URL、素材 ID,或本地已保存的音频");
|
||||
if (!blob) throw new Error("参考音频必须是公网 URL、资产 ID,或本地已保存的音频");
|
||||
return blobToDataUrl(blob);
|
||||
}
|
||||
|
||||
@@ -353,7 +403,7 @@ function blobToDataUrl(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(new Error("读取本地素材失败"));
|
||||
reader.onerror = () => reject(new Error("读取本地资产失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export async function syncAppDataToWebdav(config: WebdavSyncConfig, onProgress?:
|
||||
}),
|
||||
syncDomain<AssetDomainData>(config, onProgress, {
|
||||
key: "assets",
|
||||
label: "我的素材",
|
||||
label: "我的资产",
|
||||
emptyData: { assets: [] },
|
||||
localData: async () => ({ assets: useAssetStore.getState().assets }),
|
||||
mergeData: (local, remote) => ({ assets: mergeById(local.assets, remote.assets, "updatedAt") }),
|
||||
@@ -323,7 +323,7 @@ function domainPath(domain: DomainKey, path: string) {
|
||||
|
||||
function domainLabel(domain: DomainKey) {
|
||||
if (domain === "canvas") return "画布";
|
||||
if (domain === "assets") return "我的素材";
|
||||
if (domain === "assets") return "我的资产";
|
||||
if (domain === "image-workbench") return "生图工作台";
|
||||
return "视频创作台";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
import { localForageStorage } from "@/lib/localforage-storage";
|
||||
|
||||
export type InstalledPlugin = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
url: string; // 安装来源,可用于更新
|
||||
source: string; // 缓存的插件源码,离线可用、版本固定
|
||||
enabled: boolean;
|
||||
local?: boolean; // 自动发现于 web/public/plugins 的本地插件(默认关闭,启用时按 url 重新拉取)
|
||||
official?: boolean; // 从官方注册表安装(用于在管理器里归类)
|
||||
installedAt: string;
|
||||
};
|
||||
|
||||
type PluginStore = {
|
||||
plugins: InstalledPlugin[];
|
||||
upsert: (plugin: Omit<InstalledPlugin, "installedAt"> & { installedAt?: string }) => void;
|
||||
setEnabled: (id: string, enabled: boolean) => void;
|
||||
remove: (id: string) => void;
|
||||
};
|
||||
|
||||
export const usePluginStore = create<PluginStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
plugins: [],
|
||||
upsert: (plugin) =>
|
||||
set((state) => {
|
||||
const installedAt = plugin.installedAt || new Date().toISOString();
|
||||
const exists = state.plugins.some((item) => item.id === plugin.id);
|
||||
const next = { ...plugin, installedAt };
|
||||
return { plugins: exists ? state.plugins.map((item) => (item.id === plugin.id ? next : item)) : [next, ...state.plugins] };
|
||||
}),
|
||||
setEnabled: (id, enabled) => set((state) => ({ plugins: state.plugins.map((item) => (item.id === id ? { ...item, enabled } : item)) })),
|
||||
remove: (id) => set((state) => ({ plugins: state.plugins.filter((item) => item.id !== id) })),
|
||||
}),
|
||||
{
|
||||
name: "infinite-canvas:plugin_store",
|
||||
storage: createJSONStorage(() => localForageStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -4,6 +4,13 @@ import { persist } from "zustand/middleware";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export type ApiCallFormat = "openai" | "gemini";
|
||||
export type ModelCapability = "image" | "video" | "text" | "audio";
|
||||
|
||||
export type ChannelModel = {
|
||||
name: string;
|
||||
capability: ModelCapability;
|
||||
script?: string;
|
||||
};
|
||||
|
||||
export type ModelChannel = {
|
||||
id: string;
|
||||
@@ -11,7 +18,7 @@ export type ModelChannel = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
apiFormat: ApiCallFormat;
|
||||
models: string[];
|
||||
models: ChannelModel[];
|
||||
};
|
||||
|
||||
export type AiConfig = {
|
||||
@@ -35,10 +42,6 @@ export type AiConfig = {
|
||||
videoWatermark: string;
|
||||
systemPrompt: string;
|
||||
models: string[];
|
||||
imageModels: string[];
|
||||
videoModels: string[];
|
||||
textModels: string[];
|
||||
audioModels: string[];
|
||||
quality: string;
|
||||
size: string;
|
||||
count: string;
|
||||
@@ -52,10 +55,9 @@ export type WebdavSyncConfig = {
|
||||
directory: string;
|
||||
lastSyncedAt: string;
|
||||
};
|
||||
export type ConfigTabKey = "channels" | "models" | "preferences" | "webdav";
|
||||
export type ConfigTabKey = "channels" | "preferences" | "webdav";
|
||||
|
||||
export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store";
|
||||
export type ModelCapability = "image" | "video" | "text" | "audio";
|
||||
const CHANNEL_MODEL_SEPARATOR = "::";
|
||||
const OPENAI_BASE_URL = "https://api.openai.com";
|
||||
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com";
|
||||
@@ -72,7 +74,12 @@ export const defaultConfig: AiConfig = {
|
||||
baseUrl: OPENAI_BASE_URL,
|
||||
apiKey: "",
|
||||
apiFormat: "openai",
|
||||
models: ["gpt-image-2", "grok-imagine-video", "gpt-5.5", "gpt-4o-mini-tts"],
|
||||
models: [
|
||||
{ name: "gpt-image-2", capability: "image" },
|
||||
{ name: "grok-imagine-video", capability: "video" },
|
||||
{ name: "gpt-5.5", capability: "text" },
|
||||
{ name: "gpt-4o-mini-tts", capability: "audio" },
|
||||
],
|
||||
},
|
||||
],
|
||||
model: "default::gpt-image-2",
|
||||
@@ -90,10 +97,6 @@ export const defaultConfig: AiConfig = {
|
||||
videoWatermark: "false",
|
||||
systemPrompt: "",
|
||||
models: ["default::gpt-image-2", "default::grok-imagine-video", "default::gpt-5.5", "default::gpt-4o-mini-tts"],
|
||||
imageModels: ["default::gpt-image-2"],
|
||||
videoModels: ["default::grok-imagine-video"],
|
||||
textModels: ["default::gpt-5.5"],
|
||||
audioModels: ["default::gpt-4o-mini-tts"],
|
||||
quality: "auto",
|
||||
size: "1:1",
|
||||
count: "1",
|
||||
@@ -122,44 +125,44 @@ type ConfigStore = {
|
||||
clearPromptContinue: () => void;
|
||||
};
|
||||
|
||||
function isVideoModelName(model: string) {
|
||||
const value = modelOptionName(model).toLowerCase();
|
||||
return value.includes("seedance") || value.includes("video") || value.includes("sora") || value.includes("veo") || value.includes("kling") || value.includes("wan") || value.includes("hailuo");
|
||||
const VIDEO_KEYWORDS = ["seedance", "video", "sora", "veo", "kling", "wan", "hailuo"];
|
||||
const AUDIO_KEYWORDS = ["audio", "tts", "speech", "voice", "music", "sound"];
|
||||
const IMAGE_KEYWORDS = ["seedream", "gpt-image", "image", "dall-e", "dalle", "imagen", "flux", "sdxl", "stable-diffusion", "midjourney"];
|
||||
|
||||
/** Best-effort default capability for a freshly fetched model name; user can override in the channel editor. */
|
||||
export function guessCapability(name: string): ModelCapability {
|
||||
const value = name.toLowerCase();
|
||||
if (VIDEO_KEYWORDS.some((keyword) => value.includes(keyword))) return "video";
|
||||
if (AUDIO_KEYWORDS.some((keyword) => value.includes(keyword))) return "audio";
|
||||
if (IMAGE_KEYWORDS.some((keyword) => value.includes(keyword))) return "image";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function isImageModelName(model: string) {
|
||||
const value = modelOptionName(model).toLowerCase();
|
||||
return !isVideoModelName(model) && !isAudioModelName(model) && (value.includes("seedream") || value.includes("gpt-image") || value.includes("image") || value.includes("dall-e") || value.includes("dalle") || value.includes("imagen") || value.includes("flux") || value.includes("sdxl") || value.includes("stable-diffusion") || value.includes("midjourney"));
|
||||
function findChannelModel(config: AiConfig, value: string): { channel: ModelChannel; model: ChannelModel } | null {
|
||||
const decoded = decodeChannelModel(value);
|
||||
const name = decoded?.model || value;
|
||||
const channel = decoded ? config.channels.find((item) => item.id === decoded.channelId) : config.channels.find((item) => item.models.some((model) => model.name === name));
|
||||
const model = channel?.models.find((item) => item.name === name);
|
||||
return channel && model ? { channel, model } : null;
|
||||
}
|
||||
|
||||
function isAudioModelName(model: string) {
|
||||
const value = modelOptionName(model).toLowerCase();
|
||||
return value.includes("audio") || value.includes("tts") || value.includes("speech") || value.includes("voice") || value.includes("music") || value.includes("sound");
|
||||
export function modelCapabilityOf(config: AiConfig, value: string): ModelCapability | undefined {
|
||||
return findChannelModel(config, value)?.model.capability;
|
||||
}
|
||||
|
||||
function isTextModelName(model: string) {
|
||||
return !isImageModelName(model) && !isVideoModelName(model) && !isAudioModelName(model);
|
||||
}
|
||||
|
||||
export function modelMatchesCapability(model: string, capability?: ModelCapability) {
|
||||
export function modelMatchesCapability(config: AiConfig, value: string, capability?: ModelCapability) {
|
||||
if (!capability) return true;
|
||||
if (capability === "image") return isImageModelName(model);
|
||||
if (capability === "video") return isVideoModelName(model);
|
||||
if (capability === "audio") return isAudioModelName(model);
|
||||
return isTextModelName(model);
|
||||
}
|
||||
|
||||
export function filterModelsByCapability(models: string[], capability?: ModelCapability) {
|
||||
return capability ? models.filter((model) => modelMatchesCapability(model, capability)) : models;
|
||||
return modelCapabilityOf(config, value) === capability;
|
||||
}
|
||||
|
||||
export function selectableModelsByCapability(config: AiConfig, capability?: ModelCapability) {
|
||||
if (!capability) return config.models;
|
||||
return config[modelListKey(capability)];
|
||||
return config.channels.flatMap((channel) => channel.models.filter((model) => model.capability === capability).map((model) => encodeChannelModel(channel.id, model.name)));
|
||||
}
|
||||
|
||||
function modelListKey(capability: ModelCapability) {
|
||||
return `${capability}Models` as "imageModels" | "videoModels" | "textModels" | "audioModels";
|
||||
/** The user script (if any) attached to a model; empty string means use the system default call. */
|
||||
export function resolveModelScript(config: AiConfig, value: string) {
|
||||
return findChannelModel(config, value)?.model.script?.trim() || "";
|
||||
}
|
||||
|
||||
function isAiConfigReady(config: AiConfig, model: string) {
|
||||
@@ -215,7 +218,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
channels,
|
||||
models,
|
||||
imageModel: normalizeModelOptionValue(config.imageModel || config.model, channels),
|
||||
videoModel: normalizeModelOptionValue(config.videoModel || "grok-imagine-video", channels),
|
||||
videoModel: normalizeModelOptionValue(config.videoModel, channels),
|
||||
textModel: normalizeModelOptionValue(config.textModel || config.model, channels),
|
||||
audioModel: normalizeModelOptionValue(config.audioModel || defaultConfig.audioModel, channels),
|
||||
audioVoice: config.audioVoice || defaultConfig.audioVoice,
|
||||
@@ -227,10 +230,6 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
videoGenerateAudio: config.videoGenerateAudio || "true",
|
||||
videoWatermark: config.videoWatermark || "false",
|
||||
canvasImageCount: config.canvasImageCount || "3",
|
||||
imageModels: Array.isArray(persistedConfig.imageModels) ? normalizeModelList(config.imageModels, channels) : filterModelsByCapability(models, "image"),
|
||||
videoModels: Array.isArray(persistedConfig.videoModels) ? normalizeModelList(config.videoModels, channels) : filterModelsByCapability(models, "video"),
|
||||
textModels: Array.isArray(persistedConfig.textModels) ? normalizeModelList(config.textModels, channels) : filterModelsByCapability(models, "text"),
|
||||
audioModels: Array.isArray(persistedConfig.audioModels) ? normalizeModelList(config.audioModels, channels) : filterModelsByCapability(models, "audio"),
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -238,18 +237,26 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
),
|
||||
);
|
||||
|
||||
function normalizeModelList(models: string[], channels: ModelChannel[]) {
|
||||
const allModelOptions = channels.flatMap((channel) => channel.models.map((model) => encodeChannelModel(channel.id, model)));
|
||||
return Array.from(new Set((models || []).map((model) => model.trim()).filter(Boolean)))
|
||||
.map((model) => normalizeModelOptionValue(model, channels))
|
||||
.filter((model) => !allModelOptions.length || allModelOptions.includes(model) || !isChannelModelValue(model));
|
||||
}
|
||||
|
||||
export function useEffectiveConfig() {
|
||||
const config = useConfigStore((state) => state.config);
|
||||
return useMemo(() => ({ ...config, channelMode: "local" as const }), [config]);
|
||||
}
|
||||
|
||||
/** Normalize a mixed list of raw model names or model objects into deduped ChannelModel entries. */
|
||||
export function normalizeChannelModels(models: Array<string | ChannelModel> | undefined): ChannelModel[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ChannelModel[] = [];
|
||||
for (const item of models || []) {
|
||||
const name = (typeof item === "string" ? item : item?.name || "").trim();
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
const capability = typeof item === "string" ? guessCapability(name) : item.capability || guessCapability(name);
|
||||
const script = typeof item === "string" ? undefined : item.script?.trim() || undefined;
|
||||
result.push({ name, capability, script });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createModelChannel(channel?: Partial<ModelChannel>): ModelChannel {
|
||||
const apiFormat = normalizeApiFormat(channel?.apiFormat);
|
||||
return {
|
||||
@@ -258,7 +265,7 @@ export function createModelChannel(channel?: Partial<ModelChannel>): ModelChanne
|
||||
baseUrl: channel?.baseUrl?.trim() || defaultBaseUrlForApiFormat(apiFormat),
|
||||
apiKey: channel?.apiKey || "",
|
||||
apiFormat,
|
||||
models: uniqueRawModels(channel?.models || []),
|
||||
models: normalizeChannelModels(channel?.models),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -288,7 +295,7 @@ export function modelOptionLabel(config: AiConfig, value: string) {
|
||||
}
|
||||
|
||||
export function modelOptionsFromChannels(channels: ModelChannel[]) {
|
||||
return uniqueModelOptions(channels.flatMap((channel) => channel.models.map((model) => encodeChannelModel(channel.id, model))));
|
||||
return uniqueModelOptions(channels.flatMap((channel) => channel.models.map((model) => encodeChannelModel(channel.id, model.name))));
|
||||
}
|
||||
|
||||
export function normalizeModelOptionValue(value: string | undefined, channels: ModelChannel[]) {
|
||||
@@ -297,17 +304,17 @@ export function normalizeModelOptionValue(value: string | undefined, channels: M
|
||||
const decoded = decodeChannelModel(model);
|
||||
if (decoded) {
|
||||
const channel = channels.find((item) => item.id === decoded.channelId);
|
||||
return channel && channel.models.includes(decoded.model) ? model : "";
|
||||
return channel && channel.models.some((item) => item.name === decoded.model) ? model : "";
|
||||
}
|
||||
const channel = channels.find((item) => item.models.includes(model)) || channels[0];
|
||||
return channel && channel.models.includes(model) ? encodeChannelModel(channel.id, model) : model;
|
||||
const channel = channels.find((item) => item.models.some((entry) => entry.name === model)) || channels[0];
|
||||
return channel && channel.models.some((item) => item.name === model) ? encodeChannelModel(channel.id, model) : model;
|
||||
}
|
||||
|
||||
export function resolveModelChannel(config: AiConfig, value: string) {
|
||||
const decoded = decodeChannelModel(value);
|
||||
const model = decoded?.model || value;
|
||||
const matched = decoded ? config.channels.find((channel) => channel.id === decoded.channelId) : config.channels.find((channel) => channel.models.includes(model));
|
||||
return matched || config.channels[0] || createModelChannel({ id: "default", name: "默认渠道", baseUrl: config.baseUrl, apiKey: config.apiKey, apiFormat: config.apiFormat, models: config.models.map(modelOptionName) });
|
||||
const matched = decoded ? config.channels.find((channel) => channel.id === decoded.channelId) : config.channels.find((channel) => channel.models.some((item) => item.name === model));
|
||||
return matched || config.channels[0] || createModelChannel({ id: "default", name: "默认渠道", baseUrl: config.baseUrl, apiKey: config.apiKey, apiFormat: config.apiFormat, models: config.models.map(modelOptionName).map((name) => ({ name, capability: guessCapability(name) })) });
|
||||
}
|
||||
|
||||
export function resolveModelRequestConfig(config: AiConfig, value: string) {
|
||||
@@ -328,7 +335,7 @@ function normalizeChannels(config: AiConfig) {
|
||||
...channel,
|
||||
id: channel.id || (index === 0 ? "default" : `channel-${index + 1}`),
|
||||
name: channel.name || (index === 0 ? "默认渠道" : `渠道 ${index + 1}`),
|
||||
models: uniqueRawModels(channel.models || []),
|
||||
models: normalizeChannelModels(channel.models),
|
||||
}),
|
||||
);
|
||||
if (!channels.length) {
|
||||
@@ -339,18 +346,11 @@ function normalizeChannels(config: AiConfig) {
|
||||
baseUrl: config.baseUrl || defaultConfig.baseUrl,
|
||||
apiKey: config.apiKey || "",
|
||||
apiFormat: config.apiFormat || defaultConfig.apiFormat,
|
||||
models: uniqueRawModels([
|
||||
...(config.models || []),
|
||||
config.model,
|
||||
config.imageModel,
|
||||
config.videoModel,
|
||||
config.textModel,
|
||||
config.audioModel,
|
||||
]),
|
||||
models: normalizeChannelModels([config.model, config.imageModel, config.videoModel, config.textModel, config.audioModel].map(modelOptionName)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return channels.map((channel) => ({ ...channel, models: uniqueRawModels(channel.models) }));
|
||||
return channels;
|
||||
}
|
||||
|
||||
export function defaultBaseUrlForApiFormat(apiFormat: ApiCallFormat) {
|
||||
@@ -361,10 +361,6 @@ function normalizeApiFormat(apiFormat: unknown): ApiCallFormat {
|
||||
return apiFormat === "gemini" ? "gemini" : "openai";
|
||||
}
|
||||
|
||||
function uniqueRawModels(models: string[]) {
|
||||
return Array.from(new Set((models || []).map((model) => modelOptionName(model).trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function uniqueModelOptions(models: string[]) {
|
||||
return Array.from(new Set((models || []).map((model) => model.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
|
||||
import type { CanvasAgentOp } from "@/lib/canvas/canvas-agent-ops";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasConnection, CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
|
||||
import type { CanvasResourceKind } from "@/lib/canvas/canvas-resource-references";
|
||||
|
||||
// 插件节点作为上游输入被消费时输出的资源
|
||||
export type CanvasNodeResource = { kind: CanvasResourceKind; text?: string; url?: string };
|
||||
|
||||
// 节点自带的工具栏按钮(追加到 hover 工具栏尾部)
|
||||
export type CanvasNodeToolbarItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
// 每个节点渲染时注入的上下文,是插件与画布交互的核心接口
|
||||
export type CanvasNodeContext = {
|
||||
node: CanvasNodeData;
|
||||
theme: CanvasTheme;
|
||||
scale: number;
|
||||
// 自身数据
|
||||
updateMetadata: (patch: CanvasNodeMetadata) => void;
|
||||
updateNode: (patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
|
||||
// 图访问
|
||||
getNode: (id: string) => CanvasNodeData | null;
|
||||
getNodes: () => CanvasNodeData[];
|
||||
getConnections: () => CanvasConnection[];
|
||||
getUpstream: () => CanvasNodeData[];
|
||||
getDownstream: () => CanvasNodeData[];
|
||||
// 画布操作,复用 Agent 指令集(增删节点/连线/选择/视口/触发生成)
|
||||
applyOps: (ops: CanvasAgentOp[]) => void;
|
||||
// 节点间/插件间通信
|
||||
emit: (event: string, payload?: unknown) => void;
|
||||
on: (event: string, handler: (payload: unknown) => void) => () => void;
|
||||
// 插件私有持久化,命名空间隔离
|
||||
storage: PluginStorage;
|
||||
};
|
||||
|
||||
export type PluginStorage = {
|
||||
get: <T = unknown>(key: string) => Promise<T | null>;
|
||||
set: (key: string, value: unknown) => Promise<void>;
|
||||
remove: (key: string) => Promise<void>;
|
||||
};
|
||||
|
||||
// 画布宿主提供的、与具体节点无关的能力集合(由画布页面构建、注入渲染链路)
|
||||
export type CanvasPluginHost = {
|
||||
getNode: (id: string) => CanvasNodeData | null;
|
||||
getNodes: () => CanvasNodeData[];
|
||||
getConnections: () => CanvasConnection[];
|
||||
getUpstream: (nodeId: string) => CanvasNodeData[];
|
||||
getDownstream: (nodeId: string) => CanvasNodeData[];
|
||||
updateNode: (nodeId: string, patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
|
||||
updateMetadata: (nodeId: string, patch: CanvasNodeMetadata) => void;
|
||||
applyOps: (ops: CanvasAgentOp[]) => void;
|
||||
};
|
||||
|
||||
// 节点类型定义:内置节点与插件节点统一走这套结构
|
||||
export type CanvasNodeDefinition = {
|
||||
type: string; // 内置如 "image";插件建议 "<pluginId>:<name>"
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
description?: string;
|
||||
defaultSize: { width: number; height: number };
|
||||
defaultMetadata?: CanvasNodeMetadata;
|
||||
minimapColor?: string;
|
||||
showInCreateMenu?: boolean; // 默认 true
|
||||
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
|
||||
keepAspectRatio?: (node: CanvasNodeData) => boolean;
|
||||
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
|
||||
// 渲染:内置节点由 canvas-node 内部渲染器负责,可不提供 Content
|
||||
Content?: ComponentType<{ ctx: CanvasNodeContext }>;
|
||||
Panel?: ComponentType<{ ctx: CanvasNodeContext; onClose: () => void }>;
|
||||
toolbar?: (ctx: CanvasNodeContext) => CanvasNodeToolbarItem[];
|
||||
onDoubleClick?: (ctx: CanvasNodeContext) => boolean; // 返回 true 表示已处理
|
||||
};
|
||||
|
||||
// 插件启动时可访问的应用能力
|
||||
export type CanvasPluginApp = {
|
||||
version: string;
|
||||
emit: (event: string, payload?: unknown) => void;
|
||||
on: (event: string, handler: (payload: unknown) => void) => () => void;
|
||||
// 注入插件样式,返回移除函数;传 key 时同 key 覆盖旧样式
|
||||
injectCSS: (css: string, key?: string) => () => void;
|
||||
};
|
||||
|
||||
// 插件包默认导出
|
||||
export type CanvasPlugin = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
minAppVersion?: string;
|
||||
css?: string; // 插件样式,启用时自动注入、卸载/禁用时自动清理
|
||||
nodes: CanvasNodeDefinition[];
|
||||
setup?: (app: CanvasPluginApp) => void | (() => void);
|
||||
};
|
||||
@@ -18,6 +18,9 @@ export enum CanvasNodeType {
|
||||
Group = "group",
|
||||
}
|
||||
|
||||
// 节点类型放开为字符串,内置类型用 CanvasNodeType,插件类型为 "<pluginId>:<name>"
|
||||
export type CanvasNodeTypeId = CanvasNodeType | (string & {});
|
||||
|
||||
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
|
||||
export type CanvasGenerationMode = "text" | "image" | "video" | "audio";
|
||||
export type CanvasImageGenerationType = "generation" | "edit";
|
||||
@@ -62,7 +65,7 @@ export type CanvasNodeMetadata = {
|
||||
|
||||
export type CanvasNodeData = {
|
||||
id: string;
|
||||
type: CanvasNodeType;
|
||||
type: CanvasNodeTypeId;
|
||||
title: string;
|
||||
position: Position;
|
||||
width: number;
|
||||
@@ -78,7 +81,7 @@ export type CanvasConnection = {
|
||||
|
||||
export type CanvasAssistantReference = {
|
||||
id: string;
|
||||
type: CanvasNodeType;
|
||||
type: CanvasNodeTypeId;
|
||||
title: string;
|
||||
dataUrl?: string;
|
||||
storageKey?: string;
|
||||
|
||||
Vendored
+5
@@ -2,3 +2,8 @@
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
declare const __APP_RELEASES__: import("@/lib/release").ReleaseInfo[];
|
||||
|
||||
interface ImportMetaEnv {
|
||||
// 逗号分隔的本地开发插件 URL,每次启动重新拉取(不缓存、不落库)
|
||||
readonly VITE_DEV_PLUGINS?: string;
|
||||
}
|
||||
|
||||
+31
-3
@@ -1,8 +1,8 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
|
||||
import { parseChangelog } from "./src/lib/release";
|
||||
|
||||
@@ -10,9 +10,37 @@ const webDir = dirname(fileURLToPath(import.meta.url));
|
||||
const localVersion = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim() || "dev";
|
||||
const localChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8");
|
||||
|
||||
// 暴露 /plugins/index.json:列出 public/plugins 下的本地插件文件,
|
||||
// 供前端自动发现并加入插件列表(默认关闭)。dev 下实时读目录,构建时产出静态清单。
|
||||
function localPluginsManifest(): Plugin {
|
||||
const pluginsDir = resolve(webDir, "public/plugins");
|
||||
const listLocalPlugins = () => {
|
||||
try {
|
||||
return readdirSync(pluginsDir)
|
||||
.filter((file) => file.endsWith(".js"))
|
||||
.sort()
|
||||
.map((file) => `/plugins/${file}`);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
return {
|
||||
name: "local-plugins-manifest",
|
||||
configureServer(server) {
|
||||
server.middlewares.use("/plugins/index.json", (_req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(listLocalPlugins()));
|
||||
});
|
||||
},
|
||||
generateBundle() {
|
||||
this.emitFile({ type: "asset", fileName: "plugins/index.json", source: JSON.stringify(listLocalPlugins()) });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
base: process.env.VITE_BASE || "/",
|
||||
plugins: [react()],
|
||||
plugins: [react(), localPluginsManifest()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(webDir, "src"),
|
||||
|
||||
Reference in New Issue
Block a user