diff --git a/frontend/ai_ui_mockups_wip.html b/frontend/ai_ui_mockups_wip.html new file mode 100644 index 0000000..aa20b0c --- /dev/null +++ b/frontend/ai_ui_mockups_wip.html @@ -0,0 +1,182 @@ + + + + + AI UI Brainstorming Prototypes + + + + + + + + + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8eb0dc7..79a1fb4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,11 +14,15 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.6.0", + "@types/react-syntax-highlighter": "^15.5.13", "antd": "^5.12.0", "clsx": "^2.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-markdown": "^10.1.0", "react-resizable": "^3.1.3", + "react-syntax-highlighter": "^16.1.1", + "remark-gfm": "^4.0.1", "sql-formatter": "^15.7.0", "uuid": "^9.0.1", "zustand": "^4.4.7" @@ -1525,6 +1529,15 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1536,21 +1549,57 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", "license": "MIT" }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1577,6 +1626,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1585,6 +1643,12 @@ "optional": true, "peer": true }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", @@ -1592,6 +1656,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1809,6 +1879,16 @@ "node": ">=12" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -1884,6 +1964,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", @@ -1901,6 +1991,46 @@ "node": ">=18" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz", @@ -1926,6 +2056,16 @@ "node": ">=6" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -1970,7 +2110,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1984,6 +2123,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1994,6 +2146,28 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/discontinuous-range": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", @@ -2073,6 +2247,28 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2093,6 +2289,25 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", @@ -2111,6 +2326,14 @@ } } }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2136,6 +2359,163 @@ "node": ">=6.9.0" } }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2177,6 +2557,16 @@ "node": ">=6" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2196,6 +2586,20 @@ "dev": true, "license": "MIT" }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2216,6 +2620,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/marked": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", @@ -2229,6 +2643,839 @@ "node": ">= 18" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "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" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "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" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/monaco-editor": { "version": "0.55.1", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", @@ -2250,7 +3497,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2310,6 +3556,31 @@ "node": ">=0.10.0" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", @@ -2376,6 +3647,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -2393,6 +3673,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/railroad-diagrams": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", @@ -3063,6 +4353,33 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3087,6 +4404,108 @@ "react-dom": ">= 16.3" } }, + "node_modules/react-syntax-highlighter": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -3192,6 +4611,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/sql-formatter": { "version": "15.7.0", "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.7.0.tgz", @@ -3231,6 +4660,20 @@ "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", "license": "MIT" }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz", @@ -3251,6 +4694,24 @@ "dev": true, "license": "MIT" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/stylis": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", @@ -3333,6 +4794,26 @@ "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "license": "MIT" }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3353,6 +4834,93 @@ "node": ">=14.17" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3406,6 +4974,34 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -3613,6 +5209,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 52dbb4a..ff742c6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,11 +16,15 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.6.0", + "@types/react-syntax-highlighter": "^15.5.13", "antd": "^5.12.0", "clsx": "^2.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-markdown": "^10.1.0", "react-resizable": "^3.1.3", + "react-syntax-highlighter": "^16.1.1", + "remark-gfm": "^4.0.1", "sql-formatter": "^15.7.0", "uuid": "^9.0.1", "zustand": "^4.4.7" diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 24d10b6..07c3ca0 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -0f60775ad0a6b251a4320748f196a712 \ No newline at end of file +30f0a7ce75c113ec7a46f3b09f9a37f7 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 364b4b3..ef98756 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd'; +import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Tooltip } from 'antd'; import zhCN from 'antd/locale/zh_CN'; -import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined } from '@ant-design/icons'; +import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined } from '@ant-design/icons'; import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime'; import Sidebar from './components/Sidebar'; import TabManager from './components/TabManager'; @@ -9,6 +9,8 @@ import ConnectionModal from './components/ConnectionModal'; import DataSyncModal from './components/DataSyncModal'; import DriverManagerModal from './components/DriverManagerModal'; import LogPanel from './components/LogPanel'; +import AIChatPanel from './components/AIChatPanel'; +import AISettingsModal from './components/AISettingsModal'; import { useStore } from './store'; import { SavedConnection } from './types'; import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance'; @@ -92,6 +94,9 @@ function App() { const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated()); const sidebarWidth = useStore(state => state.sidebarWidth); const setSidebarWidth = useStore(state => state.setSidebarWidth); + const aiPanelVisible = useStore(state => state.aiPanelVisible); + const toggleAIPanel = useStore(state => state.toggleAIPanel); + const setAIPanelVisible = useStore(state => state.setAIPanelVisible); const globalProxyInvalidHintShownRef = React.useRef(false); // 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView, @@ -1108,6 +1113,7 @@ function App() { const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false); const [capturingShortcutAction, setCapturingShortcutAction] = useState(null); const [isProxyModalOpen, setIsProxyModalOpen] = useState(false); + const [isAISettingsOpen, setIsAISettingsOpen] = useState(false); // Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制 @@ -1620,11 +1626,24 @@ function App() { >
-
- - - - +
+
@@ -1638,6 +1657,7 @@ function App() {
+
@@ -1696,9 +1716,14 @@ function App() { title="拖动调整宽度" /> - -
- + +
+
+ +
+ {aiPanelVisible && ( + setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} /> + )}
{isLogPanelOpen && ( setIsDriverModalOpen(false)} onOpenGlobalProxySettings={() => setIsProxyModalOpen(true)} /> + setIsAISettingsOpen(false)} + darkMode={darkMode} + overlayTheme={overlayTheme} + /> , '关于 GoNavi', '查看版本信息、仓库地址、更新状态与下载入口。')} open={isAboutOpen} diff --git a/frontend/src/components/AIChatPanel.css b/frontend/src/components/AIChatPanel.css new file mode 100644 index 0000000..08ebc1c --- /dev/null +++ b/frontend/src/components/AIChatPanel.css @@ -0,0 +1,366 @@ +.ai-chat-panel { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + border-left: 1px solid rgba(128, 128, 128, 0.12); + position: relative; +} + +/* Resize Handle */ +.ai-resize-handle { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + cursor: col-resize; + z-index: 10; + transition: background 0.15s ease; +} + +.ai-resize-handle:hover, +.ai-resize-handle.active { + background: rgba(22, 119, 255, 0.5); +} + +/* Header */ +.ai-chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid rgba(128, 128, 128, 0.1); + flex-shrink: 0; +} + +.ai-chat-header-left { + display: flex; + align-items: center; + gap: 10px; +} + +.ai-chat-header-left .ai-logo { + width: 28px; + height: 28px; + border-radius: 8px; + display: grid; + place-items: center; + font-size: 16px; + font-weight: 700; + flex-shrink: 0; +} + +.ai-chat-header-left .ai-title { + font-size: 14px; + font-weight: 700; + letter-spacing: 0.01em; +} + +.ai-chat-header-right { + display: flex; + align-items: center; + gap: 4px; +} + +/* Messages Area */ +.ai-chat-messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.ai-chat-messages::-webkit-scrollbar { + width: 5px; +} + +.ai-chat-messages::-webkit-scrollbar-track { + background: transparent; +} + +.ai-chat-messages::-webkit-scrollbar-thumb { + background: rgba(128, 128, 128, 0.3); + border-radius: 3px; +} + +/* Welcome */ +.ai-chat-welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 40px 20px; + text-align: center; + flex: 1; +} + +.ai-chat-welcome .welcome-icon { + width: 56px; + height: 56px; + border-radius: 16px; + display: grid; + place-items: center; + font-size: 28px; +} + +.ai-chat-welcome .welcome-title { + font-size: 18px; + font-weight: 700; + margin-bottom: 4px; +} + +.ai-chat-welcome .quick-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + margin-top: 8px; +} + +.ai-chat-welcome .quick-action-btn { + padding: 6px 14px; + border-radius: 20px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid; +} + +.ai-chat-welcome .quick-action-btn:hover { + background: rgba(99, 102, 241, 0.12) !important; + border-color: rgba(99, 102, 241, 0.3) !important; + color: #818cf8 !important; +} + +/* IDE Style Messages */ +.ai-ide-message { + padding: 12px 16px; + animation: ai-msg-in 0.2s ease-out; +} + +@keyframes ai-msg-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.ai-ide-message-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.ai-ide-message-content { + font-size: 13px; + line-height: 1.6; + word-break: break-word; + /* Remove pre-wrap here, as it conflicts with ReactMarkdown's block rendering */ +} + +/* Markdown Styles Override */ +.ai-markdown-content { + white-space: normal; +} +.ai-markdown-content p { + margin: 0 0 10px; +} +.ai-markdown-content p:last-child { + margin-bottom: 0; +} +.ai-markdown-content h1, +.ai-markdown-content h2, +.ai-markdown-content h3, +.ai-markdown-content h4, +.ai-markdown-content h5, +.ai-markdown-content h6 { + margin: 16px 0 8px; + line-height: 1.4; + font-weight: 600; +} +.ai-markdown-content h1:first-child, +.ai-markdown-content h2:first-child, +.ai-markdown-content h3:first-child, +.ai-markdown-content h4:first-child, +.ai-markdown-content h5:first-child, +.ai-markdown-content h6:first-child { + margin-top: 0; +} +.ai-markdown-content pre { + margin: 10px 0; + border-radius: 4px; + padding: 10px; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + font-size: 12px; + overflow-x: auto; + border: 1px solid rgba(128, 128, 128, 0.15); + background: rgba(0, 0, 0, 0.2); +} +.ai-markdown-content code { + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + background: rgba(128, 128, 128, 0.15); + padding: 2px 4px; + border-radius: 3px; + font-size: 0.95em; +} +.ai-markdown-content ul, .ai-markdown-content ol { + margin: 0 0 10px; + padding-left: 20px; +} +.ai-markdown-content li { + margin-bottom: 4px; +} + +/* Advanced Typing/Blinker indicator */ +.ai-blinking-cursor { + display: inline-block; + width: 6px; + height: 14px; + background-color: currentColor; + border-radius: 1px; + vertical-align: middle; + margin-left: 4px; + animation: blink 1s step-end infinite; +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +@keyframes ai-dot-bounce { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } +} + +/* History Drawer Styles */ +.ai-history-list::-webkit-scrollbar { + width: 4px; +} +.ai-history-list::-webkit-scrollbar-thumb { + background: rgba(128, 128, 128, 0.2); + border-radius: 4px; +} +.ai-history-list:hover::-webkit-scrollbar-thumb { + background: rgba(128, 128, 128, 0.4); +} + +.ai-history-item:hover { + background: rgba(128, 128, 128, 0.08) !important; +} + +.ai-history-item .ai-history-delete-btn { + opacity: 0; + transition: opacity 0.2s, background 0.2s; +} + +.ai-history-item:hover .ai-history-delete-btn, +.ai-history-item.active .ai-history-delete-btn { + opacity: 1; +} + +/* Input Area */ +.ai-chat-input-area { + padding: 12px 16px 16px; + border-top: 1px solid rgba(128, 128, 128, 0.1); + flex-shrink: 0; +} + +/* Textarea scrollbar */ +.ai-chat-input-wrapper textarea { + scrollbar-width: thin; + scrollbar-color: rgba(128, 128, 128, 0.3) transparent; +} + +.ai-chat-input-wrapper textarea::-webkit-scrollbar { + width: 4px; +} + +.ai-chat-input-wrapper textarea::-webkit-scrollbar-track { + background: transparent; +} + +.ai-chat-input-wrapper textarea::-webkit-scrollbar-thumb { + background: rgba(128, 128, 128, 0.3); + border-radius: 2px; +} + +.ai-chat-input-wrapper { + display: flex; + align-items: flex-end; + gap: 8px; + border-radius: 6px; + border: 1px solid transparent; + border-bottom-color: rgba(128, 128, 128, 0.4); + padding: 6px 10px; + transition: all 0.2s ease; + background: transparent !important; + box-shadow: none !important; +} + +.ai-chat-input-wrapper:focus-within { + border-color: var(--ant-primary-color, #1677ff) !important; + background: rgba(128, 128, 128, 0.05) !important; +} + +.ai-chat-input-wrapper textarea { + width: 100%; + border: none; + outline: none; + background: transparent; + resize: none; + font-size: 13px; + line-height: 1.5; + min-height: 28px; + max-height: 200px; + padding: 0; + font-family: inherit; + overflow-y: auto; +} + +.ai-chat-input-wrapper textarea::placeholder { + opacity: 0.4; +} + +.ai-chat-send-btn { + width: 26px; + height: 26px; + border-radius: 4px; + display: grid; + place-items: center; + border: none; + cursor: pointer; + flex-shrink: 0; + transition: transform 0.15s ease, opacity 0.15s ease; +} + +.ai-chat-send-btn:hover { + transform: scale(1.06); +} + +.ai-chat-send-btn:active { + transform: scale(0.96); +} + +.ai-chat-send-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +.ai-ide-message:hover .ai-message-actions { + opacity: 1 !important; +} diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx new file mode 100644 index 0000000..1f4cc40 --- /dev/null +++ b/frontend/src/components/AIChatPanel.tsx @@ -0,0 +1,848 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { Button, Tooltip, Select, Drawer, Input } from 'antd'; +import { CloseOutlined, ClearOutlined, SendOutlined, RobotOutlined, SettingOutlined, UserOutlined, CheckOutlined, CopyOutlined, DatabaseOutlined, HistoryOutlined, DeleteOutlined, PlusOutlined, MenuFoldOutlined, PlayCircleOutlined, EditOutlined, ReloadOutlined, DownOutlined } from '@ant-design/icons'; +import { useStore } from '../store'; +import { AIChatMessage } from '../types'; +import { EventsOn, EventsOff } from '../../wailsjs/runtime'; +import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import './AIChatPanel.css'; + +interface AIChatPanelProps { + width?: number; + darkMode: boolean; + bgColor?: string; + onClose: () => void; + onOpenSettings?: () => void; + onWidthChange?: (width: number) => void; + overlayTheme: OverlayWorkbenchTheme; +} + +const genId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + +const CodeCopyBtn = ({ text }: { text: string }) => { + const [copied, setCopied] = useState(false); + return ( + { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }} + style={{ + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + opacity: copied ? 1 : 0.6, + transition: 'opacity 0.2s', + }} + onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }} + onMouseLeave={(e) => { e.currentTarget.style.opacity = copied ? '1' : '0.6'; }} + > + {copied ? : } + {copied ? '已复制' : '复制代码'} + + ); +}; + +const CodeRunBtn = ({ text }: { text: string }) => { + return ( + + { + window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: { sql: text, runImmediately: false } })); + }} + style={{ + cursor: 'pointer', display: 'flex', alignItems: 'center', + opacity: 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#10b981' + }} + onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }} + onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }} + > + + 插入 + + + ); +}; + +export const AIChatPanel: React.FC = ({ width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme }) => { + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const [activeProvider, setActiveProvider] = useState(null); + const [dynamicModels, setDynamicModels] = useState([]); + const [showScrollBottom, setShowScrollBottom] = useState(false); + const [loadingModels, setLoadingModels] = useState(false); + const [panelWidth, setPanelWidth] = useState(width); + const [isResizing, setIsResizing] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + const resizeStartX = useRef(0); + const resizeStartWidth = useRef(0); + + const aiChatHistory = useStore(state => state.aiChatHistory); + const aiChatSessions = useStore(state => state.aiChatSessions); + const aiActiveSessionId = useStore(state => state.aiActiveSessionId); + const setAIActiveSessionId = useStore(state => state.setAIActiveSessionId); + const createNewAISession = useStore(state => state.createNewAISession); + const deleteAISession = useStore(state => state.deleteAISession); + + const addAIChatMessage = useStore(state => state.addAIChatMessage); + const updateAIChatMessage = useStore(state => state.updateAIChatMessage); + const deleteAIChatMessage = useStore(state => state.deleteAIChatMessage); + const truncateAIChatMessages = useStore(state => state.truncateAIChatMessages); + const clearAIChatHistory = useStore(state => state.clearAIChatHistory); + const activeContext = useStore(state => state.activeContext); + const connections = useStore(state => state.connections); + + useEffect(() => { + if (!aiActiveSessionId) { + createNewAISession(); + } + }, [aiActiveSessionId, createNewAISession]); + + const sid = aiActiveSessionId || 'session-fallback'; + + const getConnectionName = useCallback(() => { + if (!activeContext?.connectionId) return ''; + const conn = connections.find(c => c.id === activeContext.connectionId); + return conn ? conn.name : ''; + }, [activeContext, connections]); + + const activeConnName = getConnectionName(); + + const messages = aiChatHistory[sid] || []; + + // 主题色 + const textColor = overlayTheme.titleText; + const mutedColor = overlayTheme.mutedText; + const borderColor = overlayTheme.divider; + const assistantBubbleBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'; + const userBubbleBg = overlayTheme.iconBg; + const inputWrapperBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.8)'; + const quickActionBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.8)'; + const quickActionBorder = overlayTheme.sectionBorder; + + // 获取并监听活动 Provider + const loadActiveProvider = useCallback(async () => { + try { + const Service = (window as any).go?.aiservice?.Service; + if (!Service) return; + const [provRes, activeRes] = await Promise.all([ + Service.AIGetProviders?.(), + Service.AIGetActiveProvider?.(), + ]); + if (Array.isArray(provRes) && activeRes) { + const current = provRes.find((p: any) => p.id === activeRes); + setActiveProvider(current || null); + } + } catch (e) { console.warn('Failed to load active provider', e); } + }, []); + + useEffect(() => { loadActiveProvider(); }, [loadActiveProvider]); + + // 模型切换 + const handleModelChange = async (val: string) => { + if (!activeProvider) return; + try { + const Service = (window as any).go?.aiservice?.Service; + const payload = { ...activeProvider, model: val }; + await Service?.AISaveProvider?.(payload); + setActiveProvider(payload); + } catch (e) { console.warn('Failed to update provider model', e); } + }; + + // 动态获取模型列表 + const fetchDynamicModels = useCallback(async () => { + try { + setLoadingModels(true); + const Service = (window as any).go?.aiservice?.Service; + if (!Service) return; + const result = await Service.AIListModels?.(); + if (result?.success && Array.isArray(result.models) && result.models.length > 0) { + console.log('[AI Chat] Dynamic models fetched:', result.models.length, 'models. First 10:', result.models.slice(0, 10)); + setDynamicModels(result.models); + } + } catch (e) { + console.warn('Failed to fetch models', e); + } finally { + setLoadingModels(false); + } + }, []); + + // 自动滚动到底部(增加对发送状态的判定,实现完美跟随) + useEffect(() => { + if (sending) { + // 流式输出期间,改用 auto 避免动画累加导致的卡顿漂移 + messagesEndRef.current?.scrollIntoView({ behavior: 'auto', block: 'end' }); + } else { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } + }, [messages, sending]); + + // 面板初次打开时,自动聚焦输入框 + useEffect(() => { + const timer = setTimeout(() => { + textareaRef.current?.focus(); + }, 100); + return () => clearTimeout(timer); + }, []); + + // 监听从 QueryEditor 注入的 prompt + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.prompt) { + setInput(detail.prompt); + // 自动聚焦输入框并调整高度(setInput 不触发 onChange,需手动重算) + setTimeout(() => { + const el = textareaRef.current; + if (el) { + el.focus(); + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 200) + 'px'; + } + }, 50); + } + }; + window.addEventListener('gonavi:ai:inject-prompt', handler); + return () => window.removeEventListener('gonavi:ai:inject-prompt', handler); + }, []); + + // 流式监听 + useEffect(() => { + const eventName = `ai:stream:${sid}`; + let assistantMsgId = ''; + + const handler = (data: { content?: string; done?: boolean; error?: string }) => { + console.log('[AI Chat] Stream event received:', JSON.stringify(data)); + if (data.error) { + if (assistantMsgId) { + updateAIChatMessage(sid, assistantMsgId, { + content: `❌ 错误: ${data.error}`, + loading: false, + }); + } else { + // 尚未创建 assistant 消息时,新建一条错误消息 + addAIChatMessage(sid, { + id: genId(), + role: 'assistant', + content: `❌ 错误: ${data.error}`, + timestamp: Date.now(), + }); + } + assistantMsgId = ''; + setSending(false); + return; + } + + if (data.content) { + if (!assistantMsgId) { + assistantMsgId = genId(); + addAIChatMessage(sid, { + id: assistantMsgId, + role: 'assistant', + content: data.content, + timestamp: Date.now(), + loading: true, + }); + } else { + const current = useStore.getState().aiChatHistory[sid]; + const existing = current?.find(m => m.id === assistantMsgId); + updateAIChatMessage(sid, assistantMsgId, { + content: (existing?.content || '') + data.content, + }); + } + } + + if (data.done) { + if (assistantMsgId) { + updateAIChatMessage(sid, assistantMsgId, { loading: false }); + } + assistantMsgId = ''; + setSending(false); + } + }; + + EventsOn(eventName, handler); + console.log('[AI Chat] Listening on event:', eventName); + return () => { + EventsOff(eventName); + }; + }, [addAIChatMessage, updateAIChatMessage, sid]); + + // ---- 列表滚动逻辑 ---- + const handleScrollMessages = useCallback((e: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + const isNearBottom = scrollHeight - scrollTop - clientHeight < 150; + setShowScrollBottom(!isNearBottom); + }, []); + + const scrollToMessagesBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, []); + + // ---- 气泡快捷操作 ---- + const handleEditMessage = useCallback((msg: AIChatMessage) => { + truncateAIChatMessages(sid, msg.id); + deleteAIChatMessage(sid, msg.id); + setInput(msg.content); + setTimeout(() => textareaRef.current?.focus(), 50); + }, [sid, truncateAIChatMessages, deleteAIChatMessage]); + + const handleRetryMessage = useCallback(async (msg: AIChatMessage) => { + const historyLocal = useStore.getState().aiChatHistory[sid] || []; + const aiIndex = historyLocal.findIndex(m => m.id === msg.id); + if (aiIndex <= 0) return; + + let lastUserMsgIndex = -1; + for (let i = aiIndex - 1; i >= 0; i--) { + if (historyLocal[i].role === 'user') { + lastUserMsgIndex = i; + break; + } + } + + if (lastUserMsgIndex >= 0) { + const userMsg = historyLocal[lastUserMsgIndex]; + truncateAIChatMessages(sid, userMsg.id); // 保留到该 userInput 后,丢弃之前生成的失败回复 + setSending(true); + const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1); + const messagesPayload = truncatedHistory.map(m => ({ role: m.role, content: m.content })); + + try { + const Service = (window as any).go?.aiservice?.Service; + if (Service?.AIChatStream) { + await Service.AIChatStream(sid, messagesPayload); + } else if (Service?.AIChatSend) { + const result = await Service.AIChatSend(messagesPayload); + addAIChatMessage(sid, { + id: genId(), role: 'assistant', + content: result?.success ? result.content : `❌ ${result?.error || '未知错误'}`, + timestamp: Date.now() + }); + setSending(false); + } else { + setSending(false); + } + } catch(e: any) { + addAIChatMessage(sid, { id: genId(), role: 'assistant', content: `❌ 发送失败: ${e?.message || e}`, timestamp: Date.now() }); + setSending(false); + } + } + }, [sid, truncateAIChatMessages, addAIChatMessage]); + + const handleSend = useCallback(async () => { + const text = input.trim(); + if (!text || sending) return; + + setInput(''); + setSending(true); + + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; // 回车发送后重置高度 + textareaRef.current.focus(); // 保持焦点以便连续对话 + } + + const userMsg: AIChatMessage = { + id: genId(), + role: 'user', + content: text, + timestamp: Date.now(), + }; + addAIChatMessage(sid, userMsg); + + // 构建消息列表发给后端 + const allMessages = [...messages, userMsg].map(m => ({ + role: m.role, + content: m.content, + })); + + try { + const Service = (window as any).go?.aiservice?.Service; + if (Service?.AIChatStream) { + console.log('[AI Chat] Calling AIChatStream, sessionId:', sid, 'messages:', allMessages.length); + await Service.AIChatStream(sid, allMessages); + } else if (Service?.AIChatSend) { + const result = await Service.AIChatSend(allMessages); + + const assistantMsg: AIChatMessage = { + id: genId(), + role: 'assistant', + content: result?.success ? result.content : `❌ ${result?.error || '未知错误'}`, + timestamp: Date.now(), + }; + addAIChatMessage(sid, assistantMsg); + setSending(false); + } else { + const assistantMsg: AIChatMessage = { + id: genId(), + role: 'assistant', + content: '❌ AI Service 未就绪', + timestamp: Date.now(), + }; + addAIChatMessage(sid, assistantMsg); + setSending(false); + } + } catch (e: any) { + const errMsg: AIChatMessage = { + id: genId(), + role: 'assistant', + content: `❌ 发送失败: ${e?.message || e}`, + timestamp: Date.now(), + }; + addAIChatMessage(sid, errMsg); + setSending(false); + } + }, [input, sending, messages, addAIChatMessage, sid]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, [handleSend]); + + const handleStop = useCallback(async () => { + try { + const Service = (window as any).go?.aiservice?.Service; + if (Service?.AIChatCancel) { + await Service.AIChatCancel(sid); + } + } catch (e) { + console.warn('Failed to stop chat stream', e); + } + setSending(false); + }, [sid]); + + const handleClear = useCallback(() => { + createNewAISession(); + }, [createNewAISession]); + + const handleInput = useCallback((e: React.ChangeEvent) => { + setInput(e.target.value); + const el = e.target; + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 200) + 'px'; + }, []); + + const quickActions = [ + { label: '📝 生成 SQL', prompt: '请根据当前数据库表结构生成一条查询语句:' }, + { label: '🔍 解释 SQL', prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n\n```' }, + { label: '⚡ 优化建议', prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n\n```' }, + { label: '🏗️ Schema 分析', prompt: '请分析当前数据库的表结构并给出优化建议。' }, + ]; + // ---- 拖拽调整宽度 ---- + const handleResizeStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + resizeStartX.current = e.clientX; + resizeStartWidth.current = panelWidth; + }, [panelWidth]); + + useEffect(() => { + if (!isResizing) return; + const handleMouseMove = (e: MouseEvent) => { + // 面板在右侧,鼠标向左移动增大宽度 + const delta = resizeStartX.current - e.clientX; + const newWidth = Math.min(Math.max(resizeStartWidth.current + delta, 280), 700); + setPanelWidth(newWidth); + onWidthChange?.(newWidth); + }; + const handleMouseUp = () => { + setIsResizing(false); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [isResizing, onWidthChange]); + + return ( +
+ {/* 拖拽手柄 */} +
+ {/* Header */} +
+
+ +
+
+ +
+
+ + {/* Messages */} +
+ {messages.length === 0 ? ( +
+
+ + 你好,我是 GoNavi AI +
+
+ 我是你的智能数据库助手。我可以帮你生成 SQL 查询、分析表结构、解释执行逻辑以及优化数据库性能。 +
+
+ {quickActions.map(action => ( +
setInput(action.prompt)} + > + {action.label} +
+ ))} +
+
+ ) : ( + messages.map(msg => { + const isUser = msg.role === 'user'; + return ( +
+
+
+
+ {isUser + ? <> You + : <> GoNavi AI} +
+ {/* 气泡操作栏 */} +
+ {isUser ? ( + + handleEditMessage(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} /> + + ) : ( + + handleRetryMessage(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} /> + + )} + + deleteAIChatMessage(sid, msg.id)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} /> + +
+
+
+ {isUser ? ( +
{msg.content}
+ ) : ( + +
+ {match[1]} +
+ {match[1] === 'sql' && } + +
+
+ + {String(children).replace(/\n$/, '')} + +
+ ) : ( + + {children} + + ); + } + }} + > + {msg.content} + + )} + {msg.loading && ( + + )} +
+
+
+ ); + }) + )} + {sending && !messages.some(m => m.role === 'assistant' && m.loading) && ( +
+
+ 等待回复 + + + + + +
+
+ )} +
+
+ + {/* Scroll to bottom button */} + {showScrollBottom && ( +
{ e.currentTarget.style.transform = 'scale(1.1)'; e.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)'; }} + onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)'; }} + > + +
+ )} + + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={handleKeyDown as any} + placeholder="输入消息... (Enter 发送,Shift+Enter 换行)" + variant="borderless" + autoSize={{ minRows: 1, maxRows: 8 }} + style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }} + /> +
+
+ {activeConnName && ( + +
+ + + {activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''} + +
+
+ )} + + {activeProvider && ( + +
+ + {/* 基本信息 - 仅自定义/Ollama 显示 */} + {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( +
+
+ 基本信息 +
+ + 名称} + style={{ borderRadius: 10, background: inputBg }} /> + + + {presetKeyFromForm === 'custom' && ( + +
+ API 格式 +
+ {[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'claude-cli', label: 'Claude CLI (代理)' }].map(fmt => ( +
form.setFieldsValue({ apiFormat: fmt.value })} + style={{ + padding: '3px 12px', borderRadius: 6, fontSize: 11, fontWeight: 600, cursor: 'pointer', + border: `1.5px solid ${watchedApiFormat === fmt.value ? overlayTheme.selectedText : cardBorder}`, + background: watchedApiFormat === fmt.value ? overlayTheme.selectedBg : 'transparent', + color: watchedApiFormat === fmt.value ? overlayTheme.selectedText : overlayTheme.mutedText, + transition: 'all 0.2s ease', + }} + > + {fmt.label} +
+ ))} +
+
+
+ )} + +
+ + +
+ )} + + + + {/* 认证信息 */} +
+
+ 认证 & 连接 +
+ + Key} + style={{ borderRadius: 10, background: inputBg }} /> + + {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( + + URL} + suffix={} + style={{ borderRadius: 10, background: inputBg }} /> + + )} +
+ + + + {/* 操作按钮 */} +
+ + +
+ +
+ ); + }; + + // ===== 安全控制 ===== + const renderSafetySettings = () => ( +
+
+ 控制 AI 可执行的 SQL 操作类型,保护数据安全 +
+ {SAFETY_OPTIONS.map(opt => { + const active = safetyLevel === opt.value; + return ( +
handleSafetyChange(opt.value)} style={{ + padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease', + border: `1.5px solid ${active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.selectedText) : cardBorder}`, + background: active ? (opt.color === '#ef4444' ? `${opt.color}15` : overlayTheme.selectedBg) : cardBg, + display: 'flex', alignItems: 'flex-start', gap: 14, + }}> +
+ {opt.icon} +
+
+
+ {opt.label} + {active && } +
+
{opt.desc}
+
+
+ ); + })} +
+ ); + + // ===== 上下文级别 ===== + const renderContextSettings = () => ( +
+
+ 控制发送给 AI 的数据库上下文信息量 +
+ {CONTEXT_OPTIONS.map(opt => { + const active = contextLevel === opt.value; + return ( +
handleContextChange(opt.value)} style={{ + padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease', + border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`, + background: active ? overlayTheme.selectedBg : cardBg, + display: 'flex', alignItems: 'flex-start', gap: 14, + }}> +
+ {opt.icon} +
+
+
+ {opt.label} + {active && } +
+
{opt.desc}
+
+
+ ); + })} +
+ ); + + const renderBuiltinPrompts = () => ( +
+
+ 以下为当前版本 GoNavi 预设的底层 AI 提示词(只读)。它们会被动态注入到对应场景的请求上下文中。 +
+ {Object.entries(builtinPrompts).map(([title, promptText]) => ( +
+
+ {title} +
+
+ {promptText} +
+
+ ))} +
+ ); + + const tabItems = [ + { key: 'providers', label: Provider, children: isEditing ? renderProviderForm() : renderProviderList() }, + { key: 'safety', label: 安全控制, children: renderSafetySettings() }, + { key: 'context', label: 上下文, children: renderContextSettings() }, + { key: 'prompts', label: 内置提示词, children: renderBuiltinPrompts() }, + ]; + + const modalShellStyle = { + background: overlayTheme.shellBg, border: overlayTheme.shellBorder, + boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter, + }; + + return ( + +
+ +
+
+
AI 设置
+
+ 配置 AI 模型、安全级别和上下文选项 +
+
+
+ } + open={open} + onCancel={onClose} + footer={null} + width={540} + styles={{ + content: modalShellStyle, + header: { background: 'transparent', borderBottom: 'none', paddingBottom: 4 }, + body: { paddingTop: 0, height: 520, overflowY: 'auto', overflowX: 'hidden' }, + }} + > + + + ); +}; + +export default AISettingsModal; diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index bb3f353..0ec7de6 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -4,7 +4,7 @@ import { createPortal } from 'react-dom'; import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd'; import dayjs from 'dayjs'; import type { SortOrder, ColumnType } from 'antd/es/table/interface'; -import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'; +import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined } from '@ant-design/icons'; import Editor from '@monaco-editor/react'; import { DndContext, @@ -4642,6 +4642,43 @@ const DataGrid: React.FC = ({ )} + <> +
+ + + + + {isDuckDBConnection && onRequestTotalCount && ( <>
diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 5f66560..3bdb46e 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; import Editor, { OnMount } from '@monaco-editor/react'; import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd'; -import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined } from '@ant-design/icons'; +import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined } from '@ant-design/icons'; import { format } from 'sql-formatter'; import { v4 as uuidv4 } from 'uuid'; import { TabData, ColumnDefinition } from '../types'; @@ -202,8 +202,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { // Result Sets const [resultSets, setResultSets] = useState([]); const [activeResultKey, setActiveResultKey] = useState(''); - const [loading, setLoading] = useState(false); + const [executionError, setExecutionError] = useState(''); const [, setCurrentQueryId] = useState(''); const runSeqRef = useRef(0); const currentQueryIdRef = useRef(''); @@ -465,6 +465,36 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { // 应用透明主题(主题已在 main.tsx 全局注册) monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light'); + // 注册 AI 右键菜单操作 + const aiActions = [ + { id: 'ai.generateSQL', label: '🤖 AI 生成 SQL', prompt: '请根据当前数据库表结构生成查询语句:' }, + { id: 'ai.explainSQL', label: '🤖 AI 解释 SQL', useSelection: true, prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n{SQL}\n```' }, + { id: 'ai.optimizeSQL', label: '🤖 AI 优化 SQL', useSelection: true, prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n{SQL}\n```' }, + ]; + + aiActions.forEach(action => { + editor.addAction({ + id: action.id, + label: action.label, + contextMenuGroupId: '9_ai', + contextMenuOrder: 1, + run: (ed: any) => { + const selection = ed.getModel()?.getValueInRange(ed.getSelection()); + let prompt = action.prompt; + if (action.useSelection && selection) { + prompt = prompt.replace('{SQL}', selection); + } + // 打开 AI 面板并填入 prompt + const store = useStore.getState(); + if (!store.aiPanelVisible) { + store.setAIPanelVisible(true); + } + // 通过自定义事件将 prompt 发送到 AI 面板 + window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } })); + }, + }); + }); + // 全局只注册一次 SQL completion provider,避免多 tab 重复注册导致补全项重复 if (!sqlCompletionRegistered) { sqlCompletionRegistered = true; @@ -835,6 +865,25 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { } }; + const handleAIAction = (action: 'generate' | 'explain' | 'optimize' | 'schema') => { + const editor = editorRef.current; + const selection = editor?.getModel()?.getValueInRange(editor.getSelection()) || ''; + const fullSQL = getCurrentQuery(); + + const prompts: Record = { + generate: '请根据当前数据库表结构生成查询语句:', + explain: `请解释以下 SQL 语句的执行逻辑:\n\`\`\`sql\n${selection || fullSQL}\n\`\`\``, + optimize: `请分析以下 SQL 语句的性能并给出优化建议:\n\`\`\`sql\n${selection || fullSQL}\n\`\`\``, + schema: '请分析当前数据库的表结构并给出优化建议。', + }; + + const store = useStore.getState(); + if (!store.aiPanelVisible) { + store.setAIPanelVisible(true); + } + window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt: prompts[action] } })); + }; + const formatSettingsMenu: MenuProps['items'] = [ { key: 'upper', @@ -1430,9 +1479,10 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { // 清除旧查询ID clearQueryId(); } - const runSeq = ++runSeqRef.current; - setLoading(true); - const runStartTime = Date.now(); + const runSeq = ++runSeqRef.current; + setLoading(true); + setExecutionError(''); + const runStartTime = Date.now(); const conn = connections.find(c => c.id === currentConnectionId); if (!conn) { message.error("Connection not found"); @@ -1489,7 +1539,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { if (shellConvert.recognized) { if (shellConvert.error) { const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : ''; - message.error(prefix + shellConvert.error); + setExecutionError(prefix + shellConvert.error); setResultSets([]); setActiveResultKey(''); return; @@ -1522,7 +1572,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { }); if (!res.success) { const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : ''; - message.error(prefix + res.message); + setExecutionError(prefix + res.message); setResultSets([]); setActiveResultKey(''); return; @@ -1644,7 +1694,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { return; } - message.error(res.message); + setExecutionError(res.message); setResultSets([]); setActiveResultKey(''); return; @@ -1882,6 +1932,42 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { }; }, [activeTabId, tab.id, handleRun]); + // 监听并处理外部注入的 SQL 代码 (如 AI 面板) + useEffect(() => { + const handleInsertSql = (e: CustomEvent) => { + if (activeTabId !== tab.id || !e.detail?.sql) return; + const sqlText = e.detail.sql; + const editor = editorRef.current; + if (editor && (window as any).monaco) { + const position = editor.getPosition(); + if (position) { + const mText = (sqlText.endsWith('\n') ? sqlText : sqlText + '\n'); + const startRange = new (window as any).monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column); + + editor.executeEdits('ai-insert', [{ + range: startRange, + text: '\n' + mText, + forceMoveMarkers: true + }]); + editor.focus(); + + if (e.detail.runImmediately) { + const endPosition = editor.getPosition(); + editor.setSelection(new (window as any).monaco.Range( + position.lineNumber + 1, 1, + endPosition.lineNumber, endPosition.column + )); + setTimeout(() => handleRun(), 50); + } + } + } else { + setQuery((prev: string) => prev ? prev + '\n' + sqlText : sqlText); + } + }; + window.addEventListener('gonavi:insert-sql', handleInsertSql as EventListener); + return () => window.removeEventListener('gonavi:insert-sql', handleInsertSql as EventListener); + }, [activeTabId, tab.id, handleRun]); + const resolveDefaultQueryName = () => { const rawTitle = String(tab.title || '').trim(); if (!rawTitle || rawTitle.startsWith('新建查询')) { @@ -2067,6 +2153,16 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { +
@@ -2168,6 +2264,35 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { })() }))} /> + ) : executionError ? ( +
+
+ + 执行失败 +
+
+ {executionError} +
+
+ +
+
) : (
)} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d0f4e47..09524cd 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1455,6 +1455,14 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }; const onDoubleClick = (e: any, node: any) => { + // 保证用户直接双击节点未触发 onClick/onSelect 时也能强行拿到选中状态 + const { type, dataRef, key: nodeKey, title } = node; + if (type === 'connection') setActiveContext({ connectionId: nodeKey, dbName: '' }); + else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: title }); + else if (type === 'table' || type === 'view' || type === 'db-trigger' || type === 'routine') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName }); + else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName }); + else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` }); + if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') { const { id, dbName, schemaName } = node.dataRef; addTab({ @@ -3702,117 +3710,87 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return (
-
-
- - - - - - -
+ + + {searchScopes.includes('smart') ? '智' : searchScopes.length} + +
+ + + } + />
{/* Toolbar */} -
- - - - +
+ +
diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index 4a93783..f1d641d 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -138,6 +138,7 @@ const TableOverview: React.FC = ({ tab }) => { const connections = useStore(state => state.connections); const theme = useStore(state => state.theme); const addTab = useStore(state => state.addTab); + const setActiveContext = useStore(state => state.setActiveContext); const darkMode = theme === 'dark'; const [tables, setTables] = useState([]); @@ -195,6 +196,7 @@ const TableOverview: React.FC = ({ tab }) => { const openTable = useCallback((tableName: string) => { if (!connection) return; + setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' }); addTab({ id: `${connection.id}-${tab.dbName}-${tableName}`, title: tableName, @@ -203,10 +205,11 @@ const TableOverview: React.FC = ({ tab }) => { dbName: tab.dbName, tableName, }); - }, [connection, tab.dbName, addTab]); + }, [connection, tab.dbName, addTab, setActiveContext]); const openDesign = useCallback((tableName: string) => { if (!connection) return; + setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' }); addTab({ id: `design-${connection.id}-${tab.dbName}-${tableName}`, title: `设计表 (${tableName})`, @@ -217,7 +220,7 @@ const TableOverview: React.FC = ({ tab }) => { initialTab: 'columns', readOnly: false, }); - }, [connection, tab.dbName, addTab]); + }, [connection, tab.dbName, addTab, setActiveContext]); const buildConfig = useCallback(() => { if (!connection) return null; @@ -383,6 +386,7 @@ const TableOverview: React.FC = ({ tab }) => { menu={{ items: [ { key: 'new-query', label: '新建查询', icon: , onClick: () => { + setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' }); addTab({ id: `query-${Date.now()}`, title: '新建查询', diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 6a87182..e671270 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag } from './types'; +import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage } from './types'; import { ShortcutAction, ShortcutBinding, @@ -424,6 +424,12 @@ interface AppState { windowState: 'normal' | 'fullscreen' | 'maximized'; sidebarWidth: number; + // AI 运行时与持久化状态 + aiPanelVisible: boolean; + aiChatHistory: Record; // sessionId -> messages + aiChatSessions: { id: string; title: string; updatedAt: number }[]; // 历史会话列表 + aiActiveSessionId: string | null; + addConnection: (conn: SavedConnection) => void; updateConnection: (conn: SavedConnection) => void; removeConnection: (id: string) => void; @@ -475,6 +481,18 @@ interface AppState { setWindowBounds: (bounds: { width: number; height: number; x: number; y: number }) => void; setWindowState: (state: 'normal' | 'fullscreen' | 'maximized') => void; setSidebarWidth: (width: number) => void; + + // AI actions + toggleAIPanel: () => void; + setAIPanelVisible: (visible: boolean) => void; + addAIChatMessage: (sessionId: string, message: AIChatMessage) => void; + updateAIChatMessage: (sessionId: string, messageId: string, updates: Partial) => void; + deleteAIChatMessage: (sessionId: string, messageId: string) => void; + truncateAIChatMessages: (sessionId: string, upToMessageId: string) => void; + clearAIChatHistory: (sessionId: string) => void; + deleteAISession: (sessionId: string) => void; + createNewAISession: () => void; + setAIActiveSessionId: (sessionId: string | null) => void; } const sanitizeSavedQueries = (value: unknown): SavedQuery[] => { @@ -671,6 +689,12 @@ export const useStore = create()( windowState: 'normal' as const, sidebarWidth: 330, + // AI 运行状态 + aiPanelVisible: false, + aiChatHistory: {}, + aiChatSessions: [], + aiActiveSessionId: null, + addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })), updateConnection: (conn) => set((state) => ({ connections: state.connections.map(c => c.id === conn.id ? conn : c) @@ -950,6 +974,83 @@ export const useStore = create()( setWindowState: (state) => set({ windowState: state }), setSidebarWidth: (width) => set({ sidebarWidth: Math.max(200, Math.min(600, Math.trunc(width))) }), + + // AI actions + toggleAIPanel: () => set((state) => ({ aiPanelVisible: !state.aiPanelVisible })), + setAIPanelVisible: (visible) => set({ aiPanelVisible: visible }), + addAIChatMessage: (sessionId, message) => set((state) => { + const history = { ...state.aiChatHistory }; + const messages = history[sessionId] || []; + history[sessionId] = [...messages, message]; + + let newSessions = [...state.aiChatSessions]; + const existingSession = newSessions.find(s => s.id === sessionId); + + if (!existingSession) { + // 生成标题(首个 user message 内容前 20 字符) + let title = message.role === 'user' ? message.content : '新的对话'; + if (title.length > 20) { + title = title.substring(0, 20) + '...'; + } + newSessions.unshift({ id: sessionId, title, updatedAt: Date.now() }); + } else { + // 提至最新 + newSessions = newSessions.filter(s => s.id !== sessionId); + newSessions.unshift({ ...existingSession, updatedAt: Date.now() }); + } + + return { aiChatHistory: history, aiChatSessions: newSessions }; + }), + updateAIChatMessage: (sessionId, messageId, updates) => set((state) => { + const history = { ...state.aiChatHistory }; + const messages = history[sessionId]; + if (!messages) return state; + history[sessionId] = messages.map(m => + m.id === messageId ? { ...m, ...updates } : m + ); + let newSessions = [...state.aiChatSessions]; + const existingSession = newSessions.find(s => s.id === sessionId); + if (existingSession) { + newSessions = newSessions.filter(s => s.id !== sessionId); + newSessions.unshift({ ...existingSession, updatedAt: Date.now() }); + } + return { aiChatHistory: history, aiChatSessions: newSessions }; + }), + deleteAIChatMessage: (sessionId, messageId) => set((state) => { + const history = { ...state.aiChatHistory }; + if (history[sessionId]) { + history[sessionId] = history[sessionId].filter(m => m.id !== messageId); + } + return { aiChatHistory: history }; + }), + truncateAIChatMessages: (sessionId, upToMessageId) => set((state) => { + const history = { ...state.aiChatHistory }; + const messages = history[sessionId]; + if (messages) { + const idx = messages.findIndex(m => m.id === upToMessageId); + if (idx >= 0) { + history[sessionId] = messages.slice(0, idx + 1); + } + } + return { aiChatHistory: history }; + }), + clearAIChatHistory: (sessionId) => set((state) => { + const history = { ...state.aiChatHistory }; + delete history[sessionId]; + return { aiChatHistory: history }; + }), + deleteAISession: (sessionId) => set((state) => { + const history = { ...state.aiChatHistory }; + delete history[sessionId]; + const newSessions = state.aiChatSessions.filter(s => s.id !== sessionId); + const newActive = state.aiActiveSessionId === sessionId ? null : state.aiActiveSessionId; + return { aiChatHistory: history, aiChatSessions: newSessions, aiActiveSessionId: newActive }; + }), + createNewAISession: () => set(() => { + const newId = `session-${Date.now()}`; + return { aiActiveSessionId: newId }; + }), + setAIActiveSessionId: (sessionId) => set({ aiActiveSessionId: sessionId }), }), { name: 'lite-db-storage', // name of the item in the storage (must be unique) @@ -985,6 +1086,10 @@ export const useStore = create()( nextState.windowBounds = sanitizeWindowBounds(state.windowBounds); nextState.windowState = sanitizeWindowState(state.windowState); nextState.sidebarWidth = sanitizeSidebarWidth(state.sidebarWidth); + + // 保留原有的 AI 持久化记录,或者为空(版本兼容) + nextState.aiChatHistory = (state.aiChatHistory && typeof state.aiChatHistory === 'object') ? state.aiChatHistory : {}; + nextState.aiChatSessions = Array.isArray(state.aiChatSessions) ? state.aiChatSessions : []; return nextState as AppState; }, merge: (persistedState, currentState) => { @@ -1014,6 +1119,9 @@ export const useStore = create()( queryOptions: sanitizeQueryOptions(state.queryOptions), shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions), tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount), + + aiChatHistory: (state.aiChatHistory && typeof state.aiChatHistory === 'object') ? state.aiChatHistory : {}, + aiChatSessions: Array.isArray(state.aiChatSessions) ? state.aiChatSessions : [], }; }, partialize: (state) => ({ @@ -1038,6 +1146,9 @@ export const useStore = create()( windowBounds: state.windowBounds, windowState: state.windowState, sidebarWidth: state.sidebarWidth, + + aiChatHistory: state.aiChatHistory, + aiChatSessions: state.aiChatSessions, }), // Don't persist logs } ) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ea10867..072d65c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -183,3 +183,39 @@ export interface StreamEntry { id: string; fields: Record; } + +// --- AI Types --- + +export type AIProviderType = 'openai' | 'anthropic' | 'gemini' | 'custom'; +export type AISafetyLevel = 'readonly' | 'readwrite' | 'full'; +export type AIContextLevel = 'schema_only' | 'with_samples' | 'with_results'; + +export interface AIProviderConfig { + id: string; + type: AIProviderType; + name: string; + apiKey: string; + baseUrl: string; + model: string; + models?: string[]; + apiFormat?: string; // custom 专用: openai | anthropic | gemini + headers?: Record; + maxTokens: number; + temperature: number; +} + +export interface AIChatMessage { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp: number; + loading?: boolean; +} + +export interface AISafetyResult { + allowed: boolean; + operationType: 'query' | 'dml' | 'ddl' | 'other'; + requiresConfirm: boolean; + warningMessage?: string; +} + diff --git a/frontend/wailsjs/go/aiservice/Service.d.ts b/frontend/wailsjs/go/aiservice/Service.d.ts new file mode 100644 index 0000000..872b5f5 --- /dev/null +++ b/frontend/wailsjs/go/aiservice/Service.d.ts @@ -0,0 +1,38 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT +import {ai} from '../models'; +import {context} from '../models'; + +export function AIChatCancel(arg1:string):Promise; + +export function AIChatSend(arg1:Array>):Promise>; + +export function AIChatStream(arg1:string,arg2:Array>):Promise; + +export function AICheckSQL(arg1:string):Promise; + +export function AIDeleteProvider(arg1:string):Promise; + +export function AIGetActiveProvider():Promise; + +export function AIGetBuiltinPrompts():Promise>; + +export function AIGetContextLevel():Promise; + +export function AIGetProviders():Promise>; + +export function AIGetSafetyLevel():Promise; + +export function AIListModels():Promise>; + +export function AISaveProvider(arg1:ai.ProviderConfig):Promise; + +export function AISetActiveProvider(arg1:string):Promise; + +export function AISetContextLevel(arg1:string):Promise; + +export function AISetSafetyLevel(arg1:string):Promise; + +export function AITestProvider(arg1:ai.ProviderConfig):Promise>; + +export function Startup(arg1:context.Context):Promise; diff --git a/frontend/wailsjs/go/aiservice/Service.js b/frontend/wailsjs/go/aiservice/Service.js new file mode 100644 index 0000000..2e3dcf4 --- /dev/null +++ b/frontend/wailsjs/go/aiservice/Service.js @@ -0,0 +1,71 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function AIChatCancel(arg1) { + return window['go']['aiservice']['Service']['AIChatCancel'](arg1); +} + +export function AIChatSend(arg1) { + return window['go']['aiservice']['Service']['AIChatSend'](arg1); +} + +export function AIChatStream(arg1, arg2) { + return window['go']['aiservice']['Service']['AIChatStream'](arg1, arg2); +} + +export function AICheckSQL(arg1) { + return window['go']['aiservice']['Service']['AICheckSQL'](arg1); +} + +export function AIDeleteProvider(arg1) { + return window['go']['aiservice']['Service']['AIDeleteProvider'](arg1); +} + +export function AIGetActiveProvider() { + return window['go']['aiservice']['Service']['AIGetActiveProvider'](); +} + +export function AIGetBuiltinPrompts() { + return window['go']['aiservice']['Service']['AIGetBuiltinPrompts'](); +} + +export function AIGetContextLevel() { + return window['go']['aiservice']['Service']['AIGetContextLevel'](); +} + +export function AIGetProviders() { + return window['go']['aiservice']['Service']['AIGetProviders'](); +} + +export function AIGetSafetyLevel() { + return window['go']['aiservice']['Service']['AIGetSafetyLevel'](); +} + +export function AIListModels() { + return window['go']['aiservice']['Service']['AIListModels'](); +} + +export function AISaveProvider(arg1) { + return window['go']['aiservice']['Service']['AISaveProvider'](arg1); +} + +export function AISetActiveProvider(arg1) { + return window['go']['aiservice']['Service']['AISetActiveProvider'](arg1); +} + +export function AISetContextLevel(arg1) { + return window['go']['aiservice']['Service']['AISetContextLevel'](arg1); +} + +export function AISetSafetyLevel(arg1) { + return window['go']['aiservice']['Service']['AISetSafetyLevel'](arg1); +} + +export function AITestProvider(arg1) { + return window['go']['aiservice']['Service']['AITestProvider'](arg1); +} + +export function Startup(arg1) { + return window['go']['aiservice']['Service']['Startup'](arg1); +} diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index d6fb6a4..2baf781 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -4,6 +4,7 @@ import {connection} from '../models'; import {time} from '../models'; import {sync} from '../models'; import {redis} from '../models'; +import {context} from '../models'; export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise; @@ -197,6 +198,8 @@ export function SetMacNativeWindowControls(arg1:boolean):Promise; export function SetWindowTranslucency(arg1:number,arg2:number):Promise; +export function Startup(arg1:context.Context):Promise; + export function TestConnection(arg1:connection.ConnectionConfig):Promise; export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 495177e..f0e3782 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -386,6 +386,10 @@ export function SetWindowTranslucency(arg1, arg2) { return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2); } +export function Startup(arg1) { + return window['go']['app']['App']['Startup'](arg1); +} + export function TestConnection(arg1) { return window['go']['app']['App']['TestConnection'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 9a25b59..258b148 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,3 +1,58 @@ +export namespace ai { + + export class ProviderConfig { + id: string; + type: string; + name: string; + apiKey: string; + baseUrl: string; + model: string; + models?: string[]; + apiFormat?: string; + headers?: Record; + maxTokens: number; + temperature: number; + + static createFrom(source: any = {}) { + return new ProviderConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.type = source["type"]; + this.name = source["name"]; + this.apiKey = source["apiKey"]; + this.baseUrl = source["baseUrl"]; + this.model = source["model"]; + this.models = source["models"]; + this.apiFormat = source["apiFormat"]; + this.headers = source["headers"]; + this.maxTokens = source["maxTokens"]; + this.temperature = source["temperature"]; + } + } + export class SafetyResult { + allowed: boolean; + operationType: string; + requiresConfirm: boolean; + warningMessage?: string; + + static createFrom(source: any = {}) { + return new SafetyResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.allowed = source["allowed"]; + this.operationType = source["operationType"]; + this.requiresConfirm = source["requiresConfirm"]; + this.warningMessage = source["warningMessage"]; + } + } + +} + export namespace connection { export class UpdateRow { diff --git a/internal/ai/context/builder.go b/internal/ai/context/builder.go new file mode 100644 index 0000000..9c4e75a --- /dev/null +++ b/internal/ai/context/builder.go @@ -0,0 +1,213 @@ +package aicontext + +import ( + "fmt" + "strings" +) + +// PromptTemplate AI 能力类型 +type PromptTemplate string + +const ( + PromptSQLGenerate PromptTemplate = "sql_generate" + PromptSQLExplain PromptTemplate = "sql_explain" + PromptSQLOptimize PromptTemplate = "sql_optimize" + PromptDataAnalyze PromptTemplate = "data_analyze" + PromptSchemaInsight PromptTemplate = "schema_insight" + PromptGeneralChat PromptTemplate = "general_chat" +) + +// GetBuiltinPrompts 获取所有内置系统提示词集合,用于前端展示 +func GetBuiltinPrompts() map[string]string { + return map[string]string{ + "通用聊天助手": buildGeneralChatPrompt(), + "SQL 生成器": buildSQLGeneratePrompt(), + "SQL 解析器": buildSQLExplainPrompt(), + "SQL 优化器": buildSQLOptimizePrompt(), + "数据洞察分析": buildDataAnalyzePrompt(), + "表结构审查": buildSchemaInsightPrompt(), + } +} + +// BuildSystemPrompt 根据模板类型和上下文构建 System Prompt +func BuildSystemPrompt(template PromptTemplate, dbCtx *DatabaseContext) string { + var prompt string + + switch template { + case PromptSQLGenerate: + prompt = buildSQLGeneratePrompt() + case PromptSQLExplain: + prompt = buildSQLExplainPrompt() + case PromptSQLOptimize: + prompt = buildSQLOptimizePrompt() + case PromptDataAnalyze: + prompt = buildDataAnalyzePrompt() + case PromptSchemaInsight: + prompt = buildSchemaInsightPrompt() + case PromptGeneralChat: + prompt = buildGeneralChatPrompt() + default: + prompt = buildGeneralChatPrompt() + } + + if dbCtx != nil { + prompt += "\n\n" + FormatDatabaseContext(dbCtx) + } + + return prompt +} + +// FormatDatabaseContext 将数据库上下文格式化为 LLM 友好的文本 +func FormatDatabaseContext(ctx *DatabaseContext) string { + if ctx == nil || len(ctx.Tables) == 0 { + return "" + } + + var b strings.Builder + b.WriteString(fmt.Sprintf("## 当前数据库上下文\n\n数据库类型: %s\n数据库名: %s\n\n", + ctx.DatabaseType, ctx.DatabaseName)) + + b.WriteString("### 表结构\n\n") + for _, table := range ctx.Tables { + b.WriteString(fmt.Sprintf("#### 表: %s", table.Name)) + if table.Comment != "" { + b.WriteString(fmt.Sprintf(" (%s)", table.Comment)) + } + if table.RowCount > 0 { + b.WriteString(fmt.Sprintf(" [约 %d 行]", table.RowCount)) + } + b.WriteString("\n\n") + + b.WriteString("| 列名 | 类型 | 可空 | 主键 | 备注 |\n") + b.WriteString("|------|------|------|------|------|\n") + for _, col := range table.Columns { + nullable := "否" + if col.Nullable { + nullable = "是" + } + pk := "" + if col.PrimaryKey { + pk = "✓" + } + comment := col.Comment + if comment == "" { + comment = "-" + } + b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s |\n", + col.Name, col.Type, nullable, pk, comment)) + } + b.WriteString("\n") + + if len(table.Indexes) > 0 { + b.WriteString("**索引:**\n") + for _, idx := range table.Indexes { + unique := "" + if idx.Unique { + unique = " (唯一)" + } + b.WriteString(fmt.Sprintf("- %s: [%s]%s\n", + idx.Name, strings.Join(idx.Columns, ", "), unique)) + } + b.WriteString("\n") + } + + if len(table.SampleRows) > 0 { + b.WriteString(fmt.Sprintf("**采样数据 (%d 行):**\n\n", len(table.SampleRows))) + if len(table.SampleRows) > 0 { + // 使用第一行的 key 作为标题 + first := table.SampleRows[0] + var keys []string + for k := range first { + keys = append(keys, k) + } + b.WriteString("| " + strings.Join(keys, " | ") + " |\n") + b.WriteString("|" + strings.Repeat("------|", len(keys)) + "\n") + for _, row := range table.SampleRows { + var vals []string + for _, k := range keys { + vals = append(vals, fmt.Sprintf("%v", row[k])) + } + b.WriteString("| " + strings.Join(vals, " | ") + " |\n") + } + b.WriteString("\n") + } + } + } + + return b.String() +} + +func buildSQLGeneratePrompt() string { + return `你是 GoNavi AI 助手,一位顶级的数据库开发专家和 SQL 查询构建师。根据用户的自然语言需求,生成精准、优雅、高性能的 SQL 查询或 Redis 命令。 + +严苛输出规则: +1. 首要目标是输出纯粹的代码:始终将代码放在正确语言标识(如 sql 或 bash)的 markdown 代码块中。 +2. 保持精简:不要添加过多的前置闲聊,直奔主题。 +3. 保护生产安全:优先使用参数化查询或安全防范写法避免 SQL 注入。对于未指定条件的 DELETE/UPDATE 语句,必须提出强烈的红线警告!! +4. 性能至上:对大型查询默认添加合理的 LIMIT 限制(如 LIMIT 100),在 JOIN 和聚合时优先选择最高效的范式写法。 +5. 适度注释:对于存在复杂逻辑嵌套的代码,请在代码块内使用单行注释简要说明思路。` +} + +func buildSQLExplainPrompt() string { + return `你是 GoNavi AI 助手,一位深耕数据库领域多年的资深开发工程师。请用专业、条理分明且深入浅出的开发者语言向用户全盘解析 SQL 语句的底层意图与执行逻辑。 + +解析规范: +1. 宏观逻辑解构:用简短的一句话概括这条 SQL 在业务上想要解决什么问题。 +2. 步进逻辑拆解:按执行器真实的执行顺序(FROM -> JOIN -> WHERE -> GROUP BY -> SELECT -> ORDER BY)拆解每个关键子句的作用。 +3. 性能排雷点:敏锐指出可能存在的性能陷阱(如隐式类型转换、没有走索引的函数调用、潜在的笛卡尔积/全表扫描等)。 +4. 严谨的排版:使用列表呈现关键点,重点词汇加粗,确保长文不累赘。` +} + +func buildSQLOptimizePrompt() string { + return `你是 GoNavi AI 助手,一名曾主导过千万级高并发系统的全栈性能工程专家与高级 DBA。请对用户提供的原始 SQL 进行冷酷、精确的诊断并开出性能重构处方。 + +诊断与处方要求: +1. 性能瓶颈透视:精准点出当前语句死穴(不合理的驱动表、无法利用覆盖索引、多此一举的子查询等)。 +2. 重构版本的 SQL:如果存在性能提升空间,直接向用户展示彻底优化过的高性能写法,并确保逻辑等价性。 +3. 剖析原因:不仅要告诉用户“怎么改”,更要说清楚执行器“为什么这样会更快”。 +4. 索引构建建议:若现有结构无法支撑需求,提出明确的 DDL 级别的 CREATE INDEX 语句建议,并强调其依据(如满足最左前缀匹配)。 +5. 优先级评估:在回答的最后标注本次优化建议的紧迫性(高:阻断级/锁表风险;中:吞吐量瓶颈;低:长效微调)。` +} + +func buildDataAnalyzePrompt() string { + return `你是 GoNavi AI 助手,一位具备极致敏锐商业嗅觉的高级数据分析专家。你将审视用户通过查询得到的数据样本,从中提炼出蕴含的真金白银般的信息。 + +洞察目标: +1. 硬统计:总观数据行数、核心数值指标(极值、平均值、聚合中位数等)的冰冷现实。 +2. 趋势与异动:如果数据带有时间戳,敏锐捕捉其上升或下降趋势;如果有异类离群值,将其高亮标注。 +3. 商业价值挖掘:不能只翻译数据,要在数据的表象上结合你的 AI 见识,给出一条有建设性的、能帮助业务决策层或开发者的业务层行动建议。 +4. 展现格式:你的分析应该是“标题 + 浓缩要点”的极简研报形式,杜绝毫无波澜的流水账。` +} + +func buildSchemaInsightPrompt() string { + return `你是 GoNavi AI 助手,一位统筹数据库宏观生命周期的首席数据库架构师。在这个环节里,你需要对用户提供的数据库表结构执行最严厉的范式与前瞻性审查。 + +审查视界: +1. 规范化博弈:是否存在明显的反三范式设计?这种冗余是否有助于性能(适当的反范式),还是纯粹的设计失误? +2. 索引健壮性审查:评估主键选择(如自增、UUID 的利弊),是否存在冗余索引阻碍写入?以及是否遗漏了高频的联合索引。 +3. 物理容量前瞻:审视数据类型分配(如使用过大的 VARCHAR、没必要的 BIGINT 等可能带来的空间挥霍)。 +4. 代码级指引:如果存在结构性缺陷,不要只发牢骚,直接给出包含具体优化的 ALTER TABLE 结构修改建议脚本。` +} + +func buildGeneralChatPrompt() string { + return `你是 GoNavi AI 助手,一款深度集成在数据库/缓存客户端(GoNavi)内部的专属智能专家系统。 +你的目标是成为开发者、DBA 和数据科学家最得力的超级外脑,提供专业、精准、具有前瞻性的数据端解决方案。 + +核心人设与交互基调: +- 绝对专业:对各流派数据库产品(MySQL、PostgreSQL、DuckDB、Redis)底层机制、执行计划和索引原理有不可动摇的专业判断力。 +- 直击痛点:谢绝套话与无效寒暄,若用户的意图明确,首屏直接给出可以直接粘贴运行的优雅代码。 +- 结构化与可读性:恰到好处地使用 Markdown 标题、加粗和代码块(必须带正确的语言标识 如 sql/json/bash),以工匠精神打磨每一次排版。 +- 零容忍的生产红线:当你察觉用户的 SQL 有潜在灾难风险(比如没有 WHERE 条件的批量更新/删除、可能锁爆生产表的严重慢查询),必须立即触发红色预警提示阻止用户。 + +你的综合能力版图: +1. 📝 自然语言驱动:翻译人类意图为精准的查询语句。 +2. 🔍 底层原理解析:剥丝抽茧分析查询背后的执行逻辑与性能隐患。 +3. ⚡ 专家级调优:指出并化解性能瓶颈,给出覆盖全维度的索引调优思路。 +4. 📊 数据洞察炼金:不仅聚合数据,更能从结果集中挖掘商业维度的深度规律。 +5. 🏗️ 架构先知视界:全局审阅表结构设计局限,提出抗数据膨胀级别的架构演进方案。 + +互动守则: +- 永远使用专业、具有合作感且充满信心的中文与用户探讨问题。 +- 当被要求提供任何数据库代码时,需结合相关数据库引擎的最佳实践。如果不清楚当前方言版本,请以标准实现为主基调并好心指出版别差异(如 MySQL 8 窗口函数 等)。` +} + diff --git a/internal/ai/context/collector.go b/internal/ai/context/collector.go new file mode 100644 index 0000000..bfa6c36 --- /dev/null +++ b/internal/ai/context/collector.go @@ -0,0 +1,42 @@ +package aicontext + +// DatabaseContext 数据库上下文信息,传递给 AI 辅助上下文理解 +type DatabaseContext struct { + DatabaseType string `json:"databaseType"` // mysql, postgres 等 + DatabaseName string `json:"databaseName"` + Tables []TableContext `json:"tables"` +} + +// TableContext 表的上下文信息 +type TableContext struct { + Name string `json:"name"` + Comment string `json:"comment,omitempty"` + Columns []ColumnInfo `json:"columns"` + Indexes []IndexInfo `json:"indexes,omitempty"` + SampleRows []map[string]interface{} `json:"sampleRows,omitempty"` + RowCount int64 `json:"rowCount,omitempty"` +} + +// ColumnInfo 列信息 +type ColumnInfo struct { + Name string `json:"name"` + Type string `json:"type"` + Nullable bool `json:"nullable"` + PrimaryKey bool `json:"primaryKey"` + Comment string `json:"comment,omitempty"` +} + +// IndexInfo 索引信息 +type IndexInfo struct { + Name string `json:"name"` + Columns []string `json:"columns"` + Unique bool `json:"unique"` +} + +// QueryResultContext 查询结果上下文 +type QueryResultContext struct { + SQL string `json:"sql"` + Columns []string `json:"columns"` + Rows []map[string]interface{} `json:"rows"` + RowCount int `json:"rowCount"` +} diff --git a/internal/ai/provider/anthropic.go b/internal/ai/provider/anthropic.go new file mode 100644 index 0000000..035d2fc --- /dev/null +++ b/internal/ai/provider/anthropic.go @@ -0,0 +1,293 @@ +package provider + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "GoNavi-Wails/internal/ai" +) + +const ( + defaultAnthropicBaseURL = "https://api.anthropic.com" + defaultAnthropicModel = "claude-3-5-sonnet-20241022" + anthropicAPIVersion = "2023-06-01" +) + +// AnthropicProvider 实现 Anthropic Claude API 的 Provider +type AnthropicProvider struct { + config ai.ProviderConfig + baseURL string + client *http.Client +} + +// NewAnthropicProvider 创建 Anthropic Provider 实例 +func NewAnthropicProvider(config ai.ProviderConfig) (Provider, error) { + baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/") + if baseURL == "" { + baseURL = defaultAnthropicBaseURL + } + model := strings.TrimSpace(config.Model) + if model == "" { + model = defaultAnthropicModel + } + maxTokens := config.MaxTokens + if maxTokens <= 0 { + maxTokens = defaultOpenAIMaxTokens + } + temperature := config.Temperature + if temperature <= 0 { + temperature = defaultOpenAITemperature + } + + normalized := config + normalized.BaseURL = baseURL + normalized.Model = model + normalized.MaxTokens = maxTokens + normalized.Temperature = temperature + + return &AnthropicProvider{ + config: normalized, + baseURL: baseURL, + client: &http.Client{Timeout: openAIHTTPTimeout}, + }, nil +} + +func (p *AnthropicProvider) Name() string { + if strings.TrimSpace(p.config.Name) != "" { + return p.config.Name + } + return "Anthropic" +} + +func (p *AnthropicProvider) Validate() error { + if strings.TrimSpace(p.config.APIKey) == "" { + return fmt.Errorf("API Key 不能为空") + } + return nil +} + +type anthropicRequest struct { + Model string `json:"model"` + Messages []anthropicMessage `json:"messages"` + System string `json:"system,omitempty"` + MaxTokens int `json:"max_tokens"` + Temperature float64 `json:"temperature,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +type anthropicMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type anthropicResponse struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +type anthropicStreamEvent struct { + Type string `json:"type"` + Delta *struct { + Text string `json:"text"` + } `json:"delta,omitempty"` +} + +func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) { + if err := p.Validate(); err != nil { + return nil, err + } + + systemMsg, messages := extractSystemMessage(req.Messages) + anthropicMsgs := make([]anthropicMessage, len(messages)) + for i, m := range messages { + anthropicMsgs[i] = anthropicMessage{Role: m.Role, Content: m.Content} + } + + temperature := req.Temperature + if temperature <= 0 { + temperature = p.config.Temperature + } + maxTokens := req.MaxTokens + if maxTokens <= 0 { + maxTokens = p.config.MaxTokens + } + + body := anthropicRequest{ + Model: p.config.Model, + Messages: anthropicMsgs, + System: systemMsg, + MaxTokens: maxTokens, + Temperature: temperature, + } + + respBody, err := p.doRequest(ctx, body) + if err != nil { + return nil, err + } + defer respBody.Close() + + var result anthropicResponse + if err := json.NewDecoder(respBody).Decode(&result); err != nil { + return nil, fmt.Errorf("解析 Anthropic 响应失败: %w", err) + } + if result.Error != nil && result.Error.Message != "" { + return nil, fmt.Errorf("Anthropic API 错误: %s", result.Error.Message) + } + if len(result.Content) == 0 { + return nil, fmt.Errorf("Anthropic 返回空响应") + } + + return &ai.ChatResponse{ + Content: result.Content[0].Text, + TokensUsed: ai.TokenUsage{ + PromptTokens: result.Usage.InputTokens, + CompletionTokens: result.Usage.OutputTokens, + TotalTokens: result.Usage.InputTokens + result.Usage.OutputTokens, + }, + }, nil +} + +func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error { + if err := p.Validate(); err != nil { + return err + } + + systemMsg, messages := extractSystemMessage(req.Messages) + anthropicMsgs := make([]anthropicMessage, len(messages)) + for i, m := range messages { + anthropicMsgs[i] = anthropicMessage{Role: m.Role, Content: m.Content} + } + + temperature := req.Temperature + if temperature <= 0 { + temperature = p.config.Temperature + } + maxTokens := req.MaxTokens + if maxTokens <= 0 { + maxTokens = p.config.MaxTokens + } + + body := anthropicRequest{ + Model: p.config.Model, + Messages: anthropicMsgs, + System: systemMsg, + MaxTokens: maxTokens, + Temperature: temperature, + Stream: true, + } + + respBody, err := p.doRequest(ctx, body) + if err != nil { + return err + } + defer respBody.Close() + + scanner := bufio.NewScanner(respBody) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + + var event anthropicStreamEvent + if err := json.Unmarshal([]byte(data), &event); err != nil { + continue + } + + switch event.Type { + case "content_block_delta": + if event.Delta != nil && event.Delta.Text != "" { + callback(ai.StreamChunk{Content: event.Delta.Text}) + } + case "message_stop": + callback(ai.StreamChunk{Done: true}) + return nil + } + } + + callback(ai.StreamChunk{Done: true}) + return scanner.Err() +} + +func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("序列化请求失败: %w", err) + } + + url := p.baseURL + "/v1/messages" + if strings.HasSuffix(p.baseURL, "/v1") { + url = p.baseURL + "/messages" + } + + // 调试日志:打印实际请求信息 + bodyStr := string(jsonBody) + if len(bodyStr) > 500 { + bodyStr = bodyStr[:500] + "..." + } + fmt.Printf("[Anthropic DEBUG] URL: %s\n", url) + fmt.Printf("[Anthropic DEBUG] BaseURL: %s\n", p.baseURL) + fmt.Printf("[Anthropic DEBUG] Body: %s\n", bodyStr) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("x-api-key", p.config.APIKey) + httpReq.Header.Set("anthropic-version", anthropicAPIVersion) + + // 仅官方 API 发 beta 特性头(代理不发,避免触发 Claude Code 验证) + isOfficialAPI := p.baseURL == defaultAnthropicBaseURL || strings.Contains(p.baseURL, "anthropic.com") + if isOfficialAPI { + httpReq.Header.Set("anthropic-beta", "interleaved-thinking-2025-05-14,output-128k-2025-02-19,prompt-caching-2024-07-31") + } + + // 自定义 headers(用于兼容各类代理服务) + for k, v := range p.config.Headers { + httpReq.Header.Set(k, v) + } + + resp, err := p.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("发送请求到 %s 失败: %w", url, err) + } + + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("Anthropic API 返回错误 (HTTP %d): %s", resp.StatusCode, string(bodyBytes)) + } + + return resp.Body, nil +} + +// extractSystemMessage 从消息列表中提取 system 消息(Anthropic 要求 system 作为独立字段) +func extractSystemMessage(messages []ai.Message) (string, []ai.Message) { + var systemParts []string + var remaining []ai.Message + for _, m := range messages { + if m.Role == "system" { + systemParts = append(systemParts, m.Content) + } else { + remaining = append(remaining, m) + } + } + return strings.Join(systemParts, "\n\n"), remaining +} diff --git a/internal/ai/provider/claude_cli.go b/internal/ai/provider/claude_cli.go new file mode 100644 index 0000000..824e413 --- /dev/null +++ b/internal/ai/provider/claude_cli.go @@ -0,0 +1,227 @@ +package provider + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + + ai "GoNavi-Wails/internal/ai" +) + +// ClaudeCLIProvider 通过 Claude Code CLI 发送聊天请求 +// 适用于 anyrouter/newapi 等只支持 Claude Code 协议的代理服务 +type ClaudeCLIProvider struct { + config ai.ProviderConfig +} + +// NewClaudeCLIProvider 创建 ClaudeCLIProvider 实例 +func NewClaudeCLIProvider(config ai.ProviderConfig) (Provider, error) { + return &ClaudeCLIProvider{config: config}, nil +} + +func (p *ClaudeCLIProvider) Name() string { + return "ClaudeCLI" +} + +func (p *ClaudeCLIProvider) Validate() error { + _, err := exec.LookPath("claude") + if err != nil { + return fmt.Errorf("未找到 claude 命令,请先安装 Claude Code CLI: npm install -g @anthropic-ai/claude-code") + } + return nil +} + +// Chat 非流式聊天:调用 claude -p "prompt" --output-format json +func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) { + if err := p.Validate(); err != nil { + return nil, err + } + + prompt := buildPrompt(req.Messages) + args := []string{"-p", prompt, "--output-format", "json", "--no-session-persistence"} + if p.config.Model != "" { + args = append(args, "--model", p.config.Model) + } + + cmd := exec.CommandContext(ctx, "claude", args...) + p.setEnv(cmd) + + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("claude CLI 执行失败: %s", string(exitErr.Stderr)) + } + return nil, fmt.Errorf("claude CLI 执行失败: %w", err) + } + + // 解析 JSON 输出 + var result struct { + Result string `json:"result"` + } + if err := json.Unmarshal(output, &result); err != nil { + // 如果 JSON 解析失败,直接返回原始文本 + return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, nil + } + + return &ai.ChatResponse{Content: result.Result}, nil +} + +// ChatStream 流式聊天:调用 claude -p "prompt" --output-format stream-json +func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error { + if err := p.Validate(); err != nil { + return err + } + + prompt := buildPrompt(req.Messages) + args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--no-session-persistence"} + if p.config.Model != "" { + args = append(args, "--model", p.config.Model) + } + + fmt.Printf("[ClaudeCLI DEBUG] Running: claude %v\n", args) + + cmd := exec.CommandContext(ctx, "claude", args...) + p.setEnv(cmd) + + // 关闭 stdin,防止 claude CLI 等待输入 + cmd.Stdin = nil + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("创建 stdout 管道失败: %w", err) + } + + // 捕获 stderr + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if err := cmd.Start(); err != nil { + return fmt.Errorf("启动 claude CLI 失败: %w", err) + } + + fmt.Printf("[ClaudeCLI DEBUG] Process started, PID: %d\n", cmd.Process.Pid) + + // 立即通知前端:AI 正在思考(避免用户以为卡死) + callback(ai.StreamChunk{Content: "💭 *正在思考...*\n\n"}) + + // 逐行读取流式 JSON 输出 + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "" { + continue + } + + fmt.Printf("[ClaudeCLI DEBUG] Line: %s\n", line[:min(len(line), 200)]) + + var event cliStreamEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + fmt.Printf("[ClaudeCLI DEBUG] Non-JSON line: %s\n", line) + continue + } + + switch event.Type { + case "assistant": + // 助手消息开始或文本内容 + if event.Message.Content != nil { + for _, block := range event.Message.Content { + if block.Type == "text" && block.Text != "" { + callback(ai.StreamChunk{Content: block.Text}) + } + } + } + case "content_block_delta": + // 增量文本 + if event.Delta.Text != "" { + callback(ai.StreamChunk{Content: event.Delta.Text}) + } + case "result": + // 最终结果事件 — 不发送 content(assistant 事件已包含),只标记完成 + callback(ai.StreamChunk{Done: true}) + _ = cmd.Wait() + return nil + case "error": + callback(ai.StreamChunk{Error: event.Error.Message, Done: true}) + _ = cmd.Wait() + return nil + } + } + + waitErr := cmd.Wait() + stderrStr := strings.TrimSpace(stderrBuf.String()) + fmt.Printf("[ClaudeCLI DEBUG] Process exited. stderr: %s\n", stderrStr) + + if waitErr != nil { + errMsg := fmt.Sprintf("claude CLI 异常退出: %v", waitErr) + if stderrStr != "" { + errMsg = fmt.Sprintf("claude CLI 异常退出: %s", stderrStr) + } + callback(ai.StreamChunk{Error: errMsg, Done: true}) + return nil + } + + callback(ai.StreamChunk{Done: true}) + return nil +} + +// setEnv 设置 Claude CLI 的环境变量 +func (p *ClaudeCLIProvider) setEnv(cmd *exec.Cmd) { + env := cmd.Environ() + if p.config.BaseURL != "" { + baseURL := strings.TrimRight(p.config.BaseURL, "/") + env = append(env, "ANTHROPIC_BASE_URL="+baseURL) + } + if p.config.APIKey != "" { + env = append(env, "ANTHROPIC_API_KEY="+p.config.APIKey) + } + cmd.Env = env +} + +// buildPrompt 将消息列表拼接为适合 claude -p 的提示文本 +func buildPrompt(messages []ai.Message) string { + if len(messages) == 1 { + return messages[0].Content + } + + var sb strings.Builder + for _, m := range messages { + switch m.Role { + case "system": + sb.WriteString("[System]\n") + sb.WriteString(m.Content) + sb.WriteString("\n\n") + case "user": + sb.WriteString(m.Content) + sb.WriteString("\n\n") + case "assistant": + sb.WriteString("[Previous Assistant Response]\n") + sb.WriteString(m.Content) + sb.WriteString("\n\n") + } + } + return strings.TrimSpace(sb.String()) +} + +// cliStreamEvent Claude CLI stream-json 输出的事件结构 +type cliStreamEvent struct { + Type string `json:"type"` + Message struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"message,omitempty"` + Delta struct { + Text string `json:"text"` + } `json:"delta,omitempty"` + Result string `json:"result,omitempty"` + Error struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} diff --git a/internal/ai/provider/custom.go b/internal/ai/provider/custom.go new file mode 100644 index 0000000..7900ec2 --- /dev/null +++ b/internal/ai/provider/custom.go @@ -0,0 +1,74 @@ +package provider + +import ( + "context" + "fmt" + "strings" + + "GoNavi-Wails/internal/ai" +) + +// CustomProvider 自定义 Provider,根据 apiFormat 选择底层协议 +// 支持 openai / anthropic / gemini 三种 API 格式 +type CustomProvider struct { + inner Provider + name string +} + +// NewCustomProvider 创建自定义 Provider 实例 +func NewCustomProvider(config ai.ProviderConfig) (Provider, error) { + if strings.TrimSpace(config.BaseURL) == "" { + return nil, fmt.Errorf("自定义 Provider 必须指定 Base URL") + } + + // 根据 apiFormat 决定使用哪个底层协议,默认 openai + apiFormat := strings.ToLower(strings.TrimSpace(config.APIFormat)) + if apiFormat == "" { + apiFormat = "openai" + } + + var innerProvider Provider + var err error + switch apiFormat { + case "anthropic": + innerProvider, err = NewAnthropicProvider(config) + case "gemini": + innerProvider, err = NewGeminiProvider(config) + case "claude-cli": + innerProvider, err = NewClaudeCLIProvider(config) + default: // "openai" 及其他 + innerProvider, err = NewOpenAIProvider(config) + } + if err != nil { + return nil, err + } + + name := strings.TrimSpace(config.Name) + if name == "" { + name = "Custom" + } + + return &CustomProvider{ + inner: innerProvider, + name: name, + }, nil +} + +func (p *CustomProvider) Name() string { + return p.name +} + +func (p *CustomProvider) Validate() error { + if strings.TrimSpace(p.inner.(interface{ Name() string }).Name()) == "" { + // 对自定义 Provider,API Key 可选(部分本地服务不需要) + } + return nil +} + +func (p *CustomProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) { + return p.inner.Chat(ctx, req) +} + +func (p *CustomProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error { + return p.inner.ChatStream(ctx, req, callback) +} diff --git a/internal/ai/provider/gemini.go b/internal/ai/provider/gemini.go new file mode 100644 index 0000000..0c5eee7 --- /dev/null +++ b/internal/ai/provider/gemini.go @@ -0,0 +1,267 @@ +package provider + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "GoNavi-Wails/internal/ai" +) + +const ( + defaultGeminiBaseURL = "https://generativelanguage.googleapis.com" + defaultGeminiModel = "gemini-2.0-flash" +) + +// GeminiProvider 实现 Google Gemini API 的 Provider +type GeminiProvider struct { + config ai.ProviderConfig + baseURL string + client *http.Client +} + +// NewGeminiProvider 创建 Gemini Provider 实例 +func NewGeminiProvider(config ai.ProviderConfig) (Provider, error) { + baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/") + if baseURL == "" { + baseURL = defaultGeminiBaseURL + } + model := strings.TrimSpace(config.Model) + if model == "" { + model = defaultGeminiModel + } + maxTokens := config.MaxTokens + if maxTokens <= 0 { + maxTokens = defaultOpenAIMaxTokens + } + temperature := config.Temperature + if temperature <= 0 { + temperature = defaultOpenAITemperature + } + + normalized := config + normalized.BaseURL = baseURL + normalized.Model = model + normalized.MaxTokens = maxTokens + normalized.Temperature = temperature + + return &GeminiProvider{ + config: normalized, + baseURL: baseURL, + client: &http.Client{Timeout: openAIHTTPTimeout}, + }, nil +} + +func (p *GeminiProvider) Name() string { + if strings.TrimSpace(p.config.Name) != "" { + return p.config.Name + } + return "Gemini" +} + +func (p *GeminiProvider) Validate() error { + if strings.TrimSpace(p.config.APIKey) == "" { + return fmt.Errorf("API Key 不能为空") + } + return nil +} + +type geminiRequest struct { + Contents []geminiContent `json:"contents"` + SystemInstruction *geminiContent `json:"systemInstruction,omitempty"` + GenerationConfig geminiGenConfig `json:"generationConfig,omitempty"` +} + +type geminiContent struct { + Role string `json:"role,omitempty"` + Parts []geminiPart `json:"parts"` +} + +type geminiPart struct { + Text string `json:"text"` +} + +type geminiGenConfig struct { + Temperature float64 `json:"temperature,omitempty"` + MaxOutputTokens int `json:"maxOutputTokens,omitempty"` +} + +type geminiResponse struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + UsageMetadata *struct { + PromptTokenCount int `json:"promptTokenCount"` + CandidatesTokenCount int `json:"candidatesTokenCount"` + TotalTokenCount int `json:"totalTokenCount"` + } `json:"usageMetadata"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (p *GeminiProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) { + if err := p.Validate(); err != nil { + return nil, err + } + + geminiReq := p.buildRequest(req) + + url := fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s", + p.baseURL, p.config.Model, p.config.APIKey) + + respBody, err := p.doRequest(ctx, url, geminiReq) + if err != nil { + return nil, err + } + defer respBody.Close() + + var result geminiResponse + if err := json.NewDecoder(respBody).Decode(&result); err != nil { + return nil, fmt.Errorf("解析 Gemini 响应失败: %w", err) + } + if result.Error != nil && result.Error.Message != "" { + return nil, fmt.Errorf("Gemini API 错误: %s", result.Error.Message) + } + if len(result.Candidates) == 0 || len(result.Candidates[0].Content.Parts) == 0 { + return nil, fmt.Errorf("Gemini 返回空响应") + } + + var tokens ai.TokenUsage + if result.UsageMetadata != nil { + tokens = ai.TokenUsage{ + PromptTokens: result.UsageMetadata.PromptTokenCount, + CompletionTokens: result.UsageMetadata.CandidatesTokenCount, + TotalTokens: result.UsageMetadata.TotalTokenCount, + } + } + + var textParts []string + for _, part := range result.Candidates[0].Content.Parts { + if part.Text != "" { + textParts = append(textParts, part.Text) + } + } + + return &ai.ChatResponse{ + Content: strings.Join(textParts, ""), + TokensUsed: tokens, + }, nil +} + +func (p *GeminiProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error { + if err := p.Validate(); err != nil { + return err + } + + geminiReq := p.buildRequest(req) + + url := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse&key=%s", + p.baseURL, p.config.Model, p.config.APIKey) + + respBody, err := p.doRequest(ctx, url, geminiReq) + if err != nil { + return err + } + defer respBody.Close() + + scanner := bufio.NewScanner(respBody) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + + var chunk geminiResponse + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + + if len(chunk.Candidates) > 0 && len(chunk.Candidates[0].Content.Parts) > 0 { + for _, part := range chunk.Candidates[0].Content.Parts { + if part.Text != "" { + callback(ai.StreamChunk{Content: part.Text}) + } + } + } + } + + callback(ai.StreamChunk{Done: true}) + return scanner.Err() +} + +func (p *GeminiProvider) buildRequest(req ai.ChatRequest) geminiRequest { + temperature := req.Temperature + if temperature <= 0 { + temperature = p.config.Temperature + } + maxTokens := req.MaxTokens + if maxTokens <= 0 { + maxTokens = p.config.MaxTokens + } + + var systemInstruction *geminiContent + var contents []geminiContent + + for _, m := range req.Messages { + if m.Role == "system" { + systemInstruction = &geminiContent{ + Parts: []geminiPart{{Text: m.Content}}, + } + continue + } + role := m.Role + if role == "assistant" { + role = "model" + } + contents = append(contents, geminiContent{ + Role: role, + Parts: []geminiPart{{Text: m.Content}}, + }) + } + + return geminiRequest{ + Contents: contents, + SystemInstruction: systemInstruction, + GenerationConfig: geminiGenConfig{ + Temperature: temperature, + MaxOutputTokens: maxTokens, + }, + } +} + +func (p *GeminiProvider) doRequest(ctx context.Context, url string, body interface{}) (io.ReadCloser, error) { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("序列化请求失败: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := p.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("发送请求到 Gemini 失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("Gemini API 返回错误 (HTTP %d): %s", resp.StatusCode, string(bodyBytes)) + } + + return resp.Body, nil +} diff --git a/internal/ai/provider/openai.go b/internal/ai/provider/openai.go new file mode 100644 index 0000000..ff674a9 --- /dev/null +++ b/internal/ai/provider/openai.go @@ -0,0 +1,316 @@ +package provider + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "GoNavi-Wails/internal/ai" +) + +const ( + defaultOpenAIBaseURL = "https://api.openai.com/v1" + defaultOpenAIModel = "gpt-4o" + defaultOpenAIMaxTokens = 4096 + defaultOpenAITemperature = 0.7 + openAIHTTPTimeout = 120 * time.Second +) + +// OpenAIProvider 实现 OpenAI / OpenAI 兼容 API 的 Provider +type OpenAIProvider struct { + config ai.ProviderConfig + baseURL string + client *http.Client +} + +// NewOpenAIProvider 创建 OpenAI Provider 实例 +func NewOpenAIProvider(config ai.ProviderConfig) (Provider, error) { + baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/") + if baseURL == "" { + baseURL = defaultOpenAIBaseURL + } + // 确保 baseURL 包含 /v1 路径(兼容用户只填域名的情况,如 https://anyrouter.top) + if !strings.HasSuffix(baseURL, "/v1") && !strings.Contains(baseURL, "/v1/") { + baseURL = baseURL + "/v1" + } + model := strings.TrimSpace(config.Model) + if model == "" { + model = defaultOpenAIModel + } + maxTokens := config.MaxTokens + if maxTokens <= 0 { + maxTokens = defaultOpenAIMaxTokens + } + temperature := config.Temperature + if temperature <= 0 { + temperature = defaultOpenAITemperature + } + + normalized := config + normalized.BaseURL = baseURL + normalized.Model = model + normalized.MaxTokens = maxTokens + normalized.Temperature = temperature + + return &OpenAIProvider{ + config: normalized, + baseURL: baseURL, + client: &http.Client{ + Timeout: openAIHTTPTimeout, + }, + }, nil +} + +func (p *OpenAIProvider) Name() string { + if strings.TrimSpace(p.config.Name) != "" { + return p.config.Name + } + return "OpenAI" +} + +func (p *OpenAIProvider) Validate() error { + if strings.TrimSpace(p.config.APIKey) == "" { + return fmt.Errorf("API Key 不能为空") + } + return nil +} + +// openAIChatRequest OpenAI API 请求体 +type openAIChatRequest struct { + Model string `json:"model"` + Messages []openAIChatMessage `json:"messages"` + Temperature float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +type openAIChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// openAIChatResponse OpenAI API 响应体 +type openAIChatResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// openAIStreamChunk SSE 流式响应片段 +type openAIStreamChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (p *OpenAIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) { + if err := p.Validate(); err != nil { + return nil, err + } + + messages := make([]openAIChatMessage, len(req.Messages)) + for i, m := range req.Messages { + messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content} + } + + temperature := req.Temperature + if temperature <= 0 { + temperature = p.config.Temperature + } + maxTokens := req.MaxTokens + if maxTokens <= 0 { + maxTokens = p.config.MaxTokens + } + + body := openAIChatRequest{ + Model: p.config.Model, + Messages: messages, + Temperature: temperature, + MaxTokens: maxTokens, + Stream: false, + } + + respBody, err := p.doRequest(ctx, body) + if err != nil { + return nil, err + } + defer respBody.Close() + + var result openAIChatResponse + if err := json.NewDecoder(respBody).Decode(&result); err != nil { + return nil, fmt.Errorf("解析 OpenAI 响应失败: %w", err) + } + if result.Error != nil && result.Error.Message != "" { + return nil, fmt.Errorf("OpenAI API 错误: %s", result.Error.Message) + } + if len(result.Choices) == 0 { + return nil, fmt.Errorf("OpenAI 返回空响应") + } + + return &ai.ChatResponse{ + Content: result.Choices[0].Message.Content, + TokensUsed: ai.TokenUsage{ + PromptTokens: result.Usage.PromptTokens, + CompletionTokens: result.Usage.CompletionTokens, + TotalTokens: result.Usage.TotalTokens, + }, + }, nil +} + +func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error { + if err := p.Validate(); err != nil { + return err + } + + messages := make([]openAIChatMessage, len(req.Messages)) + for i, m := range req.Messages { + messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content} + } + + temperature := req.Temperature + if temperature <= 0 { + temperature = p.config.Temperature + } + maxTokens := req.MaxTokens + if maxTokens <= 0 { + maxTokens = p.config.MaxTokens + } + + body := openAIChatRequest{ + Model: p.config.Model, + Messages: messages, + Temperature: temperature, + MaxTokens: maxTokens, + Stream: true, + } + + respBody, err := p.doRequest(ctx, body) + if err != nil { + return err + } + defer respBody.Close() + + receivedContent := false + scanner := bufio.NewScanner(respBody) + // 增大 scanner buffer,防止长行被截断 + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + if !strings.HasPrefix(line, "data: ") { + // 非 SSE 数据行,可能是错误信息,记录日志 + if strings.Contains(line, "error") || strings.Contains(line, "Error") { + callback(ai.StreamChunk{Error: fmt.Sprintf("服务端返回异常: %s", line), Done: true}) + return nil + } + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + callback(ai.StreamChunk{Done: true}) + return nil + } + + var chunk openAIStreamChunk + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue // 跳过格式异常的行 + } + if chunk.Error != nil && chunk.Error.Message != "" { + callback(ai.StreamChunk{Error: fmt.Sprintf("API 错误: %s", chunk.Error.Message), Done: true}) + return nil + } + if len(chunk.Choices) > 0 { + content := chunk.Choices[0].Delta.Content + if content != "" { + receivedContent = true + callback(ai.StreamChunk{Content: content}) + } + if chunk.Choices[0].FinishReason != nil { + callback(ai.StreamChunk{Done: true}) + return nil + } + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("读取 OpenAI 流式响应失败: %w", err) + } + + // 如果流正常结束但没有收到任何内容,可能是 API 响应格式不兼容 + if !receivedContent { + callback(ai.StreamChunk{Error: "未收到任何有效响应内容,请检查 API 端点和模型是否正确", Done: true}) + return nil + } + + callback(ai.StreamChunk{Done: true}) + return nil +} + +func (p *OpenAIProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("序列化请求失败: %w", err) + } + + url := p.baseURL + "/chat/completions" + + // 调试日志 + bodyStr := string(jsonBody) + if len(bodyStr) > 500 { + bodyStr = bodyStr[:500] + "..." + } + fmt.Printf("[OpenAI DEBUG] URL: %s\n", url) + fmt.Printf("[OpenAI DEBUG] BaseURL: %s\n", p.baseURL) + fmt.Printf("[OpenAI DEBUG] Body: %s\n", bodyStr) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey) + + // 自定义 headers(用于兼容各类 OpenAI 兼容服务) + for k, v := range p.config.Headers { + httpReq.Header.Set(k, v) + } + + resp, err := p.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("发送请求到 %s 失败: %w", url, err) + } + + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("OpenAI API 返回错误 (HTTP %d): %s", resp.StatusCode, string(bodyBytes)) + } + + return resp.Body, nil +} diff --git a/internal/ai/provider/openai_test.go b/internal/ai/provider/openai_test.go new file mode 100644 index 0000000..94671a4 --- /dev/null +++ b/internal/ai/provider/openai_test.go @@ -0,0 +1,86 @@ +package provider + +import ( + "GoNavi-Wails/internal/ai" + "testing" +) + +func TestOpenAIProvider_Validate_MissingAPIKey(t *testing.T) { + p, err := NewOpenAIProvider(ai.ProviderConfig{Type: "openai", Model: "gpt-4o"}) + if err != nil { + t.Fatalf("unexpected constructor error: %v", err) + } + if err := p.Validate(); err == nil { + t.Fatal("expected validation error for missing API key") + } +} + +func TestOpenAIProvider_Validate_Valid(t *testing.T) { + p, err := NewOpenAIProvider(ai.ProviderConfig{ + Type: "openai", APIKey: "sk-test-key", Model: "gpt-4o", + }) + if err != nil { + t.Fatalf("unexpected constructor error: %v", err) + } + if err := p.Validate(); err != nil { + t.Fatalf("unexpected validation error: %v", err) + } +} + +func TestOpenAIProvider_Name_Custom(t *testing.T) { + p, _ := NewOpenAIProvider(ai.ProviderConfig{ + Type: "openai", Name: "My OpenAI", APIKey: "sk-test", + }) + if p.Name() != "My OpenAI" { + t.Fatalf("expected name 'My OpenAI', got '%s'", p.Name()) + } +} + +func TestOpenAIProvider_Name_Default(t *testing.T) { + p, _ := NewOpenAIProvider(ai.ProviderConfig{ + Type: "openai", APIKey: "sk-test", + }) + if p.Name() != "OpenAI" { + t.Fatalf("expected default name 'OpenAI', got '%s'", p.Name()) + } +} + +func TestOpenAIProvider_DefaultBaseURL(t *testing.T) { + p, _ := NewOpenAIProvider(ai.ProviderConfig{ + Type: "openai", APIKey: "sk-test", Model: "gpt-4o", + }) + op := p.(*OpenAIProvider) + if op.baseURL != "https://api.openai.com/v1" { + t.Fatalf("expected default base URL, got '%s'", op.baseURL) + } +} + +func TestOpenAIProvider_CustomBaseURL(t *testing.T) { + p, _ := NewOpenAIProvider(ai.ProviderConfig{ + Type: "openai", APIKey: "sk-test", BaseURL: "https://my-proxy.com/v1", + }) + op := p.(*OpenAIProvider) + if op.baseURL != "https://my-proxy.com/v1" { + t.Fatalf("expected custom base URL, got '%s'", op.baseURL) + } +} + +func TestOpenAIProvider_DefaultModel(t *testing.T) { + p, _ := NewOpenAIProvider(ai.ProviderConfig{ + Type: "openai", APIKey: "sk-test", + }) + op := p.(*OpenAIProvider) + if op.config.Model != "gpt-4o" { + t.Fatalf("expected default model 'gpt-4o', got '%s'", op.config.Model) + } +} + +func TestOpenAIProvider_DefaultMaxTokens(t *testing.T) { + p, _ := NewOpenAIProvider(ai.ProviderConfig{ + Type: "openai", APIKey: "sk-test", + }) + op := p.(*OpenAIProvider) + if op.config.MaxTokens != 4096 { + t.Fatalf("expected default max tokens 4096, got %d", op.config.MaxTokens) + } +} diff --git a/internal/ai/provider/provider.go b/internal/ai/provider/provider.go new file mode 100644 index 0000000..e9f1d8e --- /dev/null +++ b/internal/ai/provider/provider.go @@ -0,0 +1,19 @@ +package provider + +import ( + "context" + + "GoNavi-Wails/internal/ai" +) + +// Provider AI 模型提供者接口 +type Provider interface { + // Chat 发送消息并获取完整响应 + Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) + // ChatStream 发送消息并以流式返回 + ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error + // Name 返回 Provider 名称 + Name() string + // Validate 校验配置是否有效 + Validate() error +} diff --git a/internal/ai/provider/registry.go b/internal/ai/provider/registry.go new file mode 100644 index 0000000..2cd0c08 --- /dev/null +++ b/internal/ai/provider/registry.go @@ -0,0 +1,25 @@ +package provider + +import ( + "fmt" + "strings" + + "GoNavi-Wails/internal/ai" +) + +// NewProvider 根据配置创建 Provider 实例 +func NewProvider(config ai.ProviderConfig) (Provider, error) { + providerType := strings.ToLower(strings.TrimSpace(config.Type)) + switch providerType { + case "openai": + return NewOpenAIProvider(config) + case "anthropic": + return NewAnthropicProvider(config) + case "gemini": + return NewGeminiProvider(config) + case "custom": + return NewCustomProvider(config) + default: + return nil, fmt.Errorf("不支持的 AI Provider 类型: %s", config.Type) + } +} diff --git a/internal/ai/safety/classifier.go b/internal/ai/safety/classifier.go new file mode 100644 index 0000000..dfd9816 --- /dev/null +++ b/internal/ai/safety/classifier.go @@ -0,0 +1,101 @@ +package safety + +import ( + "strings" + "unicode" + + "GoNavi-Wails/internal/ai" +) + +// ClassifySQL 分类 SQL 语句的操作类型 +func ClassifySQL(sql string) ai.SQLOperationType { + keyword := leadingSQLKeyword(sql) + switch keyword { + case "select", "with", "show", "describe", "desc", "explain", "pragma", "values": + return ai.SQLOpQuery + case "insert", "update", "delete", "replace", "merge", "upsert": + return ai.SQLOpDML + case "create", "alter", "drop", "truncate", "rename": + return ai.SQLOpDDL + default: + return ai.SQLOpOther + } +} + +// IsHighRiskSQL 判断 SQL 是否为高风险语句 +func IsHighRiskSQL(sql string) (bool, string) { + keyword := leadingSQLKeyword(sql) + normalized := strings.ToLower(sql) + + switch keyword { + case "drop": + return true, "⚠️ 高危操作:DROP 语句将永久删除数据库对象" + case "truncate": + return true, "⚠️ 高危操作:TRUNCATE 将清空表中所有数据" + case "delete": + if !containsWhereClause(normalized) { + return true, "⚠️ 高危操作:DELETE 语句缺少 WHERE 条件,将删除所有数据" + } + case "update": + if !containsWhereClause(normalized) { + return true, "⚠️ 高危操作:UPDATE 语句缺少 WHERE 条件,将更新所有记录" + } + } + + return false, "" +} + +// containsWhereClause 简单判断 SQL 是否包含 WHERE 子句 +func containsWhereClause(normalizedSQL string) bool { + return strings.Contains(normalizedSQL, " where ") || + strings.Contains(normalizedSQL, "\nwhere ") || + strings.Contains(normalizedSQL, "\twhere ") +} + +// leadingSQLKeyword 提取 SQL 语句的首个关键字(跳过注释和空白) +func leadingSQLKeyword(query string) string { + text := strings.TrimSpace(query) + for len(text) > 0 { + trimmed := strings.TrimLeft(text, " \t\r\n") + if trimmed == "" { + return "" + } + text = trimmed + + switch { + case strings.HasPrefix(text, "--"): + if idx := strings.IndexByte(text, '\n'); idx >= 0 { + text = text[idx+1:] + continue + } + return "" + case strings.HasPrefix(text, "#"): + if idx := strings.IndexByte(text, '\n'); idx >= 0 { + text = text[idx+1:] + continue + } + return "" + case strings.HasPrefix(text, "/*"): + if idx := strings.Index(text, "*/"); idx >= 0 { + text = text[idx+2:] + continue + } + return "" + } + break + } + + if text == "" { + return "" + } + for i, r := range text { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' { + continue + } + if i == 0 { + return "" + } + return strings.ToLower(text[:i]) + } + return strings.ToLower(text) +} diff --git a/internal/ai/safety/classifier_test.go b/internal/ai/safety/classifier_test.go new file mode 100644 index 0000000..280b261 --- /dev/null +++ b/internal/ai/safety/classifier_test.go @@ -0,0 +1,145 @@ +package safety + +import ( + "GoNavi-Wails/internal/ai" + "testing" +) + +func TestClassifySQL(t *testing.T) { + tests := []struct { + sql string + want ai.SQLOperationType + }{ + {"SELECT * FROM users", ai.SQLOpQuery}, + {" select id from t", ai.SQLOpQuery}, + {"SHOW TABLES", ai.SQLOpQuery}, + {"DESCRIBE users", ai.SQLOpQuery}, + {"DESC users", ai.SQLOpQuery}, + {"EXPLAIN SELECT 1", ai.SQLOpQuery}, + {"WITH cte AS (SELECT 1) SELECT * FROM cte", ai.SQLOpQuery}, + {"PRAGMA table_info(t)", ai.SQLOpQuery}, + {"VALUES (1, 2)", ai.SQLOpQuery}, + {"INSERT INTO users VALUES (1)", ai.SQLOpDML}, + {"UPDATE users SET name='x'", ai.SQLOpDML}, + {"DELETE FROM users WHERE id=1", ai.SQLOpDML}, + {"REPLACE INTO users VALUES (1)", ai.SQLOpDML}, + {"MERGE INTO t USING s ON t.id=s.id", ai.SQLOpDML}, + {"CREATE TABLE t (id INT)", ai.SQLOpDDL}, + {"ALTER TABLE t ADD col INT", ai.SQLOpDDL}, + {"DROP TABLE t", ai.SQLOpDDL}, + {"TRUNCATE TABLE t", ai.SQLOpDDL}, + {"RENAME TABLE old TO new", ai.SQLOpDDL}, + {"/* comment */ SELECT 1", ai.SQLOpQuery}, + {"-- comment\nDELETE FROM t", ai.SQLOpDML}, + {"-- line1\n-- line2\nSELECT 1", ai.SQLOpQuery}, + {"/* block */ -- line\nUPDATE t SET x=1", ai.SQLOpDML}, + {"", ai.SQLOpOther}, + {" ", ai.SQLOpOther}, + {"-- only comment", ai.SQLOpOther}, + } + for _, tt := range tests { + got := ClassifySQL(tt.sql) + if got != tt.want { + t.Errorf("ClassifySQL(%q) = %s, want %s", tt.sql, got, tt.want) + } + } +} + +func TestIsHighRiskSQL(t *testing.T) { + tests := []struct { + sql string + highRisk bool + }{ + {"DROP TABLE users", true}, + {"DROP DATABASE test", true}, + {"TRUNCATE TABLE users", true}, + {"DELETE FROM users", true}, // 无 WHERE + {"DELETE FROM users WHERE id=1", false}, // 有 WHERE + {"UPDATE users SET name='x'", true}, // 无 WHERE + {"UPDATE users SET name='x' WHERE id=1", false}, // 有 WHERE + {"SELECT * FROM users", false}, + {"INSERT INTO users VALUES (1)", false}, + } + for _, tt := range tests { + highRisk, _ := IsHighRiskSQL(tt.sql) + if highRisk != tt.highRisk { + t.Errorf("IsHighRiskSQL(%q) = %v, want %v", tt.sql, highRisk, tt.highRisk) + } + } +} + +func TestGuard_ReadOnly(t *testing.T) { + g := NewGuard(ai.PermissionReadOnly) + tests := []struct { + sql string + allowed bool + }{ + {"SELECT * FROM t", true}, + {"INSERT INTO t VALUES (1)", false}, + {"UPDATE t SET x=1", false}, + {"DELETE FROM t", false}, + {"DROP TABLE t", false}, + {"CREATE TABLE t (id INT)", false}, + } + for _, tt := range tests { + result := g.Check(tt.sql) + if result.Allowed != tt.allowed { + t.Errorf("Guard[readonly].Check(%q).Allowed = %v, want %v", tt.sql, result.Allowed, tt.allowed) + } + } +} + +func TestGuard_ReadWrite(t *testing.T) { + g := NewGuard(ai.PermissionReadWrite) + tests := []struct { + sql string + allowed bool + confirm bool + }{ + {"SELECT * FROM t", true, false}, + {"INSERT INTO t VALUES (1)", true, true}, + {"UPDATE t SET x=1", true, true}, // 允许但需确认 + {"DELETE FROM t WHERE id=1", true, true}, // 允许但需确认 + {"DROP TABLE t", false, true}, // DDL 不允许 + {"CREATE TABLE t (id INT)", false, true}, + } + for _, tt := range tests { + result := g.Check(tt.sql) + if result.Allowed != tt.allowed { + t.Errorf("Guard[readwrite].Check(%q).Allowed = %v, want %v", tt.sql, result.Allowed, tt.allowed) + } + if result.RequiresConfirm != tt.confirm { + t.Errorf("Guard[readwrite].Check(%q).RequiresConfirm = %v, want %v", tt.sql, result.RequiresConfirm, tt.confirm) + } + } +} + +func TestGuard_Full(t *testing.T) { + g := NewGuard(ai.PermissionFull) + tests := []struct { + sql string + allowed bool + }{ + {"SELECT * FROM t", true}, + {"INSERT INTO t VALUES (1)", true}, + {"DROP TABLE t", true}, + {"CREATE TABLE t (id INT)", true}, + } + for _, tt := range tests { + result := g.Check(tt.sql) + if result.Allowed != tt.allowed { + t.Errorf("Guard[full].Check(%q).Allowed = %v, want %v", tt.sql, result.Allowed, tt.allowed) + } + } +} + +func TestGuard_HighRiskWarning(t *testing.T) { + g := NewGuard(ai.PermissionFull) + result := g.Check("DELETE FROM users") + if result.WarningMessage == "" { + t.Error("expected high-risk warning for DELETE without WHERE") + } + if !result.RequiresConfirm { + t.Error("expected RequiresConfirm for high-risk SQL") + } +} diff --git a/internal/ai/safety/guard.go b/internal/ai/safety/guard.go new file mode 100644 index 0000000..ca31bf2 --- /dev/null +++ b/internal/ai/safety/guard.go @@ -0,0 +1,71 @@ +package safety + +import ( + "GoNavi-Wails/internal/ai" +) + +// Guard AI SQL 安全策略守卫 +type Guard struct { + permissionLevel ai.SQLPermissionLevel +} + +// NewGuard 创建安全策略守卫 +func NewGuard(level ai.SQLPermissionLevel) *Guard { + return &Guard{permissionLevel: level} +} + +// SetPermissionLevel 设置权限级别 +func (g *Guard) SetPermissionLevel(level ai.SQLPermissionLevel) { + g.permissionLevel = level +} + +// GetPermissionLevel 获取当前权限级别 +func (g *Guard) GetPermissionLevel() ai.SQLPermissionLevel { + return g.permissionLevel +} + +// Check 检查 AI 生成的 SQL 是否在允许范围内 +func (g *Guard) Check(sql string) ai.SafetyResult { + opType := ClassifySQL(sql) + allowed := g.isAllowed(opType) + requiresConfirm := g.requiresConfirmation(opType) + warningMessage := "" + + if isHighRisk, msg := IsHighRiskSQL(sql); isHighRisk { + warningMessage = msg + requiresConfirm = true + } + + return ai.SafetyResult{ + Allowed: allowed, + OperationType: opType, + RequiresConfirm: requiresConfirm, + WarningMessage: warningMessage, + } +} + +func (g *Guard) isAllowed(opType ai.SQLOperationType) bool { + switch g.permissionLevel { + case ai.PermissionReadOnly: + return opType == ai.SQLOpQuery + case ai.PermissionReadWrite: + return opType == ai.SQLOpQuery || opType == ai.SQLOpDML + case ai.PermissionFull: + return opType == ai.SQLOpQuery || opType == ai.SQLOpDML || opType == ai.SQLOpDDL + default: + return opType == ai.SQLOpQuery + } +} + +func (g *Guard) requiresConfirmation(opType ai.SQLOperationType) bool { + switch opType { + case ai.SQLOpQuery: + return false + case ai.SQLOpDML: + return true // DML 始终需要确认 + case ai.SQLOpDDL: + return true // DDL 始终需要确认 + default: + return true + } +} diff --git a/internal/ai/service/service.go b/internal/ai/service/service.go new file mode 100644 index 0000000..52af6ba --- /dev/null +++ b/internal/ai/service/service.go @@ -0,0 +1,573 @@ +package aiservice + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "GoNavi-Wails/internal/ai" + aicontext "GoNavi-Wails/internal/ai/context" + "GoNavi-Wails/internal/ai/provider" + "GoNavi-Wails/internal/ai/safety" + "GoNavi-Wails/internal/logger" + + "github.com/google/uuid" + wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// Service AI 服务,作为 Wails Binding 暴露给前端 +type Service struct { + ctx context.Context + mu sync.RWMutex + providers []ai.ProviderConfig + activeProvider string // active provider ID + safetyLevel ai.SQLPermissionLevel + contextLevel ai.ContextLevel + guard *safety.Guard + configDir string // 配置存储目录 + cancelFuncs map[string]context.CancelFunc // 记录每个 session 的 context 取消函数 +} + +// NewService 创建 AI Service 实例 +func NewService() *Service { + return &Service{ + providers: make([]ai.ProviderConfig, 0), + safetyLevel: ai.PermissionReadOnly, + contextLevel: ai.ContextSchemaOnly, + guard: safety.NewGuard(ai.PermissionReadOnly), + cancelFuncs: make(map[string]context.CancelFunc), + } +} + +// Startup Wails 生命周期回调 +func (s *Service) Startup(ctx context.Context) { + s.ctx = ctx + s.configDir = resolveConfigDir() + s.loadConfig() + logger.Infof("AI Service 启动完成,已加载 %d 个 Provider", len(s.providers)) +} + +// --- Provider 管理 --- + +// AIGetProviders 获取所有 Provider 配置 +func (s *Service) AIGetProviders() []ai.ProviderConfig { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]ai.ProviderConfig, len(s.providers)) + copy(result, s.providers) + return result +} + +// AISaveProvider 保存/更新 Provider 配置 +func (s *Service) AISaveProvider(config ai.ProviderConfig) error { + fmt.Printf("[AISaveProvider DEBUG] ID: %s, Model: %s\n", config.ID, config.Model) + s.mu.Lock() + defer s.mu.Unlock() + + if strings.TrimSpace(config.ID) == "" { + config.ID = "provider-" + uuid.New().String()[:8] + } + + found := false + for i, p := range s.providers { + if p.ID == config.ID { + s.providers[i] = config + found = true + break + } + } + if !found { + s.providers = append(s.providers, config) + } + + return s.saveConfig() +} + +// AIDeleteProvider 删除 Provider +func (s *Service) AIDeleteProvider(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + newProviders := make([]ai.ProviderConfig, 0, len(s.providers)) + for _, p := range s.providers { + if p.ID != id { + newProviders = append(newProviders, p) + } + } + s.providers = newProviders + + if s.activeProvider == id { + s.activeProvider = "" + if len(s.providers) > 0 { + s.activeProvider = s.providers[0].ID + } + } + + return s.saveConfig() +} + +// AITestProvider 测试 Provider 配置是否可用 +func (s *Service) AITestProvider(config ai.ProviderConfig) map[string]interface{} { + // 如果传入脱敏的 key,使用已保存的 key + s.mu.RLock() + if isMaskedAPIKey(config.APIKey) { + for _, p := range s.providers { + if p.ID == config.ID { + config.APIKey = p.APIKey + break + } + } + } + s.mu.RUnlock() + + p, err := provider.NewProvider(config) + if err != nil { + return map[string]interface{}{"success": false, "message": err.Error()} + } + if err := p.Validate(); err != nil { + return map[string]interface{}{"success": false, "message": err.Error()} + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*1000*1000*1000) // 30s + defer cancel() + + resp, err := p.Chat(ctx, ai.ChatRequest{ + Messages: []ai.Message{ + {Role: "user", Content: "Hi, please respond with 'OK' to confirm the connection is working."}, + }, + MaxTokens: 10, + }) + if err != nil { + return map[string]interface{}{"success": false, "message": fmt.Sprintf("连接测试失败: %s", err.Error())} + } + + return map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("连接成功!模型响应: %s", truncateString(resp.Content, 100)), + } +} + +// AISetActiveProvider 设置活动 Provider +func (s *Service) AISetActiveProvider(id string) { + s.mu.Lock() + defer s.mu.Unlock() + s.activeProvider = id + _ = s.saveConfig() +} + +// AIGetActiveProvider 获取活动 Provider ID +func (s *Service) AIGetActiveProvider() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.activeProvider +} + +// AIGetBuiltinPrompts 返回内部置的各类系统提示词,用于前端展示或查询 +func (s *Service) AIGetBuiltinPrompts() map[string]string { + return aicontext.GetBuiltinPrompts() +} + +// AIListModels 获取当前活跃 Provider 的可用模型列表 +func (s *Service) AIListModels() map[string]interface{} { + s.mu.RLock() + var config ai.ProviderConfig + found := false + for _, p := range s.providers { + if p.ID == s.activeProvider { + config = p + found = true + break + } + } + s.mu.RUnlock() + + if !found { + return map[string]interface{}{"success": false, "models": []string{}, "error": "未找到活跃 Provider"} + } + + models, err := fetchModels(config) + if err != nil { + // 回退到配置中的静态模型列表 + if len(config.Models) > 0 { + return map[string]interface{}{"success": true, "models": config.Models, "source": "static"} + } + return map[string]interface{}{"success": false, "models": []string{}, "error": err.Error()} + } + + return map[string]interface{}{"success": true, "models": models, "source": "api"} +} + +// fetchModels 从供应商 API 获取可用模型列表 +func fetchModels(config ai.ProviderConfig) ([]string, error) { + providerType := config.Type + if providerType == "custom" && config.APIFormat != "" { + providerType = config.APIFormat + } + + switch providerType { + case "openai": + return fetchOpenAIModels(config) + case "anthropic": + // Anthropic 没有公开的 /models 端点,返回硬编码列表 + return []string{"claude-opus-4-6", "claude-sonnet-4-6"}, nil + case "gemini": + return fetchGeminiModels(config) + default: + return fetchOpenAIModels(config) + } +} + +// fetchOpenAIModels 获取 OpenAI 兼容 API 的模型列表 +func fetchOpenAIModels(config ai.ProviderConfig) ([]string, error) { + baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/") + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } + // 确保 baseURL 以 /v1 结尾 + if !strings.HasSuffix(baseURL, "/v1") { + baseURL = baseURL + "/v1" + } + + req, err := http.NewRequest("GET", baseURL+"/models", nil) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + req.Header.Set("Authorization", "Bearer "+config.APIKey) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("请求模型列表失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("获取模型列表失败 (HTTP %d): %s", resp.StatusCode, string(body)) + } + + var result struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("解析模型列表失败: %w", err) + } + + models := make([]string, 0, len(result.Data)) + for _, m := range result.Data { + models = append(models, m.ID) + } + return models, nil +} + +// fetchGeminiModels 获取 Gemini API 的模型列表 +func fetchGeminiModels(config ai.ProviderConfig) ([]string, error) { + baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/") + if baseURL == "" { + baseURL = "https://generativelanguage.googleapis.com" + } + + req, err := http.NewRequest("GET", baseURL+"/v1beta/models?key="+config.APIKey, nil) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("请求模型列表失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("获取模型列表失败 (HTTP %d): %s", resp.StatusCode, string(body)) + } + + var result struct { + Models []struct { + Name string `json:"name"` // e.g. "models/gemini-2.5-flash" + } `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("解析模型列表失败: %w", err) + } + + models := make([]string, 0, len(result.Models)) + for _, m := range result.Models { + // 去掉 "models/" 前缀 + name := m.Name + if strings.HasPrefix(name, "models/") { + name = strings.TrimPrefix(name, "models/") + } + models = append(models, name) + } + return models, nil +} + +// --- 安全控制 --- + +// AIGetSafetyLevel 获取当前安全级别 +func (s *Service) AIGetSafetyLevel() string { + s.mu.RLock() + defer s.mu.RUnlock() + return string(s.safetyLevel) +} + +// AISetSafetyLevel 设置安全级别 +func (s *Service) AISetSafetyLevel(level string) { + s.mu.Lock() + defer s.mu.Unlock() + + switch ai.SQLPermissionLevel(level) { + case ai.PermissionReadOnly, ai.PermissionReadWrite, ai.PermissionFull: + s.safetyLevel = ai.SQLPermissionLevel(level) + default: + s.safetyLevel = ai.PermissionReadOnly + } + s.guard.SetPermissionLevel(s.safetyLevel) + _ = s.saveConfig() +} + +// --- 上下文控制 --- + +// AIGetContextLevel 获取上下文传递级别 +func (s *Service) AIGetContextLevel() string { + s.mu.RLock() + defer s.mu.RUnlock() + return string(s.contextLevel) +} + +// AISetContextLevel 设置上下文传递级别 +func (s *Service) AISetContextLevel(level string) { + s.mu.Lock() + defer s.mu.Unlock() + + switch ai.ContextLevel(level) { + case ai.ContextSchemaOnly, ai.ContextWithSamples, ai.ContextWithResults: + s.contextLevel = ai.ContextLevel(level) + default: + s.contextLevel = ai.ContextSchemaOnly + } + _ = s.saveConfig() +} + +// --- AI 对话 --- + +// AIChatSend 同步发送 AI 对话(非流式) +func (s *Service) AIChatSend(messages []map[string]string) map[string]interface{} { + p, err := s.getActiveProvider() + if err != nil { + return map[string]interface{}{"success": false, "error": err.Error()} + } + + var aiMessages []ai.Message + for _, m := range messages { + aiMessages = append(aiMessages, ai.Message{Role: m["role"], Content: m["content"]}) + } + + resp, err := p.Chat(context.Background(), ai.ChatRequest{Messages: aiMessages}) + if err != nil { + return map[string]interface{}{"success": false, "error": err.Error()} + } + + return map[string]interface{}{ + "success": true, + "content": resp.Content, + "tokensUsed": map[string]int{ + "promptTokens": resp.TokensUsed.PromptTokens, + "completionTokens": resp.TokensUsed.CompletionTokens, + "totalTokens": resp.TokensUsed.TotalTokens, + }, + } +} + +// AIChatStream 流式发送 AI 对话(通过 EventsEmit 推送) +func (s *Service) AIChatStream(sessionID string, messages []map[string]string) { + streamCtx, cancel := context.WithCancel(context.Background()) + s.mu.Lock() + s.cancelFuncs[sessionID] = cancel + s.mu.Unlock() + + go func() { + defer func() { + s.mu.Lock() + delete(s.cancelFuncs, sessionID) + s.mu.Unlock() + cancel() // 确保释放 + }() + + p, err := s.getActiveProvider() + if err != nil { + wailsRuntime.EventsEmit(s.ctx, "ai:stream:"+sessionID, map[string]interface{}{ + "error": err.Error(), + "done": true, + }) + return + } + + var aiMessages []ai.Message + for _, m := range messages { + aiMessages = append(aiMessages, ai.Message{Role: m["role"], Content: m["content"]}) + } + + err = p.ChatStream(streamCtx, ai.ChatRequest{Messages: aiMessages}, func(chunk ai.StreamChunk) { + wailsRuntime.EventsEmit(s.ctx, "ai:stream:"+sessionID, map[string]interface{}{ + "content": chunk.Content, + "done": chunk.Done, + "error": chunk.Error, + }) + }) + + // 当 context 被主动 cancel 的时候,不把这个视为向外抛的 error + if err != nil && err != context.Canceled { + wailsRuntime.EventsEmit(s.ctx, "ai:stream:"+sessionID, map[string]interface{}{ + "error": err.Error(), + "done": true, + }) + } + }() +} + +// AIChatCancel 立即终止某个 Session 的流式对话请求 +func (s *Service) AIChatCancel(sessionID string) { + s.mu.RLock() + cancel, ok := s.cancelFuncs[sessionID] + s.mu.RUnlock() + if ok && cancel != nil { + cancel() + } +} + +// AICheckSQL 检查 SQL 的安全性 +func (s *Service) AICheckSQL(sql string) ai.SafetyResult { + s.mu.RLock() + defer s.mu.RUnlock() + return s.guard.Check(sql) +} + +// --- 内部方法 --- + +func (s *Service) getActiveProvider() (provider.Provider, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.activeProvider == "" && len(s.providers) > 0 { + s.activeProvider = s.providers[0].ID + } + + for _, cfg := range s.providers { + if cfg.ID == s.activeProvider { + return provider.NewProvider(cfg) + } + } + + return nil, fmt.Errorf("未配置 AI Provider,请先在设置中配置") +} + +// --- 配置持久化 --- + +type aiConfig struct { + Providers []ai.ProviderConfig `json:"providers"` + ActiveProvider string `json:"activeProvider"` + SafetyLevel string `json:"safetyLevel"` + ContextLevel string `json:"contextLevel"` +} + +func (s *Service) loadConfig() { + path := filepath.Join(s.configDir, "ai_config.json") + data, err := os.ReadFile(path) + if err != nil { + return // 首次启动,无配置文件 + } + + var cfg aiConfig + if err := json.Unmarshal(data, &cfg); err != nil { + logger.Error(err, "加载 AI 配置失败") + return + } + + s.providers = cfg.Providers + if s.providers == nil { + s.providers = make([]ai.ProviderConfig, 0) + } + s.activeProvider = cfg.ActiveProvider + + switch ai.SQLPermissionLevel(cfg.SafetyLevel) { + case ai.PermissionReadOnly, ai.PermissionReadWrite, ai.PermissionFull: + s.safetyLevel = ai.SQLPermissionLevel(cfg.SafetyLevel) + default: + s.safetyLevel = ai.PermissionReadOnly + } + s.guard.SetPermissionLevel(s.safetyLevel) + + switch ai.ContextLevel(cfg.ContextLevel) { + case ai.ContextSchemaOnly, ai.ContextWithSamples, ai.ContextWithResults: + s.contextLevel = ai.ContextLevel(cfg.ContextLevel) + default: + s.contextLevel = ai.ContextSchemaOnly + } +} + +func (s *Service) saveConfig() error { + cfg := aiConfig{ + Providers: s.providers, + ActiveProvider: s.activeProvider, + SafetyLevel: string(s.safetyLevel), + ContextLevel: string(s.contextLevel), + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("序列化 AI 配置失败: %w", err) + } + + if err := os.MkdirAll(s.configDir, 0o755); err != nil { + return fmt.Errorf("创建配置目录失败: %w", err) + } + + path := filepath.Join(s.configDir, "ai_config.json") + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("写入 AI 配置失败: %w", err) + } + + return nil +} + +// --- 工具函数 --- + +func resolveConfigDir() string { + configDir, err := os.UserConfigDir() + if err != nil { + configDir = "." + } + return filepath.Join(configDir, "GoNavi") +} + +func maskAPIKey(apiKey string) string { + if len(apiKey) <= 8 { + return "****" + } + return apiKey[:4] + "****" + apiKey[len(apiKey)-4:] +} + +func isMaskedAPIKey(apiKey string) bool { + return strings.Contains(apiKey, "****") +} + +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/internal/ai/types.go b/internal/ai/types.go new file mode 100644 index 0000000..eb55a6f --- /dev/null +++ b/internal/ai/types.go @@ -0,0 +1,85 @@ +package ai + +// Message 表示一条对话消息 +type Message struct { + Role string `json:"role"` // "system" | "user" | "assistant" + Content string `json:"content"` +} + +// ChatRequest AI 对话请求 +type ChatRequest struct { + Messages []Message `json:"messages"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"maxTokens"` +} + +// ChatResponse AI 对话响应 +type ChatResponse struct { + Content string `json:"content"` + TokensUsed TokenUsage `json:"tokensUsed"` +} + +// TokenUsage token 用量统计 +type TokenUsage struct { + PromptTokens int `json:"promptTokens"` + CompletionTokens int `json:"completionTokens"` + TotalTokens int `json:"totalTokens"` +} + +// StreamChunk 流式响应片段 +type StreamChunk struct { + Content string `json:"content"` + Done bool `json:"done"` + Error string `json:"error,omitempty"` +} + +// ProviderConfig AI Provider 配置 +type ProviderConfig struct { + ID string `json:"id"` + Type string `json:"type"` // openai | anthropic | gemini | custom + Name string `json:"name"` + APIKey string `json:"apiKey"` + BaseURL string `json:"baseUrl"` + Model string `json:"model"` + Models []string `json:"models,omitempty"` + APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini + Headers map[string]string `json:"headers,omitempty"` + MaxTokens int `json:"maxTokens"` + Temperature float64 `json:"temperature"` +} + +// SQLPermissionLevel AI SQL 执行权限级别 +type SQLPermissionLevel string + +const ( + PermissionReadOnly SQLPermissionLevel = "readonly" + PermissionReadWrite SQLPermissionLevel = "readwrite" + PermissionFull SQLPermissionLevel = "full" +) + +// ContextLevel AI 上下文传递级别 +type ContextLevel string + +const ( + ContextSchemaOnly ContextLevel = "schema_only" + ContextWithSamples ContextLevel = "with_samples" + ContextWithResults ContextLevel = "with_results" +) + +// SQLOperationType SQL 操作类型 +type SQLOperationType string + +const ( + SQLOpQuery SQLOperationType = "query" // SELECT, SHOW, DESCRIBE, EXPLAIN + SQLOpDML SQLOperationType = "dml" // INSERT, UPDATE, DELETE + SQLOpDDL SQLOperationType = "ddl" // CREATE, ALTER, DROP, TRUNCATE + SQLOpOther SQLOperationType = "other" +) + +// SafetyResult 安全检查结果 +type SafetyResult struct { + Allowed bool `json:"allowed"` + OperationType SQLOperationType `json:"operationType"` + RequiresConfirm bool `json:"requiresConfirm"` + WarningMessage string `json:"warningMessage,omitempty"` +} diff --git a/main.go b/main.go index 02cedcb..4e3bc59 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,10 @@ package main import ( + "context" "embed" + aiservice "GoNavi-Wails/internal/ai/service" "GoNavi-Wails/internal/app" "GoNavi-Wails/internal/logger" @@ -19,6 +21,7 @@ var assets embed.FS func main() { // Create an instance of the app structure application := app.NewApp() + aiService := aiservice.NewService() // Create application with options err := wails.Run(&options.App{ @@ -30,10 +33,14 @@ func main() { Assets: assets, }, BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 0}, - OnStartup: application.Startup, - OnShutdown: application.Shutdown, + OnStartup: func(ctx context.Context) { + application.Startup(ctx) + aiService.Startup(ctx) + }, + OnShutdown: application.Shutdown, Bind: []interface{}{ application, + aiService, }, Windows: &windows.Options{ WebviewIsTransparent: true,