From 2677364d0ed90760a869bd7b25b142e714b749c8 Mon Sep 17 00:00:00 2001 From: DurianPankek Date: Fri, 20 Mar 2026 18:23:16 +0800 Subject: [PATCH 01/14] =?UTF-8?q?=E2=9C=A8=20feat(mac-window):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=88=87=E6=8D=A2=20macOS=20=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E6=8E=A7=E5=88=B6=E4=B8=8E=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E5=85=A8=E5=B1=8F=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 500 +++++++++++++++++- frontend/package.json | 8 +- frontend/package.json.md5 | 2 +- frontend/src/App.tsx | 144 +++-- .../src/components/dataGridLayout.test.ts | 93 +--- .../src/components/redisViewerTree.test.ts | 134 ++--- .../redisViewerWorkbenchTheme.test.ts | 70 +-- frontend/src/store.ts | 15 +- frontend/src/utils/appearance.test.ts | 23 + frontend/src/utils/macWindow.test.ts | 47 ++ frontend/src/utils/macWindow.ts | 42 ++ .../src/utils/overlayWorkbenchTheme.test.ts | 42 +- frontend/wailsjs/go/app/App.d.ts | 2 + frontend/wailsjs/go/app/App.js | 4 + internal/app/app.go | 6 + internal/app/window_style_darwin.go | 70 +++ internal/app/window_style_logic.go | 32 ++ internal/app/window_style_logic_test.go | 37 ++ internal/app/window_style_stub.go | 5 + 19 files changed, 1013 insertions(+), 263 deletions(-) create mode 100644 frontend/src/utils/appearance.test.ts create mode 100644 frontend/src/utils/macWindow.test.ts create mode 100644 frontend/src/utils/macWindow.ts create mode 100644 internal/app/window_style_darwin.go create mode 100644 internal/app/window_style_logic.go create mode 100644 internal/app/window_style_logic_test.go create mode 100644 internal/app/window_style_stub.go diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 40c2497..8eb0dc7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,7 +30,8 @@ "@types/uuid": "^9.0.7", "@vitejs/plugin-react": "^4.2.1", "typescript": "^5.2.2", - "vite": "^5.0.8" + "vite": "^5.0.8", + "vitest": "^3.2.4" } }, "node_modules/@ant-design/colors": { @@ -1513,6 +1514,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1594,6 +1613,121 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/antd": { "version": "5.29.3", "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", @@ -1665,6 +1799,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -1709,6 +1853,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001766", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", @@ -1730,6 +1884,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -1803,6 +1984,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/discontinuous-range": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", @@ -1826,6 +2017,13 @@ "dev": true, "license": "ISC" }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1875,6 +2073,44 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1953,6 +2189,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1963,6 +2206,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/marked": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", @@ -2057,6 +2310,23 @@ "node": ">=0.10.0" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2064,6 +2334,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2892,6 +3175,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2915,18 +3205,52 @@ "sql-formatter": "bin/sql-formatter-cli.cjs" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", "license": "MIT" }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", "license": "MIT" }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stylis": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", @@ -2942,6 +3266,67 @@ "node": ">=12.22" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toggle-selection": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", @@ -3081,6 +3466,119 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2690060..52dbb4a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@ant-design/icons": "^5.2.6", @@ -31,6 +32,7 @@ "@types/uuid": "^9.0.7", "@vitejs/plugin-react": "^4.2.1", "typescript": "^5.2.2", - "vite": "^5.0.8" + "vite": "^5.0.8", + "vitest": "^3.2.4" } -} \ No newline at end of file +} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 0f8f4fe..4df5c44 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -5b8157374dae5f9340e31b2d0bd2c00e \ No newline at end of file +594ebbc6a946fc76ba11bee7b3f53282 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 31cf7c5..5d89559 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import LogPanel from './components/LogPanel'; import { useStore } from './store'; import { SavedConnection } from './types'; import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance'; +import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow'; import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme'; import { SHORTCUT_ACTION_META, @@ -24,7 +25,7 @@ import { isShortcutMatch, normalizeShortcutCombo, } from './utils/shortcuts'; -import { ConfigureGlobalProxy, SetWindowTranslucency } from '../wailsjs/go/app/App'; +import { ConfigureGlobalProxy, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App'; import './App.css'; const { Sider, Content } = Layout; @@ -722,6 +723,19 @@ function App() { || (runtimePlatform === '' && /mac/i.test(detectNavigatorPlatform())); const isWindowsRuntime = runtimePlatform === 'windows' || (runtimePlatform === '' && isWindowsPlatform()); + const useNativeMacWindowControls = isMacRuntime && appearance.useNativeMacWindowControls === true; + + useEffect(() => { + if (!isStoreHydrated || !isMacRuntime) { + return; + } + + try { + void SetMacNativeWindowControls(useNativeMacWindowControls).catch(() => undefined); + } catch (e) { + console.warn('Wails API: SetMacNativeWindowControls unavailable', e); + } + }, [isMacRuntime, isStoreHydrated, useNativeMacWindowControls]); const formatBytes = (bytes?: number) => { if (!bytes || bytes <= 0) return '0 B'; @@ -1169,6 +1183,10 @@ function App() { await WindowUnfullscreen(); return; } + if (useNativeMacWindowControls && isMacRuntime) { + await WindowFullscreen(); + return; + } await WindowToggleMaximise(); } catch (_) { // ignore @@ -1180,6 +1198,9 @@ function App() { if (target?.closest('[data-no-titlebar-toggle="true"]')) { return; } + if (useNativeMacWindowControls) { + return; + } void handleTitleBarWindowToggle(); }; @@ -1330,8 +1351,34 @@ function App() { }; }, []); + useEffect(() => { + if (!isMacRuntime || !useNativeMacWindowControls) { + return; + } + + const handleMacNativeEscapeCapture = (event: KeyboardEvent) => { + if (!shouldSuppressMacNativeEscapeExit(isMacRuntime, useNativeMacWindowControls, useStore.getState().windowState === 'fullscreen', event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + }; + + window.addEventListener('keydown', handleMacNativeEscapeCapture, true); + return () => { + window.removeEventListener('keydown', handleMacNativeEscapeCapture, true); + }; + }, [isMacRuntime, useNativeMacWindowControls]); + useEffect(() => { const handleGlobalShortcut = (event: KeyboardEvent) => { + if (shouldHandleMacNativeFullscreenShortcut(isMacRuntime, useNativeMacWindowControls, event)) { + event.preventDefault(); + event.stopPropagation(); + void handleTitleBarWindowToggle(); + return; + } + const matchedAction = SHORTCUT_ACTION_ORDER.find((action) => { const binding = shortcutOptions[action]; if (!binding?.enabled) { @@ -1376,7 +1423,7 @@ function App() { return () => { window.removeEventListener('keydown', handleGlobalShortcut); }; - }, [handleNewQuery, shortcutOptions, themeMode, setTheme]); + }, [handleNewQuery, handleTitleBarWindowToggle, isMacRuntime, shortcutOptions, themeMode, setTheme, useNativeMacWindowControls]); useEffect(() => { if (!capturingShortcutAction) { @@ -1523,40 +1570,45 @@ function App() { userSelect: 'none', WebkitAppRegion: 'drag', // Wails drag region '--wails-draggable': 'drag', - paddingLeft: Math.max(12, Math.round(16 * effectiveUiScale)), + paddingLeft: getMacNativeTitlebarPaddingLeft(effectiveUiScale, useNativeMacWindowControls), + paddingRight: getMacNativeTitlebarPaddingRight(effectiveUiScale, useNativeMacWindowControls), fontSize: tokenFontSize } as any} > -
+
{/* Logo can be added here if available */} GoNavi
-
e.stopPropagation()} - style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any} - > -
+ {useNativeMacWindowControls ? ( +
+ ) : ( +
e.stopPropagation()} + style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any} + > +
+ )}
@@ -2008,6 +2060,24 @@ function App() {
+ {isMacRuntime ? ( +
+
macOS 窗口控制
+
+
+
使用 macOS 原生窗口控制
+
启用后显示左上角红黄绿按钮,并优先使用 macOS 原生全屏行为。
+
+ setAppearance({ useNativeMacWindowControls: checked })} + /> +
+
+ * 已同步隐藏右上角自定义按钮;如系统窗口样式未立即刷新,可重启应用后再确认 +
+
+ ) : null}
启动窗口
@@ -2020,13 +2090,13 @@ function App() {
diff --git a/frontend/src/components/dataGridLayout.test.ts b/frontend/src/components/dataGridLayout.test.ts index e52b23c..476e863 100644 --- a/frontend/src/components/dataGridLayout.test.ts +++ b/frontend/src/components/dataGridLayout.test.ts @@ -1,69 +1,32 @@ +import { describe, expect, it } from 'vitest'; + import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout'; -const assertEqual = (actual: unknown, expected: unknown, message: string) => { - if (actual !== expected) { - throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`); - } -}; +describe('dataGridLayout helpers', () => { + it('returns zero bottom padding without horizontal overflow', () => { + expect(calculateTableBodyBottomPadding({ + hasHorizontalOverflow: false, + floatingScrollbarHeight: 10, + floatingScrollbarGap: 6, + })).toBe(0); + }); -assertEqual( - calculateTableBodyBottomPadding({ - hasHorizontalOverflow: false, - floatingScrollbarHeight: 10, - floatingScrollbarGap: 6, - }), - 0, - '无横向滚动条时不应增加底部间距' -); + it('adds safe area when horizontal overflow exists', () => { + expect(calculateTableBodyBottomPadding({ + hasHorizontalOverflow: true, + floatingScrollbarHeight: 10, + floatingScrollbarGap: 6, + })).toBe(28); + expect(calculateTableBodyBottomPadding({ + hasHorizontalOverflow: true, + floatingScrollbarHeight: 14, + floatingScrollbarGap: 4, + })).toBe(30); + }); -assertEqual( - calculateTableBodyBottomPadding({ - hasHorizontalOverflow: true, - floatingScrollbarHeight: 10, - floatingScrollbarGap: 6, - }), - 28, - '默认悬浮滚动条应预留滚动条高度、间距和额外安全区' -); - -assertEqual( - calculateTableBodyBottomPadding({ - hasHorizontalOverflow: true, - floatingScrollbarHeight: 14, - floatingScrollbarGap: 4, - }), - 30, - '较粗滚动条场景下应同步放大底部安全区' -); - -assertEqual( - calculateVirtualTableScrollX({ - totalWidth: 646, - tableViewportWidth: 1200, - isMacLike: false, - }), - 1200, - '列总宽小于视口时应按视口宽度返回 scroll.x,避免 header/body 走两套宽度' -); - -assertEqual( - calculateVirtualTableScrollX({ - totalWidth: 646, - tableViewportWidth: 0, - isMacLike: false, - }), - 646, - '未拿到视口宽度时应退回列宽总和' -); - -assertEqual( - calculateVirtualTableScrollX({ - totalWidth: 1200, - tableViewportWidth: 800, - isMacLike: true, - }), - 1202, - 'macOS 横向溢出时仍需额外预留 2px 以稳定滚动轨道' -); - -console.log('dataGridLayout tests passed'); + it('keeps scroll width aligned with viewport or content width', () => { + expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 1200, isMacLike: false })).toBe(1200); + expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 0, isMacLike: false })).toBe(646); + expect(calculateVirtualTableScrollX({ totalWidth: 1200, tableViewportWidth: 800, isMacLike: true })).toBe(1202); + }); +}); diff --git a/frontend/src/components/redisViewerTree.test.ts b/frontend/src/components/redisViewerTree.test.ts index 0db9242..7a558cb 100644 --- a/frontend/src/components/redisViewerTree.test.ts +++ b/frontend/src/components/redisViewerTree.test.ts @@ -1,3 +1,5 @@ +import { describe, expect, it } from 'vitest'; + import type { RedisKeyInfo } from '../types'; import { applyRenamedRedisKeyState, @@ -7,20 +9,6 @@ import { isGroupFullyChecked, } from './redisViewerTree'; -const assert = (condition: unknown, message: string) => { - if (!condition) { - throw new Error(message); - } -}; - -const assertEqual = (actual: unknown, expected: unknown, message: string) => { - const actualText = JSON.stringify(actual); - const expectedText = JSON.stringify(expected); - if (actualText !== expectedText) { - throw new Error(`${message}\nactual: ${actualText}\nexpected: ${expectedText}`); - } -}; - const sampleKeys: RedisKeyInfo[] = [ { key: 'app:user:1', type: 'string', ttl: -1 }, { key: 'app:user:2', type: 'string', ttl: -1 }, @@ -28,78 +16,64 @@ const sampleKeys: RedisKeyInfo[] = [ { key: 'misc', type: 'set', ttl: -1 }, ]; -const tree = buildRedisKeyTree(sampleKeys, true); -const appGroup = tree.treeData.find((node) => node.key === 'group:app'); -const userGroup = appGroup?.children?.find((node) => node.key === 'group:app:user'); +describe('redisViewerTree helpers', () => { + it('builds grouped redis key tree and group selection state', () => { + const tree = buildRedisKeyTree(sampleKeys, true); + const appGroup = tree.treeData.find((node) => node.key === 'group:app'); + const userGroup = appGroup?.children?.find((node) => node.key === 'group:app:user'); -assert(appGroup, '应生成 group:app 节点'); -assert(userGroup, '应生成 group:app:user 节点'); -assertEqual( - appGroup?.descendantRawKeys, - ['app:order:1', 'app:user:1', 'app:user:2'], - 'app 分组应收集全部后代 key' -); + expect(appGroup).toBeTruthy(); + expect(userGroup).toBeTruthy(); + expect(appGroup?.descendantRawKeys).toEqual(['app:order:1', 'app:user:1', 'app:user:2']); -const selectedAfterGroupCheck = applyTreeNodeCheck([], appGroup!, true); -assertEqual( - selectedAfterGroupCheck, - ['app:order:1', 'app:user:1', 'app:user:2'], - '勾选分组应递归选中全部后代 key' -); + const selectedAfterGroupCheck = applyTreeNodeCheck([], appGroup!, true); + expect(selectedAfterGroupCheck).toEqual(['app:order:1', 'app:user:1', 'app:user:2']); -const checkedState = buildCheckedTreeNodeState(selectedAfterGroupCheck, tree); -assertEqual( - checkedState.checked, - ['key:app:order:1', 'group:app:order', 'key:app:user:1', 'key:app:user:2', 'group:app:user', 'group:app'], - '全部后代已选中时,父分组和叶子都应进入 checked' -); -assertEqual(checkedState.halfChecked, [], '全部后代已选中时不应有 halfChecked'); -assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupCheck), true, '全部后代已选中时,分组应视为 fully checked'); + const checkedState = buildCheckedTreeNodeState(selectedAfterGroupCheck, tree); + expect(checkedState.checked).toEqual(['key:app:order:1', 'group:app:order', 'key:app:user:1', 'key:app:user:2', 'group:app:user', 'group:app']); + expect(checkedState.halfChecked).toEqual([]); + expect(isGroupFullyChecked(appGroup!, selectedAfterGroupCheck)).toBe(true); -const selectedAfterGroupUncheck = applyTreeNodeCheck(selectedAfterGroupCheck, appGroup!, false); -assertEqual(selectedAfterGroupUncheck, [], '取消勾选分组应移除全部后代 key'); -assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupUncheck), false, '取消后分组不应再是 fully checked'); + const selectedAfterGroupUncheck = applyTreeNodeCheck(selectedAfterGroupCheck, appGroup!, false); + expect(selectedAfterGroupUncheck).toEqual([]); + expect(isGroupFullyChecked(appGroup!, selectedAfterGroupUncheck)).toBe(false); + }); -const partialState = buildCheckedTreeNodeState(['app:user:1'], tree); -assertEqual( - partialState.halfChecked, - ['group:app:user', 'group:app'], - '仅部分后代选中时,相关分组应进入 halfChecked' -); -assertEqual(isGroupFullyChecked(appGroup!, ['app:user:1']), false, '部分选中时分组不应是 fully checked'); + it('marks parent groups as half checked for partial selection', () => { + const tree = buildRedisKeyTree(sampleKeys, true); + const appGroup = tree.treeData.find((node) => node.key === 'group:app'); + const partialState = buildCheckedTreeNodeState(['app:user:1'], tree); -const renamedState = applyRenamedRedisKeyState( - { - keys: sampleKeys, - selectedKey: 'app:user:2', - selectedKeys: ['app:user:1', 'app:user:2', 'misc'], - }, - 'app:user:2', - 'app:user:200' -); + expect(partialState.halfChecked).toEqual(['group:app:user', 'group:app']); + expect(isGroupFullyChecked(appGroup!, ['app:user:1'])).toBe(false); + }); -assertEqual( - renamedState.keys.map((item) => item.key), - ['app:user:1', 'app:user:200', 'app:order:1', 'misc'], - '重命名后 keys 列表应替换旧 key' -); -assertEqual(renamedState.selectedKey, 'app:user:200', '当前详情选中的 key 应切换为新 key'); -assertEqual( - renamedState.selectedKeys, - ['app:user:1', 'app:user:200', 'misc'], - '批量选中集合中的旧 key 应映射为新 key' -); + it('updates selected keys consistently after rename', () => { + const renamedState = applyRenamedRedisKeyState( + { + keys: sampleKeys, + selectedKey: 'app:user:2', + selectedKeys: ['app:user:1', 'app:user:2', 'misc'], + }, + 'app:user:2', + 'app:user:200' + ); -const unrelatedRenameState = applyRenamedRedisKeyState( - { - keys: sampleKeys, - selectedKey: 'misc', - selectedKeys: ['app:user:1'], - }, - 'app:order:1', - 'app:order:9' -); -assertEqual(unrelatedRenameState.selectedKey, 'misc', '非当前详情 key 的重命名不应影响 selectedKey'); -assertEqual(unrelatedRenameState.selectedKeys, ['app:user:1'], '非已勾选 key 的重命名不应污染选中集合'); + expect(renamedState.keys.map((item) => item.key)).toEqual(['app:user:1', 'app:user:200', 'app:order:1', 'misc']); + expect(renamedState.selectedKey).toBe('app:user:200'); + expect(renamedState.selectedKeys).toEqual(['app:user:1', 'app:user:200', 'misc']); -console.log('redisViewerTree tests passed'); + const unrelatedRenameState = applyRenamedRedisKeyState( + { + keys: sampleKeys, + selectedKey: 'misc', + selectedKeys: ['app:user:1'], + }, + 'app:order:1', + 'app:order:9' + ); + + expect(unrelatedRenameState.selectedKey).toBe('misc'); + expect(unrelatedRenameState.selectedKeys).toEqual(['app:user:1']); + }); +}); diff --git a/frontend/src/components/redisViewerWorkbenchTheme.test.ts b/frontend/src/components/redisViewerWorkbenchTheme.test.ts index f53d144..8a64063 100644 --- a/frontend/src/components/redisViewerWorkbenchTheme.test.ts +++ b/frontend/src/components/redisViewerWorkbenchTheme.test.ts @@ -1,50 +1,28 @@ +import { describe, expect, it } from 'vitest'; + import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme'; -const assertEqual = (actual: unknown, expected: unknown, message: string) => { - if (actual !== expected) { - throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`); - } -}; +describe('buildRedisWorkbenchTheme', () => { + it('builds dark redis workbench theme', () => { + const darkTheme = buildRedisWorkbenchTheme({ darkMode: true, opacity: 0.72, blur: 14 }); + expect(darkTheme.isDark).toBe(true); + expect(darkTheme.panelBg).toMatch(/^rgba\(/); + expect(darkTheme.toolbarPrimaryBg).toMatch(/^linear-gradient\(/); + expect(darkTheme.actionDangerBg).not.toBe(darkTheme.actionSecondaryBg); + expect(darkTheme.treeSelectedBg).not.toBe(darkTheme.treeHoverBg); + expect(darkTheme.appBg).toMatch(/rgba\(15, 15, 17,/); + expect(darkTheme.panelBg).toMatch(/rgba\(24, 24, 28,/); + expect(darkTheme.panelBgStrong).toMatch(/rgba\(31, 31, 36,/); + expect(darkTheme.backdropFilter).toBe('blur(14px)'); + }); -const assertNotEqual = (actual: unknown, expected: unknown, message: string) => { - if (actual === expected) { - throw new Error(`${message}\nactual: ${String(actual)}\nnotExpected: ${String(expected)}`); - } -}; - -const assertMatch = (value: string, pattern: RegExp, message: string) => { - if (!pattern.test(value)) { - throw new Error(`${message}\nactual: ${value}\npattern: ${String(pattern)}`); - } -}; - -const darkTheme = buildRedisWorkbenchTheme({ - darkMode: true, - opacity: 0.72, - blur: 14, + it('builds light redis workbench theme', () => { + const lightTheme = buildRedisWorkbenchTheme({ darkMode: false, opacity: 1, blur: 0 }); + expect(lightTheme.isDark).toBe(false); + expect(lightTheme.panelBg).toMatch(/^rgba\(/); + expect(lightTheme.contentEmptyBg).toMatch(/^linear-gradient\(/); + expect(lightTheme.textPrimary).not.toBe(lightTheme.textSecondary); + expect(lightTheme.statusTagBg).not.toBe(lightTheme.statusTagMutedBg); + expect(lightTheme.backdropFilter).toBe('none'); + }); }); - -assertEqual(darkTheme.isDark, true, 'dark 主题标记应为 true'); -assertMatch(darkTheme.panelBg, /^rgba\(/, 'dark 主题面板背景应为 rgba'); -assertMatch(darkTheme.toolbarPrimaryBg, /^linear-gradient\(/, '工具栏主按钮应使用渐变背景'); -assertNotEqual(darkTheme.actionDangerBg, darkTheme.actionSecondaryBg, '危险态按钮背景不应与普通按钮相同'); -assertNotEqual(darkTheme.treeSelectedBg, darkTheme.treeHoverBg, '树节点选中态与悬浮态不应相同'); -assertMatch(darkTheme.appBg, /rgba\(15, 15, 17,/, 'dark 背景应保持中性黑基底'); -assertMatch(darkTheme.panelBg, /rgba\(24, 24, 28,/, 'dark 面板背景应保持中性黑灰'); -assertMatch(darkTheme.panelBgStrong, /rgba\(31, 31, 36,/, 'dark 强面板背景应保持中性黑灰'); -assertEqual(darkTheme.backdropFilter, 'blur(14px)', 'blur 参数应映射为 backdropFilter'); - -const lightTheme = buildRedisWorkbenchTheme({ - darkMode: false, - opacity: 1, - blur: 0, -}); - -assertEqual(lightTheme.isDark, false, 'light 主题标记应为 false'); -assertMatch(lightTheme.panelBg, /^rgba\(/, 'light 主题面板背景应为 rgba'); -assertMatch(lightTheme.contentEmptyBg, /^linear-gradient\(/, 'light 空状态背景应为渐变'); -assertNotEqual(lightTheme.textPrimary, lightTheme.textSecondary, '主次文本颜色应区分'); -assertNotEqual(lightTheme.statusTagBg, lightTheme.statusTagMutedBg, '状态 tag 应区分普通与弱化样式'); -assertEqual(lightTheme.backdropFilter, 'none', 'blur=0 时 backdropFilter 应为 none'); - -console.log('redisViewerWorkbenchTheme tests passed'); diff --git a/frontend/src/store.ts b/frontend/src/store.ts index e081769..6a87182 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -10,7 +10,7 @@ import { sanitizeShortcutOptions, } from './utils/shortcuts'; -const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0 }; +const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0, useNativeMacWindowControls: false }; const DEFAULT_UI_SCALE = 1.0; const MIN_UI_SCALE = 0.8; const MAX_UI_SCALE = 1.25; @@ -25,7 +25,7 @@ const MAX_HOST_ENTRY_LENGTH = 512; const MAX_HOST_ENTRIES = 64; const DEFAULT_TIMEOUT_SECONDS = 30; const MAX_TIMEOUT_SECONDS = 3600; -const PERSIST_VERSION = 6; +const PERSIST_VERSION = 7; const DEFAULT_CONNECTION_TYPE = 'mysql'; const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = { enabled: false, @@ -405,7 +405,7 @@ interface AppState { activeContext: { connectionId: string; dbName: string } | null; savedQueries: SavedQuery[]; theme: 'light' | 'dark'; - appearance: { enabled: boolean; opacity: number; blur: number }; + appearance: { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }; uiScale: number; fontSize: number; startupFullscreen: boolean; @@ -450,7 +450,7 @@ interface AppState { deleteQuery: (id: string) => void; setTheme: (theme: 'light' | 'dark') => void; - setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number }>) => void; + setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }>) => void; setUiScale: (scale: number) => void; setFontSize: (size: number) => void; setStartupFullscreen: (enabled: boolean) => void; @@ -561,9 +561,9 @@ const sanitizeTableHiddenColumns = (value: unknown): Record => }; const sanitizeAppearance = ( - appearance: Partial<{ enabled: boolean; opacity: number; blur: number }> | undefined, + appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }> | undefined, version: number -): { enabled: boolean; opacity: number; blur: number } => { +): { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean } => { if (!appearance || typeof appearance !== 'object') { return { ...DEFAULT_APPEARANCE }; } @@ -571,6 +571,9 @@ const sanitizeAppearance = ( enabled: typeof appearance.enabled === 'boolean' ? appearance.enabled : DEFAULT_APPEARANCE.enabled, opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity, blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur, + useNativeMacWindowControls: typeof appearance.useNativeMacWindowControls === 'boolean' + ? appearance.useNativeMacWindowControls + : DEFAULT_APPEARANCE.useNativeMacWindowControls, }; if (version < 2 && isLegacyDefaultAppearance(appearance)) { return { ...DEFAULT_APPEARANCE }; diff --git a/frontend/src/utils/appearance.test.ts b/frontend/src/utils/appearance.test.ts new file mode 100644 index 0000000..89f19f0 --- /dev/null +++ b/frontend/src/utils/appearance.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from './appearance'; + +describe('appearance helpers', () => { + it('falls back to opaque non-blurred appearance when disabled', () => { + expect(resolveAppearanceValues({ enabled: false, opacity: 0.3, blur: 12 })).toEqual({ opacity: 1, blur: 0 }); + }); + + it('preserves configured values when appearance is enabled', () => { + expect(resolveAppearanceValues({ enabled: true, opacity: 0.72, blur: 9 })).toEqual({ opacity: 0.72, blur: 9 }); + }); + + it('caps opacity at full opacity upper bound', () => { + expect(normalizeOpacityForPlatform(2)).toBe(1); + }); + + it('never returns negative blur and formats blur filter correctly', () => { + expect(normalizeBlurForPlatform(-4)).toBe(0); + expect(blurToFilter(0)).toBeUndefined(); + expect(blurToFilter(8)).toBe('blur(8px)'); + }); +}); diff --git a/frontend/src/utils/macWindow.test.ts b/frontend/src/utils/macWindow.test.ts new file mode 100644 index 0000000..5e1d0d0 --- /dev/null +++ b/frontend/src/utils/macWindow.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { + getMacNativeTitlebarPaddingLeft, + getMacNativeTitlebarPaddingRight, + shouldHandleMacNativeFullscreenShortcut, + shouldSuppressMacNativeEscapeExit, +} from './macWindow'; + +describe('macWindow helpers', () => { + it('uses compact padding when native controls are disabled', () => { + expect(getMacNativeTitlebarPaddingLeft(1, false)).toBe(16); + expect(getMacNativeTitlebarPaddingRight(1, false)).toBe(0); + }); + + it('reserves traffic-light safe area when native controls are enabled', () => { + expect(getMacNativeTitlebarPaddingLeft(1, true)).toBe(96); + expect(getMacNativeTitlebarPaddingRight(1, true)).toBe(16); + }); + + it('keeps minimum safe area under small ui scales', () => { + expect(getMacNativeTitlebarPaddingLeft(0.5, true)).toBe(88); + expect(getMacNativeTitlebarPaddingRight(0.5, true)).toBe(12); + }); + + it('matches Control+Command+F only for mac native mode', () => { + expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(true); + expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'F' })).toBe(true); + }); + + it('rejects conflicting modifiers and non-target keys', () => { + expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: true, key: 'f' })).toBe(false); + expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: false, altKey: false, key: 'f' })).toBe(false); + expect(shouldHandleMacNativeFullscreenShortcut(false, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(false); + expect(shouldHandleMacNativeFullscreenShortcut(true, false, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(false); + expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'g' })).toBe(false); + }); + + it('suppresses Escape only in mac native fullscreen mode', () => { + expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: false })).toBe(true); + expect(shouldSuppressMacNativeEscapeExit(true, true, false, { key: 'Escape', defaultPrevented: false })).toBe(false); + expect(shouldSuppressMacNativeEscapeExit(true, false, true, { key: 'Escape', defaultPrevented: false })).toBe(false); + expect(shouldSuppressMacNativeEscapeExit(false, true, true, { key: 'Escape', defaultPrevented: false })).toBe(false); + expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Enter', defaultPrevented: false })).toBe(false); + expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: true })).toBe(false); + }); +}); diff --git a/frontend/src/utils/macWindow.ts b/frontend/src/utils/macWindow.ts new file mode 100644 index 0000000..bb156f8 --- /dev/null +++ b/frontend/src/utils/macWindow.ts @@ -0,0 +1,42 @@ +export const getMacNativeTitlebarPaddingLeft = (uiScale: number, enabled: boolean): number => { + if (!enabled) { + return Math.max(12, Math.round(16 * uiScale)); + } + return Math.max(88, Math.round(96 * uiScale)); +}; + +export const getMacNativeTitlebarPaddingRight = (uiScale: number, enabled: boolean): number => { + if (!enabled) { + return 0; + } + return Math.max(12, Math.round(16 * uiScale)); +}; + +export const shouldHandleMacNativeFullscreenShortcut = ( + isMacRuntime: boolean, + useNativeMacWindowControls: boolean, + event: Pick, +): boolean => { + if (!isMacRuntime || !useNativeMacWindowControls) { + return false; + } + if (!event.ctrlKey || !event.metaKey || event.altKey) { + return false; + } + return String(event.key || '').toLowerCase() === 'f'; +}; + +export const shouldSuppressMacNativeEscapeExit = ( + isMacRuntime: boolean, + useNativeMacWindowControls: boolean, + isFullscreen: boolean, + event: Pick, +): boolean => { + if (!isMacRuntime || !useNativeMacWindowControls || !isFullscreen) { + return false; + } + if (event.defaultPrevented) { + return false; + } + return String(event.key || '') === 'Escape'; +}; diff --git a/frontend/src/utils/overlayWorkbenchTheme.test.ts b/frontend/src/utils/overlayWorkbenchTheme.test.ts index 2979180..0a26027 100644 --- a/frontend/src/utils/overlayWorkbenchTheme.test.ts +++ b/frontend/src/utils/overlayWorkbenchTheme.test.ts @@ -1,27 +1,21 @@ +import { describe, expect, it } from 'vitest'; + import { buildOverlayWorkbenchTheme } from './overlayWorkbenchTheme'; -const assertEqual = (actual: unknown, expected: unknown, message: string) => { - if (actual !== expected) { - throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`); - } -}; +describe('buildOverlayWorkbenchTheme', () => { + it('builds dark theme tokens', () => { + const darkTheme = buildOverlayWorkbenchTheme(true); + expect(darkTheme.isDark).toBe(true); + expect(darkTheme.shellBg).toMatch(/rgba\(15, 15, 17,/); + expect(darkTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.03\)/); + expect(darkTheme.iconColor).toBe('#ffd666'); + }); -const assertMatch = (value: string, pattern: RegExp, message: string) => { - if (!pattern.test(value)) { - throw new Error(`${message}\nactual: ${value}\npattern: ${String(pattern)}`); - } -}; - -const darkTheme = buildOverlayWorkbenchTheme(true); -assertEqual(darkTheme.isDark, true, 'dark 主题标记应为 true'); -assertMatch(darkTheme.shellBg, /rgba\(15, 15, 17,/, 'dark 弹层背景应保持中性黑'); -assertMatch(darkTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.03\)/, 'dark section 背景透明度应匹配'); -assertEqual(darkTheme.iconColor, '#ffd666', 'dark 图标色应为金色强调'); - -const lightTheme = buildOverlayWorkbenchTheme(false); -assertEqual(lightTheme.isDark, false, 'light 主题标记应为 false'); -assertMatch(lightTheme.shellBg, /rgba\(255,255,255,0\.98\)/, 'light 弹层背景透明度应匹配'); -assertMatch(lightTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/, 'light section 背景透明度应匹配'); -assertEqual(lightTheme.iconColor, '#1677ff', 'light 图标色应为蓝色强调'); - -console.log('overlayWorkbenchTheme tests passed'); + it('builds light theme tokens', () => { + const lightTheme = buildOverlayWorkbenchTheme(false); + expect(lightTheme.isDark).toBe(false); + expect(lightTheme.shellBg).toMatch(/rgba\(255,255,255,0\.98\)/); + expect(lightTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/); + expect(lightTheme.iconColor).toBe('#1677ff'); + }); +}); diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 5b288e4..d6fb6a4 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -193,6 +193,8 @@ export function SelectDriverPackageFile(arg1:string):Promise; +export function SetMacNativeWindowControls(arg1:boolean):Promise; + export function SetWindowTranslucency(arg1:number,arg2:number):Promise; export function TestConnection(arg1:connection.ConnectionConfig):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index dfee01f..495177e 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -378,6 +378,10 @@ export function SelectSSHKeyFile(arg1) { return window['go']['app']['App']['SelectSSHKeyFile'](arg1); } +export function SetMacNativeWindowControls(arg1) { + return window['go']['app']['App']['SetMacNativeWindowControls'](arg1); +} + export function SetWindowTranslucency(arg1, arg2) { return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2); } diff --git a/internal/app/app.go b/internal/app/app.go index 4a0aff9..98238c9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -68,6 +68,12 @@ func (a *App) SetWindowTranslucency(opacity float64, blur float64) { setMacWindowTranslucency(opacity, blur) } +// SetMacNativeWindowControls toggles macOS native traffic-light window controls. +// On non-macOS platforms this is a no-op. +func (a *App) SetMacNativeWindowControls(enabled bool) { + setMacNativeWindowControls(enabled) +} + // Shutdown is called when the app terminates func (a *App) Shutdown(ctx context.Context) { logger.Infof("应用开始关闭,准备释放资源") diff --git a/internal/app/window_style_darwin.go b/internal/app/window_style_darwin.go new file mode 100644 index 0000000..5c1ea2b --- /dev/null +++ b/internal/app/window_style_darwin.go @@ -0,0 +1,70 @@ +//go:build darwin + +package app + +/* +#cgo CFLAGS: -x objective-c -fblocks +#cgo LDFLAGS: -framework Cocoa +#import +#import + +static void gonaviSetWindowButtonsVisible(NSWindow *window, BOOL visible) { + if (window == nil) { + return; + } + for (NSWindowButton buttonType = NSWindowCloseButton; buttonType <= NSWindowZoomButton; buttonType++) { + NSButton *button = [window standardWindowButton:buttonType]; + if (button != nil) { + [button setHidden:!visible]; + [button setEnabled:visible]; + } + } +} + +static void gonaviApplyMacWindowStyle(BOOL enabled) { + dispatch_async(dispatch_get_main_queue(), ^{ + for (NSWindow *window in [NSApp windows]) { + if (window == nil) { + continue; + } + + NSUInteger styleMask = [window styleMask]; + styleMask |= NSWindowStyleMaskClosable; + styleMask |= NSWindowStyleMaskMiniaturizable; + styleMask |= NSWindowStyleMaskResizable; + + if (enabled) { + styleMask |= NSWindowStyleMaskTitled; + styleMask |= NSWindowStyleMaskFullSizeContentView; + [window setStyleMask:styleMask]; + [window setTitleVisibility:NSWindowTitleHidden]; + [window setTitlebarAppearsTransparent:YES]; + [window setMovableByWindowBackground:YES]; + [window setCollectionBehavior:[window collectionBehavior] | NSWindowCollectionBehaviorFullScreenPrimary]; + gonaviSetWindowButtonsVisible(window, YES); + } else { + styleMask &= ~NSWindowStyleMaskTitled; + styleMask &= ~NSWindowStyleMaskFullSizeContentView; + [window setStyleMask:styleMask]; + [window setTitleVisibility:NSWindowTitleVisible]; + [window setTitlebarAppearsTransparent:NO]; + [window setMovableByWindowBackground:YES]; + gonaviSetWindowButtonsVisible(window, NO); + } + + [[window contentView] setNeedsDisplay:YES]; + [window invalidateShadow]; + } + }); +} +*/ +import "C" + +func setMacNativeWindowControls(enabled bool) { + state := resolveMacNativeWindowControlState(enabled) + flag := C.BOOL(false) + if state.ShowNativeButtons { + flag = C.BOOL(true) + } + C.gonaviApplyMacWindowStyle(flag) +} diff --git a/internal/app/window_style_logic.go b/internal/app/window_style_logic.go new file mode 100644 index 0000000..79e33cb --- /dev/null +++ b/internal/app/window_style_logic.go @@ -0,0 +1,32 @@ +package app + +type macNativeWindowControlState struct { + ShowNativeButtons bool + UseTitledWindow bool + UseFullSizeContent bool + HideWindowTitle bool + TransparentTitlebar bool + AllowNativeFullscreen bool +} + +func resolveMacNativeWindowControlState(enabled bool) macNativeWindowControlState { + if enabled { + return macNativeWindowControlState{ + ShowNativeButtons: true, + UseTitledWindow: true, + UseFullSizeContent: true, + HideWindowTitle: true, + TransparentTitlebar: true, + AllowNativeFullscreen: true, + } + } + + return macNativeWindowControlState{ + ShowNativeButtons: false, + UseTitledWindow: false, + UseFullSizeContent: false, + HideWindowTitle: false, + TransparentTitlebar: false, + AllowNativeFullscreen: false, + } +} diff --git a/internal/app/window_style_logic_test.go b/internal/app/window_style_logic_test.go new file mode 100644 index 0000000..430c6e3 --- /dev/null +++ b/internal/app/window_style_logic_test.go @@ -0,0 +1,37 @@ +package app + +import "testing" + +func TestResolveMacNativeWindowControlStateEnabled(t *testing.T) { + state := resolveMacNativeWindowControlState(true) + + if !state.ShowNativeButtons { + t.Fatal("expected native buttons to be visible when enabled") + } + if !state.UseTitledWindow || !state.UseFullSizeContent { + t.Fatal("expected enabled state to request titled full-size content window") + } + if !state.HideWindowTitle || !state.TransparentTitlebar { + t.Fatal("expected enabled state to hide title and use transparent titlebar") + } + if !state.AllowNativeFullscreen { + t.Fatal("expected enabled state to allow native fullscreen") + } +} + +func TestResolveMacNativeWindowControlStateDisabled(t *testing.T) { + state := resolveMacNativeWindowControlState(false) + + if state.ShowNativeButtons { + t.Fatal("expected native buttons to be hidden when disabled") + } + if state.UseTitledWindow || state.UseFullSizeContent { + t.Fatal("expected disabled state to avoid titled/full-size content window") + } + if state.HideWindowTitle || state.TransparentTitlebar { + t.Fatal("expected disabled state to keep title visibility and opaque titlebar") + } + if state.AllowNativeFullscreen { + t.Fatal("expected disabled state to avoid native fullscreen behavior") + } +} diff --git a/internal/app/window_style_stub.go b/internal/app/window_style_stub.go new file mode 100644 index 0000000..8c78d11 --- /dev/null +++ b/internal/app/window_style_stub.go @@ -0,0 +1,5 @@ +//go:build !darwin + +package app + +func setMacNativeWindowControls(enabled bool) {} From 36a57f9601d16ec08aa946665e8a6c1eb2cb54f8 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 20 Mar 2026 21:44:12 +0800 Subject: [PATCH 02/14] =?UTF-8?q?=E2=9C=A8=20feat(shortcut):=20=E5=B0=86?= =?UTF-8?q?=20macOS=20=E5=85=A8=E5=B1=8F=E5=88=87=E6=8D=A2=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE=E6=B3=A8=E5=86=8C=E5=88=B0=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E9=94=AE=E7=AE=A1=E7=90=86=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 toggleMacFullscreen action 到 shortcuts.ts - 新增 platformOnly 字段支持按平台过滤快捷键显示 - 默认绑定 Ctrl+Meta+F,仅 macOS 下显示 - 移除 App.tsx 中的硬编码全屏快捷键判断,统一走 shortcuts 系统 --- frontend/package.json.md5 | 2 +- frontend/src/App.tsx | 15 ++++++++------- frontend/src/utils/shortcuts.ts | 11 ++++++++++- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 4df5c44..24d10b6 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -594ebbc6a946fc76ba11bee7b3f53282 \ No newline at end of file +0f60775ad0a6b251a4320748f196a712 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5d89559..364b4b3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1372,13 +1372,6 @@ function App() { useEffect(() => { const handleGlobalShortcut = (event: KeyboardEvent) => { - if (shouldHandleMacNativeFullscreenShortcut(isMacRuntime, useNativeMacWindowControls, event)) { - event.preventDefault(); - event.stopPropagation(); - void handleTitleBarWindowToggle(); - return; - } - const matchedAction = SHORTCUT_ACTION_ORDER.find((action) => { const binding = shortcutOptions[action]; if (!binding?.enabled) { @@ -1416,6 +1409,11 @@ function App() { case 'openShortcutManager': setIsShortcutModalOpen(true); break; + case 'toggleMacFullscreen': + if (isMacRuntime && useNativeMacWindowControls) { + void handleTitleBarWindowToggle(); + } + break; } }; @@ -2145,6 +2143,9 @@ function App() { {SHORTCUT_ACTION_ORDER.map((action) => { const meta = SHORTCUT_ACTION_META[action]; + if (meta.platformOnly === 'mac' && !isMacRuntime) { + return null; + } const binding = shortcutOptions[action] ?? { combo: '', enabled: false }; const isCapturing = capturingShortcutAction === action; return ( diff --git a/frontend/src/utils/shortcuts.ts b/frontend/src/utils/shortcuts.ts index 97ffe1c..a768d32 100644 --- a/frontend/src/utils/shortcuts.ts +++ b/frontend/src/utils/shortcuts.ts @@ -6,7 +6,8 @@ export type ShortcutAction = | 'newQueryTab' | 'toggleLogPanel' | 'toggleTheme' - | 'openShortcutManager'; + | 'openShortcutManager' + | 'toggleMacFullscreen'; export interface ShortcutBinding { combo: string; @@ -19,6 +20,7 @@ export interface ShortcutActionMeta { label: string; description: string; allowInEditable?: boolean; + platformOnly?: 'mac'; } const MODIFIER_ORDER = ['Ctrl', 'Meta', 'Alt', 'Shift'] as const; @@ -76,6 +78,7 @@ export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [ 'toggleLogPanel', 'toggleTheme', 'openShortcutManager', + 'toggleMacFullscreen', ]; export const SHORTCUT_ACTION_META: Record = { @@ -105,6 +108,11 @@ export const SHORTCUT_ACTION_META: Record = description: '打开快捷键设置面板', allowInEditable: true, }, + toggleMacFullscreen: { + label: '切换原生全屏', + description: 'macOS 原生窗口控制模式下的全屏切换(⌃⌘F)', + platformOnly: 'mac', + }, }; export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = { @@ -114,6 +122,7 @@ export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = { toggleLogPanel: { combo: 'Ctrl+Shift+L', enabled: true }, toggleTheme: { combo: 'Ctrl+Shift+D', enabled: true }, openShortcutManager: { combo: 'Ctrl+,', enabled: true }, + toggleMacFullscreen: { combo: 'Ctrl+Meta+F', enabled: true }, }; const normalizeKeyToken = (value: string): string => { From 7bc358d612651eec6fc479d1f14d80b0bb8f29dd Mon Sep 17 00:00:00 2001 From: DurianPankek Date: Sat, 21 Mar 2026 16:17:29 +0800 Subject: [PATCH 03/14] =?UTF-8?q?=F0=9F=90=9B=20fix(connect):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=A6=96=E6=AC=A1=E5=90=AF=E5=8A=A8=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E8=BF=9E=E6=8E=A5=E5=81=B6=E5=8F=91=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 51 +++++- frontend/src/utils/startupReadiness.test.ts | 26 +++ frontend/src/utils/startupReadiness.ts | 26 +++ internal/app/app.go | 157 +++++++++++++++--- .../app/app_startup_connect_retry_test.go | 149 +++++++++++++++++ 5 files changed, 383 insertions(+), 26 deletions(-) create mode 100644 frontend/src/utils/startupReadiness.test.ts create mode 100644 frontend/src/utils/startupReadiness.ts create mode 100644 internal/app/app_startup_connect_retry_test.go diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 364b4b3..59fe743 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { SavedConnection } from './types'; import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance'; import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow'; import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme'; +import { getConnectionWorkbenchState } from './utils/startupReadiness'; import { SHORTCUT_ACTION_META, SHORTCUT_ACTION_ORDER, @@ -90,9 +91,11 @@ function App() { const [runtimePlatform, setRuntimePlatform] = useState(''); const [isLinuxRuntime, setIsLinuxRuntime] = useState(false); const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated()); + const [hasAppliedInitialGlobalProxy, setHasAppliedInitialGlobalProxy] = useState(false); const sidebarWidth = useStore(state => state.sidebarWidth); const setSidebarWidth = useStore(state => state.setSidebarWidth); const globalProxyInvalidHintShownRef = React.useRef(false); + const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasAppliedInitialGlobalProxy); // 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView, // 避免 GPU 持续计算窗口背后的模糊合成 @@ -198,8 +201,16 @@ function App() { content: '全局代理配置失败: ' + errMsg, key: 'global-proxy-sync-error', }); + }) + .finally(() => { + if (!cancelled) { + setHasAppliedInitialGlobalProxy(true); + } }); } catch (e) { + if (!cancelled) { + setHasAppliedInitialGlobalProxy(true); + } console.warn("Wails API: ConfigureGlobalProxy unavailable", e); } @@ -1638,8 +1649,44 @@ function App() { -
- +
+
+ +
+ {!connectionWorkbenchState.ready && ( +
+
+ + {connectionWorkbenchState.message} +
+
+ )}
{/* Floating SQL Log Toggle */} diff --git a/frontend/src/utils/startupReadiness.test.ts b/frontend/src/utils/startupReadiness.test.ts new file mode 100644 index 0000000..92c72bd --- /dev/null +++ b/frontend/src/utils/startupReadiness.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { getConnectionWorkbenchState } from './startupReadiness'; + +describe('startup readiness helpers', () => { + it('blocks sidebar interactions before local store hydration completes', () => { + expect(getConnectionWorkbenchState(false, false)).toEqual({ + ready: false, + message: '正在加载本地配置...', + }); + }); + + it('keeps sidebar blocked until initial global proxy sync finishes', () => { + expect(getConnectionWorkbenchState(true, false)).toEqual({ + ready: false, + message: '正在同步全局代理配置...', + }); + }); + + it('unblocks sidebar after startup configuration is fully applied', () => { + expect(getConnectionWorkbenchState(true, true)).toEqual({ + ready: true, + message: '', + }); + }); +}); diff --git a/frontend/src/utils/startupReadiness.ts b/frontend/src/utils/startupReadiness.ts new file mode 100644 index 0000000..3395627 --- /dev/null +++ b/frontend/src/utils/startupReadiness.ts @@ -0,0 +1,26 @@ +export interface ConnectionWorkbenchState { + ready: boolean; + message: string; +} + +export function getConnectionWorkbenchState( + isStoreHydrated: boolean, + hasAppliedInitialGlobalProxy: boolean +): ConnectionWorkbenchState { + if (!isStoreHydrated) { + return { + ready: false, + message: '正在加载本地配置...', + }; + } + if (!hasAppliedInitialGlobalProxy) { + return { + ready: false, + message: '正在同步全局代理配置...', + }; + } + return { + ready: true, + message: '', + }; +} diff --git a/internal/app/app.go b/internal/app/app.go index 98238c9..8f6ae70 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -23,6 +23,17 @@ import ( const dbCachePingInterval = 30 * time.Second +const ( + startupConnectRetryWindow = 20 * time.Second + startupConnectRetryDelay = 800 * time.Millisecond + startupConnectRetryAttempts = 4 +) + +var ( + newDatabaseFunc = db.NewDatabase + resolveDialConfigWithProxyFunc = resolveDialConfigWithProxy +) + type cachedDatabase struct { inst db.Database lastPing time.Time @@ -36,6 +47,7 @@ type queryContext struct { // App struct type App struct { ctx context.Context + startedAt time.Time dbCache map[string]cachedDatabase // Cache for DB connections mu sync.RWMutex // Mutex for cache access updateMu sync.Mutex @@ -56,9 +68,10 @@ func NewApp() *App { // so we can call the runtime methods func (a *App) Startup(ctx context.Context) { a.ctx = ctx + a.startedAt = time.Now() logger.Init() applyMacWindowTranslucencyFix() - logger.Infof("应用启动完成") + logger.Infof("应用启动完成(首次连接保护窗口=%s,最多重试=%d 次)", startupConnectRetryWindow, startupConnectRetryAttempts) } // SetWindowTranslucency 动态调整 macOS 窗口透明度。 @@ -429,12 +442,12 @@ func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Datab return nil, withLogHint{err: fmt.Errorf("%s", reason), logPath: logger.Path()} } - dbInst, err := db.NewDatabase(effectiveConfig.Type) + dbInst, err := newDatabaseFunc(effectiveConfig.Type) if err != nil { return nil, err } - connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig) + connectConfig, proxyErr := resolveDialConfigWithProxyFunc(effectiveConfig) if proxyErr != nil { _ = dbInst.Close() return nil, wrapConnectError(effectiveConfig, proxyErr) @@ -451,10 +464,7 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing isFileDB := isFileDatabaseType(effectiveConfig.Type) key := getCacheKey(effectiveConfig) - shortKey := key - if len(shortKey) > 12 { - shortKey = shortKey[:12] - } + shortKey := shortenCacheKey(key) if isFileDB { rawDSN := resolveFileDatabaseDSN(effectiveConfig) normalizedDSN := resolveFileDatabaseDSN(normalizeCacheKeyConfig(effectiveConfig)) @@ -531,26 +541,13 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing logger.Infof("未命中文件库连接缓存,开始创建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) } - logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) - logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey) - dbInst, err := db.NewDatabase(effectiveConfig.Type) + dbInst, connectedConfig, err := a.connectDatabaseWithStartupRetry(config) if err != nil { - logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey) return nil, err } - - connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig) - if proxyErr != nil { - wrapped := wrapConnectError(effectiveConfig, proxyErr) - logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) - return nil, wrapped - } - - if err := dbInst.Connect(connectConfig); err != nil { - wrapped := wrapConnectError(effectiveConfig, err) - logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) - return nil, wrapped - } + effectiveConfig = connectedConfig + key = getCacheKey(effectiveConfig) + shortKey = shortenCacheKey(key) now := time.Now() @@ -571,6 +568,118 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing return dbInst, nil } +func shortenCacheKey(key string) string { + if len(key) > 12 { + return key[:12] + } + return key +} + +func (a *App) connectDatabaseWithStartupRetry(rawConfig connection.ConnectionConfig) (db.Database, connection.ConnectionConfig, error) { + var lastErr error + var lastEffectiveConfig connection.ConnectionConfig + + for attempt := 1; attempt <= startupConnectRetryAttempts; attempt++ { + effectiveConfig := applyGlobalProxyToConnection(rawConfig) + lastEffectiveConfig = effectiveConfig + cacheKey := shortenCacheKey(getCacheKey(effectiveConfig)) + + logger.Infof("获取数据库连接:%s 缓存Key=%s 启动阶段=%s", formatConnSummary(effectiveConfig), cacheKey, a.startupPhaseLabel()) + logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s 尝试=%d/%d", effectiveConfig.Type, cacheKey, attempt, startupConnectRetryAttempts) + + dbInst, err := newDatabaseFunc(effectiveConfig.Type) + if err != nil { + logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", effectiveConfig.Type, cacheKey) + return nil, effectiveConfig, err + } + + connectConfig, proxyErr := resolveDialConfigWithProxyFunc(effectiveConfig) + if proxyErr != nil { + _ = dbInst.Close() + wrapped := wrapConnectError(effectiveConfig, proxyErr) + logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), cacheKey) + return nil, effectiveConfig, wrapped + } + + if err := dbInst.Connect(connectConfig); err == nil { + if attempt > 1 { + logger.Warnf("数据库连接在启动保护重试后成功:%s 缓存Key=%s 尝试=%d/%d", formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts) + } + return dbInst, effectiveConfig, nil + } else { + _ = dbInst.Close() + wrapped := wrapConnectError(effectiveConfig, err) + lastErr = wrapped + logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), cacheKey) + if !a.shouldRetryStartupConnect(err, attempt) { + return nil, effectiveConfig, wrapped + } + logger.Warnf("检测到启动期瞬时网络失败,准备重试连接:%s 缓存Key=%s 尝试=%d/%d 延迟=%s 原因=%s", + formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts, startupConnectRetryDelay, normalizeErrorMessage(err)) + time.Sleep(startupConnectRetryDelay) + } + } + + if lastErr == nil { + lastErr = fmt.Errorf("建立数据库连接失败") + } + return nil, lastEffectiveConfig, lastErr +} + +func (a *App) startupPhaseLabel() string { + if a == nil || a.startedAt.IsZero() { + return "未知" + } + age := time.Since(a.startedAt).Round(time.Millisecond) + if age < 0 { + age = 0 + } + if age <= startupConnectRetryWindow { + snapshot := currentGlobalProxyConfig() + state := "关闭" + if snapshot.Enabled { + state = fmt.Sprintf("启用(%s://%s:%d)", strings.ToLower(strings.TrimSpace(snapshot.Proxy.Type)), strings.TrimSpace(snapshot.Proxy.Host), snapshot.Proxy.Port) + } + return fmt.Sprintf("启动期(age=%s,全局代理=%s)", age, state) + } + return fmt.Sprintf("稳定期(age=%s)", age) +} + +func (a *App) shouldRetryStartupConnect(err error, attempt int) bool { + if attempt >= startupConnectRetryAttempts { + return false + } + if a == nil || a.startedAt.IsZero() { + return false + } + age := time.Since(a.startedAt) + if age < 0 || age > startupConnectRetryWindow { + return false + } + return isTransientStartupConnectError(err) +} + +func isTransientStartupConnectError(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(normalizeErrorMessage(err)) + transientHints := []string{ + "no route to host", + "network is unreachable", + "connection refused", + "connection timed out", + "i/o timeout", + "context deadline exceeded", + } + for _, hint := range transientHints { + if strings.Contains(message, hint) { + return true + } + } + return false +} + // generateQueryID generates a unique ID for a query using UUID v4 func generateQueryID() string { return "query-" + uuid.New().String() diff --git a/internal/app/app_startup_connect_retry_test.go b/internal/app/app_startup_connect_retry_test.go new file mode 100644 index 0000000..a840b30 --- /dev/null +++ b/internal/app/app_startup_connect_retry_test.go @@ -0,0 +1,149 @@ +package app + +import ( + "errors" + "testing" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" +) + +type fakeStartupRetryDB struct { + connect func(config connection.ConnectionConfig) error +} + +func (f *fakeStartupRetryDB) Connect(config connection.ConnectionConfig) error { + if f.connect != nil { + return f.connect(config) + } + return nil +} + +func (f *fakeStartupRetryDB) Close() error { return nil } +func (f *fakeStartupRetryDB) Ping() error { return nil } +func (f *fakeStartupRetryDB) Query(query string) ([]map[string]interface{}, []string, error) { + return nil, nil, nil +} +func (f *fakeStartupRetryDB) Exec(query string) (int64, error) { return 0, nil } +func (f *fakeStartupRetryDB) GetDatabases() ([]string, error) { return nil, nil } +func (f *fakeStartupRetryDB) GetTables(dbName string) ([]string, error) { return nil, nil } +func (f *fakeStartupRetryDB) GetCreateStatement(dbName, tableName string) (string, error) { + return "", nil +} +func (f *fakeStartupRetryDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { + return nil, nil +} +func (f *fakeStartupRetryDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { + return nil, nil +} +func (f *fakeStartupRetryDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { + return nil, nil +} +func (f *fakeStartupRetryDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { + return nil, nil +} +func (f *fakeStartupRetryDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) { + return nil, nil +} + +func TestConnectDatabaseWithStartupRetry_RetriesTransientFailureAndReappliesGlobalProxy(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + snapshot := currentGlobalProxyConfig() + defer func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + if _, err := setGlobalProxyConfig(snapshot.Enabled, snapshot.Proxy); err != nil { + t.Fatalf("restore global proxy failed: %v", err) + } + }() + + if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil { + t.Fatalf("disable global proxy failed: %v", err) + } + + seenConfigs := make([]connection.ConnectionConfig, 0, 2) + connectCalls := 0 + newDatabaseFunc = func(dbType string) (db.Database, error) { + return &fakeStartupRetryDB{ + connect: func(config connection.ConnectionConfig) error { + connectCalls++ + seenConfigs = append(seenConfigs, config) + if connectCalls == 1 { + _, _ = setGlobalProxyConfig(true, connection.ProxyConfig{Type: "socks5", Host: "127.0.0.1", Port: 1080}) + return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host") + } + return nil + }, + }, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + a := &App{startedAt: time.Now()} + rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"} + + _, effectiveConfig, err := a.connectDatabaseWithStartupRetry(rawConfig) + if err != nil { + t.Fatalf("connectDatabaseWithStartupRetry returned error: %v", err) + } + if connectCalls != 2 { + t.Fatalf("expected 2 connect attempts, got %d", connectCalls) + } + if len(seenConfigs) != 2 { + t.Fatalf("expected 2 seen configs, got %d", len(seenConfigs)) + } + if seenConfigs[0].UseProxy { + t.Fatalf("expected first attempt without proxy, got %+v", seenConfigs[0]) + } + if !seenConfigs[1].UseProxy { + t.Fatalf("expected second attempt with proxy after startup retry, got %+v", seenConfigs[1]) + } + if !effectiveConfig.UseProxy { + t.Fatalf("expected returned effective config to include proxy, got %+v", effectiveConfig) + } +} + +func TestConnectDatabaseWithStartupRetry_DoesNotRetryOutsideStartupWindow(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + defer func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + }() + + connectCalls := 0 + newDatabaseFunc = func(dbType string) (db.Database, error) { + return &fakeStartupRetryDB{ + connect: func(config connection.ConnectionConfig) error { + connectCalls++ + return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host") + }, + }, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + a := &App{startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second)} + rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"} + + _, _, err := a.connectDatabaseWithStartupRetry(rawConfig) + if err == nil { + t.Fatal("expected error, got nil") + } + if connectCalls != 1 { + t.Fatalf("expected 1 connect attempt outside startup window, got %d", connectCalls) + } +} + +func TestIsTransientStartupConnectError(t *testing.T) { + if !isTransientStartupConnectError(errors.New("dial tcp 10.1.131.86:5432: connect: no route to host")) { + t.Fatal("expected no route to host to be treated as transient startup connect error") + } + if isTransientStartupConnectError(errors.New("pq: password authentication failed")) { + t.Fatal("expected authentication failure to not be treated as transient startup connect error") + } +} From 1bda751adafc8e8d9a0e6f601f5e58dc43d4b4ed Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 22 Mar 2026 20:54:29 +0800 Subject: [PATCH 04/14] =?UTF-8?q?=E2=9C=A8=20feat(ai-chat):=20=E5=85=A8?= =?UTF-8?q?=E9=9D=A2=E5=8D=87=E7=BA=A7AI=E8=81=8A=E5=A4=A9=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E5=B9=B6=E4=BC=98=E5=8C=96=E4=BA=A4=E4=BA=92=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 消息管理:新增聊天气泡的重试、编辑与单条删除功能及相对应的持久化状态函数 - 快捷操作:支持长文一键滑动到底端,并在代码块内增加SQL一键送入编辑器的快捷执行机制 - 视觉优化:深化AI回复背景沉浸感,重绘AI洞察按钮并移除设置面板所有的冗余紫色调 - 设置调优:放宽模型初始必填限制,新增内置系统提示词(Builtin Prompt)全览面板 --- frontend/ai_ui_mockups_wip.html | 182 +++ frontend/package-lock.json | 1616 ++++++++++++++++++- frontend/package.json | 4 + frontend/package.json.md5 | 2 +- frontend/src/App.tsx | 51 +- frontend/src/components/AIChatPanel.css | 366 +++++ frontend/src/components/AIChatPanel.tsx | 848 ++++++++++ frontend/src/components/AISettingsModal.tsx | 570 +++++++ frontend/src/components/DataGrid.tsx | 39 +- frontend/src/components/QueryEditor.tsx | 141 +- frontend/src/components/Sidebar.tsx | 182 +-- frontend/src/components/TableOverview.tsx | 8 +- frontend/src/store.ts | 113 +- frontend/src/types.ts | 36 + frontend/wailsjs/go/aiservice/Service.d.ts | 38 + frontend/wailsjs/go/aiservice/Service.js | 71 + frontend/wailsjs/go/app/App.d.ts | 3 + frontend/wailsjs/go/app/App.js | 4 + frontend/wailsjs/go/models.ts | 55 + internal/ai/context/builder.go | 213 +++ internal/ai/context/collector.go | 42 + internal/ai/provider/anthropic.go | 293 ++++ internal/ai/provider/claude_cli.go | 227 +++ internal/ai/provider/custom.go | 74 + internal/ai/provider/gemini.go | 267 +++ internal/ai/provider/openai.go | 316 ++++ internal/ai/provider/openai_test.go | 86 + internal/ai/provider/provider.go | 19 + internal/ai/provider/registry.go | 25 + internal/ai/safety/classifier.go | 101 ++ internal/ai/safety/classifier_test.go | 145 ++ internal/ai/safety/guard.go | 71 + internal/ai/service/service.go | 573 +++++++ internal/ai/types.go | 85 + main.go | 11 +- 35 files changed, 6745 insertions(+), 132 deletions(-) create mode 100644 frontend/ai_ui_mockups_wip.html create mode 100644 frontend/src/components/AIChatPanel.css create mode 100644 frontend/src/components/AIChatPanel.tsx create mode 100644 frontend/src/components/AISettingsModal.tsx create mode 100644 frontend/wailsjs/go/aiservice/Service.d.ts create mode 100644 frontend/wailsjs/go/aiservice/Service.js create mode 100644 internal/ai/context/builder.go create mode 100644 internal/ai/context/collector.go create mode 100644 internal/ai/provider/anthropic.go create mode 100644 internal/ai/provider/claude_cli.go create mode 100644 internal/ai/provider/custom.go create mode 100644 internal/ai/provider/gemini.go create mode 100644 internal/ai/provider/openai.go create mode 100644 internal/ai/provider/openai_test.go create mode 100644 internal/ai/provider/provider.go create mode 100644 internal/ai/provider/registry.go create mode 100644 internal/ai/safety/classifier.go create mode 100644 internal/ai/safety/classifier_test.go create mode 100644 internal/ai/safety/guard.go create mode 100644 internal/ai/service/service.go create mode 100644 internal/ai/types.go 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, From ecc8ff1197affee23d8deb6add9fe9ed5d29663b Mon Sep 17 00:00:00 2001 From: DurianPankek Date: Mon, 23 Mar 2026 17:32:11 +0800 Subject: [PATCH 05/14] =?UTF-8?q?=F0=9F=90=9B=20fix(table-designer):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=B4=A2=E5=BC=95=E7=BC=96=E8=BE=91=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1=E4=B8=8E=E5=8B=BE=E9=80=89=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/TableDesigner.tsx | 168 ++++++++++++------ .../tableDesignerIndexUtils.test.ts | 95 ++++++++++ .../src/components/tableDesignerIndexUtils.ts | 78 ++++++++ 3 files changed, 282 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/tableDesignerIndexUtils.test.ts create mode 100644 frontend/src/components/tableDesignerIndexUtils.ts diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index a9f604b..46f58f5 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -8,6 +8,7 @@ import Editor, { loader } from '@monaco-editor/react'; import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types'; import { useStore } from '../store'; import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App'; +import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils'; interface EditableColumn extends ColumnDefinition { _key: string; @@ -48,6 +49,13 @@ interface ForeignKeyFormState { refColumnNames: string[]; } +interface SchemaExecutionResult { + ok: boolean; + message?: string; + failedStatementIndex?: number; + statementCount: number; +} + // 通用兜底类型列表 const COMMON_TYPES = [ { value: 'int' }, @@ -1511,11 +1519,10 @@ ${selectedTrigger.statement}`; } }; - const executeSchemaSql = async (sql: string, successMessage: string): Promise => { + const executeSchemaStatements = async (sqlText: string): Promise => { const conn = connections.find(c => c.id === tab.connectionId); if (!conn) { - message.error('未找到连接'); - return false; + return { ok: false, message: '未找到连接', statementCount: 0 }; } const config = { ...conn.config, @@ -1525,20 +1532,68 @@ ${selectedTrigger.statement}`; useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; + const statements = sqlText.split(/;\s*\n/).map(s => s.trim()).filter(Boolean); + for (let i = 0; i < statements.length; i++) { + let stmt = statements[i]; + if (!stmt.endsWith(';')) stmt += ';'; + const res = await DBQuery(config as any, tab.dbName || '', stmt); + if (!res.success) { + const prefix = statements.length > 1 ? `第 ${i + 1}/${statements.length} 条语句执行失败: ` : '执行失败: '; + return { + ok: false, + message: prefix + res.message, + failedStatementIndex: i, + statementCount: statements.length, + }; + } + } + return { ok: true, statementCount: statements.length }; + }; + + const buildIndexFormFromRow = (row: IndexDisplayRow): IndexFormState => { + return normalizeIndexFormFromRow( + row as IndexDisplaySnapshot, + getIndexKindOptions().map(item => item.value as IndexKind), + ); + }; + + const executeIndexEditSql = async (dropSql: string, addSql: string, previousIndex: IndexDisplayRow): Promise => { + const result = await executeSchemaStatements(`${dropSql}\n${addSql}`); + if (result.ok) { + message.success('索引修改成功'); + await fetchData(); + return true; + } + + const oldCreateSql = buildIndexCreateSql(buildIndexFormFromRow(previousIndex)); + if (!oldCreateSql) { + message.error((result.message || '执行失败') + ';且无法自动恢复原索引,请尽快检查'); + await fetchData(); + return false; + } + + if (!shouldRestoreOriginalIndex(result)) { + message.error(result.message || '执行失败'); + return false; + } + + const restoreResult = await executeSchemaStatements(oldCreateSql); + if (restoreResult.ok) { + message.error((result.message || '执行失败') + ';已自动恢复原索引'); + } else { + message.error((result.message || '执行失败') + `;恢复原索引失败: ${restoreResult.message || '未知错误'}`); + } + await fetchData(); + return false; + }; + + const executeSchemaSql = async (sql: string, successMessage: string): Promise => { try { - // 多条 DDL 语句(如 DROP INDEX + CREATE INDEX)需要逐条执行, - // 因为 Go MySQL 驱动默认不支持多语句 Exec。 - const statements = sql.split(/;\s*\n/).map(s => s.trim()).filter(Boolean); - for (let i = 0; i < statements.length; i++) { - let stmt = statements[i]; - if (!stmt.endsWith(';')) stmt += ';'; - const res = await DBQuery(config as any, tab.dbName || '', stmt); - if (!res.success) { - const prefix = statements.length > 1 ? `第 ${i + 1}/${statements.length} 条语句执行失败: ` : '执行失败: '; - message.error(prefix + res.message); - if (i > 0) await fetchData(); - return false; - } + const result = await executeSchemaStatements(sql); + if (!result.ok) { + message.error(result.message || '执行失败'); + if ((result.failedStatementIndex ?? 0) > 0) await fetchData(); + return false; } message.success(successMessage); await fetchData(); @@ -1633,32 +1688,7 @@ END;`; return; } setIndexModalMode('edit'); - const selectedName = String(selectedIndex.name || '').trim(); - const selectedNameUpper = selectedName.toUpperCase(); - const selectedTypeUpper = String(selectedIndex.indexType || '').trim().toUpperCase(); - let kind: IndexKind = 'NORMAL'; - if (selectedNameUpper === 'PRIMARY') { - kind = 'PRIMARY'; - } else if (selectedTypeUpper === 'FULLTEXT') { - kind = 'FULLTEXT'; - } else if (selectedTypeUpper === 'SPATIAL') { - kind = 'SPATIAL'; - } else if (selectedIndex.nonUnique === 0) { - kind = 'UNIQUE'; - } - const supportedKinds = new Set(getIndexKindOptions().map(item => item.value)); - if (!supportedKinds.has(kind)) { - kind = selectedIndex.nonUnique === 0 ? 'UNIQUE' : 'NORMAL'; - } - - setIndexForm({ - name: kind === 'PRIMARY' ? 'PRIMARY' : selectedName, - columnNames: [...selectedIndex.columnNames], - kind, - indexType: kind === 'NORMAL' || kind === 'UNIQUE' - ? (selectedTypeUpper || 'DEFAULT') - : 'DEFAULT', - }); + setIndexForm(buildIndexFormFromRow(selectedIndex)); setIsIndexModalOpen(true); }; @@ -1817,13 +1847,32 @@ END;`; let sql = addSql; if (indexModalMode === 'edit' && selectedIndex) { + const previousForm = buildIndexFormFromRow(selectedIndex); + const nextForm: IndexFormState = { + name: indexForm.kind === 'PRIMARY' ? 'PRIMARY' : nextName, + columnNames: [...indexForm.columnNames], + kind: indexForm.kind, + indexType: indexForm.kind === 'NORMAL' || indexForm.kind === 'UNIQUE' + ? (String(indexForm.indexType || '').trim().toUpperCase() || 'DEFAULT') + : 'DEFAULT', + }; + if (!hasIndexFormChanged(previousForm, nextForm)) { + setIndexSaving(false); + message.info('没有检测到索引变更'); + return; + } const dropSql = buildIndexDropSql(selectedIndex.name); if (!dropSql) { setIndexSaving(false); message.warning('当前数据库暂不支持删除该索引'); return; } - sql = `${dropSql}\n${addSql}`; + const ok = await executeIndexEditSql(dropSql, addSql, selectedIndex); + setIndexSaving(false); + if (ok) { + setIsIndexModalOpen(false); + } + return; } const ok = await executeSchemaSql(sql, indexModalMode === 'create' ? '索引新增成功' : '索引修改成功'); @@ -2270,12 +2319,16 @@ END;`; const allIndexKeys = groupedIndexes.map(idx => idx.key); const isAllSelected = allIndexKeys.length > 0 && selectedIndexKeys.length === allIndexKeys.length; const isIndeterminate = selectedIndexKeys.length > 0 && selectedIndexKeys.length < allIndexKeys.length; + const toggleIndexSelection = (key: string, checked?: boolean) => { + setSelectedIndexKeys(prev => getNextIndexSelection(prev, key, checked)); + }; const selectColumn = { title: () => ( e.stopPropagation()} onChange={(e) => { setSelectedIndexKeys(e.target.checked ? allIndexKeys : []); }} @@ -2286,18 +2339,19 @@ END;`; key: '_select', width: 48, render: (_: any, record: any) => ( - { + { e.stopPropagation(); - setSelectedIndexKeys(prev => - e.target.checked - ? [...prev, record.key] - : prev.filter(k => k !== record.key) - ); + toggleIndexSelection(record.key); }} - style={{ margin: 0 }} - /> + style={{ display: 'inline-flex' }} + > + undefined} + style={{ margin: 0, pointerEvents: 'none' }} + /> + ), }; @@ -2593,11 +2647,7 @@ END;`; }} onRow={(record) => ({ onClick: () => { - setSelectedIndexKeys(prev => - prev.includes(record.key) - ? prev.filter(k => k !== record.key) - : [...prev, record.key] - ); + toggleIndexSelection(record.key); }, style: { cursor: 'pointer' } })} @@ -2897,7 +2947,7 @@ END;`; />
- 修改索引会执行“先删除旧索引,再创建新索引”。 + 修改索引时若新索引创建失败,系统会尝试自动恢复原索引。
diff --git a/frontend/src/components/tableDesignerIndexUtils.test.ts b/frontend/src/components/tableDesignerIndexUtils.test.ts new file mode 100644 index 0000000..29967ab --- /dev/null +++ b/frontend/src/components/tableDesignerIndexUtils.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { + hasIndexFormChanged, + normalizeIndexFormFromRow, + shouldRestoreOriginalIndex, + toggleIndexSelection, + type IndexDisplaySnapshot, + type IndexFormSnapshot, +} from './tableDesignerIndexUtils'; + +describe('tableDesignerIndexUtils', () => { + it('normalizes index rows for edit form reuse', () => { + const row: IndexDisplaySnapshot = { + key: 'idx_user_name', + name: 'idx_user_name', + indexType: 'btree', + nonUnique: 0, + columnNames: ['name'], + }; + + expect(normalizeIndexFormFromRow(row, ['NORMAL', 'UNIQUE', 'PRIMARY', 'FULLTEXT', 'SPATIAL'])).toEqual({ + name: 'idx_user_name', + columnNames: ['name'], + kind: 'UNIQUE', + indexType: 'BTREE', + }); + }); + + it('detects no-op index edits as unchanged', () => { + const previousForm: IndexFormSnapshot = { + name: 'idx_user_name', + columnNames: ['name'], + kind: 'UNIQUE', + indexType: 'BTREE', + }; + const nextForm: IndexFormSnapshot = { + name: 'idx_user_name', + columnNames: ['name'], + kind: 'UNIQUE', + indexType: 'BTREE', + }; + + expect(hasIndexFormChanged(previousForm, nextForm)).toBe(false); + }); + + it('marks edits as changed when index columns differ', () => { + const previousForm: IndexFormSnapshot = { + name: 'idx_user_name', + columnNames: ['name'], + kind: 'NORMAL', + indexType: 'DEFAULT', + }; + const nextForm: IndexFormSnapshot = { + name: 'idx_user_name', + columnNames: ['name', 'email'], + kind: 'NORMAL', + indexType: 'DEFAULT', + }; + + expect(hasIndexFormChanged(previousForm, nextForm)).toBe(true); + }); + + it('toggles selected index keys without duplicates', () => { + expect(toggleIndexSelection([], 'idx_user_name', true)).toEqual(['idx_user_name']); + expect(toggleIndexSelection(['idx_user_name'], 'idx_user_name', true)).toEqual(['idx_user_name']); + expect(toggleIndexSelection(['idx_user_name'], 'idx_user_name')).toEqual([]); + }); + + it('keeps single-selection toggles stable across repeated clicks', () => { + let selected = toggleIndexSelection([], 'idx_user_name'); + expect(selected).toEqual(['idx_user_name']); + + selected = toggleIndexSelection(selected, 'idx_user_name'); + expect(selected).toEqual([]); + + selected = toggleIndexSelection(selected, 'idx_user_name'); + expect(selected).toEqual(['idx_user_name']); + + selected = toggleIndexSelection(selected, 'idx_user_email'); + expect(selected).toEqual(['idx_user_name', 'idx_user_email']); + + selected = toggleIndexSelection(selected, 'idx_user_email'); + expect(selected).toEqual(['idx_user_name']); + + selected = toggleIndexSelection(selected, 'idx_user_name'); + expect(selected).toEqual([]); + }); + + it('only restores original index when create step fails after drop step', () => { + expect(shouldRestoreOriginalIndex({ failedStatementIndex: 1 })).toBe(true); + expect(shouldRestoreOriginalIndex({ failedStatementIndex: 0 })).toBe(false); + expect(shouldRestoreOriginalIndex({})).toBe(false); + }); +}); diff --git a/frontend/src/components/tableDesignerIndexUtils.ts b/frontend/src/components/tableDesignerIndexUtils.ts new file mode 100644 index 0000000..11c0018 --- /dev/null +++ b/frontend/src/components/tableDesignerIndexUtils.ts @@ -0,0 +1,78 @@ +export type IndexKind = 'NORMAL' | 'UNIQUE' | 'PRIMARY' | 'FULLTEXT' | 'SPATIAL'; + +export interface IndexDisplaySnapshot { + key: string; + name: string; + indexType: string; + nonUnique: number; + columnNames: string[]; +} + +export interface IndexFormSnapshot { + name: string; + columnNames: string[]; + kind: IndexKind; + indexType: string; +} + +export interface SchemaExecutionSnapshot { + failedStatementIndex?: number; +} + +export const normalizeIndexFormFromRow = ( + row: IndexDisplaySnapshot, + supportedKinds: IndexKind[], +): IndexFormSnapshot => { + const selectedName = String(row.name || '').trim(); + const selectedNameUpper = selectedName.toUpperCase(); + const selectedTypeUpper = String(row.indexType || '').trim().toUpperCase(); + let kind: IndexKind = 'NORMAL'; + if (selectedNameUpper === 'PRIMARY') { + kind = 'PRIMARY'; + } else if (selectedTypeUpper === 'FULLTEXT') { + kind = 'FULLTEXT'; + } else if (selectedTypeUpper === 'SPATIAL') { + kind = 'SPATIAL'; + } else if (row.nonUnique === 0) { + kind = 'UNIQUE'; + } + if (!supportedKinds.includes(kind)) { + kind = row.nonUnique === 0 ? 'UNIQUE' : 'NORMAL'; + } + return { + name: kind === 'PRIMARY' ? 'PRIMARY' : selectedName, + columnNames: [...row.columnNames], + kind, + indexType: kind === 'NORMAL' || kind === 'UNIQUE' + ? (selectedTypeUpper || 'DEFAULT') + : 'DEFAULT', + }; +}; + +export const hasIndexFormChanged = ( + previousForm: IndexFormSnapshot, + nextForm: IndexFormSnapshot, +): boolean => { + if (previousForm.name !== nextForm.name) return true; + if (previousForm.kind !== nextForm.kind) return true; + if (previousForm.indexType !== nextForm.indexType) return true; + if (previousForm.columnNames.length !== nextForm.columnNames.length) return true; + return previousForm.columnNames.some((col, idx) => col !== nextForm.columnNames[idx]); +}; + +export const toggleIndexSelection = ( + selectedKeys: string[], + key: string, + checked?: boolean, +): string[] => { + const exists = selectedKeys.includes(key); + const nextChecked = checked ?? !exists; + if (nextChecked) { + return exists ? selectedKeys : [...selectedKeys, key]; + } + return selectedKeys.filter((item) => item !== key); +}; + +export const shouldRestoreOriginalIndex = (result: SchemaExecutionSnapshot): boolean => ( + (result.failedStatementIndex ?? -1) > 0 +); From 98e9e5686d08f0abfc3709ba671854aade26900d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 26 Mar 2026 16:02:08 +0800 Subject: [PATCH 06/14] =?UTF-8?q?=E2=9C=A8=20feat(ai):=20=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E5=85=A8=E6=96=B0=20AI=20Copilot=20=E5=8A=A9=E6=89=8B?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E4=B8=8E=E5=B7=A5=E4=BD=9C=E5=8C=BA=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E6=89=93=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 核心架构:新增独立 AI 会话中枢,集成主流大模型生态(含私有部署中继版)的无感衔接发问 - 智能诊断:打破信息孤岛,大模型可通过关联工作区实时数据表 DDL 和错误栈,充当专属 DBA 排错及代码编写 - 视觉与多模态:支持极简发图读图交互体验,智能补全模型所需的缺省预警 Prompt,并兼容不规范中转端点图文并茂 - UI 与性能:重构聊天浮层挂靠逻辑与渲染阻断,应对长时间巨量问答引发的卡段内存泄漏,会话自动保存归档 --- frontend/package-lock.json | 1231 ++++++++++++- frontend/package.json | 1 + frontend/package.json.md5 | 2 +- frontend/src/components/AIChatPanel.css | 123 +- frontend/src/components/AIChatPanel.tsx | 1572 +++++++++++------ frontend/src/components/AISettingsModal.tsx | 249 ++- frontend/src/components/QueryEditor.tsx | 160 +- frontend/src/components/Sidebar.tsx | 6 +- frontend/src/components/TabManager.tsx | 54 + frontend/src/components/ai/AIChatHeader.tsx | 76 + frontend/src/components/ai/AIChatInput.tsx | 574 ++++++ frontend/src/components/ai/AIChatWelcome.tsx | 64 + .../src/components/ai/AIHistoryDrawer.tsx | 127 ++ .../src/components/ai/AIMessageBubble.tsx | 714 ++++++++ frontend/src/store.ts | 90 +- frontend/src/types.ts | 27 +- frontend/wailsjs/go/aiservice/Service.d.ts | 4 +- frontend/wailsjs/go/aiservice/Service.js | 8 +- frontend/wailsjs/go/models.ts | 122 ++ internal/ai/context/builder.go | 3 +- internal/ai/provider/anthropic.go | 54 +- internal/ai/provider/claude_cli.go | 22 +- internal/ai/provider/gemini.go | 41 +- internal/ai/provider/helper.go | 26 + internal/ai/provider/openai.go | 142 +- internal/ai/service/service.go | 115 +- internal/ai/types.go | 40 +- 27 files changed, 4902 insertions(+), 745 deletions(-) create mode 100644 frontend/src/components/ai/AIChatHeader.tsx create mode 100644 frontend/src/components/ai/AIChatInput.tsx create mode 100644 frontend/src/components/ai/AIChatWelcome.tsx create mode 100644 frontend/src/components/ai/AIHistoryDrawer.tsx create mode 100644 frontend/src/components/ai/AIMessageBubble.tsx mode change 100644 => 100755 frontend/wailsjs/go/aiservice/Service.d.ts mode change 100644 => 100755 frontend/wailsjs/go/aiservice/Service.js create mode 100644 internal/ai/provider/helper.go diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 79a1fb4..dd3c9b5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "antd": "^5.12.0", "clsx": "^2.1.0", + "mermaid": "^11.13.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", @@ -135,6 +136,28 @@ "react": ">=16.9.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@antfu/install-pkg/node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", @@ -426,6 +449,51 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", + "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.1.2", + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/gast": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz", + "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", + "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz", + "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", + "license": "Apache-2.0" + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -896,6 +964,23 @@ "node": ">=12" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", + "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "mlly": "^1.8.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -946,6 +1031,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mermaid-js/parser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.1.tgz", + "integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==", + "license": "MIT", + "dependencies": { + "langium": "^4.0.0" + } + }, "node_modules/@monaco-editor/loader": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", @@ -1529,6 +1623,259 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -1560,6 +1907,12 @@ "@types/estree": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -1640,8 +1993,7 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/unist": { "version": "3.0.3", @@ -1662,6 +2014,16 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1798,6 +2160,18 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/antd": { "version": "5.29.3", "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", @@ -2041,6 +2415,32 @@ "node": ">= 16" } }, + "node_modules/chevrotain": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", + "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.1.2", + "@chevrotain/gast": "11.1.2", + "@chevrotain/regexp-to-ast": "11.1.2", + "@chevrotain/types": "11.1.2", + "@chevrotain/utils": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -2078,6 +2478,12 @@ "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", "license": "MIT" }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2094,12 +2500,529 @@ "toggle-selection": "^1.0.6" } }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -2146,6 +3069,15 @@ "node": ">=6" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2359,6 +3291,12 @@ "node": ">=6.9.0" } }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "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", @@ -2454,12 +3392,33 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "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/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -2557,6 +3516,65 @@ "node": ">=6" } }, + "node_modules/katex": { + "version": "0.16.40", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.40.tgz", + "integrity": "sha512-1DJcK/L05k1Y9Gf7wMcyuqFOL6BiY3vY0CFcAM/LPRN04NALxcl6u7lOWNsp3f/bCHWxigzQl6FbR95XJ4R84Q==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/langium": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", + "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==", + "license": "MIT", + "dependencies": { + "chevrotain": "~11.1.1", + "chevrotain-allstar": "~0.3.1", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.1.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -2913,6 +3931,69 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mermaid": { + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz", + "integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.0.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "lodash-es": "^4.17.23", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, + "node_modules/mermaid/node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mermaid/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -3476,6 +4557,18 @@ ], "license": "MIT" }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, "node_modules/monaco-editor": { "version": "0.55.1", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", @@ -3556,6 +4649,12 @@ "node": ">=0.10.0" } }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -3581,11 +4680,16 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, "license": "MIT" }, "node_modules/pathval": { @@ -3618,6 +4722,33 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -4521,6 +5652,12 @@ "node": ">=0.12" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", @@ -4566,6 +5703,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -4814,6 +5975,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4834,6 +6004,12 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -5158,6 +6334,55 @@ } } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index ff742c6..512b5ac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "antd": "^5.12.0", "clsx": "^2.1.0", + "mermaid": "^11.13.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 07c3ca0..3018db7 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -30f0a7ce75c113ec7a46f3b09f9a37f7 \ No newline at end of file +dcb87159cf0f1f6f750d1c4870911d3f \ No newline at end of file diff --git a/frontend/src/components/AIChatPanel.css b/frontend/src/components/AIChatPanel.css index 08ebc1c..88978ec 100644 --- a/frontend/src/components/AIChatPanel.css +++ b/frontend/src/components/AIChatPanel.css @@ -157,9 +157,9 @@ display: flex; align-items: center; gap: 6px; - font-size: 11px; + font-size: 13px; font-weight: 600; - margin-bottom: 6px; + margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.02em; } @@ -364,3 +364,122 @@ .ai-ide-message:hover .ai-message-actions { opacity: 1 !important; } + +/* Markdown 额外样式增强: Table & Blockquote */ +.ai-markdown-content table { + width: max-content; + min-width: 100%; + border-collapse: collapse; + margin: 12px 0; + font-size: 13px; +} + +/* 让消息内容区域成为表格的滚动约束容器 */ +.ai-ide-message-content { + max-width: 100%; + overflow-x: hidden; +} + +/* 表格滚动容器 - 不限定直接子元素 */ +.ai-markdown-content table { + display: block; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + max-width: 100%; +} + +.ai-markdown-content table::-webkit-scrollbar { + height: 4px; +} + +.ai-markdown-content table::-webkit-scrollbar-thumb { + background: rgba(128, 128, 128, 0.3); + border-radius: 2px; +} + +.ai-markdown-content th, +.ai-markdown-content td { + border: 1px solid rgba(125, 125, 125, 0.2); + padding: 6px 12px; + text-align: left; + white-space: nowrap; +} + +.ai-markdown-content th { + background: rgba(125, 125, 125, 0.1); + font-weight: 600; +} + +.ai-markdown-content blockquote { + margin: 12px 0; + padding: 8px 14px; + border-left: 4px solid rgba(125, 125, 125, 0.4); + background: rgba(125, 125, 125, 0.05); + color: inherit; + opacity: 0.85; + border-radius: 0 6px 6px 0; + font-style: italic; +} + +/* 覆盖 code 块容器样式避免和 syntax highlighter 冲突 */ +.ai-markdown-content > pre { + background: transparent !important; + padding: 0 !important; + margin: 0 !important; +} + +/* ===== 新版 AI 状态流转动画 ===== */ + +/* 1. 连接脉冲动画 (connecting) */ +.ai-wave-pulse { + display: flex; + align-items: center; + gap: 4px; +} +.ai-wave-pulse span { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: currentColor; + animation: wave-pulse-anim 1.2s ease-in-out infinite; +} +.ai-wave-pulse span:nth-child(1) { animation-delay: 0s; } +.ai-wave-pulse span:nth-child(2) { animation-delay: 0.15s; } +.ai-wave-pulse span:nth-child(3) { animation-delay: 0.3s; } + +@keyframes wave-pulse-anim { + 0%, 100% { transform: translateY(0) scale(0.8); opacity: 0.4; } + 50% { transform: translateY(-4px) scale(1.1); opacity: 1; } +} + +/* 2. 平滑高度与透明度过渡 (针对 ThinkingBlock 和 面板折叠) */ +.ai-expand-transition { + display: grid; + transition: grid-template-rows 0.3s ease-out, opacity 0.3s ease-out; +} +.ai-expand-transition.expanded { + grid-template-rows: 1fr; + opacity: 1; +} +.ai-expand-transition.collapsed { + grid-template-rows: 0fr; + opacity: 0; +} +.ai-expand-transition > div { + overflow: hidden; +} + +/* 3. Agent风格旋转Loading环 */ +.ai-spinning-ring { + width: 14px; + height: 14px; + border: 2px solid rgba(22, 119, 255, 0.2); + border-top-color: #1677ff; + border-radius: 50%; + animation: ai-spin-anim 0.8s linear infinite; + flex-shrink: 0; +} + +@keyframes ai-spin-anim { + to { transform: rotate(360deg); } +} diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 1f4cc40..ed46e11 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -1,16 +1,20 @@ -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 React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { createPortal } from 'react-dom'; import { useStore } from '../store'; -import { AIChatMessage } from '../types'; import { EventsOn, EventsOff } from '../../wailsjs/runtime'; +import { DBGetDatabases, DBGetTables } from '../../wailsjs/go/app/App'; 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 { AIChatMessage, AIToolCall } from '../types'; +import { DownOutlined } from '@ant-design/icons'; +import { message } from 'antd'; import './AIChatPanel.css'; +import { AIChatHeader } from './ai/AIChatHeader'; +import { AIChatWelcome } from './ai/AIChatWelcome'; +import { AIMessageBubble } from './ai/AIMessageBubble'; +import { AIChatInput } from './ai/AIChatInput'; +import { AIHistoryDrawer } from './ai/AIHistoryDrawer'; + interface AIChatPanelProps { width?: number; darkMode: boolean; @@ -23,56 +27,185 @@ interface AIChatPanelProps { 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 ? '已复制' : '复制代码'} - - ); +export const getDynamicMaxContextChars = (modelName?: string) => { + if (!modelName) return 258000; // 默认 258k (2026主流基线) + const lower = modelName.toLowerCase(); + + // 「星际杯」- 百万到千万级 Tokens (保守取 2~5M 字符) + if (lower.includes('gemini-1.5-pro') || lower.includes('gemini-2') || lower.includes('gemini-3')) { + return 5000000; + } + // 「超大杯」- 1M Tokens (针对 2026 旗舰:约 1,000,000 字符) + if (lower.includes('glm-5') || lower.includes('claude-4') || lower.includes('claude-3.7') || lower.includes('gpt-5') || lower.includes('qwen3') || lower.includes('deepseek-v4')) { + return 1000000; + } + if (lower.includes('claude-3-opus') || lower.includes('claude-3.5') || lower.includes('glm-4-long') || lower.includes('qwen-long')) { + return 1000000; + } + // 「大杯」- 200K ~ 258K Tokens (针对现代主流:约 258,000 字符) + if (lower.includes('claude') || lower.includes('deepseek') || lower.includes('gpt-4.5') || lower.includes('qwen2.5')) { + return 258000; + } + // 「中杯/小杯」- 128K Tokens (老基线:约 128,000 字符) + if (lower.includes('gpt-4') || lower.includes('gpt-4o') || lower.includes('glm') || lower.includes('z-ai')) { + return 128000; + } + if (lower.includes('qwen')) { + return 128000; + } + // Default fallback + return 258000; }; -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'; }} - > - - 插入 - - - ); +// 当超出指定字符上限时触发上下文自建压缩 +const compressContextIfNeeded = async (sid: string, messagesPayload: any[], maxLimit: number) => { + try { + const chars = messagesPayload.reduce((sum, m) => sum + (m.content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0); + if (chars < maxLimit) return null; + + const Service = (window as any).go?.aiservice?.Service; + if (!Service?.AIChatSend) return null; + + const connectingMsgId = genId(); + useStore.getState().addAIChatMessage(sid, { + id: connectingMsgId, role: 'assistant', phase: 'connecting', content: '⚙️ 对话已超载,正在启动记忆压缩...', timestamp: Date.now(), loading: true + }); + + const summaryPrompt = `这是一段超长对话的历史记录。为了释放上下文空间同时保留你的记忆核心,请你仔细阅读并以“技术事实、已探索出的数据结构状态、用户的中心诉求、当前进展”为准则,进行高度浓缩的结构化总结。 +注意: +1. 客观准确,不能遗漏关键业务逻辑或探索出的表名/字段。 +2. 剔除无效执行过程、客套话、JSON返回值本身。 +3. 请控制在 1000-2000 字左右,输出纯干货 Markdown。 +4. 开头直接输出总结,不要带寒暄。`; + + const sysMsg = { role: 'system', content: summaryPrompt }; + const result = await Service.AIChatSend([sysMsg, ...messagesPayload]); + + if (result?.success && result.content) { + useStore.getState().deleteAIChatMessage(sid, connectingMsgId); + return result.content; + } else { + useStore.getState().updateAIChatMessage(sid, connectingMsgId, { loading: false, phase: 'idle', content: '❌ 记忆压缩失败,将尝试原样接续...' }); + } + } catch (e) { + console.error("Compression exception:", e); + } + return null; }; -export const AIChatPanel: React.FC = ({ width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme }) => { +// 清洗错误信息:去除 HTML 标签、提取关键错误描述、截断过长文本 +const sanitizeErrorMsg = (raw: string): string => { + if (!raw || typeof raw !== 'string') return '未知错误'; + // 检测 HTML 内容 + if (raw.includes(' 内容 + const titleMatch = raw.match(/]*>([^<]+)<\/title>/i); + // 尝试提取 HTTP 状态码 + const codeMatch = raw.match(/\b(4\d{2}|5\d{2})\b/); + const title = titleMatch?.[1]?.trim(); + const code = codeMatch?.[1]; + if (title) return code ? `HTTP ${code}: ${title}` : title; + if (code) return `HTTP ${code} 服务端错误`; + return '服务端返回了异常 HTML 响应(可能是网关超时或服务不可用)'; + } + // 截断过长的纯文本错误 + if (raw.length > 300) return raw.substring(0, 280) + '...(已截断)'; + return raw; +}; + +const LOCAL_TOOLS = [ + { + type: 'function', + function: { + name: 'get_connections', + description: '当需要查询、操作数据库但用户没有选择任何连接上下文时,获取当前软件中可用的所有数据库连接信息。返回的数据包含连接ID(id)和名称(name)。', + parameters: { type: 'object', properties: {} } + } + }, + { + type: 'function', + function: { + name: 'get_databases', + description: '获取指定连接(connectionId)下的所有数据库(Database/Schema)名。', + parameters: { + type: 'object', + properties: { + connectionId: { type: 'string', description: '连接ID (从 get_connections 获取)' } + }, + required: ['connectionId'] + } + } + }, + { + type: 'function', + function: { + name: 'get_tables', + description: '当已经确定了目标连接和数据库名后,如果用户询问或隐式提到了表但你不知道确切表名,调用此工具获取该数据库下的所有表名列表(只含表名,帮助你推断目标表)。', + parameters: { + type: 'object', + properties: { + connectionId: { type: 'string', description: '连接ID' }, + dbName: { type: 'string', description: '数据库名' }, + }, + required: ['connectionId', 'dbName'] + } + } + }, + { + type: 'function', + function: { + name: 'get_columns', + description: '获取指定表的字段列表(字段名、类型、是否可空、默认值、注释等)。在生成 SQL 之前必须先调用此工具确认真实字段名,禁止猜测字段名。', + parameters: { + type: 'object', + properties: { + connectionId: { type: 'string', description: '连接ID' }, + dbName: { type: 'string', description: '数据库名' }, + tableName: { type: 'string', description: '表名' }, + }, + required: ['connectionId', 'dbName', 'tableName'] + } + } + }, + { + type: 'function', + function: { + name: 'get_table_ddl', + description: '获取指定表的完整建表语句(CREATE TABLE DDL),包含字段、索引、约束等完整结构信息。', + parameters: { + type: 'object', + properties: { + connectionId: { type: 'string', description: '连接ID' }, + dbName: { type: 'string', description: '数据库名' }, + tableName: { type: 'string', description: '表名' }, + }, + required: ['connectionId', 'dbName', 'tableName'] + } + } + }, + { + type: 'function', + function: { + name: 'execute_sql', + description: '在指定连接和数据库上执行 SQL 查询并返回结果。受安全级别控制,只读模式下只能执行 SELECT/SHOW/DESCRIBE 等查询操作。结果最多返回 50 行。', + parameters: { + type: 'object', + properties: { + connectionId: { type: 'string', description: '连接ID' }, + dbName: { type: 'string', description: '数据库名' }, + sql: { type: 'string', description: '要执行的 SQL 语句' }, + }, + required: ['connectionId', 'dbName', 'sql'] + } + } + } +]; + +export const AIChatPanel: React.FC = ({ + width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme +}) => { const [input, setInput] = useState(''); + const [draftImages, setDraftImages] = useState([]); const [sending, setSending] = useState(false); const [activeProvider, setActiveProvider] = useState(null); const [dynamicModels, setDynamicModels] = useState([]); @@ -81,25 +214,64 @@ export const AIChatPanel: React.FC = ({ width = 380, darkMode, 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 toolCallRoundRef = useRef(0); // 连续失败轮次计数 + const nudgeCountRef = useRef(0); // 催促模型使用 function call 的次数 + const panelRef = useRef(null); // 面板 DOM ref,用于拖拽时直接操作宽度 + const dragWidthRef = useRef(0); // 拖拽过程中的实时宽度(不触发 React 重渲染) 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 updateAISessionTitle = useStore(state => state.updateAISessionTitle); + const activeContext = useStore(state => state.activeContext); + const aiContexts = useStore(state => state.aiContexts); const connections = useStore(state => state.connections); + const tabs = useStore(state => state.tabs); + const activeTabId = useStore(state => state.activeTabId); + const aiPanelVisible = useStore(state => state.aiPanelVisible); + + // Auto-Context Injection Hook + useEffect(() => { + if (!aiPanelVisible) return; + const activeTab = tabs.find(t => t.id === activeTabId); + if (activeTab && (activeTab.type === 'table' || activeTab.type === 'design')) { + const { connectionId, dbName, tableName } = activeTab; + if (connectionId && dbName && tableName) { + const connKey = `${connectionId}:${dbName}`; + const currentContexts = useStore.getState().aiContexts[connKey] || []; + if (!currentContexts.find(c => c.dbName === dbName && c.tableName === tableName)) { + const conn = useStore.getState().connections.find(c => c.id === connectionId); + if (conn) { + import('../../wailsjs/go/app/App').then(({ DBShowCreateTable }) => { + DBShowCreateTable(conn.config as any, dbName, tableName).then(res => { + if (res.success && res.data) { + let createSql = ''; + if (typeof res.data === 'string') createSql = res.data; + else if (Array.isArray(res.data) && res.data.length > 0) { + const row = res.data[0]; + createSql = (Object.values(row).find(v => typeof v === 'string' && (v.toUpperCase().includes('CREATE TABLE') || v.toUpperCase().includes('CREATE'))) || Object.values(row)[1] || Object.values(row)[0]) as string; + } + if (createSql) { + useStore.getState().addAIContext(connKey, { dbName: dbName, tableName, ddl: createSql }); + } + } + }); + }).catch(err => console.error("Failed to auto-fetch table context", err)); + } + } + } + } + }, [aiPanelVisible, activeTabId, tabs]); useEffect(() => { if (!aiActiveSessionId) { @@ -108,6 +280,7 @@ export const AIChatPanel: React.FC = ({ width = 380, darkMode, }, [aiActiveSessionId, createNewAISession]); const sid = aiActiveSessionId || 'session-fallback'; + const messages = aiChatHistory[sid] || []; const getConnectionName = useCallback(() => { if (!activeContext?.connectionId) return ''; @@ -117,19 +290,13 @@ export const AIChatPanel: React.FC = ({ width = 380, darkMode, 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; @@ -147,7 +314,6 @@ export const AIChatPanel: React.FC = ({ width = 380, darkMode, useEffect(() => { loadActiveProvider(); }, [loadActiveProvider]); - // 模型切换 const handleModelChange = async (val: string) => { if (!activeProvider) return; try { @@ -158,7 +324,31 @@ export const AIChatPanel: React.FC = ({ width = 380, darkMode, } catch (e) { console.warn('Failed to update provider model', e); } }; - // 动态获取模型列表 + const activeProviderIdRef = useRef(null); + + useEffect(() => { + if (activeProvider?.id && activeProvider.id !== activeProviderIdRef.current) { + setDynamicModels([]); + activeProviderIdRef.current = activeProvider.id; + } + }, [activeProvider?.id]); + + useEffect(() => { + if (activeProvider && dynamicModels.length > 0) { + const currentModels = activeProvider.models || []; + if (JSON.stringify(currentModels) !== JSON.stringify(dynamicModels)) { + try { + const Service = (window as any).go?.aiservice?.Service; + const payload = { ...activeProvider, models: dynamicModels }; + Service?.AISaveProvider?.(payload); + setActiveProvider(payload); + } catch (e) { + console.warn('Failed to cache models', e); + } + } + } + }, [activeProvider, dynamicModels]); + const fetchDynamicModels = useCallback(async () => { try { setLoadingModels(true); @@ -166,27 +356,23 @@ export const AIChatPanel: React.FC = ({ width = 380, darkMode, 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); + const sortedModels = [...result.models].sort((a, b) => a.localeCompare(b)); + setDynamicModels(sortedModels); + } else if (result && !result.success) { + message.warning(result.error || '获取模型列表失败,可手动输入模型名称'); } - } catch (e) { + } catch (e: any) { console.warn('Failed to fetch models', e); + message.warning('获取模型列表失败: ' + (e?.message || '未知错误')); } finally { setLoadingModels(false); } }, []); - // 自动滚动到底部(增加对发送状态的判定,实现完美跟随) useEffect(() => { - if (sending) { - // 流式输出期间,改用 auto 避免动画累加导致的卡顿漂移 - messagesEndRef.current?.scrollIntoView({ behavior: 'auto', block: 'end' }); - } else { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); - } - }, [messages, sending]); + messagesEndRef.current?.scrollIntoView({ behavior: sending ? 'auto' : 'smooth', block: 'end' }); + }, [messages.length, sending]); - // 面板初次打开时,自动聚焦输入框 useEffect(() => { const timer = setTimeout(() => { textareaRef.current?.focus(); @@ -194,19 +380,15 @@ export const AIChatPanel: React.FC = ({ width = 380, darkMode, 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; + const el = textareaRef.current as any; if (el) { el.focus(); - el.style.height = 'auto'; - el.style.height = Math.min(el.scrollHeight, 200) + 'px'; } }, 50); } @@ -215,69 +397,211 @@ export const AIChatPanel: React.FC = ({ width = 380, darkMode, return () => window.removeEventListener('gonavi:ai:inject-prompt', handler); }, []); - // 流式监听 useEffect(() => { const eventName = `ai:stream:${sid}`; let assistantMsgId = ''; + let isFirstCompletion = false; + + // 新增:利用 requestAnimationFrame 缓冲高频事件,避免 React 重绘阻塞导致感官吞吐变慢 + const streamBuffer = { thinking: '', content: '' }; + let flushPending = false; + + const flushStreamBuffer = () => { + if (!assistantMsgId) return; + const current = useStore.getState().aiChatHistory[sid]; + const existing = current?.find(m => m.id === assistantMsgId); + if (!existing) return; + + const updates: any = {}; + if (streamBuffer.thinking) { + updates.thinking = (existing.thinking || '') + streamBuffer.thinking; + updates.phase = 'thinking'; + streamBuffer.thinking = ''; + } + if (streamBuffer.content) { + updates.content = (existing.content || '') + streamBuffer.content; + updates.phase = 'generating'; + streamBuffer.content = ''; + } + + if (Object.keys(updates).length > 0) { + updateAIChatMessage(sid, assistantMsgId, updates); + } + flushPending = false; + }; + + const handler = (data: { content?: string; thinking?: string; tool_calls?: AIToolCall[]; done?: boolean; error?: string }) => { + // Find connecting message if there's no active assistant string + if (!assistantMsgId) { + const history = useStore.getState().aiChatHistory[sid] || []; + const lastMsg = history[history.length - 1]; + if (lastMsg && lastMsg.role === 'assistant' && lastMsg.loading && lastMsg.phase === 'connecting') { + assistantMsgId = lastMsg.id; + // 【关键】接管 connecting 消息时,立即清空其过渡文案,防止泄漏到 AI 回复正文 + updateAIChatMessage(sid, assistantMsgId, { content: '' }); + } + } - const handler = (data: { content?: string; done?: boolean; error?: string }) => { - console.log('[AI Chat] Stream event received:', JSON.stringify(data)); if (data.error) { + const cleanErr = sanitizeErrorMsg(data.error); + const rawErr = cleanErr !== data.error ? data.error : undefined; if (assistantMsgId) { - updateAIChatMessage(sid, assistantMsgId, { - content: `❌ 错误: ${data.error}`, - loading: false, - }); + updateAIChatMessage(sid, assistantMsgId, { content: `❌ 错误: ${cleanErr}`, phase: 'idle', loading: false, rawError: rawErr }); } else { - // 尚未创建 assistant 消息时,新建一条错误消息 - addAIChatMessage(sid, { - id: genId(), - role: 'assistant', - content: `❌ 错误: ${data.error}`, - timestamp: Date.now(), - }); + addAIChatMessage(sid, { id: genId(), role: 'assistant', phase: 'idle', content: `❌ 错误: ${cleanErr}`, rawError: rawErr, timestamp: Date.now() }); } assistantMsgId = ''; setSending(false); return; } + if (data.tool_calls && data.tool_calls.length > 0) { + if (assistantMsgId) { + updateAIChatMessage(sid, assistantMsgId, { tool_calls: data.tool_calls, phase: 'tool_calling' }); + } else { + assistantMsgId = genId(); + addAIChatMessage(sid, { id: assistantMsgId, role: 'assistant', phase: 'tool_calling', content: '', tool_calls: data.tool_calls, timestamp: Date.now(), loading: true }); + } + } + + // 处理 thinking(模型思考过程) + if (data.thinking) { + if (!assistantMsgId) { + assistantMsgId = genId(); + addAIChatMessage(sid, { id: assistantMsgId, role: 'assistant', phase: 'thinking', content: '', thinking: data.thinking, timestamp: Date.now(), loading: true }); + if (sending) setSending(false); + } else { + streamBuffer.thinking += data.thinking; + if (sending) setSending(false); + } + } + if (data.content) { if (!assistantMsgId) { assistantMsgId = genId(); - addAIChatMessage(sid, { - id: assistantMsgId, - role: 'assistant', - content: data.content, - timestamp: Date.now(), - loading: true, - }); + addAIChatMessage(sid, { id: assistantMsgId, role: 'assistant', phase: 'generating', content: data.content, timestamp: Date.now(), loading: true }); + setSending(false); + const currentHistory = useStore.getState().aiChatHistory[sid] || []; + if (currentHistory.length <= 1) isFirstCompletion = true; } else { - const current = useStore.getState().aiChatHistory[sid]; - const existing = current?.find(m => m.id === assistantMsgId); - updateAIChatMessage(sid, assistantMsgId, { - content: (existing?.content || '') + data.content, - }); + streamBuffer.content += data.content; + if (sending) setSending(false); + } + } + + if (streamBuffer.thinking || streamBuffer.content) { + if (!flushPending) { + flushPending = true; + requestAnimationFrame(flushStreamBuffer); } } if (data.done) { - if (assistantMsgId) { - updateAIChatMessage(sid, assistantMsgId, { loading: false }); + // 如果有残留未 flush 的 buffer,立刻推入状态树 + if (streamBuffer.thinking || streamBuffer.content) { + flushStreamBuffer(); } + const doneAssistantId = assistantMsgId; + const doneIsFirst = isFirstCompletion; assistantMsgId = ''; - setSending(false); + setTimeout(() => { + // 🔧 清除所有残留的 connecting 过渡气泡的 loading 状态 + const currentMsgs = useStore.getState().aiChatHistory[sid] || []; + for (const msg of currentMsgs) { + if (msg.id !== doneAssistantId && msg.loading && msg.phase === 'connecting') { + updateAIChatMessage(sid, msg.id, { loading: false, phase: 'idle' }); + } + } + + if (doneAssistantId) { + const current = useStore.getState().aiChatHistory[sid]; + const existing = current?.find(m => m.id === doneAssistantId); + if (existing && existing.tool_calls && existing.tool_calls.length > 0) { + // 【关键】保持 loading:true 和 phase:'tool_calling',让 UI 能实时展示工具执行进度 + nudgeCountRef.current = 0; + setTimeout(() => executeLocalTools(existing.tool_calls!, doneAssistantId), 50); + return; + } + + // 自动催促:模型描述了要调用工具但没有 function call + if (existing && nudgeCountRef.current < 2 && + /(?:让我|我先|我来|现在|接下来|下面).*(?:查询|查找|获取|查看|检查|调用)|(?:获取|查询|查找|查看).*(?:信息|字段|列表|数据)[::]?\s*$/.test(existing.content || '')) { + nudgeCountRef.current += 1; + // 🔧 关闭当前消息的 loading 状态,消除闪烁光标 + updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' }); + // 注入 system 催促并重发 + (async () => { + try { + const currentHistory = useStore.getState().aiChatHistory[sid] || []; + const messagesPayload = currentHistory.map(m => { + const mapped: any = { role: m.role, content: m.content, images: m.images }; + if (m.tool_calls) mapped.tool_calls = m.tool_calls; + if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id; + return mapped; + }); + const sysMessages = await buildSystemContextMessages(); + // 追加催促消息 + messagesPayload.push({ role: 'user', content: '请直接使用 function call 调用工具执行操作,不要只用文字描述计划。' }); + const allMsg = [...sysMessages, ...messagesPayload]; + const Service = (window as any).go?.aiservice?.Service; + if (Service?.AIChatStream) await Service.AIChatStream(sid, allMsg, LOCAL_TOOLS); + } catch (e) { + console.error('Nudge failed', e); + setSending(false); + } + })(); + return; + } + + if (doneIsFirst) generateTitleForSession(sid); + + // 正常完成:关闭 loading,消除闪烁光标 + const hasContent = !!existing?.content?.trim(); + const hasThinking = !!existing?.thinking?.trim(); + const hasTools = !!(existing?.tool_calls?.length); + + if (!hasContent && !hasThinking && !hasTools) { + updateAIChatMessage(sid, doneAssistantId, { content: '❌ 模型未能成功响应任何内容,可能遭遇频控、上下文超载或理解拒绝。', loading: false, phase: 'idle' }); + } else { + updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' }); + } + } else { + addAIChatMessage(sid, { id: genId(), role: 'assistant', content: '❌ 请求中断:未收到任何具体回复。', timestamp: Date.now(), loading: false }); + } + setSending(false); + }, 50); } }; EventsOn(eventName, handler); - console.log('[AI Chat] Listening on event:', eventName); - return () => { - EventsOff(eventName); - }; + return () => { EventsOff(eventName); }; }, [addAIChatMessage, updateAIChatMessage, sid]); - // ---- 列表滚动逻辑 ---- + const generateTitleForSession = async (currentSid: string) => { + try { + const Service = (window as any).go?.aiservice?.Service; + const historyLocal = useStore.getState().aiChatHistory[currentSid] || []; + if (!Service?.AIChatSend || historyLocal.length < 2) return; + + const firstUserMsg = historyLocal.find(m => m.role === 'user'); + if (firstUserMsg) { + // 取用前 50 个字符截断,防止太长的查询消耗过多 Token + const snippet = firstUserMsg.content.slice(0, 50); + const titleReq = [ + { role: 'system', content: 'You are a summarizer. Provide a short 3-6 word title for this prompt. Do not use quotes, punctuation, or explain. Just the title in the same language as the prompt.' }, + { role: 'user', content: snippet } + ]; + const res = await Service.AIChatSend(titleReq); + if (res?.success && res.content) { + const cleanTitle = res.content.trim().replace(/^["']|["']$/g, ''); + updateAISessionTitle(currentSid, cleanTitle); + } + } + } catch (e) { + console.warn('Failed to auto-generate title', e); + } + }; + const handleScrollMessages = useCallback((e: React.UIEvent) => { const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; const isNearBottom = scrollHeight - scrollTop - clientHeight < 150; @@ -288,7 +612,6 @@ export const AIChatPanel: React.FC = ({ width = 380, darkMode, messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, []); - // ---- 气泡快捷操作 ---- const handleEditMessage = useCallback((msg: AIChatMessage) => { truncateAIChatMessages(sid, msg.id); deleteAIChatMessage(sid, msg.id); @@ -311,20 +634,26 @@ export const AIChatPanel: React.FC = ({ width = 380, darkMode, if (lastUserMsgIndex >= 0) { const userMsg = historyLocal[lastUserMsgIndex]; - truncateAIChatMessages(sid, userMsg.id); // 保留到该 userInput 后,丢弃之前生成的失败回复 + truncateAIChatMessages(sid, userMsg.id); setSending(true); const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1); - const messagesPayload = truncatedHistory.map(m => ({ role: m.role, content: m.content })); + const messagesPayload = truncatedHistory.map(m => ({ role: m.role, content: m.content, images: m.images })); try { + const sysMessages = await buildSystemContextMessages(); + const allMessages = [...sysMessages, ...messagesPayload]; + const Service = (window as any).go?.aiservice?.Service; if (Service?.AIChatStream) { - await Service.AIChatStream(sid, messagesPayload); + await Service.AIChatStream(sid, allMessages, LOCAL_TOOLS); } else if (Service?.AIChatSend) { - const result = await Service.AIChatSend(messagesPayload); + const result = await Service.AIChatSend(allMessages, LOCAL_TOOLS); + const errRaw = result?.error || '未知错误'; + const errClean = sanitizeErrorMsg(errRaw); addAIChatMessage(sid, { id: genId(), role: 'assistant', - content: result?.success ? result.content : `❌ ${result?.error || '未知错误'}`, + content: result?.success ? result.content : `❌ ${errClean}`, + rawError: (!result?.success && errClean !== errRaw) ? errRaw : undefined, timestamp: Date.now() }); setSending(false); @@ -332,75 +661,442 @@ export const AIChatPanel: React.FC = ({ width = 380, darkMode, setSending(false); } } catch(e: any) { - addAIChatMessage(sid, { id: genId(), role: 'assistant', content: `❌ 发送失败: ${e?.message || e}`, timestamp: Date.now() }); + const rawE = e?.message || String(e); + const cleanE = sanitizeErrorMsg(rawE); + addAIChatMessage(sid, { id: genId(), role: 'assistant', content: `❌ 发送失败: ${cleanE}`, rawError: cleanE !== rawE ? rawE : undefined, timestamp: Date.now() }); setSending(false); } } }, [sid, truncateAIChatMessages, addAIChatMessage]); + const buildSystemContextMessages = useCallback(async () => { + // 🔧 性能优化:从 store 实时读取,避免闭包捕获导致的依赖链式重建 + const { activeContext: ctx, aiContexts: ctxMap, connections: conns, tabs: allTabs, activeTabId: tabId } = useStore.getState(); + + const connectionKey = ctx?.connectionId ? `${ctx.connectionId}:${ctx.dbName || ''}` : 'default'; + const activeContextItems = ctxMap[connectionKey] || []; + const systemMessages: { role: string; content: string; images?: string[] }[] = []; + + let targetConnId = ctx?.connectionId; + let targetDbName = ctx?.dbName; + if (!targetConnId || !targetDbName) { + const activeTab = allTabs.find(t => t.id === tabId); + if (activeTab && activeTab.connectionId && activeTab.dbName) { + targetConnId = activeTab.connectionId; + targetDbName = activeTab.dbName; + } + } + + if (activeContextItems.length > 0) { + const conn = conns.find(c => c.id === targetConnId); + const dbType = conn?.config?.type || 'unknown'; + const dbDisplayType = dbType === 'diros' ? 'Doris' : dbType.charAt(0).toUpperCase() + dbType.slice(1); + const ddlChunks = activeContextItems.map(c => `-- Table: ${c.dbName}.${c.tableName}\n${c.ddl}`).join('\n\n'); + systemMessages.push({ + role: 'system', + content: `你是一个专业的数据库助手。当前连接的数据库类型是 ${dbDisplayType}。请使用 ${dbDisplayType} 方言生成 SQL。以下是用户关联的表结构信息,请在回答时优先参考:\n\n${ddlChunks}` + }); + } + else if (targetConnId && targetDbName) { + const conn = conns.find(c => c.id === targetConnId); + const dbType = conn?.config?.type || 'unknown'; + const dbDisplayType = dbType === 'diros' ? 'Doris' : dbType.charAt(0).toUpperCase() + dbType.slice(1); + systemMessages.push({ + role: 'system', + content: `你是一个专业的数据库助手。当前连接的数据库类型是 ${dbDisplayType},当前数据库名为 ${targetDbName}。如果用户需要查询特定的表或者有关当前库的信息,你可以调用提供的 get_tables 工具来主动获取数据表信息。` + }); + } + else { + const connList = conns.map(c => `{id: "${c.id}", name: "${c.name}", type: "${c.config?.type || 'unknown'}"}`).join(', '); + systemMessages.push({ + role: 'system', + content: `你是一个专业的数据库助手。用户目前在界面上没有选中任何具体的数据库或数据表用于充当上下文。 + +重要规则: +1. 如果你需要帮用户寻找目标表,千万不要凭空猜测表名!必须调用工具去获取真实数据。 +2. 完整工作流程:get_connections → get_databases → get_tables → get_columns → 生成 SQL。每一步都不可跳过。 +3. 【连接优先级 - 极重要】获取连接列表后,必须按以下优先级依次检索: + - 第一优先:host 为 localhost、127.0.0.1、或包含"本地"的连接 + - 第二优先:name 或 host 包含"开发"、"dev"、"local" 的连接,或 host 为 10.x、192.168.x、172.16-31.x 等内网 IP 的连接 + - 第三优先:其他连接(如"测试"、"生产"等) + 如果在高优先级连接中已找到目标表,直接使用该连接,不再查找低优先级连接。 +4. 如果在当前数据库中未找到目标表,必须继续查询其他数据库,不要放弃。 +5. 只有当所有可能的数据库都已检查完毕,或者已经明确找到目标表时,才可以停止。 +6. 如果是常规问答(不涉及数据库查询)则正常作答即可。 + +SQL 生成规则(极重要,必须严格遵守): +7. 【字段精确性 - 绝对红线】生成 SQL 之前,必须先调用 get_columns 获取目标表的真实字段列表。SQL 中的每一个字段名必须与 get_columns 返回的 field 字段完全一致(区分大小写)。不得自行拼凑、缩写或联想字段名(例如字段是 channel 就必须写 channel,不得写成 pay_channel)。 +8. 生成 SQL 时禁止使用 "database.table" 格式的限定前缀,只写表名本身。 +9. 报告结果时,连接名/ID 和数据库名必须严格来自同一个 get_tables 调用的实际参数。禁止将 A 连接的 connectionId 与 B 连接的 dbName 混搭。 +10. 如果有多个名称相似的数据库,请明确告诉用户目标表具体位于哪个数据库。 +11. 【关键】每个 SQL 代码块的第一行必须添加上下文声明注释,格式严格为:-- @context connectionId=<连接ID> dbName=<数据库名>。connectionId 和 dbName 必须来自同一个成功的 get_tables 调用(即你在该调用中传入的实际参数值)。示例: +\`\`\`sql +-- @context connectionId=1770778676549 dbName=mkefu_test +SELECT * FROM users WHERE status = 1; +\`\`\` + +当前存在的连接:[${connList || '无连接'}]` + }); + } + return systemMessages; + }, []); // 零依赖:函数内部通过 useStore.getState() 实时读取 + + // 记录所有成功的 get_tables 调用结果,用于表级精确匹配 + const toolContextMapRef = useRef>(new Map()); + + const executeLocalTools = useCallback(async (toolCalls: AIToolCall[], currentAsstMsgId: string) => { + const results: AIChatMessage[] = []; + // 【串行逐条执行 + 实时写入 store】 + for (const tc of toolCalls) { + let resStr = ''; + let success = false; + try { + const args = JSON.parse(tc.function.arguments || '{}'); + switch (tc.function.name) { + case 'get_connections': + const conns = useStore.getState().connections.map(c => ({ + id: c.id, + name: c.name, + type: c.config?.type, + host: (c.config as any)?.host || (c.config as any)?.addr || '' + })); + resStr = JSON.stringify(conns); + success = true; + break; + case 'get_databases': { + const conn = useStore.getState().connections.find(c => c.id === args.connectionId); + if (conn) { + try { + const dbRes = await DBGetDatabases(conn.config as any); + if (dbRes?.success && Array.isArray(dbRes.data)) { + let dNames = dbRes.data.map((r: any) => r.Database || r.database || Object.values(r)[0]); + if (dNames.length > 50) dNames = [...dNames.slice(0, 50), '...(截断)']; + resStr = JSON.stringify(dNames); + success = true; + } else { + resStr = dbRes?.message || 'Failed to fetch DBs'; + } + } catch (e: any) { + resStr = `获取数据库列表失败: ${e?.message || e}`; + } + } else { resStr = 'Connection not found'; } + break; + } + case 'get_tables': { + const conn = useStore.getState().connections.find(c => c.id === args.connectionId); + if (conn) { + try { + const rawDbName = args.dbName || args.database; + const safeDbName = rawDbName ? String(rawDbName).trim() : ''; + const tbRes = await DBGetTables(conn.config as any, safeDbName); + if (tbRes?.success && Array.isArray(tbRes.data)) { + let tNames = tbRes.data.map((r: any) => r.Table || r.table || Object.values(r)[0] as string); + if (tNames.length > 150) tNames = [...tNames.slice(0, 150), '...(截断)']; + resStr = JSON.stringify(tNames); + success = true; + // 🔑 记录已验证的上下文参数和表列表(用于后续表级精确匹配) + toolContextMapRef.current.set(`${args.connectionId}:${safeDbName}`, { + connectionId: args.connectionId, + dbName: safeDbName, + tables: tNames.filter((t: string) => t !== '...(截断)') + }); + } else { resStr = tbRes?.message || 'Failed to fetch Tables'; } + } catch (e: any) { + resStr = `获取表列表失败: ${e?.message || e}`; + } + } else { resStr = 'Connection not found'; } + break; + } + case 'get_columns': { + const conn = useStore.getState().connections.find(c => c.id === args.connectionId); + if (conn) { + try { + const safeDbName = args.dbName ? String(args.dbName).trim() : ''; + const safeTable = args.tableName ? String(args.tableName).trim() : ''; + const { DBGetColumns } = await import('../../wailsjs/go/app/App'); + const colRes = await DBGetColumns(conn.config as any, safeDbName, safeTable); + if (colRes?.success && Array.isArray(colRes.data)) { + // 只保留关键字段信息,减少 token 占用 + const cols = colRes.data.map((c: any) => { + const keys = Object.keys(c); + return { + field: c.Field || c.field || c.COLUMN_NAME || c.column_name || c.Name || c.name || (keys.length > 0 ? c[keys[0]] : ''), + type: c.Type || c.type || c.DATA_TYPE || c.data_type || (keys.length > 1 ? c[keys[1]] : ''), + nullable: c.Null || c.null || c.IS_NULLABLE || c.is_nullable || c.Nullable || c.nullable || '', + default: c.Default || c.default || c.COLUMN_DEFAULT || c.column_default || c.DefaultValue || '', + comment: c.Comment || c.comment || c.COLUMN_COMMENT || c.column_comment || c.Description || '', + }; + }); + // ⚠️ 在工具返回结果中直接注入强制警告,确保模型使用精确字段名 + const fieldNames = cols.map((c: any) => c.field).join(', '); + resStr = `⚠️ 以下为 ${safeTable} 表的真实字段列表。生成 SQL 时只能使用这些 field 值作为列名,必须原样使用,禁止修改、缩写或自行拼凑字段名。\n可用字段:${fieldNames}\n详细信息:${JSON.stringify(cols)}`; + success = true; + } else { resStr = colRes?.message || 'Failed to fetch columns'; } + } catch (e: any) { + resStr = `获取字段列表失败: ${e?.message || e}`; + } + } else { resStr = 'Connection not found'; } + break; + } + case 'get_table_ddl': { + const conn = useStore.getState().connections.find(c => c.id === args.connectionId); + if (conn) { + try { + const safeDbName = args.dbName ? String(args.dbName).trim() : ''; + const safeTable = args.tableName ? String(args.tableName).trim() : ''; + const { DBShowCreateTable } = await import('../../wailsjs/go/app/App'); + const ddlRes = await DBShowCreateTable(conn.config as any, safeDbName, safeTable); + if (ddlRes?.success) { + resStr = typeof ddlRes.data === 'string' ? ddlRes.data : JSON.stringify(ddlRes.data); + success = true; + } else { resStr = ddlRes?.message || 'Failed to fetch DDL'; } + } catch (e: any) { + resStr = `获取建表语句失败: ${e?.message || e}`; + } + } else { resStr = 'Connection not found'; } + break; + } + case 'execute_sql': { + const conn = useStore.getState().connections.find(c => c.id === args.connectionId); + if (conn) { + try { + const safeDbName = args.dbName ? String(args.dbName).trim() : ''; + const safeSql = args.sql ? String(args.sql).trim() : ''; + // 安全级别检查 + const Service = (window as any).go?.aiservice?.Service; + if (Service?.AICheckSQL) { + const check = await Service.AICheckSQL(safeSql); + if (!check.allowed) { + resStr = `安全策略拦截:当前安全级别不允许执行 ${check.operationType} 类型的 SQL。请将 SQL 展示给用户,让用户手动执行。`; + break; + } + } + const { DBQuery } = await import('../../wailsjs/go/app/App'); + const qRes = await DBQuery(conn.config as any, safeDbName, safeSql + (safeSql.toLowerCase().includes('limit') ? '' : ' LIMIT 50')); + if (qRes?.success) { + const rows = Array.isArray(qRes.data) ? qRes.data : []; + const limitedRows = rows.slice(0, 50); + resStr = JSON.stringify({ rowCount: rows.length, data: limitedRows }); + success = true; + } else { resStr = qRes?.message || 'SQL 执行失败'; } + } catch (e: any) { + resStr = `SQL 执行异常: ${e?.message || e}`; + } + } else { resStr = 'Connection not found'; } + break; + } + default: + resStr = `Unknown function: ${tc.function.name}`; + } + } catch (e: any) { + resStr = e.message; + } + + const toolResultMsg: AIChatMessage = { + id: genId(), + role: 'tool', + content: resStr, + timestamp: Date.now(), + tool_call_id: tc.id, + tool_name: tc.function.name, + success + }; + results.push(toolResultMsg); + + // 【实时写入】每执行完一条立即写入 store,让 UI 能实时看到进度打勾 + useStore.getState().addAIChatMessage(sid, toolResultMsg); + + // 延迟 150ms,给 UI 渲染时间,创造“逐个完成”的视觉节奏 + await new Promise(resolve => setTimeout(resolve, 150)); + } + + // 智能熔断:只计连续失败轮次,成功则重置 + const anySuccess = results.some(r => r.success === true); + if (anySuccess) { + toolCallRoundRef.current = 0; + } else { + toolCallRoundRef.current += 1; + if (toolCallRoundRef.current >= 3) { + useStore.getState().addAIChatMessage(sid, { + id: genId(), role: 'assistant', + content: '⚠️ 探针连续 3 轮执行失败,自动终止。请检查连接状态后重试。', + timestamp: Date.now(), + }); + setSending(false); + return; + } + } + try { + // 【过渡状态】工具执行完毕,将上一条消息的 loading 关闭(消除闪烁光标) + updateAIChatMessage(sid, currentAsstMsgId, { loading: false, phase: 'idle' }); + + // 插入过渡气泡 + const chainConnectingMsg: AIChatMessage = { + id: genId(), role: 'assistant', phase: 'connecting', + content: '汇总探针执行结果中', + timestamp: Date.now(), loading: true + }; + useStore.getState().addAIChatMessage(sid, chainConnectingMsg); + + // 模拟人类视角的平滑多段过渡 + const safeUpdateTransition = (text: string) => { + const currentMsg = useStore.getState().aiChatHistory[sid]?.find(m => m.id === chainConnectingMsg.id); + // 只有当消息仍然处于连接过渡态时才允许修改文本;如果模型已经开始吐出思考、正文、工具或结束,直接退出 + if (currentMsg && currentMsg.phase === 'connecting' && currentMsg.loading) { + updateAIChatMessage(sid, chainConnectingMsg.id, { content: text }); + } + }; + + setTimeout(() => safeUpdateTransition('向模型回传运行时数据'), 200); + setTimeout(() => safeUpdateTransition('模型大脑深度推理中'), 500); + setTimeout(() => safeUpdateTransition('等待下发操作指令'), 1200); + setTimeout(() => safeUpdateTransition('正在深度思考链路与逻辑'), 3000); + + setSending(true); + const currentHistory = useStore.getState().aiChatHistory[sid] || []; + // 过滤掉 connecting 占位消息,不发给模型 + const messagesPayload = currentHistory.filter(m => m.phase !== 'connecting').map(m => { + const mapped: any = { role: m.role, content: m.content, images: m.images }; + if (m.tool_calls) mapped.tool_calls = m.tool_calls; + if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id; + return mapped; + }); + const sysMessages = await buildSystemContextMessages(); + + let finalMessagesPayload = messagesPayload; + // 在这里加入长度检查和自动摘要(带上动态限额) + const dynamicMaxLimit = getDynamicMaxContextChars(activeProvider?.model); + const summary = await compressContextIfNeeded(sid, messagesPayload, dynamicMaxLimit); + if (summary) { + const compressedMsg: AIChatMessage = { + id: genId(), role: 'assistant', content: `【自动记忆重塑】已将超长历史探针数据和对话压缩为摘要:\n\n${summary}`, timestamp: Date.now() - 1000 + }; + const continueMsg: AIChatMessage = { + id: genId(), role: 'user', content: '请根据上述最新状态与探索结果,继续完成你先前未竟的分析或执行下一步。', timestamp: Date.now() - 500 + }; + useStore.getState().replaceAIChatHistory(sid, [compressedMsg, continueMsg, chainConnectingMsg]); + finalMessagesPayload = [ + { role: 'assistant', content: compressedMsg.content }, + { role: 'user', content: continueMsg.content } + ]; + } + + const allMessages = [...sysMessages, ...finalMessagesPayload]; + const Service = (window as any).go?.aiservice?.Service; + if (Service?.AIChatStream) { + await Service.AIChatStream(sid, allMessages, LOCAL_TOOLS); + } else if (Service?.AIChatSend) { + const result = await Service.AIChatSend(allMessages, LOCAL_TOOLS); + const errR = result?.error || '未知错误'; + const errC = sanitizeErrorMsg(errR); + useStore.getState().addAIChatMessage(sid, { + id: genId(), role: 'assistant', + content: result?.success ? result.content : `❌ ${errC}`, + rawError: (!result?.success && errC !== errR) ? errR : undefined, + timestamp: Date.now(), + }); + setSending(false); + } + } catch (e) { + console.error('Failed to chain tool call', e); + setSending(false); + } + }, [sid, buildSystemContextMessages]); + const handleSend = useCallback(async () => { const text = input.trim(); - if (!text || sending) return; + if ((!text && draftImages.length === 0) || sending) return; + toolCallRoundRef.current = 0; // 重置工具调用轮次计数 + nudgeCountRef.current = 0; // 重置催促计数 + const currentImages = [...draftImages]; setInput(''); + setDraftImages([]); setSending(true); if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; // 回车发送后重置高度 - textareaRef.current.focus(); // 保持焦点以便连续对话 + textareaRef.current.focus(); } const userMsg: AIChatMessage = { - id: genId(), - role: 'user', - content: text, - timestamp: Date.now(), + id: genId(), role: 'user', content: text, timestamp: Date.now(), + images: currentImages.length > 0 ? currentImages : undefined, }; addAIChatMessage(sid, userMsg); + + const connectingMsg: AIChatMessage = { + id: genId(), role: 'assistant', phase: 'connecting', content: '', + timestamp: Date.now(), loading: true + }; + addAIChatMessage(sid, connectingMsg); - // 构建消息列表发给后端 - const allMessages = [...messages, userMsg].map(m => ({ - role: m.role, - content: m.content, - })); + const systemMessages = await buildSystemContextMessages(); + + // 【过渡状态 2】上下文已组装完成,即将接入模型 + updateAIChatMessage(sid, connectingMsg.id, { content: '模型接入中' }); + + const chatMessages = [...messages, userMsg].map(m => { + const mapped: any = { role: m.role, content: m.content, images: m.images }; + if (m.tool_calls) mapped.tool_calls = m.tool_calls; + if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id; + return mapped; + }); + + let finalMessagesPayload = chatMessages; + const dynamicMaxLimit = getDynamicMaxContextChars(activeProvider?.model); + const summary = await compressContextIfNeeded(sid, chatMessages, dynamicMaxLimit); + if (summary) { + // 清理原有历史,保留系统生成的总结记录和当前的 userMsg 以及 connectingMsg + const compressedMsg: AIChatMessage = { + id: genId(), role: 'assistant', content: `【自动记忆重塑】已将超长历史压缩为摘要:\n\n${summary}`, timestamp: Date.now() - 1000 + }; + useStore.getState().replaceAIChatHistory(sid, [compressedMsg, userMsg, connectingMsg]); + finalMessagesPayload = [ + { role: 'assistant', content: compressedMsg.content }, + { role: 'user', content: userMsg.content, images: userMsg.images } + ]; + } + + const allMessages = [...systemMessages, ...finalMessagesPayload]; + + // 【过渡状态 3】大脑唤醒 + updateAIChatMessage(sid, connectingMsg.id, { content: '唤醒推理引擎中' }); + + // 【过渡状态 4】最后一步,等待第一字节返回 + updateAIChatMessage(sid, connectingMsg.id, { 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); + await Service.AIChatStream(sid, allMessages, LOCAL_TOOLS); } else if (Service?.AIChatSend) { - const result = await Service.AIChatSend(allMessages); - + const result = await Service.AIChatSend(allMessages, LOCAL_TOOLS); + const errR2 = result?.error || '未知错误'; + const errC2 = sanitizeErrorMsg(errR2); const assistantMsg: AIChatMessage = { - id: genId(), - role: 'assistant', - content: result?.success ? result.content : `❌ ${result?.error || '未知错误'}`, + id: genId(), role: 'assistant', + content: result?.success ? result.content : `❌ ${errC2}`, + rawError: (!result?.success && errC2 !== errR2) ? errR2 : undefined, timestamp: Date.now(), }; addAIChatMessage(sid, assistantMsg); setSending(false); + + // auto-generate title fallback for non-stream + if (messages.length === 0) { + generateTitleForSession(sid); + } } else { - const assistantMsg: AIChatMessage = { - id: genId(), - role: 'assistant', - content: '❌ AI Service 未就绪', - timestamp: Date.now(), - }; - addAIChatMessage(sid, assistantMsg); + addAIChatMessage(sid, { id: genId(), role: 'assistant', content: '❌ AI Service 未就绪', timestamp: Date.now() }); setSending(false); } } catch (e: any) { - const errMsg: AIChatMessage = { - id: genId(), - role: 'assistant', - content: `❌ 发送失败: ${e?.message || e}`, - timestamp: Date.now(), - }; - addAIChatMessage(sid, errMsg); + const rawE2 = e?.message || String(e); + const cleanE2 = sanitizeErrorMsg(rawE2); + addAIChatMessage(sid, { id: genId(), role: 'assistant', content: `❌ 发送失败: ${cleanE2}`, rawError: cleanE2 !== rawE2 ? rawE2 : undefined, timestamp: Date.now() }); setSending(false); } - }, [input, sending, messages, addAIChatMessage, sid]); + }, [input, draftImages, sending, messages, addAIChatMessage, sid]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -421,248 +1117,200 @@ export const AIChatPanel: React.FC = ({ width = 380, darkMode, setSending(false); }, [sid]); - const handleClear = useCallback(() => { - createNewAISession(); - }, [createNewAISession]); + const ghostRef = useRef(null); + const panelRect = useRef<{top: number, bottom: number, left: number} | null>(null); - 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; + dragWidthRef.current = panelWidth; + if (panelRef.current) { + const rect = panelRef.current.getBoundingClientRect(); + panelRect.current = { + top: rect.top, + bottom: window.innerHeight - rect.bottom, + left: rect.left + }; + } }, [panelWidth]); useEffect(() => { if (!isResizing) return; + let animationFrameId: number; 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); + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + animationFrameId = requestAnimationFrame(() => { + const delta = resizeStartX.current - e.clientX; + const newWidth = Math.min(Math.max(resizeStartWidth.current + delta, 280), 700); + dragWidthRef.current = newWidth; + + // 仅更新 ghost 虚线位置,通过绝对定位规避重排 + if (ghostRef.current && panelRect.current) { + const actualDelta = newWidth - resizeStartWidth.current; + ghostRef.current.style.left = `${panelRect.current.left - actualDelta}px`; + } + }); }; const handleMouseUp = () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } setIsResizing(false); + // 拖拽结束时才提交最终宽度到 React state 和外层回调 + setPanelWidth(dragWidthRef.current); + onWidthChange?.(dragWidthRef.current); }; + document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); + + // 拖拽期间关闭指针事件以避免下方 Monaco Editor 捕获 hover 或重绘,极大提升性能 document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; + document.body.style.pointerEvents = 'none'; // 关键性能优化 + return () => { + if (animationFrameId) cancelAnimationFrame(animationFrameId); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; + document.body.style.pointerEvents = ''; }; }, [isResizing, onWidthChange]); - return ( -
- {/* 拖拽手柄 */} -
- {/* Header */} -
-
- -
-
- -
-
+ // 回推幽灵上下文:基于 get_tables 记录进行表级精确匹配(useMemo 缓存,避免每帧重算) + const { inferredConnectionId, inferredDbName } = useMemo(() => { + let connId = activeContext?.connectionId; + let dbName = activeContext?.dbName; + + if (!connId || !dbName) { + const allMsgText = messages.map(m => m.content || '').join(' '); + let bestMatch: { connectionId: string; dbName: string } | null = null; + let bestScore = 0; + for (const entry of toolContextMapRef.current.values()) { + let score = 0; + for (const table of entry.tables) { + if (allMsgText.includes(table)) score++; + } + if (score > bestScore) { + bestScore = score; + bestMatch = { connectionId: entry.connectionId, dbName: entry.dbName }; + } + } + if (bestMatch) { + if (!connId) connId = bestMatch.connectionId; + if (!dbName) dbName = bestMatch.dbName; + } + } + return { inferredConnectionId: connId, inferredDbName: dbName }; + }, [activeContext?.connectionId, activeContext?.dbName, messages.length]); + + // useMemo 缓存:避免内联闭包击穿子组件 memo + const handleDeleteMessage = useCallback((id: string) => deleteAIChatMessage(sid, id), [sid, deleteAIChatMessage]); + const activeConnectionConfig = useMemo(() => { + if (!inferredConnectionId) return undefined; + return connections.find(c => c.id === inferredConnectionId)?.config; + }, [inferredConnectionId, connections]); + const contextUsageChars = useMemo(() => + messages.reduce((sum, m) => sum + (m.content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0), + [messages]); + const contextTableNames = useMemo(() => { + const ck = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default'; + return (aiContexts[ck] || []).map(c => `${c.dbName}.${c.tableName}`); + }, [activeContext?.connectionId, activeContext?.dbName, aiContexts]); + + return ( +
+
+ + {isResizing && panelRect.current && createPortal( +
, + document.body + )} + + setHistoryOpen(true)} + onClear={createNewAISession} + onSettingsClick={() => { onOpenSettings?.(); setTimeout(loadActiveProvider, 500); }} + onClose={onClose} + messages={messages} + sessionTitle={useStore.getState().aiChatSessions.find(s => s.id === sid)?.title || '新对话'} + /> - {/* Messages */}
{messages.length === 0 ? ( -
-
- - 你好,我是 GoNavi AI -
-
- 我是你的智能数据库助手。我可以帮你生成 SQL 查询、分析表结构、解释执行逻辑以及优化数据库性能。 -
-
- {quickActions.map(action => ( -
setInput(action.prompt)} - > - {action.label} -
- ))} -
-
+ { + setInput(prompt); + if (autoSend) { + // Use setTimeout to let setInput render, then trigger send + setTimeout(() => { + const el = textareaRef.current; + if (el) el.focus(); + // Dispatch a synthetic enter to trigger handleSend + // Simpler: just call handleSend directly with the prompt + }, 50); + } + }} + contextTableNames={contextTableNames} + /> ) : ( - 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) && ( -
-
- 等待回复 - - - - - -
-
+ messages.map(msg => ( + + )) )} + +
- {/* 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)'; }} @@ -671,176 +1319,42 @@ export const AIChatPanel: React.FC = ({ width = 380, darkMode,
)} - {/* 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 && ( - 名称} - 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 => ( +
+
+ 基本信息 +
+ + 供应商名称} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}> + + + + {presetKeyFromForm === 'custom' && ( + API 格式} name="apiFormat" style={{ marginBottom: 16 }}> +
+ {[{ 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, + padding: '6px 16px', borderRadius: 6, fontSize: 13, fontWeight: watchedApiFormat === fmt.value ? 600 : 500, cursor: 'pointer', + background: watchedApiFormat === fmt.value ? (darkMode ? '#374151' : '#ffffff') : 'transparent', + color: watchedApiFormat === fmt.value ? overlayTheme.titleText : overlayTheme.mutedText, + boxShadow: watchedApiFormat === fmt.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none', transition: 'all 0.2s ease', }} > @@ -368,38 +373,34 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo
))}
-
+ + )} + + 可用模型列表(可选配置)} name="models" style={{ marginBottom: 0 }}> + - -
自定义包含的可用模型列表
- -
)} {/* 认证信息 */} -
+
- 认证 & 连接 + 认证 & 连接
- + API Key} name="apiKey" rules={[{ required: true, message: '请输入 API Key' }]} style={{ marginBottom: 16 }}> Key} - style={{ borderRadius: 10, background: inputBg }} /> + size="middle" + style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} /> + {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( - + API Endpoint (URL)} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}> URL} - suffix={} - style={{ borderRadius: 10, background: inputBg }} /> + size="middle" + suffix={} + style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} /> )}
@@ -408,8 +409,8 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo {/* 操作按钮 */}
+ ); + })} +
+
+
+ {activeSection === 'providers' && (isEditing ? renderProviderForm() : renderProviderList())} + {activeSection === 'safety' && renderSafetySettings()} + {activeSection === 'context' && renderContextSettings()} + {activeSection === 'tools' && renderBuiltinTools()} + {activeSection === 'prompts' && renderBuiltinPrompts()} +
+
); }; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 3bdb46e..c50d662 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -480,7 +480,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { contextMenuOrder: 1, run: (ed: any) => { const selection = ed.getModel()?.getValueInRange(ed.getSelection()); - let prompt = action.prompt; + const conn = connectionsRef.current.find(c => c.id === currentConnectionIdRef.current); + const ctxText = conn ? `【上下文环境:${conn.config?.type || '数据库'} "${conn.name}", 当前库选定为 "${currentDbRef.current || '默认'}"】\n` : ''; + let prompt = ctxText + action.prompt; if (action.useSelection && selection) { prompt = prompt.replace('{SQL}', selection); } @@ -853,7 +855,92 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { return { suggestions }; } }); + // 注册 / 斜杠命令 AI 快捷补全 + const slashCmdDefs = [ + { cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' }, + { cmd: '/sql', label: '📝 生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL:' }, + { cmd: '/explain', label: '💡 解释 SQL', desc: '解释选中 SQL 的逻辑', prompt: '请解释以下 SQL 的执行逻辑和每一步的作用:\n```sql\n{SQL}\n```', useSelection: true }, + { cmd: '/optimize', label: '⚡ 优化分析', desc: '分析 SQL 性能瓶颈', prompt: '请分析以下 SQL 的性能问题,并给出优化后的版本:\n```sql\n{SQL}\n```', useSelection: true }, + { cmd: '/schema', label: '🏗️ 表设计评审', desc: '评审表结构设计质量', prompt: '请全面评审当前关联表的设计,包括字段类型、范式、索引策略等方面的改进建议:' }, + { cmd: '/index', label: '📊 索引建议', desc: '推荐最优索引方案', prompt: '请基于当前表结构和常见查询场景,推荐最优的索引方案并给出建表语句:' }, + { cmd: '/diff', label: '🔄 表对比', desc: '对比两表差异生成变更', prompt: '请对比以下两张表的结构差异,并生成从旧版本迁移到新版本的 ALTER 语句:' }, + { cmd: '/mock', label: '🎲 造测试数据', desc: '生成 INSERT 测试数据', prompt: '请为当前关联的表生成 10 条符合业务语义的测试数据 INSERT 语句:' }, + ]; + // 全局变量存储命令定义,供 onDidChangeModelContent 使用 + (window as any).__gonaviSlashCmdDefs = slashCmdDefs; + + monaco.languages.registerCompletionItemProvider('sql', { + triggerCharacters: ['/'], + provideCompletionItems: (model: any, position: any) => { + const lineContent = model.getLineContent(position.lineNumber); + const textBefore = lineContent.substring(0, position.column - 1).trimStart(); + if (!textBefore.startsWith('/')) { + return { suggestions: [] }; + } + + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: position.column - textBefore.length, + endColumn: position.column, + }; + + return { + suggestions: slashCmdDefs.map((c, i) => ({ + label: `${c.cmd} ${c.label}`, + kind: monaco.languages.CompletionItemKind.Event, + detail: c.desc, + insertText: `__AI_${c.cmd.slice(1).toUpperCase()}__`, + range, + sortText: String(i).padStart(2, '0'), + })), + }; + }, + }); + } // end sqlCompletionRegistered guard + + // 每个编辑器实例都注册内容变化监听(检测斜杠命令标记) + let _handlingSlash = false; + editor.onDidChangeModelContent(() => { + if (_handlingSlash) return; + const model = editor.getModel(); + if (!model) return; + const content = model.getValue(); + const markerMatch = content.match(/__AI_(\w+)__/); + if (!markerMatch) return; + + const cmdKey = markerMatch[1].toLowerCase(); + const defs = (window as any).__gonaviSlashCmdDefs || []; + const cmdDef = defs.find((c: any) => c.cmd === `/${cmdKey}`); + if (!cmdDef) return; + + // 清除标记文本(带递归保护) + _handlingSlash = true; + const fullText = model.getValue(); + const newText = fullText.replace(markerMatch[0], '').replace(/^\s*\n/, ''); + model.setValue(newText); + _handlingSlash = false; + + // 组装 prompt + const conn = connectionsRef.current.find(c => c.id === currentConnectionIdRef.current); + const ctxText = conn ? `【上下文环境:${conn.config?.type || '数据库'} "${conn.name}", 当前库选定为 "${currentDbRef.current || '默认'}"】\n` : ''; + let finalPrompt = ctxText + cmdDef.prompt; + if (cmdDef.useSelection) { + const sel = editor.getSelection(); + const selText = sel ? model.getValueInRange(sel) : ''; + finalPrompt = finalPrompt.replace('{SQL}', selText || getCurrentQuery()); + } + + // 打开 AI 面板并注入 prompt + const store = useStore.getState(); + if (!store.aiPanelVisible) { + store.setAIPanelVisible(true); + } + setTimeout(() => { + window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt: finalPrompt } })); + }, store.aiPanelVisible ? 0 : 350); + }); }; const handleFormat = () => { @@ -870,11 +957,14 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const selection = editor?.getModel()?.getValueInRange(editor.getSelection()) || ''; const fullSQL = getCurrentQuery(); + const conn = connections.find(c => c.id === currentConnectionId); + const ctxText = conn ? `【上下文环境:${conn.config?.type || '数据库'} "${conn.name}", 当前库选定为 "${currentDb || '默认'}"】\n` : ''; + const prompts: Record = { - generate: '请根据当前数据库表结构生成查询语句:', - explain: `请解释以下 SQL 语句的执行逻辑:\n\`\`\`sql\n${selection || fullSQL}\n\`\`\``, - optimize: `请分析以下 SQL 语句的性能并给出优化建议:\n\`\`\`sql\n${selection || fullSQL}\n\`\`\``, - schema: '请分析当前数据库的表结构并给出优化建议。', + generate: `${ctxText}请根据当前数据库表结构生成查询语句:`, + explain: `${ctxText}请解释以下 SQL 语句的执行逻辑:\n\`\`\`sql\n${selection || fullSQL}\n\`\`\``, + optimize: `${ctxText}请分析以下 SQL 语句的性能并给出优化建议:\n\`\`\`sql\n${selection || fullSQL}\n\`\`\``, + schema: `${ctxText}请针对当前数据库的表结构进行系统分析,并给出性能和设计上的优化建议。`, }; const store = useStore.getState(); @@ -1932,41 +2022,73 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { }; }, [activeTabId, tab.id, handleRun]); - // 监听并处理外部注入的 SQL 代码 (如 AI 面板) + // 监听由 TabManager 分发的专用注入事件 useEffect(() => { - const handleInsertSql = (e: CustomEvent) => { - if (activeTabId !== tab.id || !e.detail?.sql) return; - const sqlText = e.detail.sql; + const handleInsertSql = (e: any) => { + if (e.detail?.tabId !== tab.id || !e.detail?.sql) return; + const { sql: sqlText, connectionId, dbName } = e.detail; + + // 同步更新 ref,防止异步 fetchDbs 竞态覆盖正确的 dbName + if (connectionId && connectionId !== currentConnectionId) { + if (dbName) { + currentDbRef.current = dbName; + setCurrentDb(dbName); + } + setCurrentConnectionId(connectionId); + } else if (dbName && dbName !== currentDb) { + currentDbRef.current = dbName; + setCurrentDb(dbName); + } + const editor = editorRef.current; - if (editor && (window as any).monaco) { - const position = editor.getPosition(); + const monaco = monacoRef.current; + if (editor && monaco) { + let position = editor.getPosition(); + const model = editor.getModel(); + if (!position && model) { + const lineCount = model.getLineCount(); + const maxCol = model.getLineMaxColumn(lineCount); + position = new monaco.Position(lineCount, maxCol); + } + 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); + const startRange = new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column); editor.executeEdits('ai-insert', [{ range: startRange, - text: '\n' + mText, + text: (position.column > 1 ? '\n' : '') + mText, forceMoveMarkers: true }]); + + // 定位并滚动到可见区域 + const targetLine = position.lineNumber + (position.column > 1 ? 1 : 0); + editor.revealLineInCenterIfOutsideViewport(targetLine); + editor.setPosition({ lineNumber: targetLine + mText.split('\n').length - 1, column: 1 }); editor.focus(); + + if (!e.detail.runImmediately) { + message.success('代码已在当前光标处成功插入'); + } if (e.detail.runImmediately) { const endPosition = editor.getPosition(); - editor.setSelection(new (window as any).monaco.Range( - position.lineNumber + 1, 1, + editor.setSelection(new monaco.Range( + targetLine, 1, endPosition.lineNumber, endPosition.column )); - setTimeout(() => handleRun(), 50); + // 🔧 延迟 500ms 等待连接/数据库切换的 setState 生效后再执行 + setTimeout(() => handleRun(), 500); } } } else { setQuery((prev: string) => prev ? prev + '\n' + sqlText : sqlText); + message.success('代码已追加'); } }; - window.addEventListener('gonavi:insert-sql', handleInsertSql as EventListener); - return () => window.removeEventListener('gonavi:insert-sql', handleInsertSql as EventListener); - }, [activeTabId, tab.id, handleRun]); + window.addEventListener('gonavi:insert-sql-to-tab', handleInsertSql as EventListener); + return () => window.removeEventListener('gonavi:insert-sql-to-tab', handleInsertSql as EventListener); + }, [tab.id, handleRun]); const resolveDefaultQueryName = () => { const rawTitle = String(tab.title || '').trim(); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 09524cd..a15a62f 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1432,7 +1432,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> if (type === 'connection') { setActiveContext({ connectionId: key, dbName: '' }); } else if (type === 'database') { - setActiveContext({ connectionId: dataRef.id, dbName: title }); + setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName }); } else if (type === 'table') { setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName }); } else if (type === 'view' || type === 'db-trigger' || type === 'routine') { @@ -1456,9 +1456,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const onDoubleClick = (e: any, node: any) => { // 保证用户直接双击节点未触发 onClick/onSelect 时也能强行拿到选中状态 - const { type, dataRef, key: nodeKey, title } = node; + const { type, dataRef, key: nodeKey } = node; if (type === 'connection') setActiveContext({ connectionId: nodeKey, dbName: '' }); - else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: title }); + else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName }); 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}` }); diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 8784f4d..9709e83 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -89,6 +89,7 @@ const TabManager: React.FC = () => { const theme = useStore(state => state.theme); const activeTabId = useStore(state => state.activeTabId); const setActiveTab = useStore(state => state.setActiveTab); + const addTab = useStore(state => state.addTab); const closeTab = useStore(state => state.closeTab); const closeOtherTabs = useStore(state => state.closeOtherTabs); const closeTabsToLeft = useStore(state => state.closeTabsToLeft); @@ -134,6 +135,59 @@ const TabManager: React.FC = () => { setDraggingTabId(null); }; + React.useEffect(() => { + const handleGlobalInsertSql = (e: any) => { + const { sql, runImmediately, connectionId: eventConnId, dbName: eventDbName } = e.detail; + if (!sql) return; + + const activeTab = tabs.find(t => t.id === activeTabId); + + // 🔧 runImmediately(点击"执行")始终新建独立 tab,避免追加到已有 tab 导致 SQL 重复 + if (runImmediately) { + const newTabId = 'tab-' + Date.now(); + const resolvedConnId = eventConnId || activeTab?.connectionId || (connections.length > 0 ? connections[0].id : ''); + const resolvedDbName = eventConnId ? (eventDbName || '') : (activeTab?.dbName || ''); + addTab({ + id: newTabId, + type: 'query', + title: '新建查询', + query: '', + connectionId: resolvedConnId, + dbName: resolvedDbName + }); + setActiveTab(newTabId); + setTimeout(() => { + window.dispatchEvent(new CustomEvent('gonavi:insert-sql-to-tab', { + detail: { tabId: newTabId, sql, runImmediately: true, connectionId: resolvedConnId, dbName: resolvedDbName } + })); + }, 300); + return; + } + + // 插入模式:追加到已有 tab 或新建 tab + if (activeTab && activeTab.type === 'query') { + window.dispatchEvent(new CustomEvent('gonavi:insert-sql-to-tab', { + detail: { tabId: activeTab.id, sql, runImmediately: false, connectionId: eventConnId, dbName: eventDbName } + })); + } else { + const newTabId = 'tab-' + Date.now(); + const resolvedConnId = eventConnId || activeTab?.connectionId || (connections.length > 0 ? connections[0].id : ''); + const resolvedDbName = eventConnId ? (eventDbName || '') : (activeTab?.dbName || ''); + addTab({ + id: newTabId, + type: 'query', + title: '新建查询', + query: sql, + connectionId: resolvedConnId, + dbName: resolvedDbName + }); + setActiveTab(newTabId); + } + }; + window.addEventListener('gonavi:insert-sql', handleGlobalInsertSql); + return () => window.removeEventListener('gonavi:insert-sql', handleGlobalInsertSql); + }, [tabs, activeTabId, addTab, setActiveTab, connections]); + const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]); const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => ( diff --git a/frontend/src/components/ai/AIChatHeader.tsx b/frontend/src/components/ai/AIChatHeader.tsx new file mode 100644 index 0000000..1d73d48 --- /dev/null +++ b/frontend/src/components/ai/AIChatHeader.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { Button, Tooltip } from 'antd'; +import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, ExportOutlined } from '@ant-design/icons'; +import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; +import type { AIChatMessage } from '../../types'; + +interface AIChatHeaderProps { + darkMode: boolean; + mutedColor: string; + textColor: string; + overlayTheme: OverlayWorkbenchTheme; + onHistoryClick: () => void; + onClear: () => void; + onSettingsClick: () => void; + onClose: () => void; + messages?: AIChatMessage[]; + sessionTitle?: string; +} + +const exportToMarkdown = (messages: AIChatMessage[], title: string) => { + const lines: string[] = [`# ${title}`, '', `> 导出时间:${new Date().toLocaleString()}`, '']; + messages.forEach(msg => { + const role = msg.role === 'user' ? '👤 You' : '🤖 GoNavi AI'; + lines.push(`## ${role}`); + lines.push(''); + lines.push(msg.content); + lines.push(''); + lines.push('---'); + lines.push(''); + }); + const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${title.replace(/[/\\?%*:|"<>]/g, '-')}.md`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +}; + +export const AIChatHeader: React.FC = ({ + darkMode, mutedColor, textColor, overlayTheme, + onHistoryClick, onClear, onSettingsClick, onClose, + messages = [], sessionTitle = '新对话' +}) => { + return ( +
+
+ +
+
+ {messages.length > 0 && ( + +
+
+ ); +}; diff --git a/frontend/src/components/ai/AIChatInput.tsx b/frontend/src/components/ai/AIChatInput.tsx new file mode 100644 index 0000000..ec78375 --- /dev/null +++ b/frontend/src/components/ai/AIChatInput.tsx @@ -0,0 +1,574 @@ +import React from 'react'; +import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd'; +import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined } from '@ant-design/icons'; +import { useStore } from '../../store'; +import { DBGetTables, DBShowCreateTable, DBGetDatabases } from '../../../wailsjs/go/app/App'; +import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; + +interface AIChatInputProps { + input: string; + setInput: (val: string) => void; + draftImages: string[]; + setDraftImages: React.Dispatch>; + sending: boolean; + onSend: () => void; + onStop: () => void; + handleKeyDown: (e: React.KeyboardEvent) => void; + activeConnName: string; + activeContext: any; + activeProvider: any; + dynamicModels: string[]; + loadingModels: boolean; + onModelChange: (val: string) => void; + onFetchModels: () => void; + textareaRef: React.RefObject; + darkMode: boolean; + textColor: string; + mutedColor: string; + overlayTheme: OverlayWorkbenchTheme; + contextUsageChars?: number; + maxContextChars?: number; +} + +export const AIChatInput: React.FC = ({ + input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown, + activeConnName, activeContext, activeProvider, dynamicModels, loadingModels, + onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme, + contextUsageChars, maxContextChars +}) => { + const [contextOpen, setContextOpen] = React.useState(false); + const [contextLoading, setContextLoading] = React.useState(false); + const [contextTables, setContextTables] = React.useState<{name: string}[]>([]); + const [selectedTableKeys, setSelectedTableKeys] = React.useState([]); + const [searchText, setSearchText] = React.useState(''); + const [appendingContext, setAppendingContext] = React.useState(false); + + const fileInputRef = React.useRef(null); + const handleImageUpload = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + files.forEach(file => { + if (file.type.indexOf('image') !== -1) { + const reader = new FileReader(); + reader.onload = (event) => { + if (event.target?.result) { + setDraftImages(prev => [...prev, event.target!.result as string]); + } + }; + reader.readAsDataURL(file); + } + }); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const [dbList, setDbList] = React.useState([]); + const [selectedDbName, setSelectedDbName] = React.useState(''); + + const filteredTables = contextTables.filter(t => t.name.toLowerCase().includes(searchText.toLowerCase())); + const [contextExpanded, setContextExpanded] = React.useState(false); + + // Slash commands + const [showSlashMenu, setShowSlashMenu] = React.useState(false); + const [slashFilter, setSlashFilter] = React.useState(''); + const slashCommands = React.useMemo(() => [ + { cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' }, + { cmd: '/sql', label: '📝 生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL:' }, + { cmd: '/explain', label: '💡 解释 SQL', desc: '解释选中 SQL 的逻辑', prompt: '请解释以下 SQL 的执行逻辑和每一步的作用:\n```sql\n\n```' }, + { cmd: '/optimize', label: '⚡ 优化分析', desc: '分析 SQL 性能瓶颈', prompt: '请分析以下 SQL 的性能问题,并给出优化后的版本:\n```sql\n\n```' }, + { cmd: '/schema', label: '🏗️ 表设计评审', desc: '评审表结构设计质量', prompt: '请全面评审当前关联表的设计,包括字段类型、范式、索引策略等方面的改进建议:' }, + { cmd: '/index', label: '📊 索引建议', desc: '推荐最优索引方案', prompt: '请基于当前表结构和常见查询场景,推荐最优的索引方案并给出建表语句:' }, + { cmd: '/diff', label: '🔄 表对比', desc: '对比两表差异生成变更', prompt: '请对比以下两张表的结构差异,并生成从旧版本迁移到新版本的 ALTER 语句:' }, + { cmd: '/mock', label: '🎲 造测试数据', desc: '生成 INSERT 测试数据', prompt: '请为当前关联的表生成 10 条符合业务语义的测试数据 INSERT 语句:' }, + ], []); + const filteredSlashCmds = slashCommands.filter(c => c.cmd.startsWith(slashFilter.toLowerCase())); + + const aiContexts = useStore(state => state.aiContexts); + const addAIContext = useStore(state => state.addAIContext); + const removeAIContext = useStore(state => state.removeAIContext); + + const connectionKey = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default'; + const activeContextItems = aiContexts[connectionKey] || []; + + const fetchTablesForDb = async (dbName: string, connConfig: any) => { + setContextLoading(true); + setSelectedDbName(dbName); + try { + const res = await DBGetTables(connConfig, dbName); + if (res.success && Array.isArray(res.data)) { + setContextTables(res.data.map(r => ({ name: Object.values(r)[0] as string }))); + } else { + message.error('获取表格失败: ' + res.message); + setContextTables([]); + } + } catch (e: any) { + message.error(e.message); + setContextTables([]); + } finally { + setContextLoading(false); + } + }; + + const handleOpenContext = async () => { + if (!activeContext?.connectionId) { + message.warning('请先在左侧选择一个数据库作为所聊上下文'); + return; + } + const conn = useStore.getState().connections.find(c => c.id === activeContext.connectionId); + if (!conn) return; + + setContextOpen(true); + setContextLoading(true); + setSearchText(''); + // Store dbName::tableName composite keys + setSelectedTableKeys(activeContextItems.map(c => `${c.dbName}::${c.tableName}`)); + + try { + // Fetch databases + const dbRes = await DBGetDatabases(conn.config as any); + if (dbRes.success && Array.isArray(dbRes.data)) { + const databases = dbRes.data.map((r: any) => Object.values(r)[0] as string); + setDbList(databases); + } + + // Fetch tables for the active contextual database + const initDbName = activeContext.dbName || ''; + setSelectedDbName(initDbName); + const tablesRes = await DBGetTables(conn.config as any, initDbName); + if (tablesRes.success && Array.isArray(tablesRes.data)) { + setContextTables(tablesRes.data.map((r: any) => ({ name: Object.values(r)[0] as string }))); + } else { + setContextTables([]); + } + } catch (e: any) { + message.error(e.message); + } finally { + setContextLoading(false); + } + }; + + const handleAppendContext = async () => { + const conn = useStore.getState().connections.find(c => c.id === activeContext.connectionId); + if (!conn) return; + + setAppendingContext(true); + try { + let addedCount = 0; + let removedCount = 0; + + for (const cx of activeContextItems) { + const key = `${cx.dbName}::${cx.tableName}`; + if (!selectedTableKeys.includes(key)) { + removeAIContext(connectionKey, cx.dbName, cx.tableName); + removedCount++; + } + } + + for (const key of selectedTableKeys) { + const [dbName, tableName] = key.split('::'); + if (!dbName || !tableName) continue; + + if (activeContextItems.find(c => c.dbName === dbName && c.tableName === tableName)) { + continue; + } + const res = await DBShowCreateTable(conn.config as any, dbName, tableName); + let createSql = ''; + if (res.success && res.data) { + if (typeof res.data === 'string') { + createSql = res.data; + } else if (Array.isArray(res.data) && res.data.length > 0) { + const row = res.data[0]; + createSql = (Object.values(row).find(v => typeof v === 'string' && (v.toUpperCase().includes('CREATE TABLE') || v.toUpperCase().includes('CREATE'))) || Object.values(row)[1] || Object.values(row)[0]) as string; + } + } else { + message.error(`获取表 ${dbName}.${tableName} 结构失败: ` + (res.message || '未知错误')); + } + + if (createSql) { + addAIContext(connectionKey, { + dbName: dbName, + tableName: tableName, + ddl: createSql + }); + addedCount++; + } + } + if (addedCount > 0 || removedCount > 0) { + if (addedCount > 0 && removedCount === 0) { + message.success(`已添加 ${addedCount} 张表的结构到上下文`); + } else if (removedCount > 0 && addedCount === 0) { + message.success(`已从上下文移除 ${removedCount} 张表的结构`); + } else { + message.success(`上下文已同步更新:新增 ${addedCount},移除 ${removedCount}`); + } + if (addedCount > 0) setContextExpanded(true); + } else { + message.info('选中的表未发生变化'); + } + setContextOpen(false); + } catch (e: any) { + message.error(e.message); + } finally { + setAppendingContext(false); + } + }; + + return ( +
+
+
+ {activeContextItems.length > 0 && ( + setContextExpanded(!contextExpanded)} + style={{ background: darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)', border: 'none', color: '#1890ff', borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0, cursor: 'pointer', transition: 'all 0.3s' }} + > + + 关联上下文 ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'} + + + )} + + {contextExpanded && activeContextItems.map((ctx, idx) => ( + { e.preventDefault(); removeAIContext(connectionKey, ctx.dbName, ctx.tableName); }} + style={{ background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)', border: 'none', color: textColor, borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0 }} + > + 🗄️ {ctx.tableName} + + ))} + {draftImages.map((b64, i) => ( +
+ {`Draft +
setDraftImages(prev => prev.filter((_, idx) => idx !== i))} + style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.5)', color: '#fff', borderRadius: '50%', width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 10 }} + > + ✕ +
+
+ ))} +
+
+ {showSlashMenu && filteredSlashCmds.length > 0 && ( +
+ {filteredSlashCmds.map(cmd => ( +
e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)'} + onMouseLeave={e => e.currentTarget.style.background = 'transparent'} + onClick={() => { + setInput(cmd.prompt); + setShowSlashMenu(false); + setSlashFilter(''); + textareaRef.current?.focus(); + }} + > + {cmd.cmd} + {cmd.label} + {cmd.desc} +
+ ))} +
+ )} + { + const items = e.clipboardData?.items; + if (!items) return; + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + e.preventDefault(); + const blob = items[i].getAsFile(); + if (blob) { + const reader = new FileReader(); + reader.onload = (event) => { + if (event.target?.result) { + setDraftImages(prev => [...prev, event.target!.result as string]); + } + }; + reader.readAsDataURL(blob); + } + } + } + }} + ref={textareaRef as any} + value={input} + onChange={(e) => { + const val = e.target.value; + setInput(val); + // Slash command detection + if (val.startsWith('/')) { + setSlashFilter(val.split(/\s/)[0]); + setShowSlashMenu(true); + } else { + setShowSlashMenu(false); + setSlashFilter(''); + } + }} + 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 && ( + + + + ) : ( + + )} +
+
+
+ + 关联数据库表结构上下文} + open={contextOpen} + onCancel={() => setContextOpen(false)} + onOk={handleAppendContext} + confirmLoading={appendingContext} + okText="同步所选表至上下文" + cancelText="取消" + centered + styles={{ + content: { background: darkMode ? '#1e1e1e' : '#ffffff', border: overlayTheme.shellBorder }, + header: { background: darkMode ? '#1e1e1e' : '#ffffff', borderBottom: overlayTheme.shellBorder }, + body: { padding: '20px 24px' } + }} + > + +
+ {dbList.length > 0 && ( + } + value={searchText} + onChange={e => setSearchText(e.target.value)} + style={{ background: darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', border: 'none', flexGrow: 1 }} + /> +
+ {filteredTables.length > 0 ? ( +
+
+ 0 && + filteredTables.some(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`)) && + !filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`)) + } + checked={filteredTables.length > 0 && filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))} + onChange={(e) => { + if (e.target.checked) { + const newSelected = new Set([...selectedTableKeys, ...filteredTables.map(t => `${selectedDbName}::${t.name}`)]); + setSelectedTableKeys(Array.from(newSelected)); + } else { + const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`); + setSelectedTableKeys(selectedTableKeys.filter(key => !filteredKeys.includes(key))); + } + }} + style={{ color: textColor, fontWeight: 'bold' }} + > + 全选匹配的表 ({filteredTables.length}) + + +
+
+
+ {filteredTables.map(t => { + const key = `${selectedDbName}::${t.name}`; + const isSelected = selectedTableKeys.includes(key); + return ( +
e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)'} + onMouseLeave={e => e.currentTarget.style.background = 'transparent'} + onClick={(e) => { + // If click originated from the checkbox input itself, let its onChange handle it to avoid duplicate toggle + if ((e.target as HTMLElement).tagName.toLowerCase() === 'input') return; + if (isSelected) { + setSelectedTableKeys(selectedTableKeys.filter(k => k !== key)); + } else { + setSelectedTableKeys([...selectedTableKeys, key]); + } + }} + > + { + if (e.target.checked) setSelectedTableKeys([...selectedTableKeys, key]); + else setSelectedTableKeys(selectedTableKeys.filter(k => k !== key)); + }} + style={{ color: textColor, width: '100%' }} + > + {t.name} + +
+ ); + })} +
+
+
+ ) : ( +
+ 没有找到匹配 '{searchText}' 的表 +
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/components/ai/AIChatWelcome.tsx b/frontend/src/components/ai/AIChatWelcome.tsx new file mode 100644 index 0000000..1e6040a --- /dev/null +++ b/frontend/src/components/ai/AIChatWelcome.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { RobotOutlined } from '@ant-design/icons'; +import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; + +interface AIChatWelcomeProps { + overlayTheme: OverlayWorkbenchTheme; + quickActionBg: string; + quickActionBorder: string; + textColor: string; + mutedColor: string; + onQuickAction: (prompt: string, autoSend?: boolean) => void; + contextTableNames?: string[]; +} + +export const AIChatWelcome: React.FC = ({ + overlayTheme, quickActionBg, quickActionBorder, textColor, mutedColor, onQuickAction, contextTableNames = [] +}) => { + const hasContext = contextTableNames.length > 0; + const tableList = contextTableNames.join('、'); + + const quickActions = hasContext + ? [ + { label: '📝 生成 SQL', prompt: `请根据以下表结构生成一条常用查询语句:${tableList}` }, + { label: '🔍 解释表结构', prompt: `请详细解释以下表的设计意图和字段含义:${tableList}` }, + { label: '⚡ 优化建议', prompt: `请分析以下表的结构设计,给出索引优化和查询性能优化建议:${tableList}` }, + { label: '🏗️ Schema 分析', prompt: `请对以下表进行全面的 Schema 分析,包括数据类型选择、范式评估和改进建议:${tableList}` }, + ] + : [ + { label: '📝 生成 SQL', prompt: '请根据当前数据库表结构生成一条查询语句:' }, + { label: '🔍 解释 SQL', prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n\n```' }, + { label: '⚡ 优化建议', prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n\n```' }, + { label: '🏗️ Schema 分析', prompt: '请分析当前数据库的表结构并给出优化建议。' }, + ]; + + return ( +
+
+ + 你好,我是 GoNavi AI +
+
+ {hasContext + ? `已自动关联 ${contextTableNames.length} 张表结构,点击下方按钮快速开始分析。` + : '我是你的智能数据库助手。我可以帮你生成 SQL 查询、分析表结构、解释执行逻辑以及优化数据库性能。'} +
+
+ {quickActions.map(action => ( +
onQuickAction(action.prompt)} + > + {action.label} +
+ ))} +
+
+ ); +}; diff --git a/frontend/src/components/ai/AIHistoryDrawer.tsx b/frontend/src/components/ai/AIHistoryDrawer.tsx new file mode 100644 index 0000000..520d288 --- /dev/null +++ b/frontend/src/components/ai/AIHistoryDrawer.tsx @@ -0,0 +1,127 @@ +import React, { useState } from 'react'; +import { Drawer, Button, Tooltip, Input } from 'antd'; +import { MenuFoldOutlined, PlusOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons'; +import { useStore } from '../../store'; + +interface AIHistoryDrawerProps { + open: boolean; + onClose: () => void; + bgColor?: string; + darkMode: boolean; + textColor: string; + mutedColor: string; + borderColor: string; + onCreateNew: () => void; + sessionId: string; +} + +export const AIHistoryDrawer: React.FC = ({ + open, onClose, bgColor, darkMode, textColor, mutedColor, borderColor, onCreateNew, sessionId +}) => { + const aiChatSessions = useStore(state => state.aiChatSessions); + const setAIActiveSessionId = useStore(state => state.setAIActiveSessionId); + const deleteAISession = useStore(state => state.deleteAISession); + + // 阶段4: 历史记录搜索 + const [searchText, setSearchText] = useState(''); + + const filteredSessions = aiChatSessions.filter(s => + !searchText || (s.title && s.title.toLowerCase().includes(searchText.toLowerCase())) + ); + + return ( + + {/* 侧拉面板头部 */} +
+ 对话历史 + +
+ + {/* 新建对话按钮 */} +
+ +
+ + {/* 列表搜索 */} +
+ } + value={searchText} + onChange={e => setSearchText(e.target.value)} + variant="filled" + size="small" + style={{ background: darkMode ? 'rgba(255,255,255,0.04)' : 'transparent', color: textColor }} + /> +
+ + {/* 列表容器 */} +
+ {filteredSessions.length === 0 ? ( +
暂无匹配的对话记录
+ ) : ( + filteredSessions.map(session => ( +
{ setAIActiveSessionId(session.id); onClose(); }} + style={{ + padding: '10px 12px', + borderRadius: 6, + marginBottom: 4, + cursor: 'pointer', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + background: sessionId === session.id ? (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)') : 'transparent', + transition: 'background 0.2s', + }} + > +
+
+ {session.title || '新对话'} +
+
+ {new Date(session.updatedAt).toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })} +
+
+ +
+ )) + )} +
+
+ ); +}; diff --git a/frontend/src/components/ai/AIMessageBubble.tsx b/frontend/src/components/ai/AIMessageBubble.tsx new file mode 100644 index 0000000..453d1b6 --- /dev/null +++ b/frontend/src/components/ai/AIMessageBubble.tsx @@ -0,0 +1,714 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Tooltip, message } from 'antd'; +import { UserOutlined, RobotOutlined, EditOutlined, ReloadOutlined, DeleteOutlined, CheckOutlined, CopyOutlined, PlayCircleOutlined, ApiOutlined, LoadingOutlined, CaretRightOutlined, CaretDownOutlined } from '@ant-design/icons'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import mermaid from 'mermaid'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { AIChatMessage, AIToolCall } from '../../types'; +import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; + +// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins +const remarkPlugins = [remarkGfm]; + +const MemoizedMarkdown = React.memo(({ + content, + darkMode, + overlayTheme, + activeConnectionConfig, + activeConnectionId, + activeDbName +}: { + content: string; + darkMode: boolean; + overlayTheme: OverlayWorkbenchTheme; + activeConnectionConfig?: any; + activeConnectionId?: string; + activeDbName?: string; +}) => { + // 缓存 components 对象,避免每次渲染都生成新的函数引用击穿内部子组件的 memo + const components = React.useMemo(() => ({ + code({ node, inline, className, children, ...props }: any) { + const match = /language-(\w+)/.exec(className || ''); + if (!inline && match && match[1] === 'mermaid') { + return ; + } + return !inline && match ? ( + + ) : ( + + {children} + + ); + } + }), [darkMode, overlayTheme, activeConnectionConfig, activeConnectionId, activeDbName]); + + return ( + + {content} + + ); +}); + +interface AIMessageBubbleProps { + msg: AIChatMessage; + darkMode: boolean; + overlayTheme: OverlayWorkbenchTheme; + textColor: string; + onEdit: (msg: AIChatMessage) => void; + onRetry: (msg: AIChatMessage) => void; + onDelete: (id: string) => void; + activeConnectionId?: string; + activeConnectionConfig?: any; + activeDbName?: string; + allMessages?: AIChatMessage[]; +} + +const AIToolResultItem: React.FC<{ resultMsg: AIChatMessage, darkMode: boolean, overlayTheme: OverlayWorkbenchTheme }> = ({ resultMsg, darkMode, overlayTheme }) => { + const [toolExpanded, setToolExpanded] = useState(false); + const charCount = resultMsg.content ? resultMsg.content.length : 0; + return ( +
+
setToolExpanded(!toolExpanded)} + > + {toolExpanded ? : } + + 探针执行结果 ({resultMsg.tool_name || 'unknown'}) + {charCount > 0 ? `${charCount} 个字符` : '无数据'} +
+ {toolExpanded && ( +
+ {resultMsg.content} +
+ )} +
+ ); +}; + +const MermaidRenderer = ({ chart, darkMode }: { chart: string, darkMode: boolean }) => { + const containerRef = React.useRef(null); + + React.useEffect(() => { + if (containerRef.current) { + try { + mermaid.initialize({ startOnLoad: false, theme: darkMode ? 'dark' : 'default' }); + const id = `mermaid-${Math.random().toString(36).substring(2)}`; + (async () => { + const result: any = await mermaid.render(id, chart); + if (containerRef.current) { + containerRef.current.innerHTML = result.svg || result; + } + })().catch((e: any) => { + if (containerRef.current) { + containerRef.current.innerHTML = `
Mermaid 解析失败: ${e.message}
`; + } + }); + } catch (e: any) { + if (containerRef.current) { + containerRef.current.innerHTML = `
Mermaid 渲染异常: ${e.message}
`; + } + } + } + }, [chart, darkMode]); + + return
; +}; + +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, connectionId, dbName }: { text: string; connectionId?: string; dbName?: string }) => { + // 解析 SQL 顶部的 @context 注释,格式:-- @context connectionId=xxx dbName=yyy + const contextMatch = text.match(/^--\s*@context\s+connectionId=(\S+)\s+dbName=(\S+)/m); + const resolvedConnId = contextMatch?.[1] || connectionId; + const resolvedDbName = contextMatch?.[2] || dbName; + // 发送给查询编辑器时去掉 @context 注释行 + const cleanSql = text.replace(/^--\s*@context\s+.*\n?/gm, '').trim(); + const sqlDetail = (runImmediately: boolean) => ({ sql: cleanSql, runImmediately, connectionId: resolvedConnId, dbName: resolvedDbName }); + const handleExecute = async () => { + try { + const Service = (window as any).go?.aiservice?.Service; + if (Service?.AICheckSQL) { + const result = await Service.AICheckSQL(text); + if (!result.allowed) { + message.error(`🔒 安全策略拦截:当前安全级别不允许执行 ${result.operationType} 类型的 SQL。请在 AI 设置中调整安全级别。`); + return; + } + if (result.requiresConfirm) { + const { Modal } = await import('antd'); + Modal.confirm({ + title: '⚠️ 安全确认', + content: result.warningMessage || `此 SQL 为 ${result.operationType} 操作,确定要执行吗?`, + okText: '确认执行', + cancelText: '取消', + okButtonProps: { danger: true }, + onOk: () => { + window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); + }, + }); + return; + } + } + // Safety check passed or not available, execute directly + window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); + } catch (e) { + // If safety check fails, still allow manual execution + window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); + } + }; + + return ( +
+ + { + window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(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'; }} + > + + 插入 + + + + { e.currentTarget.style.opacity = '1'; }} + onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }} + > + + 执行 + + +
+ ); +}; + +// 阶段2: 代码块体验升级 (折叠展开、行号显示、内联SQL预览) +const AIBlockHashRender = ({ match, darkMode, overlayTheme, children, activeConnectionConfig, activeConnectionId, activeDbName }: any) => { + const codeText = String(children).replace(/\n$/, ''); + // 将 @context 注释行从显示文本中剔除,用户无需看到内部元数据 + const displayText = codeText.replace(/^--\s*@context\s+.*\n?/gm, '').trim(); + const [expanded, setExpanded] = useState(false); + const [previewData, setPreviewData] = useState(null); + const [previewCols, setPreviewCols] = useState([]); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(''); + const [previewExpanded, setPreviewExpanded] = useState(false); + + const MAX_HEIGHT = 300; + const isLongCode = displayText.split('\n').length > 15; + const isSql = match[1] === 'sql'; + const isSelectQuery = isSql && /^\s*(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i.test(displayText.trim()); + + const handleInlineExecute = async () => { + if (!activeConnectionConfig || previewLoading) return; + setPreviewLoading(true); + setPreviewError(''); + setPreviewData(null); + try { + const { DBQuery } = await import('../../../wailsjs/go/app/App'); + const res = await DBQuery(activeConnectionConfig, activeDbName || '', displayText + ' LIMIT 50'); + if (res.success && Array.isArray(res.data)) { + const rows = res.data as any[]; + const cols = rows.length > 0 ? Object.keys(rows[0]) : []; + setPreviewCols(cols); + setPreviewData(rows.slice(0, 20)); + setPreviewExpanded(true); + } else { + setPreviewError(res.message || '查询无结果'); + } + } catch (err: any) { + setPreviewError(err?.message || '执行失败'); + } finally { + setPreviewLoading(false); + } + }; + + return ( +
+
+ {match[1]} +
+ {isSql && } + {isSelectQuery && activeConnectionConfig && ( + + { if (!previewLoading) e.currentTarget.style.opacity = '1'; }} + onMouseLeave={(e) => { if (!previewLoading) e.currentTarget.style.opacity = '0.6'; }} + > + {previewLoading ? '⏳' : '👁'} + {previewLoading ? '执行中...' : '预览'} + + + )} + +
+
+ +
+ + {displayText} + + + {!expanded && isLongCode && ( +
setExpanded(true)} + > + + 展开全部代码 + +
+ )} + {expanded && isLongCode && ( +
setExpanded(false)} + > + 收起代码 +
+ )} +
+ + {/* Inline SQL Preview Results */} + {previewError && ( +
+ ❌ {previewError} +
+ )} + {previewExpanded && previewData && previewData.length > 0 && ( +
+
+ 📊 预览结果({previewData.length} 行 × {previewCols.length} 列) + setPreviewExpanded(false)}>收起 ▴ +
+
+ + + + {previewCols.map(col => ( + + ))} + + + + {previewData.map((row, ri) => ( + + {previewCols.map(col => ( + + ))} + + ))} + +
+ {col} +
+ {row[col] === null ? NULL : String(row[col])} +
+
+
+ )} + {!previewExpanded && previewData && previewData.length > 0 && ( +
setPreviewExpanded(true)} + > + 📊 查看结果({previewData.length} 行)▾ +
+ )} +
+ ); +}; + +// 可折叠思考过程组件 +const ThinkingBlock: React.FC<{ displayThinking: string; totalLen: number; isTyping: boolean; isGlobalLoading: boolean; darkMode: boolean; overlayTheme: any; hasContent: boolean }> = ({ displayThinking, totalLen, isTyping, isGlobalLoading, darkMode, overlayTheme, hasContent }) => { + // 如果整体在loading,且尚未吐出content,我们认为真正的思考还在进行;如果吐出content了,思考框就算告一段落 + const isActivelyThinking = isGlobalLoading && !hasContent; + const [expanded, setExpanded] = useState(isActivelyThinking); + const contentRef = React.useRef(null); + + React.useEffect(() => { if (isActivelyThinking) setExpanded(true); }, [isActivelyThinking]); + + // 断开连接或思考结束时,若已有内容且不再产生新内容则默认收起 + React.useEffect(() => { + if (!isGlobalLoading) setExpanded(false); + }, [isGlobalLoading]); + + // 自动滚动到思考内容底部 + React.useEffect(() => { + if (expanded && isTyping && contentRef.current) { + contentRef.current.scrollTop = contentRef.current.scrollHeight; + } + }, [displayThinking, expanded, isTyping]); + + return ( +
+
setExpanded(e => !e)} + style={{ + display: 'flex', alignItems: 'center', gap: 6, + padding: '6px 10px', cursor: 'pointer', + background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)', + fontSize: 12, color: overlayTheme.mutedText, userSelect: 'none', + }} + > + + 💭 思考过程 + {isActivelyThinking && 思考中...} + {!isActivelyThinking && ({displayThinking.length} 字)} +
+
+
+ {displayThinking} + {isTyping && } +
+
+
+ ); +}; + +// 工具调用进度面板聚合展示组件 +const AIToolCallingBlock: React.FC<{ tool_calls: AIToolCall[]; loading: boolean; allMessages: AIChatMessage[]; darkMode: boolean; overlayTheme: any; hasContent: boolean }> = ({ tool_calls, loading, allMessages, darkMode, overlayTheme, hasContent }) => { + const totalCalls = tool_calls.length; + const allDone = tool_calls.every(tc => allMessages?.find(m => m.role === 'tool' && m.tool_call_id === tc.id)); + const [expanded, setExpanded] = useState(!allDone && loading); + + // 断开连接或执行完毕时,若已完成则默认收起 + React.useEffect(() => { + if (allDone || !loading) setExpanded(false); + }, [allDone, loading]); + + // 显示友好的人类可读动作名 + const getHumanActionName = (fname: string) => { + if (fname === 'get_connections') return '获取可用连接信息'; + if (fname === 'get_databases') return '扫描数据库列表'; + if (fname === 'get_tables') return '分析表结构信息'; + return fname; + }; + + return ( +
+
setExpanded(!expanded)} + style={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + padding: '8px 12px', cursor: 'pointer', userSelect: 'none', + background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)', + }} + > +
+ {!allDone && loading ? ( +
+ ) : ( + + )} + {!allDone && loading ? '正在执行数据探针...' : `数据探针执行完毕 (${totalCalls} 项)`} +
+ +
+
+
+ {tool_calls.map((tc, idx) => { + const resultMsg = allMessages?.find(m => m.role === 'tool' && m.tool_call_id === tc.id); + const isDone = !!resultMsg; + const actionName = getHumanActionName(tc.function.name); + return ( +
+
+ {isDone + ? + : (loading ?
: ) + } + {actionName} +
+ {resultMsg && } +
+ ); + })} +
+
+
+ ); +}; + +export const AIMessageBubble: React.FC = React.memo(({ msg, darkMode, overlayTheme, textColor, onEdit, onRetry, onDelete, activeConnectionId, activeConnectionConfig, activeDbName, allMessages }) => { + const [isCopied, setIsCopied] = useState(false); + const isUser = msg.role === 'user'; + + const displayContent = msg.content; + const isTypingThinking = !!(msg.loading && msg.phase === 'thinking'); + + if (msg.role === 'tool') return null; + + // 如果是纯空壳的加载状态(connecting,或还在思考/工具阶段但还没吐出一个字的 content) + const isWaitState = msg.phase === 'connecting' || + (msg.loading && !msg.content && (msg.phase === 'thinking' || msg.phase === 'tool_calling')); + + if (isWaitState) { + return ( +
+
+
+
+ +
+ {msg.content || '正在建立连接'}... +
+ + {/* 即使在波纹过渡态,如果有 thinking / tool_calls 也要显示出来,只是把它们压在波纹下面 */} +
0) ? 12 : 0 }}> + {!isUser && msg.thinking && ( + + )} + {!isUser && msg.tool_calls && msg.tool_calls.length > 0 && ( + + )} +
+
+
+ ); + } + + return ( +
+
+
+
+ {isUser + ? <> You + : <> GoNavi AI} +
+ {/* 气泡操作栏 */} +
+ + {isCopied ? ( + + ) : ( + { + navigator.clipboard.writeText(msg.content); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} /> + )} + + {isUser ? ( + + onEdit(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} /> + + ) : ( + + onRetry(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} /> + + )} + + onDelete(msg.id)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} /> + +
+
+
+ {msg.images && msg.images.length > 0 && ( +
+ {msg.images.map((img, i) => ( + {`Attached + ))} +
+ )} + {/* 可折叠思考过程 */} + {!isUser && msg.thinking && ( + + )} + {isUser ? ( +
{msg.content}
+ ) : ( + + )} + {/* 错误原文复制按钮 */} + {!isUser && msg.rawError && ( +
+ +
+ )} + {/* 工具调用进度展示 */} + {!isUser && msg.tool_calls && msg.tool_calls.length > 0 && ( + + )} + {msg.loading && msg.phase !== 'tool_calling' && msg.content && ( + + )} +
+
+
+ ); +}); diff --git a/frontend/src/store.ts b/frontend/src/store.ts index e671270..55fcc2a 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, AIChatMessage } from './types'; +import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem } from './types'; import { ShortcutAction, ShortcutBinding, @@ -427,8 +427,15 @@ interface AppState { // AI 运行时与持久化状态 aiPanelVisible: boolean; aiChatHistory: Record; // sessionId -> messages + replaceAIChatHistory: (sessionId: string, messages: AIChatMessage[]) => void; aiChatSessions: { id: string; title: string; updatedAt: number }[]; // 历史会话列表 aiActiveSessionId: string | null; + updateAISessionTitle: (sessionId: string, title: string) => void; + + aiContexts: Record; + addAIContext: (connectionKey: string, context: AIContextItem) => void; + removeAIContext: (connectionKey: string, dbName: string, tableName: string) => void; + clearAIContexts: (connectionKey: string) => void; addConnection: (conn: SavedConnection) => void; updateConnection: (conn: SavedConnection) => void; @@ -694,6 +701,7 @@ export const useStore = create()( aiChatHistory: {}, aiChatSessions: [], aiActiveSessionId: null, + aiContexts: {}, addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })), updateConnection: (conn) => set((state) => ({ @@ -1002,19 +1010,26 @@ export const useStore = create()( return { aiChatHistory: history, aiChatSessions: newSessions }; }), updateAIChatMessage: (sessionId, messageId, updates) => set((state) => { - const history = { ...state.aiChatHistory }; - const messages = history[sessionId]; + const messages = state.aiChatHistory[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() }); + // 🔧 性能优化:用 findIndex + 定点替换代替全量 map,长对话场景下从 O(n) 降至 O(1) + const idx = messages.findIndex(m => m.id === messageId); + if (idx < 0) return state; + const newMessages = [...messages]; + newMessages[idx] = { ...newMessages[idx], ...updates }; + const history = { ...state.aiChatHistory, [sessionId]: newMessages }; + // 仅当非纯 content 追加时才重排 session 顺序(性能优化:流式打字时跳过) + const isContentOnlyUpdate = Object.keys(updates).length === 1 && 'content' in updates; + if (!isContentOnlyUpdate) { + 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 }; } - return { aiChatHistory: history, aiChatSessions: newSessions }; + return { aiChatHistory: history }; }), deleteAIChatMessage: (sessionId, messageId) => set((state) => { const history = { ...state.aiChatHistory }; @@ -1039,6 +1054,11 @@ export const useStore = create()( delete history[sessionId]; return { aiChatHistory: history }; }), + replaceAIChatHistory: (sessionId, messages) => set((state) => { + const history = { ...state.aiChatHistory }; + history[sessionId] = messages; + return { aiChatHistory: history }; + }), deleteAISession: (sessionId) => set((state) => { const history = { ...state.aiChatHistory }; delete history[sessionId]; @@ -1051,6 +1071,39 @@ export const useStore = create()( return { aiActiveSessionId: newId }; }), setAIActiveSessionId: (sessionId) => set({ aiActiveSessionId: sessionId }), + updateAISessionTitle: (sessionId, title) => set((state) => { + const newSessions = [...state.aiChatSessions]; + const session = newSessions.find(s => s.id === sessionId); + if (session) { + session.title = title; + } + return { aiChatSessions: newSessions }; + }), + addAIContext: (connectionKey, context) => set((state) => { + const contexts = state.aiContexts[connectionKey] || []; + if (contexts.find(c => c.dbName === context.dbName && c.tableName === context.tableName)) { + return state; + } + return { + aiContexts: { + ...state.aiContexts, + [connectionKey]: [...contexts, context] + } + }; + }), + removeAIContext: (connectionKey, dbName, tableName) => set((state) => { + const contexts = state.aiContexts[connectionKey] || []; + return { + aiContexts: { + ...state.aiContexts, + [connectionKey]: contexts.filter(c => !(c.dbName === dbName && c.tableName === tableName)) + } + }; + }), + clearAIContexts: (connectionKey) => set((state) => { + const { [connectionKey]: _, ...rest } = state.aiContexts; + return { aiContexts: rest }; + }), }), { name: 'lite-db-storage', // name of the item in the storage (must be unique) @@ -1147,8 +1200,17 @@ export const useStore = create()( windowState: state.windowState, sidebarWidth: state.sidebarWidth, - aiChatHistory: state.aiChatHistory, - aiChatSessions: state.aiChatSessions, + // 只持久化最近 20 个会话的聊天记录,防止 localStorage 膨胀 + aiChatHistory: (() => { + const MAX_PERSIST_SESSIONS = 20; + const recentIds = new Set(state.aiChatSessions.slice(0, MAX_PERSIST_SESSIONS).map(s => s.id)); + const trimmed: Record = {}; + for (const id of recentIds) { + if (state.aiChatHistory[id]) trimmed[id] = state.aiChatHistory[id]; + } + return trimmed; + })(), + aiChatSessions: state.aiChatSessions.slice(0, 50), }), // Don't persist logs } ) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 072d65c..8633c10 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -190,6 +190,12 @@ export type AIProviderType = 'openai' | 'anthropic' | 'gemini' | 'custom'; export type AISafetyLevel = 'readonly' | 'readwrite' | 'full'; export type AIContextLevel = 'schema_only' | 'with_samples' | 'with_results'; +export interface AIContextItem { + dbName: string; + tableName: string; + ddl: string; +} + export interface AIProviderConfig { id: string; type: AIProviderType; @@ -204,12 +210,31 @@ export interface AIProviderConfig { temperature: number; } +export interface AIToolCall { + id: string; + type: string; + function: { + name: string; + arguments: string; + }; +} + +export type ChatPhase = 'idle' | 'connecting' | 'thinking' | 'generating' | 'tool_calling'; + export interface AIChatMessage { id: string; - role: 'user' | 'assistant' | 'system'; + role: 'user' | 'assistant' | 'system' | 'tool'; + phase?: ChatPhase; content: string; + thinking?: string; timestamp: number; loading?: boolean; + images?: string[]; // base64 encoded images with data URI prefix + tool_calls?: AIToolCall[]; + tool_call_id?: string; + tool_name?: string; // used for UI display + rawError?: string; // 存储未清洗的原始错误信息,用于用户复制排查 + success?: boolean; // 标记探针执行是否成功 } export interface AISafetyResult { diff --git a/frontend/wailsjs/go/aiservice/Service.d.ts b/frontend/wailsjs/go/aiservice/Service.d.ts old mode 100644 new mode 100755 index 872b5f5..6ffc07a --- a/frontend/wailsjs/go/aiservice/Service.d.ts +++ b/frontend/wailsjs/go/aiservice/Service.d.ts @@ -5,9 +5,9 @@ import {context} from '../models'; export function AIChatCancel(arg1:string):Promise; -export function AIChatSend(arg1:Array>):Promise>; +export function AIChatSend(arg1:Array,arg2:Array):Promise>; -export function AIChatStream(arg1:string,arg2:Array>):Promise; +export function AIChatStream(arg1:string,arg2:Array,arg3:Array):Promise; export function AICheckSQL(arg1:string):Promise; diff --git a/frontend/wailsjs/go/aiservice/Service.js b/frontend/wailsjs/go/aiservice/Service.js old mode 100644 new mode 100755 index 2e3dcf4..7f5de4a --- a/frontend/wailsjs/go/aiservice/Service.js +++ b/frontend/wailsjs/go/aiservice/Service.js @@ -6,12 +6,12 @@ export function AIChatCancel(arg1) { return window['go']['aiservice']['Service']['AIChatCancel'](arg1); } -export function AIChatSend(arg1) { - return window['go']['aiservice']['Service']['AIChatSend'](arg1); +export function AIChatSend(arg1, arg2) { + return window['go']['aiservice']['Service']['AIChatSend'](arg1, arg2); } -export function AIChatStream(arg1, arg2) { - return window['go']['aiservice']['Service']['AIChatStream'](arg1, arg2); +export function AIChatStream(arg1, arg2, arg3) { + return window['go']['aiservice']['Service']['AIChatStream'](arg1, arg2, arg3); } export function AICheckSQL(arg1) { diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 258b148..e9558a8 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,5 +1,78 @@ export namespace ai { + export class ToolCall { + id: string; + type: string; + // Go type: struct { Name string "json:\"name\""; Arguments string "json:\"arguments\"" } + function: any; + + static createFrom(source: any = {}) { + return new ToolCall(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.type = source["type"]; + this.function = this.convertValues(source["function"], Object); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class Message { + role: string; + content: string; + images?: string[]; + tool_call_id?: string; + tool_calls?: ToolCall[]; + + static createFrom(source: any = {}) { + return new Message(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.role = source["role"]; + this.content = source["content"]; + this.images = source["images"]; + this.tool_call_id = source["tool_call_id"]; + this.tool_calls = this.convertValues(source["tool_calls"], ToolCall); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class ProviderConfig { id: string; type: string; @@ -50,6 +123,55 @@ export namespace ai { this.warningMessage = source["warningMessage"]; } } + export class ToolFunction { + name: string; + description: string; + parameters: any; + + static createFrom(source: any = {}) { + return new ToolFunction(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.description = source["description"]; + this.parameters = source["parameters"]; + } + } + export class Tool { + type: string; + function: ToolFunction; + + static createFrom(source: any = {}) { + return new Tool(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.type = source["type"]; + this.function = this.convertValues(source["function"], ToolFunction); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + } diff --git a/internal/ai/context/builder.go b/internal/ai/context/builder.go index 9c4e75a..f61d49a 100644 --- a/internal/ai/context/builder.go +++ b/internal/ai/context/builder.go @@ -208,6 +208,7 @@ func buildGeneralChatPrompt() string { 互动守则: - 永远使用专业、具有合作感且充满信心的中文与用户探讨问题。 -- 当被要求提供任何数据库代码时,需结合相关数据库引擎的最佳实践。如果不清楚当前方言版本,请以标准实现为主基调并好心指出版别差异(如 MySQL 8 窗口函数 等)。` +- 当被要求提供任何数据库代码时,需结合相关数据库引擎的最佳实践。如果不清楚当前方言版本,请以标准实现为主基调并好心指出版别差异(如 MySQL 8 窗口函数 等)。 +- 绝不轻易拒绝:如果用户要求写 SQL 但并未显式挂载任何表的详细 DDL,请尽最大努力根据对话上下文中带入的【纯表名列表】去推测他要查询哪个表。如果实在无法推断,请温柔且专业地向用户解释目前已知的表有哪些,并询问到底想查哪张表。` } diff --git a/internal/ai/provider/anthropic.go b/internal/ai/provider/anthropic.go index 035d2fc..94312ec 100644 --- a/internal/ai/provider/anthropic.go +++ b/internal/ai/provider/anthropic.go @@ -82,8 +82,42 @@ type anthropicRequest struct { } type anthropicMessage struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content interface{} `json:"content"` +} + +func buildAnthropicMessages(reqMessages []ai.Message) []anthropicMessage { + messages := make([]anthropicMessage, 0, len(reqMessages)) + for _, m := range reqMessages { + if len(m.Images) > 0 { + var contentParts []map[string]interface{} + for _, img := range m.Images { + mimeType, rawBase64, err := ParseDataURI(img) + if err == nil { + contentParts = append(contentParts, map[string]interface{}{ + "type": "image", + "source": map[string]interface{}{ + "type": "base64", + "media_type": mimeType, + "data": rawBase64, + }, + }) + } + } + text := m.Content + if text == "" { + text = "请描述和分析这张图片。" // 防止强 System Prompt 下模型仅看到空文本且忽略图片直接回复打招呼 + } + contentParts = append(contentParts, map[string]interface{}{ + "type": "text", + "text": text, + }) + messages = append(messages, anthropicMessage{Role: m.Role, Content: contentParts}) + } else { + messages = append(messages, anthropicMessage{Role: m.Role, Content: m.Content}) + } + } + return messages } type anthropicResponse struct { @@ -112,10 +146,7 @@ func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C } systemMsg, messages := extractSystemMessage(req.Messages) - anthropicMsgs := make([]anthropicMessage, len(messages)) - for i, m := range messages { - anthropicMsgs[i] = anthropicMessage{Role: m.Role, Content: m.Content} - } + anthropicMsgs := buildAnthropicMessages(messages) temperature := req.Temperature if temperature <= 0 { @@ -167,10 +198,7 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest, } systemMsg, messages := extractSystemMessage(req.Messages) - anthropicMsgs := make([]anthropicMessage, len(messages)) - for i, m := range messages { - anthropicMsgs[i] = anthropicMessage{Role: m.Role, Content: m.Content} - } + anthropicMsgs := buildAnthropicMessages(messages) temperature := req.Temperature if temperature <= 0 { @@ -253,6 +281,12 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io httpReq.Header.Set("x-api-key", p.config.APIKey) httpReq.Header.Set("anthropic-version", anthropicAPIVersion) + if strings.Contains(string(jsonBody), `"stream":true`) || strings.Contains(string(jsonBody), `"stream": true`) { + httpReq.Header.Set("Accept", "text/event-stream") + httpReq.Header.Set("Cache-Control", "no-cache") + httpReq.Header.Set("Connection", "keep-alive") + } + // 仅官方 API 发 beta 特性头(代理不发,避免触发 Claude Code 验证) isOfficialAPI := p.baseURL == defaultAnthropicBaseURL || strings.Contains(p.baseURL, "anthropic.com") if isOfficialAPI { diff --git a/internal/ai/provider/claude_cli.go b/internal/ai/provider/claude_cli.go index 824e413..4cd9b00 100644 --- a/internal/ai/provider/claude_cli.go +++ b/internal/ai/provider/claude_cli.go @@ -105,8 +105,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, fmt.Printf("[ClaudeCLI DEBUG] Process started, PID: %d\n", cmd.Process.Pid) - // 立即通知前端:AI 正在思考(避免用户以为卡死) - callback(ai.StreamChunk{Content: "💭 *正在思考...*\n\n"}) + // 前端已有 loading 动画,无需在 content 中注入"正在思考" // 逐行读取流式 JSON 输出 scanner := bufio.NewScanner(stdout) @@ -131,14 +130,18 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, // 助手消息开始或文本内容 if event.Message.Content != nil { for _, block := range event.Message.Content { - if block.Type == "text" && block.Text != "" { + if block.Type == "thinking" && block.Thinking != "" { + callback(ai.StreamChunk{Thinking: block.Thinking}) + } else if block.Type == "text" && block.Text != "" { callback(ai.StreamChunk{Content: block.Text}) } } } case "content_block_delta": - // 增量文本 - if event.Delta.Text != "" { + // 增量文本或增量思考 + if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" { + callback(ai.StreamChunk{Thinking: event.Delta.Thinking}) + } else if event.Delta.Text != "" { callback(ai.StreamChunk{Content: event.Delta.Text}) } case "result": @@ -213,12 +216,15 @@ type cliStreamEvent struct { Type string `json:"type"` Message struct { Content []struct { - Type string `json:"type"` - Text string `json:"text"` + Type string `json:"type"` + Text string `json:"text"` + Thinking string `json:"thinking"` } `json:"content"` } `json:"message,omitempty"` Delta struct { - Text string `json:"text"` + Type string `json:"type"` + Text string `json:"text"` + Thinking string `json:"thinking"` } `json:"delta,omitempty"` Result string `json:"result,omitempty"` Error struct { diff --git a/internal/ai/provider/gemini.go b/internal/ai/provider/gemini.go index 0c5eee7..b4cf910 100644 --- a/internal/ai/provider/gemini.go +++ b/internal/ai/provider/gemini.go @@ -83,7 +83,13 @@ type geminiContent struct { } type geminiPart struct { - Text string `json:"text"` + Text string `json:"text,omitempty"` + InlineData *geminiBlob `json:"inlineData,omitempty"` +} + +type geminiBlob struct { + MimeType string `json:"mimeType"` + Data string `json:"data"` } type geminiGenConfig struct { @@ -205,10 +211,6 @@ func (p *GeminiProvider) buildRequest(req ai.ChatRequest) geminiRequest { if temperature <= 0 { temperature = p.config.Temperature } - maxTokens := req.MaxTokens - if maxTokens <= 0 { - maxTokens = p.config.MaxTokens - } var systemInstruction *geminiContent var contents []geminiContent @@ -224,9 +226,29 @@ func (p *GeminiProvider) buildRequest(req ai.ChatRequest) geminiRequest { if role == "assistant" { role = "model" } + var parts []geminiPart + text := m.Content + if text == "" && len(m.Images) > 0 { + text = "请描述和分析这张图片。" // 同样避免 Gemini 认为意图不明确 + } + if text != "" { + parts = append(parts, geminiPart{Text: text}) + } + for _, img := range m.Images { + mimeType, rawBase64, err := ParseDataURI(img) + if err == nil { + parts = append(parts, geminiPart{ + InlineData: &geminiBlob{ + MimeType: mimeType, + Data: rawBase64, + }, + }) + } + } + contents = append(contents, geminiContent{ Role: role, - Parts: []geminiPart{{Text: m.Content}}, + Parts: parts, }) } @@ -235,7 +257,6 @@ func (p *GeminiProvider) buildRequest(req ai.ChatRequest) geminiRequest { SystemInstruction: systemInstruction, GenerationConfig: geminiGenConfig{ Temperature: temperature, - MaxOutputTokens: maxTokens, }, } } @@ -252,6 +273,12 @@ func (p *GeminiProvider) doRequest(ctx context.Context, url string, body interfa } httpReq.Header.Set("Content-Type", "application/json") + if strings.Contains(url, "alt=sse") { + httpReq.Header.Set("Accept", "text/event-stream") + httpReq.Header.Set("Cache-Control", "no-cache") + httpReq.Header.Set("Connection", "keep-alive") + } + resp, err := p.client.Do(httpReq) if err != nil { return nil, fmt.Errorf("发送请求到 Gemini 失败: %w", err) diff --git a/internal/ai/provider/helper.go b/internal/ai/provider/helper.go new file mode 100644 index 0000000..e6bb0d3 --- /dev/null +++ b/internal/ai/provider/helper.go @@ -0,0 +1,26 @@ +package provider + +import ( + "fmt" + "strings" +) + +// ParseDataURI 解析前端传递的 Data URI,返回 mimeType 和去掉前缀的 rawBase64 +func ParseDataURI(dataURI string) (mimeType, rawBase64 string, err error) { + if !strings.HasPrefix(dataURI, "data:") { + // 如果前端漏了前缀,默认容错当做 jpeg 处理 + return "image/jpeg", dataURI, nil + } + parts := strings.SplitN(dataURI, ",", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid data URI format") + } + meta := strings.TrimPrefix(parts[0], "data:") + metaParts := strings.Split(meta, ";") + mimeType = metaParts[0] + if mimeType == "" { + mimeType = "image/jpeg" // fallback + } + rawBase64 = parts[1] + return mimeType, rawBase64, nil +} diff --git a/internal/ai/provider/openai.go b/internal/ai/provider/openai.go index ff674a9..87d5214 100644 --- a/internal/ai/provider/openai.go +++ b/internal/ai/provider/openai.go @@ -88,18 +88,67 @@ type openAIChatRequest struct { Temperature float64 `json:"temperature,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` Stream bool `json:"stream,omitempty"` + Tools []ai.Tool `json:"tools,omitempty"` } type openAIChatMessage struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content interface{} `json:"content,omitempty"` + ToolCalls []ai.ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +func buildOpenAIMessages(reqMessages []ai.Message, modelName string, baseURL string) []openAIChatMessage { + messages := make([]openAIChatMessage, len(reqMessages)) + for i, m := range reqMessages { + if m.Role == "tool" { + messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content, ToolCallID: m.ToolCallID} + continue + } + if len(m.ToolCalls) > 0 { + messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content, ToolCalls: m.ToolCalls} + continue + } + + if len(m.Images) > 0 { + var contentParts []map[string]interface{} + text := m.Content + if text == "" { + text = "请描述和分析这张图片。" // 兼容部分模型(如 ZhipuAI/GLM-4V)强制要求图片必须伴随有效文本块,同时防止强 System Prompt 下模型当成空消息处理 + } + contentParts = append(contentParts, map[string]interface{}{ + "type": "text", + "text": text, + }) + for _, img := range m.Images { + imgURL := img + // 仅当直接请求智谱官方 API 域名时(它原生不接受 data 协议前缀),才截取裸 Base64 + if strings.Contains(strings.ToLower(baseURL), "bigmodel") { + if _, raw, err := ParseDataURI(img); err == nil { + imgURL = raw + } + } + contentParts = append(contentParts, map[string]interface{}{ + "type": "image_url", + "image_url": map[string]interface{}{ + "url": imgURL, + }, + }) + } + messages[i] = openAIChatMessage{Role: m.Role, Content: contentParts} + } else { + messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content} + } + } + return messages } // openAIChatResponse OpenAI API 响应体 type openAIChatResponse struct { Choices []struct { Message struct { - Content string `json:"content"` + Content string `json:"content"` + ToolCalls []ai.ToolCall `json:"tool_calls,omitempty"` } `json:"message"` FinishReason string `json:"finish_reason"` } `json:"choices"` @@ -114,10 +163,22 @@ type openAIChatResponse struct { } // openAIStreamChunk SSE 流式响应片段 +type openAIToolCallDelta struct { + Index int `json:"index"` + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function *struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` + } `json:"function,omitempty"` +} + type openAIStreamChunk struct { Choices []struct { Delta struct { - Content string `json:"content"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content"` + ToolCalls []openAIToolCallDelta `json:"tool_calls,omitempty"` } `json:"delta"` FinishReason *string `json:"finish_reason"` } `json:"choices"` @@ -131,26 +192,19 @@ func (p *OpenAIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.Chat return nil, err } - messages := make([]openAIChatMessage, len(req.Messages)) - for i, m := range req.Messages { - messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content} - } + messages := buildOpenAIMessages(req.Messages, p.config.Model, p.baseURL) 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, + Tools: req.Tools, } respBody, err := p.doRequest(ctx, body) @@ -177,6 +231,7 @@ func (p *OpenAIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.Chat CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens, }, + ToolCalls: result.Choices[0].Message.ToolCalls, }, nil } @@ -185,26 +240,19 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cal return err } - messages := make([]openAIChatMessage, len(req.Messages)) - for i, m := range req.Messages { - messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content} - } + messages := buildOpenAIMessages(req.Messages, p.config.Model, p.baseURL) 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, + Tools: req.Tools, } respBody, err := p.doRequest(ctx, body) @@ -214,6 +262,8 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cal defer respBody.Close() receivedContent := false + var activeToolCalls []ai.ToolCall + scanner := bufio.NewScanner(respBody) // 增大 scanner buffer,防止长行被截断 scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) @@ -245,12 +295,49 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cal return nil } if len(chunk.Choices) > 0 { - content := chunk.Choices[0].Delta.Content + choice := chunk.Choices[0] + + // Handle ToolCalls delta + if len(choice.Delta.ToolCalls) > 0 { + receivedContent = true + for _, tcDelta := range choice.Delta.ToolCalls { + // Expand activeToolCalls slice if index is larger + for len(activeToolCalls) <= tcDelta.Index { + activeToolCalls = append(activeToolCalls, ai.ToolCall{Type: "function"}) + } + if tcDelta.ID != "" { + activeToolCalls[tcDelta.Index].ID = tcDelta.ID + } + if tcDelta.Function != nil { + if tcDelta.Function.Name != "" { + activeToolCalls[tcDelta.Index].Function.Name += tcDelta.Function.Name + } + if tcDelta.Function.Arguments != "" { + activeToolCalls[tcDelta.Index].Function.Arguments += tcDelta.Function.Arguments + } + } + } + // 实时推送目前已解析的 ToolCalls 状态 + callback(ai.StreamChunk{ToolCalls: activeToolCalls}) + } + + content := choice.Delta.Content if content != "" { receivedContent = true callback(ai.StreamChunk{Content: content}) } - if chunk.Choices[0].FinishReason != nil { + + // 支持 DeepSeek/千问等模型的 reasoning_content 字段 + if choice.Delta.ReasoningContent != "" { + receivedContent = true + callback(ai.StreamChunk{Thinking: choice.Delta.ReasoningContent}) + } + + if choice.FinishReason != nil { + if *choice.FinishReason == "tool_calls" { + callback(ai.StreamChunk{ToolCalls: activeToolCalls, Done: true}) + return nil + } callback(ai.StreamChunk{Done: true}) return nil } @@ -296,6 +383,13 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, body interface{}) (io.Re httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey) + // 仅在流式请求时明确声明 SSE,防止代理缓冲 + if strings.Contains(string(jsonBody), `"stream":true`) || strings.Contains(string(jsonBody), `"stream": true`) { + httpReq.Header.Set("Accept", "text/event-stream") + httpReq.Header.Set("Cache-Control", "no-cache") + httpReq.Header.Set("Connection", "keep-alive") + } + // 自定义 headers(用于兼容各类 OpenAI 兼容服务) for k, v := range p.config.Headers { httpReq.Header.Set(k, v) diff --git a/internal/ai/service/service.go b/internal/ai/service/service.go index 52af6ba..addab29 100644 --- a/internal/ai/service/service.go +++ b/internal/ai/service/service.go @@ -114,7 +114,7 @@ func (s *Service) AIDeleteProvider(id string) error { return s.saveConfig() } -// AITestProvider 测试 Provider 配置是否可用 +// AITestProvider 测试 Provider 配置是否可用,仅测试端点连通性与密钥,不实际调用对话 func (s *Service) AITestProvider(config ai.ProviderConfig) map[string]interface{} { // 如果传入脱敏的 key,使用已保存的 key s.mu.RLock() @@ -128,30 +128,84 @@ func (s *Service) AITestProvider(config ai.ProviderConfig) map[string]interface{ } 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()} + baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/") + providerType := config.Type + if providerType == "custom" && config.APIFormat != "" { + providerType = config.APIFormat } - ctx, cancel := context.WithTimeout(context.Background(), 30*1000*1000*1000) // 30s - defer cancel() + client := &http.Client{Timeout: 10 * time.Second} + var err error + + switch providerType { + case "openai": + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } + if !strings.HasSuffix(baseURL, "/v1") && !strings.Contains(baseURL, "/v1/") { + baseURL = baseURL + "/v1" + } + // 使用 /models 端点验证连通性和鉴权 + req, _ := http.NewRequest("GET", baseURL+"/models", nil) + req.Header.Set("Authorization", "Bearer "+config.APIKey) + for k, v := range config.Headers { + req.Header.Set(k, v) + } + resp, reqErr := client.Do(req) + if reqErr != nil { + err = reqErr + } else { + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + err = fmt.Errorf("API Key 验证失败 (HTTP %d)", resp.StatusCode) + } else if resp.StatusCode >= 500 { + err = fmt.Errorf("上游服务器内部错误 (HTTP %d)", resp.StatusCode) + } + } + case "anthropic": + if baseURL == "" { + baseURL = "https://api.anthropic.com" + } + req, _ := http.NewRequest("GET", baseURL, nil) + resp, reqErr := client.Do(req) + if reqErr != nil { + err = reqErr + } else { + resp.Body.Close() + } + case "gemini": + if baseURL == "" { + baseURL = "https://generativelanguage.googleapis.com" + } + req, _ := http.NewRequest("GET", baseURL+"/v1beta/models?key="+config.APIKey, nil) + resp, reqErr := client.Do(req) + if reqErr != nil { + err = reqErr + } else { + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest { + err = fmt.Errorf("API Key 无效或请求错误 (HTTP %d)", resp.StatusCode) + } + } + default: + if baseURL != "" { + req, _ := http.NewRequest("GET", baseURL, nil) + resp, reqErr := client.Do(req) + if reqErr != nil { + err = reqErr + } else { + resp.Body.Close() + } + } + } - 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)), + "message": "端点连通性测试成功!", } } @@ -364,19 +418,14 @@ func (s *Service) AISetContextLevel(level string) { // --- AI 对话 --- -// AIChatSend 同步发送 AI 对话(非流式) -func (s *Service) AIChatSend(messages []map[string]string) map[string]interface{} { +// AIChatSend 非流式发送 AI 对话 +func (s *Service) AIChatSend(messages []ai.Message, tools []ai.Tool) 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}) + resp, err := p.Chat(context.Background(), ai.ChatRequest{Messages: messages, Tools: tools}) if err != nil { return map[string]interface{}{"success": false, "error": err.Error()} } @@ -384,6 +433,7 @@ func (s *Service) AIChatSend(messages []map[string]string) map[string]interface{ return map[string]interface{}{ "success": true, "content": resp.Content, + "tool_calls": resp.ToolCalls, "tokensUsed": map[string]int{ "promptTokens": resp.TokensUsed.PromptTokens, "completionTokens": resp.TokensUsed.CompletionTokens, @@ -393,7 +443,7 @@ func (s *Service) AIChatSend(messages []map[string]string) map[string]interface{ } // AIChatStream 流式发送 AI 对话(通过 EventsEmit 推送) -func (s *Service) AIChatStream(sessionID string, messages []map[string]string) { +func (s *Service) AIChatStream(sessionID string, messages []ai.Message, tools []ai.Tool) { streamCtx, cancel := context.WithCancel(context.Background()) s.mu.Lock() s.cancelFuncs[sessionID] = cancel @@ -416,16 +466,13 @@ func (s *Service) AIChatStream(sessionID string, messages []map[string]string) { 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) { + err = p.ChatStream(streamCtx, ai.ChatRequest{Messages: messages, Tools: tools}, func(chunk ai.StreamChunk) { wailsRuntime.EventsEmit(s.ctx, "ai:stream:"+sessionID, map[string]interface{}{ - "content": chunk.Content, - "done": chunk.Done, - "error": chunk.Error, + "content": chunk.Content, + "thinking": chunk.Thinking, + "tool_calls": chunk.ToolCalls, + "done": chunk.Done, + "error": chunk.Error, }) }) diff --git a/internal/ai/types.go b/internal/ai/types.go index eb55a6f..0c83c6d 100644 --- a/internal/ai/types.go +++ b/internal/ai/types.go @@ -1,9 +1,35 @@ package ai +// ToolCall 表示 AI 发出的工具调用 +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` // "function" + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +// ToolFunction 表示可使用的函数定义 +type ToolFunction struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters any `json:"parameters"` // JSON Schema definitions +} + +// Tool 工具申明 +type Tool struct { + Type string `json:"type"` // "function" + Function ToolFunction `json:"function"` +} + // Message 表示一条对话消息 type Message struct { - Role string `json:"role"` // "system" | "user" | "assistant" - Content string `json:"content"` + Role string `json:"role"` // "system" | "user" | "assistant" | "tool" + Content string `json:"content"` + Images []string `json:"images,omitempty"` // base64 encoded images with data:image/png;base64,... prefix + ToolCallID string `json:"tool_call_id,omitempty"` // 当 role 为 "tool" 时必须传递 + ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 当 role 为 "assistant" 并试图调工具时传递 } // ChatRequest AI 对话请求 @@ -11,12 +37,14 @@ type ChatRequest struct { Messages []Message `json:"messages"` Temperature float64 `json:"temperature"` MaxTokens int `json:"maxTokens"` + Tools []Tool `json:"tools,omitempty"` } // ChatResponse AI 对话响应 type ChatResponse struct { Content string `json:"content"` TokensUsed TokenUsage `json:"tokensUsed"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` } // TokenUsage token 用量统计 @@ -28,9 +56,11 @@ type TokenUsage struct { // StreamChunk 流式响应片段 type StreamChunk struct { - Content string `json:"content"` - Done bool `json:"done"` - Error string `json:"error,omitempty"` + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` + Done bool `json:"done"` + Error string `json:"error,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` } // ProviderConfig AI Provider 配置 From b958ff6481348616acb7d501c17640a1ff312653 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 26 Mar 2026 17:57:29 +0800 Subject: [PATCH 07/14] =?UTF-8?q?=F0=9F=90=9B=20fix(ai/query-editor/mac-wi?= =?UTF-8?q?ndow):=20=E4=BF=AE=E5=A4=8D=E6=A8=A1=E5=9E=8B=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=80=A7=E5=B9=B6=E4=BC=98=E5=8C=96=E5=8D=B3=E6=97=B6=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E4=B8=8E=E7=AA=97=E5=8F=A3=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI 兼容性:为 Anthropic Provider 补齐 tools/tool_use/tool_result 协议转换,支持工具调用与流式工具结果解析 - 降级策略:OpenAI 兼容接口在 tools 请求返回 400/422/404 时自动回退为纯文本模式 - 配置修复:调整 MiniMax 预设为 Anthropic 兼容端点并更新默认模型列表 - 状态隔离:AI 聊天面板停止将动态模型列表写回供应商配置,避免污染静态 models 数据 - 编辑器修复:QueryEditor 在 runImmediately 场景下避免重复追加 SQL,改为直接选中并执行 - 交互优化:修复 macOS 原生窗口控制切换与标题栏点击行为,避免窗口按钮状态异常 --- frontend/src/App.tsx | 3 - frontend/src/components/AIChatPanel.tsx | 17 +- frontend/src/components/AISettingsModal.tsx | 2 +- frontend/src/components/QueryEditor.tsx | 17 +- frontend/src/components/TabManager.tsx | 2 +- internal/ai/provider/anthropic.go | 212 +++++++++++++++++--- internal/ai/provider/openai.go | 45 ++++- internal/app/window_style_darwin.go | 9 +- 8 files changed, 246 insertions(+), 61 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3cc6388..457b836 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1215,9 +1215,6 @@ function App() { if (target?.closest('[data-no-titlebar-toggle="true"]')) { return; } - if (useNativeMacWindowControls) { - return; - } void handleTitleBarWindowToggle(); }; diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index ed46e11..49c6660 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -333,21 +333,8 @@ export const AIChatPanel: React.FC = ({ } }, [activeProvider?.id]); - useEffect(() => { - if (activeProvider && dynamicModels.length > 0) { - const currentModels = activeProvider.models || []; - if (JSON.stringify(currentModels) !== JSON.stringify(dynamicModels)) { - try { - const Service = (window as any).go?.aiservice?.Service; - const payload = { ...activeProvider, models: dynamicModels }; - Service?.AISaveProvider?.(payload); - setActiveProvider(payload); - } catch (e) { - console.warn('Failed to cache models', e); - } - } - } - }, [activeProvider, dynamicModels]); + + // dynamicModels 仅在内存中使用,不再写回供应商配置,避免污染静态 models 列表 const fetchDynamicModels = useCallback(async () => { try { diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index cd5b63a..c9c00b3 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -34,7 +34,7 @@ const PROVIDER_PRESETS: ProviderPreset[] = [ { key: 'anthropic', label: 'Claude', icon: , desc: 'Claude Opus/Sonnet 4.6', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-sonnet-4-6', models: ['claude-opus-4-6', 'claude-sonnet-4-6'] }, { key: 'gemini', label: 'Gemini', icon: , desc: 'Gemini 3.1 / 2.5 系列', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: ['gemini-3.1-pro', 'gemini-2.5-flash', 'gemini-2.5-pro'] }, { key: 'volcengine', label: '火山引擎', icon: , desc: '火山方舟 / 豆包大模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: 'ep-xxxxxx', models: [] }, - { key: 'minimax', label: 'MiniMax', icon: , desc: 'abab6.5 / abab7 系列', color: '#e11d48', backendType: 'openai', defaultBaseUrl: 'https://api.minimax.chat/v1', defaultModel: 'abab7-chat-preview', models: ['abab7-chat-preview', 'abab6.5-chat', 'abab6.5g-chat'] }, + { key: 'minimax', label: 'MiniMax', icon: , desc: 'abab6.5 / abab7 系列', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-Text-01', models: ['MiniMax-Text-01', 'MiniMax-Text-01-vision', 'MiniMax-Text-01-search', 'MiniMax-Text-01-code', 'MiniMax-Text-01-web', 'MiniMax-Text-01-sql', 'MiniMax-Text-01-python', 'MiniMax-Text-01-math', 'MiniMax-Text-01-doc'] }, { key: 'ollama', label: 'Ollama', icon: , desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] }, { key: 'custom', label: '自定义', icon: , desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] }, ]; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index c50d662..7d2fbe5 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -2040,11 +2040,25 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { setCurrentDb(dbName); } + const editor = editorRef.current; const monaco = monacoRef.current; if (editor && monaco) { - let position = editor.getPosition(); const model = editor.getModel(); + const existingContent = editor.getValue?.() || ''; + + // runImmediately 模式下,如果编辑器内容已是待注入的 SQL(TabManager 创建时已传入), + // 跳过追加,直接选中全部内容并执行 + if (e.detail.runImmediately && existingContent.trim() === sqlText.trim()) { + if (model) { + const lineCount = model.getLineCount(); + const maxCol = model.getLineMaxColumn(lineCount); + editor.setSelection(new monaco.Range(1, 1, lineCount, maxCol)); + editor.focus(); + setTimeout(() => handleRun(), 500); + } + } else { + let position = editor.getPosition(); if (!position && model) { const lineCount = model.getLineCount(); const maxCol = model.getLineMaxColumn(lineCount); @@ -2081,6 +2095,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { setTimeout(() => handleRun(), 500); } } + } } else { setQuery((prev: string) => prev ? prev + '\n' + sqlText : sqlText); message.success('代码已追加'); diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 9709e83..4c8cb73 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -151,7 +151,7 @@ const TabManager: React.FC = () => { id: newTabId, type: 'query', title: '新建查询', - query: '', + query: sql, connectionId: resolvedConnId, dbName: resolvedDbName }); diff --git a/internal/ai/provider/anthropic.go b/internal/ai/provider/anthropic.go index 94312ec..1104680 100644 --- a/internal/ai/provider/anthropic.go +++ b/internal/ai/provider/anthropic.go @@ -72,13 +72,23 @@ func (p *AnthropicProvider) Validate() error { 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"` + 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"` + Tools []anthropicTool `json:"tools,omitempty"` +} + +// anthropicTool Anthropic 格式的工具定义 +type anthropicTool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema any `json:"input_schema"` } type anthropicMessage struct { @@ -86,9 +96,66 @@ type anthropicMessage struct { Content interface{} `json:"content"` } +// convertToolsToAnthropic 将 OpenAI 格式的 tools 转换为 Anthropic 格式 +func convertToolsToAnthropic(tools []ai.Tool) []anthropicTool { + if len(tools) == 0 { + return nil + } + result := make([]anthropicTool, 0, len(tools)) + for _, t := range tools { + result = append(result, anthropicTool{ + Name: t.Function.Name, + Description: t.Function.Description, + InputSchema: t.Function.Parameters, + }) + } + return result +} + func buildAnthropicMessages(reqMessages []ai.Message) []anthropicMessage { messages := make([]anthropicMessage, 0, len(reqMessages)) for _, m := range reqMessages { + // tool result 消息:转换为 Anthropic 的 tool_result content block + if m.Role == "tool" { + messages = append(messages, anthropicMessage{ + Role: "user", + Content: []map[string]interface{}{ + { + "type": "tool_result", + "tool_use_id": m.ToolCallID, + "content": m.Content, + }, + }, + }) + continue + } + + // assistant 带 tool_calls:转换为 Anthropic 的 tool_use content block + if m.Role == "assistant" && len(m.ToolCalls) > 0 { + var contentParts []map[string]interface{} + if m.Content != "" { + contentParts = append(contentParts, map[string]interface{}{ + "type": "text", + "text": m.Content, + }) + } + for _, tc := range m.ToolCalls { + var input interface{} + if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil { + input = map[string]interface{}{} + } + contentParts = append(contentParts, map[string]interface{}{ + "type": "tool_use", + "id": tc.ID, + "name": tc.Function.Name, + "input": input, + }) + } + messages = append(messages, anthropicMessage{Role: "assistant", Content: contentParts}) + continue + } + + // 图片消息 if len(m.Images) > 0 { var contentParts []map[string]interface{} for _, img := range m.Images { @@ -106,7 +173,7 @@ func buildAnthropicMessages(reqMessages []ai.Message) []anthropicMessage { } text := m.Content if text == "" { - text = "请描述和分析这张图片。" // 防止强 System Prompt 下模型仅看到空文本且忽略图片直接回复打招呼 + text = "请描述和分析这张图片。" } contentParts = append(contentParts, map[string]interface{}{ "type": "text", @@ -120,11 +187,19 @@ func buildAnthropicMessages(reqMessages []ai.Message) []anthropicMessage { return messages } +// --- 响应体类型 --- + +type anthropicContentBlock struct { + Type string `json:"type"` // "text" | "tool_use" + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` // tool_use + Name string `json:"name,omitempty"` // tool_use + Input json.RawMessage `json:"input,omitempty"` // tool_use +} + type anthropicResponse struct { - Content []struct { - Text string `json:"text"` - } `json:"content"` - Usage struct { + Content []anthropicContentBlock `json:"content"` + Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` } `json:"usage"` @@ -133,13 +208,20 @@ type anthropicResponse struct { } `json:"error,omitempty"` } +// 流式事件类型 type anthropicStreamEvent struct { - Type string `json:"type"` - Delta *struct { - Text string `json:"text"` + Type string `json:"type"` + Index int `json:"index,omitempty"` + ContentBlock *anthropicContentBlock `json:"content_block,omitempty"` + Delta *struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + PartialJSON string `json:"partial_json,omitempty"` } `json:"delta,omitempty"` } +// --- Chat 非流式 --- + func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) { if err := p.Validate(); err != nil { return nil, err @@ -163,6 +245,7 @@ func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C System: systemMsg, MaxTokens: maxTokens, Temperature: temperature, + Tools: convertToolsToAnthropic(req.Tools), } respBody, err := p.doRequest(ctx, body) @@ -182,8 +265,35 @@ func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C return nil, fmt.Errorf("Anthropic 返回空响应") } + // 解析响应中的 text 和 tool_use content blocks + var textContent string + var toolCalls []ai.ToolCall + for _, block := range result.Content { + switch block.Type { + case "text": + textContent += block.Text + case "tool_use": + argsStr := "{}" + if len(block.Input) > 0 { + argsStr = string(block.Input) + } + toolCalls = append(toolCalls, ai.ToolCall{ + ID: block.ID, + Type: "function", + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: block.Name, + Arguments: argsStr, + }, + }) + } + } + return &ai.ChatResponse{ - Content: result.Content[0].Text, + Content: textContent, + ToolCalls: toolCalls, TokensUsed: ai.TokenUsage{ PromptTokens: result.Usage.InputTokens, CompletionTokens: result.Usage.OutputTokens, @@ -192,6 +302,8 @@ func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C }, nil } +// --- ChatStream 流式 --- + func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error { if err := p.Validate(); err != nil { return err @@ -216,6 +328,7 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest, MaxTokens: maxTokens, Temperature: temperature, Stream: true, + Tools: convertToolsToAnthropic(req.Tools), } respBody, err := p.doRequest(ctx, body) @@ -224,6 +337,14 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest, } defer respBody.Close() + // 跟踪当前活跃的 tool_use blocks + type activeToolUse struct { + id string + name string + argsJSON strings.Builder + } + activeBlocks := make(map[int]*activeToolUse) // index -> block + scanner := bufio.NewScanner(respBody) for scanner.Scan() { line := scanner.Text() @@ -238,10 +359,54 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest, } switch event.Type { - case "content_block_delta": - if event.Delta != nil && event.Delta.Text != "" { - callback(ai.StreamChunk{Content: event.Delta.Text}) + case "content_block_start": + if event.ContentBlock != nil && event.ContentBlock.Type == "tool_use" { + activeBlocks[event.Index] = &activeToolUse{ + id: event.ContentBlock.ID, + name: event.ContentBlock.Name, + } } + + case "content_block_delta": + if event.Delta == nil { + continue + } + switch event.Delta.Type { + case "text_delta": + if event.Delta.Text != "" { + callback(ai.StreamChunk{Content: event.Delta.Text}) + } + case "input_json_delta": + if block, ok := activeBlocks[event.Index]; ok { + block.argsJSON.WriteString(event.Delta.PartialJSON) + } + } + + case "content_block_stop": + if block, ok := activeBlocks[event.Index]; ok { + argsStr := block.argsJSON.String() + if argsStr == "" { + argsStr = "{}" + } + // 产出完整的 tool call + callback(ai.StreamChunk{ + ToolCalls: []ai.ToolCall{ + { + ID: block.id, + Type: "function", + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: block.name, + Arguments: argsStr, + }, + }, + }, + }) + delete(activeBlocks, event.Index) + } + case "message_stop": callback(ai.StreamChunk{Done: true}) return nil @@ -252,6 +417,8 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest, return scanner.Err() } +// --- HTTP 请求 --- + func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) { jsonBody, err := json.Marshal(body) if err != nil { @@ -263,15 +430,6 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io 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) diff --git a/internal/ai/provider/openai.go b/internal/ai/provider/openai.go index 87d5214..5ff9f10 100644 --- a/internal/ai/provider/openai.go +++ b/internal/ai/provider/openai.go @@ -209,7 +209,17 @@ func (p *OpenAIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.Chat respBody, err := p.doRequest(ctx, body) if err != nil { - return nil, err + // 当带 tools 的请求返回 400 时,自动降级为不带 tools 的纯文本请求 + if len(req.Tools) > 0 && isHTTP400Error(err) { + fmt.Println("[OpenAI] 模型不支持 Function Calling,自动降级为纯文本模式") + body.Tools = nil + respBody, err = p.doRequest(ctx, body) + if err != nil { + return nil, err + } + } else { + return nil, err + } } defer respBody.Close() @@ -257,7 +267,17 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cal respBody, err := p.doRequest(ctx, body) if err != nil { - return err + // 当带 tools 的请求返回 400 时,自动降级为不带 tools 的纯文本请求 + if len(req.Tools) > 0 && isHTTP400Error(err) { + fmt.Println("[OpenAI] 模型不支持 Function Calling,自动降级为纯文本模式") + body.Tools = nil + respBody, err = p.doRequest(ctx, body) + if err != nil { + return err + } + } else { + return err + } } defer respBody.Close() @@ -366,14 +386,7 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, body interface{}) (io.Re 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 { @@ -408,3 +421,15 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, body interface{}) (io.Re return resp.Body, nil } + +// isHTTP400Error 检查错误是否为 HTTP 4xx 客户端错误(400/422 等), +// 通常表示模型不支持请求中的某些参数(如 tools/functions)。 +func isHTTP400Error(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "(HTTP 400)") || + strings.Contains(msg, "(HTTP 422)") || + strings.Contains(msg, "(HTTP 404)") +} diff --git a/internal/app/window_style_darwin.go b/internal/app/window_style_darwin.go index 5c1ea2b..41b2651 100644 --- a/internal/app/window_style_darwin.go +++ b/internal/app/window_style_darwin.go @@ -8,6 +8,9 @@ package app #import #import +static inline BOOL gonaviBoolYES() { return YES; } +static inline BOOL gonaviBoolNO() { return NO; } + static void gonaviSetWindowButtonsVisible(NSWindow *window, BOOL visible) { if (window == nil) { return; @@ -62,9 +65,9 @@ import "C" func setMacNativeWindowControls(enabled bool) { state := resolveMacNativeWindowControlState(enabled) - flag := C.BOOL(false) if state.ShowNativeButtons { - flag = C.BOOL(true) + C.gonaviApplyMacWindowStyle(C.gonaviBoolYES()) + } else { + C.gonaviApplyMacWindowStyle(C.gonaviBoolNO()) } - C.gonaviApplyMacWindowStyle(flag) } From 9f6d524e3dc79240348a2b297bcf5442d88074c3 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 26 Mar 2026 22:26:25 +0800 Subject: [PATCH 08/14] =?UTF-8?q?=F0=9F=90=9B=20fix(ai/provider):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Kimi=20=E4=B8=8E=20MiniMax=20=E4=BE=9B?= =?UTF-8?q?=E5=BA=94=E5=95=86=E5=85=BC=E5=AE=B9=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调整 Kimi 预设为 Anthropic 兼容入口并修正 Moonshot 域名回显 - 修复 Anthropic 请求地址归一化,确保聊天请求正确落到 /v1/messages - 修正 Kimi 模型列表与测试连接路由,固定使用 Moonshot /v1/models - 修正 MiniMax 默认模型与兼容模型集合,避免请求不存在的 /anthropic/v1/models - 为 MiniMax 健康检查改用最小化 messages 请求,并兼容旧模型名配置 - 补充 Kimi 与 MiniMax 供应商回归测试,更新需求追踪文档 --- frontend/package.json.md5 | 2 +- frontend/src/components/AIChatPanel.css | 12 + frontend/src/components/AIChatPanel.tsx | 58 ++- frontend/src/components/AISettingsModal.tsx | 101 +++-- .../src/components/ai/AIMessageBubble.tsx | 37 +- frontend/src/store.ts | 285 ++++++++----- frontend/wailsjs/go/aiservice/Service.d.ts | 8 + frontend/wailsjs/go/aiservice/Service.js | 16 + internal/ai/provider/anthropic.go | 22 +- internal/ai/provider/anthropic_test.go | 24 ++ internal/ai/provider/gemini.go | 3 +- internal/ai/provider/openai.go | 3 +- internal/ai/provider/openai_test.go | 37 +- internal/ai/service/service.go | 392 +++++++++++++++--- internal/ai/service/service_test.go | 78 ++++ 15 files changed, 848 insertions(+), 230 deletions(-) create mode 100644 internal/ai/provider/anthropic_test.go create mode 100644 internal/ai/service/service_test.go diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 3018db7..efbd2b6 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -dcb87159cf0f1f6f750d1c4870911d3f \ No newline at end of file +6ba85e4f456d2c0d230cab198c7dc02b \ No newline at end of file diff --git a/frontend/src/components/AIChatPanel.css b/frontend/src/components/AIChatPanel.css index 88978ec..5b935eb 100644 --- a/frontend/src/components/AIChatPanel.css +++ b/frontend/src/components/AIChatPanel.css @@ -483,3 +483,15 @@ @keyframes ai-spin-anim { to { transform: rotate(360deg); } } + +/* 面板/弹窗内部 toast 定位覆盖:从 fixed(视口顶部)改为 absolute(容器内部顶部) */ +.ai-chat-panel .ant-message, +.ai-settings-body .ant-message { + position: absolute !important; + top: 16px !important; + left: 50% !important; + transform: translateX(-50%) !important; + right: auto !important; + width: max-content; + z-index: 100; +} diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 49c6660..6f2702e 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -1,12 +1,12 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; -import { useStore } from '../store'; +import { useStore, loadAISessionsFromBackend, loadAISessionFromBackend } from '../store'; import { EventsOn, EventsOff } from '../../wailsjs/runtime'; import { DBGetDatabases, DBGetTables } from '../../wailsjs/go/app/App'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { AIChatMessage, AIToolCall } from '../types'; import { DownOutlined } from '@ant-design/icons'; -import { message } from 'antd'; +import { message as antdMessage } from 'antd'; import './AIChatPanel.css'; import { AIChatHeader } from './ai/AIChatHeader'; @@ -224,6 +224,9 @@ export const AIChatPanel: React.FC = ({ const panelRef = useRef(null); // 面板 DOM ref,用于拖拽时直接操作宽度 const dragWidthRef = useRef(0); // 拖拽过程中的实时宽度(不触发 React 重渲染) + // 面板内部 toast 通知(不在屏幕顶部,而在面板容器内显示) + const [messageApi, messageContextHolder] = antdMessage.useMessage({ getContainer: () => panelRef.current || document.body }); + const aiChatHistory = useStore(state => state.aiChatHistory); const aiActiveSessionId = useStore(state => state.aiActiveSessionId); const createNewAISession = useStore(state => state.createNewAISession); @@ -280,6 +283,21 @@ export const AIChatPanel: React.FC = ({ }, [aiActiveSessionId, createNewAISession]); const sid = aiActiveSessionId || 'session-fallback'; + + // 面板首次可见时从后端加载会话列表 + const sessionsLoadedRef = useRef(false); + useEffect(() => { + if (!aiPanelVisible || sessionsLoadedRef.current) return; + sessionsLoadedRef.current = true; + loadAISessionsFromBackend(); + }, [aiPanelVisible]); + + // 切换会话时按需从后端加载消息 + useEffect(() => { + if (sid && sid !== 'session-fallback') { + loadAISessionFromBackend(sid); + } + }, [sid]); const messages = aiChatHistory[sid] || []; const getConnectionName = useCallback(() => { @@ -314,6 +332,17 @@ export const AIChatPanel: React.FC = ({ useEffect(() => { loadActiveProvider(); }, [loadActiveProvider]); + // 监听供应商配置变更(来自设置面板的删除/新增/切换操作),重新加载 active provider 并清空已缓存的模型 + useEffect(() => { + const handler = () => { + setDynamicModels([]); + activeProviderIdRef.current = null; + loadActiveProvider(); + }; + window.addEventListener('gonavi:ai:provider-changed', handler); + return () => window.removeEventListener('gonavi:ai:provider-changed', handler); + }, [loadActiveProvider]); + const handleModelChange = async (val: string) => { if (!activeProvider) return; try { @@ -331,7 +360,12 @@ export const AIChatPanel: React.FC = ({ setDynamicModels([]); activeProviderIdRef.current = activeProvider.id; } - }, [activeProvider?.id]); + // 供应商被删除后 activeProvider 变为 null,此时也必须清空残留模型 + if (!activeProvider) { + setDynamicModels([]); + activeProviderIdRef.current = null; + } + }, [activeProvider?.id, activeProvider]); // dynamicModels 仅在内存中使用,不再写回供应商配置,避免污染静态 models 列表 @@ -346,11 +380,11 @@ export const AIChatPanel: React.FC = ({ const sortedModels = [...result.models].sort((a, b) => a.localeCompare(b)); setDynamicModels(sortedModels); } else if (result && !result.success) { - message.warning(result.error || '获取模型列表失败,可手动输入模型名称'); + messageApi.warning(result.error || '获取模型列表失败,可手动输入模型名称'); } } catch (e: any) { console.warn('Failed to fetch models', e); - message.warning('获取模型列表失败: ' + (e?.message || '未知错误')); + messageApi.warning('获取模型列表失败: ' + (e?.message || '未知错误')); } finally { setLoadingModels(false); } @@ -993,6 +1027,17 @@ SELECT * FROM users WHERE status = 1; const handleSend = useCallback(async () => { const text = input.trim(); if ((!text && draftImages.length === 0) || sending) return; + + // 前置校验:必须配置供应商且选择模型后才能发送 + if (!activeProvider) { + messageApi.warning('请先在 AI 设置中配置供应商'); + return; + } + if (!activeProvider.model || !activeProvider.model.trim()) { + messageApi.warning('请先选择模型 ID(点击工具栏的模型下拉框选择)'); + return; + } + toolCallRoundRef.current = 0; // 重置工具调用轮次计数 nudgeCountRef.current = 0; // 重置催促计数 @@ -1083,7 +1128,7 @@ SELECT * FROM users WHERE status = 1; addAIChatMessage(sid, { id: genId(), role: 'assistant', content: `❌ 发送失败: ${cleanE2}`, rawError: cleanE2 !== rawE2 ? rawE2 : undefined, timestamp: Date.now() }); setSending(false); } - }, [input, draftImages, sending, messages, addAIChatMessage, sid]); + }, [input, draftImages, sending, messages, addAIChatMessage, sid, activeProvider]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -1213,6 +1258,7 @@ SELECT * FROM users WHERE status = 1; return (
+ {messageContextHolder}
{isResizing && panelRect.current && createPortal( diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index c9c00b3..83378d9 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { Modal, Button, Input, Select, Form, message, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd'; import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons'; import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types'; @@ -26,21 +26,40 @@ interface ProviderPreset { } const PROVIDER_PRESETS: ProviderPreset[] = [ - { key: 'openai', label: 'OpenAI', icon: , desc: 'GPT-5.4 / 5.3 系列', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.4', models: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-5.3'] }, - { key: 'deepseek', label: 'DeepSeek', icon: , desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: ['deepseek-chat', 'deepseek-reasoner'] }, - { key: 'qwen', label: '通义千问', icon: , desc: 'Qwen3.5 / Qwen3 系列', color: '#6366f1', backendType: 'openai', defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', defaultModel: 'qwen3.5-max', models: ['qwen3.5-max', 'qwen3-plus', 'qwen3-turbo'] }, - { key: 'zhipu', label: '智谱 GLM', icon: , desc: 'GLM-5 / GLM-5-Turbo', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-5', models: ['glm-5', 'glm-5-turbo', 'glm-4.7-flash'] }, - { key: 'moonshot', label: 'Kimi', icon: , desc: 'Kimi K2.5 系列', color: '#0d9488', backendType: 'openai', defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModel: 'kimi-k2.5', models: ['kimi-k2.5', 'kimi-k2-turbo-preview', 'kimi-k2-thinking'] }, - { key: 'anthropic', label: 'Claude', icon: , desc: 'Claude Opus/Sonnet 4.6', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-sonnet-4-6', models: ['claude-opus-4-6', 'claude-sonnet-4-6'] }, - { key: 'gemini', label: 'Gemini', icon: , desc: 'Gemini 3.1 / 2.5 系列', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: ['gemini-3.1-pro', 'gemini-2.5-flash', 'gemini-2.5-pro'] }, + { key: 'openai', label: 'OpenAI', icon: , desc: 'GPT-5.4 / 5.3 系列', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', models: [] }, + { key: 'deepseek', label: 'DeepSeek', icon: , desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] }, + { key: 'qwen', label: '通义千问', icon: , desc: 'Qwen3.5 / Qwen3 系列', color: '#6366f1', backendType: 'openai', defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', defaultModel: 'qwen-max', models: [] }, + { key: 'zhipu', label: '智谱 GLM', icon: , desc: 'GLM-5 / GLM-5-Turbo', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-4', models: [] }, + { key: 'moonshot', label: 'Kimi', icon: , desc: 'Kimi K2.5 (Anthropic 兼容)', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] }, + { key: 'anthropic', label: 'Claude', icon: , desc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] }, + { key: 'gemini', label: 'Gemini', icon: , desc: 'Gemini 3.1 / 2.5 系列', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: [] }, { key: 'volcengine', label: '火山引擎', icon: , desc: '火山方舟 / 豆包大模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: 'ep-xxxxxx', models: [] }, - { key: 'minimax', label: 'MiniMax', icon: , desc: 'abab6.5 / abab7 系列', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-Text-01', models: ['MiniMax-Text-01', 'MiniMax-Text-01-vision', 'MiniMax-Text-01-search', 'MiniMax-Text-01-code', 'MiniMax-Text-01-web', 'MiniMax-Text-01-sql', 'MiniMax-Text-01-python', 'MiniMax-Text-01-math', 'MiniMax-Text-01-doc'] }, + { key: 'minimax', label: 'MiniMax', icon: , desc: 'M2.7 / M2.5 系列 (Anthropic 兼容)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M2.7', models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2'] }, { key: 'ollama', label: 'Ollama', icon: , desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] }, { key: 'custom', label: '自定义', icon: , desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] }, ]; const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1]; +const getProviderHostname = (raw?: string): string => { + if (!raw) return ''; + try { + return new URL(raw).hostname.toLowerCase(); + } catch { + return ''; + } +}; + +const matchProviderPreset = (provider: Pick): ProviderPreset => { + const host = getProviderHostname(provider.baseUrl); + if (host.endsWith('moonshot.cn')) { + return findPreset('moonshot'); + } + return PROVIDER_PRESETS.find(pr => pr.backendType === provider.type && host !== '' && host === getProviderHostname(pr.defaultBaseUrl)) + || PROVIDER_PRESETS.find(pr => pr.backendType === provider.type) + || findPreset('custom'); +}; + const SAFETY_OPTIONS: { label: string; value: AISafetyLevel; desc: string; color: string; icon: string }[] = [ { label: '只读模式', value: 'readonly', desc: 'AI 仅可执行 SELECT 等查询操作,最安全', color: '#22c55e', icon: '🔒' }, { label: '读写模式', value: 'readwrite', desc: 'AI 可执行 INSERT/UPDATE/DELETE,危险操作需二次确认', color: '#f59e0b', icon: '⚠️' }, @@ -65,6 +84,10 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const [builtinPrompts, setBuiltinPrompts] = useState>({}); const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers'); const [form] = Form.useForm(); + const modalBodyRef = useRef(null); + + // Modal 内部 toast 通知 + const [messageApi, messageContextHolder] = antdMessage.useMessage({ getContainer: () => modalBodyRef.current || document.body }); // 主题色 const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'; @@ -108,31 +131,45 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const newProvider: AIProviderConfig = { id: '', type: preset.backendType, name: '', apiKey: '', baseUrl: preset.defaultBaseUrl, model: preset.defaultModel, - maxTokens: 4096, temperature: 0.7, + models: [], maxTokens: 4096, temperature: 0.7, }; setEditingProvider({ ...newProvider, presetKey: 'openai' } as any); setIsEditing(true); setTestStatus('idle'); + form.resetFields(); form.setFieldsValue({ ...newProvider, presetKey: 'openai', apiFormat: 'openai' }); }; const handleEditProvider = (p: AIProviderConfig) => { // 尝试根据 baseUrl 和 type 推断 preset - const matchedPreset = PROVIDER_PRESETS.find(pr => pr.backendType === p.type && p.baseUrl?.includes(new URL(pr.defaultBaseUrl || 'http://x').hostname)) - || PROVIDER_PRESETS.find(pr => pr.backendType === p.type) - || findPreset('custom'); + const matchedPreset = matchProviderPreset(p); setEditingProvider(p); setIsEditing(true); setTestStatus('idle'); - form.setFieldsValue({ ...p, presetKey: matchedPreset.key, apiFormat: p.apiFormat || 'openai' }); + form.resetFields(); + form.setFieldsValue({ ...p, type: matchedPreset.backendType, models: p.models || [], presetKey: matchedPreset.key, apiFormat: p.apiFormat || 'openai' }); }; const handleDeleteProvider = async (id: string) => { try { const Service = (window as any).go?.aiservice?.Service; + const wasActive = id === activeProviderId; await Service?.AIDeleteProvider?.(id); - void message.success('已删除'); void loadConfig(); - } catch (e: any) { void message.error(e?.message || '删除失败'); } + await loadConfig(); + // 合并提示:删除的是当前激活的供应商时,附带自动切换信息 + if (wasActive) { + const newProviders: any[] = await Service?.AIGetProviders?.() || []; + if (newProviders.length > 0) { + const newActiveName = newProviders[0]?.name || '下一个供应商'; + void messageApi.success(`已删除,自动切换到「${newActiveName}」`); + } else { + void messageApi.success('已删除'); + } + } else { + void messageApi.success('已删除'); + } + window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed')); + } catch (e: any) { void messageApi.error(e?.message || '删除失败'); } }; const handleSaveProvider = async () => { @@ -150,20 +187,24 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo // 内置供应商自动使用 preset label 作为名称 const finalName = isCustomLike ? (values.name || preset.label) : preset.label; + const finalBaseUrl = values.baseUrl || preset.defaultBaseUrl; + const payload = { ...editingProvider, ...values, name: finalName, model: finalModel, models: resolvedModels, + baseUrl: finalBaseUrl, apiFormat: values.apiFormat || 'openai', }; // 后端 AISaveProvider 统一处理新增和更新,返回 void,失败抛异常 await Service?.AISaveProvider?.(payload); - void message.success('已保存'); setIsEditing(false); setEditingProvider(null); void loadConfig(); + void messageApi.success('已保存'); setIsEditing(false); setEditingProvider(null); void loadConfig(); + window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed')); } catch (e: any) { if (e?.errorFields) { /* antd form validation error, ignore */ } - else void message.error(e?.message || '保存失败'); + else void messageApi.error(e?.message || '保存失败'); } finally { setLoading(false); } }; @@ -171,8 +212,9 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo try { const Service = (window as any).go?.aiservice?.Service; await Service?.AISetActiveProvider?.(id); - setActiveProviderId(id); void message.success('已切换'); - } catch (e: any) { void message.error(e?.message || '切换失败'); } + setActiveProviderId(id); void messageApi.success('已切换'); + window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed')); + } catch (e: any) { void messageApi.error(e?.message || '切换失败'); } }; const handleSafetyChange = async (level: AISafetyLevel) => { @@ -197,10 +239,12 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo setLoading(true); setTestStatus('idle'); const Service = (window as any).go?.aiservice?.Service; - const res = await Service?.AITestProvider?.({ ...values, maxTokens: Number(values.maxTokens) || 4096, temperature: Number(values.temperature) ?? 0.7 }); - if (res?.success) { setTestStatus('success'); void message.success('连接成功'); } - else { setTestStatus('error'); void message.error(`测试失败: ${res?.message || '未知错误'}`); } - } catch (e: any) { setTestStatus('error'); void message.error(e?.message || '测试失败'); } + const preset = findPreset(values.presetKey || 'openai'); + const finalBaseUrl = values.baseUrl || preset.defaultBaseUrl; + const res = await Service?.AITestProvider?.({ ...values, baseUrl: finalBaseUrl, maxTokens: Number(values.maxTokens) || 4096, temperature: Number(values.temperature) ?? 0.7 }); + if (res?.success) { setTestStatus('success'); void messageApi.success('连接成功'); } + else { setTestStatus('error'); void messageApi.error(`测试失败: ${res?.message || '未知错误'}`); } + } catch (e: any) { setTestStatus('error'); void messageApi.error(e?.message || '测试失败'); } finally { setLoading(false); } }; @@ -238,9 +282,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo
)} {providers.map(p => { - const matchedPreset = PROVIDER_PRESETS.find(pr => pr.backendType === p.type && p.baseUrl?.includes(new URL(pr.defaultBaseUrl || 'http://x').hostname)) - || PROVIDER_PRESETS.find(pr => pr.backendType === p.type) - || findPreset('custom'); + const matchedPreset = matchProviderPreset(p); const isActive = p.id === activeProviderId; return (
handleSetActive(p.id)} style={{ @@ -605,7 +647,8 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo body: { paddingTop: 8, height: 620, overflow: 'hidden' }, }} > -
+
+ {messageContextHolder}
设置导航
diff --git a/frontend/src/components/ai/AIMessageBubble.tsx b/frontend/src/components/ai/AIMessageBubble.tsx index 453d1b6..93348b5 100644 --- a/frontend/src/components/ai/AIMessageBubble.tsx +++ b/frontend/src/components/ai/AIMessageBubble.tsx @@ -544,7 +544,28 @@ export const AIMessageBubble: React.FC = React.memo(({ msg const [isCopied, setIsCopied] = useState(false); const isUser = msg.role === 'user'; - const displayContent = msg.content; + // 从 content 中提取 ... 标签内容(部分模型如 MiniMax、DeepSeek 会以文本形式返回思考过程) + const { displayContent, parsedThinking } = React.useMemo(() => { + const content = msg.content || ''; + // 优先使用后端已结构化的 thinking 字段(如 Claude API 原生 thinking) + if (msg.thinking) { + return { displayContent: content, parsedThinking: msg.thinking }; + } + // 尝试从 content 中提取 ... 标签 + const thinkRegex = /([\s\S]*?)(?:<\/think>|$)/g; + let thinkParts: string[] = []; + let cleanContent = content; + let match; + while ((match = thinkRegex.exec(content)) !== null) { + thinkParts.push(match[1].trim()); + } + if (thinkParts.length > 0) { + // 移除所有 ... 标签(含未闭合的) + cleanContent = content.replace(/[\s\S]*?(?:<\/think>|$)/g, '').trim(); + return { displayContent: cleanContent, parsedThinking: thinkParts.join('\n\n') }; + } + return { displayContent: content, parsedThinking: '' }; + }, [msg.content, msg.thinking]); const isTypingThinking = !!(msg.loading && msg.phase === 'thinking'); if (msg.role === 'tool') return null; @@ -568,11 +589,11 @@ export const AIMessageBubble: React.FC = React.memo(({ msg
{/* 即使在波纹过渡态,如果有 thinking / tool_calls 也要显示出来,只是把它们压在波纹下面 */} -
0) ? 12 : 0 }}> - {!isUser && msg.thinking && ( +
0) ? 12 : 0 }}> + {!isUser && parsedThinking && ( = React.memo(({ msg
)} {/* 可折叠思考过程 */} - {!isUser && msg.thinking && ( + {!isUser && parsedThinking && ( > = {}; + +function _debouncedPersistSession(sessionId: string) { + if (_persistTimers[sessionId]) clearTimeout(_persistTimers[sessionId]); + _persistTimers[sessionId] = setTimeout(() => { + delete _persistTimers[sessionId]; + const state = useStore.getState(); + const messages = state.aiChatHistory[sessionId]; + const sessionMeta = state.aiChatSessions.find(s => s.id === sessionId); + if (!messages && !sessionMeta) return; // session 已被删除,跳过 + const title = sessionMeta?.title || '新的对话'; + const updatedAt = sessionMeta?.updatedAt || Date.now(); + const messagesJSON = JSON.stringify(messages || []); + const Service = (window as any).go?.aiservice?.Service; + Service?.AISaveSession?.(sessionId, title, updatedAt, messagesJSON).catch((e: any) => { + console.error('[AI Session Persist] 持久化失败:', sessionId, e); + }); + }, 2000); +} + +/** 从后端加载会话列表(仅元数据,不含消息体) */ +export async function loadAISessionsFromBackend(): Promise<{ id: string; title: string; updatedAt: number }[]> { + const Service = (window as any).go?.aiservice?.Service; + if (!Service?.AIGetSessions) return []; + try { + const sessions = await Service.AIGetSessions(); + if (Array.isArray(sessions)) { + useStore.setState({ aiChatSessions: sessions }); + return sessions; + } + } catch (e) { + console.error('[AI Session] 加载会话列表失败:', e); + } + return []; +} + +/** 从后端加载指定会话的消息数据到内存 */ +export async function loadAISessionFromBackend(sessionId: string): Promise { + const state = useStore.getState(); + // 如果内存中已有消息,跳过重复加载 + if (state.aiChatHistory[sessionId]?.length > 0) return true; + + const Service = (window as any).go?.aiservice?.Service; + if (!Service?.AILoadSession) return false; + try { + const result = await Service.AILoadSession(sessionId); + if (result?.success) { + let messages = result.messages; + // messages 可能是 JSON string 或已解析的数组 + if (typeof messages === 'string') { + try { messages = JSON.parse(messages); } catch { messages = []; } + } + if (Array.isArray(messages)) { + useStore.setState((prev) => ({ + aiChatHistory: { ...prev.aiChatHistory, [sessionId]: messages }, + })); + return true; + } + } + } catch (e) { + console.error('[AI Session] 加载会话消息失败:', sessionId, e); + } + return false; +} + export const useStore = create()( persist( (set) => ({ @@ -986,99 +1054,123 @@ export const useStore = create()( // 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 messages = state.aiChatHistory[sessionId]; - if (!messages) return state; - // 🔧 性能优化:用 findIndex + 定点替换代替全量 map,长对话场景下从 O(n) 降至 O(1) - const idx = messages.findIndex(m => m.id === messageId); - if (idx < 0) return state; - const newMessages = [...messages]; - newMessages[idx] = { ...newMessages[idx], ...updates }; - const history = { ...state.aiChatHistory, [sessionId]: newMessages }; - // 仅当非纯 content 追加时才重排 session 顺序(性能优化:流式打字时跳过) - const isContentOnlyUpdate = Object.keys(updates).length === 1 && 'content' in updates; - if (!isContentOnlyUpdate) { - 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 }; - } - return { aiChatHistory: history }; - }), - 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 }; - }), - replaceAIChatHistory: (sessionId, messages) => set((state) => { - const history = { ...state.aiChatHistory }; - history[sessionId] = messages; - 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 }; - }), + 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) { + 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 }; + }); + // 异步持久化到文件(fire-and-forget,防抖由外层控制) + _debouncedPersistSession(sessionId); + }, + updateAIChatMessage: (sessionId, messageId, updates) => { + set((state) => { + const messages = state.aiChatHistory[sessionId]; + if (!messages) return state; + const idx = messages.findIndex(m => m.id === messageId); + if (idx < 0) return state; + const newMessages = [...messages]; + newMessages[idx] = { ...newMessages[idx], ...updates }; + const history = { ...state.aiChatHistory, [sessionId]: newMessages }; + const isContentOnlyUpdate = Object.keys(updates).length === 1 && 'content' in updates; + if (!isContentOnlyUpdate) { + 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 }; + } + return { aiChatHistory: history }; + }); + // 流式打字高频调用,防抖 2 秒后才写磁盘 + _debouncedPersistSession(sessionId); + }, + deleteAIChatMessage: (sessionId, messageId) => { + set((state) => { + const history = { ...state.aiChatHistory }; + if (history[sessionId]) { + history[sessionId] = history[sessionId].filter(m => m.id !== messageId); + } + return { aiChatHistory: history }; + }); + _debouncedPersistSession(sessionId); + }, + 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 }; + }); + _debouncedPersistSession(sessionId); + }, + clearAIChatHistory: (sessionId) => { + set((state) => { + const history = { ...state.aiChatHistory }; + delete history[sessionId]; + return { aiChatHistory: history }; + }); + _debouncedPersistSession(sessionId); + }, + replaceAIChatHistory: (sessionId, messages) => { + set((state) => { + const history = { ...state.aiChatHistory }; + history[sessionId] = messages; + return { aiChatHistory: history }; + }); + _debouncedPersistSession(sessionId); + }, + 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 }; + }); + // 删除文件 + const Service = (window as any).go?.aiservice?.Service; + Service?.AIDeleteSession?.(sessionId).catch(() => {}); + }, createNewAISession: () => set(() => { const newId = `session-${Date.now()}`; return { aiActiveSessionId: newId }; }), setAIActiveSessionId: (sessionId) => set({ aiActiveSessionId: sessionId }), - updateAISessionTitle: (sessionId, title) => set((state) => { - const newSessions = [...state.aiChatSessions]; - const session = newSessions.find(s => s.id === sessionId); - if (session) { - session.title = title; - } - return { aiChatSessions: newSessions }; - }), + updateAISessionTitle: (sessionId, title) => { + set((state) => { + const newSessions = [...state.aiChatSessions]; + const session = newSessions.find(s => s.id === sessionId); + if (session) { + session.title = title; + } + return { aiChatSessions: newSessions }; + }); + _debouncedPersistSession(sessionId); + }, addAIContext: (connectionKey, context) => set((state) => { const contexts = state.aiContexts[connectionKey] || []; if (contexts.find(c => c.dbName === context.dbName && c.tableName === context.tableName)) { @@ -1173,8 +1265,9 @@ export const useStore = create()( shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions), tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount), - aiChatHistory: (state.aiChatHistory && typeof state.aiChatHistory === 'object') ? state.aiChatHistory : {}, - aiChatSessions: Array.isArray(state.aiChatSessions) ? state.aiChatSessions : [], + // AI 会话数据不再从 localStorage 恢复,改为从后端文件加载 + aiChatHistory: {}, + aiChatSessions: [], }; }, partialize: (state) => ({ @@ -1200,17 +1293,7 @@ export const useStore = create()( windowState: state.windowState, sidebarWidth: state.sidebarWidth, - // 只持久化最近 20 个会话的聊天记录,防止 localStorage 膨胀 - aiChatHistory: (() => { - const MAX_PERSIST_SESSIONS = 20; - const recentIds = new Set(state.aiChatSessions.slice(0, MAX_PERSIST_SESSIONS).map(s => s.id)); - const trimmed: Record = {}; - for (const id of recentIds) { - if (state.aiChatHistory[id]) trimmed[id] = state.aiChatHistory[id]; - } - return trimmed; - })(), - aiChatSessions: state.aiChatSessions.slice(0, 50), + // AI 会话数据已迁移到后端文件持久化(~/.gonavi/sessions/),不再写入 localStorage }), // Don't persist logs } ) diff --git a/frontend/wailsjs/go/aiservice/Service.d.ts b/frontend/wailsjs/go/aiservice/Service.d.ts index 6ffc07a..52dc1a6 100755 --- a/frontend/wailsjs/go/aiservice/Service.d.ts +++ b/frontend/wailsjs/go/aiservice/Service.d.ts @@ -13,6 +13,8 @@ export function AICheckSQL(arg1:string):Promise; export function AIDeleteProvider(arg1:string):Promise; +export function AIDeleteSession(arg1:string):Promise; + export function AIGetActiveProvider():Promise; export function AIGetBuiltinPrompts():Promise>; @@ -23,10 +25,16 @@ export function AIGetProviders():Promise>; export function AIGetSafetyLevel():Promise; +export function AIGetSessions():Promise>>; + export function AIListModels():Promise>; +export function AILoadSession(arg1:string):Promise>; + export function AISaveProvider(arg1:ai.ProviderConfig):Promise; +export function AISaveSession(arg1:string,arg2:string,arg3:number,arg4:string):Promise; + export function AISetActiveProvider(arg1:string):Promise; export function AISetContextLevel(arg1:string):Promise; diff --git a/frontend/wailsjs/go/aiservice/Service.js b/frontend/wailsjs/go/aiservice/Service.js index 7f5de4a..acebb37 100755 --- a/frontend/wailsjs/go/aiservice/Service.js +++ b/frontend/wailsjs/go/aiservice/Service.js @@ -22,6 +22,10 @@ export function AIDeleteProvider(arg1) { return window['go']['aiservice']['Service']['AIDeleteProvider'](arg1); } +export function AIDeleteSession(arg1) { + return window['go']['aiservice']['Service']['AIDeleteSession'](arg1); +} + export function AIGetActiveProvider() { return window['go']['aiservice']['Service']['AIGetActiveProvider'](); } @@ -42,14 +46,26 @@ export function AIGetSafetyLevel() { return window['go']['aiservice']['Service']['AIGetSafetyLevel'](); } +export function AIGetSessions() { + return window['go']['aiservice']['Service']['AIGetSessions'](); +} + export function AIListModels() { return window['go']['aiservice']['Service']['AIListModels'](); } +export function AILoadSession(arg1) { + return window['go']['aiservice']['Service']['AILoadSession'](arg1); +} + export function AISaveProvider(arg1) { return window['go']['aiservice']['Service']['AISaveProvider'](arg1); } +export function AISaveSession(arg1, arg2, arg3, arg4) { + return window['go']['aiservice']['Service']['AISaveSession'](arg1, arg2, arg3, arg4); +} + export function AISetActiveProvider(arg1) { return window['go']['aiservice']['Service']['AISetActiveProvider'](arg1); } diff --git a/internal/ai/provider/anthropic.go b/internal/ai/provider/anthropic.go index 1104680..1733222 100644 --- a/internal/ai/provider/anthropic.go +++ b/internal/ai/provider/anthropic.go @@ -15,10 +15,23 @@ import ( const ( defaultAnthropicBaseURL = "https://api.anthropic.com" - defaultAnthropicModel = "claude-3-5-sonnet-20241022" anthropicAPIVersion = "2023-06-01" ) +func normalizeAnthropicMessagesURL(baseURL string) string { + url := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if url == "" { + url = defaultAnthropicBaseURL + } + if strings.HasSuffix(url, "/messages") { + return url + } + if strings.HasSuffix(url, "/v1") { + return url + "/messages" + } + return url + "/v1/messages" +} + // AnthropicProvider 实现 Anthropic Claude API 的 Provider type AnthropicProvider struct { config ai.ProviderConfig @@ -34,7 +47,7 @@ func NewAnthropicProvider(config ai.ProviderConfig) (Provider, error) { } model := strings.TrimSpace(config.Model) if model == "" { - model = defaultAnthropicModel + return nil, fmt.Errorf("模型 ID 不能为空,请在设置中选择或输入模型") } maxTokens := config.MaxTokens if maxTokens <= 0 { @@ -425,10 +438,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io return nil, fmt.Errorf("序列化请求失败: %w", err) } - url := p.baseURL + "/v1/messages" - if strings.HasSuffix(p.baseURL, "/v1") { - url = p.baseURL + "/messages" - } + url := normalizeAnthropicMessagesURL(p.baseURL) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody)) if err != nil { diff --git a/internal/ai/provider/anthropic_test.go b/internal/ai/provider/anthropic_test.go new file mode 100644 index 0000000..3c119f2 --- /dev/null +++ b/internal/ai/provider/anthropic_test.go @@ -0,0 +1,24 @@ +package provider + +import "testing" + +func TestNormalizeAnthropicMessagesURL_AppendsMessagesSuffix(t *testing.T) { + url := normalizeAnthropicMessagesURL("https://api.anthropic.com") + if url != "https://api.anthropic.com/v1/messages" { + t.Fatalf("expected normalized anthropic messages url, got %q", url) + } +} + +func TestNormalizeAnthropicMessagesURL_UsesMoonshotAnthropicMessagesEndpoint(t *testing.T) { + url := normalizeAnthropicMessagesURL("https://api.moonshot.cn/anthropic") + if url != "https://api.moonshot.cn/anthropic/v1/messages" { + t.Fatalf("expected moonshot anthropic messages url, got %q", url) + } +} + +func TestNormalizeAnthropicMessagesURL_PreservesExplicitMessagesPath(t *testing.T) { + url := normalizeAnthropicMessagesURL("https://api.moonshot.cn/anthropic/v1/messages") + if url != "https://api.moonshot.cn/anthropic/v1/messages" { + t.Fatalf("expected explicit messages path to be preserved, got %q", url) + } +} diff --git a/internal/ai/provider/gemini.go b/internal/ai/provider/gemini.go index b4cf910..ebadde9 100644 --- a/internal/ai/provider/gemini.go +++ b/internal/ai/provider/gemini.go @@ -15,7 +15,6 @@ import ( const ( defaultGeminiBaseURL = "https://generativelanguage.googleapis.com" - defaultGeminiModel = "gemini-2.0-flash" ) // GeminiProvider 实现 Google Gemini API 的 Provider @@ -33,7 +32,7 @@ func NewGeminiProvider(config ai.ProviderConfig) (Provider, error) { } model := strings.TrimSpace(config.Model) if model == "" { - model = defaultGeminiModel + return nil, fmt.Errorf("模型 ID 不能为空,请在设置中选择或输入模型") } maxTokens := config.MaxTokens if maxTokens <= 0 { diff --git a/internal/ai/provider/openai.go b/internal/ai/provider/openai.go index 5ff9f10..1e86caf 100644 --- a/internal/ai/provider/openai.go +++ b/internal/ai/provider/openai.go @@ -16,7 +16,6 @@ import ( const ( defaultOpenAIBaseURL = "https://api.openai.com/v1" - defaultOpenAIModel = "gpt-4o" defaultOpenAIMaxTokens = 4096 defaultOpenAITemperature = 0.7 openAIHTTPTimeout = 120 * time.Second @@ -41,7 +40,7 @@ func NewOpenAIProvider(config ai.ProviderConfig) (Provider, error) { } model := strings.TrimSpace(config.Model) if model == "" { - model = defaultOpenAIModel + return nil, fmt.Errorf("模型 ID 不能为空,请在设置中选择或输入模型") } maxTokens := config.MaxTokens if maxTokens <= 0 { diff --git a/internal/ai/provider/openai_test.go b/internal/ai/provider/openai_test.go index 94671a4..c200178 100644 --- a/internal/ai/provider/openai_test.go +++ b/internal/ai/provider/openai_test.go @@ -28,18 +28,24 @@ func TestOpenAIProvider_Validate_Valid(t *testing.T) { } func TestOpenAIProvider_Name_Custom(t *testing.T) { - p, _ := NewOpenAIProvider(ai.ProviderConfig{ - Type: "openai", Name: "My OpenAI", APIKey: "sk-test", + p, err := NewOpenAIProvider(ai.ProviderConfig{ + Type: "openai", Name: "My OpenAI", APIKey: "sk-test", Model: "gpt-4o", }) + if err != nil { + t.Fatalf("unexpected constructor error: %v", err) + } 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", + p, err := NewOpenAIProvider(ai.ProviderConfig{ + Type: "openai", APIKey: "sk-test", Model: "gpt-4o", }) + if err != nil { + t.Fatalf("unexpected constructor error: %v", err) + } if p.Name() != "OpenAI" { t.Fatalf("expected default name 'OpenAI', got '%s'", p.Name()) } @@ -56,29 +62,34 @@ func TestOpenAIProvider_DefaultBaseURL(t *testing.T) { } func TestOpenAIProvider_CustomBaseURL(t *testing.T) { - p, _ := NewOpenAIProvider(ai.ProviderConfig{ - Type: "openai", APIKey: "sk-test", BaseURL: "https://my-proxy.com/v1", + p, err := NewOpenAIProvider(ai.ProviderConfig{ + Type: "openai", APIKey: "sk-test", BaseURL: "https://my-proxy.com/v1", Model: "gpt-4o", }) + if err != nil { + t.Fatalf("unexpected constructor error: %v", err) + } 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{ +func TestOpenAIProvider_RejectsMissingModel(t *testing.T) { + _, err := 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) + if err == nil { + t.Fatal("expected constructor error for missing model") } } func TestOpenAIProvider_DefaultMaxTokens(t *testing.T) { - p, _ := NewOpenAIProvider(ai.ProviderConfig{ - Type: "openai", APIKey: "sk-test", + p, err := NewOpenAIProvider(ai.ProviderConfig{ + Type: "openai", APIKey: "sk-test", Model: "gpt-4o", }) + if err != nil { + t.Fatalf("unexpected constructor error: %v", err) + } 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/service/service.go b/internal/ai/service/service.go index addab29..bf21638 100644 --- a/internal/ai/service/service.go +++ b/internal/ai/service/service.go @@ -35,6 +35,16 @@ type Service struct { cancelFuncs map[string]context.CancelFunc // 记录每个 session 的 context 取消函数 } +var miniMaxAnthropicModels = []string{ + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", + "MiniMax-M2.5", + "MiniMax-M2.5-highspeed", + "MiniMax-M2.1", + "MiniMax-M2.1-highspeed", + "MiniMax-M2", +} + // NewService 创建 AI Service 实例 func NewService() *Service { return &Service{ @@ -63,6 +73,9 @@ func (s *Service) AIGetProviders() []ai.ProviderConfig { result := make([]ai.ProviderConfig, len(s.providers)) copy(result, s.providers) + for i := range result { + result[i] = normalizeProviderConfig(result[i]) + } return result } @@ -72,6 +85,8 @@ func (s *Service) AISaveProvider(config ai.ProviderConfig) error { s.mu.Lock() defer s.mu.Unlock() + config = normalizeProviderConfig(config) + if strings.TrimSpace(config.ID) == "" { config.ID = "provider-" + uuid.New().String()[:8] } @@ -128,65 +143,36 @@ func (s *Service) AITestProvider(config ai.ProviderConfig) map[string]interface{ } s.mu.RUnlock() + config = normalizeProviderConfig(config) baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/") - providerType := config.Type - if providerType == "custom" && config.APIFormat != "" { - providerType = config.APIFormat - } + providerType := normalizedProviderType(config) client := &http.Client{Timeout: 10 * time.Second} var err error switch providerType { - case "openai": - if baseURL == "" { - baseURL = "https://api.openai.com/v1" - } - if !strings.HasSuffix(baseURL, "/v1") && !strings.Contains(baseURL, "/v1/") { - baseURL = baseURL + "/v1" - } - // 使用 /models 端点验证连通性和鉴权 - req, _ := http.NewRequest("GET", baseURL+"/models", nil) - req.Header.Set("Authorization", "Bearer "+config.APIKey) - for k, v := range config.Headers { - req.Header.Set(k, v) + case "openai", "anthropic", "gemini": + req, reqErr := newProviderHealthCheckRequest(config) + if reqErr != nil { + err = reqErr + break } resp, reqErr := client.Do(req) if reqErr != nil { err = reqErr } else { defer resp.Body.Close() - if resp.StatusCode == http.StatusUnauthorized { - err = fmt.Errorf("API Key 验证失败 (HTTP %d)", resp.StatusCode) + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + err = fmt.Errorf("API Key 无效或请求错误 (HTTP %d)", resp.StatusCode) + } else if providerType == "gemini" && resp.StatusCode == http.StatusBadRequest { + err = fmt.Errorf("API Key 无效或请求错误 (HTTP %d)", resp.StatusCode) + } else if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + err = fmt.Errorf("接口返回异常 (HTTP %d): %s", resp.StatusCode, string(body)) } else if resp.StatusCode >= 500 { err = fmt.Errorf("上游服务器内部错误 (HTTP %d)", resp.StatusCode) } } - case "anthropic": - if baseURL == "" { - baseURL = "https://api.anthropic.com" - } - req, _ := http.NewRequest("GET", baseURL, nil) - resp, reqErr := client.Do(req) - if reqErr != nil { - err = reqErr - } else { - resp.Body.Close() - } - case "gemini": - if baseURL == "" { - baseURL = "https://generativelanguage.googleapis.com" - } - req, _ := http.NewRequest("GET", baseURL+"/v1beta/models?key="+config.APIKey, nil) - resp, reqErr := client.Do(req) - if reqErr != nil { - err = reqErr - } else { - defer resp.Body.Close() - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest { - err = fmt.Errorf("API Key 无效或请求错误 (HTTP %d)", resp.StatusCode) - } - } default: if baseURL != "" { req, _ := http.NewRequest("GET", baseURL, nil) @@ -209,6 +195,153 @@ func (s *Service) AITestProvider(config ai.ProviderConfig) map[string]interface{ } } +func normalizedProviderType(config ai.ProviderConfig) string { + providerType := strings.ToLower(strings.TrimSpace(config.Type)) + if providerType == "custom" && strings.TrimSpace(config.APIFormat) != "" { + return strings.ToLower(strings.TrimSpace(config.APIFormat)) + } + return providerType +} + +func isMiniMaxAnthropicProvider(config ai.ProviderConfig) bool { + if normalizedProviderType(config) != "anthropic" { + return false + } + baseURL := strings.ToLower(strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")) + return strings.Contains(baseURL, "api.minimax.io") || strings.Contains(baseURL, "api.minimaxi.com") +} + +func isMoonshotAnthropicProvider(config ai.ProviderConfig) bool { + if normalizedProviderType(config) != "anthropic" { + return false + } + baseURL := strings.ToLower(strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")) + return strings.Contains(baseURL, "api.moonshot.cn") +} + +func defaultStaticModelsForProvider(config ai.ProviderConfig) []string { + if isMiniMaxAnthropicProvider(config) { + return append([]string(nil), miniMaxAnthropicModels...) + } + return nil +} + +func normalizeProviderConfig(config ai.ProviderConfig) ai.ProviderConfig { + staticModels := defaultStaticModelsForProvider(config) + if len(staticModels) > 0 && len(config.Models) == 0 { + config.Models = staticModels + } + model := strings.TrimSpace(config.Model) + if isMiniMaxAnthropicProvider(config) && (model == "" || strings.HasPrefix(strings.ToLower(model), "minimax-text-")) { + config.Model = miniMaxAnthropicModels[0] + } + return config +} + +func resolveModelsURL(config ai.ProviderConfig) string { + config = normalizeProviderConfig(config) + providerType := normalizedProviderType(config) + baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/") + + switch providerType { + case "anthropic": + if isMoonshotAnthropicProvider(config) { + return "https://api.moonshot.cn/v1/models" + } + if baseURL == "" { + baseURL = "https://api.anthropic.com" + } + if !strings.HasSuffix(baseURL, "/v1") && !strings.Contains(baseURL, "/v1/") { + baseURL = baseURL + "/v1" + } + return baseURL + "/models" + case "gemini": + if baseURL == "" { + baseURL = "https://generativelanguage.googleapis.com" + } + return baseURL + "/v1beta/models?key=" + config.APIKey + case "openai": + fallthrough + default: + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } + if !strings.HasSuffix(baseURL, "/v1") && !strings.Contains(baseURL, "/v1/") { + baseURL = baseURL + "/v1" + } + return baseURL + "/models" + } +} + +func newModelsRequest(config ai.ProviderConfig) (*http.Request, error) { + config = normalizeProviderConfig(config) + url := resolveModelsURL(config) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + + switch normalizedProviderType(config) { + case "anthropic": + req.Header.Set("x-api-key", config.APIKey) + req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("Authorization", "Bearer "+config.APIKey) + case "gemini": + // Gemini 使用 query string 传递 key,无需额外鉴权头 + default: + req.Header.Set("Authorization", "Bearer "+config.APIKey) + } + + for k, v := range config.Headers { + req.Header.Set(k, v) + } + + return req, nil +} + +func resolveAnthropicMessagesURL(baseURL string) string { + url := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if url == "" { + url = "https://api.anthropic.com" + } + if strings.HasSuffix(url, "/messages") { + return url + } + if strings.HasSuffix(url, "/v1") { + return url + "/messages" + } + return url + "/v1/messages" +} + +func newProviderHealthCheckRequest(config ai.ProviderConfig) (*http.Request, error) { + config = normalizeProviderConfig(config) + if isMiniMaxAnthropicProvider(config) { + body := map[string]interface{}{ + "model": config.Model, + "max_tokens": 1, + "messages": []map[string]string{ + {"role": "user", "content": "ping"}, + }, + } + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("序列化请求失败: %w", err) + } + req, err := http.NewRequest("POST", resolveAnthropicMessagesURL(config.BaseURL), strings.NewReader(string(bodyBytes))) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", config.APIKey) + req.Header.Set("anthropic-version", "2023-06-01") + for k, v := range config.Headers { + req.Header.Set(k, v) + } + return req, nil + } + return newModelsRequest(config) +} + // AISetActiveProvider 设置活动 Provider func (s *Service) AISetActiveProvider(id string) { s.mu.Lock() @@ -261,17 +394,16 @@ func (s *Service) AIListModels() map[string]interface{} { // fetchModels 从供应商 API 获取可用模型列表 func fetchModels(config ai.ProviderConfig) ([]string, error) { - providerType := config.Type - if providerType == "custom" && config.APIFormat != "" { - providerType = config.APIFormat + providerType := normalizedProviderType(config) + if staticModels := defaultStaticModelsForProvider(config); len(staticModels) > 0 { + return staticModels, nil } switch providerType { case "openai": return fetchOpenAIModels(config) case "anthropic": - // Anthropic 没有公开的 /models 端点,返回硬编码列表 - return []string{"claude-opus-4-6", "claude-sonnet-4-6"}, nil + return fetchAnthropicModels(config) case "gemini": return fetchGeminiModels(config) default: @@ -281,20 +413,45 @@ func fetchModels(config ai.ProviderConfig) ([]string, error) { // 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 := newModelsRequest(config) + if err != nil { + return nil, err } - req, err := http.NewRequest("GET", baseURL+"/models", nil) + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) if err != nil { - return nil, fmt.Errorf("创建请求失败: %w", err) + 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 +} + +// fetchAnthropicModels 获取 Anthropic API 的模型列表 +func fetchAnthropicModels(config ai.ProviderConfig) ([]string, error) { + req, err := newModelsRequest(config) + if err != nil { + return nil, err } - req.Header.Set("Authorization", "Bearer "+config.APIKey) client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) @@ -515,7 +672,7 @@ func (s *Service) getActiveProvider() (provider.Provider, error) { for _, cfg := range s.providers { if cfg.ID == s.activeProvider { - return provider.NewProvider(cfg) + return provider.NewProvider(normalizeProviderConfig(cfg)) } } @@ -548,6 +705,9 @@ func (s *Service) loadConfig() { if s.providers == nil { s.providers = make([]ai.ProviderConfig, 0) } + for i := range s.providers { + s.providers[i] = normalizeProviderConfig(s.providers[i]) + } s.activeProvider = cfg.ActiveProvider switch ai.SQLPermissionLevel(cfg.SafetyLevel) { @@ -591,14 +751,122 @@ func (s *Service) saveConfig() error { return nil } +// --- 会话文件持久化 --- + +// sessionFileData 会话文件的 JSON 结构 +type sessionFileData struct { + ID string `json:"id"` + Title string `json:"title"` + UpdatedAt int64 `json:"updatedAt"` + Messages json.RawMessage `json:"messages"` // 透传前端格式,后端不解析消息体 +} + +func (s *Service) sessionsDir() string { + return filepath.Join(s.configDir, "sessions") +} + +// AIGetSessions 获取所有会话的元数据列表(不含消息体) +func (s *Service) AIGetSessions() []map[string]interface{} { + dir := s.sessionsDir() + entries, err := os.ReadDir(dir) + if err != nil { + return []map[string]interface{}{} + } + + var sessions []map[string]interface{} + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + var sfd sessionFileData + if err := json.Unmarshal(data, &sfd); err != nil { + continue + } + sessions = append(sessions, map[string]interface{}{ + "id": sfd.ID, + "title": sfd.Title, + "updatedAt": sfd.UpdatedAt, + }) + } + + // 按 updatedAt 降序排列 + for i := 0; i < len(sessions); i++ { + for j := i + 1; j < len(sessions); j++ { + ti, _ := sessions[i]["updatedAt"].(int64) + tj, _ := sessions[j]["updatedAt"].(int64) + if tj > ti { + sessions[i], sessions[j] = sessions[j], sessions[i] + } + } + } + + return sessions +} + +// AILoadSession 加载指定会话的完整数据(含消息) +func (s *Service) AILoadSession(sessionID string) map[string]interface{} { + path := filepath.Join(s.sessionsDir(), sessionID+".json") + data, err := os.ReadFile(path) + if err != nil { + return map[string]interface{}{"success": false, "error": "会话不存在"} + } + var sfd sessionFileData + if err := json.Unmarshal(data, &sfd); err != nil { + return map[string]interface{}{"success": false, "error": "会话数据损坏"} + } + return map[string]interface{}{ + "success": true, + "id": sfd.ID, + "title": sfd.Title, + "updatedAt": sfd.UpdatedAt, + "messages": sfd.Messages, + } +} + +// AISaveSession 保存会话数据到文件 +func (s *Service) AISaveSession(sessionID string, title string, updatedAt float64, messagesJSON string) error { + dir := s.sessionsDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("创建 sessions 目录失败: %w", err) + } + + sfd := sessionFileData{ + ID: sessionID, + Title: title, + UpdatedAt: int64(updatedAt), + Messages: json.RawMessage(messagesJSON), + } + + data, err := json.MarshalIndent(sfd, "", " ") + if err != nil { + return fmt.Errorf("序列化会话数据失败: %w", err) + } + + path := filepath.Join(dir, sessionID+".json") + return os.WriteFile(path, data, 0o644) +} + +// AIDeleteSession 删除会话文件 +func (s *Service) AIDeleteSession(sessionID string) error { + path := filepath.Join(s.sessionsDir(), sessionID+".json") + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("删除会话失败: %w", err) + } + return nil +} + // --- 工具函数 --- func resolveConfigDir() string { - configDir, err := os.UserConfigDir() + homeDir, err := os.UserHomeDir() if err != nil { - configDir = "." + homeDir = "." } - return filepath.Join(configDir, "GoNavi") + return filepath.Join(homeDir, ".gonavi") } func maskAPIKey(apiKey string) string { diff --git a/internal/ai/service/service_test.go b/internal/ai/service/service_test.go new file mode 100644 index 0000000..6574b5a --- /dev/null +++ b/internal/ai/service/service_test.go @@ -0,0 +1,78 @@ +package aiservice + +import ( + "reflect" + "testing" + + "GoNavi-Wails/internal/ai" +) + +func TestResolveModelsURL_UsesMoonshotOpenAIModelsEndpointForKimiAnthropicBaseURL(t *testing.T) { + url := resolveModelsURL(ai.ProviderConfig{ + Type: "anthropic", + BaseURL: "https://api.moonshot.cn/anthropic", + }) + if url != "https://api.moonshot.cn/v1/models" { + t.Fatalf("expected moonshot models endpoint, got %q", url) + } +} + +func TestResolveModelsURL_UsesAnthropicModelsEndpointForOfficialAnthropic(t *testing.T) { + url := resolveModelsURL(ai.ProviderConfig{ + Type: "anthropic", + BaseURL: "https://api.anthropic.com", + }) + if url != "https://api.anthropic.com/v1/models" { + t.Fatalf("expected anthropic models endpoint, got %q", url) + } +} + +func TestResolveModelsURL_UsesOpenAIModelsEndpointForOpenAICompatibleProvider(t *testing.T) { + url := resolveModelsURL(ai.ProviderConfig{ + Type: "openai", + BaseURL: "https://api.openai.com/v1", + }) + if url != "https://api.openai.com/v1/models" { + t.Fatalf("expected openai models endpoint, got %q", url) + } +} + +func TestDefaultStaticModelsForProvider_ReturnsMiniMaxAnthropicModels(t *testing.T) { + models := defaultStaticModelsForProvider(ai.ProviderConfig{ + Type: "anthropic", + BaseURL: "https://api.minimaxi.com/anthropic", + }) + expected := []string{ + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", + "MiniMax-M2.5", + "MiniMax-M2.5-highspeed", + "MiniMax-M2.1", + "MiniMax-M2.1-highspeed", + "MiniMax-M2", + } + if !reflect.DeepEqual(models, expected) { + t.Fatalf("expected MiniMax static models %v, got %v", expected, models) + } +} + +func TestNewProviderHealthCheckRequest_UsesMessagesEndpointForMiniMaxAnthropic(t *testing.T) { + req, err := newProviderHealthCheckRequest(ai.ProviderConfig{ + Type: "anthropic", + BaseURL: "https://api.minimaxi.com/anthropic", + Model: "MiniMax-M2.7", + APIKey: "sk-test", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Method != "POST" { + t.Fatalf("expected POST request, got %s", req.Method) + } + if req.URL.String() != "https://api.minimaxi.com/anthropic/v1/messages" { + t.Fatalf("expected MiniMax messages endpoint, got %q", req.URL.String()) + } + if got := req.Header.Get("x-api-key"); got != "sk-test" { + t.Fatalf("expected x-api-key header to be set, got %q", got) + } +} From d4d685b0769f22ed94b1ded6d5741c1ee3dcab0a Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Mar 2026 10:49:39 +0800 Subject: [PATCH 09/14] =?UTF-8?q?=E2=9C=A8=20feat(ci/ai):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20dev=20=E5=88=86=E6=94=AF=E8=87=AA=E5=8A=A8=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E5=B7=A5=E4=BD=9C=E6=B5=81=E5=B9=B6=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=20Claude=20CLI=20Windows=20=E5=85=BC=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI 新增:添加 dev-build.yml,push dev 分支自动触发全平台构建并发布 Pre-release - CI 清理:删除已废弃的 test-build-all-platforms.yml 和 test-macos-build.yml - Claude CLI:新增 Windows 环境自动探测 Git Bash 路径(ProgramFiles/LocalAppData 多候选) - Claude CLI:setEnv 改为可返回 error,环境变量操作提纯为 buildClaudeCLIEnv 纯函数 - Claude CLI:新增 upsertEnv/fileExists/joinWindowsPath 等工具函数 --- .github/workflows/dev-build.yml | 607 ++++++++++++++++++ .../workflows/test-build-all-platforms.yml | 412 ------------ .github/workflows/test-macos-build.yml | 94 --- frontend/package.json.md5 | 2 +- internal/ai/provider/claude_cli.go | 163 ++++- internal/ai/provider/claude_cli_test.go | 69 ++ 6 files changed, 829 insertions(+), 518 deletions(-) create mode 100644 .github/workflows/dev-build.yml delete mode 100644 .github/workflows/test-build-all-platforms.yml delete mode 100644 .github/workflows/test-macos-build.yml create mode 100644 internal/ai/provider/claude_cli_test.go diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml new file mode 100644 index 0000000..2bd557c --- /dev/null +++ b/.github/workflows/dev-build.yml @@ -0,0 +1,607 @@ +name: Dev Build + +on: + push: + branches: + - dev + +permissions: + contents: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + build: + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + platform: darwin/amd64 + os_name: MacOS + arch_name: Amd64 + build_name: gonavi-build-darwin-amd64 + wails_tags: "" + artifact_suffix: "" + build_optional_agents: true + linux_webkit: "" + - os: macos-latest + platform: darwin/arm64 + os_name: MacOS + arch_name: Arm64 + build_name: gonavi-build-darwin-arm64 + wails_tags: "" + artifact_suffix: "" + build_optional_agents: true + linux_webkit: "" + - os: windows-latest + platform: windows/amd64 + os_name: Windows + arch_name: Amd64 + build_name: gonavi-build-windows-amd64 + wails_tags: "" + artifact_suffix: "" + build_optional_agents: true + linux_webkit: "" + - os: windows-latest + platform: windows/arm64 + os_name: Windows + arch_name: Arm64 + build_name: gonavi-build-windows-arm64 + wails_tags: "" + artifact_suffix: "" + build_optional_agents: true + linux_webkit: "" + - os: ubuntu-22.04 + platform: linux/amd64 + os_name: Linux + arch_name: Amd64 + build_name: gonavi-build-linux-amd64 + wails_tags: "" + artifact_suffix: "" + build_optional_agents: true + linux_webkit: "4.0" + - os: ubuntu-24.04 + platform: linux/amd64 + os_name: Linux + arch_name: Amd64 + build_name: gonavi-build-linux-amd64-webkit41 + wails_tags: "webkit2_41" + artifact_suffix: "-WebKit41" + build_optional_agents: false + linux_webkit: "4.1" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + check-latest: true + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install UPX (Windows) + if: contains(matrix.platform, 'windows') + shell: pwsh + run: | + $UPX_VERSION = "4.2.4" + $url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip" + $zipPath = "$env:RUNNER_TEMP\upx.zip" + $extractPath = "$env:RUNNER_TEMP\upx" + Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..." + Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing + Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force + $upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1 + "$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + $upxCmd = Join-Path $upxDir.FullName "upx.exe" + if (!(Test-Path $upxCmd)) { + Write-Error "❌ 未检测到 upx,无法保证 Windows 产物经过压缩" + exit 1 + } + & $upxCmd --version + + - name: Install Linux Dependencies + if: contains(matrix.platform, 'linux') + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev + + if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then + sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev + else + sudo apt-get install -y libwebkit2gtk-4.0-dev + fi + + sudo apt-get install -y upx-ucl || sudo apt-get install -y upx + upx --version + + sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true + + LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage" + PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage" + + echo "📥 下载 linuxdeploy..." + wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \ + -O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || { + echo "⚠️ linuxdeploy 下载失败,AppImage 打包将跳过" + touch /tmp/skip-appimage + } + + echo "📥 下载 linuxdeploy-plugin-gtk..." + wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \ + -O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || { + echo "⚠️ linuxdeploy-plugin-gtk 下载失败,AppImage 打包将跳过" + touch /tmp/skip-appimage + } + + if [ ! -f /tmp/skip-appimage ]; then + chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk + echo "✅ AppImage 工具准备完成" + fi + + - name: Install Wails + run: go install -v github.com/wailsapp/wails/v2/cmd/wails@latest + + - name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64) + id: msys2_duckdb + if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }} + continue-on-error: true + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + install: >- + mingw-w64-ucrt-x86_64-gcc + + - name: Configure DuckDB CGO Toolchain (Windows AMD64) + if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }} + shell: pwsh + run: | + function Find-MingwBin([string[]]$candidates) { + foreach ($bin in $candidates) { + if ([string]::IsNullOrWhiteSpace($bin)) { + continue + } + $gcc = Join-Path $bin 'gcc.exe' + $gxx = Join-Path $bin 'g++.exe' + if ((Test-Path $gcc) -and (Test-Path $gxx)) { + return $bin + } + } + return $null + } + + $msys2Outcome = "${{ steps.msys2_duckdb.outcome }}" + $msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}" + $candidateBins = @() + if (-not [string]::IsNullOrWhiteSpace($msys2Location)) { + $candidateBins += Join-Path $msys2Location 'ucrt64\bin' + } + $candidateBins += @( + 'C:\msys64\ucrt64\bin', + 'D:\a\_temp\msys64\ucrt64\bin' + ) + $candidateBins = @($candidateBins | Select-Object -Unique) + + $mingwBin = Find-MingwBin $candidateBins + if (-not $mingwBin) { + if ($msys2Outcome -ne 'success') { + Write-Warning "⚠️ MSYS2 安装步骤结果为 $msys2Outcome,回退到 UCRT64 本机路径探测" + } else { + Write-Warning "⚠️ MSYS2 已执行,但未找到 UCRT64 gcc/g++,回退到本机路径探测" + } + $mingwBin = Find-MingwBin $candidateBins + } + + if (-not $mingwBin) { + Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。已检查:$($candidateBins -join ', ')" + exit 1 + } + + $gcc = (Join-Path $mingwBin 'gcc.exe') + $gxx = (Join-Path $mingwBin 'g++.exe') + + if (!(Test-Path $gcc) -or !(Test-Path $gxx)) { + Write-Error "❌ DuckDB 编译器缺失:gcc=$gcc g++=$gxx" + exit 1 + } + + "$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + "CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + Write-Host "✅ 已配置 DuckDB cgo 编译器: gcc=$gcc g++=$gxx" + + - name: Verify DuckDB CGO Toolchain (Windows AMD64) + if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }} + shell: pwsh + run: | + & "$env:CC" --version + & "$env:CXX" --version + + # ---- 生成 dev 版本号 ---- + - name: Generate Dev Version + id: version + shell: bash + run: | + SHORT_SHA=$(git rev-parse --short HEAD) + DEV_VERSION="dev-${SHORT_SHA}" + echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT" + echo "📌 Dev 版本号: ${DEV_VERSION}" + + - name: Build + shell: bash + run: | + set -euo pipefail + DEV_VERSION="${{ steps.version.outputs.version }}" + if [ -n "${{ matrix.wails_tags }}" ]; then + wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}" + else + wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}" + fi + + - name: Build Optional Driver Agents + if: ${{ matrix.build_optional_agents }} + shell: bash + run: | + set -euo pipefail + TARGET_PLATFORM="${{ matrix.platform }}" + GOOS="${TARGET_PLATFORM%%/*}" + GOARCH="${TARGET_PLATFORM##*/}" + DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse) + OUTDIR="drivers/${{ matrix.os_name }}" + mkdir -p "$OUTDIR" + + for DRIVER in "${DRIVERS[@]}"; do + BUILD_DRIVER="$DRIVER" + if [ "$DRIVER" = "doris" ]; then + BUILD_DRIVER="diros" + fi + if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then + echo "⚠️ 跳过 DuckDB driver(当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64)" + continue + fi + TAG="gonavi_${BUILD_DRIVER}_driver" + OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}" + if [ "$GOOS" = "windows" ]; then + OUTPUT="${OUTPUT}.exe" + fi + OUTPUT_PATH="${OUTDIR}/${OUTPUT}" + echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})" + if [ "$DRIVER" = "duckdb" ]; then + CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \ + -tags "${TAG}" \ + -trimpath \ + -ldflags "-s -w" \ + -o "${OUTPUT_PATH}" \ + ./cmd/optional-driver-agent + else + CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \ + -tags "${TAG}" \ + -trimpath \ + -ldflags "-s -w" \ + -o "${OUTPUT_PATH}" \ + ./cmd/optional-driver-agent + fi + done + + # macOS Packaging + - name: Package macOS DMG + if: contains(matrix.platform, 'darwin') + run: | + brew install create-dmg + VERSION="${{ steps.version.outputs.version }}" + cd build/bin + + APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1) + if [ -z "$APP_PATH" ]; then + echo "❌ 未找到 .app 应用包!" + exit 1 + fi + APP_NAME=$(basename "$APP_PATH") + + APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1) + if [ -z "$APP_BIN" ]; then + echo "❌ 未找到 macOS 应用主程序!" + exit 1 + fi + echo "ℹ️ macOS 产物不执行 UPX 压缩,保留原始主程序。" + + echo "🔏 正在进行 Ad-hoc 签名..." + codesign --force --deep --sign - "$APP_NAME" + + DMG_NAME="${{ matrix.build_name }}.dmg" + FINAL_NAME="GoNavi-${VERSION}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg" + echo "📦 正在生成 DMG: $DMG_NAME..." + + create-dmg \ + --volname "GoNavi Dev Build" \ + --window-pos 200 120 \ + --window-size 800 400 \ + --icon-size 100 \ + --icon "$APP_NAME" 200 190 \ + --hide-extension "$APP_NAME" \ + --app-drop-link 600 185 \ + "$DMG_NAME" \ + "$APP_NAME" + + mv "$DMG_NAME" "../../$FINAL_NAME" + + # Windows Packaging + - name: Package Windows EXE + if: contains(matrix.platform, 'windows') + shell: pwsh + run: | + Set-Location build/bin + $version = "${{ steps.version.outputs.version }}" + $target = "${{ matrix.build_name }}" + $finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe" + + if (Test-Path "$target.exe") { + $finalExe = "$target.exe" + } elseif (Test-Path "$target") { + Rename-Item -Path "$target" -NewName "$target.exe" + $finalExe = "$target.exe" + } else { + Write-Error "❌ 未找到构建产物 '$target'!" + exit 1 + } + + $isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64" + if ($isArm64Target) { + Write-Warning "⚠️ UPX 当前不支持 win64/arm64,跳过压缩并保留原始 EXE。" + $LASTEXITCODE = 0 + } else { + $upxCmd = Get-Command upx -ErrorAction SilentlyContinue + if ($null -eq $upxCmd) { + Write-Error "❌ 未找到 upx,无法保证 Windows 产物经过压缩" + exit 1 + } + $beforeBytes = (Get-Item -LiteralPath $finalExe).Length + Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..." + & upx --best --lzma --force $finalExe | Out-Host + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ UPX 压缩失败($LASTEXITCODE)" + exit 1 + } + & upx -t $finalExe | Out-Host + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ UPX 校验失败($LASTEXITCODE)" + exit 1 + } + $afterBytes = (Get-Item -LiteralPath $finalExe).Length + if ($afterBytes -lt $beforeBytes) { + $savedBytes = $beforeBytes - $afterBytes + Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB,减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB)) + } else { + Write-Host ("ℹ️ UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB)) + } + } + + Write-Host "📦 输出 Windows 可执行文件 $finalExeName..." + Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force + + # Linux Packaging + - name: Package Linux + if: contains(matrix.platform, 'linux') + run: | + VERSION="${{ steps.version.outputs.version }}" + cd build/bin + TARGET="${{ matrix.build_name }}" + TAR_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.tar.gz" + APPIMAGE_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.AppImage" + + if [ ! -f "$TARGET" ]; then + echo "❌ 未找到构建产物 '$TARGET'!" + exit 1 + fi + + chmod +x "$TARGET" + BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]') + echo "🗜️ 正在使用 UPX 压缩 Linux 可执行文件: $TARGET ..." + upx --best --lzma --force "$TARGET" + upx -t "$TARGET" + AFTER_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]') + if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then + SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES)) + awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ Linux UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }' + else + awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf "ℹ️ Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }' + fi + + echo "📦 正在打包 $TAR_NAME..." + tar -czvf "$TAR_NAME" "$TARGET" + mv "$TAR_NAME" ../../ + + if [ -f /tmp/skip-appimage ]; then + echo "⚠️ 跳过 AppImage 打包" + exit 0 + fi + + echo "📦 正在生成 AppImage..." + mkdir -p AppDir/usr/bin + mkdir -p AppDir/usr/share/applications + mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps + + cp "$TARGET" AppDir/usr/bin/gonavi + + printf '%s\n' \ + '[Desktop Entry]' \ + 'Name=GoNavi' \ + 'Exec=gonavi' \ + 'Icon=gonavi' \ + 'Type=Application' \ + 'Categories=Development;Database;' \ + 'Comment=Database Management Tool' \ + > AppDir/usr/share/applications/gonavi.desktop + + cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop + + if [ -f "../../build/appicon.png" ]; then + cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png + cp "../../build/appicon.png" AppDir/gonavi.png + else + convert -size 256x256 xc:#336791 -fill white -gravity center -pointsize 48 -annotate 0 "GoNavi" AppDir/gonavi.png || \ + wget -q "https://via.placeholder.com/256/336791/FFFFFF?text=GoNavi" -O AppDir/gonavi.png || \ + touch AppDir/gonavi.png + cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png + fi + + export DEPLOY_GTK_VERSION=3 + /tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || { + echo "⚠️ AppImage 生成失败,但 tar.gz 已成功生成" + exit 0 + } + + mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || { + echo "⚠️ AppImage 重命名失败" + exit 0 + } + + if [ -f "$APPIMAGE_NAME" ]; then + mv "$APPIMAGE_NAME" ../../ + echo "✅ AppImage 生成成功" + fi + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: dev-build-artifacts-${{ strategy.job-index }} + path: | + GoNavi-*.dmg + GoNavi-*.exe + GoNavi-*.tar.gz + GoNavi-*.AppImage + drivers/** + retention-days: 7 + + # 汇总所有产物并发布为 Pre-release + release: + name: Publish Dev Pre-release + needs: build + runs-on: ubuntu-latest + steps: + - name: Download All Artifacts + uses: actions/download-artifact@v4 + with: + path: release-assets + pattern: dev-build-artifacts-* + merge-multiple: true + + - name: List Assets + run: ls -R release-assets + + - name: Package Driver Agents Bundle + shell: bash + run: | + set -euo pipefail + cd release-assets + if [ ! -d drivers ]; then + echo "⚠️ 未找到 drivers 目录,跳过驱动总包打包" + exit 0 + fi + if [ -z "$(find drivers -type f 2>/dev/null | head -n 1)" ]; then + echo "⚠️ drivers 目录为空,跳过驱动总包打包" + rm -rf drivers + exit 0 + fi + + echo "📦 打包驱动总包:GoNavi-DriverAgents.zip" + python3 - <<'PY' + import json + import os + import zipfile + from pathlib import Path + + out_name = "GoNavi-DriverAgents.zip" + index_name = "GoNavi-DriverAgents-Index.json" + base = Path("drivers") + out_path = Path(out_name) + index_path = Path(index_name) + if out_path.exists(): + out_path.unlink() + if index_path.exists(): + index_path.unlink() + + size_index = {} + with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for p in base.rglob("*"): + if not p.is_file(): + continue + arcname = p.relative_to(base).as_posix() + zf.write(p, arcname) + size_index[p.name] = p.stat().st_size + + index_path.write_text( + json.dumps({"assets": size_index}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + print(f"created {out_name} size={out_path.stat().st_size} bytes") + print(f"created {index_name} entries={len(size_index)}") + PY + + rm -rf drivers + + - name: Generate SHA256SUMS + shell: bash + run: | + cd release-assets + FILES=() + while IFS= read -r file; do + if [ -n "$file" ]; then + FILES+=("$file") + fi + done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort) + if [ ${#FILES[@]} -eq 0 ]; then + echo "⚠️ 未找到可签名资产,生成空 SHA256SUMS" + : > SHA256SUMS + else + sha256sum "${FILES[@]}" > SHA256SUMS + fi + + - name: Generate Dev Version + id: version + run: | + SHORT_SHA="${GITHUB_SHA:0:7}" + DEV_VERSION="dev-${SHORT_SHA}" + echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT" + + # 删除旧的 dev pre-release(保持只有最新一个) + - name: Delete Previous Dev Release + uses: dev-drprasad/delete-tag-and-release@v1.1 + continue-on-error: true + with: + tag_name: dev-latest + delete_release: true + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Dev Pre-release + uses: softprops/action-gh-release@v2 + with: + tag_name: dev-latest + name: "🧪 Dev Build (${{ steps.version.outputs.version }})" + target_commitish: ${{ github.sha }} + files: release-assets/* + prerelease: true + draft: false + body: | + ## 🧪 测试版本 (Dev Build) + + **版本**: `${{ steps.version.outputs.version }}` + **分支**: `dev` + **提交**: [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}) + **构建时间**: ${{ github.event.head_commit.timestamp }} + + > ⚠️ 这是开发测试版本,仅供内部测试使用,不建议用于生产环境。 + > 每次 push 到 `dev` 分支会自动覆盖此 release。 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-build-all-platforms.yml b/.github/workflows/test-build-all-platforms.yml deleted file mode 100644 index d1df7e5..0000000 --- a/.github/workflows/test-build-all-platforms.yml +++ /dev/null @@ -1,412 +0,0 @@ -name: Test Build All Platforms (Manual) - -on: - workflow_dispatch: - inputs: - build_label: - description: "测试包标识(仅用于文件名)" - required: false - default: "test" - -permissions: - contents: read - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - -concurrency: - group: test-build-${{ github.ref }} - cancel-in-progress: false - -jobs: - build: - name: Build ${{ matrix.platform }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: macos-latest - platform: darwin/amd64 - os_name: MacOS - arch_name: Amd64 - build_name: gonavi-test-darwin-amd64 - wails_tags: "" - artifact_suffix: "" - build_optional_agents: true - linux_webkit: "" - - os: macos-latest - platform: darwin/arm64 - os_name: MacOS - arch_name: Arm64 - build_name: gonavi-test-darwin-arm64 - wails_tags: "" - artifact_suffix: "" - build_optional_agents: true - linux_webkit: "" - - os: windows-latest - platform: windows/amd64 - os_name: Windows - arch_name: Amd64 - build_name: gonavi-test-windows-amd64 - wails_tags: "" - artifact_suffix: "" - build_optional_agents: true - linux_webkit: "" - - os: windows-latest - platform: windows/arm64 - os_name: Windows - arch_name: Arm64 - build_name: gonavi-test-windows-arm64 - wails_tags: "" - artifact_suffix: "" - build_optional_agents: true - linux_webkit: "" - - os: ubuntu-22.04 - platform: linux/amd64 - os_name: Linux - arch_name: Amd64 - build_name: gonavi-test-linux-amd64 - wails_tags: "" - artifact_suffix: "" - build_optional_agents: true - linux_webkit: "4.0" - - os: ubuntu-24.04 - platform: linux/amd64 - os_name: Linux - arch_name: Amd64 - build_name: gonavi-test-linux-amd64-webkit41 - wails_tags: "webkit2_41" - artifact_suffix: "-WebKit41" - build_optional_agents: false - linux_webkit: "4.1" - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: '1.24' - check-latest: true - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install UPX (Windows) - if: contains(matrix.platform, 'windows') - shell: pwsh - run: | - $UPX_VERSION = "4.2.4" - $url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip" - $zipPath = "$env:RUNNER_TEMP\upx.zip" - $extractPath = "$env:RUNNER_TEMP\upx" - Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..." - Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing - Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force - $upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1 - "$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 - $upxCmd = Join-Path $upxDir.FullName "upx.exe" - if (!(Test-Path $upxCmd)) { - Write-Error "❌ 未检测到 upx,无法保证 Windows 测试产物经过压缩" - exit 1 - } - & $upxCmd --version - - - name: Install Linux Dependencies - if: contains(matrix.platform, 'linux') - run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev - - if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then - sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev - else - sudo apt-get install -y libwebkit2gtk-4.0-dev - fi - - sudo apt-get install -y upx-ucl || sudo apt-get install -y upx - upx --version - - sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true - - LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage" - PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage" - - wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || { - echo "skip-appimage=true" >> "$GITHUB_ENV" - } - wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || { - echo "skip-appimage=true" >> "$GITHUB_ENV" - } - - if [ "${skip-appimage:-false}" != "true" ]; then - chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk - fi - - - name: Install Wails - run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 - - - name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64) - id: msys2_duckdb - if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }} - continue-on-error: true - uses: msys2/setup-msys2@v2 - with: - msystem: UCRT64 - update: true - install: >- - mingw-w64-ucrt-x86_64-gcc - - - name: Configure DuckDB CGO Toolchain (Windows AMD64) - if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }} - shell: pwsh - run: | - function Find-MingwBin([string[]]$candidates) { - foreach ($bin in $candidates) { - if ([string]::IsNullOrWhiteSpace($bin)) { - continue - } - $gcc = Join-Path $bin 'gcc.exe' - $gxx = Join-Path $bin 'g++.exe' - if ((Test-Path $gcc) -and (Test-Path $gxx)) { - return $bin - } - } - return $null - } - - $msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}" - $candidateBins = @() - if (-not [string]::IsNullOrWhiteSpace($msys2Location)) { - $candidateBins += Join-Path $msys2Location 'ucrt64\bin' - } - $candidateBins += @( - 'C:\msys64\ucrt64\bin', - 'D:\a\_temp\msys64\ucrt64\bin' - ) - $candidateBins = @($candidateBins | Select-Object -Unique) - - $mingwBin = Find-MingwBin $candidateBins - if (-not $mingwBin) { - Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。" - exit 1 - } - - $gcc = Join-Path $mingwBin 'gcc.exe' - $gxx = Join-Path $mingwBin 'g++.exe' - "$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 - "CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - - name: Build App - shell: bash - run: | - set -euo pipefail - BUILD_LABEL="${{ inputs.build_label }}" - if [ -z "$BUILD_LABEL" ]; then - BUILD_LABEL="test" - fi - APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}" - if [ -n "${{ matrix.wails_tags }}" ]; then - wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}" - else - wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}" - fi - - - name: Build Optional Driver Agents - if: ${{ matrix.build_optional_agents }} - shell: bash - run: | - set -euo pipefail - TARGET_PLATFORM="${{ matrix.platform }}" - GOOS="${TARGET_PLATFORM%%/*}" - GOARCH="${TARGET_PLATFORM##*/}" - DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse) - OUTDIR="drivers/${{ matrix.os_name }}" - mkdir -p "$OUTDIR" - - for DRIVER in "${DRIVERS[@]}"; do - BUILD_DRIVER="$DRIVER" - if [ "$DRIVER" = "doris" ]; then - BUILD_DRIVER="diros" - fi - if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then - echo "跳过 DuckDB driver: ${GOOS}/${GOARCH}" - continue - fi - TAG="gonavi_${BUILD_DRIVER}_driver" - OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}" - if [ "$GOOS" = "windows" ]; then - OUTPUT="${OUTPUT}.exe" - fi - OUTPUT_PATH="${OUTDIR}/${OUTPUT}" - if [ "$DRIVER" = "duckdb" ]; then - CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent - else - CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent - fi - done - - - name: Package macOS - if: contains(matrix.platform, 'darwin') - shell: bash - run: | - set -euo pipefail - brew install create-dmg - LABEL="${{ inputs.build_label }}" - if [ -z "$LABEL" ]; then - LABEL="test" - fi - cd build/bin - APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1) - if [ -z "$APP_PATH" ]; then - echo "未找到 .app 应用包" - exit 1 - fi - APP_NAME=$(basename "$APP_PATH") - APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1) - if [ -z "$APP_BIN" ]; then - echo "未找到 macOS 应用主程序" - exit 1 - fi - echo "ℹ️ macOS 产物不执行 UPX 压缩,保留原始主程序。" - codesign --force --deep --sign - "$APP_NAME" - ZIP_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.zip" - DMG_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.dmg" - mkdir -p ../../artifacts - ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "../../artifacts/$ZIP_NAME" - create-dmg \ - --volname "GoNavi Test Installer" \ - --window-pos 200 120 \ - --window-size 800 400 \ - --icon-size 100 \ - --icon "$APP_NAME" 200 190 \ - --hide-extension "$APP_NAME" \ - --app-drop-link 600 185 \ - "$DMG_NAME" \ - "$APP_NAME" - mv "$DMG_NAME" "../../artifacts/$DMG_NAME" - shasum -a 256 "../../artifacts/$ZIP_NAME" > "../../artifacts/$ZIP_NAME.sha256" - shasum -a 256 "../../artifacts/$DMG_NAME" > "../../artifacts/$DMG_NAME.sha256" - - - name: Package Windows - if: contains(matrix.platform, 'windows') - shell: pwsh - run: | - $label = "${{ inputs.build_label }}" - if ([string]::IsNullOrWhiteSpace($label)) { $label = 'test' } - Set-Location build/bin - $target = "${{ matrix.build_name }}" - $finalExeName = "GoNavi-$label-${{ matrix.os_name }}-${{ matrix.arch_name }}-run$env:GITHUB_RUN_NUMBER.exe" - if (Test-Path "$target.exe") { - $finalExe = "$target.exe" - } elseif (Test-Path "$target") { - Rename-Item -Path "$target" -NewName "$target.exe" - $finalExe = "$target.exe" - } else { - Write-Error "未找到构建产物 '$target'" - exit 1 - } - $isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64" - if ($isArm64Target) { - Write-Warning "⚠️ UPX 当前不支持 win64/arm64,跳过压缩并保留原始 EXE。" - $LASTEXITCODE = 0 - } else { - $upxCmd = Get-Command upx -ErrorAction SilentlyContinue - if ($null -eq $upxCmd) { - Write-Error "❌ 未找到 upx,无法保证 Windows 测试产物经过压缩" - exit 1 - } - $beforeBytes = (Get-Item -LiteralPath $finalExe).Length - Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..." - & upx --best --lzma --force $finalExe | Out-Host - if ($LASTEXITCODE -ne 0) { - Write-Error "❌ UPX 压缩失败($LASTEXITCODE)" - exit 1 - } - & upx -t $finalExe | Out-Host - if ($LASTEXITCODE -ne 0) { - Write-Error "❌ UPX 校验失败($LASTEXITCODE)" - exit 1 - } - $afterBytes = (Get-Item -LiteralPath $finalExe).Length - if ($afterBytes -lt $beforeBytes) { - $savedBytes = $beforeBytes - $afterBytes - Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB,减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB)) - } else { - Write-Host ("ℹ️ UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB)) - } - } - New-Item -ItemType Directory -Force -Path ..\..\artifacts | Out-Null - Copy-Item -LiteralPath $finalExe -Destination "..\..\artifacts\$finalExeName" -Force - Get-FileHash "..\..\artifacts\$finalExeName" -Algorithm SHA256 | ForEach-Object { "{0} *{1}" -f $_.Hash.ToLower(), (Split-Path $_.Path -Leaf) } | Out-File "..\..\artifacts\$finalExeName.sha256" -Encoding ascii - - - name: Package Linux - if: contains(matrix.platform, 'linux') - shell: bash - run: | - set -euo pipefail - LABEL="${{ inputs.build_label }}" - if [ -z "$LABEL" ]; then - LABEL="test" - fi - cd build/bin - TARGET="${{ matrix.build_name }}" - TAR_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.tar.gz" - APPIMAGE_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.AppImage" - mkdir -p ../../artifacts - - if [ ! -f "$TARGET" ]; then - echo "未找到构建产物 '$TARGET'" - exit 1 - fi - chmod +x "$TARGET" - BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]') - echo "🗜️ 使用 UPX 压缩 Linux 可执行文件: $TARGET ..." - upx --best --lzma --force "$TARGET" - upx -t "$TARGET" - AFTER_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]') - if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then - SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES)) - awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ Linux UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }' - else - awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf "ℹ️ Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }' - fi - tar -czvf "../../artifacts/$TAR_NAME" "$TARGET" - sha256sum "../../artifacts/$TAR_NAME" > "../../artifacts/$TAR_NAME.sha256" - - if [ "${skip-appimage:-false}" = "true" ]; then - echo "跳过 AppImage 打包" - exit 0 - fi - - mkdir -p AppDir/usr/bin AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps - cp "$TARGET" AppDir/usr/bin/gonavi - printf '%s\n' '[Desktop Entry]' 'Name=GoNavi' 'Exec=gonavi' 'Icon=gonavi' 'Type=Application' 'Categories=Development;Database;' 'Comment=Database Management Tool' > AppDir/usr/share/applications/gonavi.desktop - cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop - if [ -f "../../build/appicon.png" ]; then - cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png - cp "../../build/appicon.png" AppDir/gonavi.png - else - touch AppDir/gonavi.png - cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png - fi - export DEPLOY_GTK_VERSION=3 - /tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || exit 0 - mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || exit 0 - mv "$APPIMAGE_NAME" "../../artifacts/$APPIMAGE_NAME" - sha256sum "../../artifacts/$APPIMAGE_NAME" > "../../artifacts/$APPIMAGE_NAME.sha256" - - - name: Upload Artifact - uses: actions/upload-artifact@v4 - with: - name: test-build-${{ matrix.build_name }}-run${{ github.run_number }} - path: | - artifacts/* - drivers/** - if-no-files-found: error - retention-days: 7 diff --git a/.github/workflows/test-macos-build.yml b/.github/workflows/test-macos-build.yml deleted file mode 100644 index d022e91..0000000 --- a/.github/workflows/test-macos-build.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Test Build macOS (Manual) - -on: - workflow_dispatch: - inputs: - build_label: - description: "测试包标识(仅用于文件名)" - required: false - default: "test" - push: - branches: - - feature/kingbase_opt - paths: - - ".github/workflows/test-macos-build.yml" - -permissions: - contents: read - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - -jobs: - build-macos: - name: Build macOS ${{ matrix.arch }} - runs-on: macos-latest - strategy: - fail-fast: false - matrix: - include: - - platform: darwin/amd64 - arch: amd64 - - platform: darwin/arm64 - arch: arm64 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: "1.24.3" - check-latest: true - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install Wails - run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 - - - name: Build App - run: | - set -euo pipefail - OUTPUT_NAME="gonavi-test-${{ matrix.arch }}" - BUILD_LABEL="${{ inputs.build_label }}" - if [ -z "$BUILD_LABEL" ]; then - BUILD_LABEL="test" - fi - APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}" - wails build \ - -platform "${{ matrix.platform }}" \ - -clean \ - -o "$OUTPUT_NAME" \ - -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}" - - - name: Package Zip - run: | - set -euo pipefail - APP_PATH="build/bin/gonavi-test-${{ matrix.arch }}.app" - if [ ! -d "$APP_PATH" ]; then - APP_PATH=$(find build/bin -maxdepth 1 -name "*.app" | head -n 1 || true) - fi - if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then - echo "未找到 .app 产物" - ls -la build/bin || true - exit 1 - fi - LABEL="${{ inputs.build_label }}" - if [ -z "$LABEL" ]; then - LABEL="test" - fi - ZIP_NAME="GoNavi-${LABEL}-macos-${{ matrix.arch }}-run${GITHUB_RUN_NUMBER}.zip" - mkdir -p artifacts - ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "artifacts/$ZIP_NAME" - shasum -a 256 "artifacts/$ZIP_NAME" > "artifacts/$ZIP_NAME.sha256" - - - name: Upload Artifact - uses: actions/upload-artifact@v4 - with: - name: gonavi-macos-${{ matrix.arch }}-run${{ github.run_number }} - path: artifacts/* - if-no-files-found: error diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index efbd2b6..3018db7 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -6ba85e4f456d2c0d230cab198c7dc02b \ No newline at end of file +dcb87159cf0f1f6f750d1c4870911d3f \ No newline at end of file diff --git a/internal/ai/provider/claude_cli.go b/internal/ai/provider/claude_cli.go index 4cd9b00..4f5b64a 100644 --- a/internal/ai/provider/claude_cli.go +++ b/internal/ai/provider/claude_cli.go @@ -6,12 +6,16 @@ import ( "context" "encoding/json" "fmt" + "os" "os/exec" + "runtime" "strings" ai "GoNavi-Wails/internal/ai" ) +var claudeLookPath = exec.LookPath + // ClaudeCLIProvider 通过 Claude Code CLI 发送聊天请求 // 适用于 anyrouter/newapi 等只支持 Claude Code 协议的代理服务 type ClaudeCLIProvider struct { @@ -28,10 +32,13 @@ func (p *ClaudeCLIProvider) Name() string { } func (p *ClaudeCLIProvider) Validate() error { - _, err := exec.LookPath("claude") + _, err := claudeLookPath("claude") if err != nil { return fmt.Errorf("未找到 claude 命令,请先安装 Claude Code CLI: npm install -g @anthropic-ai/claude-code") } + if _, err := resolveClaudeCodeGitBashPath(os.Environ(), runtime.GOOS, claudeLookPath, fileExists); err != nil { + return err + } return nil } @@ -48,7 +55,9 @@ func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C } cmd := exec.CommandContext(ctx, "claude", args...) - p.setEnv(cmd) + if err := p.setEnv(cmd); err != nil { + return nil, err + } output, err := cmd.Output() if err != nil { @@ -85,7 +94,9 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, fmt.Printf("[ClaudeCLI DEBUG] Running: claude %v\n", args) cmd := exec.CommandContext(ctx, "claude", args...) - p.setEnv(cmd) + if err := p.setEnv(cmd); err != nil { + return err + } // 关闭 stdin,防止 claude CLI 等待输入 cmd.Stdin = nil @@ -174,16 +185,146 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, } // 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) +func (p *ClaudeCLIProvider) setEnv(cmd *exec.Cmd) error { + env, err := buildClaudeCLIEnv(p.config, cmd.Environ(), runtime.GOOS, claudeLookPath, fileExists) + if err != nil { + return err } cmd.Env = env + return nil +} + +func buildClaudeCLIEnv(config ai.ProviderConfig, baseEnv []string, goos string, lookPath func(string) (string, error), exists func(string) bool) ([]string, error) { + env := append([]string(nil), baseEnv...) + if config.BaseURL != "" { + env = upsertEnv(env, "ANTHROPIC_BASE_URL", strings.TrimRight(config.BaseURL, "/")) + } + if config.APIKey != "" { + env = upsertEnv(env, "ANTHROPIC_API_KEY", config.APIKey) + } + + gitBashPath, err := resolveClaudeCodeGitBashPath(env, goos, lookPath, exists) + if err != nil { + return nil, err + } + if gitBashPath != "" { + env = upsertEnv(env, "CLAUDE_CODE_GIT_BASH_PATH", gitBashPath) + } + return env, nil +} + +func resolveClaudeCodeGitBashPath(env []string, goos string, lookPath func(string) (string, error), exists func(string) bool) (string, error) { + if goos != "windows" { + return "", nil + } + + if configured := strings.TrimSpace(envValue(env, "CLAUDE_CODE_GIT_BASH_PATH")); configured != "" { + if exists(configured) { + return configured, nil + } + return "", fmt.Errorf("Claude Code CLI 在 Windows 下需要 git-bash,但 CLAUDE_CODE_GIT_BASH_PATH 指向的 bash.exe 不存在: %s", configured) + } + + for _, command := range []string{"bash.exe", "bash"} { + if bashPath, err := lookPath(command); err == nil && exists(bashPath) { + return bashPath, nil + } + } + + if gitPath, err := lookPath("git.exe"); err == nil { + gitDir := parentWindowsPath(gitPath) + for _, candidate := range []string{ + joinWindowsPath(parentWindowsPath(gitDir), "bin", "bash.exe"), + joinWindowsPath(gitDir, "bash.exe"), + } { + if candidate != "" && exists(candidate) { + return candidate, nil + } + } + } + + for _, candidate := range windowsGitBashCandidates(env) { + if exists(candidate) { + return candidate, nil + } + } + + return "", fmt.Errorf("Claude Code CLI 在 Windows 下需要 git-bash。请安装 Git for Windows(https://git-scm.com/downloads/win);如果已安装但未加入 PATH,请设置环境变量 CLAUDE_CODE_GIT_BASH_PATH 指向 bash.exe,例如 C:\\Program Files\\Git\\bin\\bash.exe") +} + +func windowsGitBashCandidates(env []string) []string { + candidates := make([]string, 0, 3) + for _, base := range []string{ + envValue(env, "ProgramFiles"), + envValue(env, "ProgramFiles(x86)"), + envValue(env, "LocalAppData"), + } { + base = strings.TrimSpace(base) + if base == "" { + continue + } + if strings.EqualFold(base, envValue(env, "LocalAppData")) { + candidates = append(candidates, joinWindowsPath(base, "Programs", "Git", "bin", "bash.exe")) + continue + } + candidates = append(candidates, joinWindowsPath(base, "Git", "bin", "bash.exe")) + } + return candidates +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + return strings.TrimPrefix(entry, prefix) + } + } + return "" +} + +func upsertEnv(env []string, key, value string) []string { + prefix := key + "=" + for i, entry := range env { + if strings.HasPrefix(entry, prefix) { + env[i] = prefix + value + return env + } + } + return append(env, prefix+value) +} + +func fileExists(path string) bool { + info, err := os.Stat(strings.TrimSpace(path)) + return err == nil && !info.IsDir() +} + +func joinWindowsPath(base string, parts ...string) string { + result := strings.TrimSpace(strings.ReplaceAll(base, "/", `\`)) + if result != "" { + result = strings.TrimRight(result, `\`) + } + + for _, part := range parts { + part = strings.Trim(strings.ReplaceAll(strings.TrimSpace(part), "/", `\`), `\`) + if part == "" { + continue + } + if result == "" { + result = part + continue + } + result += `\` + part + } + return result +} + +func parentWindowsPath(path string) string { + path = strings.TrimRight(strings.ReplaceAll(strings.TrimSpace(path), "/", `\`), `\`) + idx := strings.LastIndex(path, `\`) + if idx <= 0 { + return "" + } + return path[:idx] } // buildPrompt 将消息列表拼接为适合 claude -p 的提示文本 diff --git a/internal/ai/provider/claude_cli_test.go b/internal/ai/provider/claude_cli_test.go new file mode 100644 index 0000000..4bd3ccb --- /dev/null +++ b/internal/ai/provider/claude_cli_test.go @@ -0,0 +1,69 @@ +package provider + +import ( + "errors" + "strings" + "testing" + + "GoNavi-Wails/internal/ai" +) + +func TestBuildClaudeCLIEnv_IncludesAnthropicProxyEnv(t *testing.T) { + env, err := buildClaudeCLIEnv(ai.ProviderConfig{ + BaseURL: "https://proxy.example.com/", + APIKey: "sk-test", + }, []string{"PATH=/usr/bin"}, "darwin", func(name string) (string, error) { + return "", errors.New("unexpected lookup") + }, func(path string) bool { + return false + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got := envValue(env, "ANTHROPIC_BASE_URL"); got != "https://proxy.example.com" { + t.Fatalf("expected trimmed base url, got %q", got) + } + if got := envValue(env, "ANTHROPIC_API_KEY"); got != "sk-test" { + t.Fatalf("expected api key in env, got %q", got) + } +} + +func TestBuildClaudeCLIEnv_UsesDetectedGitBashOnWindows(t *testing.T) { + env, err := buildClaudeCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) { + switch name { + case "bash.exe": + return "", errors.New("not found") + case "bash": + return "", errors.New("not found") + case "git.exe": + return "C:\\Program Files\\Git\\cmd\\git.exe", nil + default: + return "", errors.New("unexpected lookup") + } + }, func(path string) bool { + return path == `C:\Program Files\Git\bin\bash.exe` + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got := envValue(env, "CLAUDE_CODE_GIT_BASH_PATH"); got != `C:\Program Files\Git\bin\bash.exe` { + t.Fatalf("expected detected git bash path, got %q", got) + } +} + +func TestBuildClaudeCLIEnv_ReturnsActionableErrorWhenGitBashMissingOnWindows(t *testing.T) { + _, err := buildClaudeCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) { + return "", errors.New("not found") + }, func(path string) bool { + return false + }) + if err == nil { + t.Fatal("expected error when git bash is missing on windows") + } + if !strings.Contains(err.Error(), "git-bash") { + t.Fatalf("expected git-bash hint, got %v", err) + } + if !strings.Contains(err.Error(), "CLAUDE_CODE_GIT_BASH_PATH") { + t.Fatalf("expected env var hint, got %v", err) + } +} From 37ac13b94efa02b7bd3c53819eb89bab8a298dd2 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Mar 2026 11:42:57 +0800 Subject: [PATCH 10/14] =?UTF-8?q?=F0=9F=90=9B=20fix(ai/wails-binding):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E7=94=9F=E6=88=90=E7=B1=BB=E5=9E=8B=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 收敛 App 与 AI Service 的内部生命周期方法,避免被 Wails 误导出到前端 - 将启动初始化改为包级生命周期接线,保持主程序启动流程不变 - 隐藏内部清理方法,移除生成绑定中的无效 context/time 类型声明 - 同步更新 frontend/wailsjs 绑定文件,清理 Service 与 App 的错误导出 - 调整相关测试调用,确保内部方法重命名后行为一致 --- frontend/wailsjs/go/aiservice/Service.d.ts | 3 --- frontend/wailsjs/go/aiservice/Service.js | 4 ---- frontend/wailsjs/go/app/App.d.ts | 6 ------ frontend/wailsjs/go/app/App.js | 8 -------- internal/ai/service/service.go | 15 ++++++++++----- internal/app/app.go | 15 ++++++++++----- internal/app/methods_db_cancel_test.go | 4 ++-- main.go | 4 ++-- 8 files changed, 24 insertions(+), 35 deletions(-) diff --git a/frontend/wailsjs/go/aiservice/Service.d.ts b/frontend/wailsjs/go/aiservice/Service.d.ts index 52dc1a6..54c462d 100755 --- a/frontend/wailsjs/go/aiservice/Service.d.ts +++ b/frontend/wailsjs/go/aiservice/Service.d.ts @@ -1,7 +1,6 @@ // 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; @@ -42,5 +41,3 @@ 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 index acebb37..42100f1 100755 --- a/frontend/wailsjs/go/aiservice/Service.js +++ b/frontend/wailsjs/go/aiservice/Service.js @@ -81,7 +81,3 @@ export function 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 2baf781..d8203ed 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -1,10 +1,8 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT 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; @@ -16,8 +14,6 @@ export function CheckDriverNetworkStatus():Promise; export function CheckForUpdates():Promise; -export function CleanupStaleQueries(arg1:time.Duration):Promise; - export function ConfigureDriverRuntimeDirectory(arg1:string):Promise; export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise; @@ -198,8 +194,6 @@ 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 f0e3782..8862f24 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -22,10 +22,6 @@ export function CheckForUpdates() { return window['go']['app']['App']['CheckForUpdates'](); } -export function CleanupStaleQueries(arg1) { - return window['go']['app']['App']['CleanupStaleQueries'](arg1); -} - export function ConfigureDriverRuntimeDirectory(arg1) { return window['go']['app']['App']['ConfigureDriverRuntimeDirectory'](arg1); } @@ -386,10 +382,6 @@ 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/internal/ai/service/service.go b/internal/ai/service/service.go index bf21638..e387441 100644 --- a/internal/ai/service/service.go +++ b/internal/ai/service/service.go @@ -31,7 +31,7 @@ type Service struct { safetyLevel ai.SQLPermissionLevel contextLevel ai.ContextLevel guard *safety.Guard - configDir string // 配置存储目录 + configDir string // 配置存储目录 cancelFuncs map[string]context.CancelFunc // 记录每个 session 的 context 取消函数 } @@ -56,8 +56,13 @@ func NewService() *Service { } } -// Startup Wails 生命周期回调 -func (s *Service) Startup(ctx context.Context) { +// InitializeLifecycle attaches runtime context without exposing lifecycle internals to Wails bindings. +func InitializeLifecycle(s *Service, ctx context.Context) { + s.startup(ctx) +} + +// startup Wails 生命周期回调 +func (s *Service) startup(ctx context.Context) { s.ctx = ctx s.configDir = resolveConfigDir() s.loadConfig() @@ -588,8 +593,8 @@ func (s *Service) AIChatSend(messages []ai.Message, tools []ai.Tool) map[string] } return map[string]interface{}{ - "success": true, - "content": resp.Content, + "success": true, + "content": resp.Content, "tool_calls": resp.ToolCalls, "tokensUsed": map[string]int{ "promptTokens": resp.TokensUsed.PromptTokens, diff --git a/internal/app/app.go b/internal/app/app.go index 8f6ae70..2b441a5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -64,9 +64,14 @@ func NewApp() *App { } } -// Startup is called when the app starts. The context is saved -// so we can call the runtime methods -func (a *App) Startup(ctx context.Context) { +// InitializeLifecycle attaches runtime context without exposing lifecycle internals to Wails bindings. +func InitializeLifecycle(a *App, ctx context.Context) { + a.startup(ctx) +} + +// startup is called when the app starts. The context is saved +// so we can call the runtime methods. +func (a *App) startup(ctx context.Context) { a.ctx = ctx a.startedAt = time.Now() logger.Init() @@ -700,8 +705,8 @@ func (a *App) CancelQuery(queryID string) connection.QueryResult { return connection.QueryResult{Success: false, Message: "查询不存在或已完成"} } -// CleanupStaleQueries removes queries older than maxAge -func (a *App) CleanupStaleQueries(maxAge time.Duration) { +// cleanupStaleQueries removes queries older than maxAge. +func (a *App) cleanupStaleQueries(maxAge time.Duration) { a.queryMu.Lock() defer a.queryMu.Unlock() diff --git a/internal/app/methods_db_cancel_test.go b/internal/app/methods_db_cancel_test.go index 29d7936..36ffe39 100644 --- a/internal/app/methods_db_cancel_test.go +++ b/internal/app/methods_db_cancel_test.go @@ -88,7 +88,7 @@ func TestCleanupStaleQueries(t *testing.T) { app.queryMu.Unlock() // Cleanup queries older than 1 hour - app.CleanupStaleQueries(1 * time.Hour) + app.cleanupStaleQueries(1 * time.Hour) // Verify stale query was removed app.queryMu.Lock() @@ -110,7 +110,7 @@ func TestCleanupStaleQueries(t *testing.T) { defer cancel2() // Cleanup queries older than 1 hour - app.CleanupStaleQueries(1 * time.Hour) + app.cleanupStaleQueries(1 * time.Hour) // Verify fresh query still exists app.queryMu.Lock() diff --git a/main.go b/main.go index 4e3bc59..8c4d999 100644 --- a/main.go +++ b/main.go @@ -34,8 +34,8 @@ func main() { }, BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 0}, OnStartup: func(ctx context.Context) { - application.Startup(ctx) - aiService.Startup(ctx) + app.InitializeLifecycle(application, ctx) + aiservice.InitializeLifecycle(aiService, ctx) }, OnShutdown: application.Shutdown, Bind: []interface{}{ From a5fdfefa2d9875b357f99f1a0166f4f9fccd5333 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Mar 2026 12:04:55 +0800 Subject: [PATCH 11/14] =?UTF-8?q?=F0=9F=90=9B=20fix(ai/volcengine):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=81=AB=E5=B1=B1=E5=BC=95=E6=93=8E=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E8=B7=AF=E5=BE=84=E5=B9=B6=E6=8B=86=E5=88=86=E5=8F=8C?= =?UTF-8?q?=E9=A2=84=E8=AE=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OpenAI 兼容 URL 归一化改为保留已有 v3 和 v4 版本段,避免火山与智谱地址被错误补 /v1 - 对误填 /chat/completions 和 /models 的地址先回退到 base URL,再拼接目标端点 - 模型列表与连通性检测复用统一端点解析逻辑,修复火山 Coding Plan 等兼容服务请求 - AI 设置页拆分火山方舟与火山 Coding Plan 两个预设,并按完整路径精确匹配回显 - 修正模型下拉默认值行为,未选模型时保持占位态,避免误用动态列表首项 - 补充 provider 与 service 回归测试,并新增需求追踪文档 --- frontend/src/components/AISettingsModal.tsx | 24 ++++++- frontend/src/components/ai/AIChatInput.tsx | 2 +- frontend/wailsjs/runtime/package.json | 0 frontend/wailsjs/runtime/runtime.d.ts | 0 frontend/wailsjs/runtime/runtime.js | 0 internal/ai/provider/helper.go | 71 +++++++++++++++++++++ internal/ai/provider/openai.go | 16 +---- internal/ai/provider/openai_test.go | 70 ++++++++++++++++++++ internal/ai/service/service.go | 8 +-- internal/ai/service/service_test.go | 37 +++++++++++ 10 files changed, 206 insertions(+), 22 deletions(-) mode change 100644 => 100755 frontend/wailsjs/runtime/package.json mode change 100644 => 100755 frontend/wailsjs/runtime/runtime.d.ts mode change 100644 => 100755 frontend/wailsjs/runtime/runtime.js diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 83378d9..8f6436c 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -33,7 +33,8 @@ const PROVIDER_PRESETS: ProviderPreset[] = [ { key: 'moonshot', label: 'Kimi', icon: , desc: 'Kimi K2.5 (Anthropic 兼容)', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] }, { key: 'anthropic', label: 'Claude', icon: , desc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] }, { key: 'gemini', label: 'Gemini', icon: , desc: 'Gemini 3.1 / 2.5 系列', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: [] }, - { key: 'volcengine', label: '火山引擎', icon: , desc: '火山方舟 / 豆包大模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: 'ep-xxxxxx', models: [] }, + { key: 'volcengine-ark', label: '火山方舟', icon: , desc: 'Ark 通用推理 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] }, + { key: 'volcengine-coding', label: '火山 Coding Plan', icon: , desc: 'Ark Code / Coding Plan', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] }, { key: 'minimax', label: 'MiniMax', icon: , desc: 'M2.7 / M2.5 系列 (Anthropic 兼容)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M2.7', models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2'] }, { key: 'ollama', label: 'Ollama', icon: , desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] }, { key: 'custom', label: '自定义', icon: , desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] }, @@ -50,7 +51,28 @@ const getProviderHostname = (raw?: string): string => { } }; +const getProviderFingerprint = (raw?: string): string => { + if (!raw) return ''; + try { + const url = new URL(raw); + const normalizedPath = url.pathname.replace(/\/+$/, '').toLowerCase(); + return `${url.hostname.toLowerCase()}${normalizedPath}`; + } catch { + return ''; + } +}; + const matchProviderPreset = (provider: Pick): ProviderPreset => { + const fingerprint = getProviderFingerprint(provider.baseUrl); + const exactPreset = PROVIDER_PRESETS.find(pr => + pr.backendType === provider.type + && fingerprint !== '' + && fingerprint === getProviderFingerprint(pr.defaultBaseUrl) + ); + if (exactPreset) { + return exactPreset; + } + const host = getProviderHostname(provider.baseUrl); if (host.endsWith('moonshot.cn')) { return findPreset('moonshot'); diff --git a/frontend/src/components/ai/AIChatInput.tsx b/frontend/src/components/ai/AIChatInput.tsx index ec78375..3dbd216 100644 --- a/frontend/src/components/ai/AIChatInput.tsx +++ b/frontend/src/components/ai/AIChatInput.tsx @@ -354,7 +354,7 @@ export const AIChatInput: React.FC = ({