From 98e9e5686d08f0abfc3709ba671854aade26900d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 26 Mar 2026 16:02:08 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai):=20=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=85=A8=E6=96=B0=20AI=20Copilot=20=E5=8A=A9=E6=89=8B=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E4=B8=8E=E5=B7=A5=E4=BD=9C=E5=8C=BA=E6=99=BA=E8=83=BD?= =?UTF-8?q?=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 配置