diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index a73453c..0410dec 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -23,7 +23,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version-file: 'go.mod' - name: Setup Node uses: actions/setup-node@v5 @@ -70,27 +70,130 @@ jobs: drivers: ${{ steps.detect.outputs.drivers }} has_changes: ${{ steps.detect.outputs.has_changes }} release_source: ${{ steps.detect.outputs.release_source }} + compare_base: ${{ steps.detect.outputs.compare_base }} + force_global_driver_builds: ${{ steps.detect.outputs.force_global_driver_builds }} + source_commit: ${{ steps.published_source.outputs.source_commit }} + has_manifest: ${{ steps.published_source.outputs.has_manifest }} steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 0 + - name: Resolve published driver release source + id: published_source + env: + DRIVER_RELEASE_TOKEN: ${{ secrets.DRIVER_RELEASE_TOKEN }} + shell: bash + run: | + set -euo pipefail + manifest_path="$RUNNER_TEMP/published-driver-manifest.json" + SOURCE_COMMIT="$(python3 tools/resolve-driver-release-source.py --repo Syngnat/GoNavi-DriverAgents --tag dev-latest --manifest-output "$manifest_path")" + echo "source_commit=${SOURCE_COMMIT}" >> "$GITHUB_OUTPUT" + if [[ -s "$manifest_path" ]]; then + echo "has_manifest=true" >> "$GITHUB_OUTPUT" + echo "🧭 Published dev driver release exposes revision manifest" + else + echo "has_manifest=false" >> "$GITHUB_OUTPUT" + echo "🧭 Published dev driver release has no revision manifest" + fi + if [[ -n "$SOURCE_COMMIT" && -s "$manifest_path" ]]; then + if bash ./tools/validate-driver-release-manifest.sh --commit "$SOURCE_COMMIT" --manifest "$manifest_path"; then + if python3 tools/validate-driver-release-assets.py --repo Syngnat/GoNavi-DriverAgents --tag dev-latest; then + echo "manifest_valid=true" >> "$GITHUB_OUTPUT" + echo "🧭 Published dev driver release manifest and actual assets are consistent with its source commit" + else + echo "manifest_valid=false" >> "$GITHUB_OUTPUT" + echo "⚠️ Published dev driver release assets do not match manifest; forcing full rebuild" + fi + else + echo "manifest_valid=false" >> "$GITHUB_OUTPUT" + echo "⚠️ Published dev driver release manifest is stale; forcing full rebuild" + fi + else + echo "manifest_valid=false" >> "$GITHUB_OUTPUT" + fi + if [[ -n "$SOURCE_COMMIT" ]]; then + echo "🧭 Last published dev driver release source commit: $SOURCE_COMMIT" + else + echo "🧭 Unable to resolve published dev driver release source commit; fallback to push diff base" + fi + - name: Detect changed driver agents id: detect shell: bash run: | set -euo pipefail - BASE_REF="${{ github.event.before }}" - if [[ -z "$BASE_REF" || "$BASE_REF" =~ ^0+$ ]]; then - if BASE_REF="$(git rev-parse HEAD^ 2>/dev/null)"; then - : + merge_csv() { + local current="$1" + local incoming="$2" + local merged="$current" + local seen=",${current}," + local item + IFS=',' read -r -a items <<< "$incoming" + for item in "${items[@]}"; do + item="$(printf '%s' "$item" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" + if [[ -z "$item" ]]; then + continue + fi + if [[ "$seen" == *",$item,"* ]]; then + continue + fi + if [[ -n "$merged" ]]; then + merged="${merged},${item}" + else + merged="$item" + fi + seen="${seen}${item}," + done + printf '%s\n' "$merged" + } + + BASE_REF="${{ steps.published_source.outputs.source_commit }}" + HAS_MANIFEST="${{ steps.published_source.outputs.has_manifest }}" + MANIFEST_VALID="${{ steps.published_source.outputs.manifest_valid }}" + if [[ "$HAS_MANIFEST" != "true" ]]; then + echo "⚠️ Published driver release lacks revision manifest; forcing full rebuild to self-heal old assets" + BASE_REF="all" + elif [[ "$MANIFEST_VALID" != "true" ]]; then + echo "⚠️ Published driver release manifest is stale; forcing full rebuild to self-heal old assets" + BASE_REF="all" + fi + if [[ -n "$BASE_REF" ]]; then + if git rev-parse --verify "${BASE_REF}^{commit}" >/dev/null 2>&1 && git merge-base --is-ancestor "$BASE_REF" "$GITHUB_SHA"; then + echo "🧭 Using last published driver release source commit as detection base: $BASE_REF" else - BASE_REF="all" + echo "⚠️ Published driver release source commit is unavailable or not an ancestor of $GITHUB_SHA: $BASE_REF" + BASE_REF="" fi fi + + if [[ -z "$BASE_REF" ]]; then + echo "⚠️ Falling back to full driver rebuild because published driver release source commit is unavailable" + BASE_REF="all" + fi + echo "🧭 Final driver detection base: $BASE_REF" DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base "$BASE_REF" --head "$GITHUB_SHA")" + if [[ "$BASE_REF" != "all" ]]; then + REVISION_DRIVERS="" + for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do + PLATFORM_DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")" || { + echo "⚠️ 平台 revision 差异对比失败(${PLATFORM}),保守回退为全量驱动重建" + DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")" + REVISION_DRIVERS="" + break + } + REVISION_DRIVERS="$(merge_csv "$REVISION_DRIVERS" "$PLATFORM_DRIVERS")" + done + if [[ -n "$REVISION_DRIVERS" ]]; then + echo "🧭 Revision diff union drivers: $REVISION_DRIVERS" + DRIVERS="$(merge_csv "$DRIVERS" "$REVISION_DRIVERS")" + fi + fi + FORCE_GLOBAL_DRIVER_BUILDS="$(bash ./tools/should-force-global-driver-builds.sh --base "$BASE_REF" --head "$GITHUB_SHA")" echo "drivers=${DRIVERS}" >> "$GITHUB_OUTPUT" + echo "compare_base=${BASE_REF}" >> "$GITHUB_OUTPUT" + echo "force_global_driver_builds=${FORCE_GLOBAL_DRIVER_BUILDS}" >> "$GITHUB_OUTPUT" if [ -n "$DRIVERS" ]; then echo "has_changes=true" >> "$GITHUB_OUTPUT" echo "🧭 Changed driver agents: $DRIVERS" @@ -98,6 +201,9 @@ jobs: echo "has_changes=false" >> "$GITHUB_OUTPUT" echo "🧭 No driver-agent changes detected" fi + if [[ "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" ]]; then + echo "🧭 Driver build/release plumbing changed; preserve global driver rebuild set on every platform" + fi echo "release_source=dev-latest" >> "$GITHUB_OUTPUT" build: @@ -172,20 +278,35 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version-file: 'go.mod' - name: Download frontend dist + id: download_frontend_dist + continue-on-error: true uses: actions/download-artifact@v7 with: name: frontend-dist - path: . + path: frontend-artifact + + - name: Reset failed frontend dist download + if: steps.download_frontend_dist.outcome != 'success' + shell: bash + run: rm -rf frontend-artifact + + - name: Retry frontend dist download + if: steps.download_frontend_dist.outcome != 'success' + uses: actions/download-artifact@v7 + with: + name: frontend-dist + path: frontend-artifact - name: Extract frontend dist shell: bash run: | set -euo pipefail mkdir -p frontend/dist - tar -xf frontend-dist.tar -C frontend/dist + test -s frontend-artifact/frontend-dist.tar + tar -xf frontend-artifact/frontend-dist.tar -C frontend/dist test -s frontend/dist/index.html - name: Install UPX (Windows) @@ -252,7 +373,7 @@ jobs: - name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64) id: msys2_duckdb - if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' && contains(format(',{0},', needs.driver_agents.outputs.drivers), ',duckdb,') }} + if: ${{ matrix.platform == 'windows/amd64' }} continue-on-error: true uses: msys2/setup-msys2@v2 with: @@ -260,9 +381,10 @@ jobs: update: true install: >- mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-binutils - name: Configure DuckDB CGO Toolchain (Windows AMD64) - if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' && contains(format(',{0},', needs.driver_agents.outputs.drivers), ',duckdb,') }} + if: ${{ matrix.platform == 'windows/amd64' }} shell: pwsh run: | function Find-MingwBin([string[]]$candidates) { @@ -320,7 +442,7 @@ jobs: Write-Host "✅ 已配置 DuckDB cgo 编译器: gcc=$gcc g++=$gxx" - name: Verify DuckDB CGO Toolchain (Windows AMD64) - if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' && contains(format(',{0},', needs.driver_agents.outputs.drivers), ',duckdb,') }} + if: ${{ matrix.platform == 'windows/amd64' }} shell: pwsh run: | & "$env:CC" --version @@ -338,32 +460,83 @@ jobs: - name: Build shell: bash - env: - CHANGED_DRIVER_AGENTS: ${{ needs.driver_agents.outputs.drivers }} run: | set -euo pipefail DEV_VERSION="${{ steps.version.outputs.version }}" - if [ -n "$CHANGED_DRIVER_AGENTS" ]; then - ./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.platform }}" --drivers "$CHANGED_DRIVER_AGENTS" - else - echo "🧭 No driver-agent changes; keeping committed driver revisions" - fi + echo "🧭 为 ${{ matrix.platform }} 全量生成 driver-agent revision 指纹,避免跨平台沿用旧 revision" + ./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.platform }}" + REVISION_HASH="$(python3 - <<'PY' + import hashlib + from pathlib import Path + + print(hashlib.sha256(Path("internal/db/driver_agent_revisions_gen.go").read_bytes()).hexdigest()) + PY + )" + export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}" + mkdir -p "$GOCACHE" + echo "🧭 使用隔离 GOCACHE:$GOCACHE" if [ -n "${{ matrix.wails_tags }}" ]; then wails build -s -skipbindings -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 -s -skipbindings -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 + - name: Resolve Platform Driver Revision Diff + id: revision_diff if: ${{ matrix.build_optional_agents && needs.driver_agents.outputs.has_changes == 'true' }} shell: bash + run: | + set -euo pipefail + BASE_REF="${{ needs.driver_agents.outputs.compare_base }}" + FALLBACK_DRIVERS="${{ needs.driver_agents.outputs.drivers }}" + FORCE_GLOBAL_DRIVER_BUILDS="${{ needs.driver_agents.outputs.force_global_driver_builds }}" + + if [[ -z "$BASE_REF" || "$BASE_REF" == "all" || "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" ]]; then + if [[ "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" && -n "$BASE_REF" && "$BASE_REF" != "all" ]]; then + echo "⚠️ 当前提交涉及 driver 构建/发布链路,保留全局驱动重建结果:${FALLBACK_DRIVERS}" + else + echo "⚠️ 当前 driver 检测基线不可做平台 diff,回退使用全局检测结果:${FALLBACK_DRIVERS}" + fi + echo "drivers=${FALLBACK_DRIVERS}" >> "$GITHUB_OUTPUT" + else + echo "🧭 对比当前平台 revision:base=${BASE_REF} head=${GITHUB_SHA} platform=${{ matrix.platform }}" + if DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "${{ matrix.platform }}")"; then + echo "🧭 当前平台实际需要重建的 driver agents: ${DRIVERS:-}" + echo "drivers=${DRIVERS}" >> "$GITHUB_OUTPUT" + else + echo "⚠️ revision 差异对比失败,保守回退为全量重建" + ALL_DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")" + echo "drivers=${ALL_DRIVERS}" >> "$GITHUB_OUTPUT" + fi + fi + + DRIVERS="$(sed -n 's/^drivers=//p' "$GITHUB_OUTPUT" | tail -n 1)" + if [[ -n "$DRIVERS" ]]; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build Optional Driver Agents + if: ${{ matrix.build_optional_agents && steps.revision_diff.outputs.has_changes == 'true' }} + shell: bash env: - CHANGED_DRIVER_AGENTS: ${{ needs.driver_agents.outputs.drivers }} + CHANGED_DRIVER_AGENTS: ${{ steps.revision_diff.outputs.drivers }} run: | set -euo pipefail TARGET_PLATFORM="${{ matrix.platform }}" GOOS="${TARGET_PLATFORM%%/*}" GOARCH="${TARGET_PLATFORM##*/}" + REVISION_HASH="$(python3 - <<'PY' + import hashlib + from pathlib import Path + + print(hashlib.sha256(Path("internal/db/driver_agent_revisions_gen.go").read_bytes()).hexdigest()) + PY + )" + export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}" + mkdir -p "$GOCACHE" + echo "🧭 可选驱动使用隔离 GOCACHE:$GOCACHE" IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS" OUTDIR="drivers/${{ matrix.os_name }}" mkdir -p "$OUTDIR" @@ -372,17 +545,51 @@ jobs: prepare_duckdb_windows_library() { local lib_dir="$RUNNER_TEMP/duckdb-windows-${DUCKDB_WINDOWS_LIBRARY_VERSION}" + local extract_dir="$RUNNER_TEMP/duckdb-windows-extract-${DUCKDB_WINDOWS_LIBRARY_VERSION}" local zip_path="$RUNNER_TEMP/libduckdb-windows-amd64.zip" - if [ -f "$lib_dir/duckdb.dll" ] && [ -f "$lib_dir/duckdb.lib" ]; then + if [ -f "$lib_dir/duckdb.dll" ] && [ -f "$lib_dir/libduckdb.dll.a" ] && [ -f "$lib_dir/libduckdb.a" ]; then echo "$lib_dir" return 0 fi mkdir -p "$lib_dir" - curl -fsSL "$DUCKDB_WINDOWS_LIBRARY_URL" -o "$zip_path" - unzip -qo "$zip_path" -d "$lib_dir" - cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.dll.a" - cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.a" - echo "$lib_dir" + rm -rf "$extract_dir" + rm -f "$zip_path" + local attempt dll_path + for attempt in 1 2 3; do + echo "📥 下载 DuckDB Windows 动态库 (${attempt}/3): ${DUCKDB_WINDOWS_LIBRARY_URL}" >&2 + if curl --retry 3 --retry-delay 2 --retry-all-errors --connect-timeout 20 --max-time 300 -fsSL "$DUCKDB_WINDOWS_LIBRARY_URL" -o "$zip_path"; then + if unzip -tq "$zip_path" >/dev/null 2>&1; then + rm -rf "$lib_dir" + rm -rf "$extract_dir" + mkdir -p "$lib_dir" + mkdir -p "$extract_dir" + unzip -qo "$zip_path" -d "$extract_dir" + dll_path="$(find "$extract_dir" -type f -name duckdb.dll | head -n 1 || true)" + if [ -n "$dll_path" ]; then + cp "$dll_path" "$lib_dir/duckdb.dll" + go run ./cmd/mingw-import-lib \ + --dll "$lib_dir/duckdb.dll" \ + --output-lib "$lib_dir/libduckdb.dll.a" + cp "$lib_dir/libduckdb.dll.a" "$lib_dir/libduckdb.a" + rm -rf "$extract_dir" + echo "$lib_dir" + return 0 + fi + echo "⚠️ DuckDB Windows 动态库压缩包缺少 duckdb.dll,准备重试" >&2 + else + echo "⚠️ DuckDB Windows 动态库压缩包校验失败,准备重试" >&2 + fi + else + echo "⚠️ DuckDB Windows 动态库下载失败,准备重试" >&2 + fi + rm -f "$zip_path" + rm -rf "$lib_dir" + rm -rf "$extract_dir" + mkdir -p "$lib_dir" + sleep $((attempt * 2)) + done + echo "❌ 无法准备 DuckDB Windows 动态库:${DUCKDB_WINDOWS_LIBRARY_URL}" >&2 + return 1 } for DRIVER in "${DRIVERS[@]}"; do @@ -394,48 +601,98 @@ jobs: echo "⚠️ 跳过 DuckDB driver(当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64)" continue fi - TAG="gonavi_${BUILD_DRIVER}_driver" - BUILD_TAGS="$TAG" - OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}" - if [ "$GOOS" = "windows" ]; then - OUTPUT="${OUTPUT}.exe" + DRIVER_VARIANTS=("") + if [ "$DRIVER" = "mongodb" ]; then + DRIVER_VARIANTS=("v1" "v2" "") fi - OUTPUT_PATH="${OUTDIR}/${OUTPUT}" - DUCKDB_LIB_DIR="" - if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" = "amd64" ]; then - DUCKDB_LIB_DIR="$(prepare_duckdb_windows_library)" - BUILD_TAGS="${BUILD_TAGS} duckdb_use_lib" - fi - echo "🔧 构建 ${OUTPUT_PATH} (tags=${BUILD_TAGS})" - if [ "$DRIVER" = "duckdb" ]; then - if [ -n "$DUCKDB_LIB_DIR" ]; then - CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" CGO_LDFLAGS="-L${DUCKDB_LIB_DIR} -lduckdb" PATH="${DUCKDB_LIB_DIR}:$PATH" go build \ - -tags "${BUILD_TAGS}" \ - -trimpath \ - -ldflags "-s -w" \ - -o "${OUTPUT_PATH}" \ - ./cmd/optional-driver-agent - cp "$DUCKDB_LIB_DIR/duckdb.dll" "$OUTDIR/duckdb.dll" - bash ./tools/compress-driver-artifact.sh "$OUTDIR/duckdb.dll" "$TARGET_PLATFORM" "${{ matrix.os_name }}/duckdb.dll" + for DRIVER_VARIANT in "${DRIVER_VARIANTS[@]}"; do + TAG="gonavi_${BUILD_DRIVER}_driver" + BUILD_TAGS="$TAG" + OUTPUT_STEM="${DRIVER}-driver-agent" + if [ "$DRIVER" = "mongodb" ]; then + if [ "$DRIVER_VARIANT" = "v1" ]; then + BUILD_TAGS="gonavi_mongodb_driver_v1" + OUTPUT_STEM="mongodb-driver-agent-v1" + elif [ "$DRIVER_VARIANT" = "v2" ]; then + BUILD_TAGS="gonavi_mongodb_driver" + OUTPUT_STEM="mongodb-driver-agent-v2" + else + BUILD_TAGS="gonavi_mongodb_driver" + fi + fi + OUTPUT="${OUTPUT_STEM}-${GOOS}-${GOARCH}" + if [ "$GOOS" = "windows" ]; then + OUTPUT="${OUTPUT}.exe" + fi + OUTPUT_PATH="${OUTDIR}/${OUTPUT}" + DUCKDB_LIB_DIR="" + if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" = "amd64" ]; then + DUCKDB_LIB_DIR="$(prepare_duckdb_windows_library)" + BUILD_TAGS="${BUILD_TAGS} duckdb_use_lib" + fi + echo "🔧 构建 ${OUTPUT_PATH} (tags=${BUILD_TAGS})" + if [ "$DRIVER" = "duckdb" ]; then + if [ -n "$DUCKDB_LIB_DIR" ]; then + DUCKDB_LIB_DIR_GCC="$(cygpath -m "$DUCKDB_LIB_DIR")" + DUCKDB_LIB_DIR_PATH="$(cygpath -u "$DUCKDB_LIB_DIR")" + # cgo 会把每个 CGO_LDFLAGS 片段转成 //go:cgo_ldflag,-L 参数不能再额外包引号。 + DUCKDB_WINDOWS_CGO_LDFLAGS="-L${DUCKDB_LIB_DIR_GCC} -lduckdb -lstdc++ -lm -lws2_32 -lwsock32 -lrstrtmgr" + CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" CGO_LDFLAGS="${DUCKDB_WINDOWS_CGO_LDFLAGS}" PATH="${DUCKDB_LIB_DIR_PATH}:$PATH" go build \ + -tags "${BUILD_TAGS}" \ + -trimpath \ + -ldflags "-s -w" \ + -o "${OUTPUT_PATH}" \ + ./cmd/optional-driver-agent + cp "$DUCKDB_LIB_DIR/duckdb.dll" "$OUTDIR/duckdb.dll" + bash ./tools/compress-driver-artifact.sh "$OUTDIR/duckdb.dll" "$TARGET_PLATFORM" "${{ matrix.os_name }}/duckdb.dll" + else + CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \ + -tags "${BUILD_TAGS}" \ + -trimpath \ + -ldflags "-s -w" \ + -o "${OUTPUT_PATH}" \ + ./cmd/optional-driver-agent + fi else - CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \ + CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \ -tags "${BUILD_TAGS}" \ -trimpath \ -ldflags "-s -w" \ -o "${OUTPUT_PATH}" \ ./cmd/optional-driver-agent fi - else - CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \ - -tags "${BUILD_TAGS}" \ - -trimpath \ - -ldflags "-s -w" \ - -o "${OUTPUT_PATH}" \ - ./cmd/optional-driver-agent - fi - bash ./tools/compress-driver-artifact.sh "${OUTPUT_PATH}" "$TARGET_PLATFORM" "${{ matrix.os_name }}/${OUTPUT}" + bash ./tools/compress-driver-artifact.sh "${OUTPUT_PATH}" "$TARGET_PLATFORM" "${{ matrix.os_name }}/${OUTPUT}" + if [ "$DRIVER" = "duckdb" ] && [ -n "$DUCKDB_LIB_DIR" ]; then + DUCKDB_ZIP_PATH="${OUTDIR}/duckdb-driver.zip" + export DUCKDB_ZIP_PATH + export DUCKDB_AGENT_PATH="${OUTPUT_PATH}" + export DUCKDB_DLL_PATH="${OUTDIR}/duckdb.dll" + python3 - <<'PY' + import os + import zipfile + + zip_path = os.environ["DUCKDB_ZIP_PATH"] + entries = [ + ("Windows/duckdb-driver-agent-windows-amd64.exe", os.environ["DUCKDB_AGENT_PATH"]), + ("Windows/duckdb.dll", os.environ["DUCKDB_DLL_PATH"]), + ] + + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for arcname, src in entries: + if not os.path.isfile(src): + raise FileNotFoundError(src) + zf.write(src, arcname) + PY + echo "📦 已生成 DuckDB Windows 专属驱动包: ${DUCKDB_ZIP_PATH}" + fi + done done + bash ./tools/verify-driver-agent-revisions.sh \ + --assets-dir drivers \ + --platform "$TARGET_PLATFORM" \ + --drivers "$CHANGED_DRIVER_AGENTS" + # macOS Packaging - name: Package macOS DMG if: contains(matrix.platform, 'darwin') @@ -690,53 +947,13 @@ jobs: fi echo "📦 打包驱动总包:GoNavi-DriverAgents.zip" - python3 - <<'PY' - import json - import os - import shutil - import zipfile - from pathlib import Path + python3 ../tools/package-driver-release-assets.py \ + drivers \ + ../driver-release-assets - out_name = "GoNavi-DriverAgents.zip" - index_name = "GoNavi-DriverAgents-Index.json" - base = Path("drivers") - driver_release_dir = Path("../driver-release-assets") - if driver_release_dir.exists(): - shutil.rmtree(driver_release_dir) - driver_release_dir.mkdir(parents=True, exist_ok=True) - out_path = driver_release_dir / out_name - index_path = driver_release_dir / index_name - if out_path.exists(): - out_path.unlink() - if index_path.exists(): - index_path.unlink() - - size_index = {} - standalone_assets = [] - with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in sorted(base.rglob("*")): - if not p.is_file(): - continue - arcname = p.relative_to(base).as_posix() - if p.name in size_index: - raise RuntimeError(f"driver asset name conflict: {p.name}") - zf.write(p, arcname) - size_index[p.name] = p.stat().st_size - standalone_path = driver_release_dir / p.name - if standalone_path.exists(): - raise RuntimeError(f"release asset already exists: {standalone_path}") - shutil.copy2(p, standalone_path) - standalone_assets.append(standalone_path.name) - - 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)}") - print(f"published standalone driver assets={len(standalone_assets)}") - PY + python3 ../tools/generate-driver-release-manifest.py \ + --assets-dir drivers \ + --output ../driver-release-assets/GoNavi-DriverAgents-Manifest.json rm -rf drivers echo "has_driver_assets=true" >> "$GITHUB_OUTPUT" @@ -888,23 +1105,32 @@ jobs: - name: Create Dev Driver Agents Pre-release if: steps.driver_assets.outputs.has_driver_assets == 'true' - uses: softprops/action-gh-release@v3 - with: - repository: Syngnat/GoNavi-DriverAgents - tag_name: dev-latest - name: "GoNavi Driver Agents (${{ steps.version.outputs.version }})" - files: driver-release-assets/* - fail_on_unmatched_files: true - prerelease: true - draft: false - make_latest: false - body: | + env: + GH_TOKEN: ${{ secrets.DRIVER_RELEASE_TOKEN }} + shell: bash + run: | + set -euo pipefail + mapfile -t DRIVER_RELEASE_ASSETS < <(find driver-release-assets -maxdepth 1 -type f | sort) + if [ ${#DRIVER_RELEASE_ASSETS[@]} -eq 0 ]; then + echo "未找到 driver release assets" + exit 1 + fi + + NOTES_FILE="$RUNNER_TEMP/dev-driver-release-notes.md" + cat > "$NOTES_FILE" <<'EOF' GoNavi dev driver-agent assets. **版本**: `${{ steps.version.outputs.version }}` **来源仓库**: `${{ github.repository }}` **提交**: [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}) - token: ${{ secrets.DRIVER_RELEASE_TOKEN }} + EOF + + gh release create dev-latest "${DRIVER_RELEASE_ASSETS[@]}" \ + --repo Syngnat/GoNavi-DriverAgents \ + --title "GoNavi Driver Agents (${{ steps.version.outputs.version }})" \ + --notes-file "$NOTES_FILE" \ + --prerelease \ + --latest=false - name: Create Dev Pre-release uses: softprops/action-gh-release@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ae6071..df05264 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version-file: 'go.mod' - name: Setup Node uses: actions/setup-node@v5 @@ -66,17 +66,94 @@ jobs: drivers: ${{ steps.detect.outputs.drivers }} has_changes: ${{ steps.detect.outputs.has_changes }} release_source: ${{ steps.detect.outputs.release_source }} + compare_base: ${{ steps.detect.outputs.compare_base }} + force_global_driver_builds: ${{ steps.detect.outputs.force_global_driver_builds }} + published_source_commit: ${{ steps.published_source.outputs.source_commit }} + has_manifest: ${{ steps.published_source.outputs.has_manifest }} + manifest_valid: ${{ steps.published_source.outputs.manifest_valid }} steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 0 + - name: Resolve published driver release source + id: published_source + env: + DRIVER_RELEASE_TOKEN: ${{ secrets.DRIVER_RELEASE_TOKEN }} + shell: bash + run: | + set -euo pipefail + PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "${GITHUB_SHA}^" 2>/dev/null || true)" + manifest_path="$RUNNER_TEMP/published-driver-manifest.json" + if [[ -z "$PREV_TAG" ]]; then + echo "source_commit=" >> "$GITHUB_OUTPUT" + echo "has_manifest=false" >> "$GITHUB_OUTPUT" + echo "manifest_valid=false" >> "$GITHUB_OUTPUT" + echo "🧭 No previous stable tag found; full rebuild will be used" + exit 0 + fi + SOURCE_COMMIT="$(python3 tools/resolve-driver-release-source.py --repo Syngnat/GoNavi-DriverAgents --tag "$PREV_TAG" --manifest-output "$manifest_path")" + echo "source_commit=${SOURCE_COMMIT}" >> "$GITHUB_OUTPUT" + if [[ -s "$manifest_path" ]]; then + echo "has_manifest=true" >> "$GITHUB_OUTPUT" + echo "🧭 Published driver release exposes revision manifest" + else + echo "has_manifest=false" >> "$GITHUB_OUTPUT" + echo "🧭 Published driver release has no revision manifest" + fi + if [[ -n "$SOURCE_COMMIT" && -s "$manifest_path" ]]; then + if bash ./tools/validate-driver-release-manifest.sh --commit "$SOURCE_COMMIT" --manifest "$manifest_path"; then + if python3 tools/validate-driver-release-assets.py --repo Syngnat/GoNavi-DriverAgents --tag "$PREV_TAG"; then + echo "manifest_valid=true" >> "$GITHUB_OUTPUT" + echo "🧭 Published driver release manifest and actual assets are consistent with its source commit" + else + echo "manifest_valid=false" >> "$GITHUB_OUTPUT" + echo "⚠️ Published driver release assets do not match manifest; forcing full rebuild" + fi + else + echo "manifest_valid=false" >> "$GITHUB_OUTPUT" + echo "⚠️ Published driver release manifest is stale; forcing full rebuild" + fi + else + echo "manifest_valid=false" >> "$GITHUB_OUTPUT" + fi + if [[ -n "$SOURCE_COMMIT" ]]; then + echo "🧭 Last published release source commit (${PREV_TAG}): $SOURCE_COMMIT" + else + echo "🧭 Unable to resolve published release source commit for ${PREV_TAG}; fallback to previous tag diff" + fi + - name: Detect changed driver agents id: detect shell: bash run: | set -euo pipefail + merge_csv() { + local current="$1" + local incoming="$2" + local merged="$current" + local seen=",${current}," + local item + IFS=',' read -r -a items <<< "$incoming" + for item in "${items[@]}"; do + item="$(printf '%s' "$item" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" + if [[ -z "$item" ]]; then + continue + fi + if [[ "$seen" == *",$item,"* ]]; then + continue + fi + if [[ -n "$merged" ]]; then + merged="${merged},${item}" + else + merged="$item" + fi + seen="${seen}${item}," + done + printf '%s\n' "$merged" + } + git fetch --force --tags PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "${GITHUB_SHA}^" 2>/dev/null || true)" if [ -n "$PREV_TAG" ]; then @@ -86,9 +163,39 @@ jobs: BASE_REF="all" RELEASE_SOURCE="all" fi + HAS_MANIFEST="${{ steps.published_source.outputs.has_manifest }}" + MANIFEST_VALID="${{ steps.published_source.outputs.manifest_valid }}" + if [[ "$RELEASE_SOURCE" != "all" && "$HAS_MANIFEST" != "true" ]]; then + echo "⚠️ Published driver release lacks revision manifest; forcing full rebuild to self-heal old assets" + BASE_REF="all" + RELEASE_SOURCE="all" + elif [[ "$RELEASE_SOURCE" != "all" && "$MANIFEST_VALID" != "true" ]]; then + echo "⚠️ Published driver release manifest is stale; forcing full rebuild to self-heal old assets" + BASE_REF="all" + RELEASE_SOURCE="all" + fi DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base "$BASE_REF" --head "$GITHUB_SHA")" + if [[ "$BASE_REF" != "all" ]]; then + REVISION_DRIVERS="" + for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do + PLATFORM_DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")" || { + echo "⚠️ 平台 revision 差异对比失败(${PLATFORM}),保守回退为全量驱动重建" + DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")" + REVISION_DRIVERS="" + break + } + REVISION_DRIVERS="$(merge_csv "$REVISION_DRIVERS" "$PLATFORM_DRIVERS")" + done + if [[ -n "$REVISION_DRIVERS" ]]; then + echo "🧭 Revision diff union drivers: $REVISION_DRIVERS" + DRIVERS="$(merge_csv "$DRIVERS" "$REVISION_DRIVERS")" + fi + fi + FORCE_GLOBAL_DRIVER_BUILDS="$(bash ./tools/should-force-global-driver-builds.sh --base "$BASE_REF" --head "$GITHUB_SHA")" echo "drivers=${DRIVERS}" >> "$GITHUB_OUTPUT" echo "release_source=${RELEASE_SOURCE}" >> "$GITHUB_OUTPUT" + echo "compare_base=${BASE_REF}" >> "$GITHUB_OUTPUT" + echo "force_global_driver_builds=${FORCE_GLOBAL_DRIVER_BUILDS}" >> "$GITHUB_OUTPUT" if [ -n "$DRIVERS" ]; then echo "has_changes=true" >> "$GITHUB_OUTPUT" echo "🧭 Changed driver agents since ${BASE_REF}: $DRIVERS" @@ -96,6 +203,9 @@ jobs: echo "has_changes=false" >> "$GITHUB_OUTPUT" echo "🧭 No driver-agent changes since ${BASE_REF}" fi + if [[ "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" ]]; then + echo "🧭 Driver build/release plumbing changed; preserve global driver rebuild set on every platform" + fi # Phase 1: Build in parallel and output artifacts build: @@ -167,24 +277,41 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v5 + with: + fetch-depth: 0 - name: Setup Go uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version-file: 'go.mod' - name: Download frontend dist + id: download_frontend_dist + continue-on-error: true uses: actions/download-artifact@v7 with: name: frontend-dist - path: . + path: frontend-artifact + + - name: Reset failed frontend dist download + if: steps.download_frontend_dist.outcome != 'success' + shell: bash + run: rm -rf frontend-artifact + + - name: Retry frontend dist download + if: steps.download_frontend_dist.outcome != 'success' + uses: actions/download-artifact@v7 + with: + name: frontend-dist + path: frontend-artifact - name: Extract frontend dist shell: bash run: | set -euo pipefail mkdir -p frontend/dist - tar -xf frontend-dist.tar -C frontend/dist + test -s frontend-artifact/frontend-dist.tar + tar -xf frontend-artifact/frontend-dist.tar -C frontend/dist test -s frontend/dist/index.html - name: Install UPX (Windows) @@ -255,7 +382,7 @@ jobs: - name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64) id: msys2_duckdb - if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' && contains(format(',{0},', needs.driver_agents.outputs.drivers), ',duckdb,') }} + if: ${{ matrix.platform == 'windows/amd64' }} continue-on-error: true uses: msys2/setup-msys2@v2 with: @@ -263,9 +390,10 @@ jobs: update: true install: >- mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-binutils - name: Configure DuckDB CGO Toolchain (Windows AMD64) - if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' && contains(format(',{0},', needs.driver_agents.outputs.drivers), ',duckdb,') }} + if: ${{ matrix.platform == 'windows/amd64' }} shell: pwsh run: | function Find-MingwBin([string[]]$candidates) { @@ -323,7 +451,7 @@ jobs: Write-Host "✅ 已配置 DuckDB cgo 编译器: gcc=$gcc g++=$gxx" - name: Verify DuckDB CGO Toolchain (Windows AMD64) - if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' && contains(format(',{0},', needs.driver_agents.outputs.drivers), ',duckdb,') }} + if: ${{ matrix.platform == 'windows/amd64' }} shell: pwsh run: | & "$env:CC" --version @@ -331,31 +459,82 @@ jobs: - name: Build shell: bash - env: - CHANGED_DRIVER_AGENTS: ${{ needs.driver_agents.outputs.drivers }} run: | set -euo pipefail - if [ -n "$CHANGED_DRIVER_AGENTS" ]; then - ./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.platform }}" --drivers "$CHANGED_DRIVER_AGENTS" - else - echo "🧭 No driver-agent changes; keeping committed driver revisions" - fi + echo "🧭 为 ${{ matrix.platform }} 全量生成 driver-agent revision 指纹,避免跨平台沿用旧 revision" + ./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.platform }}" + REVISION_HASH="$(python3 - <<'PY' + import hashlib + from pathlib import Path + + print(hashlib.sha256(Path("internal/db/driver_agent_revisions_gen.go").read_bytes()).hexdigest()) + PY + )" + export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}" + mkdir -p "$GOCACHE" + echo "🧭 使用隔离 GOCACHE:$GOCACHE" if [ -n "${{ matrix.wails_tags }}" ]; then wails build -s -skipbindings -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}" else wails build -s -skipbindings -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}" fi - - name: Build Optional Driver Agents + - name: Resolve Platform Driver Revision Diff + id: revision_diff if: ${{ matrix.build_optional_agents && needs.driver_agents.outputs.has_changes == 'true' }} shell: bash + run: | + set -euo pipefail + BASE_REF="${{ needs.driver_agents.outputs.compare_base }}" + FALLBACK_DRIVERS="${{ needs.driver_agents.outputs.drivers }}" + FORCE_GLOBAL_DRIVER_BUILDS="${{ needs.driver_agents.outputs.force_global_driver_builds }}" + + if [[ -z "$BASE_REF" || "$BASE_REF" == "all" || "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" ]]; then + if [[ "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" && -n "$BASE_REF" && "$BASE_REF" != "all" ]]; then + echo "⚠️ 当前提交涉及 driver 构建/发布链路,保留全局驱动重建结果:${FALLBACK_DRIVERS}" + else + echo "⚠️ 未拿到可比对的历史 release 基线,回退使用全局检测结果:${FALLBACK_DRIVERS}" + fi + echo "drivers=${FALLBACK_DRIVERS}" >> "$GITHUB_OUTPUT" + else + echo "🧭 对比当前平台 revision:base=${BASE_REF} head=${GITHUB_SHA} platform=${{ matrix.platform }}" + if DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "${{ matrix.platform }}")"; then + echo "🧭 当前平台实际需要重建的 driver agents: ${DRIVERS:-}" + echo "drivers=${DRIVERS}" >> "$GITHUB_OUTPUT" + else + echo "⚠️ revision 差异对比失败,保守回退为全量重建" + ALL_DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")" + echo "drivers=${ALL_DRIVERS}" >> "$GITHUB_OUTPUT" + fi + fi + + DRIVERS="$(sed -n 's/^drivers=//p' "$GITHUB_OUTPUT" | tail -n 1)" + if [[ -n "$DRIVERS" ]]; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build Optional Driver Agents + if: ${{ matrix.build_optional_agents && steps.revision_diff.outputs.has_changes == 'true' }} + shell: bash env: - CHANGED_DRIVER_AGENTS: ${{ needs.driver_agents.outputs.drivers }} + CHANGED_DRIVER_AGENTS: ${{ steps.revision_diff.outputs.drivers }} run: | set -euo pipefail TARGET_PLATFORM="${{ matrix.platform }}" GOOS="${TARGET_PLATFORM%%/*}" GOARCH="${TARGET_PLATFORM##*/}" + REVISION_HASH="$(python3 - <<'PY' + import hashlib + from pathlib import Path + + print(hashlib.sha256(Path("internal/db/driver_agent_revisions_gen.go").read_bytes()).hexdigest()) + PY + )" + export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}" + mkdir -p "$GOCACHE" + echo "🧭 可选驱动使用隔离 GOCACHE:$GOCACHE" IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS" OUTDIR="drivers/${{ matrix.os_name }}" mkdir -p "$OUTDIR" @@ -364,17 +543,51 @@ jobs: prepare_duckdb_windows_library() { local lib_dir="$RUNNER_TEMP/duckdb-windows-${DUCKDB_WINDOWS_LIBRARY_VERSION}" + local extract_dir="$RUNNER_TEMP/duckdb-windows-extract-${DUCKDB_WINDOWS_LIBRARY_VERSION}" local zip_path="$RUNNER_TEMP/libduckdb-windows-amd64.zip" - if [ -f "$lib_dir/duckdb.dll" ] && [ -f "$lib_dir/duckdb.lib" ]; then + if [ -f "$lib_dir/duckdb.dll" ] && [ -f "$lib_dir/libduckdb.dll.a" ] && [ -f "$lib_dir/libduckdb.a" ]; then echo "$lib_dir" return 0 fi mkdir -p "$lib_dir" - curl -fsSL "$DUCKDB_WINDOWS_LIBRARY_URL" -o "$zip_path" - unzip -qo "$zip_path" -d "$lib_dir" - cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.dll.a" - cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.a" - echo "$lib_dir" + rm -rf "$extract_dir" + rm -f "$zip_path" + local attempt dll_path + for attempt in 1 2 3; do + echo "📥 下载 DuckDB Windows 动态库 (${attempt}/3): ${DUCKDB_WINDOWS_LIBRARY_URL}" >&2 + if curl --retry 3 --retry-delay 2 --retry-all-errors --connect-timeout 20 --max-time 300 -fsSL "$DUCKDB_WINDOWS_LIBRARY_URL" -o "$zip_path"; then + if unzip -tq "$zip_path" >/dev/null 2>&1; then + rm -rf "$lib_dir" + rm -rf "$extract_dir" + mkdir -p "$lib_dir" + mkdir -p "$extract_dir" + unzip -qo "$zip_path" -d "$extract_dir" + dll_path="$(find "$extract_dir" -type f -name duckdb.dll | head -n 1 || true)" + if [ -n "$dll_path" ]; then + cp "$dll_path" "$lib_dir/duckdb.dll" + go run ./cmd/mingw-import-lib \ + --dll "$lib_dir/duckdb.dll" \ + --output-lib "$lib_dir/libduckdb.dll.a" + cp "$lib_dir/libduckdb.dll.a" "$lib_dir/libduckdb.a" + rm -rf "$extract_dir" + echo "$lib_dir" + return 0 + fi + echo "⚠️ DuckDB Windows 动态库压缩包缺少 duckdb.dll,准备重试" >&2 + else + echo "⚠️ DuckDB Windows 动态库压缩包校验失败,准备重试" >&2 + fi + else + echo "⚠️ DuckDB Windows 动态库下载失败,准备重试" >&2 + fi + rm -f "$zip_path" + rm -rf "$lib_dir" + rm -rf "$extract_dir" + mkdir -p "$lib_dir" + sleep $((attempt * 2)) + done + echo "❌ 无法准备 DuckDB Windows 动态库:${DUCKDB_WINDOWS_LIBRARY_URL}" >&2 + return 1 } for DRIVER in "${DRIVERS[@]}"; do @@ -386,48 +599,98 @@ jobs: echo "⚠️ 跳过 DuckDB driver(当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64)" continue fi - TAG="gonavi_${BUILD_DRIVER}_driver" - BUILD_TAGS="$TAG" - OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}" - if [ "$GOOS" = "windows" ]; then - OUTPUT="${OUTPUT}.exe" + DRIVER_VARIANTS=("") + if [ "$DRIVER" = "mongodb" ]; then + DRIVER_VARIANTS=("v1" "v2" "") fi - OUTPUT_PATH="${OUTDIR}/${OUTPUT}" - DUCKDB_LIB_DIR="" - if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" = "amd64" ]; then - DUCKDB_LIB_DIR="$(prepare_duckdb_windows_library)" - BUILD_TAGS="${BUILD_TAGS} duckdb_use_lib" - fi - echo "🔧 构建 ${OUTPUT_PATH} (tags=${BUILD_TAGS})" - if [ "$DRIVER" = "duckdb" ]; then - if [ -n "$DUCKDB_LIB_DIR" ]; then - CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" CGO_LDFLAGS="-L${DUCKDB_LIB_DIR} -lduckdb" PATH="${DUCKDB_LIB_DIR}:$PATH" go build \ - -tags "${BUILD_TAGS}" \ - -trimpath \ - -ldflags "-s -w" \ - -o "${OUTPUT_PATH}" \ - ./cmd/optional-driver-agent - cp "$DUCKDB_LIB_DIR/duckdb.dll" "$OUTDIR/duckdb.dll" - bash ./tools/compress-driver-artifact.sh "$OUTDIR/duckdb.dll" "$TARGET_PLATFORM" "${{ matrix.os_name }}/duckdb.dll" + for DRIVER_VARIANT in "${DRIVER_VARIANTS[@]}"; do + TAG="gonavi_${BUILD_DRIVER}_driver" + BUILD_TAGS="$TAG" + OUTPUT_STEM="${DRIVER}-driver-agent" + if [ "$DRIVER" = "mongodb" ]; then + if [ "$DRIVER_VARIANT" = "v1" ]; then + BUILD_TAGS="gonavi_mongodb_driver_v1" + OUTPUT_STEM="mongodb-driver-agent-v1" + elif [ "$DRIVER_VARIANT" = "v2" ]; then + BUILD_TAGS="gonavi_mongodb_driver" + OUTPUT_STEM="mongodb-driver-agent-v2" + else + BUILD_TAGS="gonavi_mongodb_driver" + fi + fi + OUTPUT="${OUTPUT_STEM}-${GOOS}-${GOARCH}" + if [ "$GOOS" = "windows" ]; then + OUTPUT="${OUTPUT}.exe" + fi + OUTPUT_PATH="${OUTDIR}/${OUTPUT}" + DUCKDB_LIB_DIR="" + if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" = "amd64" ]; then + DUCKDB_LIB_DIR="$(prepare_duckdb_windows_library)" + BUILD_TAGS="${BUILD_TAGS} duckdb_use_lib" + fi + echo "🔧 构建 ${OUTPUT_PATH} (tags=${BUILD_TAGS})" + if [ "$DRIVER" = "duckdb" ]; then + if [ -n "$DUCKDB_LIB_DIR" ]; then + DUCKDB_LIB_DIR_GCC="$(cygpath -m "$DUCKDB_LIB_DIR")" + DUCKDB_LIB_DIR_PATH="$(cygpath -u "$DUCKDB_LIB_DIR")" + # cgo 会把每个 CGO_LDFLAGS 片段转成 //go:cgo_ldflag,-L 参数不能再额外包引号。 + DUCKDB_WINDOWS_CGO_LDFLAGS="-L${DUCKDB_LIB_DIR_GCC} -lduckdb -lstdc++ -lm -lws2_32 -lwsock32 -lrstrtmgr" + CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" CGO_LDFLAGS="${DUCKDB_WINDOWS_CGO_LDFLAGS}" PATH="${DUCKDB_LIB_DIR_PATH}:$PATH" go build \ + -tags "${BUILD_TAGS}" \ + -trimpath \ + -ldflags "-s -w" \ + -o "${OUTPUT_PATH}" \ + ./cmd/optional-driver-agent + cp "$DUCKDB_LIB_DIR/duckdb.dll" "$OUTDIR/duckdb.dll" + bash ./tools/compress-driver-artifact.sh "$OUTDIR/duckdb.dll" "$TARGET_PLATFORM" "${{ matrix.os_name }}/duckdb.dll" + else + CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \ + -tags "${BUILD_TAGS}" \ + -trimpath \ + -ldflags "-s -w" \ + -o "${OUTPUT_PATH}" \ + ./cmd/optional-driver-agent + fi else - CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \ + CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \ -tags "${BUILD_TAGS}" \ -trimpath \ -ldflags "-s -w" \ -o "${OUTPUT_PATH}" \ ./cmd/optional-driver-agent fi - else - CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \ - -tags "${BUILD_TAGS}" \ - -trimpath \ - -ldflags "-s -w" \ - -o "${OUTPUT_PATH}" \ - ./cmd/optional-driver-agent - fi - bash ./tools/compress-driver-artifact.sh "${OUTPUT_PATH}" "$TARGET_PLATFORM" "${{ matrix.os_name }}/${OUTPUT}" + bash ./tools/compress-driver-artifact.sh "${OUTPUT_PATH}" "$TARGET_PLATFORM" "${{ matrix.os_name }}/${OUTPUT}" + if [ "$DRIVER" = "duckdb" ] && [ -n "$DUCKDB_LIB_DIR" ]; then + DUCKDB_ZIP_PATH="${OUTDIR}/duckdb-driver.zip" + export DUCKDB_ZIP_PATH + export DUCKDB_AGENT_PATH="${OUTPUT_PATH}" + export DUCKDB_DLL_PATH="${OUTDIR}/duckdb.dll" + python3 - <<'PY' + import os + import zipfile + + zip_path = os.environ["DUCKDB_ZIP_PATH"] + entries = [ + ("Windows/duckdb-driver-agent-windows-amd64.exe", os.environ["DUCKDB_AGENT_PATH"]), + ("Windows/duckdb.dll", os.environ["DUCKDB_DLL_PATH"]), + ] + + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for arcname, src in entries: + if not os.path.isfile(src): + raise FileNotFoundError(src) + zf.write(src, arcname) + PY + echo "📦 已生成 DuckDB Windows 专属驱动包: ${DUCKDB_ZIP_PATH}" + fi + done done + bash ./tools/verify-driver-agent-revisions.sh \ + --assets-dir drivers \ + --platform "$TARGET_PLATFORM" \ + --drivers "$CHANGED_DRIVER_AGENTS" + # macOS Packaging - name: Package macOS DMG if: contains(matrix.platform, 'darwin') @@ -702,6 +965,7 @@ jobs: REQUIRED_FILES+=("drivers/Windows/${driver}-driver-agent-windows-arm64.exe") else REQUIRED_FILES+=("drivers/Windows/duckdb.dll") + REQUIRED_FILES+=("drivers/Windows/duckdb-driver.zip") fi for file in "${REQUIRED_FILES[@]}"; do @@ -738,53 +1002,13 @@ jobs: fi echo "📦 打包驱动总包:GoNavi-DriverAgents.zip" - python3 - <<'PY' - import json - import os - import shutil - import zipfile - from pathlib import Path + python3 ../tools/package-driver-release-assets.py \ + drivers \ + ../driver-release-assets - out_name = "GoNavi-DriverAgents.zip" - index_name = "GoNavi-DriverAgents-Index.json" - base = Path("drivers") - driver_release_dir = Path("../driver-release-assets") - if driver_release_dir.exists(): - shutil.rmtree(driver_release_dir) - driver_release_dir.mkdir(parents=True, exist_ok=True) - out_path = driver_release_dir / out_name - index_path = driver_release_dir / index_name - if out_path.exists(): - out_path.unlink() - if index_path.exists(): - index_path.unlink() - - size_index = {} - standalone_assets = [] - with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in sorted(base.rglob("*")): - if not p.is_file(): - continue - arcname = p.relative_to(base).as_posix() - if p.name in size_index: - raise RuntimeError(f"driver asset name conflict: {p.name}") - zf.write(p, arcname) - size_index[p.name] = p.stat().st_size - standalone_path = driver_release_dir / p.name - if standalone_path.exists(): - raise RuntimeError(f"release asset already exists: {standalone_path}") - shutil.copy2(p, standalone_path) - standalone_assets.append(standalone_path.name) - - 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)}") - print(f"published standalone driver assets={len(standalone_assets)}") - PY + python3 ../tools/generate-driver-release-manifest.py \ + --assets-dir drivers \ + --output ../driver-release-assets/GoNavi-DriverAgents-Manifest.json # GoNavi 主仓库只保留主程序包;驱动资产发布到独立仓库。 rm -rf drivers @@ -825,18 +1049,28 @@ jobs: sha256sum "${FILES[@]}" > SHA256SUMS - name: Create Driver Agents Release - uses: softprops/action-gh-release@v3 if: startsWith(github.ref, 'refs/tags/') && steps.driver_assets.outputs.has_driver_assets == 'true' - with: - repository: Syngnat/GoNavi-DriverAgents - tag_name: ${{ github.ref_name }} - name: "GoNavi Driver Agents ${{ github.ref_name }}" - files: driver-release-assets/* - fail_on_unmatched_files: true - make_latest: true - body: | + env: + GH_TOKEN: ${{ secrets.DRIVER_RELEASE_TOKEN }} + shell: bash + run: | + set -euo pipefail + mapfile -t DRIVER_RELEASE_ASSETS < <(find driver-release-assets -maxdepth 1 -type f | sort) + if [ ${#DRIVER_RELEASE_ASSETS[@]} -eq 0 ]; then + echo "未找到 driver release assets" + exit 1 + fi + + NOTES_FILE="$RUNNER_TEMP/driver-release-notes.md" + cat > "$NOTES_FILE" <<'EOF' GoNavi driver-agent assets for `${{ github.ref_name }}`. - token: ${{ secrets.DRIVER_RELEASE_TOKEN }} + EOF + + gh release create "${{ github.ref_name }}" "${DRIVER_RELEASE_ASSETS[@]}" \ + --repo Syngnat/GoNavi-DriverAgents \ + --title "GoNavi Driver Agents ${{ github.ref_name }}" \ + --notes-file "$NOTES_FILE" \ + --latest - name: Checkout code for changelog uses: actions/checkout@v5 diff --git a/.gitignore b/.gitignore index af09dda..2810a07 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ build/bin/ # wails / node artifacts (按需) node_modules/ +frontend/wailsjs/tsconfig.json dist/ .DS_Store diff --git a/AI_EXTENSIONS_ROADMAP.md b/AI_EXTENSIONS_ROADMAP.md new file mode 100644 index 0000000..dd196e6 --- /dev/null +++ b/AI_EXTENSIONS_ROADMAP.md @@ -0,0 +1,106 @@ +# AI 扩展能力路线 + +当前 GoNavi 的 AI 链路是: + +1. 前端 `AIChatPanel` 组装 system messages。 +2. 前端声明本地固定工具 `LOCAL_TOOLS`。 +3. 后端 `aiservice.Service` 只负责 Provider 配置、安全级别与模型转发。 + +这套结构已经足够承接“用户级提示词”,但要继续承接 MCP 和 Skills,需要先把“提示词 / 工具 / 技能”三层职责拆开。 + +## 1. 用户级自定义提示词 + +已落地的方向: + +- 配置存储在 `ai_config.json` 的 `userPromptSettings`。 +- 由 `AISettingsModal` 提供编辑入口。 +- 由 `AIChatPanel` 在运行时追加为 system message。 + +建议长期保持 4 个层级: + +- `global`: 所有 AI 会话统一追加。 +- `database`: 数据库 / SQL 场景追加。 +- `jvm`: JVM 资源浏览与分析场景追加。 +- `jvmDiagnostic`: JVM 诊断命令规划场景追加。 + +这样既能满足“个人习惯”定制,也不会把所有场景揉成一条超长总提示词。 + +## 2. MCP 能力开放 + +目标不是把 MCP 做成新的聊天面板,而是把它变成“外部工具源”。 + +建议后续拆成三层: + +1. `tool registry` + - 统一收口内置工具、本地扩展工具、MCP 工具。 + - 对模型只暴露统一的 `tools[]`。 +2. `mcp server config` + - 保存 server 名称、transport、启动命令或 URL、超时、启用状态。 + - 由后端维护生命周期与连通性。 +3. `mcp runtime bridge` + - 负责 `list tools / call tool / errors / timeout / auth`。 + +### MCP 是否需要单独 GitHub 仓库 + +不需要把“GoNavi 对 MCP 的支持”单独拆仓库。 + +更合理的边界是: + +- `GoNavi 主仓库` + - 维护 MCP client、配置、UI、工具注册和运行时桥接。 +- `单独仓库(可选)` + - 只在你要发布一个可复用的 MCP Server 时才有价值。 + - 例如 `gonavi-mcp-sql-tools`、`gonavi-mcp-jvm-agent` 这类独立 server。 + +结论: + +- “客户端支持 MCP” 不需要新仓库。 +- “某个独立 MCP Server” 是否拆仓库,取决于它要不要单独发布、复用或部署。 + +## 3. Skills 设计 + +Skills 不建议直接等同于“另一种提示词”。 + +更合适的定义是: + +- `skill manifest` + - 名称、说明、适用场景、是否默认启用。 +- `skill prompt` + - 该技能追加的 system prompt / few-shot / 输出约束。 +- `skill tool requirements` + - 该技能依赖哪些内置工具或 MCP 工具。 +- `skill shortcuts` + - 可选地给欢迎卡片、斜杠命令或快速动作提供入口。 + +一个 Skill 本质上应该是“提示词 + 工具依赖 + 使用入口”的组合,而不是单独一段文案。 + +### Skills 是否需要单独 GitHub 仓库 + +第一阶段不需要。 + +建议顺序: + +1. 先在 GoNavi 主仓库内把 Skills manifest/runtime 跑通。 +2. 等格式稳定后,再考虑增加“本地目录导入”或“Git 仓库导入”。 + +只有当你明确要做下面两件事时,独立仓库才值得: + +- 把 Skills 当作社区共享资产分发。 +- 让不同团队独立维护自己的 skill pack。 + +## 建议的下一步实现顺序 + +1. 抽出统一 `ToolRegistry`,让 `LOCAL_TOOLS` 不再硬编码在聊天面板内部。 +2. 在 AI 设置中新增 `MCP Servers` 配置页。 +3. 后端先支持最小 transport: + - `stdio` + - `http/sse`(如果后续确认需要) +4. 在 AI 设置中新增 `Skills` 配置页。 +5. 让 Skill 以 manifest 形式声明: + - `id` + - `name` + - `description` + - `systemPrompt` + - `requiredTools` + - `scopes` +6. 再决定是否增加“从 Git 仓库同步 MCP/Skills 包”的分发能力。 diff --git a/README.md b/README.md index 6029bcb..cd75c60 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience | Document | MongoDB | Optional driver agent | Document query, collection browsing, connection management | | Time-series | TDengine | Optional driver agent | Time-series schema browsing and querying | | Columnar Analytics | ClickHouse | Optional driver agent | Analytical query, object browsing, SQL execution | +| Search | Elasticsearch | Optional driver agent | Index browsing, mapping inspection, JSON DSL / query_string search | | Extensibility | Custom Driver/DSN | Custom | Extend to more data sources via Driver + DSN |

📸 Screenshots

@@ -212,6 +213,16 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0 If you use Linux artifacts with the `-WebKit41` suffix, prefer Debian 13 / Ubuntu 24.04+. +### Linux: Chinese text appears as square boxes + +Minimal Ubuntu 24.04 LTS desktop/server environments may not include Chinese CJK fonts. Install Noto / WenQuanYi fonts and restart GoNavi: + +```bash +sudo apt-get update +sudo apt-get install -y fonts-noto-cjk fonts-wqy-microhei +fc-cache -fv +``` + --- ## Contributing diff --git a/README.zh-CN.md b/README.zh-CN.md index 688f86e..e8f9627 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -50,6 +50,7 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做 | 文档型 | MongoDB | 可选驱动代理 | 文档查询、集合浏览、连接管理 | | 时序 | TDengine | 可选驱动代理 | 时序库表浏览、查询分析 | | 列式分析 | ClickHouse | 可选驱动代理 | 分析查询、对象浏览、SQL 执行 | +| 搜索 | Elasticsearch | 可选驱动代理 | 索引浏览、Mapping 检查、JSON DSL / query_string 查询 | | 扩展接入 | Custom Driver/DSN | 自定义 | 通过 Driver + DSN 接入更多数据源 |

📸 项目截图

@@ -195,6 +196,16 @@ sudo apt-get update sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18 ``` +### Linux 中文显示为方框 + +Ubuntu 24.04 LTS 的最小化桌面或服务器环境可能没有安装中文 CJK 字体,GoNavi 打开后中文会显示为方框。安装 Noto / 文泉驿字体后重启 GoNavi: + +```bash +sudo apt-get update +sudo apt-get install -y fonts-noto-cjk fonts-wqy-microhei +fc-cache -fv +``` + --- ## 贡献指南 diff --git a/build-driver-agents.sh b/build-driver-agents.sh index 854348d..7574585 100755 --- a/build-driver-agents.sh +++ b/build-driver-agents.sh @@ -5,7 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" -DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss iris mongodb tdengine clickhouse) +DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss gaussdb iris mongodb tdengine iotdb clickhouse elasticsearch) DEFAULT_PLATFORMS=(darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64) DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4" DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip" @@ -42,7 +42,9 @@ normalize_driver() { case "$name" in doris|diros) echo "doris" ;; open_gauss|open-gauss) echo "opengauss" ;; - mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|iris|mongodb|tdengine|clickhouse) + gaussdb|gauss_db|gauss-db) echo "gaussdb" ;; + elasticsearch|elastic) echo "elasticsearch" ;; + mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse) echo "$name" ;; *) @@ -144,6 +146,40 @@ PY fi } +zip_duckdb_windows_package() { + local bundle_stage_dir="$1" + local zip_path="$2" + + rm -f "$zip_path" + if command -v python3 >/dev/null 2>&1; then + BUNDLE_STAGE_DIR="$bundle_stage_dir" BUNDLE_ZIP_PATH="$zip_path" python3 - <<'PY' +import os +import zipfile + +stage_dir = os.environ["BUNDLE_STAGE_DIR"] +zip_path = os.environ["BUNDLE_ZIP_PATH"] +entries = [ + ("Windows/duckdb-driver-agent-windows-amd64.exe", os.path.join(stage_dir, "Windows", "duckdb-driver-agent-windows-amd64.exe")), + ("Windows/duckdb.dll", os.path.join(stage_dir, "Windows", "duckdb.dll")), +] + +with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for arcname, src in entries: + if not os.path.isfile(src): + raise FileNotFoundError(src) + zf.write(src, arcname) +PY + elif command -v zip >/dev/null 2>&1; then + ( + cd "$bundle_stage_dir" + zip -qry "$zip_path" "Windows/duckdb-driver-agent-windows-amd64.exe" "Windows/duckdb.dll" + ) + else + echo "❌ 未找到 python3 或 zip,无法生成 DuckDB Windows 专属驱动包。" + exit 1 + fi +} + prepare_duckdb_windows_library() { local cache_root="$1" local lib_dir="$cache_root/duckdb-windows-${DUCKDB_WINDOWS_LIBRARY_VERSION}" @@ -201,6 +237,7 @@ driver_csv="" target_platform="" out_root="dist/driver-agents" bundle_name="GoNavi-DriverAgents.zip" +duckdb_windows_zip_name="duckdb-driver.zip" strict_mode="false" upx_mode="${GONAVI_DRIVER_AGENT_UPX:-auto}" @@ -402,6 +439,14 @@ fi zip_bundle "$bundle_zip_path" "$bundle_stage_dir" +duckdb_asset_path="$out_root_abs/windows-amd64/duckdb-driver-agent-windows-amd64.exe" +duckdb_dll_path="$out_root_abs/windows-amd64/$DUCKDB_WINDOWS_SUPPORT_DLL" +if [[ -f "$duckdb_asset_path" && -f "$duckdb_dll_path" ]]; then + duckdb_zip_path="$out_root_abs/windows-amd64/$duckdb_windows_zip_name" + zip_duckdb_windows_package "$bundle_stage_dir" "$duckdb_zip_path" + built_assets+=("Windows/$duckdb_windows_zip_name") +fi + echo "" echo "✅ 构建完成" echo " 单文件输出根目录:$out_root_abs" diff --git a/cmd/gonavi-mcp-server/README.md b/cmd/gonavi-mcp-server/README.md new file mode 100644 index 0000000..48ba95a --- /dev/null +++ b/cmd/gonavi-mcp-server/README.md @@ -0,0 +1,193 @@ +# GoNavi MCP Server + +`gonavi-mcp-server` 会把 GoNavi 已保存连接背后的数据库能力通过 MCP 暴露给外部客户端。本机客户端默认使用 `stdio`;云端 Agent 可使用显式开启的 Streamable HTTP 模式。 + +## 当前提供的 tools + +- `get_connections` + - 返回 GoNavi 已保存连接的 `id/name/type/target/defaultDatabase` 等摘要信息 +- `get_databases` + - 入参:`connectionId` +- `get_tables` + - 入参:`connectionId`、可选 `dbName` +- `get_columns` + - 入参:`connectionId`、可选 `dbName`、`tableName` +- `get_table_ddl` + - 入参:`connectionId`、可选 `dbName`、`tableName` +- `execute_sql` + - 入参:`connectionId`、可选 `dbName`、`sql` + - 默认只允许只读 SQL + - 如果 SQL 包含 DDL/DML,必须显式传 `allowMutating=true` + - `maxRowsPerResult` 用来限制单个结果集返回的行数,默认 `200` + +远程 Agent 只需要库表结构时,启动 HTTP 模式请加 `--schema-only`。该模式不注册 `execute_sql`,只保留连接摘要、库表、字段、索引、外键、触发器和 DDL 工具。 + +## 运行方式 + +开发态直接运行: + +```powershell +go run ./cmd/gonavi-mcp-server +``` + +显式运行本机 `stdio`: + +```powershell +go run ./cmd/gonavi-mcp-server stdio +``` + +也可以先编译: + +```powershell +go build -o .\bin\gonavi-mcp-server.exe .\cmd\gonavi-mcp-server +``` + +远程 Agent 使用 Streamable HTTP 时必须设置 bearer token: + +```powershell +$env:GONAVI_MCP_HTTP_TOKEN = "<随机token>" +go run ./cmd/gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --schema-only +``` + +安装包主程序也支持同样模式: + +```powershell +& "C:\Program Files\GoNavi\GoNavi.exe" mcp-server http --addr 127.0.0.1:8765 --path /mcp --token "<随机token>" --schema-only +``` + +默认建议只监听 `127.0.0.1`,再通过 SSH 隧道、反向代理或内网网关暴露给云端 Agent。不要在没有 TLS、防火墙和鉴权的情况下直接监听公网地址。 + +无图形界面或需要把配置交给云端 Agent 时,可直接生成 OpenClaw / Hermans 等远程 MCP 配置: + +```powershell +& "C:\Program Files\GoNavi\GoNavi.exe" mcp-server remote-config --client openclaw --url "https://<你的域名或隧道地址>/mcp" --token "<随机token>" --schema-only +``` + +独立 server 开发态也支持同样能力: + +```powershell +go run ./cmd/gonavi-mcp-server remote-config --client hermans --url "https://<你的域名或隧道地址>/mcp" --token "<随机token>" --schema-only +``` + +## Claude Code / Codex / OpenClaw / Hermans + +正式安装包场景,推荐直接在 GoNavi 里使用“AI 设置 -> MCP 服务 -> 安装到 Claude Code / 安装到 Codex”。 + +它会自动把当前安装的 `GoNavi.exe` 写入 Claude Code 的用户级 `~/.claude.json`,命令形态类似: + +```json +{ + "mcpServers": { + "gonavi": { + "type": "stdio", + "command": "C:\\Program Files\\GoNavi\\GoNavi.exe", + "args": ["mcp-server"], + "env": {} + } + } +} +``` + +这样用户不需要自己找本机 `gonavi-mcp-server.exe` 路径,安装包本体就能直接作为 MCP 入口。 + +Codex 当前使用 `~/.codex/config.toml`,GoNavi 会写入类似下面这段: + +```toml +[mcp_servers.gonavi] +command = 'C:\Program Files\GoNavi\GoNavi.exe' +args = ['mcp-server'] +startup_timeout_sec = 60 +``` + +仓库开发态如果要在本机 `Claude Code CLI` 里稳定使用这个 MCP,仍然推荐走仓库内包装脚本: + +```powershell +.\tools\claude-gonavi-mcp.ps1 -p "必须调用 gonavi MCP 的 get_connections 工具" +``` + +或者: + +```cmd +tools\claude-gonavi-mcp.cmd -p "必须调用 gonavi MCP 的 get_connections 工具" +``` + +这个脚本会先构建 `bin\gonavi-mcp-server.exe`,再通过 `--mcp-config` 和 `--strict-mcp-config` 把 GoNavi MCP 单独注入当前 Claude 会话,避免默认混合 MCP 加载时序导致的首轮工具未挂载问题。 + +OpenClaw、Hermans 这类部署在云端或远端 Linux 的 Agent,不能直接使用 Windows 本机的 `stdio` 命令。GoNavi 的连接信息和数据库密码仍应留在 Windows 本机,由 GoNavi MCP 读取保存连接和系统凭据;远端 Agent 只拿到 MCP tools 和 `connectionId`。 + +推荐接入形态: + +1. Windows 本机运行 GoNavi,并保持能访问已保存的数据库连接。 +2. 在 Windows 本机启动 `GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only`。 +3. 通过 SSH 隧道、反向代理或内网网关把 `http://127.0.0.1:8765/mcp` 暴露为云端 Agent 可访问的 HTTPS 地址。 +4. 在 OpenClaw / Hermans 中添加远程 MCP Server,transport 选择 Streamable HTTP,URL 指向 `/mcp` 地址,并设置请求头 `Authorization: Bearer <随机token>`。 +5. 先调用 `get_connections` 获取 `connectionId`,再调用 `get_databases`、`get_tables`、`get_columns`、`get_table_ddl` 等工具读取结构。 + +如果目标 Agent 支持 `mcpServers` JSON,可按下面的通用片段配置: + +```json +{ + "mcpServers": { + "gonavi": { + "type": "streamable-http", + "url": "https://<你的域名或隧道地址>/mcp", + "headers": { + "Authorization": "Bearer <随机token>" + } + } + } +} +``` + +不要把数据库 `host/user/password` 写入云端 Agent 的配置文件。默认 `--schema-only` 不暴露 `execute_sql`;如果你明确需要远程执行 SQL,可以去掉该参数,此时 `execute_sql` 仍受 GoNavi AI 安全设置控制,写操作必须显式传 `allowMutating=true`。 + +## MCP 客户端配置示例 + +开发态: + +```json +{ + "mcpServers": { + "gonavi": { + "command": "go", + "args": ["run", "./cmd/gonavi-mcp-server"] + } + } +} +``` + +Windows 独立 server 编译产物(开发态): + +```json +{ + "mcpServers": { + "gonavi": { + "command": "D:\\Work\\CodeRepos\\GoNavi\\bin\\gonavi-mcp-server.exe", + "args": [] + } + } +} +``` + +Windows 已安装 GoNavi(推荐给最终用户): + +```json +{ + "mcpServers": { + "gonavi": { + "type": "stdio", + "command": "C:\\Program Files\\GoNavi\\GoNavi.exe", + "args": ["mcp-server"], + "env": {} + } + } +} +``` + +## 使用说明 + +- 先调用 `get_connections`,拿到 `connectionId` +- 之后所有数据库工具都只传 `connectionId`,由 GoNavi 服务端内部解析保存连接和密钥 +- 如果 `dbName` 为空,会优先使用该保存连接里的默认数据库 +- Server 会读取 GoNavi 当前活动数据目录里的连接配置,并通过系统 keyring/凭据管理器解析密文 +- 如果本机凭据存储不可用,依赖密钥的连接会返回对应错误 diff --git a/cmd/gonavi-mcp-server/main.go b/cmd/gonavi-mcp-server/main.go new file mode 100644 index 0000000..95e7bfd --- /dev/null +++ b/cmd/gonavi-mcp-server/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "strings" + + "GoNavi-Wails/internal/mcpserver" +) + +func main() { + ctx := context.Background() + err := run(ctx, os.Args[1:]) + if err != nil { + log.Printf("GoNavi MCP Server 退出: %v", err) + } +} + +func run(ctx context.Context, args []string) error { + if len(args) == 0 { + return mcpserver.RunAppStdioServer(ctx) + } + + mode := strings.ToLower(strings.TrimSpace(args[0])) + switch mode { + case "stdio", "--stdio": + return mcpserver.RunAppStdioServer(ctx) + case "http", "--http", "streamable-http", "--streamable-http": + options, err := mcpserver.ParseHTTPServerOptions(args[1:]) + if err != nil { + return err + } + log.Printf("GoNavi MCP Streamable HTTP Server 启动:addr=%s path=%s schemaOnly=%v", options.Addr, options.Path, options.SchemaOnly) + return mcpserver.RunAppStreamableHTTPServer(ctx, options) + case "remote-config", "--remote-config": + return mcpserver.WriteRemoteMCPClientConfig(os.Stdout, args[1:]) + default: + return fmt.Errorf("未知 MCP server 模式: %s(支持 stdio/http/remote-config)", args[0]) + } +} diff --git a/cmd/mingw-import-lib/main.go b/cmd/mingw-import-lib/main.go new file mode 100644 index 0000000..e0a6bd8 --- /dev/null +++ b/cmd/mingw-import-lib/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "GoNavi-Wails/internal/buildutil" +) + +func main() { + var ( + dllPath string + dlltoolPath string + outputLib string + ) + + flag.StringVar(&dllPath, "dll", "", "Path to the source DLL") + flag.StringVar(&dlltoolPath, "dlltool", "", "Optional path to dlltool executable") + flag.StringVar(&outputLib, "output-lib", "", "Output import library path") + flag.Parse() + + if err := buildutil.GenerateWindowsImportLibraryFromDLL(dllPath, dlltoolPath, outputLib); err != nil { + fmt.Fprintf(os.Stderr, "generate mingw import library failed: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/optional-driver-agent/main.go b/cmd/optional-driver-agent/main.go index bf24273..4915947 100644 --- a/cmd/optional-driver-agent/main.go +++ b/cmd/optional-driver-agent/main.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "reflect" + "strconv" "strings" "time" @@ -17,6 +18,7 @@ import ( type agentRequest struct { ID int64 `json:"id"` Method string `json:"method"` + SessionID string `json:"sessionId,omitempty"` Config *connection.ConnectionConfig `json:"config,omitempty"` Query string `json:"query,omitempty"` TimeoutMs int64 `json:"timeoutMs,omitempty"` @@ -39,6 +41,8 @@ const ( agentMethodClose = "close" agentMethodMetadata = "metadata" agentMethodPing = "ping" + agentMethodOpenSession = "openSession" + agentMethodCloseSession = "closeSession" agentMethodQuery = "query" agentMethodExec = "exec" agentMethodGetDatabases = "getDatabases" @@ -59,6 +63,12 @@ var ( agentDatabaseFactory func() db.Database ) +type agentRuntime struct { + inst db.Database + sessions map[string]db.StatementExecer + nextSessionID int64 +} + func main() { if agentDatabaseFactory == nil || strings.TrimSpace(agentDriverType) == "" { fmt.Fprintf(os.Stderr, "未配置驱动代理 provider,请使用 gonavi__driver 标签构建\n") @@ -70,7 +80,9 @@ func main() { writer := bufio.NewWriter(os.Stdout) defer writer.Flush() - var inst db.Database + runtimeState := &agentRuntime{ + sessions: make(map[string]db.StatementExecer), + } for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { @@ -87,23 +99,21 @@ func main() { continue } - resp := handleRequest(&inst, req) + resp := handleRequest(runtimeState, req) if err := writeResponse(writer, resp); err != nil { fmt.Fprintf(os.Stderr, "写入响应失败:%v\n", err) break } } - if inst != nil { - _ = inst.Close() - } + runtimeState.close() if err := scanner.Err(); err != nil { fmt.Fprintf(os.Stderr, "读取请求失败:%v\n", err) } } -func handleRequest(inst *db.Database, req agentRequest) agentResponse { +func handleRequest(runtimeState *agentRuntime, req agentRequest) agentResponse { resp := agentResponse{ID: req.ID, Success: true} method := strings.TrimSpace(req.Method) @@ -112,9 +122,7 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse { if req.Config == nil { return fail(resp, "连接配置为空") } - if *inst != nil { - _ = (*inst).Close() - } + runtimeState.close() next := agentDatabaseFactory() if next == nil { return fail(resp, "驱动代理初始化失败") @@ -122,14 +130,13 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse { if err := next.Connect(*req.Config); err != nil { return fail(resp, err.Error()) } - *inst = next + runtimeState.inst = next return resp case agentMethodClose: - if *inst != nil { - if err := (*inst).Close(); err != nil { + if runtimeState.inst != nil { + if err := runtimeState.close(); err != nil { return fail(resp, err.Error()) } - *inst = nil } return resp case agentMethodMetadata: @@ -139,74 +146,124 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse { "protocolSchema": "json-lines-v1", } return resp + case agentMethodOpenSession: + if runtimeState.inst == nil { + return fail(resp, "connection not open") + } + provider, ok := runtimeState.inst.(db.SessionExecerProvider) + if !ok { + return fail(resp, fmt.Sprintf("当前数据源(%s)不支持 SQL 编辑器托管事务", strings.TrimSpace(agentDriverType))) + } + openCtx := context.Background() + var cancel context.CancelFunc + if req.TimeoutMs > 0 { + openCtx, cancel = context.WithTimeout(context.Background(), time.Duration(req.TimeoutMs)*time.Millisecond) + defer cancel() + } + session, err := provider.OpenSessionExecer(openCtx) + if err != nil { + return fail(resp, err.Error()) + } + sessionID := runtimeState.nextID() + runtimeState.sessions[sessionID] = session + resp.Data = sessionID + return resp + case agentMethodCloseSession: + if err := runtimeState.closeSession(req.SessionID); err != nil { + return fail(resp, err.Error()) + } + return resp } - if *inst == nil { + if runtimeState.inst == nil { return fail(resp, "connection not open") } + if session, ok, err := runtimeState.session(req.SessionID); err != nil { + return fail(resp, err.Error()) + } else if ok { + switch method { + case agentMethodQuery: + data, fields, err := queryStatementWithOptionalTimeout(session, req.Query, req.TimeoutMs) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + resp.Fields = fields + case agentMethodExec: + affected, err := execStatementWithOptionalTimeout(session, req.Query, req.TimeoutMs) + if err != nil { + return fail(resp, err.Error()) + } + resp.RowsAffected = affected + default: + return fail(resp, "当前事务会话不支持该方法") + } + return resp + } + switch method { case agentMethodPing: - if err := (*inst).Ping(); err != nil { + if err := runtimeState.inst.Ping(); err != nil { return fail(resp, err.Error()) } case agentMethodQuery: - data, fields, err := queryWithOptionalTimeout(*inst, req.Query, req.TimeoutMs) + data, fields, err := queryWithOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs) if err != nil { return fail(resp, err.Error()) } resp.Data = data resp.Fields = fields case agentMethodExec: - affected, err := execWithOptionalTimeout(*inst, req.Query, req.TimeoutMs) + affected, err := execWithOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs) if err != nil { return fail(resp, err.Error()) } resp.RowsAffected = affected case agentMethodGetDatabases: - data, err := (*inst).GetDatabases() + data, err := runtimeState.inst.GetDatabases() if err != nil { return fail(resp, err.Error()) } resp.Data = data case agentMethodGetTables: - data, err := (*inst).GetTables(req.DBName) + data, err := runtimeState.inst.GetTables(req.DBName) if err != nil { return fail(resp, err.Error()) } resp.Data = data case agentMethodGetCreateStmt: - data, err := (*inst).GetCreateStatement(req.DBName, req.TableName) + data, err := runtimeState.inst.GetCreateStatement(req.DBName, req.TableName) if err != nil { return fail(resp, err.Error()) } resp.Data = data case agentMethodGetColumns: - data, err := (*inst).GetColumns(req.DBName, req.TableName) + data, err := runtimeState.inst.GetColumns(req.DBName, req.TableName) if err != nil { return fail(resp, err.Error()) } resp.Data = data case agentMethodGetAllColumns: - data, err := (*inst).GetAllColumns(req.DBName) + data, err := runtimeState.inst.GetAllColumns(req.DBName) if err != nil { return fail(resp, err.Error()) } resp.Data = data case agentMethodGetIndexes: - data, err := (*inst).GetIndexes(req.DBName, req.TableName) + data, err := runtimeState.inst.GetIndexes(req.DBName, req.TableName) if err != nil { return fail(resp, err.Error()) } resp.Data = data case agentMethodGetForeignKey: - data, err := (*inst).GetForeignKeys(req.DBName, req.TableName) + data, err := runtimeState.inst.GetForeignKeys(req.DBName, req.TableName) if err != nil { return fail(resp, err.Error()) } resp.Data = data case agentMethodGetTriggers: - data, err := (*inst).GetTriggers(req.DBName, req.TableName) + data, err := runtimeState.inst.GetTriggers(req.DBName, req.TableName) if err != nil { return fail(resp, err.Error()) } @@ -215,7 +272,7 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse { if req.Changes == nil { return fail(resp, "变更集为空") } - applier, ok := (*inst).(interface { + applier, ok := runtimeState.inst.(interface { ApplyChanges(tableName string, changes connection.ChangeSet) error }) if !ok { @@ -231,6 +288,67 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse { return resp } +func (r *agentRuntime) nextID() string { + r.ensureSessionMap() + r.nextSessionID++ + return "session-" + strconv.FormatInt(r.nextSessionID, 10) +} + +func (r *agentRuntime) session(sessionID string) (db.StatementExecer, bool, error) { + r.ensureSessionMap() + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return nil, false, nil + } + session, ok := r.sessions[sessionID] + if !ok || session == nil { + return nil, false, fmt.Errorf("事务会话不存在或已结束") + } + return session, true, nil +} + +func (r *agentRuntime) closeSession(sessionID string) error { + r.ensureSessionMap() + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return fmt.Errorf("事务会话 ID 不能为空") + } + session, ok := r.sessions[sessionID] + if ok { + delete(r.sessions, sessionID) + } + if !ok || session == nil { + return fmt.Errorf("事务会话不存在或已结束") + } + return session.Close() +} + +func (r *agentRuntime) close() error { + var closeErr error + r.ensureSessionMap() + for sessionID, session := range r.sessions { + delete(r.sessions, sessionID) + if session != nil { + if err := session.Close(); err != nil && closeErr == nil { + closeErr = err + } + } + } + if r.inst != nil { + if err := r.inst.Close(); err != nil && closeErr == nil { + closeErr = err + } + r.inst = nil + } + return closeErr +} + +func (r *agentRuntime) ensureSessionMap() { + if r.sessions == nil { + r.sessions = make(map[string]db.StatementExecer) + } +} + func writeResponse(writer *bufio.Writer, resp agentResponse) error { // 对响应数据做统一 JSON 安全归一化: // 将 map[any]any(如 duckdb.Map)递归转换为 map[string]any,避免序列化失败导致代理进程退出。 @@ -301,7 +419,23 @@ func normalizeAgentResponseData(v interface{}) interface{} { } } -func queryWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) { +type agentQueryRunner interface { + Query(string) ([]map[string]interface{}, []string, error) +} + +type agentQueryContextRunner interface { + QueryContext(context.Context, string) ([]map[string]interface{}, []string, error) +} + +type agentExecRunner interface { + Exec(string) (int64, error) +} + +type agentExecContextRunner interface { + ExecContext(context.Context, string) (int64, error) +} + +func queryWithOptionalTimeout(inst agentQueryRunner, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) { effectiveTimeoutMs := timeoutMs if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") { effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond) @@ -309,9 +443,7 @@ func queryWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) ( if effectiveTimeoutMs <= 0 { return inst.Query(query) } - if q, ok := inst.(interface { - QueryContext(context.Context, string) ([]map[string]interface{}, []string, error) - }); ok { + if q, ok := inst.(agentQueryContextRunner); ok { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond) defer cancel() return q.QueryContext(ctx, query) @@ -319,7 +451,15 @@ func queryWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) ( return inst.Query(query) } -func execWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) (int64, error) { +func queryStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) { + queryRunner, ok := inst.(agentQueryRunner) + if !ok { + return nil, nil, fmt.Errorf("当前事务会话不支持查询语句") + } + return queryWithOptionalTimeout(queryRunner, query, timeoutMs) +} + +func execWithOptionalTimeout(inst agentExecRunner, query string, timeoutMs int64) (int64, error) { effectiveTimeoutMs := timeoutMs if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") { effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond) @@ -327,12 +467,14 @@ func execWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) (i if effectiveTimeoutMs <= 0 { return inst.Exec(query) } - if e, ok := inst.(interface { - ExecContext(context.Context, string) (int64, error) - }); ok { + if e, ok := inst.(agentExecContextRunner); ok { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond) defer cancel() return e.ExecContext(ctx, query) } return inst.Exec(query) } + +func execStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) (int64, error) { + return execWithOptionalTimeout(inst, query, timeoutMs) +} diff --git a/cmd/optional-driver-agent/main_test.go b/cmd/optional-driver-agent/main_test.go index 7e082e1..da976a0 100644 --- a/cmd/optional-driver-agent/main_test.go +++ b/cmd/optional-driver-agent/main_test.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "errors" + "strings" "testing" "time" @@ -77,8 +78,8 @@ func TestHandleRequestMetadataReportsAgentRevision(t *testing.T) { agentDriverType = "clickhouse" agentDatabaseFactory = func() db.Database { return nil } - var inst db.Database - resp := handleRequest(&inst, agentRequest{ID: 7, Method: agentMethodMetadata}) + runtimeState := &agentRuntime{sessions: make(map[string]db.StatementExecer)} + resp := handleRequest(runtimeState, agentRequest{ID: 7, Method: agentMethodMetadata}) if !resp.Success { t.Fatalf("metadata request failed: %s", resp.Error) } @@ -150,6 +151,45 @@ func (f *fakeAgentTimeoutDB) GetTriggers(dbName, tableName string) ([]connection return nil, nil } +type fakeAgentSessionDB struct { + fakeAgentTimeoutDB + session *fakeAgentStatementSession +} + +func (f *fakeAgentSessionDB) OpenSessionExecer(ctx context.Context) (db.StatementExecer, error) { + f.session = &fakeAgentStatementSession{} + return f.session, nil +} + +type fakeAgentStatementSession struct { + queryCalls int + execCalls int + closed bool +} + +func (f *fakeAgentStatementSession) Query(query string) ([]map[string]interface{}, []string, error) { + return f.QueryContext(context.Background(), query) +} + +func (f *fakeAgentStatementSession) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) { + f.queryCalls++ + return []map[string]interface{}{{"session_ok": 1}}, []string{"session_ok"}, nil +} + +func (f *fakeAgentStatementSession) Exec(query string) (int64, error) { + return f.ExecContext(context.Background(), query) +} + +func (f *fakeAgentStatementSession) ExecContext(ctx context.Context, query string) (int64, error) { + f.execCalls++ + return 9, nil +} + +func (f *fakeAgentStatementSession) Close() error { + f.closed = true + return nil +} + func TestQueryWithOptionalTimeout_UsesQueryContext(t *testing.T) { fake := &fakeAgentTimeoutDB{} data, fields, err := queryWithOptionalTimeout(fake, "SELECT 1", int64((2 * time.Second).Milliseconds())) @@ -198,3 +238,71 @@ func TestQueryWithOptionalTimeout_ClickHouseLegacyModeUsesQueryContext(t *testin t.Fatalf("clickhouse legacy query 调用路径异常,QueryContext=%v Query=%v", fake.queryContextCalled, fake.queryCalled) } } + +func TestHandleRequest_UsesPinnedSessionForSessionScopedQueryAndExec(t *testing.T) { + old := agentDriverType + defer func() { agentDriverType = old }() + agentDriverType = "sqlserver" + + fake := &fakeAgentSessionDB{} + runtimeState := &agentRuntime{ + inst: fake, + sessions: make(map[string]db.StatementExecer), + } + + openResp := handleRequest(runtimeState, agentRequest{ID: 1, Method: agentMethodOpenSession}) + if !openResp.Success { + t.Fatalf("openSession failed: %s", openResp.Error) + } + sessionID, ok := openResp.Data.(string) + if !ok || strings.TrimSpace(sessionID) == "" { + t.Fatalf("unexpected session id payload: %#v", openResp.Data) + } + if fake.session == nil { + t.Fatal("expected OpenSessionExecer to create a pinned session") + } + + queryResp := handleRequest(runtimeState, agentRequest{ + ID: 2, + Method: agentMethodQuery, + SessionID: sessionID, + Query: "SELECT 1", + }) + if !queryResp.Success { + t.Fatalf("session query failed: %s", queryResp.Error) + } + if fake.queryCalled || fake.queryContextCalled { + t.Fatalf("expected session query to bypass database-level query path, got Query=%v QueryContext=%v", fake.queryCalled, fake.queryContextCalled) + } + if fake.session.queryCalls != 1 { + t.Fatalf("expected pinned session queryCalls=1, got %d", fake.session.queryCalls) + } + + execResp := handleRequest(runtimeState, agentRequest{ + ID: 3, + Method: agentMethodExec, + SessionID: sessionID, + Query: "UPDATE t SET v = 1", + }) + if !execResp.Success { + t.Fatalf("session exec failed: %s", execResp.Error) + } + if fake.execCalled || fake.execContextCalled { + t.Fatalf("expected session exec to bypass database-level exec path, got Exec=%v ExecContext=%v", fake.execCalled, fake.execContextCalled) + } + if fake.session.execCalls != 1 { + t.Fatalf("expected pinned session execCalls=1, got %d", fake.session.execCalls) + } + + closeResp := handleRequest(runtimeState, agentRequest{ + ID: 4, + Method: agentMethodCloseSession, + SessionID: sessionID, + }) + if !closeResp.Success { + t.Fatalf("closeSession failed: %s", closeResp.Error) + } + if !fake.session.closed { + t.Fatal("expected pinned session to close") + } +} diff --git a/cmd/optional-driver-agent/provider_elasticsearch.go b/cmd/optional-driver-agent/provider_elasticsearch.go new file mode 100644 index 0000000..cd65b9f --- /dev/null +++ b/cmd/optional-driver-agent/provider_elasticsearch.go @@ -0,0 +1,12 @@ +//go:build gonavi_elasticsearch_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "elasticsearch" + agentDatabaseFactory = func() db.Database { + return &db.ElasticsearchDB{} + } +} diff --git a/cmd/optional-driver-agent/provider_gaussdb.go b/cmd/optional-driver-agent/provider_gaussdb.go new file mode 100644 index 0000000..6ad5750 --- /dev/null +++ b/cmd/optional-driver-agent/provider_gaussdb.go @@ -0,0 +1,12 @@ +//go:build gonavi_gaussdb_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "gaussdb" + agentDatabaseFactory = func() db.Database { + return &db.GaussDB{} + } +} diff --git a/cmd/optional-driver-agent/provider_iotdb.go b/cmd/optional-driver-agent/provider_iotdb.go new file mode 100644 index 0000000..0beb13e --- /dev/null +++ b/cmd/optional-driver-agent/provider_iotdb.go @@ -0,0 +1,12 @@ +//go:build gonavi_iotdb_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "iotdb" + agentDatabaseFactory = func() db.Database { + return &db.IoTDBDB{} + } +} diff --git a/docs/driver-manifest.json b/docs/driver-manifest.json index 7dd4802..81d826f 100644 --- a/docs/driver-manifest.json +++ b/docs/driver-manifest.json @@ -96,6 +96,12 @@ "version": "1.11.1", "checksumPolicy": "off", "downloadUrl": "builtin://activate/postgres" + }, + "elasticsearch": { + "engine": "go", + "version": "8.19.0", + "checksumPolicy": "off", + "downloadUrl": "builtin://activate/elasticsearch" } } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2b0ef39..cf89390 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "antd": "^5.12.0", "clsx": "^2.1.0", + "fflate": "^0.8.3", "mermaid": "^11.13.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -3347,6 +3348,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 45be874..197e39c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "antd": "^5.12.0", "clsx": "^2.1.0", + "fflate": "^0.8.3", "mermaid": "^11.13.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 0a899cc..55123bf 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -9cd8f4622f2dcccbe605710ea36885d4 \ No newline at end of file +eccaaf323f1be46f3102979e48be98e2 diff --git a/frontend/public/db-icons/chroma.svg b/frontend/public/db-icons/chroma.svg new file mode 100644 index 0000000..0d190a4 --- /dev/null +++ b/frontend/public/db-icons/chroma.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/db-icons/dameng.png b/frontend/public/db-icons/dameng.png new file mode 100644 index 0000000..ba44acb Binary files /dev/null and b/frontend/public/db-icons/dameng.png differ diff --git a/frontend/public/db-icons/elasticsearch.svg b/frontend/public/db-icons/elasticsearch.svg new file mode 100644 index 0000000..bbac79d --- /dev/null +++ b/frontend/public/db-icons/elasticsearch.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/public/db-icons/gaussdb.ico b/frontend/public/db-icons/gaussdb.ico new file mode 100644 index 0000000..bd9490b Binary files /dev/null and b/frontend/public/db-icons/gaussdb.ico differ diff --git a/frontend/public/db-icons/goldendb.ico b/frontend/public/db-icons/goldendb.ico new file mode 100644 index 0000000..74d3adb Binary files /dev/null and b/frontend/public/db-icons/goldendb.ico differ diff --git a/frontend/public/db-icons/highgo.ico b/frontend/public/db-icons/highgo.ico new file mode 100644 index 0000000..5a56dc0 Binary files /dev/null and b/frontend/public/db-icons/highgo.ico differ diff --git a/frontend/public/db-icons/iotdb.svg b/frontend/public/db-icons/iotdb.svg new file mode 100644 index 0000000..d742630 --- /dev/null +++ b/frontend/public/db-icons/iotdb.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/db-icons/iris.png b/frontend/public/db-icons/iris.png new file mode 100644 index 0000000..048b311 Binary files /dev/null and b/frontend/public/db-icons/iris.png differ diff --git a/frontend/public/db-icons/jvm.ico b/frontend/public/db-icons/jvm.ico new file mode 100644 index 0000000..c6b6979 Binary files /dev/null and b/frontend/public/db-icons/jvm.ico differ diff --git a/frontend/public/db-icons/kafka.png b/frontend/public/db-icons/kafka.png new file mode 100644 index 0000000..a4b5359 Binary files /dev/null and b/frontend/public/db-icons/kafka.png differ diff --git a/frontend/public/db-icons/kingbase.ico b/frontend/public/db-icons/kingbase.ico new file mode 100644 index 0000000..b1f280c Binary files /dev/null and b/frontend/public/db-icons/kingbase.ico differ diff --git a/frontend/public/db-icons/mqtt.svg b/frontend/public/db-icons/mqtt.svg new file mode 100644 index 0000000..20fe3d6 --- /dev/null +++ b/frontend/public/db-icons/mqtt.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/db-icons/oceanbase.png b/frontend/public/db-icons/oceanbase.png new file mode 100644 index 0000000..9d43ce8 Binary files /dev/null and b/frontend/public/db-icons/oceanbase.png differ diff --git a/frontend/public/db-icons/opengauss.ico b/frontend/public/db-icons/opengauss.ico new file mode 100644 index 0000000..aec5a87 Binary files /dev/null and b/frontend/public/db-icons/opengauss.ico differ diff --git a/frontend/public/db-icons/oracle.ico b/frontend/public/db-icons/oracle.ico new file mode 100644 index 0000000..c59864b Binary files /dev/null and b/frontend/public/db-icons/oracle.ico differ diff --git a/frontend/public/db-icons/qdrant.svg b/frontend/public/db-icons/qdrant.svg new file mode 100644 index 0000000..c10ad96 --- /dev/null +++ b/frontend/public/db-icons/qdrant.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/db-icons/rabbitmq.svg b/frontend/public/db-icons/rabbitmq.svg new file mode 100644 index 0000000..db28ea5 --- /dev/null +++ b/frontend/public/db-icons/rabbitmq.svg @@ -0,0 +1,7 @@ + + RabbitMQ + + diff --git a/frontend/public/db-icons/rocketmq.png b/frontend/public/db-icons/rocketmq.png new file mode 100644 index 0000000..7a70ce7 Binary files /dev/null and b/frontend/public/db-icons/rocketmq.png differ diff --git a/frontend/public/db-icons/starrocks.png b/frontend/public/db-icons/starrocks.png new file mode 100644 index 0000000..a41cbb7 Binary files /dev/null and b/frontend/public/db-icons/starrocks.png differ diff --git a/frontend/public/db-icons/tdengine.ico b/frontend/public/db-icons/tdengine.ico new file mode 100644 index 0000000..1918d15 Binary files /dev/null and b/frontend/public/db-icons/tdengine.ico differ diff --git a/frontend/public/db-icons/vastbase.svg b/frontend/public/db-icons/vastbase.svg new file mode 100644 index 0000000..370d089 --- /dev/null +++ b/frontend/public/db-icons/vastbase.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.ai-panel-error-boundary.test.ts b/frontend/src/App.ai-panel-error-boundary.test.ts index 42f4b28..ada430e 100644 --- a/frontend/src/App.ai-panel-error-boundary.test.ts +++ b/frontend/src/App.ai-panel-error-boundary.test.ts @@ -6,11 +6,16 @@ const appSource = readFileSync( fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)), 'utf8', ); +const aiPanelBoundarySource = readFileSync( + fileURLToPath(new globalThis.URL('./components/ai/AIPanelErrorBoundary.tsx', import.meta.url)), + 'utf8', +); describe('AI panel lazy-load guard', () => { it('keeps AI panel failures scoped to the panel area with retry support', () => { expect(appSource).toContain("import AIChatPanel from './components/AIChatPanel';"); - expect(appSource).toContain('class AIPanelErrorBoundary extends React.Component'); + expect(appSource).toContain("import AIPanelErrorBoundary from './components/ai/AIPanelErrorBoundary';"); + expect(aiPanelBoundarySource).toContain('class AIPanelErrorBoundary extends React.Component'); expect(appSource).toContain(' { const caseToken = `case '${action}':`; @@ -120,6 +128,10 @@ describe('tool center menu entries', () => { expect(appSource).toContain('ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`;'); }); + it('keeps legacy sidebar resize bounds aligned with the v2 sider CSS limits', () => { + expect(appCss).toMatch(/body\[data-ui-version="legacy"\]\s+\.ant-layout-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*420px\s*!important;/s); + }); + it('keeps connection modal warm-mounted while leaving the other heavyweight modals conditional', () => { expect(appSource).toContain('const [isConnectionModalMounted, setIsConnectionModalMounted] = useState(false);'); expect(appSource).toContain('{isConnectionModalMounted && ('); @@ -181,7 +193,7 @@ describe('tool center menu entries', () => { ['toggleLogPanel', 'handleToggleLogPanel();'], ['toggleTheme', 'setTheme('], ['openShortcutManager', 'setIsShortcutModalOpen(true);'], - ['toggleMacFullscreen', 'handleTitleBarWindowToggle();'], + ['toggleMacFullscreen', 'handleTitleBarWindowToggle({ allowMacNativeFullscreen: true });'], ['resetWindowZoom', 'handleManualResetWindowZoom();'], ]); @@ -195,11 +207,48 @@ describe('tool center menu entries', () => { expect(appSource).toContain('switchActiveTabByOffset, themeMode'); }); + it('automatically resets WebView2 zoom when a Windows taskbar restore returns focus', () => { + expect(appSource).toContain('shouldResetWebViewZoomForScaleFix(reason, hasViewportScaleDrift)'); + expect(appSource).toContain('const shouldResetWebViewZoom = shouldResetWebViewZoomForScaleFix(reason, hasViewportScaleDrift);'); + expect(appSource).toContain('if (shouldResetWebViewZoom && !isMaximised)'); + expect(appSource).toContain('const res = await (window as any).go?.app?.App?.ResetWebViewZoom?.();'); + expect(appSource).toContain('if (!shouldApplyWindowsScaleFix(reason, hasViewportScaleDrift))'); + expect(appSource).toContain('const nudgedWidth = getWindowsScaleFixNudgedWidth(width);'); + expect(appSource).toContain('WindowSetSize(nudgedWidth, height);'); + expect(appSource).toContain('该异常不一定表现为 viewport ratio drift'); + }); + + it('captures window state on startup and lifecycle events instead of waiting only for the polling interval', () => { + expect(appSource).toContain('const scheduleWindowStateSave = (delayMs = 120) => {'); + expect(appSource).toContain('if (hydrated) {'); + expect(appSource).toContain('scheduleWindowStateSave(320);'); + expect(appSource).toContain('const unsubscribeHydration = useStore.persist.onFinishHydration(() => {'); + expect(appSource).toContain("window.addEventListener('resize', handleWindowRuntimeChange);"); + expect(appSource).toContain("window.addEventListener('focus', handleWindowRuntimeChange);"); + expect(appSource).toContain("window.addEventListener('pageshow', handleWindowRuntimeChange);"); + expect(appSource).toContain("window.addEventListener('pagehide', handleWindowLifecycleFlush, { capture: true });"); + expect(appSource).toContain("window.addEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });"); + }); + + it('keeps titlebar double-click on maximise while shortcuts may enter macOS fullscreen', () => { + expect(appSource).toContain('const handleTitleBarWindowToggle = async (options?: { allowMacNativeFullscreen?: boolean }) => {'); + expect(appSource).toContain('const allowMacNativeFullscreen = options?.allowMacNativeFullscreen === true;'); + expect(appSource).toContain('if (allowMacNativeFullscreen && useNativeMacWindowControls && isMacRuntime) {'); + expect(appSource).toContain('void handleTitleBarWindowToggle({ allowMacNativeFullscreen: false });'); + expect(getGlobalShortcutCaseBlock('toggleMacFullscreen')).toContain('handleTitleBarWindowToggle({ allowMacNativeFullscreen: true });'); + }); + it('captures global shortcuts before Monaco/editor defaults consume them', () => { expect(appSource).toContain("window.addEventListener('keydown', handleGlobalShortcut, true);"); expect(appSource).toContain("window.removeEventListener('keydown', handleGlobalShortcut, true);"); }); + it('skips the native mac titlebar bridge when the current runtime does not expose it', () => { + expect(appSource).toContain("const backendApp = (window as any).go?.app?.App;"); + expect(appSource).toContain("if (typeof backendApp?.SetMacNativeWindowControls !== 'function') {"); + expect(appSource).toContain('void safeWindowRuntimeCall(() => SetMacNativeWindowControls(useNativeMacWindowControls), undefined);'); + }); + it('listens for command search query-tab events and routes them through handleNewQuery', () => { expect(appSource).toContain("window.addEventListener('gonavi:create-query-tab', handleCreateQueryTabEvent as EventListener);"); expect(appSource).toContain("window.removeEventListener('gonavi:create-query-tab', handleCreateQueryTabEvent as EventListener);"); @@ -228,6 +277,11 @@ describe('global appearance tokens', () => { expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies)'); expect(appSource).toContain('ListInstalledFontFamilies()'); expect(appSource).toContain('const [installedFontFamilies, setInstalledFontFamilies] = useState(EMPTY_INSTALLED_FONT_FAMILIES);'); + expect(appSource).toContain("import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';"); + expect(appSource).toContain(' React.ReactNode; - onError?: (error: Error, errorInfo: React.ErrorInfo) => void; -} - -interface AIPanelErrorBoundaryState { - hasError: boolean; - error: Error | null; -} - -class AIPanelErrorBoundary extends React.Component< - AIPanelErrorBoundaryProps, - AIPanelErrorBoundaryState -> { - constructor(props: AIPanelErrorBoundaryProps) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error: Error): AIPanelErrorBoundaryState { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - this.props.onError?.(error, errorInfo); - } - - render() { - if (this.state.hasError) { - return this.props.fallback(this.state.error); - } - - return this.props.children; - } -} - function App() { const { language, t } = useI18n(); const [isModalOpen, setIsModalOpen] = useState(false); const [isConnectionModalMounted, setIsConnectionModalMounted] = useState(false); const [isSyncModalOpen, setIsSyncModalOpen] = useState(false); + const [syncModalEntryMode, setSyncModalEntryMode] = useState('sync'); const [isDriverModalOpen, setIsDriverModalOpen] = useState(false); const [editingConnection, setEditingConnection] = useState(null); const connectionModalWarmupDoneRef = useRef(false); @@ -252,6 +231,7 @@ function App() { const setGlobalProxy = useStore(state => state.setGlobalProxy); const replaceConnections = useStore(state => state.replaceConnections); const replaceGlobalProxy = useStore(state => state.replaceGlobalProxy); + const replaceSavedQueries = useStore(state => state.replaceSavedQueries); const shortcutOptions = useStore(state => state.shortcutOptions); const updateShortcut = useStore(state => state.updateShortcut); const resetShortcutOptions = useStore(state => state.resetShortcutOptions); @@ -276,6 +256,90 @@ function App() { const effectiveSidebarTreeFontSize = sidebarTreeFontSizeFollowsGlobal ? effectiveFontSize : (sanitizeSidebarTreeFontSize(appearance.sidebarTreeFontSize) ?? effectiveFontSize); + const tabDisplaySettings = useMemo( + () => sanitizeTabDisplaySettings(appearance.tabDisplay), + [appearance.tabDisplay], + ); + const tabDisplayElementOrder = useMemo( + () => resolveTabDisplayElementOrder(tabDisplaySettings), + [tabDisplaySettings], + ); + const visibleTabDisplayElementKeys = useMemo( + () => new Set([ + ...tabDisplaySettings.primaryElements, + ...tabDisplaySettings.secondaryElements, + ]), + [tabDisplaySettings], + ); + const setTabDisplaySettings = useCallback((settings: Partial) => { + setAppearance({ + tabDisplay: applyTabDisplaySettingsPatch(tabDisplaySettings, settings), + }); + }, [setAppearance, tabDisplaySettings]); + const setTabDisplayLayout = useCallback((layout: TabDisplayLayout) => { + if (layout === tabDisplaySettings.layout) return; + setAppearance({ + tabDisplay: switchTabDisplayLayout(tabDisplaySettings, layout), + }); + }, [setAppearance, tabDisplaySettings]); + const updateTabDisplayElementVisibility = useCallback((key: TabDisplayElementKey, checked: boolean) => { + setFocusedTabDisplayElementKey(key); + const removeKey = (keys: TabDisplayElementKey[]) => keys.filter((item) => item !== key); + if (!checked) { + setTabDisplaySettings({ + layout: tabDisplaySettings.layout, + primaryElements: removeKey(tabDisplaySettings.primaryElements), + secondaryElements: removeKey(tabDisplaySettings.secondaryElements), + }); + return; + } + + const primaryElements = removeKey(tabDisplaySettings.primaryElements); + const secondaryElements = removeKey(tabDisplaySettings.secondaryElements); + if (tabDisplaySettings.layout === 'double' && TAB_DISPLAY_SECONDARY_DEFAULT_KEYS.includes(key)) { + secondaryElements.push(key); + } else { + primaryElements.push(key); + } + setTabDisplaySettings({ + layout: tabDisplaySettings.layout, + primaryElements, + secondaryElements, + }); + }, [setTabDisplaySettings, tabDisplaySettings]); + const moveTabDisplayElement = useCallback((key: TabDisplayElementKey, offset: -1 | 1) => { + setFocusedTabDisplayElementKey(key); + const moveWithin = (keys: TabDisplayElementKey[]) => { + const index = keys.indexOf(key); + if (index < 0) return keys; + const nextIndex = index + offset; + if (nextIndex < 0 || nextIndex >= keys.length) return keys; + const next = [...keys]; + [next[index], next[nextIndex]] = [next[nextIndex], next[index]]; + return next; + }; + + setTabDisplaySettings({ + layout: tabDisplaySettings.layout, + primaryElements: moveWithin(tabDisplaySettings.primaryElements), + secondaryElements: moveWithin(tabDisplaySettings.secondaryElements), + }); + }, [setTabDisplaySettings, tabDisplaySettings]); + const setTabDisplayElementRow = useCallback((key: TabDisplayElementKey, row: 'primary' | 'secondary') => { + setFocusedTabDisplayElementKey(key); + const primaryElements = tabDisplaySettings.primaryElements.filter((item) => item !== key); + const secondaryElements = tabDisplaySettings.secondaryElements.filter((item) => item !== key); + if (row === 'primary') { + primaryElements.push(key); + } else { + secondaryElements.push(key); + } + setTabDisplaySettings({ + layout: tabDisplaySettings.layout, + primaryElements, + secondaryElements, + }); + }, [setTabDisplaySettings, tabDisplaySettings]); const resolvedUiFontFamily = resolveUIFontFamily(appearance.customUIFontFamily); const resolvedMonoFontFamily = resolveMonoFontFamily(appearance.customMonoFontFamily); const appComponentSize: 'small' | 'middle' | 'large' = effectiveUiScale <= 0.92 ? 'small' : (effectiveUiScale >= 1.12 ? 'large' : 'middle'); @@ -301,6 +365,7 @@ function App() { () => buildFontFamilyOptions(runtimePlatform, 'mono', installedFontFamilies), [installedFontFamilies, runtimePlatform], ); + const linuxCJKFontInstallHint = getLinuxCJKFontInstallHint(runtimePlatform, installedFontFamilies); const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated()); const [hasLoadedSecureConfig, setHasLoadedSecureConfig] = useState(false); const [viewportWidth, setViewportWidth] = useState(() => (typeof window === 'undefined' ? 1280 : window.innerWidth || 1280)); @@ -315,6 +380,7 @@ function App() { const [isSecurityUpdateProgressOpen, setIsSecurityUpdateProgressOpen] = useState(false); const [securityUpdateProgressStage, setSecurityUpdateProgressStage] = useState(() => t('app.security_update.stage.checking_saved_config')); const [securityUpdateRepairSource, setSecurityUpdateRepairSource] = useState(null); + const [focusedTabDisplayElementKey, setFocusedTabDisplayElementKey] = useState(null); const [focusedAIProviderId, setFocusedAIProviderId] = useState(undefined); const [connectionPackageDialog, setConnectionPackageDialog] = useState(() => createClosedConnectionPackageDialogState()); const [pendingConnectionImportPayload, setPendingConnectionImportPayload] = useState(null); @@ -421,6 +487,33 @@ function App() { }; }, [isStoreHydrated]); + useEffect(() => { + if (!isStoreHydrated) { + return; + } + + let cancelled = false; + const loadSavedQueries = async () => { + try { + await bootstrapSavedQueries({ + backend: (window as any).go?.app?.App, + replaceSavedQueries: (queries) => { + if (!cancelled) { + replaceSavedQueries(queries); + } + }, + }); + } catch (err) { + console.warn('Failed to bootstrap saved queries', err); + } + }; + + void loadSavedQueries(); + return () => { + cancelled = true; + }; + }, [isStoreHydrated, replaceSavedQueries]); + const normalizeSecurityUpdateStatus = useCallback((status?: Partial | null): SecurityUpdateStatus => { const fallback = createEmptySecurityUpdateStatus(); return { @@ -698,9 +791,15 @@ function App() { // 定时保存窗口状态、尺寸与位置 useEffect(() => { const SAVE_INTERVAL_MS = 2000; + let cancelled = false; + let hydrated = useStore.persist.hasHydrated(); + let eventSaveTimer: number | null = null; let lastSaved = ''; const saveWindowState = async () => { + if (cancelled || !hydrated) { + return; + } try { const [isFs, isMax] = await Promise.all([ safeWindowRuntimeCall(() => WindowIsFullscreen(), false), @@ -744,8 +843,67 @@ function App() { } }; - const timer = window.setInterval(saveWindowState, SAVE_INTERVAL_MS); - return () => window.clearInterval(timer); + const scheduleWindowStateSave = (delayMs = 120) => { + if (cancelled || !hydrated) { + return; + } + if (eventSaveTimer !== null) { + window.clearTimeout(eventSaveTimer); + } + eventSaveTimer = window.setTimeout(() => { + eventSaveTimer = null; + void saveWindowState(); + }, delayMs); + }; + + const handleWindowRuntimeChange = () => { + scheduleWindowStateSave(); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + scheduleWindowStateSave(120); + } + }; + + const handleWindowLifecycleFlush = () => { + void saveWindowState(); + }; + + if (hydrated) { + scheduleWindowStateSave(320); + } + const unsubscribeHydration = useStore.persist.onFinishHydration(() => { + if (cancelled || hydrated) { + return; + } + hydrated = true; + scheduleWindowStateSave(320); + }); + + const timer = window.setInterval(() => { + void saveWindowState(); + }, SAVE_INTERVAL_MS); + window.addEventListener('resize', handleWindowRuntimeChange); + window.addEventListener('focus', handleWindowRuntimeChange); + window.addEventListener('pageshow', handleWindowRuntimeChange); + window.addEventListener('pagehide', handleWindowLifecycleFlush, { capture: true }); + window.addEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true }); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + cancelled = true; + if (eventSaveTimer !== null) { + window.clearTimeout(eventSaveTimer); + } + window.clearInterval(timer); + window.removeEventListener('resize', handleWindowRuntimeChange); + window.removeEventListener('focus', handleWindowRuntimeChange); + window.removeEventListener('pageshow', handleWindowRuntimeChange); + window.removeEventListener('pagehide', handleWindowLifecycleFlush, { capture: true }); + window.removeEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true }); + document.removeEventListener('visibilitychange', handleVisibilityChange); + unsubscribeHydration(); + }; }, []); useEffect(() => { @@ -791,15 +949,28 @@ function App() { devicePixelRatio: Number(window.devicePixelRatio) || 1, visualViewportScale: window.visualViewport?.scale, }); + const shouldResetWebViewZoom = shouldResetWebViewZoomForScaleFix(reason, hasViewportScaleDrift); + + if (shouldResetWebViewZoom && !isMaximised) { + try { + const res = await (window as any).go?.app?.App?.ResetWebViewZoom?.(); + if (!res?.success) { + console.warn('ResetWebViewZoom unavailable in fixWindowScaleIfNeeded:', res?.message); + } + } catch (e) { + console.warn('ResetWebViewZoom call failed in fixWindowScaleIfNeeded', e); + } + } if (isMaximised) { if (!shouldToggleMaximisedWindowForScaleFix(reason, hasViewportScaleDrift)) { - // restore + drift(任务栏点击恢复后字体异常变大)的零感知修复路径: + // restore(任务栏点击恢复后字体异常变大/变糊)的零感知修复路径: // 调 backend App.ResetWebViewZoom 触发 WebView2 ICoreWebView2Controller::put_ZoomFactor(1.0), - // 让 WebView2 重算 D2D/DirectWrite 字体度量。完全不动窗口、零动画。 + // 让 WebView2 重算 D2D/DirectWrite 字体度量。该异常不一定表现为 viewport ratio drift, + // 所以 restore 场景不能依赖 hasViewportScaleDrift。完全不动窗口、零动画。 // backend 失败(wails 升级破坏反射 / 非 Windows)时回退到 dispatch resize 兜底; // 用户仍可按 Ctrl+Shift+0 手动 toggle 修复。 - if (shouldResetWebViewZoomForScaleFix(reason, hasViewportScaleDrift)) { + if (shouldResetWebViewZoom) { try { const res = await (window as any).go?.app?.App?.ResetWebViewZoom?.(); if (!res?.success) { @@ -1480,12 +1651,11 @@ function App() { if (!isStoreHydrated || !isMacRuntime) { return; } - - try { - void SetMacNativeWindowControls(useNativeMacWindowControls).catch(() => undefined); - } catch (e) { - console.warn('Wails API: SetMacNativeWindowControls unavailable', e); + const backendApp = (window as any).go?.app?.App; + if (typeof backendApp?.SetMacNativeWindowControls !== 'function') { + return; } + void safeWindowRuntimeCall(() => SetMacNativeWindowControls(useNativeMacWindowControls), undefined); }, [isMacRuntime, isStoreHydrated, useNativeMacWindowControls]); useEffect(() => { @@ -2002,7 +2172,7 @@ function App() { try { setPendingConnectionImportPayload(null); const importedViews = await importConnectionsPayload(raw, ''); - if (importKind === 'mysql-workbench-xml' && importedViews.some(v => !v.hasPrimaryPassword)) { + if ((importKind === 'mysql-workbench-xml' || importKind === 'navicat-ncx') && importedViews.some(v => !v.hasPrimaryPassword)) { void message.warning(t('app.connection_package.message.imported_with_missing_passwords', { count: importedViews.length })); } else { void message.success(t('app.connection_package.message.imported_connections', { count: importedViews.length })); @@ -2139,12 +2309,17 @@ function App() { const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false); const [isThemeModalOpen, setIsThemeModalOpen] = useState(false); const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme'); + const [isLinuxCJKFontBannerDismissed, setIsLinuxCJKFontBannerDismissed] = useState(false); const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false); const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false); const [isSnippetModalOpen, setIsSnippetModalOpen] = useState(false); const [capturingShortcutAction, setCapturingShortcutAction] = useState(null); + const tabDisplaySettingsPanelRef = useRef(null); + const [tabDisplaySettingsFocusRequest, setTabDisplaySettingsFocusRequest] = useState(0); useEffect(() => { - if (!isThemeModalOpen || themeModalSection !== 'appearance') { + const shouldLoadInstalledFonts = + runtimePlatform === 'linux' || (isThemeModalOpen && themeModalSection === 'appearance'); + if (!shouldLoadInstalledFonts) { return; } if (hasLoadedInstalledFontsRef.current || isFontFamiliesLoading) { @@ -2192,7 +2367,17 @@ function App() { return () => { cancelled = true; }; - }, [isThemeModalOpen, t, themeModalSection]); + }, [isThemeModalOpen, runtimePlatform, t, themeModalSection]); + + useEffect(() => { + if (!isThemeModalOpen || themeModalSection !== 'appearance' || tabDisplaySettingsFocusRequest === 0) { + return; + } + const timer = window.setTimeout(() => { + tabDisplaySettingsPanelRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' }); + }, 80); + return () => window.clearTimeout(timer); + }, [isThemeModalOpen, themeModalSection, tabDisplaySettingsFocusRequest]); const shortcutConflictMap = useMemo(() => { const map: Partial> = {}; @@ -2612,7 +2797,8 @@ function App() { } }, [securityUpdateRepairSource]); - const handleTitleBarWindowToggle = async () => { + const handleTitleBarWindowToggle = async (options?: { allowMacNativeFullscreen?: boolean }) => { + const allowMacNativeFullscreen = options?.allowMacNativeFullscreen === true; const syncWindowStateFromRuntime = async () => { try { const [isFullscreen, isMaximised] = await Promise.all([ @@ -2633,7 +2819,7 @@ function App() { void emitWindowDiagnostic('action:titlebar-toggle:after-unfullscreen'); return; } - if (useNativeMacWindowControls && isMacRuntime) { + if (allowMacNativeFullscreen && useNativeMacWindowControls && isMacRuntime) { await WindowFullscreen(); await syncWindowStateFromRuntime(); void emitWindowDiagnostic('action:titlebar-toggle:after-fullscreen'); @@ -2658,7 +2844,7 @@ function App() { if (target?.closest('[data-no-titlebar-toggle="true"]')) { return; } - void handleTitleBarWindowToggle(); + void handleTitleBarWindowToggle({ allowMacNativeFullscreen: false }); }; // handleManualResetWindowZoom 由 resetWindowZoom 快捷键(默认 Ctrl+Shift+0)触发, @@ -2931,6 +3117,19 @@ function App() { }; }, []); + useEffect(() => { + const handleOpenTabDisplaySettingsEvent = () => { + setIsSettingsModalOpen(false); + setThemeModalSection('appearance'); + setIsThemeModalOpen(true); + setTabDisplaySettingsFocusRequest((current) => current + 1); + }; + window.addEventListener('gonavi:open-tab-display-settings', handleOpenTabDisplaySettingsEvent as EventListener); + return () => { + window.removeEventListener('gonavi:open-tab-display-settings', handleOpenTabDisplaySettingsEvent as EventListener); + }; + }, []); + useEffect(() => { const handleCreateQueryTabEvent = () => { handleNewQuery(); @@ -3023,7 +3222,7 @@ function App() { break; case 'toggleMacFullscreen': if (isMacRuntime && useNativeMacWindowControls) { - void handleTitleBarWindowToggle(); + void handleTitleBarWindowToggle({ allowMacNativeFullscreen: true }); } break; case 'resetWindowZoom': @@ -3196,6 +3395,13 @@ function App() { ), [darkMode]); + const showLinuxCJKFontBanner = Boolean( + linuxCJKFontInstallHint && + hasLoadedInstalledFontsRef.current && + !isFontFamiliesLoading && + !fontFamiliesLoadError && + !isLinuxCJKFontBannerDismissed, + ); return ( + {showLinuxCJKFontBanner && ( + { + setThemeModalSection('appearance'); + setIsThemeModalOpen(true); + }} + onDismiss={() => setIsLinuxCJKFontBannerDismissed(true)} + /> + )} + , + title: '表结构比对', + description: '对比源表与目标表结构差异,只预览不执行。', + onClick: () => { + setIsToolsModalOpen(false); + setSyncModalEntryMode('schemaCompare'); + setIsSyncModalOpen(true); + }, + }, + { + key: 'data-compare', + icon: , + title: '数据比对', + description: '按主键分析新增、更新、删除和相同行。', + onClick: () => { + setIsToolsModalOpen(false); + setSyncModalEntryMode('dataCompare'); + setIsSyncModalOpen(true); + }, + }, { key: 'sync', icon: , @@ -3614,6 +3854,7 @@ function App() { description: t('app.tools.entry.sync.description'), onClick: () => { setIsToolsModalOpen(false); + setSyncModalEntryMode('sync'); setIsSyncModalOpen(true); }, }, @@ -3846,6 +4087,7 @@ function App() { setIsSyncModalOpen(false)} + entryMode={syncModalEntryMode} /> )} {isDriverModalOpen && ( @@ -4165,6 +4407,23 @@ function App() { {t('app.theme.ui_version.beta_warning')} )} + {appearance.uiVersion === 'v2' && ( +
+
新版左侧搜索模式
+ setAppearance({ v2SidebarSearchMode: value as 'command' | 'filter' })} + /> +
+ 新版命令搜索适合跳转连接、表和动作,可在面板中开启同步开关持续过滤左侧树;旧版侧栏筛选会直接显示输入框并持久保留筛选内容。 +
+
+ )}
{t('app.theme.mode_title')}
@@ -4274,6 +4533,24 @@ function App() { ? t('app.theme.font_family.loaded_ui_hint', { count: installedFontFamilies.length }) : t('app.theme.font_family.loading_ui_hint'))}
+ {linuxCJKFontInstallHint && hasLoadedInstalledFontsRef.current && !isFontFamiliesLoading && !fontFamiliesLoadError && ( +
+ Ubuntu/Linux 未检测到中文 CJK 字体,界面可能显示方框。请安装: + {linuxCJKFontInstallHint} + ,然后重启 GoNavi。 +
+ )}
{t('app.theme.font_family.mono_title')}
@@ -4308,6 +4585,181 @@ function App() {
+
+
+
+
Tab 标签展示
+
+ 自定义连接名、对象类型、对象名、数据库、Schema 和 Host/IP 的展示顺序;双行模式可把上下文放到副行。 +
+
+ setTabDisplayLayout(value as TabDisplayLayout)} + /> +
+
+ {tabDisplayElementOrder.map((key) => { + const meta = TAB_DISPLAY_ELEMENT_META[key]; + const checked = visibleTabDisplayElementKeys.has(key); + const row = tabDisplaySettings.secondaryElements.includes(key) ? 'secondary' : 'primary'; + const currentRowElements = row === 'secondary' + ? tabDisplaySettings.secondaryElements + : tabDisplaySettings.primaryElements; + const indexInRow = currentRowElements.indexOf(key); + const canMoveUp = checked && indexInRow > 0; + const canMoveDown = checked && indexInRow >= 0 && indexInRow < currentRowElements.length - 1; + const isFocused = focusedTabDisplayElementKey === key; + return ( +
setFocusedTabDisplayElementKey(key)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setFocusedTabDisplayElementKey(key); + } + }} + style={{ + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto', + gap: 10, + alignItems: 'center', + padding: '9px 10px', + borderRadius: 10, + border: `1px solid ${isFocused + ? (darkMode ? 'rgba(255,214,102,0.54)' : 'rgba(24,144,255,0.54)') + : (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`, + boxShadow: isFocused + ? (darkMode ? '0 0 0 2px rgba(255,214,102,0.14)' : '0 0 0 2px rgba(24,144,255,0.12)') + : 'none', + background: isFocused + ? (darkMode ? 'linear-gradient(90deg, rgba(255,214,102,0.12) 0%, rgba(255,255,255,0.045) 100%)' : 'linear-gradient(90deg, rgba(24,144,255,0.10) 0%, rgba(255,255,255,0.78) 100%)') + : checked + ? (darkMode ? 'rgba(255,255,255,0.045)' : 'rgba(255,255,255,0.62)') + : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(16,24,40,0.025)'), + cursor: 'pointer', + transition: 'border-color 140ms ease, box-shadow 140ms ease, background 140ms ease', + }} + > +
+ + {checked && indexInRow >= 0 ? indexInRow + 1 : '-'} + + event.stopPropagation()} + onChange={(nextChecked) => updateTabDisplayElementVisibility(key, nextChecked)} + /> +
+
+ {meta.label} + {isFocused ? ( + + 当前 + + ) : null} + {checked && tabDisplaySettings.layout === 'double' ? ( + + {row === 'secondary' ? '副行' : '主行'} + + ) : null} +
+
{meta.description}
+
+
+
+ {tabDisplaySettings.layout === 'double' && checked ? ( + setTabDisplayElementRow(key, value as 'primary' | 'secondary')} + onClick={(event) => event.stopPropagation()} + /> + ) : null} + + +
+
+ ); + })} +
+
+ 当前预览:{tabDisplaySettings.layout === 'double' ? '主行 ' : ''} + {tabDisplaySettings.primaryElements.map((key) => TAB_DISPLAY_ELEMENT_META[key].label).join(' / ') || '默认标签'} + {tabDisplaySettings.layout === 'double' && tabDisplaySettings.secondaryElements.length > 0 + ? `,副行 ${tabDisplaySettings.secondaryElements.map((key) => TAB_DISPLAY_ELEMENT_META[key].label).join(' / ')}` + : ''} + {focusedTabDisplayElementKey + ? `;当前选中 ${TAB_DISPLAY_ELEMENT_META[focusedTabDisplayElementKey].label}` + : ''} +
+
{t('app.theme.appearance.transparency_blur_title')}
diff --git a/frontend/src/App.ui-version.test.ts b/frontend/src/App.ui-version.test.ts index e7e8c0f..26fedf2 100644 --- a/frontend/src/App.ui-version.test.ts +++ b/frontend/src/App.ui-version.test.ts @@ -30,6 +30,9 @@ describe('UI version switch placement', () => { expect(appSource).toContain("onClick={() => setAppearance({ uiVersion: item.key as 'legacy' | 'v2' })}"); expect(appSource).toContain('新版 UI 仍在 Beta'); expect(appSource).toContain('Windows、macOS 与 Linux 均可切换'); + expect(appSource).toContain('新版左侧搜索模式'); + expect(appSource).toContain("value={appearance.v2SidebarSearchMode ?? 'command'}"); + expect(appSource).toContain("setAppearance({ v2SidebarSearchMode: value as 'command' | 'filter' })"); }); it('uses the card-style v2 switch from the redesign instead of the segmented pill', () => { @@ -42,6 +45,7 @@ describe('UI version switch placement', () => { expect(uiVersionBlock).toContain("label: '旧版 UI'"); expect(uiVersionBlock).toContain("label: '新版 UI'"); expect(uiVersionBlock).toContain('CheckOutlined'); - expect(uiVersionBlock).not.toContain(' { + it('clears conflict markers from the merged files', () => { + expect(source).not.toMatch(/^<{7}|^={7}|^>{7}/m); + expect(testSource).not.toMatch(/^<{7}|^={7}|^>{7}/m); + }); -const panelFallbackKeys = [ - 'ai_chat.panel.history.empty', - 'ai_chat.panel.session.default_title', -] as const; + it('keeps dev split architecture while retaining render-boundary isolation', () => { + expect(source).toContain("import AIChatPanelConversationView from './ai/AIChatPanelConversationView';"); + expect(source).toContain("import { useAIChatRuntimeResources } from './ai/useAIChatRuntimeResources';"); + expect(source).toContain("import { useAIChatStreamSubscription } from './ai/useAIChatStreamSubscription';"); + expect(source).toContain("import { useAIChatLocalTools } from './ai/useAIChatLocalTools';"); -const composerNoticeKeys = [ - 'ai_chat.composer_notice.missing_provider.title', - 'ai_chat.composer_notice.missing_provider.description', - 'ai_chat.composer_notice.missing_model.title', - 'ai_chat.composer_notice.missing_model.description', - 'ai_chat.composer_notice.model_fetch_failed.title', - 'ai_chat.composer_notice.model_fetch_failed.default_description', - 'ai_chat.composer_notice.model_fetch_failed.detail_description', -] as const; - -const sendLifecycleKeys = [ - 'ai_chat.panel.status.model_connecting', - 'ai_chat.panel.status.waking_engine', - 'ai_chat.panel.status.waiting_response', - 'ai_chat.panel.message.service_not_ready', - 'ai_chat.panel.message.send_failed', -] as const; - -const toolTransitionAndErrorKeys = [ - 'ai_chat.panel.message.error', - 'ai_chat.panel.message.empty_response', - 'ai_chat.panel.message.request_interrupted', - 'ai_chat.panel.status.summarizing_probe', - 'ai_chat.panel.status.returning_runtime_data', - 'ai_chat.panel.status.deep_reasoning', - 'ai_chat.panel.status.waiting_instruction', - 'ai_chat.panel.status.analyzing_chain', -] as const; - -const modelControlKeys = [ - 'ai_chat.panel.model_control.force_tool_call', - 'ai_chat.panel.model_control.continue_after_summary', -] as const; - -const memoryAndSanitizedErrorKeys = [ - 'ai_chat.panel.status.memory_compressing', - 'ai_chat.panel.status.memory_compress_failed', - 'ai_chat.panel.status.memory_summary', - 'ai_chat.panel.status.memory_probe_summary', - 'ai_chat.panel.error.unknown', - 'ai_chat.panel.error.http_server', - 'ai_chat.panel.error.html_response', - 'ai_chat.panel.error.truncated_suffix', -] as const; - -const memorySummaryPromptKey = 'ai_chat.panel.prompt.memory_summary' as const; - -const jvmDiagnosticPromptKey = 'ai_chat.panel.prompt.jvm_diagnostic' as const; - -const jvmRuntimePromptKey = 'ai_chat.panel.prompt.jvm_runtime' as const; - -const sqlPromptKeys = [ - 'ai_chat.panel.prompt.sql.context_tables', - 'ai_chat.panel.prompt.sql.current_database', - 'ai_chat.panel.prompt.sql.no_context', - 'ai_chat.panel.prompt.sql.no_connections', -] as const; - -const jvmDiagnosticPolicyKeys = [ - 'ai_chat.panel.jvm_diagnostic.policy.read_only', - 'ai_chat.panel.jvm_diagnostic.policy.plan_first', - 'ai_chat.panel.jvm_diagnostic.permission.allowed', - 'ai_chat.panel.jvm_diagnostic.permission.forbidden', -] as const; - -const jvmRuntimePolicyKeys = [ - 'ai_chat.panel.jvm_runtime.policy.read_only', - 'ai_chat.panel.jvm_runtime.policy.preview_required', - 'ai_chat.panel.jvm_runtime.resource_path.current', - 'ai_chat.panel.jvm_runtime.resource_path.missing', -] as const; - -const executeLocalToolWrapperKeys = [ - 'ai_chat.panel.tool_result.columns_exact_fields', - 'ai_chat.panel.tool_error.connection_not_found', - 'ai_chat.panel.tool_error.unknown_function', - 'ai_chat.panel.tool_error.fetch_databases_failed', - 'ai_chat.panel.tool_error.fetch_tables_failed', - 'ai_chat.panel.tool_error.fetch_columns_failed', - 'ai_chat.panel.tool_error.fetch_table_ddl_failed', - 'ai_chat.panel.tool_error.sql_blocked', - 'ai_chat.panel.tool_error.sql_execute_failed', - 'ai_chat.panel.tool_error.sql_execute_exception', - 'ai_chat.panel.probe.max_rounds', - 'ai_chat.panel.probe.consecutive_failed', -] as const; - -const localToolSchemaKeys = [ - 'ai_chat.panel.local_tool.get_connections.description', - 'ai_chat.panel.local_tool.get_databases.description', - 'ai_chat.panel.local_tool.get_tables.description', - 'ai_chat.panel.local_tool.get_columns.description', - 'ai_chat.panel.local_tool.get_table_ddl.description', - 'ai_chat.panel.local_tool.execute_sql.description', - 'ai_chat.panel.local_tool.param.connection_id', - 'ai_chat.panel.local_tool.param.connection_id_from_get_connections', - 'ai_chat.panel.local_tool.param.db_name', - 'ai_chat.panel.local_tool.param.table_name', - 'ai_chat.panel.local_tool.param.sql', -] as const; - -const localToolFunctionNames = [ - 'get_connections', - 'get_databases', - 'get_tables', - 'get_columns', - 'get_table_ddl', - 'execute_sql', -] as const; - -const localToolSchemaRawSnippets = [ - "type: 'function'", - "type: 'object'", - "type: 'string'", - "connectionId: { type: 'string'", - "dbName: { type: 'string'", - "tableName: { type: 'string'", - "sql: { type: 'string'", - "required: ['connectionId']", - "required: ['connectionId', 'dbName']", - "required: ['connectionId', 'dbName', 'tableName']", - "required: ['connectionId', 'dbName', 'sql']", -] as const; - -const fixedChineseLocalToolSchemaSnippets = [ - '当需要查询、操作数据库但用户没有选择任何连接上下文时', - '获取指定连接(connectionId)下的所有数据库', - '当已经确定了目标连接和数据库名后', - '获取指定表的字段列表', - '获取指定表的完整建表语句', - '在指定连接和数据库上执行 SQL 查询并返回结果', - '连接ID', - '数据库名', - '表名', - '要执行的 SQL 语句', -] as const; - -const aiInsightKeys = [ - 'ai_chat.panel.insight.context.linked_title', - 'ai_chat.panel.insight.context.empty_title', - 'ai_chat.panel.insight.context.linked_body', - 'ai_chat.panel.insight.context.empty_body', - 'ai_chat.panel.insight.context.table_separator', - 'ai_chat.panel.insight.context.more_tables_suffix', - 'ai_chat.panel.insight.query.slowest_title', - 'ai_chat.panel.insight.query.empty_title', - 'ai_chat.panel.insight.query.empty_body', - 'ai_chat.panel.insight.status.failed_title', - 'ai_chat.panel.insight.status.ok_title', - 'ai_chat.panel.insight.status.recent_body', - 'ai_chat.panel.insight.status.empty_body', - 'ai_chat.panel.insight.write.detected_title', - 'ai_chat.panel.insight.write.readonly_title', - 'ai_chat.panel.insight.write.detected_body', - 'ai_chat.panel.insight.write.readonly_body', -] as const; - -const getPlaceholders = (value: string): string[] => - Array.from(value.matchAll(/\{\{([A-Za-z0-9_]+)\}\}/g), (match) => match[1]).sort(); - -const countOccurrences = (value: string, needle: string): number => value.split(needle).length - 1; - -const getStreamSubscriptionEffect = (): string => { - const marker = 'EventsOn(eventName, handler);'; - const markerIndex = source.indexOf(marker); - expect(markerIndex).toBeGreaterThan(-1); - - const effectStart = source.lastIndexOf('useEffect(() => {', markerIndex); - const depsEnd = source.indexOf(']);', markerIndex); - expect(effectStart).toBeGreaterThan(-1); - expect(depsEnd).toBeGreaterThan(-1); - - return source.slice(effectStart, depsEnd + 3); -}; - -const getStreamSubscriptionDeps = (): string[] => { - const effect = getStreamSubscriptionEffect(); - const match = effect.match(/\}, \[([\s\S]*?)\]\);$/); - expect(match).not.toBeNull(); - - return (match?.[1] || '') - .split(',') - .map((part) => part.trim()) - .filter(Boolean); -}; - -const getExecuteLocalToolsCallback = (): string => { - const marker = 'const executeLocalTools = useCallback(async'; - const callbackStart = source.indexOf(marker); - expect(callbackStart).toBeGreaterThan(-1); - - const rest = source.slice(callbackStart); - const callbackEnd = rest.match(/^ \}, \[([^\]]*)\]\);/m); - expect(callbackEnd).not.toBeNull(); - expect(callbackEnd?.index).toBeDefined(); - - return rest.slice(0, (callbackEnd?.index || 0) + (callbackEnd?.[0].length || 0)); -}; - -const getExecuteLocalToolsDeps = (): string[] => { - const callback = getExecuteLocalToolsCallback(); - const match = callback.match(/\}, \[([^\]]*)\]\);$/); - expect(match).not.toBeNull(); - - return (match?.[1] || '') - .split(',') - .map((part) => part.trim()) - .filter(Boolean); -}; - -const getLocalToolsBuilder = (): string => { - const marker = 'const buildLocalTools = (translateLocalToolSchema: AIChatTranslator) => ['; - const builderStart = source.indexOf(marker); - expect(builderStart).toBeGreaterThan(-1); - - const builderEnd = source.indexOf('export const AIChatPanel', builderStart); - expect(builderEnd).toBeGreaterThan(builderStart); - - return source.slice(builderStart, builderEnd); -}; - -const getFetchDynamicModelsCallback = (): string => { - const marker = 'const fetchDynamicModels = useCallback(async () => {'; - const callbackStart = source.indexOf(marker); - expect(callbackStart).toBeGreaterThan(-1); - - const rest = source.slice(callbackStart); - const callbackEnd = rest.match(/^ \}, \[([^\]]*)\]\);/m); - expect(callbackEnd).not.toBeNull(); - expect(callbackEnd?.index).toBeDefined(); - - return rest.slice(0, (callbackEnd?.index || 0) + (callbackEnd?.[0].length || 0)); -}; - -const getFetchDynamicModelsDeps = (): string[] => { - const callback = getFetchDynamicModelsCallback(); - const match = callback.match(/\}, \[([^\]]*)\]\);$/); - expect(match).not.toBeNull(); - - return (match?.[1] || '') - .split(',') - .map((part) => part.trim()) - .filter(Boolean); -}; - -const getAiInsightsMemo = (): string => { - const marker = 'const aiInsights = useMemo(() => {'; - const memoStart = source.indexOf(marker); - expect(memoStart).toBeGreaterThan(-1); - - const rest = source.slice(memoStart); - const memoEnd = rest.match(/^ \}, \[([^\]]*)\]\);/m); - expect(memoEnd).not.toBeNull(); - expect(memoEnd?.index).toBeDefined(); - - return rest.slice(0, (memoEnd?.index || 0) + (memoEnd?.[0].length || 0)); -}; - -const getAiInsightsDeps = (): string[] => { - const memo = getAiInsightsMemo(); - const match = memo.match(/\}, \[([^\]]*)\]\);$/); - expect(match).not.toBeNull(); - - return (match?.[1] || '') - .split(',') - .map((part) => part.trim()) - .filter(Boolean); -}; - -const getBuildSystemContextMessagesCallback = (): string => { - const marker = 'const buildSystemContextMessages = useCallback(async'; - const callbackStart = source.indexOf(marker); - expect(callbackStart).toBeGreaterThan(-1); - - const rest = source.slice(callbackStart); - const callbackEnd = rest.match(/^ \}, \[\]\);/m); - expect(callbackEnd).not.toBeNull(); - expect(callbackEnd?.index).toBeDefined(); - - return rest.slice(0, (callbackEnd?.index || 0) + (callbackEnd?.[0].length || 0)); -}; - -const getBuildSystemContextMessagesDeps = (): string[] => { - const callback = getBuildSystemContextMessagesCallback(); - const match = callback.match(/\}, \[([^\]]*)\]\);$/); - expect(match).not.toBeNull(); - - return (match?.[1] || '') - .split(',') - .map((part) => part.trim()) - .filter(Boolean); -}; - -describe('AIChatPanel message render isolation', () => { - it('keeps per-message render failures scoped to the broken bubble', () => { - expect(source).toContain('class AIMessageRenderBoundary extends React.Component'); - expect(source).toContain('[AI Message Render Error]'); + expect(boundarySource).toContain('class AIMessageRenderBoundary extends React.Component'); + expect(conversationViewSource).toContain("import AIMessageRenderBoundary from './AIMessageRenderBoundary';"); + expect(conversationViewSource).toContain(' { - for (const key of renderErrorKeys) { - expect(source).toContain(key); - } - - expect(source).toContain('translateRenderError={t}'); - expect(source).toContain("this.state.error?.message || translateRenderError('ai_chat.panel.render_error.unknown')"); - - expect(source).not.toContain('这条 AI 消息渲染失败,已自动隔离'); - expect(source).not.toContain('其余对话仍可继续使用。你可以先删除这条异常消息,再继续操作。'); - expect(source).not.toContain('未知渲染错误'); - expect(source).not.toContain('重试渲染'); - expect(source).not.toContain('删除这条消息'); - }); - - it('keeps render-boundary catalog keys present and placeholder-free in every language', () => { - for (const language of SUPPORTED_LANGUAGES) { - for (const key of renderErrorKeys) { - expect(catalogs[language]).toHaveProperty(key); - expect(catalogs[language][key]).toBeTruthy(); - expect(getPlaceholders(catalogs[language][key])).toEqual([]); - } - } - }); - - it('uses i18n fallback text for V2 history and default session titles', () => { - expect(source).toContain("t('ai_chat.panel.history.empty')"); - expect(source).toContain("session.title || t('ai_chat.panel.session.default_title')"); - expect(source).toContain("?.title || t('ai_chat.panel.session.default_title')"); - expect(countOccurrences(source, "t('ai_chat.panel.history.empty')")).toBe(1); - expect(countOccurrences(source, "t('ai_chat.panel.session.default_title')")).toBe(2); - - expect(source).not.toContain('暂无历史会话'); - expect(source).not.toContain("session.title || '新对话'"); - expect(source).not.toContain("?.title || '新对话'"); - }); - - it('keeps panel fallback catalog keys present and placeholder-free in every language', () => { - for (const language of SUPPORTED_LANGUAGES) { - const catalog = catalogs[language] as Record; - for (const key of panelFallbackKeys) { - expect(catalog).toHaveProperty(key); - expect(catalog[key]).toBeTruthy(); - expect(getPlaceholders(catalog[key])).toEqual([]); - } - } - }); - - it('keeps raw session titles untranslated while localizing only empty-title fallback', () => { - expect(source).toContain("session.title || t('ai_chat.panel.session.default_title')"); - expect(source).not.toContain('t(session.title'); - expect(source).not.toContain('t(currentSession'); - }); - - it('uses i18n keys for send lifecycle chrome while preserving raw send details', () => { - for (const key of sendLifecycleKeys) { - expect(source).toContain(key); - } - - expect(source).toContain("t('ai_chat.panel.message.send_failed', { detail: cleanE })"); - expect(source).toContain("t('ai_chat.panel.message.send_failed', { detail: cleanE2 })"); - expect(source).not.toContain('t(cleanE'); - expect(source).not.toContain('t(cleanE2'); - expect(source).not.toContain('t(rawE'); - expect(source).not.toContain('t(rawE2'); - - expect(source).not.toContain('等待模型响应'); - expect(source).not.toContain('❌ 发送失败:'); - }); - - it('keeps send-failed wrapper catalog placeholder limited to detail in every language', () => { - for (const language of SUPPORTED_LANGUAGES) { - const catalog = catalogs[language] as Record; - expect(catalog).toHaveProperty('ai_chat.panel.message.send_failed'); - expect(catalog['ai_chat.panel.message.send_failed']).toBeTruthy(); - expect(getPlaceholders(catalog['ai_chat.panel.message.send_failed'])).toEqual(['detail']); - } - }); - - it('uses i18n keys for tool transition and generic error chrome while preserving raw details', () => { - for (const key of toolTransitionAndErrorKeys) { - expect(source).toContain(key); - } - - expect(source).toContain("t('ai_chat.panel.message.error', { detail: cleanErr })"); - expect(source).not.toContain('t(cleanErr'); - expect(source).not.toContain('t(rawErr'); - - expect(source).not.toContain('❌ 错误: ${cleanErr}'); - expect(source).not.toContain('❌ 模型未能成功响应任何内容,可能遭遇频控、上下文超载或理解拒绝。'); - expect(source).not.toContain('❌ 请求中断:未收到任何具体回复。'); - expect(source).not.toContain('汇总探针执行结果中'); - expect(source).not.toContain('向模型回传运行时数据'); - expect(source).not.toContain('模型大脑深度推理中'); - expect(source).not.toContain('等待下发操作指令'); - expect(source).not.toContain('正在深度思考链路与逻辑'); - }); - - it('keeps generic error wrapper catalog placeholder limited to detail in every language', () => { - for (const language of SUPPORTED_LANGUAGES) { - const catalog = catalogs[language] as Record; - expect(catalog).toHaveProperty('ai_chat.panel.message.error'); - expect(catalog['ai_chat.panel.message.error']).toBeTruthy(); - expect(getPlaceholders(catalog['ai_chat.panel.message.error'])).toEqual(['detail']); - } - }); - - it('uses i18n keys for model-control prompt wrappers', () => { - const streamSubscriptionEffect = getStreamSubscriptionEffect(); - const executeLocalToolsCallback = getExecuteLocalToolsCallback(); - - expect(streamSubscriptionEffect).toContain("content: tRef.current('ai_chat.panel.model_control.force_tool_call')"); - expect(executeLocalToolsCallback).toContain( - "content: translateToolChrome('ai_chat.panel.model_control.continue_after_summary')", - ); - }); - - it('does not keep fixed Chinese model-control prompt wrappers in code', () => { - expect(source).not.toContain('请直接使用 function call 调用工具执行操作,不要只用文字描述计划。'); - expect(source).not.toContain('请根据上述最新状态与探索结果,继续完成你先前未竟的分析或执行下一步。'); - }); - - it('keeps model-control catalog keys present and placeholder-free in every language', () => { - for (const language of SUPPORTED_LANGUAGES) { - const catalog = catalogs[language] as Record; - for (const key of modelControlKeys) { - expect(catalog).toHaveProperty(key); - expect(catalog[key]).toBeTruthy(); - expect(getPlaceholders(catalog[key])).toEqual([]); - } - } - }); - - it('uses i18n keys for executeLocalTools fixed wrappers while preserving raw tool details', () => { - for (const key of executeLocalToolWrapperKeys) { - expect(source).toContain(key); - } - - expect(source).toContain("translateToolChrome('ai_chat.panel.probe.max_rounds', { count: MAX_TOOL_CALL_ROUNDS })"); - expect(source).toContain("translateToolChrome('ai_chat.panel.probe.consecutive_failed')"); - expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.connection_not_found')"); - expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.unknown_function', { functionName: tc.function.name })"); - expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.fetch_databases_failed', { detail: String(e?.message || e) })"); - expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.fetch_tables_failed', { detail: String(e?.message || e) })"); - expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.fetch_columns_failed', { detail: String(e?.message || e) })"); - expect(source).toContain("translateToolChrome('ai_chat.panel.tool_result.columns_exact_fields', { tableName: safeTable, fieldNames, detailJson: JSON.stringify(cols) })"); - expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.fetch_table_ddl_failed', { detail: String(e?.message || e) })"); - expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.sql_blocked', { operationType: check.operationType })"); - expect(source).toContain("qRes?.message || translateToolChrome('ai_chat.panel.tool_error.sql_execute_failed')"); - expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.sql_execute_exception', { detail: String(e?.message || e) })"); - - expect(source).not.toContain('t(qRes?.message'); - expect(source).not.toContain('t(safeSql'); - expect(source).not.toContain('t(safeDbName'); - expect(source).not.toContain('t(safeTable'); - expect(source).not.toContain('t(toolResult.content'); - expect(source).not.toContain('t(fieldNames'); - expect(source).not.toContain('t(JSON.stringify(cols)'); - - expect(source).not.toContain('以下为 ${safeTable} 表的真实字段列表'); - expect(source).not.toContain('可用字段:${fieldNames}'); - expect(source).not.toContain('详细信息:${JSON.stringify(cols)}'); - expect(source).not.toContain('`⚠️ 工具调用已达 ${MAX_TOOL_CALL_ROUNDS} 轮上限,自动终止循环。如需继续探索,请发送新的消息。`'); - expect(source).not.toContain('`获取数据库列表失败: ${e?.message || e}`'); - expect(source).not.toContain('`获取表列表失败: ${e?.message || e}`'); - expect(source).not.toContain('`获取字段列表失败: ${e?.message || e}`'); - expect(source).not.toContain('`获取建表语句失败: ${e?.message || e}`'); - expect(source).not.toContain('`安全策略拦截:当前安全级别不允许执行 ${check.operationType} 类型的 SQL。请将 SQL 展示给用户,让用户手动执行。`'); - expect(source).not.toContain("'SQL 执行失败'"); - expect(source).not.toContain('`SQL 执行异常: ${e?.message || e}`'); - expect(source).not.toContain("'⚠️ 探针连续 3 轮执行失败,自动终止。请检查连接状态后重试。'"); - expect(source).not.toContain("'Connection not found'"); - expect(source).not.toContain('`Unknown function: ${tc.function.name}`'); - }); - - it('keeps executeLocalTools wrapper catalog placeholders exact in every language', () => { - const placeholderExpectations: Record<(typeof executeLocalToolWrapperKeys)[number], string[]> = { - 'ai_chat.panel.tool_result.columns_exact_fields': ['detailJson', 'fieldNames', 'tableName'], - 'ai_chat.panel.tool_error.connection_not_found': [], - 'ai_chat.panel.tool_error.unknown_function': ['functionName'], - 'ai_chat.panel.tool_error.fetch_databases_failed': ['detail'], - 'ai_chat.panel.tool_error.fetch_tables_failed': ['detail'], - 'ai_chat.panel.tool_error.fetch_columns_failed': ['detail'], - 'ai_chat.panel.tool_error.fetch_table_ddl_failed': ['detail'], - 'ai_chat.panel.tool_error.sql_blocked': ['operationType'], - 'ai_chat.panel.tool_error.sql_execute_failed': [], - 'ai_chat.panel.tool_error.sql_execute_exception': ['detail'], - 'ai_chat.panel.probe.max_rounds': ['count'], - 'ai_chat.panel.probe.consecutive_failed': [], - }; - - for (const language of SUPPORTED_LANGUAGES) { - const catalog = catalogs[language] as Record; - for (const key of executeLocalToolWrapperKeys) { - expect(catalog).toHaveProperty(key); - expect(catalog[key]).toBeTruthy(); - expect(getPlaceholders(catalog[key])).toEqual(placeholderExpectations[key]); - } - } - }); - - it('uses the current translator inside executeLocalTools without depending on stale t closures', () => { - const callback = getExecuteLocalToolsCallback(); - const deps = getExecuteLocalToolsDeps(); - - expect(callback).toContain('const translateToolChrome: AIChatTranslator = (key, params) => tRef.current(key, params);'); - expect(callback).not.toContain('const translateToolChrome = tRef.current;'); - expect(deps).not.toContain('t'); - expect(callback).not.toMatch(/\bt\(/); - - for (const key of executeLocalToolWrapperKeys) { - expect(callback).toContain(`translateToolChrome('${key}'`); - } - }); - - it('builds LOCAL_TOOLS schema descriptions from the current translator while preserving raw schema identifiers', () => { - const builder = getLocalToolsBuilder(); - - expect(source).toContain('const getLocalTools = useCallback(() => buildLocalTools(tRef.current), []);'); - expect(source).toContain('AIChatStream(sid, allMsg, getLocalTools())'); - expect(source).toContain('AIChatStream(sid, allMessages, getLocalTools())'); - expect(source).toContain('AIChatSend(allMessages, getLocalTools())'); - expect(source).toContain('const chainTools = totalToolRoundRef.current >= SOFT_LIMIT_ROUNDS ? [] : getLocalTools();'); - expect(source).not.toContain('LOCAL_TOOLS'); - - for (const key of localToolSchemaKeys) { - expect(builder).toContain(`translateLocalToolSchema('${key}')`); - } - - for (const functionName of localToolFunctionNames) { - expect(builder).toContain(`name: '${functionName}'`); - } - - for (const rawSnippet of localToolSchemaRawSnippets) { - expect(builder).toContain(rawSnippet); - } - - for (const fixedChineseSnippet of fixedChineseLocalToolSchemaSnippets) { - expect(builder).not.toContain(fixedChineseSnippet); - } - }); - - it('keeps local tool schema catalog entries placeholder-free in every language', () => { - for (const language of SUPPORTED_LANGUAGES) { - const catalog = catalogs[language] as Record; - for (const key of localToolSchemaKeys) { - expect(catalog).toHaveProperty(key); - expect(catalog[key]).toBeTruthy(); - expect(getPlaceholders(catalog[key])).toEqual([]); - } - } - }); - - it('uses i18n composer notices while preserving raw model-fetch failure detail', () => { - const callback = getFetchDynamicModelsCallback(); - const deps = getFetchDynamicModelsDeps(); - - expect(source).toContain('AIComposerNoticeDescriptor'); - expect(source).toContain( - 'const [composerNoticeState, setComposerNoticeState] = useState(null);', - ); - expect(source).toContain( - 'const composerNotice = useMemo(() => buildAIComposerNotice(t, composerNoticeState), [composerNoticeState, t]);', - ); + it('restores panel-level i18n orchestration for composer notices and send lifecycle text', () => { + expect(source).toContain("import { useI18n } from '../i18n/provider';"); + expect(source).toContain("import type { AIComposerNoticeDescriptor } from '../utils/aiComposerNotice';"); + expect(source).toContain("import { buildAIComposerNotice } from '../utils/aiComposerNotice';"); + expect(source).toContain("const { t } = useI18n();"); + expect(source).toContain("const [composerNoticeState, setComposerNoticeState] = useState(null);"); + expect(source).toContain("buildAIComposerNotice(t, composerNoticeState) ?? runtimeComposerNotice"); expect(source).toContain("setComposerNoticeState({ kind: 'missing_provider' });"); + expect(source).toContain("setComposerNoticeState({ kind: 'provider_incomplete', issues: readiness.issues });"); expect(source).toContain("setComposerNoticeState({ kind: 'missing_model' });"); - expect(callback).toContain("setComposerNoticeState({ kind: 'model_fetch_failed', detail: result.error });"); - expect(callback).toContain("const detail = e?.message || String(e || '');"); - expect(callback).toContain("setComposerNoticeState({ kind: 'model_fetch_failed', detail });"); - expect(deps).not.toContain('t'); - - expect(source).not.toContain('setComposerNotice(buildMissingProviderNotice(t));'); - expect(source).not.toContain('setComposerNotice(buildMissingModelNotice(t));'); - expect(callback).not.toContain('buildModelFetchFailedNotice(t'); - expect(callback).not.toMatch(/\bt\(/); - expect(source).not.toContain('setComposerNotice(buildMissingProviderNotice());'); - expect(source).not.toContain('setComposerNotice(buildMissingModelNotice());'); - expect(source).not.toContain("buildModelFetchFailedNotice('获取模型列表失败:'"); - }); - - it('keeps composer notice catalog placeholders exact in every language', () => { - const placeholderExpectations: Record<(typeof composerNoticeKeys)[number], string[]> = { - 'ai_chat.composer_notice.missing_provider.title': [], - 'ai_chat.composer_notice.missing_provider.description': [], - 'ai_chat.composer_notice.missing_model.title': [], - 'ai_chat.composer_notice.missing_model.description': [], - 'ai_chat.composer_notice.model_fetch_failed.title': [], - 'ai_chat.composer_notice.model_fetch_failed.default_description': [], - 'ai_chat.composer_notice.model_fetch_failed.detail_description': ['detail'], - }; - - for (const language of SUPPORTED_LANGUAGES) { - const catalog = catalogs[language] as Record; - for (const key of composerNoticeKeys) { - expect(catalog).toHaveProperty(key); - expect(catalog[key]).toBeTruthy(); - expect(getPlaceholders(catalog[key])).toEqual(placeholderExpectations[key]); - } - } - }); - - it('uses i18n keys for AI insight chrome while preserving raw insight data', () => { - const memo = getAiInsightsMemo(); - const deps = getAiInsightsDeps(); - - for (const key of aiInsightKeys) { - expect(memo).toContain(key); - } - - expect(memo).toContain('slowest.sql.slice(0, 140)'); - expect(memo).toContain('errors[0]?.message ||'); - expect(deps).toContain('t'); - - expect(source).not.toContain('已关联 ${contextCount} 张表'); - expect(source).not.toContain('尚未关联表结构'); - expect(source).not.toContain('当前对话会带上 '); - expect(source).not.toContain(' 的结构上下文。'); - expect(source).not.toContain('在表页打开 AI 后会自动关联当前表,也可以在输入框上方手动添加上下文。'); - expect(source).not.toContain('最近最慢查询 ${Math.round(slowest.duration).toLocaleString()}ms'); - expect(source).not.toContain('暂无查询耗时样本'); - expect(source).not.toContain('执行查询后这里会显示可用于优化分析的 SQL 线索。'); - expect(source).not.toContain('${errors.length} 条最近查询失败'); - expect(source).not.toContain('最近查询状态正常'); - expect(source).not.toContain('已记录 ${recentLogs.length} 条最近 SQL,可直接让 AI 解释或优化。'); - expect(source).not.toContain('暂无 SQL 日志。'); - expect(source).not.toContain('检测到 ${writeCount} 条写操作'); - expect(source).not.toContain('当前以只读分析为主'); - expect(source).not.toContain('涉及写入的 SQL 建议先生成预览与回滚语句,再执行提交。'); - expect(source).not.toContain('AI 默认优先解释、生成 SELECT、分析 Schema 与优化索引。'); - }); - - it('keeps AI insight catalog placeholders exact in every language', () => { - const placeholderExpectations: Record<(typeof aiInsightKeys)[number], string[]> = { - 'ai_chat.panel.insight.context.linked_title': ['count'], - 'ai_chat.panel.insight.context.empty_title': [], - 'ai_chat.panel.insight.context.linked_body': ['tables'], - 'ai_chat.panel.insight.context.empty_body': [], - 'ai_chat.panel.insight.context.table_separator': [], - 'ai_chat.panel.insight.context.more_tables_suffix': [], - 'ai_chat.panel.insight.query.slowest_title': ['duration'], - 'ai_chat.panel.insight.query.empty_title': [], - 'ai_chat.panel.insight.query.empty_body': [], - 'ai_chat.panel.insight.status.failed_title': ['count'], - 'ai_chat.panel.insight.status.ok_title': [], - 'ai_chat.panel.insight.status.recent_body': ['count'], - 'ai_chat.panel.insight.status.empty_body': [], - 'ai_chat.panel.insight.write.detected_title': ['count'], - 'ai_chat.panel.insight.write.readonly_title': [], - 'ai_chat.panel.insight.write.detected_body': [], - 'ai_chat.panel.insight.write.readonly_body': [], - }; - - for (const language of SUPPORTED_LANGUAGES) { - const catalog = catalogs[language] as Record; - for (const key of aiInsightKeys) { - expect(catalog).toHaveProperty(key); - expect(catalog[key]).toBeTruthy(); - expect(getPlaceholders(catalog[key])).toEqual(placeholderExpectations[key]); - } - } - }); - - it('keeps stream subscription stable when the UI translator changes', () => { - const effect = getStreamSubscriptionEffect(); - const effectWithoutTranslatorRefCalls = effect.replace(/tRef\.current\(/g, ''); - const deps = getStreamSubscriptionDeps(); - - expect(deps).toContain('addAIChatMessage'); - expect(deps).toContain('updateAIChatMessage'); - expect(deps).toContain('sid'); - expect(deps).not.toContain('t'); for (const key of [ - 'ai_chat.panel.message.error', - 'ai_chat.panel.message.empty_response', - 'ai_chat.panel.message.request_interrupted', + 'ai_chat.panel.status.model_connecting', + 'ai_chat.panel.status.waking_engine', + 'ai_chat.panel.status.waiting_response', + 'ai_chat.panel.status.memory_summary', + 'ai_chat.panel.message.service_not_ready', ]) { - expect(effect).toContain(`tRef.current('${key}'`); - expect(effectWithoutTranslatorRefCalls).not.toContain(`t('${key}'`); - } - }); - - it('uses i18n keys for memory compression chrome while preserving generated summaries as raw detail', () => { - for (const key of memoryAndSanitizedErrorKeys) { - expect(source).toContain(key); + expect(source).toContain(`t('${key}'`); } - expect(source).toContain("content: t('ai_chat.panel.status.memory_compressing')"); - expect(source).toContain("content: t('ai_chat.panel.status.memory_compress_failed')"); - expect(source).toContain("content: t('ai_chat.panel.status.memory_summary', { summary })"); - expect(source).toContain("content: translateToolChrome('ai_chat.panel.status.memory_probe_summary', { summary })"); - expect(source).not.toContain('t(summary'); - - expect(source).not.toContain('对话已超载,正在启动记忆压缩'); - expect(source).not.toContain('记忆压缩失败,将尝试原样接续'); - expect(source).not.toContain('【自动记忆重塑】已将超长历史压缩为摘要'); - expect(source).not.toContain('【自动记忆重塑】已将超长历史探针数据和对话压缩为摘要'); + expect(source).not.toContain('buildMissingProviderNotice'); + expect(source).not.toContain('buildIncompleteProviderNotice'); + expect(source).not.toContain('buildMissingModelNotice'); }); - it('uses the memory-summary prompt catalog key instead of a fixed source prompt', () => { - expect(source).toContain(`t('${memorySummaryPromptKey}')`); - - expect(source).not.toContain('这是一段超长对话的历史记录。为了释放上下文空间同时保留你的记忆核心'); - expect(source).not.toContain('技术事实、已探索出的数据结构状态、用户的中心诉求、当前进展'); - expect(source).not.toContain('剔除无效执行过程、客套话、JSON返回值本身。'); - expect(source).not.toContain('请控制在 1000-2000 字左右,输出纯干货 Markdown。'); - }); - - it('keeps the memory-summary prompt catalog entry placeholder-free in every language', () => { - for (const language of SUPPORTED_LANGUAGES) { - const catalog = catalogs[language] as Record; - expect(catalog).toHaveProperty(memorySummaryPromptKey); - expect(catalog[memorySummaryPromptKey]).toBeTruthy(); - expect(getPlaceholders(catalog[memorySummaryPromptKey])).toEqual([]); - } - }); - - it('uses the JVM diagnostic prompt catalog key while preserving raw diagnostic context values', () => { - const callback = getBuildSystemContextMessagesCallback(); - const callbackWithoutTranslatorRefCalls = callback.replace(/tRef\.current\(/g, ''); - const deps = getBuildSystemContextMessagesDeps(); - - expect(source).toContain(`const jvmDiagnosticPromptKey = '${jvmDiagnosticPromptKey}' as const;`); - expect(callback).toContain('content: tRef.current(jvmDiagnosticPromptKey, {'); - expect(callback).toContain('connectionName: activeConnection.name'); - expect(callback).toContain("host: activeConnection.config.host || '-'"); - expect(callback).toContain('transport: diagnosticTransport'); - expect(callback).toContain('environment'); - expect(callback).toContain( - "readOnlyPolicy: tRef.current(readOnly ? 'ai_chat.panel.jvm_diagnostic.policy.read_only' : 'ai_chat.panel.jvm_diagnostic.policy.plan_first')", - ); - expect(callback).toContain( - "observePolicy: tRef.current(diagnostic?.allowObserveCommands !== false ? 'ai_chat.panel.jvm_diagnostic.permission.allowed' : 'ai_chat.panel.jvm_diagnostic.permission.forbidden')", - ); - expect(callback).toContain( - "tracePolicy: tRef.current(diagnostic?.allowTraceCommands === true ? 'ai_chat.panel.jvm_diagnostic.permission.allowed' : 'ai_chat.panel.jvm_diagnostic.permission.forbidden')", - ); - expect(callback).toContain( - "mutatingPolicy: tRef.current(diagnostic?.allowMutatingCommands === true ? 'ai_chat.panel.jvm_diagnostic.permission.allowed' : 'ai_chat.panel.jvm_diagnostic.permission.forbidden')", - ); - expect(deps).not.toContain('t'); - expect(callbackWithoutTranslatorRefCalls).not.toMatch(/content:\s*t\(jvmDiagnosticPromptKey,\s*\{/); - expect(callbackWithoutTranslatorRefCalls).not.toMatch(/\bt\(/); - - expect(source).not.toContain('你是 GoNavi 的 JVM 诊断助手'); - expect(source).not.toContain('当前页签是 Arthas 兼容诊断工作台'); - expect(source).not.toContain('回答规则:\n1. 可以先给一小段分析'); - expect(source).not.toContain('命令权限:observe='); - expect(source).not.toContain('JSON 字段严格限定为 intent、transport、command、riskLevel、reason、expectedSignals'); - expect(source).not.toContain('t(activeConnection.name'); - expect(source).not.toContain('t(activeConnection.config.host'); - expect(source).not.toContain('t(diagnosticTransport'); - expect(source).not.toContain('t(environment'); - }); - - it('keeps the JVM diagnostic prompt placeholders exact and preserves raw plan identifiers in every language', () => { - const expectedPromptPlaceholders = [ - 'connectionName', - 'environment', - 'host', - 'mutatingPolicy', - 'observePolicy', - 'readOnlyPolicy', - 'tracePolicy', - 'transport', - ]; - const rawPromptIdentifiers = [ - 'GoNavi', - 'JVM', - 'Arthas', - 'JSON', - 'intent', - 'transport', - 'command', - 'riskLevel', - 'reason', - 'expectedSignals', - 'low', - 'medium', - 'high', - 'observe', - 'trace', - 'mutating', - ]; - - for (const language of SUPPORTED_LANGUAGES) { - const catalog = catalogs[language] as Record; - expect(catalog).toHaveProperty(jvmDiagnosticPromptKey); - expect(catalog[jvmDiagnosticPromptKey]).toBeTruthy(); - expect(getPlaceholders(catalog[jvmDiagnosticPromptKey])).toEqual(expectedPromptPlaceholders); - - for (const rawIdentifier of rawPromptIdentifiers) { - expect(catalog[jvmDiagnosticPromptKey]).toContain(rawIdentifier); - } - - for (const key of jvmDiagnosticPolicyKeys) { - expect(catalog).toHaveProperty(key); - expect(catalog[key]).toBeTruthy(); - expect(getPlaceholders(catalog[key])).toEqual([]); - } - } - }); - - it('uses the JVM runtime prompt catalog key while preserving raw runtime context values', () => { - const callback = getBuildSystemContextMessagesCallback(); - const callbackWithoutTranslatorRefCalls = callback.replace(/tRef\.current\(/g, ''); - const deps = getBuildSystemContextMessagesDeps(); - - expect(source).toContain(`const jvmRuntimePromptKey = '${jvmRuntimePromptKey}' as const;`); - expect(callback).toContain('content: tRef.current(jvmRuntimePromptKey, {'); - expect(callback).toContain('connectionName: activeConnection.name'); - expect(callback).toContain("host: activeConnection.config.host || '-'"); - expect(callback).toContain('providerMode'); - expect(callback).toContain('environment'); - expect(callback).toContain( - "connectionPolicy: tRef.current(readOnly ? 'ai_chat.panel.jvm_runtime.policy.read_only' : 'ai_chat.panel.jvm_runtime.policy.preview_required')", - ); - expect(callback).toContain( - "resourcePathStatus: tRef.current(resourcePath ? 'ai_chat.panel.jvm_runtime.resource_path.current' : 'ai_chat.panel.jvm_runtime.resource_path.missing', { resourcePath })", - ); - expect(deps).not.toContain('t'); - expect(callbackWithoutTranslatorRefCalls).not.toMatch(/content:\s*t\(jvmRuntimePromptKey,\s*\{/); - expect(callbackWithoutTranslatorRefCalls).not.toMatch(/\bt\(/); - - expect(source).not.toContain('你是 GoNavi 的 JVM 运行时分析助手'); - expect(source).not.toContain('当前上下文不是 SQL,而是 JVM 资源工作台'); - expect(source).not.toContain('JSON 字段严格限定为 targetType、selector、action、payload、reason'); - expect(source).not.toContain('selector.resourcePath 优先使用当前资源路径'); - expect(source).not.toContain('不要输出脚本、命令或“已经执行成功”之类的表述'); - expect(callbackWithoutTranslatorRefCalls).not.toContain('t(activeConnection.name'); - expect(callbackWithoutTranslatorRefCalls).not.toContain('t(activeConnection.config.host'); - expect(callbackWithoutTranslatorRefCalls).not.toContain('t(providerMode'); - expect(callbackWithoutTranslatorRefCalls).not.toContain('t(environment'); - expect(callbackWithoutTranslatorRefCalls).not.toContain('t(resourcePath'); - }); - - it('keeps the JVM runtime prompt placeholders exact and preserves raw plan identifiers in every language', () => { - const expectedPromptPlaceholders = [ - 'connectionName', - 'connectionPolicy', - 'environment', - 'host', - 'providerMode', - 'resourcePathStatus', - ]; - const policyPlaceholderExpectations: Record<(typeof jvmRuntimePolicyKeys)[number], string[]> = { - 'ai_chat.panel.jvm_runtime.policy.read_only': [], - 'ai_chat.panel.jvm_runtime.policy.preview_required': [], - 'ai_chat.panel.jvm_runtime.resource_path.current': ['resourcePath'], - 'ai_chat.panel.jvm_runtime.resource_path.missing': [], - }; - const rawPromptIdentifiers = [ - 'GoNavi', - 'JVM', - 'SQL', - 'JSON', - 'targetType', - 'selector', - 'action', - 'payload', - 'reason', - 'supportedActions', - 'selector.resourcePath', - 'resourcePath', - 'format', - 'json', - 'text', - ]; - - for (const language of SUPPORTED_LANGUAGES) { - const catalog = catalogs[language] as Record; - expect(catalog).toHaveProperty(jvmRuntimePromptKey); - expect(catalog[jvmRuntimePromptKey]).toBeTruthy(); - expect(getPlaceholders(catalog[jvmRuntimePromptKey])).toEqual(expectedPromptPlaceholders); - - for (const rawIdentifier of rawPromptIdentifiers) { - expect(catalog[jvmRuntimePromptKey]).toContain(rawIdentifier); - } - - for (const key of jvmRuntimePolicyKeys) { - expect(catalog).toHaveProperty(key); - expect(catalog[key]).toBeTruthy(); - expect(getPlaceholders(catalog[key])).toEqual(policyPlaceholderExpectations[key]); - } - } - }); - - it('uses SQL system prompt catalog keys while preserving raw SQL context parameters', () => { - const callback = getBuildSystemContextMessagesCallback(); - const callbackWithoutTranslatorRefCalls = callback.replace(/tRef\.current\(/g, ''); - const deps = getBuildSystemContextMessagesDeps(); - - for (const key of sqlPromptKeys) { - expect(callback).toContain(key); - } - - expect(callback).toContain('const sqlPromptKey = activeContextItems.length > 0'); - expect(callback).toContain("targetConnId && targetDbName"); - expect(callback).toContain("conns.length > 0"); - expect(callback).toContain('content: tRef.current(sqlPromptKey, sqlPromptParams),'); - expect(callback).toContain('dbDisplayType,'); - expect(callback).toContain('ddlChunks,'); - expect(callback).toContain('targetDbName,'); - expect(callback).toContain('connList,'); - expect(deps).not.toContain('t'); - expect(callbackWithoutTranslatorRefCalls).not.toMatch(/\bt\(/); - - expect(callbackWithoutTranslatorRefCalls).not.toContain('t(dbDisplayType'); - expect(callbackWithoutTranslatorRefCalls).not.toContain('t(ddlChunks'); - expect(callbackWithoutTranslatorRefCalls).not.toContain('t(targetDbName'); - expect(callbackWithoutTranslatorRefCalls).not.toContain('t(connList'); - - expect(source).not.toContain('你是一个专业的数据库助手。当前连接的数据库类型是'); - expect(source).not.toContain('用户目前在界面上没有选中任何具体的数据库或数据表'); - expect(source).not.toContain('当前存在的连接:'); - }); - - it('keeps SQL system prompt placeholders exact and raw workflow identifiers intact in every language', () => { - const placeholderExpectations: Record<(typeof sqlPromptKeys)[number], string[]> = { - 'ai_chat.panel.prompt.sql.context_tables': ['dbDisplayType', 'ddlChunks'], - 'ai_chat.panel.prompt.sql.current_database': ['dbDisplayType', 'targetDbName'], - 'ai_chat.panel.prompt.sql.no_context': ['connList'], - 'ai_chat.panel.prompt.sql.no_connections': [], - }; - const rawPromptIdentifiers = [ - 'SQL', - 'DDL', - 'get_connections', - 'get_databases', - 'get_tables', - 'get_columns', - 'host', - 'localhost', - '127.0.0.1', - 'name', - 'dev', - 'local', - 'connectionId', - 'dbName', - 'database.table', - '@context', - ]; - - for (const language of SUPPORTED_LANGUAGES) { - const catalog = catalogs[language] as Record; - for (const key of sqlPromptKeys) { - expect(catalog).toHaveProperty(key); - expect(catalog[key]).toBeTruthy(); - expect(getPlaceholders(catalog[key])).toEqual(placeholderExpectations[key]); - } - - const combinedPrompt = sqlPromptKeys.map((key) => catalog[key]).join('\n'); - for (const rawIdentifier of rawPromptIdentifiers) { - expect(combinedPrompt).toContain(rawIdentifier); - } - } - }); - - it('passes the active translator into sanitized error fallbacks without translating raw details', () => { - expect(source).toContain('const sanitizeErrorMsg = (raw: string, t: AIChatTranslator): string =>'); - expect(source).toContain('sanitizeErrorMsg(data.error, tRef.current)'); - expect(source).toContain('sanitizeErrorMsg(errRaw, t)'); - expect(source).toContain('sanitizeErrorMsg(errR, translateToolChrome)'); - expect(source).toContain('sanitizeErrorMsg(errR2, t)'); - expect(source).toContain('sanitizeErrorMsg(rawE, t)'); - expect(source).toContain('sanitizeErrorMsg(rawE2, t)'); - - expect(source).toContain("return t('ai_chat.panel.error.unknown')"); - expect(source).toContain("return t('ai_chat.panel.error.http_server', { code })"); - expect(source).toContain("return t('ai_chat.panel.error.html_response')"); - expect(source).toContain("+ t('ai_chat.panel.error.truncated_suffix')"); - expect(source).not.toContain('t(title'); - expect(source).not.toContain('t(raw'); - }); - - it('uses the generic i18n error wrapper for non-stream AIChatSend fallbacks', () => { - expect(source).toContain("content: result?.success ? result.content : t('ai_chat.panel.message.error', { detail: errClean })"); - expect(source).toContain("content: result?.success ? result.content : translateToolChrome('ai_chat.panel.message.error', { detail: errC })"); - expect(source).toContain("content: result?.success ? result.content : t('ai_chat.panel.message.error', { detail: errC2 })"); - - expect(source).not.toContain('`❌ ${errClean}`'); - expect(source).not.toContain('`❌ ${errC}`'); - expect(source).not.toContain('`❌ ${errC2}`'); - }); - - it('keeps memory and sanitized-error catalog placeholders consistent in every language', () => { - const placeholderExpectations: Record<(typeof memoryAndSanitizedErrorKeys)[number], string[]> = { - 'ai_chat.panel.status.memory_compressing': [], - 'ai_chat.panel.status.memory_compress_failed': [], - 'ai_chat.panel.status.memory_summary': ['summary'], - 'ai_chat.panel.status.memory_probe_summary': ['summary'], - 'ai_chat.panel.error.unknown': [], - 'ai_chat.panel.error.http_server': ['code'], - 'ai_chat.panel.error.html_response': [], - 'ai_chat.panel.error.truncated_suffix': [], - }; - - for (const language of SUPPORTED_LANGUAGES) { - const catalog = catalogs[language] as Record; - for (const key of memoryAndSanitizedErrorKeys) { - expect(catalog).toHaveProperty(key); - expect(catalog[key]).toBeTruthy(); - expect(getPlaceholders(catalog[key])).toEqual(placeholderExpectations[key]); - } - } + it('keeps translated session and insight chrome in the panel layer instead of falling back to hardcoded copy', () => { + expect(source).toContain("() => orderedAISessions.find((session) => session.id === sid)?.title || t('ai_chat.panel.session.default_title')"); + expect(source).toContain("title: session.title || t('ai_chat.panel.session.default_title')"); + expect(source).toContain("t('ai_chat.panel.insight.context.linked_title', { count: contextCount })"); + expect(source).toContain("t('ai_chat.panel.insight.context.linked_body', { tables: tablePreview })"); + expect(source).toContain("t('ai_chat.panel.insight.query.slowest_title', { duration: Math.round(slowest.duration).toLocaleString() })"); + expect(source).toContain("t('ai_chat.panel.insight.status.recent_body', { count: recentLogs.length })"); + expect(source).toContain("t('ai_chat.panel.insight.write.detected_title', { count: writeCount })"); + expect(source).not.toContain('buildAIChatInsights({'); + expect(source).not.toContain("|| '新对话'"); }); }); diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index f0172f1..86fed1d 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -1,32 +1,46 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; -import { useStore, loadAISessionsFromBackend, loadAISessionFromBackend } from '../store'; -import { EventsOn, EventsOff } from '../../wailsjs/runtime'; -import { DBGetDatabases, DBGetTables } from '../../wailsjs/go/app/App'; +import { useStore } from '../store'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import type { + AIChatAttachment, AIChatMessage, - AIToolCall, JVMAIPlanContext, JVMDiagnosticPlanContext, } from '../types'; -import { DatabaseOutlined, DownOutlined, HistoryOutlined, TableOutlined, WarningOutlined } from '@ant-design/icons'; import './AIChatPanel.css'; import { AIChatHeader } from './ai/AIChatHeader'; -import { AIChatWelcome } from './ai/AIChatWelcome'; -import { AIMessageBubble } from './ai/AIMessageBubble'; import { AIChatInput } from './ai/AIChatInput'; import { AIHistoryDrawer } from './ai/AIHistoryDrawer'; -import type { AIComposerNoticeDescriptor } from '../utils/aiComposerNotice'; +import AIChatPanelConversationView from './ai/AIChatPanelConversationView'; +import { useAIChatStreamSubscription } from './ai/useAIChatStreamSubscription'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import type { AIComposerNoticeDescriptor } from '../utils/aiComposerNotice'; import { buildAIComposerNotice } from '../utils/aiComposerNotice'; -import { buildAIReadonlyPreviewSQL } from '../utils/aiSqlLimit'; -import { resolveAITableSchemaToolResult } from '../utils/aiTableSchemaTool'; import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut'; import { toAIRequestMessage } from '../utils/aiMessagePayload'; +import { compressContextIfNeeded, getDynamicMaxContextChars } from '../utils/aiChatRuntime'; import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts'; import { isMacLikePlatform } from '../utils/appearance'; +import { buildAvailableAIChatTools } from '../utils/aiToolRegistry'; +import { + buildAIChatInlineHistorySessions, + calculateAIContextUsageChars, + collectAIChatContextTableNames, + inferAIChatConnectionContext, + resolveAIChatPanelMode, +} from './ai/aiChatPanelDerivedState'; +import { dispatchAIChatPayload } from './ai/aiChatPayloadDispatch'; +import { buildAIChatReadinessSnapshot } from './ai/aiChatReadiness'; +import { buildAISystemContextMessages } from './ai/aiSystemContextMessages'; +import { useAIChatRuntimeResources } from './ai/useAIChatRuntimeResources'; +import { useAIChatAutoContext } from './ai/useAIChatAutoContext'; +import { useAIChatPanelResize } from './ai/useAIChatPanelResize'; +import { useAIChatPlanContexts } from './ai/useAIChatPlanContexts'; +import { useAIChatSessionState } from './ai/useAIChatSessionState'; +import { useAIChatSessionTitleGenerator } from './ai/useAIChatSessionTitleGenerator'; +import { useAIChatLocalTools } from './ai/useAIChatLocalTools'; import { useI18n } from '../i18n/provider'; interface AIChatPanelProps { @@ -41,323 +55,41 @@ interface AIChatPanelProps { const genId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; -type AIChatTranslator = (key: string, params?: Record) => string; - -const jvmDiagnosticPromptKey = 'ai_chat.panel.prompt.jvm_diagnostic' as const; -const jvmRuntimePromptKey = 'ai_chat.panel.prompt.jvm_runtime' as const; - -interface AIMessageRenderBoundaryProps { - children: React.ReactNode; - msg: AIChatMessage; - darkMode: boolean; - overlayTheme: OverlayWorkbenchTheme; - onDeleteMessage: (id: string) => void; - onError?: (error: Error, errorInfo: React.ErrorInfo, msg: AIChatMessage) => void; - translateRenderError: (key: string) => string; -} - -interface AIMessageRenderBoundaryState { - hasError: boolean; - error: Error | null; -} - -class AIMessageRenderBoundary extends React.Component< - AIMessageRenderBoundaryProps, - AIMessageRenderBoundaryState -> { - constructor(props: AIMessageRenderBoundaryProps) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error: Error): AIMessageRenderBoundaryState { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - this.props.onError?.(error, errorInfo, this.props.msg); - } - - private handleRetryRender = () => { - this.setState({ hasError: false, error: null }); - }; - - render() { - if (this.state.hasError) { - const { msg, darkMode, overlayTheme, onDeleteMessage, translateRenderError } = this.props; - return ( -
-
-
- {translateRenderError('ai_chat.panel.render_error.title')} -
-
- {translateRenderError('ai_chat.panel.render_error.description')} -
-
- {this.state.error?.message || translateRenderError('ai_chat.panel.render_error.unknown')} -
-
- - -
-
-
- ); - } - - return this.props.children; - } -} - -export const getDynamicMaxContextChars = (modelName?: string) => { - if (!modelName) return 258000; // 默认 258k (2026主流基线) - const lower = modelName.toLowerCase(); - - // 「星际杯」- 百万到千万级 Tokens (保守取 2~5M 字符) - if (lower.includes('gemini-1.5-pro') || lower.includes('gemini-2') || lower.includes('gemini-3')) { - return 5000000; - } - // 「超大杯」- 1M Tokens (针对 2026 旗舰:约 1,000,000 字符) - if (lower.includes('glm-5') || lower.includes('claude-4') || lower.includes('claude-3.7') || lower.includes('gpt-5') || lower.includes('qwen3') || lower.includes('deepseek-v4')) { - return 1000000; - } - if (lower.includes('claude-3-opus') || lower.includes('claude-3.5') || lower.includes('glm-4-long') || lower.includes('qwen-long')) { - return 1000000; - } - // 「大杯」- 200K ~ 258K Tokens (针对现代主流:约 258,000 字符) - if (lower.includes('claude') || lower.includes('deepseek') || lower.includes('gpt-4.5') || lower.includes('qwen2.5')) { - return 258000; - } - // 「中杯/小杯」- 128K Tokens (老基线:约 128,000 字符) - if (lower.includes('gpt-4') || lower.includes('gpt-4o') || lower.includes('glm') || lower.includes('z-ai')) { - return 128000; - } - if (lower.includes('qwen')) { - return 128000; - } - // Default fallback - return 258000; -}; - -// 当超出指定字符上限时触发上下文自建压缩 -const compressContextIfNeeded = async (sid: string, messagesPayload: any[], maxLimit: number, t: AIChatTranslator) => { - try { - const chars = messagesPayload.reduce((sum, m) => sum + (m.content?.length || 0) + (m.reasoning_content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0); - if (chars < maxLimit) return null; - - const Service = (window as any).go?.aiservice?.Service; - if (!Service?.AIChatSend) return null; - - const connectingMsgId = genId(); - useStore.getState().addAIChatMessage(sid, { - id: connectingMsgId, role: 'assistant', phase: 'connecting', content: t('ai_chat.panel.status.memory_compressing'), timestamp: Date.now(), loading: true - }); - - const summaryPrompt = t('ai_chat.panel.prompt.memory_summary'); - - const sysMsg = { role: 'system', content: summaryPrompt }; - const result = await Service.AIChatSend([sysMsg, ...messagesPayload]); - - if (result?.success && result.content) { - useStore.getState().deleteAIChatMessage(sid, connectingMsgId); - return result.content; - } else { - useStore.getState().updateAIChatMessage(sid, connectingMsgId, { loading: false, phase: 'idle', content: t('ai_chat.panel.status.memory_compress_failed') }); - } - } catch (e) { - console.error("Compression exception:", e); - } - return null; -}; - -// 清洗错误信息:去除 HTML 标签、提取关键错误描述、截断过长文本 -const sanitizeErrorMsg = (raw: string, t: AIChatTranslator): string => { - if (!raw || typeof raw !== 'string') return t('ai_chat.panel.error.unknown'); - // 检测 HTML 内容 - if (raw.includes(' 内容 - const titleMatch = raw.match(/]*>([^<]+)<\/title>/i); - // 尝试提取 HTTP 状态码 - const codeMatch = raw.match(/\b(4\d{2}|5\d{2})\b/); - const title = titleMatch?.[1]?.trim(); - const code = codeMatch?.[1]; - if (title) return code ? `HTTP ${code}: ${title}` : title; - if (code) return t('ai_chat.panel.error.http_server', { code }); - return t('ai_chat.panel.error.html_response'); - } - // 截断过长的纯文本错误 - if (raw.length > 300) return raw.substring(0, 280) + t('ai_chat.panel.error.truncated_suffix'); - return raw; -}; - -const buildLocalTools = (translateLocalToolSchema: AIChatTranslator) => [ - { - type: 'function', - function: { - name: 'get_connections', - description: translateLocalToolSchema('ai_chat.panel.local_tool.get_connections.description'), - parameters: { type: 'object', properties: {} } - } - }, - { - type: 'function', - function: { - name: 'get_databases', - description: translateLocalToolSchema('ai_chat.panel.local_tool.get_databases.description'), - parameters: { - type: 'object', - properties: { - connectionId: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.connection_id_from_get_connections') } - }, - required: ['connectionId'] - } - } - }, - { - type: 'function', - function: { - name: 'get_tables', - description: translateLocalToolSchema('ai_chat.panel.local_tool.get_tables.description'), - parameters: { - type: 'object', - properties: { - connectionId: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.connection_id') }, - dbName: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.db_name') }, - }, - required: ['connectionId', 'dbName'] - } - } - }, - { - type: 'function', - function: { - name: 'get_columns', - description: translateLocalToolSchema('ai_chat.panel.local_tool.get_columns.description'), - parameters: { - type: 'object', - properties: { - connectionId: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.connection_id') }, - dbName: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.db_name') }, - tableName: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.table_name') }, - }, - required: ['connectionId', 'dbName', 'tableName'] - } - } - }, - { - type: 'function', - function: { - name: 'get_table_ddl', - description: translateLocalToolSchema('ai_chat.panel.local_tool.get_table_ddl.description'), - parameters: { - type: 'object', - properties: { - connectionId: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.connection_id') }, - dbName: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.db_name') }, - tableName: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.table_name') }, - }, - required: ['connectionId', 'dbName', 'tableName'] - } - } - }, - { - type: 'function', - function: { - name: 'execute_sql', - description: translateLocalToolSchema('ai_chat.panel.local_tool.execute_sql.description'), - parameters: { - type: 'object', - properties: { - connectionId: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.connection_id') }, - dbName: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.db_name') }, - sql: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.sql') }, - }, - required: ['connectionId', 'dbName', 'sql'] - } - } - } -]; - -export const AIChatPanel: React.FC = ({ - width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme +export const AIChatPanel: React.FC = ({ + width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme }) => { const { t } = useI18n(); - const tRef = useRef(t); - tRef.current = t; - const getLocalTools = useCallback(() => buildLocalTools(tRef.current), []); const [input, setInput] = useState(''); - const [draftImages, setDraftImages] = useState([]); + const [draftAttachments, setDraftAttachments] = useState([]); const [sending, setSending] = useState(false); - const [activeProvider, setActiveProvider] = useState(null); - const [dynamicModels, setDynamicModels] = useState([]); const [showScrollBottom, setShowScrollBottom] = useState(false); - const [loadingModels, setLoadingModels] = useState(false); - const [composerNoticeState, setComposerNoticeState] = useState(null); - const [panelWidth, setPanelWidth] = useState(width); - const [isResizing, setIsResizing] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [activePanelMode, setActivePanelMode] = useState<'chat' | 'insights' | 'history'>('chat'); - const composerNotice = useMemo(() => buildAIComposerNotice(t, composerNoticeState), [composerNoticeState, t]); - + const [composerNoticeState, setComposerNoticeState] = useState(null); + const { + activeProvider, + composerNotice: runtimeComposerNotice, + dynamicModels, + fetchDynamicModels, + handleComposerAction, + handleModelChange, + handleOpenSettingsFromPanel, + loadingModels, + mcpTools, + skills, + userPromptSettings, + } = useAIChatRuntimeResources({ onOpenSettings }); + const messagesEndRef = useRef(null); const textareaRef = useRef(null); - const resizeStartX = useRef(0); - const resizeStartWidth = useRef(0); - const toolCallRoundRef = useRef(0); // 连续失败轮次计数 - const totalToolRoundRef = useRef(0); // 全局工具调用总轮次计数(防止无限循环) - const nudgeCountRef = useRef(0); // 催促模型使用 function call 的次数 - const panelRef = useRef(null); // 面板 DOM ref,用于拖拽时直接操作宽度 - const dragWidthRef = useRef(0); // 拖拽过程中的实时宽度(不触发 React 重渲染) - const pendingJVMPlanContextRef = useRef(undefined); - const pendingJVMDiagnosticPlanContextRef = useRef(undefined); + const nudgeCountRef = useRef(0); + const { + getCurrentJVMPlanContext, + getCurrentJVMDiagnosticPlanContext, + pendingJVMPlanContextRef, + pendingJVMDiagnosticPlanContextRef, + } = useAIChatPlanContexts(); - useEffect(() => { - setPanelWidth(width); - dragWidthRef.current = width; - }, [width]); - - const aiChatHistory = useStore(state => state.aiChatHistory); const aiActiveSessionId = useStore(state => state.aiActiveSessionId); const appearance = useStore(state => state.appearance); const createNewAISession = useStore(state => state.createNewAISession); @@ -366,240 +98,79 @@ export const AIChatPanel: React.FC = ({ const deleteAIChatMessage = useStore(state => state.deleteAIChatMessage); const truncateAIChatMessages = useStore(state => state.truncateAIChatMessages); const updateAISessionTitle = useStore(state => state.updateAISessionTitle); - + const activeContext = useStore(state => state.activeContext); const aiContexts = useStore(state => state.aiContexts); const connections = useStore(state => state.connections); const tabs = useStore(state => state.tabs); const activeTabId = useStore(state => state.activeTabId); const sqlLogs = useStore(state => state.sqlLogs); - const aiChatSessions = useStore(state => state.aiChatSessions); const setAIActiveSessionId = useStore(state => state.setAIActiveSessionId); const aiPanelVisible = useStore(state => state.aiPanelVisible); const isV2Ui = appearance.uiVersion === 'v2'; const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform()); + const { + ghostRef, + handleResizeStart, + isResizing, + panelRect, + panelRef, + panelWidth, + } = useAIChatPanelResize({ + width, + isV2Ui, + onWidthChange, + }); + const availableTools = useMemo( + () => buildAvailableAIChatTools(mcpTools), + [mcpTools], + ); const aiChatSendShortcutBinding = useStore(state => resolveShortcutBinding( state.shortcutOptions, 'sendAIChatMessage', activeShortcutPlatform, )); + const { sid, messages, orderedAISessions } = useAIChatSessionState({ + aiActiveSessionId, + aiPanelVisible, + createNewAISession, + }); - const getCurrentJVMPlanContext = useCallback((): JVMAIPlanContext | undefined => { - const state = useStore.getState(); - const activeTab = state.tabs.find(t => t.id === state.activeTabId); - if (!activeTab || activeTab.type !== 'jvm-resource') { - return undefined; - } - - const activeConnection = state.connections.find(c => c.id === activeTab.connectionId); - if (activeConnection?.config?.type !== 'jvm') { - return undefined; - } - - const resourcePath = String(activeTab.resourcePath || '').trim(); - if (!resourcePath) { - return undefined; - } - - return { - tabId: activeTab.id, - connectionId: activeTab.connectionId, - providerMode: (activeTab.providerMode || activeConnection.config.jvm?.preferredMode || 'jmx') as JVMAIPlanContext['providerMode'], - resourcePath, - }; - }, []); - - const getCurrentJVMDiagnosticPlanContext = useCallback((): JVMDiagnosticPlanContext | undefined => { - const state = useStore.getState(); - const activeTab = state.tabs.find(t => t.id === state.activeTabId); - if (!activeTab || activeTab.type !== 'jvm-diagnostic') { - return undefined; - } - - const activeConnection = state.connections.find(c => c.id === activeTab.connectionId); - if (activeConnection?.config?.type !== 'jvm') { - return undefined; - } - - return { - tabId: activeTab.id, - connectionId: activeTab.connectionId, - transport: activeConnection.config.jvm?.diagnostic?.transport || 'agent-bridge', - }; - }, []); - - // Auto-Context Injection Hook - useEffect(() => { - if (!aiPanelVisible) return; - const activeTab = tabs.find(t => t.id === activeTabId); - if (activeTab && (activeTab.type === 'table' || activeTab.type === 'design')) { - const { connectionId, dbName, tableName } = activeTab; - if (connectionId && dbName && tableName) { - const connKey = `${connectionId}:${dbName}`; - const currentContexts = useStore.getState().aiContexts[connKey] || []; - if (!currentContexts.find(c => c.dbName === dbName && c.tableName === tableName)) { - const conn = useStore.getState().connections.find(c => c.id === connectionId); - if (conn) { - import('../../wailsjs/go/app/App').then(({ DBShowCreateTable }) => { - DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, dbName, tableName).then(res => { - if (res.success && res.data) { - let createSql = ''; - if (typeof res.data === 'string') createSql = res.data; - else if (Array.isArray(res.data) && res.data.length > 0) { - const row = res.data[0]; - createSql = (Object.values(row).find(v => typeof v === 'string' && (v.toUpperCase().includes('CREATE TABLE') || v.toUpperCase().includes('CREATE'))) || Object.values(row)[1] || Object.values(row)[0]) as string; - } - if (createSql) { - useStore.getState().addAIContext(connKey, { dbName: dbName, tableName, ddl: createSql }); - } - } - }); - }).catch(err => console.error("Failed to auto-fetch table context", err)); - } - } - } - } - }, [aiPanelVisible, activeTabId, tabs]); + useAIChatAutoContext({ + aiPanelVisible, + activeTabId, + tabs, + }); useEffect(() => { - if (!aiActiveSessionId) { - createNewAISession(); + if (runtimeComposerNotice) { + setComposerNoticeState(null); } - }, [aiActiveSessionId, createNewAISession]); - - const sid = aiActiveSessionId || 'session-fallback'; - - // 面板首次可见时从后端加载会话列表 - const sessionsLoadedRef = useRef(false); - useEffect(() => { - if (!aiPanelVisible || sessionsLoadedRef.current) return; - sessionsLoadedRef.current = true; - loadAISessionsFromBackend(); - }, [aiPanelVisible]); - - // 切换会话时按需从后端加载消息 - useEffect(() => { - if (sid && sid !== 'session-fallback') { - loadAISessionFromBackend(sid); - } - }, [sid]); - const messages = aiChatHistory[sid] || []; + }, [runtimeComposerNotice]); const getConnectionName = useCallback(() => { let connectionId = activeContext?.connectionId; if (!connectionId) { - const activeTab = tabs.find(t => t.id === activeTabId); + const activeTab = tabs.find(tab => tab.id === activeTabId); connectionId = activeTab?.connectionId; } if (!connectionId) return ''; - const conn = connections.find(c => c.id === connectionId); - return conn ? conn.name : ''; + const connection = connections.find(item => item.id === connectionId); + return connection ? connection.name : ''; }, [activeContext, activeTabId, connections, tabs]); const activeConnName = getConnectionName(); + const composerNotice = useMemo( + () => buildAIComposerNotice(t, composerNoticeState) ?? runtimeComposerNotice, + [composerNoticeState, runtimeComposerNotice, t], + ); const textColor = overlayTheme.titleText; const mutedColor = overlayTheme.mutedText; const borderColor = overlayTheme.divider; - const assistantBubbleBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'; const quickActionBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.8)'; const quickActionBorder = overlayTheme.sectionBorder; - const loadActiveProvider = useCallback(async () => { - try { - const Service = (window as any).go?.aiservice?.Service; - if (!Service) return; - const [provRes, activeRes] = await Promise.all([ - Service.AIGetProviders?.(), - Service.AIGetActiveProvider?.(), - ]); - if (Array.isArray(provRes) && activeRes) { - const current = provRes.find((p: any) => p.id === activeRes); - setActiveProvider(current || null); - } - } catch (e) { console.warn('Failed to load active provider', e); } - }, []); - - useEffect(() => { loadActiveProvider(); }, [loadActiveProvider]); - - // 监听供应商配置变更(来自设置面板的删除/新增/切换操作),重新加载 active provider 并清空已缓存的模型 - useEffect(() => { - const handler = () => { - setDynamicModels([]); - setComposerNoticeState(null); - activeProviderIdRef.current = null; - loadActiveProvider(); - }; - window.addEventListener('gonavi:ai:provider-changed', handler); - return () => window.removeEventListener('gonavi:ai:provider-changed', handler); - }, [loadActiveProvider]); - - const handleModelChange = async (val: string) => { - if (!activeProvider) return; - try { - const Service = (window as any).go?.aiservice?.Service; - const payload = { - ...activeProvider, - model: val, - apiKey: activeProvider.apiKey || '', - hasSecret: activeProvider.hasSecret ?? Boolean(activeProvider.secretRef), - }; - await Service?.AISaveProvider?.(payload); - setActiveProvider(payload); - setComposerNoticeState(null); - } catch (e) { console.warn('Failed to update provider model', e); } - }; - - const activeProviderIdRef = useRef(null); - - useEffect(() => { - if (activeProvider?.id && activeProvider.id !== activeProviderIdRef.current) { - setDynamicModels([]); - setComposerNoticeState(null); - activeProviderIdRef.current = activeProvider.id; - } - // 供应商被删除后 activeProvider 变为 null,此时也必须清空残留模型 - if (!activeProvider) { - setDynamicModels([]); - setComposerNoticeState(null); - activeProviderIdRef.current = null; - } - }, [activeProvider?.id, activeProvider]); - - useEffect(() => { - if (activeProvider?.model && String(activeProvider.model).trim()) { - setComposerNoticeState(null); - } - }, [activeProvider?.model]); - - - // dynamicModels 仅在内存中使用,不再写回供应商配置,避免污染静态 models 列表 - - const fetchDynamicModels = useCallback(async () => { - try { - setLoadingModels(true); - setComposerNoticeState(null); - const Service = (window as any).go?.aiservice?.Service; - if (!Service) return; - const result = await Service.AIListModels?.(); - if (result?.success && Array.isArray(result.models) && result.models.length > 0) { - const sortedModels = [...result.models].sort((a, b) => a.localeCompare(b)); - setDynamicModels(sortedModels); - setComposerNoticeState(null); - } else if (result && !result.success) { - setDynamicModels([]); - setComposerNoticeState({ kind: 'model_fetch_failed', detail: result.error }); - } - } catch (e: any) { - console.warn('Failed to fetch models', e); - setDynamicModels([]); - const detail = e?.message || String(e || ''); - setComposerNoticeState({ kind: 'model_fetch_failed', detail }); - } finally { - setLoadingModels(false); - } - }, []); - useEffect(() => { if (messages.length === 0) return; messagesEndRef.current?.scrollIntoView({ behavior: sending ? 'auto' : 'smooth', block: 'end' }); @@ -613,15 +184,12 @@ export const AIChatPanel: React.FC = ({ }, []); useEffect(() => { - const handler = (e: Event) => { - const detail = (e as CustomEvent).detail; + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; if (detail?.prompt) { setInput(detail.prompt); setTimeout(() => { - const el = textareaRef.current as any; - if (el) { - el.focus(); - } + textareaRef.current?.focus(); }, 50); } }; @@ -629,258 +197,10 @@ export const AIChatPanel: React.FC = ({ return () => window.removeEventListener('gonavi:ai:inject-prompt', handler); }, []); - useEffect(() => { - const eventName = `ai:stream:${sid}`; - let assistantMsgId = ''; - let isFirstCompletion = false; + const generateTitleForSession = useAIChatSessionTitleGenerator({ updateAISessionTitle }); - // 新增:利用 requestAnimationFrame 缓冲高频事件,避免 React 重绘阻塞导致感官吞吐变慢 - const streamBuffer = { thinking: '', reasoningContent: '', content: '' }; - let flushPending = false; - - const flushStreamBuffer = () => { - if (!assistantMsgId) return; - const current = useStore.getState().aiChatHistory[sid]; - const existing = current?.find(m => m.id === assistantMsgId); - if (!existing) return; - - const updates: any = {}; - if (streamBuffer.thinking) { - updates.thinking = (existing.thinking || '') + streamBuffer.thinking; - updates.phase = 'thinking'; - streamBuffer.thinking = ''; - } - if (streamBuffer.reasoningContent) { - updates.reasoning_content = (existing.reasoning_content || '') + streamBuffer.reasoningContent; - streamBuffer.reasoningContent = ''; - } - if (streamBuffer.content) { - updates.content = (existing.content || '') + streamBuffer.content; - updates.phase = 'generating'; - streamBuffer.content = ''; - } - - if (Object.keys(updates).length > 0) { - updateAIChatMessage(sid, assistantMsgId, updates); - } - flushPending = false; - }; - - const handler = (data: { content?: string; thinking?: string; reasoning_content?: string; tool_calls?: AIToolCall[]; done?: boolean; error?: string }) => { - // Find connecting message if there's no active assistant string - if (!assistantMsgId) { - const history = useStore.getState().aiChatHistory[sid] || []; - const lastMsg = history[history.length - 1]; - if (lastMsg && lastMsg.role === 'assistant' && lastMsg.loading && lastMsg.phase === 'connecting') { - assistantMsgId = lastMsg.id; - // 【关键】接管 connecting 消息时,立即清空其过渡文案,防止泄漏到 AI 回复正文 - updateAIChatMessage(sid, assistantMsgId, { content: '' }); - } - } - - if (data.error) { - const cleanErr = sanitizeErrorMsg(data.error, tRef.current); - const rawErr = cleanErr !== data.error ? data.error : undefined; - if (assistantMsgId) { - updateAIChatMessage(sid, assistantMsgId, { content: tRef.current('ai_chat.panel.message.error', { detail: cleanErr }), phase: 'idle', loading: false, rawError: rawErr }); - } else { - addAIChatMessage(sid, { - id: genId(), - role: 'assistant', - phase: 'idle', - content: tRef.current('ai_chat.panel.message.error', { detail: cleanErr }), - rawError: rawErr, - timestamp: Date.now(), - jvmPlanContext: pendingJVMPlanContextRef.current, - jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, - }); - } - assistantMsgId = ''; - setSending(false); - return; - } - - if (data.tool_calls && data.tool_calls.length > 0) { - if (assistantMsgId) { - updateAIChatMessage(sid, assistantMsgId, { tool_calls: data.tool_calls, phase: 'tool_calling' }); - } else { - assistantMsgId = genId(); - addAIChatMessage(sid, { - id: assistantMsgId, - role: 'assistant', - phase: 'tool_calling', - content: '', - tool_calls: data.tool_calls, - timestamp: Date.now(), - loading: true, - jvmPlanContext: pendingJVMPlanContextRef.current, - jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, - }); - } - } - - // 处理 thinking(模型思考过程) - const displayThinking = data.thinking || data.reasoning_content || ''; - if (displayThinking || data.reasoning_content) { - if (!assistantMsgId) { - assistantMsgId = genId(); - addAIChatMessage(sid, { - id: assistantMsgId, - role: 'assistant', - phase: 'thinking', - content: '', - thinking: displayThinking || undefined, - reasoning_content: data.reasoning_content || undefined, - timestamp: Date.now(), - loading: true, - jvmPlanContext: pendingJVMPlanContextRef.current, - jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, - }); - if (sending) setSending(false); - } else { - streamBuffer.thinking += displayThinking; - if (data.reasoning_content) { - streamBuffer.reasoningContent += data.reasoning_content; - } - if (sending) setSending(false); - } - } - - if (data.content) { - if (!assistantMsgId) { - assistantMsgId = genId(); - addAIChatMessage(sid, { - id: assistantMsgId, - role: 'assistant', - phase: 'generating', - content: data.content, - timestamp: Date.now(), - loading: true, - jvmPlanContext: pendingJVMPlanContextRef.current, - jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, - }); - setSending(false); - const currentHistory = useStore.getState().aiChatHistory[sid] || []; - if (currentHistory.length <= 1) isFirstCompletion = true; - } else { - streamBuffer.content += data.content; - if (sending) setSending(false); - } - } - - if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) { - if (!flushPending) { - flushPending = true; - requestAnimationFrame(flushStreamBuffer); - } - } - - if (data.done) { - // 如果有残留未 flush 的 buffer,立刻推入状态树 - if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) { - flushStreamBuffer(); - } - const doneAssistantId = assistantMsgId; - const doneIsFirst = isFirstCompletion; - assistantMsgId = ''; - setTimeout(() => { - // 🔧 清除所有残留的 connecting 过渡气泡的 loading 状态 - const currentMsgs = useStore.getState().aiChatHistory[sid] || []; - for (const msg of currentMsgs) { - if (msg.id !== doneAssistantId && msg.loading && msg.phase === 'connecting') { - updateAIChatMessage(sid, msg.id, { loading: false, phase: 'idle' }); - } - } - - if (doneAssistantId) { - const current = useStore.getState().aiChatHistory[sid]; - const existing = current?.find(m => m.id === doneAssistantId); - if (existing && existing.tool_calls && existing.tool_calls.length > 0) { - // 【关键】保持 loading:true 和 phase:'tool_calling',让 UI 能实时展示工具执行进度 - nudgeCountRef.current = 0; - setTimeout(() => executeLocalTools(existing.tool_calls!, doneAssistantId), 50); - return; - } - - // 自动催促:模型描述了要调用工具但没有 function call - if (existing && nudgeCountRef.current < 2 && - /(?:让我|我先|我来|现在|接下来|下面).*(?:查询|查找|获取|查看|检查|调用)|(?:获取|查询|查找|查看).*(?:信息|字段|列表|数据)[::]?\s*$/.test(existing.content || '')) { - nudgeCountRef.current += 1; - // 🔧 关闭当前消息的 loading 状态,消除闪烁光标 - updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' }); - // 注入 system 催促并重发 - (async () => { - try { - const currentHistory = useStore.getState().aiChatHistory[sid] || []; - const messagesPayload = currentHistory.map(toAIRequestMessage); - const sysMessages = await buildSystemContextMessages( - existing.jvmPlanContext, - existing.jvmDiagnosticPlanContext, - ); - // 追加催促消息 - messagesPayload.push({ role: 'user', content: tRef.current('ai_chat.panel.model_control.force_tool_call') }); - const allMsg = [...sysMessages, ...messagesPayload]; - const Service = (window as any).go?.aiservice?.Service; - if (Service?.AIChatStream) await Service.AIChatStream(sid, allMsg, getLocalTools()); - } catch (e) { - console.error('Nudge failed', e); - setSending(false); - } - })(); - return; - } - - if (doneIsFirst) generateTitleForSession(sid); - - // 正常完成:关闭 loading,消除闪烁光标 - const hasContent = !!existing?.content?.trim(); - const hasThinking = !!existing?.thinking?.trim(); - const hasTools = !!(existing?.tool_calls?.length); - - if (!hasContent && !hasThinking && !hasTools) { - updateAIChatMessage(sid, doneAssistantId, { content: tRef.current('ai_chat.panel.message.empty_response'), loading: false, phase: 'idle' }); - } else { - updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' }); - } - } else { - addAIChatMessage(sid, { id: genId(), role: 'assistant', content: tRef.current('ai_chat.panel.message.request_interrupted'), timestamp: Date.now(), loading: false }); - } - setSending(false); - }, 50); - } - }; - - EventsOn(eventName, handler); - return () => { EventsOff(eventName); }; - }, [addAIChatMessage, updateAIChatMessage, sid, getLocalTools]); - - const generateTitleForSession = async (currentSid: string) => { - try { - const Service = (window as any).go?.aiservice?.Service; - const historyLocal = useStore.getState().aiChatHistory[currentSid] || []; - if (!Service?.AIChatSend || historyLocal.length < 2) return; - - const firstUserMsg = historyLocal.find(m => m.role === 'user'); - if (firstUserMsg) { - // 取用前 50 个字符截断,防止太长的查询消耗过多 Token - const snippet = firstUserMsg.content.slice(0, 50); - const titleReq = [ - { role: 'system', content: 'You are a summarizer. Provide a short 3-6 word title for this prompt. Do not use quotes, punctuation, or explain. Just the title in the same language as the prompt.' }, - { role: 'user', content: snippet } - ]; - const res = await Service.AIChatSend(titleReq); - if (res?.success && res.content) { - const cleanTitle = res.content.trim().replace(/^["']|["']$/g, ''); - updateAISessionTitle(currentSid, cleanTitle); - } - } - } catch (e) { - console.warn('Failed to auto-generate title', e); - } - }; - - const handleScrollMessages = useCallback((e: React.UIEvent) => { - const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + const handleScrollMessages = useCallback((event: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; const isNearBottom = scrollHeight - scrollTop - clientHeight < 150; setShowScrollBottom(!isNearBottom); }, []); @@ -893,14 +213,54 @@ export const AIChatPanel: React.FC = ({ truncateAIChatMessages(sid, msg.id); deleteAIChatMessage(sid, msg.id); setInput(msg.content); + setDraftAttachments(msg.attachments || []); setTimeout(() => textareaRef.current?.focus(), 50); }, [sid, truncateAIChatMessages, deleteAIChatMessage]); + const buildSystemContextMessages = useCallback(( + overrideJVMPlanContext?: JVMAIPlanContext, + overrideJVMDiagnosticPlanContext?: JVMDiagnosticPlanContext, + ) => { + const { activeContext, aiContexts, connections, tabs, activeTabId } = useStore.getState(); + return buildAISystemContextMessages({ + activeContext, + aiContexts, + connections, + tabs, + activeTabId, + availableToolNames: availableTools.map((tool) => tool.function.name), + skills, + userPromptSettings, + overrideJVMPlanContext, + overrideJVMDiagnosticPlanContext, + }); + }, [availableTools, skills, userPromptSettings]); + + const { + executeLocalTools, + resetToolCallState, + toolContextMapRef, + } = useAIChatLocalTools({ + sid, + activeProviderModel: activeProvider?.model, + availableTools, + buildSystemContextMessages, + dynamicModels, + mcpTools, + nextMessageId: genId, + pendingJVMPlanContextRef, + pendingJVMDiagnosticPlanContextRef, + setSending, + skills, + updateAIChatMessage, + userPromptSettings, + }); + const handleRetryMessage = useCallback(async (msg: AIChatMessage) => { const historyLocal = useStore.getState().aiChatHistory[sid] || []; - const aiIndex = historyLocal.findIndex(m => m.id === msg.id); + const aiIndex = historyLocal.findIndex(message => message.id === msg.id); if (aiIndex <= 0) return; - + let lastUserMsgIndex = -1; for (let i = aiIndex - 1; i >= 0; i--) { if (historyLocal[i].role === 'user') { @@ -908,14 +268,12 @@ export const AIChatPanel: React.FC = ({ break; } } - + if (lastUserMsgIndex >= 0) { const userMsg = historyLocal[lastUserMsgIndex]; - truncateAIChatMessages(sid, userMsg.id); + truncateAIChatMessages(sid, userMsg.id); - // 重置计数器(与 handleSend 保持一致) - toolCallRoundRef.current = 0; - totalToolRoundRef.current = 0; + resetToolCallState(); nudgeCountRef.current = 0; const retryJVMPlanContext = msg.jvmPlanContext || getCurrentJVMPlanContext(); const retryJVMDiagnosticPlanContext = @@ -925,10 +283,13 @@ export const AIChatPanel: React.FC = ({ setSending(true); - // 插入 connecting 过渡消息(波纹动画),与 handleSend 保持一致 const connectingMsg: AIChatMessage = { - id: genId(), role: 'assistant', phase: 'connecting', content: '', - timestamp: Date.now(), loading: true, + id: genId(), + role: 'assistant', + phase: 'connecting', + content: '', + timestamp: Date.now(), + loading: true, jvmPlanContext: retryJVMPlanContext, jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext, }; @@ -936,535 +297,119 @@ export const AIChatPanel: React.FC = ({ const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1); const messagesPayload = truncatedHistory.map(toAIRequestMessage); - + try { const sysMessages = await buildSystemContextMessages( retryJVMPlanContext, retryJVMDiagnosticPlanContext, ); const allMessages = [...sysMessages, ...messagesPayload]; - - const Service = (window as any).go?.aiservice?.Service; - if (Service?.AIChatStream) { - await Service.AIChatStream(sid, allMessages, getLocalTools()); - } else if (Service?.AIChatSend) { - const result = await Service.AIChatSend(allMessages, getLocalTools()); - const errRaw = result?.error || t('ai_chat.panel.error.unknown'); - const errClean = sanitizeErrorMsg(errRaw, t); - addAIChatMessage(sid, { - id: genId(), role: 'assistant', - content: result?.success ? result.content : t('ai_chat.panel.message.error', { detail: errClean }), - thinking: result?.success ? result.reasoning_content : undefined, - reasoning_content: result?.success ? result.reasoning_content : undefined, - rawError: (!result?.success && errClean !== errRaw) ? errRaw : undefined, - timestamp: Date.now(), - jvmPlanContext: retryJVMPlanContext, - jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext, - }); - setSending(false); - } else { - setSending(false); - } - } catch(e: any) { - const rawE = e?.message || String(e); - const cleanE = sanitizeErrorMsg(rawE, t); - addAIChatMessage(sid, { - id: genId(), - role: 'assistant', - content: t('ai_chat.panel.message.send_failed', { detail: cleanE }), - rawError: cleanE !== rawE ? rawE : undefined, - timestamp: Date.now(), + await dispatchAIChatPayload({ + sid, + messages: allMessages, + tools: availableTools, + addAIChatMessage, + updateAIChatMessage, + setSending, + nextMessageId: genId, + pendingAssistantMessageId: connectingMsg.id, jvmPlanContext: retryJVMPlanContext, jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext, }); + } catch { setSending(false); } } }, [ sid, + availableTools, + buildSystemContextMessages, truncateAIChatMessages, addAIChatMessage, getCurrentJVMPlanContext, getCurrentJVMDiagnosticPlanContext, - getLocalTools, - t, + resetToolCallState, + updateAIChatMessage, ]); - const buildSystemContextMessages = useCallback(async ( - overrideJVMPlanContext?: JVMAIPlanContext, - overrideJVMDiagnosticPlanContext?: JVMDiagnosticPlanContext, - ) => { - // 🔧 性能优化:从 store 实时读取,避免闭包捕获导致的依赖链式重建 - const { activeContext: ctx, aiContexts: ctxMap, connections: conns, tabs: allTabs, activeTabId: tabId } = useStore.getState(); - - const connectionKey = ctx?.connectionId ? `${ctx.connectionId}:${ctx.dbName || ''}` : 'default'; - const activeContextItems = ctxMap[connectionKey] || []; - const systemMessages: { role: string; content: string; images?: string[] }[] = []; - const matchesDiagnosticContext = (tab: typeof allTabs[number]) => { - if (!overrideJVMDiagnosticPlanContext || tab.type !== 'jvm-diagnostic') { - return false; - } - const tabConnection = conns.find(c => c.id === tab.connectionId); - const tabTransport = tabConnection?.config?.jvm?.diagnostic?.transport || 'agent-bridge'; - return ( - tab.connectionId === overrideJVMDiagnosticPlanContext.connectionId && - tabTransport === overrideJVMDiagnosticPlanContext.transport - ); - }; - const activeTab = overrideJVMDiagnosticPlanContext - ? ( - allTabs.find(t => t.id === overrideJVMDiagnosticPlanContext.tabId && matchesDiagnosticContext(t)) || - allTabs.find(t => matchesDiagnosticContext(t)) - ) - : overrideJVMPlanContext - ? ( - allTabs.find(t => t.id === overrideJVMPlanContext.tabId) || - allTabs.find( - t => - t.type === 'jvm-resource' && - t.connectionId === overrideJVMPlanContext.connectionId && - t.providerMode === overrideJVMPlanContext.providerMode && - String(t.resourcePath || '').trim() === overrideJVMPlanContext.resourcePath, - ) - ) - : allTabs.find(t => t.id === tabId); - const activeConnection = activeTab?.connectionId - ? conns.find(c => c.id === activeTab.connectionId) - : undefined; - - if ( - activeTab && - activeTab.type === 'jvm-diagnostic' && - activeConnection?.config?.type === 'jvm' - ) { - const diagnostic = activeConnection.config.jvm?.diagnostic; - const diagnosticTransport = overrideJVMDiagnosticPlanContext?.transport || diagnostic?.transport || 'agent-bridge'; - const readOnly = activeConnection.config.jvm?.readOnly !== false; - const environment = activeConnection.config.jvm?.environment || 'unknown'; - systemMessages.push({ - role: 'system', - content: tRef.current(jvmDiagnosticPromptKey, { - connectionName: activeConnection.name, - host: activeConnection.config.host || '-', - transport: diagnosticTransport, - environment, - readOnlyPolicy: tRef.current(readOnly ? 'ai_chat.panel.jvm_diagnostic.policy.read_only' : 'ai_chat.panel.jvm_diagnostic.policy.plan_first'), - observePolicy: tRef.current(diagnostic?.allowObserveCommands !== false ? 'ai_chat.panel.jvm_diagnostic.permission.allowed' : 'ai_chat.panel.jvm_diagnostic.permission.forbidden'), - tracePolicy: tRef.current(diagnostic?.allowTraceCommands === true ? 'ai_chat.panel.jvm_diagnostic.permission.allowed' : 'ai_chat.panel.jvm_diagnostic.permission.forbidden'), - mutatingPolicy: tRef.current(diagnostic?.allowMutatingCommands === true ? 'ai_chat.panel.jvm_diagnostic.permission.allowed' : 'ai_chat.panel.jvm_diagnostic.permission.forbidden'), - }), - }); - return systemMessages; - } - - if ( - activeTab && - (activeTab.type === 'jvm-resource' || activeTab.type === 'jvm-overview' || activeTab.type === 'jvm-audit') && - activeConnection?.config?.type === 'jvm' - ) { - const providerMode = activeTab.providerMode || activeConnection.config.jvm?.preferredMode || 'jmx'; - const resourcePath = activeTab.resourcePath || ''; - const readOnly = activeConnection.config.jvm?.readOnly !== false; - const environment = activeConnection.config.jvm?.environment || 'unknown'; - systemMessages.push({ - role: 'system', - content: tRef.current(jvmRuntimePromptKey, { - connectionName: activeConnection.name, - host: activeConnection.config.host || '-', - providerMode, - environment, - connectionPolicy: tRef.current(readOnly ? 'ai_chat.panel.jvm_runtime.policy.read_only' : 'ai_chat.panel.jvm_runtime.policy.preview_required'), - resourcePathStatus: tRef.current(resourcePath ? 'ai_chat.panel.jvm_runtime.resource_path.current' : 'ai_chat.panel.jvm_runtime.resource_path.missing', { resourcePath }), - }), - }); - return systemMessages; - } - - let targetConnId = ctx?.connectionId; - let targetDbName = ctx?.dbName; - if (!targetConnId || !targetDbName) { - if (activeTab && activeTab.connectionId && activeTab.dbName) { - targetConnId = activeTab.connectionId; - targetDbName = activeTab.dbName; - } - } - - const conn = conns.find(c => c.id === targetConnId); - const dbType = conn?.config?.type || 'unknown'; - const dbDisplayType = dbType === 'diros' ? 'Doris' : dbType.charAt(0).toUpperCase() + dbType.slice(1); - const ddlChunks = activeContextItems.map(c => `-- Table: ${c.dbName}.${c.tableName}\n${c.ddl}`).join('\n\n'); - const connList = conns.map(c => `{id: "${c.id}", name: "${c.name}", type: "${c.config?.type || 'unknown'}"}`).join(', '); - const sqlPromptKey = activeContextItems.length > 0 - ? 'ai_chat.panel.prompt.sql.context_tables' - : targetConnId && targetDbName - ? 'ai_chat.panel.prompt.sql.current_database' - : conns.length > 0 - ? 'ai_chat.panel.prompt.sql.no_context' - : 'ai_chat.panel.prompt.sql.no_connections'; - const sqlPromptParams = activeContextItems.length > 0 - ? { - dbDisplayType, - ddlChunks, - } - : targetConnId && targetDbName - ? { - dbDisplayType, - targetDbName, - } - : conns.length > 0 - ? { - connList, - } - : {}; - systemMessages.push({ - role: 'system', - content: tRef.current(sqlPromptKey, sqlPromptParams), - }); - return systemMessages; - }, []); // 零依赖:函数内部通过 useStore.getState() 实时读取 - - // 记录所有成功的 get_tables 调用结果,用于表级精确匹配 - const toolContextMapRef = useRef>(new Map()); - - const executeLocalTools = useCallback(async (toolCalls: AIToolCall[], currentAsstMsgId: string) => { - const translateToolChrome: AIChatTranslator = (key, params) => tRef.current(key, params); - const currentAsstMsg = (useStore.getState().aiChatHistory[sid] || []).find(m => m.id === currentAsstMsgId); - const inheritedJVMPlanContext = currentAsstMsg?.jvmPlanContext || pendingJVMPlanContextRef.current; - const inheritedJVMDiagnosticPlanContext = - currentAsstMsg?.jvmDiagnosticPlanContext || pendingJVMDiagnosticPlanContextRef.current; - pendingJVMPlanContextRef.current = inheritedJVMPlanContext; - pendingJVMDiagnosticPlanContextRef.current = inheritedJVMDiagnosticPlanContext; - - // 【全局轮次熔断】防止模型(如 DeepSeek)在已生成答案后仍无限循环调用工具 - const MAX_TOOL_CALL_ROUNDS = 15; - totalToolRoundRef.current += 1; - if (totalToolRoundRef.current > MAX_TOOL_CALL_ROUNDS) { - updateAIChatMessage(sid, currentAsstMsgId, { loading: false, phase: 'idle' }); - useStore.getState().addAIChatMessage(sid, { - id: genId(), role: 'assistant', - content: translateToolChrome('ai_chat.panel.probe.max_rounds', { count: MAX_TOOL_CALL_ROUNDS }), - timestamp: Date.now(), - jvmPlanContext: inheritedJVMPlanContext, - jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, - }); - setSending(false); - return; - } - - const results: AIChatMessage[] = []; - // 【串行逐条执行 + 实时写入 store】 - for (const tc of toolCalls) { - let resStr = ''; - let success = false; - try { - const args = JSON.parse(tc.function.arguments || '{}'); - switch (tc.function.name) { - case 'get_connections': - const conns = useStore.getState().connections.map(c => ({ - id: c.id, - name: c.name, - type: c.config?.type, - host: (c.config as any)?.host || (c.config as any)?.addr || '' - })); - resStr = JSON.stringify(conns); - success = true; - break; - case 'get_databases': { - const conn = useStore.getState().connections.find(c => c.id === args.connectionId); - if (conn) { - try { - const dbRes = await DBGetDatabases(buildRpcConnectionConfig(conn.config) as any); - if (dbRes?.success && Array.isArray(dbRes.data)) { - let dNames = dbRes.data.map((r: any) => r.Database || r.database || Object.values(r)[0]); - if (dNames.length > 50) dNames = [...dNames.slice(0, 50), '...(截断)']; - resStr = JSON.stringify(dNames); - success = true; - } else { - resStr = dbRes?.message || 'Failed to fetch DBs'; - } - } catch (e: any) { - resStr = translateToolChrome('ai_chat.panel.tool_error.fetch_databases_failed', { detail: String(e?.message || e) }); - } - } else { resStr = translateToolChrome('ai_chat.panel.tool_error.connection_not_found'); } - break; - } - case 'get_tables': { - const conn = useStore.getState().connections.find(c => c.id === args.connectionId); - if (conn) { - try { - const rawDbName = args.dbName || args.database; - const safeDbName = rawDbName ? String(rawDbName).trim() : ''; - const tbRes = await DBGetTables(buildRpcConnectionConfig(conn.config) as any, safeDbName); - if (tbRes?.success && Array.isArray(tbRes.data)) { - let tNames = tbRes.data.map((r: any) => r.Table || r.table || Object.values(r)[0] as string); - if (tNames.length > 150) tNames = [...tNames.slice(0, 150), '...(截断)']; - resStr = JSON.stringify(tNames); - success = true; - // 🔑 记录已验证的上下文参数和表列表(用于后续表级精确匹配) - toolContextMapRef.current.set(`${args.connectionId}:${safeDbName}`, { - connectionId: args.connectionId, - dbName: safeDbName, - tables: tNames.filter((t: string) => t !== '...(截断)') - }); - } else { resStr = tbRes?.message || 'Failed to fetch Tables'; } - } catch (e: any) { - resStr = translateToolChrome('ai_chat.panel.tool_error.fetch_tables_failed', { detail: String(e?.message || e) }); - } - } else { resStr = translateToolChrome('ai_chat.panel.tool_error.connection_not_found'); } - break; - } - case 'get_columns': { - const conn = useStore.getState().connections.find(c => c.id === args.connectionId); - if (conn) { - try { - const safeDbName = args.dbName ? String(args.dbName).trim() : ''; - const safeTable = args.tableName ? String(args.tableName).trim() : ''; - const { DBGetColumns } = await import('../../wailsjs/go/app/App'); - const colRes = await DBGetColumns(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable); - if (colRes?.success && Array.isArray(colRes.data)) { - // 只保留关键字段信息,减少 token 占用 - const cols = colRes.data.map((c: any) => { - const keys = Object.keys(c); - return { - field: c.Field || c.field || c.COLUMN_NAME || c.column_name || c.Name || c.name || (keys.length > 0 ? c[keys[0]] : ''), - type: c.Type || c.type || c.DATA_TYPE || c.data_type || (keys.length > 1 ? c[keys[1]] : ''), - nullable: c.Null || c.null || c.IS_NULLABLE || c.is_nullable || c.Nullable || c.nullable || '', - default: c.Default || c.default || c.COLUMN_DEFAULT || c.column_default || c.DefaultValue || '', - comment: c.Comment || c.comment || c.COLUMN_COMMENT || c.column_comment || c.Description || '', - }; - }); - // ⚠️ 在工具返回结果中直接注入强制警告,确保模型使用精确字段名 - const fieldNames = cols.map((c: any) => c.field).join(', '); - resStr = translateToolChrome('ai_chat.panel.tool_result.columns_exact_fields', { tableName: safeTable, fieldNames, detailJson: JSON.stringify(cols) }); - success = true; - } else { resStr = colRes?.message || 'Failed to fetch columns'; } - } catch (e: any) { - resStr = translateToolChrome('ai_chat.panel.tool_error.fetch_columns_failed', { detail: String(e?.message || e) }); - } - } else { resStr = translateToolChrome('ai_chat.panel.tool_error.connection_not_found'); } - break; - } - case 'get_table_ddl': { - const conn = useStore.getState().connections.find(c => c.id === args.connectionId); - if (conn) { - try { - const safeDbName = args.dbName ? String(args.dbName).trim() : ''; - const safeTable = args.tableName ? String(args.tableName).trim() : ''; - const { DBShowCreateTable, DBGetColumns } = await import('../../wailsjs/go/app/App'); - const rpcConfig = buildRpcConnectionConfig(conn.config) as any; - const toolResult = await resolveAITableSchemaToolResult({ - tableName: safeTable, - fetchDDL: () => DBShowCreateTable(rpcConfig, safeDbName, safeTable), - fetchColumns: () => DBGetColumns(rpcConfig, safeDbName, safeTable), - }); - resStr = toolResult.content; - success = toolResult.success; - } catch (e: any) { - resStr = translateToolChrome('ai_chat.panel.tool_error.fetch_table_ddl_failed', { detail: String(e?.message || e) }); - } - } else { resStr = translateToolChrome('ai_chat.panel.tool_error.connection_not_found'); } - break; - } - case 'execute_sql': { - const conn = useStore.getState().connections.find(c => c.id === args.connectionId); - if (conn) { - try { - const safeDbName = args.dbName ? String(args.dbName).trim() : ''; - const safeSql = args.sql ? String(args.sql).trim() : ''; - // 安全级别检查 - const Service = (window as any).go?.aiservice?.Service; - if (Service?.AICheckSQL) { - const check = await Service.AICheckSQL(safeSql); - if (!check.allowed) { - resStr = translateToolChrome('ai_chat.panel.tool_error.sql_blocked', { operationType: check.operationType }); - break; - } - } - const { DBQuery } = await import('../../wailsjs/go/app/App'); - const finalSql = buildAIReadonlyPreviewSQL(conn.config?.type || '', safeSql, 50, conn.config?.driver || ''); - const qRes = await DBQuery(buildRpcConnectionConfig(conn.config) as any, safeDbName, finalSql); - if (qRes?.success) { - const rows = Array.isArray(qRes.data) ? qRes.data : []; - const limitedRows = rows.slice(0, 50); - resStr = JSON.stringify({ rowCount: rows.length, data: limitedRows }); - success = true; - } else { resStr = qRes?.message || translateToolChrome('ai_chat.panel.tool_error.sql_execute_failed'); } - } catch (e: any) { - resStr = translateToolChrome('ai_chat.panel.tool_error.sql_execute_exception', { detail: String(e?.message || e) }); - } - } else { resStr = translateToolChrome('ai_chat.panel.tool_error.connection_not_found'); } - break; - } - default: - resStr = translateToolChrome('ai_chat.panel.tool_error.unknown_function', { functionName: tc.function.name }); - } - } catch (e: any) { - resStr = e.message; - } - - const toolResultMsg: AIChatMessage = { - id: genId(), - role: 'tool', - content: resStr, - timestamp: Date.now(), - tool_call_id: tc.id, - tool_name: tc.function.name, - success - }; - results.push(toolResultMsg); - - // 【实时写入】每执行完一条立即写入 store,让 UI 能实时看到进度打勾 - useStore.getState().addAIChatMessage(sid, toolResultMsg); - - // 延迟 150ms,给 UI 渲染时间,创造“逐个完成”的视觉节奏 - await new Promise(resolve => setTimeout(resolve, 150)); - } - - // 智能熔断:只计连续失败轮次,成功则重置 - const anySuccess = results.some(r => r.success === true); - if (anySuccess) { - toolCallRoundRef.current = 0; - } else { - toolCallRoundRef.current += 1; - if (toolCallRoundRef.current >= 3) { - useStore.getState().addAIChatMessage(sid, { - id: genId(), role: 'assistant', - content: translateToolChrome('ai_chat.panel.probe.consecutive_failed'), - timestamp: Date.now(), - jvmPlanContext: inheritedJVMPlanContext, - jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, - }); - setSending(false); - return; - } - } - try { - // 【过渡状态】工具执行完毕,将上一条消息的 loading 关闭(消除闪烁光标) - updateAIChatMessage(sid, currentAsstMsgId, { loading: false, phase: 'idle' }); - - // 插入过渡气泡 - const chainConnectingMsg: AIChatMessage = { - id: genId(), role: 'assistant', phase: 'connecting', - content: translateToolChrome('ai_chat.panel.status.summarizing_probe'), - timestamp: Date.now(), loading: true, - jvmPlanContext: inheritedJVMPlanContext, - jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, - }; - useStore.getState().addAIChatMessage(sid, chainConnectingMsg); - - // 模拟人类视角的平滑多段过渡 - const safeUpdateTransition = (text: string) => { - const currentMsg = useStore.getState().aiChatHistory[sid]?.find(m => m.id === chainConnectingMsg.id); - // 只有当消息仍然处于连接过渡态时才允许修改文本;如果模型已经开始吐出思考、正文、工具或结束,直接退出 - if (currentMsg && currentMsg.phase === 'connecting' && currentMsg.loading) { - updateAIChatMessage(sid, chainConnectingMsg.id, { content: text }); - } - }; - - setTimeout(() => safeUpdateTransition(translateToolChrome('ai_chat.panel.status.returning_runtime_data')), 200); - setTimeout(() => safeUpdateTransition(translateToolChrome('ai_chat.panel.status.deep_reasoning')), 500); - setTimeout(() => safeUpdateTransition(translateToolChrome('ai_chat.panel.status.waiting_instruction')), 1200); - setTimeout(() => safeUpdateTransition(translateToolChrome('ai_chat.panel.status.analyzing_chain')), 3000); - - setSending(true); - const currentHistory = useStore.getState().aiChatHistory[sid] || []; - // 过滤掉 connecting 占位消息,不发给模型 - const messagesPayload = currentHistory.filter(m => m.phase !== 'connecting').map(toAIRequestMessage); - const sysMessages = await buildSystemContextMessages( - inheritedJVMPlanContext, - inheritedJVMDiagnosticPlanContext, - ); - - let finalMessagesPayload = messagesPayload; - // 在这里加入长度检查和自动摘要(带上动态限额) - const dynamicMaxLimit = getDynamicMaxContextChars(activeProvider?.model); - const summary = await compressContextIfNeeded(sid, messagesPayload, dynamicMaxLimit, translateToolChrome); - if (summary) { - const compressedMsg: AIChatMessage = { - id: genId(), role: 'assistant', content: translateToolChrome('ai_chat.panel.status.memory_probe_summary', { summary }), timestamp: Date.now() - 1000 - }; - const continueMsg: AIChatMessage = { - id: genId(), role: 'user', content: translateToolChrome('ai_chat.panel.model_control.continue_after_summary'), timestamp: Date.now() - 500 - }; - useStore.getState().replaceAIChatHistory(sid, [compressedMsg, continueMsg, chainConnectingMsg]); - finalMessagesPayload = [ - { role: 'assistant', content: compressedMsg.content }, - { role: 'user', content: continueMsg.content } - ]; - } - - const allMessages = [...sysMessages, ...finalMessagesPayload]; - - // 【软收敛】超过 10 轮工具调用后,不再传递 tools 参数,从物理层面强制模型只能用文本回答 - const SOFT_LIMIT_ROUNDS = 10; - const chainTools = totalToolRoundRef.current >= SOFT_LIMIT_ROUNDS ? [] : getLocalTools(); - - const Service = (window as any).go?.aiservice?.Service; - if (Service?.AIChatStream) { - await Service.AIChatStream(sid, allMessages, chainTools); - } else if (Service?.AIChatSend) { - const result = await Service.AIChatSend(allMessages, chainTools); - const errR = result?.error || translateToolChrome('ai_chat.panel.error.unknown'); - const errC = sanitizeErrorMsg(errR, translateToolChrome); - useStore.getState().addAIChatMessage(sid, { - id: genId(), role: 'assistant', - content: result?.success ? result.content : translateToolChrome('ai_chat.panel.message.error', { detail: errC }), - thinking: result?.success ? result.reasoning_content : undefined, - reasoning_content: result?.success ? result.reasoning_content : undefined, - rawError: (!result?.success && errC !== errR) ? errR : undefined, - timestamp: Date.now(), - jvmPlanContext: inheritedJVMPlanContext, - jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, - }); - setSending(false); - } - } catch (e) { - console.error('Failed to chain tool call', e); - setSending(false); - } - }, [sid, buildSystemContextMessages, getLocalTools]); + useAIChatStreamSubscription({ + sid, + sending, + setSending, + availableTools, + addAIChatMessage, + updateAIChatMessage, + buildSystemContextMessages, + executeLocalTools, + generateTitleForSession, + nextMessageId: genId, + nudgeCountRef, + pendingJVMPlanContextRef, + pendingJVMDiagnosticPlanContextRef, + }); const handleSend = useCallback(async () => { const text = input.trim(); - if ((!text && draftImages.length === 0) || sending) return; + if ((!text && draftAttachments.length === 0) || sending) return; - // 前置校验:必须配置供应商且选择模型后才能发送 - if (!activeProvider) { + const connectionKey = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default'; + const readiness = buildAIChatReadinessSnapshot({ + activeProvider, + dynamicModels, + loadingModels, + activeContext, + activeContextItems: aiContexts[connectionKey] || [], + }); + + if (readiness.status === 'missing_provider') { setComposerNoticeState({ kind: 'missing_provider' }); return; } - if (!activeProvider.model || !activeProvider.model.trim()) { + if (readiness.status === 'provider_incomplete') { + setComposerNoticeState({ kind: 'provider_incomplete', issues: readiness.issues }); + return; + } + if (readiness.status === 'missing_model' || readiness.status === 'loading_models') { setComposerNoticeState({ kind: 'missing_model' }); return; } setComposerNoticeState(null); - toolCallRoundRef.current = 0; // 重置工具调用轮次计数 - totalToolRoundRef.current = 0; // 重置总轮次计数 - nudgeCountRef.current = 0; // 重置催促计数 + resetToolCallState(); + nudgeCountRef.current = 0; const currentJVMPlanContext = getCurrentJVMPlanContext(); const currentJVMDiagnosticPlanContext = getCurrentJVMDiagnosticPlanContext(); pendingJVMPlanContextRef.current = currentJVMPlanContext; pendingJVMDiagnosticPlanContextRef.current = currentJVMDiagnosticPlanContext; - const currentImages = [...draftImages]; + const currentAttachments = [...draftAttachments]; + const currentImages = currentAttachments + .filter((attachment) => attachment.kind === 'image' && attachment.dataUrl) + .map((attachment) => attachment.dataUrl as string); + const currentFileAttachments = currentAttachments.filter((attachment) => attachment.kind !== 'image'); setInput(''); - setDraftImages([]); + setDraftAttachments([]); setSending(true); - if (textareaRef.current) { - textareaRef.current.focus(); - } + textareaRef.current?.focus(); const userMsg: AIChatMessage = { - id: genId(), role: 'user', content: text, timestamp: Date.now(), + id: genId(), + role: 'user', + content: text, + timestamp: Date.now(), images: currentImages.length > 0 ? currentImages : undefined, + attachments: currentFileAttachments.length > 0 ? currentFileAttachments : undefined, }; addAIChatMessage(sid, userMsg); - + const connectingMsg: AIChatMessage = { - id: genId(), role: 'assistant', phase: 'connecting', content: '', - timestamp: Date.now(), loading: true, + id: genId(), + role: 'assistant', + phase: 'connecting', + content: '', + timestamp: Date.now(), + loading: true, jvmPlanContext: currentJVMPlanContext, jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, }; @@ -1475,101 +420,72 @@ export const AIChatPanel: React.FC = ({ currentJVMDiagnosticPlanContext, ); - // 【过渡状态 2】上下文已组装完成,即将接入模型 updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.model_connecting') }); const chatMessages = [...messages, userMsg].map(toAIRequestMessage); let finalMessagesPayload = chatMessages; const dynamicMaxLimit = getDynamicMaxContextChars(activeProvider?.model); - const summary = await compressContextIfNeeded(sid, chatMessages, dynamicMaxLimit, t); + const summary = await compressContextIfNeeded(sid, chatMessages, dynamicMaxLimit); if (summary) { - // 清理原有历史,保留系统生成的总结记录和当前的 userMsg 以及 connectingMsg const compressedMsg: AIChatMessage = { - id: genId(), role: 'assistant', content: t('ai_chat.panel.status.memory_summary', { summary }), timestamp: Date.now() - 1000 + id: genId(), + role: 'assistant', + content: t('ai_chat.panel.status.memory_summary', { summary }), + timestamp: Date.now() - 1000, }; useStore.getState().replaceAIChatHistory(sid, [compressedMsg, userMsg, connectingMsg]); finalMessagesPayload = [ { role: 'assistant', content: compressedMsg.content }, - { role: 'user', content: userMsg.content, images: userMsg.images } + toAIRequestMessage(userMsg), ]; } const allMessages = [...systemMessages, ...finalMessagesPayload]; - // 【过渡状态 3】大脑唤醒 updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.waking_engine') }); - - // 【过渡状态 4】最后一步,等待第一字节返回 updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.waiting_response') }); - try { - const Service = (window as any).go?.aiservice?.Service; - if (Service?.AIChatStream) { - await Service.AIChatStream(sid, allMessages, getLocalTools()); - } else if (Service?.AIChatSend) { - const result = await Service.AIChatSend(allMessages, getLocalTools()); - const errR2 = result?.error || t('ai_chat.panel.error.unknown'); - const errC2 = sanitizeErrorMsg(errR2, t); - const assistantMsg: AIChatMessage = { - id: genId(), role: 'assistant', - content: result?.success ? result.content : t('ai_chat.panel.message.error', { detail: errC2 }), - thinking: result?.success ? result.reasoning_content : undefined, - reasoning_content: result?.success ? result.reasoning_content : undefined, - rawError: (!result?.success && errC2 !== errR2) ? errR2 : undefined, - timestamp: Date.now(), - jvmPlanContext: currentJVMPlanContext, - jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, - }; - addAIChatMessage(sid, assistantMsg); - setSending(false); - - // auto-generate title fallback for non-stream - if (messages.length === 0) { - generateTitleForSession(sid); - } - } else { - addAIChatMessage(sid, { - id: genId(), - role: 'assistant', - content: t('ai_chat.panel.message.service_not_ready'), - timestamp: Date.now(), - jvmPlanContext: currentJVMPlanContext, - jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, - }); - setSending(false); - } - } catch (e: any) { - const rawE2 = e?.message || String(e); - const cleanE2 = sanitizeErrorMsg(rawE2, t); - addAIChatMessage(sid, { - id: genId(), - role: 'assistant', - content: t('ai_chat.panel.message.send_failed', { detail: cleanE2 }), - rawError: cleanE2 !== rawE2 ? rawE2 : undefined, - timestamp: Date.now(), - jvmPlanContext: currentJVMPlanContext, - jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, - }); - setSending(false); - } + await dispatchAIChatPayload({ + sid, + messages: allMessages, + tools: availableTools, + addAIChatMessage, + updateAIChatMessage, + setSending, + nextMessageId: genId, + pendingAssistantMessageId: connectingMsg.id, + jvmPlanContext: currentJVMPlanContext, + jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, + unavailableContent: t('ai_chat.panel.message.service_not_ready'), + onNonStreamSuccess: messages.length === 0 + ? () => generateTitleForSession(sid) + : undefined, + }); }, [ input, - draftImages, + draftAttachments, sending, messages, addAIChatMessage, sid, + activeContext, activeProvider, + aiContexts, + availableTools, buildSystemContextMessages, + dynamicModels, + generateTitleForSession, getCurrentJVMPlanContext, getCurrentJVMDiagnosticPlanContext, - getLocalTools, + loadingModels, + resetToolCallState, t, + updateAIChatMessage, ]); - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - consumeAIChatSendShortcutOnKeyDown(aiChatSendShortcutBinding, e, handleSend); + const handleKeyDown = useCallback((event: React.KeyboardEvent) => { + consumeAIChatSendShortcutOnKeyDown(aiChatSendShortcutBinding, event, handleSend); }, [aiChatSendShortcutBinding, handleSend]); const handleStop = useCallback(async () => { @@ -1578,201 +494,140 @@ export const AIChatPanel: React.FC = ({ if (Service?.AIChatCancel) { await Service.AIChatCancel(sid); } - } catch (e) { - console.warn('Failed to stop chat stream', e); + } catch (error) { + console.warn('Failed to stop chat stream', error); } setSending(false); }, [sid]); - const ghostRef = useRef(null); - const panelRect = useRef<{top: number, bottom: number, left: number} | null>(null); + const { inferredConnectionId, inferredDbName } = useMemo( + () => inferAIChatConnectionContext({ + activeConnectionId: activeContext?.connectionId, + activeDbName: activeContext?.dbName, + messages, + toolContextEntries: toolContextMapRef.current.values(), + }), + [activeContext?.connectionId, activeContext?.dbName, messages], + ); - const handleResizeStart = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - setIsResizing(true); - resizeStartX.current = e.clientX; - resizeStartWidth.current = panelWidth; - dragWidthRef.current = panelWidth; - if (panelRef.current) { - const rect = panelRef.current.getBoundingClientRect(); - panelRect.current = { - top: rect.top, - bottom: window.innerHeight - rect.bottom, - left: rect.left - }; - } - }, [panelWidth]); - - useEffect(() => { - if (!isResizing) return; - let animationFrameId: number; - const handleMouseMove = (e: MouseEvent) => { - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - animationFrameId = requestAnimationFrame(() => { - const delta = resizeStartX.current - e.clientX; - const minWidth = isV2Ui ? 300 : 280; - const maxWidth = isV2Ui ? 520 : 700; - const newWidth = Math.min(Math.max(resizeStartWidth.current + delta, minWidth), maxWidth); - dragWidthRef.current = newWidth; - - // 仅更新 ghost 虚线位置,通过绝对定位规避重排 - if (ghostRef.current && panelRect.current) { - const actualDelta = newWidth - resizeStartWidth.current; - ghostRef.current.style.left = `${panelRect.current.left - actualDelta}px`; - } - }); - }; - const handleMouseUp = () => { - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - setIsResizing(false); - // 拖拽结束时才提交最终宽度到 React state 和外层回调 - setPanelWidth(dragWidthRef.current); - onWidthChange?.(dragWidthRef.current); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - // 拖拽期间关闭指针事件以避免下方 Monaco Editor 捕获 hover 或重绘,极大提升性能 - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - document.body.style.pointerEvents = 'none'; // 关键性能优化 - - return () => { - if (animationFrameId) cancelAnimationFrame(animationFrameId); - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - document.body.style.pointerEvents = ''; - }; - }, [isResizing, isV2Ui, onWidthChange]); - - // 回推幽灵上下文:基于 get_tables 记录进行表级精确匹配(useMemo 缓存,避免每帧重算) - const { inferredConnectionId, inferredDbName } = useMemo(() => { - let connId = activeContext?.connectionId; - let dbName = activeContext?.dbName; - - if (!connId || !dbName) { - const allMsgText = messages.map(m => m.content || '').join(' '); - let bestMatch: { connectionId: string; dbName: string } | null = null; - let bestScore = 0; - for (const entry of toolContextMapRef.current.values()) { - let score = 0; - for (const table of entry.tables) { - if (allMsgText.includes(table)) score++; - } - if (score > bestScore) { - bestScore = score; - bestMatch = { connectionId: entry.connectionId, dbName: entry.dbName }; - } - } - if (bestMatch) { - if (!connId) connId = bestMatch.connectionId; - if (!dbName) dbName = bestMatch.dbName; - } - } - return { inferredConnectionId: connId, inferredDbName: dbName }; - }, [activeContext?.connectionId, activeContext?.dbName, messages.length]); - - // useMemo 缓存:避免内联闭包击穿子组件 memo const handleDeleteMessage = useCallback((id: string) => deleteAIChatMessage(sid, id), [sid, deleteAIChatMessage]); const handleMessageRenderError = useCallback((error: Error, errorInfo: React.ErrorInfo, msg: AIChatMessage) => { console.error('[AI Message Render Error]', msg.id, error, errorInfo); + const renderErrorPayload = { + messageId: msg.id, + role: msg.role, + contentPreview: String(msg.content || '').slice(0, 240), + message: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack, + recordedAt: Date.now(), + }; if (typeof window !== 'undefined') { - (window as any).__gonaviLastAIMessageRenderError = { - messageId: msg.id, - role: msg.role, - contentPreview: String(msg.content || '').slice(0, 240), - message: error.message, - stack: error.stack, - componentStack: errorInfo.componentStack, - }; + (window as any).__gonaviLastAIMessageRenderError = renderErrorPayload; } + (globalThis as any).__gonaviLastAIMessageRenderError = renderErrorPayload; }, []); + const currentSessionTitle = useMemo( + () => orderedAISessions.find((session) => session.id === sid)?.title || t('ai_chat.panel.session.default_title'), + [orderedAISessions, sid, t], + ); const activeConnectionConfig = useMemo(() => { if (!inferredConnectionId) return undefined; - const connection = connections.find(c => c.id === inferredConnectionId); + const connection = connections.find(item => item.id === inferredConnectionId); return connection ? buildRpcConnectionConfig(connection.config) : undefined; }, [inferredConnectionId, connections]); - const contextUsageChars = useMemo(() => - messages.reduce((sum, m) => sum + (m.content?.length || 0) + (m.reasoning_content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0), - [messages]); - const contextTableNames = useMemo(() => { - const ck = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default'; - return (aiContexts[ck] || []).map(c => `${c.dbName}.${c.tableName}`); - }, [activeContext?.connectionId, activeContext?.dbName, aiContexts]); + const contextUsageChars = useMemo( + () => calculateAIContextUsageChars(messages), + [messages], + ); + const contextTableNames = useMemo( + () => collectAIChatContextTableNames({ + aiContexts, + activeConnectionId: activeContext?.connectionId, + activeDbName: activeContext?.dbName, + }), + [activeContext?.connectionId, activeContext?.dbName, aiContexts], + ); const aiInsights = useMemo(() => { const recentLogs = sqlLogs.slice(0, 24); const slowest = recentLogs .filter((log) => log.status === 'success') - .sort((a, b) => b.duration - a.duration)[0]; + .sort((left, right) => right.duration - left.duration)[0]; const errors = recentLogs.filter((log) => log.status === 'error'); const writeCount = recentLogs.filter((log) => /\b(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE)\b/i.test(log.sql)).length; const contextCount = contextTableNames.length; const tableSeparator = t('ai_chat.panel.insight.context.table_separator'); const tablePreview = `${contextTableNames.slice(0, 3).join(tableSeparator)}${contextCount > 3 ? t('ai_chat.panel.insight.context.more_tables_suffix') : ''}`; + return [ { - tone: 'info', - title: contextCount > 0 ? t('ai_chat.panel.insight.context.linked_title', { count: contextCount }) : t('ai_chat.panel.insight.context.empty_title'), + tone: 'info' as const, + title: contextCount > 0 + ? t('ai_chat.panel.insight.context.linked_title', { count: contextCount }) + : t('ai_chat.panel.insight.context.empty_title'), body: contextCount > 0 ? t('ai_chat.panel.insight.context.linked_body', { tables: tablePreview }) : t('ai_chat.panel.insight.context.empty_body'), }, { - tone: slowest && slowest.duration > 1000 ? 'warn' : 'accent', - title: slowest ? t('ai_chat.panel.insight.query.slowest_title', { duration: Math.round(slowest.duration).toLocaleString() }) : t('ai_chat.panel.insight.query.empty_title'), + tone: slowest && slowest.duration > 1000 ? 'warn' as const : 'accent' as const, + title: slowest + ? t('ai_chat.panel.insight.query.slowest_title', { duration: Math.round(slowest.duration).toLocaleString() }) + : t('ai_chat.panel.insight.query.empty_title'), body: slowest ? slowest.sql.slice(0, 140) : t('ai_chat.panel.insight.query.empty_body'), }, { - tone: errors.length > 0 ? 'warn' : 'info', - title: errors.length > 0 ? t('ai_chat.panel.insight.status.failed_title', { count: errors.length }) : t('ai_chat.panel.insight.status.ok_title'), - body: errors[0]?.message || (recentLogs.length > 0 ? t('ai_chat.panel.insight.status.recent_body', { count: recentLogs.length }) : t('ai_chat.panel.insight.status.empty_body')), + tone: errors.length > 0 ? 'warn' as const : 'info' as const, + title: errors.length > 0 + ? t('ai_chat.panel.insight.status.failed_title', { count: errors.length }) + : t('ai_chat.panel.insight.status.ok_title'), + body: errors[0]?.message || ( + recentLogs.length > 0 + ? t('ai_chat.panel.insight.status.recent_body', { count: recentLogs.length }) + : t('ai_chat.panel.insight.status.empty_body') + ), }, { - tone: writeCount > 0 ? 'warn' : 'accent', - title: writeCount > 0 ? t('ai_chat.panel.insight.write.detected_title', { count: writeCount }) : t('ai_chat.panel.insight.write.readonly_title'), - body: writeCount > 0 ? t('ai_chat.panel.insight.write.detected_body') : t('ai_chat.panel.insight.write.readonly_body'), + tone: writeCount > 0 ? 'warn' as const : 'accent' as const, + title: writeCount > 0 + ? t('ai_chat.panel.insight.write.detected_title', { count: writeCount }) + : t('ai_chat.panel.insight.write.readonly_title'), + body: writeCount > 0 + ? t('ai_chat.panel.insight.write.detected_body') + : t('ai_chat.panel.insight.write.readonly_body'), }, ]; }, [contextTableNames, sqlLogs, t]); + const panelHistorySessions = useMemo( + () => buildAIChatInlineHistorySessions( + orderedAISessions.map((session) => ({ + ...session, + title: session.title || t('ai_chat.panel.session.default_title'), + })), + ), + [orderedAISessions, t], + ); + const effectivePanelMode = useMemo( + () => resolveAIChatPanelMode(isV2Ui, activePanelMode), + [activePanelMode, isV2Ui], + ); - const renderPanelHistoryList = () => { - const sessions = aiChatSessions.slice(0, 8); - if (sessions.length === 0) { - return
{t('ai_chat.panel.history.empty')}
; - } - return sessions.map((session) => ( - - )); - }; - const effectivePanelMode = isV2Ui ? activePanelMode : 'chat'; + const handleComposerActionWithNoticeReset = useCallback((actionKey: 'open-settings' | 'reload-models') => { + setComposerNoticeState(null); + handleComposerAction(actionKey); + }, [handleComposerAction]); + + const handleModelChangeWithNoticeReset = useCallback((model: string) => { + setComposerNoticeState(null); + void handleModelChange(model); + }, [handleModelChange]); return (
- + {isResizing && panelRect.current && createPortal( -
= ({ createNewAISession(); setActivePanelMode('chat'); }} - onSettingsClick={() => { onOpenSettings?.(); setTimeout(loadActiveProvider, 500); }} + onSettingsClick={handleOpenSettingsFromPanel} onClose={onClose} - messages={messages} - sessionTitle={useStore.getState().aiChatSessions.find(s => s.id === sid)?.title || t('ai_chat.panel.session.default_title')} + sessionTitle={currentSessionTitle} activeMode={effectivePanelMode} onModeChange={(mode) => { if (!isV2Ui) return; @@ -1819,106 +673,50 @@ export const AIChatPanel: React.FC = ({ }} /> -
- {effectivePanelMode === 'chat' && ( - messages.length === 0 ? ( - { - setInput(prompt); - if (autoSend) { - // Use setTimeout to let setInput render, then trigger send - setTimeout(() => { - const el = textareaRef.current; - if (el) el.focus(); - // Dispatch a synthetic enter to trigger handleSend - // Simpler: just call handleSend directly with the prompt - }, 50); - } - }} - contextTableNames={contextTableNames} - isV2Ui={isV2Ui} - /> - ) : ( - messages.map(msg => ( - - - - )) - ) - )} - - {effectivePanelMode === 'insights' && ( -
- {aiInsights.map((item) => ( -
- - {item.tone === 'warn' ? : item.tone === 'accent' ? : } - -
- {item.title} -

{item.body}

-
-
- ))} -
- )} - - {effectivePanelMode === 'history' && ( -
- {renderPanelHistoryList()} -
- )} - - -
-
- - {showScrollBottom && ( -
{ e.currentTarget.style.transform = 'scale(1.1)'; e.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)'; }} - onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)'; }} - > - -
- )} + { + setInput(prompt); + if (autoSend) { + window.setTimeout(() => { + textareaRef.current?.focus(); + }, 50); + } + }} + onSelectSession={(sessionId) => { + setAIActiveSessionId(sessionId); + setActivePanelMode('chat'); + }} + onEditMessage={handleEditMessage} + onRetryMessage={handleRetryMessage} + onDeleteMessage={handleDeleteMessage} + onMessageRenderError={handleMessageRenderError} + onScrollBottom={scrollToMessagesBottom} + /> = ({ sendShortcutBinding={aiChatSendShortcutBinding} shortcutPlatform={activeShortcutPlatform} composerNotice={composerNotice} - onModelChange={handleModelChange} + onComposerAction={handleComposerActionWithNoticeReset} + onModelChange={handleModelChangeWithNoticeReset} onFetchModels={fetchDynamicModels} textareaRef={textareaRef} darkMode={darkMode} diff --git a/frontend/src/components/AISettingsModal.edit-password.test.tsx b/frontend/src/components/AISettingsModal.edit-password.test.tsx index 8bf7074..f5694ee 100644 --- a/frontend/src/components/AISettingsModal.edit-password.test.tsx +++ b/frontend/src/components/AISettingsModal.edit-password.test.tsx @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; const source = readFileSync(new URL('./AISettingsModal.tsx', import.meta.url), 'utf8'); const aiChatPanelCss = readFileSync(new URL('./AIChatPanel.css', import.meta.url), 'utf8'); +const providersSectionSource = readFileSync(new URL('./ai/AISettingsProvidersSection.tsx', import.meta.url), 'utf8'); describe('AISettingsModal edit password behavior', () => { it('loads editable provider details before opening the edit modal', () => { @@ -10,9 +11,63 @@ describe('AISettingsModal edit password behavior', () => { expect(source).toContain('await Service.AIGetEditableProvider(p.id)'); }); + it('loads and saves user-level custom prompts through the AI service', () => { + expect(source).toContain("callOrFallback(() => Service.AIGetUserPromptSettings?.(), EMPTY_AI_USER_PROMPT_SETTINGS)"); + expect(source).toContain('await Service?.AISaveUserPromptSettings?.(payload);'); + expect(source).toContain("window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'))"); + expect(source).toContain("import AISettingsPromptsSection from './ai/AISettingsPromptsSection';"); + expect(source).toContain(' { + expect(source).toContain('Service.AIGetMCPClientInstallStatuses?.()'); + expect(source).toContain('Service.AIGetMCPServers?.()'); + expect(source).toContain('Service.AIListMCPTools?.()'); + expect(source).toContain('Service.AIGetSkills?.()'); + expect(source).toContain("import AISettingsSkillsSection from './ai/AISettingsSkillsSection';"); + expect(source).toContain(' { + expect(source).toContain("import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';"); + expect(source).toContain("import AISettingsProvidersSection from './ai/AISettingsProvidersSection';"); + expect(source).toContain("import AISettingsSidebar, { type AISettingsSectionKey } from './ai/AISettingsSidebar';"); + expect(source).toContain("import AISettingsSafetySection from './ai/AISettingsSafetySection';"); + expect(source).toContain("import AISettingsContextSection from './ai/AISettingsContextSection';"); + expect(source).toContain(' { + expect(source).toContain('mcpClientStatuses={mcpClientStatuses}'); + expect(source).toContain('selectedMCPClient={selectedMCPClient}'); + expect(source).toContain("import { useAIMCPClientInstaller } from './ai/useAIMCPClientInstaller';"); + expect(source).toContain('} = useAIMCPClientInstaller({'); + expect(source).toContain('handleSelectMCPClient,'); + expect(source).toContain('loadMCPClientStatuses,'); + expect(source).toContain('selectedMCPClientStatus,'); + expect(source).toContain('onSelectClient={handleSelectMCPClient}'); + expect(source).toContain('onRefreshStatus={() => void loadMCPClientStatuses()}'); + expect(source).toContain('onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}'); + expect(source).toContain('onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()}'); + expect(source).toContain('onInstallSelectedClient={handleInstallSelectedMCPClient}'); + }); + + it('waits briefly for the AI service bridge before warning and removes noisy provider debug logs', () => { + expect(source).toContain('const resolveAIService = useCallback(async () => {'); + expect(source).toContain('const service = await waitForAIService();'); + expect(source).not.toContain("console.log('[AI] AIGetProviders result:'"); + expect(source).not.toContain("console.log('[AI] AIGetActiveProvider result:'"); + }); + it('keeps the prefilled api key masked by default', () => { expect(source).toContain('const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);'); - expect(source).toContain('visible: primaryPasswordVisible,'); + expect(providersSectionSource).toContain('visible: primaryPasswordVisible,'); }); it('does not render the clear helper block anymore', () => { @@ -28,9 +83,10 @@ describe('AISettingsModal edit password behavior', () => { it('keeps long ai settings toast errors wrapped within the modal body', () => { expect(aiChatPanelCss).toContain('.ai-settings-body .ant-message {'); - expect(aiChatPanelCss).toContain('width: min(100%, 720px);'); - expect(aiChatPanelCss).toContain('max-width: calc(100% - 32px);'); + expect(aiChatPanelCss).toContain('width: fit-content;'); + expect(aiChatPanelCss).toContain('max-width: min(520px, calc(100% - 32px));'); expect(aiChatPanelCss).toContain('.ai-settings-body .ant-message .ant-message-notice-content {'); + expect(aiChatPanelCss).toContain('max-width: 100%;'); expect(aiChatPanelCss).toContain('white-space: normal;'); expect(aiChatPanelCss).toContain('overflow-wrap: anywhere;'); }); diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index a1412c9..01c0077 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -1,27 +1,38 @@ -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 React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { Modal, Form, message as antdMessage } from 'antd'; +import { RobotOutlined } from '@ant-design/icons'; +import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AIMCPHTTPServerStatus, AISkillConfig } 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 { resolveProviderSecretDraft } from '../utils/providerSecretDraft'; import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { useI18n } from '../i18n/provider'; +import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry'; +import { EMPTY_MCP_CLIENT_STATUSES } from '../utils/mcpClientInstallStatus'; +import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog'; +import AISettingsMCPSection from './ai/AISettingsMCPSection'; +import type { AIMCPHTTPServerDraft } from './ai/AIMCPHTTPServerPanel'; +import AISettingsSidebar, { type AISettingsSectionKey } from './ai/AISettingsSidebar'; +import AISettingsSafetySection from './ai/AISettingsSafetySection'; +import AISettingsContextSection from './ai/AISettingsContextSection'; +import AISettingsProvidersSection from './ai/AISettingsProvidersSection'; +import AISettingsPromptsSection from './ai/AISettingsPromptsSection'; +import AISettingsSkillsSection from './ai/AISettingsSkillsSection'; +import { useAIMCPClientInstaller } from './ai/useAIMCPClientInstaller'; +import { + EMPTY_AI_USER_PROMPT_SETTINGS, + EMPTY_MCP_SERVER, + EMPTY_SKILL, + PROVIDER_PRESETS, + findPreset, + matchProviderPreset, + type ProviderPreset, + waitForAIService, +} from './ai/aiSettingsModalConfig'; interface AISettingsModalProps { open: boolean; onClose: () => void; @@ -30,56 +41,41 @@ interface AISettingsModalProps { focusProviderId?: string; } -// 预设配置:每个预设映射到后端 type(openai/anthropic/gemini/custom)并附带默认 URL 和 Model -interface ProviderPreset { - key: string; - labelKey: string; - fallbackLabel: string; - icon: React.ReactNode; - descKey: string; - fallbackDesc: string; - color: string; - backendType: AIProviderType; - fixedApiFormat?: string; - defaultBaseUrl: string; - defaultModel: string; - models: string[]; -} - -const PROVIDER_PRESETS: ProviderPreset[] = [ - { key: 'openai', labelKey: 'ai_settings.provider_preset.openai.label', fallbackLabel: 'OpenAI', icon: , descKey: 'ai_settings.provider_preset.openai.desc', fallbackDesc: 'GPT-5.4 / 5.3 series', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', models: [] }, - { key: 'deepseek', labelKey: 'ai_settings.provider_preset.deepseek.label', fallbackLabel: 'DeepSeek', icon: , descKey: 'ai_settings.provider_preset.deepseek.desc', fallbackDesc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] }, - { key: 'qwen-bailian', labelKey: 'ai_settings.provider_preset.qwen_bailian.label', fallbackLabel: 'Qwen (Bailian General)', icon: , descKey: 'ai_settings.provider_preset.qwen_bailian.desc', fallbackDesc: 'Bailian Anthropic-compatible endpoint / remote model list', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] }, - { key: 'qwen-coding-plan', labelKey: 'ai_settings.provider_preset.qwen_coding_plan.label', fallbackLabel: 'Qwen (Coding Plan)', icon: , descKey: 'ai_settings.provider_preset.qwen_coding_plan.desc', fallbackDesc: 'Claude Code CLI proxy chain / official supported model list', color: '#4f46e5', backendType: 'custom', fixedApiFormat: 'claude-cli', defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, defaultModel: '', models: QWEN_CODING_PLAN_MODELS }, - { key: 'zhipu', labelKey: 'ai_settings.provider_preset.zhipu.label', fallbackLabel: 'Zhipu GLM', icon: , descKey: 'ai_settings.provider_preset.zhipu.desc', fallbackDesc: 'GLM-5 / GLM-5-Turbo', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-4', models: [] }, - { key: 'moonshot', labelKey: 'ai_settings.provider_preset.moonshot.label', fallbackLabel: 'Kimi', icon: , descKey: 'ai_settings.provider_preset.moonshot.desc', fallbackDesc: 'Kimi K2.5 (Anthropic-compatible)', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] }, - { key: 'anthropic', labelKey: 'ai_settings.provider_preset.anthropic.label', fallbackLabel: 'Claude', icon: , descKey: 'ai_settings.provider_preset.anthropic.desc', fallbackDesc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] }, - { key: 'gemini', labelKey: 'ai_settings.provider_preset.gemini.label', fallbackLabel: 'Gemini', icon: , descKey: 'ai_settings.provider_preset.gemini.desc', fallbackDesc: 'Gemini 3.1 / 2.5 series', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: [] }, - { key: 'volcengine-ark', labelKey: 'ai_settings.provider_preset.volcengine_ark.label', fallbackLabel: 'Volcengine Ark', icon: , descKey: 'ai_settings.provider_preset.volcengine_ark.desc', fallbackDesc: 'Ark general inference / Doubao models', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] }, - { key: 'volcengine-coding', labelKey: 'ai_settings.provider_preset.volcengine_coding.label', fallbackLabel: 'Volcengine Coding Plan', icon: , descKey: 'ai_settings.provider_preset.volcengine_coding.desc', fallbackDesc: 'Ark Code / Coding Plan', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] }, - { key: 'minimax', labelKey: 'ai_settings.provider_preset.minimax.label', fallbackLabel: 'MiniMax', icon: , descKey: 'ai_settings.provider_preset.minimax.desc', fallbackDesc: 'M2.7 / M2.5 series (Anthropic-compatible)', 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', labelKey: 'ai_settings.provider_preset.ollama.label', fallbackLabel: 'Ollama', icon: , descKey: 'ai_settings.provider_preset.ollama.desc', fallbackDesc: 'Locally deployed open-source models', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] }, - { key: 'custom', labelKey: 'ai_settings.provider_preset.custom.label', fallbackLabel: 'Custom', icon: , descKey: 'ai_settings.provider_preset.custom.desc', fallbackDesc: 'Custom API endpoint', 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): ProviderPreset => { - const presetKey = resolveProviderPresetKey(provider, PROVIDER_PRESETS, 'custom'); - return findPreset(presetKey); +const DEFAULT_MCP_HTTP_SERVER_STATUS: AIMCPHTTPServerStatus = { + running: false, + addr: '127.0.0.1:8765', + path: '/mcp', + url: 'http://127.0.0.1:8765/mcp', + schemaOnly: true, + message: 'GoNavi MCP HTTP 服务未启动', }; -const SAFETY_OPTIONS: { labelKey: string; value: AISafetyLevel; descKey: string; color: string; icon: string }[] = [ - { labelKey: 'ai_settings.safety.readonly.label', value: 'readonly', descKey: 'ai_settings.safety.readonly.desc', color: '#22c55e', icon: '🔒' }, - { labelKey: 'ai_settings.safety.readwrite.label', value: 'readwrite', descKey: 'ai_settings.safety.readwrite.desc', color: '#f59e0b', icon: '⚠️' }, - { labelKey: 'ai_settings.safety.full.label', value: 'full', descKey: 'ai_settings.safety.full.desc', color: '#ef4444', icon: '🔓' }, -]; +const DEFAULT_MCP_HTTP_SERVER_DRAFT: AIMCPHTTPServerDraft = { + addr: DEFAULT_MCP_HTTP_SERVER_STATUS.addr, + path: DEFAULT_MCP_HTTP_SERVER_STATUS.path, + authorizationHeader: '', +}; -const CONTEXT_OPTIONS: { labelKey: string; value: AIContextLevel; descKey: string; icon: string }[] = [ - { labelKey: 'ai_settings.context.schema_only.label', value: 'schema_only', descKey: 'ai_settings.context.schema_only.desc', icon: '📋' }, - { labelKey: 'ai_settings.context.with_samples.label', value: 'with_samples', descKey: 'ai_settings.context.with_samples.desc', icon: '📊' }, - { labelKey: 'ai_settings.context.with_results.label', value: 'with_results', descKey: 'ai_settings.context.with_results.desc', icon: '📑' }, -]; +const buildMCPHTTPServerDraftFromStatus = ( + status: AIMCPHTTPServerStatus, + fallback: AIMCPHTTPServerDraft = DEFAULT_MCP_HTTP_SERVER_DRAFT, +): AIMCPHTTPServerDraft => ({ + addr: String(status.addr || fallback.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr).trim(), + path: String(status.path || fallback.path || DEFAULT_MCP_HTTP_SERVER_STATUS.path).trim(), + authorizationHeader: String( + status.authorizationHeader || + (status.token ? `Bearer ${status.token}` : '') || + fallback.authorizationHeader || + '', + ).trim(), +}); + +const normalizeMCPHTTPAuthorizationToken = (value: string): string => { + const trimmed = String(value || '').trim(); + if (!trimmed) return ''; + const withoutHeaderName = trimmed.replace(/^Authorization\s*:\s*/i, '').trim(); + return withoutHeaderName.replace(/^Bearer\s+/i, '').trim(); +}; const AISettingsModal: React.FC = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => { const { t } = useI18n(); @@ -87,15 +83,23 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const [activeProviderId, setActiveProviderId] = useState(''); const [safetyLevel, setSafetyLevel] = useState('readonly'); const [contextLevel, setContextLevel] = useState('schema_only'); + const [mcpServers, setMCPServers] = useState([]); + const [mcpTools, setMCPTools] = useState([]); + const [mcpHTTPServerStatus, setMCPHTTPServerStatus] = useState(DEFAULT_MCP_HTTP_SERVER_STATUS); + const [mcpHTTPServerDraft, setMCPHTTPServerDraft] = useState(DEFAULT_MCP_HTTP_SERVER_DRAFT); + const [mcpHTTPServerLoading, setMCPHTTPServerLoading] = useState(false); + const [skills, setSkills] = useState([]); const [editingProvider, setEditingProvider] = useState(null); const [isEditing, setIsEditing] = useState(false); const [loading, setLoading] = useState(false); const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [builtinPrompts, setBuiltinPrompts] = useState>({}); - const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers'); + const [userPromptSettings, setUserPromptSettings] = useState(EMPTY_AI_USER_PROMPT_SETTINGS); + const [activeSection, setActiveSection] = useState('providers'); const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false); const [form] = Form.useForm(); const modalBodyRef = useRef(null); + const missingAIServiceWarnedRef = useRef(false); // Modal 内部 toast 通知 const [messageApi, messageContextHolder] = antdMessage.useMessage({ getContainer: () => modalBodyRef.current || document.body }); @@ -104,39 +108,133 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo 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 skillRequiredToolOptions = useMemo(() => ([ + ...BUILTIN_AI_TOOL_INFO.map((tool) => ({ + label: `${tool.name} · 内置工具`, + value: tool.name, + })), + ...mcpTools.map((tool) => ({ + label: `${tool.alias} · ${tool.serverName}`, + value: tool.alias, + })), + ]), [mcpTools]); + + const resolveAIService = useCallback(async () => { + const service = await waitForAIService(); + if (service) { + missingAIServiceWarnedRef.current = false; + return service; + } + if (!missingAIServiceWarnedRef.current) { + console.warn('[AI] Service not found on window.go'); + missingAIServiceWarnedRef.current = true; + } + return null; + }, []); + + const copyTextToClipboard = useCallback(async (text: string, successMessage: string) => { + if (typeof navigator?.clipboard?.writeText !== 'function') { + throw new Error('当前环境不支持复制到剪贴板'); + } + await navigator.clipboard.writeText(text); + void messageApi.success(successMessage); + }, [messageApi]); + + const { + handleCopySelectedMCPConfigPath, + handleCopySelectedMCPLaunchCommand, + handleInstallSelectedMCPClient, + handleSelectMCPClient, + loadMCPClientStatuses, + mcpClientStatusLoading, + mcpClientStatuses, + resetMCPClientSelectionTouched, + selectedMCPClient, + selectedMCPClientCommandText, + selectedMCPClientStatus, + syncMCPClientStatuses, + } = useAIMCPClientInstaller({ + resolveAIService, + messageApi, + copyTextToClipboard, + onBeforeInstall: () => setLoading(true), + onAfterInstall: () => setLoading(false), + onConfigChanged: () => window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')), + }); 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?.() || {}, + const Service = await resolveAIService(); + if (!Service) { + return; + } + const callOrFallback = async (loader: (() => Promise) | undefined, fallback: T): Promise => { + if (typeof loader !== 'function') { + return fallback; + } + try { + return await loader(); + } catch (error) { + console.warn('[AI] settings load fallback', error); + return fallback; + } + }; + const [provRes, safeRes, ctxRes, promptsRes, userPromptsRes, mcpServersRes, mcpToolsRes, mcpHTTPServerStatusRes, skillsRes, mcpClientStatusesRes] = await Promise.all([ + callOrFallback(() => Service.AIGetProviders?.(), []), + callOrFallback(() => Service.AIGetSafetyLevel?.(), 'readonly'), + callOrFallback(() => Service.AIGetContextLevel?.(), 'schema_only'), + callOrFallback(() => Service.AIGetBuiltinPrompts?.(), {}), + callOrFallback(() => Service.AIGetUserPromptSettings?.(), EMPTY_AI_USER_PROMPT_SETTINGS), + callOrFallback(() => Service.AIGetMCPServers?.(), []), + callOrFallback(() => Service.AIListMCPTools?.(), []), + callOrFallback(() => Service.AIGetMCPHTTPServerStatus?.(), DEFAULT_MCP_HTTP_SERVER_STATUS), + callOrFallback(() => Service.AIGetSkills?.(), []), + callOrFallback(() => Service.AIGetMCPClientInstallStatuses?.(), EMPTY_MCP_CLIENT_STATUSES), ]); - 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); + if (userPromptsRes) { + setUserPromptSettings({ + ...EMPTY_AI_USER_PROMPT_SETTINGS, + ...userPromptsRes, + }); + } + if (Array.isArray(mcpServersRes)) setMCPServers(mcpServersRes); + if (Array.isArray(mcpToolsRes)) setMCPTools(mcpToolsRes); + if (mcpHTTPServerStatusRes) { + const nextStatus = { + ...DEFAULT_MCP_HTTP_SERVER_STATUS, + ...mcpHTTPServerStatusRes, + }; + setMCPHTTPServerStatus(nextStatus); + setMCPHTTPServerDraft((prev) => buildMCPHTTPServerDraftFromStatus(nextStatus, prev)); + } + if (Array.isArray(skillsRes)) setSkills(skillsRes); + if (Array.isArray(mcpClientStatusesRes)) { + syncMCPClientStatuses(mcpClientStatusesRes); + } } catch (e) { console.warn('Failed to load AI config', e); } - }, []); + }, [resolveAIService, syncMCPClientStatuses]); useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]); + useEffect(() => { + if (open) { + resetMCPClientSelectionTouched(); + } + }, [open, resetMCPClientSelectionTouched]); + useEffect(() => { if (!open || !focusProviderId) { return; @@ -252,7 +350,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo customModels: values.models, }); // 内置供应商自动使用 preset label 作为名称 - const finalName = isCustomLike ? (values.name || preset.fallbackLabel) : preset.fallbackLabel; + const finalName = isCustomLike ? (values.name || preset.label) : preset.label; const finalBaseUrl = resolvePresetBaseURL({ presetKey: values.presetKey, @@ -314,6 +412,196 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo } catch (e) { /* ignore */ } }; + const handleSaveUserPromptSettings = async () => { + try { + setLoading(true); + const Service = (window as any).go?.aiservice?.Service; + const payload = { + global: String(userPromptSettings.global || ''), + database: String(userPromptSettings.database || ''), + jvm: String(userPromptSettings.jvm || ''), + jvmDiagnostic: String(userPromptSettings.jvmDiagnostic || ''), + }; + await Service?.AISaveUserPromptSettings?.(payload); + setUserPromptSettings(payload); + void messageApi.success('自定义提示词已保存'); + window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')); + } catch (e: any) { + void messageApi.error(e?.message || '保存自定义提示词失败'); + } finally { + setLoading(false); + } + }; + + const updateMCPServerDraft = (id: string, patch: Partial) => { + setMCPServers((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item)); + }; + + const handleAddMCPServer = (seed?: Partial) => { + setMCPServers((prev) => [...prev, EMPTY_MCP_SERVER(seed)]); + }; + + const handleSaveMCPServer = async (server: AIMCPServerConfig) => { + try { + setLoading(true); + const Service = (window as any).go?.aiservice?.Service; + await Service?.AISaveMCPServer?.(server); + await loadConfig(); + void messageApi.success('MCP 服务已保存'); + window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')); + } catch (e: any) { + void messageApi.error(e?.message || '保存 MCP 服务失败'); + } finally { + setLoading(false); + } + }; + + const handleDeleteMCPServer = async (id: string) => { + try { + setLoading(true); + const Service = (window as any).go?.aiservice?.Service; + if (typeof Service?.AIDeleteMCPServer === 'function' && !String(id).startsWith('mcp-draft-')) { + await Service.AIDeleteMCPServer(id); + await loadConfig(); + window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')); + } else { + setMCPServers((prev) => prev.filter((item) => item.id !== id)); + } + void messageApi.success('MCP 服务已删除'); + } catch (e: any) { + void messageApi.error(e?.message || '删除 MCP 服务失败'); + } finally { + setLoading(false); + } + }; + + const handleTestMCPServer = async (server: AIMCPServerConfig) => { + try { + setLoading(true); + const Service = (window as any).go?.aiservice?.Service; + const res = await Service?.AITestMCPServer?.(server); + if (res?.success) { + void messageApi.success(res?.message || 'MCP 服务连接成功'); + if (typeof Service?.AIListMCPTools === 'function') { + const nextTools = await Service.AIListMCPTools(); + if (Array.isArray(nextTools)) setMCPTools(nextTools); + } else if (Array.isArray(res?.tools)) { + setMCPTools(res.tools); + } + } else { + void messageApi.error(res?.message || 'MCP 服务测试失败'); + } + } catch (e: any) { + void messageApi.error(e?.message || '测试 MCP 服务失败'); + } finally { + setLoading(false); + } + }; + + const handleToggleMCPHTTPServer = async (checked: boolean) => { + try { + setMCPHTTPServerLoading(true); + const Service = await resolveAIService(); + if (!Service) { + throw new Error('当前运行时暂不支持 MCP HTTP 服务控制'); + } + if (checked && typeof Service.AIStartMCPHTTPServer !== 'function') { + throw new Error('当前版本暂不支持启动 MCP HTTP 服务'); + } + if (!checked && typeof Service.AIStopMCPHTTPServer !== 'function') { + throw new Error('当前版本暂不支持停止 MCP HTTP 服务'); + } + const nextStatus = checked + ? await Service.AIStartMCPHTTPServer({ + addr: mcpHTTPServerDraft.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr, + path: mcpHTTPServerDraft.path || DEFAULT_MCP_HTTP_SERVER_STATUS.path, + token: normalizeMCPHTTPAuthorizationToken(mcpHTTPServerDraft.authorizationHeader), + schemaOnly: true, + }) + : await Service.AIStopMCPHTTPServer(); + if (nextStatus) { + const normalizedStatus = { + ...DEFAULT_MCP_HTTP_SERVER_STATUS, + ...nextStatus, + }; + setMCPHTTPServerStatus(normalizedStatus); + setMCPHTTPServerDraft((prev) => buildMCPHTTPServerDraftFromStatus(normalizedStatus, prev)); + } + void messageApi.success(checked ? 'GoNavi MCP HTTP 服务已启动' : 'GoNavi MCP HTTP 服务已停止'); + } catch (e: any) { + void messageApi.error(e?.message || '切换 GoNavi MCP HTTP 服务失败'); + } finally { + setMCPHTTPServerLoading(false); + } + }; + + const handleUpdateMCPHTTPServerDraft = (patch: Partial) => { + setMCPHTTPServerDraft((prev) => ({ + ...prev, + ...patch, + })); + }; + + const handleCopyMCPHTTPServerURL = async () => { + const url = String(mcpHTTPServerStatus.url || '').trim(); + if (!url) { + void messageApi.error('当前没有可复制的 MCP HTTP URL'); + return; + } + await copyTextToClipboard(url, 'MCP HTTP URL 已复制'); + }; + + const handleCopyMCPHTTPServerAuthorization = async () => { + const authorizationHeader = String(mcpHTTPServerStatus.authorizationHeader || '').trim(); + if (!authorizationHeader) { + void messageApi.error('请先启动 MCP HTTP 服务生成 Authorization Header'); + return; + } + await copyTextToClipboard(`Authorization: ${authorizationHeader}`, 'Authorization Header 已复制'); + }; + + const updateSkillDraft = (id: string, patch: Partial) => { + setSkills((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item)); + }; + + const handleAddSkill = () => { + setSkills((prev) => [...prev, EMPTY_SKILL()]); + }; + + const handleSaveSkill = async (skill: AISkillConfig) => { + try { + setLoading(true); + const Service = (window as any).go?.aiservice?.Service; + await Service?.AISaveSkill?.(skill); + await loadConfig(); + void messageApi.success('Skill 已保存'); + window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')); + } catch (e: any) { + void messageApi.error(e?.message || '保存 Skill 失败'); + } finally { + setLoading(false); + } + }; + + const handleDeleteSkill = async (id: string) => { + try { + setLoading(true); + const Service = (window as any).go?.aiservice?.Service; + if (typeof Service?.AIDeleteSkill === 'function' && !String(id).startsWith('skill-draft-')) { + await Service.AIDeleteSkill(id); + await loadConfig(); + window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')); + } else { + setSkills((prev) => prev.filter((item) => item.id !== id)); + } + void messageApi.success('Skill 已删除'); + } catch (e: any) { + void messageApi.error(e?.message || '删除 Skill 失败'); + } finally { + setLoading(false); + } + }; + const handleTestProvider = async () => { try { const values = await form.validateFields(); @@ -379,367 +667,6 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo }); }; - // ---- 字段装饰器样式 ---- - 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, - }; - const presetLabel = (preset: ProviderPreset): string => t(preset.labelKey) || preset.fallbackLabel; - const presetDesc = (preset: ProviderPreset): string => t(preset.descKey) || preset.fallbackDesc; - - // ===== Provider 列表 ===== - const renderProviderList = () => ( -
- {providers.length === 0 && ( -
- - {t('ai_settings.provider.empty.title')}
- {t('ai_settings.provider.empty.description')} -
- )} - {providers.map(p => { - const matchedPreset = matchProviderPreset(p); - const isActive = p.id === activeProviderId; - return ( -
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, - }}> -
- {matchedPreset.icon || } -
-
-
- {p.name || p.type} - {isActive && } -
-
- {presetLabel(matchedPreset)} - · - {p.model || t('ai_settings.provider.no_model')} -
-
- - -
- ); - })} - -
- ); - - // ===== Provider 编辑表单 ===== - const renderProviderForm = () => { - const presetKeyFromForm = watchedPresetKey || (editingProvider as any)?.presetKey || 'openai'; - return ( -
- {/* 顶部返回 */} -
- - - {editingProvider?.id ? t('ai_settings.provider.editor.edit_title') : t('ai_settings.provider.editor.add_title')} - -
- -
- {/* Provider 类型选择 - 卡片式 */} -
-
- {t('ai_settings.form.section.service_type')} -
- -
- {PROVIDER_PRESETS.map(pt => ( -
{ 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)'), - }}> -
- {pt.icon} -
-
-
{presetLabel(pt)}
-
{presetDesc(pt)}
-
-
- ))} -
-
- -
- - {/* 基本信息 - 仅自定义/Ollama 显示 */} - {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( -
-
- {t('ai_settings.form.section.basic')} -
- - {t('ai_settings.form.provider_name')}} name="name" rules={[{ required: true, message: t('ai_settings.form.provider_name_required') }]} style={{ marginBottom: 16 }}> - - - - {presetKeyFromForm === 'custom' && ( - {t('ai_settings.form.api_format')}} name="apiFormat" style={{ marginBottom: 16 }}> -
- {[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'claude-cli', label: 'Claude CLI' }].map(fmt => ( -
form.setFieldsValue({ apiFormat: fmt.value })} - style={{ - padding: '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} -
- ))} -
-
- )} - - {t('ai_settings.form.model_list')}} name="models" style={{ marginBottom: 0 }}> - - - - {/* 认证信息 */} -
-
- {t('ai_settings.form.section.auth_connection')} -
- {t('ai_settings.form.api_key')}} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); if (apiKey || editingProvider?.id) { return Promise.resolve(); } return Promise.reject(new Error(t('ai_settings.form.api_key_required'))); } }]} style={{ marginBottom: 16 }}> - - - - {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( - {t('ai_settings.form.api_endpoint')}} name="baseUrl" rules={[{ required: true, message: t('ai_settings.form.api_endpoint_required') }]} style={{ marginBottom: 0 }}> - } - style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} /> - - )} -
- - - - {/* 操作按钮 */} -
- - -
- -
- ); - }; - - // ===== 安全控制 ===== - const renderSafetySettings = () => ( -
-
- {t('ai_settings.safety.description')} -
- {SAFETY_OPTIONS.map(opt => { - const active = safetyLevel === opt.value; - return ( -
handleSafetyChange(opt.value)} style={{ - padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease', - border: `1.5px solid ${active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.selectedText) : cardBorder}`, - background: active ? (opt.color === '#ef4444' ? `${opt.color}15` : overlayTheme.selectedBg) : cardBg, - display: 'flex', alignItems: 'flex-start', gap: 14, - }}> -
- {opt.icon} -
-
-
- {t(opt.labelKey)} - {active && } -
-
{t(opt.descKey)}
-
-
- ); - })} -
- ); - - // ===== 上下文级别 ===== - const renderContextSettings = () => ( -
-
- {t('ai_settings.context.description')} -
- {CONTEXT_OPTIONS.map(opt => { - const active = contextLevel === opt.value; - return ( -
handleContextChange(opt.value)} style={{ - padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease', - border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`, - background: active ? overlayTheme.selectedBg : cardBg, - display: 'flex', alignItems: 'flex-start', gap: 14, - }}> -
- {opt.icon} -
-
-
- {t(opt.labelKey)} - {active && } -
-
{t(opt.descKey)}
-
-
- ); - })} -
- ); - - const renderBuiltinPrompts = () => ( -
-
- {t('ai_settings.prompts.description')} -
- {Object.entries(builtinPrompts).map(([title, promptText]) => ( -
-
- {title} -
-
- {promptText} -
-
- ))} -
- ); - - const BUILTIN_TOOLS_INFO = [ - { name: 'get_connections', icon: '🔗', descKey: 'ai_settings.tools.get_connections.desc', detailKey: 'ai_settings.tools.get_connections.detail', params: t('ai_settings.tools.params.none') }, - { name: 'get_databases', icon: '🗄️', descKey: 'ai_settings.tools.get_databases.desc', detailKey: 'ai_settings.tools.get_databases.detail', params: 'connectionId' }, - { name: 'get_tables', icon: '📋', descKey: 'ai_settings.tools.get_tables.desc', detailKey: 'ai_settings.tools.get_tables.detail', params: 'connectionId, dbName' }, - { name: 'get_columns', icon: '🔍', descKey: 'ai_settings.tools.get_columns.desc', detailKey: 'ai_settings.tools.get_columns.detail', params: 'connectionId, dbName, tableName' }, - { name: 'get_table_ddl', icon: '📝', descKey: 'ai_settings.tools.get_table_ddl.desc', detailKey: 'ai_settings.tools.get_table_ddl.detail', params: 'connectionId, dbName, tableName' }, - { name: 'execute_sql', icon: '▶️', descKey: 'ai_settings.tools.execute_sql.desc', detailKey: 'ai_settings.tools.execute_sql.detail', params: 'connectionId, dbName, sql' }, - ]; - - const renderBuiltinTools = () => ( -
-
- {t('ai_settings.tools.description')} -
-
- {t('ai_settings.tools.workflow')} -
- {BUILTIN_TOOLS_INFO.map(tool => ( -
-
- {tool.icon} -
-
- {tool.name} -
-
{t(tool.descKey)}
-
-
-
- {t(tool.detailKey)} -
-
- - {t('ai_settings.tools.params_label')} - - {tool.params} - -
-
- ))} -
- ); - const modalShellStyle = { background: overlayTheme.shellBg, border: overlayTheme.shellBorder, boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter, @@ -775,54 +702,138 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo >
{messageContextHolder} -
-
{t('ai_settings.nav.title')}
-
- {[ - { key: 'providers', title: t('ai_settings.nav.providers.title'), description: t('ai_settings.nav.providers.description'), icon: }, - { key: 'safety', title: t('ai_settings.nav.safety.title'), description: t('ai_settings.nav.safety.description'), icon: }, - { key: 'context', title: t('ai_settings.nav.context.title'), description: t('ai_settings.nav.context.description'), icon: }, - { key: 'tools', title: t('ai_settings.nav.tools.title'), description: t('ai_settings.nav.tools.description'), icon: }, - { key: 'prompts', title: t('ai_settings.nav.prompts.title'), description: t('ai_settings.nav.prompts.description'), icon: }, - ].map((item) => { - const active = activeSection === item.key; - return ( - - ); - })} -
-
+
- {activeSection === 'providers' && (isEditing ? renderProviderForm() : renderProviderList())} - {activeSection === 'safety' && renderSafetySettings()} - {activeSection === 'context' && renderContextSettings()} - {activeSection === 'tools' && renderBuiltinTools()} - {activeSection === 'prompts' && renderBuiltinPrompts()} + {activeSection === 'providers' && ( + + )} + {activeSection === 'safety' && ( + + )} + {activeSection === 'context' && ( + + )} + {activeSection === 'mcp' && ( + void handleCopyMCPHTTPServerURL()} + onCopyHTTPServerAuthorization={() => void handleCopyMCPHTTPServerAuthorization()} + onSelectClient={handleSelectMCPClient} + onRefreshStatus={() => void loadMCPClientStatuses()} + onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()} + onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()} + onInstallSelectedClient={handleInstallSelectedMCPClient} + onAddServer={handleAddMCPServer} + onUpdateServerDraft={updateMCPServerDraft} + onTestServer={handleTestMCPServer} + onSaveServer={handleSaveMCPServer} + onDeleteServer={handleDeleteMCPServer} + /> + )} + {activeSection === 'skills' && ( + + )} + {activeSection === 'tools' && ( + + )} + {activeSection === 'prompts' && ( + setUserPromptSettings((prev) => ({ + ...prev, + [key]: value, + }))} + onSave={handleSaveUserPromptSettings} + /> + )}
@@ -830,6 +841,3 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo }; export default AISettingsModal; - - - diff --git a/frontend/src/components/ConnectionModal.edit-password.test.tsx b/frontend/src/components/ConnectionModal.edit-password.test.tsx index 615d678..02418f4 100644 --- a/frontend/src/components/ConnectionModal.edit-password.test.tsx +++ b/frontend/src/components/ConnectionModal.edit-password.test.tsx @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest'; import { readFileSync } from 'node:fs'; -const source = readFileSync(new URL('./ConnectionModal.tsx', import.meta.url), 'utf8'); +const connectionModalSource = readFileSync(new URL('./ConnectionModal.tsx', import.meta.url), 'utf8'); +const redisSectionsSource = readFileSync(new URL('./ConnectionModalRedisSections.tsx', import.meta.url), 'utf8'); +const mongoSectionsSource = readFileSync(new URL('./ConnectionModalMongoSections.tsx', import.meta.url), 'utf8'); +const connectionTypeCatalogSource = readFileSync(new URL('../utils/connectionTypeCatalog.ts', import.meta.url), 'utf8'); +const connectionTypeCapabilitiesSource = readFileSync(new URL('../utils/connectionTypeCapabilities.ts', import.meta.url), 'utf8'); +const source = `${connectionModalSource}\n${redisSectionsSource}\n${mongoSectionsSource}\n${connectionTypeCatalogSource}\n${connectionTypeCapabilitiesSource}`; describe('ConnectionModal edit password behavior', () => { it('keeps the prefilled primary password masked by default', () => { @@ -36,3 +41,189 @@ describe('ConnectionModal edit password behavior', () => { ); }); }); + +describe('ConnectionModal data source registry', () => { + it('exposes Elasticsearch in the create-connection picker with HTTP defaults', () => { + expect(source).toContain("case 'elasticsearch':"); + expect(source).toContain('return 9200;'); + expect(source).toContain('elasticsearch: ["http", "https"]'); + expect(source).toContain("key: 'elasticsearch'"); + expect(source).toContain("name: 'Elasticsearch'"); + expect(source).toContain('icon: getDbIcon(item.key, undefined, 36)'); + expect(source).toContain('type === "elasticsearch"'); + expect(source).toContain("return '支持索引浏览、Mapping 检查、JSON DSL 和 query_string 查询';"); + expect(source).toContain('const PRIMARY_USERNAME_OPTIONAL_TYPES = new Set(['); + expect(source).toContain('"mqtt",'); + expect(source).toContain( + 'type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "rocketmq" || type === "mqtt" || type === "kafka" || type === "rabbitmq") ? "" : "root";', + ); + expect(source).toContain('PRIMARY_USERNAME_OPTIONAL_TYPES.has(dbType)'); + expect(source).toContain('label="显示数据库 (留空显示全部)"'); + }); + + it('keeps MQTT username optional during test-connection validation', () => { + expect(source).toContain('"mqtt",'); + expect(source).toContain('PRIMARY_USERNAME_OPTIONAL_TYPES.has(dbType)'); + expect(source).toContain(': [createUriAwareRequiredRule("请输入用户名")]'); + expect(source).toContain('? "未开启认证可留空"'); + }); + + it('exposes Chroma in the create-connection picker with vector defaults', () => { + expect(source).toContain("case 'chroma':"); + expect(source).toContain('return 8000;'); + expect(source).toContain('chroma: ["http", "https", "chroma"]'); + expect(source).toContain("key: 'chroma'"); + expect(source).toContain("name: 'Chroma'"); + expect(source).toContain('type === "chroma"'); + expect(source).toContain("return 'Collection 浏览、向量检索和元数据过滤';"); + expect(source).toContain('return "http://127.0.0.1:8000/default_database?tenant=default_tenant";'); + expect(source).toContain('return "tenant=default_tenant&apiKey=...";'); + }); + + it('exposes Qdrant in the create-connection picker with vector defaults', () => { + expect(source).toContain("case 'qdrant':"); + expect(source).toContain('return 6333;'); + expect(source).toContain('qdrant: ["http", "https", "qdrant"]'); + expect(source).toContain("key: 'qdrant'"); + expect(source).toContain("name: 'Qdrant'"); + expect(source).toContain('type === "qdrant"'); + expect(source).toContain("return 'Collection 浏览、向量搜索和 Payload 过滤';"); + expect(source).toContain('return "http://127.0.0.1:6333";'); + expect(source).toContain('return "apiKey=...";'); + }); + + it('exposes Apache IoTDB in the create-connection picker with timeseries defaults', () => { + expect(source).toContain("case 'iotdb':"); + expect(source).toContain('return 6667;'); + expect(source).toContain('iotdb: ["iotdb"]'); + expect(source).toContain("key: 'iotdb'"); + expect(source).toContain("name: 'Apache IoTDB'"); + expect(source).toContain('dbType === "iotdb"'); + expect(source).toContain("return 'Storage Group / Device / Timeseries';"); + expect(source).toContain('return "iotdb://root:root@127.0.0.1:6667/root.sg";'); + expect(source).toContain('return "fetchSize=1024&timeZone=Asia%2FShanghai";'); + }); + + it('exposes RocketMQ in the create-connection picker with nameserver and topic defaults', () => { + expect(source).toContain("case 'rocketmq':"); + expect(source).toContain('return 9876;'); + expect(source).toContain('rocketmq: ["rocketmq", "rmq"]'); + expect(source).toContain("key: 'rocketmq'"); + expect(source).toContain("name: 'RocketMQ'"); + expect(source).toContain('dbType === "rocketmq"'); + expect(source).toContain("return 'NameServer / Topic / Consumer Group';"); + expect(source).toContain('return "rocketmq://accessKey:secretKey@127.0.0.1:9876,127.0.0.2:9876/orders.events?topology=cluster&groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest";'); + expect(source).toContain('return "groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest";'); + expect(source).toContain('label="默认 Topic(可选)"'); + expect(source).toContain('label={dbType === "rocketmq" ? "Access Key" : "用户名"}'); + expect(source).toContain('label={dbType === "rocketmq" ? "Secret Key" : "密码"}'); + expect(source).toContain('emptyPlaceholder: dbType === "rocketmq" ? "未开启认证可留空" : "密码"'); + expect(source).toContain('retainedLabel: dbType === "rocketmq" ? "已保存 Secret Key" : "已保存密码"'); + }); + + it('exposes MQTT in the create-connection picker with broker and topic-filter defaults', () => { + expect(source).toContain("case 'mqtt':"); + expect(source).toContain('return 1883;'); + expect(source).toContain('mqtt: ["mqtt", "mqtts", "tcp", "ssl", "tls"]'); + expect(source).toContain("key: 'mqtt'"); + expect(source).toContain("name: 'MQTT'"); + expect(source).toContain('dbType === "mqtt"'); + expect(source).toContain("return 'Broker / Topic Filter / QoS';"); + expect(source).toContain('return "mqtt://user:pass@127.0.0.1:1883/devices%2F%2B%2Ftelemetry?topology=cluster&clientId=gonavi-desktop&qos=1";'); + expect(source).toContain('return "topics=devices%2F%2B%2Ftelemetry,%24SYS%2F%23&clientId=gonavi-desktop&qos=1&cleanSession=true&fetchWaitMs=4000";'); + expect(source).toContain('label="默认 Topic / Filter(可选)"'); + }); + + it('exposes Kafka in the create-connection picker with broker and topic defaults', () => { + expect(source).toContain("case 'kafka':"); + expect(source).toContain('return 9092;'); + expect(source).toContain("key: 'kafka'"); + expect(source).toContain("name: 'Kafka'"); + expect(source).toContain('dbType === "kafka"'); + expect(source).toContain("return 'Broker / Topic / Consumer Group';"); + expect(source).toContain('return "kafka://user:pass@127.0.0.1:9092,127.0.0.2:9092/orders.events?topology=cluster&groupId=analytics&mechanism=scram-sha-256";'); + expect(source).toContain('return "groupId=gonavi&mechanism=scram-sha-256&clientId=gonavi-desktop&startOffset=latest";'); + expect(source).toContain('label="默认 Topic(可选)"'); + }); + + it('exposes RabbitMQ in the create-connection picker with management-api and vhost defaults', () => { + expect(source).toContain("case 'rabbitmq':"); + expect(source).toContain('return 15672;'); + expect(source).toContain('rabbitmq: ["rabbitmq", "http", "https"]'); + expect(source).toContain("key: 'rabbitmq'"); + expect(source).toContain("name: 'RabbitMQ'"); + expect(source).toContain('dbType === "rabbitmq"'); + expect(source).toContain("return 'Management API / Virtual Host / Queue';"); + expect(source).toContain('return "rabbitmq://guest:guest@127.0.0.1:15672/%2F?defaultQueue=orders.queue&exchange=events.topic&timeout=30";'); + expect(source).toContain('return "defaultQueue=orders.queue&exchange=events.topic&managementPathPrefix=/rabbitmq";'); + expect(source).toContain('label="默认 Virtual Host(可选)"'); + }); + + it('exposes GaussDB in the create-connection picker with PostgreSQL-family defaults', () => { + expect(source).toContain("case 'gaussdb':"); + expect(source).toContain('return 5432;'); + expect(source).toContain('gaussdb: ["gaussdb", "postgresql", "postgres"]'); + expect(source).toContain("key: 'gaussdb'"); + expect(source).toContain("name: 'GaussDB'"); + expect(source).toContain('type === "gaussdb"'); + expect(source).toContain('return "gaussdb://user:pass@127.0.0.1:5432/db_name";'); + expect(source).toContain('return "application_name=GoNavi&statement_timeout=30000";'); + expect(source).toContain('? "gaussdb"'); + expect(source).toContain('dbType === "gaussdb"'); + }); + + it('exposes GoldenDB in the create-connection picker with MySQL-compatible defaults', () => { + expect(source).toContain("case 'goldendb':"); + expect(source).toContain('return 1523;'); + expect(source).toContain("key: 'goldendb'"); + expect(source).toContain("name: 'GoldenDB'"); + expect(source).toContain('type === "goldendb"'); + expect(source).toContain("return 'MySQL 兼容 / 分布式事务';"); + expect(source).toContain('dbType === "goldendb" ? "goldendb" : "mysql"'); + expect(source).toContain('type === "goldendb" ? "goldendb" : "mysql"'); + expect(source).toContain('? "goldendb"'); + }); + + it('keeps OceanBase Oracle service name optional for OBClient/MySQL-wire connections', () => { + expect(source).toContain('OceanBase Oracle 服务名 (Service Name,可选)'); + expect(source).toContain('isOceanBaseOracle\n ? []'); + expect(source).toContain('连接 OBClient/OBServer MySQL-wire 入口时可留空'); + expect(source).toContain('只有连接 OBProxy Oracle listener/TNS 入口时才需要填写 SERVICE_NAME'); + expect(source).toContain('createUriAwareRequiredRule("请输入 Oracle 服务名(例如 ORCLPDB1)")'); + expect(source).not.toContain('请输入 OceanBase Oracle 服务名'); + expect(source).not.toContain('Oracle 租户必须填写监听器注册的 SERVICE_NAME'); + }); +}); + +describe('ConnectionModal Redis Sentinel configuration', () => { + it('exposes Sentinel topology fields and safe defaults', () => { + expect(source).toContain('label: "哨兵模式"'); + expect(source).toContain('name="redisSentinelMaster"'); + expect(source).toContain('Sentinel master 名称'); + expect(source).toContain('name="redisSentinelPassword"'); + expect(source).toContain('hasRedisSentinelPassword'); + expect(source).toContain('clearKey: "redisSentinelPassword"'); + expect(source).toContain('form.setFieldValue("port", 26379)'); + expect(source).toContain('form.setFieldValue("port", 6379)'); + }); + + it('keeps the saved host as the primary Redis node when editing multi-node configs', () => { + expect(source).toContain('const savedPrimaryAddress = isFileDbConfigType'); + expect(source).toContain('savedPrimaryAddress,'); + expect(source).toContain('...(Array.isArray(config.hosts) ? config.hosts : [])'); + expect(source).toContain('const redisHosts ='); + expect(source).toContain('configType === "redis" ? normalizedHosts.slice(1) : [];'); + }); +}); + +describe('ConnectionModal MongoDB configuration', () => { + it('keeps replica, SRV, and read preference fields in the split Mongo sections', () => { + expect(source).toContain('ConnectionModalMongoSections'); + expect(source).toContain('name="mongoSrv"'); + expect(source).toContain('SRV 与 SSH 隧道同时启用'); + expect(source).toContain('name="mongoReplicaPassword"'); + expect(source).toContain('clearKey: "mongoReplicaPassword"'); + expect(source).toContain('自动发现成员'); + expect(source).toContain('fieldName: "mongoReadPreference"'); + }); +}); diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index de91219..6cc9dca 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -65,6 +65,32 @@ import { mergeParsedUriValuesForForm } from "../utils/connectionUriMerge"; import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig"; import { getCustomConnectionDriverHelp } from "../utils/driverImportGuidance"; import { isBackendCancelledResult } from "../utils/connectionExport"; +import { + buildRedisUriFromValues, + parseRedisUriToFormValues, + resolveRedisConfigDraft, +} from "../utils/redisConnectionUri"; +import { + CONNECTION_TYPE_GROUPS, + getAllConnectionTypeCatalogItems, + getConnectionTypeDefaultPort as getDefaultPortByType, + getConnectionTypeHint, +} from "../utils/connectionTypeCatalog"; +import { + isFileDatabaseType, + isMySQLCompatibleType, + isPostgresCompatibleSSLType, + singleHostUriSchemesByType, + supportsConnectionParamsForType, + supportsSSLCAPathForType, + supportsSSLClientCertificateForType, + supportsSSLForType, +} from "../utils/connectionTypeCapabilities"; +import { + normalizeDriverType, + resolveConnectionDriverType, + type DriverStatusSnapshot, +} from "../utils/connectionDriverType"; import { describeUnsupportedOceanBaseProtocol, normalizeOceanBaseProtocol, @@ -92,6 +118,7 @@ import { MongoDiscoverMembers, TestConnection, RedisConnect, + RedisGetDatabases, SelectDatabaseFile, SelectCertificateFile, SelectSSHKeyFile, @@ -112,8 +139,19 @@ const MAX_URI_LENGTH = 4096; const MAX_CONNECTION_PARAMS_LENGTH = 4096; const MAX_URI_HOSTS = 32; const MAX_TIMEOUT_SECONDS = 3600; +const PRIMARY_USERNAME_OPTIONAL_TYPES = new Set([ + "mongodb", + "elasticsearch", + "chroma", + "qdrant", + "rocketmq", + "mqtt", + "kafka", + "rabbitmq", +]); const CONNECTION_MODAL_WIDTH = 960; const CONNECTION_MODAL_BODY_HEIGHT = 620; +const REDIS_DEFAULT_DATABASE_COUNT = 16; const STEP1_SIDEBAR_DIVIDER_DARK = "rgba(255, 255, 255, 0.16)"; const STEP1_SIDEBAR_DIVIDER_LIGHT = "rgba(0, 0, 0, 0.08)"; const CLICKHOUSE_PROTOCOL_OPTIONS: Array<{ @@ -132,6 +170,62 @@ const OCEANBASE_PROTOCOL_OPTIONS: Array<{ { value: "mysql", label: "MySQL" }, { value: "oracle", label: "Oracle" }, ]; + +const normalizeRedisDatabaseIndex = (value: unknown): number | null => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return null; + return Math.trunc(parsed); +}; + +const buildRedisDatabaseList = (...values: unknown[]): number[] => { + const indexes = new Set(); + for (let i = 0; i < REDIS_DEFAULT_DATABASE_COUNT; i += 1) { + indexes.add(i); + } + const collect = (value: unknown) => { + if (Array.isArray(value)) { + value.forEach(collect); + return; + } + const index = normalizeRedisDatabaseIndex(value); + if (index !== null) { + indexes.add(index); + } + }; + values.forEach(collect); + return Array.from(indexes).sort((a, b) => a - b); +}; + +const extractRedisDatabaseList = (value: unknown): number[] => { + if (!Array.isArray(value)) return []; + const indexes = new Set(); + value.forEach((row: any) => { + const index = normalizeRedisDatabaseIndex(row?.index ?? row?.Index); + if (index !== null) { + indexes.add(index); + } + }); + const result = Array.from(indexes).sort((a, b) => a - b); + return result.length > 0 ? result : buildRedisDatabaseList(); +}; + +const normalizeRedisDatabaseSelection = ( + value: unknown, + supportedDbs: number[], +): number[] | undefined => { + if (!Array.isArray(value)) return undefined; + const supported = new Set(supportedDbs); + const selected = Array.from( + new Set( + value + .map(normalizeRedisDatabaseIndex) + .filter((index): index is number => index !== null) + .filter((index) => supported.size === 0 || supported.has(index)), + ), + ).sort((a, b) => a - b); + return selected.length > 0 ? selected : undefined; +}; + const normalizeClickHouseProtocolValue = ( value: unknown, ): ClickHouseProtocolChoice => { @@ -178,6 +272,7 @@ type ConnectionSecretKey = | "httpTunnelPassword" | "mysqlReplicaPassword" | "mongoReplicaPassword" + | "redisSentinelPassword" | "opaqueURI" | "opaqueDSN"; @@ -214,6 +309,7 @@ const createEmptyConnectionSecretClearState = httpTunnelPassword: false, mysqlReplicaPassword: false, mongoReplicaPassword: false, + redisSentinelPassword: false, opaqueURI: false, opaqueDSN: false, }); @@ -240,6 +336,8 @@ const resolveInitialSecretFieldValue = ( return String(config.mysqlReplicaPassword || ""); case "mongoReplicaPassword": return String(config.mongoReplicaPassword || ""); + case "redisSentinelPassword": + return String(config.redisSentinelPassword || ""); case "uri": return String(config.uri || ""); case "dsn": @@ -249,231 +347,6 @@ const resolveInitialSecretFieldValue = ( } }; -const getDefaultPortByType = (type: string) => { - switch (type) { - case "jvm": - return 9010; - case "mysql": - return 3306; - case "oceanbase": - return 2881; - case "doris": - case "diros": - case "starrocks": - return 9030; - case "sphinx": - return 9306; - case "clickhouse": - return 9000; - case "postgres": - case "opengauss": - return 5432; - case "redis": - return 6379; - case "tdengine": - return 6041; - case "oracle": - return 1521; - case "dameng": - return 5236; - case "kingbase": - return 54321; - case "sqlserver": - return 1433; - case "iris": - return 1972; - case "mongodb": - return 27017; - case "highgo": - return 5866; - case "mariadb": - return 3306; - case "vastbase": - return 5432; - case "sqlite": - return 0; - case "duckdb": - return 0; - default: - return 3306; - } -}; - -const singleHostUriSchemesByType: Record = { - postgres: ["postgresql", "postgres"], - opengauss: ["opengauss", "jdbc:opengauss", "postgresql", "postgres"], - clickhouse: ["clickhouse"], - oracle: ["oracle"], - sqlserver: ["sqlserver"], - iris: ["iris", "intersystems"], - redis: ["redis"], - tdengine: ["tdengine"], - dameng: ["dameng", "dm"], - kingbase: ["kingbase"], - highgo: ["highgo"], - vastbase: ["vastbase"], -}; - -const sslSupportedTypes = new Set([ - "mysql", - "mariadb", - "oceanbase", - "doris", - "diros", - "starrocks", - "sphinx", - "dameng", - "clickhouse", - "postgres", - "sqlserver", - "oracle", - "kingbase", - "highgo", - "vastbase", - "opengauss", - "mongodb", - "redis", - "tdengine", -]); - -const supportsSSLForType = (type: string) => - sslSupportedTypes.has( - String(type || "") - .trim() - .toLowerCase(), - ); - -const sslCAPathSupportedTypes = new Set([ - "mysql", - "mariadb", - "oceanbase", - "diros", - "starrocks", - "sphinx", - "clickhouse", - "postgres", - "sqlserver", - "kingbase", - "highgo", - "vastbase", - "opengauss", - "mongodb", - "redis", -]); - -const sslClientCertificateSupportedTypes = new Set([ - "mysql", - "mariadb", - "oceanbase", - "diros", - "starrocks", - "sphinx", - "dameng", - "clickhouse", - "postgres", - "kingbase", - "highgo", - "vastbase", - "opengauss", - "mongodb", - "redis", -]); - -const supportsSSLCAPathForType = (type: string) => - sslCAPathSupportedTypes.has( - String(type || "") - .trim() - .toLowerCase(), - ); - -const supportsSSLClientCertificateForType = (type: string) => - sslClientCertificateSupportedTypes.has( - String(type || "") - .trim() - .toLowerCase(), - ); - -const isPostgresCompatibleSSLType = (type: string) => - [ - "postgres", - "kingbase", - "highgo", - "vastbase", - "opengauss", - ].includes( - String(type || "") - .trim() - .toLowerCase(), - ); - -const isFileDatabaseType = (type: string) => - type === "sqlite" || type === "duckdb"; - -const isMySQLCompatibleType = (type: string) => - type === "mysql" || - type === "mariadb" || - type === "oceanbase" || - type === "doris" || - type === "diros" || - type === "starrocks" || - type === "sphinx"; - -const supportsConnectionParamsForType = (type: string) => - isMySQLCompatibleType(type) || - type === "postgres" || - type === "kingbase" || - type === "highgo" || - type === "vastbase" || - type === "opengauss" || - type === "oracle" || - type === "sqlserver" || - type === "iris" || - type === "clickhouse" || - type === "mongodb" || - type === "dameng" || - type === "tdengine"; - -type DriverStatusSnapshot = { - type: string; - name: string; - connectable: boolean; - expectedRevision?: string; - needsUpdate?: boolean; - updateReason?: string; - affectedConnections?: number; - message?: string; -}; - -const normalizeDriverType = (value: string): string => { - const normalized = String(value || "") - .trim() - .toLowerCase(); - if (normalized === "postgresql") return "postgres"; - if (normalized === "doris") return "diros"; - if ( - normalized === "intersystems" || - normalized === "intersystemsiris" || - normalized === "inter-systems-iris" || - normalized === "inter-systems" - ) - return "iris"; - if ( - normalized === "open_gauss" || - normalized === "open-gauss" || - normalized === "opengauss" - ) - return "opengauss"; - return normalized; -}; - -const resolveConnectionDriverType = (type: string, driver?: string): string => { - const normalizedType = normalizeDriverType(type); - if (normalizedType !== "custom") { - return normalizedType; - } - return normalizeDriverType(driver || ""); -}; - const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; @@ -505,7 +378,7 @@ const ConnectionModal: React.FC<{ const [testResult, setTestResult] = useState(null); const [testErrorLogOpen, setTestErrorLogOpen] = useState(false); const [dbList, setDbList] = useState([]); - const [redisDbList, setRedisDbList] = useState([]); // Redis databases 0-15 + const [redisDbList, setRedisDbList] = useState([]); const [mongoMembers, setMongoMembers] = useState([]); const [discoveringMembers, setDiscoveringMembers] = useState(false); const [uriFeedback, setUriFeedback] = useState(null); @@ -541,6 +414,9 @@ const ConnectionModal: React.FC<{ ); const disableLocalBackdropFilter = isMacLikePlatform(); const mysqlTopology = Form.useWatch("mysqlTopology", form) || "single"; + const rocketmqTopology = Form.useWatch("rocketmqTopology", form) || "single"; + const mqttTopology = Form.useWatch("mqttTopology", form) || "single"; + const kafkaTopology = Form.useWatch("kafkaTopology", form) || "single"; const mongoTopology = Form.useWatch("mongoTopology", form) || "single"; const mongoSrv = Form.useWatch("mongoSrv", form) || false; const redisTopology = Form.useWatch("redisTopology", form) || "single"; @@ -574,6 +450,10 @@ const ConnectionModal: React.FC<{ ); const isOceanBaseOracle = dbType === "oceanbase" && oceanBaseProtocol === "oracle"; const isMySQLLike = isMySQLCompatibleType(dbType) && !isOceanBaseOracle; + const isRocketMQ = dbType === "rocketmq"; + const isMQTT = dbType === "mqtt"; + const isKafka = dbType === "kafka"; + const isRabbitMQ = dbType === "rabbitmq"; const supportsConnectionParams = supportsConnectionParamsForType(dbType); const isSSLType = supportsSSLForType(dbType); const supportsSSLCAPath = supportsSSLCAPathForType(dbType); @@ -944,19 +824,30 @@ const ConnectionModal: React.FC<{ setMongoMembers([]); } if (fieldName === "redisTopology") { - const supportedDbs = Array.from({ length: 16 }, (_, i) => i); + const nextRedisTopology = String(value || "single").toLowerCase(); + const currentRedisPort = Number(form.getFieldValue("port") || 0); + if ( + nextRedisTopology === "sentinel" && + (!currentRedisPort || currentRedisPort === 6379) + ) { + form.setFieldValue("port", 26379); + } else if ( + nextRedisTopology !== "sentinel" && + currentRedisPort === 26379 + ) { + form.setFieldValue("port", 6379); + } + const supportedDbs = buildRedisDatabaseList( + form.getFieldValue("redisDB"), + form.getFieldValue("includeRedisDatabases"), + ); setRedisDbList(supportedDbs); - const selectedDbsRaw = form.getFieldValue("includeRedisDatabases"); - const selectedDbs = Array.isArray(selectedDbsRaw) - ? selectedDbsRaw.map((entry: any) => Number(entry)) - : []; - const validDbs = selectedDbs - .filter((entry: number) => Number.isFinite(entry)) - .map((entry: number) => Math.trunc(entry)) - .filter((entry: number) => supportedDbs.includes(entry)); form.setFieldValue( "includeRedisDatabases", - validDbs.length > 0 ? validDbs : undefined, + normalizeRedisDatabaseSelection( + form.getFieldValue("includeRedisDatabases"), + supportedDbs, + ), ); } if (fieldName === "proxyType") { @@ -1625,6 +1516,9 @@ const ConnectionModal: React.FC<{ const mysqlDefaultPort = getDefaultPortByType(type); const parsed = parseMultiHostUri(trimmedUri, "mysql") || + parseMultiHostUri(trimmedUri, "goldendb") || + parseMultiHostUri(trimmedUri, "greatdb") || + parseMultiHostUri(trimmedUri, "gdb") || parseMultiHostUri(trimmedUri, "jdbc:mysql") || parseMultiHostUri(trimmedUri, "oceanbase") || parseMultiHostUri(trimmedUri, "jdbc:oceanbase") || @@ -1714,61 +1608,7 @@ const ConnectionModal: React.FC<{ } if (type === "redis") { - const parsed = - parseMultiHostUri(trimmedUri, "redis") || - parseMultiHostUri(trimmedUri, "rediss"); - if (!parsed) { - return null; - } - if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { - return null; - } - if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { - return null; - } - const hostList = normalizeAddressList(parsed.hosts, 6379); - if (!hostList.length) { - return null; - } - const primary = parseHostPort(hostList[0] || "localhost:6379", 6379); - const topologyParam = String( - parsed.params.get("topology") || "", - ).toLowerCase(); - const dbText = String(parsed.database || "") - .trim() - .replace(/^\//, ""); - const dbIndex = Number(dbText); - const isRediss = trimmedUri.toLowerCase().startsWith("rediss://"); - const skipVerifyText = String(parsed.params.get("skip_verify") || "") - .trim() - .toLowerCase(); - const skipVerify = - skipVerifyText === "1" || - skipVerifyText === "true" || - skipVerifyText === "yes" || - skipVerifyText === "on"; - return { - host: primary?.host || "localhost", - port: primary?.port || 6379, - user: parsed.username || "", - password: parsed.password || "", - useSSL: isRediss, - sslMode: isRediss - ? skipVerify - ? "skip-verify" - : "required" - : "disable", - ...extractSSLPathValuesFromParams(parsed.params, type), - redisTopology: - hostList.length > 1 || topologyParam === "cluster" - ? "cluster" - : "single", - redisHosts: hostList.slice(1), - redisDB: - Number.isFinite(dbIndex) && dbIndex >= 0 && dbIndex <= 15 - ? Math.trunc(dbIndex) - : 0, - }; + return parseRedisUriToFormValues(trimmedUri); } if (type === "mongodb") { @@ -1852,6 +1692,208 @@ const ConnectionModal: React.FC<{ }; } + if (type === "kafka") { + const defaultPort = getDefaultPortByType(type); + const parsed = + parseMultiHostUri(trimmedUri, "kafka") || + parseMultiHostUri(trimmedUri, "apache-kafka") || + parseMultiHostUri(trimmedUri, "apache_kafka"); + if (!parsed) { + return null; + } + if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { + return null; + } + if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { + return null; + } + const hostList = normalizeAddressList(parsed.hosts, defaultPort); + if (!hostList.length) { + return null; + } + const primary = parseHostPort( + hostList[0] || `localhost:${defaultPort}`, + defaultPort, + ); + const tlsEnabled = normalizeUriBool( + parsed.params.get("tls") || + parsed.params.get("ssl") || + parsed.params.get("useSSL") || + parsed.params.get("use_ssl"), + ); + const skipVerify = normalizeUriBool( + parsed.params.get("skip_verify") || parsed.params.get("skipVerify"), + ); + const topology = String(parsed.params.get("topology") || "") + .trim() + .toLowerCase(); + const timeoutValue = Number(parsed.params.get("timeout")); + return { + host: primary?.host || "localhost", + port: primary?.port || defaultPort, + user: parsed.username, + password: parsed.password, + database: parsed.database || "", + useSSL: tlsEnabled, + sslMode: tlsEnabled ? (skipVerify ? "skip-verify" : "required") : "disable", + ...extractSSLPathValuesFromParams(parsed.params, type), + kafkaTopology: + topology === "cluster" || hostList.length > 1 ? "cluster" : "single", + kafkaHosts: hostList.slice(1), + connectionParams: serializeConnectionParams(parsed.params), + timeout: + Number.isFinite(timeoutValue) && timeoutValue > 0 + ? Math.min(MAX_TIMEOUT_SECONDS, Math.trunc(timeoutValue)) + : undefined, + }; + } + + if (type === "mqtt") { + const defaultPort = getDefaultPortByType(type); + const parsed = + parseMultiHostUri(trimmedUri, "mqtt") || + parseMultiHostUri(trimmedUri, "mqtts") || + parseMultiHostUri(trimmedUri, "tcp") || + parseMultiHostUri(trimmedUri, "ssl") || + parseMultiHostUri(trimmedUri, "tls"); + if (!parsed) { + return null; + } + if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { + return null; + } + if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { + return null; + } + const hostList = normalizeAddressList(parsed.hosts, defaultPort); + if (!hostList.length) { + return null; + } + const primary = parseHostPort( + hostList[0] || `localhost:${defaultPort}`, + defaultPort, + ); + const lowerUri = trimmedUri.toLowerCase(); + const tlsEnabled = + lowerUri.startsWith("mqtts://") || + lowerUri.startsWith("ssl://") || + lowerUri.startsWith("tls://") || + normalizeUriBool( + parsed.params.get("tls") || + parsed.params.get("ssl") || + parsed.params.get("useSSL") || + parsed.params.get("use_ssl"), + ); + const skipVerify = normalizeUriBool( + parsed.params.get("skip_verify") || parsed.params.get("skipVerify"), + ); + const topology = String(parsed.params.get("topology") || "") + .trim() + .toLowerCase(); + const timeoutValue = Number(parsed.params.get("timeout")); + return { + host: primary?.host || "localhost", + port: primary?.port || defaultPort, + user: parsed.username, + password: parsed.password, + database: parsed.database || "", + useSSL: tlsEnabled, + sslMode: tlsEnabled ? (skipVerify ? "skip-verify" : "required") : "disable", + ...extractSSLPathValuesFromParams(parsed.params, type), + mqttTopology: + topology === "cluster" || hostList.length > 1 ? "cluster" : "single", + mqttHosts: hostList.slice(1), + connectionParams: serializeConnectionParams(parsed.params), + timeout: + Number.isFinite(timeoutValue) && timeoutValue > 0 + ? Math.min(MAX_TIMEOUT_SECONDS, Math.trunc(timeoutValue)) + : undefined, + }; + } + + if (type === "rocketmq") { + const defaultPort = getDefaultPortByType(type); + const parsed = + parseMultiHostUri(trimmedUri, "rocketmq") || + parseMultiHostUri(trimmedUri, "rmq"); + if (!parsed) { + return null; + } + if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { + return null; + } + if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { + return null; + } + const hostList = normalizeAddressList(parsed.hosts, defaultPort); + if (!hostList.length) { + return null; + } + const primary = parseHostPort( + hostList[0] || `localhost:${defaultPort}`, + defaultPort, + ); + const topology = String(parsed.params.get("topology") || "") + .trim() + .toLowerCase(); + const timeoutValue = Number(parsed.params.get("timeout")); + return { + host: primary?.host || "localhost", + port: primary?.port || defaultPort, + user: parsed.username, + password: parsed.password, + database: parsed.database || "", + rocketmqTopology: + topology === "cluster" || hostList.length > 1 ? "cluster" : "single", + rocketmqHosts: hostList.slice(1), + connectionParams: serializeConnectionParams(parsed.params), + timeout: + Number.isFinite(timeoutValue) && timeoutValue > 0 + ? Math.min(MAX_TIMEOUT_SECONDS, Math.trunc(timeoutValue)) + : undefined, + }; + } + + if (type === "rabbitmq") { + const defaultPort = getDefaultPortByType(type); + const parsed = parseSingleHostUri( + trimmedUri, + ["rabbitmq", "http", "https"], + defaultPort, + ); + if (!parsed) { + return null; + } + const lowerUri = trimmedUri.toLowerCase(); + const tlsEnabled = + lowerUri.startsWith("https://") || + normalizeUriBool( + parsed.params.get("tls") || + parsed.params.get("ssl") || + parsed.params.get("useSSL") || + parsed.params.get("use_ssl"), + ); + const skipVerify = normalizeUriBool( + parsed.params.get("skip_verify") || parsed.params.get("skipVerify"), + ); + const timeoutValue = Number(parsed.params.get("timeout")); + return { + host: parsed.host, + port: parsed.port, + user: parsed.username, + password: parsed.password, + database: parsed.database || "", + useSSL: tlsEnabled, + sslMode: tlsEnabled ? (skipVerify ? "skip-verify" : "required") : "disable", + ...extractSSLPathValuesFromParams(parsed.params, type), + connectionParams: serializeConnectionParams(parsed.params), + timeout: + Number.isFinite(timeoutValue) && timeoutValue > 0 + ? Math.min(MAX_TIMEOUT_SECONDS, Math.trunc(timeoutValue)) + : undefined, + }; + } + if (type === "clickhouse") { const httpValues = parseClickHouseHTTPUriToValues(trimmedUri); if (httpValues) { @@ -1899,7 +1941,8 @@ const ConnectionModal: React.FC<{ type === "kingbase" || type === "highgo" || type === "vastbase" || - type === "opengauss" + type === "opengauss" || + type === "gaussdb" ) { const sslMode = String(parsed.params.get("sslmode") || "") .trim() @@ -2012,6 +2055,22 @@ const ConnectionModal: React.FC<{ parsedValues.useSSL = false; parsedValues.sslMode = "disable"; } + } else if (type === "chroma" || type === "qdrant") { + const tls = String( + parsed.params.get("tls") || + parsed.params.get("ssl") || + parsed.params.get("useSSL") || + parsed.params.get("use_ssl") || + "", + ) + .trim() + .toLowerCase(); + const skipVerify = normalizeBool( + parsed.params.get("skip_verify") || parsed.params.get("skipVerify"), + ); + const enabled = tls ? normalizeBool(tls) : trimmedUri.toLowerCase().startsWith("https://"); + parsedValues.useSSL = enabled; + parsedValues.sslMode = enabled ? (skipVerify ? "skip-verify" : "required") : "disable"; } } return parsedValues; @@ -2057,9 +2116,9 @@ const ConnectionModal: React.FC<{ if (isMySQLCompatibleType(dbType)) { const defaultPort = getDefaultPortByType(dbType); const scheme = - dbType === "diros" ? "doris" : dbType === "starrocks" ? "starrocks" : dbType === "oceanbase" ? "oceanbase" : "mysql"; + dbType === "diros" ? "doris" : dbType === "starrocks" ? "starrocks" : dbType === "oceanbase" ? "oceanbase" : dbType === "goldendb" ? "goldendb" : "mysql"; if (dbType === "oceanbase") { - return `${scheme}://sys%40oracle001:pass@127.0.0.1:${defaultPort}/SERVICE_NAME?protocol=oracle`; + return `${scheme}://sys%40oracle001:pass@127.0.0.1:${defaultPort}?protocol=oracle`; } return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`; } @@ -2074,8 +2133,29 @@ const ConnectionModal: React.FC<{ if (dbType === "clickhouse") { return "clickhouse://default:pass@127.0.0.1:9000/default"; } + if (dbType === "chroma") { + return "http://127.0.0.1:8000/default_database?tenant=default_tenant"; + } + if (dbType === "qdrant") { + return "http://127.0.0.1:6333"; + } + if (dbType === "iotdb") { + return "iotdb://root:root@127.0.0.1:6667/root.sg"; + } + if (dbType === "rocketmq") { + return "rocketmq://accessKey:secretKey@127.0.0.1:9876,127.0.0.2:9876/orders.events?topology=cluster&groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest"; + } + if (dbType === "mqtt") { + return "mqtt://user:pass@127.0.0.1:1883/devices%2F%2B%2Ftelemetry?topology=cluster&clientId=gonavi-desktop&qos=1"; + } + if (dbType === "kafka") { + return "kafka://user:pass@127.0.0.1:9092,127.0.0.2:9092/orders.events?topology=cluster&groupId=analytics&mechanism=scram-sha-256"; + } + if (dbType === "rabbitmq") { + return "rabbitmq://guest:guest@127.0.0.1:15672/%2F?defaultQueue=orders.queue&exchange=events.topic&timeout=30"; + } if (dbType === "redis") { - return "redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster"; + return "redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster 或 redis://:pass@10.0.0.1:26379,10.0.0.2:26379/0?topology=sentinel&master=mymaster"; } if (dbType === "oracle") { return "oracle://user:pass@127.0.0.1:1521/ORCLPDB1"; @@ -2086,6 +2166,9 @@ const ConnectionModal: React.FC<{ if (dbType === "opengauss") { return "opengauss://user:pass@127.0.0.1:5432/db_name"; } + if (dbType === "gaussdb") { + return "gaussdb://user:pass@127.0.0.1:5432/db_name"; + } return t("connection.modal.example", { value: "postgres://user:pass@127.0.0.1:5432/db_name", }); @@ -2106,6 +2189,7 @@ const ConnectionModal: React.FC<{ case "highgo": case "vastbase": case "opengauss": + case "gaussdb": return "application_name=GoNavi&statement_timeout=30000"; case "oracle": return "PREFETCH_ROWS=5000&TRACE FILE=/tmp/go-ora.trc"; @@ -2117,10 +2201,24 @@ const ConnectionModal: React.FC<{ return "max_execution_time=60&compress=lz4"; case "mongodb": return "retryWrites=true&readPreference=secondaryPreferred"; + case "chroma": + return "tenant=default_tenant&apiKey=..."; + case "qdrant": + return "apiKey=..."; case "dameng": return "schema=SYSDBA"; case "tdengine": return "timezone=Asia%2FShanghai"; + case "iotdb": + return "fetchSize=1024&timeZone=Asia%2FShanghai"; + case "rocketmq": + return "groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest"; + case "mqtt": + return "topics=devices%2F%2B%2Ftelemetry,%24SYS%2F%23&clientId=gonavi-desktop&qos=1&cleanSession=true&fetchWaitMs=4000"; + case "kafka": + return "groupId=gonavi&mechanism=scram-sha-256&clientId=gonavi-desktop&startOffset=latest"; + case "rabbitmq": + return "defaultQueue=orders.queue&exchange=events.topic&managementPathPrefix=/rabbitmq"; default: return "key=value&another=value"; } @@ -2179,48 +2277,114 @@ const ConnectionModal: React.FC<{ const dbPath = database ? `/${encodeURIComponent(database)}` : "/"; const query = params.toString(); const scheme = - type === "diros" ? "doris" : type === "starrocks" ? "starrocks" : type === "oceanbase" ? "oceanbase" : "mysql"; + type === "diros" ? "doris" : type === "starrocks" ? "starrocks" : type === "oceanbase" ? "oceanbase" : type === "goldendb" ? "goldendb" : "mysql"; return `${scheme}://${encodedAuth}${hosts.join(",")}${dbPath}${query ? `?${query}` : ""}`; } - if (type === "redis") { - const primary = toAddress(host, port, 6379); - const clusterHosts = - values.redisTopology === "cluster" - ? normalizeAddressList(values.redisHosts, 6379) + if (type === "kafka") { + const primary = toAddress(host, port, defaultPort); + const brokers = + values.kafkaTopology === "cluster" + ? normalizeAddressList(values.kafkaHosts, defaultPort) : []; - const hosts = normalizeAddressList([primary, ...clusterHosts], 6379); + const allBrokers = normalizeAddressList([primary, ...brokers], defaultPort); const params = new URLSearchParams(); - if (hosts.length > 1 || values.redisTopology === "cluster") { + if (allBrokers.length > 1 || values.kafkaTopology === "cluster") { params.set("topology", "cluster"); } - const redisUser = String(values.user || "").trim(); - const redisPassword = String(values.password || ""); - let redisAuth = ""; - if (redisUser || redisPassword) { - const encodedPassword = redisPassword - ? encodeURIComponent(redisPassword) - : ""; - redisAuth = redisUser - ? `${encodeURIComponent(redisUser)}${redisPassword ? `:${encodedPassword}` : ""}@` - : `:${encodedPassword}@`; - } - const redisDB = Number.isFinite(Number(values.redisDB)) - ? Math.max(0, Math.min(15, Math.trunc(Number(values.redisDB)))) - : 0; - const dbPath = `/${redisDB}`; if (values.useSSL) { const mode = String(values.sslMode || "preferred") .trim() .toLowerCase(); + params.set("tls", "true"); if (mode === "skip-verify" || mode === "preferred") { params.set("skip_verify", "true"); } + appendSSLPathParamsForUri(params, type, values); } - appendSSLPathParamsForUri(params, type, values); + if (Number.isFinite(timeout) && timeout > 0) { + params.set("timeout", String(timeout)); + } + mergeConnectionParams(params, values.connectionParams); + const topicPath = database ? `/${encodeURIComponent(database)}` : ""; const query = params.toString(); - const scheme = values.useSSL ? "rediss" : "redis"; - return `${scheme}://${redisAuth}${hosts.join(",")}${dbPath}${query ? `?${query}` : ""}`; + return `kafka://${encodedAuth}${allBrokers.join(",")}${topicPath}${query ? `?${query}` : ""}`; + } + + if (type === "mqtt") { + const primary = toAddress(host, port, defaultPort); + const brokers = + values.mqttTopology === "cluster" + ? normalizeAddressList(values.mqttHosts, defaultPort) + : []; + const allBrokers = normalizeAddressList([primary, ...brokers], defaultPort); + const params = new URLSearchParams(); + if (allBrokers.length > 1 || values.mqttTopology === "cluster") { + params.set("topology", "cluster"); + } + if (values.useSSL) { + const mode = String(values.sslMode || "preferred") + .trim() + .toLowerCase(); + params.set("tls", "true"); + if (mode === "skip-verify" || mode === "preferred") { + params.set("skip_verify", "true"); + } + appendSSLPathParamsForUri(params, type, values); + } + if (Number.isFinite(timeout) && timeout > 0) { + params.set("timeout", String(timeout)); + } + mergeConnectionParams(params, values.connectionParams); + const topicPath = database ? `/${encodeURIComponent(database)}` : ""; + const query = params.toString(); + return `mqtt://${encodedAuth}${allBrokers.join(",")}${topicPath}${query ? `?${query}` : ""}`; + } + + if (type === "rocketmq") { + const primary = toAddress(host, port, defaultPort); + const nameservers = + values.rocketmqTopology === "cluster" + ? normalizeAddressList(values.rocketmqHosts, defaultPort) + : []; + const allNameServers = normalizeAddressList([primary, ...nameservers], defaultPort); + const params = new URLSearchParams(); + if (allNameServers.length > 1 || values.rocketmqTopology === "cluster") { + params.set("topology", "cluster"); + } + if (Number.isFinite(timeout) && timeout > 0) { + params.set("timeout", String(timeout)); + } + mergeConnectionParams(params, values.connectionParams); + const topicPath = database ? `/${encodeURIComponent(database)}` : ""; + const query = params.toString(); + return `rocketmq://${encodedAuth}${allNameServers.join(",")}${topicPath}${query ? `?${query}` : ""}`; + } + + if (type === "rabbitmq") { + const address = toAddress(host, port, defaultPort); + const params = new URLSearchParams(); + if (values.useSSL) { + const mode = String(values.sslMode || "preferred") + .trim() + .toLowerCase(); + params.set("tls", "true"); + if (mode === "skip-verify" || mode === "preferred") { + params.set("skip_verify", "true"); + } + appendSSLPathParamsForUri(params, type, values); + } + if (Number.isFinite(timeout) && timeout > 0) { + params.set("timeout", String(timeout)); + } + mergeConnectionParams(params, values.connectionParams); + const vhostPath = database ? `/${encodeURIComponent(database)}` : ""; + const query = params.toString(); + return `rabbitmq://${encodedAuth}${address}${vhostPath}${query ? `?${query}` : ""}`; + } + + if (type === "redis") { + return buildRedisUriFromValues(values); } if (isFileDatabaseType(type)) { @@ -2292,8 +2456,14 @@ const ConnectionModal: React.FC<{ ? normalizeClickHouseProtocolValue(values.clickHouseProtocol) : "auto"; const scheme = - type === "postgres" + type === "gaussdb" + ? "gaussdb" + : type === "postgres" ? "postgresql" + : type === "chroma" || type === "qdrant" + ? values.useSSL + ? "https" + : "http" : type === "clickhouse" && clickHouseProtocol === "http" ? values.useSSL ? "https" @@ -2344,6 +2514,11 @@ const ConnectionModal: React.FC<{ if (mode === "skip-verify" || mode === "preferred") { params.set("skip_verify", "true"); } + } else if (type === "chroma" || type === "qdrant") { + if (mode === "skip-verify" || mode === "preferred") { + params.set("skip_verify", "true"); + } + appendSSLPathParamsForUri(params, type, values); } } else if (supportsSSLForType(type)) { if (isPostgresCompatibleSSLType(type)) { @@ -2585,18 +2760,27 @@ const ConnectionModal: React.FC<{ const defaultPort = getDefaultPortByType(configType); const isFileDbConfigType = isFileDatabaseType(configType); const jvmDefaultValues = buildDefaultJVMConnectionValues(); + const savedPrimaryAddress = isFileDbConfigType + ? "" + : toAddress( + config.host || "localhost", + Number(config.port || defaultPort), + defaultPort, + ); const normalizedHosts = isFileDbConfigType ? [] - : normalizeAddressList(config.hosts, defaultPort); + : normalizeAddressList( + [ + savedPrimaryAddress, + ...(Array.isArray(config.hosts) ? config.hosts : []), + ], + defaultPort, + ); const primaryAddress = isFileDbConfigType ? null : parseHostPort( normalizedHosts[0] || - toAddress( - config.host || "localhost", - Number(config.port || defaultPort), - defaultPort, - ), + savedPrimaryAddress, defaultPort, ); const primaryHost = isFileDbConfigType @@ -2607,6 +2791,7 @@ const ConnectionModal: React.FC<{ : primaryAddress?.port || Number(config.port || defaultPort); const mysqlReplicaHosts = configType === "mysql" || + configType === "goldendb" || configType === "mariadb" || configType === "oceanbase" || configType === "diros" || @@ -2614,6 +2799,12 @@ const ConnectionModal: React.FC<{ configType === "sphinx" ? normalizedHosts.slice(1) : []; + const rocketmqHosts = + configType === "rocketmq" ? normalizedHosts.slice(1) : []; + const mqttHosts = + configType === "mqtt" ? normalizedHosts.slice(1) : []; + const kafkaHosts = + configType === "kafka" ? normalizedHosts.slice(1) : []; const mongoHosts = configType === "mongodb" ? normalizedHosts.slice(1) : []; const redisHosts = @@ -2621,13 +2812,24 @@ const ConnectionModal: React.FC<{ const mysqlIsReplica = String(config.topology || "").toLowerCase() === "replica" || mysqlReplicaHosts.length > 0; + const rocketmqIsCluster = + String(config.topology || "").toLowerCase() === "cluster" || + rocketmqHosts.length > 0; + const mqttIsCluster = + String(config.topology || "").toLowerCase() === "cluster" || + mqttHosts.length > 0; + const kafkaIsCluster = + String(config.topology || "").toLowerCase() === "cluster" || + kafkaHosts.length > 0; const mongoIsReplica = String(config.topology || "").toLowerCase() === "replica" || mongoHosts.length > 0 || !!config.replicaSet; + const redisTopologyValue = String(config.topology || "").toLowerCase(); + const redisIsSentinel = redisTopologyValue === "sentinel"; const redisIsCluster = - String(config.topology || "").toLowerCase() === "cluster" || - redisHosts.length > 0; + !redisIsSentinel && + (redisTopologyValue === "cluster" || redisHosts.length > 0); const { allowedModes: resolvedJvmAllowedModes, preferredMode: resolvedJvmPreferredMode, @@ -2691,12 +2893,25 @@ const ConnectionModal: React.FC<{ timeout: resolvedJvmTimeout, mysqlTopology: mysqlIsReplica ? "replica" : "single", mysqlReplicaHosts: mysqlReplicaHosts, + rocketmqTopology: rocketmqIsCluster ? "cluster" : "single", + rocketmqHosts: rocketmqHosts, + mqttTopology: mqttIsCluster ? "cluster" : "single", + mqttHosts: mqttHosts, + kafkaTopology: kafkaIsCluster ? "cluster" : "single", + kafkaHosts: kafkaHosts, mysqlReplicaUser: config.mysqlReplicaUser || "", mysqlReplicaPassword: config.mysqlReplicaPassword || "", mongoTopology: mongoIsReplica ? "replica" : "single", mongoHosts: mongoHosts, - redisTopology: redisIsCluster ? "cluster" : "single", + redisTopology: redisIsSentinel + ? "sentinel" + : redisIsCluster + ? "cluster" + : "single", redisHosts: redisHosts, + redisSentinelMaster: config.redisSentinelMaster || "", + redisSentinelUser: config.redisSentinelUser || "", + redisSentinelPassword: config.redisSentinelPassword || "", mongoSrv: !!config.mongoSrv, mongoReplicaSet: config.replicaSet || "", mongoAuthSource: config.authSource || "", @@ -2816,7 +3031,12 @@ const ConnectionModal: React.FC<{ } // 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表 if (configType === "redis") { - setRedisDbList(Array.from({ length: 16 }, (_, i) => i)); + setRedisDbList( + buildRedisDatabaseList( + config.redisDB, + initialValues.includeRedisDatabases, + ), + ); } } else { // Create mode: Start at step 1 @@ -2893,6 +3113,16 @@ const ConnectionModal: React.FC<{ clearSecret: clearSecrets.mongoReplicaPassword, forceClear: !mongoReplicaEnabled, }); + const redisSentinelEnabled = + config.type === "redis" && + config.topology === "sentinel" && + values.savePassword !== false; + const redisSentinelDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasRedisSentinelPassword, + valueInput: config.redisSentinelPassword, + clearSecret: clearSecrets.redisSentinelPassword, + forceClear: !redisSentinelEnabled, + }); const opaqueUriDraft = resolveConnectionSecretDraft({ hasSecret: initialValues?.hasOpaqueURI, valueInput: config.uri, @@ -2961,6 +3191,7 @@ const ConnectionModal: React.FC<{ dsn: opaqueDsnDraft.value, mysqlReplicaPassword: mysqlReplicaDraft.value, mongoReplicaPassword: mongoReplicaDraft.value, + redisSentinelPassword: redisSentinelDraft.value, }, includeDatabases: values.includeDatabases, includeRedisDatabases: isRedisType @@ -2974,6 +3205,7 @@ const ConnectionModal: React.FC<{ clearHttpTunnelPassword: httpTunnelDraft.clearStoredSecret, clearMySQLReplicaPassword: mysqlReplicaDraft.clearStoredSecret, clearMongoReplicaPassword: mongoReplicaDraft.clearStoredSecret, + clearRedisSentinelPassword: redisSentinelDraft.clearStoredSecret, clearOpaqueURI: opaqueUriDraft.clearStoredSecret, clearOpaqueDSN: opaqueDsnDraft.clearStoredSecret, }; @@ -3123,6 +3355,14 @@ const ConnectionModal: React.FC<{ ) { return t("connection.modal.secret.blocking.mongoReplica"); } + if ( + clearSecrets.redisSentinelPassword && + values.type === "redis" && + values.redisTopology === "sentinel" && + String(values.redisSentinelPassword ?? "") === "" + ) { + return "测试连接前请填写新的 Sentinel 密码,或取消清除已保存 Sentinel 密码"; + } if ( values.type === "mongodb" && values.savePassword === false && @@ -3214,7 +3454,32 @@ const ConnectionModal: React.FC<{ void message.destroy("connection-test-failure"); setTestResult({ type: "success", message: res.message }); if (isRedisType) { - setRedisDbList(Array.from({ length: 16 }, (_, i) => i)); + const dbRes = await withClientTimeout( + RedisGetDatabases(config as any), + rpcTimeoutMs, + `连接成功但拉取 Redis 数据库列表超时(>${timeoutSeconds} 秒)`, + ); + if (dbRes.success) { + const supportedDbs = extractRedisDatabaseList(dbRes.data); + setRedisDbList(supportedDbs); + form.setFieldValue( + "includeRedisDatabases", + normalizeRedisDatabaseSelection( + form.getFieldValue("includeRedisDatabases"), + supportedDbs, + ), + ); + } else { + setRedisDbList( + buildRedisDatabaseList( + config.redisDB, + form.getFieldValue("includeRedisDatabases"), + ), + ); + message.warning( + `连接成功,但获取 Redis 数据库列表失败:${normalizeConnectionSecretErrorMessage(dbRes.message, "未知错误")}`, + ); + } } else if (!isJVMType) { // Other databases: fetch database list const dbRes = await withClientTimeout( @@ -3582,7 +3847,7 @@ const ConnectionModal: React.FC<{ } let hosts: string[] = []; - let topology: "single" | "replica" | "cluster" | undefined; + let topology: "single" | "replica" | "cluster" | "sentinel" | undefined; let replicaSet = ""; let authSource = ""; let readPreference = ""; @@ -3592,6 +3857,9 @@ const ConnectionModal: React.FC<{ let mongoAuthMechanism = ""; let mongoReplicaUser = ""; let mongoReplicaPassword = ""; + let redisSentinelMaster = ""; + let redisSentinelUser = ""; + let redisSentinelPassword = ""; const savePassword = type === "mongodb" ? mergedValues.savePassword !== false : true; @@ -3614,6 +3882,57 @@ const ConnectionModal: React.FC<{ } } + if (type === "kafka") { + const brokers = + mergedValues.kafkaTopology === "cluster" + ? normalizeAddressList(mergedValues.kafkaHosts, defaultPort) + : []; + const allHosts = normalizeAddressList( + [`${primaryHost}:${primaryPort}`, ...brokers], + defaultPort, + ); + if (mergedValues.kafkaTopology === "cluster" || allHosts.length > 1) { + hosts = allHosts; + topology = "cluster"; + } else { + topology = "single"; + } + } + + if (type === "mqtt") { + const brokers = + mergedValues.mqttTopology === "cluster" + ? normalizeAddressList(mergedValues.mqttHosts, defaultPort) + : []; + const allHosts = normalizeAddressList( + [`${primaryHost}:${primaryPort}`, ...brokers], + defaultPort, + ); + if (mergedValues.mqttTopology === "cluster" || allHosts.length > 1) { + hosts = allHosts; + topology = "cluster"; + } else { + topology = "single"; + } + } + + if (type === "rocketmq") { + const nameservers = + mergedValues.rocketmqTopology === "cluster" + ? normalizeAddressList(mergedValues.rocketmqHosts, defaultPort) + : []; + const allHosts = normalizeAddressList( + [`${primaryHost}:${primaryPort}`, ...nameservers], + defaultPort, + ); + if (mergedValues.rocketmqTopology === "cluster" || allHosts.length > 1) { + hosts = allHosts; + topology = "cluster"; + } else { + topology = "single"; + } + } + if (type === "mongodb") { mongoSrvEnabled = !!mergedValues.mongoSrv; const extraHosts = @@ -3653,23 +3972,19 @@ const ConnectionModal: React.FC<{ } if (type === "redis") { - const clusterNodes = - mergedValues.redisTopology === "cluster" - ? normalizeAddressList(mergedValues.redisHosts, defaultPort) - : []; - const allHosts = normalizeAddressList( - [`${primaryHost}:${primaryPort}`, ...clusterNodes], + const redisDraft = resolveRedisConfigDraft( + mergedValues, + primaryHost, + primaryPort, defaultPort, ); - if (mergedValues.redisTopology === "cluster" || allHosts.length > 1) { - hosts = allHosts; - topology = "cluster"; - } else { - topology = "single"; - } - mergedValues.redisDB = Number.isFinite(Number(mergedValues.redisDB)) - ? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB)))) - : 0; + primaryPort = redisDraft.primaryPort; + hosts = redisDraft.hosts; + topology = redisDraft.topology; + redisSentinelMaster = redisDraft.redisSentinelMaster; + redisSentinelUser = redisDraft.redisSentinelUser; + redisSentinelPassword = redisDraft.redisSentinelPassword; + mergedValues.redisDB = redisDraft.redisDB; } const sshConfig = mergedValues.useSSH @@ -3769,8 +4084,11 @@ const ConnectionModal: React.FC<{ connectionParams: normalizedConnectionParams, timeout: Number(mergedValues.timeout || 30), redisDB: Number.isFinite(Number(mergedValues.redisDB)) - ? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB)))) + ? Math.max(0, Math.trunc(Number(mergedValues.redisDB))) : 0, + redisSentinelMaster: redisSentinelMaster, + redisSentinelUser: redisSentinelUser, + redisSentinelPassword: keepPassword ? redisSentinelPassword : "", uri: String(mergedValues.uri || "").trim(), clickHouseProtocol: type === "clickhouse" @@ -3853,6 +4171,9 @@ const ConnectionModal: React.FC<{ includeDatabases: undefined, includeRedisDatabases: undefined, mysqlTopology: "single", + rocketmqTopology: "single", + mqttTopology: "single", + kafkaTopology: "single", redisTopology: "single", mongoTopology: "single", mongoSrv: false, @@ -3862,7 +4183,13 @@ const ConnectionModal: React.FC<{ mongoAuthMechanism: "", savePassword: true, mysqlReplicaHosts: [], + rocketmqHosts: [], + mqttHosts: [], + kafkaHosts: [], redisHosts: [], + redisSentinelMaster: "", + redisSentinelUser: "", + redisSentinelPassword: "", mongoHosts: [], mysqlReplicaUser: "", mysqlReplicaPassword: "", @@ -3912,6 +4239,9 @@ const ConnectionModal: React.FC<{ httpTunnelUser: "", httpTunnelPassword: "", mysqlTopology: "single", + rocketmqTopology: "single", + mqttTopology: "single", + kafkaTopology: "single", redisTopology: "single", mongoTopology: "single", mongoSrv: false, @@ -3921,7 +4251,13 @@ const ConnectionModal: React.FC<{ mongoAuthMechanism: "", savePassword: true, mysqlReplicaHosts: [], + rocketmqHosts: [], + mqttHosts: [], + kafkaHosts: [], redisHosts: [], + redisSentinelMaster: "", + redisSentinelUser: "", + redisSentinelPassword: "", mongoHosts: [], mysqlReplicaUser: "", mysqlReplicaPassword: "", @@ -3932,7 +4268,7 @@ const ConnectionModal: React.FC<{ }); } else if (type !== "custom") { const defaultUser = - type === "clickhouse" ? "default" : type === "redis" ? "" : "root"; + type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "rocketmq" || type === "mqtt" || type === "kafka" || type === "rabbitmq") ? "" : "root"; const sslCapableType = supportsSSLForType(type); setUseSSL(false); setUseHttpTunnel(false); @@ -3951,6 +4287,9 @@ const ConnectionModal: React.FC<{ httpTunnelUser: "", httpTunnelPassword: "", mysqlTopology: "single", + rocketmqTopology: "single", + mqttTopology: "single", + kafkaTopology: "single", redisTopology: "single", mongoTopology: "single", mongoSrv: false, @@ -3960,7 +4299,13 @@ const ConnectionModal: React.FC<{ mongoAuthMechanism: "", savePassword: true, mysqlReplicaHosts: [], + rocketmqHosts: [], + mqttHosts: [], + kafkaHosts: [], redisHosts: [], + redisSentinelMaster: "", + redisSentinelUser: "", + redisSentinelPassword: "", mongoHosts: [], mysqlReplicaUser: "", mysqlReplicaPassword: "", @@ -4014,170 +4359,19 @@ const ConnectionModal: React.FC<{ const driverStatusChecking = hasCurrentDriverType && !driverStatusLoaded && step === 2; - const dbTypeGroups = [ - { - label: t("connection.modal.step1.group.relational"), - items: [ - { - key: "mysql", - name: "MySQL", - icon: getDbIcon("mysql", undefined, 36), - }, - { - key: "mariadb", - name: "MariaDB", - icon: getDbIcon("mariadb", undefined, 36), - }, - { - key: "diros", - name: "Doris", - icon: getDbIcon("diros", undefined, 36), - }, - { - key: "starrocks", - name: "StarRocks", - icon: getDbIcon("starrocks", undefined, 36), - }, - { - key: "sphinx", - name: "Sphinx", - icon: getDbIcon("sphinx", undefined, 36), - }, - { - key: "clickhouse", - name: "ClickHouse", - icon: getDbIcon("clickhouse", undefined, 36), - }, - { - key: "postgres", - name: "PostgreSQL", - icon: getDbIcon("postgres", undefined, 36), - }, - { - key: "sqlserver", - name: "SQL Server", - icon: getDbIcon("sqlserver", undefined, 36), - }, - { - key: "iris", - name: "InterSystems IRIS", - icon: getDbIcon("iris", undefined, 36), - }, - { - key: "sqlite", - name: "SQLite", - icon: getDbIcon("sqlite", undefined, 36), - }, - { - key: "duckdb", - name: "DuckDB", - icon: getDbIcon("duckdb", undefined, 36), - }, - { - key: "oracle", - name: "Oracle", - icon: getDbIcon("oracle", undefined, 36), - }, - ], - }, - { - label: t("connection.modal.step1.group.domestic"), - items: [ - { - key: "oceanbase", - name: "OceanBase", - icon: getDbIcon("oceanbase", undefined, 36), - }, - { - key: "dameng", - name: "Dameng (达梦)", - icon: getDbIcon("dameng", undefined, 36), - }, - { - key: "kingbase", - name: "Kingbase (人大金仓)", - icon: getDbIcon("kingbase", undefined, 36), - }, - { - key: "highgo", - name: "HighGo (瀚高)", - icon: getDbIcon("highgo", undefined, 36), - }, - { - key: "vastbase", - name: "Vastbase (海量)", - icon: getDbIcon("vastbase", undefined, 36), - }, - { - key: "opengauss", - name: "OpenGauss", - icon: getDbIcon("opengauss", undefined, 36), - }, - ], - }, - { - label: t("connection.modal.step1.group.nosql"), - items: [ - { - key: "mongodb", - name: "MongoDB", - icon: getDbIcon("mongodb", undefined, 36), - }, - { - key: "redis", - name: "Redis", - icon: getDbIcon("redis", undefined, 36), - }, - ], - }, - { - label: t("connection.modal.step1.group.timeseries"), - items: [ - { - key: "tdengine", - name: "TDengine", - icon: getDbIcon("tdengine", undefined, 36), - }, - ], - }, - { - label: t("connection.modal.step1.group.other"), - items: [ - { - key: "jvm", - name: "JVM Runtime", - step1Name: t("connection_modal.config_section.jvmRuntime.title"), - icon: getDbIcon("jvm", undefined, 36), - }, - { - key: "custom", - name: t("connection_modal.db_type.custom"), - icon: getDbIcon("custom", undefined, 36), - }, - ], - }, - ]; + const dbTypeGroups = useMemo( + () => + CONNECTION_TYPE_GROUPS.map((group) => ({ + ...group, + items: group.items.map((item) => ({ + ...item, + icon: getDbIcon(item.key, undefined, 36), + })), + })), + [], + ); - const dbTypes = dbTypeGroups.flatMap((g) => g.items); - const getDbTypeHint = (type: string) => { - switch (type) { - case "jvm": - return t("connection.modal.step1.hint.jvm"); - case "custom": - return t("connection.modal.step1.hint.custom"); - case "redis": - return t("connection.modal.step1.hint.redis"); - case "mongodb": - return t("connection.modal.step1.hint.mongodb"); - case "oceanbase": - return t("connection.modal.step1.hint.oceanBase"); - case "sqlite": - case "duckdb": - return t("connection.modal.step1.hint.file"); - default: - return t("connection.modal.step1.hint.standard"); - } - }; + const dbTypes = getAllConnectionTypeCatalogItems(); const renderStep1 = () => (
- {item.step1Name || item.name} + {item.name} - {getDbTypeHint(item.key)} + {getConnectionTypeHint(item.key)}
@@ -5347,7 +5541,8 @@ const ConnectionModal: React.FC<{ dbType === "kingbase" || dbType === "highgo" || dbType === "vastbase" || - dbType === "opengauss") && + dbType === "opengauss" || + dbType === "gaussdb") && renderConfigSectionCard({ sectionKey: "service", icon: , @@ -5368,6 +5563,70 @@ const ConnectionModal: React.FC<{ ), })} + {dbType === "kafka" && + renderConfigSectionCard({ + sectionKey: "service", + icon: , + children: ( + + + + ), + })} + + {dbType === "rocketmq" && + renderConfigSectionCard({ + sectionKey: "service", + icon: , + children: ( + + + + ), + })} + + {dbType === "mqtt" && + renderConfigSectionCard({ + sectionKey: "service", + icon: , + children: ( + + + + ), + })} + + {dbType === "rabbitmq" && + renderConfigSectionCard({ + sectionKey: "service", + icon: , + children: ( + + + + ), + })} + {(dbType === "oracle" || isOceanBaseOracle) && renderConfigSectionCard({ sectionKey: "service", @@ -5382,15 +5641,15 @@ const ConnectionModal: React.FC<{ ) : t("connection.modal.field.serviceName.label") } - rules={[ - createUriAwareRequiredRule( - isOceanBaseOracle - ? t( - "connection.modal.field.oceanBaseServiceName.required", - ) - : t("connection.modal.field.serviceName.required"), - ), - ]} + rules={ + isOceanBaseOracle + ? [] + : [ + createUriAwareRequiredRule( + t("connection.modal.field.serviceName.required"), + ), + ] + } help={ isOceanBaseOracle ? t( @@ -5438,6 +5697,132 @@ const ConnectionModal: React.FC<{ }), })} + {isKafka && + renderConfigSectionCard({ + sectionKey: "connectionMode", + icon: , + children: renderChoiceCards({ + fieldName: "kafkaTopology", + value: String(kafkaTopology), + options: [ + { + value: "single", + label: "单 Broker", + description: "只配置一个 bootstrap broker,适合本地或简单环境。", + }, + { + value: "cluster", + label: "集群模式", + description: "配置多个 bootstrap broker,提高发现与故障切换成功率。", + }, + ], + }), + })} + + {isRocketMQ && + renderConfigSectionCard({ + sectionKey: "connectionMode", + icon: , + children: renderChoiceCards({ + fieldName: "rocketmqTopology", + value: String(rocketmqTopology), + options: [ + { + value: "single", + label: "单 NameServer", + description: "只配置一个 NameServer,适合本地或简单环境。", + }, + { + value: "cluster", + label: "集群模式", + description: "配置多个 NameServer,提高路由发现与故障切换成功率。", + }, + ], + }), + })} + + {isMQTT && + renderConfigSectionCard({ + sectionKey: "connectionMode", + icon: , + children: renderChoiceCards({ + fieldName: "mqttTopology", + value: String(mqttTopology), + options: [ + { + value: "single", + label: "单 Broker", + description: "只配置一个 broker,适合本地或简单环境。", + }, + { + value: "cluster", + label: "集群模式", + description: "配置多个 broker,提高连接发现与故障切换成功率。", + }, + ], + }), + })} + + {isKafka && + kafkaTopology === "cluster" && + renderConfigSectionCard({ + sectionKey: "replica", + icon: , + children: ( + + + + ), + })} + + {isMQTT && + mqttTopology === "cluster" && + renderConfigSectionCard({ + sectionKey: "replica", + icon: , + children: ( + + + , @@ -7149,6 +7542,9 @@ const ConnectionModal: React.FC<{ connectionParams: "", oceanBaseProtocol: "mysql", mysqlTopology: "single", + rocketmqTopology: "single", + mqttTopology: "single", + kafkaTopology: "single", redisTopology: "single", mongoTopology: "single", mongoSrv: false, @@ -7156,7 +7552,13 @@ const ConnectionModal: React.FC<{ mongoAuthMechanism: "", savePassword: true, mysqlReplicaHosts: [], + rocketmqHosts: [], + mqttHosts: [], + kafkaHosts: [], redisHosts: [], + redisSentinelMaster: "", + redisSentinelUser: "", + redisSentinelPassword: "", mongoHosts: [], mysqlReplicaUser: "", mysqlReplicaPassword: "", @@ -7276,19 +7678,32 @@ const ConnectionModal: React.FC<{ ); } if (changed.redisTopology !== undefined) { - const supportedDbs = Array.from({ length: 16 }, (_, i) => i); + const nextRedisTopology = String( + changed.redisTopology || "single", + ).toLowerCase(); + const currentRedisPort = Number(form.getFieldValue("port") || 0); + if ( + nextRedisTopology === "sentinel" && + (!currentRedisPort || currentRedisPort === 6379) + ) { + form.setFieldValue("port", 26379); + } else if ( + nextRedisTopology !== "sentinel" && + currentRedisPort === 26379 + ) { + form.setFieldValue("port", 6379); + } + const supportedDbs = buildRedisDatabaseList( + form.getFieldValue("redisDB"), + form.getFieldValue("includeRedisDatabases"), + ); setRedisDbList(supportedDbs); - const selectedDbsRaw = form.getFieldValue("includeRedisDatabases"); - const selectedDbs = Array.isArray(selectedDbsRaw) - ? selectedDbsRaw.map((entry: any) => Number(entry)) - : []; - const validDbs = selectedDbs - .filter((entry: number) => Number.isFinite(entry)) - .map((entry: number) => Math.trunc(entry)) - .filter((entry: number) => supportedDbs.includes(entry)); form.setFieldValue( "includeRedisDatabases", - validDbs.length > 0 ? validDbs : undefined, + normalizeRedisDatabaseSelection( + form.getFieldValue("includeRedisDatabases"), + supportedDbs, + ), ); } if ( diff --git a/frontend/src/components/ConnectionModalMongoSections.tsx b/frontend/src/components/ConnectionModalMongoSections.tsx new file mode 100644 index 0000000..98df36a --- /dev/null +++ b/frontend/src/components/ConnectionModalMongoSections.tsx @@ -0,0 +1,364 @@ +import React from "react"; +import { + Alert, + Button, + Checkbox, + Form, + Input, + Select, + Space, + Table, + Tag, + Typography, +} from "antd"; +import { + ApiOutlined, + ClusterOutlined, + ThunderboltOutlined, +} from "@ant-design/icons"; + +import type { MongoMemberInfo, SavedConnection } from "../types"; +import { + getStoredSecretPlaceholder, + type ConnectionConfigSectionKey, +} from "../utils/connectionModalPresentation"; +import { noAutoCapInputProps } from "../utils/inputAutoCap"; + +const { Text } = Typography; + +type ChoiceCardOption = { + value: string; + label: string; + description?: string; +}; + +type RenderChoiceCards = (params: { + fieldName: string; + value: string; + options: ChoiceCardOption[]; + minWidth?: number; + onSelect?: (value: string) => void; +}) => React.ReactNode; + +type RenderConfigSectionCard = (params: { + sectionKey: ConnectionConfigSectionKey; + icon: React.ReactNode; + children: React.ReactNode; + badge?: React.ReactNode; +}) => React.ReactNode; + +type RenderStoredSecretControls = (params: { + fieldName: string; + clearKey: "mongoReplicaPassword"; + hasStoredSecret?: boolean; + clearLabel: string; + description: string; +}) => React.ReactNode; + +interface ConnectionModalMongoSectionsProps { + mongoTopology: string; + mongoSrv: boolean; + useSSH: boolean; + darkMode: boolean; + modalMutedTextStyle: React.CSSProperties; + mongoReadPreference: string; + mongoMembers: MongoMemberInfo[]; + discoveringMembers: boolean; + initialValues?: SavedConnection | null; + renderChoiceCards: RenderChoiceCards; + renderConfigSectionCard: RenderConfigSectionCard; + renderStoredSecretControls: RenderStoredSecretControls; + setChoiceFieldValue: (fieldName: string, value: string | boolean) => void; + handleDiscoverMongoMembers: () => void; +} + +const ConnectionModalMongoSections: React.FC = ({ + mongoTopology, + mongoSrv, + useSSH, + darkMode, + modalMutedTextStyle, + mongoReadPreference, + mongoMembers, + discoveringMembers, + initialValues, + renderChoiceCards, + renderConfigSectionCard, + renderStoredSecretControls, + setChoiceFieldValue, + handleDiscoverMongoMembers, +}) => ( + <> + {renderConfigSectionCard({ + sectionKey: "connectionMode", + icon: , + children: renderChoiceCards({ + fieldName: "mongoTopology", + value: String(mongoTopology), + options: [ + { + value: "single", + label: "单机模式", + description: "只连接一个 MongoDB 节点。", + }, + { + value: "replica", + label: "副本集 / 多节点", + description: "配置副本集名称和多个候选节点。", + }, + ], + }), + })} + + {renderConfigSectionCard({ + sectionKey: "mongoDiscovery", + icon: , + children: ( + <> + +
+ {[ + { + value: false, + label: "标准地址", + description: "使用 host:port 直连或副本集节点列表。", + }, + { + value: true, + label: "SRV 地址", + description: "使用 mongodb+srv,由 DNS 发现目标节点。", + }, + ].map((option) => { + const active = mongoSrv === option.value; + return ( + + ); + })} +
+ {mongoSrv && useSSH && ( + + )} + + ), + })} + + {mongoTopology === "replica" && + renderConfigSectionCard({ + sectionKey: "replica", + icon: , + children: ( + <> + + + + + + +
+ + + + {renderStoredSecretControls({ + fieldName: "mongoReplicaPassword", + clearKey: "mongoReplicaPassword", + hasStoredSecret: initialValues?.hasMongoReplicaPassword, + clearLabel: "清除已保存副本集密码", + description: + "当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。", + })} + + + + {mongoMembers.length > 0 && ( + record.host} + pagination={false} + dataSource={mongoMembers} + style={{ marginBottom: 12 }} + columns={[ + { title: "Host", dataIndex: "host", width: "48%" }, + { + title: "角色", + dataIndex: "role", + width: "32%", + render: (value: string, record: MongoMemberInfo) => ( + + {value || "UNKNOWN"} + + ), + }, + { + title: "健康", + dataIndex: "healthy", + width: "20%", + render: (value: boolean) => ( + + {value ? "正常" : "异常"} + + ), + }, + ]} + /> + )} + + ), + })} + + {renderConfigSectionCard({ + sectionKey: "mongoPolicy", + icon: , + children: ( +
+ + + +
+ 读偏好 (readPreference) + {renderChoiceCards({ + fieldName: "mongoReadPreference", + value: String(mongoReadPreference), + minWidth: 130, + options: [ + { + value: "primary", + label: "primary", + description: "只读主节点。", + }, + { + value: "primaryPreferred", + label: "primaryPreferred", + description: "主节点优先。", + }, + { + value: "secondary", + label: "secondary", + description: "只读从节点。", + }, + { + value: "secondaryPreferred", + label: "secondaryPreferred", + description: "从节点优先。", + }, + { + value: "nearest", + label: "nearest", + description: "选择最近节点。", + }, + ], + })} +
+
+ ), + })} + +); + +export default ConnectionModalMongoSections; diff --git a/frontend/src/components/ConnectionModalRedisSections.tsx b/frontend/src/components/ConnectionModalRedisSections.tsx new file mode 100644 index 0000000..33a794e --- /dev/null +++ b/frontend/src/components/ConnectionModalRedisSections.tsx @@ -0,0 +1,242 @@ +import React from "react"; +import { Form, Input, Select } from "antd"; +import { + ClusterOutlined, + DatabaseOutlined, + SafetyCertificateOutlined, +} from "@ant-design/icons"; + +import type { SavedConnection } from "../types"; +import { + getStoredSecretPlaceholder, + type ConnectionConfigSectionKey, +} from "../utils/connectionModalPresentation"; +import { noAutoCapInputProps } from "../utils/inputAutoCap"; + +type ChoiceCardOption = { + value: string; + label: string; + description?: string; +}; + +type RenderChoiceCards = (params: { + fieldName: string; + value: string; + options: ChoiceCardOption[]; + minWidth?: number; + onSelect?: (value: string) => void; +}) => React.ReactNode; + +type RenderConfigSectionCard = (params: { + sectionKey: ConnectionConfigSectionKey; + icon: React.ReactNode; + children: React.ReactNode; + badge?: React.ReactNode; +}) => React.ReactNode; + +type RenderStoredSecretControls = (params: { + fieldName: string; + clearKey: "redisSentinelPassword"; + hasStoredSecret?: boolean; + clearLabel: string; + description: string; +}) => React.ReactNode; + +interface ConnectionModalRedisSectionsProps { + redisTopology: string; + redisDbList: number[]; + initialValues?: SavedConnection | null; + primaryPasswordVisible: boolean; + setPrimaryPasswordVisible: (visible: boolean) => void; + renderChoiceCards: RenderChoiceCards; + renderConfigSectionCard: RenderConfigSectionCard; + renderStoredSecretControls: RenderStoredSecretControls; + createUriAwareRequiredRule: ( + messageText: string, + validateValue?: (value: unknown) => boolean, + ) => any; +} + +const ConnectionModalRedisSections: React.FC = ({ + redisTopology, + redisDbList, + initialValues, + primaryPasswordVisible, + setPrimaryPasswordVisible, + renderChoiceCards, + renderConfigSectionCard, + renderStoredSecretControls, + createUriAwareRequiredRule, +}) => ( + <> + {renderConfigSectionCard({ + sectionKey: "connectionMode", + icon: , + children: ( + <> + {renderChoiceCards({ + fieldName: "redisTopology", + value: String(redisTopology), + options: [ + { + value: "single", + label: "单机模式", + description: "只连接一个 Redis 节点。", + }, + { + value: "cluster", + label: "集群模式", + description: "Redis Cluster,配置多个种子节点。", + }, + { + value: "sentinel", + label: "哨兵模式", + description: "通过 Sentinel 发现主节点,适合主从高可用。", + }, + ], + })} + {(redisTopology === "cluster" || redisTopology === "sentinel") && ( + <> + + + + )} + + )} + + ), + })} + + {renderConfigSectionCard({ + sectionKey: "credentials", + icon: , + children: ( + <> + + + + {redisTopology === "sentinel" && ( + <> +
+ + + + + + +
+ {renderStoredSecretControls({ + fieldName: "redisSentinelPassword", + clearKey: "redisSentinelPassword", + hasStoredSecret: initialValues?.hasRedisSentinelPassword, + clearLabel: "清除已保存 Sentinel 密码", + description: + "当前已保存 Sentinel 密码。留空表示继续沿用,输入新值表示替换。", + })} + + )} + + ), + })} + + {renderConfigSectionCard({ + sectionKey: "databaseScope", + icon: , + children: ( + + + + ), + })} + +); + +export default ConnectionModalRedisSections; diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index 5b80dca..fb0d4b8 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -11,7 +11,7 @@ import DataGrid, { } from './DataGrid'; import { V2CellContextMenuView, V2ColumnHeaderContextMenuView, V2TableGroupContextMenuView } from './V2TableContextMenu'; import { setCurrentLanguage, t } from '../i18n'; -import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator'; +import { DUCKDB_ROWID_LOCATOR_COLUMN, ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator'; const storeState = vi.hoisted(() => ({ connections: [ @@ -43,6 +43,11 @@ const storeState = vi.hoisted(() => ({ showColumnType: false, }, setQueryOptions: vi.fn(), + dataEditTransactionOptions: { + commitMode: 'manual' as 'manual' | 'auto', + autoCommitDelayMs: 5000, + }, + setDataEditTransactionOptions: vi.fn(), addTab: vi.fn(), setActiveContext: vi.fn(), tableColumnOrders: {}, @@ -68,6 +73,8 @@ const backendApp = vi.hoisted(() => ({ DBGetColumns: vi.fn(), DBGetIndexes: vi.fn(), DBGetForeignKeys: vi.fn(), + DBGetTriggers: vi.fn(), + DBQuery: vi.fn(), DBShowCreateTable: vi.fn(), })); @@ -115,6 +122,16 @@ vi.mock('./ImportPreviewModal', () => ({ default: () => null, })); +vi.mock('./TableDesigner', () => ({ + default: ({ tab, embedded }: { tab: { tableName?: string; initialTab?: string }; embedded?: boolean }) => ( +
+ SCHEMA DESIGNER + {tab.tableName || 'unknown-table'} + {tab.initialTab || 'columns'} +
+ ), +})); + vi.mock('@ant-design/icons', () => { const Icon = () => ; @@ -156,6 +173,7 @@ vi.mock('@ant-design/icons', () => { vi.mock('@dnd-kit/core', () => ({ DndContext: ({ children }: any) => <>{children}, PointerSensor: vi.fn(), + KeyboardSensor: vi.fn(), MouseSensor: vi.fn(), TouchSensor: vi.fn(), useSensor: vi.fn(() => ({})), @@ -174,6 +192,7 @@ vi.mock('@dnd-kit/sortable', () => ({ isDragging: false, })), horizontalListSortingStrategy: vi.fn(), + sortableKeyboardCoordinates: vi.fn(), arrayMove: (items: any[], from: number, to: number) => { const next = [...items]; const [item] = next.splice(from, 1); @@ -224,9 +243,54 @@ vi.mock('antd', () => { ) : null ); - Modal.useModal = () => [{ info: vi.fn(() => ({ destroy: vi.fn() })) }, null]; + Modal.useModal = () => { + const [infoConfig, setInfoConfig] = React.useState(null); + return [{ + info: vi.fn((config: any) => { + setInfoConfig(config); + return { + destroy: vi.fn(() => { + setInfoConfig(null); + }), + }; + }), + }, infoConfig ?
{infoConfig.content}
: null]; + }; const passthrough = ({ children }: any) => <>{children}; + const Dropdown = ({ children, menu, disabled }: any) => ( + <> + {children} + {!disabled && menu?.items?.map((item: any) => ( + item?.type === 'divider' + ? null + : + ))} + + ); + const Space = ({ children }: any) =>
{children}
; + const Tabs = ({ items = [], activeKey, onChange }: any) => { + const resolvedActiveKey = activeKey ?? items[0]?.key; + const activeItem = items.find((item: any) => item.key === resolvedActiveKey) || items[0]; + return ( +
+
+ {items.map((item: any) => ( + + ))} +
+
{activeItem?.children ?? null}
+
+ ); + }; + const Empty: any = ({ description }: any) =>
{description || 'empty'}
; + Empty.PRESENTED_IMAGE_SIMPLE = 'presented-image-simple'; + const Tag = ({ children }: any) => {children}; + const Radio: any = ({ children }: any) => {children}; + Radio.Group = ({ children }: any) =>
{children}
; + Radio.Button = ({ children }: any) => ; const Segmented = ({ value, options, onChange }: any) => (
{(options || []).map((option: any) => ( @@ -256,10 +320,10 @@ vi.mock('antd', () => { message: messageApi, Input, Button, - Dropdown: passthrough, + Dropdown, Form, Pagination: () => null, - Select: () => null, + Select: ({ children }: any) =>
{children}
, Modal, Checkbox: ({ checked, onChange }: any) => , Segmented, @@ -268,6 +332,11 @@ vi.mock('antd', () => { DatePicker: () => null, TimePicker: () => null, AutoComplete: ({ children }: any) => <>{children}, + Tabs, + Empty, + Space, + Tag, + Radio, }; }); @@ -355,6 +424,37 @@ describe('DataGrid commit change set', () => { }); }); + it('uses hidden DuckDB rowid only as locator and excludes it from update values', () => { + const result = buildDataGridCommitChangeSet({ + addedRows: [], + modifiedRows: { + 'row-1': { [GONAVI_ROW_KEY]: 'row-1', NAME: 'new-name', [DUCKDB_ROWID_LOCATOR_COLUMN]: 18 }, + }, + deletedRowKeys: new Set(), + data: [{ [GONAVI_ROW_KEY]: 'row-1', NAME: 'old-name', [DUCKDB_ROWID_LOCATOR_COLUMN]: 17 }], + editLocator: { + strategy: 'duckdb-rowid', + columns: ['rowid'], + valueColumns: [DUCKDB_ROWID_LOCATOR_COLUMN], + hiddenColumns: [DUCKDB_ROWID_LOCATOR_COLUMN], + readOnly: false, + }, + visibleColumnNames: ['NAME'], + rowKeyToString, + normalizeCommitCellValue: normalizeValue, + shouldCommitColumn: commitColumnGuard, + }); + + expect(result).toEqual({ + ok: true, + changes: { + inserts: [], + updates: [{ keys: { rowid: 17 }, values: { NAME: 'new-name' } }], + deletes: [], + }, + }); + }); + it('commits only writable result columns and maps aliases back to table columns', () => { const result = buildDataGridCommitChangeSet({ addedRows: [], @@ -547,9 +647,22 @@ describe('DataGrid DDL interactions', () => { backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] }); backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] }); backendApp.DBGetForeignKeys.mockResolvedValue({ success: true, data: [] }); + backendApp.DBGetTriggers.mockResolvedValue({ success: true, data: [] }); + backendApp.DBQuery.mockResolvedValue({ success: true, data: [] }); backendApp.DBShowCreateTable.mockResolvedValue({ success: true, data: 'CREATE TABLE users' }); setCurrentLanguage('zh-CN'); storeState.appearance.uiVersion = 'legacy'; + storeState.dataEditTransactionOptions = { + commitMode: 'manual', + autoCommitDelayMs: 5000, + }; + storeState.setDataEditTransactionOptions.mockReset(); + storeState.setDataEditTransactionOptions.mockImplementation((options: Partial) => { + storeState.dataEditTransactionOptions = { + ...storeState.dataEditTransactionOptions, + ...options, + }; + }); storeState.addTab.mockReset(); storeState.setActiveContext.mockReset(); testRenderState.latestColumns = []; @@ -588,6 +701,7 @@ describe('DataGrid DDL interactions', () => { }); afterEach(() => { + vi.useRealTimers(); backendApp.ImportData.mockReset(); backendApp.ExportTable.mockReset(); backendApp.ExportData.mockReset(); @@ -596,6 +710,8 @@ describe('DataGrid DDL interactions', () => { backendApp.DBGetColumns.mockReset(); backendApp.DBGetIndexes.mockReset(); backendApp.DBGetForeignKeys.mockReset(); + backendApp.DBGetTriggers.mockReset(); + backendApp.DBQuery.mockReset(); backendApp.DBShowCreateTable.mockReset(); vi.unstubAllGlobals(); }); @@ -693,6 +809,7 @@ describe('DataGrid DDL interactions', () => { connectionId: 'conn-1', dbName: 'main', tableName: 'customers', + objectType: 'table', }); }, ); @@ -1039,6 +1156,49 @@ describe('DataGrid DDL interactions', () => { }); }); + it('exports query-result rows from in-memory data without rerunning ExportQuery', async () => { + backendApp.ExportData.mockResolvedValue({ success: true }); + backendApp.ExportQuery.mockResolvedValue({ success: true }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await waitForEffects(); + + await act(async () => { + await findButton(renderer!, 'HTML').props.onClick(); + }); + + const exportAllButton = findButton(renderer!, t('data_grid.export.all_rows', { count: 2 })); + await act(async () => { + await exportAllButton.props.onClick(); + }); + await waitForEffects(); + + expect(backendApp.ExportData).toHaveBeenCalledTimes(1); + expect(backendApp.ExportData).toHaveBeenCalledWith( + [{ owner: 'sa' }, { owner: 'dbo' }], + ['owner'], + 'export', + 'html', + ); + expect(backendApp.ExportQuery).not.toHaveBeenCalled(); + }); + it('copies loaded column data from the v2 column header context menu', async () => { storeState.appearance.uiVersion = 'v2'; @@ -1229,8 +1389,114 @@ describe('DataGrid DDL interactions', () => { renderer!.unmount(); }); - it('switches the v2 footer field tab into the main fields view', async () => { + it('auto commits pending table edits after the configured delay', async () => { + vi.useFakeTimers(); storeState.appearance.uiVersion = 'v2'; + storeState.dataEditTransactionOptions = { + commitMode: 'auto', + autoCommitDelayMs: 3000, + }; + backendApp.ApplyChanges.mockResolvedValue({ success: true, message: 'ok' }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await waitForEffects(); + + const nameColumn = testRenderState.latestColumns.find((column) => column.key === 'name'); + const contextTarget = { + closest: (selector: string) => selector === '[data-row-key][data-col-name]' + ? { + getAttribute: (name: string) => { + if (name === 'data-row-key') return 'row-1'; + if (name === 'data-col-name') return 'name'; + return null; + }, + } + : null, + } as unknown as HTMLElement; + + const openMenu = async () => { + const cellProps = nameColumn.onCell({ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' }); + await act(async () => { + cellProps.onContextMenu({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + clientX: 160, + clientY: 120, + currentTarget: contextTarget, + target: contextTarget, + }); + }); + }; + + await openMenu(); + await act(async () => { + findButton(renderer!, '复制本行为新增行').props.onClick({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }); + }); + await openMenu(); + await act(async () => { + findButton(renderer!, t('data_grid.context_menu.paste_row_as_new_count', { count: 1 })).props.onClick({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }); + }); + + expect(backendApp.ApplyChanges).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(2999); + await Promise.resolve(); + }); + expect(backendApp.ApplyChanges).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.ApplyChanges).toHaveBeenCalledTimes(1); + expect(backendApp.ApplyChanges.mock.calls[0][3]).toMatchObject({ + inserts: [ + expect.objectContaining({ + id: 1, + name: 'alpha', + }), + ], + updates: [], + deletes: [], + locatorStrategy: 'primary-key', + }); + expect(messageApi.success).toHaveBeenCalledWith('自动提交成功'); + renderer!.unmount(); + }); + + it('switches the v2 footer object tab into the embedded designer view', async () => { + storeState.appearance.uiVersion = 'v2'; + backendApp.DBGetColumns.mockResolvedValueOnce({ + success: true, + data: [ + { name: 'id', type: 'bigint', key: 'PRI', nullable: 'NO', default: '', comment: '' }, + { name: 'name', type: 'varchar(255)', key: '', nullable: 'YES', default: '', comment: '' }, + ], + }); let renderer: ReactTestRenderer; await act(async () => { @@ -1248,12 +1514,12 @@ describe('DataGrid DDL interactions', () => { await waitForEffects(); await act(async () => { - findButton(renderer!, '字段信息').props.onClick(); + findButton(renderer!, '对象设计').props.onClick(); }); const content = textContent(renderer!.root); - expect(content).toContain(t('data_grid.metadata_view.fields_badge')); - expect(content).toContain(t('data_grid.metadata_view.field_count', { count: 2 })); + expect(content).toContain('SCHEMA DESIGNER'); + expect(content).toContain('字段'); expect(content).toContain('id'); expect(content).toContain('name'); }); @@ -1277,9 +1543,9 @@ describe('DataGrid DDL interactions', () => { await waitForEffects(); await act(async () => { - findButton(renderer!, '字段信息').props.onClick(); + findButton(renderer!, '对象设计').props.onClick(); }); - expect(textContent(renderer!.root)).toContain(t('data_grid.metadata_view.fields_badge')); + expect(textContent(renderer!.root)).toContain('SCHEMA DESIGNER'); storeState.appearance.uiVersion = 'legacy'; await act(async () => { @@ -1297,13 +1563,54 @@ describe('DataGrid DDL interactions', () => { await waitForEffects(); const content = textContent(renderer!.root); - expect(content).not.toContain(t('data_grid.metadata_view.field_count', { count: 2 })); + expect(content).not.toContain('SCHEMA DESIGNER'); expect(content).not.toContain('gn-v2-data-grid-fields-view'); expect(content).toContain('数据预览'); expect(content).toContain('结果视图'); expect(content).toContain('字段信息'); }); + it('keeps the v2 fields tab as read-only field info for views', async () => { + storeState.appearance.uiVersion = 'v2'; + backendApp.DBGetColumns.mockResolvedValueOnce({ + success: true, + data: [ + { name: 'id', type: 'bigint', key: '', nullable: 'NO', default: '', comment: '' }, + { name: 'name', type: 'varchar(255)', key: '', nullable: 'YES', default: '', comment: '' }, + ], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await waitForEffects(); + + expect(textContent(renderer!.root)).toContain('字段信息'); + expect(textContent(renderer!.root)).not.toContain('对象设计'); + + await act(async () => { + findButton(renderer!, '字段信息').props.onClick(); + }); + + const content = textContent(renderer!.root); + expect(content).toContain(t('data_grid.metadata_view.fields_badge')); + expect(content).toContain(t('data_grid.metadata_view.field_count', { count: 2 })); + expect(content).toContain('id'); + expect(content).toContain('name'); + expect(content).not.toContain('SCHEMA DESIGNER'); + }); + it('renders the v2 footer DDL view with the Monaco SQL editor', async () => { storeState.appearance.uiVersion = 'v2'; backendApp.DBShowCreateTable.mockResolvedValueOnce({ @@ -1335,10 +1642,48 @@ describe('DataGrid DDL interactions', () => { expect(editors).toHaveLength(1); expect(editors[0].props['data-language']).toBe('sql'); expect(editors[0].props['data-read-only']).toBe('true'); - expect(textContent(editors[0])).toContain('CREATE TABLE users'); + expect(textContent(editors[0])).toContain('CREATE TABLE'); + expect(textContent(editors[0])).toContain('users'); expect(renderer!.root.findAll((node) => node.type === 'pre' && textContent(node).includes('CREATE TABLE users'))).toHaveLength(0); }); + it('formats DuckDB DDL into readable multiline SQL in the v2 view', async () => { + storeState.appearance.uiVersion = 'v2'; + storeState.connections[0].config.type = 'duckdb'; + backendApp.DBShowCreateTable.mockResolvedValueOnce({ + success: true, + data: 'CREATE TABLE customers(customer_id BIGINT, customer_code VARCHAR, city VARCHAR, tier VARCHAR, signup_date DATE, lifetime_value DECIMAL(12,2), PRIMARY KEY(customer_id));', + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await waitForEffects(); + + await act(async () => { + findButton(renderer!, '查看 DDL').props.onClick(); + }); + await waitForEffects(); + + const editors = renderer!.root.findAll((node) => node.props['data-monaco-editor'] === 'true'); + expect(editors).toHaveLength(1); + const ddlText = textContent(editors[0]); + expect(ddlText).toContain('CREATE TABLE customers ('); + expect(ddlText).toContain('customer_id BIGINT,'); + expect(ddlText).toContain('PRIMARY KEY (customer_id)'); + expect(ddlText).toContain('\n'); + }); + it('opens the v2 DDL view as a right sidebar while keeping the table visible', async () => { storeState.appearance.uiVersion = 'v2'; backendApp.DBShowCreateTable.mockResolvedValueOnce({ diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index a9aa455..1a2c6a1 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -20,6 +20,7 @@ import { DataGridV2DdlSideWorkspace, DataGridV2DdlView } from './DataGridV2DdlWo import { DataGridV2ErView, DataGridV2FieldsView } from './DataGridV2MetadataViews'; import { I18nProvider } from '../i18n/provider'; import { getCurrentLanguage, setCurrentLanguage, type LanguagePreference } from '../i18n'; +import { V2CellContextMenuView } from './V2TableContextMenu'; import { cloneShortcutOptions, DEFAULT_SHORTCUT_OPTIONS } from '../utils/shortcuts'; const mockStoreState = vi.hoisted(() => ({ @@ -45,6 +46,11 @@ vi.mock('../store', () => ({ showColumnType: false, }, setQueryOptions: vi.fn(), + dataEditTransactionOptions: { + commitMode: 'manual', + autoCommitDelayMs: 5000, + }, + setDataEditTransactionOptions: vi.fn(), addTab: vi.fn(), setActiveContext: vi.fn(), tableColumnOrders: {}, @@ -112,6 +118,8 @@ describe('DataGrid layout', () => { columnNames={['id', 'name']} loading={false} tableName="users" + dbName="main" + connectionId="conn-1" readOnly pagination={{ current: 1, @@ -128,6 +136,7 @@ describe('DataGrid layout', () => { expect(markup).toContain('data-grid-column-quick-find-action="true"'); expect(markup).toContain('字段显示'); expect(markup).toContain('跳列'); + expect(markup).toContain('对象设计'); expect(markup).toContain('data-grid-page-find="true"'); expect(markup).toContain('data-grid-page-find-prev="true"'); expect(markup).toContain('data-grid-page-find-next="true"'); @@ -778,6 +787,34 @@ describe('DataGrid layout', () => { expect(source).not.toMatch(/ { + const markup = renderToStaticMarkup( + {}} + />, + ); + + expect(markup).toContain('字段信息'); + expect(markup).not.toContain('对象设计'); + }); + it('falls back to the current i18n language when rendered outside I18nProvider', () => { const previousUiVersion = mockStoreState.uiVersion; const previousLanguage = getCurrentLanguage(); @@ -1677,6 +1714,7 @@ describe('DataGrid layout', () => { { expect(markup).toContain('gn-v2-data-grid-table-wrap'); expect(markup).toContain('· main'); expect(markup).toContain('提交事务'); + expect(markup).toContain('手动提交'); expect(markup).toContain('AI 洞察'); }); + it('clears modified cell markers when refreshing the grid', () => { + const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + + expect(source).toMatch(/const handleRefreshGrid = useCallback\(\(\) => \{[\s\S]*setModifiedColumns\(\{\}\);[\s\S]*if \(onReload\) onReload\(\);[\s\S]*\}, \[clearAutoCommitTimer, onReload\]\);/); + }); + + it('renders a cell-level undo action in the v2 context menu for modified cells', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('撤销此单元格修改'); + }); + it('preserves fractional seconds when rendering datetime values', () => { expect(formatCellDisplayText('2026-05-10T09:12:33.456+08:00')).toBe('2026-05-10 09:12:33.456'); }); @@ -1918,6 +1977,7 @@ describe('DataGrid layout', () => { expect(tableMarkup).toContain('data-grid-ddl-action="true"'); expect(tableMarkup).toContain('查看 DDL'); + expect(tableMarkup).toContain('对象设计'); expect(tableMarkup).not.toContain('data-grid-locate-sidebar-action="true"'); const schemaTableMarkup = renderDataGridWithI18n( @@ -1939,6 +1999,7 @@ describe('DataGrid layout', () => { expect(schemaTableMarkup).toContain('data-grid-ddl-action="true"'); expect(schemaTableMarkup).toContain('查看 DDL'); + expect(schemaTableMarkup).toContain('对象设计'); expect(schemaTableMarkup).toContain('data-grid-page-find="true"'); const queryMarkup = renderDataGridWithI18n( @@ -1960,6 +2021,8 @@ describe('DataGrid layout', () => { ); expect(queryMarkup).not.toContain('data-grid-ddl-action="true"'); + expect(queryMarkup).toContain('字段信息'); + expect(queryMarkup).not.toContain('对象设计'); }); it('keeps row copy and paste as context menu actions instead of toolbar buttons', () => { @@ -2123,6 +2186,15 @@ describe('DataGrid layout', () => { expect(source).toContain('.${gridId} .data-grid-inline-editor-input'); }); + it('disables browser autocapitalization for inline cell editors', () => { + const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + + const editorInputCount = source.match(/\{\.\.\.noAutoCapInputProps\}[\s\S]{0,180}className="data-grid-inline-editor-input"/g)?.length || 0; + + expect(source).toContain("import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap';"); + expect(editorInputCount).toBe(2); + }); + it('renders a quick WHERE condition editor when table filters are visible', () => { const markup = renderDataGridWithI18n( { expect(source).toContain('type VirtualTableScrollReference = TableReference & {'); expect(source).toContain('const tableRef = useRef(null);'); expect(source).toContain('resolveDataGridHorizontalWheelDelta({'); + expect(source).toContain('const virtualHorizontalAlignmentRafRef = useRef(null);'); expect(source).toContain('const scheduleVirtualHorizontalWheel = useCallback'); expect(source).toContain('pendingTableHorizontalDeltaRef.current += delta;'); expect(source).toContain('tableHorizontalWheelRafRef.current = requestAnimationFrame'); + expect(source).toContain('const scheduleVirtualHorizontalAlignment = useCallback((preferredLeft?: number) => {'); + expect(source).toContain('virtualHorizontalElementsRef.current = { tableContainer: null, holderEl: null, innerEl: null, headerEl: null };'); + expect(source).toContain('applyVirtualHorizontalOffset(tableContainer, nextLeft, { forceInternalScroll: true });'); + expect(source).toContain('}, [horizontalScrollVisible, scheduleVirtualHorizontalAlignment, tableRenderData, tableScrollX, virtualEditingCell]);'); expect(source).toContain('tableInstance.scrollTo({ left: clampedOffset, top: holderEl.scrollTop });'); + expect(source).toContain('applyVirtualHorizontalOffset(tableContainer, latestExternalScroll.scrollLeft, { forceInternalScroll: true });'); expect(source).toContain('if (externalSyncRafRef.current !== null)'); expect(source).toContain('externalSyncRafRef.current = requestAnimationFrame'); expect(source).toContain('const scheduleSyncExternalScrollFromTargets = useCallback'); @@ -2237,6 +2315,12 @@ describe('DataGrid layout', () => { expect(source).toContain("const useVirtualCellContentContain = false;"); expect(source).toContain("const useVirtualEditableVisibilityHints = !isMacLike && !isV2Ui;"); expect(source).toContain("contain: ${useVirtualRowCellContain ? 'layout paint style' : 'none'};"); + expect(source).toContain('.${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-thumb:hover'); + expect(source).toContain('.${gridId} .ant-table-body::-webkit-scrollbar-thumb:hover'); + expect(source).toContain('.${gridId} .rc-virtual-list-holder::-webkit-scrollbar-thumb:hover'); + expect(source).toContain('.${gridId} .data-grid-external-horizontal-scroll::-webkit-scrollbar-thumb:hover'); + expect(source).toContain('background-clip: border-box;'); + expect(source).toContain('horizontalScrollbarThumbHoverBg'); expect(source).toContain('const handleSharedCellContextMenu = useCallback'); expect(source).toContain('const shouldUsePlainVirtualContent = isV2Ui && !modifiedStyle;'); expect(source).toContain('if (shouldUsePlainVirtualContent) {'); @@ -2259,7 +2343,13 @@ describe('DataGrid layout', () => { expect(css).toContain('width: 66px !important;'); expect(css).toContain('grid-template-columns: 160px 26px 26px !important;'); expect(css).toContain('container-name: gn-v2-data-grid-statusbar;'); - expect(css).toContain('body[data-ui-version="v2"] .gn-v2-data-grid-status-right::-webkit-scrollbar'); + expect(css).toContain('body[data-ui-version="v2"] .gn-v2-data-grid-statusbar::-webkit-scrollbar'); + expect(css).toContain('scrollbar-width: thin;'); + expect(css).toContain('min-width: max-content;'); + expect(css).toContain('flex: 0 0 auto;'); + expect(css).toContain('body[data-ui-version="v2"] .gn-v2-data-grid-status-center {'); + expect(css).not.toContain('.gn-v2-data-grid-status-center > span:last-child {\n display: none;'); + expect(css).not.toContain('.gn-v2-data-grid-status-center > span:nth-child(2) {\n display: none;'); expect(css).toContain('body[data-ui-version="v2"] .gn-v2-data-grid-pagination-wrap::-webkit-scrollbar'); expect(css).toContain('@container gn-v2-data-grid-statusbar (max-width: 960px)'); expect(css).toContain('@container gn-v2-data-grid-statusbar (max-width: 760px)'); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index ae7c717..bc5545f 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -133,6 +133,7 @@ import DataGridPreviewPanel from './DataGridPreviewPanel'; import { DataGridJsonView, DataGridTextView } from './DataGridRecordViews'; import { DataGridV2DdlSideWorkspace, DataGridV2DdlView } from './DataGridV2DdlWorkspace'; import { DataGridV2ErView, DataGridV2FieldsView } from './DataGridV2MetadataViews'; +import TableDesigner from './TableDesigner'; import { useDataGridFilters } from './useDataGridFilters'; import { useDataGridDdlView } from './useDataGridDdlView'; import { useDataGridModalEditors } from './useDataGridModalEditors'; @@ -197,6 +198,12 @@ const CELL_KEY_SEP = '\u0001'; const CELL_SELECTION_DRAG_THRESHOLD_PX = 4; const DATE_TIME_CACHE_LIMIT = 2000; const TABLE_CELL_PREVIEW_MAX_CHARS = 240; +const DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS = [ + { value: 3000, label: '3 秒' }, + { value: 5000, label: '5 秒' }, + { value: 10000, label: '10 秒' }, + { value: 30000, label: '30 秒' }, +]; const DATA_GRID_DISPLAY_RENDER_VERSION = Symbol('DATA_GRID_DISPLAY_RENDER_VERSION'); const DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION = Symbol('DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION'); const DEFAULT_GRID_MONO_FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace'; @@ -783,6 +790,7 @@ interface EditableCellProps { handleSave: (record: Item) => void; focusCell?: (record: Item, dataIndex: string, title: React.ReactNode) => void; columnType?: string; + dbType?: string; inputCellPadding?: React.CSSProperties; as?: any; modifiedColumns?: Record>; @@ -832,6 +840,7 @@ const areEditableCellPropsEqual = (prevProps: EditableCellProps, nextProps: Edit if (prevProps.dataIndex !== nextProps.dataIndex) return false; if (prevProps.title !== nextProps.title) return false; if (prevProps.columnType !== nextProps.columnType) return false; + if (prevProps.dbType !== nextProps.dbType) return false; if (prevProps.darkMode !== nextProps.darkMode) return false; if (prevProps.as !== nextProps.as) return false; if (prevProps.handleSave !== nextProps.handleSave) return false; @@ -869,6 +878,7 @@ const EditableCell: React.FC = React.memo(({ handleSave, focusCell, columnType, + dbType, inputCellPadding, as: Component = 'td', modifiedColumns, @@ -958,7 +968,7 @@ const EditableCell: React.FC = React.memo(({ let childNode = children; - const pickerType = getTemporalPickerType(columnType); + const pickerType = getTemporalPickerType(columnType, dbType); const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[dataIndex] || ''))); const isRowDeleted = deletedRowKeys && rowKeyStr && record?.[GONAVI_ROW_KEY] !== undefined @@ -1033,6 +1043,7 @@ const EditableCell: React.FC = React.memo(({ ) ) : ( void; scrollSnapshot?: { top: number; left: number }; onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void; + toolbarExtraActions?: React.ReactNode; } type GridFilterCondition = FilterCondition & { @@ -1498,10 +1512,11 @@ const VIRTUAL_EDITING_CELL_STYLE: React.CSSProperties = { }; const DataGrid: React.FC = ({ - data, columnNames, loading, tableName, exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false, + data, columnNames, loading, tableName, objectType = 'table', exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false, + resultExportAllSql, onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition, onApplyQuickWhereCondition, - scrollSnapshot, onScrollSnapshotChange + scrollSnapshot, onScrollSnapshotChange, toolbarExtraActions }) => { const connections = useStore(state => state.connections); const addTab = useStore(state => state.addTab); @@ -1512,6 +1527,8 @@ const DataGrid: React.FC = ({ const uiScale = useStore(state => state.uiScale); const queryOptions = useStore(state => state.queryOptions); const setQueryOptions = useStore(state => state.setQueryOptions); + const dataEditTransactionOptions = useStore(state => state.dataEditTransactionOptions); + const setDataEditTransactionOptions = useStore(state => state.setDataEditTransactionOptions); const tableColumnOrders = useStore(state => state.tableColumnOrders); const enableColumnOrderMemory = useStore(state => state.enableColumnOrderMemory); const setTableColumnOrder = useStore(state => state.setTableColumnOrder); @@ -1748,6 +1765,7 @@ const DataGrid: React.FC = ({ const canImport = exportScope === 'table' && !!tableName; const canExport = !!connectionId && (isQueryResultExport || !!tableName); const canViewDdl = exportScope === 'table' && !!connectionId && !!tableName; + const canOpenObjectDesigner = exportScope === 'table' && objectType === 'table' && !!connectionId && !!tableName; const filteredExportSql = useMemo(() => String(exportSqlWithFilter || '').trim(), [exportSqlWithFilter]); const hasFilteredExportSql = exportScope === 'table' && filteredExportSql.length > 0; @@ -1778,10 +1796,12 @@ const DataGrid: React.FC = ({ columnMetaTooltipColor: darkMode ? 'rgba(255, 236, 179, 0.98)' : '#262626', panelFrameColor: darkMode ? 'rgba(0, 0, 0, 0.42)' : 'rgba(0, 0, 0, 0.18)', floatingScrollbarThumbBg: darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(0,0,0,0.44)', + floatingScrollbarThumbHoverBg: darkMode ? 'rgba(255,255,255,0.78)' : 'rgba(0,0,0,0.54)', floatingScrollbarThumbBorderColor: darkMode ? 'rgba(255,255,255,0.26)' : 'rgba(255,255,255,0.52)', floatingScrollbarThumbShadow: (isMacLike || isV2Ui) ? 'none' : (darkMode ? '0 4px 14px rgba(0,0,0,0.42)' : '0 4px 10px rgba(0,0,0,0.20)'), verticalScrollbarTrackBg: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)', horizontalScrollbarThumbBg: darkMode ? 'rgba(255,255,255,0.20)' : 'rgba(0,0,0,0.14)', + horizontalScrollbarThumbHoverBg: darkMode ? 'rgba(255,255,255,0.30)' : 'rgba(0,0,0,0.24)', toolbarDividerColor: darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)', paginationShellBg: darkMode ? `linear-gradient(135deg, rgba(17,22,34,${_glassMode ? Math.max(0.22, opacity * 0.38) : 0.82}) 0%, rgba(10,14,24,${_glassMode ? Math.max(0.28, opacity * 0.46) : 0.9}) 100%)` @@ -1820,8 +1840,8 @@ const DataGrid: React.FC = ({ selectionAccentHex, selectionAccentRgb, columnMetaHintColor, columnMetaTooltipColor, panelFrameColor, - floatingScrollbarThumbBg, floatingScrollbarThumbBorderColor, floatingScrollbarThumbShadow, - verticalScrollbarTrackBg, horizontalScrollbarThumbBg, + floatingScrollbarThumbBg, floatingScrollbarThumbHoverBg, floatingScrollbarThumbBorderColor, floatingScrollbarThumbShadow, + verticalScrollbarTrackBg, horizontalScrollbarThumbBg, horizontalScrollbarThumbHoverBg, toolbarDividerColor, paginationShellBg, paginationShellBorderColor, paginationShellShadow, paginationChipBg, paginationChipBorderColor, paginationHoverBg, @@ -1950,6 +1970,7 @@ const DataGrid: React.FC = ({ const externalSyncRafRef = useRef(null); const tableTargetSyncRafRef = useRef(null); const tableHorizontalWheelRafRef = useRef(null); + const virtualHorizontalAlignmentRafRef = useRef(null); const pendingTableHorizontalDeltaRef = useRef(0); const pendingTableTargetSyncSourceRef = useRef(null); const scrollSnapshotRafRef = useRef(null); @@ -2348,7 +2369,7 @@ const DataGrid: React.FC = ({ if (value === undefined) return undefined; const normalizedName = String(columnName || '').trim(); const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()]; - const temporal = isTemporalColumnType(meta?.type); + const temporal = isTemporalColumnType(meta?.type, dbType); if (!temporal) { return value; @@ -2369,7 +2390,7 @@ const DataGrid: React.FC = ({ return value; }, - [columnMetaMap, columnMetaMapByLowerName] + [columnMetaMap, columnMetaMapByLowerName, dbType] ); const openForeignKeyTarget = useCallback((target: ForeignKeyTarget) => { @@ -2385,6 +2406,7 @@ const DataGrid: React.FC = ({ connectionId, dbName: targetDbName, tableName: refTableName, + objectType: 'table', }); }, [addTab, connectionId, dbName, setActiveContext]); @@ -2457,8 +2479,15 @@ const DataGrid: React.FC = ({ } .${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-thumb { background: ${darkMode ? 'rgba(255,255,255,0.28)' : 'rgba(0,0,0,0.22)'}; + border: 0; + background-clip: border-box; border-radius: 999px; } + .${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-thumb:hover { + background: ${darkMode ? 'rgba(255,255,255,0.38)' : 'rgba(0,0,0,0.32)'}; + border: 0; + background-clip: border-box; + } .${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-track { background: transparent; } @@ -2694,9 +2723,16 @@ const DataGrid: React.FC = ({ .${gridId} .ant-table-body::-webkit-scrollbar-thumb { background: ${floatingScrollbarThumbBg}; border: 1px solid ${floatingScrollbarThumbBorderColor}; + background-clip: border-box; border-radius: 999px; box-shadow: ${floatingScrollbarThumbShadow}; } + .${gridId} .ant-table-body::-webkit-scrollbar-thumb:hover { + background: ${floatingScrollbarThumbHoverBg}; + border: 1px solid ${floatingScrollbarThumbBorderColor}; + background-clip: border-box; + box-shadow: ${floatingScrollbarThumbShadow}; + } .${gridId} .rc-virtual-list-holder { scrollbar-width: thin; scrollbar-color: ${floatingScrollbarThumbBg} transparent; @@ -2713,9 +2749,16 @@ const DataGrid: React.FC = ({ .${gridId} .rc-virtual-list-holder::-webkit-scrollbar-thumb { background: ${floatingScrollbarThumbBg}; border: 1px solid ${floatingScrollbarThumbBorderColor}; + background-clip: border-box; border-radius: 999px; box-shadow: ${floatingScrollbarThumbShadow}; } + .${gridId} .rc-virtual-list-holder::-webkit-scrollbar-thumb:hover { + background: ${floatingScrollbarThumbHoverBg}; + border: 1px solid ${floatingScrollbarThumbBorderColor}; + background-clip: border-box; + box-shadow: ${floatingScrollbarThumbShadow}; + } .${gridId} .data-grid-external-horizontal-scroll { position: absolute; left: ${floatingScrollbarInset}px; @@ -2739,9 +2782,16 @@ const DataGrid: React.FC = ({ .${gridId} .data-grid-external-horizontal-scroll::-webkit-scrollbar-thumb { background: ${horizontalScrollbarThumbBg}; border: 1px solid ${horizontalScrollbarThumbBorderColor}; + background-clip: border-box; border-radius: 999px; box-shadow: ${horizontalScrollbarThumbShadow}; } + .${gridId} .data-grid-external-horizontal-scroll::-webkit-scrollbar-thumb:hover { + background: ${horizontalScrollbarThumbHoverBg}; + border: 1px solid ${horizontalScrollbarThumbBorderColor}; + background-clip: border-box; + box-shadow: ${horizontalScrollbarThumbShadow}; + } .${gridId} .data-grid-external-horizontal-scroll-inner { height: 1px; } @@ -3240,6 +3290,7 @@ const DataGrid: React.FC = ({ canViewDdl, currentConnConfig, dbName, + dbType, tableName, isV2Ui, cellEditMode, @@ -3255,6 +3306,22 @@ const DataGrid: React.FC = ({ }, }); + useEffect(() => { + const handleExternalViewModeChange = (event: Event) => { + const detail = (event as CustomEvent)?.detail || {}; + if (String(detail.connectionId || '') !== String(connectionId || '')) return; + if (String(detail.dbName || '') !== String(dbName || '')) return; + if (String(detail.tableName || '') !== String(tableName || '')) return; + const nextMode = String(detail.viewMode || '').trim(); + if (!nextMode) return; + if (!['table', 'json', 'text', 'fields', 'ddl', 'er'].includes(nextMode)) return; + handleViewModeChange(nextMode as GridViewMode); + }; + + window.addEventListener('gonavi:data-grid:set-view-mode', handleExternalViewModeChange as EventListener); + return () => window.removeEventListener('gonavi:data-grid:set-view-mode', handleExternalViewModeChange as EventListener); + }, [canOpenObjectDesigner, connectionId, dbName, handleViewModeChange, tableName]); + useEffect(() => { if (!isTableSurfaceActive || !isV2Ui || !cellContextMenu.visible) return; const portal = cellContextMenuPortalRef.current; @@ -3970,6 +4037,26 @@ const DataGrid: React.FC = ({ const pendingChangeCount = addedRows.length + Object.keys(modifiedRows).length + deletedRowKeys.size; const hasChanges = pendingChangeCount > 0; + const dataEditCommitMode = dataEditTransactionOptions?.commitMode === 'auto' ? 'auto' : 'manual'; + const dataEditAutoCommitDelayMs = DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS.some((item) => item.value === dataEditTransactionOptions?.autoCommitDelayMs) + ? Number(dataEditTransactionOptions?.autoCommitDelayMs) + : 5000; + const [autoCommitRemainingSeconds, setAutoCommitRemainingSeconds] = useState(null); + const autoCommitTimerRef = useRef | null>(null); + const autoCommitCountdownRef = useRef | null>(null); + const autoCommitChangeTokenRef = useRef(0); + const autoCommitFailedTokenRef = useRef(-1); + const clearAutoCommitTimer = useCallback(() => { + if (autoCommitTimerRef.current) { + clearTimeout(autoCommitTimerRef.current); + autoCommitTimerRef.current = null; + } + if (autoCommitCountdownRef.current) { + clearInterval(autoCommitCountdownRef.current); + autoCommitCountdownRef.current = null; + } + setAutoCommitRemainingSeconds(null); + }, []); const allSelectedAreDeleted = useMemo(() => { if (selectedRowKeys.length === 0) return false; @@ -3987,6 +4074,11 @@ const DataGrid: React.FC = ({ }, [addedRows, rowKeyStr]); const modifiedRowKeySet = useMemo(() => new Set(Object.keys(modifiedRows)), [modifiedRows]); + useEffect(() => { + autoCommitChangeTokenRef.current += 1; + autoCommitFailedTokenRef.current = -1; + }, [addedRows, modifiedRows, deletedRowKeys]); + const rowClassName = useCallback((record: Item) => { const k = record?.[GONAVI_ROW_KEY]; if (k === undefined || k === null) return ''; @@ -4309,6 +4401,45 @@ const DataGrid: React.FC = ({ setCellContextMenu(prev => ({ ...prev, visible: false })); }, [cellContextMenu, handleCellSave, effectiveEditLocator, translateDataGrid]); + const canUndoContextMenuCellChange = useMemo(() => { + const record = cellContextMenu.record; + const dataIndex = String(cellContextMenu.dataIndex || '').trim(); + const rowKey = record?.[GONAVI_ROW_KEY]; + if (!record || !dataIndex || rowKey === undefined || rowKey === null) return false; + const keyStr = rowKeyStr(rowKey); + if (addedRowKeySet.has(keyStr)) return false; + return !!modifiedColumns[keyStr]?.has(dataIndex); + }, [addedRowKeySet, cellContextMenu.dataIndex, cellContextMenu.record, modifiedColumns, rowKeyStr]); + + const handleUndoContextMenuCellChange = useCallback(() => { + const record = cellContextMenu.record; + const dataIndex = String(cellContextMenu.dataIndex || '').trim(); + const rowKey = record?.[GONAVI_ROW_KEY]; + if (!record || !dataIndex || rowKey === undefined || rowKey === null) return; + + const keyStr = rowKeyStr(rowKey); + if (addedRowKeySet.has(keyStr)) { + void message.info('新增行请使用删除选中或整表回滚撤销'); + setCellContextMenu(prev => ({ ...prev, visible: false })); + return; + } + if (!modifiedColumns[keyStr]?.has(dataIndex)) { + setCellContextMenu(prev => ({ ...prev, visible: false })); + return; + } + + const originalRow = data.find((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === keyStr); + if (!originalRow) { + void message.error('未找到该单元格的原始数据,无法撤销'); + setCellContextMenu(prev => ({ ...prev, visible: false })); + return; + } + + handleCellSave({ ...record, [dataIndex]: originalRow[dataIndex] }); + setCellContextMenu(prev => ({ ...prev, visible: false })); + void message.success('已撤销单元格修改'); + }, [addedRowKeySet, cellContextMenu.dataIndex, cellContextMenu.record, data, handleCellSave, modifiedColumns, rowKeyStr]); + const handleCellEditorSave = useCallback(() => { if (!cellEditorMeta) return; if (!isWritableResultColumn(cellEditorMeta.dataIndex, effectiveEditLocator)) { @@ -4350,7 +4481,7 @@ const DataGrid: React.FC = ({ } const columnType = (columnMetaMap[dataIndex] || columnMetaMapByLowerName[dataIndex.toLowerCase()])?.type; - const pickerType = getTemporalPickerType(columnType); + const pickerType = getTemporalPickerType(columnType, dbType); const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(raw || ''))); const fieldName = getCellFieldName(record, dataIndex); if (isDateTimeField) { @@ -4365,7 +4496,7 @@ const DataGrid: React.FC = ({ title, columnType, }); - }, [canModifyData, columnMetaMap, columnMetaMapByLowerName, form, openCellEditor, rowKeyStr]); + }, [canModifyData, columnMetaMap, columnMetaMapByLowerName, dbType, form, openCellEditor, rowKeyStr]); const handleVirtualCellActivate = useCallback((record: Item, dataIndex: string, title: React.ReactNode) => { if (!canModifyData) return; @@ -4495,7 +4626,7 @@ const DataGrid: React.FC = ({ return; } - const pickerType = getTemporalPickerType(editingCell.columnType); + const pickerType = getTemporalPickerType(editingCell.columnType, dbType); const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[editingCell.dataIndex] || ''))); const fieldName = getCellFieldName(record, editingCell.dataIndex); try { @@ -4514,7 +4645,7 @@ const DataGrid: React.FC = ({ closeVirtualInlineEditor(); } } - }, [closeVirtualInlineEditor, form, handleCellSave, virtualEditingCell]); + }, [closeVirtualInlineEditor, dbType, form, handleCellSave, virtualEditingCell]); const pageFindMatches = useMemo(() => collectDataGridFindMatches( mergedDisplayData, @@ -4636,7 +4767,7 @@ const DataGrid: React.FC = ({ displayMap[col] = toFormText(displayVal); // 日期时间类型: 将字符串值转为 dayjs 对象供 DatePicker 使用 const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; - const rowPickerType = getTemporalPickerType(colMeta?.type); + const rowPickerType = getTemporalPickerType(colMeta?.type, dbType); if (rowPickerType && displayVal !== null && displayVal !== undefined) { const dVal = parseToDayjs(displayVal, rowPickerType); formMap[col] = dVal; @@ -4653,7 +4784,7 @@ const DataGrid: React.FC = ({ nullCols, formValues: formMap, }); - }, [canModifyData, mergedDisplayData, data, addedRows, visibleColumnNames, rowKeyStr, columnMetaMap, columnMetaMapByLowerName, openRowEditor, translateDataGrid]); + }, [canModifyData, mergedDisplayData, data, addedRows, visibleColumnNames, rowKeyStr, columnMetaMap, columnMetaMapByLowerName, dbType, openRowEditor, translateDataGrid]); const openCurrentViewRowEditor = useCallback(() => { if (!canModifyData) return; @@ -4820,7 +4951,7 @@ const DataGrid: React.FC = ({ if (!isWritableResultColumn(col, effectiveEditLocator)) return; if (val && dayjs.isDayjs(val)) { const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; - const rowPickerType = getTemporalPickerType(colMeta?.type); + const rowPickerType = getTemporalPickerType(colMeta?.type, dbType); convertedValues[col] = formatFromDayjs(val as dayjs.Dayjs, rowPickerType); } else { convertedValues[col] = val; @@ -4839,7 +4970,7 @@ const DataGrid: React.FC = ({ // 日期时间类型: 将 dayjs 对象转回格式化字符串 if (nextVal && dayjs.isDayjs(nextVal)) { const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; - const rowPickerType = getTemporalPickerType(colMeta?.type); + const rowPickerType = getTemporalPickerType(colMeta?.type, dbType); nextVal = formatFromDayjs(nextVal as dayjs.Dayjs, rowPickerType); } const baseVal = baseRawMap[col]; @@ -4854,7 +4985,7 @@ const DataGrid: React.FC = ({ }); closeRowEditor(); - }, [rowEditorRowKey, rowEditorForm, addedRows, visibleColumnNames, rowKeyStr, closeRowEditor, effectiveEditLocator, columnMetaMap, columnMetaMapByLowerName]); + }, [rowEditorRowKey, rowEditorForm, addedRows, visibleColumnNames, rowKeyStr, closeRowEditor, effectiveEditLocator, columnMetaMap, columnMetaMapByLowerName, dbType]); const enableVirtual = isTableSurfaceActive; @@ -4971,6 +5102,7 @@ const DataGrid: React.FC = ({ cellProps.handleSave = handleCellSave; cellProps.focusCell = openCellEditor; cellProps.columnType = displayColumnTypeMap[dataIndex]; + cellProps.dbType = dbType; cellProps.inputCellPadding = inputCellPadding; cellProps.modifiedColumns = modifiedColumns; cellProps.rowKeyStr = rowKeyStr; @@ -5001,7 +5133,7 @@ const DataGrid: React.FC = ({ : undefined; const shouldUsePlainVirtualContent = isV2Ui && !modifiedStyle; if (enableVirtual && enableInlineEditableCell) { - const pickerType = getTemporalPickerType(columnType); + const pickerType = getTemporalPickerType(columnType, dbType); const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[dataIndex] || ''))); const virtualCellStyle = modifiedStyle ? { ...virtualCellWrapperStyle, ...modifiedStyle } : virtualCellWrapperStyle; const virtualEditable = !!col.editable && !rowDeletedForRender; @@ -5074,6 +5206,7 @@ const DataGrid: React.FC = ({ ) ) : ( = ({ return originalRenderContent; } }; - }), [columns, useInlineEditableBodyCell, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, handleSharedCellContextMenu, displayColumnTypeMap, inputCellPadding, virtualCellWrapperStyle, modifiedColumns, rowKeyStr, deletedRowKeys, darkMode, virtualEditingCell, form, saveVirtualInlineEditor, lockVirtualInlineTableScroll, closeVirtualInlineEditor, updateFocusedCell]); + }), [columns, useInlineEditableBodyCell, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, handleSharedCellContextMenu, displayColumnTypeMap, dbType, inputCellPadding, virtualCellWrapperStyle, modifiedColumns, rowKeyStr, deletedRowKeys, darkMode, virtualEditingCell, form, saveVirtualInlineEditor, lockVirtualInlineTableScroll, closeVirtualInlineEditor, updateFocusedCell]); const handleAddRow = () => { const newKey = `new-${Date.now()}`; @@ -5272,7 +5405,8 @@ const DataGrid: React.FC = ({ visibleColumnNames, rowKeyStr, normalizeCommitCellValue, shouldCommitColumn, connectionId, tableName, connections, rowLocatorMessages, translateDataGrid]); - const handleCommit = async () => { + const handleCommit = useCallback(async (source: 'manual' | 'auto' = 'manual') => { + clearAutoCommitTimer(); if (!connectionId || !tableName) return; const conn = connections.find(c => c.id === connectionId); if (!conn) return; @@ -5321,6 +5455,7 @@ const DataGrid: React.FC = ({ if (deletes.length > 0) logSql += `DELETE ${deletes.length} rows;\n`; if (res.success) { + autoCommitFailedTokenRef.current = -1; addSqlLog({ id: Date.now().toString(), timestamp: Date.now(), @@ -5330,7 +5465,7 @@ const DataGrid: React.FC = ({ message: res.message, dbName }); - void message.success(translateDataGrid('data_grid.message.transaction_committed')); + void message.success(source === 'auto' ? '自动提交成功' : translateDataGrid('data_grid.message.transaction_committed')); setAddedRows([]); setModifiedRows({}); setDeletedRowKeys(new Set()); @@ -5346,9 +5481,73 @@ const DataGrid: React.FC = ({ message: res.message, dbName }); - void message.error(translateDataGrid('data_grid.message.commit_failed', { detail: res.message })); + if (source === 'auto') { + autoCommitFailedTokenRef.current = autoCommitChangeTokenRef.current; + } + void message.error(source === 'auto' + ? `自动提交失败: ${res.message}` + : translateDataGrid('data_grid.message.commit_failed', { detail: res.message })); } - }; + }, [ + clearAutoCommitTimer, + connectionId, + tableName, + connections, + addedRows, + modifiedRows, + deletedRowKeys, + data, + effectiveEditLocator, + visibleColumnNames, + rowKeyStr, + normalizeCommitCellValue, + shouldCommitColumn, + dbName, + addSqlLog, + onReload, + translateDataGrid, + ]); + + useEffect(() => { + if (!canModifyData || dataEditCommitMode !== 'auto' || !hasChanges) { + clearAutoCommitTimer(); + return; + } + if (autoCommitFailedTokenRef.current === autoCommitChangeTokenRef.current) { + clearAutoCommitTimer(); + return; + } + + const delayMs = dataEditAutoCommitDelayMs; + const dueAt = Date.now() + delayMs; + const updateRemaining = () => { + setAutoCommitRemainingSeconds(Math.max(1, Math.ceil((dueAt - Date.now()) / 1000))); + }; + clearAutoCommitTimer(); + updateRemaining(); + autoCommitCountdownRef.current = setInterval(updateRemaining, 250); + autoCommitTimerRef.current = setTimeout(() => { + autoCommitTimerRef.current = null; + if (autoCommitCountdownRef.current) { + clearInterval(autoCommitCountdownRef.current); + autoCommitCountdownRef.current = null; + } + setAutoCommitRemainingSeconds(null); + void handleCommit('auto'); + }, delayMs); + + return clearAutoCommitTimer; + }, [ + canModifyData, + dataEditCommitMode, + dataEditAutoCommitDelayMs, + hasChanges, + pendingChangeCount, + handleCommit, + clearAutoCommitTimer, + ]); + + useEffect(() => clearAutoCommitTimer, [clearAutoCommitTimer]); const copyToClipboard = useCallback((text: string) => { navigator.clipboard.writeText(text).catch(console.error); @@ -5853,12 +6052,15 @@ const DataGrid: React.FC = ({ }, [tableName, filterConditions, quickWhereCondition, sortInfo, pkColumns, displayOutputColumnNames]); const queryResultCurrentPageRows = useMemo(() => { + if (isQueryResultExport) { + return mergedDisplayData; + } if (!pagination) { return mergedDisplayData; } const offset = Math.max(0, (pagination.current - 1) * pagination.pageSize); return mergedDisplayData.slice(offset, offset + pagination.pageSize); - }, [mergedDisplayData, pagination]); + }, [isQueryResultExport, mergedDisplayData, pagination]); const exportQueryResultRows = useCallback(async (format: string, scope: QueryResultExportScope) => { if (scope === 'selected') { @@ -5878,8 +6080,13 @@ const DataGrid: React.FC = ({ await exportData(queryResultCurrentPageRows, format); return; } + const exportAllSql = String(resultExportAllSql || '').trim(); + if (exportAllSql && connectionId) { + await exportByQuery(exportAllSql, format, tableName || 'query_result'); + return; + } await exportData(mergedDisplayData, format); - }, [exportData, mergedDisplayData, queryResultCurrentPageRows, rowKeyStr, selectedRowKeys, translateDataGrid]); + }, [connectionId, exportByQuery, exportData, mergedDisplayData, queryResultCurrentPageRows, resultExportAllSql, rowKeyStr, selectedRowKeys, tableName, translateDataGrid]); const openQueryResultExportScopeModal = useCallback((format: string) => { let instance: { destroy: () => void } | null = null; @@ -5905,7 +6112,7 @@ const DataGrid: React.FC = ({ {translateDataGrid('data_grid.export.current_page_rows', { count: queryResultCurrentPageRows.length })}
@@ -5914,7 +6121,7 @@ const DataGrid: React.FC = ({ okButtonProps: { style: { display: 'none' } }, maskClosable: true, }); - }, [exportQueryResultRows, mergedDisplayData.length, modal, queryResultCurrentPageRows.length, selectedRowKeys.length, translateDataGrid]); + }, [exportQueryResultRows, mergedDisplayData.length, modal, queryResultCurrentPageRows.length, resultExportAllSql, selectedRowKeys.length, translateDataGrid]); // Context Menu Export const handleExportSelected = useCallback(async (format: string, record: any) => { @@ -5989,6 +6196,9 @@ const DataGrid: React.FC = ({ handleCopyColumnData(cellContextMenu.dataIndex); closeMenu(); return; + case 'undo-cell-change': + handleUndoContextMenuCellChange(); + return; case 'set-null': handleCellSetNull(); return; @@ -6058,6 +6268,7 @@ const DataGrid: React.FC = ({ getTargets, handleBatchFillToSelected, handleCellSetNull, + handleUndoContextMenuCellChange, handleCopyContextMenuFieldName, handleCopyCsv, handleCopyDelete, @@ -6379,7 +6590,7 @@ const DataGrid: React.FC = ({ return { holderEl, clampedOffset, currentOffset }; }, [resolveVirtualHorizontalElements, tableScrollX]); - const applyVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement, nextOffset: number) => { + const applyVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement, nextOffset: number, options?: { forceInternalScroll?: boolean }) => { const synced = syncVirtualHorizontalVisualOffset(tableContainer, nextOffset); if (!synced) { return false; @@ -6387,7 +6598,7 @@ const DataGrid: React.FC = ({ const { holderEl, clampedOffset, currentOffset } = synced; const deltaX = clampedOffset - currentOffset; - if (Math.abs(deltaX) < 0.5) return true; + if (Math.abs(deltaX) < 0.5 && !options?.forceInternalScroll) return true; const tableInstance = tableRef.current; if (tableInstance && typeof tableInstance.scrollTo === 'function') { @@ -6406,6 +6617,34 @@ const DataGrid: React.FC = ({ return true; }, [syncVirtualHorizontalVisualOffset]); + const scheduleVirtualHorizontalAlignment = useCallback((preferredLeft?: number) => { + if (!enableVirtual || !isTableSurfaceActive) return; + if (virtualHorizontalAlignmentRafRef.current !== null) { + cancelAnimationFrame(virtualHorizontalAlignmentRafRef.current); + } + virtualHorizontalAlignmentRafRef.current = requestAnimationFrame(() => { + virtualHorizontalAlignmentRafRef.current = null; + const tableContainer = tableContainerRef.current; + if (!(tableContainer instanceof HTMLElement)) return; + + virtualHorizontalElementsRef.current = { tableContainer: null, holderEl: null, innerEl: null, headerEl: null }; + const externalScroll = externalHorizontalScrollRef.current; + const nextLeft = Math.max(0, preferredLeft ?? externalScroll?.scrollLeft ?? lastTableScrollLeftRef.current); + const applied = applyVirtualHorizontalOffset(tableContainer, nextLeft, { forceInternalScroll: true }); + const resolvedLeft = applied ? readVirtualHorizontalOffset(tableContainer) : nextLeft; + lastTableScrollLeftRef.current = resolvedLeft; + if (externalScroll && Math.abs(externalScroll.scrollLeft - resolvedLeft) > 1) { + externalScroll.scrollLeft = resolvedLeft; + } + lastExternalScrollLeftRef.current = externalScroll?.scrollLeft ?? resolvedLeft; + requestAnimationFrame(() => { + const latestContainer = tableContainerRef.current; + if (!(latestContainer instanceof HTMLElement)) return; + syncVirtualHorizontalVisualOffset(latestContainer, resolvedLeft); + }); + }); + }, [applyVirtualHorizontalOffset, enableVirtual, isTableSurfaceActive, readVirtualHorizontalOffset, syncVirtualHorizontalVisualOffset]); + const flushVirtualHorizontalWheel = useCallback((tableContainer: HTMLElement) => { tableHorizontalWheelRafRef.current = null; const delta = pendingTableHorizontalDeltaRef.current; @@ -6654,7 +6893,7 @@ const DataGrid: React.FC = ({ // 虚拟表格路径:通过合成 WheelEvent 驱动 rc-virtual-list 内部状态, // rc-table 自动同步 header scrollLeft。 if (enableVirtual && tableContainer instanceof HTMLElement) { - const applied = applyVirtualHorizontalOffset(tableContainer, latestExternalScroll.scrollLeft); + const applied = applyVirtualHorizontalOffset(tableContainer, latestExternalScroll.scrollLeft, { forceInternalScroll: true }); if (applied) { // WheelEvent 经 rc-virtual-list 处理后状态异步更新,延迟同步 ref requestAnimationFrame(() => { @@ -6943,6 +7182,17 @@ const DataGrid: React.FC = ({ return () => cancelAnimationFrame(rafId); }, [isTableSurfaceActive, totalWidth, mergedDisplayData.length, pagination?.total, pagination?.pageSize, recalculateTableMetrics]); + useEffect(() => { + if (!horizontalScrollVisible) return; + scheduleVirtualHorizontalAlignment(); + return () => { + if (virtualHorizontalAlignmentRafRef.current !== null) { + cancelAnimationFrame(virtualHorizontalAlignmentRafRef.current); + virtualHorizontalAlignmentRafRef.current = null; + } + }; + }, [horizontalScrollVisible, scheduleVirtualHorizontalAlignment, tableRenderData, tableScrollX, virtualEditingCell]); + // 虚拟表列对齐:antd 虚拟表 body 使用
+
(非 ), // 不会自动拉伸列宽到视口。而 header
会被 antd 的 CSS 或 JS // 设置为 width:100% 自动拉伸。强制 header table 宽度等于 scroll.x, @@ -7334,7 +7584,7 @@ const DataGrid: React.FC = ({ const isJson = looksLikeJsonText(sample); const useTextArea = isJson || sample.includes('\n') || sample.length >= 160; const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; - const pickerType = getTemporalPickerType(colMeta?.type); + const pickerType = getTemporalPickerType(colMeta?.type, dbType); const isTemporalValue = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(sample || ''))); const isWritable = isWritableResultColumn(col, effectiveEditLocator); return { @@ -7348,15 +7598,27 @@ const DataGrid: React.FC = ({ isWritable, }; }) - ), [displayColumnNames, columnMetaMap, columnMetaMapByLowerName, effectiveEditLocator, rowEditorOpen, rowEditorRowKey]); + ), [displayColumnNames, columnMetaMap, columnMetaMapByLowerName, dbType, effectiveEditLocator, rowEditorOpen, rowEditorRowKey]); const handleRefreshGrid = useCallback(() => { + clearAutoCommitTimer(); + autoCommitFailedTokenRef.current = -1; setAddedRows([]); setModifiedRows({}); setDeletedRowKeys(new Set()); + setModifiedColumns({}); setSelectedRowKeys([]); if (onReload) onReload(); - }, [onReload]); + }, [clearAutoCommitTimer, onReload]); + + const handleResetPendingChanges = useCallback(() => { + clearAutoCommitTimer(); + autoCommitFailedTokenRef.current = -1; + setAddedRows([]); + setModifiedRows({}); + setDeletedRowKeys(new Set()); + setModifiedColumns({}); + }, [clearAutoCommitTimer]); const handleToggleFilterWithDefault = useCallback(() => { if (!onToggleFilter) return; @@ -7432,6 +7694,10 @@ const DataGrid: React.FC = ({ copiedCellPatchColumnCount={copiedCellPatch ? Object.keys(copiedCellPatch.values).length : 0} hasChanges={hasChanges} pendingChangeCount={pendingChangeCount} + dataEditCommitMode={dataEditCommitMode} + dataEditAutoCommitDelayMs={dataEditAutoCommitDelayMs} + dataEditAutoCommitDelayOptions={DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS} + autoCommitRemainingSeconds={autoCommitRemainingSeconds} canImport={canImport} canExport={canExport} isQueryResultExport={isQueryResultExport} @@ -7440,6 +7706,7 @@ const DataGrid: React.FC = ({ aiShortcutLabel={aiShortcutLabel} legacyAiButtonStyle={legacyAiButtonStyle} paginationTotalCountLoading={pagination?.totalCountLoading} + toolbarExtraActions={toolbarExtraActions} filterConditions={filterConditions} sortInfo={sortInfo} displayColumnNames={displayColumnNames} @@ -7457,12 +7724,9 @@ const DataGrid: React.FC = ({ exportMenu={exportMenu} queryResultCopyMenu={queryResultCopyMenu} dbType={dbType} - onResetPendingChanges={() => { - setAddedRows([]); - setModifiedRows({}); - setDeletedRowKeys(new Set()); - setModifiedColumns({}); - }} + onResetPendingChanges={handleResetPendingChanges} + onDataEditCommitModeChange={(mode) => setDataEditTransactionOptions({ commitMode: mode })} + onDataEditAutoCommitDelayChange={(delayMs) => setDataEditTransactionOptions({ autoCommitDelayMs: delayMs })} onRefresh={handleRefreshGrid} onToggleFilterClick={handleToggleFilterWithDefault} onAddRow={handleAddRow} @@ -7572,15 +7836,32 @@ const DataGrid: React.FC = ({ {viewMode === 'table' ? ( renderDataTableView() ) : isV2Ui && viewMode === 'fields' ? ( - + canOpenObjectDesigner ? ( + + ) : ( + + ) ) : isV2Ui && viewMode === 'ddl' && ddlViewLayout === 'side' ? ( = ({ rowLabel={cellContextMenu.record?.[GONAVI_ROW_KEY] === undefined ? undefined : `row ${String(cellContextMenu.record?.[GONAVI_ROW_KEY])}`} selectedRowCount={selectedRowKeys.length} canModifyData={canModifyData} + canUndoCellChange={canUndoContextMenuCellChange} copiedRowCount={copiedRowsForPaste.length} canPasteCopiedColumns={!!copiedCellPatch} supportsCopyInsert={supportsCopyInsert} @@ -7726,6 +8008,7 @@ const DataGrid: React.FC = ({ copiedRowsForPasteLength={copiedRowsForPaste.length} selectedRowKeysLength={selectedRowKeys.length} copiedCellPatchAvailable={!!copiedCellPatch} + canUndoCellChange={canUndoContextMenuCellChange} supportsCopyInsert={supportsCopyInsert} translate={translateDataGrid} onClose={() => setCellContextMenu(prev => ({ ...prev, visible: false }))} @@ -7743,6 +8026,7 @@ const DataGrid: React.FC = ({ copyRowsForPaste([rowKey]); }} onPasteCopiedRowsAsNew={handlePasteCopiedRowsAsNew} + onUndoCellChange={handleUndoContextMenuCellChange} onSetNull={handleCellSetNull} onEditRow={handleOpenContextMenuRowEditor} onFillToSelected={() => { @@ -7797,6 +8081,7 @@ const DataGrid: React.FC = ({ ({ - Tooltip: ({ children, title }: { children: React.ReactNode; title?: React.ReactNode }) => ( + Tooltip: ({ children, title, rootClassName }: { children: React.ReactNode; title?: React.ReactNode; rootClassName?: string }) => ( <>
{title}
+
{title}
{children} ), @@ -55,6 +56,43 @@ describe('DataGridColumnTitle', () => { expect(markup).toContain('align-items:flex-start'); }); + it('keeps column metadata tooltip readable in light theme', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-tooltip-root-class="gn-data-grid-column-meta-tooltip"'); + expect(markup).toContain('class="gn-data-grid-column-meta-tooltip-content"'); + expect(markup).toContain('color:var(--gn-fg-1, #fff)'); + expect(markup).not.toContain('color:#fff'); + }); + + it('keeps the configured warm metadata tooltip color in dark theme', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('color:rgba(255, 236, 179, 0.98)'); + }); + it('renders foreign-key jump affordance when reference target exists', () => { const markup = renderToStaticMarkup( = ({ return titleNode; } + const tooltipTextColor = darkMode ? columnMetaTooltipColor : 'var(--gn-fg-1, #fff)'; + return ( {hoverLines.join('\n')} )} + rootClassName="gn-data-grid-column-meta-tooltip" styles={{ root: { maxWidth: 640 } }} {...(!darkMode ? { color: 'rgba(0, 0, 0, 0.82)' } : {})} > diff --git a/frontend/src/components/DataGridLegacyCellContextMenu.tsx b/frontend/src/components/DataGridLegacyCellContextMenu.tsx index d748b59..a5a47f1 100644 --- a/frontend/src/components/DataGridLegacyCellContextMenu.tsx +++ b/frontend/src/components/DataGridLegacyCellContextMenu.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { createPortal } from 'react-dom'; -import { CopyOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons'; +import { CopyOutlined, EditOutlined, UndoOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons'; import { t } from '../i18n'; interface CellContextMenuState { @@ -20,6 +20,7 @@ interface DataGridLegacyCellContextMenuProps { copiedRowsForPasteLength: number; selectedRowKeysLength: number; copiedCellPatchAvailable: boolean; + canUndoCellChange: boolean; supportsCopyInsert: boolean; translate?: (key: string, params?: Record) => string; onClose: () => void; @@ -27,6 +28,7 @@ interface DataGridLegacyCellContextMenuProps { onCopyRowData: () => void; onCopyRowForPaste: () => void; onPasteCopiedRowsAsNew: () => void; + onUndoCellChange: () => void; onSetNull: () => void; onEditRow: () => void; onFillToSelected: () => void; @@ -68,6 +70,7 @@ const DataGridLegacyCellContextMenu: React.FC {canModifyData && ( <> +
{ + if (canUndoCellChange) { + onUndoCellChange(); + } + }} + > + + 撤销此单元格修改 +
{translate('data_grid.batch_fill.set_null')}
diff --git a/frontend/src/components/DataGridSecondaryActions.tsx b/frontend/src/components/DataGridSecondaryActions.tsx index 44080b6..4e90cb5 100644 --- a/frontend/src/components/DataGridSecondaryActions.tsx +++ b/frontend/src/components/DataGridSecondaryActions.tsx @@ -17,6 +17,7 @@ export type DataGridSecondaryActionsTranslate = (key: string, params?: I18nParam export interface DataGridSecondaryActionsProps { isV2Ui: boolean; canViewDdl: boolean; + canOpenObjectDesigner: boolean; viewMode: GridViewMode; ddlLoading: boolean; showColumnComment: boolean; @@ -39,6 +40,7 @@ export interface DataGridSecondaryActionsProps { const DataGridSecondaryActions: React.FC = ({ isV2Ui, canViewDdl, + canOpenObjectDesigner, viewMode, ddlLoading, showColumnComment, @@ -58,9 +60,13 @@ const DataGridSecondaryActions: React.FC = ({ translate = defaultTranslate, }) => { if (isV2Ui) { + const fieldsActionLabel = canOpenObjectDesigner + ? '对象设计' + : translate('data_grid.column_settings.field_info'); + const fieldsActionIcon = canOpenObjectDesigner ? : ; const viewTabItems: Array<{ key: GridViewMode; label: string; icon: React.ReactNode; disabled?: boolean }> = [ { key: 'table', label: translate('data_grid.secondary.data_preview'), icon: }, - { key: 'fields', label: translate('data_grid.column_settings.field_info'), icon: }, + { key: 'fields', label: fieldsActionLabel, icon: fieldsActionIcon }, { key: 'ddl', label: translate('data_grid.secondary.view_ddl'), icon: , disabled: !canViewDdl }, { key: 'er', label: translate('data_grid.secondary.er_diagram'), icon: }, ]; diff --git a/frontend/src/components/DataGridToolbarFrame.tsx b/frontend/src/components/DataGridToolbarFrame.tsx index cfc8441..31fc08b 100644 --- a/frontend/src/components/DataGridToolbarFrame.tsx +++ b/frontend/src/components/DataGridToolbarFrame.tsx @@ -66,6 +66,10 @@ export interface DataGridToolbarFrameProps { copiedCellPatchColumnCount: number; hasChanges: boolean; pendingChangeCount: number; + dataEditCommitMode: 'manual' | 'auto'; + dataEditAutoCommitDelayMs: number; + dataEditAutoCommitDelayOptions: Array<{ value: number; label: string }>; + autoCommitRemainingSeconds: number | null; canImport: boolean; canExport: boolean; isQueryResultExport: boolean; @@ -74,6 +78,7 @@ export interface DataGridToolbarFrameProps { aiShortcutLabel: string; legacyAiButtonStyle?: React.CSSProperties; paginationTotalCountLoading?: boolean; + toolbarExtraActions?: React.ReactNode; filterConditions: GridFilterCondition[]; sortInfo: GridSortInfo[]; displayColumnNames: string[]; @@ -92,6 +97,8 @@ export interface DataGridToolbarFrameProps { queryResultCopyMenu: MenuProps['items']; dbType: string; onResetPendingChanges: () => void; + onDataEditCommitModeChange: (mode: 'manual' | 'auto') => void; + onDataEditAutoCommitDelayChange: (delayMs: number) => void; onRefresh: () => void; onToggleFilterClick: () => void; onAddRow: () => void; @@ -160,6 +167,10 @@ const DataGridToolbarFrame: React.FC = ({ copiedCellPatchColumnCount, hasChanges, pendingChangeCount, + dataEditCommitMode, + dataEditAutoCommitDelayMs, + dataEditAutoCommitDelayOptions, + autoCommitRemainingSeconds, canImport, canExport, isQueryResultExport, @@ -168,6 +179,7 @@ const DataGridToolbarFrame: React.FC = ({ aiShortcutLabel, legacyAiButtonStyle, paginationTotalCountLoading, + toolbarExtraActions, filterConditions, sortInfo, displayColumnNames, @@ -186,6 +198,8 @@ const DataGridToolbarFrame: React.FC = ({ queryResultCopyMenu, dbType, onResetPendingChanges, + onDataEditCommitModeChange, + onDataEditAutoCommitDelayChange, onRefresh, onToggleFilterClick, onAddRow, @@ -365,6 +379,32 @@ const DataGridToolbarFrame: React.FC = ({ )} {hasChanges && } + + + )} + {dataEditCommitMode === 'auto' && hasChanges && autoCommitRemainingSeconds !== null && ( + + {autoCommitRemainingSeconds}s 后提交 + + )} )} @@ -417,6 +457,13 @@ const DataGridToolbarFrame: React.FC = ({ + {toolbarExtraActions && ( + <> + {renderToolbarDivider()} + {toolbarExtraActions} + + )} + {prefersManualTotalCount && ( <> {renderToolbarDivider()} diff --git a/frontend/src/components/DataSyncModal.entry-mode.test.ts b/frontend/src/components/DataSyncModal.entry-mode.test.ts new file mode 100644 index 0000000..d26c84a --- /dev/null +++ b/frontend/src/components/DataSyncModal.entry-mode.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveDataSyncEntryModePresentation } from './dataSyncEntryMode'; + +describe('resolveDataSyncEntryModePresentation', () => { + it('marks schema compare as a read-only independent entry', () => { + const presentation = resolveDataSyncEntryModePresentation('schemaCompare'); + + expect(presentation.title).toBe('表结构比对'); + expect(presentation.analyzeButtonText).toBe('开始比对'); + expect(presentation.badgeText).toBe('结构比对'); + expect(presentation.readOnly).toBe(true); + }); + + it('marks data compare as a read-only independent entry', () => { + const presentation = resolveDataSyncEntryModePresentation('dataCompare'); + + expect(presentation.title).toBe('数据比对'); + expect(presentation.tableSelectLabel).toContain('比对数据'); + expect(presentation.badgeText).toBe('数据比对'); + expect(presentation.readOnly).toBe(true); + }); + + it('keeps the original sync entry writable', () => { + const presentation = resolveDataSyncEntryModePresentation('sync'); + + expect(presentation.title).toBe('数据同步工作台'); + expect(presentation.analyzeButtonText).toBe('对比差异'); + expect(presentation.badgeText).toBe('同步模式'); + expect(presentation.readOnly).toBe(false); + }); +}); diff --git a/frontend/src/components/DataSyncModal.tsx b/frontend/src/components/DataSyncModal.tsx index 1950ace..a1429b4 100644 --- a/frontend/src/components/DataSyncModal.tsx +++ b/frontend/src/components/DataSyncModal.tsx @@ -7,10 +7,12 @@ import { SavedConnection } from '../types'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter } from '../utils/appearance'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql'; import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert'; import { buildDataSyncRequest, type SourceDatasetMode, validateDataSyncSelection } from './dataSyncRequest'; import { t } from '../i18n'; import { useOptionalI18n } from '../i18n/provider'; +import { resolveDataSyncEntryModePresentation, type DataSyncEntryMode } from './dataSyncEntryMode'; const { Title, Text } = Typography; const { Step } = Steps; const { Option } = Select; @@ -48,26 +50,11 @@ type TableOps = { type WorkflowType = 'sync' | 'migration'; const quoteSqlIdent = (dbType: string, ident: string): string => { - const raw = String(ident || '').trim(); - if (!raw) return raw; - const t = String(dbType || '').toLowerCase(); - if (t === 'mysql' || t === 'mariadb' || t === 'oceanbase' || t === 'diros' || t === 'starrocks' || t === 'sphinx' || t === 'clickhouse' || t === 'tdengine') { - return `\`${raw.replace(/`/g, '``')}\``; - } - if (t === 'sqlserver') { - return `[${raw.replace(/]/g, ']]')}]`; - } - return `"${raw.replace(/"/g, '""')}"`; + return quoteIdentPart(dbType, String(ident || '').trim()); }; const quoteSqlTable = (dbType: string, tableName: string): string => { - const raw = String(tableName || '').trim(); - if (!raw) return raw; - if (!raw.includes('.')) return quoteSqlIdent(dbType, raw); - return raw - .split('.') - .map((part) => quoteSqlIdent(dbType, part)) - .join('.'); + return quoteQualifiedIdent(dbType, String(tableName || '').trim()); }; const toSqlLiteral = (value: any, dbType: string): string => { @@ -114,6 +101,17 @@ const resolveRedisDbIndex = (raw?: string): number => { return Number.isInteger(value) && value >= 0 && value <= 15 ? value : 0; }; +const isServiceNameBackedSyncConnection = (conn?: SavedConnection): boolean => { + const type = String(conn?.config?.type || '').trim().toLowerCase(); + if (type === 'oracle') return true; + if (type !== 'oceanbase') return false; + const explicitProtocol = String((conn?.config as any)?.oceanBaseProtocol || '').trim().toLowerCase(); + if (explicitProtocol === 'oracle') return true; + const params = new URLSearchParams(String(conn?.config?.connectionParams || '')); + const protocol = String(params.get('protocol') || params.get('tenantMode') || '').trim().toLowerCase(); + return protocol === 'oracle'; +}; + const buildSqlPreview = ( previewData: any, tableName: string, @@ -193,7 +191,11 @@ const buildSqlPreview = ( }; }; -const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => { +const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?: DataSyncEntryMode }> = ({ open, onClose, entryMode = 'sync' }) => { + const entryPresentation = resolveDataSyncEntryModePresentation(entryMode); + const isSchemaCompareEntry = entryMode === 'schemaCompare'; + const isDataCompareEntry = entryMode === 'dataCompare'; + const isCompareEntry = entryPresentation.readOnly; const connections = useStore((state) => state.connections); const themeMode = useStore((state) => state.theme); const appearance = useStore((state) => state.appearance); @@ -225,7 +227,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, // Options const [workflowType, setWorkflowType] = useState('sync'); - const [syncContent, setSyncContent] = useState<'data' | 'schema' | 'both'>('data'); + const [syncContent, setSyncContent] = useState<'data' | 'schema' | 'both'>(isSchemaCompareEntry ? 'schema' : 'data'); const [syncMode, setSyncMode] = useState('insert_update'); const [autoAddColumns, setAutoAddColumns] = useState(true); const [targetTableStrategy, setTargetTableStrategy] = useState<'existing_only' | 'auto_create_if_missing' | 'smart'>('existing_only'); @@ -258,7 +260,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, const normalizeConnConfig = (conn: SavedConnection, database?: string) => ( buildRpcConnectionConfig(conn.config, { - database: typeof database === 'string' ? database : (conn.config.database || ''), + database: typeof database === 'string' && !isServiceNameBackedSyncConnection(conn) ? database : (conn.config.database || ''), }) ); @@ -284,8 +286,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, }); return () => { - offLog(); - offProgress(); + if (typeof offLog === 'function') offLog(); + if (typeof offProgress === 'function') offProgress(); }; }, [open]); @@ -307,7 +309,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, setSourceDatasetMode('table'); setSourceQuery(''); setWorkflowType('sync'); - setSyncContent('data'); + setSyncContent(isSchemaCompareEntry ? 'schema' : 'data'); setSyncMode('insert_update'); setAutoAddColumns(true); setTargetTableStrategy('existing_only'); @@ -327,9 +329,48 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, jobIdRef.current = ''; autoScrollRef.current = true; } - }, [open]); + }, [open, isSchemaCompareEntry]); useEffect(() => { + if (isSchemaCompareEntry) { + if (workflowType !== 'sync') { + setWorkflowType('sync'); + } + if (sourceDatasetMode !== 'table') { + setSourceDatasetMode('table'); + } + if (syncContent !== 'schema') { + setSyncContent('schema'); + } + if (syncMode !== 'insert_update') { + setSyncMode('insert_update'); + } + if (targetTableStrategy !== 'existing_only') { + setTargetTableStrategy('existing_only'); + } + if (createIndexes) { + setCreateIndexes(false); + } + return; + } + if (isDataCompareEntry) { + if (workflowType !== 'sync') { + setWorkflowType('sync'); + } + if (syncContent !== 'data') { + setSyncContent('data'); + } + if (syncMode !== 'insert_update') { + setSyncMode('insert_update'); + } + if (targetTableStrategy !== 'existing_only') { + setTargetTableStrategy('existing_only'); + } + if (createIndexes) { + setCreateIndexes(false); + } + return; + } if (workflowType === 'migration') { if (syncMode === 'insert_update') { setSyncMode('insert_only'); @@ -351,7 +392,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, setCreateIndexes(false); } } - }, [workflowType]); + }, [isSchemaCompareEntry, isDataCompareEntry, workflowType, sourceDatasetMode, syncContent, syncMode, targetTableStrategy, createIndexes]); useEffect(() => { if (sourceDatasetMode !== 'query') return; @@ -379,42 +420,42 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, setSourceConnId(connId); setSourceDb(''); const conn = connections.find(c => c.id === connId); - if (conn) { - setLoading(true); - try { - const res = await DBGetDatabases(normalizeConnConfig(conn) as any); - if (res.success) { - const dbRows = Array.isArray(res.data) ? res.data : []; - setSourceDbs(dbRows - .map((r: any) => r?.Database || r?.database || r?.username) - .filter((name: any) => typeof name === 'string' && name.trim() !== '')); - } - } catch(e: any) { + if (conn) { + setLoading(true); + try { + const res = await DBGetDatabases(normalizeConnConfig(conn) as any); + if (res.success) { + const dbRows = Array.isArray(res.data) ? res.data : []; + setSourceDbs(dbRows + .map((r: any) => r?.Database || r?.database || r?.username) + .filter((name: any) => typeof name === 'string' && name.trim() !== '')); + } + } catch(e: any) { message.error(tr('data_sync.message.fetch_source_databases_failed_detail', { detail: e?.message || String(e) })); } - setLoading(false); - } + setLoading(false); + } }; const handleTargetConnChange = async (connId: string) => { setTargetConnId(connId); setTargetDb(''); const conn = connections.find(c => c.id === connId); - if (conn) { - setLoading(true); - try { - const res = await DBGetDatabases(normalizeConnConfig(conn) as any); - if (res.success) { - const dbRows = Array.isArray(res.data) ? res.data : []; - setTargetDbs(dbRows - .map((r: any) => r?.Database || r?.database || r?.username) - .filter((name: any) => typeof name === 'string' && name.trim() !== '')); - } - } catch(e: any) { + if (conn) { + setLoading(true); + try { + const res = await DBGetDatabases(normalizeConnConfig(conn) as any); + if (res.success) { + const dbRows = Array.isArray(res.data) ? res.data : []; + setTargetDbs(dbRows + .map((r: any) => r?.Database || r?.database || r?.username) + .filter((name: any) => typeof name === 'string' && name.trim() !== '')); + } + } catch(e: any) { message.error(tr('data_sync.message.fetch_target_databases_failed_detail', { detail: e?.message || String(e) })); } - setLoading(false); - } + setLoading(false); + } }; const nextToTables = async () => { @@ -428,15 +469,15 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, const dbName = isSourceQueryMode ? targetDb : sourceDb; const conn = connections.find(c => c.id === connId); if (conn) { - const config = normalizeConnConfig(conn, dbName); - const res = await DBGetTables(config as any, dbName); - if (res.success) { - // DBGetTables returns [{Table: "name"}, ...] - const tableRows = Array.isArray(res.data) ? res.data : []; - const tables = tableRows - .map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0]) - .filter((name: any) => typeof name === 'string' && name.trim() !== ''); - setAllTables(tables as string[]); + const config = normalizeConnConfig(conn, dbName); + const res = await DBGetTables(config as any, dbName); + if (res.success) { + // DBGetTables returns [{Table: "name"}, ...] + const tableRows = Array.isArray(res.data) ? res.data : []; + const tables = tableRows + .map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0]) + .filter((name: any) => typeof name === 'string' && name.trim() !== ''); + setAllTables(tables as string[]); setSelectedTables(prev => { const existing = prev.filter((name) => tables.includes(name)); if (isSourceQueryMode) { @@ -444,7 +485,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, } return existing; }); - setCurrentStep(1); + setCurrentStep(1); } else { message.error(res.message ? tr('data_sync.message.fetch_tables_failed_detail', { detail: res.message }) : tr('data_sync.message.fetch_tables_failed')); } @@ -484,6 +525,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, const config = buildDataSyncRequest({ sourceConfig: normalizeConnConfig(sConn, sourceDb), targetConfig: normalizeConnConfig(tConn, targetDb), + sourceDatabase: sourceDb, + targetDatabase: targetDb, selectedTables, sourceDatasetMode, sourceQuery, @@ -539,6 +582,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, const config = buildDataSyncRequest({ sourceConfig: normalizeConnConfig(sConn, sourceDb), targetConfig: normalizeConnConfig(tConn, targetDb), + sourceDatabase: sourceDb, + targetDatabase: targetDb, selectedTables, sourceDatasetMode, sourceQuery, @@ -611,6 +656,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, const config = buildDataSyncRequest({ sourceConfig: normalizeConnConfig(sConn, sourceDb), targetConfig: normalizeConnConfig(tConn, targetDb), + sourceDatabase: sourceDb, + targetDatabase: targetDb, selectedTables, sourceDatasetMode, sourceQuery, @@ -689,7 +736,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, }, [diffTables]); const isSourceQueryMode = sourceDatasetMode === 'query'; - const isMigrationWorkflow = workflowType === 'migration'; + const isMigrationWorkflow = !isCompareEntry && workflowType === 'migration'; const sourceConn = useMemo(() => connections.find(c => c.id === sourceConnId), [connections, sourceConnId]); const targetConn = useMemo(() => connections.find(c => c.id === targetConnId), [connections, targetConnId]); const sourceType = String(sourceConn?.config?.type || '').toLowerCase(); @@ -806,8 +853,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, <> { @@ -841,15 +892,23 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
-
{isMigrationWorkflow ? tr('data_sync.title.migration') : tr('data_sync.title.sync')}
+
+ {isMigrationWorkflow + ? tr('data_sync.title.migration') + : (isCompareEntry ? entryPresentation.heroTitle : tr('data_sync.title.sync'))} +
{isMigrationWorkflow ? tr('data_sync.title.migration_description') - : tr('data_sync.title.sync_description')} + : (isCompareEntry ? entryPresentation.heroDescription : tr('data_sync.title.sync_description'))}
- {isMigrationWorkflow ? : } {isMigrationWorkflow ? tr('data_sync.badge.migration_mode') : tr('data_sync.badge.sync_mode')} + + {isMigrationWorkflow ? : } {isMigrationWorkflow + ? tr('data_sync.badge.migration_mode') + : (isCompareEntry ? entryPresentation.badgeText : tr('data_sync.badge.sync_mode'))} + {sourceConnId ? tr('data_sync.badge.source_selected') : tr('data_sync.badge.source_pending')} {tr('data_sync.badge.table_count', { count: selectedTables.length || 0 })}
@@ -858,7 +917,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, - +
@@ -911,36 +970,48 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
- {tr('data_sync.help.workflow_type')} + {isCompareEntry + ? '当前入口只做差异分析和预览,不会执行同步、建表、补字段或删除数据。' + : tr('data_sync.help.workflow_type')}
- - - - - - - + {!isCompareEntry && ( + + + + )} + {!isSchemaCompareEntry && ( + + + + )} + {isSourceQueryMode && ( void }> = ({ open, message={tr('data_sync.alert.query_mode')} /> )} - - - - - - - - - + {!isCompareEntry && ( + + + + )} + {!isCompareEntry && ( + + + + )} + {!isCompareEntry && ( + + + + )} {isRedisMongoKeyspaceMigration && ( void }> = ({ open, /> )} - - setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}> - {tr('data_sync.option.auto_add_columns')} - - - - setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}> - {tr('data_sync.option.create_indexes')} - - + {(!isCompareEntry || isSchemaCompareEntry) && ( + + setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}> + {isSchemaCompareEntry + ? '生成目标表缺失字段的兼容变更 SQL(仅预览,不执行)' + : tr('data_sync.option.auto_add_columns')} + + + )} + {!isCompareEntry && ( + + setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}> + {tr('data_sync.option.create_indexes')} + + + )} {isMigrationWorkflow && targetTableStrategy !== 'existing_only' && ( void }> = ({ open, style={{ marginBottom: 12 }} /> )} - {!isMigrationWorkflow && ( + {!isCompareEntry && !isMigrationWorkflow && ( void }> = ({ open, {!isSourceQueryMode && ( <>
- {tr('data_sync.help.select_tables')} + {isCompareEntry ? entryPresentation.tableSelectLabel : tr('data_sync.help.select_tables')} setShowSameTables(e.target.checked)}> {tr('data_sync.option.show_same_tables')} @@ -1215,55 +1298,62 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, {currentStep === 2 && (
- - -
- `${syncProgress.current}/${syncProgress.total}`} + -
+
+ `${syncProgress.current}/${syncProgress.total}`} + /> +
- {tr('data_sync.title.execution_log')} -
{ - const el = logBoxRef.current; - if (!el) return; - const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; - autoScrollRef.current = nearBottom; - }} - style={{ - background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(248,250,252,0.92)', - border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', - borderRadius: 14, - padding: 12, - height: 300, - overflowY: 'auto', - fontFamily: 'var(--gn-font-mono)' - }} - > - {syncLogs.map((item, i: number) =>
{renderSyncLogItem(item)}
)} -
+ {isCompareEntry ? '分析日志' : tr('data_sync.title.execution_log')} +
{ + const el = logBoxRef.current; + if (!el) return; + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + autoScrollRef.current = nearBottom; + }} + style={{ + background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(248,250,252,0.92)', + border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', + borderRadius: 14, + padding: 12, + height: 300, + overflowY: 'auto', + fontFamily: 'var(--gn-font-mono)' + }} + > + {syncLogs.map((item, i: number) =>
{renderSyncLogItem(item)}
)} +
)} @@ -1274,26 +1364,38 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, {currentStep === 0 && ( )} - {currentStep === 1 && ( - <> - - - + {currentStep === 1 && ( + <> + + + {isCompareEntry && ( + + )} + {!isCompareEntry && ( + + )} )} {currentStep === 2 && ( <> - - + + )}
@@ -1369,7 +1471,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, label: tr('data_sync.preview.tab.insert', { count: previewData.totalInserts || 0 }), children: (
- {tr('data_sync.preview.selection_hint.insert')} + {isCompareEntry ? '行选择只影响 SQL 预览范围,不会执行写入。' : tr('data_sync.preview.selection_hint.insert')}
void }> = ({ open, label: tr('data_sync.preview.tab.update', { count: previewData.totalUpdates || 0 }), children: (
- {tr('data_sync.preview.selection_hint.update')} + {isCompareEntry ? '行选择只影响 SQL 预览范围,不会执行写入。' : tr('data_sync.preview.selection_hint.update')}
void }> = ({ open, children: (
- {tr('data_sync.preview.selection_hint.delete')} + {isCompareEntry ? '行选择只影响 SQL 预览范围,不会执行写入。' : tr('data_sync.preview.selection_hint.delete')}
void }> = ({ open, showIcon message={ previewHasDataDiff - ? tr('data_sync.preview.sql.data_help') - : tr('data_sync.preview.sql.schema_help') + ? (isCompareEntry ? 'SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,仅用于审核差异。' : tr('data_sync.preview.sql.data_help')) + : (isCompareEntry ? 'SQL 预览展示结构差异建议语句,仅用于审核差异。' : tr('data_sync.preview.sql.schema_help')) } />
diff --git a/frontend/src/components/DataViewer.primary-key.test.tsx b/frontend/src/components/DataViewer.primary-key.test.tsx index 69240a5..1e2febe 100644 --- a/frontend/src/components/DataViewer.primary-key.test.tsx +++ b/frontend/src/components/DataViewer.primary-key.test.tsx @@ -4,7 +4,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TabData } from '../types'; -import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator'; +import { DUCKDB_ROWID_LOCATOR_COLUMN, ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator'; import DataViewer from './DataViewer'; const storeState = vi.hoisted(() => ({ @@ -184,6 +184,26 @@ describe('DataViewer safe editing locator', () => { renderer.unmount(); }); + it('enables table preview editing when primary key metadata uses boolean aliases', async () => { + backendApp.DBGetColumns.mockResolvedValue({ + success: true, + data: [{ column_name: 'ID', isPrimary: true }, { column_name: 'NAME' }], + }); + + const renderer = await renderAndReload(); + + expect(dataGridState.latestProps?.pkColumns).toEqual(['ID']); + expect(dataGridState.latestProps?.editLocator).toMatchObject({ + strategy: 'primary-key', + columns: ['ID'], + valueColumns: ['ID'], + readOnly: false, + }); + expect(dataGridState.latestProps?.readOnly).toBe(false); + expect(messageApi.warning).not.toHaveBeenCalled(); + renderer.unmount(); + }); + it('uses a unique index when the table has no primary key', async () => { backendApp.DBGetColumns.mockResolvedValue({ success: true, @@ -208,6 +228,65 @@ describe('DataViewer safe editing locator', () => { renderer.unmount(); }); + it('keeps DuckDB table preview writable when unique index metadata arrives as a safe locator', async () => { + storeState.connections[0].config.type = 'duckdb'; + storeState.connections[0].config.database = 'main'; + backendApp.DBGetColumns.mockResolvedValue({ + success: true, + data: [{ name: 'slug', key: '' }, { name: 'name', key: '' }], + }); + backendApp.DBGetIndexes.mockResolvedValue({ + success: true, + data: [{ name: 'events_slug_key', columnName: 'slug', nonUnique: 0, seqInIndex: 1, indexType: 'UNIQUE' }], + }); + + const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-unique', dbName: 'main', tableName: 'main.events', title: 'events' })); + + expect(dataGridState.latestProps?.pkColumns).toEqual([]); + expect(dataGridState.latestProps?.editLocator).toMatchObject({ + strategy: 'unique-key', + columns: ['slug'], + valueColumns: ['slug'], + readOnly: false, + }); + expect(dataGridState.latestProps?.readOnly).toBe(false); + expect(messageApi.warning).not.toHaveBeenCalled(); + renderer.unmount(); + }); + + it('uses hidden DuckDB rowid when no primary or unique key is available', async () => { + storeState.connections[0].config.type = 'duckdb'; + storeState.connections[0].config.database = 'main'; + backendApp.DBGetColumns.mockResolvedValue({ + success: true, + data: [{ name: 'name', key: '' }], + }); + backendApp.DBGetIndexes.mockResolvedValue({ + success: true, + data: [], + }); + backendApp.DBQuery.mockResolvedValue({ + success: true, + fields: ['name', DUCKDB_ROWID_LOCATOR_COLUMN], + data: [{ name: 'launch', [DUCKDB_ROWID_LOCATOR_COLUMN]: 17 }], + }); + + const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-rowid', dbName: 'main', tableName: 'main.events', title: 'events' })); + + expect(dataGridState.latestProps?.pkColumns).toEqual([]); + expect(dataGridState.latestProps?.editLocator).toMatchObject({ + strategy: 'duckdb-rowid', + columns: ['rowid'], + valueColumns: [DUCKDB_ROWID_LOCATOR_COLUMN], + hiddenColumns: [DUCKDB_ROWID_LOCATOR_COLUMN], + readOnly: false, + }); + expect(dataGridState.latestProps?.readOnly).toBe(false); + expect(messageApi.warning).not.toHaveBeenCalled(); + expect(backendApp.DBQuery.mock.calls.some((call: any[]) => String(call[2]).includes(`rowid AS "${DUCKDB_ROWID_LOCATOR_COLUMN}"`))).toBe(true); + renderer.unmount(); + }); + it('enables MongoDB table preview editing through the _id locator', async () => { storeState.connections[0].config.type = 'mongodb'; storeState.connections[0].config.database = 'app'; @@ -319,6 +398,28 @@ describe('DataViewer safe editing locator', () => { renderer.unmount(); }); + it('keeps DuckDB table preview writable when primary key metadata arrives for a qualified table name', async () => { + storeState.connections[0].config.type = 'duckdb'; + storeState.connections[0].config.database = 'main'; + backendApp.DBGetColumns.mockResolvedValue({ + success: true, + data: [{ name: 'id', key: 'PRI' }, { name: 'name', key: '' }], + }); + + const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-pri', dbName: 'main', tableName: 'main.events', title: 'events' })); + + expect(dataGridState.latestProps?.pkColumns).toEqual(['id']); + expect(dataGridState.latestProps?.editLocator).toMatchObject({ + strategy: 'primary-key', + columns: ['id'], + valueColumns: ['id'], + readOnly: false, + }); + expect(dataGridState.latestProps?.readOnly).toBe(false); + expect(messageApi.warning).not.toHaveBeenCalled(); + renderer.unmount(); + }); + it('invalidates a stale known total when table data grows after a manual refresh', async () => { storeState.connections[0].config.type = 'mysql'; storeState.connections[0].config.database = 'main'; diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index 381cb49..cc2a516 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -17,6 +17,7 @@ import { validateQuickWhereCondition, } from '../utils/dataGridWhereFilter'; import { + DUCKDB_ROWID_LOCATOR_COLUMN, ORACLE_ROWID_LOCATOR_COLUMN, resolveEditRowLocator, type EditRowLocator, @@ -27,6 +28,7 @@ import { getColumnDefinitionName, getColumnDefinitionType, } from '../utils/columnDefinition'; +import { splitQualifiedNameLast, splitQualifiedNameSegments } from '../utils/qualifiedName'; type ViewerPaginationState = { current: number; @@ -206,42 +208,35 @@ const buildDataViewerBaseSelectSQL = ( locator?: EditRowLocator, ): string => { const quotedTableName = quoteQualifiedIdent(dbType, tableName); - if (locator?.strategy !== 'oracle-rowid') { + if (locator?.strategy !== 'oracle-rowid' && locator?.strategy !== 'duckdb-rowid') { return `SELECT * FROM ${quotedTableName} ${whereSQL}`; } const alias = 'gonavi_row_source'; - const rowIDAlias = quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN); - return `SELECT ${alias}.*, ${alias}.ROWID AS ${rowIDAlias} FROM ${quotedTableName} ${alias} ${whereSQL}`; -}; - -const normalizeDuckDBIdentifier = (raw: string): string => { - const text = String(raw || '').trim(); - if (text.length >= 2) { - const first = text[0]; - const last = text[text.length - 1]; - if ((first === '"' && last === '"') || (first === '`' && last === '`')) { - return text.slice(1, -1).trim(); - } + if (locator?.strategy === 'duckdb-rowid') { + const duckdbRowIDAlias = quoteIdentPart(dbType, DUCKDB_ROWID_LOCATOR_COLUMN); + return `SELECT ${alias}.*, ${alias}.rowid AS ${duckdbRowIDAlias} FROM ${quotedTableName} ${alias} ${whereSQL}`; } - return text; + + const oracleRowIDAlias = quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN); + return `SELECT ${alias}.*, ${alias}.ROWID AS ${oracleRowIDAlias} FROM ${quotedTableName} ${alias} ${whereSQL}`; }; const resolveDuckDBSchemaAndTable = (dbName: string, tableName: string) => { const rawTable = String(tableName || '').trim(); if (!rawTable) return { schemaName: 'main', pureTableName: '' }; - const parts = rawTable.split('.'); - if (parts.length >= 2) { - const pureTableName = normalizeDuckDBIdentifier(parts[parts.length - 1]); - const schemaName = normalizeDuckDBIdentifier(parts[parts.length - 2]); - if (schemaName && pureTableName) { - return { schemaName, pureTableName }; - } + const segments = splitQualifiedNameSegments(rawTable); + if (segments.length >= 2) { + return { + schemaName: segments[segments.length - 2], + pureTableName: segments[segments.length - 1], + }; } - const fallbackSchema = normalizeDuckDBIdentifier(String(dbName || '').trim()) || 'main'; - return { schemaName: fallbackSchema, pureTableName: normalizeDuckDBIdentifier(rawTable) }; + const fallbackParsed = splitQualifiedNameLast(String(dbName || '').trim()); + const fallbackSchema = fallbackParsed.objectName || String(dbName || '').trim() || 'main'; + return { schemaName: fallbackSchema, pureTableName: segments[0] || rawTable }; }; const escapeSQLLiteral = (value: string): string => String(value || '').replace(/'/g, "''"); @@ -566,7 +561,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ const dbType = resolveDataSourceType(config); const dbTypeLower = String(dbType || '').trim().toLowerCase(); - const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros'; + const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'goldendb' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros'; const normalizedQuickWhereCondition = normalizeQuickWhereCondition(quickWhereCondition); const quickWhereValidation = validateQuickWhereCondition(normalizedQuickWhereCondition); if (!quickWhereValidation.ok) { @@ -634,13 +629,16 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ const resultColumns = getTableColumnNames(columnDefs); const locatorColumns = isOracleLikeDialect(dbType) ? [...resultColumns, ORACLE_ROWID_LOCATOR_COLUMN] - : resultColumns; + : (String(dbType || '').trim().toLowerCase() === 'duckdb' + ? [...resultColumns, DUCKDB_ROWID_LOCATOR_COLUMN] + : resultColumns); let nextLocator = localizeDataViewerReadOnlyLocator(resolveEditRowLocator({ dbType, resultColumns: locatorColumns, primaryKeys, indexes, allowOracleRowID: true, + allowDuckDBRowID: String(dbType || '').trim().toLowerCase() === 'duckdb', }), tr); if (nextLocator.readOnly && primaryKeys.length === 0 && !resIndexes?.success && !isOracleLikeDialect(dbType)) { @@ -1154,6 +1152,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ columnNames={columnNames} loading={loading} tableName={tab.tableName} + objectType={tab.objectType || 'table'} exportScope="table" dbName={tab.dbName} connectionId={tab.connectionId} diff --git a/frontend/src/components/DatabaseIcons.test.tsx b/frontend/src/components/DatabaseIcons.test.tsx index 4d5fa5e..ed9afbd 100644 --- a/frontend/src/components/DatabaseIcons.test.tsx +++ b/frontend/src/components/DatabaseIcons.test.tsx @@ -4,11 +4,40 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { DB_ICON_TYPES, getDbIcon, getDbIconLabel } from './DatabaseIcons'; +const BRAND_ICON_CASES: Array<[string, string, string]> = [ + ['elasticsearch', 'Elasticsearch', 'elasticsearch.svg'], + ['oceanbase', 'OceanBase', 'oceanbase.png'], + ['oracle', 'Oracle', 'oracle.ico'], + ['starrocks', 'StarRocks', 'starrocks.png'], + ['kingbase', '金仓', 'kingbase.ico'], + ['dameng', '达梦', 'dameng.png'], + ['vastbase', 'VastBase', 'vastbase.svg'], + ['opengauss', 'OpenGauss', 'opengauss.ico'], + ['gaussdb', 'GaussDB', 'gaussdb.ico'], + ['goldendb', 'GoldenDB', 'goldendb.ico'], + ['highgo', '瀚高', 'highgo.ico'], + ['iris', 'InterSystems IRIS', 'iris.png'], + ['tdengine', 'TDengine', 'tdengine.ico'], + ['iotdb', 'Apache IoTDB', 'iotdb.svg'], + ['rocketmq', 'RocketMQ', 'rocketmq.png'], + ['mqtt', 'MQTT', 'mqtt.svg'], + ['kafka', 'Kafka', 'kafka.png'], + ['rabbitmq', 'RabbitMQ', 'rabbitmq.svg'], + ['chroma', 'Chroma', 'chroma.svg'], + ['qdrant', 'Qdrant', 'qdrant.svg'], + ['jvm', 'JVM', 'jvm.ico'], +]; + describe('DatabaseIcons', () => { - it('includes InterSystems IRIS in the selectable database icons', () => { - expect(DB_ICON_TYPES).toContain('iris'); - expect(getDbIconLabel('iris')).toBe('InterSystems IRIS'); - }); + for (const [type, label, asset] of BRAND_ICON_CASES) { + it(`includes ${label} in the selectable database icons`, () => { + expect(DB_ICON_TYPES).toContain(type); + expect(getDbIconLabel(type)).toBe(label); + const markup = renderToStaticMarkup(<>{getDbIcon(type, undefined, 22)}); + expect(markup).toContain(asset); + expect(markup).toContain(`alt="${type}"`); + }); + } it('wraps database icons in a consistent frame for sidebar sizing', () => { const mysqlMarkup = renderToStaticMarkup(<>{getDbIcon('mysql', undefined, 22)}); diff --git a/frontend/src/components/DatabaseIcons.tsx b/frontend/src/components/DatabaseIcons.tsx index a54a0f5..126a820 100644 --- a/frontend/src/components/DatabaseIcons.tsx +++ b/frontend/src/components/DatabaseIcons.tsx @@ -35,6 +35,7 @@ const DB_DEFAULT_COLORS: Record = { postgres: '#336791', redis: '#DC382D', mongodb: '#47A248', + elasticsearch: '#FEC514', jvm: '#1677FF', kingbase: '#1890FF', dameng: '#E6002D', @@ -45,9 +46,18 @@ const DB_DEFAULT_COLORS: Record = { duckdb: '#FFC107', vastbase: '#0066CC', opengauss: '#2446A8', + gaussdb: '#0B7FAB', + goldendb: '#D97706', highgo: '#00A86B', iris: '#1F6FEB', tdengine: '#2962FF', + iotdb: '#0F766E', + rocketmq: '#EA580C', + mqtt: '#0EA5A4', + kafka: '#F97316', + rabbitmq: '#FF6B35', + chroma: '#7C3AED', + qdrant: '#DC244C', diros: '#0050B3', starrocks: '#00A6A6', sphinx: '#2F5D62', @@ -57,29 +67,90 @@ const DB_DEFAULT_COLORS: Record = { export const getDbDefaultColor = (type: string): string => DB_DEFAULT_COLORS[type?.toLowerCase()] || DB_DEFAULT_COLORS.custom; -// ─── 有品牌 SVG 文件的数据库类型(文件在 /db-icons/ 下) ──── +type BrandAssetConfig = { + background?: string; + borderColor?: string; + iconScale?: number; + src: string; +}; -const BRAND_SVG_TYPES = new Set([ - 'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'clickhouse', 'sqlite', - 'diros', 'sphinx', 'duckdb', 'sqlserver', -]); +// ─── 官方品牌资源(文件在 /db-icons/ 下) ──────────────────── -/** 品牌 SVG 图标:用 加载 /db-icons/*.svg */ -const BrandSvgIcon: React.FC<{ type: string; size: number; color?: string }> = ({ type, size, color }) => { - const bgColor = color || getDbDefaultColor(type); +const BRAND_ASSET_CONFIGS: Record = { + mysql: { src: '/db-icons/mysql.svg' }, + mariadb: { src: '/db-icons/mariadb.svg' }, + oceanbase: { src: '/db-icons/oceanbase.png', iconScale: 0.72 }, + postgres: { src: '/db-icons/postgres.svg' }, + redis: { src: '/db-icons/redis.svg' }, + mongodb: { src: '/db-icons/mongodb.svg' }, + elasticsearch: { src: '/db-icons/elasticsearch.svg' }, + jvm: { src: '/db-icons/jvm.ico', iconScale: 0.72 }, + kingbase: { src: '/db-icons/kingbase.ico', iconScale: 0.72 }, + dameng: { src: '/db-icons/dameng.png', iconScale: 0.72 }, + oracle: { src: '/db-icons/oracle.ico', iconScale: 0.72 }, + sqlserver: { src: '/db-icons/sqlserver.svg' }, + clickhouse: { src: '/db-icons/clickhouse.svg' }, + sqlite: { src: '/db-icons/sqlite.svg' }, + duckdb: { src: '/db-icons/duckdb.svg' }, + vastbase: { src: '/db-icons/vastbase.svg', iconScale: 0.84 }, + opengauss: { src: '/db-icons/opengauss.ico', iconScale: 0.72 }, + gaussdb: { src: '/db-icons/gaussdb.ico', iconScale: 0.72 }, + goldendb: { src: '/db-icons/goldendb.ico', iconScale: 0.72 }, + highgo: { src: '/db-icons/highgo.ico', iconScale: 0.72 }, + iris: { src: '/db-icons/iris.png', iconScale: 0.72 }, + tdengine: { src: '/db-icons/tdengine.ico', iconScale: 0.72 }, + iotdb: { + src: '/db-icons/iotdb.svg', + background: '#0F766E', + borderColor: '#0F766E', + iconScale: 0.82, + }, + rocketmq: { + src: '/db-icons/rocketmq.png', + background: '#0F172A', + borderColor: '#EA580C', + iconScale: 0.84, + }, + mqtt: { + src: '/db-icons/mqtt.svg', + background: '#0F172A', + borderColor: '#0EA5A4', + iconScale: 0.84, + }, + kafka: { src: '/db-icons/kafka.png', iconScale: 0.8 }, + rabbitmq: { src: '/db-icons/rabbitmq.svg', iconScale: 0.74 }, + chroma: { src: '/db-icons/chroma.svg', iconScale: 0.9 }, + qdrant: { src: '/db-icons/qdrant.svg', iconScale: 0.74 }, + diros: { src: '/db-icons/diros.svg' }, + starrocks: { + src: '/db-icons/starrocks.png', + background: '#0B1021', + borderColor: '#00A6A6', + iconScale: 0.84, + }, + sphinx: { src: '/db-icons/sphinx.svg' }, +}; + +const BRAND_ASSET_TYPES = new Set(Object.keys(BRAND_ASSET_CONFIGS)); + +/** 品牌图标:用 加载官方 svg/png/ico 资源 */ +const BrandAssetIcon: React.FC<{ type: string; size: number; color?: string }> = ({ type, size, color }) => { + const config = BRAND_ASSET_CONFIGS[type.toLowerCase()]; + const bgColor = color || config?.borderColor || getDbDefaultColor(type); + const iconScale = config?.iconScale ?? 0.64; return ( {type} @@ -87,98 +158,105 @@ const BrandSvgIcon: React.FC<{ type: string; size: number; color?: string }> = ( ); }; -// ─── 彩色标签图标(fallback) ────────────────────────────── - -/** 通用彩色标签:填充背景 + 白色粗体缩写 */ -const ColorBadge: React.FC<{ size: number; color: string; label: string }> = ({ size, color, label }) => { - const textSize = label.length <= 2 ? size * 0.48 : size * 0.38; - return ( - - - - 2 ? -0.5 : 0} - > - {label} - - - - ); -}; - // ─── 各数据库图标 ─────────────────────────────────────────── -// 有品牌 SVG 的数据库 +// 有品牌官方资源的数据库 const MySQLIcon: React.FC = ({ size = 16, color }) => ( - + ); const MariaDBIcon: React.FC = ({ size = 16, color }) => ( - + ); const OceanBaseIcon: React.FC = ({ size = 16, color }) => ( - + ); const PostgresIcon: React.FC = ({ size = 16, color }) => ( - + ); const RedisIcon: React.FC = ({ size = 16, color }) => ( - + ); const MongoDBIcon: React.FC = ({ size = 16, color }) => ( - + ); const ClickHouseIcon: React.FC = ({ size = 16, color }) => ( - + ); const SQLiteIcon: React.FC = ({ size = 16, color }) => ( - + ); -// 无品牌 SVG → 彩色文字标签 const OracleIcon: React.FC = ({ size = 16, color }) => ( - + ); const SQLServerIcon: React.FC = ({ size = 16, color }) => ( - + ); const DorisIcon: React.FC = ({ size = 16, color }) => ( - + ); const StarRocksIcon: React.FC = ({ size = 16, color }) => ( - + ); const SphinxIcon: React.FC = ({ size = 16, color }) => ( - + ); const DuckDBIcon: React.FC = ({ size = 16, color }) => ( - + ); const KingBaseIcon: React.FC = ({ size = 16, color }) => ( - + ); const DamengIcon: React.FC = ({ size = 16, color }) => ( - + ); const VastBaseIcon: React.FC = ({ size = 16, color }) => ( - + ); const OpenGaussIcon: React.FC = ({ size = 16, color }) => ( - + +); +const GaussDBIcon: React.FC = ({ size = 16, color }) => ( + +); +const GoldenDBIcon: React.FC = ({ size = 16, color }) => ( + ); const HighGoIcon: React.FC = ({ size = 16, color }) => ( - + ); const IrisIcon: React.FC = ({ size = 16, color }) => ( - + ); const TDengineIcon: React.FC = ({ size = 16, color }) => ( - + +); +const IoTDBIcon: React.FC = ({ size = 16, color }) => ( + +); +const RocketMQIcon: React.FC = ({ size = 16, color }) => ( + +); +const MQTTIcon: React.FC = ({ size = 16, color }) => ( + +); +const KafkaIcon: React.FC = ({ size = 16, color }) => ( + +); +const RabbitMQIcon: React.FC = ({ size = 16, color }) => ( + +); +const ChromaIcon: React.FC = ({ size = 16, color }) => ( + +); +const QdrantIcon: React.FC = ({ size = 16, color }) => ( + ); const JVMIcon: React.FC = ({ size = 16, color }) => ( - + +); +const ElasticsearchIcon: React.FC = ({ size = 16, color }) => ( + ); /** Custom — 齿轮图标 */ @@ -197,13 +275,6 @@ const CustomIcon: React.FC = ({ size = 16, color }) => { // ─── 图标注册表 ───────────────────────────────────────────── -const DorisIconFallback: React.FC = ({ size = 16, color }) => ( - -); -const SphinxIconFallback: React.FC = ({ size = 16, color }) => ( - -); - const DB_ICON_MAP: Record> = { mysql: MySQLIcon, mariadb: MariaDBIcon, @@ -224,9 +295,19 @@ const DB_ICON_MAP: Record> = { duckdb: DuckDBIcon, vastbase: VastBaseIcon, opengauss: OpenGaussIcon, + gaussdb: GaussDBIcon, + goldendb: GoldenDBIcon, highgo: HighGoIcon, iris: IrisIcon, tdengine: TDengineIcon, + iotdb: IoTDBIcon, + rocketmq: RocketMQIcon, + mqtt: MQTTIcon, + kafka: KafkaIcon, + rabbitmq: RabbitMQIcon, + chroma: ChromaIcon, + qdrant: QdrantIcon, + elasticsearch: ElasticsearchIcon, custom: CustomIcon, }; @@ -234,11 +315,11 @@ const DB_ICON_MAP: Record> = { export const DB_ICON_TYPES: string[] = [ 'mysql', 'mariadb', 'oceanbase', 'postgres', 'redis', 'mongodb', 'jvm', 'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse', 'starrocks', - 'kingbase', 'dameng', 'vastbase', 'opengauss', 'highgo', 'iris', 'tdengine', 'custom', + 'kingbase', 'dameng', 'vastbase', 'opengauss', 'gaussdb', 'goldendb', 'highgo', 'iris', 'tdengine', 'iotdb', 'rocketmq', 'mqtt', 'kafka', 'rabbitmq', 'chroma', 'qdrant', 'elasticsearch', 'custom', ]; -/** 该类型是否有品牌 SVG 文件 */ -export const hasBrandSvg = (type: string): boolean => BRAND_SVG_TYPES.has(type?.toLowerCase()); +/** 该类型是否有品牌图标资源 */ +export const hasBrandSvg = (type: string): boolean => BRAND_ASSET_TYPES.has(type?.toLowerCase()); /** 获取数据库图标 React 节点 */ export const getDbIcon = (type: string, color?: string, size?: number): React.ReactNode => { @@ -256,7 +337,10 @@ export const getDbIconLabel = (type: string): string => { sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite', starrocks: 'StarRocks', duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦', - vastbase: 'VastBase', opengauss: 'OpenGauss', highgo: '瀚高', iris: 'InterSystems IRIS', tdengine: 'TDengine', + vastbase: 'VastBase', opengauss: 'OpenGauss', gaussdb: 'GaussDB', goldendb: 'GoldenDB', highgo: '瀚高', iris: 'InterSystems IRIS', tdengine: 'TDengine', iotdb: 'Apache IoTDB', rocketmq: 'RocketMQ', mqtt: 'MQTT', kafka: 'Kafka', rabbitmq: 'RabbitMQ', + chroma: 'Chroma', + qdrant: 'Qdrant', + elasticsearch: 'Elasticsearch', custom: '自定义', }; return labels[type?.toLowerCase()] || type; diff --git a/frontend/src/components/DefinitionViewer.object-edit.test.tsx b/frontend/src/components/DefinitionViewer.object-edit.test.tsx new file mode 100644 index 0000000..ac1b7b9 --- /dev/null +++ b/frontend/src/components/DefinitionViewer.object-edit.test.tsx @@ -0,0 +1,378 @@ +import React from 'react'; +import { act, create } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { TabData } from '../types'; +import DefinitionViewer from './DefinitionViewer'; + +const storeState = vi.hoisted(() => ({ + connections: [ + { + id: 'conn-1', + name: 'local', + config: { + type: 'postgres', + host: '127.0.0.1', + port: 5432, + user: 'postgres', + password: '', + database: 'main', + }, + }, + ], + theme: 'light', + addTab: vi.fn(), + setActiveContext: vi.fn(), +})); + +const backendApp = vi.hoisted(() => ({ + DBQuery: vi.fn(), +})); + +vi.mock('../store', () => ({ + useStore: (selector: (state: typeof storeState) => any) => selector(storeState), +})); + +vi.mock('../../wailsjs/go/app/App', () => backendApp); + +vi.mock('@ant-design/icons', () => ({ + EditOutlined: () => , +})); + +vi.mock('./MonacoEditor', () => ({ + default: ({ value, options }: any) => ( +
+      {value}
+    
+ ), +})); + +vi.mock('antd', () => ({ + Spin: ({ tip }: any) =>
{tip}
, + Alert: ({ message, description }: any) =>
{message}{description}
, + Button: ({ children, onClick, icon }: any) => ( + + ), +})); + +const flushPromises = async (count = 6) => { + for (let i = 0; i < count; i += 1) { + await Promise.resolve(); + } +}; + +const findButtonText = (node: any): string => ( + (node.children || []) + .map((item: any) => (typeof item === 'string' ? item : findButtonText(item))) + .join('') +); + +const createTab = (overrides: Partial = {}): TabData => ({ + id: 'view-def-conn-1-main-reporting.active_users', + title: '视图: reporting.active_users', + type: 'view-def', + connectionId: 'conn-1', + dbName: 'main', + viewName: 'reporting.active_users', + viewKind: 'view', + schemaName: 'reporting', + ...overrides, +}); + +describe('DefinitionViewer object edit entry', () => { + beforeEach(() => { + storeState.addTab.mockReset(); + storeState.setActiveContext.mockReset(); + storeState.theme = 'light'; + storeState.connections[0].config.type = 'postgres'; + backendApp.DBQuery.mockReset(); + backendApp.DBQuery.mockResolvedValue({ + success: true, + data: [{ view_definition: 'SELECT id, name FROM users' }], + }); + }); + + it('opens an editable query tab for view definitions', async () => { + let renderer: any; + await act(async () => { + renderer = create(); + await flushPromises(); + }); + + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + + await act(async () => { + button.props.onClick(); + }); + + expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'main' }); + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + title: '修改视图: reporting.active_users', + type: 'query', + connectionId: 'conn-1', + dbName: 'main', + queryMode: 'object-edit', + query: expect.stringContaining('CREATE OR REPLACE VIEW reporting.active_users AS'), + })); + expect(storeState.addTab.mock.calls[0][0].query).toContain('SELECT id, name FROM users;'); + }); + + it('adds CREATE OR REPLACE without duplicating view fragments returned without ddl prefix', async () => { + backendApp.DBQuery.mockResolvedValue({ + success: true, + data: [{ view_definition: 'VIEW reporting.active_users AS\nSELECT id, name FROM users' }], + }); + + let renderer: any; + await act(async () => { + renderer = create(); + await flushPromises(); + }); + + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + + await act(async () => { + button.props.onClick(); + }); + + const query = storeState.addTab.mock.calls[0][0].query; + expect(query).toContain('CREATE OR REPLACE VIEW reporting.active_users AS'); + expect(query).toContain('SELECT id, name FROM users;'); + expect(query).not.toContain('AS\nVIEW reporting.active_users AS'); + }); + + it('opens an editable query tab for routine definitions', async () => { + backendApp.DBQuery.mockResolvedValue({ + success: true, + data: [{ routine_definition: 'CREATE OR REPLACE FUNCTION reporting.refresh_stats() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;' }], + }); + + let renderer: any; + await act(async () => { + renderer = create(); + await flushPromises(); + }); + + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + + await act(async () => { + button.props.onClick(); + }); + + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + title: '修改函数/存储过程: reporting.refresh_stats', + type: 'query', + queryMode: 'object-edit', + query: expect.stringContaining('CREATE OR REPLACE FUNCTION reporting.refresh_stats()'), + })); + }); + + it('uses SQL Server catalog metadata when loading routine definitions', async () => { + storeState.connections[0].config.type = 'sqlserver'; + backendApp.DBQuery.mockResolvedValue({ + success: true, + data: [{ routine_definition: 'CREATE PROCEDURE [reporting].[refresh_stats]\nAS\nSELECT 1;' }], + }); + + let renderer: any; + await act(async () => { + renderer = create(); + await flushPromises(); + }); + + const sql = String(backendApp.DBQuery.mock.calls[0][2] || ''); + expect(sql).toContain('FROM [main].sys.all_sql_modules AS m'); + expect(sql).toContain("WHERE o.name = N'refresh_stats'"); + expect(sql).toContain("AND s.name = N'reporting'"); + expect(sql).not.toContain('OBJECT_DEFINITION'); + expect(String(renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')[0].children.join(''))).toContain('CREATE PROCEDURE [reporting].[refresh_stats]'); + }); + + it('joins SQL Server sp_helptext rows when catalog metadata is empty', async () => { + storeState.connections[0].config.type = 'sqlserver'; + backendApp.DBQuery + .mockResolvedValueOnce({ success: true, data: [] }) + .mockResolvedValueOnce({ + success: true, + data: [ + { Text: 'CREATE PROCEDURE [reporting].[refresh_stats]\n' }, + { Text: 'AS\n' }, + { Text: 'BEGIN\n SELECT 1;\nEND' }, + ], + }); + + let renderer: any; + await act(async () => { + renderer = create(); + await flushPromises(); + }); + + expect(backendApp.DBQuery.mock.calls[1][2]).toBe("EXEC [main].sys.sp_helptext @objname = N'[reporting].[refresh_stats]'"); + const editorText = String(renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')[0].children.join('')); + expect(editorText).toContain('CREATE PROCEDURE [reporting].[refresh_stats]'); + expect(editorText).toContain('BEGIN\n SELECT 1;\nEND'); + }); + + it('adds CREATE OR REPLACE for routine source snippets returned without ddl prefix', async () => { + storeState.connections[0].config.type = 'oracle'; + backendApp.DBQuery.mockResolvedValue({ + success: true, + data: [ + { TEXT: 'PROCEDURE proc_tally2accept(p_id IN NUMBER) IS\n' }, + { TEXT: ' v_count PLS_INTEGER;\n' }, + { TEXT: 'BEGIN\n' }, + { TEXT: ' SELECT COUNT(*) INTO v_count FROM dual;\n' }, + { TEXT: 'END;\n' }, + ], + }); + + let renderer: any; + await act(async () => { + renderer = create(); + await flushPromises(); + }); + + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + + await act(async () => { + button.props.onClick(); + }); + + const query = storeState.addTab.mock.calls[0][0].query; + expect(query).toContain('CREATE OR REPLACE PROCEDURE proc_tally2accept(p_id IN NUMBER)'); + expect(query).toContain('v_count PLS_INTEGER;'); + expect(query).toContain('SELECT COUNT(*) INTO v_count FROM dual;'); + }); + + it('reloads the latest object definition before opening object edit', async () => { + backendApp.DBQuery + .mockResolvedValueOnce({ + success: true, + data: [{ view_definition: 'SELECT id FROM users' }], + }) + .mockResolvedValueOnce({ + success: true, + data: [{ view_definition: 'SELECT id, name, updated_at FROM users' }], + }); + + let renderer: any; + await act(async () => { + renderer = create(); + await flushPromises(); + }); + + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + + await act(async () => { + await button.props.onClick(); + await flushPromises(); + }); + + expect(backendApp.DBQuery).toHaveBeenCalledTimes(2); + const query = storeState.addTab.mock.calls[0][0].query; + expect(query).toContain('SELECT id, name, updated_at FROM users;'); + expect(query).not.toContain('SELECT id FROM users;'); + + const editor = renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')[0]; + expect(String(editor.children.join(''))).toContain('SELECT id, name, updated_at FROM users'); + }); + + it('keeps the current definition visible when refresh for object edit fails', async () => { + backendApp.DBQuery + .mockResolvedValueOnce({ + success: true, + data: [{ view_definition: 'SELECT id, name FROM users' }], + }) + .mockResolvedValueOnce({ + success: false, + message: 'network down', + data: [], + }); + + let renderer: any; + await act(async () => { + renderer = create(); + await flushPromises(); + }); + + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + + await act(async () => { + await button.props.onClick(); + await flushPromises(); + }); + + expect(storeState.addTab).not.toHaveBeenCalled(); + expect(String(renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')[0].children.join(''))).toContain('SELECT id, name FROM users'); + expect(findButtonText(renderer.root)).toContain('刷新最新定义失败'); + expect(findButtonText(renderer.root)).toContain('network down'); + }); + + it('does not keep the previous object definition when switching objects and the new load fails', async () => { + backendApp.DBQuery + .mockResolvedValueOnce({ + success: true, + data: [{ view_definition: 'SELECT id, name FROM users' }], + }) + .mockResolvedValueOnce({ + success: false, + message: 'load failed', + data: [], + }); + + let renderer: any; + await act(async () => { + renderer = create(); + await flushPromises(); + }); + + await act(async () => { + renderer.update(); + await flushPromises(); + }); + + expect(findButtonText(renderer.root)).toContain('加载失败'); + expect(findButtonText(renderer.root)).toContain('load failed'); + expect(renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')).toHaveLength(0); + expect(findButtonText(renderer.root)).not.toContain('SELECT id, name FROM users'); + }); +}); diff --git a/frontend/src/components/DefinitionViewer.tsx b/frontend/src/components/DefinitionViewer.tsx index 1fff34b..60aae46 100644 --- a/frontend/src/components/DefinitionViewer.tsx +++ b/frontend/src/components/DefinitionViewer.tsx @@ -1,12 +1,15 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import Editor from './MonacoEditor'; -import { Spin, Alert } from 'antd'; +import { Button, Spin, Alert } from 'antd'; +import { EditOutlined } from '@ant-design/icons'; import { TabData } from '../types'; import { useStore } from '../store'; import { DBQuery } from '../../wailsjs/go/app/App'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol'; import { useI18n } from '../i18n/provider'; +import { splitQualifiedNameLast } from '../utils/qualifiedName'; +import { buildSqlServerObjectDefinitionQueries } from '../utils/sqlServerObjectDefinition'; interface DefinitionViewerProps { tab: TabData; @@ -29,15 +32,65 @@ const normalizeMySQLViewDDL = (rawDefinition: unknown): string => { return `${normalized};`; }; +const ensureSqlStatementTerminator = (sql: string): string => { + const normalized = String(sql || '').trim(); + if (!normalized) return ''; + return /;\s*$/.test(normalized) ? normalized : `${normalized};`; +}; + +const buildEditableDefinitionSql = (tab: TabData, definition: string, objectLabel: string, objectName: string): string => { + const normalizedDefinition = String(definition || '').trim(); + const header = `-- 修改${objectLabel}: ${objectName}\n-- 请确认语法兼容当前数据库后执行\n`; + if (!normalizedDefinition) { + return `${header}-- 当前对象定义为空,请补全 ${objectName} 的 DDL 后执行\n`; + } + + if (/^\s*--\s*(未找到|暂不支持|当前)/.test(normalizedDefinition)) { + return `${header}${ensureSqlStatementTerminator(normalizedDefinition)}`; + } + + if (tab.type === 'view-def' && !/^\s*create\b/i.test(normalizedDefinition)) { + if (/^\s*view\b/i.test(normalizedDefinition)) { + return `${header}${ensureSqlStatementTerminator(normalizedDefinition.replace(/^\s*view\b/i, 'CREATE OR REPLACE VIEW'))}`; + } + return `${header}CREATE OR REPLACE VIEW ${objectName} AS\n${ensureSqlStatementTerminator(normalizedDefinition)}`; + } + + if ( + tab.type === 'routine-def' + && !/^\s*create\b/i.test(normalizedDefinition) + && /^\s*(function|procedure)\b/i.test(normalizedDefinition) + ) { + return `${header}${ensureSqlStatementTerminator(`CREATE OR REPLACE ${normalizedDefinition}`)}`; + } + + return `${header}${ensureSqlStatementTerminator(normalizedDefinition)}`; +}; + const DefinitionViewer: React.FC = ({ tab }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [definition, setDefinition] = useState(''); + const [openingObjectEdit, setOpeningObjectEdit] = useState(false); + const isMountedRef = useRef(true); + const loadedDefinitionKeyRef = useRef(''); 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 { t } = useI18n(); + const objectIdentityKey = [ + tab.connectionId, + tab.dbName, + tab.type, + tab.viewName, + tab.viewKind, + tab.eventName, + tab.routineName, + tab.routineType, + ].map((item) => String(item || '')).join('||'); const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''"); @@ -46,12 +99,14 @@ const DefinitionViewer: React.FC = ({ tab }) => { if (type === 'custom') { const driver = String(conn?.config?.driver || '').trim().toLowerCase(); if (driver === 'diros' || driver === 'doris') return 'mysql'; + if (driver === 'goldendb' || driver === 'greatdb' || driver === 'gdb') return 'mysql'; if (driver === 'oceanbase') return normalizeOceanBaseProtocol(conn?.config?.oceanBaseProtocol) === 'oracle' ? 'oracle' : 'mysql'; if (driver === 'opengauss' || driver === 'open_gauss' || driver === 'open-gauss') return 'opengauss'; + if (driver === 'gaussdb' || driver === 'gauss_db' || driver === 'gauss-db') return 'gaussdb'; return driver; } if (type === 'oceanbase' && normalizeOceanBaseProtocol(conn?.config?.oceanBaseProtocol) === 'oracle') return 'oracle'; - if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql'; + if (type === 'goldendb' || type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql'; if (type === 'dameng') return 'dm'; return type; }; @@ -65,12 +120,8 @@ const DefinitionViewer: React.FC = ({ tab }) => { }; const parseSchemaAndName = (fullName: string): { schema: string; name: string } => { - const raw = String(fullName || '').trim(); - const idx = raw.lastIndexOf('.'); - if (idx > 0 && idx < raw.length - 1) { - return { schema: raw.substring(0, idx), name: raw.substring(idx + 1) }; - } - return { schema: '', name: raw }; + const parsed = splitQualifiedNameLast(fullName); + return { schema: parsed.parentPath, name: parsed.objectName }; }; const getCaseInsensitiveRawValue = (row: Record, candidateKeys: string[]): any => { @@ -150,12 +201,13 @@ const DefinitionViewer: React.FC = ({ tab }) => { case 'kingbase': case 'highgo': case 'vastbase': - case 'opengauss': { + case 'opengauss': + case 'gaussdb': { const schemaRef = schema || 'public'; return [`SELECT pg_get_viewdef('${escapeSQLLiteral(schemaRef)}.${safeName}'::regclass, true) AS view_definition`]; } case 'sqlserver': - return [`SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(viewName)}')) AS view_definition`]; + return buildSqlServerObjectDefinitionQueries('view', viewName, dbName, 'view_definition'); case 'oracle': case 'dm': if (schema) { @@ -198,12 +250,13 @@ const DefinitionViewer: React.FC = ({ tab }) => { case 'kingbase': case 'highgo': case 'vastbase': - case 'opengauss': { + case 'opengauss': + case 'gaussdb': { const schemaRef = schema || 'public'; return [`SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${escapeSQLLiteral(schemaRef)}' AND p.proname = '${safeName}' LIMIT 1`]; } case 'sqlserver': - return [`SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(routineName)}')) AS routine_definition`]; + return buildSqlServerObjectDefinitionQueries('routine', routineName, dbName, 'routine_definition'); case 'oracle': case 'dm': { const owner = schema ? escapeSQLLiteral(schema).toUpperCase() : (safeDbName ? safeDbName.toUpperCase() : ''); @@ -331,6 +384,19 @@ const DefinitionViewer: React.FC = ({ tab }) => { case 'oracle': case 'dm': return row.view_definition || row.VIEW_DEFINITION || row.text || row.TEXT || Object.values(row)[0] || ''; + case 'sqlserver': { + const directDefinition = getCaseInsensitiveRawValue(row, ['view_definition', 'definition']); + if (directDefinition !== undefined && directDefinition !== null && String(directDefinition).trim() !== '') { + return String(directDefinition); + } + const helpTextDefinition = data + .map((item) => getCaseInsensitiveRawValue(item, ['Text', 'text'])) + .filter((value) => value !== undefined && value !== null) + .map((value) => String(value)) + .join(''); + if (helpTextDefinition.trim()) return helpTextDefinition; + return String(Object.values(row)[0] || ''); + } default: return row.view_definition || row.VIEW_DEFINITION || row.sql || row.SQL || Object.values(row)[0] || ''; } @@ -382,6 +448,19 @@ const DefinitionViewer: React.FC = ({ tab }) => { } return JSON.stringify(row, null, 2); } + case 'sqlserver': { + const directDefinition = getCaseInsensitiveRawValue(data[0], ['routine_definition', 'definition']); + if (directDefinition !== undefined && directDefinition !== null && String(directDefinition).trim() !== '') { + return String(directDefinition); + } + const helpTextDefinition = data + .map((row) => getCaseInsensitiveRawValue(row, ['Text', 'text'])) + .filter((value) => value !== undefined && value !== null) + .map((value) => String(value)) + .join(''); + if (helpTextDefinition.trim()) return helpTextDefinition; + return String(Object.values(data[0])[0] || ''); + } default: { const row = data[0]; return row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row)[0] || ''; @@ -413,112 +492,155 @@ const DefinitionViewer: React.FC = ({ tab }) => { } }; + const loadDefinition = async (): Promise<{ success: boolean; definition?: string; error?: string }> => { + const conn = connections.find(c => c.id === tab.connectionId); + if (!conn) { + return { success: false, error: t('definition_viewer.error.connection_not_found') }; + } + + const dbName = tab.dbName || ''; + const dialect = getMetadataDialect(conn); + const sphinxLike = isSphinxConnection(conn) && dialect === 'mysql'; + + let queries: string[]; + let extractFn: (dialect: string, data: any[]) => string; + let resolvedObjectLabel: string; + + if (tab.type === 'view-def') { + const viewName = tab.viewName || ''; + if (!viewName) { + return { success: false, error: t('definition_viewer.error.view_name_empty') }; + } + queries = buildShowViewQueries(dialect, viewName, dbName, tab.viewKind); + extractFn = extractViewDefinition; + resolvedObjectLabel = tab.viewKind === 'materialized' + ? t('definition_viewer.object.materialized_view') + : t('definition_viewer.object.view'); + } else if (tab.type === 'event-def') { + const eventName = tab.eventName || ''; + if (!eventName) { + return { success: false, error: t('definition_viewer.error.event_name_empty') }; + } + queries = buildShowEventQueries(dialect, eventName, dbName); + extractFn = extractEventDefinition; + resolvedObjectLabel = t('definition_viewer.object.event'); + } else { + const routineName = tab.routineName || ''; + const routineType = tab.routineType || 'FUNCTION'; + if (!routineName) { + return { success: false, error: t('definition_viewer.error.routine_name_empty') }; + } + queries = buildShowRoutineQueries(dialect, routineName, routineType, dbName); + extractFn = extractRoutineDefinition; + resolvedObjectLabel = t('definition_viewer.object.routine'); + } + + if (!queries.length || String(queries[0] || '').startsWith('--')) { + return { + success: true, + definition: String( + queries[0] || `-- ${t('definition_viewer.editor.unsupported_object_definition')}`, + ), + }; + } + + try { + 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: '' } + }; + + const result = await runQueryCandidates(config, dbName, queries); + + if (result.success && Array.isArray(result.data) && result.data.length > 0) { + return { success: true, definition: extractFn(dialect, result.data) }; + } + + if (result.success) { + if (sphinxLike) { + const version = await getVersionHint(config, dbName); + const versionText = version + ? t('definition_viewer.editor.sphinx.version_suffix', { version }) + : ''; + return { + success: true, + definition: `-- ${t('definition_viewer.editor.sphinx.empty_result', { + version: versionText, + object: resolvedObjectLabel, + })}\n-- ${t('definition_viewer.editor.sphinx.compat_queries_hint')}`, + }; + } + return { + success: true, + definition: `-- ${t('definition_viewer.editor.object_definition_not_found', { + object: resolvedObjectLabel, + })}`, + }; + } + + if (sphinxLike) { + const version = await getVersionHint(config, dbName); + const versionText = version + ? t('definition_viewer.editor.sphinx.version_suffix', { version }) + : ''; + const failedMessage = result.message + ? `${t('definition_viewer.editor.sphinx.failed_message_label')}: ${result.message}` + : t('definition_viewer.editor.sphinx.failed_message_unknown'); + return { + success: true, + definition: `-- ${t('definition_viewer.editor.sphinx.unsupported_query', { + version: versionText, + object: resolvedObjectLabel, + })}\n-- ${failedMessage}`, + }; + } + + return { + success: false, + error: result.message || t('definition_viewer.error.query_failed'), + }; + } catch (e: any) { + return { + success: false, + error: t('definition_viewer.error.query_failed_detail', { + detail: e?.message || String(e), + }), + }; + } + }; + useEffect(() => { - const loadDefinition = async () => { + let cancelled = false; + const syncDefinition = async () => { setLoading(true); setError(null); - - const conn = connections.find(c => c.id === tab.connectionId); - if (!conn) { - setError(t('definition_viewer.error.connection_not_found')); - setLoading(false); + const result = await loadDefinition(); + if (cancelled) { return; } - - const dbName = tab.dbName || ''; - const dialect = getMetadataDialect(conn); - const sphinxLike = isSphinxConnection(conn) && dialect === 'mysql'; - - let queries: string[]; - let extractFn: (dialect: string, data: any[]) => string; - let objectLabel: string; - - if (tab.type === 'view-def') { - const viewName = tab.viewName || ''; - if (!viewName) { - setError(t('definition_viewer.error.view_name_empty')); - setLoading(false); - return; - } - queries = buildShowViewQueries(dialect, viewName, dbName, tab.viewKind); - extractFn = extractViewDefinition; - objectLabel = tab.viewKind === 'materialized' - ? t('definition_viewer.object.materialized_view') - : t('definition_viewer.object.view'); - } else if (tab.type === 'event-def') { - const eventName = tab.eventName || ''; - if (!eventName) { - setError(t('definition_viewer.error.event_name_empty')); - setLoading(false); - return; - } - queries = buildShowEventQueries(dialect, eventName, dbName); - extractFn = extractEventDefinition; - objectLabel = t('definition_viewer.object.event'); + if (result.success) { + loadedDefinitionKeyRef.current = objectIdentityKey; + setDefinition(String(result.definition || '')); } else { - const routineName = tab.routineName || ''; - const routineType = tab.routineType || 'FUNCTION'; - if (!routineName) { - setError(t('definition_viewer.error.routine_name_empty')); - setLoading(false); - return; - } - queries = buildShowRoutineQueries(dialect, routineName, routineType, dbName); - extractFn = extractRoutineDefinition; - objectLabel = t('definition_viewer.object.routine'); - } - - if (!queries.length || String(queries[0] || '').startsWith('--')) { - setDefinition(String(queries[0] || `-- ${t('definition_viewer.editor.unsupported_object_definition')}`)); - setLoading(false); - return; - } - - try { - 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: '' } - }; - - const result = await runQueryCandidates(config, dbName, queries); - - if (result.success && Array.isArray(result.data) && result.data.length > 0) { - const def = extractFn(dialect, result.data); - setDefinition(def); - return; - } - - if (result.success) { - if (sphinxLike) { - const version = await getVersionHint(config, dbName); - const versionText = version ? t('definition_viewer.editor.sphinx.version_suffix', { version }) : ''; - setDefinition(`-- ${t('definition_viewer.editor.sphinx.empty_result', { version: versionText, object: objectLabel })}\n-- ${t('definition_viewer.editor.sphinx.compat_queries_hint')}`); - return; - } - setDefinition(`-- ${t('definition_viewer.editor.object_definition_not_found', { object: objectLabel })}`); - } else if (sphinxLike) { - const version = await getVersionHint(config, dbName); - const versionText = version ? t('definition_viewer.editor.sphinx.version_suffix', { version }) : ''; - const failedMessage = result.message - ? `${t('definition_viewer.editor.sphinx.failed_message_label')}: ${result.message}` - : t('definition_viewer.editor.sphinx.failed_message_unknown'); - setDefinition(`-- ${t('definition_viewer.editor.sphinx.unsupported_query', { version: versionText, object: objectLabel })}\n-- ${failedMessage}`); - } else { - setError(result.message || t('definition_viewer.error.query_failed')); - } - } catch (e: any) { - setError(t('definition_viewer.error.query_failed_detail', { detail: e?.message || String(e) })); - } finally { - setLoading(false); + setError(result.error || t('definition_viewer.error.query_failed')); } + setLoading(false); }; - loadDefinition(); - }, [tab.connectionId, tab.dbName, tab.viewName, tab.viewKind, tab.eventName, tab.routineName, tab.routineType, tab.type, connections, t]); + syncDefinition(); + + return () => { + cancelled = true; + }; + }, [tab.connectionId, tab.dbName, tab.viewName, tab.viewKind, tab.eventName, tab.routineName, tab.routineType, tab.type, connections, objectIdentityKey, t]); + + useEffect(() => () => { + isMountedRef.current = false; + }, []); const objectLabel = tab.type === 'view-def' ? (tab.viewKind === 'materialized' ? t('definition_viewer.object.materialized_view') : t('definition_viewer.object.view')) @@ -529,6 +651,44 @@ const DefinitionViewer: React.FC = ({ tab }) => { const loadingTip = tab.type === 'view-def' ? t('definition_viewer.loading.view_definition') : (tab.type === 'event-def' ? t('definition_viewer.loading.event_definition') : t('definition_viewer.loading.routine_definition')); + const normalizedObjectName = String(objectName || '').trim(); + const displayedDefinition = loadedDefinitionKeyRef.current === objectIdentityKey ? definition : ''; + const hasDefinition = String(displayedDefinition || '').trim() !== ''; + + const openObjectEditQuery = async () => { + if (!normalizedObjectName || openingObjectEdit) return; + const dbName = String(tab.dbName || '').trim(); + setOpeningObjectEdit(true); + setError(null); + try { + const result = await loadDefinition(); + if (!isMountedRef.current) { + return; + } + if (!result.success) { + setError(result.error || t('definition_viewer.error.query_failed')); + return; + } + const latestDefinition = String(result.definition || ''); + loadedDefinitionKeyRef.current = objectIdentityKey; + setDefinition(latestDefinition); + const query = buildEditableDefinitionSql(tab, latestDefinition, objectLabel, normalizedObjectName); + setActiveContext({ connectionId: tab.connectionId, dbName }); + addTab({ + id: `query-edit-object-${tab.connectionId}-${dbName}-${Date.now()}`, + title: `修改${objectLabel}: ${normalizedObjectName}`, + type: 'query', + connectionId: tab.connectionId, + dbName, + query, + queryMode: 'object-edit', + }); + } finally { + if (isMountedRef.current) { + setOpeningObjectEdit(false); + } + } + }; if (loading) { return ( @@ -538,7 +698,7 @@ const DefinitionViewer: React.FC = ({ tab }) => { ); } - if (error) { + if (error && !hasDefinition) { return (
@@ -548,17 +708,27 @@ const DefinitionViewer: React.FC = ({ tab }) => { return (
-
- {objectLabel}: {objectName} - {tab.dbName && {t('definition_viewer.field.database')}: {tab.dbName}} - {tab.routineType && {t('definition_viewer.field.type')}: {tab.routineType}} +
+
+ {objectLabel}: {objectName} + {tab.dbName && {t('definition_viewer.field.database')}: {tab.dbName}} + {tab.routineType && {t('definition_viewer.field.type')}: {tab.routineType}} +
+
+ {error && hasDefinition && ( +
+ +
+ )}
({ + theme: 'light', + appearance: { + enabled: true, + opacity: 1, + blur: 0, + uiVersion: 'legacy', + }, +})); + +const backendApp = vi.hoisted(() => ({ + CheckDriverNetworkStatus: vi.fn(), + DownloadDriverPackage: vi.fn(), + GetDriverVersionList: vi.fn(), + GetDriverVersionPackageSize: vi.fn(), + GetDriverStatusList: vi.fn(), + InstallLocalDriverPackage: vi.fn(), + OpenDriverDownloadDirectory: vi.fn(), + RemoveDriverPackage: vi.fn(), + SelectDriverPackageDirectory: vi.fn(), + SelectDriverPackageFile: vi.fn(), +})); + +const runtimeApi = vi.hoisted(() => ({ + EventsOn: vi.fn(() => vi.fn()), +})); + +const messageApi = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + info: vi.fn(), +})); + +vi.mock('../store', () => ({ + useStore: (selector: (state: typeof storeState) => any) => selector(storeState), +})); + +vi.mock('../../wailsjs/go/app/App', () => backendApp); +vi.mock('../../wailsjs/runtime/runtime', () => runtimeApi); + +vi.mock('@ant-design/icons', () => { + const Icon = () => ; + return { + DeleteOutlined: Icon, + DownloadOutlined: Icon, + FileSearchOutlined: Icon, + FolderOpenOutlined: Icon, + InfoCircleFilled: Icon, + ReloadOutlined: Icon, + }; +}); + +describe('driver-agent update prompt placement', () => { + it('keeps revision mismatch prompts inside driver manager only', () => { + expect(driverManagerModalSource).toContain('需要重装'); + expect(driverManagerModalSource).toContain('row.needsUpdate'); + + expect(connectionModalSource).not.toContain('当前数据源驱动代理建议重装'); + expect(connectionModalSource).not.toContain('去驱动管理重装'); + + expect(sidebarSource).not.toContain('warnIfConnectionDriverAgentNeedsUpdate'); + expect(sidebarSource).not.toContain('driver-agent-update-'); + expect(sidebarSource).not.toContain('驱动代理需要重装:'); + }); +}); + +vi.mock('antd', () => { + const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => ( + + ); + + const Input: any = ({ value, onChange, placeholder }: any) => ( + + ); + Input.Search = ({ value, onChange, placeholder }: any) => ( + + ); + + const Select = ({ value, options, disabled, loading, placeholder, onOpenChange, onChange }: any) => ( + + ); + const Progress = () =>
; + const Tag = ({ children }: any) => {children}; + const Switch = ({ checked, onChange, disabled }: any) => ( + + ); + const Space = ({ children }: any) =>
{children}
; + const Text = ({ children }: any) => {children}; + const Paragraph = ({ children }: any) =>
{children}
; + const Typography = { Paragraph, Text }; + const Alert = ({ children, message, description }: any) =>
{children}{message}{description}
; + const Empty: any = ({ description }: any) =>
{description}
; + Empty.PRESENTED_IMAGE_SIMPLE = null; + const Collapse = ({ items }: any) => ( +
{items?.map((item: any) =>
{item.label}{item.children}
)}
+ ); + const Modal: any = ({ children, open, footer, title }: any) => (open ? ( +
+ {children} +
{footer}
+
+ ) : null); + Modal.confirm = vi.fn(); + + return { + Alert, + Button, + Collapse, + Empty, + Input, + Modal, + Progress, + Select, + Space, + Switch, + Tag, + Typography, + message: messageApi, + }; +}); + +const flushPromises = async () => { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +}; + +const textContent = (node: any): string => + (node.children || []) + .map((item: any) => (typeof item === 'string' ? item : textContent(item))) + .join(''); + +const findButton = (renderer: ReactTestRenderer, text: string) => + renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0]; + +describe('DriverManagerModal toolbar actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + backendApp.GetDriverStatusList.mockResolvedValue({ + success: true, + data: { + downloadDir: 'D:/drivers', + drivers: [ + { + type: 'duckdb', + name: 'DuckDB', + builtIn: false, + pinnedVersion: '2.5.6', + runtimeAvailable: false, + packageInstalled: false, + connectable: false, + defaultDownloadUrl: 'builtin://activate/duckdb', + message: '未启用', + }, + ], + }, + }); + backendApp.CheckDriverNetworkStatus.mockResolvedValue({ + success: true, + data: { + reachable: true, + summary: 'ok', + recommendedProxy: false, + proxyConfigured: false, + checks: [], + }, + }); + backendApp.GetDriverVersionList.mockResolvedValue({ + success: true, + data: { + versions: [{ version: '2.5.6', downloadUrl: 'builtin://activate/duckdb', recommended: true }], + }, + }); + backendApp.DownloadDriverPackage.mockImplementation(() => new Promise(() => {})); + backendApp.OpenDriverDownloadDirectory.mockResolvedValue({ success: true }); + backendApp.SelectDriverPackageDirectory.mockResolvedValue({ success: true, data: { path: 'D:/drivers/import' } }); + }); + + it('keeps directory tools enabled while a single driver install is running', async () => { + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushPromises(); + + const installButton = findButton(renderer!, '安装启用'); + const openDirButtonBefore = findButton(renderer!, '打开驱动目录'); + const importDirButtonBefore = findButton(renderer!, '导入驱动目录'); + const installAllButtonBefore = findButton(renderer!, '安装所有驱动'); + + expect(openDirButtonBefore.props.disabled).toBeFalsy(); + expect(importDirButtonBefore.props.disabled).toBeFalsy(); + expect(installAllButtonBefore.props.disabled).toBeFalsy(); + + await act(async () => { + installButton.props.onClick(); + await Promise.resolve(); + }); + + const openDirButtonAfter = findButton(renderer!, '打开驱动目录'); + const importDirButtonAfter = findButton(renderer!, '导入驱动目录'); + const installAllButtonAfter = findButton(renderer!, '安装所有驱动'); + + expect(openDirButtonAfter.props.disabled).toBeFalsy(); + expect(importDirButtonAfter.props.disabled).toBeFalsy(); + expect(installAllButtonAfter.props.disabled).toBe(true); + }); + + it('releases install action when the driver install watchdog expires', async () => { + vi.useFakeTimers(); + try { + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushPromises(); + + const installButton = findButton(renderer!, '安装启用'); + await act(async () => { + installButton.props.onClick(); + await Promise.resolve(); + }); + + expect(findButton(renderer!, '安装启用').props.disabled).toBe(true); + + await act(async () => { + vi.advanceTimersByTime(12 * 60 * 1000); + await Promise.resolve(); + }); + + expect(findButton(renderer!, '安装启用').props.disabled).toBeFalsy(); + expect(messageApi.error).toHaveBeenCalledWith(expect.stringContaining('超过 12 分钟仍未完成')); + } finally { + vi.useRealTimers(); + } + }); + + it('reinstalls stale MongoDB v2 drivers with the v1 compatibility default', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + vi.setSystemTime(new Date(Date.now() + 2 * 60 * 1000)); + backendApp.GetDriverStatusList.mockResolvedValue({ + success: true, + data: { + downloadDir: 'D:/drivers', + drivers: [ + { + type: 'mongodb', + name: 'MongoDB', + builtIn: false, + pinnedVersion: '1.17.9', + installedVersion: '2.5.0', + runtimeAvailable: true, + packageInstalled: true, + connectable: true, + needsUpdate: true, + defaultDownloadUrl: 'builtin://activate/mongodb', + message: '建议重装', + }, + ], + }, + }); + backendApp.GetDriverVersionList.mockResolvedValue({ + success: true, + data: { + versions: [ + { version: '2.5.0', downloadUrl: 'builtin://activate/mongodb?version=2.5.0' }, + { version: '1.17.9', downloadUrl: 'builtin://activate/mongodb', recommended: true }, + ], + }, + }); + backendApp.DownloadDriverPackage.mockResolvedValue({ success: true }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushPromises(); + + const reinstallButton = findButton(renderer!, '重装驱动'); + await act(async () => { + await reinstallButton.props.onClick(); + }); + + expect(backendApp.DownloadDriverPackage).toHaveBeenCalledWith( + 'mongodb', + '1.17.9', + 'builtin://activate/mongodb', + 'D:/drivers', + ); + } finally { + vi.useRealTimers(); + } + }); + + it('allows switching installed TDengine drivers to a historical compatible version', async () => { + backendApp.GetDriverStatusList.mockResolvedValue({ + success: true, + data: { + downloadDir: 'D:/drivers', + drivers: [ + { + type: 'tdengine', + name: 'TDengine', + builtIn: false, + pinnedVersion: '3.7.8', + installedVersion: '3.7.8', + runtimeAvailable: true, + packageInstalled: true, + connectable: true, + defaultDownloadUrl: 'builtin://activate/tdengine', + message: '已启用', + }, + ], + }, + }); + backendApp.GetDriverVersionList.mockResolvedValue({ + success: true, + data: { + versions: [ + { version: '3.7.8', downloadUrl: 'builtin://activate/tdengine', recommended: true }, + { version: '3.3.1', downloadUrl: 'builtin://activate/tdengine?channel=history&version=3.3.1' }, + ], + }, + }); + backendApp.DownloadDriverPackage.mockResolvedValue({ success: true }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushPromises(); + + const refreshButton = findButton(renderer!, '刷新'); + await act(async () => { + await refreshButton.props.onClick(); + }); + await flushPromises(); + + const versionSelect = renderer!.root.findByType('select'); + await act(async () => { + versionSelect.props.onFocus(); + }); + await flushPromises(); + expect(backendApp.GetDriverVersionList).toHaveBeenCalledWith('tdengine', ''); + + const reloadedVersionSelect = renderer!.root.findByType('select'); + await act(async () => { + reloadedVersionSelect.props.onChange({ target: { value: '3.3.1@@builtin://activate/tdengine?channel=history&version=3.3.1' } }); + }); + await flushPromises(); + + const switchButtons = renderer!.root.findAll((node) => node.type === 'button' && textContent(node).includes('切换版本')); + expect(switchButtons).toHaveLength(1); + const switchButton = switchButtons[0]; + await act(async () => { + await switchButton.props.onClick(); + }); + + expect(backendApp.DownloadDriverPackage).toHaveBeenCalledWith( + 'tdengine', + '3.3.1', + 'builtin://activate/tdengine?channel=history&version=3.3.1', + 'D:/drivers', + ); + }); +}); diff --git a/frontend/src/components/DriverManagerModal.tsx b/frontend/src/components/DriverManagerModal.tsx index df32375..670b253 100644 --- a/frontend/src/components/DriverManagerModal.tsx +++ b/frontend/src/components/DriverManagerModal.tsx @@ -8,6 +8,7 @@ import { useStore } from '../store'; import { t } from '../i18n'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { isBackendCancelledResult } from '../utils/connectionExport'; +import { normalizeDriverProgressUpdate, type DriverProgressState } from '../utils/driverProgress'; import { buildDriverManagerWorkbenchTheme } from '../utils/driverManagerWorkbenchTheme'; import { getDriverLocalImportButtonLabel, @@ -60,12 +61,6 @@ type DriverProgressEvent = { percent?: number; }; -type ProgressState = { - status: 'start' | 'downloading' | 'done' | 'error'; - message: string; - percent: number; -}; - type DriverLocalSourceCode = 'file' | 'directory'; type DriverActionKind = '' | 'install' | 'remove' | 'local'; type DriverBatchActionKind = '' | 'install-all' | 'reinstall-updates' | 'remove-all'; @@ -135,6 +130,7 @@ const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`; const DRIVER_STATUS_CACHE_TTL_MS = 60 * 1000; const DRIVER_NETWORK_CACHE_TTL_MS = 5 * 60 * 1000; +const DRIVER_INSTALL_WATCHDOG_MS = 12 * 60 * 1000; const normalizeDriverSearchText = (value: string) => String(value || '').trim().toLowerCase(); const isSlimBuildInstallUnavailable = (row: DriverStatusRow) => row.reasonCode === 'slim_build_missing_driver' && !row.packageInstalled; const resolveDriverBatchActionLabel = (actionKind: DriverBatchActionKind) => { @@ -482,7 +478,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG const [actionState, setActionState] = useState<{ driverType: string; kind: DriverActionKind }>({ driverType: '', kind: '' }); const [batchAction, setBatchAction] = useState(''); const [batchProgress, setBatchProgress] = useState(null); - const [progressMap, setProgressMap] = useState>({}); + const [progressMap, setProgressMap] = useState>({}); const [operationLogMap, setOperationLogMap] = useState>({}); const [logDriverType, setLogDriverType] = useState(''); const [logModalOpen, setLogModalOpen] = useState(false); @@ -493,7 +489,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG const [versionLoadingMap, setVersionLoadingMap] = useState>({}); const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState>({}); const downloadDirRef = useRef(downloadDir); - const batchBusy = batchDirectoryImporting || batchAction !== '' || actionState.kind !== ''; + const progressMapRef = useRef>({}); + const batchBusy = batchDirectoryImporting || batchAction !== ''; + const installMutatingBusy = batchBusy || actionState.kind !== ''; useEffect(() => { downloadDirRef.current = downloadDir; @@ -515,6 +513,31 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG ) ), []); + const updateDriverProgress = useCallback((driverType: string, incoming: DriverProgressState) => { + const normalized = String(driverType || '').trim().toLowerCase(); + if (!normalized) { + return undefined; + } + const nextProgress = normalizeDriverProgressUpdate(progressMapRef.current[normalized], incoming); + progressMapRef.current = { + ...progressMapRef.current, + [normalized]: nextProgress, + }; + setProgressMap(progressMapRef.current); + return nextProgress; + }, []); + + const clearDriverProgress = useCallback((driverType: string) => { + const normalized = String(driverType || '').trim().toLowerCase(); + if (!normalized) { + return; + } + const next = { ...progressMapRef.current }; + delete next[normalized]; + progressMapRef.current = next; + setProgressMap(next); + }, []); + const modalBodyStyle = useMemo(() => ({ maxHeight: 'calc(100vh - 220px)', overflowY: 'auto', @@ -779,6 +802,8 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG return prev; } const preferred = + (row.needsUpdate ? options.find((option) => option.version === row.pinnedVersion) : undefined) || + (row.needsUpdate ? options.find((option) => option.recommended) : undefined) || options.find((option) => option.version === row.installedVersion) || options.find((option) => option.version === row.pinnedVersion) || options.find((option) => option.recommended) || @@ -912,18 +937,19 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG } const messageText = String(event.message || '').trim(); const percent = Math.max(0, Math.min(100, Number(event.percent || 0))); - setProgressMap((prev) => ({ - ...prev, - [driverType]: { - status, - message: messageText, - percent, - }, - })); - const progressText = `${Math.round(percent)}%`; - const statusText = String(status || '').toUpperCase(); - const lineText = `[${statusText}] ${messageText || '-'} (${progressText})`; - const lineSignature = `${statusText}|${messageText || '-'}`; + const nextProgress = updateDriverProgress(driverType, { + status, + message: messageText, + percent, + }); + if (!nextProgress) { + return; + } + const progressText = `${Math.round(nextProgress.percent)}%`; + const statusText = String(nextProgress.status || '').toUpperCase(); + const logMessageText = nextProgress.message || '-'; + const lineText = `[${statusText}] ${logMessageText} (${progressText})`; + const lineSignature = `${statusText}|${logMessageText}`; appendOperationLog(driverType, lineText, lineSignature, 'update-last'); }); } catch (error) { @@ -934,7 +960,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG off(); } }; - }, [appendOperationLog]); + }, [appendOperationLog, updateDriverProgress]); const resolveLocalImportVersion = useCallback((row: DriverStatusRow) => { const options = versionMap[row.type] || []; @@ -946,20 +972,41 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG return selectedOption?.version || row.pinnedVersion || ''; }, [selectedVersionMap, versionMap]); + const resolveSelectedVersionOption = useCallback((row: DriverStatusRow) => { + const options = versionMap[row.type] || []; + const selectedKey = selectedVersionMap[row.type]; + return ( + options.find((item) => buildVersionOptionKey(item) === selectedKey) || + options.find((item) => item.recommended) || + options[0] + ); + }, [selectedVersionMap, versionMap]); + + const resolveInstalledDriverVersion = useCallback((row: DriverStatusRow) => ( + String(row.installedVersion || '').trim() || String(row.pinnedVersion || '').trim() + ), []); + + const isDriverVersionSwitchPending = useCallback((row: DriverStatusRow) => { + if (row.builtIn || (!row.packageInstalled && !row.connectable)) { + return false; + } + const selectedVersion = String(resolveSelectedVersionOption(row)?.version || '').trim(); + const installedVersion = resolveInstalledDriverVersion(row); + return !!selectedVersion && !!installedVersion && selectedVersion !== installedVersion; + }, [resolveInstalledDriverVersion, resolveSelectedVersionOption]); + const installDriver = useCallback(async ( row: DriverStatusRow, actionOptions?: { silentToast?: boolean; skipRefresh?: boolean }, ) => { setActionState({ driverType: row.type, kind: 'install' }); - setProgressMap((prev) => ({ - ...prev, - [row.type]: { - status: 'start', - message: t('driver.modal.progress.install.start'), - percent: 0, - }, - })); + updateDriverProgress(row.type, { + status: 'start', + message: t('driver.modal.progress.install.start'), + percent: 0, + }); appendOperationLog(row.type, t('driver.modal.operationLog.autoInstall.start')); + let watchdogId: ReturnType | undefined; try { let versionOptions = versionMap[row.type] || []; if (versionOptions.length === 0) { @@ -968,12 +1015,25 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG const selectedKey = selectedVersionMap[row.type]; const selectedOption = versionOptions.find((item) => buildVersionOptionKey(item) === selectedKey) || + (row.needsUpdate ? versionOptions.find((item) => item.version === row.pinnedVersion) : undefined) || + (row.needsUpdate ? versionOptions.find((item) => item.recommended) : undefined) || versionOptions.find((item) => item.recommended) || versionOptions[0]; const selectedVersion = selectedOption?.version || row.pinnedVersion || ''; const selectedDownloadURL = selectedOption?.downloadUrl || row.defaultDownloadUrl || ''; - const result = await DownloadDriverPackage(row.type, selectedVersion, selectedDownloadURL, downloadDir); + const installWatchdog = new Promise((_, reject) => { + watchdogId = setTimeout(() => { + reject(new Error(`安装 ${row.name} 超过 ${Math.round(DRIVER_INSTALL_WATCHDOG_MS / 60000)} 分钟仍未完成。后台任务可能仍在下载或构建,请稍后刷新状态;如多次出现,请检查代理或改用本地驱动包导入。`)); + }, DRIVER_INSTALL_WATCHDOG_MS); + }); + const result = await Promise.race([ + DownloadDriverPackage(row.type, selectedVersion, selectedDownloadURL, downloadDir), + installWatchdog, + ]); + if (watchdogId) { + clearTimeout(watchdogId); + } if (!result?.success) { const errText = resolveDriverErrorMessage( result?.message, @@ -1001,10 +1061,25 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG await refreshStatus(false); } return true; + } catch (error) { + const errText = error instanceof Error ? error.message : String(error || `安装 ${row.name} 失败`); + appendOperationLog(row.type, `[ERROR] ${errText}`); + updateDriverProgress(row.type, { + status: 'error', + message: errText, + percent: 0, + }); + if (!actionOptions?.silentToast) { + message.error(errText); + } + return false; } finally { + if (watchdogId) { + clearTimeout(watchdogId); + } setActionState({ driverType: '', kind: '' }); } - }, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, resolveDriverErrorMessage, selectedVersionMap, versionMap]); + }, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, resolveDriverErrorMessage, selectedVersionMap, updateDriverProgress, versionMap]); const installDriverFromLocalPath = useCallback(async ( row: DriverStatusRow, @@ -1022,14 +1097,11 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG } setActionState({ driverType: row.type, kind: 'local' }); - setProgressMap((prev) => ({ - ...prev, - [row.type]: { - status: 'start', - message: t('driver.modal.progress.localImport.start'), - percent: 0, - }, - })); + updateDriverProgress(row.type, { + status: 'start', + message: t('driver.modal.progress.localImport.start'), + percent: 0, + }); const selectedVersion = resolveLocalImportVersion(row); const versionTip = formatDriverVersionTip(selectedVersion); const logVersionTip = formatDriverLogVersionTip(selectedVersion); @@ -1068,7 +1140,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG } finally { setActionState({ driverType: '', kind: '' }); } - }, [appendOperationLog, downloadDir, refreshStatus, resolveDriverErrorMessage, resolveLocalImportVersion]); + }, [appendOperationLog, downloadDir, refreshStatus, resolveDriverErrorMessage, resolveLocalImportVersion, updateDriverProgress]); const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => { const fileRes = await SelectDriverPackageFile(downloadDir); @@ -1223,11 +1295,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG if (!options?.silentToast) { message.success(t('driver.modal.success.removeDriver', { name: row.name })); } - setProgressMap((prev) => { - const next = { ...prev }; - delete next[row.type]; - return next; - }); + clearDriverProgress(row.type); if (!options?.skipRefresh) { await refreshStatus(false); } @@ -1235,7 +1303,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG } finally { setActionState({ driverType: '', kind: '' }); } - }, [appendOperationLog, downloadDir, refreshStatus, resolveDriverErrorMessage]); + }, [appendOperationLog, clearDriverProgress, downloadDir, refreshStatus, resolveDriverErrorMessage]); const resolvePackageSizeText = (row: DriverStatusRow): string => { if (row.builtIn) { @@ -1299,22 +1367,12 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG return {t('driver.modal.card.noInstallNeeded')}; } - const versionLocked = row.packageInstalled || row.connectable; - if (versionLocked) { - const installedVersion = String(row.installedVersion || '').trim(); - const revisionHint = row.needsUpdate ? t('driver.modal.card.versionLock.reinstallSuffix') : ''; - return ( - - {installedVersion - ? t('driver.modal.card.versionLock.installedVersion', { version: installedVersion, suffix: revisionHint }) - : t('driver.modal.card.versionLock.installed', { suffix: revisionHint })} - - ); - } - const options = versionMap[row.type] || []; const selectedKey = selectedVersionMap[row.type]; const selectOptions = buildVersionSelectOptions(options); + const installedVersion = resolveInstalledDriverVersion(row); + const versionSwitchPending = isDriverVersionSwitchPending(row); + const selectedOption = resolveSelectedVersionOption(row); const mongoHint = row.type === 'mongodb' ? t('driver.modal.card.mongodbVersionHint') : ''; @@ -1342,6 +1400,13 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG void loadVersionPackageSize(row, value); }} /> + {(row.packageInstalled || row.connectable) ? ( + + {versionSwitchPending + ? `当前已安装 ${installedVersion || '当前版本'},已选择 ${selectedOption?.version || '目标版本'},点击“切换版本”生效` + : `${installedVersion ? `${installedVersion}(已安装` : '已安装'}${row.needsUpdate ? ',需重装' : ''}${installedVersion ? ')' : ''}`} + + ) : null} {mongoHint ? {mongoHint} : null}
); @@ -1357,6 +1422,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG const loadingLocal = actionState.driverType === row.type && actionState.kind === 'local'; const logs = operationLogMap[row.type] || []; const hasLogs = logs.length > 0; + const versionSwitchPending = isDriverVersionSwitchPending(row); if (isSlimBuildUnavailable && !row.packageInstalled) { return {t('driver.modal.card.fullOnly')}; @@ -1366,6 +1432,10 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG + ) : versionSwitchPending ? ( + ) : row.connectable ? ( )} @@ -1922,12 +1992,12 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG setForceOverwriteInstalled(checked)} - disabled={batchBusy} + disabled={batchDirectoryImporting} /> + +
+); + +export default LinuxCJKFontBanner; diff --git a/frontend/src/components/MessagePublishModal.tsx b/frontend/src/components/MessagePublishModal.tsx new file mode 100644 index 0000000..85571f2 --- /dev/null +++ b/frontend/src/components/MessagePublishModal.tsx @@ -0,0 +1,288 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Alert, Checkbox, Form, Input, Modal, Select, Space, Typography, message } from 'antd'; + +import { DBQuery } from '../../wailsjs/go/app/App'; +import type { SavedConnection } from '../types'; +import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { + buildMessagePublishCommand, + createDefaultMessagePublishDraft, + getMessagePublishPresentation, + type MessagePublishDraft, +} from '../utils/messagePublish'; + +const { Text } = Typography; +const { TextArea } = Input; + +const ROCKETMQ_DELAY_LEVEL_OPTIONS = [ + { label: '不延时', value: 0 }, + { label: '1 · 1s', value: 1 }, + { label: '2 · 5s', value: 2 }, + { label: '3 · 10s', value: 3 }, + { label: '4 · 30s', value: 4 }, + { label: '5 · 1m', value: 5 }, + { label: '6 · 2m', value: 6 }, + { label: '7 · 3m', value: 7 }, + { label: '8 · 4m', value: 8 }, + { label: '9 · 5m', value: 9 }, + { label: '10 · 6m', value: 10 }, + { label: '11 · 7m', value: 11 }, + { label: '12 · 8m', value: 12 }, + { label: '13 · 9m', value: 13 }, + { label: '14 · 10m', value: 14 }, + { label: '15 · 20m', value: 15 }, + { label: '16 · 30m', value: 16 }, + { label: '17 · 1h', value: 17 }, + { label: '18 · 2h', value: 18 }, +]; + +export type MessagePublishModalProps = { + open: boolean; + connection: SavedConnection | null; + executionDbName?: string; + defaultDestination?: string; + onCancel: () => void; + onSuccess?: (result: { destination: string; affectedRows: number; commandText: string }) => void; +}; + +const MessagePublishModal: React.FC = ({ + open, + connection, + executionDbName = '', + defaultDestination = '', + onCancel, + onSuccess, +}) => { + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + const presentation = useMemo( + () => getMessagePublishPresentation(connection?.config), + [connection], + ); + + useEffect(() => { + if (!open || !connection) return; + form.setFieldsValue( + createDefaultMessagePublishDraft( + connection.config, + defaultDestination, + ), + ); + }, [connection, defaultDestination, form, open]); + + useEffect(() => { + if (open) return; + form.resetFields(); + setSubmitting(false); + }, [form, open]); + + const handleSubmit = async () => { + if (!connection) return; + + let values: MessagePublishDraft; + try { + values = await form.validateFields(); + } catch { + return; + } + + let command; + try { + command = buildMessagePublishCommand(connection.config, values); + } catch (error: any) { + void message.error(error?.message || '构造发送命令失败'); + return; + } + + setSubmitting(true); + try { + const res = await DBQuery( + buildRpcConnectionConfig(connection.config) as any, + executionDbName, + command.commandText, + ); + if (!res?.success) { + void message.error(`发送失败: ${res?.message || '未知错误'}`); + return; + } + + const affectedRows = Number((res.data as any)?.affectedRows); + onSuccess?.({ + destination: command.destinationLabel, + affectedRows: Number.isFinite(affectedRows) ? affectedRows : 0, + commandText: command.commandText, + }); + } catch (error: any) { + void message.error(`发送失败: ${error?.message || String(error)}`); + } finally { + setSubmitting(false); + } + }; + + return ( + { void handleSubmit(); }} + okText="发送" + confirmLoading={submitting} + width={720} + destroyOnHidden + maskClosable={!submitting} + > + + + + + form={form} + layout="vertical" + initialValues={createDefaultMessagePublishDraft(connection?.config, defaultDestination)} + > + + + + + {presentation.showExchange && ( + + + + )} + + {presentation.showRoutingKey && ( + + + + )} + + {presentation.showQos && ( + + + + )} + + {presentation.showDelayLevel && ( + + + + + + + + ) : ( + + + + )} + + )} + + +