mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 12:19:47 +08:00
Compare commits
27 Commits
v0.6.3
...
fix/window
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa9d8d243a | ||
|
|
6e55d63877 | ||
|
|
c126c4b731 | ||
|
|
c85de27aac | ||
|
|
eeef0f06ed | ||
|
|
fcd4d4026c | ||
|
|
a7bee7f3b6 | ||
|
|
ed4a7b96d4 | ||
|
|
09d013f27d | ||
|
|
09aa526570 | ||
|
|
5844cd7c01 | ||
|
|
4f74c44147 | ||
|
|
a5fdfefa2d | ||
|
|
37ac13b94e | ||
|
|
d4d685b076 | ||
|
|
9f6d524e3d | ||
|
|
a89289f1cc | ||
|
|
b958ff6481 | ||
|
|
98e9e5686d | ||
|
|
93446e060e | ||
|
|
ecc8ff1197 | ||
|
|
82369b4070 | ||
|
|
1bda751ada | ||
|
|
7bc358d612 | ||
|
|
36a57f9601 | ||
|
|
e85c561f1e | ||
|
|
2677364d0e |
607
.github/workflows/dev-build.yml
vendored
Normal file
607
.github/workflows/dev-build.yml
vendored
Normal file
@@ -0,0 +1,607 @@
|
||||
name: Dev Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: darwin/amd64
|
||||
os_name: MacOS
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-build-darwin-amd64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: macos-latest
|
||||
platform: darwin/arm64
|
||||
os_name: MacOS
|
||||
arch_name: Arm64
|
||||
build_name: gonavi-build-darwin-arm64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: windows-latest
|
||||
platform: windows/amd64
|
||||
os_name: Windows
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-build-windows-amd64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: windows-latest
|
||||
platform: windows/arm64
|
||||
os_name: Windows
|
||||
arch_name: Arm64
|
||||
build_name: gonavi-build-windows-arm64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: ubuntu-22.04
|
||||
platform: linux/amd64
|
||||
os_name: Linux
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-build-linux-amd64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: "4.0"
|
||||
- os: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
os_name: Linux
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-build-linux-amd64-webkit41
|
||||
wails_tags: "webkit2_41"
|
||||
artifact_suffix: "-WebKit41"
|
||||
build_optional_agents: false
|
||||
linux_webkit: "4.1"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install UPX (Windows)
|
||||
if: contains(matrix.platform, 'windows')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$UPX_VERSION = "4.2.4"
|
||||
$url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip"
|
||||
$zipPath = "$env:RUNNER_TEMP\upx.zip"
|
||||
$extractPath = "$env:RUNNER_TEMP\upx"
|
||||
Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..."
|
||||
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
|
||||
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
|
||||
$upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1
|
||||
"$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
$upxCmd = Join-Path $upxDir.FullName "upx.exe"
|
||||
if (!(Test-Path $upxCmd)) {
|
||||
Write-Error "❌ 未检测到 upx,无法保证 Windows 产物经过压缩"
|
||||
exit 1
|
||||
}
|
||||
& $upxCmd --version
|
||||
|
||||
- name: Install Linux Dependencies
|
||||
if: contains(matrix.platform, 'linux')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev
|
||||
|
||||
if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev
|
||||
else
|
||||
sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||
fi
|
||||
|
||||
sudo apt-get install -y upx-ucl || sudo apt-get install -y upx
|
||||
upx --version
|
||||
|
||||
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
|
||||
|
||||
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
|
||||
|
||||
echo "📥 下载 linuxdeploy..."
|
||||
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \
|
||||
-O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || {
|
||||
echo "⚠️ linuxdeploy 下载失败,AppImage 打包将跳过"
|
||||
touch /tmp/skip-appimage
|
||||
}
|
||||
|
||||
echo "📥 下载 linuxdeploy-plugin-gtk..."
|
||||
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \
|
||||
-O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || {
|
||||
echo "⚠️ linuxdeploy-plugin-gtk 下载失败,AppImage 打包将跳过"
|
||||
touch /tmp/skip-appimage
|
||||
}
|
||||
|
||||
if [ ! -f /tmp/skip-appimage ]; then
|
||||
chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk
|
||||
echo "✅ AppImage 工具准备完成"
|
||||
fi
|
||||
|
||||
- name: Install Wails
|
||||
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
|
||||
- name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64)
|
||||
id: msys2_duckdb
|
||||
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||
continue-on-error: true
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
install: >-
|
||||
mingw-w64-ucrt-x86_64-gcc
|
||||
|
||||
- name: Configure DuckDB CGO Toolchain (Windows AMD64)
|
||||
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
function Find-MingwBin([string[]]$candidates) {
|
||||
foreach ($bin in $candidates) {
|
||||
if ([string]::IsNullOrWhiteSpace($bin)) {
|
||||
continue
|
||||
}
|
||||
$gcc = Join-Path $bin 'gcc.exe'
|
||||
$gxx = Join-Path $bin 'g++.exe'
|
||||
if ((Test-Path $gcc) -and (Test-Path $gxx)) {
|
||||
return $bin
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$msys2Outcome = "${{ steps.msys2_duckdb.outcome }}"
|
||||
$msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}"
|
||||
$candidateBins = @()
|
||||
if (-not [string]::IsNullOrWhiteSpace($msys2Location)) {
|
||||
$candidateBins += Join-Path $msys2Location 'ucrt64\bin'
|
||||
}
|
||||
$candidateBins += @(
|
||||
'C:\msys64\ucrt64\bin',
|
||||
'D:\a\_temp\msys64\ucrt64\bin'
|
||||
)
|
||||
$candidateBins = @($candidateBins | Select-Object -Unique)
|
||||
|
||||
$mingwBin = Find-MingwBin $candidateBins
|
||||
if (-not $mingwBin) {
|
||||
if ($msys2Outcome -ne 'success') {
|
||||
Write-Warning "⚠️ MSYS2 安装步骤结果为 $msys2Outcome,回退到 UCRT64 本机路径探测"
|
||||
} else {
|
||||
Write-Warning "⚠️ MSYS2 已执行,但未找到 UCRT64 gcc/g++,回退到本机路径探测"
|
||||
}
|
||||
$mingwBin = Find-MingwBin $candidateBins
|
||||
}
|
||||
|
||||
if (-not $mingwBin) {
|
||||
Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。已检查:$($candidateBins -join ', ')"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$gcc = (Join-Path $mingwBin 'gcc.exe')
|
||||
$gxx = (Join-Path $mingwBin 'g++.exe')
|
||||
|
||||
if (!(Test-Path $gcc) -or !(Test-Path $gxx)) {
|
||||
Write-Error "❌ DuckDB 编译器缺失:gcc=$gcc g++=$gxx"
|
||||
exit 1
|
||||
}
|
||||
|
||||
"$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
"CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Host "✅ 已配置 DuckDB cgo 编译器: gcc=$gcc g++=$gxx"
|
||||
|
||||
- name: Verify DuckDB CGO Toolchain (Windows AMD64)
|
||||
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
& "$env:CC" --version
|
||||
& "$env:CXX" --version
|
||||
|
||||
# ---- 生成 dev 版本号 ----
|
||||
- name: Generate Dev Version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
DEV_VERSION="dev-${SHORT_SHA}"
|
||||
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "📌 Dev 版本号: ${DEV_VERSION}"
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEV_VERSION="${{ steps.version.outputs.version }}"
|
||||
if [ -n "${{ matrix.wails_tags }}" ]; then
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}"
|
||||
else
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}"
|
||||
fi
|
||||
|
||||
- name: Build Optional Driver Agents
|
||||
if: ${{ matrix.build_optional_agents }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET_PLATFORM="${{ matrix.platform }}"
|
||||
GOOS="${TARGET_PLATFORM%%/*}"
|
||||
GOARCH="${TARGET_PLATFORM##*/}"
|
||||
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
|
||||
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
for DRIVER in "${DRIVERS[@]}"; do
|
||||
BUILD_DRIVER="$DRIVER"
|
||||
if [ "$DRIVER" = "doris" ]; then
|
||||
BUILD_DRIVER="diros"
|
||||
fi
|
||||
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
|
||||
echo "⚠️ 跳过 DuckDB driver(当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64)"
|
||||
continue
|
||||
fi
|
||||
TAG="gonavi_${BUILD_DRIVER}_driver"
|
||||
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
OUTPUT="${OUTPUT}.exe"
|
||||
fi
|
||||
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
|
||||
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
|
||||
if [ "$DRIVER" = "duckdb" ]; then
|
||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${TAG}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
else
|
||||
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${TAG}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
fi
|
||||
done
|
||||
|
||||
# macOS Packaging
|
||||
- name: Package macOS DMG
|
||||
if: contains(matrix.platform, 'darwin')
|
||||
run: |
|
||||
brew install create-dmg
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
cd build/bin
|
||||
|
||||
APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1)
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "❌ 未找到 .app 应用包!"
|
||||
exit 1
|
||||
fi
|
||||
APP_NAME=$(basename "$APP_PATH")
|
||||
|
||||
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
|
||||
if [ -z "$APP_BIN" ]; then
|
||||
echo "❌ 未找到 macOS 应用主程序!"
|
||||
exit 1
|
||||
fi
|
||||
echo "ℹ️ macOS 产物不执行 UPX 压缩,保留原始主程序。"
|
||||
|
||||
echo "🔏 正在进行 Ad-hoc 签名..."
|
||||
codesign --force --deep --sign - "$APP_NAME"
|
||||
|
||||
DMG_NAME="${{ matrix.build_name }}.dmg"
|
||||
FINAL_NAME="GoNavi-${VERSION}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg"
|
||||
echo "📦 正在生成 DMG: $DMG_NAME..."
|
||||
|
||||
create-dmg \
|
||||
--volname "GoNavi Dev Build" \
|
||||
--window-pos 200 120 \
|
||||
--window-size 800 400 \
|
||||
--icon-size 100 \
|
||||
--icon "$APP_NAME" 200 190 \
|
||||
--hide-extension "$APP_NAME" \
|
||||
--app-drop-link 600 185 \
|
||||
"$DMG_NAME" \
|
||||
"$APP_NAME"
|
||||
|
||||
mv "$DMG_NAME" "../../$FINAL_NAME"
|
||||
|
||||
# Windows Packaging
|
||||
- name: Package Windows EXE
|
||||
if: contains(matrix.platform, 'windows')
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-Location build/bin
|
||||
$version = "${{ steps.version.outputs.version }}"
|
||||
$target = "${{ matrix.build_name }}"
|
||||
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
|
||||
|
||||
if (Test-Path "$target.exe") {
|
||||
$finalExe = "$target.exe"
|
||||
} elseif (Test-Path "$target") {
|
||||
Rename-Item -Path "$target" -NewName "$target.exe"
|
||||
$finalExe = "$target.exe"
|
||||
} else {
|
||||
Write-Error "❌ 未找到构建产物 '$target'!"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64"
|
||||
if ($isArm64Target) {
|
||||
Write-Warning "⚠️ UPX 当前不支持 win64/arm64,跳过压缩并保留原始 EXE。"
|
||||
$LASTEXITCODE = 0
|
||||
} else {
|
||||
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
|
||||
if ($null -eq $upxCmd) {
|
||||
Write-Error "❌ 未找到 upx,无法保证 Windows 产物经过压缩"
|
||||
exit 1
|
||||
}
|
||||
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
|
||||
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
|
||||
& upx --best --lzma --force $finalExe | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "❌ UPX 压缩失败($LASTEXITCODE)"
|
||||
exit 1
|
||||
}
|
||||
& upx -t $finalExe | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "❌ UPX 校验失败($LASTEXITCODE)"
|
||||
exit 1
|
||||
}
|
||||
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
|
||||
if ($afterBytes -lt $beforeBytes) {
|
||||
$savedBytes = $beforeBytes - $afterBytes
|
||||
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB,减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
|
||||
} else {
|
||||
Write-Host ("ℹ️ UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."
|
||||
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
|
||||
|
||||
# Linux Packaging
|
||||
- name: Package Linux
|
||||
if: contains(matrix.platform, 'linux')
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
cd build/bin
|
||||
TARGET="${{ matrix.build_name }}"
|
||||
TAR_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.tar.gz"
|
||||
APPIMAGE_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.AppImage"
|
||||
|
||||
if [ ! -f "$TARGET" ]; then
|
||||
echo "❌ 未找到构建产物 '$TARGET'!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x "$TARGET"
|
||||
BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
|
||||
echo "🗜️ 正在使用 UPX 压缩 Linux 可执行文件: $TARGET ..."
|
||||
upx --best --lzma --force "$TARGET"
|
||||
upx -t "$TARGET"
|
||||
AFTER_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
|
||||
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
|
||||
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
|
||||
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ Linux UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
|
||||
else
|
||||
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf "ℹ️ Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
|
||||
fi
|
||||
|
||||
echo "📦 正在打包 $TAR_NAME..."
|
||||
tar -czvf "$TAR_NAME" "$TARGET"
|
||||
mv "$TAR_NAME" ../../
|
||||
|
||||
if [ -f /tmp/skip-appimage ]; then
|
||||
echo "⚠️ 跳过 AppImage 打包"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "📦 正在生成 AppImage..."
|
||||
mkdir -p AppDir/usr/bin
|
||||
mkdir -p AppDir/usr/share/applications
|
||||
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
|
||||
|
||||
cp "$TARGET" AppDir/usr/bin/gonavi
|
||||
|
||||
printf '%s\n' \
|
||||
'[Desktop Entry]' \
|
||||
'Name=GoNavi' \
|
||||
'Exec=gonavi' \
|
||||
'Icon=gonavi' \
|
||||
'Type=Application' \
|
||||
'Categories=Development;Database;' \
|
||||
'Comment=Database Management Tool' \
|
||||
> AppDir/usr/share/applications/gonavi.desktop
|
||||
|
||||
cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop
|
||||
|
||||
if [ -f "../../build/appicon.png" ]; then
|
||||
cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
|
||||
cp "../../build/appicon.png" AppDir/gonavi.png
|
||||
else
|
||||
convert -size 256x256 xc:#336791 -fill white -gravity center -pointsize 48 -annotate 0 "GoNavi" AppDir/gonavi.png || \
|
||||
wget -q "https://via.placeholder.com/256/336791/FFFFFF?text=GoNavi" -O AppDir/gonavi.png || \
|
||||
touch AppDir/gonavi.png
|
||||
cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
|
||||
fi
|
||||
|
||||
export DEPLOY_GTK_VERSION=3
|
||||
/tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || {
|
||||
echo "⚠️ AppImage 生成失败,但 tar.gz 已成功生成"
|
||||
exit 0
|
||||
}
|
||||
|
||||
mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || {
|
||||
echo "⚠️ AppImage 重命名失败"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if [ -f "$APPIMAGE_NAME" ]; then
|
||||
mv "$APPIMAGE_NAME" ../../
|
||||
echo "✅ AppImage 生成成功"
|
||||
fi
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dev-build-artifacts-${{ strategy.job-index }}
|
||||
path: |
|
||||
GoNavi-*.dmg
|
||||
GoNavi-*.exe
|
||||
GoNavi-*.tar.gz
|
||||
GoNavi-*.AppImage
|
||||
drivers/**
|
||||
retention-days: 7
|
||||
|
||||
# 汇总所有产物并发布为 Pre-release
|
||||
release:
|
||||
name: Publish Dev Pre-release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download All Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: release-assets
|
||||
pattern: dev-build-artifacts-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: List Assets
|
||||
run: ls -R release-assets
|
||||
|
||||
- name: Package Driver Agents Bundle
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release-assets
|
||||
if [ ! -d drivers ]; then
|
||||
echo "⚠️ 未找到 drivers 目录,跳过驱动总包打包"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$(find drivers -type f 2>/dev/null | head -n 1)" ]; then
|
||||
echo "⚠️ drivers 目录为空,跳过驱动总包打包"
|
||||
rm -rf drivers
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "📦 打包驱动总包:GoNavi-DriverAgents.zip"
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
out_name = "GoNavi-DriverAgents.zip"
|
||||
index_name = "GoNavi-DriverAgents-Index.json"
|
||||
base = Path("drivers")
|
||||
out_path = Path(out_name)
|
||||
index_path = Path(index_name)
|
||||
if out_path.exists():
|
||||
out_path.unlink()
|
||||
if index_path.exists():
|
||||
index_path.unlink()
|
||||
|
||||
size_index = {}
|
||||
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for p in base.rglob("*"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
arcname = p.relative_to(base).as_posix()
|
||||
zf.write(p, arcname)
|
||||
size_index[p.name] = p.stat().st_size
|
||||
|
||||
index_path.write_text(
|
||||
json.dumps({"assets": size_index}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print(f"created {out_name} size={out_path.stat().st_size} bytes")
|
||||
print(f"created {index_name} entries={len(size_index)}")
|
||||
PY
|
||||
|
||||
rm -rf drivers
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
shell: bash
|
||||
run: |
|
||||
cd release-assets
|
||||
FILES=()
|
||||
while IFS= read -r file; do
|
||||
if [ -n "$file" ]; then
|
||||
FILES+=("$file")
|
||||
fi
|
||||
done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort)
|
||||
if [ ${#FILES[@]} -eq 0 ]; then
|
||||
echo "⚠️ 未找到可签名资产,生成空 SHA256SUMS"
|
||||
: > SHA256SUMS
|
||||
else
|
||||
sha256sum "${FILES[@]}" > SHA256SUMS
|
||||
fi
|
||||
|
||||
- name: Generate Dev Version
|
||||
id: version
|
||||
run: |
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
DEV_VERSION="dev-${SHORT_SHA}"
|
||||
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 删除旧的 dev pre-release(保持只有最新一个)
|
||||
- name: Delete Previous Dev Release
|
||||
uses: dev-drprasad/delete-tag-and-release@v1.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
tag_name: dev-latest
|
||||
delete_release: true
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create Dev Pre-release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: dev-latest
|
||||
name: "🧪 Dev Build (${{ steps.version.outputs.version }})"
|
||||
target_commitish: ${{ github.sha }}
|
||||
files: release-assets/*
|
||||
prerelease: true
|
||||
draft: false
|
||||
body: |
|
||||
## 🧪 测试版本 (Dev Build)
|
||||
|
||||
**版本**: `${{ steps.version.outputs.version }}`
|
||||
**分支**: `dev`
|
||||
**提交**: [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})
|
||||
**构建时间**: ${{ github.event.head_commit.timestamp }}
|
||||
|
||||
> ⚠️ 这是开发测试版本,仅供内部测试使用,不建议用于生产环境。
|
||||
> 每次 push 到 `dev` 分支会自动覆盖此 release。
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
412
.github/workflows/test-build-all-platforms.yml
vendored
412
.github/workflows/test-build-all-platforms.yml
vendored
@@ -1,412 +0,0 @@
|
||||
name: Test Build All Platforms (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_label:
|
||||
description: "测试包标识(仅用于文件名)"
|
||||
required: false
|
||||
default: "test"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
concurrency:
|
||||
group: test-build-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: darwin/amd64
|
||||
os_name: MacOS
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-test-darwin-amd64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: macos-latest
|
||||
platform: darwin/arm64
|
||||
os_name: MacOS
|
||||
arch_name: Arm64
|
||||
build_name: gonavi-test-darwin-arm64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: windows-latest
|
||||
platform: windows/amd64
|
||||
os_name: Windows
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-test-windows-amd64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: windows-latest
|
||||
platform: windows/arm64
|
||||
os_name: Windows
|
||||
arch_name: Arm64
|
||||
build_name: gonavi-test-windows-arm64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: ubuntu-22.04
|
||||
platform: linux/amd64
|
||||
os_name: Linux
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-test-linux-amd64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: "4.0"
|
||||
- os: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
os_name: Linux
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-test-linux-amd64-webkit41
|
||||
wails_tags: "webkit2_41"
|
||||
artifact_suffix: "-WebKit41"
|
||||
build_optional_agents: false
|
||||
linux_webkit: "4.1"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install UPX (Windows)
|
||||
if: contains(matrix.platform, 'windows')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$UPX_VERSION = "4.2.4"
|
||||
$url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip"
|
||||
$zipPath = "$env:RUNNER_TEMP\upx.zip"
|
||||
$extractPath = "$env:RUNNER_TEMP\upx"
|
||||
Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..."
|
||||
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
|
||||
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
|
||||
$upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1
|
||||
"$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
$upxCmd = Join-Path $upxDir.FullName "upx.exe"
|
||||
if (!(Test-Path $upxCmd)) {
|
||||
Write-Error "❌ 未检测到 upx,无法保证 Windows 测试产物经过压缩"
|
||||
exit 1
|
||||
}
|
||||
& $upxCmd --version
|
||||
|
||||
- name: Install Linux Dependencies
|
||||
if: contains(matrix.platform, 'linux')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev
|
||||
|
||||
if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev
|
||||
else
|
||||
sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||
fi
|
||||
|
||||
sudo apt-get install -y upx-ucl || sudo apt-get install -y upx
|
||||
upx --version
|
||||
|
||||
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
|
||||
|
||||
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
|
||||
|
||||
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || {
|
||||
echo "skip-appimage=true" >> "$GITHUB_ENV"
|
||||
}
|
||||
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || {
|
||||
echo "skip-appimage=true" >> "$GITHUB_ENV"
|
||||
}
|
||||
|
||||
if [ "${skip-appimage:-false}" != "true" ]; then
|
||||
chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk
|
||||
fi
|
||||
|
||||
- name: Install Wails
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
|
||||
|
||||
- name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64)
|
||||
id: msys2_duckdb
|
||||
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||
continue-on-error: true
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
install: >-
|
||||
mingw-w64-ucrt-x86_64-gcc
|
||||
|
||||
- name: Configure DuckDB CGO Toolchain (Windows AMD64)
|
||||
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
function Find-MingwBin([string[]]$candidates) {
|
||||
foreach ($bin in $candidates) {
|
||||
if ([string]::IsNullOrWhiteSpace($bin)) {
|
||||
continue
|
||||
}
|
||||
$gcc = Join-Path $bin 'gcc.exe'
|
||||
$gxx = Join-Path $bin 'g++.exe'
|
||||
if ((Test-Path $gcc) -and (Test-Path $gxx)) {
|
||||
return $bin
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}"
|
||||
$candidateBins = @()
|
||||
if (-not [string]::IsNullOrWhiteSpace($msys2Location)) {
|
||||
$candidateBins += Join-Path $msys2Location 'ucrt64\bin'
|
||||
}
|
||||
$candidateBins += @(
|
||||
'C:\msys64\ucrt64\bin',
|
||||
'D:\a\_temp\msys64\ucrt64\bin'
|
||||
)
|
||||
$candidateBins = @($candidateBins | Select-Object -Unique)
|
||||
|
||||
$mingwBin = Find-MingwBin $candidateBins
|
||||
if (-not $mingwBin) {
|
||||
Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$gcc = Join-Path $mingwBin 'gcc.exe'
|
||||
$gxx = Join-Path $mingwBin 'g++.exe'
|
||||
"$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
"CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Build App
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUILD_LABEL="${{ inputs.build_label }}"
|
||||
if [ -z "$BUILD_LABEL" ]; then
|
||||
BUILD_LABEL="test"
|
||||
fi
|
||||
APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}"
|
||||
if [ -n "${{ matrix.wails_tags }}" ]; then
|
||||
wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
|
||||
else
|
||||
wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
|
||||
fi
|
||||
|
||||
- name: Build Optional Driver Agents
|
||||
if: ${{ matrix.build_optional_agents }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET_PLATFORM="${{ matrix.platform }}"
|
||||
GOOS="${TARGET_PLATFORM%%/*}"
|
||||
GOARCH="${TARGET_PLATFORM##*/}"
|
||||
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
|
||||
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
for DRIVER in "${DRIVERS[@]}"; do
|
||||
BUILD_DRIVER="$DRIVER"
|
||||
if [ "$DRIVER" = "doris" ]; then
|
||||
BUILD_DRIVER="diros"
|
||||
fi
|
||||
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
|
||||
echo "跳过 DuckDB driver: ${GOOS}/${GOARCH}"
|
||||
continue
|
||||
fi
|
||||
TAG="gonavi_${BUILD_DRIVER}_driver"
|
||||
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
OUTPUT="${OUTPUT}.exe"
|
||||
fi
|
||||
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
|
||||
if [ "$DRIVER" = "duckdb" ]; then
|
||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent
|
||||
else
|
||||
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Package macOS
|
||||
if: contains(matrix.platform, 'darwin')
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
brew install create-dmg
|
||||
LABEL="${{ inputs.build_label }}"
|
||||
if [ -z "$LABEL" ]; then
|
||||
LABEL="test"
|
||||
fi
|
||||
cd build/bin
|
||||
APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1)
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "未找到 .app 应用包"
|
||||
exit 1
|
||||
fi
|
||||
APP_NAME=$(basename "$APP_PATH")
|
||||
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
|
||||
if [ -z "$APP_BIN" ]; then
|
||||
echo "未找到 macOS 应用主程序"
|
||||
exit 1
|
||||
fi
|
||||
echo "ℹ️ macOS 产物不执行 UPX 压缩,保留原始主程序。"
|
||||
codesign --force --deep --sign - "$APP_NAME"
|
||||
ZIP_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.zip"
|
||||
DMG_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.dmg"
|
||||
mkdir -p ../../artifacts
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "../../artifacts/$ZIP_NAME"
|
||||
create-dmg \
|
||||
--volname "GoNavi Test Installer" \
|
||||
--window-pos 200 120 \
|
||||
--window-size 800 400 \
|
||||
--icon-size 100 \
|
||||
--icon "$APP_NAME" 200 190 \
|
||||
--hide-extension "$APP_NAME" \
|
||||
--app-drop-link 600 185 \
|
||||
"$DMG_NAME" \
|
||||
"$APP_NAME"
|
||||
mv "$DMG_NAME" "../../artifacts/$DMG_NAME"
|
||||
shasum -a 256 "../../artifacts/$ZIP_NAME" > "../../artifacts/$ZIP_NAME.sha256"
|
||||
shasum -a 256 "../../artifacts/$DMG_NAME" > "../../artifacts/$DMG_NAME.sha256"
|
||||
|
||||
- name: Package Windows
|
||||
if: contains(matrix.platform, 'windows')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$label = "${{ inputs.build_label }}"
|
||||
if ([string]::IsNullOrWhiteSpace($label)) { $label = 'test' }
|
||||
Set-Location build/bin
|
||||
$target = "${{ matrix.build_name }}"
|
||||
$finalExeName = "GoNavi-$label-${{ matrix.os_name }}-${{ matrix.arch_name }}-run$env:GITHUB_RUN_NUMBER.exe"
|
||||
if (Test-Path "$target.exe") {
|
||||
$finalExe = "$target.exe"
|
||||
} elseif (Test-Path "$target") {
|
||||
Rename-Item -Path "$target" -NewName "$target.exe"
|
||||
$finalExe = "$target.exe"
|
||||
} else {
|
||||
Write-Error "未找到构建产物 '$target'"
|
||||
exit 1
|
||||
}
|
||||
$isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64"
|
||||
if ($isArm64Target) {
|
||||
Write-Warning "⚠️ UPX 当前不支持 win64/arm64,跳过压缩并保留原始 EXE。"
|
||||
$LASTEXITCODE = 0
|
||||
} else {
|
||||
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
|
||||
if ($null -eq $upxCmd) {
|
||||
Write-Error "❌ 未找到 upx,无法保证 Windows 测试产物经过压缩"
|
||||
exit 1
|
||||
}
|
||||
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
|
||||
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
|
||||
& upx --best --lzma --force $finalExe | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "❌ UPX 压缩失败($LASTEXITCODE)"
|
||||
exit 1
|
||||
}
|
||||
& upx -t $finalExe | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "❌ UPX 校验失败($LASTEXITCODE)"
|
||||
exit 1
|
||||
}
|
||||
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
|
||||
if ($afterBytes -lt $beforeBytes) {
|
||||
$savedBytes = $beforeBytes - $afterBytes
|
||||
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB,减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
|
||||
} else {
|
||||
Write-Host ("ℹ️ UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
|
||||
}
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path ..\..\artifacts | Out-Null
|
||||
Copy-Item -LiteralPath $finalExe -Destination "..\..\artifacts\$finalExeName" -Force
|
||||
Get-FileHash "..\..\artifacts\$finalExeName" -Algorithm SHA256 | ForEach-Object { "{0} *{1}" -f $_.Hash.ToLower(), (Split-Path $_.Path -Leaf) } | Out-File "..\..\artifacts\$finalExeName.sha256" -Encoding ascii
|
||||
|
||||
- name: Package Linux
|
||||
if: contains(matrix.platform, 'linux')
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LABEL="${{ inputs.build_label }}"
|
||||
if [ -z "$LABEL" ]; then
|
||||
LABEL="test"
|
||||
fi
|
||||
cd build/bin
|
||||
TARGET="${{ matrix.build_name }}"
|
||||
TAR_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.tar.gz"
|
||||
APPIMAGE_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.AppImage"
|
||||
mkdir -p ../../artifacts
|
||||
|
||||
if [ ! -f "$TARGET" ]; then
|
||||
echo "未找到构建产物 '$TARGET'"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$TARGET"
|
||||
BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
|
||||
echo "🗜️ 使用 UPX 压缩 Linux 可执行文件: $TARGET ..."
|
||||
upx --best --lzma --force "$TARGET"
|
||||
upx -t "$TARGET"
|
||||
AFTER_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
|
||||
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
|
||||
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
|
||||
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ Linux UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
|
||||
else
|
||||
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf "ℹ️ Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
|
||||
fi
|
||||
tar -czvf "../../artifacts/$TAR_NAME" "$TARGET"
|
||||
sha256sum "../../artifacts/$TAR_NAME" > "../../artifacts/$TAR_NAME.sha256"
|
||||
|
||||
if [ "${skip-appimage:-false}" = "true" ]; then
|
||||
echo "跳过 AppImage 打包"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p AppDir/usr/bin AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps
|
||||
cp "$TARGET" AppDir/usr/bin/gonavi
|
||||
printf '%s\n' '[Desktop Entry]' 'Name=GoNavi' 'Exec=gonavi' 'Icon=gonavi' 'Type=Application' 'Categories=Development;Database;' 'Comment=Database Management Tool' > AppDir/usr/share/applications/gonavi.desktop
|
||||
cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop
|
||||
if [ -f "../../build/appicon.png" ]; then
|
||||
cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
|
||||
cp "../../build/appicon.png" AppDir/gonavi.png
|
||||
else
|
||||
touch AppDir/gonavi.png
|
||||
cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
|
||||
fi
|
||||
export DEPLOY_GTK_VERSION=3
|
||||
/tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || exit 0
|
||||
mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || exit 0
|
||||
mv "$APPIMAGE_NAME" "../../artifacts/$APPIMAGE_NAME"
|
||||
sha256sum "../../artifacts/$APPIMAGE_NAME" > "../../artifacts/$APPIMAGE_NAME.sha256"
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-build-${{ matrix.build_name }}-run${{ github.run_number }}
|
||||
path: |
|
||||
artifacts/*
|
||||
drivers/**
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
94
.github/workflows/test-macos-build.yml
vendored
94
.github/workflows/test-macos-build.yml
vendored
@@ -1,94 +0,0 @@
|
||||
name: Test Build macOS (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_label:
|
||||
description: "测试包标识(仅用于文件名)"
|
||||
required: false
|
||||
default: "test"
|
||||
push:
|
||||
branches:
|
||||
- feature/kingbase_opt
|
||||
paths:
|
||||
- ".github/workflows/test-macos-build.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
name: Build macOS ${{ matrix.arch }}
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: darwin/amd64
|
||||
arch: amd64
|
||||
- platform: darwin/arm64
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24.3"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install Wails
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
|
||||
|
||||
- name: Build App
|
||||
run: |
|
||||
set -euo pipefail
|
||||
OUTPUT_NAME="gonavi-test-${{ matrix.arch }}"
|
||||
BUILD_LABEL="${{ inputs.build_label }}"
|
||||
if [ -z "$BUILD_LABEL" ]; then
|
||||
BUILD_LABEL="test"
|
||||
fi
|
||||
APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}"
|
||||
wails build \
|
||||
-platform "${{ matrix.platform }}" \
|
||||
-clean \
|
||||
-o "$OUTPUT_NAME" \
|
||||
-ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
|
||||
|
||||
- name: Package Zip
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_PATH="build/bin/gonavi-test-${{ matrix.arch }}.app"
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
APP_PATH=$(find build/bin -maxdepth 1 -name "*.app" | head -n 1 || true)
|
||||
fi
|
||||
if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
|
||||
echo "未找到 .app 产物"
|
||||
ls -la build/bin || true
|
||||
exit 1
|
||||
fi
|
||||
LABEL="${{ inputs.build_label }}"
|
||||
if [ -z "$LABEL" ]; then
|
||||
LABEL="test"
|
||||
fi
|
||||
ZIP_NAME="GoNavi-${LABEL}-macos-${{ matrix.arch }}-run${GITHUB_RUN_NUMBER}.zip"
|
||||
mkdir -p artifacts
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "artifacts/$ZIP_NAME"
|
||||
shasum -a 256 "artifacts/$ZIP_NAME" > "artifacts/$ZIP_NAME.sha256"
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gonavi-macos-${{ matrix.arch }}-run${{ github.run_number }}
|
||||
path: artifacts/*
|
||||
if-no-files-found: error
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,6 +21,8 @@ GoNavi-Wails.exe
|
||||
.claude/
|
||||
.gemini/
|
||||
**/tmpclaude-*
|
||||
docs/superpowers/
|
||||
docs/需求追踪/
|
||||
|
||||
CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
|
||||
22
README.md
22
README.md
@@ -53,19 +53,24 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
|
||||
<h2 align="center">📸 Screenshots</h2>
|
||||
|
||||
<div align="center">
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
|
||||
<br />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/f0c57590-d987-4ecf-89b2-64efad60b6d7" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### AI Assistant (New)
|
||||
- **Multi-provider Support**: OpenAI, Google Gemini, Anthropic Claude, and custom API support.
|
||||
- **Context-Aware Chat**: Attach table schemas to the AI context for accurate SQL generation and assistance.
|
||||
- **Slash Commands**: Quick commands for generating SQL, explaining queries, optimizing performance, and reviewing schema designs.
|
||||
|
||||
### Performance
|
||||
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
|
||||
- **Virtualized rendering**: keeps large result sets responsive.
|
||||
@@ -207,6 +212,11 @@ For the full workflow, branch model, and maintainer sync rules, see:
|
||||
|
||||
External contributors should open pull requests directly against `main`.
|
||||
|
||||
## Links
|
||||
|
||||
- [linux.do](https://linux.do/)
|
||||
- [AIBook](https://aibook.ren/)
|
||||
|
||||
## License
|
||||
|
||||
Licensed under [Apache-2.0](LICENSE).
|
||||
|
||||
@@ -52,19 +52,24 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做
|
||||
<h2 align="center">📸 项目截图</h2>
|
||||
|
||||
<div align="center">
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
|
||||
<br />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/f0c57590-d987-4ecf-89b2-64efad60b6d7" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 核心特性
|
||||
|
||||
### AI 智能助手 (New)
|
||||
- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude,同时支持任意自定义兼容 OpenAI 格式的 API。
|
||||
- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI,让 SQL 生成、分析变得更精准。
|
||||
- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等)。
|
||||
|
||||
### 性能与交互
|
||||
- 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。
|
||||
- 虚拟滚动渲染,降低大结果集卡顿风险。
|
||||
@@ -190,6 +195,11 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0
|
||||
|
||||
外部贡献者统一直接向 `main` 发起 Pull Request。
|
||||
|
||||
## 友情链接
|
||||
|
||||
- [linux.do](https://linux.do/)
|
||||
- [AI全书](https://aibook.ren/)
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目采用 [Apache-2.0 协议](LICENSE)。
|
||||
|
||||
9
assets_dev.go
Normal file
9
assets_dev.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build dev
|
||||
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
// 开发模式下由 Wails DevServer 提供前端资源,这里只提供一个稳定的占位 FS,
|
||||
// 避免编译时依赖 frontend/dist 被并发重建。
|
||||
var assets = os.DirFS(".")
|
||||
13
assets_prod.go
Normal file
13
assets_prod.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !dev
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
var embeddedAssets embed.FS
|
||||
|
||||
var assets fs.FS = embeddedAssets
|
||||
182
frontend/ai_ui_mockups_wip.html
Normal file
182
frontend/ai_ui_mockups_wip.html
Normal file
@@ -0,0 +1,182 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AI UI Brainstorming Prototypes</title>
|
||||
<!-- React & ReactDOM -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
<!-- Babel -->
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<!-- Ant Design -->
|
||||
<script src="https://unpkg.com/dayjs/dayjs.min.js"></script>
|
||||
<script src="https://unpkg.com/antd/dist/antd.min.js"></script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/antd/dist/reset.css" />
|
||||
<!-- Icons -->
|
||||
<script src="https://unpkg.com/@ant-design/icons/dist/index.umd.js"></script>
|
||||
<style>
|
||||
body { padding: 40px; background: #f0f2f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; }
|
||||
.prototype-container { display: flex; gap: 40px; }
|
||||
.prototype-column { flex: 1; max-width: 600px; background: white; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); overflow: hidden; }
|
||||
.prototype-header { padding: 16px 24px; border-bottom: 1px solid #f0f0f0; background: #fafafa; font-weight: bold; }
|
||||
.prototype-body { padding: 24px; }
|
||||
|
||||
/* Default App Theme Colors (Light Mode) */
|
||||
:root {
|
||||
--gn-border: rgba(16,24,40,0.08);
|
||||
--gn-bg: rgba(255,255,255,0.84);
|
||||
--gn-text: #162033;
|
||||
--gn-muted: rgba(16,24,40,0.55);
|
||||
--gn-primary: #1677ff;
|
||||
--gn-primary-bg: rgba(24,144,255,0.1);
|
||||
}
|
||||
|
||||
/* V1 Styles: Professional List */
|
||||
.v1-list-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 16px; margin-bottom: 8px; border-radius: 8px;
|
||||
border: 1px solid transparent; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.v1-list-item:hover { background: #f5f5f5; }
|
||||
.v1-list-item.selected {
|
||||
background: var(--gn-primary-bg); border-color: var(--gn-primary);
|
||||
}
|
||||
|
||||
/* V2 Styles: Refined Cards (ConnectionModal Style) */
|
||||
.v2-card-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
|
||||
}
|
||||
.v2-card {
|
||||
padding: 16px; border-radius: 12px; border: 1px solid var(--gn-border);
|
||||
cursor: pointer; transition: all 0.2s; background: white;
|
||||
box-shadow: inset 0 0 0 1px rgba(16,24,40,0.01);
|
||||
}
|
||||
.v2-card:hover { border-color: #d9d9d9; background: #fafafa; }
|
||||
.v2-card.selected {
|
||||
border-color: var(--gn-primary); box-shadow: 0 0 0 1px var(--gn-primary) inset;
|
||||
}
|
||||
|
||||
.section-title { font-size: 13px; font-weight: 600; color: var(--gn-muted); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel">
|
||||
const { useState } = React;
|
||||
const { Input, Slider, Select, Button, Form, ConfigProvider } = antd;
|
||||
const { ThunderboltOutlined, CloudOutlined, ExperimentOutlined, AppstoreOutlined, SettingOutlined, LinkOutlined, KeyOutlined } = icons;
|
||||
|
||||
const PROVIDERS = [
|
||||
{ key: 'openai', label: 'OpenAI', icon: <ThunderboltOutlined />, desc: 'GPT-4o / o1' },
|
||||
{ key: 'deepseek', label: 'DeepSeek', icon: <ThunderboltOutlined />, desc: 'V3 / R1' },
|
||||
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Sonnet 3.5' },
|
||||
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '通用 API' },
|
||||
];
|
||||
|
||||
const V1ListDesign = () => {
|
||||
const [selected, setSelected] = useState('openai');
|
||||
return (
|
||||
<div className="prototype-column">
|
||||
<div className="prototype-header">方案一:IDE 专业列表风格 (更克制、无彩色渐变)</div>
|
||||
<div className="prototype-body">
|
||||
<div className="section-title">提供商选择</div>
|
||||
<div style={{ marginBottom: 24, padding: 8, background: '#fafafa', borderRadius: 10, border: '1px solid #f0f0f0' }}>
|
||||
{PROVIDERS.map(p => (
|
||||
<div key={p.key} className={`v1-list-item ${selected === p.key ? 'selected' : ''}`} onClick={() => setSelected(p.key)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 6, display: 'grid', placeItems: 'center',
|
||||
background: selected === p.key ? '#1677ff' : '#e6f4ff',
|
||||
color: selected === p.key ? '#fff' : '#1677ff', fontSize: 16
|
||||
}}>
|
||||
{p.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, color: 'var(--gn-text)', fontSize: 14 }}>{p.label}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--gn-muted)' }}>{p.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: 16, height: 16, borderRadius: '50%', border: `2px solid ${selected === p.key ? 'var(--gn-primary)' : '#d9d9d9'}`, padding: 2 }}>
|
||||
{selected === p.key && <div style={{ width: '100%', height: '100%', background: 'var(--gn-primary)', borderRadius: '50%' }} />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="section-title">连接配置 (紧凑表单)</div>
|
||||
<Form layout="vertical" size="middle">
|
||||
<Form.Item label="API Endpoint">
|
||||
<Input placeholder="https://api.openai.com/v1" prefix={<LinkOutlined style={{color: 'var(--gn-muted)'}}/>} />
|
||||
</Form.Item>
|
||||
<Form.Item label="API Key">
|
||||
<Input.Password placeholder="sk-..." prefix={<KeyOutlined style={{color: 'var(--gn-muted)'}}/>} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Model Name">
|
||||
<Input placeholder="gpt-4o" prefix={<AppstoreOutlined style={{color: 'var(--gn-muted)'}}/>} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const V2CardDesign = () => {
|
||||
const [selected, setSelected] = useState('openai');
|
||||
return (
|
||||
<div className="prototype-column">
|
||||
<div className="prototype-header">方案二:GoNavi 统一卡片风格 (类似 ConnectionModal)</div>
|
||||
<div className="prototype-body">
|
||||
<div className="section-title">选择服务提供商</div>
|
||||
<div className="v2-card-grid" style={{ marginBottom: 24 }}>
|
||||
{PROVIDERS.map(p => (
|
||||
<div key={p.key} className={`v2-card ${selected === p.key ? 'selected' : ''}`} onClick={() => setSelected(p.key)}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{ color: selected === p.key ? 'var(--gn-primary)' : 'var(--gn-muted)', fontSize: 20, marginTop: 2 }}>
|
||||
{p.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: 'var(--gn-text)', fontSize: 14 }}>{p.label}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--gn-muted)', marginTop: 4 }}>{p.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 20, borderRadius: 12, border: '1px solid var(--gn-border)', background: '#fafafa' }}>
|
||||
<div className="section-title" style={{ marginTop: 0 }}>认证与设置</div>
|
||||
<Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} size="middle">
|
||||
<Form.Item label="Endpoint" style={{ marginBottom: 16 }}>
|
||||
<Input placeholder="https://api..." />
|
||||
</Form.Item>
|
||||
<Form.Item label="API Key" style={{ marginBottom: 16 }}>
|
||||
<Input.Password placeholder="sk-..." />
|
||||
</Form.Item>
|
||||
<Form.Item label="模型名称" style={{ marginBottom: 0 }}>
|
||||
<Input placeholder="例如 gpt-4o" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => (
|
||||
<ConfigProvider theme={{ token: { colorPrimary: '#1677ff', borderRadius: 6 } }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 24, margin: 0 }}>AI 设置 UI 重构探讨</h1>
|
||||
<p style={{ color: 'var(--gn-muted)' }}>当前设计带有太多渐变和鲜艳色彩("AI 味")。以下是遵循 GoNavi 本身设计规范(克制、专业)的两个方案:</p>
|
||||
</div>
|
||||
<div className="prototype-container">
|
||||
<V1ListDesign />
|
||||
<V2CardDesign />
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
3534
frontend/package-lock.json
generated
3534
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
@@ -15,11 +16,17 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"antd": "^5.12.0",
|
||||
"clsx": "^2.1.0",
|
||||
"mermaid": "^11.13.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"recharts": "^3.8.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sql-formatter": "^15.7.0",
|
||||
"uuid": "^9.0.1",
|
||||
"zustand": "^4.4.7"
|
||||
@@ -31,6 +38,7 @@
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
5b8157374dae5f9340e31b2d0bd2c00e
|
||||
f697e821b4acd5cf614d63d46453e8a4
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Tooltip } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
@@ -9,10 +9,14 @@ import ConnectionModal from './components/ConnectionModal';
|
||||
import DataSyncModal from './components/DataSyncModal';
|
||||
import DriverManagerModal from './components/DriverManagerModal';
|
||||
import LogPanel from './components/LogPanel';
|
||||
import AIChatPanel from './components/AIChatPanel';
|
||||
import AISettingsModal from './components/AISettingsModal';
|
||||
import { useStore } from './store';
|
||||
import { SavedConnection } from './types';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
|
||||
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
|
||||
import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme';
|
||||
import { getConnectionWorkbenchState } from './utils/startupReadiness';
|
||||
import {
|
||||
SHORTCUT_ACTION_META,
|
||||
SHORTCUT_ACTION_ORDER,
|
||||
@@ -24,7 +28,14 @@ import {
|
||||
isShortcutMatch,
|
||||
normalizeShortcutCombo,
|
||||
} from './utils/shortcuts';
|
||||
import { ConfigureGlobalProxy, SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import {
|
||||
SIDEBAR_UTILITY_ITEM_KEYS,
|
||||
resolveAIEntryPlacement,
|
||||
resolveAIEdgeHandleAttachment,
|
||||
resolveAIEdgeHandleDockStyle,
|
||||
resolveAIEdgeHandleStyle,
|
||||
} from './utils/aiEntryLayout';
|
||||
import { ConfigureGlobalProxy, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import './App.css';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
@@ -89,9 +100,14 @@ function App() {
|
||||
const [runtimePlatform, setRuntimePlatform] = useState('');
|
||||
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
|
||||
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
|
||||
const [hasAppliedInitialGlobalProxy, setHasAppliedInitialGlobalProxy] = useState(false);
|
||||
const sidebarWidth = useStore(state => state.sidebarWidth);
|
||||
const setSidebarWidth = useStore(state => state.setSidebarWidth);
|
||||
const aiPanelVisible = useStore(state => state.aiPanelVisible);
|
||||
const toggleAIPanel = useStore(state => state.toggleAIPanel);
|
||||
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
|
||||
const globalProxyInvalidHintShownRef = React.useRef(false);
|
||||
const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasAppliedInitialGlobalProxy);
|
||||
|
||||
// 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView,
|
||||
// 避免 GPU 持续计算窗口背后的模糊合成
|
||||
@@ -197,8 +213,16 @@ function App() {
|
||||
content: '全局代理配置失败: ' + errMsg,
|
||||
key: 'global-proxy-sync-error',
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setHasAppliedInitialGlobalProxy(true);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setHasAppliedInitialGlobalProxy(true);
|
||||
}
|
||||
console.warn("Wails API: ConfigureGlobalProxy unavailable", e);
|
||||
}
|
||||
|
||||
@@ -722,6 +746,19 @@ function App() {
|
||||
|| (runtimePlatform === '' && /mac/i.test(detectNavigatorPlatform()));
|
||||
const isWindowsRuntime = runtimePlatform === 'windows'
|
||||
|| (runtimePlatform === '' && isWindowsPlatform());
|
||||
const useNativeMacWindowControls = isMacRuntime && appearance.useNativeMacWindowControls === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStoreHydrated || !isMacRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
void SetMacNativeWindowControls(useNativeMacWindowControls).catch(() => undefined);
|
||||
} catch (e) {
|
||||
console.warn('Wails API: SetMacNativeWindowControls unavailable', e);
|
||||
}
|
||||
}, [isMacRuntime, isStoreHydrated, useNativeMacWindowControls]);
|
||||
|
||||
const formatBytes = (bytes?: number) => {
|
||||
if (!bytes || bytes <= 0) return '0 B';
|
||||
@@ -1094,6 +1131,62 @@ function App() {
|
||||
const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false);
|
||||
const [capturingShortcutAction, setCapturingShortcutAction] = useState<ShortcutAction | null>(null);
|
||||
const [isProxyModalOpen, setIsProxyModalOpen] = useState(false);
|
||||
const [isAISettingsOpen, setIsAISettingsOpen] = useState(false);
|
||||
const aiEntryPlacement = resolveAIEntryPlacement();
|
||||
const aiEdgeHandleAttachment = resolveAIEdgeHandleAttachment(aiPanelVisible);
|
||||
const aiEdgeHandleDockStyle = useMemo(
|
||||
() => resolveAIEdgeHandleDockStyle(aiEdgeHandleAttachment),
|
||||
[aiEdgeHandleAttachment],
|
||||
);
|
||||
const aiEdgeHandleStyle = useMemo(() => (
|
||||
resolveAIEdgeHandleStyle({
|
||||
darkMode,
|
||||
aiPanelVisible,
|
||||
effectiveUiScale,
|
||||
})
|
||||
), [aiPanelVisible, darkMode, effectiveUiScale]);
|
||||
const sidebarUtilityItems = useMemo(() => {
|
||||
const itemMap = {
|
||||
tools: {
|
||||
key: 'tools',
|
||||
title: '工具',
|
||||
icon: <ToolOutlined />,
|
||||
onClick: () => setIsToolsModalOpen(true),
|
||||
},
|
||||
proxy: {
|
||||
key: 'proxy',
|
||||
title: '代理',
|
||||
icon: <GlobalOutlined />,
|
||||
onClick: () => setIsProxyModalOpen(true),
|
||||
},
|
||||
theme: {
|
||||
key: 'theme',
|
||||
title: '主题',
|
||||
icon: <SkinOutlined />,
|
||||
onClick: () => setIsThemeModalOpen(true),
|
||||
},
|
||||
about: {
|
||||
key: 'about',
|
||||
title: '关于',
|
||||
icon: <InfoCircleOutlined />,
|
||||
onClick: () => setIsAboutOpen(true),
|
||||
},
|
||||
} as const;
|
||||
|
||||
return SIDEBAR_UTILITY_ITEM_KEYS.map((key) => itemMap[key]);
|
||||
}, []);
|
||||
const renderAIEdgeHandle = () => (
|
||||
<Tooltip title="AI 助手">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<RobotOutlined />}
|
||||
onClick={toggleAIPanel}
|
||||
style={aiEdgeHandleStyle}
|
||||
>
|
||||
AI
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
|
||||
// Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制
|
||||
@@ -1169,6 +1262,10 @@ function App() {
|
||||
await WindowUnfullscreen();
|
||||
return;
|
||||
}
|
||||
if (useNativeMacWindowControls && isMacRuntime) {
|
||||
await WindowFullscreen();
|
||||
return;
|
||||
}
|
||||
await WindowToggleMaximise();
|
||||
} catch (_) {
|
||||
// ignore
|
||||
@@ -1330,6 +1427,25 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMacRuntime || !useNativeMacWindowControls) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMacNativeEscapeCapture = (event: KeyboardEvent) => {
|
||||
if (!shouldSuppressMacNativeEscapeExit(isMacRuntime, useNativeMacWindowControls, useStore.getState().windowState === 'fullscreen', event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleMacNativeEscapeCapture, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleMacNativeEscapeCapture, true);
|
||||
};
|
||||
}, [isMacRuntime, useNativeMacWindowControls]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalShortcut = (event: KeyboardEvent) => {
|
||||
const matchedAction = SHORTCUT_ACTION_ORDER.find((action) => {
|
||||
@@ -1369,6 +1485,11 @@ function App() {
|
||||
case 'openShortcutManager':
|
||||
setIsShortcutModalOpen(true);
|
||||
break;
|
||||
case 'toggleMacFullscreen':
|
||||
if (isMacRuntime && useNativeMacWindowControls) {
|
||||
void handleTitleBarWindowToggle();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1376,7 +1497,7 @@ function App() {
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleGlobalShortcut);
|
||||
};
|
||||
}, [handleNewQuery, shortcutOptions, themeMode, setTheme]);
|
||||
}, [handleNewQuery, handleTitleBarWindowToggle, isMacRuntime, shortcutOptions, themeMode, setTheme, useNativeMacWindowControls]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!capturingShortcutAction) {
|
||||
@@ -1523,40 +1644,45 @@ function App() {
|
||||
userSelect: 'none',
|
||||
WebkitAppRegion: 'drag', // Wails drag region
|
||||
'--wails-draggable': 'drag',
|
||||
paddingLeft: Math.max(12, Math.round(16 * effectiveUiScale)),
|
||||
paddingLeft: getMacNativeTitlebarPaddingLeft(effectiveUiScale, useNativeMacWindowControls),
|
||||
paddingRight: getMacNativeTitlebarPaddingRight(effectiveUiScale, useNativeMacWindowControls),
|
||||
fontSize: tokenFontSize
|
||||
} as any}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: Math.max(6, Math.round(8 * effectiveUiScale)), fontWeight: 600 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: Math.max(6, Math.round(8 * effectiveUiScale)), fontWeight: 600, minWidth: 0 }}>
|
||||
{/* Logo can be added here if available */}
|
||||
GoNavi
|
||||
</div>
|
||||
<div
|
||||
data-no-titlebar-toggle="true"
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MinusOutlined />}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={WindowMinimise}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BorderOutlined />}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={() => { void handleTitleBarWindowToggle(); }}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
danger
|
||||
className="titlebar-close-btn"
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={Quit}
|
||||
/>
|
||||
</div>
|
||||
{useNativeMacWindowControls ? (
|
||||
<div style={{ minWidth: Math.max(40, Math.round(48 * effectiveUiScale)) }} />
|
||||
) : (
|
||||
<div
|
||||
data-no-titlebar-toggle="true"
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MinusOutlined />}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={WindowMinimise}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BorderOutlined />}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={() => { void handleTitleBarWindowToggle(); }}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
danger
|
||||
className="titlebar-close-btn"
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={Quit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Layout style={{ flex: 1, minHeight: 0, minWidth: 0 }}>
|
||||
@@ -1570,11 +1696,12 @@ function App() {
|
||||
>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: `12px ${sidebarHorizontalPadding}px 8px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isSidebarNarrow ? 'repeat(2, minmax(0, 1fr))' : 'repeat(4, minmax(0, 1fr))', gap: 8, width: '100%' }}>
|
||||
<Button type="text" icon={<ToolOutlined />} title="工具" style={utilityButtonStyle} onClick={() => setIsToolsModalOpen(true)}>{isSidebarUltraCompact ? null : '工具'}</Button>
|
||||
<Button type="text" icon={<GlobalOutlined />} title="代理" style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)}>{isSidebarUltraCompact ? null : '代理'}</Button>
|
||||
<Button type="text" icon={<SkinOutlined />} title="主题" style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)}>{isSidebarUltraCompact ? null : '主题'}</Button>
|
||||
<Button type="text" icon={<InfoCircleOutlined />} title="关于" style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)}>{isSidebarUltraCompact ? null : '关于'}</Button>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, width: '100%' }}>
|
||||
{sidebarUtilityItems.map((item) => (
|
||||
<Tooltip key={item.key} title={item.title}>
|
||||
<Button type="text" icon={item.icon} style={utilityButtonStyle} onClick={item.onClick} />
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: `0 ${sidebarHorizontalPadding}px 10px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
@@ -1588,8 +1715,44 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden', paddingBottom: 58 }}>
|
||||
<Sidebar onEditConnection={handleEditConnection} />
|
||||
<div style={{ flex: 1, overflow: 'hidden', paddingBottom: 58, position: 'relative' }}>
|
||||
<div style={{ height: '100%', opacity: connectionWorkbenchState.ready ? 1 : 0.72, pointerEvents: connectionWorkbenchState.ready ? 'auto' : 'none' }}>
|
||||
<Sidebar onEditConnection={handleEditConnection} />
|
||||
</div>
|
||||
{!connectionWorkbenchState.ready && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
background: darkMode ? 'rgba(7, 12, 20, 0.42)' : 'rgba(255, 255, 255, 0.58)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 999,
|
||||
background: darkMode ? 'rgba(15, 23, 36, 0.86)' : 'rgba(255, 255, 255, 0.94)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(22,32,51,0.08)',
|
||||
boxShadow: darkMode ? '0 12px 24px rgba(0,0,0,0.26)' : '0 12px 24px rgba(15,23,42,0.08)',
|
||||
color: darkMode ? 'rgba(255,255,255,0.88)' : '#162033',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Spin size="small" />
|
||||
<span>{connectionWorkbenchState.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating SQL Log Toggle */}
|
||||
@@ -1646,9 +1809,26 @@ function App() {
|
||||
title="拖动调整宽度"
|
||||
/>
|
||||
</Sider>
|
||||
<Content style={{ background: isLogPanelOpen ? bgContent : 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, marginBottom: isLogPanelOpen ? 8 : 0, borderRadius: isLogPanelOpen ? windowCornerRadius : 0, clipPath: isLogPanelOpen ? `inset(0 round ${windowCornerRadius}px)` : 'none' }}>
|
||||
<TabManager />
|
||||
<Content style={{ background: isLogPanelOpen ? bgContent : 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
|
||||
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'row', position: 'relative' }}>
|
||||
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, marginBottom: isLogPanelOpen ? 8 : 0, borderRadius: isLogPanelOpen ? windowCornerRadius : 0, clipPath: isLogPanelOpen ? `inset(0 round ${windowCornerRadius}px)` : 'none' }}>
|
||||
<TabManager />
|
||||
</div>
|
||||
{aiEntryPlacement === 'content-edge' && aiEdgeHandleAttachment === 'content-shell' && (
|
||||
<div style={aiEdgeHandleDockStyle}>
|
||||
{renderAIEdgeHandle()}
|
||||
</div>
|
||||
)}
|
||||
{aiPanelVisible && (
|
||||
<div style={{ position: 'relative', display: 'flex', flexShrink: 0, overflow: 'visible' }}>
|
||||
{aiEntryPlacement === 'content-edge' && aiEdgeHandleAttachment === 'panel-shell' && (
|
||||
<div style={aiEdgeHandleDockStyle}>
|
||||
{renderAIEdgeHandle()}
|
||||
</div>
|
||||
)}
|
||||
<AIChatPanel darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isLogPanelOpen && (
|
||||
<LogPanel
|
||||
@@ -1747,6 +1927,12 @@ function App() {
|
||||
onClose={() => setIsDriverModalOpen(false)}
|
||||
onOpenGlobalProxySettings={() => setIsProxyModalOpen(true)}
|
||||
/>
|
||||
<AISettingsModal
|
||||
open={isAISettingsOpen}
|
||||
onClose={() => setIsAISettingsOpen(false)}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
/>
|
||||
<Modal
|
||||
title={renderUtilityModalTitle(<InfoCircleOutlined />, '关于 GoNavi', '查看版本信息、仓库地址、更新状态与下载入口。')}
|
||||
open={isAboutOpen}
|
||||
@@ -2008,6 +2194,24 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isMacRuntime ? (
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>macOS 窗口控制</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>使用 macOS 原生窗口控制</div>
|
||||
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>启用后显示左上角红黄绿按钮,并优先使用 macOS 原生全屏行为。</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={appearance.useNativeMacWindowControls === true}
|
||||
onChange={(checked) => setAppearance({ useNativeMacWindowControls: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 8 }}>
|
||||
* 已同步隐藏右上角自定义按钮;如系统窗口样式未立即刷新,可重启应用后再确认
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>启动窗口</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
@@ -2020,13 +2224,13 @@ function App() {
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, paddingTop: 8, paddingBottom: 12 }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setUiScale(DEFAULT_UI_SCALE);
|
||||
setFontSize(DEFAULT_FONT_SIZE);
|
||||
setAppearance({ enabled: true, opacity: 1.0, blur: 0 });
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
onClick={() => {
|
||||
setUiScale(DEFAULT_UI_SCALE);
|
||||
setFontSize(DEFAULT_FONT_SIZE);
|
||||
setAppearance({ enabled: true, opacity: 1.0, blur: 0, useNativeMacWindowControls: false });
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2075,6 +2279,9 @@ function App() {
|
||||
</div>
|
||||
{SHORTCUT_ACTION_ORDER.map((action) => {
|
||||
const meta = SHORTCUT_ACTION_META[action];
|
||||
if (meta.platformOnly === 'mac' && !isMacRuntime) {
|
||||
return null;
|
||||
}
|
||||
const binding = shortcutOptions[action] ?? { combo: '', enabled: false };
|
||||
const isCapturing = capturingShortcutAction === action;
|
||||
return (
|
||||
|
||||
497
frontend/src/components/AIChatPanel.css
Normal file
497
frontend/src/components/AIChatPanel.css
Normal file
@@ -0,0 +1,497 @@
|
||||
.ai-chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid rgba(128, 128, 128, 0.12);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Resize Handle */
|
||||
.ai-resize-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-resize-handle:hover,
|
||||
.ai-resize-handle.active {
|
||||
background: rgba(22, 119, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.ai-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-chat-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ai-chat-header-left .ai-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-chat-header-left .ai-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.ai-chat-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Messages Area */
|
||||
.ai-chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ai-chat-messages::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.ai-chat-messages::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ai-chat-messages::-webkit-scrollbar-thumb {
|
||||
background: rgba(128, 128, 128, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Welcome */
|
||||
.ai-chat-welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ai-chat-welcome .welcome-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.ai-chat-welcome .welcome-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-welcome .quick-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-welcome .quick-action-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.ai-chat-welcome .quick-action-btn:hover {
|
||||
background: rgba(99, 102, 241, 0.12) !important;
|
||||
border-color: rgba(99, 102, 241, 0.3) !important;
|
||||
color: #818cf8 !important;
|
||||
}
|
||||
|
||||
/* IDE Style Messages */
|
||||
.ai-ide-message {
|
||||
padding: 12px 16px;
|
||||
animation: ai-msg-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes ai-msg-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-ide-message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.ai-ide-message-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
/* Remove pre-wrap here, as it conflicts with ReactMarkdown's block rendering */
|
||||
}
|
||||
|
||||
/* Markdown Styles Override */
|
||||
.ai-markdown-content {
|
||||
white-space: normal;
|
||||
}
|
||||
.ai-markdown-content p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.ai-markdown-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.ai-markdown-content h1,
|
||||
.ai-markdown-content h2,
|
||||
.ai-markdown-content h3,
|
||||
.ai-markdown-content h4,
|
||||
.ai-markdown-content h5,
|
||||
.ai-markdown-content h6 {
|
||||
margin: 16px 0 8px;
|
||||
line-height: 1.4;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ai-markdown-content h1:first-child,
|
||||
.ai-markdown-content h2:first-child,
|
||||
.ai-markdown-content h3:first-child,
|
||||
.ai-markdown-content h4:first-child,
|
||||
.ai-markdown-content h5:first-child,
|
||||
.ai-markdown-content h6:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.ai-markdown-content pre {
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.ai-markdown-content code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
background: rgba(128, 128, 128, 0.15);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.ai-markdown-content ul, .ai-markdown-content ol {
|
||||
margin: 0 0 10px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.ai-markdown-content li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Advanced Typing/Blinker indicator */
|
||||
.ai-blinking-cursor {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 14px;
|
||||
background-color: currentColor;
|
||||
border-radius: 1px;
|
||||
vertical-align: middle;
|
||||
margin-left: 4px;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes ai-dot-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* History Drawer Styles */
|
||||
.ai-history-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.ai-history-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ai-history-list:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(128, 128, 128, 0.4);
|
||||
}
|
||||
|
||||
.ai-history-item:hover {
|
||||
background: rgba(128, 128, 128, 0.08) !important;
|
||||
}
|
||||
|
||||
.ai-history-item .ai-history-delete-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.ai-history-item:hover .ai-history-delete-btn,
|
||||
.ai-history-item.active .ai-history-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Input Area */
|
||||
.ai-chat-input-area {
|
||||
padding: 12px 16px 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Textarea scrollbar */
|
||||
.ai-chat-input-wrapper textarea {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
|
||||
}
|
||||
|
||||
.ai-chat-input-wrapper textarea::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-input-wrapper textarea::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ai-chat-input-wrapper textarea::-webkit-scrollbar-thumb {
|
||||
background: rgba(128, 128, 128, 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.ai-chat-input-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
border-bottom-color: rgba(128, 128, 128, 0.4);
|
||||
padding: 6px 10px;
|
||||
transition: all 0.2s ease;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ai-chat-input-wrapper:focus-within {
|
||||
border-color: var(--ant-primary-color, #1677ff) !important;
|
||||
background: rgba(128, 128, 128, 0.05) !important;
|
||||
}
|
||||
|
||||
.ai-chat-input-wrapper textarea {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
min-height: 28px;
|
||||
max-height: 200px;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ai-chat-input-wrapper textarea::placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.ai-chat-send-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-send-btn:hover {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.ai-chat-send-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.ai-chat-send-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ai-ide-message:hover .ai-message-actions {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* 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); }
|
||||
}
|
||||
|
||||
/* 面板/弹窗内部 toast 定位覆盖:从 fixed(视口顶部)改为 absolute(容器内部顶部) */
|
||||
.ai-chat-panel .ant-message,
|
||||
.ai-settings-body .ant-message {
|
||||
position: absolute !important;
|
||||
top: 16px !important;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) !important;
|
||||
right: auto !important;
|
||||
width: max-content;
|
||||
z-index: 100;
|
||||
}
|
||||
1413
frontend/src/components/AIChatPanel.tsx
Normal file
1413
frontend/src/components/AIChatPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
767
frontend/src/components/AISettingsModal.tsx
Normal file
767
frontend/src/components/AISettingsModal.tsx
Normal file
@@ -0,0 +1,767 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
|
||||
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types';
|
||||
import {
|
||||
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
|
||||
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||
QWEN_CODING_PLAN_MODELS,
|
||||
resolveProviderPresetKey,
|
||||
resolvePresetBaseURL,
|
||||
resolvePresetModelSelection,
|
||||
resolvePresetTransport,
|
||||
} from '../utils/aiProviderPresets';
|
||||
import {
|
||||
PROVIDER_PRESET_CARD_BASE_STYLE,
|
||||
PROVIDER_PRESET_CARD_CONTENT_STYLE,
|
||||
PROVIDER_PRESET_CARD_DESCRIPTION_STYLE,
|
||||
PROVIDER_PRESET_GRID_STYLE,
|
||||
PROVIDER_PRESET_CARD_TITLE_STYLE,
|
||||
} from '../utils/aiSettingsPresetLayout';
|
||||
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
|
||||
interface AISettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
}
|
||||
|
||||
// 预设配置:每个预设映射到后端 type(openai/anthropic/gemini/custom)并附带默认 URL 和 Model
|
||||
interface ProviderPreset {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
desc: string;
|
||||
color: string;
|
||||
backendType: AIProviderType;
|
||||
fixedApiFormat?: string;
|
||||
defaultBaseUrl: string;
|
||||
defaultModel: string;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
{ key: 'openai', label: 'OpenAI', icon: <ApiOutlined />, desc: 'GPT-5.4 / 5.3 系列', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', models: [] },
|
||||
{ key: 'deepseek', label: 'DeepSeek', icon: <ThunderboltOutlined />, desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] },
|
||||
{ key: 'qwen-bailian', label: '通义千问(百炼通用)', icon: <CloudOutlined />, desc: '百炼 Anthropic 兼容 / 模型从远端拉取', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] },
|
||||
{ key: 'qwen-coding-plan', label: '通义千问(Coding Plan)', icon: <CloudOutlined />, desc: 'Claude Code CLI 代理链路 / 使用官方支持模型清单', color: '#4f46e5', backendType: 'custom', fixedApiFormat: 'claude-cli', defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, defaultModel: '', models: QWEN_CODING_PLAN_MODELS },
|
||||
{ key: 'zhipu', label: '智谱 GLM', icon: <ExperimentOutlined />, desc: 'GLM-5 / GLM-5-Turbo', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-4', models: [] },
|
||||
{ key: 'moonshot', label: 'Kimi', icon: <ExperimentOutlined />, desc: 'Kimi K2.5 (Anthropic 兼容)', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] },
|
||||
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] },
|
||||
{ key: 'gemini', label: 'Gemini', icon: <CloudOutlined />, desc: 'Gemini 3.1 / 2.5 系列', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: [] },
|
||||
{ key: 'volcengine-ark', label: '火山方舟', icon: <CloudOutlined />, desc: 'Ark 通用推理 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] },
|
||||
{ key: 'volcengine-coding', label: '火山 Coding Plan', icon: <CloudOutlined />, desc: 'Ark Code / Coding Plan', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] },
|
||||
{ key: 'minimax', label: 'MiniMax', icon: <ExperimentOutlined />, desc: 'M2.7 / M2.5 系列 (Anthropic 兼容)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M2.7', models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2'] },
|
||||
{ key: 'ollama', label: 'Ollama', icon: <AppstoreOutlined />, desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] },
|
||||
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] },
|
||||
];
|
||||
|
||||
const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1];
|
||||
|
||||
const matchProviderPreset = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>): ProviderPreset => {
|
||||
const presetKey = resolveProviderPresetKey(provider, PROVIDER_PRESETS, 'custom');
|
||||
return findPreset(presetKey);
|
||||
};
|
||||
|
||||
const SAFETY_OPTIONS: { label: string; value: AISafetyLevel; desc: string; color: string; icon: string }[] = [
|
||||
{ label: '只读模式', value: 'readonly', desc: 'AI 仅可执行 SELECT 等查询操作,最安全', color: '#22c55e', icon: '🔒' },
|
||||
{ label: '读写模式', value: 'readwrite', desc: 'AI 可执行 INSERT/UPDATE/DELETE,危险操作需二次确认', color: '#f59e0b', icon: '⚠️' },
|
||||
{ label: '完全模式', value: 'full', desc: 'AI 可执行所有操作(含 DDL),高危操作自动告警', color: '#ef4444', icon: '🔓' },
|
||||
];
|
||||
|
||||
const CONTEXT_OPTIONS: { label: string; value: AIContextLevel; desc: string; icon: string }[] = [
|
||||
{ label: '仅 Schema', value: 'schema_only', desc: '只传递表/列结构信息给 AI', icon: '📋' },
|
||||
{ label: '含采样数据', value: 'with_samples', desc: '包含少量采样数据帮助 AI 理解数据特征', icon: '📊' },
|
||||
{ label: '含查询结果', value: 'with_results', desc: '传递最近的查询结果作为上下文', icon: '📑' },
|
||||
];
|
||||
|
||||
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme }) => {
|
||||
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
|
||||
const [activeProviderId, setActiveProviderId] = useState<string>('');
|
||||
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
|
||||
const [contextLevel, setContextLevel] = useState<AIContextLevel>('schema_only');
|
||||
const [editingProvider, setEditingProvider] = useState<AIProviderConfig | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [builtinPrompts, setBuiltinPrompts] = useState<Record<string, string>>({});
|
||||
const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers');
|
||||
const [form] = Form.useForm();
|
||||
const modalBodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Modal 内部 toast 通知
|
||||
const [messageApi, messageContextHolder] = antdMessage.useMessage({ getContainer: () => modalBodyRef.current || document.body });
|
||||
|
||||
// 主题色
|
||||
const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
|
||||
const cardBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
|
||||
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)';
|
||||
const sectionLabelColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)';
|
||||
const inputBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
|
||||
|
||||
// Hook 必须在组件顶层调用,不能在条件分支内
|
||||
const watchedType = Form.useWatch('type', form);
|
||||
const watchedPresetKey = Form.useWatch('presetKey', form);
|
||||
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (!Service) { console.warn('[AI] Service not found on window.go'); return; }
|
||||
const [provRes, safeRes, ctxRes, promptsRes] = await Promise.all([
|
||||
Service.AIGetProviders?.() || [],
|
||||
Service.AIGetSafetyLevel?.() || 'readonly',
|
||||
Service.AIGetContextLevel?.() || 'schema_only',
|
||||
Service.AIGetBuiltinPrompts?.() || {},
|
||||
]);
|
||||
console.log('[AI] AIGetProviders result:', JSON.stringify(provRes), 'isArray:', Array.isArray(provRes));
|
||||
if (Array.isArray(provRes)) {
|
||||
setProviders(provRes);
|
||||
const activeRes = await Service.AIGetActiveProvider?.();
|
||||
console.log('[AI] AIGetActiveProvider result:', activeRes);
|
||||
if (activeRes) setActiveProviderId(activeRes);
|
||||
}
|
||||
if (safeRes) setSafetyLevel(safeRes);
|
||||
if (ctxRes) setContextLevel(ctxRes);
|
||||
if (promptsRes) setBuiltinPrompts(promptsRes);
|
||||
} catch (e) { console.warn('Failed to load AI config', e); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
|
||||
|
||||
const handleAddProvider = () => {
|
||||
const preset = findPreset('openai');
|
||||
const newProvider: AIProviderConfig = {
|
||||
id: '', type: preset.backendType, name: '', apiKey: '',
|
||||
baseUrl: preset.defaultBaseUrl, model: preset.defaultModel,
|
||||
models: [], maxTokens: 4096, temperature: 0.7,
|
||||
};
|
||||
setEditingProvider({ ...newProvider, presetKey: 'openai' } as any);
|
||||
setIsEditing(true);
|
||||
setTestStatus('idle');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ ...newProvider, presetKey: 'openai', apiFormat: 'openai' });
|
||||
};
|
||||
|
||||
const handleEditProvider = (p: AIProviderConfig) => {
|
||||
// 尝试根据 baseUrl 和 type 推断 preset
|
||||
const matchedPreset = matchProviderPreset(p);
|
||||
const resolvedTransport = resolvePresetTransport({
|
||||
presetBackendType: matchedPreset.backendType,
|
||||
presetFixedApiFormat: matchedPreset.fixedApiFormat,
|
||||
valuesApiFormat: p.apiFormat,
|
||||
});
|
||||
setEditingProvider(p);
|
||||
setIsEditing(true);
|
||||
setTestStatus('idle');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
...p,
|
||||
type: resolvedTransport.type,
|
||||
models: p.models || [],
|
||||
presetKey: matchedPreset.key,
|
||||
apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai',
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteProvider = async (id: string) => {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
const wasActive = id === activeProviderId;
|
||||
await Service?.AIDeleteProvider?.(id);
|
||||
await loadConfig();
|
||||
// 合并提示:删除的是当前激活的供应商时,附带自动切换信息
|
||||
if (wasActive) {
|
||||
const newProviders: any[] = await Service?.AIGetProviders?.() || [];
|
||||
if (newProviders.length > 0) {
|
||||
const newActiveName = newProviders[0]?.name || '下一个供应商';
|
||||
void messageApi.success(`已删除,自动切换到「${newActiveName}」`);
|
||||
} else {
|
||||
void messageApi.success('已删除');
|
||||
}
|
||||
} else {
|
||||
void messageApi.success('已删除');
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||||
} catch (e: any) { void messageApi.error(e?.message || '删除失败'); }
|
||||
};
|
||||
|
||||
const handleSaveProvider = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
|
||||
// 构建 payload,处理 model/models 逻辑
|
||||
const preset = findPreset(values.presetKey);
|
||||
const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama';
|
||||
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
|
||||
presetKey: values.presetKey,
|
||||
presetDefaultModel: preset.defaultModel,
|
||||
presetModels: preset.models,
|
||||
valuesModel: values.model,
|
||||
customModels: values.models,
|
||||
});
|
||||
// 内置供应商自动使用 preset label 作为名称
|
||||
const finalName = isCustomLike ? (values.name || preset.label) : preset.label;
|
||||
|
||||
const finalBaseUrl = resolvePresetBaseURL({
|
||||
presetKey: values.presetKey,
|
||||
presetDefaultBaseUrl: preset.defaultBaseUrl,
|
||||
valuesBaseUrl: values.baseUrl,
|
||||
});
|
||||
const resolvedTransport = resolvePresetTransport({
|
||||
presetBackendType: preset.backendType,
|
||||
presetFixedApiFormat: preset.fixedApiFormat,
|
||||
valuesApiFormat: values.apiFormat,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
...editingProvider,
|
||||
...values,
|
||||
...resolvedTransport,
|
||||
name: finalName,
|
||||
model: finalModel,
|
||||
models: resolvedModels,
|
||||
baseUrl: finalBaseUrl,
|
||||
apiFormat: resolvedTransport.apiFormat,
|
||||
};
|
||||
// 后端 AISaveProvider 统一处理新增和更新,返回 void,失败抛异常
|
||||
await Service?.AISaveProvider?.(payload);
|
||||
void messageApi.success('已保存'); setIsEditing(false); setEditingProvider(null); void loadConfig();
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) { /* antd form validation error, ignore */ }
|
||||
else void messageApi.error(e?.message || '保存失败');
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleSetActive = async (id: string) => {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
await Service?.AISetActiveProvider?.(id);
|
||||
setActiveProviderId(id); void messageApi.success('已切换');
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||||
} catch (e: any) { void messageApi.error(e?.message || '切换失败'); }
|
||||
};
|
||||
|
||||
const handleSafetyChange = async (level: AISafetyLevel) => {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
await Service?.AISetSafetyLevel?.(level);
|
||||
setSafetyLevel(level);
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleContextChange = async (level: AIContextLevel) => {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
await Service?.AISetContextLevel?.(level);
|
||||
setContextLevel(level);
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleTestProvider = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
setTestStatus('idle');
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
const preset = findPreset(values.presetKey || 'openai');
|
||||
const finalBaseUrl = resolvePresetBaseURL({
|
||||
presetKey: values.presetKey || 'openai',
|
||||
presetDefaultBaseUrl: preset.defaultBaseUrl,
|
||||
valuesBaseUrl: values.baseUrl,
|
||||
});
|
||||
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
|
||||
presetKey: values.presetKey || 'openai',
|
||||
presetDefaultModel: preset.defaultModel,
|
||||
presetModels: preset.models,
|
||||
valuesModel: values.model,
|
||||
customModels: values.models,
|
||||
});
|
||||
const resolvedTransport = resolvePresetTransport({
|
||||
presetBackendType: preset.backendType,
|
||||
presetFixedApiFormat: preset.fixedApiFormat,
|
||||
valuesApiFormat: values.apiFormat,
|
||||
});
|
||||
const res = await Service?.AITestProvider?.({
|
||||
...editingProvider,
|
||||
...values,
|
||||
...resolvedTransport,
|
||||
baseUrl: finalBaseUrl,
|
||||
model: finalModel,
|
||||
models: resolvedModels,
|
||||
maxTokens: Number(values.maxTokens) || 4096,
|
||||
temperature: Number(values.temperature) ?? 0.7,
|
||||
apiFormat: resolvedTransport.apiFormat,
|
||||
});
|
||||
if (res?.success) { setTestStatus('success'); void messageApi.success('连接成功'); }
|
||||
else { setTestStatus('error'); void messageApi.error(`测试失败: ${res?.message || '未知错误'}`); }
|
||||
} catch (e: any) { setTestStatus('error'); void messageApi.error(e?.message || '测试失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handlePresetChange = (presetKey: string) => {
|
||||
const preset = findPreset(presetKey);
|
||||
const resolvedTransport = resolvePresetTransport({
|
||||
presetBackendType: preset.backendType,
|
||||
presetFixedApiFormat: preset.fixedApiFormat,
|
||||
valuesApiFormat: form.getFieldValue('apiFormat'),
|
||||
});
|
||||
form.setFieldsValue({
|
||||
presetKey,
|
||||
type: resolvedTransport.type,
|
||||
apiFormat: resolvedTransport.apiFormat || 'openai',
|
||||
baseUrl: preset.defaultBaseUrl,
|
||||
model: preset.defaultModel,
|
||||
});
|
||||
};
|
||||
|
||||
// ---- 字段装饰器样式 ----
|
||||
const fieldGroupStyle: React.CSSProperties = {
|
||||
padding: '14px 16px', borderRadius: 12, border: `1px solid ${cardBorder}`,
|
||||
background: cardBg, marginBottom: 12,
|
||||
};
|
||||
const fieldLabelStyle: React.CSSProperties = {
|
||||
fontSize: 13, fontWeight: 700, textTransform: 'uppercase' as const, letterSpacing: '0.08em',
|
||||
color: sectionLabelColor, marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6,
|
||||
};
|
||||
|
||||
// ===== Provider 列表 =====
|
||||
const renderProviderList = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{providers.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center', padding: '36px 20px', color: overlayTheme.mutedText, fontSize: 14,
|
||||
border: `1px dashed ${cardBorder}`, borderRadius: 14, background: cardBg,
|
||||
}}>
|
||||
<RobotOutlined style={{ fontSize: 32, marginBottom: 12, opacity: 0.3, display: 'block' }} />
|
||||
暂未配置模型供应商<br />
|
||||
<span style={{ fontSize: 13, opacity: 0.6 }}>添加一个以开始使用 AI 助手</span>
|
||||
</div>
|
||||
)}
|
||||
{providers.map(p => {
|
||||
const matchedPreset = matchProviderPreset(p);
|
||||
const isActive = p.id === activeProviderId;
|
||||
return (
|
||||
<div key={p.id} onClick={() => handleSetActive(p.id)} style={{
|
||||
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
|
||||
border: `1.5px solid ${isActive ? overlayTheme.selectedText : cardBorder}`,
|
||||
background: isActive ? overlayTheme.selectedBg : cardBg,
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center',
|
||||
background: isActive ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'),
|
||||
color: isActive ? overlayTheme.iconColor : overlayTheme.mutedText,
|
||||
fontSize: 18, flexShrink: 0, transition: 'all 0.2s ease',
|
||||
}}>
|
||||
{matchedPreset.icon || <ApiOutlined />}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{p.name || p.type}
|
||||
{isActive && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 13 }} />}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginTop: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>{matchedPreset.label}</span>
|
||||
<span style={{ opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{p.model || '未选择模型'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Space size={2}>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" size="small" icon={<EditOutlined />}
|
||||
onClick={e => { e.stopPropagation(); handleEditProvider(p); }}
|
||||
style={{ color: overlayTheme.mutedText }} />
|
||||
</Tooltip>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDeleteProvider(p.id)}
|
||||
okButtonProps={{ danger: true }} okText="删除" cancelText="取消">
|
||||
<Button type="text" size="small" icon={<DeleteOutlined />} danger
|
||||
onClick={e => e.stopPropagation()} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddProvider}
|
||||
style={{ borderRadius: 12, height: 42, borderColor: darkMode ? 'rgba(255,255,255,0.12)' : undefined }}>
|
||||
添加模型供应商
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ===== Provider 编辑表单 =====
|
||||
const renderProviderForm = () => {
|
||||
const presetKeyFromForm = watchedPresetKey || (editingProvider as any)?.presetKey || 'openai';
|
||||
return (
|
||||
<div>
|
||||
{/* 顶部返回 */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Button size="small" onClick={() => { setIsEditing(false); setEditingProvider(null); }}
|
||||
style={{ borderRadius: 8 }}>← 返回</Button>
|
||||
<span style={{ fontWeight: 700, fontSize: 16, color: overlayTheme.titleText }}>
|
||||
{editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical" size="small">
|
||||
{/* Provider 类型选择 - 卡片式 */}
|
||||
<div style={fieldGroupStyle}>
|
||||
<div style={fieldLabelStyle}>
|
||||
<AppstoreOutlined style={{ fontSize: 14 }} /> 服务类型
|
||||
</div>
|
||||
<Form.Item name="presetKey" noStyle>
|
||||
<div style={PROVIDER_PRESET_GRID_STYLE}>
|
||||
{PROVIDER_PRESETS.map(pt => (
|
||||
<div key={pt.key} onClick={() => { form.setFieldValue('presetKey', pt.key); handlePresetChange(pt.key); }}
|
||||
style={{
|
||||
...PROVIDER_PRESET_CARD_BASE_STYLE,
|
||||
border: `1.5px solid ${presetKeyFromForm === pt.key ? overlayTheme.selectedText : 'transparent'}`,
|
||||
background: presetKeyFromForm === pt.key ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
|
||||
boxShadow: presetKeyFromForm === pt.key ? 'none' : (darkMode ? 'inset 0 0 0 1px rgba(255,255,255,0.028)' : 'inset 0 0 0 1px rgba(16,24,40,0.03)'),
|
||||
}}>
|
||||
<div style={{
|
||||
color: presetKeyFromForm === pt.key ? overlayTheme.iconColor : overlayTheme.mutedText,
|
||||
fontSize: 18, marginTop: 2, transition: 'all 0.2s ease', flexShrink: 0,
|
||||
}}>
|
||||
{pt.icon}
|
||||
</div>
|
||||
<div style={PROVIDER_PRESET_CARD_CONTENT_STYLE}>
|
||||
<div style={{ ...PROVIDER_PRESET_CARD_TITLE_STYLE, fontSize: 13, fontWeight: 700, color: overlayTheme.titleText, lineHeight: 1.3 }}>{pt.label}</div>
|
||||
<div style={{ ...PROVIDER_PRESET_CARD_DESCRIPTION_STYLE, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.4 }}>{pt.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item name="type" hidden><Input /></Form.Item>
|
||||
</div>
|
||||
|
||||
{/* 基本信息 - 仅自定义/Ollama 显示 */}
|
||||
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
|
||||
<div style={{ ...fieldGroupStyle, marginTop: 16 }}>
|
||||
<div style={fieldLabelStyle}>
|
||||
<RobotOutlined style={{ fontSize: 14 }} /> 基本信息
|
||||
</div>
|
||||
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>供应商名称</span>} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}>
|
||||
<Input placeholder="例如:我的自建 OpenAI / 专属大模型"
|
||||
size="middle"
|
||||
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
|
||||
</Form.Item>
|
||||
|
||||
{presetKeyFromForm === 'custom' && (
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API 格式</span>} name="apiFormat" style={{ marginBottom: 16 }}>
|
||||
<div style={{
|
||||
display: 'inline-flex', padding: 4, background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.04)',
|
||||
borderRadius: 8, gap: 4
|
||||
}}>
|
||||
{[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'claude-cli', label: 'Claude CLI' }].map(fmt => (
|
||||
<div
|
||||
key={fmt.value}
|
||||
onClick={() => form.setFieldsValue({ apiFormat: fmt.value })}
|
||||
style={{
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{fmt.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>可用模型列表(可选配置)</span>} name="models" style={{ marginBottom: 0 }}>
|
||||
<Select mode="tags" size="middle" placeholder="配置指定的模型ID,留空则默认去服务端拉取" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
<Form.Item name="model" hidden><Input /></Form.Item>
|
||||
<Form.Item name="name" hidden><Input /></Form.Item>
|
||||
|
||||
{/* 认证信息 */}
|
||||
<div style={{ ...fieldGroupStyle, marginTop: 16 }}>
|
||||
<div style={fieldLabelStyle}>
|
||||
<KeyOutlined style={{ fontSize: 14 }} /> 认证 & 连接
|
||||
</div>
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ required: true, message: '请输入 API Key' }]} style={{ marginBottom: 16 }}>
|
||||
<Input.Password placeholder="sk-... / 你的 API Key"
|
||||
size="middle"
|
||||
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
|
||||
</Form.Item>
|
||||
|
||||
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Endpoint (URL)</span>} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}>
|
||||
<Input placeholder={findPreset(presetKeyFromForm).defaultBaseUrl || 'https://...'}
|
||||
size="middle"
|
||||
suffix={<LinkOutlined style={{ color: overlayTheme.mutedText }} />}
|
||||
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12, paddingTop: 16,
|
||||
borderTop: `1px solid ${cardBorder}`, paddingBottom: 24,
|
||||
}}>
|
||||
<Button onClick={handleTestProvider} loading={loading} style={{ borderRadius: 10 }}
|
||||
icon={testStatus === 'success' ? <CheckOutlined style={{ color: '#22c55e' }} /> : undefined}>
|
||||
{testStatus === 'success' ? '连接正常' : testStatus === 'error' ? '重新测试' : '测试连接'}
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSaveProvider} loading={loading}
|
||||
style={{ borderRadius: 10, fontWeight: 600 }}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== 安全控制 =====
|
||||
const renderSafetySettings = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 8 }}>
|
||||
控制 AI 可执行的 SQL 操作类型,保护数据安全
|
||||
</div>
|
||||
{SAFETY_OPTIONS.map(opt => {
|
||||
const active = safetyLevel === opt.value;
|
||||
return (
|
||||
<div key={opt.value} onClick={() => handleSafetyChange(opt.value)} style={{
|
||||
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
|
||||
border: `1.5px solid ${active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.selectedText) : cardBorder}`,
|
||||
background: active ? (opt.color === '#ef4444' ? `${opt.color}15` : overlayTheme.selectedBg) : cardBg,
|
||||
display: 'flex', alignItems: 'flex-start', gap: 14,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', fontSize: 18, flexShrink: 0,
|
||||
background: active ? (opt.color === '#ef4444' ? `${opt.color}25` : overlayTheme.iconBg) : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
|
||||
color: active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.iconColor) : overlayTheme.mutedText,
|
||||
transition: 'all 0.2s ease',
|
||||
}}>
|
||||
{opt.icon}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{opt.label}
|
||||
{active && <CheckOutlined style={{ color: opt.color === '#ef4444' ? opt.color : overlayTheme.iconColor, fontSize: 14 }} />}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 4, lineHeight: '1.5' }}>{opt.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ===== 上下文级别 =====
|
||||
const renderContextSettings = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 8 }}>
|
||||
控制发送给 AI 的数据库上下文信息量
|
||||
</div>
|
||||
{CONTEXT_OPTIONS.map(opt => {
|
||||
const active = contextLevel === opt.value;
|
||||
return (
|
||||
<div key={opt.value} onClick={() => handleContextChange(opt.value)} style={{
|
||||
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
|
||||
border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`,
|
||||
background: active ? overlayTheme.selectedBg : cardBg,
|
||||
display: 'flex', alignItems: 'flex-start', gap: 14,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', fontSize: 18, flexShrink: 0,
|
||||
background: active ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
|
||||
color: active ? overlayTheme.iconColor : overlayTheme.mutedText,
|
||||
transition: 'all 0.2s ease',
|
||||
}}>
|
||||
{opt.icon}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{opt.label}
|
||||
{active && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 14 }} />}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 4, lineHeight: '1.5' }}>{opt.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderBuiltinPrompts = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
|
||||
以下为当前版本 GoNavi 预设的底层 AI 提示词(只读)。它们会被动态注入到对应场景的请求上下文中。
|
||||
</div>
|
||||
{Object.entries(builtinPrompts).map(([title, promptText]) => (
|
||||
<div key={title} style={{
|
||||
padding: '12px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: cardBg,
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<RobotOutlined style={{ color: overlayTheme.iconColor }} /> {title}
|
||||
</div>
|
||||
<div style={{
|
||||
background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)',
|
||||
padding: '10px 12px', borderRadius: 8, fontSize: 13, color: overlayTheme.mutedText,
|
||||
whiteSpace: 'pre-wrap', fontFamily: 'monospace', lineHeight: 1.5,
|
||||
userSelect: 'text', border: darkMode ? '1px solid rgba(255,255,255,0.03)' : '1px solid rgba(0,0,0,0.02)'
|
||||
}}>
|
||||
{promptText}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const BUILTIN_TOOLS_INFO = [
|
||||
{ name: 'get_connections', icon: '🔗', desc: '获取所有可用的数据库连接', detail: '返回连接 ID、名称、类型 (MySQL/PostgreSQL 等) 和 Host 地址。AI 根据返回信息决定优先探索哪个连接。', params: '无参数' },
|
||||
{ name: 'get_databases', icon: '🗄️', desc: '获取指定连接下的所有数据库', detail: '传入 connectionId,返回该连接下的数据库/Schema 名称列表。', params: 'connectionId: 连接 ID' },
|
||||
{ name: 'get_tables', icon: '📋', desc: '获取指定数据库下的所有表名', detail: '传入 connectionId 和 dbName,返回表名列表。AI 用它来定位用户提到的目标表。', params: 'connectionId, dbName' },
|
||||
{ name: 'get_columns', icon: '🔍', desc: '获取指定表的字段结构', detail: '传入 connectionId、dbName 和 tableName,返回每个字段的名称、类型、是否可空、默认值和注释。AI 在生成 SQL 前必须调用此工具确认真实字段名。', params: 'connectionId, dbName, tableName' },
|
||||
{ name: 'get_table_ddl', icon: '📝', desc: '获取表的建表语句 (DDL)', detail: '传入 connectionId、dbName 和 tableName,返回完整的 CREATE TABLE 语句,包含字段定义、索引、约束等信息。', params: 'connectionId, dbName, tableName' },
|
||||
{ name: 'execute_sql', icon: '▶️', desc: '执行 SQL 查询并返回结果', detail: '传入 connectionId、dbName 和 sql,在目标数据库上执行 SQL 并返回结果(最多 50 行)。受安全级别控制,只读模式下仅允许 SELECT/SHOW/DESCRIBE。', params: 'connectionId, dbName, sql' },
|
||||
];
|
||||
|
||||
const renderBuiltinTools = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
|
||||
AI 助手在处理数据库相关问题时,可以自动调用以下内置工具获取真实数据,全程无需人工干预。
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, padding: '8px 12px', borderRadius: 8, background: cardBg, border: `1px solid ${cardBorder}` }}>
|
||||
💡 工作流程:get_connections → get_databases → get_tables → get_columns → 生成 SQL
|
||||
</div>
|
||||
{BUILTIN_TOOLS_INFO.map(tool => (
|
||||
<div key={tool.name} style={{
|
||||
padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg,
|
||||
transition: 'all 0.2s ease',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 20 }}>{tool.icon}</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, fontFamily: 'monospace' }}>
|
||||
{tool.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 2 }}>{tool.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.6, padding: '8px 12px',
|
||||
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.02)', borderRadius: 8,
|
||||
}}>
|
||||
{tool.detail}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<ToolOutlined style={{ fontSize: 12 }} />
|
||||
<span>参数:</span>
|
||||
<code style={{ fontFamily: 'monospace', fontSize: 12, padding: '1px 6px', borderRadius: 4, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
|
||||
{tool.params}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const modalShellStyle = {
|
||||
background: overlayTheme.shellBg, border: overlayTheme.shellBorder,
|
||||
boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter,
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 38, height: 38, borderRadius: 12, display: 'grid', placeItems: 'center',
|
||||
background: overlayTheme.iconBg, color: overlayTheme.iconColor, fontSize: 18, flexShrink: 0,
|
||||
}}>
|
||||
<RobotOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>AI 设置</div>
|
||||
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
|
||||
配置 AI 模型、安全级别和上下文选项
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={820}
|
||||
styles={{
|
||||
content: modalShellStyle,
|
||||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||
body: { paddingTop: 8, height: 620, overflow: 'hidden' },
|
||||
}}
|
||||
>
|
||||
<div ref={modalBodyRef} className="ai-settings-body" style={{ display: 'grid', gridTemplateColumns: '180px minmax(0, 1fr)', gap: 16, padding: '12px 0', height: '100%', minHeight: 0, overflow: 'hidden', alignItems: 'stretch', position: 'relative' }}>
|
||||
{messageContextHolder}
|
||||
<div style={{ padding: '0 12px', height: 'fit-content' }}>
|
||||
<div style={{ marginBottom: 12, fontWeight: 600, color: overlayTheme.titleText }}>设置导航</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{[
|
||||
{ key: 'providers', title: '模型供应商', description: '配置大模型接口与秘钥', icon: <ApiOutlined /> },
|
||||
{ key: 'safety', title: '安全控制', description: '限制 AI 操作风险级别', icon: <SafetyCertificateOutlined /> },
|
||||
{ key: 'context', title: '上下文', description: '配置携带的数据架构信息', icon: <RobotOutlined /> },
|
||||
{ key: 'tools', title: '内置工具', description: '查看 AI 可调用的数据探针', icon: <ToolOutlined /> },
|
||||
{ key: 'prompts', title: '内置提示词', description: '查看系统预设的底层要求', icon: <ExperimentOutlined /> },
|
||||
].map((item) => {
|
||||
const active = activeSection === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setActiveSection(item.key as typeof activeSection)}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '12px 14px',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${active
|
||||
? (darkMode ? 'rgba(255,214,102,0.3)' : 'rgba(24,144,255,0.24)')
|
||||
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`,
|
||||
background: active
|
||||
? (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.12) 0%, rgba(255,214,102,0.06) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.10) 0%, rgba(24,144,255,0.05) 100%)')
|
||||
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
|
||||
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 16 }}>{item.icon}</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 700 }}>{item.title}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.6, color: active ? (darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(22,32,51,0.68)') : 'rgba(128,128,128,0.7)' }}>
|
||||
{item.description}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 0, minHeight: 0, height: '100%', overflowY: 'auto', overflowX: 'hidden', paddingRight: 8, paddingBottom: 28 }}>
|
||||
{activeSection === 'providers' && (isEditing ? renderProviderForm() : renderProviderList())}
|
||||
{activeSection === 'safety' && renderSafetySettings()}
|
||||
{activeSection === 'context' && renderContextSettings()}
|
||||
{activeSection === 'tools' && renderBuiltinTools()}
|
||||
{activeSection === 'prompts' && renderBuiltinPrompts()}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AISettingsModal;
|
||||
@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom';
|
||||
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import type { SortOrder, ColumnType } from 'antd/es/table/interface';
|
||||
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import {
|
||||
DndContext,
|
||||
@@ -32,6 +32,7 @@ import 'react-resizable/css/styles.css';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { resolvePaginationPageText, resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination';
|
||||
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
|
||||
|
||||
// --- Error Boundary ---
|
||||
@@ -818,6 +819,7 @@ interface DataGridProps {
|
||||
total: number,
|
||||
totalKnown?: boolean,
|
||||
totalApprox?: boolean,
|
||||
approximateTotal?: number,
|
||||
totalCountLoading?: boolean,
|
||||
totalCountCancelled?: boolean,
|
||||
};
|
||||
@@ -995,7 +997,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const selectionColumnWidth = 46;
|
||||
const currentConnConfig = connections.find(c => c.id === connectionId)?.config;
|
||||
const dataSourceCaps = getDataSourceCapabilities(currentConnConfig);
|
||||
const isDuckDBConnection = dataSourceCaps.type === 'duckdb';
|
||||
const prefersManualTotalCount = dataSourceCaps.preferManualTotalCount;
|
||||
const supportsApproximateTableCount = dataSourceCaps.supportsApproximateTableCount;
|
||||
const supportsApproximateTotalPages = dataSourceCaps.supportsApproximateTotalPages;
|
||||
const supportsCopyInsert = dataSourceCaps.supportsCopyInsert;
|
||||
const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport;
|
||||
const isQueryResultExport = exportScope === 'queryResult';
|
||||
@@ -3504,7 +3508,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const values = orderedCols.map(c => {
|
||||
const v = r[c];
|
||||
if (v === null || v === undefined) return 'NULL';
|
||||
const escaped = String(v).replace(/'/g, "''");
|
||||
const str = typeof v === 'string' ? normalizeDateTimeString(v) : String(v);
|
||||
const escaped = str.replace(/'/g, "''");
|
||||
return `'${escaped}'`;
|
||||
});
|
||||
const targetTable = tableName || 'table';
|
||||
@@ -4482,37 +4487,20 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const paginationSummaryText = useMemo(() => {
|
||||
if (!pagination) return '';
|
||||
const total = Number.isFinite(pagination.total) ? pagination.total : 0;
|
||||
const rangeStart = Math.max(0, (pagination.current - 1) * pagination.pageSize + (total > 0 ? 1 : 0));
|
||||
const hasValidRange = total > 0 && rangeStart > 0;
|
||||
const rangeEnd = hasValidRange ? Math.min(total, rangeStart + pagination.pageSize - 1) : 0;
|
||||
const currentCount = hasValidRange ? Math.max(0, rangeEnd - rangeStart + 1) : 0;
|
||||
|
||||
if (pagination.totalKnown === false) {
|
||||
if (isDuckDBConnection) {
|
||||
if (pagination.totalCountLoading) return `当前 ${currentCount} 条 / 正在统计精确总数…`;
|
||||
if (pagination.totalApprox && Number.isFinite(total) && total > 0) return `当前 ${currentCount} 条 / 约 ${total} 条`;
|
||||
if (pagination.totalCountCancelled) return `当前 ${currentCount} 条 / 已取消统计`;
|
||||
return `当前 ${currentCount} 条 / 总数未统计`;
|
||||
}
|
||||
return `当前 ${currentCount} 条 / 正在统计总数…`;
|
||||
}
|
||||
|
||||
if (isDuckDBConnection && (!Number.isFinite(total) || total <= 0)) {
|
||||
return '当前 0 条 / 共 0 条';
|
||||
}
|
||||
|
||||
return `当前 ${currentCount} 条 / 共 ${total} 条`;
|
||||
}, [pagination, isDuckDBConnection]);
|
||||
return resolvePaginationSummaryText({
|
||||
pagination,
|
||||
prefersManualTotalCount,
|
||||
supportsApproximateTableCount,
|
||||
});
|
||||
}, [pagination, prefersManualTotalCount, supportsApproximateTableCount]);
|
||||
|
||||
const paginationPageText = useMemo(() => {
|
||||
if (!pagination) return '';
|
||||
const total = Number.isFinite(pagination.total) ? pagination.total : 0;
|
||||
const canShowTotalPages = pagination.totalKnown !== false || (isDuckDBConnection && pagination.totalApprox && total > 0);
|
||||
if (!canShowTotalPages || total <= 0) return `第 ${pagination.current} 页`;
|
||||
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pagination.pageSize)));
|
||||
return `第 ${pagination.current} / ${totalPages} 页`;
|
||||
}, [pagination, isDuckDBConnection]);
|
||||
return resolvePaginationPageText({
|
||||
pagination,
|
||||
supportsApproximateTotalPages,
|
||||
});
|
||||
}, [pagination, supportsApproximateTotalPages]);
|
||||
|
||||
const handlePageSizeChange = useCallback((value: string) => {
|
||||
if (!pagination || !onPageChange) return;
|
||||
@@ -4642,7 +4630,44 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{isDuckDBConnection && onRequestTotalCount && (
|
||||
<>
|
||||
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
|
||||
<Tooltip title="一键借助 AI 智能分析当前查询页数据">
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
style={{
|
||||
background: darkMode ? 'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(16,185,129,0.05))' : 'linear-gradient(135deg, rgba(16,185,129,0.1), rgba(16,185,129,0.02))',
|
||||
borderColor: darkMode ? 'rgba(16,185,129,0.3)' : 'rgba(16,185,129,0.4)',
|
||||
color: '#10b981',
|
||||
fontWeight: 500,
|
||||
boxShadow: darkMode ? '0 2px 8px rgba(16,185,129,0.1)' : '0 2px 6px rgba(16,185,129,0.05)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = darkMode ? 'linear-gradient(135deg, rgba(16,185,129,0.25), rgba(16,185,129,0.1))' : 'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(16,185,129,0.05))';
|
||||
e.currentTarget.style.borderColor = '#10b981';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = darkMode ? 'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(16,185,129,0.05))' : 'linear-gradient(135deg, rgba(16,185,129,0.1), rgba(16,185,129,0.02))';
|
||||
e.currentTarget.style.borderColor = darkMode ? 'rgba(16,185,129,0.3)' : 'rgba(16,185,129,0.4)';
|
||||
}}
|
||||
onClick={() => {
|
||||
const sampleData = mergedDisplayData.slice(0, 10);
|
||||
const prompt = `请帮我分析以下查询结果数据(取前 ${sampleData.length} 条示例):\n\`\`\`json\n${JSON.stringify(sampleData, null, 2)}\n\`\`\`\n\n请分析数据特征、发现规律,或者给出一些业务上的洞察。`;
|
||||
const store = useStore.getState();
|
||||
const wasClosed = !store.aiPanelVisible;
|
||||
if (wasClosed) store.setAIPanelVisible(true);
|
||||
// 如果面板刚打开,需要等待组件挂载完成后再注入 prompt
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
|
||||
}, wasClosed ? 350 : 0);
|
||||
}}
|
||||
>
|
||||
AI 数据洞察
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
||||
{prefersManualTotalCount && onRequestTotalCount && (
|
||||
<>
|
||||
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
|
||||
<Tooltip title={pagination?.totalCountLoading ? '取消本次精确总数统计(不会影响当前浏览)' : '按当前筛选统计精确总数'}>
|
||||
@@ -5501,7 +5526,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
total={resolvePaginationTotalForControl({
|
||||
pagination,
|
||||
supportsApproximateTotalPages,
|
||||
})}
|
||||
showSizeChanger={false}
|
||||
onChange={onPageChange}
|
||||
showTitle={false}
|
||||
|
||||
@@ -6,7 +6,9 @@ import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
|
||||
import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveApproximateTableCountStrategy } from '../utils/approximateTableCount';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
|
||||
|
||||
type ViewerPaginationState = {
|
||||
current: number;
|
||||
@@ -14,6 +16,7 @@ type ViewerPaginationState = {
|
||||
total: number;
|
||||
totalKnown: boolean;
|
||||
totalApprox: boolean;
|
||||
approximateTotal?: number;
|
||||
totalCountLoading: boolean;
|
||||
totalCountCancelled: boolean;
|
||||
};
|
||||
@@ -70,30 +73,6 @@ const parseTotalFromCountRow = (row: any): number | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseDuckDBApproxTotalRow = (row: any): number | null => {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const entries = Object.entries(row as Record<string, unknown>);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const preferredKeys = ['approx_total', 'estimated_size', 'estimated_rows', 'row_count', 'count', 'total'];
|
||||
for (const preferred of preferredKeys) {
|
||||
for (const [key, raw] of entries) {
|
||||
if (String(key || '').trim().toLowerCase() !== preferred) continue;
|
||||
const parsed = toNonNegativeFiniteNumber(raw);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, raw] of entries) {
|
||||
const normalized = String(key || '').trim().toLowerCase();
|
||||
if (normalized.includes('estimate') || normalized.includes('row') || normalized.includes('count') || normalized.includes('total')) {
|
||||
const parsed = toNonNegativeFiniteNumber(raw);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeDuckDBIdentifier = (raw: string): string => {
|
||||
const text = String(raw || '').trim();
|
||||
if (text.length >= 2) {
|
||||
@@ -201,7 +180,7 @@ const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
|
||||
};
|
||||
};
|
||||
|
||||
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
|
||||
const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]);
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||
@@ -214,6 +193,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const countKeyRef = useRef<string>('');
|
||||
const duckdbApproxSeqRef = useRef(0);
|
||||
const duckdbApproxKeyRef = useRef<string>('');
|
||||
const oracleApproxSeqRef = useRef(0);
|
||||
const oracleApproxKeyRef = useRef<string>('');
|
||||
const manualCountSeqRef = useRef(0);
|
||||
const manualCountKeyRef = useRef<string>('');
|
||||
const pkSeqRef = useRef(0);
|
||||
@@ -228,6 +209,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
left: initialViewerSnapshot.scrollLeft,
|
||||
});
|
||||
const initialLoadRef = useRef(false);
|
||||
const skipNextAutoFetchRef = useRef(false);
|
||||
|
||||
const [pagination, setPagination] = useState<ViewerPaginationState>({
|
||||
current: initialViewerSnapshot.currentPage,
|
||||
@@ -246,8 +228,10 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
|
||||
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
|
||||
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
|
||||
const currentConnType = currentConnCaps.type;
|
||||
const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult;
|
||||
const preferManualTotalCount = currentConnCaps.preferManualTotalCount;
|
||||
const supportsApproximateTableCount = currentConnCaps.supportsApproximateTableCount;
|
||||
const supportsApproximateTotalPages = currentConnCaps.supportsApproximateTotalPages;
|
||||
const persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial<ViewerFilterSnapshot>) => {
|
||||
const normalizedTabId = String(tabId || '').trim();
|
||||
if (!normalizedTabId) return;
|
||||
@@ -288,6 +272,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
pkKeyRef.current = '';
|
||||
countKeyRef.current = '';
|
||||
duckdbApproxKeyRef.current = '';
|
||||
oracleApproxKeyRef.current = '';
|
||||
manualCountKeyRef.current = '';
|
||||
duckdbSafeSelectCacheRef.current = {};
|
||||
latestConfigRef.current = null;
|
||||
@@ -297,6 +282,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
latestCountKeyRef.current = '';
|
||||
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
|
||||
initialLoadRef.current = false;
|
||||
skipNextAutoFetchRef.current = true;
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: snapshot.currentPage,
|
||||
@@ -304,6 +290,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
total: 0,
|
||||
totalKnown: false,
|
||||
totalApprox: false,
|
||||
approximateTotal: undefined,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
}));
|
||||
@@ -317,10 +304,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
});
|
||||
}, [tab.id, persistViewerSnapshot]);
|
||||
|
||||
const handleDuckDBManualCount = useCallback(async () => {
|
||||
if (latestDbTypeRef.current !== 'duckdb') {
|
||||
return;
|
||||
}
|
||||
const handleManualTotalCount = useCallback(async () => {
|
||||
const config = latestConfigRef.current;
|
||||
const dbName = latestDbNameRef.current;
|
||||
const countSql = latestCountSqlRef.current;
|
||||
@@ -341,7 +325,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const resCount = await DBQuery(countConfig as any, dbName, countSql);
|
||||
const countDuration = Date.now() - countStart;
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-duckdb-manual-count`,
|
||||
id: `log-${Date.now()}-manual-count`,
|
||||
timestamp: Date.now(),
|
||||
sql: countSql,
|
||||
status: resCount?.success ? 'success' : 'error',
|
||||
@@ -375,6 +359,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
total,
|
||||
totalKnown: true,
|
||||
totalApprox: false,
|
||||
approximateTotal: undefined,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
}));
|
||||
@@ -386,7 +371,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
}, [addSqlLog]);
|
||||
|
||||
const handleDuckDBCancelManualCount = useCallback(() => {
|
||||
const handleCancelManualTotalCount = useCallback(() => {
|
||||
manualCountSeqRef.current++;
|
||||
setPagination(prev => ({ ...prev, totalCountLoading: false, totalCountCancelled: true }));
|
||||
}, []);
|
||||
@@ -438,7 +423,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const totalRows = Number(pagination.total);
|
||||
const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0;
|
||||
const totalKnown = pagination.totalKnown && hasFiniteTotal;
|
||||
const totalPages = hasFiniteTotal ? Math.max(1, Math.ceil(totalRows / size)) : 0;
|
||||
const approximateTotalRows = Number(pagination.approximateTotal);
|
||||
const hasApproximateTotalPages =
|
||||
!totalKnown &&
|
||||
supportsApproximateTotalPages &&
|
||||
pagination.totalApprox &&
|
||||
Number.isFinite(approximateTotalRows) &&
|
||||
approximateTotalRows > 0;
|
||||
const effectiveTotalRows = hasApproximateTotalPages ? approximateTotalRows : totalRows;
|
||||
const totalPages = Number.isFinite(effectiveTotalRows) && effectiveTotalRows > 0 ? Math.max(1, Math.ceil(effectiveTotalRows / size)) : 0;
|
||||
const currentPage = totalPages > 0 ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page);
|
||||
const offset = (currentPage - 1) * size;
|
||||
const isClickHouse = !isMongoDB && dbTypeLower === 'clickhouse';
|
||||
@@ -632,6 +625,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
total: derivedTotal,
|
||||
totalKnown: true,
|
||||
totalApprox: false,
|
||||
approximateTotal: undefined,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
};
|
||||
@@ -647,13 +641,20 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
}
|
||||
const keepManualCounting = prev.totalCountLoading && manualCountKeyRef.current === countKey;
|
||||
if (isDuckDB && prev.totalApprox && duckdbApproxKeyRef.current === countKey && Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
|
||||
const hasApproximateTotalForCurrentKey =
|
||||
prev.totalApprox &&
|
||||
(duckdbApproxKeyRef.current === countKey || oracleApproxKeyRef.current === countKey) &&
|
||||
Number.isFinite(prev.approximateTotal) &&
|
||||
Number(prev.approximateTotal) >= minExpectedTotal;
|
||||
if (hasApproximateTotalForCurrentKey) {
|
||||
return {
|
||||
...prev,
|
||||
current: currentPage,
|
||||
pageSize: size,
|
||||
total: derivedTotal,
|
||||
totalKnown: false,
|
||||
totalApprox: true,
|
||||
approximateTotal: prev.approximateTotal,
|
||||
totalCountLoading: keepManualCounting,
|
||||
totalCountCancelled: false,
|
||||
};
|
||||
@@ -665,12 +666,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
total: derivedTotal,
|
||||
totalKnown: false,
|
||||
totalApprox: false,
|
||||
approximateTotal: undefined,
|
||||
totalCountLoading: keepManualCounting,
|
||||
totalCountCancelled: keepManualCounting ? false : prev.totalCountCancelled,
|
||||
};
|
||||
});
|
||||
|
||||
const shouldRunAsyncCount = !derivedTotalKnown && !isDuckDB;
|
||||
const shouldRunAsyncCount = !derivedTotalKnown && !preferManualTotalCount;
|
||||
if (shouldRunAsyncCount) {
|
||||
if (countKeyRef.current !== countKey) {
|
||||
countKeyRef.current = countKey;
|
||||
@@ -695,7 +697,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
});
|
||||
|
||||
if (countSeqRef.current !== countSeq) return;
|
||||
if (countKeyRef.current !== countKey) return;
|
||||
if (latestCountKeyRef.current !== countKey) return;
|
||||
|
||||
if (!resCount.success) return;
|
||||
if (!Array.isArray(resCount.data) || resCount.data.length === 0) return;
|
||||
@@ -708,6 +710,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
total,
|
||||
totalKnown: true,
|
||||
totalApprox: false,
|
||||
approximateTotal: undefined,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
}));
|
||||
@@ -720,48 +723,88 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (isDuckDB && !derivedTotalKnown && whereSQL.trim() === '' && duckdbApproxKeyRef.current !== countKey) {
|
||||
duckdbApproxKeyRef.current = countKey;
|
||||
const approxSeq = ++duckdbApproxSeqRef.current;
|
||||
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
|
||||
const escapedSchema = escapeSQLLiteral(schemaName);
|
||||
const escapedTable = escapeSQLLiteral(pureTableName);
|
||||
const approxConfig: any = { ...(config as any), timeout: 3 };
|
||||
const approxSqlCandidates = [
|
||||
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`,
|
||||
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`,
|
||||
];
|
||||
if (!derivedTotalKnown) {
|
||||
const approximateCountStrategy = supportsApproximateTableCount
|
||||
? resolveApproximateTableCountStrategy({ dbType: dbTypeLower, whereSQL })
|
||||
: 'none';
|
||||
|
||||
(async () => {
|
||||
for (const approxSql of approxSqlCandidates) {
|
||||
try {
|
||||
const approxRes = await DBQuery(approxConfig as any, dbName, approxSql);
|
||||
if (duckdbApproxSeqRef.current !== approxSeq) return;
|
||||
if (countKeyRef.current !== countKey) return;
|
||||
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
|
||||
if (approximateCountStrategy === 'duckdb-estimated-size' && duckdbApproxKeyRef.current !== countKey) {
|
||||
duckdbApproxKeyRef.current = countKey;
|
||||
const approxSeq = ++duckdbApproxSeqRef.current;
|
||||
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
|
||||
const escapedSchema = escapeSQLLiteral(schemaName);
|
||||
const escapedTable = escapeSQLLiteral(pureTableName);
|
||||
const approxConfig: any = { ...(config as any), timeout: 3 };
|
||||
const approxSqlCandidates = [
|
||||
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`,
|
||||
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`,
|
||||
];
|
||||
|
||||
const approxTotal = parseDuckDBApproxTotalRow(approxRes.data[0]);
|
||||
if (approxTotal === null) continue;
|
||||
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue;
|
||||
(async () => {
|
||||
for (const approxSql of approxSqlCandidates) {
|
||||
try {
|
||||
const approxRes = await DBQuery(approxConfig as any, dbName, approxSql);
|
||||
if (duckdbApproxSeqRef.current !== approxSeq) return;
|
||||
if (latestCountKeyRef.current !== countKey) return;
|
||||
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
|
||||
|
||||
const approxTotal = parseApproximateTableCountRow(approxRes.data[0]);
|
||||
if (approxTotal === null) continue;
|
||||
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue;
|
||||
|
||||
setPagination(prev => {
|
||||
if (latestCountKeyRef.current !== countKey) return prev;
|
||||
if (prev.totalKnown) return prev;
|
||||
return {
|
||||
...prev,
|
||||
totalKnown: false,
|
||||
totalApprox: true,
|
||||
approximateTotal: approxTotal,
|
||||
totalCountCancelled: false,
|
||||
};
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
if (duckdbApproxSeqRef.current !== approxSeq) return;
|
||||
if (latestCountKeyRef.current !== countKey) return;
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (approximateCountStrategy === 'oracle-num-rows' && oracleApproxKeyRef.current !== countKey) {
|
||||
oracleApproxKeyRef.current = countKey;
|
||||
const approxSeq = ++oracleApproxSeqRef.current;
|
||||
const approxConfig: any = { ...(config as any), timeout: 3 };
|
||||
const approxSql = buildOracleApproximateTotalSql({ dbName, tableName });
|
||||
|
||||
DBQuery(approxConfig as any, dbName, approxSql)
|
||||
.then((approxRes: any) => {
|
||||
if (oracleApproxSeqRef.current !== approxSeq) return;
|
||||
if (latestCountKeyRef.current !== countKey) return;
|
||||
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) return;
|
||||
|
||||
const approxTotal = parseApproximateTableCountRow(approxRes.data[0], ['approx_total', 'num_rows', 'estimated_rows', 'row_count', 'count', 'total']);
|
||||
if (approxTotal === null) return;
|
||||
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) return;
|
||||
|
||||
setPagination(prev => {
|
||||
if (countKeyRef.current !== countKey) return prev;
|
||||
if (latestCountKeyRef.current !== countKey) return prev;
|
||||
if (prev.totalKnown) return prev;
|
||||
return {
|
||||
...prev,
|
||||
total: approxTotal,
|
||||
totalKnown: false,
|
||||
totalApprox: true,
|
||||
approximateTotal: approxTotal,
|
||||
totalCountCancelled: false,
|
||||
};
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
if (duckdbApproxSeqRef.current !== approxSeq) return;
|
||||
if (countKeyRef.current !== countKey) return;
|
||||
}
|
||||
}
|
||||
})();
|
||||
})
|
||||
.catch(() => {
|
||||
if (oracleApproxSeqRef.current !== approxSeq) return;
|
||||
if (latestCountKeyRef.current !== countKey) return;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message.error(String(resData.message || '查询失败'));
|
||||
@@ -780,7 +823,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
});
|
||||
}
|
||||
if (fetchSeqRef.current === seq) setLoading(false);
|
||||
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown]);
|
||||
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
|
||||
// 依赖 pkColumns:在无手动排序时可回退到主键稳定排序。
|
||||
// 主键信息只会在首次加载后更新一次,避免循环查询。
|
||||
|
||||
@@ -828,7 +871,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialLoadRef.current) {
|
||||
const action = resolveDataViewerAutoFetchAction({
|
||||
skipNextAutoFetch: skipNextAutoFetchRef.current,
|
||||
hasInitialLoad: initialLoadRef.current,
|
||||
});
|
||||
if (action === 'skip') {
|
||||
skipNextAutoFetchRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (action === 'load-current-page') {
|
||||
initialLoadRef.current = true;
|
||||
fetchData(pagination.current, pagination.pageSize);
|
||||
return;
|
||||
@@ -851,8 +902,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
onSort={handleSort}
|
||||
onPageChange={handlePageChange}
|
||||
pagination={pagination}
|
||||
onRequestTotalCount={currentConnType === 'duckdb' ? handleDuckDBManualCount : undefined}
|
||||
onCancelTotalCount={currentConnType === 'duckdb' ? handleDuckDBCancelManualCount : undefined}
|
||||
onRequestTotalCount={preferManualTotalCount ? handleManualTotalCount : undefined}
|
||||
onCancelTotalCount={preferManualTotalCount ? handleCancelManualTotalCount : undefined}
|
||||
showFilter={showFilter}
|
||||
onToggleFilter={handleToggleFilter}
|
||||
onApplyFilter={handleApplyFilter}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import Editor, { OnMount } from '@monaco-editor/react';
|
||||
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
|
||||
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined } from '@ant-design/icons';
|
||||
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { format } from 'sql-formatter';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { TabData, ColumnDefinition } from '../types';
|
||||
@@ -183,7 +183,7 @@ let sharedAllColumnsData: {dbName: string, tableName: string, name: string, type
|
||||
let sharedVisibleDbs: string[] = [];
|
||||
let sharedColumnsCacheData: Record<string, any[]> = {};
|
||||
|
||||
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
|
||||
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
|
||||
|
||||
type ResultSet = {
|
||||
@@ -202,8 +202,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
// Result Sets
|
||||
const [resultSets, setResultSets] = useState<ResultSet[]>([]);
|
||||
const [activeResultKey, setActiveResultKey] = useState<string>('');
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [executionError, setExecutionError] = useState<string>('');
|
||||
const [, setCurrentQueryId] = useState<string>('');
|
||||
const runSeqRef = useRef(0);
|
||||
const currentQueryIdRef = useRef('');
|
||||
@@ -465,6 +465,38 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
// 应用透明主题(主题已在 main.tsx 全局注册)
|
||||
monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light');
|
||||
|
||||
// 注册 AI 右键菜单操作
|
||||
const aiActions = [
|
||||
{ id: 'ai.generateSQL', label: '🤖 AI 生成 SQL', prompt: '请根据当前数据库表结构生成查询语句:' },
|
||||
{ id: 'ai.explainSQL', label: '🤖 AI 解释 SQL', useSelection: true, prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n{SQL}\n```' },
|
||||
{ id: 'ai.optimizeSQL', label: '🤖 AI 优化 SQL', useSelection: true, prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n{SQL}\n```' },
|
||||
];
|
||||
|
||||
aiActions.forEach(action => {
|
||||
editor.addAction({
|
||||
id: action.id,
|
||||
label: action.label,
|
||||
contextMenuGroupId: '9_ai',
|
||||
contextMenuOrder: 1,
|
||||
run: (ed: any) => {
|
||||
const selection = ed.getModel()?.getValueInRange(ed.getSelection());
|
||||
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);
|
||||
}
|
||||
// 打开 AI 面板并填入 prompt
|
||||
const store = useStore.getState();
|
||||
if (!store.aiPanelVisible) {
|
||||
store.setAIPanelVisible(true);
|
||||
}
|
||||
// 通过自定义事件将 prompt 发送到 AI 面板
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 全局只注册一次 SQL completion provider,避免多 tab 重复注册导致补全项重复
|
||||
if (!sqlCompletionRegistered) {
|
||||
sqlCompletionRegistered = true;
|
||||
@@ -823,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 = () => {
|
||||
@@ -835,6 +952,28 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAIAction = (action: 'generate' | 'explain' | 'optimize' | 'schema') => {
|
||||
const editor = editorRef.current;
|
||||
const selection = editor?.getModel()?.getValueInRange(editor.getSelection()) || '';
|
||||
const fullSQL = getCurrentQuery();
|
||||
|
||||
const conn = connections.find(c => c.id === currentConnectionId);
|
||||
const ctxText = conn ? `【上下文环境:${conn.config?.type || '数据库'} "${conn.name}", 当前库选定为 "${currentDb || '默认'}"】\n` : '';
|
||||
|
||||
const prompts: Record<string, string> = {
|
||||
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();
|
||||
if (!store.aiPanelVisible) {
|
||||
store.setAIPanelVisible(true);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt: prompts[action] } }));
|
||||
};
|
||||
|
||||
const formatSettingsMenu: MenuProps['items'] = [
|
||||
{
|
||||
key: 'upper',
|
||||
@@ -1430,9 +1569,10 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
// 清除旧查询ID
|
||||
clearQueryId();
|
||||
}
|
||||
const runSeq = ++runSeqRef.current;
|
||||
setLoading(true);
|
||||
const runStartTime = Date.now();
|
||||
const runSeq = ++runSeqRef.current;
|
||||
setLoading(true);
|
||||
setExecutionError('');
|
||||
const runStartTime = Date.now();
|
||||
const conn = connections.find(c => c.id === currentConnectionId);
|
||||
if (!conn) {
|
||||
message.error("Connection not found");
|
||||
@@ -1446,13 +1586,14 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
const config = {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || "",
|
||||
database: conn.config.database || "",
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" },
|
||||
timeout: Math.max(Number(conn.config.timeout) || 30, 120),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1489,7 +1630,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
if (shellConvert.recognized) {
|
||||
if (shellConvert.error) {
|
||||
const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : '';
|
||||
message.error(prefix + shellConvert.error);
|
||||
setExecutionError(prefix + shellConvert.error);
|
||||
setResultSets([]);
|
||||
setActiveResultKey('');
|
||||
return;
|
||||
@@ -1522,7 +1663,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
});
|
||||
if (!res.success) {
|
||||
const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : '';
|
||||
message.error(prefix + res.message);
|
||||
setExecutionError(prefix + res.message);
|
||||
setResultSets([]);
|
||||
setActiveResultKey('');
|
||||
return;
|
||||
@@ -1644,7 +1785,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
message.error(res.message);
|
||||
setExecutionError(res.message);
|
||||
setResultSets([]);
|
||||
setActiveResultKey('');
|
||||
return;
|
||||
@@ -1702,8 +1843,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
let simpleTableName: string | undefined = undefined;
|
||||
if (rawStatement) {
|
||||
// 支持多行 SQL:SELECT * FROM [schema.]table [WHERE...] [ORDER BY...] [LIMIT...] 等
|
||||
const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+(?:[\w`"]+\.)?[`"]?(\w+)[`"]?\s*(?:$|[\s;])/im);
|
||||
// 支持多行 SQL:SELECT [cols] FROM [schema.]table [WHERE...] [ORDER BY...] [LIMIT...] 等
|
||||
// JOIN 查询表名歧义,不提取
|
||||
const hasJoin = /\bJOIN\b/i.test(rawStatement);
|
||||
const tableMatch = !hasJoin
|
||||
? rawStatement.match(/^\s*SELECT\s+.+?\s+FROM\s+(?:[\w`"\[\].]+\.)?[`"\[]?(\w+)[`"\]]?\s*(?:$|[\s;])/im)
|
||||
: null;
|
||||
if (tableMatch) {
|
||||
simpleTableName = tableMatch[1];
|
||||
if (!forceReadOnlyResult) {
|
||||
@@ -1882,6 +2027,89 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
};
|
||||
}, [activeTabId, tab.id, handleRun]);
|
||||
|
||||
// 监听由 TabManager 分发的专用注入事件
|
||||
useEffect(() => {
|
||||
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;
|
||||
const monaco = monacoRef.current;
|
||||
if (editor && monaco) {
|
||||
const model = editor.getModel();
|
||||
const existingContent = editor.getValue?.() || '';
|
||||
|
||||
// runImmediately 模式下,如果编辑器内容已是待注入的 SQL(TabManager 创建时已传入),
|
||||
// 跳过追加,直接选中全部内容并执行
|
||||
if (e.detail.runImmediately && existingContent.trim() === sqlText.trim()) {
|
||||
if (model) {
|
||||
const lineCount = model.getLineCount();
|
||||
const maxCol = model.getLineMaxColumn(lineCount);
|
||||
editor.setSelection(new monaco.Range(1, 1, lineCount, maxCol));
|
||||
editor.focus();
|
||||
setTimeout(() => handleRun(), 500);
|
||||
}
|
||||
} else {
|
||||
let position = editor.getPosition();
|
||||
if (!position && model) {
|
||||
const lineCount = model.getLineCount();
|
||||
const maxCol = model.getLineMaxColumn(lineCount);
|
||||
position = new monaco.Position(lineCount, maxCol);
|
||||
}
|
||||
|
||||
if (position) {
|
||||
const mText = (sqlText.endsWith('\n') ? sqlText : sqlText + '\n');
|
||||
const startRange = new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column);
|
||||
|
||||
editor.executeEdits('ai-insert', [{
|
||||
range: startRange,
|
||||
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 monaco.Range(
|
||||
targetLine, 1,
|
||||
endPosition.lineNumber, endPosition.column
|
||||
));
|
||||
// 🔧 延迟 500ms 等待连接/数据库切换的 setState 生效后再执行
|
||||
setTimeout(() => handleRun(), 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setQuery((prev: string) => prev ? prev + '\n' + sqlText : sqlText);
|
||||
message.success('代码已追加');
|
||||
}
|
||||
};
|
||||
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();
|
||||
if (!rawTitle || rawTitle.startsWith('新建查询')) {
|
||||
@@ -2067,6 +2295,16 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
<Button icon={<SettingOutlined />} />
|
||||
</Dropdown>
|
||||
</Button.Group>
|
||||
|
||||
<Dropdown menu={{ items: [
|
||||
{ key: 'ai-generate', label: '生成 SQL', icon: <RobotOutlined />, onClick: () => handleAIAction('generate') },
|
||||
{ key: 'ai-explain', label: '解释 SQL', icon: <RobotOutlined />, onClick: () => handleAIAction('explain') },
|
||||
{ key: 'ai-optimize', label: '优化 SQL', icon: <RobotOutlined />, onClick: () => handleAIAction('optimize') },
|
||||
{ type: 'divider' as const },
|
||||
{ key: 'ai-schema', label: 'Schema 分析', icon: <RobotOutlined />, onClick: () => handleAIAction('schema') },
|
||||
] }} placement="bottomRight">
|
||||
<Button icon={<RobotOutlined />} style={{ color: '#818cf8' }}>AI</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div style={{ height: editorHeight, minHeight: '100px' }}>
|
||||
@@ -2168,6 +2406,35 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
})()
|
||||
}))}
|
||||
/>
|
||||
) : executionError ? (
|
||||
<div style={{ flex: 1, minHeight: 0, padding: 24, display: 'flex', flexDirection: 'column', gap: 16, background: darkMode ? '#1e1e1e' : '#fafafa', overflow: 'auto' }}>
|
||||
<div style={{ color: '#ff4d4f', fontWeight: 'bold', fontSize: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<CloseOutlined />
|
||||
<span>执行失败</span>
|
||||
</div>
|
||||
<div className="custom-scrollbar" style={{ padding: 16, background: darkMode ? '#2d1a1a' : '#fff2f0', border: `1px solid ${darkMode ? '#5c2020' : '#ffccc7'}`, borderRadius: 6, color: darkMode ? '#ffa39e' : '#cf1322', fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: '40vh', overflow: 'auto' }}>
|
||||
{executionError}
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<RobotOutlined />}
|
||||
style={{ background: '#818cf8', borderColor: '#818cf8', boxShadow: '0 2px 0 rgba(129, 140, 248, 0.2)' }}
|
||||
onClick={() => {
|
||||
const errSql = getCurrentQuery();
|
||||
const prompt = `我在执行以下 SQL 时遇到了错误:\n\`\`\`sql\n${errSql}\n\`\`\`\n\n数据库报错信息如下:\n\`\`\`text\n${executionError}\n\`\`\`\n\n请帮我分析错误原因,并给出修改建议。`;
|
||||
const store = useStore.getState();
|
||||
const wasClosed = !store.aiPanelVisible;
|
||||
if (wasClosed) store.setAIPanelVisible(true);
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
|
||||
}, wasClosed ? 350 : 0);
|
||||
}}
|
||||
>
|
||||
一键 AI 诊断
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, minHeight: 0 }} />
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Button, Space, message } from 'antd';
|
||||
import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
@@ -14,6 +14,67 @@ interface CommandResult {
|
||||
result: any;
|
||||
error?: string;
|
||||
timestamp: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
// 智能解析 Redis 脚本块,保护多行引号内的换行符
|
||||
function parseRedisScriptBlocks(script: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
let currentBlock = "";
|
||||
let inQuote: string | null = null;
|
||||
let isEscaping = false;
|
||||
|
||||
const lines = script.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!inQuote && (trimmed === '' || trimmed.startsWith('//') || trimmed.startsWith('#'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let j = 0; j < line.length; j++) {
|
||||
const char = line[j];
|
||||
|
||||
if (isEscaping) {
|
||||
isEscaping = false;
|
||||
currentBlock += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
isEscaping = true;
|
||||
currentBlock += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
if (inQuote === char) {
|
||||
inQuote = null;
|
||||
} else if (!inQuote) {
|
||||
inQuote = char;
|
||||
}
|
||||
}
|
||||
|
||||
currentBlock += char;
|
||||
}
|
||||
|
||||
if (inQuote || (i < lines.length - 1 && currentBlock.trim() !== '')) {
|
||||
if (!inQuote) {
|
||||
blocks.push(currentBlock.trim());
|
||||
currentBlock = "";
|
||||
} else {
|
||||
currentBlock += '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBlock.trim() !== '') {
|
||||
blocks.push(currentBlock.trim());
|
||||
}
|
||||
|
||||
return blocks.filter(b => b.trim() !== '');
|
||||
}
|
||||
|
||||
const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, redisDB }) => {
|
||||
@@ -23,6 +84,13 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
||||
const [command, setCommand] = useState('');
|
||||
const [results, setResults] = useState<CommandResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// UI Layout state
|
||||
const [editorHeight, setEditorHeight] = useState(250);
|
||||
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const resultsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const editorRef = useRef<any>(null);
|
||||
|
||||
const getConfig = useCallback(() => {
|
||||
@@ -37,77 +105,173 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
||||
};
|
||||
}, [connection, redisDB]);
|
||||
|
||||
const handleEditorMount: OnMount = (editor) => {
|
||||
const handleEditorMount: OnMount = (editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
// Add keyboard shortcut for execute
|
||||
editor.addCommand(
|
||||
// Ctrl/Cmd + Enter
|
||||
2048 | 3, // KeyMod.CtrlCmd | KeyCode.Enter
|
||||
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
|
||||
() => handleExecute()
|
||||
);
|
||||
|
||||
if (!(window as any).__redisCompletionRegistered) {
|
||||
(window as any).__redisCompletionRegistered = true;
|
||||
|
||||
const redisCommands = [
|
||||
"APPEND", "AUTH", "BGREWRITEAOF", "BGSAVE", "BITCOUNT", "BITFIELD", "BITOP",
|
||||
"BITPOS", "BLPOP", "BRPOP", "BRPOPLPUSH", "BZMPOP", "BZPOPMIN", "BZPOPMAX",
|
||||
"CLIENT", "CLUSTER", "COMMAND", "CONFIG", "DBSIZE", "DEBUG", "DECR", "DECRBY",
|
||||
"DEL", "DISCARD", "DUMP", "ECHO", "EVAL", "EVALSHA", "EXEC", "EXISTS", "EXPIRE",
|
||||
"EXPIREAT", "EXPIRETIME", "FLUSHALL", "FLUSHDB", "GEOADD", "GEODIST", "GEOHASH",
|
||||
"GEOPOS", "GEORADIUS", "GEORADIUSBYMEMBER", "GEOSEARCH", "GEOSEARCHSTORE",
|
||||
"GET", "GETBIT", "GETDEL", "GETEX", "GETRANGE", "GETSET", "HDEL", "HELLO",
|
||||
"HEXISTS", "HGET", "HGETALL", "HINCRBY", "HINCRBYFLOAT", "HKEYS", "HLEN",
|
||||
"HMGET", "HMSET", "HSCAN", "HSET", "HSETNX", "HSTRLEN", "HVALS", "INCR",
|
||||
"INCRBY", "INCRBYFLOAT", "INFO", "KEYS", "LASTSAVE", "LCS", "LINDEX", "LINSERT",
|
||||
"LLEN", "LMOVE", "LMPOP", "LPOP", "LPOS", "LPUSH", "LPUSHX", "LRANGE", "LREM",
|
||||
"LSET", "LTRIM", "MEMORY", "MGET", "MIGRATE", "MODULE", "MONITOR", "MOVE", "MSET",
|
||||
"MSETNX", "MULTI", "OBJECT", "PERSIST", "PEXPIRE", "PEXPIREAT", "PEXPIRETIME",
|
||||
"PFADD", "PFCOUNT", "PFMERGE", "PING", "PSETEX", "PSUBSCRIBE", "PTTL", "PUBLISH",
|
||||
"PUBSUB", "PUNSUBSCRIBE", "QUIT", "RANDOMKEY", "READONLY", "READWRITE", "RENAME",
|
||||
"RENAMENX", "RESET", "RESTORE", "ROLE", "RPOP", "RPOPLPUSH", "RPUSH", "RPUSHX",
|
||||
"SADD", "SAVE", "SCAN", "SCARD", "SCRIPT", "SDIFF", "SDIFFSTORE", "SELECT",
|
||||
"SET", "SETBIT", "SETEX", "SETNX", "SETRANGE", "SHUTDOWN", "SINTER", "SINTERCARD",
|
||||
"SINTERSTORE", "SISMEMBER", "SLAVEOF", "SLOWLOG", "SMEMBERS", "SMISMEMBER",
|
||||
"SMOVE", "SORT", "SORT_RO", "SPOP", "SRANDMEMBER", "SREM", "SSCAN", "STRLEN",
|
||||
"SUBSCRIBE", "SUNION", "SUNIONSTORE", "SWAPDB", "SYNC", "TIME", "TOUCH", "TTL",
|
||||
"TYPE", "UNLINK", "UNSUBSCRIBE", "UNWATCH", "WAIT", "WATCH", "XACK", "XADD",
|
||||
"XAUTOCLAIM", "XCLAIM", "XDEL", "XGROUP", "XINFO", "XLEN", "XPENDING", "XRANGE",
|
||||
"XREAD", "XREADGROUP", "XREVRANGE", "XTRIM", "ZADD", "ZCARD", "ZCOUNT", "ZDIFF",
|
||||
"ZDIFFSTORE", "ZINCRBY", "ZINTER", "ZINTERCARD", "ZINTERSTORE", "ZLEXCOUNT",
|
||||
"ZMPOP", "ZMSCORE", "ZPOPMAX", "ZPOPMIN", "ZRANDMEMBER", "ZRANGE", "ZRANGEBYLEX",
|
||||
"ZRANGEBYSCORE", "ZRANK", "ZREM", "ZREMRANGEBYLEX", "ZREMRANGEBYRANK",
|
||||
"ZREMRANGEBYSCORE", "ZREVRANGE", "ZREVRANGEBYLEX", "ZREVRANGEBYSCORE", "ZREVRANK",
|
||||
"ZSCAN", "ZSCORE", "ZUNION", "ZUNIONSTORE"
|
||||
];
|
||||
|
||||
monaco.languages.registerCompletionItemProvider('redis', {
|
||||
provideCompletionItems: (model: any, position: any) => {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn
|
||||
};
|
||||
return {
|
||||
suggestions: redisCommands.map(cmd => ({
|
||||
label: cmd,
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: cmd,
|
||||
range: range,
|
||||
detail: "Redis Command"
|
||||
}))
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
|
||||
const cmdToExecute = command.trim();
|
||||
let cmdToExecute = '';
|
||||
|
||||
// 1. 获取用户是否有高亮选中的文本
|
||||
const selection = editorRef.current?.getSelection();
|
||||
if (selection && !selection.isEmpty()) {
|
||||
cmdToExecute = editorRef.current?.getModel()?.getValueInRange(selection) || '';
|
||||
} else {
|
||||
// 没有选中则取全部文本
|
||||
cmdToExecute = editorRef.current?.getValue() || '';
|
||||
}
|
||||
|
||||
cmdToExecute = cmdToExecute.trim();
|
||||
if (!cmdToExecute) {
|
||||
message.warning('请输入命令');
|
||||
message.warning('请输入要执行的命令');
|
||||
return;
|
||||
}
|
||||
|
||||
// Support multiple commands separated by newlines
|
||||
const commands = cmdToExecute.split('\n').filter(c => c.trim() && !c.trim().startsWith('//') && !c.trim().startsWith('#'));
|
||||
// 2. 智能解析多行命令
|
||||
const commands = parseRedisScriptBlocks(cmdToExecute);
|
||||
if (commands.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
const newResults: CommandResult[] = [];
|
||||
|
||||
for (const cmd of commands) {
|
||||
const trimmedCmd = cmd.trim();
|
||||
if (!trimmedCmd) continue;
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisExecuteCommand(config, trimmedCmd);
|
||||
const res = await (window as any).go.app.App.RedisExecuteCommand(config, cmd);
|
||||
newResults.push({
|
||||
command: trimmedCmd,
|
||||
command: cmd,
|
||||
result: res.success ? res.data : null,
|
||||
error: res.success ? undefined : res.message,
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
durationMs: Date.now() - start
|
||||
});
|
||||
} catch (e: any) {
|
||||
newResults.push({
|
||||
command: trimmedCmd,
|
||||
command: cmd,
|
||||
result: null,
|
||||
error: e?.message || String(e),
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
durationMs: Date.now() - start
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setResults(prev => [...newResults, ...prev]);
|
||||
setResults(prev => [...prev, ...newResults]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Auto scroll to bottom when new results arrive
|
||||
useEffect(() => {
|
||||
if (resultsEndRef.current) {
|
||||
resultsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [results]);
|
||||
|
||||
const handleClear = () => {
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
const formatResult = (result: any): string => {
|
||||
const formatResult = (result: any): React.ReactNode => {
|
||||
if (result === null || result === undefined) {
|
||||
return '(nil)';
|
||||
return <span style={{ color: '#569cd6' }}>(nil)</span>;
|
||||
}
|
||||
if (typeof result === 'string') {
|
||||
return `"${result}"`;
|
||||
// 尝试美化 JSON 字符串
|
||||
try {
|
||||
const parsed = JSON.parse(result);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return (
|
||||
<div style={{ marginTop: 4, padding: 8, background: 'rgba(0,0,0,0.2)', borderRadius: 4 }}>
|
||||
{JSON.stringify(parsed, null, 2)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// not a valid json, just return string
|
||||
}
|
||||
return <span style={{ color: '#ce9178' }}>"{result}"</span>;
|
||||
}
|
||||
if (typeof result === 'number') {
|
||||
return `(integer) ${result}`;
|
||||
return <span style={{ color: '#b5cea8' }}>(integer) {result}</span>;
|
||||
}
|
||||
if (Array.isArray(result)) {
|
||||
if (result.length === 0) {
|
||||
return '(empty array)';
|
||||
}
|
||||
return result.map((item, index) => `${index + 1}) ${formatResult(item)}`).join('\n');
|
||||
return (
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
{result.map((item, index) => (
|
||||
<div key={index} style={{ display: 'flex' }}>
|
||||
<span style={{ color: '#608b4e', marginRight: 8, userSelect: 'none' }}>{index + 1})</span>
|
||||
<div>{formatResult(item)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (typeof result === 'object') {
|
||||
return JSON.stringify(result, null, 2);
|
||||
@@ -115,18 +279,56 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
||||
return String(result);
|
||||
};
|
||||
|
||||
// Resizing logic
|
||||
const handleDragStart = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragRef.current = { startY: e.clientY, startHeight: editorHeight };
|
||||
document.addEventListener('mousemove', handleDragMove);
|
||||
document.addEventListener('mouseup', handleDragEnd);
|
||||
document.body.style.cursor = 'row-resize';
|
||||
};
|
||||
|
||||
const handleDragMove = useCallback((e: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const delta = e.clientY - dragRef.current.startY;
|
||||
let newHeight = dragRef.current.startHeight + delta;
|
||||
|
||||
// 限制高度
|
||||
const minHeight = 100;
|
||||
const maxHeight = containerRef.current ? containerRef.current.clientHeight - 100 : 800;
|
||||
if (newHeight < minHeight) newHeight = minHeight;
|
||||
if (newHeight > maxHeight) newHeight = maxHeight;
|
||||
|
||||
setEditorHeight(newHeight);
|
||||
|
||||
// 更新编辑器布局
|
||||
if (editorRef.current) {
|
||||
editorRef.current.layout();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
dragRef.current = null;
|
||||
document.removeEventListener('mousemove', handleDragMove);
|
||||
document.removeEventListener('mouseup', handleDragEnd);
|
||||
document.body.style.cursor = 'default';
|
||||
if (editorRef.current) {
|
||||
editorRef.current.layout();
|
||||
}
|
||||
}, [handleDragMove]);
|
||||
|
||||
if (!connection) {
|
||||
return <div style={{ padding: 20 }}>连接不存在</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* Command Input */}
|
||||
<div style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', background: '#fff' }}>
|
||||
{/* Editor Top Pane */}
|
||||
<div style={{ height: editorHeight, minHeight: 100, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#fdfdfd' }}>
|
||||
<Space>
|
||||
<span style={{ fontWeight: 500 }}>Redis 命令</span>
|
||||
<span style={{ color: '#999', fontSize: 12 }}>db{redisDB}</span>
|
||||
<span style={{ fontWeight: 600 }}>Redis Console</span>
|
||||
<span style={{ color: '#888', fontSize: 13, background: '#f0f0f0', padding: '2px 8px', borderRadius: 12 }}>db{redisDB}</span>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
@@ -135,68 +337,89 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
||||
onClick={handleExecute}
|
||||
loading={loading}
|
||||
>
|
||||
执行 (Ctrl+Enter)
|
||||
执行 (Cmd+Enter)
|
||||
</Button>
|
||||
<Button icon={<ClearOutlined />} onClick={handleClear}>清空结果</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Editor
|
||||
height="150px"
|
||||
defaultLanguage="plaintext"
|
||||
value={command}
|
||||
onChange={(value) => setCommand(value || '')}
|
||||
onMount={handleEditorMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: 'on',
|
||||
fontSize: 14,
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<Editor
|
||||
defaultLanguage="redis"
|
||||
language="redis"
|
||||
value={command}
|
||||
onChange={(value) => setCommand(value || '')}
|
||||
onMount={handleEditorMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: 'on',
|
||||
fontSize: 14,
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 4,
|
||||
padding: { top: 10, bottom: 10 }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: 'monospace' }}>
|
||||
{results.length === 0 ? (
|
||||
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>
|
||||
输入 Redis 命令并按 Ctrl+Enter 执行
|
||||
<br />
|
||||
<span style={{ fontSize: 12 }}>支持多行命令,每行一个命令</span>
|
||||
</div>
|
||||
) : (
|
||||
results.map((item, index) => (
|
||||
<div key={item.timestamp + index} style={{ padding: '8px 12px', borderBottom: '1px solid #333' }}>
|
||||
<div style={{ color: '#569cd6', marginBottom: 4 }}>
|
||||
> {item.command}
|
||||
{/* Resizer Handle */}
|
||||
<div
|
||||
className="horizontal-resizer"
|
||||
onMouseDown={handleDragStart}
|
||||
style={{
|
||||
height: 8,
|
||||
cursor: 'row-resize',
|
||||
background: '#f0f0f0',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 40, height: 4, background: '#ccc', borderRadius: 2 }} />
|
||||
</div>
|
||||
|
||||
{/* Results Terminal Bottom Pane */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '4px 12px', background: '#252526', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #333' }}>
|
||||
<span style={{ color: '#ccc', fontSize: 12 }}>Execution Output</span>
|
||||
<Button type="text" size="small" icon={<ClearOutlined />} onClick={handleClear} style={{ color: '#aaa' }}>清空控制台</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: '"Consolas", "Courier New", monospace', fontSize: 13, padding: 12 }}>
|
||||
{results.length === 0 ? (
|
||||
<div style={{ color: '#666', textAlign: 'center', marginTop: 40 }}>
|
||||
<div>在此终端执行命令,结果会以原样输出</div>
|
||||
<div style={{ fontSize: 12, marginTop: 12 }}>
|
||||
Tips: <code>选中任意行</code> 按 <code style={{ color: '#999' }}>Ctrl + Enter</code> 仅执行选中段落
|
||||
</div>
|
||||
{item.error ? (
|
||||
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
|
||||
(error) {item.error}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#ce9178', whiteSpace: 'pre-wrap' }}>
|
||||
{formatResult(item.result)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Common Commands Help */}
|
||||
<div style={{ padding: '8px 12px', borderTop: '1px solid #f0f0f0', background: '#fafafa', fontSize: 12, color: '#666' }}>
|
||||
常用命令:
|
||||
<span style={{ marginLeft: 8 }}>
|
||||
<code>KEYS *</code> |
|
||||
<code style={{ marginLeft: 8 }}>GET key</code> |
|
||||
<code style={{ marginLeft: 8 }}>SET key value</code> |
|
||||
<code style={{ marginLeft: 8 }}>HGETALL key</code> |
|
||||
<code style={{ marginLeft: 8 }}>INFO</code> |
|
||||
<code style={{ marginLeft: 8 }}>DBSIZE</code>
|
||||
</span>
|
||||
) : (
|
||||
results.map((item, index) => (
|
||||
<div key={item.timestamp + index} style={{ marginBottom: 16 }}>
|
||||
<div style={{ color: '#569cd6', marginBottom: 6, fontWeight: 'bold' }}>
|
||||
<span style={{ color: '#4CAF50', marginRight: 8 }}>➜</span>
|
||||
{item.command}
|
||||
<span style={{ color: '#666', fontSize: 11, marginLeft: 12, fontWeight: 'normal' }}>[{item.durationMs}ms]</span>
|
||||
</div>
|
||||
|
||||
<div style={{ paddingLeft: 20 }}>
|
||||
{item.error ? (
|
||||
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
|
||||
(error) {item.error}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{formatResult(item.result)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={resultsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
378
frontend/src/components/RedisMonitor.tsx
Normal file
378
frontend/src/components/RedisMonitor.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Card, Row, Col, Statistic, Select, Button, message, Tag, Typography, Tooltip, Spin } from 'antd';
|
||||
import { AreaChart, Area, XAxis, YAxis, Tooltip as RechartsTooltip, ResponsiveContainer, CartesianGrid, Legend, LineChart, Line } from 'recharts';
|
||||
import {
|
||||
DesktopOutlined,
|
||||
DashboardOutlined,
|
||||
ApiOutlined,
|
||||
HddOutlined,
|
||||
ReloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { SavedConnection } from '../types';
|
||||
import { RedisGetServerInfo } from '../../wailsjs/go/app/App';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface RedisMonitorProps {
|
||||
connectionId: string;
|
||||
redisDB: number;
|
||||
}
|
||||
|
||||
// Data point for charts
|
||||
interface MetricPoint {
|
||||
time: string;
|
||||
qps: number;
|
||||
memory: number; // in MB
|
||||
memory_rss: number; // in MB
|
||||
clients: number;
|
||||
cpuSys: number;
|
||||
cpuUser: number;
|
||||
hitRate: number;
|
||||
keys: number;
|
||||
}
|
||||
|
||||
const MAX_HISTORY_POINTS = 60; // Keep up to 60 data points
|
||||
|
||||
const RedisMonitor: React.FC<RedisMonitorProps> = ({ connectionId, redisDB }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const darkMode = theme === 'dark';
|
||||
|
||||
const [isRunning, setIsRunning] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [history, setHistory] = useState<MetricPoint[]>([]);
|
||||
const [currentInfo, setCurrentInfo] = useState<Record<string, string>>({});
|
||||
|
||||
// Ref to track if component is mounted to prevent state updates after unmount
|
||||
const mountedRef = useRef(true);
|
||||
// Interval ref
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// Previous ops counter to calculate QPS if instantaneous_ops_per_sec is not enough
|
||||
const prevMetricsRef = useRef({ prevOps: 0, prevTime: 0 });
|
||||
|
||||
const connection = connections.find((c: SavedConnection) => c.id === connectionId);
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
if (!connection) return;
|
||||
|
||||
try {
|
||||
const config = { ...connection.config, redisDB } as any;
|
||||
const res = await RedisGetServerInfo(config);
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
if (!res.success) {
|
||||
setError(res.message || 'Failed to fetch Redis info');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
const infoMap = res.data as Record<string, string>;
|
||||
setCurrentInfo(infoMap);
|
||||
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleTimeString([], { hour12: false, second: '2-digit' });
|
||||
|
||||
// Parse values
|
||||
const qps = parseInt(infoMap['instantaneous_ops_per_sec'] || '0', 10);
|
||||
const memBytes = parseInt(infoMap['used_memory'] || '0', 10);
|
||||
const memRssBytes = parseInt(infoMap['used_memory_rss'] || '0', 10);
|
||||
const clients = parseInt(infoMap['connected_clients'] || '0', 10);
|
||||
const cpuSys = parseFloat(infoMap['used_cpu_sys'] || '0');
|
||||
const cpuUser = parseFloat(infoMap['used_cpu_user'] || '0');
|
||||
|
||||
const hits = parseInt(infoMap['keyspace_hits'] || '0', 10);
|
||||
const misses = parseInt(infoMap['keyspace_misses'] || '0', 10);
|
||||
const hitRate = (hits + misses) > 0 ? (hits / (hits + misses)) * 100 : 0;
|
||||
|
||||
let keys = 0;
|
||||
Object.keys(infoMap).forEach(k => {
|
||||
if (k.startsWith('db')) {
|
||||
const m = infoMap[k].match(/keys=(\d+)/);
|
||||
if (m) keys += parseInt(m[1], 10);
|
||||
}
|
||||
});
|
||||
|
||||
const point: MetricPoint = {
|
||||
time: timeStr,
|
||||
qps,
|
||||
memory: parseFloat((memBytes / 1024 / 1024).toFixed(2)),
|
||||
memory_rss: parseFloat((memRssBytes / 1024 / 1024).toFixed(2)),
|
||||
clients,
|
||||
cpuSys: parseFloat(cpuSys.toFixed(2)),
|
||||
cpuUser: parseFloat(cpuUser.toFixed(2)),
|
||||
hitRate: parseFloat(hitRate.toFixed(2)),
|
||||
keys
|
||||
};
|
||||
|
||||
setHistory(prev => {
|
||||
const next = [...prev, point];
|
||||
if (next.length > MAX_HISTORY_POINTS) {
|
||||
return next.slice(next.length - MAX_HISTORY_POINTS);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (loading) setLoading(false);
|
||||
|
||||
} catch (err: any) {
|
||||
if (mountedRef.current) {
|
||||
setError(err.message || 'Unknown error');
|
||||
if (loading) setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
fetchMetrics(); // initial fetch
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
intervalRef.current = setInterval(fetchMetrics, 2000); // 2 second interval
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [isRunning, connectionId, redisDB, connection]);
|
||||
|
||||
if (!connection) {
|
||||
return <div style={{ padding: 20 }}>Connection not found.</div>;
|
||||
}
|
||||
|
||||
// Determine styles for charts based on theme
|
||||
const chartTextColor = darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)';
|
||||
const chartGridColor = darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
|
||||
const cardBgColor = darkMode ? '#1f1f1f' : '#ffffff';
|
||||
|
||||
const getFormatMemoryString = (bytes: string) => {
|
||||
const val = parseInt(bytes || '0', 10);
|
||||
if (val > 1024*1024*1024) return (val/1024/1024/1024).toFixed(2) + ' GB';
|
||||
if (val > 1024*1024) return (val/1024/1024).toFixed(2) + ' MB';
|
||||
if (val > 1024) return (val/1024).toFixed(2) + ' KB';
|
||||
return val + ' B';
|
||||
};
|
||||
|
||||
const getUptimeString = (seconds: string) => {
|
||||
const d = parseInt(seconds || '0', 10);
|
||||
if (d < 60) return `${d}s`;
|
||||
if (d < 3600) return `${Math.floor(d/60)}m ${d%60}s`;
|
||||
if (d < 86400) return `${Math.floor(d/3600)}h ${Math.floor((d%3600)/60)}m`;
|
||||
return `${Math.floor(d/86400)}d ${Math.floor((d%86400)/3600)}h`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', overflow: 'auto', padding: '16px 24px', backgroundColor: darkMode ? '#141414' : '#f0f2f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<div>
|
||||
<Title level={3} style={{ margin: 0, fontWeight: 600 }}>
|
||||
<DashboardOutlined style={{ marginRight: 8, color: '#1677ff' }} />
|
||||
Redis 实例监控
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
{connection.name}
|
||||
{currentInfo.redis_version && ` • Redis ${currentInfo.redis_version}`}
|
||||
{currentInfo.os && ` • ${currentInfo.os}`}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
{error && <Tag color="error" style={{ height: 32, lineHeight: '30px', fontSize: 13 }}>{error}</Tag>}
|
||||
{loading && !error && <Spin style={{ alignSelf: 'center', marginRight: 16 }} />}
|
||||
|
||||
<Button
|
||||
type={isRunning ? "default" : "primary"}
|
||||
icon={isRunning ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
onClick={() => setIsRunning(!isRunning)}
|
||||
>
|
||||
{isRunning ? '暂停刷新' : '恢复刷新'}
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchMetrics}>
|
||||
立即刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontWeight: 500 }}><DesktopOutlined /> 已用内存 (Used)</span>}
|
||||
value={getFormatMemoryString(currentInfo.used_memory || '0')}
|
||||
valueStyle={{ color: '#eb2f96', fontWeight: 600 }}
|
||||
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Peak: {getFormatMemoryString(currentInfo.used_memory_peak || '0')}</Text>}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontWeight: 500 }}><ApiOutlined /> 客户端数量 (Clients)</span>}
|
||||
value={currentInfo.connected_clients || '0'}
|
||||
valueStyle={{ color: '#1677ff', fontWeight: 600 }}
|
||||
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Blocked: {currentInfo.blocked_clients || '0'}</Text>}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontWeight: 500 }}><HddOutlined /> 吞吐量 (OPS)</span>}
|
||||
value={currentInfo.instantaneous_ops_per_sec || '0'}
|
||||
valueStyle={{ color: '#52c41a', fontWeight: 600 }}
|
||||
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>cmds/s</Text>}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontWeight: 500 }}>启动时长 (Uptime)</span>}
|
||||
value={getUptimeString(currentInfo.uptime_in_seconds || '0')}
|
||||
valueStyle={{ color: '#fa8c16', fontWeight: 600 }}
|
||||
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Days: {currentInfo.uptime_in_days || '0'}</Text>}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col span={12}>
|
||||
<Card
|
||||
bordered={false}
|
||||
title="请求吞吐量 (QPS)"
|
||||
style={{ background: cardBgColor, borderRadius: 8, height: 350, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||
styles={{ body: { padding: '16px 16px 0 0', height: 290 } }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorQps" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#52c41a" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#52c41a" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||
itemStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="qps" name="QPS" stroke="#52c41a" strokeWidth={2} fillOpacity={1} fill="url(#colorQps)" isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Card
|
||||
bordered={false}
|
||||
title="内存开销 (Memory)"
|
||||
style={{ background: cardBgColor, borderRadius: 8, height: 350, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||
styles={{ body: { padding: '16px 16px 0 0', height: 290 } }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} domain={['auto', 'auto']} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||
itemStyle={{ fontWeight: 600 }}
|
||||
formatter={(value: any) => [`${value} MB`]}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36}/>
|
||||
<Line type="monotone" dataKey="memory" name="Used Memory" stroke="#eb2f96" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="memory_rss" name="RSS Memory" stroke="#722ed1" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col span={12}>
|
||||
<Card
|
||||
bordered={false}
|
||||
title="CPU 使用率 (CPU Usage)"
|
||||
style={{ background: cardBgColor, borderRadius: 8, height: 300, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||
styles={{ body: { padding: '16px 16px 0 0', height: 240 } }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||
itemStyle={{ fontWeight: 600 }}
|
||||
formatter={(value: any) => [`${value} s`]}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36}/>
|
||||
<Line type="monotone" dataKey="cpuSys" name="System" stroke="#cf1322" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="cpuUser" name="User" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Card
|
||||
bordered={false}
|
||||
title="连接信息 (Clients & Keys)"
|
||||
style={{ background: cardBgColor, borderRadius: 8, height: 300, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||
styles={{ body: { padding: '16px 16px 0 0', height: 240 } }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||
<YAxis yAxisId="left" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||
itemStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36}/>
|
||||
<Line yAxisId="left" type="stepAfter" dataKey="clients" name="Clients" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line yAxisId="right" type="stepAfter" dataKey="keys" name="Total Keys" stroke="#fa8c16" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Card bordered={false} title="详细服务器参数" style={{ background: cardBgColor, borderRadius: 8 }}>
|
||||
<div style={{ columnCount: 3, columnGap: 40 }}>
|
||||
{['redis_version', 'os', 'arch_bits', 'multiplexing_api', 'gcc_version', 'run_id', 'tcp_port', 'uptime_in_days', 'hz', 'lru_clock', 'role', 'maxmemory_human', 'maxmemory_policy', 'mem_fragmentation_ratio', 'keyspace_hits', 'keyspace_misses', 'total_connections_received'].map(key => (
|
||||
currentInfo[key] ? (
|
||||
<div key={key} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8, borderBottom: `1px dashed ${chartGridColor}` }}>
|
||||
<Text type="secondary">{key}</Text>
|
||||
<Text strong>{currentInfo[key]}</Text>
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedisMonitor;
|
||||
@@ -30,7 +30,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
|
||||
CodeOutlined,
|
||||
TagOutlined,
|
||||
CheckOutlined,
|
||||
FilterOutlined
|
||||
FilterOutlined,
|
||||
DashboardOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
@@ -1432,7 +1433,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') {
|
||||
@@ -1455,6 +1456,14 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
|
||||
const onDoubleClick = (e: any, node: any) => {
|
||||
// 保证用户直接双击节点未触发 onClick/onSelect 时也能强行拿到选中状态
|
||||
const { type, dataRef, key: nodeKey } = node;
|
||||
if (type === 'connection') setActiveContext({ connectionId: nodeKey, dbName: '' });
|
||||
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}` });
|
||||
|
||||
if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') {
|
||||
const { id, dbName, schemaName } = node.dataRef;
|
||||
addTab({
|
||||
@@ -3090,6 +3099,20 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'open-monitor',
|
||||
label: 'Redis 实例监控',
|
||||
icon: <DashboardOutlined />,
|
||||
onClick: () => {
|
||||
addTab({
|
||||
id: `redis-monitor-${node.key}-${Date.now()}`,
|
||||
title: `监控: ${node.title}`,
|
||||
type: 'redis-monitor',
|
||||
connectionId: node.key,
|
||||
redisDB: 0
|
||||
});
|
||||
}
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'edit',
|
||||
@@ -3301,6 +3324,20 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
redisDB: redisDB
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'open-monitor',
|
||||
label: 'Redis 实例监控',
|
||||
icon: <DashboardOutlined />,
|
||||
onClick: () => {
|
||||
addTab({
|
||||
id: `redis-monitor-${id}-db${redisDB}-${Date.now()}`,
|
||||
title: `监控: ${connections.find(c => c.id === id)?.name || id}`,
|
||||
type: 'redis-monitor',
|
||||
connectionId: id,
|
||||
redisDB: redisDB
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
} else if (node.type === 'database') {
|
||||
@@ -3702,117 +3739,87 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ padding: '4px 10px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Search
|
||||
ref={searchInputRef}
|
||||
placeholder="搜索..."
|
||||
onChange={onSearch}
|
||||
size="small"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<Popover
|
||||
content={searchScopePopoverContent}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
open={isSearchScopePopoverOpen}
|
||||
onOpenChange={setIsSearchScopePopoverOpen}
|
||||
styles={{ body: { padding: 0, borderRadius: 18, overflow: 'hidden' } }}
|
||||
>
|
||||
<Tooltip title={`搜索范围:${searchScopeSummary}`}>
|
||||
<Button
|
||||
size="small"
|
||||
style={{
|
||||
minWidth: 86,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
paddingInline: 10,
|
||||
borderRadius: 10,
|
||||
borderColor: darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(16,24,40,0.12)',
|
||||
background: darkMode ? bgMain : 'rgba(255,255,255,0.92)',
|
||||
color: darkMode ? 'rgba(255,255,255,0.88)' : '#162033',
|
||||
boxShadow: isSearchScopePopoverOpen
|
||||
? (darkMode ? '0 0 0 1px rgba(255,214,102,0.22) inset' : '0 0 0 1px rgba(24,144,255,0.24) inset')
|
||||
: 'none',
|
||||
backdropFilter: darkMode ? 'blur(10px)' : 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', color: searchScopes.includes('smart') ? '#ffd666' : (darkMode ? 'rgba(255,255,255,0.72)' : 'rgba(22,32,51,0.72)') }}>
|
||||
<FilterOutlined />
|
||||
</span>
|
||||
<span style={{ fontWeight: 700, color: darkMode ? 'rgba(255,255,255,0.88)' : '#162033' }}>筛选</span>
|
||||
<span
|
||||
<div style={{ padding: '8px 14px', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="搜索..."
|
||||
onChange={onSearch}
|
||||
size="small"
|
||||
prefix={<SearchOutlined style={{ color: darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.35)', marginRight: 4 }} />}
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.03)',
|
||||
boxShadow: 'none',
|
||||
padding: '4px 8px',
|
||||
color: darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)',
|
||||
}}
|
||||
suffix={
|
||||
<Popover
|
||||
content={searchScopePopoverContent}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
open={isSearchScopePopoverOpen}
|
||||
onOpenChange={setIsSearchScopePopoverOpen}
|
||||
styles={{ body: { padding: 0, borderRadius: 16, overflow: 'hidden' } }}
|
||||
>
|
||||
<Tooltip title={`搜索范围:${searchScopeSummary}`}>
|
||||
<div
|
||||
style={{
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
display: 'inline-flex',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
background: searchScopes.includes('smart')
|
||||
? (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.12)')
|
||||
: (darkMode ? 'rgba(118,169,250,0.18)' : 'rgba(24,144,255,0.12)'),
|
||||
gap: 4,
|
||||
cursor: 'pointer',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
background: isSearchScopePopoverOpen
|
||||
? (darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.06)')
|
||||
: 'transparent',
|
||||
transition: 'background 0.2s',
|
||||
color: searchScopes.includes('smart')
|
||||
? (darkMode ? '#ffd666' : '#1677ff')
|
||||
: (darkMode ? '#91caff' : '#1677ff'),
|
||||
: (darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)'),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSearchScopePopoverOpen) {
|
||||
e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
|
||||
e.currentTarget.style.color = darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.65)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSearchScopePopoverOpen) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = searchScopes.includes('smart')
|
||||
? (darkMode ? '#ffd666' : '#1677ff')
|
||||
: (darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{searchScopes.includes('smart') ? '智' : searchScopes.length}
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', color: darkMode ? 'rgba(255,255,255,0.48)' : 'rgba(22,32,51,0.4)', fontSize: 12 }}>
|
||||
<DownOutlined />
|
||||
</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
</div>
|
||||
<FilterOutlined style={{ fontSize: 13 }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 500 }}>
|
||||
{searchScopes.includes('smart') ? '智' : searchScopes.length}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{ padding: '4px 10px', borderBottom: 'none', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FolderOpenOutlined />}
|
||||
onClick={() => {
|
||||
setRenameViewTarget(null); // Create mode
|
||||
createTagForm.resetFields();
|
||||
setIsCreateTagModalOpen(true);
|
||||
}}
|
||||
style={{ flex: '1 1 auto' }}
|
||||
>
|
||||
新建组
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CheckSquareOutlined />}
|
||||
onClick={() => openBatchOperationModal()}
|
||||
style={{ flex: '1 1 auto' }}
|
||||
>
|
||||
批量操作表
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CheckSquareOutlined />}
|
||||
onClick={() => openBatchDatabaseModal()}
|
||||
style={{ flex: '1 1 auto' }}
|
||||
>
|
||||
批量操作库
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FileAddOutlined />}
|
||||
onClick={handleOpenSQLFileFromToolbar}
|
||||
style={{ flex: '1 1 auto' }}
|
||||
>
|
||||
运行外部SQL文件
|
||||
</Button>
|
||||
<div style={{ padding: '6px 16px', display: 'flex', gap: 8, justifyContent: 'space-between', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`, borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`, background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.015)' }}>
|
||||
<Tooltip title="新建组">
|
||||
<Button size="small" type="text" icon={<FolderOpenOutlined />} onClick={() => { setRenameViewTarget(null); createTagForm.resetFields(); setIsCreateTagModalOpen(true); }} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="批量操作表">
|
||||
<Button size="small" type="text" icon={<TableOutlined />} onClick={() => openBatchOperationModal()} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="批量操作库">
|
||||
<Button size="small" type="text" icon={<DatabaseOutlined />} onClick={() => openBatchDatabaseModal()} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="运行外部SQL文件">
|
||||
<Button size="small" type="text" icon={<FileAddOutlined />} onClick={handleOpenSQLFileFromToolbar} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div ref={treeContainerRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
|
||||
@@ -12,6 +12,7 @@ import QueryEditor from './QueryEditor';
|
||||
import TableDesigner from './TableDesigner';
|
||||
import RedisViewer from './RedisViewer';
|
||||
import RedisCommandEditor from './RedisCommandEditor';
|
||||
import RedisMonitor from './RedisMonitor';
|
||||
import TriggerViewer from './TriggerViewer';
|
||||
import DefinitionViewer from './DefinitionViewer';
|
||||
import TableOverview from './TableOverview';
|
||||
@@ -89,6 +90,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 +136,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: sql,
|
||||
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) => (
|
||||
@@ -145,17 +200,20 @@ const TabManager: React.FC = () => {
|
||||
const items = useMemo(() => tabs.map((tab, index) => {
|
||||
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
|
||||
const displayTitle = buildTabDisplayTitle(tab, connectionName);
|
||||
const tabIsActive = tab.id === activeTabId;
|
||||
let content;
|
||||
if (tab.type === 'query') {
|
||||
content = <QueryEditor tab={tab} />;
|
||||
content = <QueryEditor tab={tab} isActive={tabIsActive} />;
|
||||
} else if (tab.type === 'table') {
|
||||
content = <DataViewer tab={tab} />;
|
||||
content = <DataViewer tab={tab} isActive={tabIsActive} />;
|
||||
} else if (tab.type === 'design') {
|
||||
content = <TableDesigner tab={tab} />;
|
||||
} else if (tab.type === 'redis-keys') {
|
||||
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'redis-command') {
|
||||
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'redis-monitor') {
|
||||
content = <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'trigger') {
|
||||
content = <TriggerViewer tab={tab} />;
|
||||
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
|
||||
@@ -202,7 +260,7 @@ const TabManager: React.FC = () => {
|
||||
key: tab.id,
|
||||
children: content,
|
||||
};
|
||||
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
|
||||
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -8,6 +8,7 @@ import Editor, { loader } from '@monaco-editor/react';
|
||||
import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
|
||||
import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils';
|
||||
|
||||
interface EditableColumn extends ColumnDefinition {
|
||||
_key: string;
|
||||
@@ -48,6 +49,13 @@ interface ForeignKeyFormState {
|
||||
refColumnNames: string[];
|
||||
}
|
||||
|
||||
interface SchemaExecutionResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
failedStatementIndex?: number;
|
||||
statementCount: number;
|
||||
}
|
||||
|
||||
// 通用兜底类型列表
|
||||
const COMMON_TYPES = [
|
||||
{ value: 'int' },
|
||||
@@ -1511,11 +1519,10 @@ ${selectedTrigger.statement}`;
|
||||
}
|
||||
};
|
||||
|
||||
const executeSchemaSql = async (sql: string, successMessage: string): Promise<boolean> => {
|
||||
const executeSchemaStatements = async (sqlText: string): Promise<SchemaExecutionResult> => {
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) {
|
||||
message.error('未找到连接');
|
||||
return false;
|
||||
return { ok: false, message: '未找到连接', statementCount: 0 };
|
||||
}
|
||||
const config = {
|
||||
...conn.config,
|
||||
@@ -1525,20 +1532,68 @@ ${selectedTrigger.statement}`;
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
const statements = sqlText.split(/;\s*\n/).map(s => s.trim()).filter(Boolean);
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
let stmt = statements[i];
|
||||
if (!stmt.endsWith(';')) stmt += ';';
|
||||
const res = await DBQuery(config as any, tab.dbName || '', stmt);
|
||||
if (!res.success) {
|
||||
const prefix = statements.length > 1 ? `第 ${i + 1}/${statements.length} 条语句执行失败: ` : '执行失败: ';
|
||||
return {
|
||||
ok: false,
|
||||
message: prefix + res.message,
|
||||
failedStatementIndex: i,
|
||||
statementCount: statements.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true, statementCount: statements.length };
|
||||
};
|
||||
|
||||
const buildIndexFormFromRow = (row: IndexDisplayRow): IndexFormState => {
|
||||
return normalizeIndexFormFromRow(
|
||||
row as IndexDisplaySnapshot,
|
||||
getIndexKindOptions().map(item => item.value as IndexKind),
|
||||
);
|
||||
};
|
||||
|
||||
const executeIndexEditSql = async (dropSql: string, addSql: string, previousIndex: IndexDisplayRow): Promise<boolean> => {
|
||||
const result = await executeSchemaStatements(`${dropSql}\n${addSql}`);
|
||||
if (result.ok) {
|
||||
message.success('索引修改成功');
|
||||
await fetchData();
|
||||
return true;
|
||||
}
|
||||
|
||||
const oldCreateSql = buildIndexCreateSql(buildIndexFormFromRow(previousIndex));
|
||||
if (!oldCreateSql) {
|
||||
message.error((result.message || '执行失败') + ';且无法自动恢复原索引,请尽快检查');
|
||||
await fetchData();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!shouldRestoreOriginalIndex(result)) {
|
||||
message.error(result.message || '执行失败');
|
||||
return false;
|
||||
}
|
||||
|
||||
const restoreResult = await executeSchemaStatements(oldCreateSql);
|
||||
if (restoreResult.ok) {
|
||||
message.error((result.message || '执行失败') + ';已自动恢复原索引');
|
||||
} else {
|
||||
message.error((result.message || '执行失败') + `;恢复原索引失败: ${restoreResult.message || '未知错误'}`);
|
||||
}
|
||||
await fetchData();
|
||||
return false;
|
||||
};
|
||||
|
||||
const executeSchemaSql = async (sql: string, successMessage: string): Promise<boolean> => {
|
||||
try {
|
||||
// 多条 DDL 语句(如 DROP INDEX + CREATE INDEX)需要逐条执行,
|
||||
// 因为 Go MySQL 驱动默认不支持多语句 Exec。
|
||||
const statements = sql.split(/;\s*\n/).map(s => s.trim()).filter(Boolean);
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
let stmt = statements[i];
|
||||
if (!stmt.endsWith(';')) stmt += ';';
|
||||
const res = await DBQuery(config as any, tab.dbName || '', stmt);
|
||||
if (!res.success) {
|
||||
const prefix = statements.length > 1 ? `第 ${i + 1}/${statements.length} 条语句执行失败: ` : '执行失败: ';
|
||||
message.error(prefix + res.message);
|
||||
if (i > 0) await fetchData();
|
||||
return false;
|
||||
}
|
||||
const result = await executeSchemaStatements(sql);
|
||||
if (!result.ok) {
|
||||
message.error(result.message || '执行失败');
|
||||
if ((result.failedStatementIndex ?? 0) > 0) await fetchData();
|
||||
return false;
|
||||
}
|
||||
message.success(successMessage);
|
||||
await fetchData();
|
||||
@@ -1633,32 +1688,7 @@ END;`;
|
||||
return;
|
||||
}
|
||||
setIndexModalMode('edit');
|
||||
const selectedName = String(selectedIndex.name || '').trim();
|
||||
const selectedNameUpper = selectedName.toUpperCase();
|
||||
const selectedTypeUpper = String(selectedIndex.indexType || '').trim().toUpperCase();
|
||||
let kind: IndexKind = 'NORMAL';
|
||||
if (selectedNameUpper === 'PRIMARY') {
|
||||
kind = 'PRIMARY';
|
||||
} else if (selectedTypeUpper === 'FULLTEXT') {
|
||||
kind = 'FULLTEXT';
|
||||
} else if (selectedTypeUpper === 'SPATIAL') {
|
||||
kind = 'SPATIAL';
|
||||
} else if (selectedIndex.nonUnique === 0) {
|
||||
kind = 'UNIQUE';
|
||||
}
|
||||
const supportedKinds = new Set(getIndexKindOptions().map(item => item.value));
|
||||
if (!supportedKinds.has(kind)) {
|
||||
kind = selectedIndex.nonUnique === 0 ? 'UNIQUE' : 'NORMAL';
|
||||
}
|
||||
|
||||
setIndexForm({
|
||||
name: kind === 'PRIMARY' ? 'PRIMARY' : selectedName,
|
||||
columnNames: [...selectedIndex.columnNames],
|
||||
kind,
|
||||
indexType: kind === 'NORMAL' || kind === 'UNIQUE'
|
||||
? (selectedTypeUpper || 'DEFAULT')
|
||||
: 'DEFAULT',
|
||||
});
|
||||
setIndexForm(buildIndexFormFromRow(selectedIndex));
|
||||
setIsIndexModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -1817,13 +1847,32 @@ END;`;
|
||||
let sql = addSql;
|
||||
|
||||
if (indexModalMode === 'edit' && selectedIndex) {
|
||||
const previousForm = buildIndexFormFromRow(selectedIndex);
|
||||
const nextForm: IndexFormState = {
|
||||
name: indexForm.kind === 'PRIMARY' ? 'PRIMARY' : nextName,
|
||||
columnNames: [...indexForm.columnNames],
|
||||
kind: indexForm.kind,
|
||||
indexType: indexForm.kind === 'NORMAL' || indexForm.kind === 'UNIQUE'
|
||||
? (String(indexForm.indexType || '').trim().toUpperCase() || 'DEFAULT')
|
||||
: 'DEFAULT',
|
||||
};
|
||||
if (!hasIndexFormChanged(previousForm, nextForm)) {
|
||||
setIndexSaving(false);
|
||||
message.info('没有检测到索引变更');
|
||||
return;
|
||||
}
|
||||
const dropSql = buildIndexDropSql(selectedIndex.name);
|
||||
if (!dropSql) {
|
||||
setIndexSaving(false);
|
||||
message.warning('当前数据库暂不支持删除该索引');
|
||||
return;
|
||||
}
|
||||
sql = `${dropSql}\n${addSql}`;
|
||||
const ok = await executeIndexEditSql(dropSql, addSql, selectedIndex);
|
||||
setIndexSaving(false);
|
||||
if (ok) {
|
||||
setIsIndexModalOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await executeSchemaSql(sql, indexModalMode === 'create' ? '索引新增成功' : '索引修改成功');
|
||||
@@ -2270,12 +2319,16 @@ END;`;
|
||||
const allIndexKeys = groupedIndexes.map(idx => idx.key);
|
||||
const isAllSelected = allIndexKeys.length > 0 && selectedIndexKeys.length === allIndexKeys.length;
|
||||
const isIndeterminate = selectedIndexKeys.length > 0 && selectedIndexKeys.length < allIndexKeys.length;
|
||||
const toggleIndexSelection = (key: string, checked?: boolean) => {
|
||||
setSelectedIndexKeys(prev => getNextIndexSelection(prev, key, checked));
|
||||
};
|
||||
|
||||
const selectColumn = {
|
||||
title: () => (
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => {
|
||||
setSelectedIndexKeys(e.target.checked ? allIndexKeys : []);
|
||||
}}
|
||||
@@ -2286,18 +2339,19 @@ END;`;
|
||||
key: '_select',
|
||||
width: 48,
|
||||
render: (_: any, record: any) => (
|
||||
<Checkbox
|
||||
checked={selectedIndexKeys.includes(record.key)}
|
||||
onChange={(e) => {
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedIndexKeys(prev =>
|
||||
e.target.checked
|
||||
? [...prev, record.key]
|
||||
: prev.filter(k => k !== record.key)
|
||||
);
|
||||
toggleIndexSelection(record.key);
|
||||
}}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
style={{ display: 'inline-flex' }}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedIndexKeys.includes(record.key)}
|
||||
onChange={() => undefined}
|
||||
style={{ margin: 0, pointerEvents: 'none' }}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -2593,11 +2647,7 @@ END;`;
|
||||
}}
|
||||
onRow={(record) => ({
|
||||
onClick: () => {
|
||||
setSelectedIndexKeys(prev =>
|
||||
prev.includes(record.key)
|
||||
? prev.filter(k => k !== record.key)
|
||||
: [...prev, record.key]
|
||||
);
|
||||
toggleIndexSelection(record.key);
|
||||
},
|
||||
style: { cursor: 'pointer' }
|
||||
})}
|
||||
@@ -2897,7 +2947,7 @@ END;`;
|
||||
/>
|
||||
</Space>
|
||||
<div style={{ color: '#888', fontSize: 12 }}>
|
||||
修改索引会执行“先删除旧索引,再创建新索引”。
|
||||
修改索引时若新索引创建失败,系统会尝试自动恢复原索引。
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
@@ -138,6 +138,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const setActiveContext = useStore(state => state.setActiveContext);
|
||||
const darkMode = theme === 'dark';
|
||||
|
||||
const [tables, setTables] = useState<TableStatRow[]>([]);
|
||||
@@ -195,6 +196,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
|
||||
const openTable = useCallback((tableName: string) => {
|
||||
if (!connection) return;
|
||||
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
|
||||
addTab({
|
||||
id: `${connection.id}-${tab.dbName}-${tableName}`,
|
||||
title: tableName,
|
||||
@@ -203,10 +205,11 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
dbName: tab.dbName,
|
||||
tableName,
|
||||
});
|
||||
}, [connection, tab.dbName, addTab]);
|
||||
}, [connection, tab.dbName, addTab, setActiveContext]);
|
||||
|
||||
const openDesign = useCallback((tableName: string) => {
|
||||
if (!connection) return;
|
||||
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
|
||||
addTab({
|
||||
id: `design-${connection.id}-${tab.dbName}-${tableName}`,
|
||||
title: `设计表 (${tableName})`,
|
||||
@@ -217,7 +220,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
initialTab: 'columns',
|
||||
readOnly: false,
|
||||
});
|
||||
}, [connection, tab.dbName, addTab]);
|
||||
}, [connection, tab.dbName, addTab, setActiveContext]);
|
||||
|
||||
const buildConfig = useCallback(() => {
|
||||
if (!connection) return null;
|
||||
@@ -383,6 +386,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
|
||||
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: '新建查询',
|
||||
|
||||
76
frontend/src/components/ai/AIChatHeader.tsx
Normal file
76
frontend/src/components/ai/AIChatHeader.tsx
Normal file
@@ -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<AIChatHeaderProps> = ({
|
||||
darkMode, mutedColor, textColor, overlayTheme,
|
||||
onHistoryClick, onClear, onSettingsClick, onClose,
|
||||
messages = [], sessionTitle = '新对话'
|
||||
}) => {
|
||||
return (
|
||||
<div className="ai-chat-header" style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}>
|
||||
<div className="ai-chat-header-left" style={{ gap: 8 }}>
|
||||
<Tooltip title="历史会话">
|
||||
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={onHistoryClick} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
<div className="ai-logo" style={{ background: overlayTheme.iconBg, color: overlayTheme.iconColor, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, borderRadius: 6, fontSize: 12 }}>
|
||||
<RobotOutlined />
|
||||
</div>
|
||||
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
|
||||
</div>
|
||||
<div className="ai-chat-header-right">
|
||||
{messages.length > 0 && (
|
||||
<Tooltip title="导出为 Markdown">
|
||||
<Button type="text" size="small" icon={<ExportOutlined />} onClick={() => exportToMarkdown(messages, sessionTitle)} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="新对话 (清空当前)">
|
||||
<Button type="text" size="small" icon={<ClearOutlined />} onClick={onClear} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="AI 设置">
|
||||
<Button type="text" size="small" icon={<SettingOutlined />} onClick={onSettingsClick} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="关闭面板">
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
frontend/src/components/ai/AIChatInput.notice.test.tsx
Normal file
61
frontend/src/components/ai/AIChatInput.notice.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AIChatInput } from './AIChatInput';
|
||||
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useStore: (selector: (state: any) => any) => selector({
|
||||
aiContexts: {},
|
||||
addAIContext: vi.fn(),
|
||||
removeAIContext: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../wailsjs/go/app/App', () => ({
|
||||
DBGetTables: vi.fn(),
|
||||
DBShowCreateTable: vi.fn(),
|
||||
DBGetDatabases: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('AIChatInput notice layout', () => {
|
||||
it('renders the composer notice above the input editor', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AIChatInput
|
||||
input=""
|
||||
setInput={() => {}}
|
||||
draftImages={[]}
|
||||
setDraftImages={() => {}}
|
||||
sending={false}
|
||||
onSend={() => {}}
|
||||
onStop={() => {}}
|
||||
handleKeyDown={() => {}}
|
||||
activeConnName=""
|
||||
activeContext={null}
|
||||
activeProvider={{ model: '', models: [] }}
|
||||
dynamicModels={[]}
|
||||
loadingModels={false}
|
||||
composerNotice={{
|
||||
tone: 'error',
|
||||
title: '模型列表加载失败',
|
||||
description: '请检查供应商入口和 API Key。',
|
||||
}}
|
||||
onModelChange={() => {}}
|
||||
onFetchModels={() => {}}
|
||||
textareaRef={React.createRef<HTMLTextAreaElement>()}
|
||||
darkMode={false}
|
||||
textColor="#162033"
|
||||
mutedColor="rgba(16,24,40,0.55)"
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
/>
|
||||
);
|
||||
|
||||
const noticeIndex = markup.indexOf('data-ai-chat-composer-notice="true"');
|
||||
const inputIndex = markup.indexOf('data-ai-chat-composer-input="true"');
|
||||
|
||||
expect(noticeIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(inputIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(noticeIndex).toBeLessThan(inputIndex);
|
||||
});
|
||||
});
|
||||
632
frontend/src/components/ai/AIChatInput.tsx
Normal file
632
frontend/src/components/ai/AIChatInput.tsx
Normal file
@@ -0,0 +1,632 @@
|
||||
import React from 'react';
|
||||
import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
|
||||
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { useStore } from '../../store';
|
||||
import { DBGetTables, DBShowCreateTable, DBGetDatabases } from '../../../wailsjs/go/app/App';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import type { AIComposerNotice } from '../../utils/aiComposerNotice';
|
||||
|
||||
interface AIChatInputProps {
|
||||
input: string;
|
||||
setInput: (val: string) => void;
|
||||
draftImages: string[];
|
||||
setDraftImages: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
sending: boolean;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent) => void;
|
||||
activeConnName: string;
|
||||
activeContext: any;
|
||||
activeProvider: any;
|
||||
dynamicModels: string[];
|
||||
loadingModels: boolean;
|
||||
composerNotice?: AIComposerNotice | null;
|
||||
onModelChange: (val: string) => void;
|
||||
onFetchModels: () => void;
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
darkMode: boolean;
|
||||
textColor: string;
|
||||
mutedColor: string;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
contextUsageChars?: number;
|
||||
maxContextChars?: number;
|
||||
}
|
||||
|
||||
export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown,
|
||||
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
|
||||
composerNotice,
|
||||
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<string[]>([]);
|
||||
const [searchText, setSearchText] = React.useState('');
|
||||
const [appendingContext, setAppendingContext] = React.useState(false);
|
||||
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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<string[]>([]);
|
||||
const [selectedDbName, setSelectedDbName] = React.useState<string>('');
|
||||
|
||||
const filteredTables = contextTables.filter(t => t.name.toLowerCase().includes(searchText.toLowerCase()));
|
||||
const [contextExpanded, setContextExpanded] = React.useState(false);
|
||||
const composerNoticePalette = React.useMemo(() => {
|
||||
if (composerNotice?.tone === 'error') {
|
||||
return darkMode
|
||||
? {
|
||||
background: 'rgba(255,120,117,0.12)',
|
||||
borderColor: 'rgba(255,120,117,0.24)',
|
||||
iconColor: '#ff7875',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(255,77,79,0.08)',
|
||||
borderColor: 'rgba(255,77,79,0.16)',
|
||||
iconColor: '#ff4d4f',
|
||||
};
|
||||
}
|
||||
|
||||
return darkMode
|
||||
? {
|
||||
background: 'rgba(250,173,20,0.12)',
|
||||
borderColor: 'rgba(250,173,20,0.22)',
|
||||
iconColor: '#ffd666',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(250,173,20,0.08)',
|
||||
borderColor: 'rgba(250,173,20,0.18)',
|
||||
iconColor: '#d48806',
|
||||
};
|
||||
}, [composerNotice, darkMode]);
|
||||
|
||||
// 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 (
|
||||
<div className="ai-chat-input-area" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
|
||||
<div className="ai-chat-input-wrapper" style={{
|
||||
borderColor: 'transparent',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
gap: 8,
|
||||
padding: '8px 4px 8px'
|
||||
}}>
|
||||
<div className="ai-chat-input-preview-area" style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{activeContextItems.length > 0 && (
|
||||
<Tag
|
||||
onClick={() => 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' }}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<DatabaseOutlined /> 关联上下文 ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'}
|
||||
</span>
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
{contextExpanded && activeContextItems.map((ctx, idx) => (
|
||||
<Tag
|
||||
key={`ctx-${idx}`}
|
||||
closable
|
||||
onClose={(e) => { 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 }}
|
||||
>
|
||||
<span style={{ fontSize: 13 }}>🗄️ {ctx.tableName}</span>
|
||||
</Tag>
|
||||
))}
|
||||
{draftImages.map((b64, i) => (
|
||||
<div key={i} style={{ position: 'relative', width: 60, height: 60, borderRadius: 6, overflow: 'hidden', border: overlayTheme.shellBorder }}>
|
||||
<img src={b64} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt={`Draft ${i}`} />
|
||||
<div
|
||||
onClick={() => 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 }}
|
||||
>
|
||||
✕
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{composerNotice && (
|
||||
<div
|
||||
data-ai-chat-composer-notice="true"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 12,
|
||||
background: composerNoticePalette.background,
|
||||
border: `1px solid ${composerNoticePalette.borderColor}`,
|
||||
}}
|
||||
>
|
||||
<ExclamationCircleFilled style={{ color: composerNoticePalette.iconColor, fontSize: 14, marginTop: 1, flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: textColor, lineHeight: 1.4 }}>
|
||||
{composerNotice.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5, marginTop: 2, wordBreak: 'break-word' }}>
|
||||
{composerNotice.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
|
||||
{showSlashMenu && filteredSlashCmds.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4,
|
||||
background: darkMode ? '#2a2a2a' : '#fff',
|
||||
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)'}`,
|
||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', zIndex: 100,
|
||||
maxHeight: 220, overflowY: 'auto', padding: 4
|
||||
}}>
|
||||
{filteredSlashCmds.map(cmd => (
|
||||
<div
|
||||
key={cmd.cmd}
|
||||
style={{
|
||||
padding: '8px 12px', borderRadius: 6, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
transition: 'background 0.15s'
|
||||
}}
|
||||
onMouseEnter={e => 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();
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: textColor, minWidth: 80 }}>{cmd.cmd}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: textColor }}>{cmd.label}</span>
|
||||
<span style={{ fontSize: 11, color: mutedColor, marginLeft: 'auto' }}>{cmd.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Input.TextArea
|
||||
onPaste={(e) => {
|
||||
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' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{activeConnName && (
|
||||
<Tooltip title="当前数据查询上下文">
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 11, padding: '2px 8px', borderRadius: 12,
|
||||
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
||||
color: overlayTheme.mutedText, cursor: 'default'
|
||||
}}>
|
||||
<DatabaseOutlined style={{ fontSize: 10 }} />
|
||||
<span style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{activeProvider && (
|
||||
<Select
|
||||
size="small"
|
||||
variant="filled"
|
||||
value={activeProvider.model || undefined}
|
||||
onChange={onModelChange}
|
||||
onDropdownVisibleChange={(open) => {
|
||||
if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) {
|
||||
onFetchModels();
|
||||
}
|
||||
}}
|
||||
loading={loadingModels}
|
||||
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
|
||||
style={{ width: 130, fontSize: 11, background: 'transparent' }}
|
||||
dropdownStyle={{ minWidth: 200 }}
|
||||
showSearch
|
||||
placeholder="选择模型"
|
||||
/>
|
||||
)}
|
||||
|
||||
{contextUsageChars !== undefined && maxContextChars !== undefined && (
|
||||
<Tooltip title={`当前会话记忆已用字符。达到限制(${(maxContextChars/1000).toFixed(0)}k)时将触发自动压缩。`}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 10, padding: '2px 6px', borderRadius: 12, border: '1px solid transparent',
|
||||
background: contextUsageChars > maxContextChars * 0.8 ? (darkMode ? 'rgba(250, 173, 20, 0.1)' : 'rgba(250, 173, 20, 0.08)') : (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'),
|
||||
borderColor: contextUsageChars > maxContextChars * 0.8 ? 'rgba(250, 173, 20, 0.3)' : 'transparent',
|
||||
color: contextUsageChars > maxContextChars * 0.8 ? '#faad14' : overlayTheme.mutedText, cursor: 'default',
|
||||
transition: 'all 0.3s'
|
||||
}}>
|
||||
<span>🧠 {(contextUsageChars / 1000).toFixed(1)}k / {(maxContextChars / 1000).toFixed(0)}k</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
<Tooltip title="上传图片/截图">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PictureOutlined style={{ fontSize: 16 }} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = textColor}
|
||||
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="关联附带数据库表上下文">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<TableOutlined style={{ fontSize: 16 }} />}
|
||||
onClick={handleOpenContext}
|
||||
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = textColor}
|
||||
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
|
||||
/>
|
||||
</Tooltip>
|
||||
{sending ? (
|
||||
<button
|
||||
className="ai-chat-send-btn ai-chat-stop-btn"
|
||||
onClick={onStop}
|
||||
title="停止生成"
|
||||
style={{
|
||||
background: 'rgba(255,77,79,0.1)',
|
||||
color: '#ff4d4f', border: '1px solid rgba(255,77,79,0.2)',
|
||||
width: 26, height: 26, borderRadius: 6, padding: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 10, height: 10, background: 'currentColor', borderRadius: 2 }} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="ai-chat-send-btn"
|
||||
onClick={() => onSend()}
|
||||
disabled={!input.trim() && draftImages.length === 0}
|
||||
title="发送"
|
||||
style={{
|
||||
background: (input.trim() || draftImages.length > 0) ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)'),
|
||||
color: (input.trim() || draftImages.length > 0) ? overlayTheme.iconColor : mutedColor,
|
||||
width: 26, height: 26, borderRadius: 6, border: 'none', padding: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: (input.trim() || draftImages.length > 0) ? 'pointer' : 'not-allowed', flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<SendOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={<span style={{ color: textColor }}>关联数据库表结构上下文</span>}
|
||||
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' }
|
||||
}}
|
||||
>
|
||||
<Spin spinning={contextLoading}>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 12 }}>
|
||||
{dbList.length > 0 && (
|
||||
<Select
|
||||
value={selectedDbName}
|
||||
onChange={val => {
|
||||
const c = useStore.getState().connections.find(conn => conn.id === activeContext?.connectionId);
|
||||
if (c) fetchTablesForDb(val, c.config);
|
||||
}}
|
||||
options={dbList.map(d => ({ label: d, value: d }))}
|
||||
style={{ width: 160, flexShrink: 0 }}
|
||||
placeholder="切换数据库"
|
||||
showSearch
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
placeholder="在当前库搜索表名..."
|
||||
prefix={<SearchOutlined style={{ color: overlayTheme.mutedText }} />}
|
||||
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 }}
|
||||
/>
|
||||
</div>
|
||||
{filteredTables.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`, paddingBottom: 12, marginBottom: 8 }}>
|
||||
<Checkbox
|
||||
indeterminate={
|
||||
filteredTables.length > 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})
|
||||
</Checkbox>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: 0, height: 'auto', fontSize: 13 }}
|
||||
onClick={() => {
|
||||
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
|
||||
const remainingSelected = selectedTableKeys.filter(key => !filteredKeys.includes(key));
|
||||
const toAdd = filteredKeys.filter(key => !selectedTableKeys.includes(key));
|
||||
setSelectedTableKeys([...remainingSelected, ...toAdd]);
|
||||
}}
|
||||
>
|
||||
反选匹配结果
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ maxHeight: 300, overflowY: 'auto', margin: '0 -24px', padding: '0 24px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{filteredTables.map(t => {
|
||||
const key = `${selectedDbName}::${t.name}`;
|
||||
const isSelected = selectedTableKeys.includes(key);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
borderRadius: 6,
|
||||
transition: 'background 0.2s',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseEnter={e => 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]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setSelectedTableKeys([...selectedTableKeys, key]);
|
||||
else setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
|
||||
}}
|
||||
style={{ color: textColor, width: '100%' }}
|
||||
>
|
||||
<span style={{ fontSize: 13, userSelect: 'none' }}>{t.name}</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center', color: overlayTheme.mutedText }}>
|
||||
没有找到匹配 '{searchText}' 的表
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
64
frontend/src/components/ai/AIChatWelcome.tsx
Normal file
64
frontend/src/components/ai/AIChatWelcome.tsx
Normal file
@@ -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<AIChatWelcomeProps> = ({
|
||||
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 (
|
||||
<div className="ai-chat-welcome" style={{ padding: '30px 20px', alignItems: 'flex-start', textAlign: 'left' }}>
|
||||
<div style={{ color: overlayTheme.titleText, fontSize: 16, fontWeight: 600, marginBottom: 8 }}>
|
||||
<RobotOutlined style={{ marginRight: 8, color: overlayTheme.iconColor }} />
|
||||
你好,我是 GoNavi AI
|
||||
</div>
|
||||
<div className="welcome-desc" style={{ color: mutedColor, fontSize: 13, lineHeight: 1.6, marginBottom: 20 }}>
|
||||
{hasContext
|
||||
? `已自动关联 ${contextTableNames.length} 张表结构,点击下方按钮快速开始分析。`
|
||||
: '我是你的智能数据库助手。我可以帮你生成 SQL 查询、分析表结构、解释执行逻辑以及优化数据库性能。'}
|
||||
</div>
|
||||
<div className="quick-actions">
|
||||
{quickActions.map(action => (
|
||||
<div
|
||||
key={action.label}
|
||||
className="quick-action-btn"
|
||||
style={{
|
||||
background: quickActionBg,
|
||||
borderColor: quickActionBorder,
|
||||
color: textColor,
|
||||
}}
|
||||
onClick={() => onQuickAction(action.prompt)}
|
||||
>
|
||||
{action.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
127
frontend/src/components/ai/AIHistoryDrawer.tsx
Normal file
127
frontend/src/components/ai/AIHistoryDrawer.tsx
Normal file
@@ -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<AIHistoryDrawerProps> = ({
|
||||
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 (
|
||||
<Drawer
|
||||
placement="left"
|
||||
closable={false}
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
getContainer={false}
|
||||
style={{ position: 'absolute', background: bgColor || (darkMode ? '#1e1e1e' : '#f8f9fa') }}
|
||||
width={260}
|
||||
bodyStyle={{ padding: 0, display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{/* 侧拉面板头部 */}
|
||||
<div style={{ padding: '16px 16px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: textColor }}>对话历史</span>
|
||||
<Tooltip title="收起">
|
||||
<Button type="text" size="small" icon={<MenuFoldOutlined />} onClick={onClose} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 新建对话按钮 */}
|
||||
<div style={{ padding: '0 12px 12px' }}>
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => { onCreateNew(); onClose(); }}
|
||||
style={{ borderColor: borderColor, color: textColor, background: 'transparent' }}
|
||||
>
|
||||
开启新对话
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 列表搜索 */}
|
||||
<div style={{ padding: '0 12px 12px' }}>
|
||||
<Input
|
||||
placeholder="搜索历史记录..."
|
||||
prefix={<SearchOutlined style={{ color: mutedColor }} />}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
variant="filled"
|
||||
size="small"
|
||||
style={{ background: darkMode ? 'rgba(255,255,255,0.04)' : 'transparent', color: textColor }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 列表容器 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 10px 16px' }} className="ai-history-list">
|
||||
{filteredSessions.length === 0 ? (
|
||||
<div style={{ padding: '30px 0', textAlign: 'center', color: mutedColor, fontSize: 12 }}>暂无匹配的对话记录</div>
|
||||
) : (
|
||||
filteredSessions.map(session => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`ai-history-item ${sessionId === session.id ? 'active' : ''}`}
|
||||
onClick={() => { 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',
|
||||
}}
|
||||
>
|
||||
<div style={{ overflow: 'hidden', flex: 1, paddingRight: 8 }}>
|
||||
<div style={{ fontSize: 13, color: textColor, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: sessionId === session.id ? 600 : 'normal' }}>
|
||||
{session.title || '新对话'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: mutedColor, marginTop: 4 }}>
|
||||
{new Date(session.updatedAt).toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
className="ai-history-delete-btn"
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteAISession(session.id);
|
||||
}}
|
||||
style={{ display: sessionId === session.id ? 'inline-flex' : undefined }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
735
frontend/src/components/ai/AIMessageBubble.tsx
Normal file
735
frontend/src/components/ai/AIMessageBubble.tsx
Normal file
@@ -0,0 +1,735 @@
|
||||
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 <MermaidRenderer chart={String(children).replace(/\n$/, '')} darkMode={darkMode} />;
|
||||
}
|
||||
return !inline && match ? (
|
||||
<AIBlockHashRender match={match} darkMode={darkMode} overlayTheme={overlayTheme} children={children} activeConnectionConfig={activeConnectionConfig} activeConnectionId={activeConnectionId} activeDbName={activeDbName} />
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
}), [darkMode, overlayTheme, activeConnectionConfig, activeConnectionId, activeDbName]);
|
||||
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<div style={{
|
||||
background: darkMode ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.02)',
|
||||
borderRadius: 6,
|
||||
padding: '6px 10px',
|
||||
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}`,
|
||||
marginTop: 8,
|
||||
width: '100%'
|
||||
}}>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', gap: 6, fontSize: 12, color: overlayTheme.mutedText }}
|
||||
onClick={() => setToolExpanded(!toolExpanded)}
|
||||
>
|
||||
{toolExpanded ? <CaretDownOutlined /> : <CaretRightOutlined />}
|
||||
<ApiOutlined style={{ color: '#1677ff' }} />
|
||||
<span>探针执行结果 (<span style={{ fontFamily: 'monospace', color: overlayTheme.iconColor }}>{resultMsg.tool_name || 'unknown'}</span>)</span>
|
||||
<span style={{ fontSize: 11, marginLeft: 8, opacity: 0.6 }}>{charCount > 0 ? `${charCount} 个字符` : '无数据'}</span>
|
||||
</div>
|
||||
{toolExpanded && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: 300, overflowY: 'auto', background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.03)', padding: 8, borderRadius: 6 }}>
|
||||
{resultMsg.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MermaidRenderer = ({ chart, darkMode }: { chart: string, darkMode: boolean }) => {
|
||||
const containerRef = React.useRef<HTMLDivElement>(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 = `<div style="color:#ef4444; padding:12px; background:rgba(239,68,68,0.1); border-radius:6px; font-size:12px">Mermaid 解析失败: ${e.message}</div>`;
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = `<div style="color:#ef4444; padding:12px; background:rgba(239,68,68,0.1); border-radius:6px; font-size:12px">Mermaid 渲染异常: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [chart, darkMode]);
|
||||
|
||||
return <div ref={containerRef} className="ai-mermaid-container" style={{ margin: '16px 0', display: 'flex', justifyContent: 'flex-start', overflowX: 'auto' }} />;
|
||||
};
|
||||
|
||||
const CodeCopyBtn = ({ text }: { text: string }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<span
|
||||
className="ai-code-copy-btn"
|
||||
onClick={() => {
|
||||
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 ? <CheckOutlined style={{ color: '#52c41a' }} /> : <CopyOutlined />}
|
||||
<span style={{ marginLeft: 4 }}>{copied ? '已复制' : '复制代码'}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<Tooltip title="将该段 SQL 注入查询工作区(可快捷修改或执行)">
|
||||
<span
|
||||
className="ai-code-run-btn"
|
||||
onClick={() => {
|
||||
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'; }}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
<span style={{ marginLeft: 4 }}>插入</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="立即执行(受 AI 安全策略管控)">
|
||||
<span
|
||||
className="ai-code-run-btn"
|
||||
onClick={handleExecute}
|
||||
style={{
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center',
|
||||
opacity: 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#1677ff'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
<span style={{ marginLeft: 4 }}>执行</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 阶段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<any[] | null>(null);
|
||||
const [previewCols, setPreviewCols] = useState<string[]>([]);
|
||||
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 (
|
||||
<div className="ai-code-block-container" style={{ margin: '12px 0', border: overlayTheme.sectionBorder, borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div className="ai-code-header" style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '6px 12px', background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
||||
fontSize: 12, color: overlayTheme.mutedText
|
||||
}}>
|
||||
<span style={{ fontFamily: 'monospace' }}>{match[1]}</span>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
{isSql && <CodeRunBtn text={codeText} connectionId={activeConnectionId} dbName={activeDbName} />}
|
||||
{isSelectQuery && activeConnectionConfig && (
|
||||
<Tooltip title="在聊天内预览查询结果(最多20行)">
|
||||
<span
|
||||
onClick={handleInlineExecute}
|
||||
style={{
|
||||
cursor: previewLoading ? 'wait' : 'pointer', display: 'flex', alignItems: 'center',
|
||||
opacity: previewLoading ? 1 : 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#faad14'
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!previewLoading) e.currentTarget.style.opacity = '1'; }}
|
||||
onMouseLeave={(e) => { if (!previewLoading) e.currentTarget.style.opacity = '0.6'; }}
|
||||
>
|
||||
{previewLoading ? '⏳' : '👁'}
|
||||
<span style={{ marginLeft: 4 }}>{previewLoading ? '执行中...' : '预览'}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<CodeCopyBtn text={displayText} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<SyntaxHighlighter
|
||||
style={darkMode ? vscDarkPlus as any : vs as any}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
showLineNumbers={true}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.02)',
|
||||
maxHeight: expanded ? 'none' : (isLongCode ? MAX_HEIGHT : 'none'),
|
||||
overflowY: expanded ? 'auto' : 'hidden',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.6
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
fontSize: '14px',
|
||||
fontFamily: 'Menlo, Monaco, Consolas, "Courier New", monospace'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{displayText}
|
||||
</SyntaxHighlighter>
|
||||
|
||||
{!expanded && isLongCode && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0, left: 0, right: 0,
|
||||
height: 60,
|
||||
background: `linear-gradient(to bottom, transparent, ${darkMode ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.9)'})`,
|
||||
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
|
||||
paddingBottom: 8, cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: overlayTheme.iconColor, background: darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)', padding: '2px 8px', borderRadius: 12 }}>
|
||||
展开全部代码
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{expanded && isLongCode && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex', justifyContent: 'center', padding: '6px 0',
|
||||
background: darkMode ? 'rgba(0,0,0,0.3)' : 'rgba(0,0,0,0.02)', cursor: 'pointer',
|
||||
borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}`
|
||||
}}
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: overlayTheme.iconColor }}>收起代码</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline SQL Preview Results */}
|
||||
{previewError && (
|
||||
<div style={{ padding: '8px 12px', fontSize: 12, color: '#ef4444', background: darkMode ? 'rgba(239,68,68,0.1)' : 'rgba(239,68,68,0.05)', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}` }}>
|
||||
❌ {previewError}
|
||||
</div>
|
||||
)}
|
||||
{previewExpanded && previewData && previewData.length > 0 && (
|
||||
<div style={{ borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}` }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 12px', background: darkMode ? 'rgba(250,173,20,0.08)' : 'rgba(250,173,20,0.05)' }}>
|
||||
<span style={{ fontSize: 11, color: overlayTheme.mutedText }}>📊 预览结果({previewData.length} 行 × {previewCols.length} 列)</span>
|
||||
<span style={{ fontSize: 11, color: overlayTheme.mutedText, cursor: 'pointer' }} onClick={() => setPreviewExpanded(false)}>收起 ▴</span>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto', maxHeight: 200, overflowY: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11, fontFamily: 'monospace' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{previewCols.map(col => (
|
||||
<th key={col} style={{ padding: '4px 8px', textAlign: 'left', background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', color: overlayTheme.titleText, fontWeight: 600, whiteSpace: 'nowrap', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}` }}>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewData.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{previewCols.map(col => (
|
||||
<td key={col} style={{ padding: '3px 8px', color: overlayTheme.mutedText, whiteSpace: 'nowrap', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'}`, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{row[col] === null ? <span style={{ color: '#999', fontStyle: 'italic' }}>NULL</span> : String(row[col])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!previewExpanded && previewData && previewData.length > 0 && (
|
||||
<div
|
||||
style={{ padding: '4px 12px', cursor: 'pointer', fontSize: 11, color: overlayTheme.mutedText, background: darkMode ? 'rgba(250,173,20,0.05)' : 'rgba(250,173,20,0.03)', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'}` }}
|
||||
onClick={() => setPreviewExpanded(true)}
|
||||
>
|
||||
📊 查看结果({previewData.length} 行)▾
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 可折叠思考过程组件
|
||||
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<HTMLDivElement>(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 (
|
||||
<div style={{
|
||||
marginBottom: hasContent ? 8 : 0,
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<span style={{ transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', fontSize: 10 }}>▶</span>
|
||||
<span>💭 思考过程</span>
|
||||
{isActivelyThinking && <span style={{ fontSize: 10, color: '#8b5cf6', animation: 'pulse 1.5s ease-in-out infinite' }}>思考中...</span>}
|
||||
{!isActivelyThinking && <span style={{ fontSize: 10, opacity: 0.5 }}>({displayThinking.length} 字)</span>}
|
||||
</div>
|
||||
<div className={`ai-expand-transition ${expanded ? 'expanded' : 'collapsed'}`}>
|
||||
<div ref={contentRef} style={{
|
||||
padding: expanded ? '8px 12px' : '0 12px',
|
||||
borderLeft: '3px solid #8b5cf6',
|
||||
margin: '0 8px 8px',
|
||||
fontSize: 12, lineHeight: 1.7,
|
||||
color: overlayTheme.mutedText,
|
||||
fontStyle: 'italic',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
maxHeight: 400, overflowY: 'auto',
|
||||
}}>
|
||||
{displayThinking}
|
||||
{isTyping && <span className="ai-blinking-cursor" style={{ background: '#8b5cf6', marginLeft: 4, width: 6, height: 12, display: 'inline-block', verticalAlign: 'middle', opacity: 0.8 }} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 工具调用进度面板聚合展示组件
|
||||
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 (
|
||||
<div style={{
|
||||
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.025)',
|
||||
borderRadius: 8, fontSize: 12, overflow: 'hidden',
|
||||
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}`,
|
||||
marginTop: hasContent ? 12 : 0,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
<div
|
||||
onClick={() => 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)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: overlayTheme.titleText, fontWeight: 500 }}>
|
||||
{!allDone && loading ? (
|
||||
<div className="ai-spinning-ring" />
|
||||
) : (
|
||||
<CheckOutlined style={{ color: '#10b981' }} />
|
||||
)}
|
||||
<span>{!allDone && loading ? '正在执行数据探针...' : `数据探针执行完毕 (${totalCalls} 项)`}</span>
|
||||
</div>
|
||||
<span style={{ transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', fontSize: 10, color: overlayTheme.mutedText }}>▶</span>
|
||||
</div>
|
||||
<div className={`ai-expand-transition ${expanded ? 'expanded' : 'collapsed'}`}>
|
||||
<div style={{ padding: expanded ? '4px 12px 12px' : '0 12px' }}>
|
||||
{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 (
|
||||
<div key={tc.id} style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 4,
|
||||
marginTop: 6, paddingLeft: 8,
|
||||
borderLeft: `2px solid ${isDone ? '#10b981' : (loading ? '#1677ff' : overlayTheme.shellBorder)}`,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{isDone
|
||||
? <CheckOutlined style={{ color: '#10b981', fontSize: 11 }} />
|
||||
: (loading ? <div className="ai-spinning-ring" style={{ width: 10, height: 10, borderWidth: 1.5 }} /> : <ApiOutlined style={{ color: overlayTheme.mutedText, fontSize: 11 }} />)
|
||||
}
|
||||
<span style={{ color: isDone ? overlayTheme.mutedText : overlayTheme.titleText }}>{actionName}</span>
|
||||
</div>
|
||||
{resultMsg && <AIToolResultItem resultMsg={resultMsg} darkMode={darkMode} overlayTheme={overlayTheme} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIMessageBubble: React.FC<AIMessageBubbleProps> = React.memo(({ msg, darkMode, overlayTheme, textColor, onEdit, onRetry, onDelete, activeConnectionId, activeConnectionConfig, activeDbName, allMessages }) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const isUser = msg.role === 'user';
|
||||
|
||||
// 从 content 中提取 <think>...</think> 标签内容(部分模型如 MiniMax、DeepSeek 会以文本形式返回思考过程)
|
||||
const { displayContent, parsedThinking } = React.useMemo(() => {
|
||||
const content = msg.content || '';
|
||||
// 优先使用后端已结构化的 thinking 字段(如 Claude API 原生 thinking)
|
||||
if (msg.thinking) {
|
||||
return { displayContent: content, parsedThinking: msg.thinking };
|
||||
}
|
||||
// 尝试从 content 中提取 <think>...</think> 标签
|
||||
const thinkRegex = /<think>([\s\S]*?)(?:<\/think>|$)/g;
|
||||
let thinkParts: string[] = [];
|
||||
let cleanContent = content;
|
||||
let match;
|
||||
while ((match = thinkRegex.exec(content)) !== null) {
|
||||
thinkParts.push(match[1].trim());
|
||||
}
|
||||
if (thinkParts.length > 0) {
|
||||
// 移除所有 <think>...</think> 标签(含未闭合的)
|
||||
cleanContent = content.replace(/<think>[\s\S]*?(?:<\/think>|$)/g, '').trim();
|
||||
return { displayContent: cleanContent, parsedThinking: thinkParts.join('\n\n') };
|
||||
}
|
||||
return { displayContent: content, parsedThinking: '' };
|
||||
}, [msg.content, msg.thinking]);
|
||||
const isTypingThinking = !!(msg.loading && msg.phase === 'thinking');
|
||||
|
||||
if (msg.role === 'tool') return null;
|
||||
|
||||
// 如果是纯空壳的加载状态(connecting,或还在思考/工具阶段但还没吐出一个字的 content)
|
||||
const isWaitState = msg.phase === 'connecting' ||
|
||||
(msg.loading && !msg.content && (msg.phase === 'thinking' || msg.phase === 'tool_calling'));
|
||||
|
||||
if (isWaitState) {
|
||||
return (
|
||||
<div className="ai-ide-message" style={{ borderBottom: 'none', padding: '8px 16px' }}>
|
||||
<div style={{
|
||||
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
|
||||
borderRadius: 12, padding: '14px 16px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: overlayTheme.mutedText }}>
|
||||
<div className="ai-wave-pulse">
|
||||
<span /> <span /> <span />
|
||||
</div>
|
||||
<span style={{ fontSize: 13, opacity: 0.8 }}>{msg.content || '正在建立连接'}...</span>
|
||||
</div>
|
||||
|
||||
{/* 即使在波纹过渡态,如果有 thinking / tool_calls 也要显示出来,只是把它们压在波纹下面 */}
|
||||
<div style={{ marginTop: parsedThinking || (msg.tool_calls && msg.tool_calls.length > 0) ? 12 : 0 }}>
|
||||
{!isUser && parsedThinking && (
|
||||
<ThinkingBlock
|
||||
displayThinking={parsedThinking}
|
||||
totalLen={parsedThinking.length}
|
||||
isTyping={isTypingThinking}
|
||||
isGlobalLoading={!!msg.loading}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
hasContent={false}
|
||||
/>
|
||||
)}
|
||||
{!isUser && msg.tool_calls && msg.tool_calls.length > 0 && (
|
||||
<AIToolCallingBlock
|
||||
tool_calls={msg.tool_calls}
|
||||
loading={!!msg.loading}
|
||||
allMessages={allMessages || []}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
hasContent={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-ide-message" style={{ borderBottom: 'none', padding: '8px 16px' }}>
|
||||
<div style={{
|
||||
background: isUser ? (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)') : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'),
|
||||
borderRadius: 12,
|
||||
padding: '14px 16px',
|
||||
}}>
|
||||
<div className="ai-ide-message-header" style={{
|
||||
color: isUser ? overlayTheme.mutedText : overlayTheme.titleText,
|
||||
marginBottom: isUser ? 6 : 10,
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
{isUser
|
||||
? <><UserOutlined /> <span>You</span></>
|
||||
: <><RobotOutlined style={{ color: overlayTheme.iconColor }} /> <span>GoNavi AI</span></>}
|
||||
</div>
|
||||
{/* 气泡操作栏 */}
|
||||
<div className="ai-message-actions" style={{ display: 'flex', gap: 8, opacity: 0, transition: 'opacity 0.2s', padding: '0 4px' }}>
|
||||
<Tooltip title={isCopied ? "已复制" : "复制全文"}>
|
||||
{isCopied ? (
|
||||
<CheckOutlined className="ai-action-icon" style={{ color: '#10b981' }} />
|
||||
) : (
|
||||
<CopyOutlined className="ai-action-icon" onClick={() => {
|
||||
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} />
|
||||
)}
|
||||
</Tooltip>
|
||||
{isUser ? (
|
||||
<Tooltip title="编辑此条消息(移除其后所有记录并重新发送)">
|
||||
<EditOutlined className="ai-action-icon" onClick={() => onEdit(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="重新生成(移除此条并触发上次用户输入重发)">
|
||||
<ReloadOutlined className="ai-action-icon" onClick={() => onRetry(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="删除单条消息">
|
||||
<DeleteOutlined className="ai-action-icon" onClick={() => onDelete(msg.id)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ai-ide-message-content ai-markdown-content" style={{ color: textColor }}>
|
||||
{msg.images && msg.images.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{msg.images.map((img, i) => (
|
||||
<img key={i} src={img} alt={`Attached ${i}`} style={{ maxWidth: 200, maxHeight: 200, borderRadius: 8, objectFit: 'contain', border: overlayTheme.shellBorder }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 可折叠思考过程 */}
|
||||
{!isUser && parsedThinking && (
|
||||
<ThinkingBlock
|
||||
displayThinking={parsedThinking}
|
||||
totalLen={parsedThinking.length}
|
||||
isTyping={isTypingThinking}
|
||||
isGlobalLoading={!!msg.loading}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
hasContent={!!msg.content}
|
||||
/>
|
||||
)}
|
||||
{isUser ? (
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 13 }}>{msg.content}</div>
|
||||
) : (
|
||||
<MemoizedMarkdown
|
||||
content={displayContent}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
activeConnectionConfig={activeConnectionConfig}
|
||||
activeConnectionId={activeConnectionId}
|
||||
activeDbName={activeDbName}
|
||||
/>
|
||||
)}
|
||||
{/* 错误原文复制按钮 */}
|
||||
{!isUser && msg.rawError && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(msg.rawError || '');
|
||||
const btn = document.getElementById(`raw-err-btn-${msg.id}`);
|
||||
if (btn) { btn.textContent = '✅ 已复制'; setTimeout(() => { btn.textContent = '📋 复制报错原文'; }, 1500); }
|
||||
}}
|
||||
id={`raw-err-btn-${msg.id}`}
|
||||
style={{
|
||||
fontSize: 12, padding: '3px 10px', borderRadius: 6, cursor: 'pointer',
|
||||
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)'}`,
|
||||
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
|
||||
color: overlayTheme.mutedText, transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
📋 复制报错原文
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* 工具调用进度展示 */}
|
||||
{!isUser && msg.tool_calls && msg.tool_calls.length > 0 && (
|
||||
<AIToolCallingBlock
|
||||
tool_calls={msg.tool_calls}
|
||||
loading={!!msg.loading}
|
||||
allMessages={allMessages || []}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
hasContent={!!msg.content}
|
||||
/>
|
||||
)}
|
||||
{msg.loading && msg.phase !== 'tool_calling' && msg.content && (
|
||||
<span className="ai-blinking-cursor" style={{ background: overlayTheme.iconColor }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,69 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
|
||||
|
||||
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
|
||||
}
|
||||
};
|
||||
describe('dataGridLayout helpers', () => {
|
||||
it('returns zero bottom padding without horizontal overflow', () => {
|
||||
expect(calculateTableBodyBottomPadding({
|
||||
hasHorizontalOverflow: false,
|
||||
floatingScrollbarHeight: 10,
|
||||
floatingScrollbarGap: 6,
|
||||
})).toBe(0);
|
||||
});
|
||||
|
||||
assertEqual(
|
||||
calculateTableBodyBottomPadding({
|
||||
hasHorizontalOverflow: false,
|
||||
floatingScrollbarHeight: 10,
|
||||
floatingScrollbarGap: 6,
|
||||
}),
|
||||
0,
|
||||
'无横向滚动条时不应增加底部间距'
|
||||
);
|
||||
it('adds safe area when horizontal overflow exists', () => {
|
||||
expect(calculateTableBodyBottomPadding({
|
||||
hasHorizontalOverflow: true,
|
||||
floatingScrollbarHeight: 10,
|
||||
floatingScrollbarGap: 6,
|
||||
})).toBe(28);
|
||||
expect(calculateTableBodyBottomPadding({
|
||||
hasHorizontalOverflow: true,
|
||||
floatingScrollbarHeight: 14,
|
||||
floatingScrollbarGap: 4,
|
||||
})).toBe(30);
|
||||
});
|
||||
|
||||
assertEqual(
|
||||
calculateTableBodyBottomPadding({
|
||||
hasHorizontalOverflow: true,
|
||||
floatingScrollbarHeight: 10,
|
||||
floatingScrollbarGap: 6,
|
||||
}),
|
||||
28,
|
||||
'默认悬浮滚动条应预留滚动条高度、间距和额外安全区'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateTableBodyBottomPadding({
|
||||
hasHorizontalOverflow: true,
|
||||
floatingScrollbarHeight: 14,
|
||||
floatingScrollbarGap: 4,
|
||||
}),
|
||||
30,
|
||||
'较粗滚动条场景下应同步放大底部安全区'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateVirtualTableScrollX({
|
||||
totalWidth: 646,
|
||||
tableViewportWidth: 1200,
|
||||
isMacLike: false,
|
||||
}),
|
||||
1200,
|
||||
'列总宽小于视口时应按视口宽度返回 scroll.x,避免 header/body 走两套宽度'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateVirtualTableScrollX({
|
||||
totalWidth: 646,
|
||||
tableViewportWidth: 0,
|
||||
isMacLike: false,
|
||||
}),
|
||||
646,
|
||||
'未拿到视口宽度时应退回列宽总和'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateVirtualTableScrollX({
|
||||
totalWidth: 1200,
|
||||
tableViewportWidth: 800,
|
||||
isMacLike: true,
|
||||
}),
|
||||
1202,
|
||||
'macOS 横向溢出时仍需额外预留 2px 以稳定滚动轨道'
|
||||
);
|
||||
|
||||
console.log('dataGridLayout tests passed');
|
||||
it('keeps scroll width aligned with viewport or content width', () => {
|
||||
expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 1200, isMacLike: false })).toBe(1200);
|
||||
expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 0, isMacLike: false })).toBe(646);
|
||||
expect(calculateVirtualTableScrollX({ totalWidth: 1200, tableViewportWidth: 800, isMacLike: true })).toBe(1202);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { RedisKeyInfo } from '../types';
|
||||
import {
|
||||
applyRenamedRedisKeyState,
|
||||
@@ -7,20 +9,6 @@ import {
|
||||
isGroupFullyChecked,
|
||||
} from './redisViewerTree';
|
||||
|
||||
const assert = (condition: unknown, message: string) => {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
const actualText = JSON.stringify(actual);
|
||||
const expectedText = JSON.stringify(expected);
|
||||
if (actualText !== expectedText) {
|
||||
throw new Error(`${message}\nactual: ${actualText}\nexpected: ${expectedText}`);
|
||||
}
|
||||
};
|
||||
|
||||
const sampleKeys: RedisKeyInfo[] = [
|
||||
{ key: 'app:user:1', type: 'string', ttl: -1 },
|
||||
{ key: 'app:user:2', type: 'string', ttl: -1 },
|
||||
@@ -28,78 +16,64 @@ const sampleKeys: RedisKeyInfo[] = [
|
||||
{ key: 'misc', type: 'set', ttl: -1 },
|
||||
];
|
||||
|
||||
const tree = buildRedisKeyTree(sampleKeys, true);
|
||||
const appGroup = tree.treeData.find((node) => node.key === 'group:app');
|
||||
const userGroup = appGroup?.children?.find((node) => node.key === 'group:app:user');
|
||||
describe('redisViewerTree helpers', () => {
|
||||
it('builds grouped redis key tree and group selection state', () => {
|
||||
const tree = buildRedisKeyTree(sampleKeys, true);
|
||||
const appGroup = tree.treeData.find((node) => node.key === 'group:app');
|
||||
const userGroup = appGroup?.children?.find((node) => node.key === 'group:app:user');
|
||||
|
||||
assert(appGroup, '应生成 group:app 节点');
|
||||
assert(userGroup, '应生成 group:app:user 节点');
|
||||
assertEqual(
|
||||
appGroup?.descendantRawKeys,
|
||||
['app:order:1', 'app:user:1', 'app:user:2'],
|
||||
'app 分组应收集全部后代 key'
|
||||
);
|
||||
expect(appGroup).toBeTruthy();
|
||||
expect(userGroup).toBeTruthy();
|
||||
expect(appGroup?.descendantRawKeys).toEqual(['app:order:1', 'app:user:1', 'app:user:2']);
|
||||
|
||||
const selectedAfterGroupCheck = applyTreeNodeCheck([], appGroup!, true);
|
||||
assertEqual(
|
||||
selectedAfterGroupCheck,
|
||||
['app:order:1', 'app:user:1', 'app:user:2'],
|
||||
'勾选分组应递归选中全部后代 key'
|
||||
);
|
||||
const selectedAfterGroupCheck = applyTreeNodeCheck([], appGroup!, true);
|
||||
expect(selectedAfterGroupCheck).toEqual(['app:order:1', 'app:user:1', 'app:user:2']);
|
||||
|
||||
const checkedState = buildCheckedTreeNodeState(selectedAfterGroupCheck, tree);
|
||||
assertEqual(
|
||||
checkedState.checked,
|
||||
['key:app:order:1', 'group:app:order', 'key:app:user:1', 'key:app:user:2', 'group:app:user', 'group:app'],
|
||||
'全部后代已选中时,父分组和叶子都应进入 checked'
|
||||
);
|
||||
assertEqual(checkedState.halfChecked, [], '全部后代已选中时不应有 halfChecked');
|
||||
assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupCheck), true, '全部后代已选中时,分组应视为 fully checked');
|
||||
const checkedState = buildCheckedTreeNodeState(selectedAfterGroupCheck, tree);
|
||||
expect(checkedState.checked).toEqual(['key:app:order:1', 'group:app:order', 'key:app:user:1', 'key:app:user:2', 'group:app:user', 'group:app']);
|
||||
expect(checkedState.halfChecked).toEqual([]);
|
||||
expect(isGroupFullyChecked(appGroup!, selectedAfterGroupCheck)).toBe(true);
|
||||
|
||||
const selectedAfterGroupUncheck = applyTreeNodeCheck(selectedAfterGroupCheck, appGroup!, false);
|
||||
assertEqual(selectedAfterGroupUncheck, [], '取消勾选分组应移除全部后代 key');
|
||||
assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupUncheck), false, '取消后分组不应再是 fully checked');
|
||||
const selectedAfterGroupUncheck = applyTreeNodeCheck(selectedAfterGroupCheck, appGroup!, false);
|
||||
expect(selectedAfterGroupUncheck).toEqual([]);
|
||||
expect(isGroupFullyChecked(appGroup!, selectedAfterGroupUncheck)).toBe(false);
|
||||
});
|
||||
|
||||
const partialState = buildCheckedTreeNodeState(['app:user:1'], tree);
|
||||
assertEqual(
|
||||
partialState.halfChecked,
|
||||
['group:app:user', 'group:app'],
|
||||
'仅部分后代选中时,相关分组应进入 halfChecked'
|
||||
);
|
||||
assertEqual(isGroupFullyChecked(appGroup!, ['app:user:1']), false, '部分选中时分组不应是 fully checked');
|
||||
it('marks parent groups as half checked for partial selection', () => {
|
||||
const tree = buildRedisKeyTree(sampleKeys, true);
|
||||
const appGroup = tree.treeData.find((node) => node.key === 'group:app');
|
||||
const partialState = buildCheckedTreeNodeState(['app:user:1'], tree);
|
||||
|
||||
const renamedState = applyRenamedRedisKeyState(
|
||||
{
|
||||
keys: sampleKeys,
|
||||
selectedKey: 'app:user:2',
|
||||
selectedKeys: ['app:user:1', 'app:user:2', 'misc'],
|
||||
},
|
||||
'app:user:2',
|
||||
'app:user:200'
|
||||
);
|
||||
expect(partialState.halfChecked).toEqual(['group:app:user', 'group:app']);
|
||||
expect(isGroupFullyChecked(appGroup!, ['app:user:1'])).toBe(false);
|
||||
});
|
||||
|
||||
assertEqual(
|
||||
renamedState.keys.map((item) => item.key),
|
||||
['app:user:1', 'app:user:200', 'app:order:1', 'misc'],
|
||||
'重命名后 keys 列表应替换旧 key'
|
||||
);
|
||||
assertEqual(renamedState.selectedKey, 'app:user:200', '当前详情选中的 key 应切换为新 key');
|
||||
assertEqual(
|
||||
renamedState.selectedKeys,
|
||||
['app:user:1', 'app:user:200', 'misc'],
|
||||
'批量选中集合中的旧 key 应映射为新 key'
|
||||
);
|
||||
it('updates selected keys consistently after rename', () => {
|
||||
const renamedState = applyRenamedRedisKeyState(
|
||||
{
|
||||
keys: sampleKeys,
|
||||
selectedKey: 'app:user:2',
|
||||
selectedKeys: ['app:user:1', 'app:user:2', 'misc'],
|
||||
},
|
||||
'app:user:2',
|
||||
'app:user:200'
|
||||
);
|
||||
|
||||
const unrelatedRenameState = applyRenamedRedisKeyState(
|
||||
{
|
||||
keys: sampleKeys,
|
||||
selectedKey: 'misc',
|
||||
selectedKeys: ['app:user:1'],
|
||||
},
|
||||
'app:order:1',
|
||||
'app:order:9'
|
||||
);
|
||||
assertEqual(unrelatedRenameState.selectedKey, 'misc', '非当前详情 key 的重命名不应影响 selectedKey');
|
||||
assertEqual(unrelatedRenameState.selectedKeys, ['app:user:1'], '非已勾选 key 的重命名不应污染选中集合');
|
||||
expect(renamedState.keys.map((item) => item.key)).toEqual(['app:user:1', 'app:user:200', 'app:order:1', 'misc']);
|
||||
expect(renamedState.selectedKey).toBe('app:user:200');
|
||||
expect(renamedState.selectedKeys).toEqual(['app:user:1', 'app:user:200', 'misc']);
|
||||
|
||||
console.log('redisViewerTree tests passed');
|
||||
const unrelatedRenameState = applyRenamedRedisKeyState(
|
||||
{
|
||||
keys: sampleKeys,
|
||||
selectedKey: 'misc',
|
||||
selectedKeys: ['app:user:1'],
|
||||
},
|
||||
'app:order:1',
|
||||
'app:order:9'
|
||||
);
|
||||
|
||||
expect(unrelatedRenameState.selectedKey).toBe('misc');
|
||||
expect(unrelatedRenameState.selectedKeys).toEqual(['app:user:1']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,50 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
|
||||
|
||||
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
|
||||
}
|
||||
};
|
||||
describe('buildRedisWorkbenchTheme', () => {
|
||||
it('builds dark redis workbench theme', () => {
|
||||
const darkTheme = buildRedisWorkbenchTheme({ darkMode: true, opacity: 0.72, blur: 14 });
|
||||
expect(darkTheme.isDark).toBe(true);
|
||||
expect(darkTheme.panelBg).toMatch(/^rgba\(/);
|
||||
expect(darkTheme.toolbarPrimaryBg).toMatch(/^linear-gradient\(/);
|
||||
expect(darkTheme.actionDangerBg).not.toBe(darkTheme.actionSecondaryBg);
|
||||
expect(darkTheme.treeSelectedBg).not.toBe(darkTheme.treeHoverBg);
|
||||
expect(darkTheme.appBg).toMatch(/rgba\(15, 15, 17,/);
|
||||
expect(darkTheme.panelBg).toMatch(/rgba\(24, 24, 28,/);
|
||||
expect(darkTheme.panelBgStrong).toMatch(/rgba\(31, 31, 36,/);
|
||||
expect(darkTheme.backdropFilter).toBe('blur(14px)');
|
||||
});
|
||||
|
||||
const assertNotEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
if (actual === expected) {
|
||||
throw new Error(`${message}\nactual: ${String(actual)}\nnotExpected: ${String(expected)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertMatch = (value: string, pattern: RegExp, message: string) => {
|
||||
if (!pattern.test(value)) {
|
||||
throw new Error(`${message}\nactual: ${value}\npattern: ${String(pattern)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const darkTheme = buildRedisWorkbenchTheme({
|
||||
darkMode: true,
|
||||
opacity: 0.72,
|
||||
blur: 14,
|
||||
it('builds light redis workbench theme', () => {
|
||||
const lightTheme = buildRedisWorkbenchTheme({ darkMode: false, opacity: 1, blur: 0 });
|
||||
expect(lightTheme.isDark).toBe(false);
|
||||
expect(lightTheme.panelBg).toMatch(/^rgba\(/);
|
||||
expect(lightTheme.contentEmptyBg).toMatch(/^linear-gradient\(/);
|
||||
expect(lightTheme.textPrimary).not.toBe(lightTheme.textSecondary);
|
||||
expect(lightTheme.statusTagBg).not.toBe(lightTheme.statusTagMutedBg);
|
||||
expect(lightTheme.backdropFilter).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
assertEqual(darkTheme.isDark, true, 'dark 主题标记应为 true');
|
||||
assertMatch(darkTheme.panelBg, /^rgba\(/, 'dark 主题面板背景应为 rgba');
|
||||
assertMatch(darkTheme.toolbarPrimaryBg, /^linear-gradient\(/, '工具栏主按钮应使用渐变背景');
|
||||
assertNotEqual(darkTheme.actionDangerBg, darkTheme.actionSecondaryBg, '危险态按钮背景不应与普通按钮相同');
|
||||
assertNotEqual(darkTheme.treeSelectedBg, darkTheme.treeHoverBg, '树节点选中态与悬浮态不应相同');
|
||||
assertMatch(darkTheme.appBg, /rgba\(15, 15, 17,/, 'dark 背景应保持中性黑基底');
|
||||
assertMatch(darkTheme.panelBg, /rgba\(24, 24, 28,/, 'dark 面板背景应保持中性黑灰');
|
||||
assertMatch(darkTheme.panelBgStrong, /rgba\(31, 31, 36,/, 'dark 强面板背景应保持中性黑灰');
|
||||
assertEqual(darkTheme.backdropFilter, 'blur(14px)', 'blur 参数应映射为 backdropFilter');
|
||||
|
||||
const lightTheme = buildRedisWorkbenchTheme({
|
||||
darkMode: false,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
});
|
||||
|
||||
assertEqual(lightTheme.isDark, false, 'light 主题标记应为 false');
|
||||
assertMatch(lightTheme.panelBg, /^rgba\(/, 'light 主题面板背景应为 rgba');
|
||||
assertMatch(lightTheme.contentEmptyBg, /^linear-gradient\(/, 'light 空状态背景应为渐变');
|
||||
assertNotEqual(lightTheme.textPrimary, lightTheme.textSecondary, '主次文本颜色应区分');
|
||||
assertNotEqual(lightTheme.statusTagBg, lightTheme.statusTagMutedBg, '状态 tag 应区分普通与弱化样式');
|
||||
assertEqual(lightTheme.backdropFilter, 'none', 'blur=0 时 backdropFilter 应为 none');
|
||||
|
||||
console.log('redisViewerWorkbenchTheme tests passed');
|
||||
|
||||
95
frontend/src/components/tableDesignerIndexUtils.test.ts
Normal file
95
frontend/src/components/tableDesignerIndexUtils.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
hasIndexFormChanged,
|
||||
normalizeIndexFormFromRow,
|
||||
shouldRestoreOriginalIndex,
|
||||
toggleIndexSelection,
|
||||
type IndexDisplaySnapshot,
|
||||
type IndexFormSnapshot,
|
||||
} from './tableDesignerIndexUtils';
|
||||
|
||||
describe('tableDesignerIndexUtils', () => {
|
||||
it('normalizes index rows for edit form reuse', () => {
|
||||
const row: IndexDisplaySnapshot = {
|
||||
key: 'idx_user_name',
|
||||
name: 'idx_user_name',
|
||||
indexType: 'btree',
|
||||
nonUnique: 0,
|
||||
columnNames: ['name'],
|
||||
};
|
||||
|
||||
expect(normalizeIndexFormFromRow(row, ['NORMAL', 'UNIQUE', 'PRIMARY', 'FULLTEXT', 'SPATIAL'])).toEqual({
|
||||
name: 'idx_user_name',
|
||||
columnNames: ['name'],
|
||||
kind: 'UNIQUE',
|
||||
indexType: 'BTREE',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects no-op index edits as unchanged', () => {
|
||||
const previousForm: IndexFormSnapshot = {
|
||||
name: 'idx_user_name',
|
||||
columnNames: ['name'],
|
||||
kind: 'UNIQUE',
|
||||
indexType: 'BTREE',
|
||||
};
|
||||
const nextForm: IndexFormSnapshot = {
|
||||
name: 'idx_user_name',
|
||||
columnNames: ['name'],
|
||||
kind: 'UNIQUE',
|
||||
indexType: 'BTREE',
|
||||
};
|
||||
|
||||
expect(hasIndexFormChanged(previousForm, nextForm)).toBe(false);
|
||||
});
|
||||
|
||||
it('marks edits as changed when index columns differ', () => {
|
||||
const previousForm: IndexFormSnapshot = {
|
||||
name: 'idx_user_name',
|
||||
columnNames: ['name'],
|
||||
kind: 'NORMAL',
|
||||
indexType: 'DEFAULT',
|
||||
};
|
||||
const nextForm: IndexFormSnapshot = {
|
||||
name: 'idx_user_name',
|
||||
columnNames: ['name', 'email'],
|
||||
kind: 'NORMAL',
|
||||
indexType: 'DEFAULT',
|
||||
};
|
||||
|
||||
expect(hasIndexFormChanged(previousForm, nextForm)).toBe(true);
|
||||
});
|
||||
|
||||
it('toggles selected index keys without duplicates', () => {
|
||||
expect(toggleIndexSelection([], 'idx_user_name', true)).toEqual(['idx_user_name']);
|
||||
expect(toggleIndexSelection(['idx_user_name'], 'idx_user_name', true)).toEqual(['idx_user_name']);
|
||||
expect(toggleIndexSelection(['idx_user_name'], 'idx_user_name')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps single-selection toggles stable across repeated clicks', () => {
|
||||
let selected = toggleIndexSelection([], 'idx_user_name');
|
||||
expect(selected).toEqual(['idx_user_name']);
|
||||
|
||||
selected = toggleIndexSelection(selected, 'idx_user_name');
|
||||
expect(selected).toEqual([]);
|
||||
|
||||
selected = toggleIndexSelection(selected, 'idx_user_name');
|
||||
expect(selected).toEqual(['idx_user_name']);
|
||||
|
||||
selected = toggleIndexSelection(selected, 'idx_user_email');
|
||||
expect(selected).toEqual(['idx_user_name', 'idx_user_email']);
|
||||
|
||||
selected = toggleIndexSelection(selected, 'idx_user_email');
|
||||
expect(selected).toEqual(['idx_user_name']);
|
||||
|
||||
selected = toggleIndexSelection(selected, 'idx_user_name');
|
||||
expect(selected).toEqual([]);
|
||||
});
|
||||
|
||||
it('only restores original index when create step fails after drop step', () => {
|
||||
expect(shouldRestoreOriginalIndex({ failedStatementIndex: 1 })).toBe(true);
|
||||
expect(shouldRestoreOriginalIndex({ failedStatementIndex: 0 })).toBe(false);
|
||||
expect(shouldRestoreOriginalIndex({})).toBe(false);
|
||||
});
|
||||
});
|
||||
78
frontend/src/components/tableDesignerIndexUtils.ts
Normal file
78
frontend/src/components/tableDesignerIndexUtils.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export type IndexKind = 'NORMAL' | 'UNIQUE' | 'PRIMARY' | 'FULLTEXT' | 'SPATIAL';
|
||||
|
||||
export interface IndexDisplaySnapshot {
|
||||
key: string;
|
||||
name: string;
|
||||
indexType: string;
|
||||
nonUnique: number;
|
||||
columnNames: string[];
|
||||
}
|
||||
|
||||
export interface IndexFormSnapshot {
|
||||
name: string;
|
||||
columnNames: string[];
|
||||
kind: IndexKind;
|
||||
indexType: string;
|
||||
}
|
||||
|
||||
export interface SchemaExecutionSnapshot {
|
||||
failedStatementIndex?: number;
|
||||
}
|
||||
|
||||
export const normalizeIndexFormFromRow = (
|
||||
row: IndexDisplaySnapshot,
|
||||
supportedKinds: IndexKind[],
|
||||
): IndexFormSnapshot => {
|
||||
const selectedName = String(row.name || '').trim();
|
||||
const selectedNameUpper = selectedName.toUpperCase();
|
||||
const selectedTypeUpper = String(row.indexType || '').trim().toUpperCase();
|
||||
let kind: IndexKind = 'NORMAL';
|
||||
if (selectedNameUpper === 'PRIMARY') {
|
||||
kind = 'PRIMARY';
|
||||
} else if (selectedTypeUpper === 'FULLTEXT') {
|
||||
kind = 'FULLTEXT';
|
||||
} else if (selectedTypeUpper === 'SPATIAL') {
|
||||
kind = 'SPATIAL';
|
||||
} else if (row.nonUnique === 0) {
|
||||
kind = 'UNIQUE';
|
||||
}
|
||||
if (!supportedKinds.includes(kind)) {
|
||||
kind = row.nonUnique === 0 ? 'UNIQUE' : 'NORMAL';
|
||||
}
|
||||
return {
|
||||
name: kind === 'PRIMARY' ? 'PRIMARY' : selectedName,
|
||||
columnNames: [...row.columnNames],
|
||||
kind,
|
||||
indexType: kind === 'NORMAL' || kind === 'UNIQUE'
|
||||
? (selectedTypeUpper || 'DEFAULT')
|
||||
: 'DEFAULT',
|
||||
};
|
||||
};
|
||||
|
||||
export const hasIndexFormChanged = (
|
||||
previousForm: IndexFormSnapshot,
|
||||
nextForm: IndexFormSnapshot,
|
||||
): boolean => {
|
||||
if (previousForm.name !== nextForm.name) return true;
|
||||
if (previousForm.kind !== nextForm.kind) return true;
|
||||
if (previousForm.indexType !== nextForm.indexType) return true;
|
||||
if (previousForm.columnNames.length !== nextForm.columnNames.length) return true;
|
||||
return previousForm.columnNames.some((col, idx) => col !== nextForm.columnNames[idx]);
|
||||
};
|
||||
|
||||
export const toggleIndexSelection = (
|
||||
selectedKeys: string[],
|
||||
key: string,
|
||||
checked?: boolean,
|
||||
): string[] => {
|
||||
const exists = selectedKeys.includes(key);
|
||||
const nextChecked = checked ?? !exists;
|
||||
if (nextChecked) {
|
||||
return exists ? selectedKeys : [...selectedKeys, key];
|
||||
}
|
||||
return selectedKeys.filter((item) => item !== key);
|
||||
};
|
||||
|
||||
export const shouldRestoreOriginalIndex = (result: SchemaExecutionSnapshot): boolean => (
|
||||
(result.failedStatementIndex ?? -1) > 0
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag } from './types';
|
||||
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem } from './types';
|
||||
import {
|
||||
ShortcutAction,
|
||||
ShortcutBinding,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
sanitizeShortcutOptions,
|
||||
} from './utils/shortcuts';
|
||||
|
||||
const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0 };
|
||||
const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0, useNativeMacWindowControls: false };
|
||||
const DEFAULT_UI_SCALE = 1.0;
|
||||
const MIN_UI_SCALE = 0.8;
|
||||
const MAX_UI_SCALE = 1.25;
|
||||
@@ -25,7 +25,7 @@ const MAX_HOST_ENTRY_LENGTH = 512;
|
||||
const MAX_HOST_ENTRIES = 64;
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
const MAX_TIMEOUT_SECONDS = 3600;
|
||||
const PERSIST_VERSION = 6;
|
||||
const PERSIST_VERSION = 7;
|
||||
const DEFAULT_CONNECTION_TYPE = 'mysql';
|
||||
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
|
||||
enabled: false,
|
||||
@@ -405,7 +405,7 @@ interface AppState {
|
||||
activeContext: { connectionId: string; dbName: string } | null;
|
||||
savedQueries: SavedQuery[];
|
||||
theme: 'light' | 'dark';
|
||||
appearance: { enabled: boolean; opacity: number; blur: number };
|
||||
appearance: { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean };
|
||||
uiScale: number;
|
||||
fontSize: number;
|
||||
startupFullscreen: boolean;
|
||||
@@ -424,6 +424,19 @@ interface AppState {
|
||||
windowState: 'normal' | 'fullscreen' | 'maximized';
|
||||
sidebarWidth: number;
|
||||
|
||||
// AI 运行时与持久化状态
|
||||
aiPanelVisible: boolean;
|
||||
aiChatHistory: Record<string, AIChatMessage[]>; // 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<string, AIContextItem[]>;
|
||||
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;
|
||||
removeConnection: (id: string) => void;
|
||||
@@ -450,7 +463,7 @@ interface AppState {
|
||||
deleteQuery: (id: string) => void;
|
||||
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number }>) => void;
|
||||
setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }>) => void;
|
||||
setUiScale: (scale: number) => void;
|
||||
setFontSize: (size: number) => void;
|
||||
setStartupFullscreen: (enabled: boolean) => void;
|
||||
@@ -475,6 +488,18 @@ interface AppState {
|
||||
setWindowBounds: (bounds: { width: number; height: number; x: number; y: number }) => void;
|
||||
setWindowState: (state: 'normal' | 'fullscreen' | 'maximized') => void;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
|
||||
// AI actions
|
||||
toggleAIPanel: () => void;
|
||||
setAIPanelVisible: (visible: boolean) => void;
|
||||
addAIChatMessage: (sessionId: string, message: AIChatMessage) => void;
|
||||
updateAIChatMessage: (sessionId: string, messageId: string, updates: Partial<AIChatMessage>) => void;
|
||||
deleteAIChatMessage: (sessionId: string, messageId: string) => void;
|
||||
truncateAIChatMessages: (sessionId: string, upToMessageId: string) => void;
|
||||
clearAIChatHistory: (sessionId: string) => void;
|
||||
deleteAISession: (sessionId: string) => void;
|
||||
createNewAISession: () => void;
|
||||
setAIActiveSessionId: (sessionId: string | null) => void;
|
||||
}
|
||||
|
||||
const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
|
||||
@@ -561,9 +586,9 @@ const sanitizeTableHiddenColumns = (value: unknown): Record<string, string[]> =>
|
||||
};
|
||||
|
||||
const sanitizeAppearance = (
|
||||
appearance: Partial<{ enabled: boolean; opacity: number; blur: number }> | undefined,
|
||||
appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }> | undefined,
|
||||
version: number
|
||||
): { enabled: boolean; opacity: number; blur: number } => {
|
||||
): { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean } => {
|
||||
if (!appearance || typeof appearance !== 'object') {
|
||||
return { ...DEFAULT_APPEARANCE };
|
||||
}
|
||||
@@ -571,6 +596,9 @@ const sanitizeAppearance = (
|
||||
enabled: typeof appearance.enabled === 'boolean' ? appearance.enabled : DEFAULT_APPEARANCE.enabled,
|
||||
opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity,
|
||||
blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur,
|
||||
useNativeMacWindowControls: typeof appearance.useNativeMacWindowControls === 'boolean'
|
||||
? appearance.useNativeMacWindowControls
|
||||
: DEFAULT_APPEARANCE.useNativeMacWindowControls,
|
||||
};
|
||||
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
|
||||
return { ...DEFAULT_APPEARANCE };
|
||||
@@ -639,6 +667,74 @@ const unwrapPersistedAppState = (persistedState: unknown): Record<string, unknow
|
||||
return raw;
|
||||
};
|
||||
|
||||
// --- AI 会话文件持久化辅助函数 ---
|
||||
|
||||
/** 每个 session 独立防抖定时器(2秒) */
|
||||
const _persistTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||
|
||||
function _debouncedPersistSession(sessionId: string) {
|
||||
if (_persistTimers[sessionId]) clearTimeout(_persistTimers[sessionId]);
|
||||
_persistTimers[sessionId] = setTimeout(() => {
|
||||
delete _persistTimers[sessionId];
|
||||
const state = useStore.getState();
|
||||
const messages = state.aiChatHistory[sessionId];
|
||||
const sessionMeta = state.aiChatSessions.find(s => s.id === sessionId);
|
||||
if (!messages && !sessionMeta) return; // session 已被删除,跳过
|
||||
const title = sessionMeta?.title || '新的对话';
|
||||
const updatedAt = sessionMeta?.updatedAt || Date.now();
|
||||
const messagesJSON = JSON.stringify(messages || []);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
Service?.AISaveSession?.(sessionId, title, updatedAt, messagesJSON).catch((e: any) => {
|
||||
console.error('[AI Session Persist] 持久化失败:', sessionId, e);
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/** 从后端加载会话列表(仅元数据,不含消息体) */
|
||||
export async function loadAISessionsFromBackend(): Promise<{ id: string; title: string; updatedAt: number }[]> {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (!Service?.AIGetSessions) return [];
|
||||
try {
|
||||
const sessions = await Service.AIGetSessions();
|
||||
if (Array.isArray(sessions)) {
|
||||
useStore.setState({ aiChatSessions: sessions });
|
||||
return sessions;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AI Session] 加载会话列表失败:', e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 从后端加载指定会话的消息数据到内存 */
|
||||
export async function loadAISessionFromBackend(sessionId: string): Promise<boolean> {
|
||||
const state = useStore.getState();
|
||||
// 如果内存中已有消息,跳过重复加载
|
||||
if (state.aiChatHistory[sessionId]?.length > 0) return true;
|
||||
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (!Service?.AILoadSession) return false;
|
||||
try {
|
||||
const result = await Service.AILoadSession(sessionId);
|
||||
if (result?.success) {
|
||||
let messages = result.messages;
|
||||
// messages 可能是 JSON string 或已解析的数组
|
||||
if (typeof messages === 'string') {
|
||||
try { messages = JSON.parse(messages); } catch { messages = []; }
|
||||
}
|
||||
if (Array.isArray(messages)) {
|
||||
useStore.setState((prev) => ({
|
||||
aiChatHistory: { ...prev.aiChatHistory, [sessionId]: messages },
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AI Session] 加载会话消息失败:', sessionId, e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
@@ -668,6 +764,13 @@ export const useStore = create<AppState>()(
|
||||
windowState: 'normal' as const,
|
||||
sidebarWidth: 330,
|
||||
|
||||
// AI 运行状态
|
||||
aiPanelVisible: false,
|
||||
aiChatHistory: {},
|
||||
aiChatSessions: [],
|
||||
aiActiveSessionId: null,
|
||||
aiContexts: {},
|
||||
|
||||
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
|
||||
updateConnection: (conn) => set((state) => ({
|
||||
connections: state.connections.map(c => c.id === conn.id ? conn : c)
|
||||
@@ -947,6 +1050,152 @@ export const useStore = create<AppState>()(
|
||||
setWindowState: (state) => set({ windowState: state }),
|
||||
|
||||
setSidebarWidth: (width) => set({ sidebarWidth: Math.max(200, Math.min(600, Math.trunc(width))) }),
|
||||
|
||||
// AI actions
|
||||
toggleAIPanel: () => set((state) => ({ aiPanelVisible: !state.aiPanelVisible })),
|
||||
setAIPanelVisible: (visible) => set({ aiPanelVisible: visible }),
|
||||
addAIChatMessage: (sessionId, message) => {
|
||||
set((state) => {
|
||||
const history = { ...state.aiChatHistory };
|
||||
const messages = history[sessionId] || [];
|
||||
history[sessionId] = [...messages, message];
|
||||
|
||||
let newSessions = [...state.aiChatSessions];
|
||||
const existingSession = newSessions.find(s => s.id === sessionId);
|
||||
|
||||
if (!existingSession) {
|
||||
let title = message.role === 'user' ? message.content : '新的对话';
|
||||
if (title.length > 20) {
|
||||
title = title.substring(0, 20) + '...';
|
||||
}
|
||||
newSessions.unshift({ id: sessionId, title, updatedAt: Date.now() });
|
||||
} else {
|
||||
newSessions = newSessions.filter(s => s.id !== sessionId);
|
||||
newSessions.unshift({ ...existingSession, updatedAt: Date.now() });
|
||||
}
|
||||
|
||||
return { aiChatHistory: history, aiChatSessions: newSessions };
|
||||
});
|
||||
// 异步持久化到文件(fire-and-forget,防抖由外层控制)
|
||||
_debouncedPersistSession(sessionId);
|
||||
},
|
||||
updateAIChatMessage: (sessionId, messageId, updates) => {
|
||||
set((state) => {
|
||||
const messages = state.aiChatHistory[sessionId];
|
||||
if (!messages) return state;
|
||||
const idx = messages.findIndex(m => m.id === messageId);
|
||||
if (idx < 0) return state;
|
||||
const newMessages = [...messages];
|
||||
newMessages[idx] = { ...newMessages[idx], ...updates };
|
||||
const history = { ...state.aiChatHistory, [sessionId]: newMessages };
|
||||
const isContentOnlyUpdate = Object.keys(updates).length === 1 && 'content' in updates;
|
||||
if (!isContentOnlyUpdate) {
|
||||
let newSessions = [...state.aiChatSessions];
|
||||
const existingSession = newSessions.find(s => s.id === sessionId);
|
||||
if (existingSession) {
|
||||
newSessions = newSessions.filter(s => s.id !== sessionId);
|
||||
newSessions.unshift({ ...existingSession, updatedAt: Date.now() });
|
||||
}
|
||||
return { aiChatHistory: history, aiChatSessions: newSessions };
|
||||
}
|
||||
return { aiChatHistory: history };
|
||||
});
|
||||
// 流式打字高频调用,防抖 2 秒后才写磁盘
|
||||
_debouncedPersistSession(sessionId);
|
||||
},
|
||||
deleteAIChatMessage: (sessionId, messageId) => {
|
||||
set((state) => {
|
||||
const history = { ...state.aiChatHistory };
|
||||
if (history[sessionId]) {
|
||||
history[sessionId] = history[sessionId].filter(m => m.id !== messageId);
|
||||
}
|
||||
return { aiChatHistory: history };
|
||||
});
|
||||
_debouncedPersistSession(sessionId);
|
||||
},
|
||||
truncateAIChatMessages: (sessionId, upToMessageId) => {
|
||||
set((state) => {
|
||||
const history = { ...state.aiChatHistory };
|
||||
const messages = history[sessionId];
|
||||
if (messages) {
|
||||
const idx = messages.findIndex(m => m.id === upToMessageId);
|
||||
if (idx >= 0) {
|
||||
history[sessionId] = messages.slice(0, idx + 1);
|
||||
}
|
||||
}
|
||||
return { aiChatHistory: history };
|
||||
});
|
||||
_debouncedPersistSession(sessionId);
|
||||
},
|
||||
clearAIChatHistory: (sessionId) => {
|
||||
set((state) => {
|
||||
const history = { ...state.aiChatHistory };
|
||||
delete history[sessionId];
|
||||
return { aiChatHistory: history };
|
||||
});
|
||||
_debouncedPersistSession(sessionId);
|
||||
},
|
||||
replaceAIChatHistory: (sessionId, messages) => {
|
||||
set((state) => {
|
||||
const history = { ...state.aiChatHistory };
|
||||
history[sessionId] = messages;
|
||||
return { aiChatHistory: history };
|
||||
});
|
||||
_debouncedPersistSession(sessionId);
|
||||
},
|
||||
deleteAISession: (sessionId) => {
|
||||
set((state) => {
|
||||
const history = { ...state.aiChatHistory };
|
||||
delete history[sessionId];
|
||||
const newSessions = state.aiChatSessions.filter(s => s.id !== sessionId);
|
||||
const newActive = state.aiActiveSessionId === sessionId ? null : state.aiActiveSessionId;
|
||||
return { aiChatHistory: history, aiChatSessions: newSessions, aiActiveSessionId: newActive };
|
||||
});
|
||||
// 删除文件
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
Service?.AIDeleteSession?.(sessionId).catch(() => {});
|
||||
},
|
||||
createNewAISession: () => set(() => {
|
||||
const newId = `session-${Date.now()}`;
|
||||
return { aiActiveSessionId: newId };
|
||||
}),
|
||||
setAIActiveSessionId: (sessionId) => set({ aiActiveSessionId: sessionId }),
|
||||
updateAISessionTitle: (sessionId, title) => {
|
||||
set((state) => {
|
||||
const newSessions = [...state.aiChatSessions];
|
||||
const session = newSessions.find(s => s.id === sessionId);
|
||||
if (session) {
|
||||
session.title = title;
|
||||
}
|
||||
return { aiChatSessions: newSessions };
|
||||
});
|
||||
_debouncedPersistSession(sessionId);
|
||||
},
|
||||
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)
|
||||
@@ -982,6 +1231,10 @@ export const useStore = create<AppState>()(
|
||||
nextState.windowBounds = sanitizeWindowBounds(state.windowBounds);
|
||||
nextState.windowState = sanitizeWindowState(state.windowState);
|
||||
nextState.sidebarWidth = sanitizeSidebarWidth(state.sidebarWidth);
|
||||
|
||||
// 保留原有的 AI 持久化记录,或者为空(版本兼容)
|
||||
nextState.aiChatHistory = (state.aiChatHistory && typeof state.aiChatHistory === 'object') ? state.aiChatHistory : {};
|
||||
nextState.aiChatSessions = Array.isArray(state.aiChatSessions) ? state.aiChatSessions : [];
|
||||
return nextState as AppState;
|
||||
},
|
||||
merge: (persistedState, currentState) => {
|
||||
@@ -1011,6 +1264,10 @@ export const useStore = create<AppState>()(
|
||||
queryOptions: sanitizeQueryOptions(state.queryOptions),
|
||||
shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions),
|
||||
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
|
||||
|
||||
// AI 会话数据不再从 localStorage 恢复,改为从后端文件加载
|
||||
aiChatHistory: {},
|
||||
aiChatSessions: [],
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({
|
||||
@@ -1035,6 +1292,8 @@ export const useStore = create<AppState>()(
|
||||
windowBounds: state.windowBounds,
|
||||
windowState: state.windowState,
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
|
||||
// AI 会话数据已迁移到后端文件持久化(~/.gonavi/sessions/),不再写入 localStorage
|
||||
}), // Don't persist logs
|
||||
}
|
||||
)
|
||||
|
||||
@@ -118,7 +118,7 @@ export interface TriggerDefinition {
|
||||
export interface TabData {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
|
||||
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'redis-monitor' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
|
||||
connectionId: string;
|
||||
dbName?: string;
|
||||
tableName?: string;
|
||||
@@ -183,3 +183,63 @@ export interface StreamEntry {
|
||||
id: string;
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
// --- AI Types ---
|
||||
|
||||
export type AIProviderType = 'openai' | 'anthropic' | 'gemini' | 'custom';
|
||||
export type AISafetyLevel = 'readonly' | 'readwrite' | 'full';
|
||||
export type AIContextLevel = 'schema_only' | 'with_samples' | 'with_results';
|
||||
|
||||
export interface AIContextItem {
|
||||
dbName: string;
|
||||
tableName: string;
|
||||
ddl: string;
|
||||
}
|
||||
|
||||
export interface AIProviderConfig {
|
||||
id: string;
|
||||
type: AIProviderType;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
models?: string[];
|
||||
apiFormat?: string; // custom 专用: openai | anthropic | gemini | claude-cli
|
||||
headers?: Record<string, string>;
|
||||
maxTokens: number;
|
||||
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' | '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 {
|
||||
allowed: boolean;
|
||||
operationType: 'query' | 'dml' | 'ddl' | 'other';
|
||||
requiresConfirm: boolean;
|
||||
warningMessage?: string;
|
||||
}
|
||||
|
||||
33
frontend/src/utils/aiComposerNotice.test.ts
Normal file
33
frontend/src/utils/aiComposerNotice.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildModelFetchFailedNotice,
|
||||
buildMissingModelNotice,
|
||||
buildMissingProviderNotice,
|
||||
} from './aiComposerNotice';
|
||||
|
||||
describe('ai composer notice helpers', () => {
|
||||
it('builds a compact notice for missing provider', () => {
|
||||
expect(buildMissingProviderNotice()).toEqual({
|
||||
tone: 'warning',
|
||||
title: '还没有可用供应商',
|
||||
description: '先在 AI 设置里添加并启用一个模型供应商。',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a compact notice for missing model selection', () => {
|
||||
expect(buildMissingModelNotice()).toEqual({
|
||||
tone: 'warning',
|
||||
title: '先选择一个模型',
|
||||
description: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a readable inline notice for model fetch failures', () => {
|
||||
expect(buildModelFetchFailedNotice('当前接口未返回可用模型')).toEqual({
|
||||
tone: 'error',
|
||||
title: '模型列表加载失败',
|
||||
description: '当前接口未返回可用模型',
|
||||
});
|
||||
});
|
||||
});
|
||||
27
frontend/src/utils/aiComposerNotice.ts
Normal file
27
frontend/src/utils/aiComposerNotice.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type AIComposerNoticeTone = 'warning' | 'error';
|
||||
|
||||
export interface AIComposerNotice {
|
||||
tone: AIComposerNoticeTone;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const defaultModelFetchFailedDescription = '请检查供应商入口、API Key 或账号权限,然后重新打开模型下拉。';
|
||||
|
||||
export const buildMissingProviderNotice = (): AIComposerNotice => ({
|
||||
tone: 'warning',
|
||||
title: '还没有可用供应商',
|
||||
description: '先在 AI 设置里添加并启用一个模型供应商。',
|
||||
});
|
||||
|
||||
export const buildMissingModelNotice = (): AIComposerNotice => ({
|
||||
tone: 'warning',
|
||||
title: '先选择一个模型',
|
||||
description: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。',
|
||||
});
|
||||
|
||||
export const buildModelFetchFailedNotice = (error?: string): AIComposerNotice => ({
|
||||
tone: 'error',
|
||||
title: '模型列表加载失败',
|
||||
description: String(error || '').trim() || defaultModelFetchFailedDescription,
|
||||
});
|
||||
71
frontend/src/utils/aiEntryLayout.test.ts
Normal file
71
frontend/src/utils/aiEntryLayout.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
SIDEBAR_UTILITY_ITEM_KEYS,
|
||||
resolveAIEntryPlacement,
|
||||
resolveAIEdgeHandleAttachment,
|
||||
resolveAIEdgeHandleDockStyle,
|
||||
resolveAIEdgeHandleStyle,
|
||||
} from './aiEntryLayout';
|
||||
|
||||
describe('ai entry layout', () => {
|
||||
it('keeps the sidebar utility group free of the AI entry', () => {
|
||||
expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'proxy', 'theme', 'about']);
|
||||
});
|
||||
|
||||
it('anchors the AI entry to the content edge', () => {
|
||||
expect(resolveAIEntryPlacement()).toBe('content-edge');
|
||||
});
|
||||
|
||||
it('attaches the closed handle to the content shell', () => {
|
||||
expect(resolveAIEdgeHandleAttachment(false)).toBe('content-shell');
|
||||
});
|
||||
|
||||
it('attaches the open handle to the panel shell', () => {
|
||||
expect(resolveAIEdgeHandleAttachment(true)).toBe('panel-shell');
|
||||
});
|
||||
|
||||
it('keeps the closed handle docked on the content edge', () => {
|
||||
expect(resolveAIEdgeHandleDockStyle('content-shell')).toMatchObject({
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 0,
|
||||
zIndex: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the open handle outside the panel shell to avoid header overlap', () => {
|
||||
expect(resolveAIEdgeHandleDockStyle('panel-shell')).toMatchObject({
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: '100%',
|
||||
zIndex: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the attached active appearance when the AI panel is open', () => {
|
||||
const style = resolveAIEdgeHandleStyle({
|
||||
darkMode: true,
|
||||
aiPanelVisible: true,
|
||||
effectiveUiScale: 1,
|
||||
});
|
||||
|
||||
expect(style.color).toBe('#ffd666');
|
||||
expect(style.background).toBe('rgba(255,214,102,0.12)');
|
||||
expect(style.borderRadius).toBe('10px 0 0 10px');
|
||||
expect(style.borderRight).toBe('none');
|
||||
expect(style.height).toBe(24);
|
||||
});
|
||||
|
||||
it('uses the subdued attached appearance when the AI panel is closed', () => {
|
||||
const style = resolveAIEdgeHandleStyle({
|
||||
darkMode: false,
|
||||
aiPanelVisible: false,
|
||||
effectiveUiScale: 1,
|
||||
});
|
||||
|
||||
expect(style.color).toBe('rgba(22,32,51,0.82)');
|
||||
expect(style.background).toBe('rgba(15,23,42,0.04)');
|
||||
expect(style.paddingInline).toBe(8);
|
||||
expect(style.borderRadius).toBe('10px 0 0 10px');
|
||||
});
|
||||
});
|
||||
59
frontend/src/utils/aiEntryLayout.ts
Normal file
59
frontend/src/utils/aiEntryLayout.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'proxy', 'theme', 'about'] as const;
|
||||
|
||||
export type AIEntryPlacement = 'content-edge';
|
||||
export type AIEdgeHandleAttachment = 'content-shell' | 'panel-shell';
|
||||
|
||||
export interface ResolveAIEdgeHandleStyleInput {
|
||||
darkMode: boolean;
|
||||
aiPanelVisible: boolean;
|
||||
effectiveUiScale: number;
|
||||
}
|
||||
|
||||
export const resolveAIEntryPlacement = (): AIEntryPlacement => 'content-edge';
|
||||
|
||||
export const resolveAIEdgeHandleAttachment = (
|
||||
aiPanelVisible: boolean,
|
||||
): AIEdgeHandleAttachment => (aiPanelVisible ? 'panel-shell' : 'content-shell');
|
||||
|
||||
export const resolveAIEdgeHandleDockStyle = (
|
||||
attachment: AIEdgeHandleAttachment,
|
||||
): CSSProperties => ({
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: attachment === 'panel-shell' ? '100%' : 0,
|
||||
zIndex: 12,
|
||||
});
|
||||
|
||||
export const resolveAIEdgeHandleStyle = ({
|
||||
darkMode,
|
||||
aiPanelVisible,
|
||||
effectiveUiScale,
|
||||
}: ResolveAIEdgeHandleStyleInput): CSSProperties => {
|
||||
const inactiveColor = darkMode ? 'rgba(255,255,255,0.86)' : 'rgba(22,32,51,0.82)';
|
||||
const inactiveBackground = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(15,23,42,0.04)';
|
||||
const inactiveBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)';
|
||||
|
||||
return {
|
||||
height: Math.max(24, Math.round(24 * effectiveUiScale)),
|
||||
paddingInline: Math.max(8, Math.round(8 * effectiveUiScale)),
|
||||
borderRadius: '10px 0 0 10px',
|
||||
border: `1px solid ${aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.22)' : 'rgba(24,144,255,0.18)') : inactiveBorder}`,
|
||||
borderRight: 'none',
|
||||
background: aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.10)') : inactiveBackground,
|
||||
color: aiPanelVisible ? (darkMode ? '#ffd666' : '#1677ff') : inactiveColor,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: Math.max(4, Math.round(4 * effectiveUiScale)),
|
||||
fontSize: Math.max(12, Math.round(12 * effectiveUiScale)),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
boxShadow: 'none',
|
||||
backdropFilter: 'none',
|
||||
WebkitBackdropFilter: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
};
|
||||
};
|
||||
185
frontend/src/utils/aiProviderPresets.test.ts
Normal file
185
frontend/src/utils/aiProviderPresets.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AIProviderType } from '../types';
|
||||
import {
|
||||
LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL,
|
||||
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
|
||||
QWEN_BAILIAN_MODELS_BASE_URL,
|
||||
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||
QWEN_CODING_PLAN_MODELS,
|
||||
matchQwenPresetKey,
|
||||
resolvePresetBaseURL,
|
||||
resolvePresetModelSelection,
|
||||
resolvePresetTransport,
|
||||
resolveProviderPresetKey,
|
||||
} from './aiProviderPresets';
|
||||
|
||||
type PresetMatcher = {
|
||||
key: string;
|
||||
backendType: AIProviderType;
|
||||
defaultBaseUrl: string;
|
||||
fixedApiFormat?: string;
|
||||
};
|
||||
|
||||
const PRESETS: PresetMatcher[] = [
|
||||
{ key: 'openai', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1' },
|
||||
{ key: 'qwen-bailian', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL },
|
||||
{
|
||||
key: 'qwen-coding-plan',
|
||||
backendType: 'custom',
|
||||
defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||
fixedApiFormat: 'claude-cli',
|
||||
},
|
||||
{ key: 'custom', backendType: 'custom', defaultBaseUrl: '' },
|
||||
];
|
||||
|
||||
describe('ai provider preset helpers', () => {
|
||||
it('maps legacy Bailian compatible-mode URL back to the Bailian preset', () => {
|
||||
expect(matchQwenPresetKey({
|
||||
type: 'openai',
|
||||
baseUrl: QWEN_BAILIAN_MODELS_BASE_URL,
|
||||
})).toBe('qwen-bailian');
|
||||
});
|
||||
|
||||
it('maps Coding Plan Claude CLI config back to the dedicated Coding Plan preset', () => {
|
||||
expect(matchQwenPresetKey({
|
||||
type: 'custom',
|
||||
apiFormat: 'claude-cli',
|
||||
baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||
})).toBe('qwen-coding-plan');
|
||||
});
|
||||
|
||||
it('maps legacy Coding Plan OpenAI config back to the dedicated Coding Plan preset', () => {
|
||||
expect(matchQwenPresetKey({
|
||||
type: 'openai',
|
||||
baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL,
|
||||
})).toBe('qwen-coding-plan');
|
||||
});
|
||||
|
||||
it('does not treat a custom OpenAI endpoint as the built-in Coding Plan preset', () => {
|
||||
expect(matchQwenPresetKey({
|
||||
type: 'custom',
|
||||
apiFormat: 'openai',
|
||||
baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('does not keep a baked-in model list for the Coding Plan preset', () => {
|
||||
expect(QWEN_CODING_PLAN_MODELS).toEqual([
|
||||
'qwen3.5-plus',
|
||||
'kimi-k2.5',
|
||||
'glm-5',
|
||||
'MiniMax-M2.5',
|
||||
'qwen3-max-2026-01-23',
|
||||
'qwen3-coder-next',
|
||||
'qwen3-coder-plus',
|
||||
'glm-4.7',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps built-in preset model empty when the preset intentionally requires an explicit selection', () => {
|
||||
expect(resolvePresetModelSelection({
|
||||
presetKey: 'qwen-coding-plan',
|
||||
presetDefaultModel: '',
|
||||
presetModels: QWEN_CODING_PLAN_MODELS,
|
||||
valuesModel: '',
|
||||
customModels: [],
|
||||
})).toEqual({
|
||||
model: '',
|
||||
models: QWEN_CODING_PLAN_MODELS,
|
||||
});
|
||||
});
|
||||
|
||||
it('still falls back to the first configured model for custom-like presets', () => {
|
||||
expect(resolvePresetModelSelection({
|
||||
presetKey: 'custom',
|
||||
presetDefaultModel: '',
|
||||
presetModels: [],
|
||||
valuesModel: '',
|
||||
customModels: ['foo-model', 'bar-model'],
|
||||
})).toEqual({
|
||||
model: 'foo-model',
|
||||
models: ['foo-model', 'bar-model'],
|
||||
});
|
||||
});
|
||||
|
||||
it('forces built-in presets back to their standard base URL when saving or testing', () => {
|
||||
expect(resolvePresetBaseURL({
|
||||
presetKey: 'qwen-bailian',
|
||||
presetDefaultBaseUrl: 'https://dashscope.aliyuncs.com/apps/anthropic',
|
||||
valuesBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
})).toBe('https://dashscope.aliyuncs.com/apps/anthropic');
|
||||
});
|
||||
|
||||
it('keeps the user-entered base URL for custom-like presets', () => {
|
||||
expect(resolvePresetBaseURL({
|
||||
presetKey: 'custom',
|
||||
presetDefaultBaseUrl: '',
|
||||
valuesBaseUrl: 'https://example-proxy.internal/v1',
|
||||
})).toBe('https://example-proxy.internal/v1');
|
||||
});
|
||||
|
||||
it('forces qwen coding plan to save as custom plus claude-cli', () => {
|
||||
expect(resolvePresetTransport({
|
||||
presetBackendType: 'custom',
|
||||
presetFixedApiFormat: 'claude-cli',
|
||||
valuesApiFormat: 'anthropic',
|
||||
})).toEqual({
|
||||
type: 'custom',
|
||||
apiFormat: 'claude-cli',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps custom preset transport editable', () => {
|
||||
expect(resolvePresetTransport({
|
||||
presetBackendType: 'custom',
|
||||
valuesApiFormat: 'gemini',
|
||||
})).toEqual({
|
||||
type: 'custom',
|
||||
apiFormat: 'gemini',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProviderPresetKey', () => {
|
||||
it('不会把自定义 OpenAI 端点误识别成千问 Coding Plan', () => {
|
||||
const key = resolveProviderPresetKey(
|
||||
{
|
||||
type: 'custom',
|
||||
apiFormat: 'openai',
|
||||
baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL,
|
||||
},
|
||||
PRESETS,
|
||||
'custom',
|
||||
);
|
||||
|
||||
expect(key).toBe('custom');
|
||||
});
|
||||
|
||||
it('仍然能识别当前内置的千问 Coding Plan 预设', () => {
|
||||
const key = resolveProviderPresetKey(
|
||||
{
|
||||
type: 'custom',
|
||||
apiFormat: 'claude-cli',
|
||||
baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||
},
|
||||
PRESETS,
|
||||
'custom',
|
||||
);
|
||||
|
||||
expect(key).toBe('qwen-coding-plan');
|
||||
});
|
||||
|
||||
it('仍然能识别当前内置的千问百炼预设', () => {
|
||||
const key = resolveProviderPresetKey(
|
||||
{
|
||||
type: 'anthropic',
|
||||
apiFormat: undefined,
|
||||
baseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL,
|
||||
},
|
||||
PRESETS,
|
||||
'custom',
|
||||
);
|
||||
|
||||
expect(key).toBe('qwen-bailian');
|
||||
});
|
||||
});
|
||||
216
frontend/src/utils/aiProviderPresets.ts
Normal file
216
frontend/src/utils/aiProviderPresets.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import type { AIProviderConfig, AIProviderType } from '../types';
|
||||
|
||||
export const LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
export const LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL = 'https://coding.dashscope.aliyuncs.com/v1';
|
||||
export const QWEN_BAILIAN_ANTHROPIC_BASE_URL = 'https://dashscope.aliyuncs.com/apps/anthropic';
|
||||
export const QWEN_CODING_PLAN_ANTHROPIC_BASE_URL = 'https://coding.dashscope.aliyuncs.com/apps/anthropic';
|
||||
export const QWEN_BAILIAN_MODELS_BASE_URL = LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL;
|
||||
|
||||
export const QWEN_CODING_PLAN_MODELS = [
|
||||
'qwen3.5-plus',
|
||||
'kimi-k2.5',
|
||||
'glm-5',
|
||||
'MiniMax-M2.5',
|
||||
'qwen3-max-2026-01-23',
|
||||
'qwen3-coder-next',
|
||||
'qwen3-coder-plus',
|
||||
'glm-4.7',
|
||||
];
|
||||
|
||||
const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama']);
|
||||
|
||||
export interface ResolvePresetModelSelectionInput {
|
||||
presetKey: string;
|
||||
presetDefaultModel: string;
|
||||
presetModels: string[];
|
||||
valuesModel?: string;
|
||||
customModels?: string[];
|
||||
}
|
||||
|
||||
export interface ResolvePresetModelSelectionResult {
|
||||
model: string;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
export interface ResolvePresetBaseURLInput {
|
||||
presetKey: string;
|
||||
presetDefaultBaseUrl: string;
|
||||
valuesBaseUrl?: string;
|
||||
}
|
||||
|
||||
export interface ResolvePresetTransportInput {
|
||||
presetBackendType: AIProviderType;
|
||||
presetFixedApiFormat?: string;
|
||||
valuesApiFormat?: string;
|
||||
}
|
||||
|
||||
export interface ResolvePresetTransportResult {
|
||||
type: AIProviderType;
|
||||
apiFormat?: string;
|
||||
}
|
||||
|
||||
export interface ProviderPresetMatcher {
|
||||
key: string;
|
||||
backendType: AIProviderType;
|
||||
defaultBaseUrl: string;
|
||||
fixedApiFormat?: string;
|
||||
}
|
||||
|
||||
export const getProviderHostname = (raw?: string): string => {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
return new URL(raw).hostname.toLowerCase();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const getProviderFingerprint = (raw?: string): string => {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
const normalizedPath = url.pathname.replace(/\/+$/, '').toLowerCase();
|
||||
return `${url.hostname.toLowerCase()}${normalizedPath}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const matchQwenPresetKey = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>): string | null => {
|
||||
const fingerprint = getProviderFingerprint(provider.baseUrl);
|
||||
|
||||
if (
|
||||
fingerprint !== ''
|
||||
&& fingerprint === getProviderFingerprint(QWEN_BAILIAN_ANTHROPIC_BASE_URL)
|
||||
&& provider.type === 'anthropic'
|
||||
) {
|
||||
return 'qwen-bailian';
|
||||
}
|
||||
|
||||
if (
|
||||
fingerprint !== ''
|
||||
&& fingerprint === getProviderFingerprint(LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL)
|
||||
&& provider.type === 'openai'
|
||||
) {
|
||||
return 'qwen-bailian';
|
||||
}
|
||||
|
||||
if (
|
||||
fingerprint !== ''
|
||||
&& fingerprint === getProviderFingerprint(QWEN_CODING_PLAN_ANTHROPIC_BASE_URL)
|
||||
&& provider.type === 'custom'
|
||||
&& provider.apiFormat === 'claude-cli'
|
||||
) {
|
||||
return 'qwen-coding-plan';
|
||||
}
|
||||
|
||||
if (
|
||||
fingerprint !== ''
|
||||
&& fingerprint === getProviderFingerprint(LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL)
|
||||
&& provider.type === 'openai'
|
||||
) {
|
||||
return 'qwen-coding-plan';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolveProviderPresetKey = (
|
||||
provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>,
|
||||
presets: ProviderPresetMatcher[],
|
||||
fallbackKey = 'custom',
|
||||
): string => {
|
||||
const qwenPresetKey = matchQwenPresetKey(provider);
|
||||
if (qwenPresetKey) {
|
||||
return qwenPresetKey;
|
||||
}
|
||||
|
||||
const fingerprint = getProviderFingerprint(provider.baseUrl);
|
||||
const exactPreset = presets.find((preset) =>
|
||||
preset.backendType === provider.type
|
||||
&& fingerprint !== ''
|
||||
&& fingerprint === getProviderFingerprint(preset.defaultBaseUrl)
|
||||
&& (!preset.fixedApiFormat || preset.fixedApiFormat === provider.apiFormat),
|
||||
);
|
||||
if (exactPreset) {
|
||||
return exactPreset.key;
|
||||
}
|
||||
|
||||
// custom 供应商必须保守处理,避免仅凭 host 错误吞掉用户显式保存的自定义配置。
|
||||
if (provider.type === 'custom') {
|
||||
return fallbackKey;
|
||||
}
|
||||
|
||||
const host = getProviderHostname(provider.baseUrl);
|
||||
if (provider.type === 'anthropic' && host.endsWith('moonshot.cn')) {
|
||||
const moonshotPreset = presets.find((preset) => preset.key === 'moonshot');
|
||||
if (moonshotPreset) {
|
||||
return moonshotPreset.key;
|
||||
}
|
||||
}
|
||||
|
||||
const hostPreset = presets.find((preset) =>
|
||||
preset.backendType === provider.type
|
||||
&& host !== ''
|
||||
&& host === getProviderHostname(preset.defaultBaseUrl)
|
||||
&& (!preset.fixedApiFormat || preset.fixedApiFormat === provider.apiFormat),
|
||||
);
|
||||
if (hostPreset) {
|
||||
return hostPreset.key;
|
||||
}
|
||||
|
||||
const typePreset = presets.find((preset) => preset.backendType === provider.type && !preset.fixedApiFormat);
|
||||
return typePreset?.key || fallbackKey;
|
||||
};
|
||||
|
||||
export const resolvePresetModelSelection = ({
|
||||
presetKey,
|
||||
presetDefaultModel,
|
||||
presetModels,
|
||||
valuesModel,
|
||||
customModels,
|
||||
}: ResolvePresetModelSelectionInput): ResolvePresetModelSelectionResult => {
|
||||
const isCustomLike = CUSTOM_LIKE_PRESET_KEYS.has(presetKey);
|
||||
const resolvedModels = isCustomLike ? (customModels || []) : presetModels;
|
||||
const fallbackModel = resolvedModels.length > 0 ? resolvedModels[0] : '';
|
||||
return {
|
||||
models: resolvedModels,
|
||||
model: isCustomLike ? (valuesModel || fallbackModel) : (valuesModel || presetDefaultModel),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolvePresetBaseURL = ({
|
||||
presetKey,
|
||||
presetDefaultBaseUrl,
|
||||
valuesBaseUrl,
|
||||
}: ResolvePresetBaseURLInput): string => {
|
||||
if (CUSTOM_LIKE_PRESET_KEYS.has(presetKey)) {
|
||||
return valuesBaseUrl || presetDefaultBaseUrl;
|
||||
}
|
||||
return presetDefaultBaseUrl;
|
||||
};
|
||||
|
||||
export const resolvePresetTransport = ({
|
||||
presetBackendType,
|
||||
presetFixedApiFormat,
|
||||
valuesApiFormat,
|
||||
}: ResolvePresetTransportInput): ResolvePresetTransportResult => {
|
||||
if (presetFixedApiFormat) {
|
||||
return {
|
||||
type: presetBackendType,
|
||||
apiFormat: presetFixedApiFormat,
|
||||
};
|
||||
}
|
||||
|
||||
if (presetBackendType === 'custom') {
|
||||
return {
|
||||
type: presetBackendType,
|
||||
apiFormat: valuesApiFormat || 'openai',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: presetBackendType,
|
||||
apiFormat: undefined,
|
||||
};
|
||||
};
|
||||
56
frontend/src/utils/aiSettingsPresetLayout.test.ts
Normal file
56
frontend/src/utils/aiSettingsPresetLayout.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
PROVIDER_PRESET_CARD_BASE_STYLE,
|
||||
PROVIDER_PRESET_CARD_CONTENT_STYLE,
|
||||
PROVIDER_PRESET_CARD_DESCRIPTION_STYLE,
|
||||
PROVIDER_PRESET_GRID_STYLE,
|
||||
PROVIDER_PRESET_CARD_TITLE_STYLE,
|
||||
} from './aiSettingsPresetLayout';
|
||||
|
||||
describe('ai settings preset layout', () => {
|
||||
it('uses a fixed grid auto row height so provider bubbles stay visually consistent across rows', () => {
|
||||
expect(PROVIDER_PRESET_GRID_STYLE).toMatchObject({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||
gap: 6,
|
||||
gridAutoRows: '96px',
|
||||
alignItems: 'stretch',
|
||||
});
|
||||
});
|
||||
|
||||
it('stretches each provider card to fill the row height', () => {
|
||||
expect(PROVIDER_PRESET_CARD_BASE_STYLE).toMatchObject({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
height: '100%',
|
||||
minHeight: '96px',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the text column compact instead of pinning the description to the bottom', () => {
|
||||
expect(PROVIDER_PRESET_CARD_CONTENT_STYLE).toMatchObject({
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
expect(PROVIDER_PRESET_CARD_DESCRIPTION_STYLE).toMatchObject({
|
||||
marginTop: 4,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
expect(PROVIDER_PRESET_CARD_TITLE_STYLE).toMatchObject({
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
});
|
||||
});
|
||||
47
frontend/src/utils/aiSettingsPresetLayout.ts
Normal file
47
frontend/src/utils/aiSettingsPresetLayout.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const PROVIDER_PRESET_CARD_HEIGHT = 96;
|
||||
|
||||
export const PROVIDER_PRESET_GRID_STYLE: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||
gap: 6,
|
||||
gridAutoRows: `${PROVIDER_PRESET_CARD_HEIGHT}px`,
|
||||
alignItems: 'stretch',
|
||||
};
|
||||
|
||||
export const PROVIDER_PRESET_CARD_BASE_STYLE: CSSProperties = {
|
||||
padding: '12px 14px',
|
||||
borderRadius: 12,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
height: '100%',
|
||||
minHeight: `${PROVIDER_PRESET_CARD_HEIGHT}px`,
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
export const PROVIDER_PRESET_CARD_CONTENT_STYLE: CSSProperties = {
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
};
|
||||
|
||||
export const PROVIDER_PRESET_CARD_DESCRIPTION_STYLE: CSSProperties = {
|
||||
marginTop: 4,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
export const PROVIDER_PRESET_CARD_TITLE_STYLE: CSSProperties = {
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
23
frontend/src/utils/appearance.test.ts
Normal file
23
frontend/src/utils/appearance.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from './appearance';
|
||||
|
||||
describe('appearance helpers', () => {
|
||||
it('falls back to opaque non-blurred appearance when disabled', () => {
|
||||
expect(resolveAppearanceValues({ enabled: false, opacity: 0.3, blur: 12 })).toEqual({ opacity: 1, blur: 0 });
|
||||
});
|
||||
|
||||
it('preserves configured values when appearance is enabled', () => {
|
||||
expect(resolveAppearanceValues({ enabled: true, opacity: 0.72, blur: 9 })).toEqual({ opacity: 0.72, blur: 9 });
|
||||
});
|
||||
|
||||
it('caps opacity at full opacity upper bound', () => {
|
||||
expect(normalizeOpacityForPlatform(2)).toBe(1);
|
||||
});
|
||||
|
||||
it('never returns negative blur and formats blur filter correctly', () => {
|
||||
expect(normalizeBlurForPlatform(-4)).toBe(0);
|
||||
expect(blurToFilter(0)).toBeUndefined();
|
||||
expect(blurToFilter(8)).toBe('blur(8px)');
|
||||
});
|
||||
});
|
||||
28
frontend/src/utils/approximateTableCount.test.ts
Normal file
28
frontend/src/utils/approximateTableCount.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildOracleApproximateTotalSql,
|
||||
parseApproximateTableCountRow,
|
||||
resolveApproximateTableCountStrategy,
|
||||
} from './approximateTableCount';
|
||||
|
||||
describe('approximateTableCount', () => {
|
||||
it('uses oracle metadata approximate total only for unfiltered full-table preview', () => {
|
||||
expect(resolveApproximateTableCountStrategy({ dbType: 'oracle', whereSQL: '' })).toBe('oracle-num-rows');
|
||||
expect(resolveApproximateTableCountStrategy({ dbType: 'oracle', whereSQL: 'WHERE id = 1' })).toBe('none');
|
||||
});
|
||||
|
||||
it('keeps duckdb approximate count on unfiltered previews', () => {
|
||||
expect(resolveApproximateTableCountStrategy({ dbType: 'duckdb', whereSQL: '' })).toBe('duckdb-estimated-size');
|
||||
});
|
||||
|
||||
it('builds Oracle approx count SQL from owner and table name', () => {
|
||||
expect(buildOracleApproximateTotalSql({ dbName: 'HR', tableName: 'HR.EMPLOYEES' })).toContain("owner = 'HR'");
|
||||
expect(buildOracleApproximateTotalSql({ dbName: 'HR', tableName: 'HR.EMPLOYEES' })).toContain("table_name = 'EMPLOYEES'");
|
||||
});
|
||||
|
||||
it('parses approximate total rows using preferred keys', () => {
|
||||
expect(parseApproximateTableCountRow({ NUM_ROWS: '1234' }, ['num_rows'])).toBe(1234);
|
||||
expect(parseApproximateTableCountRow({ approx_total: 5678 }, ['approx_total'])).toBe(5678);
|
||||
});
|
||||
});
|
||||
106
frontend/src/utils/approximateTableCount.ts
Normal file
106
frontend/src/utils/approximateTableCount.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
export type ApproximateTableCountStrategy = 'none' | 'duckdb-estimated-size' | 'oracle-num-rows';
|
||||
|
||||
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
|
||||
const toNonNegativeFiniteNumber = (value: unknown): number | null => {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return value >= 0n && value <= MAX_SAFE_BIGINT ? Number(value) : null;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const text = value.trim();
|
||||
if (!text) return null;
|
||||
if (/^[+-]?\d+$/.test(text)) {
|
||||
try {
|
||||
const parsed = BigInt(text);
|
||||
return parsed >= 0n && parsed <= MAX_SAFE_BIGINT ? Number(parsed) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const parsed = Number(text);
|
||||
return Number.isFinite(parsed) && parsed >= 0 && parsed <= Number.MAX_SAFE_INTEGER ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const stripOuterQuotes = (value: string): string => {
|
||||
const trimmed = String(value || '').trim();
|
||||
if (trimmed.length < 2) return trimmed;
|
||||
const first = trimmed[0];
|
||||
const last = trimmed[trimmed.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === '`' && last === '`') || (first === '[' && last === ']')) {
|
||||
return trimmed.slice(1, -1).trim();
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const escapeSQLLiteral = (value: string): string => String(value || '').replace(/'/g, "''");
|
||||
|
||||
const resolveOracleOwnerAndTable = (params: { dbName: string; tableName: string }) => {
|
||||
const rawTable = String(params.tableName || '').trim();
|
||||
const parts = rawTable.split('.').map(stripOuterQuotes).filter(Boolean);
|
||||
const tableName = String(parts[parts.length - 1] || rawTable || '').trim();
|
||||
const ownerCandidate = parts.length >= 2 ? parts[parts.length - 2] : String(params.dbName || '').trim();
|
||||
return {
|
||||
owner: ownerCandidate.toUpperCase(),
|
||||
tableName: tableName.toUpperCase(),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveApproximateTableCountStrategy = (params: {
|
||||
dbType: string;
|
||||
whereSQL: string;
|
||||
}): ApproximateTableCountStrategy => {
|
||||
const dbType = String(params.dbType || '').trim().toLowerCase();
|
||||
const whereSQL = String(params.whereSQL || '').trim();
|
||||
if (whereSQL) return 'none';
|
||||
if (dbType === 'duckdb') return 'duckdb-estimated-size';
|
||||
if (dbType === 'oracle') return 'oracle-num-rows';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
export const buildOracleApproximateTotalSql = (params: { dbName: string; tableName: string }): string => {
|
||||
const { owner, tableName } = resolveOracleOwnerAndTable(params);
|
||||
const escapedTable = escapeSQLLiteral(tableName);
|
||||
if (!owner) {
|
||||
return `SELECT num_rows AS approx_total FROM user_tables WHERE table_name = '${escapedTable}' AND ROWNUM = 1`;
|
||||
}
|
||||
return `SELECT num_rows AS approx_total FROM all_tables WHERE owner = '${escapeSQLLiteral(owner)}' AND table_name = '${escapedTable}' AND ROWNUM = 1`;
|
||||
};
|
||||
|
||||
export const parseApproximateTableCountRow = (
|
||||
row: unknown,
|
||||
preferredKeys: string[] = ['approx_total', 'estimated_size', 'estimated_rows', 'row_count', 'num_rows', 'count', 'total'],
|
||||
): number | null => {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const entries = Object.entries(row as Record<string, unknown>);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
for (const preferredKey of preferredKeys) {
|
||||
const normalizedPreferred = String(preferredKey || '').trim().toLowerCase();
|
||||
for (const [key, value] of entries) {
|
||||
if (String(key || '').trim().toLowerCase() !== normalizedPreferred) continue;
|
||||
const parsed = toNonNegativeFiniteNumber(value);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const normalizedKey = String(key || '').trim().toLowerCase();
|
||||
if (!normalizedKey.includes('estimate') && !normalizedKey.includes('row') && !normalizedKey.includes('count') && !normalizedKey.includes('total')) {
|
||||
continue;
|
||||
}
|
||||
const parsed = toNonNegativeFiniteNumber(value);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
|
||||
for (const [, value] of entries) {
|
||||
const parsed = toNonNegativeFiniteNumber(value);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
57
frontend/src/utils/dataGridPagination.test.ts
Normal file
57
frontend/src/utils/dataGridPagination.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
resolvePaginationPageText,
|
||||
resolvePaginationSummaryText,
|
||||
resolvePaginationTotalForControl,
|
||||
} from './dataGridPagination';
|
||||
|
||||
describe('dataGridPagination', () => {
|
||||
it('shows Oracle approximate total in summary but not in total-page chip', () => {
|
||||
const pagination = {
|
||||
current: 3,
|
||||
pageSize: 100,
|
||||
total: 301,
|
||||
totalKnown: false,
|
||||
totalApprox: true,
|
||||
approximateTotal: 1832451,
|
||||
};
|
||||
|
||||
expect(resolvePaginationSummaryText({
|
||||
pagination,
|
||||
prefersManualTotalCount: true,
|
||||
supportsApproximateTableCount: true,
|
||||
})).toContain('约 1832451 条');
|
||||
|
||||
expect(resolvePaginationPageText({
|
||||
pagination,
|
||||
supportsApproximateTotalPages: false,
|
||||
})).toBe('第 3 页');
|
||||
|
||||
expect(resolvePaginationTotalForControl({
|
||||
pagination,
|
||||
supportsApproximateTotalPages: false,
|
||||
})).toBe(301);
|
||||
});
|
||||
|
||||
it('still allows DuckDB to use approximate totals for page counts', () => {
|
||||
const pagination = {
|
||||
current: 2,
|
||||
pageSize: 100,
|
||||
total: 201,
|
||||
totalKnown: false,
|
||||
totalApprox: true,
|
||||
approximateTotal: 1000,
|
||||
};
|
||||
|
||||
expect(resolvePaginationPageText({
|
||||
pagination,
|
||||
supportsApproximateTotalPages: true,
|
||||
})).toBe('第 2 / 10 页');
|
||||
|
||||
expect(resolvePaginationTotalForControl({
|
||||
pagination,
|
||||
supportsApproximateTotalPages: true,
|
||||
})).toBe(1000);
|
||||
});
|
||||
});
|
||||
92
frontend/src/utils/dataGridPagination.ts
Normal file
92
frontend/src/utils/dataGridPagination.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export type PaginationStateLike = {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalKnown?: boolean;
|
||||
totalApprox?: boolean;
|
||||
approximateTotal?: number;
|
||||
totalCountLoading?: boolean;
|
||||
totalCountCancelled?: boolean;
|
||||
};
|
||||
|
||||
const toFiniteNonNegativeNumber = (value: unknown): number | null => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const resolveApproximateTotal = (pagination: PaginationStateLike): number | null => {
|
||||
if (!pagination.totalApprox) return null;
|
||||
const approximateTotal = toFiniteNonNegativeNumber(pagination.approximateTotal);
|
||||
return approximateTotal !== null && approximateTotal > 0 ? approximateTotal : null;
|
||||
};
|
||||
|
||||
const resolveCurrentCount = (pagination: PaginationStateLike): number => {
|
||||
const total = toFiniteNonNegativeNumber(pagination.total) ?? 0;
|
||||
const rangeStart = Math.max(0, (pagination.current - 1) * pagination.pageSize + (total > 0 ? 1 : 0));
|
||||
const hasValidRange = total > 0 && rangeStart > 0;
|
||||
if (!hasValidRange) return 0;
|
||||
const rangeEnd = Math.min(total, rangeStart + pagination.pageSize - 1);
|
||||
return Math.max(0, rangeEnd - rangeStart + 1);
|
||||
};
|
||||
|
||||
export const resolvePaginationSummaryText = (params: {
|
||||
pagination: PaginationStateLike;
|
||||
prefersManualTotalCount: boolean;
|
||||
supportsApproximateTableCount: boolean;
|
||||
}): string => {
|
||||
const { pagination, prefersManualTotalCount, supportsApproximateTableCount } = params;
|
||||
const currentCount = resolveCurrentCount(pagination);
|
||||
const total = toFiniteNonNegativeNumber(pagination.total) ?? 0;
|
||||
const approximateTotal = resolveApproximateTotal(pagination);
|
||||
|
||||
if (pagination.totalKnown === false) {
|
||||
if (prefersManualTotalCount) {
|
||||
if (pagination.totalCountLoading) return `当前 ${currentCount} 条 / 正在统计精确总数…`;
|
||||
if (supportsApproximateTableCount && approximateTotal !== null) return `当前 ${currentCount} 条 / 约 ${approximateTotal} 条`;
|
||||
if (pagination.totalCountCancelled) return `当前 ${currentCount} 条 / 已取消统计`;
|
||||
return `当前 ${currentCount} 条 / 总数未统计`;
|
||||
}
|
||||
return `当前 ${currentCount} 条 / 正在统计总数…`;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(total) || total <= 0) {
|
||||
return '当前 0 条 / 共 0 条';
|
||||
}
|
||||
|
||||
return `当前 ${currentCount} 条 / 共 ${total} 条`;
|
||||
};
|
||||
|
||||
export const resolvePaginationPageText = (params: {
|
||||
pagination: PaginationStateLike;
|
||||
supportsApproximateTotalPages: boolean;
|
||||
}): string => {
|
||||
const { pagination, supportsApproximateTotalPages } = params;
|
||||
const exactTotal = toFiniteNonNegativeNumber(pagination.total) ?? 0;
|
||||
const approximateTotal = resolveApproximateTotal(pagination);
|
||||
const effectiveTotal =
|
||||
pagination.totalKnown !== false
|
||||
? exactTotal
|
||||
: supportsApproximateTotalPages && approximateTotal !== null
|
||||
? approximateTotal
|
||||
: 0;
|
||||
|
||||
if (effectiveTotal <= 0) return `第 ${pagination.current} 页`;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(effectiveTotal / Math.max(1, pagination.pageSize)));
|
||||
if (pagination.totalKnown === false && !(supportsApproximateTotalPages && approximateTotal !== null)) {
|
||||
return `第 ${pagination.current} 页`;
|
||||
}
|
||||
return `第 ${pagination.current} / ${totalPages} 页`;
|
||||
};
|
||||
|
||||
export const resolvePaginationTotalForControl = (params: {
|
||||
pagination: PaginationStateLike;
|
||||
supportsApproximateTotalPages: boolean;
|
||||
}): number => {
|
||||
const { pagination, supportsApproximateTotalPages } = params;
|
||||
const exactTotal = toFiniteNonNegativeNumber(pagination.total) ?? 0;
|
||||
const approximateTotal = resolveApproximateTotal(pagination);
|
||||
if (pagination.totalKnown !== false) return exactTotal;
|
||||
if (supportsApproximateTotalPages && approximateTotal !== null) return approximateTotal;
|
||||
return exactTotal;
|
||||
};
|
||||
32
frontend/src/utils/dataSourceCapabilities.test.ts
Normal file
32
frontend/src/utils/dataSourceCapabilities.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getDataSourceCapabilities } from './dataSourceCapabilities';
|
||||
|
||||
describe('dataSourceCapabilities', () => {
|
||||
it('treats Oracle table preview totals as manual exact count plus approximate metadata count', () => {
|
||||
expect(getDataSourceCapabilities({ type: 'oracle' })).toMatchObject({
|
||||
type: 'oracle',
|
||||
preferManualTotalCount: true,
|
||||
supportsApproximateTableCount: true,
|
||||
supportsApproximateTotalPages: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps DuckDB manual count and approximate total support', () => {
|
||||
expect(getDataSourceCapabilities({ type: 'duckdb' })).toMatchObject({
|
||||
type: 'duckdb',
|
||||
preferManualTotalCount: true,
|
||||
supportsApproximateTableCount: true,
|
||||
supportsApproximateTotalPages: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps MySQL on automatic total count mode', () => {
|
||||
expect(getDataSourceCapabilities({ type: 'mysql' })).toMatchObject({
|
||||
type: 'mysql',
|
||||
preferManualTotalCount: false,
|
||||
supportsApproximateTableCount: false,
|
||||
supportsApproximateTotalPages: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,9 @@ const COPY_INSERT_TYPES = new Set([
|
||||
|
||||
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
|
||||
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'clickhouse']);
|
||||
const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle']);
|
||||
const APPROXIMATE_TABLE_COUNT_TYPES = new Set(['duckdb', 'oracle']);
|
||||
const APPROXIMATE_TOTAL_PAGE_TYPES = new Set(['duckdb']);
|
||||
|
||||
export type DataSourceCapabilities = {
|
||||
type: string;
|
||||
@@ -71,6 +74,9 @@ export type DataSourceCapabilities = {
|
||||
supportsSqlQueryExport: boolean;
|
||||
supportsCopyInsert: boolean;
|
||||
forceReadOnlyQueryResult: boolean;
|
||||
preferManualTotalCount: boolean;
|
||||
supportsApproximateTableCount: boolean;
|
||||
supportsApproximateTotalPages: boolean;
|
||||
};
|
||||
|
||||
export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => {
|
||||
@@ -81,6 +87,8 @@ export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCap
|
||||
supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type),
|
||||
supportsCopyInsert: COPY_INSERT_TYPES.has(type),
|
||||
forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type),
|
||||
preferManualTotalCount: MANUAL_TOTAL_COUNT_TYPES.has(type),
|
||||
supportsApproximateTableCount: APPROXIMATE_TABLE_COUNT_TYPES.has(type),
|
||||
supportsApproximateTotalPages: APPROXIMATE_TOTAL_PAGE_TYPES.has(type),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
26
frontend/src/utils/dataViewerAutoFetch.test.ts
Normal file
26
frontend/src/utils/dataViewerAutoFetch.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveDataViewerAutoFetchAction } from './dataViewerAutoFetch';
|
||||
|
||||
describe('resolveDataViewerAutoFetchAction', () => {
|
||||
it('skips one fetch while tab state is hydrating', () => {
|
||||
expect(resolveDataViewerAutoFetchAction({
|
||||
skipNextAutoFetch: true,
|
||||
hasInitialLoad: false,
|
||||
})).toBe('skip');
|
||||
});
|
||||
|
||||
it('loads current page on the first real fetch', () => {
|
||||
expect(resolveDataViewerAutoFetchAction({
|
||||
skipNextAutoFetch: false,
|
||||
hasInitialLoad: false,
|
||||
})).toBe('load-current-page');
|
||||
});
|
||||
|
||||
it('reloads from first page after sort or filter changes', () => {
|
||||
expect(resolveDataViewerAutoFetchAction({
|
||||
skipNextAutoFetch: false,
|
||||
hasInitialLoad: true,
|
||||
})).toBe('reload-first-page');
|
||||
});
|
||||
});
|
||||
16
frontend/src/utils/dataViewerAutoFetch.ts
Normal file
16
frontend/src/utils/dataViewerAutoFetch.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type DataViewerAutoFetchAction = 'skip' | 'load-current-page' | 'reload-first-page';
|
||||
|
||||
export const resolveDataViewerAutoFetchAction = (params: {
|
||||
skipNextAutoFetch: boolean;
|
||||
hasInitialLoad: boolean;
|
||||
}): DataViewerAutoFetchAction => {
|
||||
if (params.skipNextAutoFetch) {
|
||||
return 'skip';
|
||||
}
|
||||
|
||||
if (!params.hasInitialLoad) {
|
||||
return 'load-current-page';
|
||||
}
|
||||
|
||||
return 'reload-first-page';
|
||||
};
|
||||
47
frontend/src/utils/macWindow.test.ts
Normal file
47
frontend/src/utils/macWindow.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getMacNativeTitlebarPaddingLeft,
|
||||
getMacNativeTitlebarPaddingRight,
|
||||
shouldHandleMacNativeFullscreenShortcut,
|
||||
shouldSuppressMacNativeEscapeExit,
|
||||
} from './macWindow';
|
||||
|
||||
describe('macWindow helpers', () => {
|
||||
it('uses compact padding when native controls are disabled', () => {
|
||||
expect(getMacNativeTitlebarPaddingLeft(1, false)).toBe(16);
|
||||
expect(getMacNativeTitlebarPaddingRight(1, false)).toBe(0);
|
||||
});
|
||||
|
||||
it('reserves traffic-light safe area when native controls are enabled', () => {
|
||||
expect(getMacNativeTitlebarPaddingLeft(1, true)).toBe(96);
|
||||
expect(getMacNativeTitlebarPaddingRight(1, true)).toBe(16);
|
||||
});
|
||||
|
||||
it('keeps minimum safe area under small ui scales', () => {
|
||||
expect(getMacNativeTitlebarPaddingLeft(0.5, true)).toBe(88);
|
||||
expect(getMacNativeTitlebarPaddingRight(0.5, true)).toBe(12);
|
||||
});
|
||||
|
||||
it('matches Control+Command+F only for mac native mode', () => {
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(true);
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'F' })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects conflicting modifiers and non-target keys', () => {
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: true, key: 'f' })).toBe(false);
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: false, altKey: false, key: 'f' })).toBe(false);
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(false, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(false);
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, false, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(false);
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'g' })).toBe(false);
|
||||
});
|
||||
|
||||
it('suppresses Escape only in mac native fullscreen mode', () => {
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: false })).toBe(true);
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, true, false, { key: 'Escape', defaultPrevented: false })).toBe(false);
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, false, true, { key: 'Escape', defaultPrevented: false })).toBe(false);
|
||||
expect(shouldSuppressMacNativeEscapeExit(false, true, true, { key: 'Escape', defaultPrevented: false })).toBe(false);
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Enter', defaultPrevented: false })).toBe(false);
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
42
frontend/src/utils/macWindow.ts
Normal file
42
frontend/src/utils/macWindow.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export const getMacNativeTitlebarPaddingLeft = (uiScale: number, enabled: boolean): number => {
|
||||
if (!enabled) {
|
||||
return Math.max(12, Math.round(16 * uiScale));
|
||||
}
|
||||
return Math.max(88, Math.round(96 * uiScale));
|
||||
};
|
||||
|
||||
export const getMacNativeTitlebarPaddingRight = (uiScale: number, enabled: boolean): number => {
|
||||
if (!enabled) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(12, Math.round(16 * uiScale));
|
||||
};
|
||||
|
||||
export const shouldHandleMacNativeFullscreenShortcut = (
|
||||
isMacRuntime: boolean,
|
||||
useNativeMacWindowControls: boolean,
|
||||
event: Pick<KeyboardEvent, 'ctrlKey' | 'metaKey' | 'altKey' | 'key'>,
|
||||
): boolean => {
|
||||
if (!isMacRuntime || !useNativeMacWindowControls) {
|
||||
return false;
|
||||
}
|
||||
if (!event.ctrlKey || !event.metaKey || event.altKey) {
|
||||
return false;
|
||||
}
|
||||
return String(event.key || '').toLowerCase() === 'f';
|
||||
};
|
||||
|
||||
export const shouldSuppressMacNativeEscapeExit = (
|
||||
isMacRuntime: boolean,
|
||||
useNativeMacWindowControls: boolean,
|
||||
isFullscreen: boolean,
|
||||
event: Pick<KeyboardEvent, 'key' | 'defaultPrevented'>,
|
||||
): boolean => {
|
||||
if (!isMacRuntime || !useNativeMacWindowControls || !isFullscreen) {
|
||||
return false;
|
||||
}
|
||||
if (event.defaultPrevented) {
|
||||
return false;
|
||||
}
|
||||
return String(event.key || '') === 'Escape';
|
||||
};
|
||||
@@ -1,27 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildOverlayWorkbenchTheme } from './overlayWorkbenchTheme';
|
||||
|
||||
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
|
||||
}
|
||||
};
|
||||
describe('buildOverlayWorkbenchTheme', () => {
|
||||
it('builds dark theme tokens', () => {
|
||||
const darkTheme = buildOverlayWorkbenchTheme(true);
|
||||
expect(darkTheme.isDark).toBe(true);
|
||||
expect(darkTheme.shellBg).toMatch(/rgba\(15, 15, 17,/);
|
||||
expect(darkTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.03\)/);
|
||||
expect(darkTheme.iconColor).toBe('#ffd666');
|
||||
});
|
||||
|
||||
const assertMatch = (value: string, pattern: RegExp, message: string) => {
|
||||
if (!pattern.test(value)) {
|
||||
throw new Error(`${message}\nactual: ${value}\npattern: ${String(pattern)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const darkTheme = buildOverlayWorkbenchTheme(true);
|
||||
assertEqual(darkTheme.isDark, true, 'dark 主题标记应为 true');
|
||||
assertMatch(darkTheme.shellBg, /rgba\(15, 15, 17,/, 'dark 弹层背景应保持中性黑');
|
||||
assertMatch(darkTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.03\)/, 'dark section 背景透明度应匹配');
|
||||
assertEqual(darkTheme.iconColor, '#ffd666', 'dark 图标色应为金色强调');
|
||||
|
||||
const lightTheme = buildOverlayWorkbenchTheme(false);
|
||||
assertEqual(lightTheme.isDark, false, 'light 主题标记应为 false');
|
||||
assertMatch(lightTheme.shellBg, /rgba\(255,255,255,0\.98\)/, 'light 弹层背景透明度应匹配');
|
||||
assertMatch(lightTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/, 'light section 背景透明度应匹配');
|
||||
assertEqual(lightTheme.iconColor, '#1677ff', 'light 图标色应为蓝色强调');
|
||||
|
||||
console.log('overlayWorkbenchTheme tests passed');
|
||||
it('builds light theme tokens', () => {
|
||||
const lightTheme = buildOverlayWorkbenchTheme(false);
|
||||
expect(lightTheme.isDark).toBe(false);
|
||||
expect(lightTheme.shellBg).toMatch(/rgba\(255,255,255,0\.98\)/);
|
||||
expect(lightTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/);
|
||||
expect(lightTheme.iconColor).toBe('#1677ff');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ export type ShortcutAction =
|
||||
| 'newQueryTab'
|
||||
| 'toggleLogPanel'
|
||||
| 'toggleTheme'
|
||||
| 'openShortcutManager';
|
||||
| 'openShortcutManager'
|
||||
| 'toggleMacFullscreen';
|
||||
|
||||
export interface ShortcutBinding {
|
||||
combo: string;
|
||||
@@ -19,6 +20,7 @@ export interface ShortcutActionMeta {
|
||||
label: string;
|
||||
description: string;
|
||||
allowInEditable?: boolean;
|
||||
platformOnly?: 'mac';
|
||||
}
|
||||
|
||||
const MODIFIER_ORDER = ['Ctrl', 'Meta', 'Alt', 'Shift'] as const;
|
||||
@@ -76,6 +78,7 @@ export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
|
||||
'toggleLogPanel',
|
||||
'toggleTheme',
|
||||
'openShortcutManager',
|
||||
'toggleMacFullscreen',
|
||||
];
|
||||
|
||||
export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> = {
|
||||
@@ -105,6 +108,11 @@ export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> =
|
||||
description: '打开快捷键设置面板',
|
||||
allowInEditable: true,
|
||||
},
|
||||
toggleMacFullscreen: {
|
||||
label: '切换原生全屏',
|
||||
description: 'macOS 原生窗口控制模式下的全屏切换(⌃⌘F)',
|
||||
platformOnly: 'mac',
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
|
||||
@@ -114,6 +122,7 @@ export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
|
||||
toggleLogPanel: { combo: 'Ctrl+Shift+L', enabled: true },
|
||||
toggleTheme: { combo: 'Ctrl+Shift+D', enabled: true },
|
||||
openShortcutManager: { combo: 'Ctrl+,', enabled: true },
|
||||
toggleMacFullscreen: { combo: 'Ctrl+Meta+F', enabled: true },
|
||||
};
|
||||
|
||||
const normalizeKeyToken = (value: string): string => {
|
||||
|
||||
26
frontend/src/utils/startupReadiness.test.ts
Normal file
26
frontend/src/utils/startupReadiness.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getConnectionWorkbenchState } from './startupReadiness';
|
||||
|
||||
describe('startup readiness helpers', () => {
|
||||
it('blocks sidebar interactions before local store hydration completes', () => {
|
||||
expect(getConnectionWorkbenchState(false, false)).toEqual({
|
||||
ready: false,
|
||||
message: '正在加载本地配置...',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps sidebar blocked until initial global proxy sync finishes', () => {
|
||||
expect(getConnectionWorkbenchState(true, false)).toEqual({
|
||||
ready: false,
|
||||
message: '正在同步全局代理配置...',
|
||||
});
|
||||
});
|
||||
|
||||
it('unblocks sidebar after startup configuration is fully applied', () => {
|
||||
expect(getConnectionWorkbenchState(true, true)).toEqual({
|
||||
ready: true,
|
||||
message: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
26
frontend/src/utils/startupReadiness.ts
Normal file
26
frontend/src/utils/startupReadiness.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface ConnectionWorkbenchState {
|
||||
ready: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function getConnectionWorkbenchState(
|
||||
isStoreHydrated: boolean,
|
||||
hasAppliedInitialGlobalProxy: boolean
|
||||
): ConnectionWorkbenchState {
|
||||
if (!isStoreHydrated) {
|
||||
return {
|
||||
ready: false,
|
||||
message: '正在加载本地配置...',
|
||||
};
|
||||
}
|
||||
if (!hasAppliedInitialGlobalProxy) {
|
||||
return {
|
||||
ready: false,
|
||||
message: '正在同步全局代理配置...',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
message: '',
|
||||
};
|
||||
}
|
||||
43
frontend/wailsjs/go/aiservice/Service.d.ts
vendored
Executable file
43
frontend/wailsjs/go/aiservice/Service.d.ts
vendored
Executable file
@@ -0,0 +1,43 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {ai} from '../models';
|
||||
|
||||
export function AIChatCancel(arg1:string):Promise<void>;
|
||||
|
||||
export function AIChatSend(arg1:Array<ai.Message>,arg2:Array<ai.Tool>):Promise<Record<string, any>>;
|
||||
|
||||
export function AIChatStream(arg1:string,arg2:Array<ai.Message>,arg3:Array<ai.Tool>):Promise<void>;
|
||||
|
||||
export function AICheckSQL(arg1:string):Promise<ai.SafetyResult>;
|
||||
|
||||
export function AIDeleteProvider(arg1:string):Promise<void>;
|
||||
|
||||
export function AIDeleteSession(arg1:string):Promise<void>;
|
||||
|
||||
export function AIGetActiveProvider():Promise<string>;
|
||||
|
||||
export function AIGetBuiltinPrompts():Promise<Record<string, string>>;
|
||||
|
||||
export function AIGetContextLevel():Promise<string>;
|
||||
|
||||
export function AIGetProviders():Promise<Array<ai.ProviderConfig>>;
|
||||
|
||||
export function AIGetSafetyLevel():Promise<string>;
|
||||
|
||||
export function AIGetSessions():Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function AIListModels():Promise<Record<string, any>>;
|
||||
|
||||
export function AILoadSession(arg1:string):Promise<Record<string, any>>;
|
||||
|
||||
export function AISaveProvider(arg1:ai.ProviderConfig):Promise<void>;
|
||||
|
||||
export function AISaveSession(arg1:string,arg2:string,arg3:number,arg4:string):Promise<void>;
|
||||
|
||||
export function AISetActiveProvider(arg1:string):Promise<void>;
|
||||
|
||||
export function AISetContextLevel(arg1:string):Promise<void>;
|
||||
|
||||
export function AISetSafetyLevel(arg1:string):Promise<void>;
|
||||
|
||||
export function AITestProvider(arg1:ai.ProviderConfig):Promise<Record<string, any>>;
|
||||
83
frontend/wailsjs/go/aiservice/Service.js
Executable file
83
frontend/wailsjs/go/aiservice/Service.js
Executable file
@@ -0,0 +1,83 @@
|
||||
// @ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function AIChatCancel(arg1) {
|
||||
return window['go']['aiservice']['Service']['AIChatCancel'](arg1);
|
||||
}
|
||||
|
||||
export function AIChatSend(arg1, arg2) {
|
||||
return window['go']['aiservice']['Service']['AIChatSend'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function AIChatStream(arg1, arg2, arg3) {
|
||||
return window['go']['aiservice']['Service']['AIChatStream'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function AICheckSQL(arg1) {
|
||||
return window['go']['aiservice']['Service']['AICheckSQL'](arg1);
|
||||
}
|
||||
|
||||
export function AIDeleteProvider(arg1) {
|
||||
return window['go']['aiservice']['Service']['AIDeleteProvider'](arg1);
|
||||
}
|
||||
|
||||
export function AIDeleteSession(arg1) {
|
||||
return window['go']['aiservice']['Service']['AIDeleteSession'](arg1);
|
||||
}
|
||||
|
||||
export function AIGetActiveProvider() {
|
||||
return window['go']['aiservice']['Service']['AIGetActiveProvider']();
|
||||
}
|
||||
|
||||
export function AIGetBuiltinPrompts() {
|
||||
return window['go']['aiservice']['Service']['AIGetBuiltinPrompts']();
|
||||
}
|
||||
|
||||
export function AIGetContextLevel() {
|
||||
return window['go']['aiservice']['Service']['AIGetContextLevel']();
|
||||
}
|
||||
|
||||
export function AIGetProviders() {
|
||||
return window['go']['aiservice']['Service']['AIGetProviders']();
|
||||
}
|
||||
|
||||
export function AIGetSafetyLevel() {
|
||||
return window['go']['aiservice']['Service']['AIGetSafetyLevel']();
|
||||
}
|
||||
|
||||
export function AIGetSessions() {
|
||||
return window['go']['aiservice']['Service']['AIGetSessions']();
|
||||
}
|
||||
|
||||
export function AIListModels() {
|
||||
return window['go']['aiservice']['Service']['AIListModels']();
|
||||
}
|
||||
|
||||
export function AILoadSession(arg1) {
|
||||
return window['go']['aiservice']['Service']['AILoadSession'](arg1);
|
||||
}
|
||||
|
||||
export function AISaveProvider(arg1) {
|
||||
return window['go']['aiservice']['Service']['AISaveProvider'](arg1);
|
||||
}
|
||||
|
||||
export function AISaveSession(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['aiservice']['Service']['AISaveSession'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function AISetActiveProvider(arg1) {
|
||||
return window['go']['aiservice']['Service']['AISetActiveProvider'](arg1);
|
||||
}
|
||||
|
||||
export function AISetContextLevel(arg1) {
|
||||
return window['go']['aiservice']['Service']['AISetContextLevel'](arg1);
|
||||
}
|
||||
|
||||
export function AISetSafetyLevel(arg1) {
|
||||
return window['go']['aiservice']['Service']['AISetSafetyLevel'](arg1);
|
||||
}
|
||||
|
||||
export function AITestProvider(arg1) {
|
||||
return window['go']['aiservice']['Service']['AITestProvider'](arg1);
|
||||
}
|
||||
5
frontend/wailsjs/go/app/App.d.ts
vendored
5
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -1,7 +1,6 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {connection} from '../models';
|
||||
import {time} from '../models';
|
||||
import {sync} from '../models';
|
||||
import {redis} from '../models';
|
||||
|
||||
@@ -15,8 +14,6 @@ export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckForUpdates():Promise<connection.QueryResult>;
|
||||
|
||||
export function CleanupStaleQueries(arg1:time.Duration):Promise<void>;
|
||||
|
||||
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise<connection.QueryResult>;
|
||||
@@ -193,6 +190,8 @@ export function SelectDriverPackageFile(arg1:string):Promise<connection.QueryRes
|
||||
|
||||
export function SelectSSHKeyFile(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SetMacNativeWindowControls(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -22,10 +22,6 @@ export function CheckForUpdates() {
|
||||
return window['go']['app']['App']['CheckForUpdates']();
|
||||
}
|
||||
|
||||
export function CleanupStaleQueries(arg1) {
|
||||
return window['go']['app']['App']['CleanupStaleQueries'](arg1);
|
||||
}
|
||||
|
||||
export function ConfigureDriverRuntimeDirectory(arg1) {
|
||||
return window['go']['app']['App']['ConfigureDriverRuntimeDirectory'](arg1);
|
||||
}
|
||||
@@ -378,6 +374,10 @@ export function SelectSSHKeyFile(arg1) {
|
||||
return window['go']['app']['App']['SelectSSHKeyFile'](arg1);
|
||||
}
|
||||
|
||||
export function SetMacNativeWindowControls(arg1) {
|
||||
return window['go']['app']['App']['SetMacNativeWindowControls'](arg1);
|
||||
}
|
||||
|
||||
export function SetWindowTranslucency(arg1, arg2) {
|
||||
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,180 @@
|
||||
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;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
models?: string[];
|
||||
apiFormat?: string;
|
||||
headers?: Record<string, string>;
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProviderConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.type = source["type"];
|
||||
this.name = source["name"];
|
||||
this.apiKey = source["apiKey"];
|
||||
this.baseUrl = source["baseUrl"];
|
||||
this.model = source["model"];
|
||||
this.models = source["models"];
|
||||
this.apiFormat = source["apiFormat"];
|
||||
this.headers = source["headers"];
|
||||
this.maxTokens = source["maxTokens"];
|
||||
this.temperature = source["temperature"];
|
||||
}
|
||||
}
|
||||
export class SafetyResult {
|
||||
allowed: boolean;
|
||||
operationType: string;
|
||||
requiresConfirm: boolean;
|
||||
warningMessage?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SafetyResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.allowed = source["allowed"];
|
||||
this.operationType = source["operationType"];
|
||||
this.requiresConfirm = source["requiresConfirm"];
|
||||
this.warningMessage = source["warningMessage"];
|
||||
}
|
||||
}
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export namespace connection {
|
||||
|
||||
export class UpdateRow {
|
||||
|
||||
214
internal/ai/context/builder.go
Normal file
214
internal/ai/context/builder.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package aicontext
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PromptTemplate AI 能力类型
|
||||
type PromptTemplate string
|
||||
|
||||
const (
|
||||
PromptSQLGenerate PromptTemplate = "sql_generate"
|
||||
PromptSQLExplain PromptTemplate = "sql_explain"
|
||||
PromptSQLOptimize PromptTemplate = "sql_optimize"
|
||||
PromptDataAnalyze PromptTemplate = "data_analyze"
|
||||
PromptSchemaInsight PromptTemplate = "schema_insight"
|
||||
PromptGeneralChat PromptTemplate = "general_chat"
|
||||
)
|
||||
|
||||
// GetBuiltinPrompts 获取所有内置系统提示词集合,用于前端展示
|
||||
func GetBuiltinPrompts() map[string]string {
|
||||
return map[string]string{
|
||||
"通用聊天助手": buildGeneralChatPrompt(),
|
||||
"SQL 生成器": buildSQLGeneratePrompt(),
|
||||
"SQL 解析器": buildSQLExplainPrompt(),
|
||||
"SQL 优化器": buildSQLOptimizePrompt(),
|
||||
"数据洞察分析": buildDataAnalyzePrompt(),
|
||||
"表结构审查": buildSchemaInsightPrompt(),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildSystemPrompt 根据模板类型和上下文构建 System Prompt
|
||||
func BuildSystemPrompt(template PromptTemplate, dbCtx *DatabaseContext) string {
|
||||
var prompt string
|
||||
|
||||
switch template {
|
||||
case PromptSQLGenerate:
|
||||
prompt = buildSQLGeneratePrompt()
|
||||
case PromptSQLExplain:
|
||||
prompt = buildSQLExplainPrompt()
|
||||
case PromptSQLOptimize:
|
||||
prompt = buildSQLOptimizePrompt()
|
||||
case PromptDataAnalyze:
|
||||
prompt = buildDataAnalyzePrompt()
|
||||
case PromptSchemaInsight:
|
||||
prompt = buildSchemaInsightPrompt()
|
||||
case PromptGeneralChat:
|
||||
prompt = buildGeneralChatPrompt()
|
||||
default:
|
||||
prompt = buildGeneralChatPrompt()
|
||||
}
|
||||
|
||||
if dbCtx != nil {
|
||||
prompt += "\n\n" + FormatDatabaseContext(dbCtx)
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
// FormatDatabaseContext 将数据库上下文格式化为 LLM 友好的文本
|
||||
func FormatDatabaseContext(ctx *DatabaseContext) string {
|
||||
if ctx == nil || len(ctx.Tables) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("## 当前数据库上下文\n\n数据库类型: %s\n数据库名: %s\n\n",
|
||||
ctx.DatabaseType, ctx.DatabaseName))
|
||||
|
||||
b.WriteString("### 表结构\n\n")
|
||||
for _, table := range ctx.Tables {
|
||||
b.WriteString(fmt.Sprintf("#### 表: %s", table.Name))
|
||||
if table.Comment != "" {
|
||||
b.WriteString(fmt.Sprintf(" (%s)", table.Comment))
|
||||
}
|
||||
if table.RowCount > 0 {
|
||||
b.WriteString(fmt.Sprintf(" [约 %d 行]", table.RowCount))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString("| 列名 | 类型 | 可空 | 主键 | 备注 |\n")
|
||||
b.WriteString("|------|------|------|------|------|\n")
|
||||
for _, col := range table.Columns {
|
||||
nullable := "否"
|
||||
if col.Nullable {
|
||||
nullable = "是"
|
||||
}
|
||||
pk := ""
|
||||
if col.PrimaryKey {
|
||||
pk = "✓"
|
||||
}
|
||||
comment := col.Comment
|
||||
if comment == "" {
|
||||
comment = "-"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s |\n",
|
||||
col.Name, col.Type, nullable, pk, comment))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(table.Indexes) > 0 {
|
||||
b.WriteString("**索引:**\n")
|
||||
for _, idx := range table.Indexes {
|
||||
unique := ""
|
||||
if idx.Unique {
|
||||
unique = " (唯一)"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- %s: [%s]%s\n",
|
||||
idx.Name, strings.Join(idx.Columns, ", "), unique))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(table.SampleRows) > 0 {
|
||||
b.WriteString(fmt.Sprintf("**采样数据 (%d 行):**\n\n", len(table.SampleRows)))
|
||||
if len(table.SampleRows) > 0 {
|
||||
// 使用第一行的 key 作为标题
|
||||
first := table.SampleRows[0]
|
||||
var keys []string
|
||||
for k := range first {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
b.WriteString("| " + strings.Join(keys, " | ") + " |\n")
|
||||
b.WriteString("|" + strings.Repeat("------|", len(keys)) + "\n")
|
||||
for _, row := range table.SampleRows {
|
||||
var vals []string
|
||||
for _, k := range keys {
|
||||
vals = append(vals, fmt.Sprintf("%v", row[k]))
|
||||
}
|
||||
b.WriteString("| " + strings.Join(vals, " | ") + " |\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func buildSQLGeneratePrompt() string {
|
||||
return `你是 GoNavi AI 助手,一位顶级的数据库开发专家和 SQL 查询构建师。根据用户的自然语言需求,生成精准、优雅、高性能的 SQL 查询或 Redis 命令。
|
||||
|
||||
严苛输出规则:
|
||||
1. 首要目标是输出纯粹的代码:始终将代码放在正确语言标识(如 sql 或 bash)的 markdown 代码块中。
|
||||
2. 保持精简:不要添加过多的前置闲聊,直奔主题。
|
||||
3. 保护生产安全:优先使用参数化查询或安全防范写法避免 SQL 注入。对于未指定条件的 DELETE/UPDATE 语句,必须提出强烈的红线警告!!
|
||||
4. 性能至上:对大型查询默认添加合理的 LIMIT 限制(如 LIMIT 100),在 JOIN 和聚合时优先选择最高效的范式写法。
|
||||
5. 适度注释:对于存在复杂逻辑嵌套的代码,请在代码块内使用单行注释简要说明思路。`
|
||||
}
|
||||
|
||||
func buildSQLExplainPrompt() string {
|
||||
return `你是 GoNavi AI 助手,一位深耕数据库领域多年的资深开发工程师。请用专业、条理分明且深入浅出的开发者语言向用户全盘解析 SQL 语句的底层意图与执行逻辑。
|
||||
|
||||
解析规范:
|
||||
1. 宏观逻辑解构:用简短的一句话概括这条 SQL 在业务上想要解决什么问题。
|
||||
2. 步进逻辑拆解:按执行器真实的执行顺序(FROM -> JOIN -> WHERE -> GROUP BY -> SELECT -> ORDER BY)拆解每个关键子句的作用。
|
||||
3. 性能排雷点:敏锐指出可能存在的性能陷阱(如隐式类型转换、没有走索引的函数调用、潜在的笛卡尔积/全表扫描等)。
|
||||
4. 严谨的排版:使用列表呈现关键点,重点词汇加粗,确保长文不累赘。`
|
||||
}
|
||||
|
||||
func buildSQLOptimizePrompt() string {
|
||||
return `你是 GoNavi AI 助手,一名曾主导过千万级高并发系统的全栈性能工程专家与高级 DBA。请对用户提供的原始 SQL 进行冷酷、精确的诊断并开出性能重构处方。
|
||||
|
||||
诊断与处方要求:
|
||||
1. 性能瓶颈透视:精准点出当前语句死穴(不合理的驱动表、无法利用覆盖索引、多此一举的子查询等)。
|
||||
2. 重构版本的 SQL:如果存在性能提升空间,直接向用户展示彻底优化过的高性能写法,并确保逻辑等价性。
|
||||
3. 剖析原因:不仅要告诉用户“怎么改”,更要说清楚执行器“为什么这样会更快”。
|
||||
4. 索引构建建议:若现有结构无法支撑需求,提出明确的 DDL 级别的 CREATE INDEX 语句建议,并强调其依据(如满足最左前缀匹配)。
|
||||
5. 优先级评估:在回答的最后标注本次优化建议的紧迫性(高:阻断级/锁表风险;中:吞吐量瓶颈;低:长效微调)。`
|
||||
}
|
||||
|
||||
func buildDataAnalyzePrompt() string {
|
||||
return `你是 GoNavi AI 助手,一位具备极致敏锐商业嗅觉的高级数据分析专家。你将审视用户通过查询得到的数据样本,从中提炼出蕴含的真金白银般的信息。
|
||||
|
||||
洞察目标:
|
||||
1. 硬统计:总观数据行数、核心数值指标(极值、平均值、聚合中位数等)的冰冷现实。
|
||||
2. 趋势与异动:如果数据带有时间戳,敏锐捕捉其上升或下降趋势;如果有异类离群值,将其高亮标注。
|
||||
3. 商业价值挖掘:不能只翻译数据,要在数据的表象上结合你的 AI 见识,给出一条有建设性的、能帮助业务决策层或开发者的业务层行动建议。
|
||||
4. 展现格式:你的分析应该是“标题 + 浓缩要点”的极简研报形式,杜绝毫无波澜的流水账。`
|
||||
}
|
||||
|
||||
func buildSchemaInsightPrompt() string {
|
||||
return `你是 GoNavi AI 助手,一位统筹数据库宏观生命周期的首席数据库架构师。在这个环节里,你需要对用户提供的数据库表结构执行最严厉的范式与前瞻性审查。
|
||||
|
||||
审查视界:
|
||||
1. 规范化博弈:是否存在明显的反三范式设计?这种冗余是否有助于性能(适当的反范式),还是纯粹的设计失误?
|
||||
2. 索引健壮性审查:评估主键选择(如自增、UUID 的利弊),是否存在冗余索引阻碍写入?以及是否遗漏了高频的联合索引。
|
||||
3. 物理容量前瞻:审视数据类型分配(如使用过大的 VARCHAR、没必要的 BIGINT 等可能带来的空间挥霍)。
|
||||
4. 代码级指引:如果存在结构性缺陷,不要只发牢骚,直接给出包含具体优化的 ALTER TABLE 结构修改建议脚本。`
|
||||
}
|
||||
|
||||
func buildGeneralChatPrompt() string {
|
||||
return `你是 GoNavi AI 助手,一款深度集成在数据库/缓存客户端(GoNavi)内部的专属智能专家系统。
|
||||
你的目标是成为开发者、DBA 和数据科学家最得力的超级外脑,提供专业、精准、具有前瞻性的数据端解决方案。
|
||||
|
||||
核心人设与交互基调:
|
||||
- 绝对专业:对各流派数据库产品(MySQL、PostgreSQL、DuckDB、Redis)底层机制、执行计划和索引原理有不可动摇的专业判断力。
|
||||
- 直击痛点:谢绝套话与无效寒暄,若用户的意图明确,首屏直接给出可以直接粘贴运行的优雅代码。
|
||||
- 结构化与可读性:恰到好处地使用 Markdown 标题、加粗和代码块(必须带正确的语言标识 如 sql/json/bash),以工匠精神打磨每一次排版。
|
||||
- 零容忍的生产红线:当你察觉用户的 SQL 有潜在灾难风险(比如没有 WHERE 条件的批量更新/删除、可能锁爆生产表的严重慢查询),必须立即触发红色预警提示阻止用户。
|
||||
|
||||
你的综合能力版图:
|
||||
1. 📝 自然语言驱动:翻译人类意图为精准的查询语句。
|
||||
2. 🔍 底层原理解析:剥丝抽茧分析查询背后的执行逻辑与性能隐患。
|
||||
3. ⚡ 专家级调优:指出并化解性能瓶颈,给出覆盖全维度的索引调优思路。
|
||||
4. 📊 数据洞察炼金:不仅聚合数据,更能从结果集中挖掘商业维度的深度规律。
|
||||
5. 🏗️ 架构先知视界:全局审阅表结构设计局限,提出抗数据膨胀级别的架构演进方案。
|
||||
|
||||
互动守则:
|
||||
- 永远使用专业、具有合作感且充满信心的中文与用户探讨问题。
|
||||
- 当被要求提供任何数据库代码时,需结合相关数据库引擎的最佳实践。如果不清楚当前方言版本,请以标准实现为主基调并好心指出版别差异(如 MySQL 8 窗口函数 等)。
|
||||
- 绝不轻易拒绝:如果用户要求写 SQL 但并未显式挂载任何表的详细 DDL,请尽最大努力根据对话上下文中带入的【纯表名列表】去推测他要查询哪个表。如果实在无法推断,请温柔且专业地向用户解释目前已知的表有哪些,并询问到底想查哪张表。`
|
||||
}
|
||||
|
||||
42
internal/ai/context/collector.go
Normal file
42
internal/ai/context/collector.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package aicontext
|
||||
|
||||
// DatabaseContext 数据库上下文信息,传递给 AI 辅助上下文理解
|
||||
type DatabaseContext struct {
|
||||
DatabaseType string `json:"databaseType"` // mysql, postgres 等
|
||||
DatabaseName string `json:"databaseName"`
|
||||
Tables []TableContext `json:"tables"`
|
||||
}
|
||||
|
||||
// TableContext 表的上下文信息
|
||||
type TableContext struct {
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Columns []ColumnInfo `json:"columns"`
|
||||
Indexes []IndexInfo `json:"indexes,omitempty"`
|
||||
SampleRows []map[string]interface{} `json:"sampleRows,omitempty"`
|
||||
RowCount int64 `json:"rowCount,omitempty"`
|
||||
}
|
||||
|
||||
// ColumnInfo 列信息
|
||||
type ColumnInfo struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Nullable bool `json:"nullable"`
|
||||
PrimaryKey bool `json:"primaryKey"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// IndexInfo 索引信息
|
||||
type IndexInfo struct {
|
||||
Name string `json:"name"`
|
||||
Columns []string `json:"columns"`
|
||||
Unique bool `json:"unique"`
|
||||
}
|
||||
|
||||
// QueryResultContext 查询结果上下文
|
||||
type QueryResultContext struct {
|
||||
SQL string `json:"sql"`
|
||||
Columns []string `json:"columns"`
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
RowCount int `json:"rowCount"`
|
||||
}
|
||||
514
internal/ai/provider/anthropic.go
Normal file
514
internal/ai/provider/anthropic.go
Normal file
@@ -0,0 +1,514 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAnthropicBaseURL = "https://api.anthropic.com"
|
||||
anthropicAPIVersion = "2023-06-01"
|
||||
)
|
||||
|
||||
func normalizeAnthropicMessagesURL(baseURL string) string {
|
||||
url := strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if url == "" {
|
||||
url = defaultAnthropicBaseURL
|
||||
}
|
||||
if strings.HasSuffix(url, "/messages") {
|
||||
return url
|
||||
}
|
||||
if strings.HasSuffix(url, "/v1") {
|
||||
return url + "/messages"
|
||||
}
|
||||
return url + "/v1/messages"
|
||||
}
|
||||
|
||||
func IsDashScopeAnthropicCompatibleBaseURL(baseURL string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
return host == "dashscope.aliyuncs.com" || host == "coding.dashscope.aliyuncs.com"
|
||||
}
|
||||
|
||||
func ApplyAnthropicAuthHeaders(headers http.Header, baseURL string, apiKey string) {
|
||||
headers.Set("x-api-key", apiKey)
|
||||
if IsDashScopeAnthropicCompatibleBaseURL(baseURL) {
|
||||
headers.Set("Authorization", "Bearer "+apiKey)
|
||||
headers.Del("anthropic-version")
|
||||
return
|
||||
}
|
||||
headers.Set("anthropic-version", anthropicAPIVersion)
|
||||
}
|
||||
|
||||
// AnthropicProvider 实现 Anthropic Claude API 的 Provider
|
||||
type AnthropicProvider struct {
|
||||
config ai.ProviderConfig
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewAnthropicProvider 创建 Anthropic Provider 实例
|
||||
func NewAnthropicProvider(config ai.ProviderConfig) (Provider, error) {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = defaultAnthropicBaseURL
|
||||
}
|
||||
model := strings.TrimSpace(config.Model)
|
||||
if model == "" {
|
||||
return nil, fmt.Errorf("模型 ID 不能为空,请在设置中选择或输入模型")
|
||||
}
|
||||
maxTokens := config.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = defaultOpenAIMaxTokens
|
||||
}
|
||||
temperature := config.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = defaultOpenAITemperature
|
||||
}
|
||||
|
||||
normalized := config
|
||||
normalized.BaseURL = baseURL
|
||||
normalized.Model = model
|
||||
normalized.MaxTokens = maxTokens
|
||||
normalized.Temperature = temperature
|
||||
|
||||
return &AnthropicProvider{
|
||||
config: normalized,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{Timeout: openAIHTTPTimeout},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *AnthropicProvider) Name() string {
|
||||
if strings.TrimSpace(p.config.Name) != "" {
|
||||
return p.config.Name
|
||||
}
|
||||
return "Anthropic"
|
||||
}
|
||||
|
||||
func (p *AnthropicProvider) Validate() error {
|
||||
if strings.TrimSpace(p.config.APIKey) == "" {
|
||||
return fmt.Errorf("API Key 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- 请求体类型 ---
|
||||
|
||||
type anthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []anthropicMessage `json:"messages"`
|
||||
System string `json:"system,omitempty"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Tools []anthropicTool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
// anthropicTool Anthropic 格式的工具定义
|
||||
type anthropicTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputSchema any `json:"input_schema"`
|
||||
}
|
||||
|
||||
type anthropicMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"`
|
||||
}
|
||||
|
||||
// convertToolsToAnthropic 将 OpenAI 格式的 tools 转换为 Anthropic 格式
|
||||
func convertToolsToAnthropic(tools []ai.Tool) []anthropicTool {
|
||||
if len(tools) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]anthropicTool, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
result = append(result, anthropicTool{
|
||||
Name: t.Function.Name,
|
||||
Description: t.Function.Description,
|
||||
InputSchema: t.Function.Parameters,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildAnthropicMessages(reqMessages []ai.Message) []anthropicMessage {
|
||||
messages := make([]anthropicMessage, 0, len(reqMessages))
|
||||
for _, m := range reqMessages {
|
||||
// tool result 消息:转换为 Anthropic 的 tool_result content block
|
||||
if m.Role == "tool" {
|
||||
messages = append(messages, anthropicMessage{
|
||||
Role: "user",
|
||||
Content: []map[string]interface{}{
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": m.ToolCallID,
|
||||
"content": m.Content,
|
||||
},
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// assistant 带 tool_calls:转换为 Anthropic 的 tool_use content block
|
||||
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
|
||||
var contentParts []map[string]interface{}
|
||||
if m.Content != "" {
|
||||
contentParts = append(contentParts, map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": m.Content,
|
||||
})
|
||||
}
|
||||
for _, tc := range m.ToolCalls {
|
||||
var input interface{}
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
input = map[string]interface{}{}
|
||||
}
|
||||
contentParts = append(contentParts, map[string]interface{}{
|
||||
"type": "tool_use",
|
||||
"id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
"input": input,
|
||||
})
|
||||
}
|
||||
messages = append(messages, anthropicMessage{Role: "assistant", Content: contentParts})
|
||||
continue
|
||||
}
|
||||
|
||||
// 图片消息
|
||||
if len(m.Images) > 0 {
|
||||
var contentParts []map[string]interface{}
|
||||
for _, img := range m.Images {
|
||||
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 = "请描述和分析这张图片。"
|
||||
}
|
||||
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 anthropicContentBlock struct {
|
||||
Type string `json:"type"` // "text" | "tool_use"
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"` // tool_use
|
||||
Name string `json:"name,omitempty"` // tool_use
|
||||
Input json.RawMessage `json:"input,omitempty"` // tool_use
|
||||
}
|
||||
|
||||
type anthropicResponse struct {
|
||||
Content []anthropicContentBlock `json:"content"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// 流式事件类型
|
||||
type anthropicStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Index int `json:"index,omitempty"`
|
||||
ContentBlock *anthropicContentBlock `json:"content_block,omitempty"`
|
||||
Delta *struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
PartialJSON string `json:"partial_json,omitempty"`
|
||||
} `json:"delta,omitempty"`
|
||||
}
|
||||
|
||||
// --- Chat 非流式 ---
|
||||
|
||||
func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
systemMsg, messages := extractSystemMessage(req.Messages)
|
||||
anthropicMsgs := buildAnthropicMessages(messages)
|
||||
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = p.config.Temperature
|
||||
}
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = p.config.MaxTokens
|
||||
}
|
||||
|
||||
body := anthropicRequest{
|
||||
Model: p.config.Model,
|
||||
Messages: anthropicMsgs,
|
||||
System: systemMsg,
|
||||
MaxTokens: maxTokens,
|
||||
Temperature: temperature,
|
||||
Tools: convertToolsToAnthropic(req.Tools),
|
||||
}
|
||||
|
||||
respBody, err := p.doRequest(ctx, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
var result anthropicResponse
|
||||
if err := json.NewDecoder(respBody).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("解析 Anthropic 响应失败: %w", err)
|
||||
}
|
||||
if result.Error != nil && result.Error.Message != "" {
|
||||
return nil, fmt.Errorf("Anthropic API 错误: %s", result.Error.Message)
|
||||
}
|
||||
if len(result.Content) == 0 {
|
||||
return nil, fmt.Errorf("Anthropic 返回空响应")
|
||||
}
|
||||
|
||||
// 解析响应中的 text 和 tool_use content blocks
|
||||
var textContent string
|
||||
var toolCalls []ai.ToolCall
|
||||
for _, block := range result.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
textContent += block.Text
|
||||
case "tool_use":
|
||||
argsStr := "{}"
|
||||
if len(block.Input) > 0 {
|
||||
argsStr = string(block.Input)
|
||||
}
|
||||
toolCalls = append(toolCalls, ai.ToolCall{
|
||||
ID: block.ID,
|
||||
Type: "function",
|
||||
Function: struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}{
|
||||
Name: block.Name,
|
||||
Arguments: argsStr,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &ai.ChatResponse{
|
||||
Content: textContent,
|
||||
ToolCalls: toolCalls,
|
||||
TokensUsed: ai.TokenUsage{
|
||||
PromptTokens: result.Usage.InputTokens,
|
||||
CompletionTokens: result.Usage.OutputTokens,
|
||||
TotalTokens: result.Usage.InputTokens + result.Usage.OutputTokens,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- ChatStream 流式 ---
|
||||
|
||||
func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
systemMsg, messages := extractSystemMessage(req.Messages)
|
||||
anthropicMsgs := buildAnthropicMessages(messages)
|
||||
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = p.config.Temperature
|
||||
}
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = p.config.MaxTokens
|
||||
}
|
||||
|
||||
body := anthropicRequest{
|
||||
Model: p.config.Model,
|
||||
Messages: anthropicMsgs,
|
||||
System: systemMsg,
|
||||
MaxTokens: maxTokens,
|
||||
Temperature: temperature,
|
||||
Stream: true,
|
||||
Tools: convertToolsToAnthropic(req.Tools),
|
||||
}
|
||||
|
||||
respBody, err := p.doRequest(ctx, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
// 跟踪当前活跃的 tool_use blocks
|
||||
type activeToolUse struct {
|
||||
id string
|
||||
name string
|
||||
argsJSON strings.Builder
|
||||
}
|
||||
activeBlocks := make(map[int]*activeToolUse) // index -> block
|
||||
|
||||
scanner := bufio.NewScanner(respBody)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
|
||||
var event anthropicStreamEvent
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "content_block_start":
|
||||
if event.ContentBlock != nil && event.ContentBlock.Type == "tool_use" {
|
||||
activeBlocks[event.Index] = &activeToolUse{
|
||||
id: event.ContentBlock.ID,
|
||||
name: event.ContentBlock.Name,
|
||||
}
|
||||
}
|
||||
|
||||
case "content_block_delta":
|
||||
if event.Delta == nil {
|
||||
continue
|
||||
}
|
||||
switch event.Delta.Type {
|
||||
case "text_delta":
|
||||
if event.Delta.Text != "" {
|
||||
callback(ai.StreamChunk{Content: event.Delta.Text})
|
||||
}
|
||||
case "input_json_delta":
|
||||
if block, ok := activeBlocks[event.Index]; ok {
|
||||
block.argsJSON.WriteString(event.Delta.PartialJSON)
|
||||
}
|
||||
}
|
||||
|
||||
case "content_block_stop":
|
||||
if block, ok := activeBlocks[event.Index]; ok {
|
||||
argsStr := block.argsJSON.String()
|
||||
if argsStr == "" {
|
||||
argsStr = "{}"
|
||||
}
|
||||
// 产出完整的 tool call
|
||||
callback(ai.StreamChunk{
|
||||
ToolCalls: []ai.ToolCall{
|
||||
{
|
||||
ID: block.id,
|
||||
Type: "function",
|
||||
Function: struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}{
|
||||
Name: block.name,
|
||||
Arguments: argsStr,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
delete(activeBlocks, event.Index)
|
||||
}
|
||||
|
||||
case "message_stop":
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
// --- HTTP 请求 ---
|
||||
|
||||
func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
url := normalizeAnthropicMessagesURL(p.baseURL)
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
ApplyAnthropicAuthHeaders(httpReq.Header, p.baseURL, p.config.APIKey)
|
||||
|
||||
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 {
|
||||
httpReq.Header.Set("anthropic-beta", "interleaved-thinking-2025-05-14,output-128k-2025-02-19,prompt-caching-2024-07-31")
|
||||
}
|
||||
|
||||
// 自定义 headers(用于兼容各类代理服务)
|
||||
for k, v := range p.config.Headers {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送请求到 %s 失败: %w", url, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("Anthropic API 返回错误 (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// extractSystemMessage 从消息列表中提取 system 消息(Anthropic 要求 system 作为独立字段)
|
||||
func extractSystemMessage(messages []ai.Message) (string, []ai.Message) {
|
||||
var systemParts []string
|
||||
var remaining []ai.Message
|
||||
for _, m := range messages {
|
||||
if m.Role == "system" {
|
||||
systemParts = append(systemParts, m.Content)
|
||||
} else {
|
||||
remaining = append(remaining, m)
|
||||
}
|
||||
}
|
||||
return strings.Join(systemParts, "\n\n"), remaining
|
||||
}
|
||||
57
internal/ai/provider/anthropic_test.go
Normal file
57
internal/ai/provider/anthropic_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeAnthropicMessagesURL_AppendsMessagesSuffix(t *testing.T) {
|
||||
url := normalizeAnthropicMessagesURL("https://api.anthropic.com")
|
||||
if url != "https://api.anthropic.com/v1/messages" {
|
||||
t.Fatalf("expected normalized anthropic messages url, got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAnthropicMessagesURL_UsesMoonshotAnthropicMessagesEndpoint(t *testing.T) {
|
||||
url := normalizeAnthropicMessagesURL("https://api.moonshot.cn/anthropic")
|
||||
if url != "https://api.moonshot.cn/anthropic/v1/messages" {
|
||||
t.Fatalf("expected moonshot anthropic messages url, got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAnthropicMessagesURL_PreservesExplicitMessagesPath(t *testing.T) {
|
||||
url := normalizeAnthropicMessagesURL("https://api.moonshot.cn/anthropic/v1/messages")
|
||||
if url != "https://api.moonshot.cn/anthropic/v1/messages" {
|
||||
t.Fatalf("expected explicit messages path to be preserved, got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAnthropicAuthHeaders_UsesOfficialAnthropicHeadersForAnthropicAPI(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
ApplyAnthropicAuthHeaders(headers, "https://api.anthropic.com", "sk-test")
|
||||
|
||||
if got := headers.Get("x-api-key"); got != "sk-test" {
|
||||
t.Fatalf("expected x-api-key header, got %q", got)
|
||||
}
|
||||
if got := headers.Get("anthropic-version"); got != anthropicAPIVersion {
|
||||
t.Fatalf("expected anthropic-version header, got %q", got)
|
||||
}
|
||||
if got := headers.Get("Authorization"); got != "" {
|
||||
t.Fatalf("expected no authorization header for official anthropic, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAnthropicAuthHeaders_UsesBearerForDashScopeCompatibleAnthropic(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
ApplyAnthropicAuthHeaders(headers, "https://coding.dashscope.aliyuncs.com/apps/anthropic", "sk-sp-test")
|
||||
|
||||
if got := headers.Get("Authorization"); got != "Bearer sk-sp-test" {
|
||||
t.Fatalf("expected bearer authorization header, got %q", got)
|
||||
}
|
||||
if got := headers.Get("x-api-key"); got != "sk-sp-test" {
|
||||
t.Fatalf("expected x-api-key header, got %q", got)
|
||||
}
|
||||
if got := headers.Get("anthropic-version"); got != "" {
|
||||
t.Fatalf("expected no anthropic-version header for DashScope, got %q", got)
|
||||
}
|
||||
}
|
||||
511
internal/ai/provider/claude_cli.go
Normal file
511
internal/ai/provider/claude_cli.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ai "GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
var claudeLookPath = exec.LookPath
|
||||
var claudeCommandContext = exec.CommandContext
|
||||
var claudeCLIRequestTimeout = 90 * time.Second
|
||||
|
||||
// ClaudeCLIProvider 通过 Claude Code CLI 发送聊天请求
|
||||
// 适用于 anyrouter/newapi 等只支持 Claude Code 协议的代理服务
|
||||
type ClaudeCLIProvider struct {
|
||||
config ai.ProviderConfig
|
||||
}
|
||||
|
||||
// NewClaudeCLIProvider 创建 ClaudeCLIProvider 实例
|
||||
func NewClaudeCLIProvider(config ai.ProviderConfig) (Provider, error) {
|
||||
return &ClaudeCLIProvider{config: config}, nil
|
||||
}
|
||||
|
||||
func (p *ClaudeCLIProvider) Name() string {
|
||||
return "ClaudeCLI"
|
||||
}
|
||||
|
||||
func (p *ClaudeCLIProvider) Validate() error {
|
||||
_, err := claudeLookPath("claude")
|
||||
if err != nil {
|
||||
return fmt.Errorf("未找到 claude 命令,请先安装 Claude Code CLI: npm install -g @anthropic-ai/claude-code")
|
||||
}
|
||||
if _, err := resolveClaudeCodeGitBashPath(os.Environ(), runtime.GOOS, claudeLookPath, fileExists); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Chat 非流式聊天:调用 claude -p "prompt" --output-format json
|
||||
func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := ensureClaudeCLITimeout(ctx, claudeCLIRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
prompt := buildPrompt(req.Messages)
|
||||
args := []string{"-p", prompt, "--output-format", "json", "--no-session-persistence"}
|
||||
if p.config.Model != "" {
|
||||
args = append(args, "--model", p.config.Model)
|
||||
}
|
||||
|
||||
cmd := claudeCommandContext(ctx, "claude", args...)
|
||||
if err := p.setEnv(cmd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if isClaudeCLITimeout(ctx, err) {
|
||||
return nil, fmt.Errorf("claude CLI 执行超时(%s),当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout)
|
||||
}
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return nil, fmt.Errorf("claude CLI 执行失败: %s", string(exitErr.Stderr))
|
||||
}
|
||||
return nil, fmt.Errorf("claude CLI 执行失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析 JSON 输出
|
||||
var result cliStreamEvent
|
||||
if err := json.Unmarshal(output, &result); err != nil {
|
||||
// 如果 JSON 解析失败,直接返回原始文本
|
||||
return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, nil
|
||||
}
|
||||
if errMsg, hasError := extractClaudeCLIEventError(result); hasError {
|
||||
return nil, fmt.Errorf("claude CLI 返回错误: %s", errMsg)
|
||||
}
|
||||
|
||||
return &ai.ChatResponse{Content: result.Result}, nil
|
||||
}
|
||||
|
||||
// ChatStream 流式聊天:调用 claude -p "prompt" --output-format stream-json
|
||||
func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := ensureClaudeCLITimeout(ctx, claudeCLIRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
prompt := buildPrompt(req.Messages)
|
||||
args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--no-session-persistence"}
|
||||
if p.config.Model != "" {
|
||||
args = append(args, "--model", p.config.Model)
|
||||
}
|
||||
|
||||
fmt.Printf("[ClaudeCLI DEBUG] Running: claude %v\n", args)
|
||||
|
||||
cmd := claudeCommandContext(ctx, "claude", args...)
|
||||
if err := p.setEnv(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 关闭 stdin,防止 claude CLI 等待输入
|
||||
cmd.Stdin = nil
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 stdout 管道失败: %w", err)
|
||||
}
|
||||
|
||||
// 捕获 stderr
|
||||
var stderrBuf bytes.Buffer
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("启动 claude CLI 失败: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[ClaudeCLI DEBUG] Process started, PID: %d\n", cmd.Process.Pid)
|
||||
|
||||
// 前端已有 loading 动画,无需在 content 中注入"正在思考"
|
||||
|
||||
// 逐行读取流式 JSON 输出
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("[ClaudeCLI DEBUG] Line: %s\n", line[:min(len(line), 200)])
|
||||
|
||||
var event cliStreamEvent
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
fmt.Printf("[ClaudeCLI DEBUG] Non-JSON line: %s\n", line)
|
||||
continue
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "system":
|
||||
if isClaudeCLISystemRetryEvent(event) {
|
||||
if errMsg, hasError := extractClaudeCLISystemRetryError(event); hasError {
|
||||
callback(ai.StreamChunk{Error: errMsg, Done: true})
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
case "assistant":
|
||||
if errMsg, hasError := extractClaudeCLIEventError(event); hasError {
|
||||
callback(ai.StreamChunk{Error: errMsg, Done: true})
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
}
|
||||
// 助手消息开始或文本内容
|
||||
if event.Message.Content != nil {
|
||||
for _, block := range event.Message.Content {
|
||||
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.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":
|
||||
if errMsg, hasError := extractClaudeCLIEventError(event); hasError {
|
||||
callback(ai.StreamChunk{Error: errMsg, Done: true})
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
}
|
||||
// 最终结果事件 — 不发送 content(assistant 事件已包含),只标记完成
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
case "error":
|
||||
errMsg, _ := extractClaudeCLIEventError(event)
|
||||
callback(ai.StreamChunk{Error: errMsg, Done: true})
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
waitErr := cmd.Wait()
|
||||
stderrStr := strings.TrimSpace(stderrBuf.String())
|
||||
fmt.Printf("[ClaudeCLI DEBUG] Process exited. stderr: %s\n", stderrStr)
|
||||
|
||||
if isClaudeCLITimeout(ctx, waitErr) {
|
||||
callback(ai.StreamChunk{
|
||||
Error: fmt.Sprintf("claude CLI 执行超时(%s),当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout),
|
||||
Done: true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
if waitErr != nil {
|
||||
errMsg := fmt.Sprintf("claude CLI 异常退出: %v", waitErr)
|
||||
if stderrStr != "" {
|
||||
errMsg = fmt.Sprintf("claude CLI 异常退出: %s", stderrStr)
|
||||
}
|
||||
callback(ai.StreamChunk{Error: errMsg, Done: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureClaudeCLITimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
if _, hasDeadline := ctx.Deadline(); hasDeadline || timeout <= 0 {
|
||||
return ctx, func() {}
|
||||
}
|
||||
return context.WithTimeout(ctx, timeout)
|
||||
}
|
||||
|
||||
func isClaudeCLITimeout(ctx context.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// setEnv 设置 Claude CLI 的环境变量
|
||||
func (p *ClaudeCLIProvider) setEnv(cmd *exec.Cmd) error {
|
||||
env, err := buildClaudeCLIEnv(p.config, cmd.Environ(), runtime.GOOS, claudeLookPath, fileExists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Env = env
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildClaudeCLIEnv(config ai.ProviderConfig, baseEnv []string, goos string, lookPath func(string) (string, error), exists func(string) bool) ([]string, error) {
|
||||
env := append([]string(nil), baseEnv...)
|
||||
if config.BaseURL != "" {
|
||||
env = upsertEnv(env, "ANTHROPIC_BASE_URL", strings.TrimRight(config.BaseURL, "/"))
|
||||
}
|
||||
if config.APIKey != "" {
|
||||
env = upsertEnv(env, "ANTHROPIC_AUTH_TOKEN", config.APIKey)
|
||||
env = upsertEnv(env, "ANTHROPIC_API_KEY", config.APIKey)
|
||||
}
|
||||
|
||||
gitBashPath, err := resolveClaudeCodeGitBashPath(env, goos, lookPath, exists)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if gitBashPath != "" {
|
||||
env = upsertEnv(env, "CLAUDE_CODE_GIT_BASH_PATH", gitBashPath)
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
func resolveClaudeCodeGitBashPath(env []string, goos string, lookPath func(string) (string, error), exists func(string) bool) (string, error) {
|
||||
if goos != "windows" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if configured := strings.TrimSpace(envValue(env, "CLAUDE_CODE_GIT_BASH_PATH")); configured != "" {
|
||||
if exists(configured) {
|
||||
return configured, nil
|
||||
}
|
||||
return "", fmt.Errorf("Claude Code CLI 在 Windows 下需要 git-bash,但 CLAUDE_CODE_GIT_BASH_PATH 指向的 bash.exe 不存在: %s", configured)
|
||||
}
|
||||
|
||||
for _, command := range []string{"bash.exe", "bash"} {
|
||||
if bashPath, err := lookPath(command); err == nil && exists(bashPath) {
|
||||
return bashPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
if gitPath, err := lookPath("git.exe"); err == nil {
|
||||
gitDir := parentWindowsPath(gitPath)
|
||||
for _, candidate := range []string{
|
||||
joinWindowsPath(parentWindowsPath(gitDir), "bin", "bash.exe"),
|
||||
joinWindowsPath(gitDir, "bash.exe"),
|
||||
} {
|
||||
if candidate != "" && exists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, candidate := range windowsGitBashCandidates(env) {
|
||||
if exists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("Claude Code CLI 在 Windows 下需要 git-bash。请安装 Git for Windows(https://git-scm.com/downloads/win);如果已安装但未加入 PATH,请设置环境变量 CLAUDE_CODE_GIT_BASH_PATH 指向 bash.exe,例如 C:\\Program Files\\Git\\bin\\bash.exe")
|
||||
}
|
||||
|
||||
func windowsGitBashCandidates(env []string) []string {
|
||||
candidates := make([]string, 0, 3)
|
||||
for _, base := range []string{
|
||||
envValue(env, "ProgramFiles"),
|
||||
envValue(env, "ProgramFiles(x86)"),
|
||||
envValue(env, "LocalAppData"),
|
||||
} {
|
||||
base = strings.TrimSpace(base)
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(base, envValue(env, "LocalAppData")) {
|
||||
candidates = append(candidates, joinWindowsPath(base, "Programs", "Git", "bin", "bash.exe"))
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, joinWindowsPath(base, "Git", "bin", "bash.exe"))
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func envValue(env []string, key string) string {
|
||||
prefix := key + "="
|
||||
for _, entry := range env {
|
||||
if strings.HasPrefix(entry, prefix) {
|
||||
return strings.TrimPrefix(entry, prefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func upsertEnv(env []string, key, value string) []string {
|
||||
prefix := key + "="
|
||||
for i, entry := range env {
|
||||
if strings.HasPrefix(entry, prefix) {
|
||||
env[i] = prefix + value
|
||||
return env
|
||||
}
|
||||
}
|
||||
return append(env, prefix+value)
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(strings.TrimSpace(path))
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func joinWindowsPath(base string, parts ...string) string {
|
||||
result := strings.TrimSpace(strings.ReplaceAll(base, "/", `\`))
|
||||
if result != "" {
|
||||
result = strings.TrimRight(result, `\`)
|
||||
}
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.Trim(strings.ReplaceAll(strings.TrimSpace(part), "/", `\`), `\`)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if result == "" {
|
||||
result = part
|
||||
continue
|
||||
}
|
||||
result += `\` + part
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parentWindowsPath(path string) string {
|
||||
path = strings.TrimRight(strings.ReplaceAll(strings.TrimSpace(path), "/", `\`), `\`)
|
||||
idx := strings.LastIndex(path, `\`)
|
||||
if idx <= 0 {
|
||||
return ""
|
||||
}
|
||||
return path[:idx]
|
||||
}
|
||||
|
||||
// buildPrompt 将消息列表拼接为适合 claude -p 的提示文本
|
||||
func buildPrompt(messages []ai.Message) string {
|
||||
if len(messages) == 1 {
|
||||
return messages[0].Content
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, m := range messages {
|
||||
switch m.Role {
|
||||
case "system":
|
||||
sb.WriteString("[System]\n")
|
||||
sb.WriteString(m.Content)
|
||||
sb.WriteString("\n\n")
|
||||
case "user":
|
||||
sb.WriteString(m.Content)
|
||||
sb.WriteString("\n\n")
|
||||
case "assistant":
|
||||
sb.WriteString("[Previous Assistant Response]\n")
|
||||
sb.WriteString(m.Content)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// cliStreamEvent Claude CLI stream-json 输出的事件结构
|
||||
type cliStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
Attempt int `json:"attempt,omitempty"`
|
||||
MaxRetries int `json:"max_retries,omitempty"`
|
||||
RetryDelayMS float64 `json:"retry_delay_ms,omitempty"`
|
||||
ErrorStatus int `json:"error_status,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Message struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"`
|
||||
} `json:"content"`
|
||||
} `json:"message,omitempty"`
|
||||
Delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"`
|
||||
} `json:"delta,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Error cliStreamEventError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type cliStreamEventError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *cliStreamEventError) UnmarshalJSON(data []byte) error {
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
e.Message = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
var text string
|
||||
if err := json.Unmarshal(data, &text); err == nil {
|
||||
e.Message = strings.TrimSpace(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
e.Message = strings.TrimSpace(payload.Message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractClaudeCLIEventError(event cliStreamEvent) (string, bool) {
|
||||
if event.Type != "error" && !event.IsError {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if msg := strings.TrimSpace(event.Result); msg != "" {
|
||||
return msg, true
|
||||
}
|
||||
|
||||
for _, block := range event.Message.Content {
|
||||
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
|
||||
return strings.TrimSpace(block.Text), true
|
||||
}
|
||||
}
|
||||
|
||||
if msg := strings.TrimSpace(event.Error.Message); msg != "" {
|
||||
return msg, true
|
||||
}
|
||||
|
||||
return "claude CLI 返回未知错误", true
|
||||
}
|
||||
|
||||
func isClaudeCLISystemRetryEvent(event cliStreamEvent) bool {
|
||||
return event.Type == "system" && event.Subtype == "api_retry"
|
||||
}
|
||||
|
||||
func extractClaudeCLISystemRetryError(event cliStreamEvent) (string, bool) {
|
||||
if !isClaudeCLISystemRetryEvent(event) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
errText := strings.TrimSpace(event.Error.Message)
|
||||
if event.ErrorStatus != 401 && event.ErrorStatus != 403 && !strings.EqualFold(errText, "authentication_failed") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if errText == "" {
|
||||
errText = "authentication_failed"
|
||||
}
|
||||
|
||||
if event.ErrorStatus > 0 {
|
||||
return fmt.Sprintf("claude CLI 鉴权失败 (HTTP %d): %s", event.ErrorStatus, errText), true
|
||||
}
|
||||
return fmt.Sprintf("claude CLI 鉴权失败: %s", errText), true
|
||||
}
|
||||
354
internal/ai/provider/claude_cli_test.go
Normal file
354
internal/ai/provider/claude_cli_test.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
func TestBuildClaudeCLIEnv_IncludesAnthropicProxyEnv(t *testing.T) {
|
||||
env, err := buildClaudeCLIEnv(ai.ProviderConfig{
|
||||
BaseURL: "https://proxy.example.com/",
|
||||
APIKey: "sk-test",
|
||||
}, []string{"PATH=/usr/bin"}, "darwin", func(name string) (string, error) {
|
||||
return "", errors.New("unexpected lookup")
|
||||
}, func(path string) bool {
|
||||
return false
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got := envValue(env, "ANTHROPIC_BASE_URL"); got != "https://proxy.example.com" {
|
||||
t.Fatalf("expected trimmed base url, got %q", got)
|
||||
}
|
||||
if got := envValue(env, "ANTHROPIC_API_KEY"); got != "sk-test" {
|
||||
t.Fatalf("expected api key in env, got %q", got)
|
||||
}
|
||||
if got := envValue(env, "ANTHROPIC_AUTH_TOKEN"); got != "sk-test" {
|
||||
t.Fatalf("expected auth token in env, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeCLIEnv_UsesDetectedGitBashOnWindows(t *testing.T) {
|
||||
env, err := buildClaudeCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) {
|
||||
switch name {
|
||||
case "bash.exe":
|
||||
return "", errors.New("not found")
|
||||
case "bash":
|
||||
return "", errors.New("not found")
|
||||
case "git.exe":
|
||||
return "C:\\Program Files\\Git\\cmd\\git.exe", nil
|
||||
default:
|
||||
return "", errors.New("unexpected lookup")
|
||||
}
|
||||
}, func(path string) bool {
|
||||
return path == `C:\Program Files\Git\bin\bash.exe`
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got := envValue(env, "CLAUDE_CODE_GIT_BASH_PATH"); got != `C:\Program Files\Git\bin\bash.exe` {
|
||||
t.Fatalf("expected detected git bash path, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeCLIEnv_ReturnsActionableErrorWhenGitBashMissingOnWindows(t *testing.T) {
|
||||
_, err := buildClaudeCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) {
|
||||
return "", errors.New("not found")
|
||||
}, func(path string) bool {
|
||||
return false
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when git bash is missing on windows")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "git-bash") {
|
||||
t.Fatalf("expected git-bash hint, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CLAUDE_CODE_GIT_BASH_PATH") {
|
||||
t.Fatalf("expected env var hint, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCLIProvider_ChatTimesOutWhenCommandDoesNotFinish(t *testing.T) {
|
||||
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\nsleep 5\n")
|
||||
restore := overrideClaudeCLIForTest(t, fakeClaude)
|
||||
defer restore()
|
||||
|
||||
originalRequestTimeout := claudeCLIRequestTimeout
|
||||
claudeCLIRequestTimeout = 200 * time.Millisecond
|
||||
defer func() {
|
||||
claudeCLIRequestTimeout = originalRequestTimeout
|
||||
}()
|
||||
|
||||
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
|
||||
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
||||
APIKey: "sk-test",
|
||||
Model: "qwen3.5-plus",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected provider error: %v", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
_, err = provider.Chat(context.Background(), ai.ChatRequest{
|
||||
Messages: []ai.Message{{Role: "user", Content: "ping"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected chat timeout error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "执行超时") {
|
||||
t.Fatalf("expected timeout error, got %v", err)
|
||||
}
|
||||
if time.Since(start) < 200*time.Millisecond {
|
||||
t.Fatalf("expected timeout path to wait for configured deadline, took %s", time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCLIProvider_ChatStreamUsesRequestTimeoutWhenNoMeaningfulResponseArrives(t *testing.T) {
|
||||
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"system\",\"subtype\":\"init\"}'\nexec sleep 5\n")
|
||||
restore := overrideClaudeCLIForTest(t, fakeClaude)
|
||||
defer restore()
|
||||
|
||||
originalRequestTimeout := claudeCLIRequestTimeout
|
||||
claudeCLIRequestTimeout = 200 * time.Millisecond
|
||||
defer func() {
|
||||
claudeCLIRequestTimeout = originalRequestTimeout
|
||||
}()
|
||||
|
||||
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
|
||||
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
||||
APIKey: "sk-test",
|
||||
Model: "qwen3.5-plus",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected provider error: %v", err)
|
||||
}
|
||||
|
||||
var chunks []ai.StreamChunk
|
||||
err = provider.ChatStream(context.Background(), ai.ChatRequest{
|
||||
Messages: []ai.Message{{Role: "user", Content: "ping"}},
|
||||
}, func(chunk ai.StreamChunk) {
|
||||
chunks = append(chunks, chunk)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected stream provider to report timeout via callback, got %v", err)
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
t.Fatal("expected timeout chunk")
|
||||
}
|
||||
lastChunk := chunks[len(chunks)-1]
|
||||
if !lastChunk.Done {
|
||||
t.Fatalf("expected timeout chunk to terminate stream, got %#v", lastChunk)
|
||||
}
|
||||
if !strings.Contains(lastChunk.Error, "执行超时") {
|
||||
t.Fatalf("expected request timeout message, got %#v", lastChunk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCLIProvider_ChatStreamAllowsDelayedMeaningfulResponse(t *testing.T) {
|
||||
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"system\",\"subtype\":\"init\"}'\nsleep 0.2\necho '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"OK\"}]}}'\necho '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"OK\"}'\n")
|
||||
restore := overrideClaudeCLIForTest(t, fakeClaude)
|
||||
defer restore()
|
||||
|
||||
originalRequestTimeout := claudeCLIRequestTimeout
|
||||
claudeCLIRequestTimeout = 1 * time.Second
|
||||
defer func() {
|
||||
claudeCLIRequestTimeout = originalRequestTimeout
|
||||
}()
|
||||
|
||||
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
|
||||
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
||||
APIKey: "sk-test",
|
||||
Model: "qwen3.5-plus",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected provider error: %v", err)
|
||||
}
|
||||
|
||||
var chunks []ai.StreamChunk
|
||||
err = provider.ChatStream(context.Background(), ai.ChatRequest{
|
||||
Messages: []ai.Message{{Role: "user", Content: "ping"}},
|
||||
}, func(chunk ai.StreamChunk) {
|
||||
chunks = append(chunks, chunk)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected delayed response to complete via callback, got %v", err)
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
t.Fatal("expected delayed response chunks")
|
||||
}
|
||||
if chunks[0].Content != "OK" {
|
||||
t.Fatalf("expected delayed content chunk, got %#v", chunks)
|
||||
}
|
||||
if !chunks[len(chunks)-1].Done {
|
||||
t.Fatalf("expected terminal done chunk, got %#v", chunks[len(chunks)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCLIProvider_ChatReturnsErrorWhenJSONResponseIsError(t *testing.T) {
|
||||
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":true,\"result\":\"API Error: Unable to connect to API (ECONNRESET)\",\"error\":\"unknown\"}'\n")
|
||||
restore := overrideClaudeCLIForTest(t, fakeClaude)
|
||||
defer restore()
|
||||
|
||||
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
|
||||
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
||||
APIKey: "sk-test",
|
||||
Model: "qwen3.5-plus",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected provider error: %v", err)
|
||||
}
|
||||
|
||||
_, err = provider.Chat(context.Background(), ai.ChatRequest{
|
||||
Messages: []ai.Message{{Role: "user", Content: "ping"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected chat error when CLI JSON marks request as failed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Unable to connect to API") {
|
||||
t.Fatalf("expected upstream API error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCLIProvider_ChatStreamReportsAssistantErrorEvent(t *testing.T) {
|
||||
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"assistant\",\"is_error\":true,\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"API Error: Unable to connect to API (ECONNRESET)\"}]},\"error\":\"unknown\"}'\n")
|
||||
restore := overrideClaudeCLIForTest(t, fakeClaude)
|
||||
defer restore()
|
||||
|
||||
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
|
||||
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
||||
APIKey: "sk-test",
|
||||
Model: "qwen3.5-plus",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected provider error: %v", err)
|
||||
}
|
||||
|
||||
var chunks []ai.StreamChunk
|
||||
err = provider.ChatStream(context.Background(), ai.ChatRequest{
|
||||
Messages: []ai.Message{{Role: "user", Content: "ping"}},
|
||||
}, func(chunk ai.StreamChunk) {
|
||||
chunks = append(chunks, chunk)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected stream provider to report error via callback, got %v", err)
|
||||
}
|
||||
if len(chunks) != 1 {
|
||||
t.Fatalf("expected a single terminal error chunk, got %#v", chunks)
|
||||
}
|
||||
if chunks[0].Content != "" {
|
||||
t.Fatalf("expected assistant error event to avoid content output, got %#v", chunks[0])
|
||||
}
|
||||
if !chunks[0].Done || !strings.Contains(chunks[0].Error, "Unable to connect to API") {
|
||||
t.Fatalf("expected upstream API error chunk, got %#v", chunks[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCLIProvider_ChatStreamReportsResultErrorEvent(t *testing.T) {
|
||||
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":true,\"result\":\"API Error: Unable to connect to API (ECONNRESET)\",\"error\":\"unknown\"}'\n")
|
||||
restore := overrideClaudeCLIForTest(t, fakeClaude)
|
||||
defer restore()
|
||||
|
||||
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
|
||||
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
||||
APIKey: "sk-test",
|
||||
Model: "qwen3.5-plus",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected provider error: %v", err)
|
||||
}
|
||||
|
||||
var chunks []ai.StreamChunk
|
||||
err = provider.ChatStream(context.Background(), ai.ChatRequest{
|
||||
Messages: []ai.Message{{Role: "user", Content: "ping"}},
|
||||
}, func(chunk ai.StreamChunk) {
|
||||
chunks = append(chunks, chunk)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected stream provider to report error via callback, got %v", err)
|
||||
}
|
||||
if len(chunks) != 1 {
|
||||
t.Fatalf("expected a single terminal error chunk, got %#v", chunks)
|
||||
}
|
||||
if chunks[0].Content != "" {
|
||||
t.Fatalf("expected result error event to avoid content output, got %#v", chunks[0])
|
||||
}
|
||||
if !chunks[0].Done || !strings.Contains(chunks[0].Error, "Unable to connect to API") {
|
||||
t.Fatalf("expected upstream API error chunk, got %#v", chunks[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCLIProvider_ChatStreamReportsApiRetryAuthenticationFailure(t *testing.T) {
|
||||
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"system\",\"subtype\":\"api_retry\",\"attempt\":1,\"max_retries\":10,\"retry_delay_ms\":536.11,\"error_status\":401,\"error\":\"authentication_failed\",\"session_id\":\"retry-1\"}'\nexec sleep 5\n")
|
||||
restore := overrideClaudeCLIForTest(t, fakeClaude)
|
||||
defer restore()
|
||||
|
||||
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
|
||||
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
||||
APIKey: "sk-test",
|
||||
Model: "qwen3.5-plus",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected provider error: %v", err)
|
||||
}
|
||||
|
||||
var chunks []ai.StreamChunk
|
||||
err = provider.ChatStream(context.Background(), ai.ChatRequest{
|
||||
Messages: []ai.Message{{Role: "user", Content: "ping"}},
|
||||
}, func(chunk ai.StreamChunk) {
|
||||
chunks = append(chunks, chunk)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected stream provider to report authentication error via callback, got %v", err)
|
||||
}
|
||||
if len(chunks) != 1 {
|
||||
t.Fatalf("expected a single terminal error chunk, got %#v", chunks)
|
||||
}
|
||||
if !chunks[0].Done {
|
||||
t.Fatalf("expected terminal error chunk, got %#v", chunks[0])
|
||||
}
|
||||
if strings.Contains(chunks[0].Error, "未收到模型响应") {
|
||||
t.Fatalf("expected auth failure instead of startup timeout, got %#v", chunks[0])
|
||||
}
|
||||
if !strings.Contains(chunks[0].Error, "401") || !strings.Contains(chunks[0].Error, "authentication_failed") {
|
||||
t.Fatalf("expected auth retry error details, got %#v", chunks[0])
|
||||
}
|
||||
}
|
||||
|
||||
func writeFakeClaudeScript(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "claude")
|
||||
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake claude script: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func overrideClaudeCLIForTest(t *testing.T, fakeClaudePath string) func() {
|
||||
t.Helper()
|
||||
|
||||
originalLookPath := claudeLookPath
|
||||
claudeLookPath = func(name string) (string, error) {
|
||||
if name == "claude" {
|
||||
return fakeClaudePath, nil
|
||||
}
|
||||
return originalLookPath(name)
|
||||
}
|
||||
|
||||
originalPath := os.Getenv("PATH")
|
||||
if err := os.Setenv("PATH", filepath.Dir(fakeClaudePath)+string(os.PathListSeparator)+originalPath); err != nil {
|
||||
t.Fatalf("failed to override PATH: %v", err)
|
||||
}
|
||||
|
||||
return func() {
|
||||
claudeLookPath = originalLookPath
|
||||
_ = os.Setenv("PATH", originalPath)
|
||||
}
|
||||
}
|
||||
74
internal/ai/provider/custom.go
Normal file
74
internal/ai/provider/custom.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
// CustomProvider 自定义 Provider,根据 apiFormat 选择底层协议
|
||||
// 支持 openai / anthropic / gemini 三种 API 格式
|
||||
type CustomProvider struct {
|
||||
inner Provider
|
||||
name string
|
||||
}
|
||||
|
||||
// NewCustomProvider 创建自定义 Provider 实例
|
||||
func NewCustomProvider(config ai.ProviderConfig) (Provider, error) {
|
||||
if strings.TrimSpace(config.BaseURL) == "" {
|
||||
return nil, fmt.Errorf("自定义 Provider 必须指定 Base URL")
|
||||
}
|
||||
|
||||
// 根据 apiFormat 决定使用哪个底层协议,默认 openai
|
||||
apiFormat := strings.ToLower(strings.TrimSpace(config.APIFormat))
|
||||
if apiFormat == "" {
|
||||
apiFormat = "openai"
|
||||
}
|
||||
|
||||
var innerProvider Provider
|
||||
var err error
|
||||
switch apiFormat {
|
||||
case "anthropic":
|
||||
innerProvider, err = NewAnthropicProvider(config)
|
||||
case "gemini":
|
||||
innerProvider, err = NewGeminiProvider(config)
|
||||
case "claude-cli":
|
||||
innerProvider, err = NewClaudeCLIProvider(config)
|
||||
default: // "openai" 及其他
|
||||
innerProvider, err = NewOpenAIProvider(config)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(config.Name)
|
||||
if name == "" {
|
||||
name = "Custom"
|
||||
}
|
||||
|
||||
return &CustomProvider{
|
||||
inner: innerProvider,
|
||||
name: name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomProvider) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *CustomProvider) Validate() error {
|
||||
if strings.TrimSpace(p.inner.(interface{ Name() string }).Name()) == "" {
|
||||
// 对自定义 Provider,API Key 可选(部分本地服务不需要)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CustomProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
|
||||
return p.inner.Chat(ctx, req)
|
||||
}
|
||||
|
||||
func (p *CustomProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
|
||||
return p.inner.ChatStream(ctx, req, callback)
|
||||
}
|
||||
293
internal/ai/provider/gemini.go
Normal file
293
internal/ai/provider/gemini.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultGeminiBaseURL = "https://generativelanguage.googleapis.com"
|
||||
)
|
||||
|
||||
// GeminiProvider 实现 Google Gemini API 的 Provider
|
||||
type GeminiProvider struct {
|
||||
config ai.ProviderConfig
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewGeminiProvider 创建 Gemini Provider 实例
|
||||
func NewGeminiProvider(config ai.ProviderConfig) (Provider, error) {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = defaultGeminiBaseURL
|
||||
}
|
||||
model := strings.TrimSpace(config.Model)
|
||||
if model == "" {
|
||||
return nil, fmt.Errorf("模型 ID 不能为空,请在设置中选择或输入模型")
|
||||
}
|
||||
maxTokens := config.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = defaultOpenAIMaxTokens
|
||||
}
|
||||
temperature := config.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = defaultOpenAITemperature
|
||||
}
|
||||
|
||||
normalized := config
|
||||
normalized.BaseURL = baseURL
|
||||
normalized.Model = model
|
||||
normalized.MaxTokens = maxTokens
|
||||
normalized.Temperature = temperature
|
||||
|
||||
return &GeminiProvider{
|
||||
config: normalized,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{Timeout: openAIHTTPTimeout},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) Name() string {
|
||||
if strings.TrimSpace(p.config.Name) != "" {
|
||||
return p.config.Name
|
||||
}
|
||||
return "Gemini"
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) Validate() error {
|
||||
if strings.TrimSpace(p.config.APIKey) == "" {
|
||||
return fmt.Errorf("API Key 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type geminiRequest struct {
|
||||
Contents []geminiContent `json:"contents"`
|
||||
SystemInstruction *geminiContent `json:"systemInstruction,omitempty"`
|
||||
GenerationConfig geminiGenConfig `json:"generationConfig,omitempty"`
|
||||
}
|
||||
|
||||
type geminiContent struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Parts []geminiPart `json:"parts"`
|
||||
}
|
||||
|
||||
type geminiPart struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
InlineData *geminiBlob `json:"inlineData,omitempty"`
|
||||
}
|
||||
|
||||
type geminiBlob struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type geminiGenConfig struct {
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
|
||||
}
|
||||
|
||||
type geminiResponse struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
} `json:"candidates"`
|
||||
UsageMetadata *struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
geminiReq := p.buildRequest(req)
|
||||
|
||||
url := fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s",
|
||||
p.baseURL, p.config.Model, p.config.APIKey)
|
||||
|
||||
respBody, err := p.doRequest(ctx, url, geminiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
var result geminiResponse
|
||||
if err := json.NewDecoder(respBody).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("解析 Gemini 响应失败: %w", err)
|
||||
}
|
||||
if result.Error != nil && result.Error.Message != "" {
|
||||
return nil, fmt.Errorf("Gemini API 错误: %s", result.Error.Message)
|
||||
}
|
||||
if len(result.Candidates) == 0 || len(result.Candidates[0].Content.Parts) == 0 {
|
||||
return nil, fmt.Errorf("Gemini 返回空响应")
|
||||
}
|
||||
|
||||
var tokens ai.TokenUsage
|
||||
if result.UsageMetadata != nil {
|
||||
tokens = ai.TokenUsage{
|
||||
PromptTokens: result.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: result.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: result.UsageMetadata.TotalTokenCount,
|
||||
}
|
||||
}
|
||||
|
||||
var textParts []string
|
||||
for _, part := range result.Candidates[0].Content.Parts {
|
||||
if part.Text != "" {
|
||||
textParts = append(textParts, part.Text)
|
||||
}
|
||||
}
|
||||
|
||||
return &ai.ChatResponse{
|
||||
Content: strings.Join(textParts, ""),
|
||||
TokensUsed: tokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
geminiReq := p.buildRequest(req)
|
||||
|
||||
url := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse&key=%s",
|
||||
p.baseURL, p.config.Model, p.config.APIKey)
|
||||
|
||||
respBody, err := p.doRequest(ctx, url, geminiReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
scanner := bufio.NewScanner(respBody)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
|
||||
var chunk geminiResponse
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(chunk.Candidates) > 0 && len(chunk.Candidates[0].Content.Parts) > 0 {
|
||||
for _, part := range chunk.Candidates[0].Content.Parts {
|
||||
if part.Text != "" {
|
||||
callback(ai.StreamChunk{Content: part.Text})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) buildRequest(req ai.ChatRequest) geminiRequest {
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = p.config.Temperature
|
||||
}
|
||||
|
||||
var systemInstruction *geminiContent
|
||||
var contents []geminiContent
|
||||
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == "system" {
|
||||
systemInstruction = &geminiContent{
|
||||
Parts: []geminiPart{{Text: m.Content}},
|
||||
}
|
||||
continue
|
||||
}
|
||||
role := m.Role
|
||||
if role == "assistant" {
|
||||
role = "model"
|
||||
}
|
||||
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: parts,
|
||||
})
|
||||
}
|
||||
|
||||
return geminiRequest{
|
||||
Contents: contents,
|
||||
SystemInstruction: systemInstruction,
|
||||
GenerationConfig: geminiGenConfig{
|
||||
Temperature: temperature,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) doRequest(ctx context.Context, url string, body interface{}) (io.ReadCloser, error) {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("Gemini API 返回错误 (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
97
internal/ai/provider/helper.go
Normal file
97
internal/ai/provider/helper.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var openAICompatibleVersionSuffixPattern = regexp.MustCompile(`(?i)(^|/)v\d+$`)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// NormalizeOpenAICompatibleBaseURL 统一归一化 OpenAI 兼容服务的 base URL。
|
||||
func NormalizeOpenAICompatibleBaseURL(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return defaultOpenAIBaseURL
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return normalizeOpenAICompatibleBaseURLString(trimmed)
|
||||
}
|
||||
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
parsed.Path = normalizeOpenAICompatiblePath(parsed.Path)
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
// ResolveOpenAICompatibleEndpoint 基于归一化 base URL 拼接 OpenAI 兼容接口路径。
|
||||
func ResolveOpenAICompatibleEndpoint(baseURL string, endpoint string) string {
|
||||
normalizedBaseURL := NormalizeOpenAICompatibleBaseURL(baseURL)
|
||||
normalizedEndpoint := strings.TrimLeft(strings.TrimSpace(endpoint), "/")
|
||||
if normalizedEndpoint == "" {
|
||||
return normalizedBaseURL
|
||||
}
|
||||
return normalizedBaseURL + "/" + normalizedEndpoint
|
||||
}
|
||||
|
||||
func normalizeOpenAICompatibleBaseURLString(raw string) string {
|
||||
normalized := strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
if normalized == "" {
|
||||
return defaultOpenAIBaseURL
|
||||
}
|
||||
|
||||
lower := strings.ToLower(normalized)
|
||||
switch {
|
||||
case strings.HasSuffix(lower, "/chat/completions"):
|
||||
normalized = normalized[:len(normalized)-len("/chat/completions")]
|
||||
case strings.HasSuffix(lower, "/models"):
|
||||
normalized = normalized[:len(normalized)-len("/models")]
|
||||
}
|
||||
normalized = strings.TrimRight(normalized, "/")
|
||||
if openAICompatibleVersionSuffixPattern.MatchString(normalized) {
|
||||
return normalized
|
||||
}
|
||||
return normalized + "/v1"
|
||||
}
|
||||
|
||||
func normalizeOpenAICompatiblePath(path string) string {
|
||||
normalized := strings.TrimRight(strings.TrimSpace(path), "/")
|
||||
lower := strings.ToLower(normalized)
|
||||
switch {
|
||||
case strings.HasSuffix(lower, "/chat/completions"):
|
||||
normalized = normalized[:len(normalized)-len("/chat/completions")]
|
||||
case strings.HasSuffix(lower, "/models"):
|
||||
normalized = normalized[:len(normalized)-len("/models")]
|
||||
}
|
||||
normalized = strings.TrimRight(normalized, "/")
|
||||
if openAICompatibleVersionSuffixPattern.MatchString(normalized) {
|
||||
return normalized
|
||||
}
|
||||
if normalized == "" {
|
||||
return "/v1"
|
||||
}
|
||||
return normalized + "/v1"
|
||||
}
|
||||
424
internal/ai/provider/openai.go
Normal file
424
internal/ai/provider/openai.go
Normal file
@@ -0,0 +1,424 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOpenAIBaseURL = "https://api.openai.com/v1"
|
||||
defaultOpenAIMaxTokens = 4096
|
||||
defaultOpenAITemperature = 0.7
|
||||
openAIHTTPTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
// OpenAIProvider 实现 OpenAI / OpenAI 兼容 API 的 Provider
|
||||
type OpenAIProvider struct {
|
||||
config ai.ProviderConfig
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewOpenAIProvider 创建 OpenAI Provider 实例
|
||||
func NewOpenAIProvider(config ai.ProviderConfig) (Provider, error) {
|
||||
baseURL := NormalizeOpenAICompatibleBaseURL(config.BaseURL)
|
||||
model := strings.TrimSpace(config.Model)
|
||||
if model == "" {
|
||||
return nil, fmt.Errorf("模型 ID 不能为空,请在设置中选择或输入模型")
|
||||
}
|
||||
maxTokens := config.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = defaultOpenAIMaxTokens
|
||||
}
|
||||
temperature := config.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = defaultOpenAITemperature
|
||||
}
|
||||
|
||||
normalized := config
|
||||
normalized.BaseURL = baseURL
|
||||
normalized.Model = model
|
||||
normalized.MaxTokens = maxTokens
|
||||
normalized.Temperature = temperature
|
||||
|
||||
return &OpenAIProvider{
|
||||
config: normalized,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{
|
||||
Timeout: openAIHTTPTimeout,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) Name() string {
|
||||
if strings.TrimSpace(p.config.Name) != "" {
|
||||
return p.config.Name
|
||||
}
|
||||
return "OpenAI"
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) Validate() error {
|
||||
if strings.TrimSpace(p.config.APIKey) == "" {
|
||||
return fmt.Errorf("API Key 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openAIChatRequest OpenAI API 请求体
|
||||
type openAIChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []openAIChatMessage `json:"messages"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Tools []ai.Tool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type openAIChatMessage struct {
|
||||
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"`
|
||||
ToolCalls []ai.ToolCall `json:"tool_calls,omitempty"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// openAIStreamChunk SSE 流式响应片段
|
||||
type 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"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []openAIToolCallDelta `json:"tool_calls,omitempty"`
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
messages := buildOpenAIMessages(req.Messages, p.config.Model, p.baseURL)
|
||||
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = p.config.Temperature
|
||||
}
|
||||
|
||||
body := openAIChatRequest{
|
||||
Model: p.config.Model,
|
||||
Messages: messages,
|
||||
Temperature: temperature,
|
||||
Stream: false,
|
||||
Tools: req.Tools,
|
||||
}
|
||||
|
||||
respBody, err := p.doRequest(ctx, body)
|
||||
if err != nil {
|
||||
// 当带 tools 的请求返回 400 时,自动降级为不带 tools 的纯文本请求
|
||||
if len(req.Tools) > 0 && isHTTP400Error(err) {
|
||||
fmt.Println("[OpenAI] 模型不支持 Function Calling,自动降级为纯文本模式")
|
||||
body.Tools = nil
|
||||
respBody, err = p.doRequest(ctx, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
var result openAIChatResponse
|
||||
if err := json.NewDecoder(respBody).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("解析 OpenAI 响应失败: %w", err)
|
||||
}
|
||||
if result.Error != nil && result.Error.Message != "" {
|
||||
return nil, fmt.Errorf("OpenAI API 错误: %s", result.Error.Message)
|
||||
}
|
||||
if len(result.Choices) == 0 {
|
||||
return nil, fmt.Errorf("OpenAI 返回空响应")
|
||||
}
|
||||
|
||||
return &ai.ChatResponse{
|
||||
Content: result.Choices[0].Message.Content,
|
||||
TokensUsed: ai.TokenUsage{
|
||||
PromptTokens: result.Usage.PromptTokens,
|
||||
CompletionTokens: result.Usage.CompletionTokens,
|
||||
TotalTokens: result.Usage.TotalTokens,
|
||||
},
|
||||
ToolCalls: result.Choices[0].Message.ToolCalls,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
messages := buildOpenAIMessages(req.Messages, p.config.Model, p.baseURL)
|
||||
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = p.config.Temperature
|
||||
}
|
||||
|
||||
body := openAIChatRequest{
|
||||
Model: p.config.Model,
|
||||
Messages: messages,
|
||||
Temperature: temperature,
|
||||
Stream: true,
|
||||
Tools: req.Tools,
|
||||
}
|
||||
|
||||
respBody, err := p.doRequest(ctx, body)
|
||||
if err != nil {
|
||||
// 当带 tools 的请求返回 400 时,自动降级为不带 tools 的纯文本请求
|
||||
if len(req.Tools) > 0 && isHTTP400Error(err) {
|
||||
fmt.Println("[OpenAI] 模型不支持 Function Calling,自动降级为纯文本模式")
|
||||
body.Tools = nil
|
||||
respBody, err = p.doRequest(ctx, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
receivedContent := false
|
||||
var activeToolCalls []ai.ToolCall
|
||||
|
||||
scanner := bufio.NewScanner(respBody)
|
||||
// 增大 scanner buffer,防止长行被截断
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
// 非 SSE 数据行,可能是错误信息,记录日志
|
||||
if strings.Contains(line, "error") || strings.Contains(line, "Error") {
|
||||
callback(ai.StreamChunk{Error: fmt.Sprintf("服务端返回异常: %s", line), Done: true})
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
var chunk openAIStreamChunk
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue // 跳过格式异常的行
|
||||
}
|
||||
if chunk.Error != nil && chunk.Error.Message != "" {
|
||||
callback(ai.StreamChunk{Error: fmt.Sprintf("API 错误: %s", chunk.Error.Message), Done: true})
|
||||
return nil
|
||||
}
|
||||
if len(chunk.Choices) > 0 {
|
||||
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})
|
||||
}
|
||||
|
||||
// 支持 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("读取 OpenAI 流式响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 如果流正常结束但没有收到任何内容,可能是 API 响应格式不兼容
|
||||
if !receivedContent {
|
||||
callback(ai.StreamChunk{Error: "未收到任何有效响应内容,请检查 API 端点和模型是否正确", Done: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
url := ResolveOpenAICompatibleEndpoint(p.baseURL, "chat/completions")
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey)
|
||||
|
||||
// 仅在流式请求时明确声明 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)
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送请求到 %s 失败: %w", url, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("OpenAI API 返回错误 (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// isHTTP400Error 检查错误是否为 HTTP 4xx 客户端错误(400/422 等),
|
||||
// 通常表示模型不支持请求中的某些参数(如 tools/functions)。
|
||||
func isHTTP400Error(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "(HTTP 400)") ||
|
||||
strings.Contains(msg, "(HTTP 422)") ||
|
||||
strings.Contains(msg, "(HTTP 404)")
|
||||
}
|
||||
167
internal/ai/provider/openai_test.go
Normal file
167
internal/ai/provider/openai_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/ai"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeOpenAICompatibleBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty uses default openai base url",
|
||||
raw: "",
|
||||
want: "https://api.openai.com/v1",
|
||||
},
|
||||
{
|
||||
name: "domain only appends v1",
|
||||
raw: "https://api.openai.com",
|
||||
want: "https://api.openai.com/v1",
|
||||
},
|
||||
{
|
||||
name: "keeps existing v1 suffix",
|
||||
raw: "https://api.deepseek.com/v1",
|
||||
want: "https://api.deepseek.com/v1",
|
||||
},
|
||||
{
|
||||
name: "keeps dashscope compatible mode path",
|
||||
raw: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
want: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
},
|
||||
{
|
||||
name: "keeps zhipu v4 path",
|
||||
raw: "https://open.bigmodel.cn/api/paas/v4",
|
||||
want: "https://open.bigmodel.cn/api/paas/v4",
|
||||
},
|
||||
{
|
||||
name: "keeps volcengine ark v3 path",
|
||||
raw: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
want: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
},
|
||||
{
|
||||
name: "keeps volcengine coding plan v3 path",
|
||||
raw: "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
want: "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
},
|
||||
{
|
||||
name: "strips chat completions suffix before normalizing",
|
||||
raw: "https://api.openai.com/v1/chat/completions",
|
||||
want: "https://api.openai.com/v1",
|
||||
},
|
||||
{
|
||||
name: "strips models suffix before normalizing",
|
||||
raw: "https://ark.cn-beijing.volces.com/api/coding/v3/models",
|
||||
want: "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := NormalizeOpenAICompatibleBaseURL(tt.raw); got != tt.want {
|
||||
t.Fatalf("expected normalized base url %q, got %q", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenAICompatibleEndpoint(t *testing.T) {
|
||||
got := ResolveOpenAICompatibleEndpoint("https://ark.cn-beijing.volces.com/api/coding/v3/models", "chat/completions")
|
||||
want := "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions"
|
||||
if got != want {
|
||||
t.Fatalf("expected endpoint %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_Validate_MissingAPIKey(t *testing.T) {
|
||||
p, err := NewOpenAIProvider(ai.ProviderConfig{Type: "openai", Model: "gpt-4o"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected constructor error: %v", err)
|
||||
}
|
||||
if err := p.Validate(); err == nil {
|
||||
t.Fatal("expected validation error for missing API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_Validate_Valid(t *testing.T) {
|
||||
p, err := NewOpenAIProvider(ai.ProviderConfig{
|
||||
Type: "openai", APIKey: "sk-test-key", Model: "gpt-4o",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected constructor error: %v", err)
|
||||
}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatalf("unexpected validation error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_Name_Custom(t *testing.T) {
|
||||
p, err := NewOpenAIProvider(ai.ProviderConfig{
|
||||
Type: "openai", Name: "My OpenAI", APIKey: "sk-test", Model: "gpt-4o",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected constructor error: %v", err)
|
||||
}
|
||||
if p.Name() != "My OpenAI" {
|
||||
t.Fatalf("expected name 'My OpenAI', got '%s'", p.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_Name_Default(t *testing.T) {
|
||||
p, err := NewOpenAIProvider(ai.ProviderConfig{
|
||||
Type: "openai", APIKey: "sk-test", Model: "gpt-4o",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected constructor error: %v", err)
|
||||
}
|
||||
if p.Name() != "OpenAI" {
|
||||
t.Fatalf("expected default name 'OpenAI', got '%s'", p.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_DefaultBaseURL(t *testing.T) {
|
||||
p, _ := NewOpenAIProvider(ai.ProviderConfig{
|
||||
Type: "openai", APIKey: "sk-test", Model: "gpt-4o",
|
||||
})
|
||||
op := p.(*OpenAIProvider)
|
||||
if op.baseURL != "https://api.openai.com/v1" {
|
||||
t.Fatalf("expected default base URL, got '%s'", op.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_CustomBaseURL(t *testing.T) {
|
||||
p, err := NewOpenAIProvider(ai.ProviderConfig{
|
||||
Type: "openai", APIKey: "sk-test", BaseURL: "https://my-proxy.com/v1", Model: "gpt-4o",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected constructor error: %v", err)
|
||||
}
|
||||
op := p.(*OpenAIProvider)
|
||||
if op.baseURL != "https://my-proxy.com/v1" {
|
||||
t.Fatalf("expected custom base URL, got '%s'", op.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_RejectsMissingModel(t *testing.T) {
|
||||
_, err := NewOpenAIProvider(ai.ProviderConfig{
|
||||
Type: "openai", APIKey: "sk-test",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected constructor error for missing model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_DefaultMaxTokens(t *testing.T) {
|
||||
p, err := NewOpenAIProvider(ai.ProviderConfig{
|
||||
Type: "openai", APIKey: "sk-test", Model: "gpt-4o",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected constructor error: %v", err)
|
||||
}
|
||||
op := p.(*OpenAIProvider)
|
||||
if op.config.MaxTokens != 4096 {
|
||||
t.Fatalf("expected default max tokens 4096, got %d", op.config.MaxTokens)
|
||||
}
|
||||
}
|
||||
19
internal/ai/provider/provider.go
Normal file
19
internal/ai/provider/provider.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
// Provider AI 模型提供者接口
|
||||
type Provider interface {
|
||||
// Chat 发送消息并获取完整响应
|
||||
Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error)
|
||||
// ChatStream 发送消息并以流式返回
|
||||
ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error
|
||||
// Name 返回 Provider 名称
|
||||
Name() string
|
||||
// Validate 校验配置是否有效
|
||||
Validate() error
|
||||
}
|
||||
25
internal/ai/provider/registry.go
Normal file
25
internal/ai/provider/registry.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
// NewProvider 根据配置创建 Provider 实例
|
||||
func NewProvider(config ai.ProviderConfig) (Provider, error) {
|
||||
providerType := strings.ToLower(strings.TrimSpace(config.Type))
|
||||
switch providerType {
|
||||
case "openai":
|
||||
return NewOpenAIProvider(config)
|
||||
case "anthropic":
|
||||
return NewAnthropicProvider(config)
|
||||
case "gemini":
|
||||
return NewGeminiProvider(config)
|
||||
case "custom":
|
||||
return NewCustomProvider(config)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的 AI Provider 类型: %s", config.Type)
|
||||
}
|
||||
}
|
||||
101
internal/ai/safety/classifier.go
Normal file
101
internal/ai/safety/classifier.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package safety
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
// ClassifySQL 分类 SQL 语句的操作类型
|
||||
func ClassifySQL(sql string) ai.SQLOperationType {
|
||||
keyword := leadingSQLKeyword(sql)
|
||||
switch keyword {
|
||||
case "select", "with", "show", "describe", "desc", "explain", "pragma", "values":
|
||||
return ai.SQLOpQuery
|
||||
case "insert", "update", "delete", "replace", "merge", "upsert":
|
||||
return ai.SQLOpDML
|
||||
case "create", "alter", "drop", "truncate", "rename":
|
||||
return ai.SQLOpDDL
|
||||
default:
|
||||
return ai.SQLOpOther
|
||||
}
|
||||
}
|
||||
|
||||
// IsHighRiskSQL 判断 SQL 是否为高风险语句
|
||||
func IsHighRiskSQL(sql string) (bool, string) {
|
||||
keyword := leadingSQLKeyword(sql)
|
||||
normalized := strings.ToLower(sql)
|
||||
|
||||
switch keyword {
|
||||
case "drop":
|
||||
return true, "⚠️ 高危操作:DROP 语句将永久删除数据库对象"
|
||||
case "truncate":
|
||||
return true, "⚠️ 高危操作:TRUNCATE 将清空表中所有数据"
|
||||
case "delete":
|
||||
if !containsWhereClause(normalized) {
|
||||
return true, "⚠️ 高危操作:DELETE 语句缺少 WHERE 条件,将删除所有数据"
|
||||
}
|
||||
case "update":
|
||||
if !containsWhereClause(normalized) {
|
||||
return true, "⚠️ 高危操作:UPDATE 语句缺少 WHERE 条件,将更新所有记录"
|
||||
}
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// containsWhereClause 简单判断 SQL 是否包含 WHERE 子句
|
||||
func containsWhereClause(normalizedSQL string) bool {
|
||||
return strings.Contains(normalizedSQL, " where ") ||
|
||||
strings.Contains(normalizedSQL, "\nwhere ") ||
|
||||
strings.Contains(normalizedSQL, "\twhere ")
|
||||
}
|
||||
|
||||
// leadingSQLKeyword 提取 SQL 语句的首个关键字(跳过注释和空白)
|
||||
func leadingSQLKeyword(query string) string {
|
||||
text := strings.TrimSpace(query)
|
||||
for len(text) > 0 {
|
||||
trimmed := strings.TrimLeft(text, " \t\r\n")
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
text = trimmed
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(text, "--"):
|
||||
if idx := strings.IndexByte(text, '\n'); idx >= 0 {
|
||||
text = text[idx+1:]
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
case strings.HasPrefix(text, "#"):
|
||||
if idx := strings.IndexByte(text, '\n'); idx >= 0 {
|
||||
text = text[idx+1:]
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
case strings.HasPrefix(text, "/*"):
|
||||
if idx := strings.Index(text, "*/"); idx >= 0 {
|
||||
text = text[idx+2:]
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
for i, r := range text {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
|
||||
continue
|
||||
}
|
||||
if i == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(text[:i])
|
||||
}
|
||||
return strings.ToLower(text)
|
||||
}
|
||||
145
internal/ai/safety/classifier_test.go
Normal file
145
internal/ai/safety/classifier_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package safety
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/ai"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifySQL(t *testing.T) {
|
||||
tests := []struct {
|
||||
sql string
|
||||
want ai.SQLOperationType
|
||||
}{
|
||||
{"SELECT * FROM users", ai.SQLOpQuery},
|
||||
{" select id from t", ai.SQLOpQuery},
|
||||
{"SHOW TABLES", ai.SQLOpQuery},
|
||||
{"DESCRIBE users", ai.SQLOpQuery},
|
||||
{"DESC users", ai.SQLOpQuery},
|
||||
{"EXPLAIN SELECT 1", ai.SQLOpQuery},
|
||||
{"WITH cte AS (SELECT 1) SELECT * FROM cte", ai.SQLOpQuery},
|
||||
{"PRAGMA table_info(t)", ai.SQLOpQuery},
|
||||
{"VALUES (1, 2)", ai.SQLOpQuery},
|
||||
{"INSERT INTO users VALUES (1)", ai.SQLOpDML},
|
||||
{"UPDATE users SET name='x'", ai.SQLOpDML},
|
||||
{"DELETE FROM users WHERE id=1", ai.SQLOpDML},
|
||||
{"REPLACE INTO users VALUES (1)", ai.SQLOpDML},
|
||||
{"MERGE INTO t USING s ON t.id=s.id", ai.SQLOpDML},
|
||||
{"CREATE TABLE t (id INT)", ai.SQLOpDDL},
|
||||
{"ALTER TABLE t ADD col INT", ai.SQLOpDDL},
|
||||
{"DROP TABLE t", ai.SQLOpDDL},
|
||||
{"TRUNCATE TABLE t", ai.SQLOpDDL},
|
||||
{"RENAME TABLE old TO new", ai.SQLOpDDL},
|
||||
{"/* comment */ SELECT 1", ai.SQLOpQuery},
|
||||
{"-- comment\nDELETE FROM t", ai.SQLOpDML},
|
||||
{"-- line1\n-- line2\nSELECT 1", ai.SQLOpQuery},
|
||||
{"/* block */ -- line\nUPDATE t SET x=1", ai.SQLOpDML},
|
||||
{"", ai.SQLOpOther},
|
||||
{" ", ai.SQLOpOther},
|
||||
{"-- only comment", ai.SQLOpOther},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := ClassifySQL(tt.sql)
|
||||
if got != tt.want {
|
||||
t.Errorf("ClassifySQL(%q) = %s, want %s", tt.sql, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHighRiskSQL(t *testing.T) {
|
||||
tests := []struct {
|
||||
sql string
|
||||
highRisk bool
|
||||
}{
|
||||
{"DROP TABLE users", true},
|
||||
{"DROP DATABASE test", true},
|
||||
{"TRUNCATE TABLE users", true},
|
||||
{"DELETE FROM users", true}, // 无 WHERE
|
||||
{"DELETE FROM users WHERE id=1", false}, // 有 WHERE
|
||||
{"UPDATE users SET name='x'", true}, // 无 WHERE
|
||||
{"UPDATE users SET name='x' WHERE id=1", false}, // 有 WHERE
|
||||
{"SELECT * FROM users", false},
|
||||
{"INSERT INTO users VALUES (1)", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
highRisk, _ := IsHighRiskSQL(tt.sql)
|
||||
if highRisk != tt.highRisk {
|
||||
t.Errorf("IsHighRiskSQL(%q) = %v, want %v", tt.sql, highRisk, tt.highRisk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuard_ReadOnly(t *testing.T) {
|
||||
g := NewGuard(ai.PermissionReadOnly)
|
||||
tests := []struct {
|
||||
sql string
|
||||
allowed bool
|
||||
}{
|
||||
{"SELECT * FROM t", true},
|
||||
{"INSERT INTO t VALUES (1)", false},
|
||||
{"UPDATE t SET x=1", false},
|
||||
{"DELETE FROM t", false},
|
||||
{"DROP TABLE t", false},
|
||||
{"CREATE TABLE t (id INT)", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
result := g.Check(tt.sql)
|
||||
if result.Allowed != tt.allowed {
|
||||
t.Errorf("Guard[readonly].Check(%q).Allowed = %v, want %v", tt.sql, result.Allowed, tt.allowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuard_ReadWrite(t *testing.T) {
|
||||
g := NewGuard(ai.PermissionReadWrite)
|
||||
tests := []struct {
|
||||
sql string
|
||||
allowed bool
|
||||
confirm bool
|
||||
}{
|
||||
{"SELECT * FROM t", true, false},
|
||||
{"INSERT INTO t VALUES (1)", true, true},
|
||||
{"UPDATE t SET x=1", true, true}, // 允许但需确认
|
||||
{"DELETE FROM t WHERE id=1", true, true}, // 允许但需确认
|
||||
{"DROP TABLE t", false, true}, // DDL 不允许
|
||||
{"CREATE TABLE t (id INT)", false, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
result := g.Check(tt.sql)
|
||||
if result.Allowed != tt.allowed {
|
||||
t.Errorf("Guard[readwrite].Check(%q).Allowed = %v, want %v", tt.sql, result.Allowed, tt.allowed)
|
||||
}
|
||||
if result.RequiresConfirm != tt.confirm {
|
||||
t.Errorf("Guard[readwrite].Check(%q).RequiresConfirm = %v, want %v", tt.sql, result.RequiresConfirm, tt.confirm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuard_Full(t *testing.T) {
|
||||
g := NewGuard(ai.PermissionFull)
|
||||
tests := []struct {
|
||||
sql string
|
||||
allowed bool
|
||||
}{
|
||||
{"SELECT * FROM t", true},
|
||||
{"INSERT INTO t VALUES (1)", true},
|
||||
{"DROP TABLE t", true},
|
||||
{"CREATE TABLE t (id INT)", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
result := g.Check(tt.sql)
|
||||
if result.Allowed != tt.allowed {
|
||||
t.Errorf("Guard[full].Check(%q).Allowed = %v, want %v", tt.sql, result.Allowed, tt.allowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuard_HighRiskWarning(t *testing.T) {
|
||||
g := NewGuard(ai.PermissionFull)
|
||||
result := g.Check("DELETE FROM users")
|
||||
if result.WarningMessage == "" {
|
||||
t.Error("expected high-risk warning for DELETE without WHERE")
|
||||
}
|
||||
if !result.RequiresConfirm {
|
||||
t.Error("expected RequiresConfirm for high-risk SQL")
|
||||
}
|
||||
}
|
||||
71
internal/ai/safety/guard.go
Normal file
71
internal/ai/safety/guard.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package safety
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
// Guard AI SQL 安全策略守卫
|
||||
type Guard struct {
|
||||
permissionLevel ai.SQLPermissionLevel
|
||||
}
|
||||
|
||||
// NewGuard 创建安全策略守卫
|
||||
func NewGuard(level ai.SQLPermissionLevel) *Guard {
|
||||
return &Guard{permissionLevel: level}
|
||||
}
|
||||
|
||||
// SetPermissionLevel 设置权限级别
|
||||
func (g *Guard) SetPermissionLevel(level ai.SQLPermissionLevel) {
|
||||
g.permissionLevel = level
|
||||
}
|
||||
|
||||
// GetPermissionLevel 获取当前权限级别
|
||||
func (g *Guard) GetPermissionLevel() ai.SQLPermissionLevel {
|
||||
return g.permissionLevel
|
||||
}
|
||||
|
||||
// Check 检查 AI 生成的 SQL 是否在允许范围内
|
||||
func (g *Guard) Check(sql string) ai.SafetyResult {
|
||||
opType := ClassifySQL(sql)
|
||||
allowed := g.isAllowed(opType)
|
||||
requiresConfirm := g.requiresConfirmation(opType)
|
||||
warningMessage := ""
|
||||
|
||||
if isHighRisk, msg := IsHighRiskSQL(sql); isHighRisk {
|
||||
warningMessage = msg
|
||||
requiresConfirm = true
|
||||
}
|
||||
|
||||
return ai.SafetyResult{
|
||||
Allowed: allowed,
|
||||
OperationType: opType,
|
||||
RequiresConfirm: requiresConfirm,
|
||||
WarningMessage: warningMessage,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Guard) isAllowed(opType ai.SQLOperationType) bool {
|
||||
switch g.permissionLevel {
|
||||
case ai.PermissionReadOnly:
|
||||
return opType == ai.SQLOpQuery
|
||||
case ai.PermissionReadWrite:
|
||||
return opType == ai.SQLOpQuery || opType == ai.SQLOpDML
|
||||
case ai.PermissionFull:
|
||||
return opType == ai.SQLOpQuery || opType == ai.SQLOpDML || opType == ai.SQLOpDDL
|
||||
default:
|
||||
return opType == ai.SQLOpQuery
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Guard) requiresConfirmation(opType ai.SQLOperationType) bool {
|
||||
switch opType {
|
||||
case ai.SQLOpQuery:
|
||||
return false
|
||||
case ai.SQLOpDML:
|
||||
return true // DML 始终需要确认
|
||||
case ai.SQLOpDDL:
|
||||
return true // DDL 始终需要确认
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
1049
internal/ai/service/service.go
Normal file
1049
internal/ai/service/service.go
Normal file
File diff suppressed because it is too large
Load Diff
157
internal/ai/service/service_qwen_test.go
Normal file
157
internal/ai/service/service_qwen_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package aiservice
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
func TestDefaultStaticModelsForProvider_DoesNotReturnBailianStaticModels(t *testing.T) {
|
||||
models := defaultStaticModelsForProvider(ai.ProviderConfig{
|
||||
Type: "anthropic",
|
||||
BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
})
|
||||
if len(models) != 0 {
|
||||
t.Fatalf("expected Bailian provider to rely on remote model list, got %v", models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStaticModelsForProvider_ReturnsDashScopeCodingPlanSupportedModels(t *testing.T) {
|
||||
expected := []string{
|
||||
"qwen3.5-plus",
|
||||
"kimi-k2.5",
|
||||
"glm-5",
|
||||
"MiniMax-M2.5",
|
||||
"qwen3-max-2026-01-23",
|
||||
"qwen3-coder-next",
|
||||
"qwen3-coder-plus",
|
||||
"glm-4.7",
|
||||
}
|
||||
testCases := []ai.ProviderConfig{
|
||||
{
|
||||
Type: "anthropic",
|
||||
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
||||
},
|
||||
{
|
||||
Type: "custom",
|
||||
APIFormat: "claude-cli",
|
||||
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
models := defaultStaticModelsForProvider(testCase)
|
||||
if !reflect.DeepEqual(models, expected) {
|
||||
t.Fatalf("expected Coding Plan supported models %v, got %v for config %#v", expected, models, testCase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProviderConfig_DoesNotForceModelForDashScopeProviders(t *testing.T) {
|
||||
bailian := normalizeProviderConfig(ai.ProviderConfig{
|
||||
Type: "anthropic",
|
||||
BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
})
|
||||
if bailian.Model != "" {
|
||||
t.Fatalf("expected Bailian model to remain empty until explicit selection, got %q", bailian.Model)
|
||||
}
|
||||
|
||||
codingPlan := normalizeProviderConfig(ai.ProviderConfig{
|
||||
Type: "anthropic",
|
||||
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
||||
})
|
||||
if codingPlan.Type != "custom" {
|
||||
t.Fatalf("expected Coding Plan provider type to normalize to custom, got %q", codingPlan.Type)
|
||||
}
|
||||
if codingPlan.APIFormat != "claude-cli" {
|
||||
t.Fatalf("expected Coding Plan provider api format to normalize to claude-cli, got %q", codingPlan.APIFormat)
|
||||
}
|
||||
if codingPlan.Model != "" {
|
||||
t.Fatalf("expected Coding Plan model to remain empty until explicit selection, got %q", codingPlan.Model)
|
||||
}
|
||||
if len(codingPlan.Models) == 0 {
|
||||
t.Fatal("expected Coding Plan provider to expose official supported models")
|
||||
}
|
||||
if codingPlan.Models[0] != "qwen3.5-plus" {
|
||||
t.Fatalf("expected Coding Plan provider to expose latest supported models, got %v", codingPlan.Models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveModelsURL_UsesDashScopeCompatibleModelsEndpointForBailianAnthropic(t *testing.T) {
|
||||
url := resolveModelsURL(ai.ProviderConfig{
|
||||
Type: "anthropic",
|
||||
BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
})
|
||||
if url != "https://dashscope.aliyuncs.com/compatible-mode/v1/models" {
|
||||
t.Fatalf("expected Bailian models endpoint, got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIListModels_ReturnsStaticModelsForDashScopeCodingPlanWithoutRemoteFetch(t *testing.T) {
|
||||
originalFetchModelsFunc := fetchModelsFunc
|
||||
fetchModelsFunc = func(config ai.ProviderConfig) ([]string, error) {
|
||||
t.Fatalf("expected Coding Plan model list to stay static and skip remote fetch, got config %#v", config)
|
||||
return nil, nil
|
||||
}
|
||||
defer func() {
|
||||
fetchModelsFunc = originalFetchModelsFunc
|
||||
}()
|
||||
|
||||
service := NewService()
|
||||
service.providers = []ai.ProviderConfig{
|
||||
{
|
||||
ID: "provider-coding-plan",
|
||||
Type: "anthropic",
|
||||
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
||||
},
|
||||
}
|
||||
service.activeProvider = "provider-coding-plan"
|
||||
|
||||
result := service.AIListModels()
|
||||
if result["success"] != true {
|
||||
t.Fatalf("expected AIListModels to succeed, got %#v", result)
|
||||
}
|
||||
models, ok := result["models"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("expected []string models, got %#v", result["models"])
|
||||
}
|
||||
if len(models) == 0 || models[0] != "qwen3.5-plus" {
|
||||
t.Fatalf("expected official static Coding Plan models, got %#v", models)
|
||||
}
|
||||
if source, _ := result["source"].(string); source != "static" {
|
||||
t.Fatalf("expected static source, got %#v", result["source"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAITestProvider_UsesClaudeCLIHealthCheckForDashScopeCodingPlan(t *testing.T) {
|
||||
originalClaudeCLIHealthCheckFunc := claudeCLIHealthCheckFunc
|
||||
defer func() {
|
||||
claudeCLIHealthCheckFunc = originalClaudeCLIHealthCheckFunc
|
||||
}()
|
||||
|
||||
var received ai.ProviderConfig
|
||||
claudeCLIHealthCheckFunc = func(config ai.ProviderConfig) error {
|
||||
received = config
|
||||
return nil
|
||||
}
|
||||
|
||||
service := NewService()
|
||||
result := service.AITestProvider(ai.ProviderConfig{
|
||||
Type: "anthropic",
|
||||
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
||||
APIKey: "sk-test",
|
||||
})
|
||||
if result["success"] != true {
|
||||
t.Fatalf("expected AITestProvider to succeed, got %#v", result)
|
||||
}
|
||||
if received.Type != "custom" {
|
||||
t.Fatalf("expected Coding Plan test to use custom provider type, got %q", received.Type)
|
||||
}
|
||||
if received.APIFormat != "claude-cli" {
|
||||
t.Fatalf("expected Coding Plan test to use claude-cli api format, got %q", received.APIFormat)
|
||||
}
|
||||
if received.Model != "qwen3.5-plus" {
|
||||
t.Fatalf("expected Coding Plan test to default probe model to qwen3.5-plus, got %q", received.Model)
|
||||
}
|
||||
}
|
||||
152
internal/ai/service/service_test.go
Normal file
152
internal/ai/service/service_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package aiservice
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
func TestResolveModelsURL_UsesMoonshotOpenAIModelsEndpointForKimiAnthropicBaseURL(t *testing.T) {
|
||||
url := resolveModelsURL(ai.ProviderConfig{
|
||||
Type: "anthropic",
|
||||
BaseURL: "https://api.moonshot.cn/anthropic",
|
||||
})
|
||||
if url != "https://api.moonshot.cn/v1/models" {
|
||||
t.Fatalf("expected moonshot models endpoint, got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveModelsURL_UsesAnthropicModelsEndpointForOfficialAnthropic(t *testing.T) {
|
||||
url := resolveModelsURL(ai.ProviderConfig{
|
||||
Type: "anthropic",
|
||||
BaseURL: "https://api.anthropic.com",
|
||||
})
|
||||
if url != "https://api.anthropic.com/v1/models" {
|
||||
t.Fatalf("expected anthropic models endpoint, got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveModelsURL_UsesOpenAIModelsEndpointForOpenAICompatibleProvider(t *testing.T) {
|
||||
url := resolveModelsURL(ai.ProviderConfig{
|
||||
Type: "openai",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
})
|
||||
if url != "https://api.openai.com/v1/models" {
|
||||
t.Fatalf("expected openai models endpoint, got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveModelsURL_UsesVersionedVolcengineCodingPlanPath(t *testing.T) {
|
||||
url := resolveModelsURL(ai.ProviderConfig{
|
||||
Type: "openai",
|
||||
BaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
})
|
||||
if url != "https://ark.cn-beijing.volces.com/api/coding/v3/models" {
|
||||
t.Fatalf("expected volcengine coding plan models endpoint, got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveModelsURL_UsesVersionedZhipuPath(t *testing.T) {
|
||||
url := resolveModelsURL(ai.ProviderConfig{
|
||||
Type: "openai",
|
||||
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
|
||||
})
|
||||
if url != "https://open.bigmodel.cn/api/paas/v4/models" {
|
||||
t.Fatalf("expected zhipu models endpoint, got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewModelsRequest_StripsChatCompletionsSuffixForOpenAICompatibleProvider(t *testing.T) {
|
||||
req, err := newModelsRequest(ai.ProviderConfig{
|
||||
Type: "openai",
|
||||
BaseURL: "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
|
||||
APIKey: "sk-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if req.URL.String() != "https://ark.cn-beijing.volces.com/api/v3/models" {
|
||||
t.Fatalf("expected normalized models endpoint, got %q", req.URL.String())
|
||||
}
|
||||
if got := req.Header.Get("Authorization"); got != "Bearer sk-test" {
|
||||
t.Fatalf("expected bearer auth header, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStaticModelsForProvider_ReturnsMiniMaxAnthropicModels(t *testing.T) {
|
||||
models := defaultStaticModelsForProvider(ai.ProviderConfig{
|
||||
Type: "anthropic",
|
||||
BaseURL: "https://api.minimaxi.com/anthropic",
|
||||
})
|
||||
expected := []string{
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
"MiniMax-M2.5",
|
||||
"MiniMax-M2.5-highspeed",
|
||||
"MiniMax-M2.1",
|
||||
"MiniMax-M2.1-highspeed",
|
||||
"MiniMax-M2",
|
||||
}
|
||||
if !reflect.DeepEqual(models, expected) {
|
||||
t.Fatalf("expected MiniMax static models %v, got %v", expected, models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStaticModelsForProvider_DoesNotReturnDashScopeBailianStaticModels(t *testing.T) {
|
||||
models := defaultStaticModelsForProvider(ai.ProviderConfig{
|
||||
Type: "anthropic",
|
||||
BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
})
|
||||
if len(models) != 0 {
|
||||
t.Fatalf("expected Bailian provider to fetch models remotely, got %v", models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewProviderHealthCheckRequest_UsesMessagesEndpointForMiniMaxAnthropic(t *testing.T) {
|
||||
req, err := newProviderHealthCheckRequest(ai.ProviderConfig{
|
||||
Type: "anthropic",
|
||||
BaseURL: "https://api.minimaxi.com/anthropic",
|
||||
Model: "MiniMax-M2.7",
|
||||
APIKey: "sk-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if req.Method != "POST" {
|
||||
t.Fatalf("expected POST request, got %s", req.Method)
|
||||
}
|
||||
if req.URL.String() != "https://api.minimaxi.com/anthropic/v1/messages" {
|
||||
t.Fatalf("expected MiniMax messages endpoint, got %q", req.URL.String())
|
||||
}
|
||||
if got := req.Header.Get("x-api-key"); got != "sk-test" {
|
||||
t.Fatalf("expected x-api-key header to be set, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewProviderHealthCheckRequest_UsesMessagesEndpointForDashScopeAnthropic(t *testing.T) {
|
||||
req, err := newProviderHealthCheckRequest(ai.ProviderConfig{
|
||||
Type: "anthropic",
|
||||
BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
Model: "qwen3.5-plus",
|
||||
APIKey: "sk-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if req.Method != "POST" {
|
||||
t.Fatalf("expected POST request, got %s", req.Method)
|
||||
}
|
||||
if req.URL.String() != "https://dashscope.aliyuncs.com/apps/anthropic/v1/messages" {
|
||||
t.Fatalf("expected DashScope messages endpoint, got %q", req.URL.String())
|
||||
}
|
||||
if got := req.Header.Get("x-api-key"); got != "sk-test" {
|
||||
t.Fatalf("expected x-api-key header to be set, got %q", got)
|
||||
}
|
||||
if got := req.Header.Get("Authorization"); got != "Bearer sk-test" {
|
||||
t.Fatalf("expected bearer authorization header, got %q", got)
|
||||
}
|
||||
if got := req.Header.Get("anthropic-version"); got != "" {
|
||||
t.Fatalf("expected no anthropic-version header for DashScope, got %q", got)
|
||||
}
|
||||
}
|
||||
111
internal/ai/service/service_volcengine_test.go
Normal file
111
internal/ai/service/service_volcengine_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package aiservice
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
func TestIsVolcengineCodingPlanProvider_MatchesCodingPlanBaseURL(t *testing.T) {
|
||||
if !isVolcengineCodingPlanProvider(ai.ProviderConfig{
|
||||
Type: "openai",
|
||||
BaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
}) {
|
||||
t.Fatal("expected volcengine coding plan provider to be detected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterVolcengineCodingPlanModels_KeepsOnlySupportedFamilies(t *testing.T) {
|
||||
filtered := filterVolcengineCodingPlanModels([]string{
|
||||
"Auto",
|
||||
"qwen3-14b-20250429",
|
||||
"wan2-1-14b-t2v-250225",
|
||||
"Doubao-Seed-2.0-Code",
|
||||
"Doubao-Seed-2.0-pro",
|
||||
"Doubao-Seed-2.0-lite",
|
||||
"doubao-seed-code-32k-250615",
|
||||
"MiniMax-M2.5",
|
||||
"GLM-4.7",
|
||||
"DeepSeek-V3.2",
|
||||
"kimi-k2-turbo-preview",
|
||||
})
|
||||
|
||||
expected := []string{
|
||||
"Auto",
|
||||
"Doubao-Seed-2.0-Code",
|
||||
"Doubao-Seed-2.0-pro",
|
||||
"Doubao-Seed-2.0-lite",
|
||||
"doubao-seed-code-32k-250615",
|
||||
"MiniMax-M2.5",
|
||||
"GLM-4.7",
|
||||
"DeepSeek-V3.2",
|
||||
"kimi-k2-turbo-preview",
|
||||
}
|
||||
if !reflect.DeepEqual(filtered, expected) {
|
||||
t.Fatalf("expected filtered models %v, got %v", expected, filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterVolcengineCodingPlanModels_DoesNotBroadlyMatchAutoKeyword(t *testing.T) {
|
||||
filtered := filterVolcengineCodingPlanModels([]string{
|
||||
"Auto",
|
||||
"automatic-router-preview",
|
||||
})
|
||||
|
||||
expected := []string{"Auto"}
|
||||
if !reflect.DeepEqual(filtered, expected) {
|
||||
t.Fatalf("expected only exact Auto model to remain, got %v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterFetchedModelsForProvider_DoesNotFilterVolcengineArk(t *testing.T) {
|
||||
rawModels := []string{
|
||||
"qwen3-14b-20250429",
|
||||
"wan2-1-14b-t2v-250225",
|
||||
}
|
||||
|
||||
filtered, err := filterFetchedModelsForProvider(ai.ProviderConfig{
|
||||
Type: "openai",
|
||||
BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
}, rawModels)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(filtered, rawModels) {
|
||||
t.Fatalf("expected ark models to stay untouched, got %v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIListModels_ReturnsFailureWhenVolcengineCodingPlanModelsAreFilteredEmpty(t *testing.T) {
|
||||
originalFetchModelsFunc := fetchModelsFunc
|
||||
fetchModelsFunc = func(config ai.ProviderConfig) ([]string, error) {
|
||||
return []string{
|
||||
"qwen3-14b-20250429",
|
||||
"wan2-1-14b-t2v-250225",
|
||||
}, nil
|
||||
}
|
||||
defer func() {
|
||||
fetchModelsFunc = originalFetchModelsFunc
|
||||
}()
|
||||
|
||||
service := NewService()
|
||||
service.providers = []ai.ProviderConfig{
|
||||
{
|
||||
ID: "provider-coding",
|
||||
Type: "openai",
|
||||
BaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
},
|
||||
}
|
||||
service.activeProvider = "provider-coding"
|
||||
|
||||
result := service.AIListModels()
|
||||
if result["success"] != false {
|
||||
t.Fatalf("expected AIListModels to fail, got %#v", result)
|
||||
}
|
||||
errorMessage, _ := result["error"].(string)
|
||||
if !strings.Contains(errorMessage, "当前接口未返回可用的火山 Coding Plan 模型") {
|
||||
t.Fatalf("expected specific coding plan error, got %q", errorMessage)
|
||||
}
|
||||
}
|
||||
115
internal/ai/types.go
Normal file
115
internal/ai/types.go
Normal file
@@ -0,0 +1,115 @@
|
||||
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" | "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 对话请求
|
||||
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 用量统计
|
||||
type TokenUsage struct {
|
||||
PromptTokens int `json:"promptTokens"`
|
||||
CompletionTokens int `json:"completionTokens"`
|
||||
TotalTokens int `json:"totalTokens"`
|
||||
}
|
||||
|
||||
// StreamChunk 流式响应片段
|
||||
type StreamChunk struct {
|
||||
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 配置
|
||||
type ProviderConfig struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // openai | anthropic | gemini | custom
|
||||
Name string `json:"name"`
|
||||
APIKey string `json:"apiKey"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Model string `json:"model"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini | claude-cli
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
MaxTokens int `json:"maxTokens"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
}
|
||||
|
||||
// SQLPermissionLevel AI SQL 执行权限级别
|
||||
type SQLPermissionLevel string
|
||||
|
||||
const (
|
||||
PermissionReadOnly SQLPermissionLevel = "readonly"
|
||||
PermissionReadWrite SQLPermissionLevel = "readwrite"
|
||||
PermissionFull SQLPermissionLevel = "full"
|
||||
)
|
||||
|
||||
// ContextLevel AI 上下文传递级别
|
||||
type ContextLevel string
|
||||
|
||||
const (
|
||||
ContextSchemaOnly ContextLevel = "schema_only"
|
||||
ContextWithSamples ContextLevel = "with_samples"
|
||||
ContextWithResults ContextLevel = "with_results"
|
||||
)
|
||||
|
||||
// SQLOperationType SQL 操作类型
|
||||
type SQLOperationType string
|
||||
|
||||
const (
|
||||
SQLOpQuery SQLOperationType = "query" // SELECT, SHOW, DESCRIBE, EXPLAIN
|
||||
SQLOpDML SQLOperationType = "dml" // INSERT, UPDATE, DELETE
|
||||
SQLOpDDL SQLOperationType = "ddl" // CREATE, ALTER, DROP, TRUNCATE
|
||||
SQLOpOther SQLOperationType = "other"
|
||||
)
|
||||
|
||||
// SafetyResult 安全检查结果
|
||||
type SafetyResult struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
OperationType SQLOperationType `json:"operationType"`
|
||||
RequiresConfirm bool `json:"requiresConfirm"`
|
||||
WarningMessage string `json:"warningMessage,omitempty"`
|
||||
}
|
||||
@@ -23,6 +23,17 @@ import (
|
||||
|
||||
const dbCachePingInterval = 30 * time.Second
|
||||
|
||||
const (
|
||||
startupConnectRetryWindow = 20 * time.Second
|
||||
startupConnectRetryDelay = 800 * time.Millisecond
|
||||
startupConnectRetryAttempts = 4
|
||||
)
|
||||
|
||||
var (
|
||||
newDatabaseFunc = db.NewDatabase
|
||||
resolveDialConfigWithProxyFunc = resolveDialConfigWithProxy
|
||||
)
|
||||
|
||||
type cachedDatabase struct {
|
||||
inst db.Database
|
||||
lastPing time.Time
|
||||
@@ -36,6 +47,7 @@ type queryContext struct {
|
||||
// App struct
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
startedAt time.Time
|
||||
dbCache map[string]cachedDatabase // Cache for DB connections
|
||||
mu sync.RWMutex // Mutex for cache access
|
||||
updateMu sync.Mutex
|
||||
@@ -52,13 +64,19 @@ func NewApp() *App {
|
||||
}
|
||||
}
|
||||
|
||||
// Startup is called when the app starts. The context is saved
|
||||
// so we can call the runtime methods
|
||||
func (a *App) Startup(ctx context.Context) {
|
||||
// InitializeLifecycle attaches runtime context without exposing lifecycle internals to Wails bindings.
|
||||
func InitializeLifecycle(a *App, ctx context.Context) {
|
||||
a.startup(ctx)
|
||||
}
|
||||
|
||||
// startup is called when the app starts. The context is saved
|
||||
// so we can call the runtime methods.
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
a.startedAt = time.Now()
|
||||
logger.Init()
|
||||
applyMacWindowTranslucencyFix()
|
||||
logger.Infof("应用启动完成")
|
||||
logger.Infof("应用启动完成(首次连接保护窗口=%s,最多重试=%d 次)", startupConnectRetryWindow, startupConnectRetryAttempts)
|
||||
}
|
||||
|
||||
// SetWindowTranslucency 动态调整 macOS 窗口透明度。
|
||||
@@ -68,6 +86,12 @@ func (a *App) SetWindowTranslucency(opacity float64, blur float64) {
|
||||
setMacWindowTranslucency(opacity, blur)
|
||||
}
|
||||
|
||||
// SetMacNativeWindowControls toggles macOS native traffic-light window controls.
|
||||
// On non-macOS platforms this is a no-op.
|
||||
func (a *App) SetMacNativeWindowControls(enabled bool) {
|
||||
setMacNativeWindowControls(enabled)
|
||||
}
|
||||
|
||||
// Shutdown is called when the app terminates
|
||||
func (a *App) Shutdown(ctx context.Context) {
|
||||
logger.Infof("应用开始关闭,准备释放资源")
|
||||
@@ -423,12 +447,12 @@ func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Datab
|
||||
return nil, withLogHint{err: fmt.Errorf("%s", reason), logPath: logger.Path()}
|
||||
}
|
||||
|
||||
dbInst, err := db.NewDatabase(effectiveConfig.Type)
|
||||
dbInst, err := newDatabaseFunc(effectiveConfig.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig)
|
||||
connectConfig, proxyErr := resolveDialConfigWithProxyFunc(effectiveConfig)
|
||||
if proxyErr != nil {
|
||||
_ = dbInst.Close()
|
||||
return nil, wrapConnectError(effectiveConfig, proxyErr)
|
||||
@@ -445,10 +469,7 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
isFileDB := isFileDatabaseType(effectiveConfig.Type)
|
||||
|
||||
key := getCacheKey(effectiveConfig)
|
||||
shortKey := key
|
||||
if len(shortKey) > 12 {
|
||||
shortKey = shortKey[:12]
|
||||
}
|
||||
shortKey := shortenCacheKey(key)
|
||||
if isFileDB {
|
||||
rawDSN := resolveFileDatabaseDSN(effectiveConfig)
|
||||
normalizedDSN := resolveFileDatabaseDSN(normalizeCacheKeyConfig(effectiveConfig))
|
||||
@@ -525,26 +546,13 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
logger.Infof("未命中文件库连接缓存,开始创建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
|
||||
}
|
||||
|
||||
logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||||
logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey)
|
||||
dbInst, err := db.NewDatabase(effectiveConfig.Type)
|
||||
dbInst, connectedConfig, err := a.connectDatabaseWithStartupRetry(config)
|
||||
if err != nil {
|
||||
logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig)
|
||||
if proxyErr != nil {
|
||||
wrapped := wrapConnectError(effectiveConfig, proxyErr)
|
||||
logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||||
return nil, wrapped
|
||||
}
|
||||
|
||||
if err := dbInst.Connect(connectConfig); err != nil {
|
||||
wrapped := wrapConnectError(effectiveConfig, err)
|
||||
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||||
return nil, wrapped
|
||||
}
|
||||
effectiveConfig = connectedConfig
|
||||
key = getCacheKey(effectiveConfig)
|
||||
shortKey = shortenCacheKey(key)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
@@ -565,6 +573,121 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
return dbInst, nil
|
||||
}
|
||||
|
||||
func shortenCacheKey(key string) string {
|
||||
if len(key) > 12 {
|
||||
return key[:12]
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func (a *App) connectDatabaseWithStartupRetry(rawConfig connection.ConnectionConfig) (db.Database, connection.ConnectionConfig, error) {
|
||||
var lastErr error
|
||||
var lastEffectiveConfig connection.ConnectionConfig
|
||||
|
||||
for attempt := 1; attempt <= startupConnectRetryAttempts; attempt++ {
|
||||
effectiveConfig := applyGlobalProxyToConnection(rawConfig)
|
||||
lastEffectiveConfig = effectiveConfig
|
||||
cacheKey := shortenCacheKey(getCacheKey(effectiveConfig))
|
||||
|
||||
logger.Infof("获取数据库连接:%s 缓存Key=%s 启动阶段=%s", formatConnSummary(effectiveConfig), cacheKey, a.startupPhaseLabel())
|
||||
logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s 尝试=%d/%d", effectiveConfig.Type, cacheKey, attempt, startupConnectRetryAttempts)
|
||||
|
||||
dbInst, err := newDatabaseFunc(effectiveConfig.Type)
|
||||
if err != nil {
|
||||
logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", effectiveConfig.Type, cacheKey)
|
||||
return nil, effectiveConfig, err
|
||||
}
|
||||
|
||||
connectConfig, proxyErr := resolveDialConfigWithProxyFunc(effectiveConfig)
|
||||
if proxyErr != nil {
|
||||
_ = dbInst.Close()
|
||||
wrapped := wrapConnectError(effectiveConfig, proxyErr)
|
||||
logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), cacheKey)
|
||||
return nil, effectiveConfig, wrapped
|
||||
}
|
||||
|
||||
if err := dbInst.Connect(connectConfig); err == nil {
|
||||
if attempt > 1 {
|
||||
logger.Warnf("数据库连接在重试后成功:%s 缓存Key=%s 尝试=%d/%d", formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts)
|
||||
}
|
||||
return dbInst, effectiveConfig, nil
|
||||
} else {
|
||||
_ = dbInst.Close()
|
||||
wrapped := wrapConnectError(effectiveConfig, err)
|
||||
lastErr = wrapped
|
||||
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), cacheKey)
|
||||
if !a.shouldRetryConnect(err, attempt) {
|
||||
return nil, effectiveConfig, wrapped
|
||||
}
|
||||
logger.Warnf("检测到瞬时网络失败,准备重试连接:%s 缓存Key=%s 尝试=%d/%d 延迟=%s 原因=%s",
|
||||
formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts, startupConnectRetryDelay, normalizeErrorMessage(err))
|
||||
time.Sleep(startupConnectRetryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("建立数据库连接失败")
|
||||
}
|
||||
return nil, lastEffectiveConfig, lastErr
|
||||
}
|
||||
|
||||
func (a *App) startupPhaseLabel() string {
|
||||
if a == nil || a.startedAt.IsZero() {
|
||||
return "未知"
|
||||
}
|
||||
age := time.Since(a.startedAt).Round(time.Millisecond)
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
if age <= startupConnectRetryWindow {
|
||||
snapshot := currentGlobalProxyConfig()
|
||||
state := "关闭"
|
||||
if snapshot.Enabled {
|
||||
state = fmt.Sprintf("启用(%s://%s:%d)", strings.ToLower(strings.TrimSpace(snapshot.Proxy.Type)), strings.TrimSpace(snapshot.Proxy.Host), snapshot.Proxy.Port)
|
||||
}
|
||||
return fmt.Sprintf("启动期(age=%s,全局代理=%s)", age, state)
|
||||
}
|
||||
return fmt.Sprintf("稳定期(age=%s)", age)
|
||||
}
|
||||
|
||||
func (a *App) shouldRetryConnect(err error, attempt int) bool {
|
||||
if attempt >= startupConnectRetryAttempts {
|
||||
return false
|
||||
}
|
||||
if !isTransientStartupConnectError(err) {
|
||||
return false
|
||||
}
|
||||
if a != nil && !a.startedAt.IsZero() {
|
||||
age := time.Since(a.startedAt)
|
||||
if age >= 0 && age <= startupConnectRetryWindow {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Outside startup window, still grant one retry for transient network glitches.
|
||||
return attempt == 1
|
||||
}
|
||||
|
||||
func isTransientStartupConnectError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(normalizeErrorMessage(err))
|
||||
transientHints := []string{
|
||||
"no route to host",
|
||||
"network is unreachable",
|
||||
"connection refused",
|
||||
"connection timed out",
|
||||
"i/o timeout",
|
||||
"context deadline exceeded",
|
||||
}
|
||||
for _, hint := range transientHints {
|
||||
if strings.Contains(message, hint) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// generateQueryID generates a unique ID for a query using UUID v4
|
||||
func generateQueryID() string {
|
||||
return "query-" + uuid.New().String()
|
||||
@@ -585,8 +708,8 @@ func (a *App) CancelQuery(queryID string) connection.QueryResult {
|
||||
return connection.QueryResult{Success: false, Message: "查询不存在或已完成"}
|
||||
}
|
||||
|
||||
// CleanupStaleQueries removes queries older than maxAge
|
||||
func (a *App) CleanupStaleQueries(maxAge time.Duration) {
|
||||
// cleanupStaleQueries removes queries older than maxAge.
|
||||
func (a *App) cleanupStaleQueries(maxAge time.Duration) {
|
||||
a.queryMu.Lock()
|
||||
defer a.queryMu.Unlock()
|
||||
|
||||
|
||||
305
internal/app/app_startup_connect_retry_test.go
Normal file
305
internal/app/app_startup_connect_retry_test.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
)
|
||||
|
||||
type fakeStartupRetryDB struct {
|
||||
connect func(config connection.ConnectionConfig) error
|
||||
}
|
||||
|
||||
func (f *fakeStartupRetryDB) Connect(config connection.ConnectionConfig) error {
|
||||
if f.connect != nil {
|
||||
return f.connect(config)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStartupRetryDB) Close() error { return nil }
|
||||
func (f *fakeStartupRetryDB) Ping() error { return nil }
|
||||
func (f *fakeStartupRetryDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
func (f *fakeStartupRetryDB) Exec(query string) (int64, error) { return 0, nil }
|
||||
func (f *fakeStartupRetryDB) GetDatabases() ([]string, error) { return nil, nil }
|
||||
func (f *fakeStartupRetryDB) GetTables(dbName string) ([]string, error) { return nil, nil }
|
||||
func (f *fakeStartupRetryDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeStartupRetryDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStartupRetryDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStartupRetryDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStartupRetryDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStartupRetryDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestConnectDatabaseWithStartupRetry_RetriesTransientFailureAndReappliesGlobalProxy(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
|
||||
snapshot := currentGlobalProxyConfig()
|
||||
defer func() {
|
||||
newDatabaseFunc = originalNewDatabaseFunc
|
||||
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
|
||||
if _, err := setGlobalProxyConfig(snapshot.Enabled, snapshot.Proxy); err != nil {
|
||||
t.Fatalf("restore global proxy failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil {
|
||||
t.Fatalf("disable global proxy failed: %v", err)
|
||||
}
|
||||
|
||||
seenConfigs := make([]connection.ConnectionConfig, 0, 2)
|
||||
connectCalls := 0
|
||||
newDatabaseFunc = func(dbType string) (db.Database, error) {
|
||||
return &fakeStartupRetryDB{
|
||||
connect: func(config connection.ConnectionConfig) error {
|
||||
connectCalls++
|
||||
seenConfigs = append(seenConfigs, config)
|
||||
if connectCalls == 1 {
|
||||
_, _ = setGlobalProxyConfig(true, connection.ProxyConfig{Type: "socks5", Host: "127.0.0.1", Port: 1080})
|
||||
return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
a := &App{startedAt: time.Now()}
|
||||
rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"}
|
||||
|
||||
_, effectiveConfig, err := a.connectDatabaseWithStartupRetry(rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("connectDatabaseWithStartupRetry returned error: %v", err)
|
||||
}
|
||||
if connectCalls != 2 {
|
||||
t.Fatalf("expected 2 connect attempts, got %d", connectCalls)
|
||||
}
|
||||
if len(seenConfigs) != 2 {
|
||||
t.Fatalf("expected 2 seen configs, got %d", len(seenConfigs))
|
||||
}
|
||||
if seenConfigs[0].UseProxy {
|
||||
t.Fatalf("expected first attempt without proxy, got %+v", seenConfigs[0])
|
||||
}
|
||||
if !seenConfigs[1].UseProxy {
|
||||
t.Fatalf("expected second attempt with proxy after startup retry, got %+v", seenConfigs[1])
|
||||
}
|
||||
if !effectiveConfig.UseProxy {
|
||||
t.Fatalf("expected returned effective config to include proxy, got %+v", effectiveConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectDatabaseWithStartupRetry_RetriesOnceOutsideStartupWindow(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
|
||||
defer func() {
|
||||
newDatabaseFunc = originalNewDatabaseFunc
|
||||
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
|
||||
}()
|
||||
|
||||
connectCalls := 0
|
||||
newDatabaseFunc = func(dbType string) (db.Database, error) {
|
||||
return &fakeStartupRetryDB{
|
||||
connect: func(config connection.ConnectionConfig) error {
|
||||
connectCalls++
|
||||
return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host")
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
a := &App{startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second)}
|
||||
rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"}
|
||||
|
||||
_, _, err := a.connectDatabaseWithStartupRetry(rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if connectCalls != 2 {
|
||||
t.Fatalf("expected 2 connect attempts outside startup window, got %d", connectCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectDatabaseWithStartupRetry_DoesNotRetryOutsideStartupWindowForNonTransientError(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
|
||||
defer func() {
|
||||
newDatabaseFunc = originalNewDatabaseFunc
|
||||
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
|
||||
}()
|
||||
|
||||
connectCalls := 0
|
||||
newDatabaseFunc = func(dbType string) (db.Database, error) {
|
||||
return &fakeStartupRetryDB{
|
||||
connect: func(config connection.ConnectionConfig) error {
|
||||
connectCalls++
|
||||
return errors.New("pq: password authentication failed")
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
a := &App{startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second)}
|
||||
rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"}
|
||||
|
||||
_, _, err := a.connectDatabaseWithStartupRetry(rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if connectCalls != 1 {
|
||||
t.Fatalf("expected 1 connect attempt outside startup window for non-transient error, got %d", connectCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectDatabaseWithStartupRetry_LogsRetryHintOutsideStartupWindow(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
|
||||
defer func() {
|
||||
newDatabaseFunc = originalNewDatabaseFunc
|
||||
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
|
||||
}()
|
||||
|
||||
logPath := logger.Path()
|
||||
beforeSize := int64(0)
|
||||
if fi, err := os.Stat(logPath); err == nil {
|
||||
beforeSize = fi.Size()
|
||||
}
|
||||
|
||||
connectCalls := 0
|
||||
newDatabaseFunc = func(dbType string) (db.Database, error) {
|
||||
return &fakeStartupRetryDB{
|
||||
connect: func(config connection.ConnectionConfig) error {
|
||||
connectCalls++
|
||||
if connectCalls == 1 {
|
||||
return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
a := &App{startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second)}
|
||||
rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"}
|
||||
|
||||
_, _, err := a.connectDatabaseWithStartupRetry(rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after retry, got error: %v", err)
|
||||
}
|
||||
if connectCalls != 2 {
|
||||
t.Fatalf("expected 2 connect attempts, got %d", connectCalls)
|
||||
}
|
||||
|
||||
logContent, readErr := os.ReadFile(logPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read log failed: %v", readErr)
|
||||
}
|
||||
if int64(len(logContent)) < beforeSize {
|
||||
t.Fatalf("expected log file to grow, before=%d after=%d", beforeSize, len(logContent))
|
||||
}
|
||||
appended := string(logContent[beforeSize:])
|
||||
if !strings.Contains(appended, "检测到瞬时网络失败,准备重试连接") {
|
||||
t.Fatalf("expected retry hint log in appended segment, got: %s", appended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectDatabaseWithStartupRetry_OutsideStartupWindowTransientFailureStopsAfterOneRetry(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
|
||||
defer func() {
|
||||
newDatabaseFunc = originalNewDatabaseFunc
|
||||
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
|
||||
}()
|
||||
|
||||
connectCalls := 0
|
||||
newDatabaseFunc = func(dbType string) (db.Database, error) {
|
||||
return &fakeStartupRetryDB{
|
||||
connect: func(config connection.ConnectionConfig) error {
|
||||
connectCalls++
|
||||
return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host")
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
a := &App{startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second)}
|
||||
rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"}
|
||||
|
||||
_, _, err := a.connectDatabaseWithStartupRetry(rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if connectCalls != 2 {
|
||||
t.Fatalf("expected 2 connect attempts outside startup window for transient error, got %d", connectCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectDatabaseWithStartupRetry_StartupWindowTransientFailureUsesFullRetryBudget(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
|
||||
defer func() {
|
||||
newDatabaseFunc = originalNewDatabaseFunc
|
||||
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
|
||||
}()
|
||||
|
||||
connectCalls := 0
|
||||
newDatabaseFunc = func(dbType string) (db.Database, error) {
|
||||
return &fakeStartupRetryDB{
|
||||
connect: func(config connection.ConnectionConfig) error {
|
||||
connectCalls++
|
||||
return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host")
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
a := &App{startedAt: time.Now()}
|
||||
rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"}
|
||||
|
||||
_, _, err := a.connectDatabaseWithStartupRetry(rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if connectCalls != startupConnectRetryAttempts {
|
||||
t.Fatalf("expected %d connect attempts in startup window, got %d", startupConnectRetryAttempts, connectCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTransientStartupConnectError(t *testing.T) {
|
||||
if !isTransientStartupConnectError(errors.New("dial tcp 10.1.131.86:5432: connect: no route to host")) {
|
||||
t.Fatal("expected no route to host to be treated as transient startup connect error")
|
||||
}
|
||||
if isTransientStartupConnectError(errors.New("pq: password authentication failed")) {
|
||||
t.Fatal("expected authentication failure to not be treated as transient startup connect error")
|
||||
}
|
||||
}
|
||||
@@ -581,6 +581,48 @@ func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, qu
|
||||
}
|
||||
}
|
||||
|
||||
// 全部为写操作且驱动支持批量 Exec → 一次性发送,大幅减少网络往返
|
||||
// 适用于 MySQL/MariaDB/Doris/PostgreSQL/SQLite/DuckDB 等支持多语句 Exec 的驱动
|
||||
if !allReadOnly {
|
||||
allWrite := true
|
||||
for _, stmt := range statements {
|
||||
if strings.TrimSpace(stmt) != "" && isReadOnlySQLQuery(runConfig.Type, stmt) {
|
||||
allWrite = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allWrite {
|
||||
if batcher, ok := dbInst.(db.BatchWriteExecer); ok {
|
||||
affected, batchErr := batcher.ExecBatchContext(ctx, query)
|
||||
if batchErr != nil && shouldRefreshCachedConnection(batchErr) {
|
||||
if a.invalidateCachedDatabase(runConfig, batchErr) {
|
||||
retryInst, retryErr := a.getDatabaseForcePing(runConfig)
|
||||
if retryErr != nil {
|
||||
logger.Error(retryErr, "DBQueryMulti 批量写重建连接失败:%s", formatConnSummary(runConfig))
|
||||
return connection.QueryResult{Success: false, Message: retryErr.Error(), QueryID: queryID}
|
||||
}
|
||||
if retryBatcher, ok2 := retryInst.(db.BatchWriteExecer); ok2 {
|
||||
affected, batchErr = retryBatcher.ExecBatchContext(ctx, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
if batchErr != nil {
|
||||
logger.Error(batchErr, "DBQueryMulti 批量写执行失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
|
||||
return connection.QueryResult{Success: false, Message: batchErr.Error(), QueryID: queryID}
|
||||
}
|
||||
logger.Infof("DBQueryMulti 批量写执行成功:%s 语句数=%d affectedRows=%d", formatConnSummary(runConfig), len(statements), affected)
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Data: []connection.ResultSetData{{
|
||||
Rows: []map[string]interface{}{{"affectedRows": affected}},
|
||||
Columns: []string{"affectedRows"},
|
||||
}},
|
||||
QueryID: queryID,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var resultSets []connection.ResultSetData
|
||||
for idx, stmt := range statements {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
|
||||
@@ -88,7 +88,7 @@ func TestCleanupStaleQueries(t *testing.T) {
|
||||
app.queryMu.Unlock()
|
||||
|
||||
// Cleanup queries older than 1 hour
|
||||
app.CleanupStaleQueries(1 * time.Hour)
|
||||
app.cleanupStaleQueries(1 * time.Hour)
|
||||
|
||||
// Verify stale query was removed
|
||||
app.queryMu.Lock()
|
||||
@@ -110,7 +110,7 @@ func TestCleanupStaleQueries(t *testing.T) {
|
||||
defer cancel2()
|
||||
|
||||
// Cleanup queries older than 1 hour
|
||||
app.CleanupStaleQueries(1 * time.Hour)
|
||||
app.cleanupStaleQueries(1 * time.Hour)
|
||||
|
||||
// Verify fresh query still exists
|
||||
app.queryMu.Lock()
|
||||
|
||||
73
internal/app/window_style_darwin.go
Normal file
73
internal/app/window_style_darwin.go
Normal file
@@ -0,0 +1,73 @@
|
||||
//go:build darwin
|
||||
|
||||
package app
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c -fblocks
|
||||
#cgo LDFLAGS: -framework Cocoa
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <dispatch/dispatch.h>
|
||||
|
||||
static inline BOOL gonaviBoolYES() { return YES; }
|
||||
static inline BOOL gonaviBoolNO() { return NO; }
|
||||
|
||||
static void gonaviSetWindowButtonsVisible(NSWindow *window, BOOL visible) {
|
||||
if (window == nil) {
|
||||
return;
|
||||
}
|
||||
for (NSWindowButton buttonType = NSWindowCloseButton; buttonType <= NSWindowZoomButton; buttonType++) {
|
||||
NSButton *button = [window standardWindowButton:buttonType];
|
||||
if (button != nil) {
|
||||
[button setHidden:!visible];
|
||||
[button setEnabled:visible];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void gonaviApplyMacWindowStyle(BOOL enabled) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
for (NSWindow *window in [NSApp windows]) {
|
||||
if (window == nil) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NSUInteger styleMask = [window styleMask];
|
||||
styleMask |= NSWindowStyleMaskClosable;
|
||||
styleMask |= NSWindowStyleMaskMiniaturizable;
|
||||
styleMask |= NSWindowStyleMaskResizable;
|
||||
|
||||
if (enabled) {
|
||||
styleMask |= NSWindowStyleMaskTitled;
|
||||
styleMask |= NSWindowStyleMaskFullSizeContentView;
|
||||
[window setStyleMask:styleMask];
|
||||
[window setTitleVisibility:NSWindowTitleHidden];
|
||||
[window setTitlebarAppearsTransparent:YES];
|
||||
[window setMovableByWindowBackground:YES];
|
||||
[window setCollectionBehavior:[window collectionBehavior] | NSWindowCollectionBehaviorFullScreenPrimary];
|
||||
gonaviSetWindowButtonsVisible(window, YES);
|
||||
} else {
|
||||
styleMask &= ~NSWindowStyleMaskTitled;
|
||||
styleMask &= ~NSWindowStyleMaskFullSizeContentView;
|
||||
[window setStyleMask:styleMask];
|
||||
[window setTitleVisibility:NSWindowTitleVisible];
|
||||
[window setTitlebarAppearsTransparent:NO];
|
||||
[window setMovableByWindowBackground:YES];
|
||||
gonaviSetWindowButtonsVisible(window, NO);
|
||||
}
|
||||
|
||||
[[window contentView] setNeedsDisplay:YES];
|
||||
[window invalidateShadow];
|
||||
}
|
||||
});
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
func setMacNativeWindowControls(enabled bool) {
|
||||
state := resolveMacNativeWindowControlState(enabled)
|
||||
if state.ShowNativeButtons {
|
||||
C.gonaviApplyMacWindowStyle(C.gonaviBoolYES())
|
||||
} else {
|
||||
C.gonaviApplyMacWindowStyle(C.gonaviBoolNO())
|
||||
}
|
||||
}
|
||||
32
internal/app/window_style_logic.go
Normal file
32
internal/app/window_style_logic.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package app
|
||||
|
||||
type macNativeWindowControlState struct {
|
||||
ShowNativeButtons bool
|
||||
UseTitledWindow bool
|
||||
UseFullSizeContent bool
|
||||
HideWindowTitle bool
|
||||
TransparentTitlebar bool
|
||||
AllowNativeFullscreen bool
|
||||
}
|
||||
|
||||
func resolveMacNativeWindowControlState(enabled bool) macNativeWindowControlState {
|
||||
if enabled {
|
||||
return macNativeWindowControlState{
|
||||
ShowNativeButtons: true,
|
||||
UseTitledWindow: true,
|
||||
UseFullSizeContent: true,
|
||||
HideWindowTitle: true,
|
||||
TransparentTitlebar: true,
|
||||
AllowNativeFullscreen: true,
|
||||
}
|
||||
}
|
||||
|
||||
return macNativeWindowControlState{
|
||||
ShowNativeButtons: false,
|
||||
UseTitledWindow: false,
|
||||
UseFullSizeContent: false,
|
||||
HideWindowTitle: false,
|
||||
TransparentTitlebar: false,
|
||||
AllowNativeFullscreen: false,
|
||||
}
|
||||
}
|
||||
37
internal/app/window_style_logic_test.go
Normal file
37
internal/app/window_style_logic_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveMacNativeWindowControlStateEnabled(t *testing.T) {
|
||||
state := resolveMacNativeWindowControlState(true)
|
||||
|
||||
if !state.ShowNativeButtons {
|
||||
t.Fatal("expected native buttons to be visible when enabled")
|
||||
}
|
||||
if !state.UseTitledWindow || !state.UseFullSizeContent {
|
||||
t.Fatal("expected enabled state to request titled full-size content window")
|
||||
}
|
||||
if !state.HideWindowTitle || !state.TransparentTitlebar {
|
||||
t.Fatal("expected enabled state to hide title and use transparent titlebar")
|
||||
}
|
||||
if !state.AllowNativeFullscreen {
|
||||
t.Fatal("expected enabled state to allow native fullscreen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMacNativeWindowControlStateDisabled(t *testing.T) {
|
||||
state := resolveMacNativeWindowControlState(false)
|
||||
|
||||
if state.ShowNativeButtons {
|
||||
t.Fatal("expected native buttons to be hidden when disabled")
|
||||
}
|
||||
if state.UseTitledWindow || state.UseFullSizeContent {
|
||||
t.Fatal("expected disabled state to avoid titled/full-size content window")
|
||||
}
|
||||
if state.HideWindowTitle || state.TransparentTitlebar {
|
||||
t.Fatal("expected disabled state to keep title visibility and opaque titlebar")
|
||||
}
|
||||
if state.AllowNativeFullscreen {
|
||||
t.Fatal("expected disabled state to avoid native fullscreen behavior")
|
||||
}
|
||||
}
|
||||
5
internal/app/window_style_stub.go
Normal file
5
internal/app/window_style_stub.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build !darwin
|
||||
|
||||
package app
|
||||
|
||||
func setMacNativeWindowControls(enabled bool) {}
|
||||
@@ -50,6 +50,13 @@ type MultiResultQuerierContext interface {
|
||||
QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error)
|
||||
}
|
||||
|
||||
// BatchWriteExecer 是可选接口,支持将多条写语句一次性批量发送执行。
|
||||
// 驱动的底层连接需支持多语句协议(如 MySQL multiStatements=true、PostgreSQL 原生多语句)。
|
||||
// 实现此接口可大幅减少批量 INSERT/UPDATE/DELETE 的网络往返次数。
|
||||
type BatchWriteExecer interface {
|
||||
ExecBatchContext(ctx context.Context, query string) (int64, error)
|
||||
}
|
||||
|
||||
// BatchApplier 定义了批量变更提交接口。
|
||||
// 支持批量编辑的驱动实现此接口,用于一次性提交前端 DataGrid 中的增删改操作。
|
||||
type BatchApplier interface {
|
||||
|
||||
@@ -90,6 +90,17 @@ func (d *DuckDB) Query(query string) ([]map[string]interface{}, []string, error)
|
||||
return scanRows(rows)
|
||||
}
|
||||
|
||||
func (d *DuckDB) ExecBatchContext(ctx context.Context, query string) (int64, error) {
|
||||
if d.conn == nil {
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := d.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (d *DuckDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if d.conn == nil {
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
|
||||
@@ -135,6 +135,17 @@ func (m *MariaDB) Query(query string) ([]map[string]interface{}, []string, error
|
||||
return scanRows(rows)
|
||||
}
|
||||
|
||||
func (m *MariaDB) ExecBatchContext(ctx context.Context, query string) (int64, error) {
|
||||
if m.conn == nil {
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := m.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (m *MariaDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if m.conn == nil {
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
|
||||
@@ -329,6 +329,17 @@ func (m *MySQLDB) Query(query string) ([]map[string]interface{}, []string, error
|
||||
return scanRows(rows)
|
||||
}
|
||||
|
||||
func (m *MySQLDB) ExecBatchContext(ctx context.Context, query string) (int64, error) {
|
||||
if m.conn == nil {
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := m.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (m *MySQLDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if m.conn == nil {
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
|
||||
@@ -233,6 +233,17 @@ func (p *PostgresDB) Query(query string) ([]map[string]interface{}, []string, er
|
||||
return scanRows(rows)
|
||||
}
|
||||
|
||||
func (p *PostgresDB) ExecBatchContext(ctx context.Context, query string) (int64, error) {
|
||||
if p.conn == nil {
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := p.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (p *PostgresDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if p.conn == nil {
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user