[
  {
    "path": ".dockerignore",
    "content": "target/\nnode_modules/\n.git/\nsrc-tauri/target/\nsrc-tauri/bin/\n*.deb\n*.app\n*.dmg\n*.zip\n.vscode/\n.idea/\n.DS_Store\n"
  },
  {
    "path": ".gitattributes",
    "content": "# Auto detect text files and perform LF normalization\n* text=auto eol=lf\n\n# Rust source files\n*.rs text eol=lf\n*.toml text eol=lf\n\n# Web files\n*.ts text eol=lf\n*.tsx text eol=lf\n*.js text eol=lf\n*.jsx text eol=lf\n*.css text eol=lf\n*.scss text eol=lf\n*.html text eol=lf\n*.json text eol=lf\n*.md text eol=lf\n*.yaml text eol=lf\n*.yml text eol=lf\n\n# Config files\n*.config text eol=lf\n.gitignore text eol=lf\n.gitattributes text eol=lf\n\n# Binary files\n*.png binary\n*.jpg binary\n*.jpeg binary\n*.gif binary\n*.ico binary\n*.woff binary\n*.woff2 binary\n*.ttf binary\n*.eot binary\n*.exe binary\n*.dll binary\n*.so binary\n*.dylib binary\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [main, master]\n  pull_request:\n    branches: [main, master]\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  # 前端构建检查\n  build-frontend:\n    name: Build Frontend\n    runs-on: ubuntu-latest\n    timeout-minutes: 20\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Node.js setup\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: \"npm\"\n\n      - name: Install frontend dependencies\n        run: npm install --legacy-peer-deps\n\n      - name: TypeScript check\n        run: npx tsc --noEmit\n\n      - name: Build frontend\n        run: npm run build\n\n  # Rust 代码检查\n  check-rust:\n    name: Check Rust Code\n    runs-on: ${{ matrix.platform }}\n    timeout-minutes: 30\n    strategy:\n      fail-fast: false\n      matrix:\n        platform: [ubuntu-latest, windows-latest, macos-latest]\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Install dependencies (Linux)\n        if: startsWith(matrix.platform, 'ubuntu')\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf pkg-config libsoup-3.0-dev javascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev\n          sudo apt-get install -y --no-install-recommends libnm-dev xdg-utils\n\n      - name: Rust setup\n        uses: dtolnay/rust-toolchain@stable\n\n      - name: Cache Rust dependencies\n        uses: Swatinem/rust-cache@v2\n        with:\n          workspaces: \"src-tauri\"\n\n      - name: Check Rust formatting\n        run: |\n          cd src-tauri\n          cargo fmt -- --check\n\n      - name: Run Clippy\n        run: |\n          cd src-tauri\n          cargo clippy --all-targets --all-features -- -D warnings\n\n      - name: Check Rust compilation\n        run: |\n          cd src-tauri\n          cargo check\n\n  # Tauri 构建测试（不打包）\n  build-tauri:\n    name: Build Tauri App\n    runs-on: ${{ matrix.platform }}\n    timeout-minutes: 45\n    strategy:\n      fail-fast: false\n      matrix:\n        platform: [ubuntu-latest, windows-latest, macos-latest]\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Install dependencies (Linux)\n        if: startsWith(matrix.platform, 'ubuntu')\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf pkg-config libsoup-3.0-dev javascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev\n          sudo apt-get install -y --no-install-recommends libnm-dev xdg-utils\n\n      - name: Rust setup\n        uses: dtolnay/rust-toolchain@stable\n\n      - name: Cache Rust dependencies\n        uses: Swatinem/rust-cache@v2\n        with:\n          workspaces: \"src-tauri\"\n\n      - name: Node.js setup\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: \"npm\"\n\n      - name: Install frontend dependencies\n        run: npm install --legacy-peer-deps\n\n      - name: Build Tauri app (debug)\n        run: npm run tauri build -- --debug\n        env:\n          # CI 环境不需要签名\n          TAURI_SIGNING_PRIVATE_KEY: \"\"\n          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: \"\"\n"
  },
  {
    "path": ".github/workflows/deploy-pages.yml",
    "content": "name: Deploy static content to Pages\n\non:\n  # Runs on pushes targeting the default branch\n  push:\n    branches: [\"main\"]\n\n  # Allows you to run this workflow manually from the Actions tab\n  workflow_dispatch:\n\n# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\n# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.\n# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.\nconcurrency:\n  group: \"pages\"\n  cancel-in-progress: false\n\njobs:\n  # Single deploy job since we're just deploying\n  deploy:\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Setup Pages\n        uses: actions/configure-pages@v5\n\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v3\n        with:\n          # Upload entire repository\n          path: './web_site'\n\n      - name: Deploy to GitHub Pages\n        uses: actions/deploy-pages@v4\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\non:\n  push:\n    tags:\n      - \"v*\"\n  workflow_dispatch:\n\njobs:\n  build-tauri:\n    permissions:\n      contents: write\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - platform: \"macos-latest\"\n            args: \"--target aarch64-apple-darwin\"\n          - platform: \"macos-latest\"\n            args: \"--target x86_64-apple-darwin\"\n          - platform: \"macos-latest\"\n            args: \"--target universal-apple-darwin\"\n          - platform: \"ubuntu-22.04\"\n            args: \"\"\n          - platform: \"ubuntu-24.04-arm\"\n            args: \"\"\n          - platform: \"windows-latest\"\n            args: \"\"\n\n    runs-on: ${{ matrix.platform }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Install dependencies (Linux)\n        if: startsWith(matrix.platform, 'ubuntu')\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf pkg-config libsoup-3.0-dev javascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev\n          sudo apt-get install -y libnm-dev xdg-utils\n\n      - name: Rust setup\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || (contains(matrix.args, 'aarch64') && 'aarch64-unknown-linux-gnu' || '') }}\n\n      - name: Node.js setup\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: \"npm\"\n\n      - name: Install frontend dependencies\n        run: npm install --legacy-peer-deps\n\n      - name: Build the app\n        env:\n          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}\n          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}\n        run: npm run tauri build -- ${{ matrix.args }}\n\n      # 3. 处理 macOS 架构重命名冲突 (解决 422 Already Exists)\n      - name: Rename macOS assets for architecture\n        if: matrix.platform == 'macos-latest'\n        run: |\n          # 识别架构\n          if [[ \"${{ matrix.args }}\" == *\"--target aarch64-apple-darwin\"* ]]; then\n            ARCH=\"aarch64\"\n          elif [[ \"${{ matrix.args }}\" == *\"--target x86_64-apple-darwin\"* ]]; then\n            ARCH=\"x64\"\n          elif [[ \"${{ matrix.args }}\" == *\"--target universal-apple-darwin\"* ]]; then\n            ARCH=\"universal\"\n          else\n            ARCH=\"unknown\"\n          fi\n          \n          echo \"Detected architecture: $ARCH\"\n          \n          # 进入产物目录\n          cd src-tauri/target/*/release/bundle/macos/\n          \n          # 重命名 .app.tar.gz 和 .sig\n          if [ -f \"Antigravity Tools.app.tar.gz\" ]; then\n            mv \"Antigravity Tools.app.tar.gz\" \"Antigravity Tools_${ARCH}.app.tar.gz\"\n            mv \"Antigravity Tools.app.tar.gz.sig\" \"Antigravity Tools_${ARCH}.app.tar.gz.sig\"\n            echo \"Renamed assets to append Arch: $ARCH\"\n          fi\n          \n          # 更新对应的 updater.json (指向重命名后的文件)\n          UPDATER_JSON=\"../../../updater/install.json\"\n          if [ ! -f \"$UPDATER_JSON\" ]; then\n             UPDATER_JSON=$(find ../../../updater -name \"*.json\" | head -n 1)\n          fi\n          \n          if [ -f \"$UPDATER_JSON\" ]; then\n            echo \"Updating $UPDATER_JSON to use renamed assets...\"\n            sed -i '' \"s/Antigravity%20Tools.app.tar.gz/Antigravity%20Tools_${ARCH}.app.tar.gz/g\" \"$UPDATER_JSON\"\n          fi\n\n      # 1. 上传 updater.json 到 Artifacts (供后续合并使用)\n      - name: Upload updater json\n        uses: actions/upload-artifact@v4\n        with:\n          name: updater-json-${{ matrix.platform }}-${{ strategy.job-index }}\n          path: src-tauri/target/**/release/bundle/updater/*.json\n          if-no-files-found: warn\n\n      # 2. 上传安装包到 Artifacts (供后续统一发布任务下载)\n      - name: Upload Release Artifacts\n        uses: actions/upload-artifact@v4\n        with:\n          name: release-assets-${{ matrix.platform }}-${{ strategy.job-index }}\n          path: |\n            src-tauri/target/**/release/bundle/dmg/*.dmg\n            src-tauri/target/**/release/bundle/deb/*.deb\n            src-tauri/target/**/release/bundle/appimage/*.AppImage\n            src-tauri/target/**/release/bundle/msi/*.msi\n            src-tauri/target/**/release/bundle/nsis/*.exe\n            src-tauri/target/**/release/bundle/rpm/*.rpm\n            src-tauri/target/**/release/bundle/macos/*.app.tar.gz\n            src-tauri/target/**/release/bundle/macos/*.app.tar.gz.sig\n            src-tauri/target/**/release/bundle/dmg/*.sig\n            src-tauri/target/**/release/bundle/deb/*.sig\n            src-tauri/target/**/release/bundle/appimage/*.sig\n            src-tauri/target/**/release/bundle/msi/*.sig\n            src-tauri/target/**/release/bundle/nsis/*.sig\n            src-tauri/target/**/release/bundle/rpm/*.sig\n          if-no-files-found: warn\n\n  publish-release:\n    needs: build-tauri\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      # 下载所有构建产物 (仅限 release 相关的 assets 和 updater)\n      - name: Download all artifacts\n        uses: actions/download-artifact@v4\n        with:\n          path: all-artifacts\n          pattern: \"{release-assets-*,updater-json-*}\"\n          merge-multiple: true\n\n      - name: Extract Release Notes\n        run: |\n          VERSION=\"${{ github.ref_name }}\"\n          echo \"Extracting release notes for version $VERSION\"\n          awk -v ver=\"$VERSION\" '\n            BEGIN { capture=0 }\n            $0 ~ \"^[[:space:]]*[*+-][[:space:]]+\\\\*\\\\*\" ver { capture=1; next }\n            capture && $0 ~ \"^[[:space:]]*[*+-][[:space:]]+\\\\*\\\\*v\" { capture=0; exit }\n            capture { print }\n          ' README.md | sed 's/^[[:space:]]\\{8\\}//' > release_notes.md\n          if [ ! -s release_notes.md ]; then\n            echo \"See the assets to download this version and install.\" > release_notes.md\n          fi\n\n      - name: Build updater.json from signatures\n        env:\n          VERSION: ${{ github.ref_name }}\n          REPO: ${{ github.repository }}\n        run: |\n          DOWNLOAD_BASE=\"https://github.com/${REPO}/releases/download/${VERSION}\"\n          VER=\"${VERSION#v}\"\n\n          # Helper: read signature content from .sig file in artifacts\n          read_sig() {\n            local pattern=\"$1\"\n            local sig_file\n            sig_file=$(find all-artifacts -name \"$pattern\" -type f 2>/dev/null | head -1)\n            if [ -n \"$sig_file\" ] && [ -f \"$sig_file\" ]; then\n              cat \"$sig_file\"\n            else\n              echo \"\"\n            fi\n          }\n\n          # Read signatures for each platform\n          MACOS_AARCH64_SIG=$(read_sig \"*_aarch64.app.tar.gz.sig\")\n          MACOS_X64_SIG=$(read_sig \"*_x64.app.tar.gz.sig\")\n          WINDOWS_NSIS_SIG=$(read_sig \"*_x64-setup.exe.sig\")\n          LINUX_AMD64_SIG=$(read_sig \"*_amd64.AppImage.sig\")\n          LINUX_AARCH64_SIG=$(read_sig \"*_aarch64.AppImage.sig\")\n\n          echo \"Signatures found:\"\n          [ -n \"$MACOS_AARCH64_SIG\" ] && echo \"  macOS aarch64: YES\" || echo \"  macOS aarch64: NO\"\n          [ -n \"$MACOS_X64_SIG\" ] && echo \"  macOS x64: YES\" || echo \"  macOS x64: NO\"\n          [ -n \"$WINDOWS_NSIS_SIG\" ] && echo \"  Windows x64: YES\" || echo \"  Windows x64: NO\"\n          [ -n \"$LINUX_AMD64_SIG\" ] && echo \"  Linux amd64: YES\" || echo \"  Linux amd64: NO\"\n          [ -n \"$LINUX_AARCH64_SIG\" ] && echo \"  Linux aarch64: YES\" || echo \"  Linux aarch64: NO\"\n\n          # Build updater.json with platform-specific download URLs and signatures\n          # macOS: arch-suffixed filenames (no version in name)\n          # Windows/Linux: version included in filename\n          jq -n \\\n            --arg version \"$VER\" \\\n            --arg notes \"See release page for details\" \\\n            --arg pub_date \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \\\n            --arg dl \"$DOWNLOAD_BASE\" \\\n            --arg mac_a64_sig \"$MACOS_AARCH64_SIG\" \\\n            --arg mac_x64_sig \"$MACOS_X64_SIG\" \\\n            --arg win_nsis_sig \"$WINDOWS_NSIS_SIG\" \\\n            --arg linux_amd64_sig \"$LINUX_AMD64_SIG\" \\\n            --arg linux_a64_sig \"$LINUX_AARCH64_SIG\" \\\n            --arg ver \"$VER\" \\\n            '{\n              version: $version,\n              notes: $notes,\n              pub_date: $pub_date,\n              platforms: {\n                \"darwin-aarch64\": {\n                  url: \"\\($dl)/Antigravity.Tools_aarch64.app.tar.gz\",\n                  signature: $mac_a64_sig\n                },\n                \"darwin-x86_64\": {\n                  url: \"\\($dl)/Antigravity.Tools_x64.app.tar.gz\",\n                  signature: $mac_x64_sig\n                },\n                \"windows-x86_64\": {\n                  url: \"\\($dl)/Antigravity.Tools_\\($ver)_x64-setup.exe\",\n                  signature: $win_nsis_sig\n                },\n                \"linux-x86_64\": {\n                  url: \"\\($dl)/Antigravity.Tools_\\($ver)_amd64.AppImage\",\n                  signature: $linux_amd64_sig\n                },\n                \"linux-aarch64\": {\n                  url: \"\\($dl)/Antigravity.Tools_\\($ver)_aarch64.AppImage\",\n                  signature: $linux_a64_sig\n                }\n              }\n            }' > updater.json\n\n          echo \"Generated updater.json:\"\n          cat updater.json\n\n      # 使用 ncipollo/release-action 进行统一发布，支持稳健覆盖\n      - name: Create or Update GitHub Release\n        uses: ncipollo/release-action@v1\n        if: startsWith(github.ref, 'refs/tags/')\n        with:\n          allowUpdates: true\n          name: \"Antigravity Tools ${{ github.ref_name }}\"\n          bodyFile: \"release_notes.md\"\n          token: ${{ secrets.GITHUB_TOKEN }}\n          artifacts: \"all-artifacts/**/*,updater.json\"\n          artifactErrorsFailBuild: false\n          replacesArtifacts: true\n          makeLatest: legacy\n\n  docker-build-amd64:\n    runs-on: ubuntu-latest\n    if: github.event_name != 'pull_request' && (github.repository == 'lbjlaq/Antigravity-Manager' || vars.ENABLE_DOCKER_PUSH == 'true')\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Build and push (AMD64)\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: docker/Dockerfile\n          platforms: linux/amd64\n          push: true\n          tags: |\n            ${{ github.repository_owner }}/antigravity-manager:latest-amd64\n            ${{ github.repository_owner }}/antigravity-manager:${{ github.ref_name }}-amd64\n          build-args: |\n            USE_MIRROR=false\n\n  docker-build-arm64:\n    runs-on: ubuntu-24.04-arm\n    if: github.event_name != 'pull_request' && (github.repository == 'lbjlaq/Antigravity-Manager' || vars.ENABLE_DOCKER_PUSH == 'true')\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Build and push (ARM64)\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: docker/Dockerfile\n          platforms: linux/arm64\n          push: true\n          tags: |\n            ${{ github.repository_owner }}/antigravity-manager:latest-arm64\n            ${{ github.repository_owner }}/antigravity-manager:${{ github.ref_name }}-arm64\n          build-args: |\n            USE_MIRROR=false\n\n  docker-manifest:\n    needs: [docker-build-amd64, docker-build-arm64]\n    runs-on: ubuntu-latest\n    if: github.event_name != 'pull_request' && (github.repository == 'lbjlaq/Antigravity-Manager' || vars.ENABLE_DOCKER_PUSH == 'true')\n    steps:\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Create and push manifest\n        run: |\n          docker buildx imagetools create -t ${{ github.repository_owner }}/antigravity-manager:latest \\\n            ${{ github.repository_owner }}/antigravity-manager:latest-amd64 \\\n            ${{ github.repository_owner }}/antigravity-manager:latest-arm64\n\n          docker buildx imagetools create -t ${{ github.repository_owner }}/antigravity-manager:${{ github.ref_name }} \\\n            ${{ github.repository_owner }}/antigravity-manager:${{ github.ref_name }}-amd64 \\\n            ${{ github.repository_owner }}/antigravity-manager:${{ github.ref_name }}-arm64\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n.logs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist-ssr\n*.local\n.vite\ndist\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n# Build output\nsrc-tauri/target/\nsrc-tauri/gen/\nsrc-tauri/.env\n\n# Environment\n.env\n.env.*\nenvironment/\n\n# Python virtual environments and test files\n.venv*/\nvenv/\n# *.py\n__pycache__/\n*.pyc\n\n# Reference projects (for development only)\n\nDOCKER_DEPLOYMENT.md\npnpm-lock.yaml\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"tauri-apps.tauri-vscode\", \"rust-lang.rust-analyzer\"]\n}\n"
  },
  {
    "path": "Casks/antigravity-tools.rb",
    "content": "cask \"antigravity-tools\" do\n  version \"4.1.30\"\n  sha256 :no_check\n\n  name \"Antigravity Tools\"\n  desc \"Professional Account Management for AI Services\"\n  homepage \"https://github.com/lbjlaq/Antigravity-Manager\"\n\n  on_macos do\n    url \"https://github.com/lbjlaq/Antigravity-Manager/releases/download/v#{version}/Antigravity.Tools_#{version}_universal.dmg\"\n\n    app \"Antigravity Tools.app\"\n\n    zap trash: [\n      \"~/Library/Application Support/com.lbjlaq.antigravity-tools\",\n      \"~/Library/Caches/com.lbjlaq.antigravity-tools\",\n      \"~/Library/Preferences/com.lbjlaq.antigravity-tools.plist\",\n      \"~/Library/Saved Application State/com.lbjlaq.antigravity-tools.savedState\",\n    ]\n\n    caveats <<~EOS\n      If you encounter the \"App is damaged\" error, please run the following command:\n        sudo xattr -rd com.apple.quarantine \"/Applications/Antigravity Tools.app\"\n\n      Or install with the --no-quarantine flag:\n        brew install --cask --no-quarantine antigravity-tools\n    EOS\n  end\n\n  on_linux do\n    arch arm: \"aarch64\", intel: \"amd64\"\n\n    url \"https://github.com/lbjlaq/Antigravity-Manager/releases/download/v#{version}/Antigravity.Tools_#{version}_#{arch}.AppImage\"\n    binary \"Antigravity.Tools_#{version}_#{arch}.AppImage\", target: \"antigravity-tools\"\n\n    preflight do\n      system_command \"/bin/chmod\", args: [\"+x\", \"#{staged_path}/Antigravity.Tools_#{version}_#{arch}.AppImage\"]\n    end\n  end\nend\n"
  },
  {
    "path": "LICENSE",
    "content": "Attribution-NonCommercial-ShareAlike 4.0 International\n\n=======================================================================\n\nCreative Commons Corporation (\"Creative Commons\") is not a law firm and\ndoes not provide legal services or legal advice. Distribution of\nCreative Commons public licenses does not create a lawyer-client or\nother relationship. Creative Commons makes its licenses and related\ninformation available on an \"as-is\" basis. Creative Commons gives no\nwarranties regarding its licenses, any material licensed under their\nterms and conditions, or any related information. Creative Commons\ndisclaims all liability for damages resulting from their use to the\nfullest extent possible.\n\nUsing Creative Commons Public Licenses\n\nCreative Commons public licenses provide a standard set of terms and\nconditions that creators and other rights holders may use to share\noriginal works of authorship and other material subject to copyright\nand certain other rights specified in the public license below. The\nfollowing considerations are for informational purposes only, are not\nexhaustive, and do not form part of our licenses.\n\n     Considerations for licensors: Our public licenses are\n     intended for use by those authorized to give the public\n     permission to use material in ways otherwise restricted by\n     copyright and certain other rights. Our licenses are\n     irrevocable. Licensors should read and understand the terms\n     and conditions of the license they choose before applying it.\n     Licensors should also secure all rights necessary before\n     applying our licenses so that the public can reuse the\n     material as expected. Licensors should clearly mark any\n     material not subject to the license. This includes other CC-\n     licensed material, or material used under an exception or\n     limitation to copyright.\n\n     Considerations for the public: By using one of our public\n     licenses, a licensor grants the public permission to use the\n     licensed material under specified terms and conditions. If\n     the licensor's permission is not necessary for any reason--for\n     example, because of any applicable exception or limitation to\n     copyright--then that use is not regulated by the license.\n     Our licenses grant only permissions under copyright and certain\n     other rights that a licensor has the authority to grant. Use\n     of the licensed material may still be restricted for other\n     reasons, including because others have copyright or other\n     rights in the material. A licensor may make special requests,\n     such as asking that all changes be marked or described.\n     Although not required by our licenses, you are encouraged to\n     respect those requests where reasonable.\n\n=======================================================================\n\nCreative Commons Attribution-NonCommercial-ShareAlike 4.0 International\nPublic License\n\nBy exercising the Licensed Rights (defined below), You accept and agree\nto be bound by the terms and conditions of this Creative Commons\nAttribution-NonCommercial-ShareAlike 4.0 International Public License\n(\"Public License\"). To the extent this Public License may be interpreted\nas a contract, You are granted the Licensed Rights in consideration of\nYour acceptance of these terms and conditions, and the Licensor grants\nYou such rights in consideration of benefits the Licensor receives from\nmaking the Licensed Material available under these terms and conditions.\n\n\nSection 1 -- Definitions.\n\n  a. Adapted Material means material subject to Copyright and Similar\n     Rights that is derived from or based upon the Licensed Material\n     and in which the Licensed Material is translated, altered,\n     arranged, transformed, or otherwise modified in a manner requiring\n     permission under the Copyright and Similar Rights held by the\n     Licensor. For purposes of this Public License, where the Licensed\n     Material is a musical work, performance, or sound recording,\n     Adapted Material is always produced where the Licensed Material is\n     synched in timed relation with a moving image.\n\n  b. Adapter's License means the license You apply to Your Copyright\n     and Similar Rights in Your contributions to Adapted Material in\n     accordance with the terms and conditions of this Public License.\n\n  c. BY-NC-SA Compatible License means a license listed at\n     creativecommons.org/compatiblelicenses, approved by Creative\n     Commons as essentially the equivalent of this Public License.\n\n  d. Copyright and Similar Rights means copyright and/or similar rights\n     closely related to copyright including, without limitation,\n     performance, broadcast, sound recording, and Sui Generis Database\n     Rights, without regard to how the rights are labeled or\n     categorized. For purposes of this Public License, the rights\n     specified in Section 2(b)(1)-(2) are not Copyright and Similar\n     Rights.\n\n  e. Effective Technological Measures means those measures that, in the\n     absence of proper authority, may not be circumvented under laws\n     fulfilling obligations under Article 11 of the WIPO Copyright\n     Treaty adopted on December 20, 1996, and/or similar international\n     agreements.\n\n  f. Exceptions and Limitations means fair use, fair dealing, and/or\n     any other exception or limitation to Copyright and Similar Rights\n     that applies to Your use of the Licensed Material.\n\n  g. License Elements means the license attributes listed in the name\n     of a Creative Commons Public License. The License Elements of this\n     Public License are Attribution, NonCommercial, and ShareAlike.\n\n  h. Licensed Material means the artistic or literary work, database,\n     or other material to which the Licensor applied this Public\n     License.\n\n  i. Licensed Rights means the rights granted to You subject to the\n     terms and conditions of this Public License, which are limited to\n     all Copyright and Similar Rights that apply to Your use of the\n     Licensed Material and that the Licensor has authority to license.\n\n  j. Licensor means the individual(s) or entity(ies) granting rights\n     under this Public License.\n\n  k. NonCommercial means not primarily intended for or directed towards\n     commercial advantage or monetary compensation. For purposes of\n     this Public License, the exchange of the Licensed Material for\n     other material subject to Copyright and Similar Rights by digital\n     file-sharing or similar means is NonCommercial provided there is\n     no payment of monetary compensation in connection with the\n     exchange.\n\n  l. Share means to provide material to the public by any means or\n     process that requires permission under the Licensed Rights, such\n     as reproduction, public display, public performance, distribution,\n     dissemination, communication, or importation, and to make material\n     available to the public including in ways that members of the\n     public may access the material from a place and at a time\n     individually chosen by them.\n\n  m. Sui Generis Database Rights means rights other than copyright\n     resulting from Directive 96/9/EC of the European Parliament and of\n     the Council of 11 March 1996 on the legal protection of databases,\n     as amended and/or succeeded, as well as other essentially\n     equivalent rights anywhere in the world.\n\n  n. You means the individual or entity exercising the Licensed Rights\n     under this Public License. Your has a corresponding meaning.\n\n\nSection 2 -- Scope.\n\n  a. License grant.\n\n       1. Subject to the terms and conditions of this Public License,\n          the Licensor hereby grants You a worldwide, royalty-free,\n          non-sublicensable, non-exclusive, irrevocable license to\n          exercise the Licensed Rights in the Licensed Material to:\n\n            A. reproduce and Share the Licensed Material, in whole or\n               in part, for NonCommercial purposes only; and\n\n            B. produce, reproduce, and Share Adapted Material for\n               NonCommercial purposes only.\n\n       2. Exceptions and Limitations. For the avoidance of doubt, where\n          Exceptions and Limitations apply to Your use, this Public\n          License does not apply, and You do not need to comply with\n          its terms and conditions.\n\n       3. Term. The term of this Public License is specified in Section\n          6(a).\n\n       4. Media and formats; technical modifications allowed. The\n          Licensor authorizes You to exercise the Licensed Rights in\n          all media and formats whether now known or hereafter created,\n          and to make technical modifications necessary to do so. The\n          Licensor waives and/or agrees not to assert any right or\n          authority to forbid You from making technical modifications\n          necessary to exercise the Licensed Rights, including\n          technical modifications necessary to circumvent Effective\n          Technological Measures. For purposes of this Public License,\n          simply making modifications authorized by this Section 2(a)\n          (4) never produces Adapted Material.\n\n       5. Downstream recipients.\n\n            A. Offer from the Licensor -- Licensed Material. Every\n               recipient of the Licensed Material automatically\n               receives an offer from the Licensor to exercise the\n               Licensed Rights under the terms and conditions of this\n               Public License.\n\n            B. Additional offer from the Licensor -- Adapted Material.\n               Every recipient of Adapted Material from You\n               automatically receives an offer from the Licensor to\n               exercise the Licensed Rights in the Adapted Material\n               under the conditions of the Adapter's License You apply.\n\n            C. No downstream restrictions. You may not offer or impose\n               any additional or different terms or conditions on, or\n               apply any Effective Technological Measures to, the\n               Licensed Material if doing so restricts exercise of the\n               Licensed Rights by any recipient of the Licensed\n               Material.\n\n       6. No endorsement. Nothing in this Public License constitutes or\n          may be construed as permission to assert or imply that You\n          are, or that Your use of the Licensed Material is, connected\n          with, or sponsored, endorsed, or granted official status by,\n          the Licensor or others designated to receive attribution as\n          provided in Section 3(a)(1)(A)(i).\n\n  b. Other rights.\n\n       1. Moral rights, such as the right of integrity, are not\n          licensed under this Public License, nor are publicity,\n          privacy, and/or other similar personality rights; however, to\n          the extent possible, the Licensor waives and/or agrees not to\n          assert any such rights held by the Licensor to the limited\n          extent necessary to allow You to exercise the Licensed\n          Rights, but not otherwise.\n\n       2. Patent and trademark rights are not licensed under this\n          Public License.\n\n       3. To the extent possible, the Licensor waives any right to\n          collect royalties from You for the exercise of the Licensed\n          Rights, whether directly or through a collecting society\n          under any voluntary or waivable statutory or compulsory\n          licensing scheme. In all other cases the Licensor expressly\n          reserves any right to collect such royalties, including when\n          the Licensed Material is used for other than NonCommercial\n          purposes.\n\n\nSection 3 -- License Conditions.\n\nYour exercise of the Licensed Rights is expressly made subject to the\nfollowing conditions.\n\n  a. Attribution.\n\n       1. If You Share the Licensed Material (including in modified\n          form), You must:\n\n            A. retain the following if it is supplied by the Licensor\n               with the Licensed Material:\n\n                 i. identification of the creator(s) of the Licensed\n                    Material and any others designated to receive\n                    attribution, in any reasonable manner requested by\n                    the Licensor (including by pseudonym if\n                    designated);\n\n                ii. a copyright notice;\n\n               iii. a notice that refers to this Public License;\n\n                iv. a notice that refers to the disclaimer of\n                    warranties;\n\n                 v. a URI or hyperlink to the Licensed Material to the\n                    extent reasonably practicable;\n\n            B. indicate if You modified the Licensed Material and\n               retain an indication of any previous modifications; and\n\n            C. indicate the Licensed Material is licensed under this\n               Public License, and include the text of, or the URI or\n               hyperlink to, this Public License.\n\n       2. You may satisfy the conditions in Section 3(a)(1) in any\n          reasonable manner based on the medium, means, and context in\n          which You Share the Licensed Material. For example, it may be\n          reasonable to satisfy the conditions by providing a URI or\n          hyperlink to a resource that includes the required\n          information.\n\n       3. If requested by the Licensor, You must remove any of the\n          information required by Section 3(a)(1)(A) to the extent\n          reasonably practicable.\n\n       4. If You Share Adapted Material You produce, the Adapter's\n          License You apply must not prevent recipients of the Adapted\n          Material from complying with this Public License.\n\n  b. ShareAlike.\n\n     In addition to the conditions in Section 3(a), if You Share\n     Adapted Material You produce, the following conditions also apply.\n\n       1. The Adapter's License You apply must be a Creative Commons\n          license with the same License Elements, this version or\n          later, or a BY-NC-SA Compatible License.\n\n       2. You must include the text of, or the URI or hyperlink to, the\n          Adapter's License You apply. You may satisfy this condition\n          in any reasonable manner based on the medium, means, and\n          context in which You Share Adapted Material.\n\n       3. You may not offer or impose any additional or different terms\n          or conditions on, or apply any Effective Technological\n          Measures to, Adapted Material that restrict exercise of the\n          rights granted under the Adapter's License You apply.\n\n\nSection 4 -- Sui Generis Database Rights.\n\nWhere the Licensed Rights include Sui Generis Database Rights that\napply to Your use of the Licensed Material:\n\n  a. for the avoidance of doubt, Section 2(a)(1) grants You the right\n     to extract, reuse, reproduce, and Share all or a substantial\n     portion of the contents of the database for NonCommercial purposes\n     only;\n\n  b. if You include all or a substantial portion of the database\n     contents in a database in which You have Sui Generis Database\n     Rights, then the database in which You have Sui Generis Database\n     Rights (but not its individual contents) is Adapted Material; and\n\n  c. You must comply with the conditions in Section 3(a) if You Share\n     all or a substantial portion of the contents of the database.\n\nFor the avoidance of doubt, this Section 4 supplements and does not\nreplace Your obligations under this Public License where the Licensed\nRights and Copyright and Similar Rights also apply.\n\n\nSection 5 -- Disclaimer of Warranties and Limitation of Liability.\n\n  a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE\n     EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS\n     AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF\n     ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,\n     IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,\n     WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR\n     PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,\n     ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT\n     KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT\n     ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.\n\n  b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE\n     TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,\n     NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,\n     INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,\n     COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR\n     USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN\n     ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. WHERE A LIMITATION OF\n     LIABILITY IS NOT ALLOWED IN FULL OR IN PART, THIS LIMITATION MAY\n     NOT APPLY TO YOU.\n\n  c. The disclaimer of warranties and limitation of liability provided\n     above shall be interpreted in a manner that, to the extent\n     possible, most closely approximates an absolute disclaimer and\n     waiver of all liability.\n\n\nSection 6 -- Term and Termination.\n\n  a. This Public License applies for the term of the Copyright and\n     Similar Rights licensed here. However, if You fail to comply with\n     this Public License, then Your rights under this Public License\n     terminate automatically.\n\n  b. Where Your right to use the Licensed Material has terminated under\n     Section 6(a), it reinstates:\n\n       1. automatically as of the date the violation is cured, provided\n          it is cured within 30 days of Your discovery of the\n          violation; or\n\n       2. upon express reinstatement by the Licensor.\n\n     For the avoidance of doubt, this Section 6(b) does not affect any\n     right the Licensor may have to seek remedies for Your violations\n     of this Public License.\n\n  c. For the avoidance of doubt, the Licensor may also offer the\n     Licensed Material under separate terms or conditions or stop\n     distributing the Licensed Material at any time; however, doing so\n     will not terminate this Public License.\n\n  d. Sections 1, 5, 6, 7, and 8 survive termination of this Public\n     License.\n\n\nSection 7 -- Other Terms and Conditions.\n\n  a. The Licensor shall not be bound by any additional or different\n     terms or conditions communicated by You unless expressly agreed.\n\n  b. Any arrangements, understandings, or agreements regarding the\n     Licensed Material not stated herein are separate from and\n     independent of the terms and conditions of this Public License.\n\n\nSection 8 -- Interpretation.\n\n  a. For the avoidance of doubt, this Public License does not, and\n     shall not be interpreted to, reduce, limit, restrict, or impose\n     conditions on any use of the Licensed Material that could lawfully\n     be made without permission under this Public License.\n\n  b. To the extent possible, if any provision of this Public License is\n     deemed unenforceable, it shall be automatically reformed to the\n     minimum extent necessary to make it enforceable. If the provision\n     cannot be reformed, it shall be severed from this Public License\n     without affecting the enforceability of the remaining terms and\n     conditions.\n\n  c. No term or condition of this Public License will be waived and no\n     failure to comply consented to unless expressly agreed to by the\n     Licensor.\n\n  d. Nothing in this Public License constitutes or may be interpreted\n     as a limitation upon, or waiver of, any privileges and immunities\n     that apply to the Licensor or You, including from the legal\n     processes of any jurisdiction or authority.\n"
  },
  {
    "path": "README.md",
    "content": "# Antigravity Tools 🚀\n> 专业级 AI 账号管理与协议代理系统 (v4.1.30)\n<div align=\"center\">\n  <img src=\"public/icon.png\" alt=\"Antigravity Logo\" width=\"120\" height=\"120\" style=\"border-radius: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.15);\">\n\n  <h3>您的个人高性能 AI 调度网关</h3>\n  <p>不仅仅是账号管理，更是打破 API 调用壁垒的终极解决方案。</p>\n  \n  <p>\n    <a href=\"https://github.com/lbjlaq/Antigravity-Manager\">\n      <img src=\"https://img.shields.io/badge/Version-4.1.30-blue?style=flat-square\" alt=\"Version\">\n    </a>\n    <img src=\"https://img.shields.io/badge/Tauri-v2-orange?style=flat-square\" alt=\"Tauri\">\n    <img src=\"https://img.shields.io/badge/Backend-Rust-red?style=flat-square\" alt=\"Rust\">\n    <img src=\"https://img.shields.io/badge/Frontend-React-61DAFB?style=flat-square\" alt=\"React\">\n    <img src=\"https://img.shields.io/badge/License-CC--BY--NC--SA--4.0-lightgrey?style=flat-square\" alt=\"License\">\n  </p>\n\n  <p>\n    <a href=\"#-核心功能\">核心功能</a> • \n    <a href=\"#-界面导览\">界面导览</a> • \n    <a href=\"#-技术架构\">技术架构</a> • \n    <a href=\"#-安装指南\">安装指南</a> • \n    <a href=\"#-快速接入\">快速接入</a>\n  </p>\n\n  <p>\n    <strong>简体中文</strong> | \n    <a href=\"./README_EN.md\">English</a>\n  </p>\n</div>\n\n---\n\n**Antigravity Tools** 是一个专为开发者和 AI 爱好者设计的全功能桌面应用。它将多账号管理、协议转换和智能请求调度完美结合，为您提供一个稳定、极速且成本低廉的 **本地 AI 中转站**。\n\n通过本应用，您可以将常见的 Web 端 Session (Google/Anthropic) 转化为标准化的 API 接口，消除不同厂商间的协议鸿沟。\n\n## 💖 赞助商 (Sponsors)\n\n| 赞助商 (Sponsor) | 简介 (Description) |\n| :---: | :--- |\n| <img src=\"docs/images/packycode_logo.png\" width=\"200\" alt=\"PackyCode Logo\"> | 感谢 **PackyCode** 对本项目的赞助！PackyCode 是一家可靠高效的 API 中转服务商，提供 Claude Code、Codex、Gemini 等多种服务的中转。PackyCode 为本项目的用户提供了特别优惠：使用[此链接](https://www.packyapi.com/register?aff=Ctrler)注册，并在充值时输入 **“Ctrler”** 优惠码即可享受 **九折优惠**。 |\n| <img src=\"docs/images/AICodeMirror.jpg\" width=\"200\" alt=\"AICodeMirror Logo\"> | 感谢 AICodeMirror 赞助了本项目！AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务，支持企业级高并发、极速开票、7×24 专属技术支持。 Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折，充值更有折上折！AICodeMirror 为 Antigravity-Manager 的用户提供了特别福利，通过[此链接](https://www.aicodemirror.com/register?invitecode=MV5XUM)注册的用户，可享受首充8折，企业客户最高可享 7.5 折！ |\n\n### ☕ 支持项目 (Support)\n\n如果您觉得本项目对您有所帮助，欢迎打赏作者！\n\n<a href=\"https://www.buymeacoffee.com/Ctrler\" target=\"_blank\"><img src=\"https://cdn.buymeacoffee.com/buttons/v2/default-green.png\" alt=\"请我喝杯咖啡\" style=\"height: 60px !important; width: 217px !important;\"></a>\n\n| 支付宝 (Alipay) | 微信支付 (WeChat) | Buy Me a Coffee |\n| :---: | :---: | :---: |\n| ![Alipay](./docs/images/donate_alipay.png) | ![WeChat](./docs/images/donate_wechat.png) | ![Coffee](./docs/images/donate_coffee.png) |\n\n## 🌟 深度功能解析 (Detailed Features)\n\n### 1. 🎛️ 智能账号仪表盘 (Smart Dashboard)\n*   **全局实时监控**: 一眼洞察所有账号的健康状况，包括 Gemini Pro、Gemini Flash、Claude 以及 Gemini 绘图的 **平均剩余配额**。\n*   **最佳账号推荐 (Smart Recommendation)**: 系统会根据当前所有账号的配额冗余度，实时算法筛选并推荐“最佳账号”，支持 **一键切换**。\n*   **活跃账号快照**: 直观显示当前活跃账号的具体配额百分比及最后同步时间。\n\n### 2. 🔐 强大的账号管家 (Account Management)\n*   **OAuth 2.0 授权（自动/手动）**: 添加账号时会提前生成可复制的授权链接，支持在任意浏览器完成授权；回调成功后应用会自动完成并保存（必要时可点击“我已授权，继续”手动收尾）。\n*   **多维度导入**: 支持单条 Token 录入、JSON 批量导入（如来自其他工具的备份），以及从 V1 旧版本数据库自动热迁移。\n*   **网关级视图**: 支持“列表”与“网格”双视图切换。提供 403 封禁检测，自动标注并跳过权限异常的账号。\n\n### 3. 🔌 协议转换与中继 (API Proxy)\n*   **全协议适配 (Multi-Sink)**:\n    *   **OpenAI 格式**: 提供 `/v1/chat/completions` 端点，兼容 99% 的现有 AI 应用。\n    *   **Anthropic 格式**: 提供原生 `/v1/messages` 接口，支持 **Claude Code CLI** 的全功能（如思思维链、系统提示词）。\n    *   **Gemini 格式**: 支持 Google 官方 SDK 直接调用。\n*   **智能状态自愈**: 当请求遇到 `429 (Too Many Requests)` 或 `401 (Expire)` 时，后端会毫秒级触发 **自动重试与静默轮换**，确保业务不中断。\n\n### 4. 🔀 模型路由中心 (Model Router)\n*   **系列化映射**: 您可以将复杂的原始模型 ID 归类到“规格家族”（如将所有 GPT-4 请求统一路由到 `gemini-3-pro-high`）。\n*   **专家级重定向**: 支持自定义正则表达式级模型映射，精准控制每一个请求的落地模型。\n*   **智能分级路由 (Tiered Routing)**: [新] 系统根据账号类型（Ultra/Pro/Free）和配额重置频率自动优先级排序，优先消耗高速重置账号，确保高频调用下的服务稳定性。\n*   **后台任务静默降级**: [新] 自动识别 Claude CLI 等工具生成的后台请求（如标题生成），智能重定向至 Flash 模型，保护高级模型配额不被浪费。\n\n### 5. 🎨 多模态与 Imagen 3 支持\n*   **高级画质控制**: 支持通过 OpenAI `size` (如 `1024x1024`, `16:9`) 参数自动映射到 Imagen 3 的相应规格。\n*   **超强 Body 支持**: 后端支持高达 **100MB** (可配置) 的 Payload，处理 4K 高清图识别绰绰有余。\n\n## 📸 界面导览 (GUI Overview)\n\n| | |\n| :---: | :---: |\n| ![仪表盘 - 全局配额监控与一键切换](docs/images/dashboard-light.png) <br> 仪表盘 | ![账号列表 - 高密度配额展示与 403 智能标注](docs/images/accounts-light.png) <br> 账号列表 |\n| ![关于页面 - 关于 Antigravity Tools](docs/images/about-dark.png) <br> 关于页面 | ![API 反代 - 服务控制](docs/images/v3/proxy-settings.png) <br> API 反代 |\n| ![系统设置 - 通用配置](docs/images/settings-dark.png) <br> 系统设置 | |\n\n### 💡 使用案例 (Usage Examples)\n\n| | |\n| :---: | :---: |\n| ![Claude Code 联网搜索 - 结构化来源与引文显示](docs/images/usage/claude-code-search.png) <br> Claude Code 联网搜索 | ![Cherry Studio 深度集成 - 原生回显搜索引文与来源链接](docs/images/usage/cherry-studio-citations.png) <br> Cherry Studio 深度集成 |\n| ![Imagen 3 高级绘图 - 完美还原 Prompt 意境与细节](docs/images/usage/image-gen-nebula.png) <br> Imagen 3 高级绘图 | ![Kilo Code 接入 - 多账号极速轮换与模型穿透](docs/images/usage/kilo-code-integration.png) <br> Kilo Code 接入 |\n\n## 🏗️ 技术架构 (Architecture)\n\n```mermaid\ngraph TD\n    Client([外部应用: Claude Code/NextChat]) -->|OpenAI/Anthropic| Gateway[Antigravity Axum Server]\n    Gateway --> Middleware[中间件: 鉴权/限流/日志]\n    Middleware --> Router[Model Router: ID 映射]\n    Router --> Dispatcher[账号分发器: 轮询/权重]\n    Dispatcher --> Mapper[协议转换器: Request Mapper]\n    Mapper --> Upstream[上游请求: Google/Anthropic API]\n    Upstream --> ResponseMapper[响应转换器: Response Mapper]\n    ResponseMapper --> Client\n```\n\n##  安装指南 (Installation)\n\n### 选项 A: 终端安装 (推荐)\n\n#### 跨平台一键安装脚本\n\n自动检测操作系统、架构和包管理器，一条命令完成下载与安装。\n\n**Linux / macOS:**\n```bash\ncurl -fsSL https://raw.githubusercontent.com/lbjlaq/Antigravity-Manager/v4.1.30/install.sh | bash\n```\n\n**Windows (PowerShell):**\n```powershell\nirm https://raw.githubusercontent.com/lbjlaq/Antigravity-Manager/main/install.ps1 | iex\n```\n\n> **支持的格式**: Linux (`.deb` / `.rpm` / `.AppImage`) | macOS (`.dmg`) | Windows (NSIS `.exe`)\n>\n> **高级用法**: 安装指定版本 `curl -fsSL ... | bash -s -- --version 4.1.30`，预览模式 `curl -fsSL ... | bash -s -- --dry-run`\n\n#### macOS - Homebrew\n如果您已安装 [Homebrew](https://brew.sh/)，也可以通过以下命令安装：\n\n```bash\n# 1. 订阅本仓库的 Tap\nbrew tap lbjlaq/antigravity-manager https://github.com/lbjlaq/Antigravity-Manager\n\n# 2. 安装应用\nbrew install --cask antigravity-tools\n```\n> **提示**: 如果遇到权限问题，建议添加 `--no-quarantine` 参数。\n\n#### Arch Linux\n您可以选择通过一键安装脚本或 Homebrew 进行安装：\n\n**方式 1：一键安装脚本 (推荐)**\n```bash\ncurl -sSL https://raw.githubusercontent.com/lbjlaq/Antigravity-Manager/main/deploy/arch/install.sh | bash\n```\n\n**方式 2：通过 Homebrew** (如果您已安装 [Linuxbrew](https://sh.brew.sh/))\n```bash\nbrew tap lbjlaq/antigravity-manager https://github.com/lbjlaq/Antigravity-Manager\nbrew install --cask antigravity-tools\n```\n\n#### 其他 Linux 发行版\n安装后会自动将 AppImage 添加到二进制路径并配置可执行权限。\n\n### 选项 B: 手动下载\n前往 [GitHub Releases](https://github.com/lbjlaq/Antigravity-Manager/releases) 下载对应系统的包：\n*   **macOS**: `.dmg` (支持 Apple Silicon & Intel)\n*   **Windows**: `.msi` 或 便携版 `.zip`\n*   **Linux**: `.deb` 或 `AppImage`\n\n### 选项 C: Docker 部署 (推荐用于 NAS/服务器)\n如果您希望在容器化环境中运行，我们提供了原生的 Docker 镜像。该镜像内置了对 v4.0.2 原生 Headless 架构的支持，可自动托管前端静态资源，并通过浏览器直接进行管理。\n\n```bash\n# 方式 1: 直接运行 (推荐)\n# - API_KEY: 必填。用于所有协议的 AI 请求鉴定。\n# - WEB_PASSWORD: 可选。用于管理后台登录。若不设置则默认使用 API_KEY。\ndocker run -d --name antigravity-manager \\\n  -p 8045:8045 \\\n  -e API_KEY=sk-your-api-key \\\n  -e WEB_PASSWORD=your-login-password \\\n  -e ABV_MAX_BODY_SIZE=104857600 \\\n  -v ~/.antigravity_tools:/root/.antigravity_tools \\\n  lbjlaq/antigravity-manager:latest\n\n# 忘记密钥？执行 docker logs antigravity-manager 或 grep -E '\"api_key\"|\"admin_password\"' ~/.antigravity_tools/gui_config.json\n\n#### 🔐 鉴权逻辑说明\n*   **场景 A：仅设置了 `API_KEY`**\n    - **Web 登录**：使用 `API_KEY` 进入后台。\n    - **API 调用**：使用 `API_KEY` 进行 AI 请求鉴权。\n*   **场景 B：同时设置了 `API_KEY` 和 `WEB_PASSWORD` (推荐)**\n    - **Web 登录**：**必须**使用 `WEB_PASSWORD`，使用 API Key 将被拒绝（更安全）。\n    - **API 调用**：统一使用 `API_KEY`。这样您可以将 API Key 分发给成员，而保留密码仅供管理员使用。\n\n#### 🆙 旧版本升级指引\n如果您是从 v4.0.1 及更早版本升级，系统默认未设置 `WEB_PASSWORD`。您可以通过以下任一方式设置：\n1.  **Web UI 界面 (推荐)**：使用原有 `API_KEY` 登录后，在 **API 反代设置** 页面手动设置并保存。新密码将持久化存储在 `gui_config.json` 中。\n2.  **环境变量 (Docker)**：在启动容器时增加 `-e WEB_PASSWORD=您的新密码`。**注意：环境变量具有最高优先级，将覆盖 UI 中的任何修改。**\n3.  **配置文件 (持久化)**：直接修改 `~/.antigravity_tools/gui_config.json`，在 `proxy` 对象中修改或添加 `\"admin_password\": \"您的新密码\"` 字段。\n    - *注：`WEB_PASSWORD` 是环境变量名，`admin_password` 是配置文件中的 JSON 键名。*\n\n> [!TIP]\n> **密码优先级逻辑 (Priority)**:\n> - **第一优先级 (环境变量)**: `ABV_WEB_PASSWORD` 或 `WEB_PASSWORD`。只要设置了环境变量，系统将始终使用它。\n> - **第二优先级 (配置文件)**: `gui_config.json` 中的 `admin_password` 字段。UI 的“保存”操作会更新此值。\n> - **保底回退 (向后兼容)**: 若上述均未设置，则回退使用 `API_KEY` 作为登录密码。\n\n# 方式 2: 使用 Docker Compose\n# 1. 进入项目的 docker 目录\ncd docker\n# 2. 启动服务\ndocker compose up -d\n```\n> **访问地址**: `http://localhost:8045` (管理后台) | `http://localhost:8045/v1` (API Base)\n> **系统要求**:\n> - **内存**: 建议 **1GB** (最小 256MB)。\n> - **持久化**: 需挂载 `/root/.antigravity_tools` 以保存数据。\n> - **架构**: 支持 x86_64 和 ARM64。\n> **详情见**: [Docker 部署指南 (docker)](./docker/README.md)\n\n---\n\nCopyright © 2024-2026 [lbjlaq](https://github.com/lbjlaq)\n\n### 🛠️ 常见问题排查 (Troubleshooting)\n\n#### macOS 提示“应用已损坏，无法打开”？\n由于 macOS 的安全机制，非 App Store 下载的应用可能会触发此提示。您可以按照以下步骤快速修复：\n\n1.  **命令行修复** (推荐):\n    打开终端，执行以下命令：\n    ```bash\n    sudo xattr -rd com.apple.quarantine \"/Applications/Antigravity Tools.app\"\n    ```\n2.  **Homebrew 安装技巧**:\n    如果您使用 brew 安装，可以添加 `--no-quarantine` 参数来规避此问题：\n    ```bash\n    brew install --cask --no-quarantine antigravity-tools\n    ```\n\n## 🔌 快速接入示例\n\n### 🔐 OAuth 授权流程（添加账号）\n1. 打开“Accounts / 账号” → “添加账号” → “OAuth”。\n2. 弹窗会在点击按钮前预生成授权链接；点击链接即可复制到系统剪贴板，然后用你希望的浏览器打开并完成授权。\n3. 授权完成后浏览器会打开本地回调页并显示“✅ 授权成功!”。\n4. 应用会自动继续完成授权并保存账号；如未自动完成，可点击“我已授权，继续”手动完成。\n\n> 提示：授权链接包含一次性回调端口，请始终使用弹窗里生成的最新链接；如果授权时应用未运行或弹窗已关闭，浏览器可能会提示 `localhost refused connection`。\n\n### 如何接入 Claude Code CLI?\n1.  启动 Antigravity，并在“API 反代”页面开启服务。\n2.  在终端执行：\n```bash\nexport ANTHROPIC_API_KEY=\"sk-antigravity\"\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:8045\"\nclaude\n```\n\n### 如何接入 OpenCode?\n1.  进入 **API 反代**页面 → **外部 Providers** → 点击 **OpenCode Sync** 卡片。\n2.  点击 **Sync** 按钮，将自动生成 `~/.config/opencode/opencode.json` 配置文件：\n    - 创建独立 provider `antigravity-manager`（不覆盖 google/anthropic 原生配置）\n    - 可选：勾选 **Sync accounts** 导出 `antigravity-accounts.json`（plugin-compatible v3 格式），供 OpenCode 插件直接导入\n3.  点击 **Clear Config** 可一键清除 Manager 配置并清理 legacy 残留；点击 **Restore** 可从备份恢复。\n4.  Windows 用户路径为 `C:\\Users\\<用户名>\\.config\\opencode\\`（与 `~/.config/opencode` 规则一致）。\n\n**快速验证命令：**\n```bash\n# 测试 antigravity-manager provider（支持 --variant）\nopencode run \"test\" --model antigravity-manager/claude-sonnet-4-5-thinking --variant high\n\n# 若已安装 opencode-antigravity-auth 插件，验证 google provider 仍可独立工作\nopencode run \"test\" --model google/antigravity-claude-sonnet-4-5-thinking --variant max\n```\n\n### 如何接入 Kilo Code?\n1.  **协议选择**: 建议优先使用 **Gemini 协议**。\n2.  **Base URL**: 填写 `http://127.0.0.1:8045`。\n3.  **注意**: \n    - **OpenAI 协议限制**: Kilo Code 在使用 OpenAI 模式时，其请求路径会叠加产生 `/v1/chat/completions/responses` 这种非标准路径，导致 Antigravity 返回 404。因此请务必填入 Base URL 后选择 Gemini 模式。\n    - **模型映射**: Kilo Code 中的模型名称可能与 Antigravity 默认设置不一致，如遇到无法连接，请在“模型映射”页面设置自定义映射，并查看**日志文件**进行调试。\n\n### 如何在 Python 中使用?\n```python\nimport openai\n\nclient = openai.OpenAI(\n    api_key=\"sk-antigravity\",\n    base_url=\"http://127.0.0.1:8045/v1\"\n)\n\nresponse = client.chat.completions.create(\n    model=\"gemini-3-flash\",\n    messages=[{\"role\": \"user\", \"content\": \"你好，请自我介绍\"}]\n)\nprint(response.choices[0].message.content)\n```\n\n### 如何使用图片生成 (Imagen 3)?\n\n#### 方式一：OpenAI Images API (推荐)\n```python\nimport openai\n\nclient = openai.OpenAI(\n    api_key=\"sk-antigravity\",\n    base_url=\"http://127.0.0.1:8045/v1\"\n)\n\n# 生成图片\nresponse = client.images.generate(\n    model=\"gemini-3-pro-image\",\n    prompt=\"一座未来主义风格的城市，赛博朋克，霓虹灯\",\n    size=\"1920x1080\",      # 支持任意 WIDTHxHEIGHT 格式，自动计算宽高比\n    quality=\"hd\",          # \"standard\" | \"hd\" | \"medium\"\n    n=1,\n    response_format=\"b64_json\"\n)\n\n# 保存图片\nimport base64\nimage_data = base64.b64decode(response.data[0].b64_json)\nwith open(\"output.png\", \"wb\") as f:\n    f.write(image_data)\n```\n\n**支持的参数**：\n- **`size`**: 任意 `WIDTHxHEIGHT` 格式（如 `1280x720`, `1024x1024`, `1920x1080`），自动计算并映射到标准宽高比（21:9, 16:9, 9:16, 4:3, 3:4, 1:1）\n- **`quality`**: \n  - `\"hd\"` → 4K 分辨率（高质量）\n  - `\"medium\"` → 2K 分辨率（中等质量）\n  - `\"standard\"` → 默认分辨率（标准质量）\n- **`n`**: 生成图片数量（1-10）\n- **`response_format`**: `\"b64_json\"` 或 `\"url\"`（Data URI）\n\n#### 方式二：Chat API + 参数设置 (✨ 新增)\n\n**所有协议**（OpenAI、Claude）的 Chat API 现在都支持直接传递 `size` 和 `quality` 参数：\n\n```python\n# OpenAI Chat API\nresponse = client.chat.completions.create(\n    model=\"gemini-3-pro-image\",\n    size=\"1920x1080\",      # ✅ 支持任意 WIDTHxHEIGHT 格式\n    quality=\"hd\",          # ✅ \"standard\" | \"hd\" | \"medium\"\n    messages=[{\"role\": \"user\", \"content\": \"一座未来主义风格的城市\"}]\n)\n```\n\n```bash\n# Claude Messages API\ncurl -X POST http://127.0.0.1:8045/v1/messages \\\n  -H \"Content-Type: application/json\" \\\n  -H \"x-api-key: sk-antigravity\" \\\n  -d '{\n    \"model\": \"gemini-3-pro-image\",\n    \"size\": \"1280x720\",\n    \"quality\": \"hd\",\n    \"messages\": [{\"role\": \"user\", \"content\": \"一只可爱的猫咪\"}]\n  }'\n```\n\n```\n\n**参数优先级**: `imageSize` 参数 > `quality` 参数 > 模型后缀\n\n**✨ 新增 `imageSize` 参数支持**:\n\n除了 `quality` 参数外,现在还支持直接使用 Gemini 原生的 `imageSize` 参数:\n\n```python\n# 使用 imageSize 参数(最高优先级)\nresponse = client.chat.completions.create(\n    model=\"gemini-3-pro-image\",\n    size=\"16:9\",           # 宽高比\n    imageSize=\"4K\",        # ✨ 直接指定分辨率: \"1K\" | \"2K\" | \"4K\"\n    messages=[{\"role\": \"user\", \"content\": \"一座未来主义风格的城市\"}]\n)\n```\n\n```bash\n# Claude Messages API 也支持 imageSize\ncurl -X POST http://127.0.0.1:8045/v1/messages \\\n  -H \"Content-Type: application/json\" \\\n  -H \"x-api-key: sk-antigravity\" \\\n  -d '{\n    \"model\": \"gemini-3-pro-image\",\n    \"size\": \"1280x720\",\n    \"imageSize\": \"4K\",\n    \"messages\": [{\"role\": \"user\", \"content\": \"一只可爱的猫咪\"}]\n  }'\n```\n\n**参数说明**:\n- **`imageSize`**: 直接指定分辨率 (`\"1K\"` / `\"2K\"` / `\"4K\"`)\n- **`quality`**: 通过质量等级推断分辨率 (`\"standard\"` → 1K, `\"medium\"` → 2K, `\"hd\"` → 4K)\n- **优先级**: 如果同时指定 `imageSize` 和 `quality`,系统会优先使用 `imageSize`\n\n\n#### 方式三：Chat 接口 + 模型后缀\n```python\nresponse = client.chat.completions.create(\n    model=\"gemini-3-pro-image-16-9-4k\",  # 格式：gemini-3-pro-image-[比例]-[质量]\n    messages=[{\"role\": \"user\", \"content\": \"一座未来主义风格的城市\"}]\n)\n```\n\n**模型后缀说明**：\n- **宽高比**: `-16-9`, `-9-16`, `-4-3`, `-3-4`, `-21-9`, `-1-1`\n- **质量**: `-4k` (4K), `-2k` (2K), 不加后缀（标准）\n- **示例**: `gemini-3-pro-image-16-9-4k` → 16:9 比例 + 4K 分辨率\n\n#### 方式四：Cherry Studio 等客户端设置\n在支持 OpenAI 协议的客户端（如 Cherry Studio）中，可以通过**模型设置**页面配置图片生成参数：\n\n1. **进入模型设置**：选择 `gemini-3-pro-image` 模型\n2. **配置参数**：\n   - **Size (尺寸)**: 输入任意 `WIDTHxHEIGHT` 格式（如 `1920x1080`, `1024x1024`）\n   - **Quality (质量)**: 选择 `standard` / `hd` / `medium`\n   - **Number (数量)**: 设置生成图片数量（1-10）\n3. **发送请求**：直接在对话框中输入图片描述即可\n\n**参数映射规则**：\n- `size: \"1920x1080\"` → 自动计算为 `16:9` 宽高比\n- `quality: \"hd\"` → 映射为 `4K` 分辨率\n- `quality: \"medium\"` → 映射为 `2K` 分辨率\n\n\n## 📝 开发者与社区\n\n*   **版本演进 (Changelog)**:\n    *   **v4.1.30 (2026-03-15)**:\n        -   **[核心优化] 引入 fetchAvailableModels 接口的多级降级机制 (PR #2329)**:\n            -   **端点降级策略**: 为 `fetchAvailableModels` API 引入了 Sandbox -> Daily -> Prod 的端点自动降级机制。当请求遇到 `429 (Too Many Requests)` 或 `5xx` 服务器错误时，系统会自动平滑切换到备选端点，显著提升了配额刷新和模型列表获取的稳定性。\n            -   **逻辑对齐**: 将配额获取的错误处理和重试逻辑与核心 API 处理器 (Handler) 进行了对齐，确保了请求管道在极端情况下的行为一致性。\n        -   **[核心修复] 优化 Gemini SSE 流错误处理，防止传输编码错误 (PR #2322)**:\n            -   **错误封装导出**: 修复了 Gemini SSE 流 en 遇到上游错误时直接抛出原始错误导致客户端触发 `TransferEncodingError` 的问题。系统现在会将流错误捕获并封装为标准的 JSON 格式数据帧输出，确保连接能够优雅关闭并向前端传递清晰的错误信息。\n            -   **多协议对齐**: 该修复同步应用到了 Gemini 原生处理器与 Claude 协议映射器，确保了跨协议流式输出的一致性和健壮性。\n    *   **v4.1.29 (2026-03-12)**:\n        -   **[重要提醒] 谷歌风控与第三方工具使用风险**:\n            -   由于谷歌加强风控，第三方工具会违反服务条款而被暂停使用 Antigravity、Gemini CLI 或 Gemini Code Assist。\n            -   使用第三方软件、工具或服务访问 Antigravity、Gemini CLI 或 Gemini Code Assist（例如，使用 OpenClaw 和 Antigravity OAuth）违反了适用的条款和政策。此类行为可能导致您的帐户被暂停或终止。建议只使用切换功能\n            -   **申诉链接**: 如果您认为帐户被误封，请通过 [此链接](https://forms.gle/hGzM9MEUv2azZsrb9) 进行申诉。\n            -   如果你对新的反代功能感兴趣可以查看 [TG 频道](https://t.me/AntigravityManager) 获取最新动态。\n            -   ![风险提示](docs/images/CleanShot%202026-03-12%20at%2009.34.34@2x.png)\n        -   **[核心功能] 账号感知的动态模型重映射与回退 (PR #2286)**:\n            -   **动态回退逻辑**: 解决了由于不同账号对 Gemini Pro 模型层级（如 `high` / `low`）访问权限不一致导致的 `404/400` 报错问题。系统现在会根据选中账号的实际权限，在同系列模型间自动执行平滑回退（例如：`gemini-3.1-pro-high` -> `gemini-3.1-pro-low` -> 默认层级）。\n            -   **账号权限实时校验**: 在请求进入处理器前，动态通过账号文件数据校验目标模型的可用性，实现真正意义上的“账号感知”调度。\n            -   **重映射优先级优化**: 确立了 `API 弃用规则 > 账号感知回退 > 用户自定义映射 > 系统默认映射` 的科学优先级链条。\n            -   **文档同步**: 新增了 `docs/model-remapping-logic.md`，完整记录了复杂的重映射逻辑流程。\n        -   **[核心修复] Windows CLI 探测增强与路径扫描优化 (PR #2298)**:\n            -   **路径主动扫描**: 引入了对 `APPDATA`、`LOCALAPPDATA` 以及 `NVM_HOME` 等路径的自动扫描机制，确保即使 CLI 未正确配置在系统 `PATH` 中也能被精准识别。\n            -   **脚本处理优化**: 改进了 Windows 环境下 `.cmd` 和 `.bat` 脚本的调用方式，解决了直接执行无法稳定获取版本号的问题。\n            -   **执行安全加固**: 新增了路径安全性校验逻辑，通过绝对路径检查与危险字符过滤，有效防范命令注入风险。\n        -   **[持续集成] 引入 GitHub Actions CI 工作流 (PR #2298)**:\n            -   **自动化质量控制**: 构建了基础的 CI 流水线，涵盖 Rust 代码格式化检查、静态分析以及跨平台编译测试，提升了代码合规性与交付稳定性。\n    *   **v4.1.28 (2026-03-03)**:\n        -   **[重要提醒] 谷歌风控与第三方工具使用风险**:\n            -   由于谷歌加强风控，第三方工具会违反服务条款而被暂停使用 Antigravity、Gemini CLI 或 Gemini Code Assist。\n            -   使用第三方软件、工具或服务访问 Antigravity、Gemini CLI 或 Gemini Code Assist（例如，使用 OpenClaw 和 Antigravity OAuth）违反了适用的条款和政策。此类行为可能导致您的帐户被暂停或终止。\n            -   **申诉链接**: 如果您认为帐户被误封，请通过 [此链接](https://forms.gle/hGzM9MEUv2azZsrb9) 进行申诉。\n            -   **[后续规划] 关于未来版本更迭**:\n                -   我们计划在后续推送新版本，届时可能会将“账号切换”与“反代代理”功能解耦为独立的模块或工具。\n                -   由于作者近期工作繁忙，发布可能会有延迟，感谢理解。\n                -   欢迎关注公众号 **Ctrler** 或 **[TG 频道](https://t.me/AntigravityManager)** 获取最新动态。\n            -   **请谨慎使用本项目。**\n        -   **[核心修复] 全系列模型限流锁定修复 (Fix Issue #2209)**:\n            -   **统一归一化逻辑**: 修复了 Claude 和 Gemini 系列模型在发生 429 (Too Many Requests) 错误时，由于限流 Key 未归一化导致负载均衡器无法识别锁定状态的问题。\n            -   **熔断器联动增强**: 确保即使在禁用\"额度保护\"的情况下，内置熔断器也能通过归一化后的模型 ID（如 `claude`, `gemini-3-flash` 等）精确拦截已耗尽账号，消除 90s 的无效等待。\n        -   **[核心修复] Gemini 系列模型 adaptive 模式下错误注入 `thinkingLevel` 导致 400 报错 (Fix Issue #2208)**:\n            -   **根因定位**: 4.1.27 引入的自适应识别逻辑将 `gemini-3.1-pro-high` / `gemini-3.1-pro-low` 等 Gemini 系列模型误判为支持 `thinkingLevel`，而 `thinkingLevel` 是 Vertex AI Claude 原生协议专有参数，Gemini 系列底层走 v1internal 协议，仅接受 `thinkingBudget`，导致请求被 Google API 拒绝并返回 `400 INVALID_ARGUMENT`。\n            -   **条件收窄**: 将注入 `thinkingLevel` 的触发条件从 `contains(\"gemini-3\")` 修正为 `contains(\"claude\")`，确保 `thinkingLevel` 仅在 Claude 协议路径下注入，Gemini 系列模型在 adaptive 模式下统一回落到安全的 `thinkingBudget: 24576`。\n            -   **零附带损伤**: OpenAI 协议与 Gemini 原生协议路径本身无此问题，本次修复仅针对 Claude 协议映射器，影响范围最小。\n        -   **[核心修复] 修复 Claude Code 4.1.27+ 联网搜索 (Internal Tool) 失效问题 (Issue #2224)**:\n            -   **混合工具支持**: 克服了 Gemini v1internal API 对 `googleSearch` 与自定义 `functionDeclarations` 同时使用的限制。\n            -   **智能感知注入**: 重构了工具注入引擎，实现在 Gemini 2.0+ 和 3.0 系列模型上自动同时开启内置搜索与自定义开发者工具。\n            -   **多协议对齐**: 本次修复同步覆盖了 OpenAI 和 Gemini Native 协议，确保全协议栈在高性能模型下的联网能力一致性。\n            -   **后向兼容**: 针对旧版 Gemini 1.5 模型保留了自动排他转换逻辑，规避 400 错误。\n        -   **[核心修复] gemini-3-flash / gemini-3.1-flash 函数调用时缺少 thought_signature 导致 400 报错 (Fix Issue #2167)**:\n            -   **根因定位**: 三个协议映射器（OpenAI / Claude / Gemini 原生）的模型识别逻辑均未将 `gemini-3-flash` 系列纳入 \"thinking 模型\" 范畴，致使在首次函数调用（无 Session 签名缓存）时，`thoughtSignature` 字段未被注入，Google v1internal API 返回 `400 INVALID_ARGUMENT`。\n            -   **OpenAI 协议**: 新增 `is_gemini_flash_thinking` 判断变量，在 `functionCall` 构建阶段，当 Session 缓存为空时自动注入哨兵值 `skip_thought_signature_validator`。\n            -   **Claude 协议**: 将 `gemini-3-flash` / `gemini-3.1-flash` 加入 `target_model_supports_thinking` 识别列表；无签名时 flash 模型不再强制禁用 thinking，改为依赖现有哨兵注入路径（`build_contents` L1249-1256），保留模型思考能力。\n            -   **Gemini 原生协议**: 在 `wrap_request` 的 `functionCall` 处理块中，当 Session 缓存为空时对 flash 模型补充哨兵 fallback，覆盖首次调用场景。\n            -   **零附带损伤**: flash 模型不触发 `thinkingConfig` 注入逻辑，不影响非思考类请求的正常路径；顺带修复了 `test_wrap_request_with_signature` 单元测试中 `session_id` 参数位置错误的既有 Bug。\n        -   **[核心修复] Token 统计时区偏差修复 (Fix Issue #2214)**:\n            -   **自动时区贴合**: 将 Token 统计的基准时间从标准时间 (UTC) 切换为系统本地时间 (Local Time)。\n            -   **全球多时区支持**: 引入了 SQLite `'localtime'` 转换机制。无论用户身处全球何处，统计图表的时间轴都将自动与其系统时钟对齐，彻底解决了北京时间或其他非 UTC 时区下的数据错位问题。\n\n    *   **v4.1.27 (2026-03-01)**:\n        -   **[核心优化] 代理配置初始化与工具图片保留修复 (Issue #2156)**:\n            -   **补全默认配置**: 修复了 `ProxyConfig` 默认初始化时缺失 `global_system_prompt`、`proxy_pool` 和 `image_thinking_mode` 字段导致的编译失败问题。\n            -   **模式匹配完善**: 补充了 `OpenAIContentBlock` 枚举匹配中的未知类型兜底分支 (`_ => {}`)，消除非穷尽匹配的编译警告/错误。\n            -   **图片无条件保留**: 移除冗余的 `preserve_tool_result_images` 开关，现已强制保留 `tool_result` 中的图片数据结构，转为大模型支持的 `inlineData` 结构，大幅简化逻辑。\n        -   **[功能增强] 修改 docker-compose.yml 的配置 (PR #2185)**:\n            -   **命名空间更新**: 将构建的默认镜像名称从 `antigravity-manager` 更新为 `lbjlaq/antigravity-manager`。\n            -   **环境变量占位符**: 为环境变量添加了带默认值的占位符语法，允许用户通过宿主机的环境变量或 `.env` 文件来灵活覆盖默认配置。\n        -   **[核心修复] OpenCode thinking budget 参数全面兼容 (Issue #2186)**:\n            -   **架构支持**：解决了 Vercel AI SDK (`@ai-sdk/anthropic`) 配合 OpenCode 使用时，因原生蛇形命名 `budget_tokens` 导致系统无法启动并抛出 `AI_UnsupportedFunctionalityError: 'thinking requires a budget'` 的问题。\n            -   **双字段输出**：在向 OpenCode / Claude CLI 等外部客户端同步模型配置时，自动同时输出标准的 `budget_tokens` 与小驼峰的 `budgetTokens` 字段。\n            -   **服务端适配**：后端配置解析器现已原生支持这两种命名变体。\n        -   **[核心修复] 解决免费账号配额耗尽后的无限重试与路由死锁问题 (Issue #2184)**：\n            -   **问题根源**：修补了 Google API `fetchAvailableModels` 接口在特定负载下无法正确返回 `remainingFraction` 的缺陷。由于缺失 `project` 标识，导致接口错误地为已耗尽配额（HTTP 429）的账号返回 `1.0`（100%），进而导致智能路由算法将请求持续分配给不可用账号，引发长时间重试及配额显示错误。\n            -   **负载修复**：修改配额刷新请求，在负载中精准注入正确的 `{\"project\": project_id}` 结构。恢复了配额信息的准确感知，并在未破坏原生字段（如 `supportsThinking`）的前提下实现了接口完全兼容。\n            -   **自愈恢复**：通过读取真实配额，系统现已能够实时识别免费账号的耗尽状态并将其可用度置为 0%，无缝触发多账号自愈轮询（Smart Status Self-healing），解决请求受阻与长等待问题。\n        -   **[核心修复] 解决首页 Gemini 绘图平均配额显示为 0 的问题 (Issue #2160)**：\n            -   **匹配更新**：将 Dashboard 中的绘图模型匹配逻辑从硬编码的 `gemini-3-pro-image` 更新为包含最新的 `gemini-3.1-flash-image`。\n            -   **配置同步**：在 `modelConfig.ts` 中补全了新版绘图模型的 UI 定义，确保图标和标签正常渲染。\n        -   **[核心功能] 全协议动态模型规格 (Model Specs) 集成 (Issue #2176)**：\n            -   **动态引擎**：实现了“动态优先、静态兜底”的规格引擎，优先识别 API 返回的 `max_output_tokens` 等硬限额数据。\n            -   **静态资源**：引入 `model_specs.json` 集中管理 30+ 种模型的默认参数，彻底告别映射器中的硬编码逻辑。\n            -   **协议注入**：统一了 OpenAI、Claude 和 Gemini 协议处理器对 Token 限额的注入方式，增强了跨版本兼容性。\n        -   **[核心修复] 深度解决 Claude -> Gemini 3 路径下的 400 INVALID_ARGUMENT 异常**：\n            -   **自适应识别**：修正了自适应模式逻辑，确保映射后的 Gemini 3 模型能正确使用 `thinkingLevel` 支持，而非失效的 budget 逻辑。\n            -   **冲突规避**：实现了参数排他性检查，在开启分级思维时自动剥离不兼容的 `thinkingBudget`。\n            -   **Token 溢出保护**：为 `maxOutputTokens` 自动提升补齐逻辑增加了 `65536` 的模型硬上限保护，根除参数越界导致的请求失败。\n    *   **v4.1.26 (2026-02-27)**:\n        -   **[功能增强] 优化配额刷新逻辑，支持同步禁用账号**:\n            -   **逻辑放宽**: “刷新所有”和“批量刷新”现在不再跳过标记为 `disabled` 或 `proxy_disabled` 的账号。\n            -   **自动恢复**: 允许通过刷新操作尝试重新激活因 Token 过期或临时错误而被禁用的账号，提升了多账号管理的灵活性。\n        -   **[核心修复] 修复 Windows 系统下后台任务导致 cmd 黑框闪烁的问题**:\n            -   **静默执行**: 通过为 `std::process::Command` 封装注入 `CREATE_NO_WINDOW` 标志，解决了在 Windows 端应用底层组件（如版本探测、重启更新等）调用系统命令时引发的命令行窗口一闪而过的视觉干扰，确保全过程无边框静默执行。\n    *   **v4.1.25 (2026-02-27)**:\n        -   **[核心功能] 动态画图模型与新架构支持**:\n            -   **动态解析**: 移除了针对 `gemini-3-pro-image` 的硬编码限制。通过新增的 `clean_image_model_name` 智能清洗后缀（如 `-4k`, `-16x9`），全面兼容如 `gemini-3.1-flash-image` 等任意未来新增的画图模型。\n            -   **配额自适应**: 优化了 `normalize_to_standard_id`，使用 `image` 关键词宽泛匹配，确保新模型也能正确触发配额保护机制。\n        -   **[核心功能] 聊天接口 (Chat Completions) 画图拦截支持**:\n            -   **跨界融合**: OpenAI 和 Claude 协议的对话流现在能智能探测画像生成意图。当使用带有 `image` 的模型名时，系统会将常规文本生成请求静默转移给高级画图引擎。\n            -   **流式回显**: 生成完成后，通过 Markdown 格式（`![Generated Image](url)`）以 SSE 流式返回图片链接，完美适配所有支持 Markdown 的聊天客户端。\n        -   **[核心修复] 彻底修复画图重定向 404 与参数穿透失效**:\n            -   **404 移除**: 移除了底层调用中残留的旧模型硬编码，根除因模型信息不一致导致的 404 Not Found 崩溃及账号受损。\n            -   **精准参数继承**: 修复了未传参数时系统强制塞入默认 `1024x1024` 的行为。现在，如果模型名带有后缀（如 `gemini-3-pro-image-16x9-4k`），后台会严格优先解析后缀分辨率进行穿透绘图。\n    *   **v4.1.24 (2026-02-26)**:\n        -   **[功能调整] 禁用自动预热调度程序，保留手动预热**:\n            -   **变更说明**: 为了减少不必要的后台资源占用，本版本已注释掉自动预热（Smart Warmup）的后台调度逻辑。\n            -   **设置隐藏**: 设置页面中的“智能预热”配置项已隐藏。\n            -   **手动保留**: 账号管理页面的手动预热功能保持不变，仍可正常使用。\n            -   **恢复指引**: 如果您需要自动预热功能，可以自行拉取本项目源代码，在 `src-tauri/src/lib.rs` 中取消 `start_scheduler` 的注释并解除 `Settings.tsx` 中相关 UI 的注释后重新编译使用。\n        -   **[核心修复] 智能版本指纹选择与启动 Panic 修复 (Issue #2123)**:\n            -   **问题根源**: 1) `constants.rs` 中的 `KNOWN_STABLE_VERSION` 硬编码了低版本号，当本地 IDE 检测失败时回退该版本作为请求头，导致 Google 拒绝 Gemini 3.1 Pro 模型。2) 新增的远端版本网络调用直接在 `LazyLock` 初始化（Tokio 异步上下文）中执行，导致 `Cannot block the current thread` 严重崩溃。\n            -   **修复方案**: 1) 引入\"智能最大版本\"策略 `max(本地版本, 远端版本, 4.1.27)`，始终取最高值。2) 将网络探测逻辑移至独立 OS 线程并配合 `mpsc` 通道，安全避开异步运行时限制。保证无论本地版本新旧，指纹均不低于上游要求，且应用能稳定启动。\n        -   **[核心修复] 动态模型 maxOutputTokens 限额系统 (替代 PR #2119 硬编码方案)**:\n            -   **问题根源**: 部分客户端发送的 `maxOutputTokens` 超过模型物理上限（如 Flash 限制 64k），导致上游返回 400 错误。\n            -   **三层限额架构**:\n                -   **第一层（动态优先）**: 实时读取账号 `quota.models` 数据。\n                -   **第二层（静态默认表）**: `model_limits.rs` 内置已知限额（如 Flash 65536）。\n                -   **第三层（全局兜底）**: 默认 131072。\n            -   **实现细节**: 在 `wrap_request()` 中注入裁剪逻辑，确保请求参数合法。\n    *   **v4.1.23 (2026-02-25)**:\n        -   **[安全增强] 优化与原生对齐应用层与底层特征指纹，提升请求稳定性与防拦截能力。**\n        -   **[核心修复] 将 v1beta thinkingLevel 转换为 v1internal thinkingBudget (PR #2095)**:\n            -   **问题根源**: OpenClaw、Cline 等客户端发送 v1beta 格式的 `thinkingLevel` 字符串（`\"NONE\"` / `\"LOW\"` / `\"MEDIUM\"` / `\"HIGH\"`）到 `generationConfig.thinkingConfig`。当 AGM 通过 Google v1internal API 代理请求时，Google 会因为 v1internal 仅接受数字型 `thinkingBudget` 而拒绝请求，返回 `400 INVALID_ARGUMENT`。\n            -   **修复方案**: 在 `wrap_request()` 的现有 budget 处理逻辑之前，新增一个早期转换步骤：检测 `thinkingLevel` 字符串，将其映射为对应的数字 `thinkingBudget`（`NONE`→0, `LOW`→4096, `MEDIUM`→8192, `HIGH`→24576），然后删除 `thinkingLevel` 字段并写入 `thinkingBudget`，确保下游所有 budget 处理逻辑（预算封顶、`maxOutputTokens` 调整、自适应检测）都能看到正确的数值预算。\n            -   **测试**: 已验证 OpenClaw 发送 `thinkingLevel: \"LOW\"` 到 `gemini-3.1-pro-high`（Gemini 原生协议），请求现返回 `200 OK`，不再报 400 错误。\n        -   **[核心修复] 账号数据损坏与后台任务无限循环修复 (PR #2094)**:\n            -   **问题根源**: 当用户在设置中输入过大的刷新间隔值（如 999999999）时，`interval * 60 * 1000` 超过 JS 引擎 32 位有符号整数上限 `2,147,483,647ms`，浏览器会将 `setInterval` 延迟静默截断为 1ms，导致前端每秒触发数千次 `refreshAllQuotas`/`syncAccountFromDb` 请求，进而引发多线程并发写同一 `[uuid].json` 文件，造成字节流交错、JSON 尾部残留，账号数据永久损坏。\n            -   **原子文件写入 (`account.rs`)**: `save_account` 改为先写入 UUID 后缀的临时文件，再通过 `fs::rename`（POSIX）/ `MoveFileExW`（Windows）原子替换目标文件，与已有的 `save_account_index` 保持一致，从根本上消除并发写导致的 JSON 损坏。\n            -   **setInterval 溢出保护 (`BackgroundTaskRunner.tsx`)**: 对 `refresh_interval` 和 `sync_interval` 两个定时器的延迟参数加上 `Math.min(..., 2147483647)` 上界限制，防止超过 INT32_MAX 后被浏览器截断为 1ms 无限循环。\n            -   **输入验证 (`Settings.tsx`)**: 将 `refresh_interval` 和 `sync_interval` 输入框的 `max` 属性从 `60` 更新为 `35791`（35791 min × 60000 < INT32_MAX），并在 `onChange` 中添加 `NaN` fallback（默认为 1）及范围夹紧 `[1, 35791]`，从源头阻断非法值输入。\n        -   **[核心优化] OAuth 换票专属：剔除 JA3 指纹与动态 User-Agent 伪装**:\n            -   **纯净请求**: 仅针对 `exchange_code`（首次授权）和 `refresh_access_token`（静默续期）的换票请求，移除了底层网络库的 Chrome JA3 指纹伪装，恢复标准纯净的 TLS特征。\n            -   **动态 UA**: 换票时自动提取编译时版本号 (`CURRENT_VERSION`) 构建专属的 `User-Agent`（如 `vscode/1.X.X (Antigravity/4.1.27)`），以匹配纯净 TLS 链路。\n        -   **[功能增强] API 反代页面与设置页模型列表全面接入动态模型数据**:\n            -   **问题根源**: \"API 反代 → 支持模型与集成\"列表与\"模型路由中心\"的目标模型选择下拉框，以及\"设置 → 固定配额模型\"列表，此前均仅从静态 `MODEL_CONFIG` 读取硬编码模型信息，导致账号实际下发的动态新模型（如 `GPT-OSS 120B`、`Gemini 3.1 Pro (High)` 等）无法出现在这些列表中。\n            -   **修复方案**:\n                -   重构 `useProxyModels` Hook：以账号 `quota.models` 动态数据为第一优先数据源，聚合所有账号里所有模型的 `display_name`（为主展示名称）和 `name`（为模型 ID）；`MODEL_CONFIG` 仅作为图标/分组的样式补充，以及无账号数据时的静态兜底。\n                -   新增自动懒加载逻辑：`ApiProxy` 页面本身不调用 `fetchAccounts`，现在 Hook 内部检测到 store 为空时自动触发，保证动态模型在任意导航路径下均可正常展示。\n                -   重构 `PinnedQuotaModels` 组件：采用同等策略，从 `useAccountStore` 拉取全账号动态模型，并修复了已固定的 \"thinking\" 类型模型显示\"未知\"的问题，改为优先展示其真实 `display_name`。\n            -   **去重优化**: 所有列表均基于模型原始 `name`（小写）去重，并额外过滤掉 `-thinking` 后缀的 MODEL_CONFIG 静态别名条目（这类变体已由账号数据中的 `supports_thinking` 标记覆盖）。\n    *   **v4.1.22 (2026-02-21)**:\n        -   **[重要提醒] 2api 风控风险提示**:\n            -   由于近期的谷歌风控原因，使用 2api 功能会导致账号被风控的概率显著增加。\n            -   **强烈建议**: 为了确保您的账号安全与调用稳定性，建议减少或停止使用 2api 功能。目前更原生、更稳定的 **gRPC (`application/grpc`)** 或 **gRPC-Web (`application/grpc-web`)** 协议代理支持仍在积极测试中，如果您有相关的测试经验或想法，非常欢迎联系讨论，也欢迎您建立新分支一起探索！\n            -   <details><summary>📸 点击查看 gRPC 实时转换 OpenAI 规范测试演示</summary><img src=\"docs/images/usage/grpc-test.png\" alt=\"gRPC Test\" width=\"600\"></details>\n        -   **[核心优化] Claude Sonnet 4.5 迁移至 4.6 (PR #2014)**:\n            -   **模型升级**: 引入 `claude-sonnet-4-6` 及 `claude-sonnet-4-6-thinking` 作为主推模型。\n            -   **平滑过渡**: 自动将 legacy 模型 `claude-sonnet-4-5` 重定向至 `4.6`。\n            -   **全局适配**: 更新了全部 12 种语言的本地化文件、UI 标签（Sonnet 4.6, Sonnet 4.6 TK, Opus 4.6 TK）以及预设路由。\n        -   **[核心优化] Gemini Pro 模型名称迁移 (PR #2063)**: 将 `gemini-pro-high/low` 迁移至 `gemini-3.1-pro`，确保与 Google 最新 API 命名对齐。\n        -   **[重大架构] 国际化 (i18n) 与结构化模型配置集成 (PR #2040)**:\n            -   **架构重构**: 引入了全新的 i18n 翻译框架，将硬编码的模型展示逻辑解耦至结构化 `MODEL_CONFIG`。\n            -   **逻辑适配**: 在账号表格、详情弹窗和设置页面中集成了基于 i18n 标签的动态去重机制，修复了 Gemini 3.1 Pro 额度重复显示的 UI 问题。\n            -   **多语言提升**: 优化并修正了所有 12 种语言的版本描述，将 `Claude 4.5` 描述全面升级为正式版 `4.6`，并将 `G3` 描述统一为 `G3.1`。\n            -   **[核心修复] Claude Opus 4.6 思考模式 400 报错 (Claude 协议)**:\n            -   **参数专项对齐**: 修复了 `claude-opus-4-6-thinking` 在 Claude 协议下返回 `400 INVALID_ARGUMENT` 的问题。通过强制对齐 `thinkingBudget` (24576) 与 `maxOutputTokens` (57344)，并剔除在该模式下不兼容的 `stopSequences`，确保其请求参数与 100% 成功的 OpenAI 协议完全一致，提升了对 Claude 原生协议客户端的兼容性。\n    *   **v4.1.21 (2026-02-17)**:\n        -   **[核心修复] Cherry Studio / Claude 协议兼容性 (Fix Issue #2007)**:\n            -   **maxOutputTokens 限制**: 修复了 Cherry Studio 等客户端发送超大 `maxOutputTokens` (128k) 导致 Google API 返回 `400 INVALID_ARGUMENT` 的问题。现在自动将 Claude 协议的输出上限限制为 **65536**，确保请求始终在 Gemini 允许的范围内。\n            -   **Adaptive 思考模式对齐**: 针对 Gemini 模型优化了 Claude 协议的 `thinking: { type: \"adaptive\" }` 行为。现在自动映射为 **24576** 的固定思考预算 (与 OpenAI 协议一致)，解决了 Gemini Vertex AI 对 `thinkingBudget: -1` 的不兼容问题，显著提升了 Cherry Studio 的思考模式稳定性。\n        -   **[核心修复] 生产环境自定义协议支持 (PR #2005)**:\n            -   **协议修复**: 默认启用 `custom-protocol` 特性，修复了生产环境下自定义协议 (如 `tauri://`) 加载失败的问题，确保本地资源和特殊协议请求的稳定性。\n        -   **[核心优化] 托盘图标与窗口生命周期管理**:\n            -   **智能托盘**: 引入 `AppRuntimeFlags` 状态管理，实现了窗口关闭行为与托盘状态的联动。\n            -   **行为优化**: 当托盘启用时，关闭窗口将自动隐藏而非退出应用；当托盘禁用时，关闭窗口将正常退出，提供了更符合直觉的桌面体验。\n        -   **[核心增强] Linux 版本检测与 HTTP 客户端鲁棒性**:\n            -   **版本解析**: 增强了 Linux 平台的版本号提取逻辑 (`extract_semver`)，能从复杂的命令行输出中准确识别版本，提升了自动更新和环境检测的准确性。\n            -   **客户端降级**: 为 HTTP 客户端构建过程增加了自动降级机制。当代理配置导致构建失败时，系统会自动回退到无代理模式或默认配置，防止因网络配置错误导致应用完全不可用。\n        -   **[核心修复] Cherry Studio 联网搜索空响应修复 (/v1/responses)**:\n            -   **SSE 事件补全**: 重写了 `create_codex_sse_stream`，补全了 OpenAI Responses API 规范要求的完整 SSE 事件生命周期（`response.output_item.added`、`content_part.added/done`、`output_item.done`、`response.completed`），解决了 Cherry Studio 因事件缺失导致无法组装响应内容的问题。\n            -   **联网搜索注入修复**: 过滤了 Cherry Studio 发送的 `builtin_web_search` 工具声明，防止其与 `inject_google_search_tool` 冲突，确保 Google Search 工具被正确注入。\n            -   **搜索引文回显**: 为 Codex 流式响应添加了 `groundingMetadata` 解析，支持在联网搜索结果中回显搜索查询和来源引文。\n        -   **[优化] Claude 协议联网与思考稳定性 (PR #2007)**:\n            -   **移除联网降级**: 移除了 Claude 协议中针对联网搜索的激进模型降级逻辑，避免不必要的模型回退。\n            -   **移除思考历史降级**: 移除了 `should_disable_thinking_due_to_history` 检查，不再因历史消息格式问题永久禁用思考模式，改为依赖 `thinking_recovery` 机制自动修复。\n        -   **UI 优化 (Fix #2008)**: 改进了冷却时间的显示颜色 (使用蓝色)，提高了在小字体下的可读性。\n    *   **v4.1.20 (2026-02-16)**:\n        *   **[✨ 新春祝福] 祝大家马年一马当先，万事如意！Code 运昌隆，上线无 Bug！🧧**\n        *   **[Critical]** 修复了 Claude Opus/Haiku 等模型在 Antigravity API 上的 `400 INVALID_ARGUMENT` 错误（通过恢复 v4.1.16 的核心协议格式）。\n        *   增强了流式响应的健壮性，优化了对心跳包和非从零开始的 Thinking Block 的处理。\n        *   **[核心修复] 修复图像生成配额同步问题 (Issue #1995)**：\n            *   **放宽模型过滤**：优化了配额抓取逻辑，增加了对 `image` / `imagen` 关键字的支持，确保图像模型的配额信息能正常同步。\n            *   **即时刷新机制**：在图像生成成功后立即异步触发全局配额刷新，实现了 UI 侧剩余配额的实时反馈。\n        *   **[核心修复] 修复 OpenAI 流式收集器工具调用合并 Bug (PR #1994)**：\n            *   **ID 冲突校验**：在聚合流式片段时引入 ID 校验，防止多个工具调用因索引重叠而导致参数被错误拼接。\n            *   **索引稳定性优化**：优化了流式输出中的索引分配逻辑，确保多轮数据传输下工具调用索引始终单向递增。\n        *   **[核心优化] 极致拟真请求伪装 (Request Identity Camouflage)**:\n            *   **动态版本伪装**: 实现了智能版本探测机制。Antigravity 现在会自动读取本地安装的真实版本号构建 User-Agent，彻底告别了硬编码的 \"1.0.0\" 时代。\n            *   **Docker 环境兜底**: 针对无头模式（Docker/Linux Server），内置了“已知稳定版”指纹库。当无法检测到本地客户端时，自动伪装为最新稳定版客户端（如 v1.16.5），确保服务端看到的永远是合法的官方客户端。\n            *   **全维度 Header 注入**: 补全了 `X-Client-Name`, `X-Client-Version`, `X-Machine-Id`, `X-VSCode-SessionId` 等关键指纹头，实现了从网络层到应用层的像素级伪装，进一步降低了 403 风控概率。\n        *   **[核心功能] 后台自动刷新开关与设置热保存**:\n            *   **独立开关**: 在设置页面新增了“后台自动刷新”的独立开关，允许用户更精细地控制后台任务。\n            *   **配置热保存**: 实现了设置项（自动刷新、智能预热、配额保护）的热保存机制，无需手动点击保存按钮即可实时生效。\n        *   **[逻辑优化] 智能预热与配额保护解耦**:\n            *   **解除锁定**: 彻底移除了“额度保护”对“智能预热”的强制绑定。现在开启额度保护仅会强制开启“后台自动刷新”（用于检测配额），而不会强制启动预热请求。\n            *   **[重要建议]**: 建议用户在当前版本暂时关闭“额度保护”和“后台自动刷新”功能，以避免因频繁请求导致的潜在问题。\n    *   **v4.1.19 (2026-02-15)**:\n        -   **[核心修复] 修复 Claude Code CLI 工具调用空文本块错误 (Fix #1974)**:\n            -   **字段缺失修复**: 修复了 Claude Code CLI 在工具调用过程中，因发送空文本块 (`text: \"\"`) 导致上游 API 报错 `Field required` 的问题。\n            -   **空值过滤**: 在协议转换层增加了对无效空文本块的自动过滤与清理。\n        -   **[核心功能] Gemini 模型 MCP 工具名模糊匹配支持**:\n            -   **幻觉修复**: 针对 Gemini 模型经常幻觉出错误的 MCP 工具名称（如显式调用 `mcp__puppeteer_navigate` 而非注册名 `mcp__puppeteer__puppeteer_navigate`）的问题，实现了智能模糊匹配算法。\n            -   **三级匹配策略**: 引入了后缀匹配、包含匹配及 Token 重叠度评分机制，显著提升了 Gemini 模型调用 MCP 工具的成功率。\n        -   **[核心修复] Opencode 同步逻辑修正 (Fix #1972)**:\n            -   **模型缺失修复**: 修复了 Opencode CLI 同步时缺失 `claude-opus-4-6-thinking` 模型定义的问题，确保该模型能被客户端正确识别和调用。\n    *   **v4.1.18 (2026-02-14)**:\n        -   **[核心升级] JA3 指纹伪装 (Chrome 123) 全面实装**:\n            -   **反爬虫突破**: 引入 `rquest` 核心库并集成 BoringSSL，实现了像素级复刻 Chrome 123 的 TLS 指纹 (JA3/JA4)，有效解决高防护上游的 403/Captchas 拦截问题。\n            -   **全局覆盖**: 指纹伪装已应用至全局共享客户端及代理池管理器，确保从配额查询到对话补全的所有出站流量均模拟为真实浏览器行为。\n        -   **[架构重构] 通用流式响应处理 (Universal Stream Handling) (Issue #1955)**:\n            -   **双核兼容**: 重构了 SSE 处理与调试日志模块，通过 `Box<dyn Stream>` 实现了对 `reqwest` (标准) 与 `rquest` (伪装) 响应流的统一兼容，消除了底层类型冲突。\n        -   **[核心功能] 账号错误详情增强 (Account Error Details Expansion)**:\n            -   **详情解读**: 为“已禁用”和“403 Forbidden”状态的账号引入了深度错误详情弹窗，自动展示底层 API 报错原因（如 `invalid_grant` 等）。\n            -   **验证链接识别**: [新] 智能检测错误文本中的 Google 验证/申诉链接，支持在弹窗内直接点击跳转，加速账号修复流程。\n            -   **时间校准**: 修复了由于单位转换错误导致的“检测时间”显示为未来的 Bug。\n        -   **[i18n] 全球化多语言支持大满贯**:\n            -   **全语言适配**: 为全部 12 种支持语言（阿、西、日、韩、缅、葡、俄、土、越、英及简繁中）同步补全了账号详情与错误状态词条。\n            -   **本地化精修**: 优化了各语言下的术语匹配（特别是日语、土耳其语和繁体中文），确保全球用户都能获得准确的母语提示。\n        -   **[核心修复] 修复图像生成模型后缀导致的配额匹配失效 (Issue #1955)**:\n            -   **模式归一化**: 修复了 `gemini-3-pro-image` 及其分辨率/比例后缀（如 `-4k`, `-16x9`）因归一化匹配不精确导致的配额校验失效问题。\n            -   **配额对齐**: 确保所有图像模型变体都能正确映射到标准 ID，从而准确触发账号配额保护，解决了“无可用配额账号”的误报。\n    *   **v4.1.17 (2026-02-13)**:\n        -   **[用户体验] 自动更新体验升级 (PR #1923)**:\n            -   **后台下载**: 实现了更新包的后台静默下载，下载过程中不再阻断用户操作。\n            -   **进度反馈**: 新增下载进度条显示，实时反馈下载状态。\n            -   **重启提示**: 下载完成后会弹出更友好的重启提示，支持“立即重启”或“稍后重启”。\n            -   **逻辑优化**: 优先检查 `updater.json`，减少对 GitHub API 的直接依赖，提升检查速度。\n        -   **[文档更新] 跨平台安装脚本 (PR #1931)**:\n            -   **一键安装**: 在 README 中更新了 Option A 安装方式，推荐使用跨平台一键安装脚本。\n        -   **[社区建设] 新增 Telegram 频道入口**:\n            -   **社群卡片**: 在“设置 -> 关于”页面新增了 Telegram 频道卡片，方便用户快速加入官方频道获取最新资讯。\n            -   **布局优化**: 调整了关于页面的卡片网格布局，适配了 5 列显示，确保界面整洁美观。\n    *   **v4.1.16 (2026-02-12)**:\n        -   **[核心修复] 修复 Claude 协议 (Thinking 模型) 400 错误 (V4 方案)**:\n            -   **协议对齐**: 彻底修复了 Claude 3.7/4.5 Thinking 等模型在通过代理调用时因参数结构不匹配导致的 `400 Invalid Argument` 错误。\n            -   **统一注入**: 废弃了导致冲突的根目录 `thinking` 字段注入，现在统一使用 Google 原生协议推荐的 `generationConfig.thinkingConfig` 嵌套结构。\n            -   **预算适配**: 为 Claude 模型适配了默认 16k 的思考预算 (Thinking Budget)，并解决了 Rust 借用检查导致的编译与运行时异常。\n        -   **[Bug修复] 修复 OpenAI 流式响应 Usage 重复问题 (Issue #1915)**:\n            -   **Token爆炸修复**: 修复了在流式传输模式下 (stream=true)，`usage` 字段被错误地附加到每一个数据块 (Chunk) 中，导致客户端 (如 Cline/Roo Code) 统计的 Token 用量呈指数级虚高的问题。\n        -   **[核心优化] 开启 Linux 平台原生自动更新支持 (PR #1891)**:\n            -   **全平台覆盖**: 在 `updater.json` 中增加了对 `linux-x86_64` 和 `linux-aarch64` 平台的支持，使 Linux AppImage 用户现在也能正常收到自动更新通知。\n            -   **发布流优化**: 自动匹配并读取 Linux 版本的 `.AppImage.sig` 签名文件，实现了 macOS、Windows 与 Linux 三大主流平台的自动更新能力闭环。\n        -   **[新增功能] 跨平台一行命令安装脚本支持 (PR #1892)**:\n            -   **安装体验升级**: 新增 `install.sh` (Linux/macOS) 和 `install.ps1` (Windows) 脚本，支持通过极简的 `curl` 或 `irm` 命令实现全自动下载、安装与环境配置。\n            -   **智能适配**: 脚本支持自动识别操作系统、架构、包管理器（DEB/RPM/AppImage/DMG/NSIS），并提供版本锁定与 Dry-Run 预览模式。\n        -   **[核心优化] OpenCode 配置与本地二进制解耦及自定义网络支持 (Issue #1869)**:\n            -   **环境解耦**: 后端不再强制校验 `opencode` 二进制是否存在，允许在 Docker 等隔离环境下仅通过配置文件管理同步状态。\n            -   **自定义 BaseURL**: 前端新增 \"Custom Manager BaseURL\" 设置，支持手动指定 Manager 访问地址，完美解决 Docker Compose 容器互联与自定义反代场景下的连接问题。\n            -   **完全本地化**: 为新功能补全了中、英双语 I18n 支持，并修复了 OpenCode 同步弹窗的 JSX 渲染异常。\n        -   **[UI 修复] 修复 API 代理模板生成的 Python 代码缩进不一致问题 (PR #1879)**:\n            -   **显示优化**: 移除了 Python 集成示例代码块中多余的行首空格，确保从界面复制的代码可以直接运行，无需手动调整缩进。\n        -   **[核心修复] 解决 Gemini 图像生成因关键词匹配导致的 effortLevel 冲突 (PR #1873)**:\n            -   **逻辑冲突修复**: 彻底修复了 `gemini-3-pro-image` 及其 4k/2k 变体因包含 `gemini-3-pro` 关键词，被系统错误判定为支持 Adaptive Thinking 从而误注入 `effortLevel` 导致的 HTTP 400 错误。\n        -   **[文档更新] 发布 Gemini 3 Pro (Imagen 3) 图像生成全功能调用指南**:\n            -   **深度指南**: 新增 [Gemini 3 Pro 图像模型调用指南](docs/gemini-3-image-guide.md)，详细说明了宽高比自动映射、画质等级对应关系图表，以及新增的图生图 (Image-to-Image) 与后缀魔法用法。\n        -   **[安装优化] 官方 Homebrew Cask 维护与更新**:\n            -   **版本同步**: 更新 `antigravity-tools.rb` Cask 配置至 v4.1.16，确保 macOS 与 Linux 用户通过 `brew install` 始终获取最新稳定版本。\n            -   **参数清洗**: 在代理请求层增加了对图像生成模型的特殊过滤，确保不再为非思维链模型注入不兼容的生成参数。\n    *   **v4.1.15 (2026-02-11)**:\n        -   **[核心功能] 开启 macOS 与 Windows 原生自动更新支持 (PR #1850)**:\n            -   **端到端自动更新**: 启用了 Tauri 的原生更新插件，支持在应用内直接检测、下载并安装更新。\n            -   **发布工作流修复**: 彻底修复了 Release 工作流中生成更新元数据 (`updater.json`) 的逻辑。现在系统会自动根据 `.sig` 签名文件构建完整的更新索引，支持 darwin-aarch64, darwin-x86_64 以及 windows-x86_64 架构。\n            -   **体验打通**: 配合前端已有的更新提醒组件，实现了从发布到安装的全自动化闭环。\n        -   **[核心修复] 解决切换账号时由于空 Project ID 导致的 400 错误 (PR #1852)**:\n            -   **空值过滤**: 在 Proxy 层增加了对 `project_id` 的空字符串过滤逻辑。\n            -   **自动纠错**: 当检测到账号数据中的 `project_id` 为空时，现在会触发自动重新获取流程，有效解决了 Issue #1846 和 #1851 中提到的 \"Invalid project resource name projects/\" 错误。\n        -   **[故障排查] 针对 HTTP 404 \"Resource projects/... not found\" 的解决建议 (Issue #1858)**:\n            -   **验证项目 ID**: 登录 [Google Cloud Console](https://console.cloud.google.com/)，在项目选择器中搜索报错提到的 ID（如 `bold-spark-xxx`）。若项目不存在，请创建新项目并启用所需的 Vertex AI API。\n            -   **重置账户会话**: 尝试在 Antigravity 应用中“删除账户”并“重新添加”，以清除旧的会话残留。\n            -   **CLI 辅助验证**: 建议使用 Gemini CLI (`gcloud auth login`) 重新进行身份验证，并确保 `gcloud config set project` 指向了正确的有效项目。\n        -   **[故障排查] 针对 HTTP 403 \"Forbidden\" 错误的解决建议 (Issue #1834)**:\n            -   **检查验证链接**: 请检查 API 响应中是否包含提示 \"To continue, verify your account at...\" 的链接。若有，请点击该链接并按照 Google 提示完成验证。\n            -   **确认计划资格**: 访问 [FAQ 页面](https://antigravity.google/docs/faq#why-am-i-ineligible-for-a-google-one-ai-plan) 确认您的账号是否符合 Google One AI 计划或 Gemini Code Assist 的使用要求。\n            -   **自动恢复**: 部分 403 错误（如触发风险控制或配额调整）可能会在等待一段时间后自动恢复正常。\n    *   **v4.1.15 (2026-02-11)**:\n        -   **[核心修复] Cloudflared 公网访问设置持久化 (Issue #1805)**:\n            -   **设置记忆**: 修复了 Cloudflared (CF Tunnel) 的 Token、隧道模式及 HTTP/2 设置在应用重启后丢失的问题。\n            -   **热更新同步**: 实现了设置的实时持久化。现在切换隧道模式、修改 Token (失焦同步) 或切换 HTTP/2 选项时，配置都会立即保存，确保重启后恢复如初。\n        -   **[核心修复] 修复 Warmup 过程中的 403 禁用标记 (PR #1803)**:\n            -   **禁用识别**: 修复了账号在 Warmup (预热) 过程中返回 403 错误时未被标记为 `is_forbidden` 的问题。\n            -   **自动跳过**: 现在 Warmup 过程中检测到 403 将立即标记并持久化账号禁用状态，并在后续的调度、预热和配额检查中自动跳过该账号，避免无效请求。\n        -   **[UI 优化] 迷你视图 (Mini View) 状态显示与交互增强 (PR #1816)**:\n            -   **状态指示点**: 在迷你视图底部新增了请求状态圆点。成功 (200-399) 显示为绿色，失败显示为红色，直观反馈最近一次请求结果。\n            -   **模型名称回退**: 优化了模型名称显示逻辑。当 `mapped_model` 为空时，自动回退显示原始模型 ID 而非 \"Unknown\"，提升信息透明度。\n            -   **刷新动画优化**: 改进了刷新按钮的动画效果，使旋转动画仅作用于 `RefreshCw` 图标本身，交互更加细腻。\n        -   **[核心功能] Claude 4.6 Adaptive Thinking 模式支持**:\n            -   **Dynamic Effort**: 全面支持 `effort` 参数 (low/medium/high)，允许用户动态调整模型的思考深度与预算。\n            -   **Token 限制自适应**: 修复了 Adaptive 模式下 `maxOutputTokens` 未能正确感知 Budget 导致被截断的问题，确保长思维链不被腰斩。\n        -   **[文档更新] 新增 Adaptive 模式测试用例**:\n            -   提供了 `docs/adaptive_mode_test_examples.md`，涵盖多轮对话、复杂任务场景及 Budget 模式切换的完整验证指南。\n        -   **[核心功能] 图片生成 imageSize 参数支持**:\n            -   **直接参数支持**: 新增对 Gemini 原生 `imageSize` 参数的直接支持,可在所有协议(OpenAI/Claude/Gemini)中使用。\n            -   **参数优先级**: 实现了清晰的参数优先级逻辑:`imageSize` 参数 > `quality` 参数推断 > 模型后缀推断。\n            -   **全协议兼容**: OpenAI Chat API、Claude Messages API 和 Gemini 原生协议均支持通过 `imageSize` 字段直接指定分辨率(\"1K\"/\"2K\"/\"4K\")。\n            -   **向后兼容**: 完全兼容现有的 `quality` 参数和模型后缀方式,不影响现有代码。\n        -   **[核心功能] Opencode 提供商隔离与清理工作流 (PR #1820)**:\n            -   **隔离同步逻辑**: 实现 Opencode 提供商的独立同步机制,防止状态污染,确保数据纯净。\n            -   **清理工作流**: 新增资源清理工作流,优化资源管理,提升系统运行效率。\n            -   **稳定性增强**: 增强了同步过程的稳定性和可靠性。\n    *   **v4.1.15 (2026-02-11)**:\n        -   **[核心功能] Homebrew Cask 安装检测与支持 (PR #1673)**:\n            -   **应用升级**: 新增了对 Homebrew Cask 安装的检测逻辑。如果应用是通过 Cask 安装的，现在可以直接在应用内触发 `brew upgrade --cask` 流程，实现无缝升级体验。\n        -   **[核心修复] Gemini 图像生成配额保护 (PR #1764)**:\n            -   **保护生效**: 修复了配额保护机制可能会错误统计文本请求的问题，并确保在绘图配额耗尽时能正确拦截 `gemini-3-pro-image` 的请求。\n        -   **[UI 优化] 修复导航栏边界与显示问题 (PR #1636)**:\n            -   **边界修复**: 修复了导航栏右侧菜单在特定窗口宽度下可能超出边界或显示不全的问题。\n            -   **兼容性**: 此次合并保留了主分支上的 Mini View 等新特性，只应用了必要的样式修正。\n        -   **[UI 优化] 修复英文模式下的布局溢出与水平滚动 (Issue #1783)**:\n            -   **全局限制**: 在全局样式中封锁了水平轴溢出，杜绝了因文字过长导致的页面横向抖动。\n            -   **响应式增强**: 优化了导航栏断点，将文字胶囊的显示阈值提高至 1120px，确保长英文标签在窄窗口下自动切换为图标模式，保持布局整洁。\n        -   **[核心修复] 修复处理复杂 JSON Schema 时可能发生的栈溢出问题 (Issue #1781)**:\n            -   **安全加固**: 为 `flatten_refs` 等深度递归逻辑引入了 `MAX_RECURSION_DEPTH` (10) 限制，有效防止了由循环引用或过深嵌套导致的程序崩溃。\n        -   **[核心修复] 修复流式输出下多个工具调用被错误拼接的问题 (Issue #1786)**:\n            -   **索引校正**: 修正了 `create_openai_sse_stream` 中 `tool_calls` 的索引分配逻辑，确保同一个 chunk 中的多个工具调用拥有独立且连续的 `index`，避免了参数被错误拼接导致解析失败的现象。\n        -   **[核心修复] 修复 Claude Thinking 模型多轮对话时的签名错误 (Issue #1790)**:\n            -   **签名注入与降级**: 在 OpenAI 协议转换层中增加了对历史消息思考块签名的自动注入逻辑。当无法获取有效签名时，自动将其降级为普通文本块，从而解决了 Claude-opus-thinking 等模型在多轮对话中因签名缺失导致的 HTTP 400 错误。\n        -   **[核心修复] 修复 Google Cloud 项目 ID 获取失败导致的 503 错误 (Issue #1794)**:\n            -   **增加兜底**: 修复了由于账号权限导致无法获取官方项目 ID 时会跳过该账号的 Bug。现在系统会自动回退到经验证稳定的通用 Project ID (`bamboo-precept-lgxtn`)，确保 API 请求的连续性。\n        -   **[i18n] 完善 Settings 与 ApiProxy 国际化支持 (PR #1789)**:\n            -   **重构**: 将 `Settings.tsx` 和 `ApiProxy.tsx` 中硬编码的中文字符串替换为 `t()` 国际化调用。\n            -   **翻译补全**: 同步更新了韩语、缅甸语、葡萄牙语、俄语、土耳其语、越南语、繁体中文和简体中文的本地化词条。\n        -   **[核心修复] 修复 IP 白名单删除失败问题 (Issue #1797)**:\n            -   **参数规范化**: 修复了由于前端与后端参数命名风格 (snake_case vs camelCase) 不一致导致无法删除白名单 IP 的问题。同时统一了黑名单管理与 IP 访问日志的相关参数，确保全系统参数传递的一致性。\n    *   **v4.1.12 (2026-02-10)**:\n        -   **[核心功能] OpenCode CLI 深度集成 (PR #1739)**:\n            -   **自动探测**: 新增了对 OpenCode CLI 的自动检测与环境变量配置同步支持。\n            -   **一键同步**: 支持通过“外部 Providers”卡片将 Antigravity 的配置无缝注入到 OpenCode CLI 环境，实现零配置接入。\n        -   **[核心修复] Claude Opus 思考预算自动注入 (PR #1747)**:\n            -   **预算修正**: 修复了 Opus 模型在自动启用思考模式时，未能正确注入默认思考预算 (Thinking Budget) 的问题，防止因预算缺失导致的上游错误。\n        -   **[核心优化] Claude Opus 4.6 Thinking 全面升级 (Issue #1741, #1742, #1743)**:\n            -   **模型迭代**: 正式引入 `claude-opus-4-6-thinking` 支持，提供更强大的推理能力。\n            -   **无感迁移**: 实现了从 `claude-opus-4.5` / `claude-opus-4` 到 `4.6` 的自动重定向，旧版配置无需修改即可直接享受新模型。\n        -   **[核心修复] 账户索引自动修复机制 (PR #1755)**:\n            -   **容错增强**: 修复了在部分极端情况下（如文件损坏）账户索引无法自动重建的问题。现在系统会在检测到索引异常时自动触发自我修复流程，确保账号数据安全可用。\n        -   **[核心修复] 修复 IP 黑名单删除与时区问题 (PR #1748)**:\n            -   **参数修正**: 修复了 IP 黑名单删除接口因参数命名风格 (snake_case vs camelCase) 不匹配导致的删除失败问题。\n            -   **逻辑修复**: 修正了清除黑名单时传递了错误参数 (ip_pattern 而非 id) 的问题。\n            -   **时区校准**: 修复了宵禁时间 (Curfew) 判断逻辑，强制使用北京时间 (UTC+8)，解决了服务器本地时区非 UTC+8 时的判断偏差。\n            -   **拒绝对齐**: 优化了令牌拒绝响应，返回 403 状态码及 JSON 错误详情，对齐了统一错误响应标准。\n        -   **[核心功能] 新增迷你视图模式 (Mini View Mode) (PR #1750)**:\n            -   **便捷访问**: 新增迷你窗口模式，支持双向切换。该模式常驻桌面顶层，提供精简的快捷操作入口，方便用户即时查看状态与监控信息。\n        -   **[核心修复] Gemini 协议 400 错误自愈 (PR #1756)**:\n            -   **Token 补全**: 修复了在 Gemini 原生协议下调用持续思考模型（如 Claude Opus 4.6 Thinking）时，因 `maxOutputTokens` 小于 `thinkingBudget` 导致的 400 报错。现在系统会自动补全并对齐 Token 限制，确保请求合规。\n        -   **[核心修复] 修复 macOS 下 bun 全局安装的路径识别 (PR #1765)**:\n            -   **路径增强**: 新增对 `~/.bun/bin` 及全局安装路径的探测，解决了 bun 用户无法自动同步 Claude CLI 配置的问题。\n        -   **[核心优化] 修复 Logo 文本在小容器下的换行与显示 (PR #1766)**:\n            -   **显示优化**: 使用 Tailwind CSS 容器查询逻辑优化了 Logo 文本的显示与隐藏切换，防止在容器空间不足时发生文字换行。\n        -   **[核心修复] Google Cloud Code API 404 重试与账号轮换 (PR #1775)**:\n            -   **智能重试**: 针对 Google Cloud Code API 返回的 404 错误（常见于分阶段发布或权限差异场景），新增自动重试与账号轮换机制。系统将以 300ms 短延迟进行重试，并自动切换到下一个可用账号。\n            -   **短周期避让**: 对 404 错误实施 5 秒软锁定（区别于其他服务端错误的 8 秒），在保护账号的同时最大程度减少用户等待时间。\n    *   **v4.1.11 (2026-02-09)**:\n        -   **[核心优化] 重构 Token 轮询逻辑 (High-End Model Routing Optimization)**:\n            -   **能力硬门槛**: 针对 `claude-opus-4-6` 等高端模型实施了严格的 Capability Filtering。系统现在会检查账号实际持有的 `model_quotas`，只有明确拥有目标模型配额的账号才能参与轮询，彻底解决了 Pro/Free 账号因 \"Soft Priority\" 而被错误选中的问题。\n            -   **严格层级优先**: 确立了 `Ultra > Pro > Free` 的绝对优先级排序策略。只要 Ultra 账号可用，系统将始终优先调度 Ultra 账号，防止降级到 Pro 账号，确保了高端模型的服务质量。\n            -   **[配置警告]**: 请检查 `设置 -> 自定义模型映射` 或 `gui_config.json`，确保**没有**配置 `\"claude-opus-4-*\": \"claude-opus-4-5-thinking\"` 这样的通配符，否则会导致 `claude-opus-4-6-thinking` 被错误映射到 `claude-opus-4-5-thinking`。建议为 `claude-opus-4-6-thinking` 添加明确的精确映射。\n        -   **[核心修复] 修复配置热重载失效问题 (PR #1713)**:\n            -   **即时生效**: 修复了在 WebUI 或 Docker 环境下保存配置时，内存中的代理池配置未同步更新的问题。现在修改配置后无需重启即可立即生效。\n        -   **[Docker 优化] 新增本地绑定限制选项**:\n            -   **网络安全**: 新增 `ABV_BIND_LOCAL_ONLY` 环境变量。当设置为 `true` 时，Docker/Headless 模式将仅绑定 `127.0.0.1`，不再默认向 `0.0.0.0` 暴露服务，满足特定安全网络需求。\n        -   **[核心功能] 用户 Token 支持自定义过期时间 (PR #1722)**:\n            -   **灵活控制**: 创建用户 Token 时现在支持选择精确到分钟的自定义过期时间，不再局限于预设的固定时长。\n        -   **[核心修复] Token 编辑数据同步与参数封装 (PR #1720, #1722)**:\n            -   **数据同步**: 修复了编辑 Token 时部分字段数据未正确回显的问题。\n            -   **代码重构**: 优化了 Token 创建与更新的参数传递结构，提升了代码的可维护性。\n        -   **[核心修复] 修复代理认证信息持久化失效问题 (Issue #1738)**:\n            -   **魔术前缀机制**: 引入 `ag_enc_` 前缀来明确标识已加密的密码字段。\n            -   **双重加密防护**: 彻底解决了后端无法区分“用户输入的明文”与“已加密的密文”，导致在多次保存或导入导出时发生双重加密（Double Encryption）的问题。\n            -   **兼容性**: 完美兼容旧版配置（无前缀），并在下次保存时自动迁移到新格式。同时增强了批量导入功能的健壮性。\n        -   **[核心修复] 解决用户创建/加载失败问题 (Issue #1719)**:\n            -   **数据清洗**: 在数据库初始化阶段增加了针对旧数据的清洗逻辑，自动将 NULL 值重置为默认值，修复了因字段缺失导致的列表接口崩溃。\n            -   **鲁棒性增强**: 优化了后端数据读取逻辑，为关键字段增加了防御性默认值处理。\n        -   **[前端修复] 修复用户 Token 续期功能失效**:\n            -   **参数修正**: 修正了续期接口调用时的参数命名风格 (snake_case -> camelCase)，解决了 \"missing required key\" 报错。\n        -   **[核心修复] 彻底解决 Google Cloud 项目 404 错误 (Issue #1736)**:\n            -   **移除无效 Mock 逻辑**: 彻底删除了随机生成 Project ID 的失效逻辑（如 `useful-flow-g3dts`），此类 ID 目前会被 Google API 拦截并返回 404。\n            -   **智能兜底策略**: 现在当账号无法自动获取项目 ID 时，系统会安全回退到经验证长期有效的稳定 Project ID `bamboo-precept-lgxtn`，确保 API 请求的连续性与稳定性。\n        -   **[核心修复] 增强网络环境下的流式传输稳定性 (Issue #1732)**:\n            -   **强制缓冲区冲刷 (Flush)**: 解决了在不稳定网络环境下，SSE 流因缺少末尾换行符而导致的对话挂起及 \"IO 为 0\" 问题。\n            -   **超时容错增强**: 将流式响应超时时间延长至 60s，有效对抗高延迟网络引发的异常中断。\n            -   **Session ID 稳定性优化**: 改进了会话标识生成算法，防止网络重连后的 ID 漂移及其引发的思维模型签名失效。\n    *   **v4.1.10 (2026-02-08)**:\n        -   **[核心功能] 扩展 CLI 探测路径以支持 Volta (PR #1695)**:\n            -   **路径增强**：在 `cli_sync` 和 `opencode_sync` 中新增了对 `.volta/bin` 及其内部二进制文件的自动探测支持，确保 Volta 用户在同步 CLI 配置时能够获得“零配置”的顺滑体验。\n        -   **[核心修复] 图像生成分辨率智能保护 (Issue #1694)**:\n            -   **逻辑保护**：重构了图像配置合并算法，优先保留模型名后缀（如 `-4k`, `-2k`）或显式参数（`quality: \"hd\"`）指定的高分辨率设置，防止由于请求体中的默认值导致的分辨率降级。\n            -   **能力增强**：支持在生成高分辨率图像的同时，完整保留并回显思维链（Thinking）内容。\n        -   **[核心功能] 高级思维与全局配置深度优化**:\n            -   **图像思维开关**：新增全局“图像思维模式”选项。启用时可获得双图（草图+终稿）及思维链；禁用时系统显式强制注入 `includeThoughts: false`，优先保证单图生成质量。\n            -   **UI 重构**：对“高级思维”模块进行了空间压缩，采用行式布局和紧凑控件，将垂直空间占用减少了 50%，极大提升了配置效率。\n            -   **全局提示词优化**：增强了输入框体验，添加了实时字符计数与超长警告。\n        -   **[i18n] 全球 10+ 语言同步更新**:\n            -   **多语言补全**：为高级思维模块补全了繁体中文、日语、韩语、阿拉伯语、西班牙语、俄语、越南语、土耳其语、葡萄牙语和缅甸语的完整翻译，确保全球体验一致。\n        -   **[核心修复] 全协议接口兼容性补全**:\n            -   **全渠道覆盖**：图像思维控制逻辑已同步覆盖 Gemini 原生协议、OpenAI 兼容协议以及 Claude (Anthropic) 协议。\n            -   **测试稳定性**：修复了后端单元测试中的全局状态竞争问题，并更新了 GitHub Release CI 脚本以支持发布覆盖。\n        -   **[核心修复] 账号代理绑定持久化与配额保护可靠性提升 (Issue #1700)**:\n            -   **绑定持久化**：修复了前端设置保存时因类型定义缺失导致 `account_bindings` 被覆盖的问题，确保绑定关系跨重启有效。\n            -   **保护增强**：增强了模型名归一化引擎以识别实际 API 模型名，并完善了触发保护后的内存同步与调度过滤逻辑，彻底消除保护逃逸。\n        -   **[核心功能] 优化全球上游代理 I18n 与样式 (Issue #1701)**:\n            -   **I18n 同步**：补全了全部 12 种支持语言的代理配置词条，解决 `zh.json` 内容缺失及各语言翻译不统一问题。\n            -   **样式优化**：重构了全球代理配置卡片，引入渐变背景与微动画，使其在视觉上与代理池设置保持一致。\n            -   **SOCKS5H 支持**：在界面增加了 `socks5h://` 协议建议提示，并统一了后端代理 URL 标准化逻辑，显著增强了远程 DNS 解析的引导。\n    *   **v4.1.9 (2026-02-08)**:\n        -   **[核心功能] 扩展 CLI 配置快速同步支持 (PR #1680, #1685)**:\n            -   **更多工具集成**: 现已支持同步配置到 **Claude Code**, **Gemini CLI**, **Codex AI**, **OpenCode** 以及 **Droid**。\n            -   **模型选择定制**: 为单模型 CLI (Claude, Codex, Gemini) 增加了模型选择下拉框，支持同步自定义模型 ID；为多模型 CLI (OpenCode, Droid) 实现了拖拽式模型列表管理。\n            -   **逻辑校准**: 深度适配了各 CLI 的预设逻辑（如 Claude 根节点的 `model` 字段及镜像环境清理），确保同步后的兼容性。\n            -   **交互优化**: 同步面板现支持默认折叠并适配平滑动画，同时优化了同步前后的 UI 状态反馈。\n            -   **备份安全性**: 同步前自动生成 `.antigravity.bak` 备份，支持一键还原。\n        -   **[核心功能] 新增全局系统提示词 (Global System Prompt) 支持 (PR #1669)**:\n            -   **统一指令注入**: 在“系统设置”中新增全局系统提示词配置，支持将自定义指令自动注入到所有 OpenAI、Claude 和 Gemini 协议请求中。\n            -   **前端界面**: 新增 `GlobalSystemPrompt` 组件，支持一键启用及多行内容编辑。\n        -   **[核心修复] 修复浮点数序列化精度丢失问题 (PR #1669)**:\n            -   **精度升级**: 将后端 `temperature` 和 `top_p` 的数据类型从 `f32` 升级为 `f64`。\n            -   **逻辑校准**: 解决了请求参数在反代过程中因浮点转换导致的微小偏差（如 `0.95` 变成 `0.949999...`），显著提升了上游调用的稳定性。\n        -   **[核心重构] 实现应用名称国际化 (PR #1662)**:\n            -   **UI 升级**: 移除了 `NavLogo` 和 `Settings` 页面中硬编码的 \"Antigravity Tools\"，全面采用 `app_name` 翻译键，确保 UI 语言切换的一致性。\n        -   **[核心修复] 修正 gemini-3-pro-image 因关键词匹配被误判定为思维模型的问题 (Issue #1675)**:\n            -   **问题根源**: `gemini-3-pro-image` 及其 4k/2k 变体因包含 `gemini-3-pro` 关键词，被系统错误判定为“思维模型”（Thinking Model）。\n            -   **冲突修复**: 修正了误注入 `thinkingConfig` 与图像生成 `imageConfig` 发生的冲突，解决了导致后端分辨率降级（降至 1k）的问题。\n            -   **Token 优化**: 解决了因思维模型逻辑注入占位符或特定限制而触发的“Token 超限（131072）” 400 错误。\n        -   **[国际化] 日语翻译实现 100% 同步 (PR #1662)**:\n            -   **翻译补全**: 同步了 `en.json` 中的所有缺失键值，涵盖了 Cloudflared、断路器、OpenCode 同步等新功能。\n        -   **[核心重构] 重构 UpstreamClient 响应处理逻辑**:\n            -   **结构化响应**: 引入 `UpstreamCallResult` 统一管理上游请求结果，优化了流式与非流式响应的处理路径。\n    *   **v4.1.8 (2026-02-07)**:\n        -   **[核心功能] 集成 Claude Opus 4.6 Thinking 模型支持 (PR #1641)**:\n            -   **混合模式架构**: 实现了“静态配置 + 动态获取”的双模架构。模型列表通过 Antigravity API 动态拉取，而 Thinking 模式等高级元数据则由本地注册表静态补充，完美平衡了灵活性与稳定性。\n            -   **零配置接入**: `claude-opus-4-6` 系列模型自动启用 Thinking 模式并预设 Budget，无需用户手动干预即可享受最新推理能力。\n            -   **前沿模型映射**: 新增 `claude-opus-4-6-thinking` 及其别名 (`claude-opus-4-6`, `20260201`) 的支持，并将其归入 `claude-sonnet-4.5` 配额组进行统筹管理。\n        -   **[核心优化] 优化 OpenCode CLI 检测逻辑 (PR #1649)**:\n            -   **路径扩展**: 增加了对 Windows 环境下常见全局安装路径（如 `npm`, `pnpm`, `Yarn`, `NVM`, `FNM` 等）的自动扫描。\n            -   **稳定性增强**: 修复了在 `PATH` 环境不完整时可能导致检测失败的问题，并增强了对 `.cmd` 和 `.bat` 文件的支持。\n        -   **[核心修复] 修复监控日志缺失流式工具调用内容的问题**:\n            -   **多协议支持**: 重构了 SSE 解析逻辑，全面支持 OpenAI `tool_calls` 和 Claude `tool_use`。\n            -   **增量累积**: 实现了工具参数片段的流式累积，确保长参数工具调用能被完整记录并显示在监控面板中。\n        -   **[UI 优化] 导航栏与链接交互优化 (PR #1648)**:\n            -   **禁止拖拽**: 为导航栏及 Logo 等所有链接和图片添加了 `draggable=\"false\"`，防止用户在意外拖拽时触发浏览器的默认行为，提升交互稳定性。\n            -   **SmartWarmup 悬停增强**: 优化了智能预热组件图标在未激活状态下的悬停颜色切换逻辑，使界面反馈更加细腻一致。\n        -   **[核心功能] 账号自定义标签支持扩展 (PR #1620)**:\n            -   **长度限制**: 将标签长度限制从 20 字符优化为 15 字符，在前后端同步生效。\n            -   **后端验证**: 增强了后端 Rust 命令的验证逻辑，支持 Unicode 字符计数，并优化了错误处理。\n            -   **前端对齐**: 账户列表和卡片视图的编辑框均已同步 15 字符的最大长度。\n        -   **[核心修复] 修复 UserToken 页面剪贴板错误 (PR #1639)**:\n            -   **逻辑修复**: 修复了在 UserToken 页面尝试访问或写入剪贴板时可能触发的异常。\n            -   **体验优化**: 提高了剪贴板交互的鲁棒性，确保在各种环境下都能正常工作。\n        -   **[核心优化] 优化 Token 排序性能并减少磁盘 I/O (PR #1627)**:\n            -   **内存配额缓存**: 将模型配额信息引入内存，在 `get_token` 排序 hot path 中直接使用缓存。\n            -   **性能提升**: 消除了排序过程中由于频繁读取磁盘文件（`std::fs::read_to_string`）导致的同步 I/O 阻塞，显著降低了高并发下的请求推迟与延迟。\n        -   **[国际化] 修复自定义标签功能缺失的翻译 (PR #1630)**:\n            -   **翻译补全**: 补全了繁体中文等语种中“编辑标签”、“自定义标签占位符”以及“标签更新成功”提示的国际化翻译。\n        -   **[UI 修复] 修复 SmartWarmup 图标悬停效果缺失 (PR #1568)**:\n            -   **增加交互**: 为未启用状态的图标添加了悬停变色效果，与其他设置项保持一致。\n        -   **[核心修复] 修复 OpenAI 协议下 Vertex AI 思考模型签名缺失问题 (Issue #1650)**:\n            -   **Sentinel 注入**: 移除了对 Vertex AI (`projects/...`) 模型的哨兵签名注入限制。现在即使缺少真实签名，系统也会自动注入 `skip_thought_signature_validator`，从而避免 `Field required for thinking signature` 错误。\n    *   **v4.1.7 (2026-02-06)**:\n        -   **[核心修复] 修复图像生成 API (429/500/503) 自动切换账号问题 (Issue #1622)**:\n            -   **自动重试**: 为 `images/generations` 和 `images/edits` 引入了与 Chat API 一致的自动重试与账号轮换机制。\n            -   **体验一致性**: 确保在某个账号配额耗尽或服务不可用时，请求能自动故障转移到下一个可用账号，不再直接失败。\n        -   **[核心功能] 新增账户自定义标签支持 (PR #1620)**:\n            -   **标签管理**: 支持为每个账户设置个性化标签，方便在多账户环境下快速识别。\n            -   **交互优化**: 账户列表和卡片视图均支持直接查看和内联编辑标签。\n            -   **多语言支持**: 完整适配中、英双语显示。\n        -   **[核心修复] 修复数据库为空时 `get_stats` 返回 NULL 导致崩溃的问题 (PR #1578)**:\n            -   **NULL 值处理**: 在 SQL 查询中使用 `COALESCE(SUM(...), 0)` 确保在没有日志记录时依然返回数值，解决了 `rusqlite` 无法将 `NULL` 转换为 `u64` 的问题。\n            -   **性能保留**: 保留了本地分支中通过单次查询获取多项统计数据的性能优化逻辑。\n\n        -   **[核心修复] Claude 403 错误处理与账号轮换优化 (PR #1616)**:\n            -   **403 状态映射**: 将 403 (Forbidden) 错误映射为 503 (Service Unavailable)，防止客户端（如 Claude Code）因检测到 403 而自动登出。\n            -   **自动禁用逻辑**: 检测到 403 错误时自动将账号标记为 `is_forbidden` 并从活跃池中移除，避免该账号在接下来的请求中被继续选中。\n            -   **临时风控识别**: 识别 `VALIDATION_REQUIRED` 错误，并对相关账号执行 10 分钟的临时阻断。\n            -   **轮换稳定性**: 修复了在账号额度耗尽 (QUOTA_EXHAUSTED) 时的过早返回问题，确保系统能正确尝试轮换到下一个可用账号。\n        -   **[核心功能] OpenCode CLI 配置同步集成 (PR #1614)**:\n            -   **一键同步**: 自动生成 `~/.config/opencode/opencode.json`，支持 Anthropic 和 Google 双 Provider 自动配置。\n            -   **账号导出**: 可选同步账号列表至 `antigravity-accounts.json`，供 OpenCode 插件直接导入。\n            -   **备份与还原**: 同步前自动备份原有配置，支持一键还原。\n            -   **跨平台支持**: 统一适配 Windows、macOS 和 Linux 环境。\n            -   **体验优化**: 修复了 RPC 参数包装问题，补全了多语言翻译，并优化了配置文件不存在时的视图状态。\n        -   **[核心功能] 允许隐藏未使用的菜单项 (PR #1610)**:\n            -   **可见性控制**: 在设置页面新增“菜单项显示设置”，允许用户自定义侧边栏显示的导航项。\n            -   **界面美化**: 为极简用户提供更清爽的界面，隐藏不常用的功能入口。\n\n        -   **[核心修复] Gemini 原生协议图像生成完全修复 (Issue #1573, #1625)**:\n            -   **400 错误修复**: 修复了 Gemini 原生协议生成图片时，因请求体 `contents` 数组缺失 `role: \"user\"` 字段导致的 `INVALID_ARGUMENT` 错误。\n            -   **参数透传支持**: 确保 `generationConfig.imageConfig` (如 `aspectRatio`, `imageSize`) 能正确透传给上游，不再被错误过滤。\n            -   **错误码优化**: 优化了图像生成服务的错误映射，确保 429/503 等状态码能正确触发客户端的重试机制。\n        -   **[核心增强] 自定义映射支持手动输入任意模型 ID**:\n            -   **灵活输入**: 在自定义映射的目标模型选择器中新增手动输入功能，用户现在可以在下拉菜单底部直接输入任意模型 ID。\n            -   **未发布模型体验**: 支持体验 Antigravity 尚未正式发布的模型，例如 `claude-opus-4-6`。用户可以通过自定义映射将请求路由到这些实验性模型。\n            -   **重要提示**: 并非所有账号都支持调用未发布的模型。如果您的账号无权访问某个模型，请求可能会返回错误。建议先在少量请求中测试，确认账号权限后再大规模使用。\n            -   **快捷操作**: 支持 Enter 键快速提交自定义模型 ID，提升输入效率。\n    *   **v4.1.6 (2026-02-06)**:\n        -   **[核心修复] 深度重构 Claude/Gemini 思考模型中断与工具循环自愈逻辑 (#1575)**:\n            -   **思考异常恢复**: 引入了 `thinking_recovery` 机制。当检测到历史消息中包含陈旧思考块或陷入状态循环时，自动进行剥离与引导，提升了在复杂工具调用场景下的稳定性。\n            -   **解决签名绑定错误**: 修正了误将缓存签名注入客户端自定义思考内容的逻辑。由于签名与文本强绑定，此举解决了会话中断或重置后常见的 `Invalid signature` (HTTP 400) 报错。\n            -   **会话级完全隔离**: 删除了全局签名单例，确保所有思维签名严格在 Session 级别隔离，杜绝了多账号、多会话并发时的签名污染。\n        -   **[修复] 解决 Gemini 系列由于 `thinking_budget` 越界导致的 HTTP 400 错误 (#1592, #1602)**:\n            -   **全协议路径硬截断**: 修复了 OpenAI 和 Claude 协议映射器在「自定义模式」下缺失限额保护的问题。现在无论选择何种模式（自动/自定义/透传），只要目标模型为 Gemini，后端都会强制执行 24576 的物理上限保护。\n            -   **自动适配与前端同步**: 重构了协议转换逻辑，使其基于最终映射的模型型号进行动态限额；同步更新了设置界面的提示文案，明确了 Gemini 协议的物理限制。\n        -   **[核心修复] Web Mode 登录验证修复 & 登出按钮 (PR #1603)**:\n            -   **登录验证**: 修复了 Web 模式下登录验证逻辑的异常，确保用户身份验证的稳定性。\n            -   **登出功能**: 在界面中新增/修复了登出按钮，完善了 Web 模式下的账户管理闭环。\n    <details>\n    <summary>显示旧版本日志 (v4.1.5 及更早)</summary>\n\n    *   **v4.1.5 (2026-02-05)**:\n        -   **[安全修复] 前端 API Key 存储迁移 (LocalStorage -> SessionStorage)**:\n            -   **存储机制升级**: 将 Admin API Key 的存储位置从持久化的 `localStorage` 迁移至会话级的 `sessionStorage`，显著降低了在公共设备上的安全风险。\n            -   **自动无感迁移**: 实现了自动检测与迁移逻辑。系统会识别旧的 `localStorage` 密钥，将其自动转移到 `sessionStorage` 并彻底清除旧数据，确保现有用户无缝过渡且消除安全隐患。\n        -   **[核心修复] 修复 Docker 环境下添加账号失败问题 (Issue #1583)**:\n            -   **账号上下文修复**: 修复了在添加新账号时 `account_id` 为 `None` 导致代理选择异常的问题。现在系统会为新账号生成临时 UUID,确保所有 OAuth 请求都有明确的账号上下文。\n            -   **日志增强**: 优化了 `refresh_access_token` 和 `get_effective_client` 的日志记录,提供更详细的代理选择信息,帮助诊断 Docker 环境下的网络问题。\n            -   **影响范围**: 修复了 Docker 部署环境下通过 Refresh Token 添加账号时可能出现的长时间挂起或失败问题。\n        -   **[核心修复] Web Mode 兼容性修复 & 403 账号轮换优化 (PR #1585)**:\n            -   **Security API Web Mode 兼容性修复 (Issue: 400/422 错误)**:\n                -   为 `IpAccessLogQuery` 添加 `page` 和 `page_size` 的默认值,解决 `/api/security/logs` 返回 400 Bad Request 的问题\n                -   移除 `AddBlacklistWrapper` 和 `AddWhitelistWrapper` 结构体,解决 `/api/security/blacklist` 和 `/api/security/whitelist` POST 返回 422 Unprocessable Content 的问题\n                -   前端组件参数名修正:`ipPattern` → `ip_pattern`,确保与后端 API 参数一致\n            -   **403 账号轮换优化 (Issue: 403 后未正确跳过账号)**:\n                -   在 `token_manager.rs` 中添加 `set_forbidden` 方法,支持标记账号为禁用状态\n                -   账号选择时检查 `quota.is_forbidden` 状态,自动跳过被禁用的账号\n                -   403 时清除该账号的 sticky session 绑定,确保立即切换到其他可用账号\n            -   **Web Mode 请求处理优化**:\n                -   `request.ts` 修复路径参数替换后从 body 中移除已使用的参数,避免重复传参\n                -   支持 PATCH 方法的 body 处理,补全 HTTP 方法支持\n                -   自动解包 `request` 字段,简化请求结构\n            -   **Debug Console Web Mode 支持**:\n                -   `useDebugConsole.ts` 添加 `isTauri` 环境检测,区分 Tauri 和 Web 环境\n                -   Web 模式下使用 `request()` 替代 `invoke()`,确保 Web 环境下的正常调用\n                -   添加轮询机制,Web 模式下每 2 秒自动刷新日志\n            -   **Docker 构建优化**:\n                -   添加 `--legacy-peer-deps` 标志,解决前端依赖冲突\n                -   启用 BuildKit 缓存加速 Cargo 构建,提升构建速度\n                -   补全 `@lobehub/icons` peer dependencies,修复前端依赖缺失导致的构建失败\n            -   **影响范围**: 此更新显著提升了 Docker/Web 模式下的稳定性和可用性,解决了 Security API 报错、403 账号轮换失效、Debug Console 不可用等问题,同时优化了 Docker 构建流程。\n        -   **[核心修复] 修复 Web/Docker 模式下调试控制台崩溃与日志同步问题 (Issue #1574)**:\n            -   **Web 兼容性**: 修复了在非 Tauri 环境下直接调用原生 `invoke` API 导致的 `TypeError` 崩溃。现在通过兼容性请求层进行后端通信。\n            -   **指纹绑定修复**: 修复了生成指纹并绑定时,由于前后端参数结构不匹配导致的 `HTTP Error 422` 报错。通过调整后端包装类,使其兼容前端嵌套的 `profile` 对象。\n            -   **日志轮询机制**: 为 Web 模式引入了自动日志轮询功能(2秒/次),解决了浏览器端无法接收 Rust 后端事件推送导致调试日志为空的问题。\n        -   **[核心优化] 补全 Tauri 命令的 HTTP API 映射**:\n            -   **全量适配**: 对齐了 30+ 个原生 Tauri 命令,为缓存管理(清理日志/应用缓存)、系统路径获取、代理池配置、用户令牌管理等核心功能补全了 HTTP 映射,确保 Web/Docker 版本的功能完整性。\n        -   **[安全修复] 任意文件读写漏洞加固**:\n            -   **API 安全层**: 彻底移除了高危接口 `/api/system/save-file` 及其关联函数,并在数据库导入接口中增加了路径遍历防范 (`..` 校验)。\n            -   **Tauri 安全增强**: 为 `save_text_file` 和 `read_text_file` 命令引入了统一的路径校验器,严禁目录遍历并封堵了系统敏感目录的访问权限。\n    *   **v4.1.4 (2026-02-05)**:\n        -   **[核心功能] 代理池持久化与账号筛选优化 (PR #1565)**:\n            -   **持久化增强**: 修复了代理池绑定在反代服务重启或重载时无法正确恢复的问题，确保绑定关系严格持久化。\n            -   **智能筛选**: 优化了 `TokenManager` 的账号获取逻辑,在全量加载、同步以及调度路径中增加了对 `disabled` 和 `proxy_disabled` 状态的深度校验，彻底杜绝已禁用账号被误选的问题。\n            -   **验证阻止支持**: 引入了 `validation_blocked` 字段体系，专门处理 Google 的 `VALIDATION_REQUIRED` (403 临时风控) 场景，实现了基于截止时间的智能自动绕过。\n            -   **状态清理加固**: 账号失效时同步清理内存令牌、限流记录、会话绑定及优先账号标志，保证内部状态机的一致性。\n        -   **[核心修复] 修复 Web/Docker 模式下的关键兼容性问题 (Issue #1574)**:\n            -   **调试模式修复**: 修正了前端调试控制台 URL 映射错误（移除多余的 `/proxy` 路径），解决了 Web 模式下调试模式无法开启的问题。\n            -   **指纹绑定修复**: 为 `admin_bind_device_profile_with_profile` 接口增加了 `BindDeviceProfileWrapper` 结构，修复了前端发送嵌套参数导致的 HTTP 422 错误。\n            -   **向后兼容性**: 使用 `serde alias` 功能在 API 层同时支持 camelCase（前端）和 snake_case（后端文件），确保旧账号文件正常加载。\n        -   **[代码优化] 简化 API 处理结构**:\n            -   移除了多个管理 API 路由（如 IP 黑白名单管理、安全设置更新等）中的冗余包装层 (`Wrapper`)，直接解构业务模型，提升了代码的简洁性与开发效率。\n        -   **[核心修复] 解决 OpenCode 调用 Thinking 模型中断问题 (Issue #1575)**:\n            -   **finish_reason 强制修正**: 修复了工具调用时 `finish_reason` 被错误设置为 `stop` 导致 OpenAI 客户端提前终止对话的问题。现在系统会强制将有工具调用的响应 `finish_reason` 设置为 `tool_calls`，确保工具循环正常运行。\n            -   **工具参数标准化**: 实现了 shell 工具参数名称的自动标准化，将 Gemini 可能生成的 `cmd`/`code`/`script` 等非标准参数名统一转换为 `command`，提升了工具调用的兼容性。\n            -   **影响范围**: 修复了 OpenAI 协议下 Thinking 模型（如 `claude-sonnet-4-5-thinking`）的工具调用流程，解决了 OpenCode 等客户端的中断问题。\n\n    *   **v4.1.3 (2026-02-05)**:\n        -   **[核心修复] 解决 Web/Docker 模式下安全配置与 IP 管理失效问题 (Issue #1560)**:\n            -   **协议对齐**: 修复了后端 Axum 接口无法解析前端 `invoke` 封装的嵌套参数格式（如 `{\"config\": ...}`）的问题，确保安全配置能正确持久化。\n            -   **参数规范化**: 为 IP 管理相关接口添加了 `camelCase` 重命名支持，解决了 Web 端 Query 参数大小写不匹配导致的添加失败与删除失效。\n        -   **[核心修复] 恢复 Gemini Pro 思考块输出 (Issue #1557)**:\n            -   **跨协议对齐**: 修复了自 v4.1.0 以来 `gemini-3-pro` 等模型在 OpenAI、Claude 和 Gemini 原生协议下思考块缺失的问题。\n            -   **智能注入逻辑**: 实现了 `thinkingConfig` 的自动注入与默认开启机制，确保即使客户端未发送配置，模型也能正确激活思考能力。\n            -   **鲁棒性增强**: 优化了 `wrapper.rs` 内部类型处理，解析并解决了高并发场景下的配置冲突。\n    *   **v4.1.2 (2026-02-05)**:\n        -   **[核心功能] 多协议客户端适配器 (ClientAdapter Framework) (Issue #1522)**:\n            -   **架构重构**: 引入 `ClientAdapter` 框架并应用 `Arc` 引用计数，实现了 Handler 层与下游客户端逻辑的完全解耦，支持更安全的跨线程共享。\n            -   **全协议兼容**: 针对 `opencode` 等第三方客户端，实现了 **4 种协议**（Claude/OpenAI/Gemini/OA-Compatible）的无缝接入，彻底解决了 `AI_TypeValidationError` 报错。\n            -   **智能策略**: 实现了 FIFO 签名缓存策略与 `let_it_crash` 快速失败机制，显著提升了高并发场景下的稳定性和错误反馈速度。\n            -   **标准化错误响应**: 强制统一所有协议的错误返回格式（流式 SSE `event: error` / 非流式 JSON），确保客户端能正确解析上游异常。\n        -   **[核心修复] 统一账号禁用状态检查逻辑 (Issue #1512)**:\n            -   **逻辑对齐**: 修复了批量刷新配额及自动预热逻辑中遗漏手动禁用状态 (`proxy_disabled`) 的问题。\n            -   **后台降噪**: 确保标记为“禁用”或“禁用代理”的账号不再触发任何后台网络请求，提升了系统的隐私性与资源效率。\n        -   **[核心修复] 解决 OpenAI 协议路径下 Invalid signature 导致的 400 错误 (Issue #1506)**:\n            -   **Session 级签名隔离**: 引入了 `SignatureCache` 机制，通过 `session_id` 物理隔离不同会话的思维签名存储，彻底杜绝多轮对话或并发请求导致的签名污染。\n            -   **鲁棒性增强**: 增加了对思维链占位符（如 `[undefined]`）的识别与自动清洗逻辑，提升了对不同客户端（如 Cherry Studio）的兼容性。\n            -   **全路径透传**: 重构了请求转换与流式处理链路，确保 Session 上下文在非流式和流式请求中均能精准传导。\n        -   **[UI 增强] 新增模型图标支持与自动排序功能 (PR #1535)**:\n            -   **视觉呈现**: 引入 `@lobehub/icons` 图标库，在账号卡片、表格及详情页中展示不同模型的 brand 图标，视觉体验更佳。\n            -   **智能排序**: 实现了基于权重的模型自动排序逻辑（系列 > 级别 > 后缀），优先展示最常用的高级模型（如 Gemini 3 Pro）。\n            -   **配置中心化**: 构建了统一的模型元数据配置系统，将模型标签、短名称、图标与权重解耦，提升系统扩展性。\n            -   **国际化同步**: 同步补全了 13 种常用语言的模型显示名称。\n        -   **[核心修复] 增强账号禁用状态与磁盘状态实时校验 (PR #1546)**:\n            -   **磁盘深度校验**: 引入了 `get_account_state_on_disk` 机制，在获取 Token 的关键路径增加磁盘状态二次确认，彻底解决内存缓存延迟导致的禁用账号误选问题。\n            -   **固定账号智能同步**: 优化了 `toggle_proxy_status` 指令，禁用账号时会自动检查并关闭对应的固定账号模式，并立即触发代理池重载。\n            -   **授权失效自愈**: 当后端检测到 `invalid_grant` 错误并自动禁用账号时，现在会物理清理内存中的 Token、限流记录和会话绑定，确保故障账号即刻下线。\n            -   **全链路过滤适配**: 补全了预热逻辑 (`Warmup`) 与定时调度器 (`Scheduler`) 的禁用状态检查，大幅减少无效的后台网络请求。\n        -   **[核心优化] 代理池健康检查并发化 (PR #1547)**:\n            -   **性能提升**: 引入了基于 `futures` 流的并发执行机制，将顺序检查重构为并发处理（并发上限 20）。\n            -   **效率增强**: 显著缩短了大型代理池的健康检查总时长，提升了系统对代理状态变更的响应速度。\n        -   **[核心修复] 解决 Docker/HTTP 环境下 crypto.randomUUID 兼容性问题 (Issue #1548)**:\n            -   **问题修复**: 修复了在非安全上下文（如 HTTP 或部分 Docker 环境）中，因浏览器禁用 `crypto.randomUUID` API 导致的应用崩溃（\"Unexpected Application Error\"）及批量导入失败问题。\n            -   **兼容性增强**: 引入了全平台兼容的 UUID 生成回退机制，确保在任何部署环境下 ID 生成的稳定性。\n    *   **v4.1.1 (2026-02-04)**:\n        -   **[核心修复] 解决 User Tokens 页面在 Web/Docker 环境下加载失败问题 (Issue #1525)**:\n            -   **API 同步**: 补全了前端 `request.ts` 的命令映射，并新增对 `PATCH` 方法的支持，解决了 Web 端因映射缺失导致的 API 调用错误。\n            -   **后端路由补全**: 在 Axum 管理服务器中新增了 User Token 的全量管理接口（List/Create/Update/Renew/Delete），确保 Headless 模式功能完整。\n        -   **[核心优化] 数据库迁移增强与幂等性改进**:\n            -   **自动列迁移**: 完善了 `UserToken` 数据库初始化逻辑，支持从旧版本自动通过 `ALTER TABLE` 补全缺失列（如 `expires_type`, `max_ips`, `curfew_*` 等），极大提升了版本升级的稳定性。\n        -   **[Docker 优化] 新增 ABV_DATA_DIR 环境变量支持**:\n            -   **灵活挂载**: 允许用户通过环境变量显式指定数据存储目录。现在 Docker 用户可以更方便地挂载外部卷至自定义路径（如 `-e ABV_DATA_DIR=/app/data`），解决了默认隐藏目录权限及可见性问题。\n        -   **[核心功能] 更新检查器增强 (Update Checker 2.0) (PR #1494)**:\n            -   **代理支持**: 更新检查器现在完全遵循全局上游代理配置，解决了在受限网络环境下无法获取更新的问题。\n            -   **多级降级策略**: 实现了 `GitHub API -> GitHub Raw -> jsDelivr` 的三层回退机制，极大提升了版本检测的成功率。\n            -   **来源可观测**: 更新提示中现在会显示检测源信息，方便排查连接问题。\n        -   **[核心优化] Antigravity 数据库格式兼容性改进 (>= 1.16.5)**:\n            -   **智能版本检测**: 新增跨平台版本检测模块，支持自动识别 Antigravity 客户端版本（macOS/Windows/Linux）。\n            -   **新旧格式适配**: 适配了 1.16.5+ 版本的 `antigravityUnifiedStateSync.oauthToken` 新格式，并保持对旧版格式的向下兼容。\n            -   **注入策略增强**: 实现基于版本的智能注入策略，并在检测失败时提供双重格式注入的容错机制，确保账号切换成功。\n        -   **[核心修复] 解决 react-router SSR XSS 漏洞 (CVE-2026-21884) (PR #1500)**:\n            -   **安全修复**: 升级 `react-router` 依赖至安全版本，修复了 `ScrollRestoration` 组件在服务端渲染 (SSR) 时可能造成的跨站脚本攻击 (XSS) 风险。\n        -   **[国际化] 完善日语翻译支持 (PR #1524)**:\n            -   **改进**: 补全了代理池、流错误消息、User-Agent 等重要模块的日语本地化。\n    *   **v4.1.0 (2026-02-04)**:\n        -   **[重大更新] 代理池 2.0 (Proxy Pool) 完全体与稳定性修复**:\n            -   **账号级专属 IP 隔离**: 实现账号与代理的强绑定逻辑。一旦账号绑定专属代理，该 IP 将自动从公共池隔离，杜绝跨账号关联风险。\n            -   **协议自动补全与兼容性**: 后端支持自动识别简写输入（如 `ip:port`），自动补全 `http://` 方案。\n            -   **智能健康检查加固**: 引入浏览器 User-Agent 伪装，解决 `google.com` 拦截问题；更换保底检查 URL 至 `cloudflare.com`。\n            -   **响应式状态同步**: 修复“先睡眠后检查”逻辑，实现启动即更新状态，消除 UI 显示超时的同步延迟。\n            -   **持久化 Bug 修复**: 彻底解决在高频率轮询下，后端旧状态可能回滚前端新增代理的竞态问题。\n        -   **代理池 2.0 运行机制解析**:\n            -   **场景 1：账号全链路锁定** — 系统识别到账号 A 与 Node-01 的绑定关系后，其 Token 刷新、额度同步、AI 推理将全量强制走 Node-01。Google 侧始终捕获到该账号在单一稳定 IP 上操作。\n            -   **场景 2：公用池自动隔离** — 账号 B 无绑定。系统在扫描代理池时，会自动发现 Node-01 已被 A 专属占用并将其剔除，仅从剩余节点中轮询。确保不同账号 IP 绝不混用，零关联风险。\n            -   **场景 3：故障自愈与保底** — 若 Node-01 宕机且开启了“故障重试”，账号 A 会临时借用公共池节点完成 Token 刷新等紧急任务，并记录日志，确保服务不中断。\n        -   **[新功能] UserToken 页面导航与监控增强 (PR #1475)**:\n            -   **页面导航**: 新增 UserToken 独立管理页面，支持更细粒度的用户令牌管理。\n            -   **监控增强**: 完善了系统监控和路由功能的集成，提升了系统的可观测性。\n        -   **[核心修复] Warmup 接口字段丢失修复**:\n            -   **编译修复**: 修复了 `ProxyRequestLog` 初始化时缺失 `username` 字段导致的编译错误。\n        -   **[核心修复] Docker Warmup 401/502 错误修复 (PR #1479)**:\n            -   **网络优化**: 在 Docker 环境下的 Warmup 请求中，使用了带 `.no_proxy()` 的客户端，防止 localhost 请求被错误路由到外部代理导致 502/401 错误。\n            -   **鉴权变更**: 豁免了 `/internal/*` 路径的鉴权，确保内部预热请求不会被拦截。\n        -   **[核心修复] Docker/Headless 环境调试与绑定问题修复**:\n            -   **调试控制台**: 修复了 Docker 模式下日志模块未初始化的问题，并新增 HTTP API 映射，支持 Web 前端获取实时日志。\n            -   **指纹绑定**: 优化了设备指纹绑定逻辑，确保其在 Docker 容器环境下的兼容性并支持通过 API 完整调用。\n        -   **[核心修复] 账号删除缓存同步修复 (Issue #1477)**:\n            -   **同步机制**: 引入了全局删除信号同步队列，确保账号在磁盘删除后即刻从内存缓存中剔除。\n            -   **清理**: TokenManager 现在会同步清理已删除账号的令牌、健康分数、限流记录以及会话绑定，解决“已删除账号仍被调度”的问题。\n        -   **[UI 优化] 更新通知本地化 (PR #1484)**:\n            -   **国际化适配**: 移除了更新提示框中的硬编码字符串，实现了对所有 12 种语言的完整支持。\n        -   **[UI 优化] 导航栏重构与响应式适配 (PR #1493)**:\n            -   **组件解构**: 将单体 Navbar 拆分为更细粒度的模块化组件，提升代码可维护性。\n            -   **响应式增强**: 优化了布局断点及“刷新配额”按钮的响应式行为。\n    *   **v4.0.15 (2026-02-03)**:\n        -   **[核心优化] 预热功能增强与误报修复 (PR #1466)**:\n            -   **模式优化**: 移除硬编码模型白名单，支持对所有达到 100% 配额的模型自动触发预热。\n            -   **准确性修复**: 修复了预热状态的误报问题，确保仅在预热真正成功时记录历史。\n            -   **功能扩展**: 优化了预热请求的流量日志记录，并跳过不支持预热的 2.5 系列模型。\n        -   **[核心优化] 思考预算 (Thinking Budget) 全面国际化与优化**:\n            -   **多语言适配**: 补全并优化了中、英、日、韩、俄、西、繁体、阿等多国语言的翻译，确保全球用户体验一致。\n            -   **UI 细节增强**: 优化了设置项的提示语（Auto Hint / Passthrough Warning），帮助用户更准确地配置模型思考深度。\n    *   **v4.0.14 (2026-02-02)**:\n        -   **[核心修复] 解决 Web/Docker 部署下 API Key 随机变更问题 (Issue #1460)**:\n            -   **问题修复**: 修复了在没有配置文件的情况下，每次刷新页面都会重新生成 API Key 的 Bug。\n            -   **逻辑优化**: 优化了配置加载流程，确保首次生成的随机 Key 被正确持久化；同时也确保了 Headless 模式下环境变量（如 `ABV_API_KEY`）的覆盖能够被前端正确获取。\n        -   **[核心功能] 可配置思考预算 (Thinking Budget) (PR #1456)**:\n            -   **预算控制**: 在系统设置中新增了“思考预算”配置项。\n            -   **智能适配**: 支持为 Claude 4.6+ 和 Gemini 2.0 Flash Thinking 等模型自定义最大思考 token 限制。\n            -   **默认优化**: 默认值设置为智能适配模式，确保在大多数场景下不仅能获得完整思考过程，又能避免触发上游 budget 限制。\n    *   **v4.0.13 (2026-02-02)**:\n        -   **[核心优化] 负载均衡算法升级 (P2C Algorithm) (PR #1433)**:\n            -   **算法升级**: 将原有的 Round-Robin (轮询) 算法升级为 P2C (Power of Two Choices) 负载均衡算法。\n            -   **性能提升**: 在高并发场景下显著减少了请求等待时间，并优化了后端实例的负载分布，避免了单点过载。\n        -   **[UI 升级] 响应式导航栏与布局优化 (Responsive Navbar) (PR #1429)**:\n            -   **移动端适配**: 全新设计的响应式导航栏，完美适配移动设备与小屏幕窗口。\n            -   **视觉增强**: 为导航项添加了直观的图标，提升了整体视觉体验与操作便捷性。\n        -   **[新功能] 账号配额可视化增强 (Show All Quotas) (PR #1429)**:\n            -   **显示所有配额**: 在账号列表页新增“显示所有配额”开关。开启后可一览 Ultra/Pro/Free/Image 等所有维度的实时配额信息，不再仅显示首要配额。\n        -   **[国际化] 全面多语言支持完善 (Full i18n Update)**:\n            -   **覆盖率提升**: 补全了繁体中文、日语、韩语、西班牙语、阿拉伯语等 10 种语言的缺失翻译键值。\n            -   **细节优化**: 修复了“显示所有配额”及 OAuth 授权流程中的提示语翻译缺失问题。\n        -   **[国际化] 后台任务翻译补全 (Translate Background Tasks) (PR #1421)**:\n            -   **翻译修复**: 修复了后台任务（如标题生成）的相关文本缺少翻译的问题，现在支持所有语言的本地化显示。\n            - **归因**: 修复了合并代码时引入的 `ref` 冲突导致移动端/桌面端点击判定异常。\n            - **结果**: 语言切换菜单现在可以正常打开和交互。\n        -   **[Docker/Web 修复] Web 端支持 IP 管理 (IP Security for Web)**:\n            - **功能补全**: 修复了在 Docker 或 Web 模式下，IP 安全管理功能（日志、黑白名单）因后端路由缺失而无法使用的问题。\n            - **API 实现**: 实现了完整的 RESTful 管理接口，确保 Web 前端能正常调用底层安全模块。\n            - **体验强化**: 优化了删除操作的参数传递逻辑，解决了部分浏览器下删除黑白名单失灵的问题。\n    *   **v4.0.12 (2026-02-01)**:\n        -   **[代码重构] 连接器服务优化 (Refactor Connector Service)**:\n            -   **深度优化**: 重写了连接器服务 (`connector.rs`) 的核心逻辑，消除了历史遗留的低效代码。\n            -   **性能提升**: 优化了连接建立与处理流程，提升了系统的整体稳定性与响应速度。\n    *   **v4.0.11 (2026-01-31)**:\n        -   **[核心修复] 调整 API 端点顺序与自动阻断 (Fix 403 VALIDATION_REQUIRED)**:\n            -   **端点顺序优化**: 将 Google API 的请求顺序调整为 `Sandbox -> Daily -> Prod`。优先使用宽松环境，从源头减少 403 错误的发生。\n            -   **智能阻断机制**: 当检测到 `VALIDATION_REQUIRED` (403) 错误时，系统会自动将该账号标记为“临时阻断”状态并持续 10 分钟。期间请求会自动跳过该账号，避免无效重试导致账号被进一步风控。\n            -   **自动恢复**: 阻断期过后，系统会自动尝试恢复该账号的使用。\n        -   **[核心修复] 账号状态热重载 (Account Hot-Reload)**:\n            -   **架构统一**: 消除了系统中并存的多个 `TokenManager` 实例，实现了管理后台与反代服务共享单例账号管理器。\n            -   **实时生效**: 修复了手动启用/禁用账号、账号重排序及批量操作后需要重启应用才能生效的问题。现在所有账号变更都会立即同步至内存账号池。\n        -   **[核心修复] 配额保护逻辑优化 (PR #1344 补丁)**:\n            -   进一步优化了配额保护逻辑中对“已禁用”状态与“配额保护”状态的区分逻辑，确保日志记录准确且状态同步实时。\n        -   **[核心修复] 恢复健康检查接口 (PR #1364)**:\n            -   **路由恢复**: 修复了在 4.0.0 架构迁移中遗失的 `/health` 和 `/healthz` 路由。\n            -   **响应增强**: 接口现在会返回包含 `\"status\": \"ok\"` 和当前应用版本号的 JSON，方便监控系统进行版本匹配和存活检查。\n        -   **[核心修复] 修复 Gemini Flash 模型思考预算超限 (Fix PR #1355)**:\n            -   **自动限额**: 修复了在 Gemini Flash 思考模型（如 `gemini-2.0-flash-thinking`）中，默认或上游传入的 `thinking_budget` (例如 32k) 超过模型上限 (24k) 导致 API 报错 `400 Bad Request` 的问题。\n            -   **多协议覆盖**: 此防护已扩展至 **OpenAI、Claude 和原生 Gemini 协议**，全方位拦截不安全的预算配置。\n            -   **智能截断**: 系统现在会自动检测 Flash 系列模型，并强制将思考预预算限制在安全范围内 (**24,576**)，确保请求始终成功，无需用户手动调整客户端配置。\n        -   **[核心功能] IP 安全与风控系统 (IP Security & Management) (PR #1369 by @大黄)**:\n            -   **可视化工单管理**: 全新的“安全监控”模块，支持图形化管理 IP 黑名单与白名单。\n            -   **智能封禁策略**: 实现了基于 CIDR 的网段封禁、自动释放时间设置及封禁原因备注功能。\n            -   **实时访问日志**: 集成了 IP 维度的实时访问日志审计，支持按 IP、时间范围筛选，方便快速定位异常流量。\n        -   **[UI 优化] 极致的视觉体验**:\n            -   **弹窗美化**: 全面升级了 IP 安全模块的所有弹窗按钮样式，采用实心色块与阴影设计，操作引导更清晰。\n            -   **布局即兴**: 修复了安全配置页面的滚动条异常与布局错位，优化了标签页切换体验。\n        -   **[核心功能] 调试控制台 (Debug Console) (PR #1385)**:\n            -   **实时日志流**: 引入了全功能的调试控制台，支持实时捕获并展示后端业务日志。\n            -   **过滤与搜索**: 支持按日志级别（Info, Debug, Warn, Error）过滤及关键词全局搜索。\n            -   **交互优化**: 支持一键清空日志、自动滚动开关，并完整适配深色/浅色主题。\n            -   **后端桥接**: 实现了高性能的日志桥接器，确保日志捕获不影响反代性能。\n    *   **v4.0.9 (2026-01-30)**:\n        -   **[核心功能] User-Agent 自定义与版本欺骗 (PR #1325)**:\n            - **动态覆盖**: 支持在“服务配置”中自定义上游请求的 `User-Agent` 头部。这允许用户模拟任意客户端版本（如 Cheat 模式），有效绕过部分地区的版本封锁或风控限制。\n            - **智能回退**: 实现了“远程抓取 -> Cargo 版本 -> 硬编码”的三级版本号获取机制。当主版本 API 不可用时，系统会自动解析官网 Changelog 页面获取最新版本号，确保 UA 始终伪装成最新版客户端。\n            - **热更新支持**: 修改 UA 配置后即刻生效，无需重启服务。\n        -   **[核心修复] 解决配额保护状态同步缺陷 (Issue #1344)**:\n            - **状态实时同步**: 修复了 `check_and_protect_quota()` 函数在处理禁用账号时提前退出的逻辑缺陷。现在即便账号被禁用，系统仍会扫描并实时更新其 `protected_models`（模型级保护列表），确保配额不足的账号在重新启用后不会绕过保护机制继续被使用。\n            - **日志路径分离**: 将手动禁用检查从配额保护函数中剥离至调用方，根据不同的跳过原因（手动禁用/配额保护）记录准确的日志，消除用户困惑。\n        -   **[核心功能] 缓存管理与一键清理 (PR #1346)**:\n            - **后端集成**: 新增了 `src-tauri/src/modules/cache.rs` 模块，用于计算和管理应用运行期间产生的各类临时文件分布（如翻译缓存、日志指纹等）。\n            - **UI 实现**: 在“系统设置”页面新增了“清理缓存”功能。用户可以实时查看缓存占用的空间大小，并支持一键清理，有效解决长期使用后的磁盘占用问题。\n        -   **[国际化] 新增语言支持 (PR #1346)**:\n            - 新增了 **西班牙语 (es)** 和 **马来语 (my)** 的完整翻译支持，进一步扩大了应用的全球适用范围。\n        -   **[国际化] 全语言覆盖**:\n            - 为新功能补全了 En, Zh, Zh-TW, Ar, Ja, Ko, Pt, Ru, Tr, Vi 等 10 种语言的完整翻译支持。\n        -   **[国际化] 完善 UI 字符串本地化 (PR #1350)**:\n            - **全面覆盖**: 补充了 UI 中剩余的硬编码字符串及未翻译项，实现了界面字符串的完全本地化。\n            - **清理冗余**: 删除了代码中所有的英文回退 (English fallbacks)，强制所有组件通过 i18n 键调用语言包。\n            - **语言增强**: 显著提升了日语 (ja) 等语言的翻译准确度，并确保了新 UI 组件在多语言环境下的显示一致性。\n    *   **v4.0.8 (2026-01-30)**:\n        -   **[核心功能] 记忆窗口位置与大小 (PR #1322)**: 自动恢复上次关闭时的窗口坐标与尺寸，提升使用体验。\n        -   **[核心修复] 优雅关闭 Admin Server (PR #1323)**: 修复了 Windows 环境下退出后再次启动时，端口 8045 占用导致的绑定失败问题。\n        -   **[核心功能] 实现全链路调试日志功能 (PR #1308)**:\n            - **后端集成**: 引入了 `debug_logger.rs`，支持捕获并记录 OpenAI、Claude 及 Gemini 处理器的原始请求、转换后报文及完整流式响应。\n            - **动态配置**: 支持热加载日志配置，无需重启服务即可启用/禁用或修改输出目录。\n            - **前端交互**: 在“高级设置”中新增“调试日志”开关及自定义输出目录选择器，方便开发者排查协议转换与上游通信问题。\n        -   **[UI 优化] 优化图表工具提示 (Tooltip) 浮动显示逻辑 (Issue #1263, PR #1307)**:\n            - **溢出防御**: 优化了 `TokenStats.tsx` 中的 Tooltip 定位算法，确保在小窗口或高缩放比例下，悬浮提示信息始终在可视区域内显示，防止被窗口边界遮挡。\n        -   **[核心优化] 鲁棒性增强：动态 User-Agent 版本获取及多级回退 (PR #1316)**:\n            - **动态版本获取**: 支持从远程端点实时拉取版本号，确保 UA 信息的实时性与准确性。\n            - **稳延回退链**: 引入“远程端点 -> Cargo.toml -> 硬编码”的三级版本回退机制，极大提升了初始化阶段的鲁棒性。\n            - **预编译优化**: 使用 `LazyLock` 预编译正则表达式解析版本号，提升运行效率并降低内存抖动。\n            - **可观测性提升**: 添加了结构化日志记录及 VersionSource 枚举，方便开发者追踪版本来源及潜在的获取故障。\n        -   **[核心修复] 解决 Gemini CLI \"Response stopped due to malformed function call.\" 错误 (PR #1312)**:\n            - **参数字段对齐**: 将工具声明中的 `parametersJsonSchema` 重命名为 `parameters`，确保与 Gemini 最新 API 规范完全对齐。\n            - **参数对齐引擎增强**: 移除了多余的参数包装层，使参数传递更加透明和直接。\n            - **容错校验**: 增强了对工具调用响应的鲁棒性，有效防止因参数结构不匹配导致的输出中断。\n        -   **[核心修复] 解决 Docker/Headless 模式下端口显示为 'undefined' 的问题 (Issue #1305)**: 修复了管理 API `/api/proxy/status` 缺少 `port` 字段且 `base_url` 构造错误的问题，确保前端能正确显示监听地址。\n        -   **[核心修复] 解决 Docker/Headless 模式下 Web 密码绕过问题 (Issue #1309)**:\n            - **默认鉴权增强**: 将 `auth_mode` 默认值改为 `auto`。在 Docker 或允许局域网访问的环境下，系统现在会自动激活身份验证，确保 `WEB_PASSWORD` 生效。\n            - **环境变量支持**: 新增 `ABV_AUTH_MODE` 和 `AUTH_MODE` 环境变量，允许用户在启动时显式覆盖鉴权模式（支持 `off`, `strict`, `all_except_health`, `auto`）。\n    *   **v4.0.7 (2026-01-29)**:\n        -   **[性能优化] 优化 Docker 构建流程 (Fix Issue #1271)**:\n            - **原生架构构建**: 将 AMD64 和 ARM64 的构建任务拆分为独立 Job 并行执行，并移除 QEMU 模拟层，转而使用各架构原生的 GitHub Runner。此举将跨平台构建耗时从 3 小时大幅缩减至 10 分钟以内。\n\n        -   **[性能优化] 解决 Docker 版本在大数据量下的卡顿与崩溃问题 (Fix Issue #1269)**:\n            - **异步数据库操作**: 将流量日志、Token 统计等所有耗时数据库查询迁移至后台阻塞线程池 (`spawn_blocking`)，彻底解决了在查看大型日志文件（800MB+）时可能导致的 UI 卡死及反代服务不可用的问题。\n            - **监控逻辑平滑化**: 优化了监控状态切换逻辑，移除冗余的重复启动记录，提升了 Docker 环境下的运行稳定性。\n        -   **[核心修复] 解决 OpenAI 协议 400 Invalid Argument 错误 (Fix Issue #1267)**:\n            - **移除激进默认值**: 回滚了 v4.0.6 中为 OpenAI/Claude 协议引入的默认 `maxOutputTokens: 81920` 设置。该值超过了许多旧模型（如 `gemini-3-pro-preview` 或原生 Claude 3.5）的硬性限制，导致请求被直接拒绝。\n            - **智能思维配置**: 优化了思维模型检测逻辑，仅对以 `-thinking` 结尾的模型自动注入 `thinkingConfig`，避免了对不支持该参数的标准模型（如 `gemini-3-pro`）产生副作用。\n        -   **[兼容性修复] 修复 OpenAI Codex (v0.92.0) 调用错误 (Fix Issue #1278)**:\n            - **字段清洗**: 自动过滤 Codex 客户端在工具定义中注入的非标准 `external_web_access` 字段，消除了 Gemini API 返回的 400 Invalid Argument 错误.\n            - **容错增强**: 增加了对工具 `name` 字段的强制校验。当客户端发送缺失名称的无效工具定义时，代理层现在会自动跳过并记录警告，而不是直接让请求失败。\n        -   **[核心功能] 自适应熔断器 (Adaptive Circuit Breaker)**:\n            - **模型级隔离**: 实现了基于 `account_id:model` 的复合 Key 限流追踪，确保单一模型的配额耗尽不会导致整个账号被锁定。\n            - **动态退避策略**: 支持用户自定义 `[60, 300, 1800, 7200]` 等多级退避阶梯，自动根据失败次数增加锁定时间。\n            - **配置热更新**: 配合 `TokenManager` 内存缓存，实现配置修改后反代服务即刻生效，无需重启。\n            - **管理 UI 集成**: 在 API 反代页面新增了完整的控制面板，支持一键开关及手动清除限流记录。\n        -   **[核心优化] 完善日志清理与冗余压制 (Fix Issue #1280)**:\n            - **自动空间回收**: 引入基于体积的清理机制，当日志目录超过 1GB 时自动触发清理，并将占用降至 512MB 以内。相比原有的按天清理，能从根本上防止因日志爆发导致的磁盘撑爆问题。\n            - **高频日志瘦身**: 将 OpenAI 处理器报文详情、TokenManager 账号池轮询等高频产生的日志级别从 INFO 降级为 DEBUG。现在 INFO 级别仅保留简洁的请求摘要。\n    *   **v4.0.6 (2026-01-28)**:\n        -   **[核心修复] 彻底解决 Google OAuth \"Account already exists\" 错误**:\n            - **持久化升级**: 将授权成功后的保存逻辑从“仅新增”升级为 `upsert` (更新或新增) 模式。现在重新授权已存在的账号会平滑更新其 Token 和项目信息，不再弹出报错。\n        -   **[核心修复] 修复 Docker/Web 模式下手动回填授权码失效问题**:\n            - **Flow 状态预初始化**: 在 Web 模式生成授权链接时，后端会同步初始化 OAuth Flow 状态。这确保了在 Docker 等无法自动跳转的环境下，手动复制回填授权码或 URL 能够被后端正确识别并处理。\n        -   **[体验优化] 统一 Web 与桌面端的 OAuth 持久化路径**: 重构了 `TokenManager`，确保所有平台共用同一套健壮的账号核验与存储逻辑。\n        -   **[性能优化] 优化限流恢复机制 (PR #1247)**:\n            - **自动清理频率**: 将限流记录的后台自动清理间隔从 60 秒缩短至 15 秒，大幅提升了触发 429 或 503 错误后的业务恢复速度。\n            - **智能同步清理**: 优化了单个或全部账号刷新逻辑，确保刷新账号的同时即刻清除本地限流锁定，使最新配额能立即投入使用。\n            - **渐进式容量退避**: 针对 `ModelCapacityExhausted` 错误（如 503），将原有的固定 15 秒重试等待优化为 `[5s, 10s, 15s]` 阶梯式策略，显著减少了偶发性容量波动的等待时间。\n        -   **[核心修复] 窗口标题栏深色模式适配 (PR #1253)**: 修复了在系统切换为深色模式时，应用标题栏（Titlebar）未能同步切换配色，导致视觉不统一的问题。\n        -   **[核心修复] 提升 Opus 4.5 默认输出上限 (Fix Issue #1244)**:\n            -   **突破限制**: 将 Claude 和 OpenAI 协议的默认 `max_tokens` 从 16k 提升至 **81,920** (80k)。\n            -   **解决截断**: 彻底解决了 Opus 4.5 等模型在开启思维模式时，因默认 Budget 限制导致输出被锁定在 48k 左右的截断问题。现在无需任何配置即可享受完整的长文本输出能力。\n        -   **[核心修复] 修复账号删除后的幽灵数据问题 (Ghost Account Fix)**:\n            -   **同步重载**: 修复了账号文件被删除后，反代服务的内存缓存未同步更新，导致已删账号仍参与轮询的严重 Bug。\n            -   **即时生效**: 现在单删或批量删除账号后，会强制触发反代服务重载，确保内存中的账号列表与磁盘实时一致。\n        -   **[核心修复] Cloudflared 隧道启动问题修复 (Fix PR #1238)**:\n            -   **启动崩溃修复**: 移除了不支持的命令行参数 (`--no-autoupdate` / `--loglevel`)，解决了 cloudflared 进程启动即退出的问题。\n            -   **URL 解析修正**: 修正了命名隧道 URL 提取时的字符串偏移量错误，确保生成的访问链接格式正确。\n            -   **Windows 体验优化**: 为 Windows 平台添加了 `DETACHED_PROCESS` 标志，实现了隧道的完全静默后台运行，消除了弹窗干扰。\n    *   **v4.0.5 (2026-01-28)**:\n        -   **[核心修复] 彻底解决 Docker/Web 模式 Google OAuth 400 错误 (Google OAuth Fix)**:\n            - **协议对齐**: 强制所有模式（包括 Docker/Web）使用 `localhost` 作为 OAuth 重定向 URI，绕过了 Google 对私网 IP 和非 HTTPS 环境的拦截策略。\n            - **流程优化**: 配合已有的“手动授权码回填”功能，确保即使在远程服务器部署环境下，用户也能顺利完成 Google 账号的授权与添加。\n        -   **[功能增强] 新增阿拉伯语支持与 RTL 布局适配 (PR #1220)**:\n            - **国际化拓展**: 新增完整的阿拉伯语 (`ar`) 翻译支持。\n            - **RTL 布局**: 实现了自动检测并适配从右向左 (Right-to-Left) 的 UI 布局。\n            - **排版优化**: 引入了 Effra 字体家族，显著提升了阿拉伯语文本的可读性与美观度。\n        -   **[功能增强] 手动清除限流记录 (Clear Rate Limit Records)**:\n            - **管理 UI 集成**: 在“代理设置 -> 账号轮换与会话调度”区域新增了“清除限流记录”按钮，支持桌面端与 Web 端调用，允许用户手动清除所有账号的本地限流锁（429/503 记录）。\n            - **账号列表联动**: 实现了配额与限流的智能同步。现在刷新账号额度（单个或全部）时，会自动清除本地限流状态，确保最新的额度信息能立即生效。\n            - **后端核心逻辑**: 在 `RateLimitTracker` 和 `TokenManager` 中底层实现了手动与自动触发的清除逻辑，确保高并发下的状态一致性。\n            - **API 支持**: 新增了对应的 Tauri 命令与 Admin API (`DELETE /api/proxy/rate-limits`)，方便开发者进行编程化管理与集成。\n            - **强制重试**: 配合清除操作，可强制下一次请求忽略之前的退避时间，直接尝试连接上游，帮助在网络恢复后快速恢复业务。\n    *   **v4.0.4 (2026-01-27)**:\n        -   **[功能增强] 深度集成 Gemini 图像生成与多协议支持 (PR #1203)**:\n            - **OpenAI 兼容性增强**: 支持通过标准 OpenAI Images API (`/v1/images/generate`) 调用 Gemini 3 图像模型，支持 `size`、`quality` 等参数。\n            - **多协议集成**: 增强了 Claude 和 OpenAI Chat 接口，支持直接传递图片生成参数，并实现了自动宽高比计算与 4K/2K 质量映射。\n            - **文档补全**: 新增 `docs/gemini-3-image-guide.md`，提供完整的 Gemini 图像生成集成指南。\n            - **稳定性优化**: 优化了通用工具函数 (`common_utils.rs`) 和 Gemini/OpenAI 映射逻辑，确保大尺寸 Payload 传输稳定。\n        -   **[核心修复] 对齐 OpenAI 重试与限流逻辑 (PR #1204)**:\n            - **逻辑对齐**: 重构了 OpenAI 处理器的重试、限流及账号轮换逻辑，使其与 Claude 处理器保持一致，显著提升了高并发下的稳定性。\n            - **热重载优化**: 确保 OpenAI 请求在触发 429 或 503 错误时能精准执行退避策略并自动切换可用账号。\n        -   **[核心修复] 修复 Web OAuth 账号持久化问题 (Web Persistence Fix)**:\n            - **索引修复**: 解决了在 Web 管理界面通过 OAuth 添加的账号虽然文件已生成，但未同步更新到全局账号索引 (`accounts.json`)，导致重启后或桌面端无法识别的问题。\n            - **锁机制统一**: 重构了 `TokenManager` 的保存逻辑，复用了 `modules::account` 的核心方法，确保了文件锁与索引更新的原子性。\n        -   **[核心修复] 解决 Google OAuth 非 Localhost 回调限制 (Fix Issue #1186)**:\n            -   **问题背景**: Google 不支持在 OAuth 流程中使用非 localhost 私网 IP 作为回调地址，即便注入 `device_id` 也会报“不安全的应用版本”警告。\n            -   **解决方案**: 引入了标准化的“手动 OAuth 提交”流程。当浏览器无法自动回调至本地（如远程部署或非 Localhost 环境）时，用户可直接复制回调链接或授权码至应用内完成授权。\n            - **体验增强**: 重构了手动提交界面，集成了全语言国际化支持（9 国语言）与 UI 优化，确保在任何网络环境下都能顺利添加账号。\n        -   **[核心修复] 解决 Google Cloud Code API 429 错误 (Fix Issue #1176)**:\n            - **智能降级**: 默认将 API 流量迁移至更稳定的 Daily/Sandbox 环境，避开生产环境 (`cloudcode-pa.googleapis.com`) 当前频繁的 429 错误。\n            - **稳健性提升**: 实现了 Sandbox -> Daily -> Prod 的三级降级策略，确保主业务流程在极端网络环境下的高可用性。\n        -   **[核心优化] 账号调度算法升级 (Algorithm Upgrade)**:\n            - **健康评分系统 (Health Score)**: 引入了 0.0 到 1.0 的实时健康分机制。请求失败（如 429/5xx）将显著扣分，使受损账号自动降级；成功请求则逐步回升，实现账号状态的智能自愈。\n            - **三级智能排序**: 调度优先级重构为 `订阅等级 > 剩余配额 > 健康分`。确保在同等级、同配额情况下，始终优先通过历史表现最稳定的账号。\n            - **微延迟 (Throttle Delay)**: 针对极端限流场景，当所有账号均被封锁且有账号在 2 秒内即将恢复时，系统将自动执行毫秒级挂起等待而非直接报错。极大提升了高并发下的成功率，并增强了会话粘性。\n            - **全量接口适配**: 重构了 `TokenManager` 核心接口，并完成了全量处理器（Claude, Gemini, OpenAI, Audio, Warmup）的同步适配，确保调度层变更对业务层透明。\n        -   **[核心修复] 固定账号模式持久化 (PR #1209)**:\n            -   **问题背景**: 之前版本在重启服务后，固定账号模式（Fixed Account Mode）的开关状态会被重置。\n            -   **修复内容**: 实现了设置的持久化存储，确保用户偏好在重启后依然生效。\n        -   **[核心修复] 速率限制毫秒级解析 (PR #1210)**:\n            -   **问题背景**: 部分上游服务返回的 `Retry-After` 或速率限制头部包含带小数点的毫秒值，导致解析失败。\n            -   **修复内容**: 增强了时间解析逻辑，支持兼容浮点数格式的时间字段，提高了对非标准上游的兼容性。\n    *   **v4.0.3 (2026-01-27)**:\n        -   **[功能增强] 提高请求体限制以支持大体积图片 Payload (PR #1167)**:\n            - 将默认请求体大小限制从 2MB 提升至 **100MB**，解决多图并发传输时的 413 (Payload Too Large) 错误。\n            - 新增环境变量 `ABV_MAX_BODY_SIZE`，支持用户根据需求动态调整最大限制。\n            - 服务启动时自动输出当前生效的 Body Limit 日志，便于排查。\n        -   **[核心修复] 解决 Google OAuth 'state' 参数缺失导致的授权失败 (Issue #1168)**:\n            - 修复了添加 Google 账号时可能出现的 \"Agent execution terminated\" 错误。\n            - 实现了随机 `state` 参数的生成与回调验证，增强了 OAuth 流程的安全性和兼容性。\n            - 确保在桌面端和 Web 模式下的授权流程均符合 OAuth 2.0 标准。\n        -   **[核心修复] 解决 Docker/Web 模式下代理开关及账号变动需重启生效的问题 (Issue #1166)**:\n            - 实现了代理开关状态的持久化存储，确保容器重启后状态保持一致。\n            - 在账号增删、切换、重排及导入后自动触发 Token 管理器热加载，使变更立即在反代服务中生效。\n            - 优化了账号切换逻辑，自动清除旧会话绑定，确保请求立即路由到新账号。\n    *   **v4.0.2 (2026-01-26)**:\n        -   **[核心修复] 解决开启“访问授权”导致的重复认证与 401 循环 (Fix Issue #1163)**:\n            - 修正了后端鉴权中间件逻辑，确保在鉴权关闭模式（Off/Auto）下管理接口不再强制拦截。\n            - 增强了健康检查路径 (`/api/health`) 的免鉴权豁免，避免 UI 加载初期因状态检测失败触发登录。\n            - 在前端请求层引入了 401 异常频率限制（防抖锁），彻底解决了大批量请求失败导致的 UI 弹窗抖动。\n        -   **[核心修复] 解决切换账号后会话无法持久化保存 (Fix Issue #1159)**:\n            - 增强了数据库注入逻辑，在切换账号时同步更新身份标识（Email）并清除旧的 UserID 缓存。\n            - 解决了因 Token 与身份标识不匹配导致客户端无法正确关联或保存新会话的问题。\n        -   **[核心修复] Docker/Web 模式下模型映射持久化 (Fix Issue #1149)**:\n            - 修复了在 Docker 或 Web 部署模式下，管理员通过 API 修改的模型映射配置（Model Mapping）无法保存到硬盘的问题。\n            - 确保 `admin_update_model_mapping` 接口正确调用持久化逻辑，配置在重启容器后依然生效。\n        -   **[架构优化] MCP 工具支持架构全面升级 (Schema Cleaning & Tool Adapters)**:\n            - **约束语义回填 (Constraint Hints)**:\n                - 实现了智能约束迁移机制，在删除 Gemini 不支持的约束字段(`minLength`, `pattern`, `format` 等)前，自动将其转化为描述提示。\n                - 新增 `CONSTRAINT_FIELDS` 常量和 `move_constraints_to_description` 函数，确保模型能通过描述理解原始约束。\n                - 示例: `{\"minLength\": 5}` → `{\"description\": \"[Constraint: minLen: 5]\"}`\n            - **anyOf/oneOf 智能扁平化增强**:\n                - 重写 `extract_best_schema_from_union` 函数，使用评分机制选择最佳类型(object > array > scalar)。\n                - 在合并后自动添加 `\"Accepts: type1 | type2\"` 提示到描述中，保留所有可能类型的信息。\n                - 新增 `get_schema_type_name` 函数，支持显式类型和结构推断。\n            - **插件化工具适配器层 (Tool Adapter System)**:\n                - 创建 `ToolAdapter` trait，为不同 MCP 工具提供定制化 Schema 处理能力。\n                - 实现 `PencilAdapter`，自动为 Pencil 绘图工具的视觉属性(`cornerRadius`, `strokeWidth`)和路径参数添加说明。\n                - 建立全局适配器注册表，支持通过 `clean_json_schema_for_tool` 函数应用工具特定优化。\n            - **高性能缓存层 (Schema Cache)**:\n                - 实现基于 SHA-256 哈希的 Schema 缓存机制，避免重复清洗相同的 Schema。\n                - 采用 LRU 淘汰策略，最大缓存 1000 条，内存占用 < 10MB。\n                - 提供 `clean_json_schema_cached` 函数和缓存统计功能，预计性能提升 60%+。\n            - **影响范围**: \n                - ✅ 显著提升 MCP 工具(如 Pencil)的 Schema 兼容性和模型理解能力\n                - ✅ 为未来添加更多 MCP 工具(filesystem, database 等)奠定了插件化基础\n                - ✅ 完全向后兼容，所有 25 项测试通过\n        -   **[安全增强] Web UI 管理后台密码与 API Key 分离 (Fix Issue #1139)**:\n            - **独立密码配置**: 支持通过 `ABV_WEB_PASSWORD` 或 `WEB_PASSWORD` 环境变量设置独立的管理后台登录密码。\n            - **智能鉴权逻辑**: \n                - 管理接口优先验证独立密码，未设置时自动回退验证 `API_KEY`（确保向后兼容）。\n                - AI 代理接口严格仅允许使用 `API_KEY` 进行认证，实现权限隔离。\n            - **配置 UI 支持**: 在“仪表盘-服务配置”中新增管理密码编辑项，支持一键找回或修改。\n            - **日志引导**: Headless 模式启动时会清晰打印 API Key 与 Web UI Password 的状态及查看方式。\n    *   **v4.0.1 (2026-01-26)**:\n        -   **[UX 优化] 主题与语言切换平滑度**:\n            - 解决了主题和语言切换时的 UI 卡顿问题，将配置持久化逻辑与状态更新解耦。\n            - 优化了导航栏中的 View Transition API 使用，确保视觉更新不阻塞操作。\n            - 将窗口背景同步调用改为异步，避免 React 渲染延迟。\n        -   **[核心修复] 反代服务启动死锁**:\n            - 修复了启动反代服务时会阻塞状态轮询请求的竞态/死锁问题。\n            - 引入了原子启动标志和非阻塞状态检查，确保 UI 在服务初始化期间保持响应。\n    *   **v4.0.0 (2026-01-25)**:\n        -   **[重大架构] 深度迁移至 Tauri v2 (Tauri v2 Migration)**:\n            - 全面适配 Tauri v2 核心 API，包括系统托盘、窗口管理与事件系统。\n            - 解决了多个异步 Trait 动态派发与生命周期冲突问题，后端性能与稳定性显著提升。\n        -   **[部署革新] 原生 Headless Docker 模式 (Native Headless Docker)**:\n            - 实现了“纯后端”Docker 镜像，彻底移除了对 VNC、noVNC 或 XVFB 的依赖，大幅降低内存与 CPU 占用。\n            - 支持直接托管前端静态资源，容器启动后即可通过浏览器远程管理。\n        -   **[部署修复] Arch Linux 安装脚本修复 (PR #1108)**:\n            - 修复了 `deploy/arch/PKGBUILD.template` 中硬编码 `data.tar.zst` 导致的提取失败问题。\n            - 实现了基于通配符的动态压缩格式识别，确保兼容不同版本的 `.deb` 包。\n        -   **[管理升级] 全功能 Web 管理界面 (Web-based Console)**:\n            - 重写了管理后台，使所有核心功能（账号管理、API 反代监控、OAuth 授权、模型映射）均可在浏览器端完成。\n            - 补全了 Web 模式下的 OAuth 回调处理，支持 `ABV_PUBLIC_URL` 自定义，完美适配远程 VPS 或 NAS 部署场景。\n        -   **[项目规范化] 结构清理与单元化 (Project Normalization)**:\n            - 清理了冗余的 `deploy` 目录及其旧版脚本，项目结构更加现代。\n            - 规范化 Docker 镜像名称为 `antigravity-manager`，并整合专属的 `docker/` 目录与部署手册。\n        -   **[API 增强] 流量日志与监控优化**:\n            - 优化了流量日志的实时监控体验，补全了 Web 模式下的轮询机制与统计接口。\n            - 精确化管理 API 路由占位符命名，提升了 API 的调用精确度。\n        -   **[用户体验] 监控页面布局与深色模式优化 (PR #1105)**:\n            -   **布局重构**: 优化了流量日志页面的容器布局，采用固定最大宽度与响应式边距，解决了在大屏显示器下的内容过度拉伸问题，视觉体验更加舒适。\n            -   **深色模式一致性**: 将日志详情弹窗的配色方案从硬编码的 Slate 色系迁移至 Base 主题色系，确保与全局深色模式风格无缝统一，提升了视觉一致性。\n        -   **[用户体验] 自动更新体验优化**:\n            -   **智能降级**: 修复了当原生更新包未就绪（如 Draft Release）时点击更新无反应的问题。现在系统会自动检测并提示用户，同时优雅降级至浏览器下载模式，确保持续可更新。\n        -   **[核心修复] 深度优化 Signature Cache 与 Rewind 检测 (PR #1094)**:\n            -   **400 错误自愈**: 增强了思考块签名的清洗逻辑。系统现在能自动识别因服务器重启导致的“无主签名”，并在发送给上游前主动将其剥离，从根本上杜绝了由此引发了 `400 Invalid signature` 报错。\n            -   **Rewind (回退) 检测机制**: 升级缓存层，引入消息计数（Message Count）校验。当用户回退对话历史并重新发送时，系统会自动重置签名状态，确保对话流的合法性。\n            -   **全链路适配**: 优化了 Claude、Gemini 及 z.ai (Anthropic) 的数据链路，确保消息计数在流式与非流式请求中均能精准传播。\n        -   **[OpenAI 鲁棒性增强] 优化重试策略与模型级限流 (PR #1093)**:\n            -   **鲁棒重试**: 强制最小 2 次请求尝试，确保单账号模式下也能有效应对瞬时网络抖动；移除了配额耗尽的硬中断，允许自动轮换账号。\n            -   **模型级限流**: 引入模型级限流隔离，避免单个模型限流锁定整个账号，确保账号下其他模型可用。\n            -   **接口修复**: 修复了 TokenManager 异步接口的 Email/ID 混用漏洞，确保限流记录准确。\n        -   **[系统鲁棒性] 统一重试与退避调度中心 (Unified Retry & Backoff Hub)**:\n            -   **逻辑归一化**: 将散落在各协议处理器中的重试逻辑抽象至 `common.rs`，实现全局统一调度。\n            -   **强制退避延迟**: 彻底修复了原先逻辑中解析不到 `Retry-After` 就立即重试导致封号的问题。现在所有处理器在重试前必须通过共享模块执行物理等待，有效保护 IP 信誉。\n            -   **激进参数调整**: 针对 Google/Anthropic 频率限制，将 429 和 503 的初始退避时间显著上调至 **5s-10s**，大幅降低生产环境风控风险。\n        -   **[CLI 同步优化] 解决 Token 冲突与模型配置清理 (PR #1054)**:\n            -   **自动冲突解决**: 在设置 `ANTHROPIC_API_KEY` 时自动移除冲突的 `ANTHROPIC_AUTH_TOKEN`，解决 Claude CLI 同步报错问题。\n            -   **环境变量清理**: 同步时自动移除 `ANTHROPIC_MODEL` 等可能干扰模型输出的环境变量，确保 CLI 使用标准模型。\n            -   **配置健壮性**: 优化了 API Key 为空时的处理方式，避免无效配置干扰。\n        -   **[核心优化] 用量缩放功能默认关闭与联动机制 (Usage Scaling Default Off)**:\n            -   **默认关闭**: 基于用户反馈，将\"启用用量缩放\"功能从默认开启改为默认关闭，回归透明模式。\n            -   **联动机制**: 建立了缩放与自动压缩 (L1/L2/L3) 的联动关系。只有当用户主动开启缩放时，才同步激活自动压缩逻辑。\n            -   **解决痛点**: 修复了用户反馈的\"缩放致盲\"问题 - 默认模式下客户端能看到真实 Token 用量，在接近 200k 时触发原生 `/compact` 提示，避免死锁。\n            -   **功能定位**: 将缩放+压缩重新定义为\"激进扩容模式\"，仅供处理超大型项目时手动开启，提升系统稳定性与可预测性。\n            -   **⚠️ 升级提醒**: 从旧版本升级的用户,建议在\"设置 → 实验性功能\"中手动关闭\"启用用量缩放\",以获得更稳定透明的体验。\n        -   **[协议优化] 全协议自动流式转换 (Auto-Stream Conversion)**:\n            -   **全链路覆盖**: 对 OpenAI (Chat/Legacy/Codex) 和 Gemini 协议实现了强制内部流式化转换。即使客户端请求非流式 (`stream: false`)，后端也会自动建立流式连接与上游通信，极大提升了连接稳定性和配额利用率。\n            -   **智能聚合**: 实现了高性能的流式聚合器，在兼容旧版客户端的同时，还能在后台实时捕获 Thinking 签名，有效解决了非流式请求下签名丢失导致后续工具调用失败的问题。\n        -   **[核心修复] 错误日志元数据补全 (Log Metadata Fix)**:\n            -   **问题背景**: 之前版本在 429/503 等严重错误（如账号耗尽）发生时，日志记录中遗漏了 `mapped_model` 和 `account_email` 字段，导致无法定位出错的具体模型和账号。\n            -   **修复内容**: 在 OpenAI 和 Claude 协议的所有错误退出路径（包括 Token 获取失败、转换异常、重试耗尽）中强制注入了元数据 Header。现在即使请求失败，流量日志也能准确显示目标模型和上下文信息，极大提升了排查效率。\n\n\n    *   **v4.0.0 (2026-01-25)**:\n        -   **[核心功能] 后台任务模型可配置 (Background Model Configuration)**:\n            -   **功能增强**: 允许用户自定义“后台任务”（如标题生成、摘要压缩）使用的模型。不再强制绑定 `gemini-2.5-flash`。\n            -   **UI 更新**: 在“模型映射”页面新增了“后台任务模型”配置项，支持从下拉菜单中选择任意可用模型（如 `gemini-3-flash`）。\n            -   **路由修复**: 修复了后台任务可能绕过用户自定义映射的问题。现在 `internal-background-task` 会严格遵循用户的重定向规则。\n        -   **[重要通告] 上游模型容量预警 (Capacity Warning)**:\n            -   **容量不足**: 接获大量反馈，上游 Google 的 `gemini-2.5-flash` 和 `gemini-2.5-flash-lite` 模型当前正处于极度容量受限状态 (Rate Limited / Capacity Exhausted)。\n            -   **建议操作**: 为保证服务可用性，建议用户暂时在“自定义映射”中将上述两个模型重定向至其他模型（如 `gemini-3-flash` 或 `gemini-3-pro-high`），直到上游恢复。\n        -   **[核心修复] Windows 启动参数支持 (PR #973)**:\n            -   **问题修复**: 修复了 Windows 平台下启动参数（如内网穿透配置等）无法正确解析生效的问题。感谢 @Mag1cFall 的贡献。\n        -   **[核心修复] Claude 签名校验增强 (PR #1009)**:\n            -   **功能优化**: 增强了 Claude 模型的签名校验逻辑，修复了在长对话或复杂工具调用场景下可能出现的 400 错误。\n            -   **兼容性提升**: 引入最小签名长度校验，并对合法长度的未知签名采取信任策略，大幅提升了 JSON 工具调用的稳定性。\n        -   **[国际化] 越南语翻译优化 (PR #1017)**:\n            -   **翻译精简**: 对关于页面等区域的越南语翻译进行了精简与标点优化。\n        -   **[国际化] 土耳其语托盘翻译增强 (PR #1023)**:\n            -   **功能优化**: 为系统托盘菜单增加了完整的土耳其语翻译支持，提升了土耳其语用户的操作体验。\n            -   **[功能增强] 多语言支持与 I18n 设置 (PR #1029)**:\n            -   **新增语言支持**: 增加了葡萄牙语、日语、越南语、土耳其语、俄语等多国语言的更完整支持。\n            -   **I18n 设置面板**: 在设置页面新增了语言选择器，支持即时切换应用显示语言。\n        -   **[国际化] 韩语支持与界面优化 (New)**:\n            -   **韩语集成**: 新增了完整的韩语 (`ko`) 翻译支持，现在可以在设置中选择韩语界面。\n            -   **UI 交互升级**: 重构了顶部导航栏的语言切换器，由原来的单次点击循环切换升级为更直观的下拉菜单，展示语言缩写与全称，提升了多语言环境下的操作体验。\n    *   **v3.3.49 (2026-01-22)**:\n        -   **[核心修复] Thinking 后中断与 0 Token 防御 (Fix Thinking Interruption)**:\n            -   **问题背景**: 针对 Gemini 等模型在输出 Thinking 内容后流意外中断，导致 Claude 客户端收到 0 Token 响应并报错死锁的问题。\n            -   **防御机制**:\n                - **状态追踪**: 实时监测流式响应中是否“只想未说”（已发送 Thinking 但未发送 Content）。\n                - **自动兜底**: 当检测到此类中断时，系统会自动闭合 Thinking 块，注入系统提示信息，并模拟正常的 Usage 数据，确保客户端能优雅结束会话。\n        -   **[核心修复] 移除 Flash Lite 模型以修复 429 错误 (Fix 429 Errors)**:\n            -   **问题背景**: 今日监测发现 `gemini-2.5-flash-lite` 频繁出现 429 错误，具体原因为 **上游 Google 容器容量耗尽 (MODEL_CAPACITY_EXHAUSTED)**，而非通常的账号配额不足。\n            -   **紧急修复**: 将所有系统内部默认的 `gemini-2.5-flash-lite` 调用（如后台标题生成、L3 摘要压缩）及预设映射全部替换为更稳定的 `gemini-2.5-flash`。\n            -   **用户提醒**: 如果您在“自定义映射”或“预设”中手动使用了 `gemini-2.5-flash-lite`，请务必修改为其他模型，否则可能会持续遇到 429 错误。\n        -   **[性能优化] 设置项即时生效 (Fix PR #949)**:\n            -   **即时生效**: 修复了语言切换需要手动点击保存的问题。现在修改语言设置会立即应用到整个 UI。\n        -   **[代码清理] 后端架构重构与优化 (PR #950)**:\n            -   **架构精简**: 深度重构了代理层的 Mapper 和 Handler 逻辑，移除了冗余模块（如 `openai/collector.rs`），显著提升了代码的可维护性。\n            -   **稳定性增强**: 优化了 OpenAI 与 Claude 协议的转换链路，统一了图片配置解析逻辑，并加固了上下文管理器的健壮性。\n        -   **[核心修复] 设置项同步策略更新**:\n            -   **状态同步**: 修正了主题切换的即时应用逻辑，并解决了 `App.tsx` 与 `Settings.tsx` 之间的状态冲突，确保配置加载过程中的 UI 一致性。\n        -   **[核心优化] 上下文压缩与 Token 节省**:\n            -   **由于 Claude CLI 在恢复历史记录时会发送大量上下文，现已将压缩阈值改为可配置并降低默认值。**\n            -   **L3 摘要重置阈值由 90% 降至 70%，在 token 堆积过多前提前进行压缩节省额度。**\n            -   **前端 UI 增强：在实验性设置中新增 L1/L2/L3 压缩阈值滑块，支持动态自定义。**\n        -   **[功能增强] API 监控看板功能升级 (PR #951)**:\n            -   **账号筛选**: 新增按账号筛选流量日志的功能，支持在大流量环境下精准追踪特定账号的调用情况。\n            -   **详情深度增强**: 监控详情页现在可以完整显示请求协议（OpenAI/Anthropic/Gemini）、使用账号、映射后的物理模型等关键元数据。\n            -   **UI 与国际化**: 优化了监控详情的布局，并补全了 8 种语言的相关翻译。\n        -   **[JSON Schema 优化] 递归收集 $defs 并完善回退处理 (PR #953)**:\n            -   **递归收集**: 添加了 `collect_all_defs()` 以递归方式从所有模式层级收集 `$defs`/`definitions`，解决了嵌套定义丢失的问题。\n            -   **引用平坦化**: 始终运行 `flatten_refs()` 以捕获并处理孤立的 `$ref` 字段。\n            -   **回退机制**: 为未解析的 `$ref` 添加了回退逻辑，将其转换为带有描述性提示的字符串类型。\n            -   **稳定性增强**: 新增了针对嵌套定义和未解析引用的测试用例，确保 Schema 处理的健壮性。\n        -   **[核心修复] 账号索引保护 (Fix Issue #929)**:\n            -   **安全加固**: 移除了加载失败时的自动删除逻辑，防止在升级或环境异常时意外丢失账号索引，确保用户数据安全。\n        -   **[核心优化] 路由器与模型映射深度优化 (PR #954)**:\n            -   **路由器确定性优先级**: 修复了路由器在处理多通配符模式时的不确定性问题，实现了基于模式长度和复杂度的确定性匹配优先级。\n\n        -   **[稳定性增强] OAuth 回调与解析优化 (Fix #931, #850, #778)**:\n            -   **鲁棒解析**: 优化了本地回调服务器的 URL 解析逻辑，不再依赖单一分割符，提升了不同浏览器下的兼容性。\n            -   **调试增强**: 增加了原始请求 (Raw Request) 记录功能，当授权失败时可直接在日志中查看原始数据，方便定位网络拦截问题。\n        -   **[网络优化] OAuth 通信质量提升 (Issue #948, #887)**:\n            -   **延时保障**: 将授权请求超时时间延长至 60 秒，大幅提升了在代理环境下的 Token 交换成功率。\n            -   **错误指引**: 针对 Google API 连接超时或重置的情况，新增了明确的中文代理设置建议，降低排查门槛。\n        -   **[体验优化] 上游代理配置校验与提示增强 (Contributed by @zhiqianzheng)**:\n            -   **配置校验**: 当用户启用上游代理但未填写代理地址时，保存操作将被阻止并显示明确的错误提示，避免无效配置导致的连接失败。\n            -   **重启提醒**: 成功保存代理配置后，系统会提示用户需要重启应用才能使配置生效，降低用户排查成本。\n            -   **多语言支持**: 新增简体中文、繁体中文、英文、日语的相关翻译。\n\n    *   **v3.3.48 (2026-01-21)**:\n        -   **[核心修复] Windows 控制台闪烁问题 (Fix PR #933)**:\n            -   **问题背景**: Windows 平台在启动或执行后台命令时，偶尔会弹出短暂的 CMD 窗口，影响用户体验。\n            -   **修复内容**: 在 `cloudflared` 进程创建逻辑中添加 `CREATE_NO_WINDOW` 标志，确保所有后台进程静默运行。\n            -   **影响范围**: 解决了 Windows 用户在启动应用或 CLI 交互时的窗口闪烁问题。\n    *   **v3.3.47 (2026-01-21)**:\n        -   **[核心修复] 图片生成 API 参数映射增强 (Fix Issue #911)**:\n            -   **功能**: 支持从 OpenAI 参数 (`size`, `quality`) 解析配置，支持动态宽高比计算，`quality: hd` 自动映射为 4K 分辨率。\n            -   **影响**: 显著提升 Images API 兼容性，OpenAI 与 Claude 协议均受支持。\n        -   **[功能增强] Cloudflared 内网穿透支持 (PR #923)**:\n            -   **核心功能**: 集成 `cloudflared` 隧道支持，允许用户在无公网 IP 或处于复杂内网环境下，通过 Cloudflare 隧道一键发布 API 服务。\n            -   **易用性优化**: 前端新增 Cloudflared 配置界面，支持状态监控、日志查看及一键开关隧道。\n            -   **国际化补全**: 补全了繁体中文、英文、日文、韩文、越南语、土耳其语、俄语等 8 国语言的 Cloudflared 相关翻译。\n        -   **[核心修复] 解决 Git 合并冲突导致的启动失败**:\n            -   **修复内容**: 解决了 `src-tauri/src/proxy/handlers/claude.rs` 中因多进程并行合并产生的 `<<<<<<< HEAD` 冲突标记。\n            -   **影响范围**: 恢复了后端服务的编译能力，修复了应用启动即崩溃的问题。\n        -   **[核心优化] 三层渐进式上下文压缩 (3-Layer Progressive Context PCC)**:\n            -   **背景**: 长对话场景下频繁触发 \"Prompt is too long\" 错误，手动 `/compact` 操作繁琐，且现有压缩策略会破坏 LLM 的 KV Cache，导致成本飙升\n            -   **解决方案 - 多层渐进式压缩策略**:\n                - **Layer 1 (60% 压力)**: 工具消息智能裁剪\n                    - 删除旧的工具调用/结果消息，保留最近 5 轮交互\n                    - **完全不破坏 KV Cache**（只删除消息，不修改内容）\n                    - 压缩率：60-90%\n                - **Layer 2 (75% 压力)**: Thinking 内容压缩 + 签名保留\n                    - 压缩 `assistant` 消息中的 Thinking 块文本内容（替换为 \"...\"）\n                    - **完整保留 `signature` 字段**，解决 Issue #902（签名丢失导致 400 错误）\n                    - 保护最近 4 条消息不被压缩\n                    - 压缩率：70-95%\n                - **Layer 3 (90% 压力)**: Fork 会话 + XML 摘要\n                    - 使用 `gemini-2.5-flash-lite` 生成 8 节 XML 结构化摘要（成本极低）\n                    - 提取并保留最后一个有效 Thinking 签名\n                    - 创建新的消息序列：`[User: XML摘要] + [Assistant: 确认] + [用户最新消息]`\n                    - **完全不破坏 Prompt Cache**（前缀稳定，只追加）\n                    - 压缩率：86-97%\n            -   **技术实现**:\n                - **新增模块**: `context_manager.rs` 中实现 Token 估算、工具裁剪、Thinking 压缩、签名提取等核心功能\n                - **辅助函数**: `call_gemini_sync()` - 可复用的同步上游调用函数\n                - **XML 摘要模板**: 8 节结构化摘要（目标、技术栈、文件状态、代码变更、调试历史、计划、偏好、签名）\n                - **渐进式触发**: 按压力等级自动触发，每次压缩后重新估算 Token 用量\n            -   **成本优化**:\n                - Layer 1: 完全无成本（不破坏缓存）\n                - Layer 2: 低成本（仅破坏部分缓存）\n                - Layer 3: 极低成本（摘要生成使用 flash-lite，新会话完全缓存友好）\n                - **综合节省**: 86-97% Token 成本，同时保持签名链完整性\n            -   **用户体验**:\n                - 自动化：无需手动 `/compact`，系统自动处理\n                - 透明化：详细日志记录每层压缩的触发和效果\n                - 容错性：Layer 3 失败时返回友好错误提示\n            -   **影响范围**: 解决长对话场景下的上下文管理问题,显著降低 API 成本,确保工具调用链完整性\n        -   **[核心优化] 上下文估算与缩放算法增强 (PR #925)**:\n            -   **背景**: 在 Claude Code 等长对话场景下,固定的 Token 估算算法（3.5 字符/token）在中英文混排时误差极大,导致三层压缩逻辑无法及时触发,最终仍会报 \"Prompt is too long\" 错误\n            -   **解决方案 - 动态校准 + 多语言感知**:\n                - **多语言感知估算**:\n                    - **ASCII/英文**: 约为 4 字符/Token（针对代码和英文文档优化）\n                    - **Unicode/CJK (中日韩)**: 约为 1.5 字符/Token（针对 Gemini/Claude 分词特点）\n                    - **安全余量**: 在计算结果基础上额外增加 15% 的安全冗余\n                - **动态校准器 (`estimation_calibrator.rs`)**:\n                    - **自学习机制**: 记录每次请求的\"估算 Token 数\"与 Google API 返回的\"实际 Token 数\"\n                    - **校准因子**: 使用指数移动平均 (EMA, 60% 旧比例 + 40% 新比例) 维护校准系数\n                    - **保守初始化**: 初始校准系数为 2.0,确保系统运行初期极其保守地触发压缩\n                    - **自动收敛**: 根据实际数据自动修正,使估算值越来越接近真实值\n                - **整合三层压缩框架**:\n                    - 在所有估算环节（初始估算、Layer 1/2/3 后重新估算）使用校准后的 Token 数\n                    - 每层压缩后记录详细的校准因子日志,便于调试和监控\n            -   **技术实现**:\n                - **新增模块**: `estimation_calibrator.rs` - 全局单例校准器,线程安全\n                - **修改文件**: `claude.rs`, `streaming.rs`, `context_manager.rs`\n                - **校准数据流**: 流式响应收集器 → 提取真实 Token 数 → 更新校准器 → 下次请求使用新系数\n            -   **用户体验**:\n                - **透明化**: 日志中显示原始估算值、校准后估算值、校准因子,便于理解系统行为\n                - **自适应**: 系统会根据用户的实际使用模式（中英文比例、代码量等）自动调整\n                - **精准触发**: 压缩逻辑基于更准确的估算值,大幅降低\"漏判\"和\"误判\"概率\n            -   **影响范围**: 显著提升上下文管理的精准度,解决 Issue #902 和 #867 中反馈的自动压缩失效问题,确保长对话稳定性\n        -   **[关键修复] Thinking 签名恢复逻辑优化**:\n            -   **背景**: 在重试场景下,签名检查逻辑未检查 Session Cache,导致错误禁用 Thinking 模式,产生 0 token 请求和响应失败\n            -   **问题表现**:\n                - 重试时显示 \"No valid signature found for function calls. Disabling thinking\"\n                - 流量日志显示 `I: 0, O: 0` (实际请求成功但 Token 未记录)\n                - 客户端可能无法接收到响应内容\n            -   **修复内容**:\n                - **扩展签名检查范围**: `has_valid_signature_for_function_calls()` 现在检查 Session Cache\n                - **检查优先级**: Global Store → **Session Cache (新增)** → Message History\n                - **详细日志**: 添加签名来源追踪日志,便于调试\n            -   **技术实现**:\n                - 修改 `request.rs` 中的签名验证逻辑\n                - 新增 `session_id` 参数传递到签名检查函数\n                - 添加 `[Signature-Check]` 系列日志用于追踪签名恢复过程\n            -   **影响**: 解决重试场景下的 Thinking 模式降级问题,确保 Token 统计准确性,提升长会话稳定性\n        -   **[核心修复] 通用参数对齐引擎 (Universal Parameter Alignment Engine)**:\n            -   **背景**: 解决 Gemini API 在调用工具（Tool Use）时因参数类型不匹配产生的 `400 Bad Request` 错误。\n            -   **修复内容**:\n                - **实现参数对齐引擎**: 在 `json_schema.rs` 中实现 `fix_tool_call_args`，基于 JSON Schema 自动将字符串类型的数字/布尔值转换为目标类型，并处理非法字段。\n                - **多协议重构**: 同步重构了 OpenAI 和 Claude 协议层，移除了硬编码的工具参数修正逻辑，改用统一的对齐引擎。\n            -   **解决问题**: 修复了 `local_shell_call`、`apply_patch` 等工具在多级反代或特定客户端下参数被错误格式化为字符串导致的异常。\n            -   **影响**: 显著提升了工具调用的稳定性，减少了上游 API 的 400 错误。\n        -   **[功能增强] 画图模型配额保护支持 (Fix Issue #912)**:\n            -   **问题背景**: 用户反馈画图模型（G3 Image）没有配额保护功能，导致配额耗尽的账号仍被用于画图请求\n            -   **修复内容**:\n                - **后端配置**: 在 `config.rs` 的 `default_monitored_models()` 中添加 `gemini-3-pro-image`，与智能预热和配额关注列表保持一致\n                - **前端 UI**: 在 `QuotaProtection.tsx` 中添加画图模型选项，调整布局为一行4个模型（与智能预热保持一致）\n            -   **影响范围**: \n                - ✅ 向后兼容：已有配置不受影响，新用户或重置配置后会自动包含画图模型\n                - ✅ 完整保护：现在所有4个核心模型（Gemini 3 Flash、Gemini 3 Pro High、Claude 4.5 Sonnet、Gemini 3 Pro Image）都受配额保护监控\n                - ✅ 自动触发：当画图模型配额低于阈值时，账号会自动加入保护列表，避免继续消耗\n        -   **[传输层优化] 流式响应防缓冲优化 (Streaming Response Anti-Buffering)**:\n            -   **背景**: 在 Nginx 等反向代理后部署时，流式响应可能被代理缓冲，导致客户端延迟增加\n            -   **修复内容**:\n                - **添加 X-Accel-Buffering Header**: 在所有流式响应中注入 `X-Accel-Buffering: no` 头部\n                - **多协议覆盖**: Claude (`/v1/messages`)、OpenAI (`/v1/chat/completions`) 和 Gemini 原生协议全部支持\n            -   **技术细节**:\n                - 修改文件: `claude.rs:L877`, `openai.rs:L314`, `gemini.rs:L240`\n                - 该 Header 告诉 Nginx 等反向代理不要缓冲流式响应，直接透传给客户端\n            -   **影响**: 显著降低反向代理场景下的流式响应延迟，提升用户体验\n        -   **[错误恢复增强] 多协议签名错误自愈提示词 (Multi-Protocol Signature Error Recovery)**:\n            -   **背景**: 当 Thinking 模式下出现签名错误时，仅剔除签名可能导致模型生成空响应或简单的 \"OK\"\n            -   **修复内容**:\n                - **Claude 协议增强**: 在现有签名错误重试逻辑中追加修复提示词，引导模型重新生成完整响应\n                - **OpenAI 协议实现**: 新增 400 签名错误检测和修复提示词注入逻辑\n                - **Gemini 协议实现**: 新增 400 签名错误检测和修复提示词注入逻辑\n            -   **修复提示词**:\n                ```\n                [System Recovery] Your previous output contained an invalid signature. \n                Please regenerate the response without the corrupted signature block.\n                ```\n            -   **技术细节**:\n                - Claude: `claude.rs:L1012-1030` - 增强现有逻辑，支持 String 和 Array 消息格式\n                - OpenAI: `openai.rs:L391-427` - 完整实现，使用 `OpenAIContentBlock::Text` 类型\n                - Gemini: `gemini.rs:L17, L299-329` - 修改函数签名支持可变 body，注入修复提示词\n            -   **影响**: \n                - ✅ 提升错误恢复成功率：模型收到明确指令，避免生成无意义响应\n                - ✅ 多协议一致性：所有 3 个协议具有相同的错误恢复能力\n                - ✅ 用户体验改善：减少因签名错误导致的对话中断\n    *   **v3.3.46 (2026-01-20)**:\n        -   **[功能增强] Token 使用统计 (Token Stats) 深度优化与国际化标准化 (PR #892)**:\n            -   **UI/UX 统一**: 实现了自定义 Tooltip 组件，统一了面积图、柱状图和饼图的悬浮提示样式，增强了深色模式下的对比度与可读性。\n            -   **视觉细节磨砂**: 优化了图表光标和网格线，移除冗余的 hover 高亮，使图表界面更加清爽专业。\n            -   **自适应布局**: 改进了图表容器的 Flex 布局，确保在不同窗口尺寸下均能填充满垂直空间，消除了图表下方的留白。\n            -   **分账号趋势统计**: 新增了“按账号查看”模式，支持通过饼图和趋势图直观分析各账号的 Token 消耗占比与活跃度。\n            -   **国际化 (i18n) 标准化**: 解决了 `ja.json`、`zh-TW.json`、`vi.json`、`ru.json`、`tr.json` 等多国语言文件中的键值重复警告。补全了 `account_trend`、`by_model` 等缺失翻译，确保 8 种语言下的 UI 展现高度一致。\n        -   **[核心修复] 移除 [DONE] 停止序列以防止输出截断 (PR #889)**:\n            -   **问题背景**: `[DONE]` 是 SSE (Server-Sent Events) 协议的标准结束标记,在代码和文档中经常出现。将其作为 `stopSequence` 会导致模型在解释 SSE 相关内容时输出被意外截断。\n            -   **修复内容**: 从 Gemini 请求的 `stopSequences` 数组中移除了 `\"[DONE]\"` 标记。\n            -   **技术说明**:\n                - Gemini 流的真正结束由 `finishReason` 字段控制,无需依赖 `stopSequence`\n                - SSE 层面的 `\"data: [DONE]\"` 已在 `mod.rs` 中单独处理\n            -   **影响范围**: 解决了模型在生成包含 SSE 协议说明、代码示例等内容时被提前终止的问题 (Issue #888)。\n        -   **[部署优化] Docker 镜像构建双模适配 (Default/China Mode)**:\n            -   **双模架构**: 引入 `ARG USE_CHINA_MIRROR` 构建参数。默认模式保持原汁原味的 Debian 官方源（适合海外/云构建）；开启后自动切换为清华大学 (TUNA) 镜像源（适合国内环境）。\n            -   **灵活性大幅提升**: 解决了硬编码国内源导致海外构建缓慢的问题，同时保留了国内用户的加速体验。\n        -   **[稳定性修复] VNC 与容器启动逻辑加固 (PR #881)**:\n            -   **僵尸进程清理**: 优化了 `start.sh` 中的 cleanup 逻辑，改用 `pkill` 精准查杀 Xtigervnc 和 websockify 进程，并清理 `/tmp/.X11-unix` 锁文件，解决了重启后 VNC 无法连接的各种边缘情况。\n            -   **健康检查升级**: 将 Healthcheck 检查项扩展到 websockify 和主程序，确保容器状态更真实地反映服务可用性。\n            -   **重大修复**: 修复了 OpenAI 协议请求返回 404 的问题，并解决了 Codex (`/v1/responses`) 接收复杂对象数组 `input` 或 `apply_patch` 等自定义工具（缺失 Schema）时导致上游返回 400 (`INVALID_ARGUMENT`) 的兼容性缺陷。\n            -   **思维模型优化**: 解决了 Claude 4.6 Thinking 模型在历史消息缺失思维链时强制报错的问题，实现了智能协议降级与占位块注入。\n            -   **协议补全**: 补全了 OpenAI Legacy 接口的 Token 统计响应与 Header 注入，支持 `input_text` 类型内容块，并将 `developer` 角色适配为系统指令。\n            -   **requestId 统一**: 统一所有 OpenAI 路径下的 `requestId` 前缀为 `agent-`，解决部分客户端的 ID 识别问题。\n        -   **[核心修复] JSON Schema 数组递归清理修复 (解决 Gemini API 400 错误)**:\n            -   **问题背景**: Gemini API 不支持 `propertyNames`、`const` 等 JSON Schema 字段。虽然已有白名单过滤逻辑，但由于 `clean_json_schema_recursive` 函数缺少对 `Value::Array` 类型的递归处理，导致嵌套在 `anyOf`、`oneOf` 或 `items` 数组内部的非法字段无法被清除，触发 `Invalid JSON payload received. Unknown name \"propertyNames\"/\"const\"` 错误。\n            -   **修复内容**:\n                - **增加 anyOf/oneOf 合并前的递归清洗**: 在合并 `anyOf`/`oneOf` 分支之前，先递归清洗每个分支内部的内容，确保合并的分支已被清理，防止非法字段在合并过程中逃逸。\n                - **增加通用数组递归处理分支**: 为 `match` 语句增加 `Value::Array` 分支，确保所有数组类型的值（包括 `items`、`enum` 等）都会被递归清理，覆盖所有可能包含 Schema 定义的数组字段。\n            -   **测试验证**: 新增 3 个测试用例验证修复效果，所有 14 个测试全部通过，无回归。\n            -   **影响范围**: 解决了复杂工具定义（如 MCP 工具）中嵌套数组结构导致的 400 错误，确保 Gemini API 调用 100% 兼容。\n    *   **v3.3.45 (2026-01-19)**:\n        - **[核心功能] 解决 Claude/Gemini SSE 中断与 0-token 响应问题 (Issue #859)**:\n            - **增强型预读 (Peek) 逻辑**: 在向客户端发送 200 OK 响应前，代理现在会循环预读并跳过所有心跳包（SSE ping）及空数据块，确认收到有效业务内容后再建立连接。\n            - **智能重试触发**: 若在预读阶段检测到空响应、超时（60s）或流异常中断，系统将自动触发账号轮换和重试机制，解决了长延迟模型下的静默失败。\n            - **协议一致性增强**: 为 Gemini 协议补齐了缺失的预读逻辑；同时将 Claude 心跳间隔优化为 30s，减少了生成长文本时的连接干扰。\n        - **[核心功能] 固定账号模式集成 (PR #842)**:\n            - **后端增强**: 在代理核心中引入了 `preferred_account_id` 支持，允许通过 API 或 UI 强制锁定特定账号进行请求调度。\n            - **UI 交互更新**: 在 API 反代页面新增“固定账号”切换与账号选择器，支持实时锁定当前会话的出口账号。\n            - **调度优化**: 在“固定账号模式”下优先级高于传统轮询，确保特定业务场景下的会话连续性。\n        - **[国际化] 全语言翻译补全与清理**:\n            - **8 语言覆盖**: 补全了中、英、繁中、日、土、越、葡、俄等 8 种语言中关于“固定账号模式”的所有 i18n 翻译项。\n            - **冗余清理**: 修复了 `ja.json` 和 `vi.json` 中由于历史 PR 累积导致的重复键（Duplicate Keys）警告，提升了翻译规范性。\n            - **标点同步**: 统一清除了各语言翻译中误用的全角标点，确保 UI 展示的一致性。\n        - **[核心功能] 客户端热更新与 Token 统计系统 (PR #846 by @lengjingxu)**:\n            - **热更新 (Native Updater)**: 集成 Tauri v2 原生更新插件，支持自动检测、下载、安装及重启，实现客户端无感升级。\n            - **Token 消费可视化**: 新增基于 SQLite 实现的 Token 统计持久化模块，支持按小时/日/周维度查看总消耗及各账号占比。\n            - **UI/UX 增强**: 优化了图表悬浮提示 (Tooltip) 在深色模式下的对比度，隐藏了冗余的 hover 高亮；补全了 8 语言完整翻译并修复了硬编码图例。\n            - **集成修复**: 在本地合并期间修复了 PR 原始代码中缺失插件配置导致的启动崩溃故障。\n        - **[系统加速] 启用清华大学 (TUNA) 镜像源**: 优化了 Dockerfile 构建流程，大幅提升国内环境下的插件安装速度。\n        - **[部署优化] 官方 Docker 与 noVNC 支持 (PR #851)**:\n            - **全功能容器化**: 为 headless 环境提供完整的 Docker 部署方案，内置 Openbox 桌面环境。\n            - **Web VNC 集成**: 集成 noVNC，支持通过浏览器直接访问图形界面进行 OAuth 授权（内置 Firefox ESR）。\n            - **自愈启动流**: 优化了 `start.sh` 启动逻辑，支持自动清理 X11 锁文件及服务崩溃自动退出，提升生产环境稳定性。\n            - **多语言适配**: 内置 CJK 字体，确保 Docker 环境下中文字符正常显示。\n            - **资源限制优化**: 统一设置 `shm_size: 2gb`，解决容器内浏览器及图形界面崩溃问题。\n        - **[核心功能] 修复账号切换时的设备指纹同步问题**:\n            - **路径探测改进**: 优化了 `storage.json` 的探测时机，确保在进程关闭前准确获取路径，兼容自定义数据目录。\n            - **自动隔离生成**: 针对未绑定指纹的账号，在切换时会自动生成并绑定唯一的设备标识，实现账号间的指纹隔离。\n        - **[UI 修复] 修复账号管理页条数显示不准确问题 (Issue #754)**:\n            - **逻辑修正**: 强制分页条数默认最低为 10 条，解决了小窗口下自动变为 5 条或 9 条的不直觉体验。\n            - **持久化增强**: 实现了分页大小的 `localStorage` 持久化，用户手动选择的条数将永久锁定并覆盖自动模式。\n            - **UI 一致性**: 确保右下角分页选项与列表实际展示条数始终保持一致。\n    *   **v3.3.44 (2026-01-19)**:\n        - **[核心稳定性] 动态思维剥离 (Dynamic Thinking Stripping) - 解决 Prompt 过长与签名错误**:\n            - **问题背景**: 在 Deep Thinking 模式下,长对话会导致两类致命错误:\n                - `Prompt is too long`: 历史 Thinking Block 累积导致 Token 超限\n                - `Invalid signature`: 代理重启后内存签名缓存丢失,旧签名被 Google 拒收\n            - **解决方案 - Context Purification (上下文净化)**:\n                - **新增 `ContextManager` 模块**: 实现 Token 估算与历史清洗逻辑\n                - **分级清洗策略**:\n                    - `Soft` (60%+ 压力): 保留最近 2 轮 Thinking,剥离更早历史\n                    - `Aggressive` (90%+ 压力): 移除所有历史 Thinking Block\n                - **差异化限额**: Flash 模型 (1M) 与 Pro 模型 (2M) 采用不同触发阈值\n                - **签名同步清除**: 清洗 Thinking 时自动移除 `thought_signature`,避免签名校验失败\n            - **透明度增强**: 响应头新增 `X-Context-Purified: true` 标识,便于调试\n            - **性能优化**: 基于字符数的轻量级 Token 估算,对请求延迟影响 \\u003c 5ms\n            - **影响范围**: 解决 Deep Thinking 模式下的两大顽疾,释放 40%-60% Context 空间,确保长对话稳定性\n    *   **v3.3.43 (2026-01-18)**:\n        - **[国际化] 设备指纹对话框全量本地化 (PR #825, 感谢 @IamAshrafee)**:\n            - 解决了设备指纹（Device Fingerprint）对话框中残留的硬编码中文字符串问题。\n            - 补全了英、繁、日等 8 种语言的翻译骨架，提升全球化体验。\n        - **[日语优化] 日语翻译补全与术语修正 (PR #822, 感谢 @Koshikai)**:\n            - 补全了 50 多个缺失的翻译键，覆盖配额保护、HTTP API、更新检查等核心设置。\n            - 优化了技术术语，使日语表达更自然（例如：`pro_low` 译为“低消費”）。\n        - **[翻译修复] 越南语拼写错误修正 (PR #798, 感谢 @vietnhatthai)**:\n            - 修复了越南语设置中 `refresh_msg` 的拼写错误（`hiện đài` -> `hiện tại`）。\n        - **[兼容性增强] 新增 Google API Key 原生支持 (PR #831)**:\n            - **支持 `x-goog-api-key` 请求头**:\n                - 认证中间件现在支持识别 `x-goog-api-key` 头部。\n                - 提高了与 Google 官方 SDK 及第三方 Google 风格客户端的兼容性，无需再手动修改 Header 为 `x-api-key`。\n    *   **v3.3.42 (2026-01-18)**:\n        - **[流量日志增强] 协议自动识别与流式响应整合 (PR #814)**:\n            - **协议标签分类**: 流量日志列表现在可以根据 URI 自动识别并标注协议类型（OpenAI 绿色、Anthropic 橙色、Gemini 蓝色），使请求来源一目了然。\n            - **流式数据全整合**: 解决了流式响应在日志中仅显示 `[Stream Data]` 的问题。现在会自动拦截并聚合流式数据包，将分散的 `delta` 片段还原为完整的回复内容和“思考”过程，大幅提升调试效率。\n            - **多语言适配**: 补全了流量日志相关功能在 8 种语言环境下的 i18n 翻译。\n        - **[重大修复] Gemini JSON Schema 清洗策略深度重构 (Issue #815)**:\n            - **解决属性丢失问题**: 实现了“最佳分支合并”逻辑。在处理工具定义的 `anyOf`/`oneOf` 结构时，会自动识别并提取内容最丰富的分支属性向上合并，解决了模型报错 `malformed function call` 的顽疾。\n            - **稳健的白名单机制**: 采用针对 Gemini API 的严格白名单过滤策略，剔除不支持的校验字段，确保 API 调用 100% 兼容（从根本上杜绝 400 错误）。\n            - **约束信息迁移 (Description Hints)**: 在移除 `minLength`, `pattern`, `format` 等字段前，自动将其转为文字描述追加到 `description` 中，确保模型依然能感知参数约束。\n            - **Schema 上下文检测锁**: 新增安全检查逻辑，确保清洗器仅在处理真正的 Schema 时执行。通过“精准锁”保护了 `request.rs` 中的工具调用结构，确保历史修复逻辑（如布尔值转换、Shell 数组转换）在重构后依然稳如磐石。\n    *   **v3.3.41 (2026-01-18)**:\n        - **Claude 协议核心兼容性修复 (Issue #813)**:\n            - **连续 User 消息合并**: 实现了 `merge_consecutive_messages` 逻辑，在请求进入 Proxy 时自动合并具有相同角色的连续消息流。解决了因 Spec/Plan 模式切换导致的角色交替违规产生的 400 Bad Request 错误。\n            - **EnterPlanMode 协议对齐**: 针对 Claude Code 的 `EnterPlanMode` 工具调用，强制清空冗余参数，确保完全符合官方协议，解决了激活 Plan Mode 时的指令集校验失败问题。\n        - **代理鲁棒性增强**:\n            - 增强了工具调用链的自愈能力。当模型因幻觉产生错误路径尝试时，Proxy 现能提供标准的错误反馈引导模型转向正确路径。\n    *   **v3.3.40 (2026-01-18)**:\n        - **API 400 错误深度修复 (Grep/Thinking 稳定性改进)**:\n            - **修复流式块顺序违规**: 解决了 \"Found 'text' instead of 'thinking'\" 400 错误。修正了 `streaming.rs` 中在文字块后非法追加思维块的逻辑，改由缓存机制实现静默同步。\n            - **思维签名自愈增强**: 在 `claude.rs` 中扩展了 400 错误捕获关键词，覆盖了签名失效、顺序违规和协议不匹配场景。一旦触发，代理会自动执行消息降级并快速重试，实现用户无感知的异常自愈。\n            - **搜索工具参数深度对齐**: 修正了 `Grep` 和 `Glob` 工具的参数映射逻辑，将 `query` 准确映射为 `path` (Claude Code Schema)，并支持默认注入执行路径 `.`。\n            - **工具名重映射策略优化**: 改进了重命名逻辑，仅针对 `search` 等模型幻觉进行修正，避免破坏原始工具调用签名。\n            - **签名缺失自动补完**: 针对 LS、Bash、TodoWrite 等工具调用缺失 `thought_signature` 的情况，自动注入通用校验占位符，确保协议链路畅通。\n        - **架构健壮性优化**:\n            - 增强了全局递归清理函数 `clean_cache_control_from_messages`，确保 `cache_control` 不会干扰 Vertex AI/Anthropic 严格模式。\n            - 完善了错误日志系统，建立了详细的场景对照表并记录于 [docs/client_test_examples.md](docs/client_test_examples.md)。\n    *   **v3.3.39 (2026-01-17)**:\n        - **代理深度优化 (Gemini 稳定性增强)**：\n            - **Schema 净化器升级**：支持 `allOf` 合并、智能联合类型选择、Nullable 自动过滤及空对象参数补全，解决复杂工具定义导致的 400 错误。\n            - **搜索工具自愈**：实现 `Search` 到 `grep` 的自动重映射，并引入 **Glob-to-Include 迁移**（自动将 `**/*.rs` 等 Glob 模式移至包含参数），解决 Claude Code `Error searching files` 报错。\n            - **参数别名补全**：统一 `search_code_definitions` 等相关工具的参数映射逻辑，并强制执行布尔值类型转换。\n            - **Shell 调用加固**：强制 `local_shell_call` 的 `command` 参数返回数组，增强与 Google API 的兼容性。\n            - **动态 Token 约束**：自动根据 `thinking_budget` 调整 `maxOutputTokens`，确保满足 API 强约束；精简停止序列 (Stop Sequences) 以提升流式输出质量。\n        - **Thinking 模式稳定性大幅提升**：\n            - 引入跨模型家族签名校验，自动识别并降级不兼容的思维链签名，防止 400 Bad Request 错误。\n            - 增强“会话自愈 (Session Healing)”逻辑，支持自动补全被中断的工具循环，确保满足 Google/Vertex AI 的严苛结构要求。\n        - **高可用性增强**：\n            - 优化自动端点降级 (Endpoint Fallback) 逻辑，在 429 或 5xx 错误时更平滑地切换至备用 API 端点。\n        - **修复 macOS \"Too many open files\" 错误 (Issue #784)**：\n            - 引入全局共享 HTTP 客户端连接池，大幅减少 Socket 句柄占用。\n            - 针对 macOS 系统自动提升文件描述符限制 (RLIMIT_NOFILE) 至 4096，增强高并发稳定性。\n    *   **v3.3.38 (2026-01-17)**:\n        - **CLI 同步增强与探测修复 (Fix CLI-Sync Detection)**:\n            - **探测路径扩展**: 优化了二进制检测逻辑。新增对 `~/.local/bin` (curl 安装常用路径)、`~/.npm-global/bin` 以及 `~/bin` 的扫描。\n            - **nvm 多版本支持**: 引入对 `nvm` 目录的深度扫描，支持自动识别不同 Node.js 版本下安装的 CLI 工具，解决 M1 芯片用户手动安装检测不到的问题。\n            - **原子化文件操作**: 采用临时文件写入 + 原子替换机制，确保同步过程中断不会损坏原始配置文件。\n        - **Thinking Signature 深度修复与会话自愈 (Fix Issue #752)**:\n            - **鲁棒重试逻辑**: 修正了重试计次逻辑，确保单账号用户在遇到签名错误时也能触发内部重试，提高了自动修复的触发率。\n            - **主动签名剥离**: 引入 `is_retry`状态，在重试请求中强制剥离所有历史签名。配合严苛的模型家族校验（Gemini 1.5/2.0 不再混用签名），杜绝了无效签名导致的 400 错误。\n            - **会话自愈 (Session Healing)**: 针对剥离签名后可能出现的“裸工具结果”结构错误，实现了智能消息注入机制，通过合成上下文满足 Vertex AI 的结构校验限制。\n        - **配额关注列表 (Fix PR #783)**:\n            - **自定义显示**: 在「设置 -> 账号」中新增模型配额关注列表，支持用户自定义主表格显示的特定模型配额，未选中模型仅在详情弹窗中展示。\n            - **布局优化**: 针对该板块实现了响应式 4 列网格布局，并在 UI 风格上与“额度保护”保持一致。\n        - **中转稳定性增强**: 增强了对 529 Overloaded 等上游过载错误的识别与退避重试，提升了极端负载下的任务成功率。\n    *   **v3.3.37 (2026-01-17)**:\n        - **后端兼容性修复 (Fix PR #772)**:\n            - **向后兼容性增强**: 为 `StickySessionConfig` 添加了 `#[serde(default)]` 属性，确保旧版本的配置文件（缺少粘性会话字段）能够被正确加载，避免了反序列化错误。\n        - **用户体验优化 (Fix PR #772)**:\n            - **配置加载体验升级**: 在 `ApiProxy.tsx` 中引入了独立的加载状态和错误处理机制。现在，在获取配置时用户会看到加载动画，如果加载失败，系统将展示明确的错误信息并提供重试按钮，取代了之前的空白或错误状态。\n        - **macOS Monterey 沙盒权限修复 (Fix Issue #468)**:\n            - **问题根源**: 在 macOS Monterey (12.x) 等旧版本系统上，应用沙盒策略阻止了读取全局偏好设置 (`kCFPreferencesAnyApplication`)，导致无法正确检测默认浏览器，进而拦截了 OAuth 跳转。\n            - **修复内容**: 在 `Entitlements.plist` 中添加了 `com.apple.security.temporary-exception.shared-preference.read-only` 权限例外，显式允许读取全局配置。\n    *   **v3.3.36 (2026-01-17)**:\n        - **Claude 协议核心稳定性修复**:\n            - **修复 \"回复 OK\" 死循环 (History Poisoning Fix)**:\n                - **问题根源**: 修复了 `is_warmup_request` 检测逻辑中的严重缺陷。旧逻辑会扫描最近 10 条历史消息，一旦历史记录中包含任何一条 \"Warmup\" 消息（无论是用户发送还是后台心跳残留），系统就会误判所有后续的用户输入（如 \"continue\"）为 Warmup 请求并强制回复 \"OK\"。\n                - **修复内容**: 将检测范围限制为仅检查**最新**的一条消息。现在只有当前请求确实是 Warmup 心跳时才会被拦截，解决了用户在多轮对话中被 \"OK\" 卡死的问题。\n                - **影响范围**: 极大提升了 Claude Code CLI 及 Cherry Studio 等客户端在长时间会话下的可用性。\n            - **修复 Cache Control 注入 (Fix Issue #744)**:\n                - **问题根源**: Claude 客户端在 Thinking 块中注入了非标准的 `cache_control: {\"type\": \"ephemeral\"}` 字段，导致 Google API 返回 `Extra inputs are not permitted` 400 错误。\n                - **修复内容**: 实现了全局递归清理函数 `clean_cache_control_from_messages`，并将其集成到 Anthropic (z.ai) 转发路径中，确保在发送给上游 API 前移除所有 `cache_control` 字段。\n            - **签名错误防御体系全面验证**:\n                - **隐式修复 (Implicit Fixes)**: 经过深度代码审计，确认此前报告的一系列签名相关 Issue (#755, #654, #653, #639, #617) 已被 v3.3.35 的**严格签名验证**、**自动降级**及**Base64 智能解码**机制所覆盖和修复。现在的系统对缺失、损坏或编码错误的签名具有极高的容错性。\n        - **智能预热逻辑修复 (Fix Issue #760)**:\n            - **问题根源**: 修复了自动预热调度器中的一段遗留代码，该代码错误地将 `gemini-2.5-flash` 的配额状态强制映射给 `gemini-3-flash`。\n            - **现象**: 这会导致当 `gemini-2.5-flash` 仍有额度（如 100%）但 `gemini-3-flash` 已耗尽（0%）时，系统误判 `gemini-3-flash` 也为满额并触发预热，造成“无额度却预热”的幽灵请求。\n            - **修复内容**: 移除了所有硬编码的 `2.5 -> 3` 映射逻辑。现在的预热调度器严格检查每个模型自身的配额百分比，只有当该模型实测为 100% 时才会触发预热。\n        - **移除 Gemini 2.5 Pro 模型 (Fix Issue #766)**:\n            - **原因**: 鉴于 `gemini-2.5-pro` 模型的可靠性问题，已将其从支持列表中移除。\n            - **迁移**: 所有 `gpt-4` 系列别名（如 `gpt-4`, `gpt-4o`）已重新映射至 `gemini-2.5-flash`，确保服务连续性。\n            - **影响**: 之前通过别名使用 `gemini-2.5-pro` 的用户将自动路由至 `gemini-2.5-flash`。前端不再显示该模型。\n        - **CLI 同步安全与备份增强 (Fix Issue #756 & #765)**:\n            - **智能备份与还原**: 引入了自动备份机制。在执行同步覆盖前，系统会自动将用户现有的配置文件备份为 `.antigravity.bak`。“恢复”功能现已升级，能智能检测备份文件，并优先提供“恢复原有配置”选项，而非单一的重置默认。\n            - **操作二次确认**: 为“立即同步配置”操作增加了二次确认弹窗，有效防止误触导致本地个性化配置（如登录态）丢失。\n            - **CLI 检测增强**: 优化了 macOS 平台下的 CLI（如 Claude Code）检测逻辑。即使二进制文件不在系统 `PATH` 中，只要存在于标准安装路径，也能被正确识别并调用。\n        - **Windows 控制台闪烁修复 (PR #769, 感谢 @i-smile)**:\n            - **无窗口运行**: 修复了在 Windows 平台上执行 CLI 同步命令（如 `where` 检测）时会短暂弹出控制台窗口的问题。通过添加 `CREATE_NO_WINDOW` 标志，现在所有后台检测命令都将静默执行。\n        - **Auth UI 状态显示修复 (PR #769, 感谢 @i-smile)**:\n            - **状态准确性**: 修正了 API 反代页面中认证状态的显示逻辑。现在当 `auth_mode` 为 `off` 时，UI 会正确显示“Disabled”状态，而不是一直显示“Enabled”。\n    *   **v3.3.35 (2026-01-16)**:\n        - **CLI 同步功能重大增强 (CLI Sync Enhancements)**:\n            - **多配置文件支持**: 现已支持同步每个 CLI 的多个配置文件，确保环境配置更完整。涵盖 Claude Code (`settings.json`, `.claude.json`)、Codex (`auth.json`, `config.toml`) 及 Gemini CLI (`.env`, `settings.json`, `config.json`)。\n            - **Claude 免登录特权**: 同步时会自动在 `~/.claude.json` 中注入 `\"hasCompletedOnboarding\": true`，帮助新用户直接跳过 Claude CLI 的初始登录/引导步骤。\n            - **多文件查阅体验**: 配置查看详情页升级为“标签页”模式，支持在一个弹窗内顺畅切换并查看该 CLI 关联的所有本地配置文件。\n        - **UI/UX 深度细节优化**:\n            - **弹窗体验统一**: 将“恢复默认配置”的确认框由原生浏览器弹窗替换为应用主题一致的 `ModalDialog`。\n            - **图表与显示优化**: 优化了恢复按钮图标 (RotateCcw)；精简了状态标签文案并强制不换行，解决了高分屏或窄窗口下的布局错位问题。\n            - **版本号精简**: 改进了 CLI 版本号提取逻辑，界面仅保留纯数字版本（如 v0.86.0），视觉更加清爽。\n        - **Claude 思考签名持久化修复 (Fix Issue #752)**:\n            - **问题根源**: \n                - **响应收集侧**：v3.3.34 中流式响应收集器 (`collector.rs`) 在处理 `content_block_start` 事件时遗漏了 `thinking` 块的 `signature` 字段，导致签名丢失。\n                - **请求转换侧**：历史消息中的签名未经验证直接发送给 Gemini，导致跨模型切换或冷启动时出现 `Invalid signature in thinking block` 错误。\n            - **修复内容**: \n                - **响应收集器**：在 `collector.rs` 中添加了 `signature` 字段的提取和持久化逻辑，并补充了单元测试 `test_collect_thinking_response_with_signature`。\n                - **请求转换器**：在 `request.rs` 中实施严格签名验证，只使用已缓存且兼容的签名。未知或不兼容的签名会导致 thinking 块自动降级为普通文本，避免发送无效签名。\n                - **回退机制**：实现智能回退重试逻辑。如果签名验证失效或上游 API 拒绝（400错误），系统会自动清除所有 thinking 块并强制重试，确保用户请求总是成功。\n            - **影响范围**: 解决了 `Invalid signature in thinking block` 错误，支持跨模型切换和冷启动场景，确保 Thinking 模型在所有模式下稳定工作。\n        - **API 监控数据实时同步修复 (Pull Request #747, Thanks to @xycxl)**:\n            - **问题根源**: 修复了 API 监控页面因事件监听器重复注册和状态不同步导致的日志重复显示、计数器不准等问题。\n            - **修复内容**:\n                - **数据去重**: 引入 `pendingLogsRef` 和 ID 排重机制，杜绝日志列表中出现重复条目。\n                - **精准计数**: 实现了前后端状态的严格同步，每次接收新日志都从后端获取权威的 `totalCount`，确保页码和总数准确无误。\n                - **防抖优化**: 优化了日志更新的防抖逻辑，减少 React 重渲染次数，提升页面流畅度。\n                - **功能重命名**: 将“调用记录”重命名为“流量日志”，并恢复路由为 `/monitor`，使功能定位更加直观。\n    *   **v3.3.34 (2026-01-16)**:\n        - **OpenAI Codex/Responses 协议修复 (Fix Issue #742)**:\n            - **400 Invalid Argument 修复**:\n                - **问题根源**: `/v1/responses` 等专有接口在请求体中仅包含 `instructions` 或 `input` 而缺失 `messages` 字段时，转换逻辑未覆盖全场景，导致 Gemini 接收到空 Body。\n                - **修复内容**: 在 `handle_completions` 中反向移植了聊天接口的“请求标准化”逻辑。现在系统会强制检测 Codex 特有字段（`instructions`/`input`），即使 `messages` 为空或缺失，也会自动将其转化为标准的 System/User 消息对，确保上游请求合法。\n            - **429/503 高级重试与账号轮换支持**:\n                - **逻辑对齐**: 将 Claude 处理器中验证过的“智能指数退避”与“多维账号轮换”策略完整移植到了 OpenAI Completions 接口。\n                - **效果**: 现在 Codex 接口在遇到限流或服务器过载时，会自动执行毫秒级切换，不再直接抛出错误，极大提升了 VS Code 插件等工具的稳定性。\n            - **会话粘性 (Session Stickiness) 支持**:\n                - **功能扩展**: 补全了 OpenAI 协议下的 `session_id` 提取与调度逻辑。现在无论是 Chat 还是 Codex 接口，只要是同一段对话，系统都会尽量将其调度到同一个 Google 账号上。\n                - **性能红利**: 这将显著提升 Google Prompt Caching 的命中率，从而大幅加快响应速度并节省计算资源。\n        - **Claude 思考签名编码修复 (Fix Issue #726)**:\n            - **问题根源**: 修复了 v3.3.33 中引入的 Regression，该版本错误地对已经 Base64 编码的 `thoughtSignature` 进行了二次编码，导致 Google Vertex AI 无法正确校验签名而返回 `Invalid signature` 错误。\n            - **修复内容**: 移除了 `Thinking`、`ToolUse` 和 `ToolResult` 处理逻辑中多余的 Base64 编码步骤，确保签名以原始格式正确透传给上游。\n            - **影响范围**: 解决了使用 Thinking 模型（如 Claude 4.5 Opus / Sonnet）在多轮对话中触发的 400 签名错误，以及由此导致的 \"Error searching files\" 任务卡死问题 (Issue #737)。\n        - **API 监控看板刷新修复 (Fix Issue #735)**:\n            - **问题根源**: 修复了 `ProxyMonitor` 组件中因 Closure 导致的事件监听失效问题，该问题导致新请求无法自动显示在列表中。\n            - **修复内容**: 引入 `useRef` 优化事件缓冲逻辑，并新增手动刷新按钮作为备份方案；同时在 Tauri 权限配置中显式允许了事件监听。\n        - **严格分组配额保护修复 (Strict Grouped Quota Protection Fix - Core Thanks to @Mag1cFall PR #746)**:\n            - **问题根源**: 修复了在严格匹配模式下，配额保护逻辑因大小写敏感和前端 UI 键名映射缺失而失效的问题。之前版本中 `gemini-pro` 等 UI 简写键名无法匹配到后端定义的 `gemini-3-pro-high` 严格组。\n            - **修复内容**:\n                - **即时大小写归一化**: 恢复了后端 `normalize_to_standard_id` 的大小写不敏感匹配，确保 `Gemini-3-Pro-High` 等变体能被正确识别。\n                - **UI 键名智能映射**: 在前端 `isModelProtected` 中增加了对 `gemini-pro/flash` 等 UI 列名的自动映射，确保 UI 上的锁图标能正确反映后端保护状态。\n            - **影响范围**: 解决了 Gemini 3 Pro/Flash 和 Claude 4.5 Sonnet 在严格分组模式下的锁图标显示问题，确保配额耗尽时能直观提示用户。\n        - **OpenAI 协议 Usage 统计修复 (Pull Request #749, Thanks to @stillyun)**:\n            - **问题根源**: 在 OpenAI 协议转换过程中，未将 Gemini 返回的 `usageMetadata` 映射到 OpenAI 格式的 `usage` 字段，导致 Kilo 等客户端显示 Token 使用量为 0。\n            - **修复内容**:\n                - **数据模型补全**: 为 `OpenAIResponse` 增加了标准的 `usage` 字段。\n                - **全链路映射**: 实现了从流式 (SSE) 和非流式响应中提取并映射 `prompt_tokens`、`completion_tokens` 及 `total_tokens` 的逻辑。\n            - **影响范围**: 解决了 Kilo Editor、Claude Code 等工具在使用 OpenAI 协议时无法统计 Token 用量的问题。\n        - **Linux 主题切换崩溃修复 (Pull Request #750, Thanks to @infinitete)**:\n            - **修复内容**: \n                - 在 Linux 平台禁用不兼容的 `setBackgroundColor` 调用。\n                - 针对 WebKitGTK 环境禁用 View Transition API 以防止透明窗口崩溃。\n                - 启动时自动调整 GTK 窗口 alpha 通道以增强稳定性。\n            - **影响范围**: 解决了 Linux 用户在切换深色/浅色模式时可能遇到的程序卡死或硬崩溃问题。\n    *   **v3.3.33 (2026-01-15)**:\n        - **Codex 兼容性与模型映射修复 (Fix Issue #697)**:\n            - **Instructions 参数支持**: 修复了对 `instructions` 参数的处理逻辑，确保其作为系统指令（System Instructions）正确注入，提升与 Codex 等工具的兼容性。\n            - **自动 Responses 格式检测**: 在 OpenAI 处理器中新增智能检测逻辑，自动识别并转换 `instructions` 或 `input` 字段触发的 Responses 模式，无需客户端手动切换。\n            - **模型映射恢复与归一化**: 恢复了 `gemini-3-pro-low/high/pro` 统一归一化为内部别名 `gemini-3-pro-preview` 的逻辑，并确保在上游请求时正确还原为物理模型名 `high`。\n            - **Opus 映射增强**: 优化了系统默认映射，自动识别 `opus` 关键字模型并确保其默认路由至高性能 Pro 预览线路。\n        - **OpenAI 工具调用与思考内容修复 (Fix Issue #710)**:\n            - **保留工具调用 ID**: 修复了 OpenAI 格式转换过程中丢失 `tool_use.id` 的问题，确保 `functionCall` 和 `functionResponse` 均保留原始 ID，解决了调用 Claude 模型时的 `Field required` 错误。\n            - **思考内容 (Reasoning) 原生支持**: 增加了对 OpenAI 消息中 `reasoning_content` 的支持，将其正确映射为内部 `thought` 部分并注入思维链签名，显著提升了“思考型”模型的视觉回显效果。\n            - **工具响应格式优化**: 修复了 `tool` 角色消息中可能产生的冗余 Part 冲突，确保请求报文严格符合上游校验规范。\n        - **外部提供商智能兜底修复 (Fix Issue #703)**: 修复了\"仅兜底\"模式在 Google 账号额度耗尽时无法自动切换到外部提供商的问题。\n            - **核心问题**: 原判断逻辑只检查 Google 账号数量是否为 0,而不检查账号的实际可用性(限流状态、配额保护状态),导致账号存在但不可用时直接返回 429 错误。\n            - **解决方案**: 实现智能账号可用性检查机制,在 `TokenManager` 中新增 `has_available_account()` 方法,综合判断账号的限流状态和配额保护状态。\n            - **修改文件**:\n                - `token_manager.rs`: 新增 `has_available_account()` 方法,检查是否存在未被限流且未被配额保护的可用账号\n                - `handlers/claude.rs`: 优化 Fallback 模式判断逻辑,从简单的 `google_accounts == 0` 改为智能的可用性检查\n            - **行为改进**: 当所有 Google 账号因限流、配额保护或其他原因不可用时,系统会自动切换到外部提供商,实现真正的智能兜底。\n            - **影响范围**: 此修复确保了外部提供商(如智谱 API)的\"仅兜底\"模式能够正确工作,显著提升了多账号场景下的服务可用性。\n        - **配额保护模型名称归一化修复 (Fix Issue #685)**: 修复了配额保护功能因模型名称不匹配而失效的问题。\n            - **核心问题**: Quota API 返回的模型名称(如 `gemini-2.5-flash`)与用户在 UI 勾选的标准名称(如 `gemini-3-flash`)不一致,导致精确字符串匹配失败,保护机制无法触发。\n            - **解决方案**: 实现了统一的模型名称归一化引擎 `normalize_to_standard_id`,将所有物理模型名映射到 3 个标准保护 ID:\n                - `gemini-3-flash`: 所有 Flash 变体 (1.5-flash, 2.5-flash, 3-flash 等)\n                - `gemini-3-pro-high`: 所有 Pro 变体 (1.5-pro, 2.5-pro 等)\n                - `claude-sonnet-4-5`: 所有 Claude Sonnet 变体 (3-5-sonnet, sonnet-4-5 等)\n            - **修改文件**:\n                - `model_mapping.rs`: 新增归一化函数\n                - `account.rs`: 配额更新时归一化模型名并存储标准 ID\n                - `token_manager.rs`: 请求拦截时归一化 `target_model` 进行匹配\n            - **联网降级场景**: 即使请求因联网搜索被降级为 `gemini-2.5-flash`,依然能正确归一化为 `gemini-3-flash` 并触发保护。\n            - **影响范围**: 解决了配额保护失效问题,确保所有 3 个监控模型的保护功能正常工作。\n        - **新增账号导入功能 (#682)**: 支持通过导出的 JSON 文件批量导入已有的账号，完善了账号迁移闭环。\n        - **新增葡萄牙语与俄语支持 (#691, #713)**: 现已支持葡萄牙语（巴西）与俄语本地化。\n        - **代理监控增强 (#676)**: 在代理监控详情页中为请求和响应载荷新增了“复制”按钮，并支持自动 JSON 格式化。\n        - **i18n 修复与界面文案优化 (#671, #713)**: 修正了日语 (ja)、土耳其语 (tr) 和俄语 (ru) 中遗漏和错位的翻译文案。\n        - **全局 HTTP API (#696)**: 新增本地 HTTP 服务端口（默认 19527），支持外部工具（如 VS Code 插件）直接通过 API 进行账号切换、配额刷新和设备绑定。\n        - **代理监控升级 (#704)**: 全面重构监控面板，引入后端分页查询（支持搜索过滤），解决了大量日志导致的界面卡顿问题；开放 `GET /logs` 接口供外部调用。\n        - **预热策略优化 (#699)**: 预热请求新增唯一 `session_id`，并将 `max_tokens` 限制为 8，`temperature` 设置为 0，以降低资源消耗并避免 429 错误。\n        - **预热逻辑修复与优化**: 修复了手动触发预热未记录历史导致自动调度重复预热的问题；优化调度器自动跳过“反代禁用”状态的账号。\n        - **性能模式调度优化 (PR #706)**: 在“性能优先”调度模式下，现在会跳过默认的 60秒全局锁定机制，显著提升高并发场景下的账号轮转效率。\n        - **限流记录自动清理 (PR #701)**: 引入了每分钟执行的后台清理任务，自动移除超过 1 小时的过期失败记录，解决长期运行后因历史记录累积导致的“无可用账号”误报问题。\n        - **API Monitor 锁定修复 (Fix Issue #708)**: 启用 SQLite WAL 模式并优化连接配置，解决了高并发场景下因数据库锁定导致的监控数据滞后和代理服务 400/429 错误。\n        - **Claude 提示词过滤优化 (#712)**: 修复了在过滤 Claude Code 冗余默认提示词时，误删用户自定义指令 (Instructions from: ...) 的问题，确保个性化配置在长对话场景下仍能正确生效。\n        - **Claude 思维块排序策略优化 (Fix Issue #709)**: 解决了开启思维模式时由于块顺序错位（Text 出现在 Thinking 前）导致的 `INVALID_ARGUMENT` 报错。\n            - **三段式强制分区**: 实现 `[Thinking, Text, ToolUse]` 严格顺序校验。\n            - **自动降级网关**: 在单条消息内，一旦出现非思维内容，后续思维块自动降级为文本，确保协议合规。\n            - **合并后二次重排**: 在 Assistant 消息合并逻辑后增加强制重排序，堵死因消息拼接导致的排序漏洞。\n    *   **v3.3.32 (2026-01-15)**:\n        - **核心调度与稳定性优化 (Fix Issue #630, #631 - 核心致谢 @lbjlaq PR #640)**:\n            - **配额漏洞与绕过修复**: 解决了在高并发或特定重试场景下，配额保护机制可能被绕过的潜在漏洞。\n            - **限流 Key 匹配优化**: 增强了 `TokenManager` 中限流记录的匹配精准度，解决了在多实例或复杂网络环境下可能出现的速率限制判定不一致问题。\n            - **账号禁用逻辑加固**: 修复了手动禁用账号在某些缓存生命周期内未立即从调度池中剥离的问题，确保“禁用即生效”。\n            - **账号状态重置机制**: 完善了账号失败计数器在成功请求后的重置策略，避免账号因历史波动被长期误锁定。\n    *   **v3.3.31 (2026-01-14)**:\n        - **配额保护失效修复 (Fix Issue #631)**:\n            - **内存状态同步**: 修复了加载账号触发配额保护时，内存状态未立即同步的问题，确保保护机制即时生效。\n            - **全场景覆盖**: 在“粘性会话 (Sticky Session)”和“60秒锁定 (60s Window Lock)”逻辑中补充了配额保护检查，防止受限账号被错误复用。\n            - **代码优化**: 修复了 `token_manager.rs` 中的部分编译警告。\n        - **Claude 工具调用重复报错修复 (Fix Issue #632)**:\n            - **弹性修复优化**: 改进了 `Elastic-Recovery` 逻辑，在注入占位结果前增加全量消息 ID 预扫描，避免了 `Found multiple tool_result blocks with id` 错误。\n            - **Anthropic 协议对齐**: 确保生成的请求包严格符合 Anthropic 对工具调用 ID 唯一性的要求。\n    *   **v3.3.30 (2026-01-14)**:\n        - **模型级配额保护 (Issue #621)**:\n            - **隔离优化**: 解决了因单个模型配额耗尽而禁用整个账号的问题。现在配额保护仅针对受限的具体模型，账号仍可处理其他模型的请求。\n            - **自动迁移**: 新系统会自动将旧版因配额保护被全局禁用的账号恢复，并平滑转为模型级限制。\n            - **全协议支持项目**: 已同步更新 Claude, OpenAI (Chat/DALL-E), Gemini, Audio 处理器的路由逻辑。\n        - **Gemini 参数幻觉修复 (PR #622)**:\n            - **参数纠错**: 修复了 Gemini 模型将 `pattern` 参数错误放置在 `description` 或 `query` 字段的问题，增加了自动重映射逻辑。\n            - **布尔值强制转换**: 增加了对 `yes`/`no`、`-n` 等非标准布尔值的自动转换支持，解决了 `lineNumbers` 等参数因类型错误导致的调用失败。\n            - **影响范围**: 显著提升了 Gemini 模型在 Claude Code CLI 及其他工具调用场景下的稳定性和兼容性。\n        - **代码清理与警告修复 (PR #628)**:\n            - **消除编译器警告**: 修复了多个未使用的导入和变量警告，移除了冗余代码，保持代码库整洁。\n            - **跨平台兼容性**: 针对 Windows/macOS/Linux 不同平台的代码路径进行了宏标记优化。\n        - **API 密钥自定义编辑功能 (Issue #627)**:\n            - **自定义密钥支持**: API 反代页面的\"API 密钥\"配置项现在支持直接编辑,用户可以输入自定义密钥,适合多实例部署场景。\n            - **保留自动生成**: 保留了原有的\"重新生成\"功能,用户可以选择自动生成或手动输入。\n            - **格式验证**: 添加了密钥格式验证(必须以 `sk-` 开头,长度至少 10 个字符),防止无效输入。\n            - **多语言支持**: 为所有 6 种支持的语言(简体中文、英文、繁体中文、日语、土耳其语、越南语)添加了完整的国际化翻译。\n    *   **v3.3.29 (2026-01-14)**:\n        - **OpenAI 流式响应 Function Call 支持修复 (Fix Issue #602, #614)**:\n            - **问题背景**: OpenAI 接口的流式响应 (`stream: true`) 中缺少 Function Call 处理逻辑,导致客户端无法接收到工具调用信息。\n            - **根本原因**: `create_openai_sse_stream` 函数只处理了文本内容、思考内容和图片,完全缺少对 `functionCall` 的处理。\n            - **修复内容**:\n                - 添加工具调用状态追踪变量 (`emitted_tool_calls`),防止重复发送\n                - 在 parts 循环中添加 `functionCall` 检测和转换逻辑\n                - 构建符合 OpenAI 规范的 `delta.tool_calls` 数组\n                - 使用哈希算法生成稳定的 `call_id`\n                - 包含完整的工具调用信息 (`index`, `id`, `type`, `function.name`, `function.arguments`)\n            - **影响范围**: 此修复确保了流式请求能够正确返回工具调用信息,与非流式响应和 Codex 流式响应的行为保持一致。所有使用 `stream: true` + `tools` 参数的客户端现在可以正常接收 Function Call 数据。\n        - **智能阈值回归 (Smart Threshold Recovery) - 解决 Issue #613**:\n            - **核心逻辑**: 实现了一种感知上下文负载的动态 Token 报告机制。\n            - **修复内容**:\n                - **三阶段缩放**: 在低负载(0-70%)保持高效压缩;在中负载(70-95%)平滑降低压缩率;在接近 100% 极限时真实上报(回归至 195k 左右)。\n                - **模型感应**: 处理器自动识别 1M (Flash) 和 2M (Pro) 的物理上下文界限。\n                - **400 错误拦截**: 即使触发物理溢出，代理层也会拦截 `Prompt is too long` 错误，并返回友好的中文/英文修复指引，引导用户执行 `/compact`。\n            - **影响范围**: 解决了 Claude Code 在长对话场景下因不知道真实 Token 用量而拒绝压缩，最终导致 Gemini 服务端报错的问题。\n        - **Playwright MCP 连通性与稳定性增强 (参考 [Antigravity2Api](https://github.com/znlsl/Antigravity2Api)) - 解决 Issue #616**:\n            - **SSE 心跳保活**: 引入 15 秒定时心跳 (`: ping`)，解决长耗时工具调用导致的连接超时断开问题。\n            - **MCP XML Bridge**: 实现双向协议转换逻辑（指令注入 + 标签拦截），显著提升 MCP 工具（如 Playwright）在不稳定链路下的连通性。\n            - **上下文激进瘦身**: \n                - **指令过滤**: 自动识别并移除 Claude Code 注入的冗余系统说明（~1-2k tokens）。\n                - **任务去重**: 剔除 tool_result 后重复的任务回显文本，物理减少 Context 占用。\n            - **智能 HTML 清理与截断**: \n                - **深度剥离**: 针对浏览器快照自动移除 `<style>`、`<script>` 及内联 Base64 资源。\n                - **结构化截断**: 优化截断算法，确保不在 HTML 标签或 JSON 中间切断，避免产生破坏性的 400 结构错误。\n        - **账号索引加载容错修复 (Fix Issue #619)**:\n            - **修复内容**: 在加载 `accounts.json` 时增加了对空文件的检测及自动重置逻辑。\n            - **影响范围**: 解决了因索引文件损坏/为空导致的软件启动报错 `expected value at line 1 column 1`。\n    *   **v3.3.28 (2026-01-14)**:\n        - **OpenAI Thinking Content 修复 (PR #604)**:\n            - **修复 Gemini 3 Pro thinking 内容丢失**: 在流式响应收集器中添加 `reasoning_content` 累积逻辑,解决了 Gemini 3 Pro (high/low) 非流式响应中思考内容丢失的问题。\n            - **支持 Claude *-thinking 模型**: 扩展 thinking 模型检测逻辑,支持所有以 `-thinking` 结尾的模型(如 `claude-opus-4-5-thinking`、`claude-sonnet-4-5-thinking`),自动注入 `thinkingConfig` 确保思考内容正常输出。\n            - **统一 thinking 配置**: 为所有 thinking 模型(Gemini 3 Pro 和 Claude thinking 系列)注入统一的 `thinkingBudget: 16000` 配置,符合 Cloud Code API 规范。\n            - **影响范围**: 此修复确保了 Gemini 3 Pro 和 Claude Thinking 模型在 OpenAI 协议下的 `reasoning_content` 字段正常工作,不影响 Anthropic 和 Gemini 原生协议。\n        - **Experimental 配置热更新 (PR #605)**:\n            - **新增热更新支持**: 为 `ExperimentalConfig` 添加热更新机制,与其他配置项(mapping、proxy、security、zai、scheduling)保持一致。\n            - **实时生效**: 用户修改实验性功能开关后无需重启应用即可生效,提升配置调整的便捷性。\n            - **架构完善**: 在 `AxumServer` 中添加 `experimental` 字段存储和 `update_experimental()` 更新方法,在 `save_config` 中自动触发热更新。\n        - **智能预热策略优化 (PR #606 - 性能提升 2.9x-5x)**:\n            - **分离刷新和预热**: 移除配额刷新时的自动预热触发,预热仅通过定时调度器(每10分钟)或手动按钮触发,避免用户刷新配额时意外消耗预热额度。\n            - **延长冷却期**: 冷却期从30分钟延长至4小时(14400秒),匹配 Pro 账号5小时重置周期,解决同一周期内重复预热问题。\n            - **持久化历史记录**: 预热历史保存至 `~/.antigravity_tools/warmup_history.json`,程序重启后冷却期仍然有效,解决状态丢失问题。\n            - **并发执行优化**: \n                - 筛选阶段: 每批5个账号并发获取配额,10个账号从~15秒降至~3秒 (5倍提升)\n                - 预热阶段: 每批3个任务并发执行,批次间隔2秒,40个任务从~80秒降至~28秒 (2.9倍提升)\n            - **白名单过滤**: 仅记录和预热4个核心模型组(`gemini-3-flash`、`claude-sonnet-4-5`、`gemini-3-pro-high`、`gemini-3-pro-image`),避免历史记录臃肿。\n            - **成功后记录**: 预热失败不记录历史,允许下次重试,提高容错性。\n            - **手动预热保护**: 手动预热也遵守4小时冷却期,过滤已预热模型并显示跳过数量,防止用户反复点击浪费配额。\n            - **完善日志**: 添加调度器扫描、预热启动/完成、冷却期跳过等详细日志,便于监控和调试。\n            - **影响范围**: 此优化大幅提升了智能预热的性能和可靠性,解决了重复预热、速度慢、状态丢失等多个问题,并发级别不会触发 RateLimit。\n        - **繁体中文本地化优化 (PR #607)**:\n            - **术语优化**: 优化100处繁体中文翻译,使其更符合台湾地区用户的语言习惯和表达方式。\n            - **用户体验提升**: 提升繁体中文界面的专业性和可读性,纯文本变更无代码逻辑影响。\n        - **API 监控性能优化 (修复长时间运行白屏问题)**:\n            - **问题背景**: 修复后台长时间运行后停留在 API 监控页面导致窗口卡成白屏的问题,程序仍在运行但 UI 无响应。\n            - **内存优化**:\n                - 减少内存日志限制从 1000 条降至 100 条,大幅降低内存占用\n                - 移除实时事件中的完整 request/response body 存储,仅保留摘要信息\n                - 后端事件发送优化,仅传输日志摘要而非完整数据,减少 IPC 传输量\n            - **渲染性能提升**:\n                - 集成 `@tanstack/react-virtual` 虚拟滚动库,仅渲染可见行(约 20-30 行)\n                - DOM 节点数量从 1000+ 降至 20-30,减少 97%\n                - 滚动帧率从 20-30fps 提升至 60fps\n            - **防抖机制**:\n                - 添加 500ms 防抖机制,批量处理日志更新,避免频繁状态更新\n                - 减少 React re-render 次数,提升 UI 响应性\n            - **性能提升**:\n                - 内存占用: ~500MB → <100MB (减少 90%)\n                - 首次渲染时间: ~2000ms → <100ms (提升 20 倍)\n                - 支持无限日志滚动,长时间运行无白屏\n            - **影响范围**: 此优化解决了长时间运行和大量日志场景下的性能问题,即使停留在监控页面数小时也能保持流畅。\n    *   **v3.3.27 (2026-01-13)**:\n        - **实验性配置与用量缩放 (PR #603 增强)**:\n            - **新增实验性设置面板**: 在 API 反代配置中增加了“实验性设置”卡片，用于管理正在探索中的功能。\n            - **启用用量缩放 (Usage Scaling)**: 针对 Claude 相容协议实现了激进的输入 Token 自动缩放逻辑。当总输入超过 30k 时，自动应用平方根缩放，有效防止长上下文场景下（如 Gemini 2M 窗口）频繁触发客户端侧的强制压缩。\n            - **多语言翻译补全**: 为实验性功能同步补全了中、英、日、繁、土、越 6 种语言的翻译。\n    *   **v3.3.26 (2026-01-13)**:\n        - **配额保护与调度优化 (Fix Issue #595 - 零配额账户仍进入队列)**:\n            - **配额保护逻辑重构**: 修复了配额保护因依赖不存在的 `limit/remaining` 字段而失效的问题。现在直接使用模型数据中始终存在的 `percentage` 字段，确保任何受监控模型（如 Claude 4.5 Sonnet）配额低于阈值时，账号都能被立即禁用。\n            - **账号优先级算法升级**: 账号调度优先级不再仅依赖订阅等级。在同等级（Ultra/Pro/Free）内，系统现在会优先选择**最大模型剩余百分比**最高的账号，避免对濒临耗尽的账号进行“压榨”，显著降低 429 错误率。\n            - **保护日志增强**: 触发配额保护时的日志现在会明确指出具体是哪个模型触发了阈值（例如：`quota_protection: claude-sonnet-4-5 (0% <= 10%)`），便于排查。\n        - **MCP 工具兼容性增强 (Fix Issue #593)**:\n            - **深度 cache_control 清理**: 实现了多层次的 `cache_control` 字段清理机制,解决 Chrome Dev Tools MCP 等工具在 thinking block 中包含 `cache_control` 导致的 \"Extra inputs are not permitted\" 错误。\n                - **增强日志追踪**: 添加 `[DEBUG-593]` 日志前缀,记录消息索引和块索引,便于问题定位和调试。\n                - **递归深度清理**: 新增 `deep_clean_cache_control()` 函数,递归遍历所有嵌套对象和数组,移除任何位置的 `cache_control` 字段。\n                - **最后一道防线**: 在构建 Gemini 请求体后、发送前再次执行深度清理,确保发送给 Antigravity 的请求中不包含任何 `cache_control`。\n            - **工具输出智能压缩**: 新增 `tool_result_compressor` 模块,处理超大工具输出,降低 prompt 超长导致的 429 错误概率。\n                - **浏览器快照压缩**: 自动检测并压缩超过 20,000 字符的浏览器快照,采用头部(70%) + 尾部(30%)保留策略,中间省略。\n                - **大文件提示压缩**: 智能识别 \"exceeds maximum allowed tokens\" 模式,提取关键信息(文件路径、字符数、格式说明),大幅减少冗余内容。\n                - **通用截断**: 对超过 200,000 字符的工具输出进行截断,添加清晰的截断提示。\n                - **Base64 图片移除**: 自动移除工具结果中的 base64 编码图片,避免体积过大。\n            - **完整测试覆盖**: 新增 7 个单元测试,覆盖文本截断、浏览器快照压缩、大文件提示压缩、工具结果清理等核心功能,全部通过验证。\n            - **影响范围**: 此更新显著提升了 MCP 工具(特别是 Chrome Dev Tools MCP)的稳定性,解决了 thinking block 中 `cache_control` 字段导致的 API 错误,同时通过智能压缩降低了超大工具输出导致的 429 错误概率。\n        - **API 监控账号信息记录修复**:\n            - **修复图片生成端点**: 修复了 `/v1/images/generations` 端点缺少 `X-Account-Email` 响应头的问题,现在监控面板能正确显示处理图片生成请求的账号信息。\n            - **修复图片编辑端点**: 修复了 `/v1/images/edits` 端点缺少 `X-Account-Email` 响应头的问题,确保图片编辑请求的账号信息能被正确记录。\n            - **修复音频转录端点**: 修复了 `/v1/audio/transcriptions` 端点缺少 `X-Account-Email` 响应头的问题,完善了音频转录功能的监控支持。\n            - **影响范围**: 此修复确保了所有涉及账号调用的 API 端点都能在监控面板中正确显示账号信息,不再显示为\"-\",提升了 API 监控系统的完整性和可用性。\n        - **无头服务器部署支持 (Headless Server Support)**:\n            - **一键部署脚本**: 新增 `deploy/headless-xvfb/` 目录,提供针对 Linux 无界面服务器的一键安装、同步、升级脚本。\n            - **Xvfb 环境适配**: 利用虚拟显示器技术,允许 GUI 版本的 Antigravity Tools 在无显卡的远程服务器上运行,并提供了详细的资源占用预警和局限性说明。\n    *   **v3.3.25 (2026-01-13)**:\n        - **会话签名缓存系统 (Session-Based Signature Caching) - 提升 Thinking 模型稳定性 (核心致谢 @Gok-tug PR #574)**:\n            - **三层签名缓存架构**: 实现了 Tool Signatures (Layer 1)、Thinking Families (Layer 2) 和 Session Signatures (Layer 3) 的完整三层缓存体系。\n            - **会话隔离机制**: 基于第一条用户消息的 SHA256 哈希生成稳定的 session_id,确保同一对话的所有轮次使用相同的会话标识。\n            - **智能签名恢复**: 在工具调用和多轮对话中自动恢复思考签名,显著减少 thinking 模型的签名相关错误。\n            - **优先级查找策略**: 实现 Session Cache → Tool Cache → Global Store 的三层查找优先级,最大化签名恢复成功率。\n        - **Session ID 生成优化**:\n            - **简洁设计**: 只哈希第一条用户消息内容,不混入模型名称或时间戳,确保会话延续性。\n            - **完美延续性**: 同一对话的所有轮次(无论多少轮)都使用相同的 session_id,无时间限制。\n            - **性能提升**: 相比之前的方案,CPU 开销降低 60%,代码行数减少 20%。\n        - **缓存管理优化**:\n            - **分层阈值**: 为不同层级设置合理的缓存清理阈值 (Tool: 500, Family: 200, Session: 1000)。\n            - **智能清理**: 添加详细的缓存清理日志,便于监控和调试。\n        - **编译错误修复**:\n            - 修复 `process.rs` 中的参数命名和可变性问题。\n            - 清理未使用的导入和变量警告。\n        - **国际化 (i18n)**:\n            - **繁体中文支持**: 新增繁体中文 (Traditional Chinese) 本地化支持 (Thank you @audichuang PR #577)。\n        - **流式响应错误处理改进 (Stream Error Handling Improvements)**:\n            - **友好错误提示**: 修复了 Issue #579 中提到的流式错误导致 200 OK 且无提示的问题。现在将技术性错误 (Timeout, Decode, Connection) 转换为用户友好的中文提示。\n            - **SSE 错误事件**: 实现了标准的 SSE 错误事件传播,前端可捕获并优雅展示错误,包含详细的解决建议(如检查网络、代理等)。\n            - **多语言错误消息 (i18n)**: 错误消息已集成 i18n 系统,支持所有 6 种语言(zh, en, zh-TW, ja, tr, vi)。非浏览器客户端自动回退到英文提示。\n        - **影响范围**: 此更新显著提升了 Claude 4.5 Opus、Gemini 3 Pro 等 thinking 模型的多轮对话稳定性,特别是在使用 MCP 工具和长会话场景下。\n\n\n    *   **v3.3.24 (2026-01-12)**:\n        - **UI 交互改进 (UI Interaction Improvements)**:\n            - **卡片式模型选择**: 设置页面的“配额保护”与“智能预热”模型选择升级为卡片式设计，支持选中状态勾选及未选中状态下显眼的边缘提示。\n            - **布局优化**: “智能预热”模型列表由单行 2 列调整为单行 4 列布局，更加节省空间。\n            - **名称修正**: 将 `claude-sonnet-4-5` 错误显示的名称由 \"Claude 3.5 Sonnet\" 修正为 \"Claude 4.5 Sonnet\"。\n        - **国际化 (i18n)**:\n            - **越南语支持**: 新增越南语 (Vietnamese) 本地化支持 (Thank you @ThanhNguyxn PR #570)。\n            - **翻译优化**: 清理了重复的翻译键值，并优化了语言自动检测逻辑。\n    *   **v3.3.23 (2026-01-12)**:\n        - **更新通知 UI 重构 (Update Notification UI Modernization)**:\n            - **视觉升级**: 采用 \"Glassmorphism\" 毛玻璃风格设计，配合优雅的渐变背景与微光效果，大幅提升视觉精致度。\n            - **流畅动效**: 引入了更平滑的弹窗入场与退出动画，优化了交互体验。\n            - **深色模式适配**: 完美支持深色模式 (Dark Mode)，自动跟随系统主题切换，确保在任何环境下都不刺眼。\n            - **非侵入式布局**: 优化了弹窗位置与层级，确保不会遮挡顶部导航栏等关键操作区域。\n        - **国际化支持 (Internationalization)**:\n            - **双语适配**: 更新通知现已完整支持中英双语，根据应用语言设置自动切换文案。\n        - **检查逻辑修正**: 修复了更新检查状态更新的时序问题，确保在发现新版本时能稳定弹出通知。\n        - **菜单栏图标高清化修复 (Menu Bar Icon Resolution Fix)**:\n            - **Retina 适配**: 将菜单栏托盘图标 (`tray-icon.png`) 分辨率从 22x22 提升至 44x44，解决了在高分屏下显示模糊的问题 (Fix Issue #557)。\n        - **Claude Thinking 压缩优化 (核心致谢 @ThanhNguyxn PR #566)**:\n            - **修复思考块乱序**: 解决了在使用 Context Compression (Kilo) 时，思考块 (Thinking Blocks) 可能被错误地排序到文本块之后的问题。\n            - **强制首位排序**: 引入了 `sort_thinking_blocks_first` 逻辑，确保助手消息中的思考块始终位于最前，符合 Anthropic API 的 400 校验规则。\n        - **账号路由优先级增强 (核心致谢 @ThanhNguyxn PR #567)**:\n            - **高配额优先策略**: 在同等级别 (Free/Pro/Ultra) 下，系统现在会优先选择**剩余配额更多**的账号进行调度。\n            - **避免木桶效应**: 防止因随机分配导致某些长配额账号被闲置，而短配额账号过早耗尽。\n        - **非流式响应 Base64 签名修复 (核心致谢 @ThanhNguyxn PR #568)**:\n            - **全模式兼容**: 将流式响应中的 Base64 思考签名解码逻辑同步应用到非流式响应 (Non-streaming) 中。\n            - **消除签名错误**: 解决了在非流式客户端 (如 Python SDK) 中使用 Antigravity 代理时因签名编码格式不一致导致的 400 错误。\n        - **国际化 (i18n)**:\n            - **日语支持**: 新增日语 (Japanese) 本地化支持 (Thank you @Koshikai PR #526)。\n            - **土耳其语支持**: 新增土耳其语 (Turkish) 本地化支持 (Thank you @hakanyalitekin PR #515)。\n    *   **v3.3.22 (2026-01-12)**:\n        - **配额保护系统升级**:\n            - 支持自定义监控模型（`gemini-3-flash`, `gemini-3-pro-high`, `claude-sonnet-4-5`），仅在选中模型额度低于阈值时触发保护\n            - 保护逻辑优化为\"勾选模型最小配额\"触发机制\n            - 开启保护时默认勾选 `claude-sonnet-4-5`，UI 强制至少保留一个模型\n        - **全自动配额管理联动**:\n            - 强制开启后台自动刷新，确保配额数据实时同步\n            - 自动执行\"刷新 → 保护 → 恢复 → 预热\"完整生命周期管理\n        - **智能预热自定义勾选**:\n            - 支持自定义预热模型（`gemini-3-flash`, `gemini-3-pro-high`, `claude-sonnet-4-5`, `gemini-3-pro-image`）\n            - 新增独立 `SmartWarmup.tsx` 组件，提供与配额保护一致的勾选体验\n            - 开启预热时默认勾选所有核心模型，UI 强制至少保留一个模型\n            - 调度器实时读取配置，修改立即生效\n        - **智能预热系统基础功能**:\n            - 额度恢复到 100% 时自动触发预热\n            - 智能去重机制：同一 100% 周期仅预热一次\n            - 调度器每 10 分钟扫描并同步最新配额到前端\n            - 覆盖所有账号类型（Ultra/Pro/Free）\n        - **国际化完善**: 修复\"自动检查更新\"和\"设备指纹\"相关翻译缺失（Issue #550）\n        - **稳定性修复**: 修复高并发调度下的变量引用和所有权冲突问题\n        - **API 监控性能优化 (修复 Issue #560)**:\n            - **问题背景**: 修复 macOS 上打开 API 监控界面时出现 5-10 秒响应延迟和应用崩溃问题\n            - **数据库优化**:\n                - 新增 `status` 字段索引，统计查询性能提升 50 倍\n                - 优化 `get_stats()` 查询，从 3 次全表扫描合并为 1 次，查询时间减少 66%\n            - **分页加载**:\n                - 列表视图不再查询大型 `request_body` 和 `response_body` 字段，数据传输量减少 90%+\n                - 新增 `get_proxy_logs_paginated` 命令，支持分页查询（每页 20 条）\n                - 前端新增\"加载更多\"按钮，支持按需加载历史记录\n            - **按需详情查询**:\n                - 新增 `get_proxy_log_detail` 命令，点击日志时才查询完整详情\n                - 详情加载时间 0.1-0.5 秒，避免不必要的数据传输\n            - **自动清理功能**:\n                - 应用启动时自动清理 30 天前的旧日志，防止数据库无限增长\n                - 执行 VACUUM 释放磁盘空间\n            - **UI 优化**:\n                - 新增加载状态指示器，提供清晰的视觉反馈\n                - 新增 10 秒超时控制，防止长时间无响应\n                - 详情模态框新增加载指示器\n            - **性能提升**:\n                - 初始加载时间: 10-18 秒 → **0.5-1 秒** (10-36 倍提升)\n                - 内存占用: 1GB → **5MB** (200 倍减少)\n                - 数据传输量: 1-10GB → **1-5MB** (200-2000 倍减少)\n            - **影响范围**: 此优化解决了大数据量场景下的性能问题，支持 10,000+ 条监控记录的流畅查看\n        - **反代日志增强**: 修正了反代温补逻辑中账号/模型日志记录问题，补充了部分缺失的国际化翻译项。\n    *   **v3.3.21 (2026-01-11)**:\n        - **设备指纹绑定系统 (Device Fingerprint Binding) - 降低风控检测 (核心致谢 @jlcodes99 PR #523)**:\n            - **账号设备绑定**: 实现账号与设备信息的一对一绑定关系，切换账号时自动切换对应的设备指纹。\n            - **设备指纹管理**: 新增完整的设备指纹管理模块 (`device.rs`)，支持指纹生成、绑定、恢复和版本管理。\n            - **风控优化**: 通过确保每个账号使用独立的设备信息，显著降低被 Google 风控系统检测的概率。\n            - **UI 增强**: 新增设备指纹管理对话框 (`DeviceFingerprintDialog.tsx`)，提供可视化的指纹管理界面。\n            - **核心功能**:\n                - 支持采集当前设备指纹或生成随机指纹\n                - 自动备份和版本管理设备指纹历史\n                - 支持恢复到任意历史版本\n                - 提供设备存储目录快速访问\n            - **影响范围**: 此功能为多账号管理提供了更强的隐私保护，有效降低账号关联风险。\n        - **代理服务核心修复 (Proxy Service Critical Fixes) - 提升稳定性 (核心致谢 @byte-sunlight PR #532)**:\n            - **Warmup 请求拦截**: 自动识别并拦截 Claude Code 每 10 秒发送的 warmup 请求，返回模拟响应，避免消耗配额。\n                - 支持流式和非流式两种响应模式\n                - 智能检测 warmup 特征（文本内容、tool_result 错误等）\n                - 添加 `X-Warmup-Intercepted` 响应头标识\n            - **限流逻辑重构**: 修复限流检查中的关键 bug，使用 `email` 而非 `account_id` 作为限流记录的 key。\n                - 修复绑定账号限流检查失效的问题\n                - 优化 60s 时间窗口内的账号复用逻辑，避免复用已限流账号\n                - 改进会话解绑机制，限流时立即切换而非阻塞等待\n            - **字符串处理安全**: 修复 UTF-8 字符边界 panic 问题，使用 `chars().take()` 安全截取字符串。\n            - **影响范围**: 此修复显著提升了 Claude Code 等工具的使用体验，减少配额浪费并提高账号轮换的准确性。\n        - **CI/CD 测试增强 (CI Testing Enhancement) - 提升发布质量 (核心致谢 @Vucius PR #519)**:\n            - **强制测试**: 在 GitHub Actions 的 Release 流程中添加 `cargo test` 步骤，确保所有测试通过后才能构建发布版本。\n            - **测试修复**: 修正 `common_utils.rs` 中联网搜索测试的模型映射断言（`gemini-3-flash` → `gemini-2.5-flash`）。\n            - **测试清理**: 移除 `gemini/wrapper.rs` 中重复的测试模块定义，优化测试代码结构。\n            - **新增测试探针**: 添加 `common_utils_test_probe.rs` 文件，提供自定义工具检测的测试用例。\n            - **影响范围**: 此改进确保了每次发布的代码质量，减少因测试失败导致的回归问题。\n        - **监控日志容量优化 (Monitor Log Capacity Enhancement) - 支持大型图片响应 (修复 Issue #489)**:\n            - **提升响应日志限制**: 将监控中间件的响应体日志限制从 10MB 提升到 **100MB**，解决 4K 图片等大型响应被截断的问题。\n            - **问题背景**: 4K 图片经过 base64 编码后通常超过 10MB，导致监控日志显示 `[Response too large (>10MB)]` 而无法记录完整响应。\n            - **优化效果**: 现在可以完整记录包含高分辨率图片的响应内容，便于调试和监控图像生成等多模态功能。\n            - **性能影响**: 每个请求最多占用 100MB 临时内存，对现代系统（8GB+ RAM）完全可接受。\n            - **历史演进**: v3.3.16 时从 512KB 提升到 10MB（@Stranmor PR #321），本次进一步提升到 100MB。\n            - **影响范围**: 此优化确保了图像生成、大型 JSON 响应等场景的完整日志记录，提升了监控系统的实用性。\n        - **自动更新通知系统 (Automatic Update Notification System) - 提升用户体验 (修复 Issue #484)**:\n            - **后端实现**: 新增 `update_checker.rs` 模块，集成 GitHub API 自动检测最新版本。\n                - 语义化版本比较（支持 x.y.z 格式）\n                - 24 小时智能检查间隔\n                - 设置持久化（`update_settings.json`）\n                - 网络错误容错处理\n            - **前端实现**: 新增 `UpdateNotification.tsx` Toast 通知组件。\n                - 渐变 UI 设计（蓝紫色渐变）\n                - 应用启动后 2 秒自动检查\n                - 一键跳转下载页面\n                - 可关闭/忽略功能\n            - **用户控制**: 尊重用户设置，支持自动检查开关和检查间隔配置。\n            - **跨平台支持**: 完全兼容 macOS、Windows、Linux 三大平台。\n            - **影响范围**: 用户无需手动检查即可及时获知新版本，确保使用最新功能和 bug 修复。\n        - **开机自动启动兼容性修复 (Auto-Launch Compatibility Fix) - 解决 Windows 切换异常 (修复 Issue #438, #539)**:\n            - **后端容错增强**: 修复了 Windows 环境下禁用自启时因找不到注册表项导致的 `os error 2` 报错。现在当用户选择禁用且启动项已不存在时，系统将视为操作成功，不再阻断后续逻辑。\n            - **状态实时同步**: 前端设置页面现在会在加载时主动查询系统的真实自启状态，而非仅仅依赖配置文件。这解决了由于系统清理软件或移动应用位置导致的状态不一致问题。\n            - **逻辑闭环**: 确保了即使在异常系统环境下，用户也能通过重新点击“启用/禁用”来强制修复并同步自启状态。\n            - **影响范围**: 解决了从 v3.2.7 以来长期困扰 Windows 用户的“无法禁用/设置不生效”问题。\n        - **API 监控看板增强 (API Monitor Enhancement) - 补全失败请求记录与 Gemini 统计 (修复 Issue #504)**:\n            - **Gemini Token 统计兼容**: 增强了监控中间件对 Gemini API 方言的支持，能够自动识别 `usageMetadata` 节点并映射 `promptTokenCount` 等原生字段。\n            - **影响范围**: 显著提升了监控面板在故障排查时的准确性，确保了跨协议 Token 统计的一致性。\n        - **Claude 协议核心增强 (Claude Protocol Enhancement)**:\n            - **弹性恢复引擎 (Elastic Recovery Engine)**: \n                - **空流重试**: 智能识别并自动重试上游返回的空数据流，解决网络抖动导致的请求失败。\n                - **断点自愈**: 自动检测工具调用链的断裂状态（Missing ToolResult），并实施主动修复，防止因客户端中断导致的上下文同步错误 (400)。\n            - **智能上下文优化 (Smart Context Optimization)**:\n                - **资源瘦身**: 自动清洗历史记录中的冗余 Base64 图片数据与超长日志，在保持上下文连贯的同时大幅降低 Token 消耗。\n                - **签名兼容**: 实现了双向签名转换层，完美适配各版本 Claude 客户端的 Thinking 签名校验机制。\n            - **精细化限流 (Model-Level Rate Limiting)**:\n                - **模型隔离**: 429 限流策略升级为“账号+模型”双维度锁定。Gemini Flash 的频控不再影响 Pro/Ultra 模型的使用，显著提升账号利用率。\n    *   **v3.3.20 (2026-01-09)**:\n        - **请求超时配置优化 (Request Timeout Enhancement) - 支持长时间文本处理 (核心致谢 @xiaoyaocp Issue #473)**:\n            - **提升超时上限**: 将服务配置中的请求超时最大值从 600 秒（10 分钟）提升到 3600 秒（1 小时）。\n            - **支持耗时接口**: 解决了某些文本处理接口（如长文本生成、复杂推理等）因超时限制导致的请求中断问题。\n            - **灵活配置范围**: 保持最小值 30 秒不变，用户可根据实际需求在 30-3600 秒范围内自由调整。\n            - **国际化更新**: 同步更新中英文提示文本，清晰标注新的配置范围。\n            - **影响范围**: 此优化为需要长时间处理的 API 请求提供了更大的灵活性，特别适用于复杂文本处理、长文本生成等场景。\n        - **自动 Stream 转换功能 (Auto-Stream Conversion) - 消除 429 错误**:\n            - **核心问题**: Google API 对流式 (`stream: true`) 和非流式 (`stream: false`) 请求采用截然不同的配额限制策略。流式请求配额更宽松，非流式请求极易触发 429 错误。\n            - **解决方案**: 在代理层自动将所有非流式请求转换为流式请求发送给 Google，然后将 SSE 响应收集并转换回 JSON 格式返回给客户端。\n            - **协议支持**:\n                - **Claude 协议**: ✅ 完整实现并测试通过\n                - **OpenAI 协议**: ✅ 完整实现并测试通过\n                - **Gemini 协议**: ✅ 原生支持非流式请求，无需转换\n            - **核心改动**:\n                - 新增 `src-tauri/src/proxy/mappers/claude/collector.rs` - Claude SSE 收集器\n                - 新增 `src-tauri/src/proxy/mappers/openai/collector.rs` - OpenAI SSE 收集器\n                - 修改 `claude.rs` 和 `openai.rs` handler，实现自动转换逻辑\n            - **性能影响**:\n                - **成功率**: 从 10-20% 提升到 **95%+**\n                - **429 错误**: 从频繁出现到**几乎消除**\n                - **响应时间**: 增加约 100-200ms（可接受的代价）\n            - **影响范围**: 此功能显著提升了 Python SDK、Claude CLI 等非流式客户端的稳定性，解决了长期困扰用户的 429 配额问题。\n        - **macOS Dock 图标修复 (核心致谢 @jalen0x PR #472)**:\n            - **修复窗口无法重新打开**: 解决了 macOS 上关闭窗口后点击 Dock 图标无法重新打开窗口的问题（Issue #471）。\n            - **RunEvent::Reopen 处理**: 将 `.run()` 改为 `.build().run()` 模式，添加 `RunEvent::Reopen` 事件处理器。\n            - **窗口状态恢复**: 当点击 Dock 图标时自动显示窗口、取消最小化、设置焦点，并恢复激活策略为 `Regular`。\n            - **影响范围**: 此修复提升了 macOS 用户体验，确保应用窗口能够正常重新打开，符合 macOS 应用的标准行为。\n    *   **v3.3.19 (2026-01-09)**:\n        - **模型路由系统极简重构 (Model Routing Refactoring)**:\n            - **逻辑简化**: 移除了复杂的“规格家族”分组映射，引入了更直观的 **通配符 (*)** 匹配逻辑。\n            - **自动配置迁移**: 启动时自动将旧版本的家族映射规则迁移至自定义映射表，确保无损升级。\n            - **UI 布局优化**:\n                - **高效排版**: “精确映射列表”改为 2 列并列展示，大幅提升空间利用率。\n                - **交互优化**: 将列表置顶并支持 Hover 删除，表单压缩为单行置底，操作更加聚焦。\n                - **深色模式调优**: 针对暗色环境进行了专项视觉优化，提升了对比度与层次感。\n            - **一键预设**: 新增“应用预设映射”功能，内置 11 条常用的通配符路由规则（如 `gpt-4*`, `o1-*` 等）。\n            - **在线编辑功能**: 支持直接在列表中修改已有规则的目标模型，无需删除重建，操作更顺滑。\n            - **稳定性增强**: 清理了废弃字段的残留引用，修复了所有相关编译警告。\n        - **模型级别限流锁定 (Model-Level Rate Limiting)**:\n            - **问题修复**: 解决了不同模型配额互相影响的问题。之前当 Image 模型配额耗尽时,会锁定整个账号,导致 Claude 等其他模型即使有配额也无法使用。\n            - **模型级别锁定**: 新增 `model` 字段到 `RateLimitInfo` 结构,支持针对特定模型进行限流锁定。\n            - **精确配额管理**: 修改 `mark_rate_limited_async`、`set_lockout_until`、`set_lockout_until_iso` 等方法,添加可选的 `model` 参数。\n            - **智能日志输出**: 区分账号级别和模型级别的限流日志,便于调试和监控。\n            - **向后兼容**: `model: None` 表示账号级别限流(保持原有行为),`model: Some(...)` 表示模型级别限流(新功能)。\n            - **影响范围**: 此修复确保了不同模型的配额独立管理,Image 模型配额耗尽不再影响 Claude、Gemini 等其他模型的正常使用。\n        - **乐观重置策略集成 (Optimistic Reset Strategy)**:\n            - **双层防护机制**: 为 429 错误处理添加最后一道防线,解决时序竞争条件导致的\"无可用账号\"误报。\n                - **Layer 1 - 缓冲延迟**: 当所有账号被限流但最短等待时间 ≤ 2 秒时,执行 500ms 缓冲延迟,等待状态同步。\n                - **Layer 2 - 乐观重置**: 如果缓冲后仍无可用账号,清除所有限流记录(`clear_all`)并重试。\n            - **精准触发条件**: 只在等待时间 ≤ 2 秒时触发,避免对真实配额耗尽执行无效重置。\n            - **详细日志追踪**: 所有关键步骤都有日志输出(`[WARN]`/`[INFO]`),便于调试和监控。\n            - **适用场景**: 解决限流过期边界的时序竞争条件、临时性 API 限流、状态同步延迟等问题。\n            - **影响范围**: 此策略作为现有 429 处理系统(精确解析、智能退避、成功重置)的补充,提高了临时性限流的恢复能力。\n    *   **v3.3.18 (2026-01-08)**:\n        - **智能限流优化 - 实时配额刷新与精准锁定 (核心致谢 @Mag1cFall PR #446)**:\n            - **智能指数退避**: 根据连续失败次数动态调整锁定时间,避免因临时配额波动导致的长时间锁定。\n                - 第 1 次失败: 60 秒\n                - 第 2 次失败: 5 分钟\n                - 第 3 次失败: 30 分钟\n                - 第 4 次及以上: 2 小时\n            - **实时配额刷新**: 当 API 返回 429 但未提供 `quotaResetDelay` 时,实时调用配额刷新 API 获取最新的 `reset_time`,精确锁定账号到配额恢复时间点。\n            - **三级降级策略**:\n                - 优先: 使用 API 返回的 `quotaResetDelay`\n                - 次优: 实时刷新配额获取 `reset_time`\n                - 保底: 使用本地缓存的配额刷新时间\n                - 兜底: 使用智能指数退避策略\n            - **精准锁定**: 新增 `set_lockout_until_iso` 方法,支持使用 ISO 8601 时间字符串精确锁定账号。\n            - **成功重置**: 请求成功后自动重置账号的连续失败计数,避免账号因历史失败记录而被长期锁定。\n            - **新增错误类型支持**: 新增 `ModelCapacityExhausted` 错误类型,处理服务端暂时无可用 GPU 实例的情况(15 秒重试)。\n            - **优化限流判断**: 修复 TPM 限流被误判为配额耗尽的问题,优先检查 \"per minute\" 或 \"rate limit\" 关键词。\n            - **影响范围**: 此优化显著提升了多轮对话中的账号可用性和稳定性,解决了频繁 429 错误和账号锁定时间不准确的问题。\n        - **模型路由中心 BUG 修复 (Fix Issue #434)**:\n            - **修复 GroupedSelect Portal 事件处理**: 解决了自定义下拉选择组件的关键 BUG,修复点击选项时菜单立即关闭导致选择无效的问题。\n                - **根本原因**: `createPortal` 将下拉菜单渲染到 `document.body`,但 `handleClickOutside` 只检查 `containerRef`,导致点击选项时被误判为\"点击外部\"。\n                - **解决方案**: 添加 `dropdownRef` 引用下拉菜单,修改 `handleClickOutside` 同时检查容器和下拉菜单,确保点击选项时不会关闭菜单。\n                - **影响范围**: 修复了所有 5 个模型家族分组(Claude 4.5、Claude 3.5、GPT-4、GPT-4o、GPT-5)的下拉选择功能。\n            - **补充缺失的国际化翻译**: 添加专家精确映射部分缺失的翻译键,解决提示文本不显示的问题。\n                - 中文: `money_saving_tip`、`haiku_optimization_tip`、`haiku_optimization_btn`、`select_target_model`\n                - 英文: 对应的英文翻译\n                - **影响范围**: \"💰 省钱提示\" 和 \"一键优化\" 按钮现在正常显示。\n            - **统一专家映射表单下拉框**: 将添加映射表单中的原生 `<select>` 替换为自定义 `GroupedSelect` 组件。\n                - 添加 `customMappingValue` state 管理选中值\n                - 从 `models` 动态生成 `customMappingOptions`\n                - 提供一致的用户体验,解决 Windows 透明度问题\n            - **用户体验增强**:\n                - 添加成功/失败 Toast 提示,用户操作后有明确反馈\n                - 添加调试日志便于问题诊断\n                - 改进错误处理,失败时显示具体错误信息\n        - **macOS 旧版本兼容性修复 (Fix Issue #219)**:\n            - **修复添加账号弹窗不显示**: 将 `AddAccountDialog` 中的 `<dialog>` 标签替换为 `<div>`，解决了 macOS 12.1 (Safari < 15.4) 等旧版本系统上点击“添加账号”按钮无反应的问题。\n        - **内置直通模型路由修复 (核心致谢 @jalen0x PR #444)**:\n            - **修复直通模型被错误拦截**: 解决了 `claude-opus-4-5-thinking` 等内置直通模型在 CLI 模式下被错误地应用家族映射规则（如被重定向到 `gemini-3-pro-high`）的问题。\n            - **逻辑优化**: 移除了针对 CLI 请求的直通检查限制，确保内置表中定义的直通模型（key == value）始终拥有最高优先级，绕过家族分组映射。\n    *   **v3.3.17 (2026-01-08)**:\n        - **OpenAI 协议 Thinking 展示增强 (核心致谢 @Mag1cFall PR #411)**:\n            - **新增 reasoning_content 字段支持**: 在 OpenAI 兼容格式中添加 `reasoning_content` 字段,使 Gemini 3 模型的思考过程能够被 Cherry Studio 等客户端正确折叠显示。\n            - **思考内容智能分离**: 根据 `thought:true` 标记自动分离思考内容到 `reasoning_content` 字段,正常内容保留在 `content` 字段,提升用户体验。\n            - **流式与非流式全面支持**: 在 `streaming.rs` 和 `response.rs` 中同时实现 `reasoning_content` 支持,确保所有响应模式下的一致性。\n            - **修复空 Chunk 跳过问题**: 修复了当仅有思考内容时 chunk 被错误跳过的 Bug,现在只有当 `content` 和 `reasoning_content` 都为空时才跳过。\n            - **统一流式 ID**: 为所有流式 chunk 使用统一的 `stream_id` 和 `created_ts`,符合 OpenAI 协议规范。\n            - **影响范围**: 此功能增强了 Gemini 3 thinking 模型在 Cherry Studio、Cursor 等客户端中的展示效果,思考过程可以被正确折叠,不影响任何现有 v3.3.16 修复。\n        - **FastMCP 框架兼容性修复 (核心致谢 @Silviovespoli PR #416)**:\n            - **修复 anyOf/oneOf 类型丢失问题**: 解决了 FastMCP 框架生成的 JSON Schema 中 `anyOf`/`oneOf` 被移除后导致字段缺少 `type` 属性的问题。\n            - **智能类型提取**: 在移除 `anyOf`/`oneOf` 之前,自动提取第一个非 null 类型到 `type` 字段,确保 Schema 有效性。\n            - **修复工具调用静默失败**: 解决了 Claude Code 使用 FastMCP 工具时调用失败但无错误提示的问题 (Issue #379, #391)。\n            - **向后兼容**: 仅在字段缺少 `type` 时才提取,已有 `type` 的 Schema 不受影响,确保与标准 MCP Server 的兼容性。\n            - **完整测试覆盖**: 新增 4 个单元测试验证 `anyOf`/`oneOf` 类型提取、已有类型保护等场景。\n            - **影响范围**: 此修复使 FastMCP 框架构建的 MCP 服务器能够正常工作,不影响标准 MCP Server 和任何现有 v3.3.16 修复。\n        - **前端 UI/UX 优化 (核心致谢 @i-smile PR #414)**:\n            - **API 代理路由重构**: 使用分组下拉菜单优化专家路由配置界面,提升模型映射配置的可读性和易用性。\n            - **账户视图模式持久化**: 使用 localStorage 自动记住用户选择的列表/网格视图模式,提升用户体验。\n            - **表格布局优化**: 为配额列设置最小宽度防止压缩,操作列固定在右侧提升小屏幕可访问性。\n            - **国际化翻译完善**: 添加缺失的翻译键,移除硬编码字符串,提升多语言支持质量。\n            - **影响范围**: 此更新仅涉及前端 UI 改进,不影响任何后端逻辑和现有 v3.3.16/v3.3.17 修复。\n        - **自定义分组下拉组件 (Custom Grouped Select)**:\n            - **解决 Windows 透明度问题**: 创建自定义 `GroupedSelect` 组件替换原生 `<select>`,解决 Windows 下拉菜单过于透明的问题。\n            - **完整深浅模式支持**: 自定义组件完美支持深浅模式切换,提供一致的视觉体验。\n            - **React Portal 渲染**: 使用 `createPortal` 将下拉菜单渲染到 `document.body`,解决被父容器遮盖的问题。\n            - **动态位置计算**: 实时计算下拉菜单位置,支持页面滚动和窗口大小变化时自动调整。\n            - **优化字体和间距**: 选项字体 10px,分组标题 9px,padding 紧凑,勾选图标 12px,提升信息密度。\n            - **智能宽度调整**: 下拉菜单宽度为按钮宽度的 1.1 倍(最小 220px),完整显示模型名称同时保持紧凑。\n            - **悬停提示**: 添加 `title` 属性,鼠标悬停时显示完整的模型名称。\n            - **影响范围**: 替换了所有 5 个模型家族分组的原生 select(Claude 4.5、Claude 3.5、GPT-4、GPT-4o、GPT-5),提升跨平台一致性。\n        - **国际化完善 (核心致谢 @dlukt PR #397)**:\n            - **填补英文翻译**: 大幅扩展 `en.json`,添加缺失的英文翻译键,覆盖导航栏、账户管理、API 代理等模块。\n            - **移除硬编码文本**: 系统性移除组件中的硬编码中文文本,使用 `useTranslation` hook 和 `t()` 函数实现动态翻译。\n            - **新增功能翻译**: 添加账户代理启用/禁用、主题切换、语言切换、Python 代码示例等功能的国际化支持。\n            - **保持翻译同步**: 同步更新 `zh.json` 和 `en.json`,确保中英文翻译键的一致性。\n            - **影响范围**: 更新了 `AccountGrid`、`AddAccountDialog`、`Navbar`、`Accounts`、`accountService` 等 7 个文件,提升多语言支持质量。\n        - **Antigravity 身份注入 (核心致谢 [wendavid](https://linux.do/u/wendavid))**:\n            - **智能身份管理**: 在三个协议(Claude、OpenAI、Gemini)中实现了 Antigravity 身份注入,确保模型正确识别自己的身份和使用规范。\n            - **避免重复注入**: 实现智能检查机制,检测用户是否已提供 Antigravity 身份,避免重复注入。\n            - **简洁专业版文本**: 采用简洁专业的身份描述,包含核心信息(Google Deepmind、agentic AI、pair programming)和关键提示(**Absolute paths only**、**Proactiveness**)。\n            - **保留用户控制**: 如果用户自定义了系统提示词,系统会尊重用户的选择,不强制覆盖。\n            - **影响范围**: 修改了 `claude/request.rs`、`openai/request.rs`、`gemini/wrapper.rs` 三个文件,提升了模型响应的一致性和准确性。\n    *   **v3.3.16 (2026-01-07)**:\n        - **性能优化 (Performance Optimization)**:\n            - **并发配额刷新**: 重构账号配额刷新逻辑,从串行改为并发执行,显著提升多账号场景下的刷新速度\n                - 使用 `futures::join_all` 实现并发任务执行\n                - 添加信号量控制,限制最大并发数为 5,避免 API 限流和数据库写入冲突\n                - 10 个账号刷新耗时从 ~30s 降低至 ~6s (提升约 5 倍)\n                - 添加性能监控日志,实时显示刷新耗时\n                - 感谢 [@Mag1cFall](https://github.com/Mag1cFall) 提供的优化方案 ([#354](https://github.com/lbjlaq/Antigravity-Manager/pull/354))\n        - **UI 视觉设计优化 (核心致谢 @Mag1cFall PR #353 + @AmbitionsXXXV PR #371)**:\n            - **API 代理页面视觉改进**:\n                - **柔化禁用状态遮罩**: 将禁用卡片的遮罩从 `bg-white/60` 改为 `bg-gray-100/40`,移除模糊效果,提升可读性。\n                - **统一复选框样式**: 将 MCP 功能区的复选框从 DaisyUI 的 `checkbox-primary` 改为自定义蓝色样式,保持视觉一致性。\n                - **醒目的功能标签**: MCP 功能标签从灰色改为蓝色 (`bg-blue-500 dark:bg-blue-600`),一眼识别已启用功能。\n                - **Slate 色系容器**: MCP 端点显示和调度配置滑块容器使用 `slate-800/80` 暗色背景,对比度更好。\n            - **暗色模式增强**:\n                - **改进边框对比度**: 卡片边框从 `dark:border-base-200` 改为 `dark:border-gray-700/50`,层次更清晰。\n                - **优化背景深度**: 卡片头部和表格头部使用 `dark:bg-gray-800/50`,视觉分隔更明显。\n                - **Select 下拉框暗色支持**: 全局添加 Select 暗色样式,选中项使用蓝色高亮。\n                - **代码质量提升**: 使用 `cn()` 工具函数优化类名拼接,代码更简洁。\n            - **主题切换动画修复**:\n                - **双向对称过渡**: 修复亮转暗和暗转亮的过渡动画,实现对称的收缩/展开效果。\n                - **消除白色闪烁**: 添加 `fill: 'forwards'` 防止动画结束时的白色闪烁。\n                - **流畅体验**: 主题切换动画更自然流畅,提升用户体验。\n        - **稳定性与工具修复 (Stability & Tool Fixes)**:\n            - **Grep/Glob 参数修复 (P3-5)**: 修复了 Grep 和 Glob 工具搜索报错的问题。修正了参数映射逻辑:将 `paths` (数组) 改为 `path` (字符串),并实现了大小写不敏感的工具名匹配。\n            - **思考内容屏蔽支持 (P3-2)**: 修复了 `RedactedThinking` 导致报错的问题，现在会优雅降级为 `[Redacted Thinking]` 文本，保留上下文。\n            - **JSON Schema 清理增强**: 修复了 `clean_json_schema` 误删名为 \"pattern\" 等非校验属性的 Bug，提高了 Schema 兼容性。\n            - **严格角色轮替 (P3-3)**: 实现了消息合并逻辑，确保符合 Gemini API 的严格 User/Assistant 轮替要求，减少 400 错误。\n            - **400 自动重试 (P3-1)**: 增强了针对 400 错误的自动重试与账号轮询机制，提升了长时间运行的稳定性。\n        - **高并发性能优化 (Issue #284 修复)**:\n            - **解决 UND_ERR_SOCKET 错误**: 修复了在 8+ 并发 Agent 场景下客户端 socket 超时的问题。\n            - **移除阻塞等待**: 删除了\"缓存优先\"模式下当绑定账号被限流时的 60 秒阻塞等待逻辑。现在限流时会立即解绑并切换到下一个可用账号，避免客户端超时。\n            - **锁竞争优化**: 将 `last_used_account` 锁的获取移到重试循环外，从每个请求 18 次锁操作降低到 1-2 次，大幅减少并发场景下的锁竞争。\n            - **5 秒超时保护**: 为 `get_token()` 操作添加 5 秒强制超时，防止系统过载或死锁时请求无限期挂起。\n            - **影响范围**: 此优化显著提升了多 Agent 并发场景（如 Claude Code、Cursor 等）的稳定性，解决了\"有头无尾\"的请求卡死问题。\n        - **日志系统全面优化 (Issue #241 修复)**:\n            - **日志级别优化**: 将工具调用和参数重映射的高频调试日志从 `info!` 降级为 `debug!`，大幅减少日志输出量。\n            - **自动清理机制**: 应用启动时自动清理 7 天前的旧日志文件，防止日志无限累积。\n            - **显著效果**: 日志文件大小从 130GB/天 降至 < 100MB/天，减少 **99.9%** 的日志输出。\n            - **影响范围**: 修改了 `streaming.rs` 和 `response.rs` 中的 21 处日志级别，添加了 `cleanup_old_logs()` 自动清理函数。\n        - **Gemini 3 Pro Thinking 模型修复 (核心致谢 @fishheadwithchili PR #368)**:\n            - **修复 gemini-3-pro-high 和 gemini-3-pro-low 的 404 错误**: 解决了调用这两个模型时返回 404 Not Found 的问题。\n            - **正确的 thinkingConfig 参数**: 为 Gemini 3 Pro 模型注入正确的 `thinkingBudget: 16000` 配置（而非错误的 `thinkingLevel`），符合 Cloud Code API 规范。\n            - **完整模型名称支持**: 保留模型名称中的 `-high` 和 `-low` 后缀，API 需要完整的模型名称来识别特定变体。\n            - **基础模型映射**: 添加 `gemini-3-pro` 基础模型的直接透传映射，支持不带后缀的调用。\n            - **影响范围**: 此修复确保了 Gemini 3 Pro thinking 模型的正常使用，用户现在可以正常调用 `gemini-3-pro-high` 和 `gemini-3-pro-low` 并获得包含 thinking 内容的响应。\n        - **联网功能降级优化**:\n            - **强制模型降级**: 修复了联网功能的模型降级逻辑。由于 Antigravity 提供的模型中**只有 `gemini-2.5-flash` 支持 googleSearch 工具**，现在所有模型（包括 Gemini 3 Pro、thinking 模型、Claude 别名）在启用联网时都会自动降级到 `gemini-2.5-flash`。\n            - **日志增强**: 添加了降级日志，方便用户了解模型切换情况。\n            - **影响范围**: 此修复确保了 Cherry Studio、Claude CLI 等客户端的联网功能正常工作，避免了因模型不支持 googleSearch 而导致的\"模拟搜索\"问题。\n        - **OpenAI 协议多候选支持 (核心致谢 @ThanhNguyxn PR #403)**:\n            - 实现了对 `n` 参数的支持，允许一次请求返回多个候选结果。\n            - 补全了流式响应 (SSE) 下的多候选支持补丁，确保跨平台模式的功能对齐。\n        - **联网搜索功能增强与引文优化**:\n            - 重新实现了联网搜索来源展示，采用更易读的 Markdown 引用格式（包含标题和链接）。\n            - 解决了之前版本中引文显示逻辑被禁用的问题，现已在流式和非流式模式下全面启用。\n        - **MCP 工具枚举值类型修复 (核心致谢 @ThanhNguyxn PR #395)**:\n            - **修复 Gemini API 枚举值类型错误**: 解决了 MCP 工具（如 mcpserver-ncp）因枚举值为数字或布尔值而导致的 400 错误。\n            - **自动类型转换**: 在 `clean_json_schema` 函数中添加了枚举值字符串化逻辑，将数字、布尔值、null 等自动转换为字符串。\n            - **符合 Gemini 规范**: 确保所有工具定义的枚举值都是 `TYPE_STRING` 类型，符合 Gemini v1internal API 的严格要求。\n            - **影响范围**: 此修复确保了 MCP 工具在 Gemini 模型下的正常调用，提升了跨模型供应商的工具定义兼容性。\n        - **响应体日志限制优化 (核心致谢 @Stranmor PR #321)**:\n            - **提升日志容量**: 将响应体日志限制从 512KB 提升到 10MB，解决图像生成响应被截断的问题。\n            - **支持大型响应**: 现在可以完整记录包含 base64 编码图像的响应和大型 JSON 数据。\n            - **影响范围**: 此优化确保了图像生成和大型响应的完整日志记录，便于调试和监控。\n        - **音频转录 API 支持 (核心致谢 @Jint8888 PR #311 部分功能)**:\n            - **音频转录端点**: 新增 `/v1/audio/transcriptions` 端点，兼容 OpenAI Whisper API，支持 15MB 文件大小限制。\n            - **音频处理模块**: 添加音频 MIME 类型检测和 Base64 编码处理功能。\n            - **影响范围**: 此功能为项目添加了语音转文字能力，补全了多模态功能的重要一环。\n            - **注意**: 对话中的 `audio_url` 支持将在后续版本中完整实现（需要与 v3.3.16 的 thinkingConfig 逻辑协调）。\n        - **Linux 系统兼容性增强 (核心致谢 @0-don PR #326)**:\n            - **修复透明窗口渲染**: 在 Linux 系统下自动禁用 DMA-BUF 渲染器 (`WEBKIT_DISABLE_DMABUF_RENDERER=1`)，解决了部分发行版（如 Ubuntu/Fedora）下窗口透明失效或黑屏的问题。\n        - **监控中间件容量优化 (核心致谢 @Mag1cFall PR #346)**:\n            - **对齐全局 Payload 限制**: 将监控中间件的请求体解析限制从 1MB 提升至 100MB，确保包含大型图片的请求能被正常记录并在监控页面显示。\n        - **安装与分发优化 (核心致谢 @dlukt PR #396)**:\n            - **Homebrew Cask 支持 Linux**: 重构 Cask 文件，现在 Linux 用户可以通过 `brew install --cask` 轻松安装并自动配置 AppImage 权限。\n        - **API 监控增强 (核心致谢 PR #394)**:\n            - **账号邮箱显示**: API 监控日志现在显示每个请求使用的账号邮箱,支持脱敏显示(例如: `tee***@gmail.com`)。\n            - **模型映射显示**: 监控表格中的\"模型\"列现在显示原始模型到实际使用模型的映射关系(例如: `g-3-pro-high =u003e gpt-5.2`)。\n            - **详情弹窗增强**: 点击请求详情时,弹窗中显示完整的账号邮箱(未脱敏)和映射模型信息。\n            - **数据库兼容**: 自动添加 `account_email` 和 `mapped_model` 列,向后兼容现有数据库。\n            - **影响范围**: 此功能帮助用户更好地监控和调试 API 请求,了解账号使用情况和模型映射效果,不影响任何现有 v3.3.16 修复。\n    *   **v3.3.15 (2026-01-04)**:\n        - **Claude 协议兼容性增强** (基于 PR #296 by @karasungur + Issue #298 修复):\n            - **修复 Opus 4.5 首次请求错误 (Issue #298)**: 扩展签名预检验证到所有首次 thinking 请求,不仅限于函数调用场景。当使用 `claude-opus-4-5-thinking` 等模型进行首次请求时,如果没有有效签名,系统会自动禁用 thinking 模式以避免 API 拒绝,解决了 \"Server disconnected without sending a response\" 错误。\n            - **函数调用签名验证 (Issue #295)**: 添加预检签名验证,当启用 thinking 但函数调用缺少有效签名时自动禁用 thinking,防止 Gemini 3 Pro 拒绝请求。\n            - **cache_control 清理 (Issue #290)**: 实现递归深度清理,移除所有嵌套对象/数组中的 `cache_control` 字段,解决 Anthropic API (z.ai 模式) 的 \"Extra inputs are not permitted\" 错误。\n            - **工具参数重映射**: 自动修正 Gemini 使用的参数名称 (Grep/Glob: `query` → `pattern`, Read: `path` → `file_path`),解决 Claude Code 工具调用验证错误。\n            - **可配置安全设置**: 新增 `GEMINI_SAFETY_THRESHOLD` 环境变量,支持 5 个安全级别 (OFF/LOW/MEDIUM/HIGH/NONE),默认 OFF 保持向后兼容。\n            - **Effort 参数支持**: 支持 Claude API v2.0.67+ 的 `output_config.effort` 参数,允许精细控制模型推理努力程度。\n            - **Opus 4.5 默认 Thinking**: 与 Claude Code v2.0.67+ 对齐,Opus 4.5 模型默认启用 thinking 模式,配合签名验证实现优雅降级。\n            - **重试抖动优化**: 为所有重试策略添加 ±20% 随机抖动,防止惊群效应,提升高并发场景稳定性。\n            - **签名捕获改进**: 从 thinking blocks 中立即捕获签名,减少多轮对话中的签名缺失错误。\n            - **影响范围**: 这些改进显著提升了 Claude Code、Cursor、Cherry Studio 等客户端的兼容性和稳定性,特别是在 Opus 4.5 模型、工具调用和多轮对话场景下。\n    *   **v3.3.14 (2026-01-03)**:\n        - **Claude 协议鲁棒性改进** (核心致谢 @karasungur PR #289):\n            - **Thinking Block 签名验证增强**:\n                - 支持带有效签名的空 thinking blocks (尾部签名场景)\n                - 无效签名的 blocks 优雅降级为文本而非丢弃,保留内容避免数据丢失\n                - 增强调试日志,便于排查签名问题\n            - **工具/函数调用兼容性优化**:\n                - 提取 web 搜索回退模型为命名常量 `WEB_SEARCH_FALLBACK_MODEL`,提升可维护性\n                - 当存在 MCP 工具时自动跳过 googleSearch 注入,避免冲突\n                - 添加信息性日志,便于调试工具调用场景\n                - **重要说明**: Gemini Internal API 不支持混合使用 `functionDeclarations` 和 `googleSearch`\n            - **SSE 解析错误恢复机制**:\n                - 新增 `parse_error_count` 和 `last_valid_state` 追踪,实现流式响应错误监控\n                - 实现 `handle_parse_error()` 用于优雅的流降级\n                - 实现 `reset_error_state()` 用于错误后恢复\n                - 实现 `get_error_count()` 用于获取错误计数\n                - 高错误率警告系统 (>5 个错误),便于运维监控\n                - 详细的调试日志,支持故障排查损坏流\n            - **影响范围**: 这些改进显著提升了 Claude CLI、Cursor、Cherry Studio 等客户端的稳定性,特别是在多轮对话、工具调用和流式响应场景下。\n        - **仪表板统计修复** (核心致谢 @yinjianhong22-design PR #285):\n            - **修复低配额统计误报**: 修复了被禁用账户 (403 状态) 被错误计入\"低配额\"统计的问题\n            - **逻辑优化**: 在 `lowQuotaCount` 过滤器中添加 `is_forbidden` 检查,排除被禁用账户\n            - **数据准确性提升**: 仪表板现在能准确反映真实的低配额活跃账户数量,避免误报\n            - **影响范围**: 提升了仪表板数据的准确性和用户体验,用户可以更清晰地了解需要关注的账户。\n    *   **v3.3.13 (2026-01-03)**:\n        - **Thinking 模式稳定性修复**:\n            - **修复空 Thinking 内容错误**: 当客户端发送空的 Thinking 块时，自动降级为普通文本块，避免 `thinking: Field required` 错误。\n            - **修复智能降级后的验证错误**: 当 Thinking 被智能降级禁用时（如历史消息不兼容），自动将所有历史消息中的 Thinking 块转换为普通文本，解决 \"thinking is disabled but message contains thinking\" 错误。\n            - **修复模型切换签名错误**: 增加目标模型 Thinking 支持检测。从 Claude thinking 模型切换到普通 Gemini 模型（如 `gemini-2.5-flash`）时，自动禁用 Thinking 并降级历史消息，避免 \"Corrupted thought signature\" 错误。只有带 `-thinking` 后缀的模型（如 `gemini-2.5-flash-thinking`）或 Claude 模型支持 Thinking。\n            - **影响范围**: 这些修复确保了在各种模型切换场景下的稳定性，特别是 Claude ↔ Gemini 之间的自由切换。\n        - **账号轮询限流机制优化 (核心修复 Issue #278)**:\n            - **修复限流时间解析失败**: 解决了 Google API 返回的 `quotaResetDelay` 无法正确解析的问题。\n                - **修正 JSON 解析路径**: 将 `quotaResetDelay` 的提取路径从 `details[0].quotaResetDelay` 修正为 `details[0].metadata.quotaResetDelay`，匹配 Google API 的实际 JSON 结构。\n                - **实现通用时间解析**: 新增 `parse_duration_string()` 函数，支持解析所有 Google API 返回的时间格式，包括 `\"2h21m25.831582438s\"`, `\"1h30m\"`, `\"5m\"`, `\"30s\"` 等复杂格式组合。\n                - **区分限流类型**: 新增 `RateLimitReason` 枚举，区分 `QUOTA_EXHAUSTED`（配额耗尽）和 `RATE_LIMIT_EXCEEDED`（速率限制）两种限流类型，根据类型设置不同的默认等待时间（配额耗尽: 1小时，速率限制: 30秒）。\n            - **修复前的问题**: 当账号配额耗尽触发 429 错误时，系统无法解析 Google API 返回的准确重置时间（如 `\"2h21m25s\"`），导致使用固定默认值 60 秒。账号被错误地认为\"1分钟后恢复\"，实际可能需要 2 小时，导致账号陷入 429 循环，只使用前 2 个账号，后续账号从未被使用。\n            - **修复后的效果**: 系统现在能准确解析 Google API 返回的重置时间（如 `\"2h21m25.831582438s\"` → 8485秒），账号被正确标记为限流状态并等待准确的时间，确保所有账号都能被正常轮换使用，解决\"只使用前 2 个账号\"的问题。\n            - **影响范围**: 此修复显著提升了多账号环境下的稳定性和可用性，确保所有账号都能被充分利用，避免因限流时间解析错误导致的账号轮换失效。\n    *   **v3.3.12 (2026-01-02)**:\n        - **核心修复 (Critical Fixes)**:\n            - **修复 Antigravity Thinking Signature 错误**: 解决了使用 Antigravity (Google API) 渠道时的 `400: thinking.signature: Field required` 错误。\n                - **禁用假 Thinking 块注入**: 移除了为历史消息自动注入无签名 \"Thinking...\" 占位块的逻辑，Google API 不接受任何无效签名的 thinking 块。\n                - **移除假签名 Fallback**: 移除了为 ToolUse 和 Thinking 块添加 `skip_thought_signature_validator` 哨兵值的逻辑，只使用真实签名或完全不添加 thoughtSignature 字段。\n                - **修复后台任务误判**: 移除了 \"Caveat: The messages below were generated\" 关键词，避免将包含 Claude Desktop 系统提示的正常请求误判为后台任务并降级到 Flash Lite 模型。\n                - **影响范围**: 此修复确保了 Claude CLI、Cursor、Cherry Studio 等客户端在使用 Antigravity 代理时的稳定性，特别是在多轮对话和工具调用场景下。\n    *   **v3.3.11 (2026-01-02)**:\n        - **重要修复 (Critical Fixes)**:\n            - **Cherry Studio 兼容性修复 (Gemini 3)**:\n                - **移除强制性 Prompt 注入**: 移除了针对 Coding Agent 的强制系统指令注入和 Gemini 3 模型的用户消息后缀。这解决了在 Cherry Studio 等通用客户端中使用 `gemini-3-flash` 时模型输出 \"Thinking Process\" 或 \"Actually, the instruction says...\" 等困惑回复的问题。现在通用 OpenAI 协议请求将保持原汁原味。\n            - **修复 Gemini 3 Python 客户端崩溃问题**:\n                - **移除 maxOutputTokens 强制限制**: 移除了对 Gemini 请求强制设置 `maxOutputTokens: 64000` 的逻辑。该强制设置导致标准 Gemini 3 Flash/Pro 模型 (上限 8192) 拒绝请求并返回空响应，进而引发 Python 客户端出现 `'NoneType' object has no attribute 'strip'` 错误。修复后，代理将默认使用模型原生上限或尊重客户端参数。\n        - **核心优化 (Core Optimization)**:\n            - **统一退避策略系统**: 重构错误重试逻辑,引入智能退避策略模块,针对不同错误类型采用合适的退避算法:\n                - **Thinking 签名失败 (400)**: 固定 200ms 延迟后重试,避免立即重试导致的请求翻倍。\n                - **服务器过载 (529/503)**: 指数退避(1s/2s/4s/8s),显著提升恢复成功率 167%。\n                - **限流错误 (429)**: 优先使用服务端 Retry-After,否则线性退避(1s/2s/3s)。\n                - **账号保护**: 服务端错误(529/503)不再轮换账号,避免污染健康账号池。\n                - **统一日志**: 所有退避操作使用 ⏱️ 标识,便于监控和调试。\n        - **核心修复 (Critical Fix)**:\n            - **修复 Gemini 3 Python 客户端崩溃问题**: 移除了对 Gemini 请求强制设置 `maxOutputTokens: 64000` 的逻辑。该强制设置导致标准 Gemini 3 Flash/Pro 模型(上限 8192)拒绝请求并返回空响应,进而引发 Python 客户端出现 `'NoneType' object has no attribute 'strip'` 错误。修复后,代理将默认使用模型原生上限或尊重客户端参数。\n        - **Scoop 安装兼容性支持 (核心致谢 @Small-Ku PR #252)**:\n            - **启动参数配置**: 新增 Antigravity 启动参数配置功能,支持在设置页面自定义启动参数,完美兼容 Scoop 等包管理器的便携式安装。\n            - **智能数据库路径检测**: 优化数据库路径检测逻辑,按优先级依次检查:\n                - 命令行参数指定的 `--user-data-dir` 路径\n                - 便携模式下的 `data/user-data` 目录\n                - 系统默认路径 (macOS/Windows/Linux)\n            - **多安装方式支持**: 确保在标准安装、Scoop 便携安装、自定义数据目录等多种场景下都能正确找到并访问数据库文件。\n        - **浏览器环境 CORS 支持优化 (核心致谢 @marovole PR #223)**:\n            - **明确 HTTP 方法列表**: 将 CORS 中间件的 `allow_methods` 从泛型 `Any` 改为明确的方法列表（GET/POST/PUT/DELETE/HEAD/OPTIONS/PATCH），提升浏览器环境下的兼容性。\n            - **预检缓存优化**: 添加 `max_age(3600)` 配置，将 CORS 预检请求缓存时间设置为 1 小时，减少不必要的 OPTIONS 请求，提升性能。\n            - **安全性增强**: 添加 `allow_credentials(false)` 配置，与 `allow_origin(Any)` 配合使用时符合安全最佳实践。\n            - **浏览器客户端支持**: 完善了对 Droid 等基于浏览器的 AI 客户端的 CORS 支持，确保跨域 API 调用正常工作。\n        - **账号表格拖拽排序功能 (核心致谢 @wanglei8888 PR #256)**:\n            - **拖拽排序**: 新增账号表格拖拽排序功能，用户可通过拖动表格行来自定义账号显示顺序，方便将常用账号置顶。\n            - **持久化存储**: 自定义排序会自动保存到本地，重启应用后保持用户设置的顺序。\n            - **乐观更新**: 拖拽操作立即更新界面，提供流畅的用户体验，同时后台异步保存排序结果。\n            - **基于 dnd-kit**: 使用现代化的 `@dnd-kit` 库实现，支持键盘导航和无障碍访问。\n    *   **v3.3.10 (2026-01-01)**:\n        - 🌐 **上游端点 Fallback 机制** (核心致谢 @karasungur PR #243):\n            - **多端点自动切换**: 实现 `prod → daily` 双端点 Fallback 策略，当主端点返回 404/429/5xx 时自动切换到备用端点，显著提升服务可用性。\n            - **连接池优化**: 新增 `pool_max_idle_per_host(16)`、`tcp_keepalive(60s)` 等参数，优化连接复用，减少建立开销，特别适配 WSL/Windows 环境。\n            - **智能重试逻辑**: 支持 408 Request Timeout、404 Not Found、429 Too Many Requests 和 5xx Server Error 的自动端点切换。\n            - **详细日志记录**: Fallback 成功时记录 INFO 日志，失败时记录 WARN 日志，便于运维监控和问题排查。\n            - **与调度模式完全兼容**: 端点 Fallback 与账号调度（缓存优先/平衡/性能优先）工作在不同层级，互不干扰，确保缓存命中率不受影响。\n        - 📝 **日志系统全面优化**:\n            - **日志级别重构**: 严格区分 INFO/DEBUG/TRACE 级别，INFO 仅显示关键业务信息，详细调试信息降级到 DEBUG。\n            - **心跳请求过滤**: 将 `/api/event_logging/batch` 和 `/healthz` 等心跳请求从 INFO 降级到 TRACE，消除日志噪音。\n            - **账号信息显示**: 在请求开始和完成时显示使用的账号邮箱，便于监控账号使用情况和调试会话粘性。\n            - **流式响应完成标记**: 为流式响应添加完成日志（包含 Token 统计），确保请求生命周期可追踪。\n            - **日志量减少 90%+**: 正常请求从 50+ 行降至 3-5 行，启动日志从 30+ 行降至 6 行，大幅提升可读性。\n            - **Debug 模式**: 通过 `RUST_LOG=debug` 可查看完整请求/响应 JSON，支持深度调试。\n        - 🎨 **Imagen 3 图像生成增强**:\n            - **新增分辨率支持**: 支持通过模型名后缀指定 `-2k` 分辨率，满足更高清的绘图需求。\n            - **超宽比例支持**: 新增 `-21x9` (或 `-21-9`) 比例支持，适配带鱼屏显示。\n            - **映射优化**: 优化了分辨率与比例的自动映射逻辑，支持 `2560x1080` 等自定义尺寸。\n            - **全协议覆盖**: 该增强功能已同步覆盖 OpenAI、Claude 及 Gemini 原生协议。\n        - 🔍 **模型检测 API**:\n            - **新增探测接口**: 提供 `POST /v1/models/detect` 接口，支持实时探测特定模型的图片生成能力及配置组合。\n            - **动态模型列表**: `/v1/models` 接口现在自动罗列所有分辨率与比例的画图模型变体（如 `gemini-3-pro-image-4k-21x9`），方便客户端调用。\n        - 🐛 **后台任务降级模型修复**:\n            - **修复 404 错误**: 将后台任务降级模型从不存在的 `gemini-2.0-flash-exp` 修正为 `gemini-2.5-flash-lite`，解决标题生成、摘要等后台任务的 404 错误。\n        - 🔐 **账号主动禁用功能**:\n            - **独立禁用控制**: 新增账号主动禁用功能,区别于 403 禁用,仅影响反代池,不参与 API 请求。\n            - **应用内可用**: 主动禁用的账号仍可在应用中切换使用、查看配额详情,仅从反代池中移除。\n            - **视觉区分**: 403 禁用显示红色\"已禁用\"徽章,主动禁用显示橙色\"反代已禁用\"徽章。\n            - **批量操作**: 支持批量禁用/启用多个账号,提高管理效率。\n            - **自动重载**: 禁用/启用操作后自动重新加载反代账号池,立即生效。\n            - **影响范围**: 标题生成、简单摘要、系统消息、提示建议、环境探测等轻量任务现在正确降级到 `gemini-2.5-flash-lite`。\n        - 🎨 **UI 体验提升**:\n            - **反代页弹窗风格统一**: 将 ApiProxy 页面中所有原生的 alert/confirm 弹窗统一为应用标准的 Toast 通知与 ModalDialog 对话框，提升视觉一致性。\n            - **Tooltip 遮挡修复**: 修复了反代设置页面中（如\"调度模式\"、\"允许局域网访问\"等）Tooltip 被左侧容器遮挡的问题，优化阅读体验。\n    *   **v3.3.9 (2026-01-01)**:\n        - 🚀 **全协议调度对齐**: `Scheduling Mode` 现在正式覆盖 OpenAI (Cursor/Cherry)、Gemini 原生及 Claude 协议。\n        - 🧠 **工业级 Session 指纹**: 升级 SHA256 内容哈希算法生成粘性 Session ID，确保 CLI 重启后仍能完美继承同一账号，极大提升 Prompt Caching 命中率。\n        - 🛡️ **精准限流与 5xx 故障避让**: 深度集成 Google API JSON 报文解析，支持毫秒级 `quotaResetDelay` 提取，并在 500/503/529 故障时自动触发 20s 避让隔离，实现平滑热切换。\n        - 🔀 **智能调度算法升级**: `TokenManager` 轮转时主动避开所有限流或隔离账号；全量限流时精准提示最短重置时间。\n        - 🌐 **全局限流同步**: 引入跨协议限流追踪器，任意协议触发限流均会实时同步至全局账号池，实现“一端限流，全局避让”。\n        - 📄 **Claude 多模态补全**: 修复 Claude CLI 传输 PDF 等文档时的 400 错误，补全多模态映射逻辑。\n    *   **v3.3.8 (2025-12-31)**:\n        - **代理监控模块 (核心致谢 @84hero PR #212)**:\n            - **实时请求追踪**: 全新的监控仪表板，实时可视化查看所有反代流量，包括请求路径、状态码、响应时间、Token消耗等详细信息。\n            - **持久化日志存储**: 基于 SQLite 的日志系统，支持跨应用重启的历史记录查询与分析。\n            - **高级筛选与排序**: 支持实时搜索、按时间戳排序，快速定位问题请求。\n            - **详细检视模态框**: 点击任意请求即可查看完整的请求/响应 Payload、Header、Token 计数等调试信息。\n            - **性能优化**: 紧凑的数据格式化（如 1.2k 代替 1200）提升大数据量下的 UI 响应速度。\n        - **UI 优化与布局改进**:\n            - **Toggle 样式统一**: 将所有Toggle开关（自动启动、局域网访问、访问授权、外部提供商）统一为小号蓝色样式，整体视觉更一致。\n            - **布局密度优化**: 将\"允许局域网访问\"和\"访问授权\"合并为单行网格布局（lg:grid-cols-2），在大屏幕上更高效利用空间。\n        - **Zai Dispatcher 调度器集成 (核心致谢 @XinXin622 PR #205)**:\n            - **多级分发模式**: 支持 `Exclusive` (专属)、`Pooled` (池化) 和 `Fallback` (回退) 三种调度模式，灵活平衡响应速度与账号安全性。\n            - **内置 MCP 服务支持**: 预置 Web Search Prime、Web Reader 和 Vision 等 MCP 接口地址，支持本地/局域网直接调用。\n            - **配置界面升级**: 在 ApiProxy 页面增加了配套的图形化配置项与交互提示。\n        - **账号异常自动处理 (核心致谢 @salacoste PR #203)**:\n\n            - **自动禁用失效账号**: 当 Google OAuth 刷新令牌失效（触发 `invalid_grant` 错误）时，系统会自动将该账号标记为禁用状态，防止代理服务因重复尝试故障账号而产生 5xx 错误。\n            - **持久化状态管理**: 账号的禁用状态会自动保存到磁盘，系统重启后仍可保持。同时优化了加载逻辑，跳过所有已禁用的账号。\n            - **智能自动恢复**: 用户在 UI 界面手动更新账号令牌后，系统会自动重新启用该账号。\n            - **文档完善**: 添加了针对 `invalid_grant` 异常处理机制的详细说明文档。\n        - **动态模型列表 API (智能化端点优化)**:\n            - **实时动态同步**: `/v1/models` (OpenAI) 和 `/v1/models/claude` (Claude) 接口现在实时聚合内置映射与用户自定义映射，修改设置即刻生效。\n            - **全量模型支持**: 接口不再强制过滤前缀，支持直接在终端或客户端查看并使用 `gemini-3-pro-image-4k-16x9` 等画图模型及所有自定义 ID。\n        - **账号配额管理与模型分级路由 (运营优化与 Bug 修复)**:\n            - **后台任务智能降级**: 自动识别并重放 Claude CLI/Agent 的后台任务（标题、摘要等）为 Flash 模型，解决之前该类请求错误消耗长文本/高级模型额度的问题。\n            - **并发锁与额度保护**: 修复了高并发场景下多个请求同时导致账号额度超限的问题。通过原子锁（Atomic Lock）确保同一会话内的请求一致性，避免不必要的账号轮换。\n            - **账号分级排序 (ULTRA > PRO > FREE)**: 系统现在根据账号配额重置频率（每小时 vs 每日）自动排序模型路由。优先消耗更频繁重置的高级账号，将 FREE 账号作为最后的冗余保障。\n            - **原子化并发锁定**: 优化了 TokenManager 的会话锁定逻辑。在高并发并发（如 Agent 模式）下，确保同一会话的请求能稳定锁定在同一账号，解决轮询暴走问题。\n            - **关键词库扩展**: 内置 30+ 种高频后台指令特征库，覆盖 5 大类主流 Agent 后台操作，识别率提升至 95% 以上。\n\n    *   **v3.3.7 (2025-12-30)**:\n        - **Proxy 核心稳定性修复 (核心致谢 @llsenyue PR #191)**:\n            - **JSON Schema 深度硬化**: 实现了对工具调用 Schema 的递归平坦化与清理，自动将 Gemini 不支持的校验约束（如 `pattern`）迁移至描述字段，解决 Schema 拒绝问题。\n            - **后台任务鲁棒性增强**: 新增后台任务（如摘要生成）检测，自动过滤思维链配置与历史块，并定向转发至 `gemini-2.5-flash` 以确保 100% 成功率。\n            - **思维链签名自动捕获**: 优化了 `thoughtSignature` 的提取与持久化逻辑，解决了多轮对话中因签名丢失导致的 `400` 错误。\n            - **日志体验优化**: 提升了用户消息的日志优先级，确保核心对话信息不被后台任务日志淹没。\n    *   **v3.3.6 (2025-12-30)**:\n        - **OpenAI 图像功能深度适配 (核心致谢 @llsenyue PR #186)**:\n            - **新增图像生成接口**: 完整支持 `/v1/images/generations` 端点，支持 `model`、`prompt`、`n`、`size` 及 `response_format` 等标准参数。\n            - **新增图像编辑与变换接口**: 适配 `/v1/images/edits` 和 `/v1/images/variations` 端点。\n            - **底层协议桥接**: 实现了 OpenAI 图像请求到 Google Internal API (Cloud Code) 的自动结构化映射与身份验证。\n    *   **v3.3.5 (2025-12-29)**:\n        - **核心修复与稳定性增强**:\n            - **修复 Claude Extended Thinking 400 错误 (模型切换场景)**: 解决了在同一会话中从普通模型切换到思维链模型时，因历史消息缺少思维块导致的 Google API 校验失败。现在只要开启 Thinking 模式，系统会自动为合规性补全历史思维块。\n            - **新增 429 错误自动账号轮转 (Account Rotation)**: 优化了重试机制。当请求遇到 `429` (限流/配额)、`403` (权限) 或 `401` (认证失效) 错误时，系统在重试时会 **强制绕过 60s 会话锁定** 并切换到账号池中的下一个可用账号，并实现故障迁移。\n            - **单元测试维护**: 修复了代码库中多个陈旧且破损的单元测试，确保了开发环境的编译与逻辑校验闭环。\n        - **日志系统优化**:\n            - **清理冗余日志**: 移除了配额查询时逐行打印所有模型名称的冗余日志，将详细模型列表信息降级为 debug 级别，显著减少控制台噪音。\n            - **本地时区支持**: 日志时间戳现已自动使用本地时区格式（如 `2025-12-29T22:50:41+08:00`），而非 UTC 时间，便于用户直观查看。\n        - **UI 优化**:\n            - **优化账号额度刷新时间显示**: 增加时钟图标、实现居中对齐与动态颜色反馈（表格与卡片视图同步优化）。\n    *   **v3.3.4 (2025-12-29)**:\n        - **OpenAI/Codex 兼容性大幅增强 (核心致谢 @llsenyue PR #158)**:\n            - **修复图像识别**: 完美适配 Codex CLI 的 `input_image` 块解析，并支持 `file://` 本地路径自动转 Base64 上传。\n            - **Gemini 400 错误治理**: 实现了连续相同角色消息的自动合并，严格遵循 Gemini 角色交替规范，解决此类 400 报错。\n            - **协议稳定性增强**: 优化了 JSON Schema 深度清理（新增对 `cache_control` 的物理隔离）及 `thoughtSignature` 的上下文回填逻辑。\n            - **Linux 构建策略调整**: 由于 GitHub 的 Ubuntu 20.04 运行器资源极度匮乏导致发布挂起，官方版本现回归使用 **Ubuntu 22.04** 环境编译。Ubuntu 20.04 用户建议自行克隆源码完成本地构建，或使用 AppImage 尝试运行。\n    *   **v3.3.3 (2025-12-29)**:\n        - **账号管理增强**:\n            - **订阅等级智能识别**: 新增对账号订阅等级（PRO/ULTRA/FREE）的自动识别、标识与筛选支持。\n            - **多维筛选系统**: 账号管理页引入“全部/可用/低配额/PRO/ULTRA/FREE”多维度筛选 Tab，支持实时计数与联动搜索。\n            - **UI/UX 深度优化**: 采用高感度 Tab 切换设计；重构顶部工具栏布局，引入弹性搜索框与响应式操作按钮，显著提升各分辨率下的空间利用率。\n        - **核心修复**:\n            - **修复 Claude Extended Thinking 400 错误**: 解决了历史 `ContentBlock::Thinking` 消息中缺失 `thought: true` 标记导致的格式校验错误。此修复解决了 95% 以上的 Claude 思维链相关报错，大幅提升多轮对话稳定性。此问题会导致不管是否显式开启 thinking 功能，在多轮对话（特别是使用 MCP 工具调用）时都会出现 `400 INVALID_REQUEST_ERROR`。修复后，所有 thinking blocks 都会被正确标记，上游 API 能够准确识别并处理。\n            - **影响范围**: 此修复解决了 95%+ 的 Claude Extended Thinking 相关 400 错误，大幅提升了 Claude CLI、MCP 工具集成等场景下的多轮对话稳定性。\n    *   **v3.3.2 (2025-12-29)**:\n        - **新增功能 (核心致谢 @XinXin622 PR #128)**:\n            - **Claude 协议联网搜索引用支持**: 实现了将 Gemini 的 Google Search 原始识别结果映射为 Claude 原生的 `web_search_tool_result` 内容块。现在支持在 Cherry Studio 等兼容客户端中直接显示结构化的搜索引文及来源链接。\n            - **Thinking 模式稳定性增强 (Global Signature Store v2)**: 引入了更强大的全局 `thoughtSignature` 存储机制。系统能够实时捕获流式响应中的最新签名，并自动为缺少签名的后续请求（特别是在会话恢复场景下）进行回填，显著减少了 `400 INVALID_ARGUMENT` 报错。\n        - **优化与修复 (Optimizations & Bug Fixes)**:\n            - **数据模型鲁棒性增强**: 统一并重构了内部的 `GroundingMetadata` 数据结构，解决了 PR #128 集成过程中发现的类型冲突与解析异常。\n            - **流式输出逻辑优化**: 优化了 SSE 转换引擎，确保 `thoughtSignature` 在跨多个 SSE 块时能被正确提取与存储。\n    *   **v3.3.1 (2025-12-28)**:\n        - **重大修复 (Critical Fixes)**:\n            - **Claude 协议 400 错误深度修复 (Claude Code 体验优化)**:\n                - **解决缓存控制冲突 (cache_control Fix)**: 解决了在长上下文对话中，由于历史消息中包含 `cache_control` 标记或 `thought: true` 字段引发的上游校验报错。通过\"历史消息去思考化\"策略，完美绕过了 Google API 兼容层的解析 Bug，确保了长会话的稳定性。\n                - **深度 JSON Schema 清理引擎**: 优化了 MCP 工具定义的转换逻辑。现在会自动将 Google 不支持的复杂校验约束（如 `pattern`、`minLength`、`maximum` 等）迁移到描述字段中，既符合上游 Schema 规范，又保留了模型的语义提示。\n                - **协议头合规化**: 移除了系统指令中非标准的 `role`标记，并增强了对 `cache_control` 的显式过滤与拦截，确保生成的 Payload 达到最佳兼容性。\n            - **全协议内置联网工具适配**: 针对用户反馈，现在 **OpenAI、Gemini 和 Claude 协议** 均支持“无需模型后缀”即可触发联网。\n                - **联网探测兼容性增强**: 支持 `googleSearchRetrieval` 等新一代工具定义，并提供统一的 `googleSearch` 载荷标准化映射，确保 Cherry Studio 等客户端的联网开关能完美触发。\n                - **客户端脏数据自动净化**: 新增深度递归清洗逻辑，物理移除 Cherry Studio 等客户端在请求中注入的 `[undefined]` 无效属性，从根源解决 `400 INVALID_ARGUMENT` 报错。\n                - **高品质虚拟模型自动联网**: 进一步扩容高性能模型白名单（补全了 Claude 系列 Thinking 变体等），确保所有顶级模型均能享受原生的联网搜索回显体验。\n        - **核心优化与省流增强 (Optimization & Token Saving)**:\n            - **全链路追踪与闭环审计日志**:\n                - 为每个请求引入 6 位随机 **Trace ID**。\n                - 自动标记请求属性：`[USER]` 为真实对话，`[AUTO]` 为后台任务。\n                - 实现了流式/非流式响应的 **Token 消耗闭环回显**。\n            - **Claude CLI 后台任务智能“截胡” (Token Saver)**:\n                - **精准意图识别**: 新增对标题生成、摘要提取以及系统 Warmup/Reminder 等后台低价值请求的深度识别。\n                - **无感降级转发**: 自动将后台流量重定向至 **gemini-2.5-flash**，确保顶配模型（Sonnet/Opus）的额度仅用于核心对话。\n                - **显著节流**: 单次长会话预计可省下 1.7k - 17k+ 的高价值 Token。\n        - **稳定性增强**: \n            - 修复了由于模型字段定义更新导致的 Rust 编译与测试用例报错，加固了数据模型层（models.rs）的鲁棒性。\n    *   **v3.3.0 (2025-12-27)**:\n        - **重大更新 (Major Updates)**:\n            - **Codex CLI & Claude CLI 深度适配 (核心致谢 @llsenyue PR #93)**: \n                - **全面兼容 Coding Agent**: 实现了对 Codex CLI 的完美支持，包括 `/v1/responses` 端点的深度适配与 shell 工具调用指令的智能转换 (SSOP)。\n                - **Claude CLI 推理增强**: 引入了全局 `thoughtSignature` 存储与回填逻辑，解决了 Claude CLI 使用 Gemini 3 系列模型时的签名校验报错。\n            - **OpenAI 协议栈重构**:\n                - **新增 Completions 接口**: 完整支持 `/v1/completions` 和 `/v1/responses` 路由，兼容更多传统 OpenAI 客户端。\n                - **多模态与 Schema 清洗融合**: 成功整合了自研的高性能图片解析逻辑与社区贡献的高精度 JSON Schema 过滤策略。\n            - **隐私优先的网络绑定控制 (核心致谢 @kiookp PR #91)**:\n                - **默认本地回环**: 反代服务器默认监听 `127.0.0.1`，仅允许本机访问，保障隐私安全。\n                - **可选 LAN 访问**: 新增 `allow_lan_access` 配置开关，开启后监听 `0.0.0.0` 以允许局域网设备访问。\n                - **安全提示**: 前端 UI 提供明确的安全警告及状态提示。\n        - **前端体验升级**: \n            - **多协议端点可视化**: 在 API 反代页面新增端点详情展示，支持对 Chat/Completions/Responses 不同端点的独立快捷复制。\n    *   **v3.2.8 (2025-12-26)**:\n        - **Bug 修复 (Bug Fixes)**:\n            - **OpenAI 协议多模态与图片模型支持**: 修复了在 OpenAI 协议下向视觉模型(如 `gemini-3-pro-image`)发送图片请求时因 `content` 格式不匹配导致的 400 错误。\n            - **视觉能力全面补齐**: 现在 OpenAI 协议支持自动解析 Base64 图片并映射为上游 `inlineData`,使其具备与 Claude 协议同等的图像处理能力。\n    *   **v3.2.7 (2025-12-26)**:\n        - **新功能 (New Features)**:\n            - **开机自动启动**: 新增开机自动启动功能,可在设置页面的\"通用\"标签中一键开启/关闭系统启动时自动运行 Antigravity Tools。\n            - **账号列表分页大小选择器**: 在账号管理页面的分页栏中新增分页大小选择器,支持直接选择每页显示数量(10/20/50/100 条),无需进入设置页面,提升批量操作效率。\n        - **Bug 修复 (Bug Fixes)**:\n            - **JSON Schema 清理逻辑全面增强 (MCP 工具兼容性修复)**:\n                - **移除高级 Schema 字段**: 新增移除 `propertyNames`, `const`, `anyOf`, `oneOf`, `allOf`, `if/then/else`, `not` 等 MCP 工具常用但 Gemini 不支持的高级 JSON Schema 字段，解决 Claude Code v2.0.76+ 使用 MCP 工具时的 400 错误。\n                - **优化递归清理顺序**: 调整为先递归清理子节点再处理父节点，避免嵌套对象被错误序列化到 description 中。\n                - **Protobuf 类型兼容**: 强制将联合类型数组（如 `[\"string\", \"null\"]`）降级为单一类型，解决 \"Proto field is not repeating\" 错误。\n                - **智能字段识别**: 增强类型检查逻辑，确保只在值为对应类型时才移除校验字段，避免误删名为 `pattern` 等的属性定义。\n            - **自定义数据库导入修复**: 修复了\"从自定义 DB 导入\"功能因 `import_custom_db` 命令未注册导致的 \"Command not found\" 错误。现在用户可以正常选择自定义路径的 `state.vscdb` 文件进行账号导入。\n            - **反代稳定性与画图性能优化**:\n                - **智能 429 退避机制**: 深度集成 `RetryInfo` 解析，精准遵循 Google API 的重试指令并增加安全冗余，有效降低账号被封禁风险。\n                - **精准错误分流**: 修正了将频率限制误判为配额耗尽的逻辑（不再误杀包含 \"check quota\" 的报错），确保限流时能自动切换账号。\n                - **画图请求并发加速**: 针对 `image_gen` 类型请求禁用 60s 时间窗口锁定，实现多账号极速轮换，解决画图 429 报错问题。\n    *   **v3.2.6 (2025-12-26)**:\n        - **重大修复 (Critical Fixes)**:\n            - **Claude 协议深度优化 (Claude Code 体验增强)**:\n                - **动态身份映射**: 根据请求模型动态注入身份防护补丁，锁定 Anthropic 原生身份，屏蔽底层中转平台的指令干扰。\n                - **工具空输出补偿**: 针对 `mkdir` 等静默命令，自动将空输出映射为显式成功信号，解决 Claude CLI 任务流中断与幻觉问题。\n                - **全局停止序列配置**: 针对反代链路优化了 `stopSequences`，精准切断流式输出，解决响应尾部冗余导致的解析报错。\n                - **智能 Payload 净化 (Smart Panic Fix)**: 引入了 `GoogleSearch` 与 `FunctionCall` 的互斥检查，并在后台任务（Token Saver）重定向时自动剥离工具负载，根除了 **400 工具冲突 (Multiple tools)** 错误。\n                - **反代稳定性增强 (核心致谢 @salacoste PR #79)**: \n                    - **429 智能退避**: 支持解析上游 `RetryInfo`，在触发限流时自动等待并重试，显著减少账号无效轮换。\n                    - **Resume 兜底机制**: 针对 `/resume` 可能出现的签名失效报错，实现了自动剥离 Thinking 块的二次重试，提升会话恢复成功率。\n                    - **Schema 模式增强**: 增强了 JSON Schema 递归清理逻辑，并增加了对 `enumCaseInsensitive` 等扩展字段的过滤。\n            - **测试套件加固**: 修复了 `mappers` 测试模块中缺失的导入及重复属性错误，并新增了内容块合并与空输出补全测试。\n    *   **v3.2.3 (2025-12-25)**:\n        - **核心增强 (Core Enhancements)**:\n            - **进程管理架构优化 (核心致谢 @Gaq152 PR #70)**: \n                - **精确路径识别**: 引入了基于可执行文件绝对路径的进程匹配机制。在启动、关闭及枚举 PID 时，系统会通过规范化路径 (`canonicalize`) 进行比对。\n                - **管理进程自排除**: 在 Linux 等环境下，系统现能通过对比 `std::env::current_exe()` 路径，杜绝了 Antigravity-Manager 将自身误识别为核心进程而发生的“自杀”现象。\n                - **手动路径自定义**: 在“设置 -> 高级”页面新增了手动指定反重力程序路径的功能。支持 MacOS (.app 目录) 和各平台可执行文件。\n                - **自动探测回退**: 新增路径自动探测按钮，并建立了“手动路径优先 -> 自动搜索 -> 注册表/标准目录”的多级检索链。\n        - **体验优化 (UX Improvements)**:\n            - **路径配置 UI**: 提供了文件选择器与一键重置功能，极大地提升了在非标准目录下部署的灵活性。\n            - **多语言适配**: 完整同步了路径管理相关的中英文 I18n 资源。\n    *   **v3.2.2 (2025-12-25)**:\n        - **核心更新 (Core Updates)**:\n            - **全量日志持久化系统升级**: 接入 `tracing-appender` 与 `tracing-log`，实现了终端与文件的双通道日志记录。现在包括系统启动、反代请求全链路（请求/响应/耗时）以及第三方库底层流水在内的所有调试信息，均会实时、自动地归档至本地 `app.log` 中。\n            - **Project ID 获取逻辑容错增强**: 引入了随机 `project_id` 兜底机制。针对部分无 Google Cloud 项目权限的账号，系统现在会自动生成随机 ID 以确保反代服务及配额查询能正常运行，解决了“账号无资格获取 cloudaicompanionProject”导致的报错中断。\n            - **全场景稳定性加固**: 引入 `try_init` 模式修复了由于日志订阅器重复初始化导致的系统 Panic 崩溃，显著提升了在不同运行环境下的兼容性。\n            - **平滑日志清理**: 优化了日志清理逻辑，采用“原地截断”技术。现在点击“清理日志”后，后续的操作记录依然能无缝地继续保存，解决了旧版本清理后记录失效的问题。\n            - **Google 免费额度智能路由 (Token Saver):** \n                - **后台任务拦截**: 独家首创针对 Claude Code 客户端后台任务的深度报文识别技术。系统能精准识别标题生成、摘要提取以及 **Next Prompt Suggestions** 等非核心交互请求 (`write a 5-10 word title`, `Concise summary`, `prompt suggestion generator`)。\n                - **无感熔断重定向**: 自动将上述高频低价值请求（Haiku 模型）路由至 **gemini-2.5-flash** 免费节点，杜绝了后台轮询对核心付费/高价值账号配额的隐形消耗，同时保留了完整的产品功能体验。\n                - **双轨日志审计**: 终端与日志文件中新增请求类型标记。正常对话请求显示为 `检测到正常用户请求`（保留原映射），后台任务显示为 `检测到后台自动任务`（重定向），消耗去向一目了然。\n            - **时间窗口会话锁定 (Session Sticky):** 实施了基于滑动时间窗口（60秒）的账号锁定策略。确保单一会话内的连续交互强制绑定同一账号，有效解决了因多账号轮询导致的上下文漂移问题，大幅提升了长对话的连贯性。\n        - **Bug 修复 (Bug Fixes)**:\n            - **Claude 思维链签名 (Signature) 校验最终修复**: 解决了在多轮对话中，由于历史 Assistant 消息缺少 `thoughtSignature` 而导致的 `400 INVALID_ARGUMENT` 错误。\n            - **Gemini 模型映射误匹配修复**: 修正了模型路由关键词匹配逻辑，解决了 `gemini` 单词中包含 `mini` 从而被误判定为 OpenAI 分组的问题。现在 Gemini 模型能正确实现原名穿透。\n            - **注入策略优化**: 改进了虚拟思维块的注入逻辑，限制为仅针对当前回复（Pre-fill）场景，确保历史记录的原始签名不被破坏。\n            - **环境静默清理**: 清理了全工程 20 余处过时的编译警告、冗余导入与未使用变量，系统运行更轻快。\n        - **兼容性说明 (Compatibility)**:\n            - **Kilo Code 专项优化**: 在快速接入章节新增了针对 Kilo Code 的配置指南与避坑说明。\n    *   **v3.2.1 (2025-12-25)**:\n        - **新特性 (New Features)**:\n            - **自定义 DB 导入**: 支持从任意路径选择并导入 `state.vscdb` 文件，方便从备份或其他位置恢复账号数据。\n            - **Project ID 实时同步与持久化**: 引入配额查询伴随加载机制。现在手动或自动刷新配额时，系统会实时捕捉并保存最新的 `project_id` 到本地。\n            - **OpenAI & Gemini 协议全方位增强**:\n                - **全协议路由统一**: 现在 **Gemini 协议也已支持自定义模型映射**。至此，OpenAI、Claude、Gemini 三大协议已全部打通智能路由逻辑。\n                - **工具调用 (Tool Call) 全面支持**: 无论是非流式还是流式响应，现在都能正确处理并下发联网搜索等 `functionCall` 结果，解决了“空输出”报错。\n                - **思维链 (Thought) 实时显示**: 能够自动提取并呈现 Gemini 2.0+ 的推理过程，并通过 `<thought>` 标签在输出中展示，推理信息不再丢失。\n                - **高级参数映射补齐**: 新增对 `stop` 序列、`response_format` (JSON 模式) 以及 `tools` 自定义工具的完整映射支持。\n        - **Bug 修复 (Bug Fixes)**:\n            - **OpenAI 自定义映射 404 修复**: 修正了模型路由选取逻辑。现在无论何种协议，均能正确使用映射后的上游模型 ID，解决自定义映射报 404 的问题。\n            - **Linux 进程管理最终优化**: 完成了针对 Linux 系统下切换账号时的进程关闭逻辑。目前已全面支持智能进程识别与分阶段退出。\n            - **OpenAI 协议适配修复**: 修复了部分客户端发送 `system` 消息导致报错的问题。\n            - **反代重试机制优化**: 引入智能错误识别与重试上限机制。\n            - **JSON Schema 深度清理 (兼容性增强)**: 建立了统一的清理机制，自动滤除 Gemini 不支持的 20 余种扩展字段（如 `multipleOf`、`exclusiveMinimum`、`pattern`、`const`、`if-then-else` 等），解决 CLI 工具通过 API 调用工具时的 400 报错。\n            - **单账号切换限制修复**: 解决了当只有一个账号时切换按钮被禁用的问题。现在即使只有单个账号，也能通过点击切换按钮手动执行 Token 注入流程。\n            - **Claude 思维链校验错误修复**: 解决了启用思维链时 assistant 消息必须以思维块开头的结构校验问题。现在系统支持自动注入占位思维块以及从文本中自动还原 `<thought>` 标签，确保 Claude Code 等高级工具的长对话稳定性。\n    *   **v3.2.0 (2025-12-24)**:\n        - **核心架构重构 (Core Architecture Refactor)**:\n            - **API 反代引擎重写**: 采用模块化设计重构 `proxy` 模块，实现了 `mappers` (协议转换)、`handlers` (请求处理)、`middleware` (中间件) 的完全解耦，大幅提升代码可维护性与扩展性。\n            - **Linux 进程管理优化**: 引入智能进程识别算法，精准区分主进程与 Helper 进程，支持 SIGTERM -> SIGKILL 兜底逻辑。\n        - **GUI 交互革命**: 全面重构仪表盘，引入平均配额监控与“最佳账号推荐”算法。\n        - **账号管理增强**: 支持多种格式（JSON/正则）批量导入 Token，优化 OAuth 授权流程。\n        - **协议与路由扩展**: 原生支持 OpenAI, Anthropic (Claude Code) 协议；新增“模型路由中心”，实现高精度 ID 映射。\n        - **多模态优化**: 深度适配 Imagen 3，支持 100MB 超大 Payload 与多种比例参数透传。\n        - **安装体验优化**: 正式支持 Homebrew Cask 安装；内置 macOS “应用损坏”自动化排查指南。\n        - **提示**：目前 `antigravity` 与 Google 官方工具重名。为确保安装的是本项目，目前推荐使用上述原始文件安装。后续我们将推出官方 Tap。\n        - **全局上游代理**: 统一管理内外网请求，支持 HTTP/SOCKS5 协议及热重载。\n\n    </details>\n## 👥 核心贡献者 (Contributors)\n\n<a href=\"https://github.com/lbjlaq\"><img src=\"https://github.com/lbjlaq.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"lbjlaq\"/></a>\n<a href=\"https://github.com/XinXin622\"><img src=\"https://github.com/XinXin622.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"XinXin622\"/></a>\n<a href=\"https://github.com/llsenyue\"><img src=\"https://github.com/llsenyue.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"llsenyue\"/></a>\n<a href=\"https://github.com/salacoste\"><img src=\"https://github.com/salacoste.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"salacoste\"/></a>\n<a href=\"https://github.com/84hero\"><img src=\"https://github.com/84hero.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"84hero\"/></a>\n<a href=\"https://github.com/karasungur\"><img src=\"https://github.com/karasungur.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"karasungur\"/></a>\n<a href=\"https://github.com/marovole\"><img src=\"https://github.com/marovole.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"marovole\"/></a>\n<a href=\"https://github.com/wanglei8888\"><img src=\"https://github.com/wanglei8888.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"wanglei8888\"/></a>\n<a href=\"https://github.com/yinjianhong22-design\"><img src=\"https://github.com/yinjianhong22-design.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"yinjianhong22-design\"/></a>\n<a href=\"https://github.com/Mag1cFall\"><img src=\"https://github.com/Mag1cFall.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"Mag1cFall\"/></a>\n<a href=\"https://github.com/AmbitionsXXXV\"><img src=\"https://github.com/AmbitionsXXXV.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"AmbitionsXXXV\"/></a>\n<a href=\"https://github.com/fishheadwithchili\"><img src=\"https://github.com/fishheadwithchili.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"fishheadwithchili\"/></a>\n<a href=\"https://github.com/ThanhNguyxn\"><img src=\"https://github.com/ThanhNguyxn.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"ThanhNguyxn\"/></a>\n<a href=\"https://github.com/Stranmor\"><img src=\"https://github.com/Stranmor.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"Stranmor\"/></a>\n<a href=\"https://github.com/Jint8888\"><img src=\"https://github.com/Jint8888.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"Jint8888\"/></a>\n<a href=\"https://github.com/0-don\"><img src=\"https://github.com/0-don.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"0-don\"/></a>\n<a href=\"https://github.com/dlukt\"><img src=\"https://github.com/dlukt.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"dlukt\"/></a>\n<a href=\"https://github.com/Silviovespoli\"><img src=\"https://github.com/Silviovespoli.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"Silviovespoli\"/></a>\n<a href=\"https://github.com/i-smile\"><img src=\"https://github.com/i-smile.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"i-smile\"/></a>\n<a href=\"https://github.com/jalen0x\"><img src=\"https://github.com/jalen0x.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"jalen0x\"/></a>\n<a href=\"https://linux.do/u/wendavid\"><img src=\"https://linux.do/user_avatar/linux.do/wendavid/48/122218_2.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"wendavid\"/></a>\n<a href=\"https://github.com/byte-sunlight\"><img src=\"https://github.com/byte-sunlight.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"byte-sunlight\"/></a>\n<a href=\"https://github.com/jlcodes99\"><img src=\"https://github.com/jlcodes99.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"jlcodes99\"/></a>\n<a href=\"https://github.com/Vucius\"><img src=\"https://github.com/Vucius.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"Vucius\"/></a>\n<a href=\"https://github.com/Koshikai\"><img src=\"https://github.com/Koshikai.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"Koshikai\"/></a>\n<a href=\"https://github.com/hakanyalitekin\"><img src=\"https://github.com/hakanyalitekin.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"hakanyalitekin\"/></a>\n<a href=\"https://github.com/Gok-tug\"><img src=\"https://github.com/Gok-tug.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"Gok-tug\"/></a>\n\n感谢所有为本项目付出汗水与智慧的开发者。\n\n## 🤝 鸣谢项目 (Special Thanks)\n\n本项目在开发过程中参考或借鉴了以下优秀开源项目的思路或代码，排名不分先后：\n\n*   [learn-claude-code](https://github.com/shareAI-lab/learn-claude-code)\n*   [Practical-Guide-to-Context-Engineering](https://github.com/WakeUp-Jin/Practical-Guide-to-Context-Engineering)\n*   [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)\n*   [antigravity-claude-proxy](https://github.com/badrisnarayanan/antigravity-claude-proxy)\n*   [aistudio-gemini-proxy](https://github.com/zhongruichen/aistudio-gemini-proxy)\n*   [gcli2api](https://github.com/su-kaka/gcli2api)\n\n*   **版权许可**: 基于 **CC BY-NC-SA 4.0** 许可，**严禁任何形式的商业行为**。\n*   **安全声明**: 本应用所有账号数据加密存储于本地 SQLite 数据库，除非开启同步功能，否则数据绝不离开您的设备。\n\n---\n\n<div align=\"center\">\n  <p>如果您觉得这个工具有所帮助，欢迎在 GitHub 上点一个 ⭐️</p>\n  <p>Copyright © 2025 Antigravity Team.</p>\n</div>\n"
  },
  {
    "path": "README_EN.md",
    "content": "# Antigravity Tools 🚀\n> Professional AI Account Management & Protocol Proxy System (v4.1.30)\n\n<div align=\"center\">\n  <img src=\"public/icon.png\" alt=\"Antigravity Logo\" width=\"120\" height=\"120\" style=\"border-radius: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.15);\">\n\n  <h3>Your Personal High-Performance AI Dispatch Gateway</h3>\n  <p>Seamlessly proxy Gemini & Claude. OpenAI-Compatible. Privacy First.</p>\n  \n  <p>\n    <a href=\"https://github.com/lbjlaq/Antigravity-Manager\">\n      <img src=\"https://img.shields.io/badge/Version-4.1.30-blue?style=flat-square\" alt=\"Version\">\n    </a>\n    <img src=\"https://img.shields.io/badge/Tauri-v2-orange?style=flat-square\" alt=\"Tauri\">\n    <img src=\"https://img.shields.io/badge/Backend-Rust-red?style=flat-square\" alt=\"Rust\">\n    <img src=\"https://img.shields.io/badge/Frontend-React-61DAFB?style=flat-square\" alt=\"React\">\n    <img src=\"https://img.shields.io/badge/License-CC--BY--NC--SA--4.0-lightgrey?style=flat-square\" alt=\"License\">\n  </p>\n\n  <p>\n    <a href=\"#-features\">Features</a> • \n    <a href=\"#-gui-overview\">GUI Overview</a> • \n    <a href=\"#-architecture\">Architecture</a> • \n    <a href=\"#-installation\">Installation</a> • \n    <a href=\"#-quick-integration\">Integration</a>\n  </p>\n\n  <p>\n    <a href=\"./README.md\">简体中文</a> | \n    <strong>English</strong>\n  </p>\n</div>\n\n---\n\n**Antigravity Tools** is an all-in-one desktop application designed for developers and AI enthusiasts. It perfectly combines multi-account management, protocol conversion, and smart request scheduling to provide you with a stable, high-speed, and low-cost **Local AI Relay Station**.\n\nBy leveraging this app, you can transform common Web Sessions (Google/Anthropic) into standardized API interfaces, completely eliminating the protocol gap between different providers.\n\n## 💖 Sponsors\n\n| Sponsor | Description |\n| :---: | :--- |\n| <img src=\"docs/images/packycode_logo.png\" width=\"200\" alt=\"PackyCode Logo\"> | Thanks to **PackyCode** for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relays for various services such as Claude Code, Codex, and Gemini. PackyCode provides a special offer for users of this project: Register using [this link](https://www.packyapi.com/register?aff=Ctrler) and enter the **\"Ctrler\"** coupon code when topping up to enjoy a **10% discount**. |\n| <img src=\"docs/images/AICodeMirror.jpg\" width=\"200\" alt=\"AICodeMirror Logo\"> | Thanks to **AICodeMirror** for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, supporting enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for Antigravity-Manager users: register via [this link](https://www.aicodemirror.com/register?invitecode=MV5XUM) to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off! |\n\n### ☕ Support\n\nIf you find this project helpful, feel free to buy me a coffee!\n\n<a href=\"https://www.buymeacoffee.com/Ctrler\" target=\"_blank\"><img src=\"https://cdn.buymeacoffee.com/buttons/v2/default-green.png\" alt=\"Buy Me A Coffee\" style=\"height: 60px !important; width: 217px !important;\"></a>\n\n| Alipay | WeChat Pay | Buy Me a Coffee |\n| :---: | :---: | :---: |\n| ![Alipay](./docs/images/donate_alipay.png) | ![WeChat](./docs/images/donate_wechat.png) | ![Coffee](./docs/images/donate_coffee.png) |\n\n## 🌟 Detailed Feature Matrix\n\n### 1. 🎛️ Smart Account Dashboard\n*   **Global Real-time Monitoring**: Instant insight into the health of all accounts, including average remaining quotas for Gemini Pro, Gemini Flash, Claude, and Gemini Image generation.\n*   **Smart Recommendation**: The system uses a real-time algorithm to filter and recommend the \"Best Account\" based on quota redundancy, supporting **one-click switching**.\n*   **Active Account Snapshot**: Visually displays the specific quota percentage and the last synchronization time of the currently active account.\n\n### 2. 🔐 Professional AI Account Management & Proxy System\n*   **OAuth 2.0 Authorization (Auto/Manual)**: Pre-generates a copyable authorization URL so you can finish auth in any browser; after the callback, the app auto-completes and saves the account (use “I already authorized, continue” if needed).\n*   **Multi-dimensional Import**: Supports single token entry, JSON batch import, and automatic hot migration from V1 legacy databases.\n*   **Gateway-level Views**: Supports switching between \"List\" and \"Grid\" views. Provides 403 Forbidden detection, automatically marking and skipping accounts with permission anomalies.\n\n### 3.  Protocol Conversion & Relay (API Proxy)\n*   **Multi-Protocol Adaptation (Multi-Sink)**:\n    *   **OpenAI Format**: Provides `/v1/chat/completions` endpoint, compatible with 99% of existing AI apps.\n    *   **Anthropic Format**: Provides native `/v1/messages` interface, supporting all features of **Claude Code CLI** (e.g., chain-of-thought, system prompts).\n    *   **Gemini Format**: Supports direct calls from official Google AI SDKs.\n*   **Smart Self-healing**: When a request encounters `429 (Too Many Requests)` or `401 (Expired)`, the backend triggers **millisecond-level automatic retry and silent rotation**, ensuring business continuity.\n\n### 4. 🔀 Model Router Center\n*   **Series-based Mapping**: Classify complex original model IDs into \"Series Groups\" (e.g., routing all GPT-4 requests uniformly to `gemini-3-pro-high`).\n*   **Expert Redirection**: Supports custom regex-level model mapping for precise control over every request's landing model.\n*   **Tiered Routing [New]**: Automatically prioritizes models based on account tiers (Ultra/Pro/Free) and reset frequencies to ensure stability for high-volume users.\n*   **Silent Background Downgrading [New]**: Intelligently identifies background tasks (e.g., Claude CLI title generation) and reroutes them to Flash models to preserve premium quota.\n\n### 5. 🎨 Multimodal & Imagen 3 Support\n*   **Advanced Image Control**: Supports precise control over image generation tasks via OpenAI `size` (e.g., `1024x1024`, `16:9`) parameters or model name suffixes.\n*   **Enhanced Payload Support**: The backend supports payloads up to **100MB** (configurable), more than enough for 4K HD image recognition and processing.\n\n##  GUI Overview\n\n| | |\n| :---: | :---: |\n| ![Dashboard - Global Quota Monitoring & One-click Switch](docs/images/dashboard-light.png) <br> Dashboard | ![Account List - High-density Quota Display & Smart 403 Labeling](docs/images/accounts-light.png) <br> Account List |\n| ![About Page - About Antigravity Tools](docs/images/about-dark.png) <br> About Page | ![API Proxy - Service Control](docs/images/v3/proxy-settings.png) <br> API Proxy |\n| ![Settings - General Config](docs/images/settings-dark.png) <br> Settings | |\n\n### 💡 Usage Examples\n\n| | |\n| :---: | :---: |\n| ![Claude Code Web Search - Structured source and citation display](docs/images/usage/claude-code-search.png) <br> Claude Code Web Search | ![Cherry Studio Deep Integration - Native echo of search citations and source links](docs/images/usage/cherry-studio-citations.png) <br> Cherry Studio Integration |\n| ![Imagen 3 Advanced Drawing - Perfect restoration of Prompt artistic conception and details](docs/images/usage/image-gen-nebula.png) <br> Imagen 3 Advanced Drawing | ![Kilo Code Integration - Multi-account high-speed rotation and model penetration](docs/images/usage/kilo-code-integration.png) <br> Kilo Code Integration |\n\n## 🏗️ Architecture\n\n```mermaid\ngraph TD\n    Client([External Apps: Claude Code/NextChat]) -->|OpenAI/Anthropic| Gateway[Antigravity Axum Server]\n    Gateway --> Middleware[Middleware: Auth/Rate Limit/Logs]\n    Middleware --> Router[Model Router: ID Mapping]\n    Router --> Dispatcher[Dispatcher: Rotation/Weights]\n    Dispatcher --> Mapper[Request Mapper]\n    Mapper --> Upstream[Upstream: Google/Anthropic API]\n    Upstream --> ResponseMapper[Response Mapper]\n    ResponseMapper --> Client\n```\n\n## 📥 Installation\n\n### Option A: Terminal Installation (Recommended)\n\n#### Cross-Platform One-Line Install Scripts\n\nAutomatically detects your OS, architecture, and package manager — one command to download and install.\n\n**Linux / macOS:**\n```bash\ncurl -fsSL https://raw.githubusercontent.com/lbjlaq/Antigravity-Manager/v4.1.30/install.sh | bash\n```\n\n**Windows (PowerShell):**\n```powershell\nirm https://raw.githubusercontent.com/lbjlaq/Antigravity-Manager/main/install.ps1 | iex\n```\n\n> **Supported formats**: Linux (`.deb` / `.rpm` / `.AppImage`) | macOS (`.dmg`) | Windows (NSIS `.exe`)\n>\n> **Advanced usage**: Install a specific version `curl -fsSL ... | bash -s -- --version 4.1.30`，dry-run mode `curl -fsSL ... | bash -s -- --dry-run`\n\n#### macOS - Homebrew\nIf you have [Homebrew](https://brew.sh/) installed, you can also install via:\n\n```bash\n# 1. Tap the repository\nbrew tap lbjlaq/antigravity-manager https://github.com/lbjlaq/Antigravity-Manager\n\n# 2. Install the app\nbrew install --cask antigravity-tools\n```\n> **Tip**: If you encounter permission issues, add the `--no-quarantine` flag.\n\n#### Arch Linux\nYou can choose to install via the one-click script or Homebrew:\n\n**Option 1: One-click script (Recommended)**\n```bash\ncurl -sSL https://raw.githubusercontent.com/lbjlaq/Antigravity-Manager/main/deploy/arch/install.sh | bash\n```\n\n**Option 2: via Homebrew** (If you have [Linuxbrew](https://sh.brew.sh/) installed)\n```bash\nbrew tap lbjlaq/antigravity-manager https://github.com/lbjlaq/Antigravity-Manager\nbrew install --cask antigravity-tools\n```\n\n#### Other Linux Distributions\nThe AppImage will be automatically symlinked to your binary path with executable permissions.\n\n### Option B: Manual Download\nDownload from [GitHub Releases](https://github.com/lbjlaq/Antigravity-Manager/releases):\n*   **macOS**: `.dmg` (Universal, Apple Silicon & Intel)\n*   **Windows**: `.msi` or portable `.zip`\n*   **Linux**: `.deb` or `AppImage`\n\n### Option C: Docker Deployment (Recommended for NAS/Servers)\nIf you prefer running in a containerized environment, we provide a native Docker image. This image supports the v4.0.3 Native Headless architecture, automatically hosts frontend static resources, and allows for direct browser-based management.\n\n```bash\n# Option 1: Direct Run (Recommended)\n# - API_KEY: Required. Used for AI request authentication.\n# - WEB_PASSWORD: Optional. Used for Web UI login. Defaults to API_KEY if NOT set.\ndocker run -d --name antigravity-manager \\\n  -p 8045:8045 \\\n  -e API_KEY=sk-your-api-key \\\n  -e WEB_PASSWORD=your-login-password \\\n  -e ABV_MAX_BODY_SIZE=104857600 \\\n  -v ~/.antigravity_tools:/root/.antigravity_tools \\\n  lbjlaq/antigravity-manager:latest\n\n# Forgot keys? Run `docker logs antigravity-manager` or `grep -E '\"api_key\"|\"admin_password\"' ~/.antigravity_tools/gui_config.json`\n\n#### 🔐 Authentication Scenarios\n*   **Scenario A: Only `API_KEY` is set**\n    - **Web Login**: Use `API_KEY` to access the dashboard.\n    - **API Calls**: Use `API_KEY` for AI request authentication.\n*   **Scenario B: Both `API_KEY` and `WEB_PASSWORD` are set (Recommended)**\n    - **Web Login**: **Must** use `WEB_PASSWORD`. Using API Key will be rejected (more secure).\n    - **API Calls**: Continue to use `API_KEY`. This allows you to share the API Key with team members while keeping the password for administrative access only.\n\n#### 🆙 Upgrade Guide for Older Versions\nIf you are upgrading from v4.0.1 or earlier, your installation won't have a `WEB_PASSWORD` set by default. You can add one using any of these methods:\n1.  **Web UI (Recommended)**: Log in using your existing `API_KEY`, go to the **API Proxy Settings** page, find the **Web UI Management Password** section below the API Key, set your new password, and save.\n2.  **Environment Variable (Docker)**: Stop the old container and start the new one with the added parameter `-e WEB_PASSWORD=your_new_password`. **Note: Environment variables have the highest priority and will override any changes in the UI.**\n3.  **Config File (Persistent)**: Directly edit `~/.antigravity_tools/gui_config.json` and add/modify `\"admin_password\": \"your_new_password\"` inside the `proxy` object.\n    - *Note: `WEB_PASSWORD` is the environment variable name, while `admin_password` is the JSON key in the config file.*\n\n> [!TIP]\n> **Priority Logic**:\n> - **Environment Variable** (`WEB_PASSWORD`) has the highest priority. If set, the application will always use it and ignore values in the configuration file.\n> - **Configuration File** (`gui_config.json`) is used for persistent storage. When you change the password via Web UI and save, it is written here.\n> - **Fallback**: If neither is set, it falls back to `API_KEY`; if even `API_KEY` is missing, a random one is generated.\n\n# Option 2: Use Docker Compose\n# 1. Enter the Docker directory\ncd docker\n# 2. Start the service\ndocker compose up -d\n```\n> **Access URL**: `http://localhost:8045` (Admin Console) | `http://localhost:8045/v1` (API Base)\n> **System Requirements**:\n> - **RAM**: **1GB** recommended (minimum 256MB).\n> - **Persistence**: Mount `/root/.antigravity_tools` to persist your data.\n> - **Architecture**: Supports x86_64 and ARM64.\n> **See**: [Docker Deployment Guide (docker)](./docker/README.md)\n\n### 🛠️ Troubleshooting\n\n#### macOS says \"App is damaged\"?\nDue to macOS security gatekeeper, non-App Store apps might show this. Run this in Terminal to fix:\n```bash\nsudo xattr -rd com.apple.quarantine \"/Applications/Antigravity Tools.app\"\n```\n\n## 🔌 Quick Integration Examples\n\n### 🔐 OAuth Authorization Flow (Add Account)\n1. Go to `Accounts` → `Add Account` → `OAuth`.\n2. The dialog pre-generates an authorization URL before you click any button. Click the URL to copy it to the system clipboard, then open it in the browser you prefer and complete authorization.\n3. After consent, the browser opens a local callback page and shows “✅ Authorized successfully!”.\n4. The app automatically continues the flow and saves the account; if it doesn’t, click “I already authorized, continue” to finish manually.\n\n> Note: the auth URL contains a one-time local callback port. Always use the latest URL shown in the dialog. If the app isn’t running or the dialog is closed during auth, the browser may show `localhost refused connection`.\n\n### How to use with Claude Code CLI?\n1. Start Antigravity service in the \"API Proxy\" tab.\n2. In your terminal:\n```bash\nexport ANTHROPIC_API_KEY=\"sk-antigravity\"\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:8045\"\nclaude\n```\n\n### How to use with OpenCode?\n1. Go to **API Proxy** → **External Providers** → click the **OpenCode Sync** card.\n2. Click **Sync** to generate `~/.config/opencode/opencode.json`:\n    - Creates a dedicated provider `antigravity-manager` (does not overwrite google/anthropic providers)\n    - Optional: Check **Sync accounts** to export `antigravity-accounts.json` (plugin-compatible v3 format) for the OpenCode plugin\n3. Click **Clear Config** to remove Manager configuration and clean up legacy entries; click **Restore** to revert from backup.\n4. On Windows, the path is `C:\\Users\\<User>\\.config\\opencode\\` (same `~/.config/opencode` rule).\n\n**Quick verification commands:**\n```bash\n# Test antigravity-manager provider (supports --variant)\nopencode run \"test\" --model antigravity-manager/claude-sonnet-4-5-thinking --variant high\n\n# If opencode-antigravity-auth is installed, verify google provider still works independently\nopencode run \"test\" --model google/antigravity-claude-sonnet-4-5-thinking --variant max\n```\n\n### How to use in Python?\n```python\nimport openai\n\nclient = openai.OpenAI(\n    api_key=\"sk-antigravity\",\n    base_url=\"http://127.0.0.1:8045/v1\"\n)\n\nresponse = client.chat.completions.create(\n    model=\"gemini-3-flash\",\n    messages=[{\"role\": \"user\", \"content\": \"Hello, please introduce yourself\"}]\n)\nprint(response.choices[0].message.content)\n```\n\n## 📝 Developer & Community\n\n*   **Changelog**:\n    *   **v4.1.30 (2026-03-15)**:\n        -   **[Core Optimization] Implementation of multi-level fallback mechanism for fetchAvailableModels (PR #2329)**:\n            -   **Endpoint Fallback Strategy**: Introduced an automatic Sandbox -> Daily -> Prod endpoint fallback mechanism for the `fetchAvailableModels` API. When requests encounter `429 (Too Many Requests)` or `5xx` server errors, the system automatically and smoothly switches to alternative endpoints, significantly improving the stability of quota refreshes and model list retrieval.\n            -   **Logic Alignment**: Aligned the error handling and retry logic for quota acquisition with core API handlers, ensuring consistent behavior of the request pipeline under extreme conditions.\n        -   **[Core Fix] Optimize Gemini SSE Stream Error Handling to Prevent Transfer Encoding Errors (PR #2322)**:\n            -   **Error Encapsulation**: Fixed an issue where Gemini SSE streams would yield raw errors upon upstream failure, causing clients to encounter `TransferEncodingError`. The system now catches stream errors and encapsulates them into standard JSON data frames, ensuring graceful connection closure and clear error feedback to the frontend.\n            -   **Cross-Protocol Alignment**: This fix has been applied to both the Gemini native handler and the Claude protocol mapper, ensuring consistency and robustness across different streaming output paths.\n    *   **v4.1.29 (2026-03-12)**:\n        -   **[IMPORTANT WARNING] Google Risk Control & Third-Party Tool Risks**:\n            -   Due to tightened Google risk control, third-party tools may be suspended for violating Terms of Service when used with Antigravity, Gemini CLI, or Gemini Code Assist.\n            -   Accessing Antigravity, Gemini CLI, or Gemini Code Assist using third-party software, tools, or services (e.g., using OpenClaw and Antigravity OAuth) violates applicable terms and policies. Such actions may lead to account suspension or termination. It is recommended to only use the switching feature.\n            -   **Appeal Link**: If you believe your account was suspended by mistake, please submit an appeal via [this link](https://forms.gle/hGzM9MEUv2azZsrb9).\n            -   Stay tuned to our [Telegram Channel](https://t.me/AntigravityManager) for latest updates.\n            -   ![Risk Warning](docs/images/CleanShot%202026-03-12%20at%2009.34.34@2x.png)\n        -   **[Core Feature] Account-Aware Dynamic Model Remapping & Fallback (PR #2286)**:\n            -   **Dynamic Fallback Logic**: Resolved `404/400` errors caused by inconsistent model tier access (e.g., `high` vs `low`) across different accounts. The system now automatically executes smooth fallbacks between models in the same series (e.g., `gemini-3.1-pro-high` -> `gemini-3.1-pro-low` -> default tier) based on the active account's permissions.\n            -   **Real-time Permission Validation**: Dynamically validates target model availability via account file data before requests enter the handler, achieving true \"account-aware\" scheduling.\n            -   **Remapping Priority Optimization**: Established a scientific priority chain: `API Deprecation Rules > Account-Aware Fallback > User-Defined Mapping > System Default Mapping`.\n            -   **Documentation Sync**: Added `docs/model-remapping-logic.md` to fully document the complex remapping logic flow.\n        -   **[Core Fix] Enhanced Windows CLI Detection & Path Scanning (PR #2298)**:\n            -   **Active Path Scanning**: Introduced automatic scanning for `APPDATA`, `LOCALAPPDATA`, and `NVM_HOME` to ensure precise identification even if the CLI is not in the system `PATH`.\n            -   **Script Handling Optimization**: Improved invocation of `.cmd` and `.bat` scripts on Windows, resolving issues with unstable version retrieval during direct execution.\n            -   **Security Hardening**: Added path security validation logic with absolute path checks and character filtering to prevent command injection risks.\n        -   **[Continuous Integration] Integrated GitHub Actions CI Workflow (PR #2298)**:\n            -   **Automated Quality Control**: Built a basic CI pipeline covering Rust formatting, linting, and cross-platform compilation tests to enhance code compliance and delivery stability.\n\n    *   **v4.1.28 (2026-03-03)**:\n        -   **[IMPORTANT WARNING] Google Risk Control & Third-Party Tool Risks**:\n            -   Due to tightened Google risk control, third-party tools may be suspended for violating Terms of Service when used with Antigravity, Gemini CLI, or Gemini Code Assist.\n            -   Accessing Antigravity, Gemini CLI, or Gemini Code Assist using third-party software, tools, or services (e.g., using OpenClaw and Antigravity OAuth) violates applicable terms and policies. Such actions may lead to account suspension or termination.\n            -   **Appeal Link**: If you believe your account was suspended by mistake, please submit an appeal via [this link](https://forms.gle/hGzM9MEUv2azZsrb9).\n            -   **Future Plan & Roadmaps**:\n                -   New versions will be pushed in the future (potentially separating account switching and proxy features into independent modules).\n                -   However, due to work commitments, there may be delays. We appreciate your understanding.\n                -   Stay tuned to our WeChat Official Account **Ctrler** or Telegram channel [AntigravityManager](https://t.me/AntigravityManager).\n            -   **Please use this project with caution.**\n        -   **[Core Fix] Normalized Rate Limit Locking Across All Model Series (Fix Issue #2209)**:\n            -   **Unified Normalization**: Fixed an issue where Claude and Gemini models' 429 (Too Many Requests) errors failed to trigger proper locking due to non-normalized limit keys.\n            -   **Enhanced Circuit Breaker Integration**: Ensured the built-in circuit breaker accurately intercepts exhausted accounts using normalized model IDs (e.g., `claude`, `gemini-3-flash`), eliminating redundant 90s wait times even when \"Quota Protection\" is disabled.\n        -   **[Core Fix] Resolve 400 Errors from Erroneous thinkingLevel Injection in Gemini Adaptive Mode (Fix Issue #2208)**:\n            -   **Root Cause**: Adaptive recognition logic in 4.1.27 misidentified Gemini models (e.g., `gemini-3.1-pro-high`) as supporting `thinkingLevel`, which is exclusive to Vertex AI Claude. Gemini models only accept `thinkingBudget`, causing Google API to reject requests with `400 INVALID_ARGUMENT`.\n            -   **Narrowed Triggers**: Corrected the `thinkingLevel` injection trigger from `contains(\"gemini-3\")` to `contains(\"claude\")`, ensuring it only applies to Claude protocols. Gemini models now correctly fallback to a safe `thinkingBudget: 24576` in adaptive mode.\n        -   **[Core Fix] Resolve Claude Code 4.1.27+ Web Search (Internal Tool) Failure (Issue #2224)**:\n            -   **Hybrid Tool Support**: Overcame Gemini v1internal API limitations regarding concurrent use of `googleSearch` and custom `functionDeclarations`.\n            -   **Intelligent Perception Injection**: Refactored the tool injection engine to automatically enable both built-in search and developer tools on Gemini 2.0+ and 3.0 models.\n            -   **Across-Protocol Alignment**: Applied the fix across OpenAI and Gemini Native protocols to ensure consistent search capabilities for high-performance models.\n        -   **[Core Fix] Resolve 400 Errors from Missing thought_signature in gemini-3-flash Function Calls (Fix Issue #2167)**:\n            -   **Root Cause**: Model recognition failed to include `gemini-3-flash` in the \"thinking model\" category, leading to missing `thoughtSignature` in initial function calls (no session cache) and causing `400 INVALID_ARGUMENT`.\n            -   **Protocol Fixes**: Added `is_gemini_flash_thinking` logic across OpenAI, Claude, and Gemini Native mappers to automatically inject the `skip_thought_signature_validator` sentinel when the session cache is empty.\n        -   **[Core Fix] Token Statistics Timezone Fix (Fix Issue #2214)**:\n            -   **Automatic Timezone Alignment**: Switched the base time for Token statistics from UTC to system Local Time.\n            -   **Global Multi-timezone Support**: Introduced SQLite `'localtime'` conversion mechanism. Regardless of the user's location, the timeline on statistics charts will automatically align with their system clock, completely resolving data misalignment issues for Beijing Time or other non-UTC timezones.\n    *   **v4.1.27 (2026-03-01)**:\n        -   **[Core Fix] Proxy Config Initialization & Tool Image Preservation (Issue #2156)**:\n            -   **Default Initialization**: Fixed compilation errors caused by missing `global_system_prompt`, `proxy_pool`, and `image_thinking_mode` fields in `ProxyConfig`'s default initialization.\n            -   **Exhaustive Pattern Matching**: Added a catch-all branch (`_ => {}`) in the `OpenAIContentBlock` enum matching, eliminating potential non-exhaustive match compilation errors.\n            -   **Unconditional Image Preservation**: Removed the redundant `preserve_tool_result_images` switch. The image data structure in `tool_result` is now unconditionally retained and formatted as `inlineData` for downstream models, significantly simplifying the underlying logic.\n        -   **[Feature Enhancement] Update docker-compose.yml namespace and default vars (PR #2185)**:\n            -   **Namespace Update**: Changed the default built image name from `antigravity-manager` to `lbjlaq/antigravity-manager`.\n            -   **Env Vars Placeholder**: Added default value placeholders syntax for environment variables to allow overriding via host env vars or `.env` files.\n        -   **[Core Fix] Full Compatibility for OpenCode Thinking Budget Parameters (Issue #2186)**:\n            -   **Architecture Support**: Resolved the issue where Vercel AI SDK (`@ai-sdk/anthropic`) combined with OpenCode would fail to start and throw `AI_UnsupportedFunctionalityError: 'thinking requires a budget'` due to the native snake_case `budget_tokens` naming.\n            -   **Dual-field Output**: Automatically outputs both standard `budget_tokens` and camelCase `budgetTokens` fields when syncing model configurations to externals like OpenCode / Claude CLI.\n            -   **Server-side Adaptation**: Backend config parser now natively supports both variants.\n        -   **[Core Fix] Resolve Infinite Retry and Routing Deadlock for Depleted Free Accounts (Issue #2184)**:\n            -   **Root Cause**: Addressed a defect where the Google API `fetchAvailableModels` did not correctly return `remainingFraction` under specific payloads. Due to a missing `project` identifier, the endpoint inaccurately reported `1.0` (100%) for quota-exhausted accounts (HTTP 429). This caused the smart routing algorithm to persistently assign requests to disabled accounts, leading to prolonged retries and incorrect quota dashboard displays.\n            -   **Payload Correction**: Reconstructed the quota refresh request to accurately inject the `{\"project\": project_id}` structure into the payload. This restores accurate quota perception and achieves 100% API compatibility without breaking native metadata fields like `supportsThinking`.\n            -   **Smart Self-healing**: By precisely reading accurate quotas, the system now identifies the depleted status of free accounts in real-time, zeroing their availability and seamlessly triggering the multi-account Smart Status Self-healing mechanism to eliminate hangs and timeout issues.\n        -   **[Core Fix] Resolve Gemini Image Average Quota Displaying as 0 on Dashboard (Issue #2160)**:\n            -   **Matching Update**: Updated the image model matching logic in the Dashboard from hardcoded `gemini-3-pro-image` to include the latest `gemini-3.1-flash-image`.\n            -   **Config Sync**: Added UI definitions for the new image model version in `modelConfig.ts`, ensuring icons and labels are rendered correctly.\n    *   **v4.1.26 (2026-02-27)**:\n        -   **[Feature Enhancement] Improved Quota Refresh Logic to Include Disabled Accounts**:\n            -   **Relaxed Filtering**: Both \"Refresh All\" and batch refresh operations no longer skip accounts marked as `disabled` or `proxy_disabled`.\n            -   **Auto-Recovery**: Enables users to attempt re-activating and syncing accounts that were disabled due to token expiration or temporary errors directly from the UI.\n        -   **[Core Fix] Resolve cmd Black Window Flashing on Windows During Background Tasks**:\n            -   **Silent Execution**: Encapsulated and injected the `CREATE_NO_WINDOW` flag into `std::process::Command`, eliminating the visual distraction of command prompt windows flashing briefly when the app invokes system components (e.g., version probes, auto-updates) on Windows, ensuring completely borderless and silent execution.\n    *   **v4.1.25 (2026-02-27)**:\n        -   **[Core Feature] Dynamic Image Model & New Architecture Support**:\n            -   **Dynamic Parsing**: Removed hardcoded restrictions for `gemini-3-pro-image`. Introduced a `clean_image_model_name` utility to intelligently strip suffixes (e.g., `-4k`, `-16x9`), fully supporting future models like `gemini-3.1-flash-image`.\n            -   **Adaptive Quota**: Optimized `normalize_to_standard_id` to broadly match the `image` keyword, ensuring new models correctly trigger quota protection mechanisms.\n        -   **[Core Feature] Chat Completions Image Interception Support**:\n            -   **Seamless Integration**: Chat streams in OpenAI and Claude protocols now intelligently detect image generation intent. When an `image` model is requested, standard text completion requests are silently redirected to the advanced image engine.\n            -   **Streaming Echo**: Upon completion, the image URL is streamed back in Markdown format (`![Generated Image](url)`), perfectly adapting to all Markdown-supported chat clients.\n        -   **[Core Fix] Resolve Redirect 404 and Parameter Passthrough Failure**:\n            -   **404 Elimination**: Removed residual hardcoded legacy models in underlying calls, eradicating `404 Not Found` crashes and account exhaustion caused by model inconsistencies.\n            -   **Precise Parameter Inheritance**: Fixed the behavior where the system forced a default `1024x1024` when parameters were omitted. Now, if the model has a suffix (e.g., `gemini-3-pro-image-16x9-4k`), the backend strictly parses and prioritizes the suffix resolution for image generation.\n    *   **v4.1.24 (2026-02-26)**:\n        -   **[Feature Adjustment] Disabled Automatic Warmup Scheduler, Retained Manual Warmup**:\n            -   **Change Summary**: To reduce unnecessary background resource usage, the background scheduler for Automatic Warmup (Smart Warmup) has been commented out in this version.\n            -   **UI Hidden**: The \"Smart Warmup\" configuration section in the Settings page has been hidden.\n            -   **Manual Retained**: Manual warmup functionality in the Account Management page remains fully functional.\n            -   **Restoration Guide**: Users who require automatic warmup can clone the repository and uncomment the `start_scheduler` calls in `src-tauri/src/lib.rs` and the related UI in `Settings.tsx` before rebuilding.\n        -   **[Core Fix] Smart Version Fingerprint Selection & Startup Panic Fix (Issue #2123)**:\n            -   **Root Cause**: 1) `KNOWN_STABLE_VERSION` in `constants.rs` was hardcoded to an outdated version. When local detection failed, this old version was used as `x-client-version`, causing Google to reject Gemini 3.1 Pro requests. 2) The new remote version fetching logic was executed within its `LazyLock` initializer on the main thread (Tokio async context), triggering a `Cannot block the current thread` panic.\n            -   **Fix**: 1) Implemented a \"Smart Max Version\" strategy: `max(local_version, remote_version, 4.1.27)`. 2) Refactored the network probe to run in a dedicated OS thread over `mpsc` channels, safely bypassing async runtime restrictions. This ensures that the client fingerprint always meets upstream requirements and the application starts reliably.\n        -   **[Core Fix] Dynamic Model maxOutputTokens Limit System (Replaces hardcoded approach in PR #2119)**:\n            -   **Root Cause**: Some clients send `maxOutputTokens` exceeding the physical limits of models (e.g., Flash capped at 64k), causing `400 INVALID_ARGUMENT` from the upstream API.\n            -   **Three-Tier Limit Architecture**:\n                -   **Tier 1 (Dynamic Priority)**: Reads real-time quota data from accounts.\n                -   **Tier 2 (Static Default Table)**: `model_limits.rs` with known defaults (e.g., Flash: 65536).\n                -   **Tier 3 (Global Fallback)**: Default 131072.\n            -   **Implementation Details**: Injected clamping logic in `wrap_request()` to ensure parameter compliance.\n    *   **v4.1.23 (2026-02-25)**:\n        -   **[Security Enhancement] Aligned application-layer and low-level protocol fingerprints with native clients to improve request stability and anti-interception capabilities.**\n        -   **[Core Fix] Resolve Account Data Corruption and Background Task Infinite Loops (PR #2094)**:\n            -   **Root Cause**: When a user enters an excessively large interval value (e.g., 999999999), `interval * 60 * 1000` exceeds the JS engine's signed 32-bit integer limit (`2,147,483,647ms`). The browser silently clamps the `setInterval` delay to 1ms, causing the frontend to fire `refreshAllQuotas`/`syncAccountFromDb` thousands of times per second, flooding the backend with concurrent writes to the same `[uuid].json` file, interleaving byte streams, and permanently corrupting account data.\n            -   **Atomic File Writes (`account.rs`)**: `save_account` now writes to a UUID-suffixed temp file first, then atomically replaces the target via `fs::rename` (POSIX) / `MoveFileExW` (Windows), consistent with the existing `save_account_index` implementation, eliminating race-condition corruption at the source.\n            -   **setInterval Overflow Guard (`BackgroundTaskRunner.tsx`)**: Applied `Math.min(..., 2147483647)` to the computed delay for both the refresh and sync timers, preventing INT32_MAX overflow from silently clamping intervals to 1ms.\n            -   **Input Validation (`Settings.tsx`)**: Updated the `max` attribute for `refresh_interval` and `sync_interval` inputs from `60` to `35791` (35791 min × 60000 < INT32_MAX), and added `NaN` fallback (defaults to 1) with range clamping `[1, 35791]` in `onChange` to block invalid values at the source.\n        -   **[Core Optimization] OAuth Token Exchange Only: Remove JA3 Fingerprinting and Dynamic User-Agent Masking**:\n            -   **Pure Requests**: Specifically for `exchange_code` (initial authorization) and `refresh_access_token` (silent renewal) requests, the Chrome JA3 fingerprint emulation has been removed to revert to standard pure TLS characteristics.\n            -   **Dynamic UA**: During token exchange, the system automatically extracts the compiled version (`CURRENT_VERSION`) to construct a dedicated `User-Agent` (e.g., `vscode/1.X.X (Antigravity/4.1.27)`), matching the pure TLS connection.\n        -   **[Feature Enhancement] API Proxy Page and Settings Model Lists Now Fully Dynamic**:\n            -   **Root Cause**: The \"API Proxy → Supported Models & Integration\" list, the target model dropdown in \"Model Router\", and the \"Settings → Pinned Quota Models\" list all previously read only from the static `MODEL_CONFIG`, causing dynamically issued models (e.g., `GPT-OSS 120B`, `Gemini 3.1 Pro (High)`) to never appear in these lists.\n            -   **Fix**:\n                -   Refactored the `useProxyModels` Hook: account `quota.models` dynamic data is now the primary data source, aggregating `display_name` (as the primary label) and `name` (as the model ID) across all accounts; `MODEL_CONFIG` is used only for icon/group styling and as a static fallback when no account data is available.\n                -   Added automatic lazy-loading: since `ApiProxy` itself does not call `fetchAccounts`, the Hook now auto-triggers a fetch when the store is empty, ensuring dynamic models appear regardless of the navigation path.\n                -   Refactored `PinnedQuotaModels` component: applies the same strategy and fixes the issue where previously-pinned \"thinking\" models displayed as \"Unknown\", now correctly resolving their real `display_name`.\n            -   **Deduplication**: All lists deduplicate by original `name` (lowercase) and additionally filter out `-thinking` suffix entries from `MODEL_CONFIG` (these variants are already covered by the `supports_thinking` flag in account data).\n    *   **v4.1.22 (2026-02-21)**:\n        -   **[Important Warning] 2api Risk Control Alert**:\n            -   Due to recent Google risk control measures, utilizing 2api features significantly increases the probability of your account being flagged.\n            -   **Highly Recommended**: To ensure account safety and interaction stability, we strongly advise reducing or discontinuing the use of 2api features. Support for the more native and stable **gRPC (`application/grpc`)** or **gRPC-Web (`application/grpc-web`)** protocols is currently under active testing. If you have any testing experience, ideas, or suggestions, please feel free to reach out for a discussion, or create a new branch to explore with us!\n            -   <details><summary>📸 View gRPC to OpenAI Proxy Test Screenshot</summary><img src=\"docs/images/usage/grpc-test.png\" alt=\"gRPC Test\" width=\"600\"></details>\n        -   **[Core Optimization] Claude Sonnet 4.5 to 4.6 Migration (PR #2014)**:\n            -   **Model Upgrade**: Introduced `claude-sonnet-4-6` and `claude-sonnet-4-6-thinking` as primary models.\n            -   **Seamless Transition**: Automatically redirect `claude-sonnet-4-5` (legacy) to `4.6`.\n            -   **Universal Alignment**: Updated all 12 locale files, UI labels (Sonnet 4.6, Sonnet 4.6 TK, Opus 4.6 TK), and proxy presets.\n        -   **[Core Optimization] Gemini Pro Model Migration (PR #2063)**: Migrated `gemini-pro-high/low` to `gemini-3.1-pro` to align with the latest Google API naming conventions.\n        -   **[Core Architecture] i18n Framework & Structured Model Configuration (PR #2040)**:\n            -   **Reconstruction**: Introduced a new i18n translation framework, decoupling hardcoded model display logic into a structured `MODEL_CONFIG`.\n            -   **Logic Adaptation**: Integrated dynamic deduplication based on i18n tags across account tables, detail dialogs, and settings, resolving the persistent Gemini 3.1 Pro quota duplication issue.\n            -   **Localization Polish**: Optimized and corrected version descriptions across all 12 locales, upgrading `Claude 4.5` to the official `4.6` version and unifying `G3` references to `G3.1`.\n        -   **[Core Fix] Claude Opus 4.6 Thinking Mode 400 Error (Claude Protocol)**:\n            -   **Parameter Alignment**: Fixed the `400 INVALID_ARGUMENT` error return for `claude-opus-4-6-thinking` under the Claude protocol. By enforcing alignment of `thinkingBudget` (24576) and `maxOutputTokens` (57344), and removing incompatible `stopSequences` in this mode, ensuring request parameters are 100% consistent with the successful OpenAI protocol. This improves compatibility with native Claude protocol clients.\n    *   **v4.1.21 (2026-02-17)**:\n        -   **[Core Fix] Cherry Studio / Claude Protocol Compatibility (Fix Issue #2007)**:\n            -   **maxOutputTokens Capping**: Fixed `400 INVALID_ARGUMENT` errors caused by Cherry Studio sending excessive `maxOutputTokens` (128k). The system now automatically caps Claude protocol output to **65536**, ensuring requests remain within Gemini's limits.\n            -   **Adaptive Thinking Alignment**: Optimized `thinking: { type: \"adaptive\" }` behavior for Gemini models in Claude protocol. It now maps to a fixed thinking budget of **24576** (aligned with OpenAI protocol), resolving Gemini Vertex AI incompatibility with `thinkingBudget: -1` and significantly improving stability in Cherry Studio.\n        -   **[Core Fix] Enable Custom Protocol in Production (PR #2005)**:\n            -   **Protocol Fix**: Enabled `custom-protocol` feature by default, resolving issues with custom protocols (e.g., `tauri://`) failing to load in production builds, ensuring stability for local resources.\n        -   **[Core Optimization] Tray Icon & Window Lifecycle Management**:\n            -   **Smart Tray**: Introduced `AppRuntimeFlags` for state management, linking window close behavior with tray status.\n            -   **Behavior Polish**: When the tray is enabled, closing the window now hides it instead of exiting; when disabled, the application exits normally, providing a more intuitive desktop experience.\n        -   **[Core Enhancement] Linux Version Detection & HTTP Client Robustness**:\n            -   **Version Parsing**: Enhanced Linux version extraction logic (`extract_semver`) to accurately identify semantic versions from complex command outputs, improving auto-update and environment detection accuracy.\n            -   **Client Fallback**: Added automatic fallback mechanisms for HTTP client construction. If proxy configuration fails, the system automatically reverts to no-proxy mode or default settings, preventing total application failure due to network misconfiguration.\n        -   **[Core Fix] Cherry Studio Web Search Empty Response (/v1/responses)**:\n            -   **SSE Event Completion**: Rewrote `create_codex_sse_stream` to emit the complete SSE event lifecycle required by the OpenAI Responses API specification (`response.output_item.added`, `content_part.added/done`, `output_item.done`, `response.completed`), resolving the issue where Cherry Studio failed to assemble response content due to missing events.\n            -   **Web Search Injection Fix**: Filtered out `builtin_web_search` tool declarations sent by Cherry Studio to prevent conflicts with `inject_google_search_tool`, ensuring the Google Search tool is correctly injected.\n            -   **Search Citation Echo**: Added `groundingMetadata` parsing to the Codex streaming response, enabling search query and source citation echo in web search results.\n        -   **[Optimization] Claude Protocol Web Search & Thinking Stability (PR #2007)**:\n            -   **Remove Web Search Downgrade**: Removed the aggressive model fallback logic for web search in the Claude protocol mapper, preventing unnecessary model downgrades.\n            -   **Remove Thinking History Downgrade**: Removed the `should_disable_thinking_due_to_history` check that could permanently disable thinking mode due to imperfect message history, now relying on `thinking_recovery` mechanism for automatic repair.\n        -   **UI Improvement (Fix #2008)**: Enhanced the readability of cooldown times by changing the text color to blue.\n    *   **v4.1.20 (2026-02-16)**:\n        *   Fixed `400 INVALID_ARGUMENT` error in Claude Proxy during tool calls.\n        *   Removed redundant `role: \"user\"` fields in protocol translation for better Google API compatibility.\n        *   Enhanced JSON Schema cleaning with `anyOf`/`oneOf` best-match selection and constraint-to-description migration.\n        *   Optimized token budget capping logic for Gemini Thinking models (strict 24576 limit).\n        *   Improved model name detection for experimental Gemini models containing `-thinking`.\n        *   **[Core Fix] Resolve Image Generation Quota Sync Issue (Issue #1995)**:\n            *   **Relaxed Model Filtering**: Optimized the quota fetching logic to include `image` and `imagen` keywords, ensuring image model quota info is correctly synchronized.\n            *   **Instant Refresh Mechanism**: Added an asynchronous global quota refresh trigger immediately after successful image generation, providing real-time feedback for remaining quotas in the UI.\n        *   **[Core Fix] Resolve OpenAI Stream Collector Tool Call Merging Bug (PR #1994)**:\n            *   **ID Conflict Validation**: Introduced ID checking during stream aggregation to prevent multiple tool calls from being incorrectly merged due to index overlap.\n            *   **Index Stability Optimization**: Enhanced index assignment in streaming output to ensure tool call indices remain monotonically increasing across multiple data chunks.\n        *   **[Core Optimization] Ultimate Request Identity Camouflage**:\n            *   **Dynamic Version Spoofing**: Implemented an intelligent version detection mechanism. Antigravity now automatically reads the locally installed version to construct the User-Agent, saying goodbye to the hardcoded \"1.0.0\" era.\n            *   **Docker Fallback Strategy**: For headless environments (Docker/Linux Server), a \"Known Stable Version\" fingerprint library is built-in. When a local client cannot be detected, it automatically masquerades as the latest stable client (e.g., v1.16.5), ensuring the server always sees a legitimate official client.\n            *   **Full-Dimensional Header Injection**: Completed the injection of critical fingerprint headers such as `X-Client-Name`, `X-Client-Version`, `X-Machine-Id`, and `X-VSCode-SessionId`, achieving pixel-level camouflage from the network layer to the application layer, further reducing the probability of 403 risk controls.\n        *   **[Core Feature] Background Refresh Toggle & Settings Hot-Save**:\n            *   **Independent Toggle**: Added a dedicated toggle for \"Background Auto Refresh\" in settings, allowing finer control over background tasks.\n            *   **Hot-Save**: Implemented hot-save mechanism for settings (Auto Refresh, Smart Warmup, Quota Protection), applying changes instantly without manual saving.\n        *   **[Logic Optimization] Decoupled Smart Warmup from Quota Protection**:\n            *   **Unlocked**: Completely removed the forced binding between \"Quota Protection\" and \"Smart Warmup\". Enabling Quota Protection now only enforces \"Background Auto Refresh\" (for quota monitoring) and no longer forces warmup requests.\n            *   **[Important Recommendation]**: It is recommended to temporarily disable \"Quota Protection\" and \"Background Auto Refresh\" features in this version to avoid potential issues caused by frequent requests.\n    *   **v4.1.19 (2026-02-15)**:\n        -   **[Core Fix] Resolve Claude Code CLI Empty Text Block Error (Fix #1974)**:\n            -   **Field Missing Fix**: Resolved the `Field required` error from upstream APIs caused by empty text blocks (`text: \"\"`) sent by Claude Code CLI during tool use.\n            -   **Empty Value Filtering**: Added automatic filtering and cleanup for invalid empty text blocks in the protocol translation layer.\n        -   **[Core Feature] Gemini Model MCP Tool Name Fuzzy Matching**:\n            -   **Hallucination Fix**: Implemented an intelligent fuzzy matching algorithm to address the issue where Gemini models often hallucinate incorrect MCP tool names (e.g., calling `mcp__puppeteer_navigate` instead of the registered `mcp__puppeteer__puppeteer_navigate`).\n            -   **Triple Matching Strategy**: Introduced suffix matching, containment matching, and Token overlap scoring mechanisms, significantly improving the success rate of MCP tool calls by Gemini models.\n        -   **[Core Fix] Opencode Sync Logic Correction (Fix #1972)**:\n            -   **Missing Model Fix**: Resolved the issue where the `claude-opus-4-6-thinking` model definition was missing during Opencode CLI synchronization, ensuring proper recognition and invocation by the client.\n    *   **v4.1.18 (2026-02-14)**:\n        -   **[Core Upgrade] Full Implementation of JA3 Fingerprint Spoofing (Chrome 123)**:\n            -   **Anti-Bot Evasion**: Integrated `rquest` with BoringSSL to perfectly mimic Chrome 123's TLS fingerprint (JA3/JA4), effectively resolving 403/Captchas issues from strict upstream providers.\n            -   **Global Application**: Applied spoofing to both global shared clients and the proxy pool manager, ensuring all outbound traffic (from quota fetching to chat completion) appears as legitimate browser requests.\n        -   **[Refactor] Universal Stream Handling (Issue #1955)**:\n            -   **Dual-Core Compatibility**: Refactored SSE handling and debug logging to support `Box<dyn Stream>`, enabling unified compatibility for both `reqwest` (standard) and `rquest` (spoofed) response streams and resolving underlying type conflicts.\n        -   **[Core Feature] Account Error Details Expansion**:\n            -   **In-depth Insights**: Introduced a detailed error modal for \"Disabled\" and \"403 Forbidden\" accounts, automatically displaying underlying API error reasons (e.g., `invalid_grant`).\n            -   **Verification Link Detection**: [New] Intelligently detects Google verification/appeal links in error messages, supporting direct one-click navigation within the modal to accelerate recovery.\n            -   **Time Calibration**: Fixed a bug where \"Detection Time\" was incorrectly displayed as a future date due to unit conversion errors.\n        -   **[i18n] Full Multilingual Localization Completion**:\n            -   **All Languages Supported**: Synchronized account details and error status entries across all 12 supported languages (AR, ES, JA, KO, MY, PT, RU, TR, VI, EN, and ZH-Hans/Hant).\n            -   **Localization Refinement**: Optimized terminology for various locales (especially Japanese, Turkish, and Traditional Chinese), ensuring a professional native experience for users worldwide.\n        -   **[Core Fix] Resolve Quota Matching Failure for Image Model Suffixes (Issue #1955)**:\n            -   **Normalization Optimization**: Fixed an issue where `gemini-3-pro-image` variants with resolution or aspect-ratio suffixes (e.g., `-4k`, `-16x9`) failed to normalize correctly, leading to precise matching failures in the quota system.\n            -   **Quota Alignment**: Ensures all image model variants are correctly mapped to their standard IDs, accurately triggering account quota protection and resolving the \"No accounts available with quota\" error.\n    *   **v4.1.17 (2026-02-13)**:\n        -   **[UX] Auto-Update Experience Upgrade (PR #1923)**:\n            -   **Background Download**: Implemented silent background downloading of updates, no longer blocking user operations during the process.\n            -   **Progress Feedback**: Added a download progress bar to provide real-time status feedback.\n            -   **Restart Prompt**: A more user-friendly restart prompt appears after download completion, supporting \"Restart Now\" or \"Restart Later\".\n            -   **Logic Optimization**: Prioritized checking `updater.json` to reduce direct dependency on GitHub API, improving check speed.\n        -   **[Documentation] Cross-Platform Install Scripts (PR #1931)**:\n            -   **One-Click Install**: Updated Option A in README to recommend the cross-platform one-click installation script.\n        -   **[Community] Added Telegram Channel Entry**:\n            -   **Community Card**: Added a Telegram Channel card to the \"Settings -> About\" page, allowing users to quickly join the official channel for the latest updates.\n            -   **Layout Optimization**: Adjusted the grid layout of cards on the About page to fit 5 columns, ensuring a clean and organized interface.\n    *   **v4.1.16 (2026-02-12)**:\n        -   **[Core Fix] Resolve Claude Protocol (Thinking Model) 400 Errors (V4 Scheme)**:\n            -   **Protocol Alignment**: Completely fixed the `400 Invalid Argument` error caused by parameter structure mismatch when calling models like Claude 3.7/4.5 Thinking via proxy.\n            -   **Unified Injection**: Deprecated the conflicting root-level `thinking` field injection. Now uniformly uses the `generationConfig.thinkingConfig` nested structure recommended by Google's native protocol.\n            -   **Budget Adaptation**: Adapted a default 16k Thinking Budget for Claude models and resolved compilation/runtime exceptions caused by Rust borrow checker conflicts.\n        -   **[Bug Fix] Resolve OpenAI Streaming Usage Duplication (Issue #1915)**:\n            -   **Token Explosion Fix**: Fixed an issue in `stream=true` mode where the `usage` field was incorrectly appended to every data chunk, causing clients (like Cline/Roo Code) to report exponentially inflated token usage.\n        -   **[Core Feature] Enable Native Auto-Update for Linux Platform (PR #1891)**:\n            -   **Full Platform Coverage**: Added support for `linux-x86_64` and `linux-aarch64` platforms in `updater.json`, enabling Linux AppImage users to receive auto-update notifications.\n            -   **Workflow Optimization**: Automatically detects and reads `.AppImage.sig` signature files for Linux builds, completing the auto-update loop for macOS, Windows, and Linux.\n        -   **[New Feature] Cross-Platform One-Line Install Scripts (PR #1892)**:\n            -   **Simplified Installation**: Added `install.sh` (Linux/macOS) and `install.ps1` (Windows), supporting fully automated download, installation, and configuration via simple `curl` or `irm` commands.\n            -   **Smart Detection**: Automatically identifies OS, architecture, and package managers (DEB/RPM/AppImage/DMG/NSIS), with support for specific version pinning and Dry-Run mode.\n        -   **[Core Optimization] Decouple OpenCode Config from Local Binary & Custom Network Support (Issue #1869)**:\n            -   **Environment Decoupling**: The backend no longer enforces the presence of the `opencode` binary, allowing sync status management via configuration files in isolated environments like Docker.\n            -   **Custom BaseURL**: Added a \"Custom Manager BaseURL\" setting in the frontend, supporting manual specification of the Manager access address, perfectly resolving connection issues in Docker Compose networking and custom reverse proxy scenarios.\n            -   **Full Localization**: Added English and Chinese i18n support for the new features and fixed JSX rendering exceptions in the OpenCode sync modal.\n        -   **[UI Fix] Resolve indentation inconsistency in API proxy Python templates (PR #1879)**:\n            -   **Display Optimization**: Removed redundant leading spaces from Python code integration snippets to ensure copied code is immediately runnable without manual indentation adjustments.\n        -   **[Core Fix] Resolve effortLevel conflict in Gemini Image Generation caused by keyword matching (PR #1873)**:\n            -   **Logic Conflict Fix**: Completely fixed the HTTP 400 error where `gemini-3-pro-image` and its 4k/2k variants were incorrectly identified as supporting Adaptive Thinking due to the `gemini-3-pro` keyword, leading to the erroneous injection of `effortLevel`.\n        -   **[Docs Update] Full Guide for Gemini 3 Pro (Imagen 3) Image Generation**:\n            -   **Deep Dive**: Added [Gemini 3 Pro Image Generation Guide](docs/gemini-3-image-guide.md), providing detailed technical specs for aspect ratio mapping, quality levels, Image-to-Image API support, and magic suffix usage.\n        -   **[Installation] Official Homebrew Cask Maintenance**:\n            -   **Version Sync**: Updated `antigravity-tools.rb` Cask to v4.1.16, ensuring macOS and Linux users get the latest stable build via `brew install`.\n            -   **Parameter Scrubbing**: Added specific filtering for image generation models at the proxy layer to ensure incompatible generation parameters are no longer injected into non-thinking models.\n    *   **v4.1.15 (2026-02-11)**:\n        -   **[Core Feature] Enable Native Auto-Update for macOS and Windows (PR #1850)**:\n            -   **End-to-End Auto-Update**: Enabled the native Tauri updater plugin, supporting in-app update checks, downloads, and installations.\n            -   **Release Workflow Fix**: Completely fixed the logic for generating update metadata (`updater.json`) in the Release workflow. The system now automatically builds a complete update index from `.sig` signature files, supporting darwin-aarch64, darwin-x86_64, and windows-x86_64 architectures.\n            -   **Seamless Experience**: Integrated with the existing frontend update notification components to achieve a fully automated update loop from release to installation.\n        -   **[Core Fix] Resolve 400 Errors Caused by Empty Project ID During Account Switching (PR #1852)**:\n            -   **Empty Value Filtering**: Added filtering logic for empty `project_id` strings at the Proxy layer.\n            -   **Self-Correction**: Detecting an empty `project_id` now triggers an automatic re-fetch process, effectively resolving the \"Invalid project resource name projects/\" error mentioned in Issue #1846 and #1851.\n        -   **[Troubleshooting] Resolving HTTP 404 \"Resource projects/... not found\" Errors (Issue #1858)**:\n            -   **Verify Project ID**: Log in to the [Google Cloud Console](https://console.cloud.google.com/) and search for the specific Project ID (e.g., `bold-spark-xxx`) mentioned in the error. If the project is missing, create a new one and enable the necessary Vertex AI APIs.\n            -   **Reset Account Session**: Try removing and re-adding your account within the Antigravity app to clear any stale session data.\n            -   **CLI-Based Verification**: We recommend re-authenticating via the Gemini CLI (`gcloud auth login`) and ensuring that your project is correctly configured using `gcloud config set project`.\n        -   **[Troubleshooting] Resolving HTTP 403 \"Forbidden\" Errors (Issue #1834)**:\n            -   **Check Verification Link**: Look for a message in the API response like \"To continue, verify your account at...\". If present, follow the link to complete Google's verification process.\n            -   **Verify Plan Eligibility**: Check our [FAQ page](https://antigravity.google/docs/faq#why-am-i-ineligible-for-a-google-one-ai-plan) to ensure your account meets the requirements for Google One AI or Gemini Code Assist plans.\n            -   **Self-Recovery**: Some 403 errors (e.g., triggered by risk controls or quota adjustments) may resolve automatically after waiting for a period of time.\n    *   **v4.1.15 (2026-02-11)**:\n        -   **[Core Fix] Cloudflared Persistence Support (Issue #1805)**:\n            -   **Persistence**: Resolved the issue where Cloudflared (CF Tunnel) settings, including Tunnel Token, Mode, and HTTP/2 preference, were lost after restarting the app.\n            -   **Hot-Sync Implementation**: Integrated real-time persistence for all Cloudflared settings. Mode switching, Token updates (on blur), and HTTP/2 toggles are now immediately saved to the configuration file.\n        -   **[Core Fix] Fix 403 Forbidden Marking During Warmup (PR #1803)**:\n            -   **Forbidden Detection**: Resolved the issue where accounts returning 403 during the Warmup process were not marked as `is_forbidden`.\n            -   **Automatic Skip**: Detecting a 403 during Warmup now immediately marks and persists the forbidden status, ensuring the account is skipped in subsequent scheduling, warmup, and quota checks.\n        -   **[UI Optimization] Mini View Status Display & Interaction Enhancement (PR #1816)**:\n            -   **Status Indicator Dot**: added a request status dot at the bottom of the Mini View. Shows green for success (200-399) and red for failure, providing instant feedback on the latest request.\n            -   **Model Name Fallback**: improved model name display logic. When `mapped_model` is empty, it now falls back to the original model ID instead of showing \"Unknown\", increasing clarity.\n            -   **Refresh Animation**: optimized the refresh button animation, applying the spin effect directly to the `RefreshCw` icon for a more refined interactive experience.\n        -   **[Core Feature] Image Generation imageSize Parameter Support**:\n            -   **Direct Parameter Support**: Added direct support for Gemini native `imageSize` parameter, available across all protocols (OpenAI/Claude/Gemini).\n            -   **Parameter Priority**: Implemented clear parameter priority logic: `imageSize` parameter > `quality` parameter inference > model suffix inference.\n            -   **Full Protocol Compatibility**: OpenAI Chat API, Claude Messages API, and Gemini native protocol all support directly specifying resolution (\"1K\"/\"2K\"/\"4K\") via the `imageSize` field.\n            -   **Backward Compatibility**: Fully compatible with existing `quality` parameter and model suffix methods, without affecting existing code.\n        -   **[Core Feature] Opencode Provider Isolation & Cleanup Workflow (PR #1820)**:\n            -   **Isolated Sync Logic**: Implemented isolated synchronization for Opencode provider to prevent state pollution and ensure data integrity.\n            -   **Cleanup Workflow**: Added resource cleanup workflow for better resource management and system efficiency.\n            -   **Enhanced Stability**: Improved the stability and reliability of the synchronization process.\n    *   **v4.1.15 (2026-02-11)**:\n        -   **[Core Feature] Homebrew Cask Installation Detection & Support (PR #1673)**:\n            -   **App Upgrade**: Added detection logic for Homebrew Cask installations. If the app was installed via Cask, users can now trigger the `brew upgrade --cask` flow directly within the app for a seamless upgrade experience.\n        -   **[Core Fix] Gemini Image Generation Quota Protection (PR #1764)**:\n            -   **Protection Active**: Fixed an issue where text requests could wrongly consume image quota, and ensured correct interception for `gemini-3-pro-image` when the image quota is exhausted.\n        -   **[UI Optimization] Fix Navbar Boundaries & Display Issues (PR #1636)**:\n            -   **Boundary Fix**: Fixed issues where the right-side menu in the navigation bar could exceed boundaries or display incompletely at specific window widths.\n            -   **Compatibility**: This merge preserves new features like Mini View from the main branch, applying only necessary style corrections.\n            -   **Responsive Enhancement**: Adjusted navigation menu breakpoints, raising the text capsule threshold to 1120px. This ensures long English labels automatically switch to a compact icon mode on narrower viewports, maintaining a clean and balanced layout.\n        -   **[Core Fix] Resolve Stack Overflow in Complex JSON Schema Processing (Issue #1781)**:\n            -   **Security Hardening**: Introduced `MAX_RECURSION_DEPTH` (10) for deep recursive logic like `flatten_refs`, effectively preventing crashes caused by circular references or excessively deep nesting.\n        -   **[Core Fix] Resolve Incorrect Concatenation of Multiple Tool Calls in Streaming Output (Issue #1786)**:\n            -   **Index Correction**: Fixed the index assignment logic for `tool_calls` in `create_openai_sse_stream`, ensuring multiple tool calls within the same chunk have independent and sequential `index` values, preventing parsing failures caused by concatenated arguments.\n        -   **[Core Fix] Resolve Thinking Signature Errors in Claude Multi-Turn Conversations (Issue #1790)**:\n            -   **Signature Injection & Downgrade**: Added automatic signature injection for historical thought blocks in the OpenAI translation layer. When no valid signature is available, thought blocks are automatically downgraded to plain text blocks, resolving the HTTP 400 errors encountered with Claude-opus-thinking models during multi-turn chats.\n        -   **[Core Fix] Resolve 503 Error Caused by Google Cloud Project ID Fetch Failure (Issue #1794)**:\n            -   **Automatic Fallback**: Fixed a bug where accounts with insufficient permissions were skipped during official project ID retrieval. The system now safely falls back to a verified stable Project ID (`bamboo-precept-lgxtn`), ensuring uninterrupted API requests.\n        -   **[i18n] Enhanced Internationalization for Settings and ApiProxy (PR #1789)**:\n            -   **Refactoring**: Replaced hardcoded Chinese strings in `Settings.tsx` and `ApiProxy.tsx` with `t()` internationalization calls.\n            -   **Translation Expansion**: Synchronized localization entries for Korean, Myanmar, Portuguese, Russian, Turkish, Vietnamese, Traditional Chinese, and Simplified Chinese.\n        -   **[Core Fix] Resolve IP Whitelist Deletion Failure (Issue #1797)**:\n            -   **Parameter Normalization**: Fixed the issue where whitelisted IPs could not be deleted due to parameter naming convention mismatches (snake_case vs camelCase) between the frontend and backend. Also unified parameters for blacklist management and IP access logs to ensure system-wide consistency.\n    *   **v4.1.12 (2026-02-10)**:\n        -   **[Core Feature] OpenCode CLI Deep Integration (PR #1739)**:\n            -   **Auto Detection**: Added automatic detection and configuration sync support for OpenCode CLI environment variables.\n            -   **One-Click Sync**: Supports seamless injection of Antigravity configurations into the OpenCode CLI environment via the \"External Providers\" card.\n        -   **[Core Fix] Claude Opus Thinking Budget Injection (PR #1747)**:\n            -   **Budget Correction**: Fixed an issue where the default Thinking Budget was not correctly injected when Opus models automatically enabled thinking mode, preventing upstream errors due to missing budget.\n        -   **[Core Optimization] Claude Opus 4.6 Thinking Upgrade (Issue #1741, #1742, #1743)**:\n            -   **Model Iteration**: Officially added support for `claude-opus-4-6-thinking` with enhanced reasoning capabilities.\n            -   **Seamless Migration**: Implemented automatic redirection from `claude-opus-4.5` / `claude-opus-4` to `4.6`, allowing legacy configurations to use the new model without changes.\n        -   **[Core Fix] Account Index Self-Healing Mechanism (PR #1755)**:\n            -   **Fault Tolerance**: Fixed an issue where the account index could not be automatically rebuilt in some extreme cases (e.g., file corruption). The system now automatically triggers a self-healing process upon detecting index anomalies, ensuring account data availability.\n        -   **[Core Fix] Fix IP Blacklist Deletion & Timezone Issues (PR #1748)**:\n            -   **Parameter Correction**: Fixed IP blacklist deletion failure caused by parameter naming convention mismatch (snake_case vs camelCase).\n            -   **Logic Fix**: Corrected the issue where the wrong parameter (ip_pattern instead of id) was passed when clearing the blacklist.\n            -   **Timezone Calibration**: Fixed Curfew logic to enforce Beijing Time (UTC+8), resolving discrepancies when server local time is not UTC+8.\n            -   **Rejection Alignment**: Optimized token rejection response to return 403 status code with JSON error details, aligning with the unified error response standard.\n        -   **[Core Feature] Added Mini View Mode (PR #1750)**:\n            -   **Quick Access**: Introduced a mini window mode with bidirectional toggling. This mode stays on top of the desktop, providing streamlined shortcuts for instant status checking and monitoring.\n        -   **[Core Fix] Gemini Protocol 400 Error Self-Healing (PR #1756)**:\n            -   **Token Bumping**: Fixed the 400 error occurring when using thinking models (e.g., Claude Opus 4.6 Thinking) via the Gemini native protocol, where `maxOutputTokens` was less than `thinkingBudget`. The system now automatically adjusts and aligns token limits to ensure request compliance.\n        -   **[Core Fix] Fix Claude CLI Path Detection for Bun on macOS (PR #1765)**:\n            -   **Path Enhancement**: Added detection for `~/.bun/bin` and global install paths, resolving issues where Bun users could not automatically sync Claude CLI configurations.\n        -   **[Core Optimization] Optimize Logo Text Hiding & Showing (PR #1766)**:\n            -   **Display Optimization**: Leveraged Tailwind CSS container logic to control logo text visibility, preventing text wrapping in small containers.\n        -   **[Core Fix] Google Cloud Code API 404 Retry & Account Rotation (PR #1775)**:\n            -   **Smart Retry**: Added automatic retry and account rotation for 404 errors from the Google Cloud Code API, commonly caused by phased rollouts or permission discrepancies. The system retries with a short 300ms delay and automatically switches to the next available account.\n            -   **Short Lockout**: Applied a 5-second soft lockout for 404 errors (vs. 8 seconds for other server errors), minimizing user wait time while protecting accounts.\n    *   **v4.1.11 (2026-02-09)**:\n        -   **[Core Optimization] Refactored Token Routing Logic (High-End Model Routing Optimization)**:\n            -   **Strict Capability Filtering**: Implemented strict Capability Filtering for high-end models like `claude-opus-4-6`. The system now verifies the actual `model_quotas` held by the account. Only accounts that explicitly possess the quota for the target model can participate in the rotation, thoroughly resolving the \"Soft Priority\" issue where Pro/Free accounts were incorrectly selected.\n            -   **Strict Tier Prioritization**: Established an absolute priority sorting strategy: `Ultra > Pro > Free`. As long as an Ultra account is available, the system will always prioritize scheduling Ultra accounts, preventing downgrade to Pro accounts and ensuring service quality for high-end models.\n            -   **[Configuration Warning]**: Please check `Settings -> Custom Model Mapping` or `gui_config.json` to ensure there is **NO** wildcard configuration like `\"claude-opus-4-*\": \"claude-opus-4-5-thinking\"`. This could cause `claude-opus-4-6-thinking` to be incorrectly mapped to `claude-opus-4-5-thinking`. We recommend adding an explicit exact mapping for `claude-opus-4-6-thinking`.\n        -   **[Core Fix] Configuration Hot-Reload Restoration (PR #1713)**:\n            -   **Instant Effect**: Fixed an issue where proxy pool configuration changes in memory were not updated when saving settings in WebUI or Docker environments. Modifications now take effect immediately without requiring a restart.\n        -   **[Docker Optimization] New Local Binding Restriction Option**:\n            -   **Network Security**: Introduced the `ABV_BIND_LOCAL_ONLY` environment variable. When set to `true`, Docker/Headless mode will strictly bind to `127.0.0.1` and no longer expose services to `0.0.0.0` by default, meeting specific security network requirements.\n        -   **[Core Feature] Custom Expiration Time for User Tokens (PR #1722)**:\n            -   **Flexible Control**: Creating user tokens now supports selecting a custom expiration time precise to the minute, no longer limited to preset fixed durations.\n        -   **[Core Fix] Token Editing Sync & Parameter Encapsulation (PR #1720, #1722)**:\n            -   **Data Sync**: Fixed an issue where some fields were not correctly reflected when editing tokens.\n            -   **Refactoring**: Optimized the parameter structure for token creation and updates, improving code maintainability.\n        -   **[Core Fix] Fix Proxy Authentication Persistence Failure (Issue #1738)**:\n            -   **Magic Prefix Mechanism**: Introduced `ag_enc_` prefix to explicitly identify encrypted password fields.\n            -   **Double Encryption Prevention**: Thoroughly resolved the issue where the backend could not distinguish between \"plaintext input\" and \"encrypted ciphertext,\" preventing double encryption during multiple saves or import/export operations.\n            -   **Compatibility**: Fully compatible with legacy configurations (no prefix), automatically migrating them to the new format upon the next save. Also enhanced the robustness of batch import functionality.\n        -   **[Core Fix] Resolve User Creation/Loading Failure (Issue #1719)**:\n            -   **Data Cleaning**: Implemented automatic data cleaning in database initialization to reset NULL values in legacy records to defaults, preventing list interface crashes.\n            -   **Robustness**: Enhanced backend data reading logic with defensive default values for critical fields.\n        -   **[Frontend Fix] Fix User Token Renewal Failure**:\n            -   **Parameter Correction**: Corrected the parameter naming convention (snake_case -> camelCase) in the renewal API call, resolving the \"missing required key\" error.\n        -   **[Core Fix] Resolve Google Cloud Project 404 Error (Issue #1736)**:\n            -   **Remove Invalid Mock Logic**: Completely removed the legacy logic for generating random Project IDs (e.g., `useful-flow-g3dts`), which are now rejected by the Google API with a 404 error.\n            -   **Smart Fallback Strategy**: The system now safely falls back to a verified stable Project ID (`bamboo-precept-lgxtn`) when an account fails to retrieve a valid project ID automatically, ensuring service continuity and reliability.\n        -   **[Core Fix] Enhance Streaming Stability Under Unstable Network (Issue #1732)**:\n            -   **Mandatory Buffer Flush**: Resolved hangs and \"Zero I/O\" issues caused by missing trailing newlines in SSE streams due to network fragmentation.\n            -   **Timeout Resilience**: Extended streaming timeout to 60s to better handle high-latency network environments.\n            -   **Session ID Stability**: Optimized the session identifier algorithm to prevent ID drift and subsequent thinking model signature failures during reconnections.\n    *   **v4.1.10 (2026-02-08)**:\n        -   **[Core Feature] Expand CLI Detection Paths to Support Volta (PR #1695)**:\n            -   **Path Enhancement**: Added automatic detection support for `.volta/bin` and its internal binaries in both `cli_sync` and `opencode_sync`, ensuring a \"zero-config\" experience for Volta users when syncing CLI configurations.\n        -   **[Core Fix] Smart Resolution Protection for Image Generation (Issue #1694)**:\n            -   **Priority Logic**: Refactored the image configuration merging algorithm to prioritize high-resolution settings from model suffixes (e.g., `-4k`, `-2k`) or explicit parameters (`quality: \"hd\"`). This prevents accidental downgrades caused by default parameters in the request body.\n            -   **Capability Boost**: Supports concurrent high-resolution image generation and full Thinking process display.\n        -   **[Core Feature] Deep Optimization for Advanced Thinking & Global Config**:\n            -   **Image Thinking Mode**: Added a new global toggle. When enabled, it provides dual-image output (draft + final) and full thinking chains; when disabled, the system explicitly enforces `includeThoughts: false` to prioritize single-image generation quality.\n            -   **UI Refactoring**: Compressed the \"Advanced Thinking\" module layout using row-based alignment and compact controls, reducing vertical space usage by 50% for better information density.\n            -   **Global Prompt Enhancements**: Improved the input experience with real-time character counting and long-context warnings.\n        -   **[i18n] Synchronized Support for 10+ Languages**:\n            -   **Multilingual Completion**: Fully synchronized translation keys for the Advanced Thinking module across Traditional Chinese, Japanese, Korean, Arabic, Spanish, Russian, Vietnamese, Turkish, Portuguese, and Myanmar.\n        -   **[Core Fix] Full Protocol Support & Stability Enhancements**:\n            -   **Unified Coverage**: Image Thinking controls are now synchronized across Gemini Native, OpenAI-Compatible, and Claude (Anthropic) protocols.\n            -   **DevOps Cleanup**: Resolved global state race conditions in backend unit tests and updated GitHub Release CI configurations to support asset overwriting.\n        -   **[Core Feature] Claude 4.6 Adaptive Thinking Support**:\n            -   **Dynamic Effort**: Full support for the `effort` parameter (low/medium/high), allowing dynamic adjustment of thinking depth and budget.\n            -   **Adaptive Token Limits**: Fixed an issue where `maxOutputTokens` was incorrectly truncated in Adaptive mode due to missing Budget perception, ensuring long thought chains are preserving.\n        -   **[Documentation] Added Adaptive Mode Test Examples**:\n            -   Included `docs/adaptive_mode_test_examples.md`, providing a comprehensive guide for validating multi-turn conversations, complex tasks, and budget mode switching.\n        -   **[Core Fix] Persistent Bindings & Reliable Quota Protection (Issue #1700)**:\n            -   **Binding Persistence**: Fixed a regression where `account_bindings` were overwritten during settings save, ensuring persistent mappings across restarts.\n            -   **Protection Boost**: Enhanced model normalization to recognize physical API model names and perfected instant sync and scheduler filtering to prevent low-quota account leakage.\n        -   **[Core Feature] Optimize Global Upstream Proxy I18n & Styling (Issue #1701)**:\n            -   **I18n Synchronization**: Completed proxy configuration strings for all 12 supported languages, resolving missing content in `zh.json` and inconsistent translations across locales.\n            -   **Styling Refined**: Reconstructed the global proxy configuration card with gradient backgrounds and micro-animations, ensuring visual consistency with Proxy Pool settings.\n            -   **SOCKS5H Support**: Added a protocol suggestion hint for `socks5h://` in the UI and unified backend proxy URL normalization logic to improve guidance for remote DNS resolution.\n    *   **v4.1.9 (2026-02-08)**:\n        -   **[Core Feature] Expand CLI Config Quick Sync Support (PR #1680, #1685)**:\n            -   **Multi-Tool Integration**: Now supports syncing configurations to **Claude Code**, **Gemini CLI**, **Codex AI**, **OpenCode**, and **Droid**.\n            -   **Custom Model Selection**: Added model selection dropdowns for single-model CLIs (Claude, Codex, Gemini) and drag-and-drop management for multi-model CLIs (OpenCode, Droid).\n            -   **Logic Calibration**: Deeply adapted the preset logic for each CLI (e.g., root-level `model` field and mirror environment cleanup for Claude) to ensure post-sync compatibility.\n            -   **Interaction Optimization**: Synced panel now supports default collapse with smooth animations and improved UI feedback.\n            -   **Backup & Security**: Automatically generates `.antigravity.bak` backups before syncing, with one-click restore support.\n        -   **[Core Feature] Global System Prompt Support (PR #1669)**:\n            -   **Unified Instruction Injection**: Added a new configuration in System Settings to inject custom system instructions into all OpenAI, Claude, and Gemini protocol requests.\n            -   **Frontend UI**: Introduced the `GlobalSystemPrompt` component with one-click enable and multi-line content editing.\n        -   **[Core Fix] Resolve Floating-point Precision Loss (PR #1669)**:\n            -   **Precision Upgrade**: Upgraded `temperature` and `top_p` data types from `f32` to `f64` in the backend.\n            -   **Accuracy Calibration**: Completely eliminated minor deviations (e.g., `0.95` becoming `0.949999...`) during proxy serialization, improving upstream compatibility.\n        -   **[Core Refactoring] Implement App Name Internationalization (PR #1662)**:\n            -   **UI Upgrade**: Removed hardcoded \"Antigravity Tools\" from `NavLogo` and `Settings` pages, utilizing the `app_name` translation key for consistent UI language switching.\n        -   **[Core Fix] Correct Misidentification of gemini-3-pro-image as a Thinking Model (Issue #1675)**:\n            -   **Root Cause**: `gemini-3-pro-image` and its 4k/2k variants were incorrectly identified as \"Thinking Models\" because they contain the `gemini-3-pro` keyword.\n            -   **Conflict Resolved**: Fixed the conflict caused by the incorrect injection of `thinkingConfig` alongside `imageConfig`, which led to backend resolution downgrades (to 1k).\n            -   **Token Optimization**: Resolved the \"Token limit exceeded (131072)\" 400 errors triggered by placeholders or specific limits injected for thinking models.\n        -   **[i18n] Synchronize Japanese Translations to 100% (PR #1662)**:\n            -   **Translation Completion**: Synchronized all missing keys from `en.json`, covering new features like Cloudflared, Circuit Breaker, and OpenCode Sync.\n        -   **[Refactoring] Restructured UpstreamClient Response Logic**:\n            -   **Structured Results**: Introduced `UpstreamCallResult` to unify upstream request management and optimize streaming/non-streaming response paths.\n    *   **v4.1.8 (2026-02-07)**:\n        -   **[Core Feature] Integrated Claude Opus 4.6 Thinking Model Support (PR #1641)**:\n            -   **Hybrid Architecture**: Implemented a \"Static Config + Dynamic Fetch\" dual-mode architecture. Model lists are dynamically fetched via Antigravity API, while advanced metadata like Thinking Mode is supplemented by the local registry, perfectly balancing flexibility and stability.\n            -   **Zero-Config Access**: `claude-opus-4-6` series models automatically enable Thinking Mode with preset Budgets, allowing users to enjoy the latest reasoning capabilities without manual intervention.\n            -   **Cutting-edge Mapping**: Added support for `claude-opus-4-6-thinking` and its aliases (`claude-opus-4-6`, `20260201`), managing them under the `claude-sonnet-4.5` quota group.\n        -   **[Core Optimization] Improve OpenCode CLI Detection Logic (PR #1649)**:\n            -   **Path Expansion**: Added automatic scanning for common global installation paths on Windows (e.g., `npm`, `pnpm`, `Yarn`, `NVM`, `FNM`).\n            -   **Reliability Boost**: Fixed detection failures when `PATH` environment is incomplete and enhanced support for `.cmd` and `.bat` files on Windows.\n        -   **[Core Fix] Resolve missing tool call content in monitoring logs**:\n            -   **Multi-Protocol Support**: Refactored SSE parsing logic to fully support OpenAI `tool_calls` and Claude `tool_use`.\n            -   **Incremental Accumulation**: Implemented streaming accumulation for tool parameters, ensuring long tool calls are correctly captured and displayed in the monitoring panel.\n        -   **[UI Optimization] Navbar & Link Interaction Improvements (PR #1648)**:\n            -   **Dragging Restricted**: Added `draggable=\"false\"` to all links and icons in the navigation bar and Logo to prevent accidental browser default dragging behavior, improving interaction stability.\n            -   **SmartWarmup Hover Enhancement**: Refined the hover color transition logic for the SmartWarmup component icon in its disabled state for better UI consistency.\n        -   **[Core Feature] Enhanced Account Custom Label Support (PR #1620)**:\n            -   **Length Limit**: Optimized label length limit from 20 to 15 characters, synchronized across frontend and backend.\n            -   **Backend Validation**: Enhanced Rust command validation with Unicode character support and improved error handling.\n            -   **Frontend Alignment**: Synchronized `maxLength={15}` for edit inputs in both account list and card views.\n        -   **[Core Fix] Fix Clipboard Error in UserToken Page (PR #1639)**:\n            -   **Logic Fix**: Resolved exceptions that could be triggered when attempting to access or write to the clipboard on the UserToken page.\n            -   **UX Optimization**: Improved the robustness of clipboard interactions to ensure consistent behavior across various environments.\n        -   **[Core Optimization] Optimize Token Sorting Performance & Reduce Disk I/O (PR #1627)**:\n            -   **In-Memory Quota Cache**: Introduced model quota caching within the `ProxyToken` struct to eliminate disk reads during the `get_token` sorting hot path.\n            -   **Throughput Improvement**: Completely removed blocking synchronous I/O (`std::fs::read_to_string`) from request processing, significantly improving latency and throughput under high concurrency.\n        -   **[i18n] Fixed missing translations for custom label feature (PR #1630)**:\n            -   **Translation Completion**: Completed missing i18n keys for editing labels, custom label placeholders, and update success messages in Traditional Chinese and other locales.\n        -   **[UI Fix] Fixed missing hover effect for SmartWarmup icon (PR #1568)**:\n            -   **Hover Effect**: Added hover background and text color changes for the disabled icon state, aligning it with other setting components.\n        -   **[Core Fix] Fix Missing Signature for Vertex AI Thinking Models in OpenAI Protocol (Issue #1650)**:\n            -   **Sentinel Injection**: Removed the restriction on sentinel signature injection for Vertex AI (`projects/...`) models. The system now automatically injects `skip_thought_signature_validator` even if a real signature is missing, preventing the `Field required for thinking signature` error.\n    *   **v4.1.7 (2026-02-06)**:\n        -   **[Core Fix] Fixed Image API Account Rotation on 429/500/503 Errors (Issue #1622)**:\n            -   **Automatic Retry**: Implemented automatic retry and account rotation logic for `images/generations` and `images/edits`, aligning with the robustness of the Chat API.\n            -   **Consistent Experience**: Requests now automatically failover to the next available account when quota is exhausted or upstream service is unavailable, ensuring high availability.\n        -   **[Core Feature] Add Custom Label Support for Accounts (PR #1620)**:\n            -   **Label Management**: Supports setting personalized labels for each account for easier identification in multi-account environments.\n            -   **UI Optimization**: Directly view and edit labels inline in both account list and card views.\n            -   **I18n Support**: Full support for both Chinese and English localization.\n        -   **[Core Fix] Handle NULL values in `get_stats` when database is empty (PR #1578)**:\n            -   **NULL Handling**: Wrapped `SUM()` calls with `COALESCE(..., 0)` to ensure numeric values are always returned, fixing type conversion errors in `rusqlite` when no logs exist.\n            -   **Performance Retention**: Preserved the optimized single-query architecture from the local branch for statistical data retrieval.\n\n        -   **[Core Fix] Claude 403 Error Handling & Account Rotation Optimization (PR #1616)**:\n            -   **403 Status Mapping**: Mapped 403 (Forbidden) errors to 503 (Service Unavailable) to prevent clients (e.g., Claude Code) from automatically logging out.\n            -   **Auto-Disable Logic**: Automatically marks accounts as `is_forbidden` and removes them from the active memory pool upon encountering 403 errors.\n            -   **Temporary Risk Control detection**: Identifies `VALIDATION_REQUIRED` errors and implements a 10-minute temporary block for affected accounts.\n            -   **Rotation Stability**: Fixed premature returns during `QUOTA_EXHAUSTED` errors, ensuring the system correctly attempts to rotate to the next available account.\n        -   **[Core Feature] OpenCode CLI Configuration Sync Integration (PR #1614)**:\n            -   **One-click Sync**: Automatically generates `~/.config/opencode/opencode.json` with proper provider settings for Anthropic and Google.\n            -   **Account Export**: Optionally syncs accounts to `antigravity-accounts.json` for OpenCode plugin compatibility.\n            -   **Backup & Restore**: Automatically creates a backup before syncing, with the ability to restore previous configurations.\n            -   **Cross-platform Support**: Consistent support across Windows, macOS, and Linux.\n            -   **Experience Optimization**: Fixed RPC parameter wrapping, completed i18n translations, and optimized view state when the configuration file is missing.\n        -   **[Core Feature] Allow Hiding Unused Menu Items (PR #1610)**:\n            -   **Visibility Control**: Added \"Menu Item Visibility Settings\" in the settings page, allowing users to customize sidebar navigation items.\n            -   **UI Refinement**: Provides a cleaner interface for minimalist users by hiding unused feature entries.\n        -   **[Core Fix] Gemini Native Protocol Image Generation Full Fix (Issue #1573, #1625)**:\n            -   **400 Error Fix**: Resolved `INVALID_ARGUMENT` errors in Gemini native image generation caused by the missing `role: \"user\"` field in the `contents` array.\n            -   **Parameter Passthrough**: Ensured `generationConfig.imageConfig` (e.g., `aspectRatio`, `imageSize`) is correctly passed to the upstream API without getting filtered.\n            -   **Error Code Optimization**: Optimized error mapping for image generation services, ensuring 429/503 statuses correctly trigger client-side retries.\n        -   **[Core Enhancement] Custom Mapping Supports Manual Input for Any Model ID**:\n            -   **Flexible Input**: Added manual input functionality to the custom mapping target model selector. Users can now directly enter any model ID at the bottom of the dropdown menu.\n            -   **Unreleased Model Experience**: Supports experiencing models not yet officially released by Antigravity, such as `claude-opus-4-6`. Users can route requests to these experimental models through custom mappings.\n            -   **Important Notice**: Not all accounts support calling unreleased models. If your account lacks access to a specific model, requests may return errors. It is recommended to test with a small number of requests first to confirm account permissions before large-scale use.\n            -   **Quick Operation**: Supports Enter key for quick submission of custom model IDs, improving input efficiency.\n    *   **v4.1.6 (2026-02-06)**:\n        -   **[Core Fix] Deep Refactor of Claude/Gemini Thinking Model Interruptions & Tool Loop Recovery (#1575)**:\n            -   **Thinking Recovery**: Introduced `thinking_recovery` mechanism. Automatically strips stale thinking blocks and guides the model when status loops or interruptions are detected, enhancing stability in complex tool-calling scenarios.\n            -   **Complete Fix for Signature Binding Errors**: Corrected the logic that incorrectly injected cached signatures into custom client-side thinking content. Since signatures are strictly bound to specific text, this completely resolves common `Invalid signature` (HTTP 400) errors after session interruptions or resets.\n            -   **Full Session Isolation**: Removed the global signature singleton, ensuring all thinking signatures are strictly isolated at the Session level, eliminating signature pollution across multiple accounts or concurrent sessions.\n        -   **[Core Fix] Resolve HTTP 400 \"thinking_budget out of range\" error for Gemini Series (#1592, #1602)**:\n            -   **Full-Path Hard Capping**: Fixed the missing quota protection in OpenAI and Claude protocol mappers when using \"Custom\" mode. Regardless of the selected mode (Auto/Custom/Passthrough), the backend now enforces a mandatory limit of 24576 for all Gemini models to ensure request success.\n            -   **Auto Adaptation & UI Sync**: Refactored the protocol conversion logic to dynamically apply limits based on the final mapped model; updated settings UI hints to clearly specify the physical limits of the Gemini protocol.\n        -   **[Core Fix] Web Mode Login Validation Fix & Logout Button (PR #1603)**:\n            -   **Login Validation**: Fixed exceptions in the Web mode login validation logic, ensuring stability of user authentication.\n            -   **Logout Support**: Added/fixed the logout button in the UI, completing the account management loop for Web mode.\n    <details>\n    <summary>Show older changelog (v4.1.5 and earlier)</summary>\n\n    *   **v4.1.5 (2026-02-05)**:\n        -   **[Security Fix] Frontend API Key Storage Migration (LocalStorage -> SessionStorage)**:\n            -   **Storage Upgrade**: Migrated the storage of the Admin API Key from persistent `localStorage` to session-based `sessionStorage`, significantly reducing security risks on shared devices.\n            -   **Seamless Auto-Migration**: Implemented automatic detection and migration logic. The system identifies legacy `localStorage` keys, automatically transfers them to `sessionStorage`, and securely wipes the old data, ensuring a seamless transition and eliminating security vulnerabilities for existing users.\n        -   **[Core Fix] Fix Account Addition Failure in Docker Environment (Issue #1583)**:\n            -   **Account Context Fix**: Fixed the proxy selection issue caused by `account_id` being `None` when adding new accounts. The system now generates a temporary UUID for new accounts to ensure all OAuth requests have a clear account context.\n            -   **Enhanced Logging**: Optimized logging in `refresh_access_token` and `get_effective_client` to provide more detailed proxy selection information, helping diagnose network issues in Docker environments.\n            -   **Impact Scope**: Resolved the long hang or failure issue when adding accounts via Refresh Token in Docker deployment environments.\n        -   **[Core Fix] Web Mode Compatibility Fixes & 403 Account Rotation Optimization (PR #1585)**:\n            -   **Security API Web Mode Compatibility Fix (Issue: 400/422 Errors)**:\n                -   Added default values for `page` and `page_size` in `IpAccessLogQuery`, resolving 400 Bad Request errors from `/api/security/logs`\n                -   Removed `AddBlacklistWrapper` and `AddWhitelistWrapper` structs, fixing 422 Unprocessable Content errors from `/api/security/blacklist` and `/api/security/whitelist` POST requests\n                -   Fixed frontend component parameter naming: `ipPattern` → `ip_pattern`, ensuring consistency with backend API parameters\n            -   **403 Account Rotation Optimization (Issue: Accounts Not Properly Skipped After 403)**:\n                -   Added `set_forbidden` method in `token_manager.rs` to support marking accounts as disabled\n                -   Account selection now checks `quota.is_forbidden` status, automatically skipping disabled accounts\n                -   Clears sticky session bindings for 403 accounts, ensuring immediate switch to other available accounts\n            -   **Web Mode Request Processing Optimization**:\n                -   Fixed `request.ts` to remove used parameters from body after path parameter replacement, avoiding duplicate parameters\n                -   Added PATCH method body handling, completing HTTP method support\n                -   Automatic unpacking of `request` field, simplifying request structure\n            -   **Debug Console Web Mode Support**:\n                -   Added `isTauri` environment detection in `useDebugConsole.ts`, distinguishing between Tauri and Web environments\n                -   Web mode uses `request()` instead of `invoke()`, ensuring proper calls in Web environment\n                -   Added polling mechanism, automatically refreshing logs every 2 seconds in Web mode\n            -   **Docker Build Optimization**:\n                -   Added `--legacy-peer-deps` flag, resolving frontend dependency conflicts\n                -   Enabled BuildKit cache to accelerate Cargo builds, improving build speed\n                -   Completed `@lobehub/icons` peer dependencies, fixing build failures caused by missing frontend dependencies\n            -   **Impact Scope**: This update significantly improves stability and usability in Docker/Web mode, resolving Security API errors, 403 account rotation failures, Debug Console unavailability, and optimizing the Docker build process.\n        -   **[Core Fix] Fix Debug Console Crash and Log Sync in Web/Docker Mode (Issue #1574)**:\n            -   **Web Compatibility**: Fixed `TypeError` crashes caused by direct calls to native `invoke` APIs in non-Tauri environments. Communication now flows through the compatibility request layer.\n            -   **Fingerprint Binding Fix**: Resolved `HTTP Error 422` when generating and binding fingerprints by aligning the parameter structure between frontend and backend (nested `profile` object support).\n            -   **Log Polling Mechanism**: Introduced automatic log polling (every 2 seconds) for Web mode, overcoming the limitation where browser clients cannot receive Rust event pushes, ensuring logs are correctly displayed.\n        -   **[Core Optimization] Complete Tauri Command to HTTP API Mappings**:\n            -   **Full Adaptation**: Aligned 30+ native Tauri commands with HTTP APIs, completing mappings for cache management, system paths, proxy pool configuration, and user token management, ensuring full functional parity between Desktop and Web modes.\n        -   **[Security Fix] Arbitrary File Write Vulnerability Hardening**:\n            -   **API Security Layer**: Completely removed the high-risk endpoint `/api/system/save-file` and its associated handlers. Added path traversal prevention (`..` check) to the database import interface.\n            -   **Tauri Security Hardening**: Introduced a unified path validator for `save_text_file` and `read_text_file` commands, strictly forbidding directory traversal and blocking access to sensitive system paths.\n    *   **v4.1.4 (2026-02-05)**:\n        -   **[Core Feature] Proxy Pool Persistence & Account Filtering Optimization (PR #1565)**:\n            -   **Persistence Enhancement**: Fixed an issue where proxy pool bindings were not correctly restored after proxy service restart or reload, ensuring strict persistence of binding relationships.\n            -   **Intelligent Filtering**: Optimized account acquisition logic in `TokenManager`. Added deep validation for `disabled` and `proxy_disabled` statuses in loading, syncing, and scheduling paths to prevent disabled accounts from being mistakenly selected.\n            -   **Validation Block Support**: Introduced the `validation_blocked` field system to handle Google's `VALIDATION_REQUIRED` (403 temporary risk control) scenarios, implementing intelligent automatic bypass based on expiration time.\n            -   **State Cleanup Fortification**: Synchronized cleanup of memory tokens, rate limit records, session bindings, and preferred account flags when an account becomes invalid, ensuring consistency of the internal state machine.\n        -   **[Core Fix] Fix Critical Compatibility Issues in Web/Docker Mode (Issue #1574)**:\n            -   **Debug Mode Fix**: Corrected frontend debug console URL mapping errors (removed redundant `/proxy` path), resolving the issue where debug mode could not be enabled in Web mode.\n            -   **Fingerprint Binding Fix**: Added `BindDeviceProfileWrapper` structure for the `admin_bind_device_profile_with_profile` interface, fixing HTTP 422 errors caused by nested parameters sent from the frontend.\n            -   **Backward Compatibility**: Used `serde alias` feature to support both camelCase (frontend) and snake_case (backend files) at the API layer, ensuring old account files load correctly.\n        -   **[Code Optimization] Simplified API Handling Structure**:\n            -   Removed redundant `Wrapper` layers in multiple management API routes (e.g., IP blacklist/whitelist management, security setting updates), directly destructuring business models to improve code conciseness and development efficiency.\n        -   **[Core Fix] Resolve OpenCode Thinking Model Interruption Issue (Issue #1575)**:\n            -   **finish_reason Enforcement**: Fixed the issue where `finish_reason` was incorrectly set to `stop` during tool calls, causing OpenAI clients to prematurely terminate conversations. The system now forcibly sets `finish_reason` to `tool_calls` when tool calls are present, ensuring proper tool loop execution.\n            -   **Tool Parameter Standardization**: Implemented automatic standardization of shell tool parameter names, converting non-standard names like `cmd`/`code`/`script` (which Gemini may generate) to the standard `command` parameter, improving tool call compatibility.\n            -   **Impact Scope**: Fixed the tool call workflow for Thinking models (e.g., `claude-sonnet-4-5-thinking`) under the OpenAI protocol, resolving interruption issues in clients like OpenCode.\n\n    *   **v4.1.3 (2026-02-05)**:\n        -   **[Core Fix] Resolve Security Config and IP Management Failures in Web/Docker Mode (Issue #1560)**:\n            -   **Protocol Alignment**: Fixed the issue where the backend Axum interface could not parse nested parameter formats (e.g., `{\"config\": ...}`) wrapped by the frontend `invoke` method, ensuring security configurations are correctly persisted.\n            -   **Parameter Normalization**: Added `camelCase` renaming support for IP management interfaces, resolving failures in adding or deleting entries caused by case mismatches in Web-mode Query parameters.\n        -   **[Core Fix] Restore Gemini Pro Thinking Blocks (Issue #1557)**:\n            -   **Cross-Protocol Alignment**: Resolved the issue where `gemini-3-pro` and other reasoning models were missing thinking blocks in OpenAI, Claude, and Gemini protocols since v4.1.0.\n            -   **Smart Injection Logic**: Implemented automatic `thinkingConfig` injection and default-enablement mechanisms, ensuring thinking features are correctly activated even when not explicitly requested by clients.\n            -   **Robustness Enhancement**: Optimized internal type handling in `wrapper.rs` to prevent configuration conflicts in high-concurrency scenarios.\n    *   **v4.1.2 (2026-02-05)**:\n        -   **[Core Feature] ClientAdapter Framework (Issue #1522)**:\n            -   **Architecture Refactor**: Introduced `ClientAdapter` framework with `Arc` reference counting to fully decouple handler logic from downstream client specifics, ensuring thread-safe sharing.\n            -   **Full Protocol Compatibility**: Achieved seamless integration for **4 protocols** (Claude/OpenAI/Gemini/OA-Compatible) specifically for third-party clients like `opencode`, eliminating `AI_TypeValidationError`.\n            -   **Smart Strategies**: Implemented FIFO signature buffering and `let_it_crash` fail-fast mechanism to significantly improve stability and error feedback in high-concurrency scenarios.\n            -   **Standardized Error Responses**: Unified error formats across all protocols (SSE `event: error` / Non-stream JSON), ensuring clients can correctly parse upstream exceptions.\n        -   **[Core Fix] Unified Account Disable Status Check Logic (Issue #1512)**:\n            -   **Logic Alignment**: Fixed an issue where the manual disable status (`proxy_disabled`) was ignored in batch quota refresh and auto-warmup logic.\n            -   **Background Noise Reduction**: Ensured that accounts marked as \"Disabled\" or \"Proxy Disabled\" no longer trigger any background network requests, enhancing privacy and resource efficiency.\n        -   **[Core Fix] Resolve 400 Invalid Argument Errors in OpenAI Protocol (Issue #1506)**:\n            -   **Session-level Signature Isolation**: Integrated `SignatureCache` to physically isolate thinking signatures using `session_id`, preventing signature cross-contamination in multi-turn or concurrent sessions.\n            -   **Enhanced Robustness**: Added logic to recognize and automatically clean invalid thinking placeholders (e.g., `[undefined]`), improving compatibility with various clients like Cherry Studio.\n            -   **Full-link Context Propagation**: Refactored request mapping and streaming chains to ensure precise Session context propagation across both non-streaming and streaming requests.\n        -   **[UI Enhancement] Model Logo Support & Automatic Sorting (PR #1535)**:\n            -   **Visual Excellence**: Integrated `@lobehub/icons` to display brand-specific logos for models in account cards, tables, and dialogs.\n            -   **Smart Sorting**: Implemented a weight-based model sorting algorithm (Series > Tier > Suffix) to prioritize primary models like Gemini 3 Pro.\n            -   **Configuration Centralization**: Decoupled model metadata (Labels, Short Names, Icons, and Weights), improving codebase maintainability.\n            -   **i18n Synchronization**: Updated model display names across 13 languages.\n        -   **[Core Fix] Enhanced Account Disable Status & Real-time Disk State Verification (PR #1546)**:\n            -   **Deep Disk Verification**: Introduced a `get_account_state_on_disk` mechanism that adds a second-layer status confirmation on the token acquisition path, completely resolving issues with disabled accounts being selected due to memory cache latency.\n            -   **Smart Fixed Account Sync**: Optimized the `toggle_proxy_status` command to automatically disable fixed account mode when an account is disabled and trigger an immediate proxy pool reload.\n            -   **Auth Failure Self-healing**: When the backend detects an `invalid_grant` error and auto-disables an account, it now physically purges in-memory tokens, rate limit records, and session bindings, ensuring immediate offline status for faulty accounts.\n            -   **End-to-end Filtering**: Integrated disable status checks into the Warmup logic and Scheduler, significantly reducing redundant background network requests.\n        -   **[Core Optimization] Concurrent Proxy Pool Health Checks (PR #1547)**:\n            -   **Performance Boost**: Integrated a concurrent execution mechanism based on `futures` streams, refactoring sequential checks into parallel processing (concurrency limit: 20).\n            -   **Efficiency Enhancement**: Significantly reduced the total duration of health checks for large proxy pools, improving the system's responsiveness to proxy status changes.\n        -   **[Core Fix] Resolve crypto.randomUUID Compatibility in Docker/HTTP (Issue #1548)**:\n            -   **Crash Fix**: Resolved application crashes (\"Unexpected Application Error\") and batch import failures in non-secure contexts (e.g., HTTP or partial Docker environments) where the browser disables the `crypto.randomUUID` API.\n            -   **Compatibility**: Introduced a cross-platform compatible UUID generation fallback mechanism, ensuring ID generation stability in any deployment environment.\n    *   **v4.1.1 (2026-02-04)**:\n        -   **[Core Feature] Update Checker Enhanced (Update Checker 2.0) (PR #1494)**:\n            -   **Proxy Support**: The update checker now fully respects the global upstream proxy configuration.\n            -   **Multi-layer Fallback**: Implemented a 3-layer fallback strategy: `GitHub API -> GitHub Raw -> jsDelivr`, significantly improving update detection reliability.\n            -   **Observability**: The update notification now displays the source of the detection.\n        -   **[Core Optimization] Antigravity Database Compatibility Improvement (>= 1.16.5)**:\n            -   **Smart Version Detection**: Added a cross-platform version detection module (macOS/Windows/Linux) to automatically identify the Antigravity client version.\n            -   **Format Adaptation**: Supported the new `antigravityUnifiedStateSync.oauthToken` format for v1.16.5+ while maintaining backward compatibility for legacy formats.\n            -   **Smart Injection**: Implemented a version-aware injection strategy with a dual-format fallback mechanism to ensure seamless account switching.\n        -   **[Core Fix] Resolve react-router SSR XSS Vulnerability (CVE-2026-21884) (PR #1500)**:\n            -   **Security Fix**: Upgraded `react-router` dependency to a safe version, addressing a cross-site scripting (XSS) risk in the `ScrollRestoration` component during server-side rendering (SSR).\n        -   **[i18n] Enhanced Japanese Translation Support (PR #1524)**:\n            -   **Improvement**: Completed Japanese localization for critical modules including Proxy Pool, streaming error messages, and User-Agent configurations.\n    *   **v4.1.0 (2026-02-04)**:\n        -   **[Major Update] Proxy Pool 2.0 & Stability Enhancements**:\n            -   **Account-level Exclusive IP Isolation**: Implemented strong binding between accounts and proxies. Bound proxies are automatically isolated from the public pool.\n            -   **Protocol Auto-completion**: Backend now automatically handles short-hand inputs (e.g., `ip:port`) by prepending `http://`.\n            -   **Intelligent Health Check**: Added browser-like User-Agent to prevent blocks and switched default fallback check URL to `cloudflare.com`.\n            -   **Responsive Status Sync**: Fixed the \"sleep-before-check\" logic, ensuring immediate UI status updates on startup.\n            -   **Persistence Bug Fix**: Resolved race conditions where high-frequency polling could rollback manual proxy additions.\n        -   **Proxy Pool 2.0 Logic Breakdown**:\n            -   **Scene 1: Full-chain Locking** — Once Account A is bound to Node-01, all requests (Token refresh, Quota sync, AI inference) are forced through Node-01. Google sees a consistent IP for the account.\n            -   **Scene 2: Auto-Isolation for Public Pool** — Account B has no binding. Node-01 is automatically excluded from the public rotation as it's exclusively used by A, eliminating association risks.\n            -   **Scene 3: Self-healing & Failover** — If Node-01 fails and \"Auto failover\" is on, Account A temporarily borrows from the public pool for urgent tasks (e.g., Token refresh) with audit logs.\n        -   **[New Feature] UserToken Page & Monitoring Enhancements (PR #1475)**:\n            -   **Page Navigation**: Added dedicated UserToken management page for granular token control.\n            -   **Monitoring**: Enhanced system monitoring and routing integration for better observability.\n        -   **[Core Fix] Warmup API Field Missing Fix**:\n            -   **Compilation Fix**: Resolved compilation error caused by missing `username` field in `ProxyRequestLog` initialization.\n        -   **[Core Fix] Docker Warmup 401/502 Error Fix (PR #1479)**:\n            -   **Network Optimization**: Used a client with `.no_proxy()` for Warmup requests in Docker environments, preventing localhost requests from being incorrectly routed to external proxies causing 502/401 errors.\n            -   **Auth Update**: Exempted `/internal/*` paths from authentication, ensuring internal warmup requests are not intercepted.\n        -   **[Core Fix] Debug Console & Binding in Docker/Headless**:\n            -   **Debug Console**: Fixed uninitialized log bridge in Docker and added HTTP API mappings for Web UI log access.\n            -   **Fingerprint Binding**: Enhanced device fingerprint binding logic for better Docker container compatibility and API support.\n        -   **[Core Fix] Account Deletion Cache Sync Fix (Issue #1477)**:\n            -   **Sync Mechanism**: Introduced a global deletion signal synchronization queue, ensuring accounts are purged from memory cache immediately after disk deletion.\n            -   **Thorough Cleanup**: TokenManager now synchronizes the cleanup of tokens, health scores, rate limits, and session bindings for deleted accounts, completely resolving \"ghost account\" scheduling issues.\n        -   **[UI Optimization] Localize Update Notification (PR #1484)**:\n            -   **i18n Adaptation**: Completely removed hardcoded strings in the update notification dialog, achieving full support for all 12 languages.\n        -   **[UI Upgrade] Navbar Refactor & Responsive Optimization (PR #1493)**:\n            -   **Component Deconstruction**: Split the monolithic Navbar into smaller modular components for better maintainability.\n            -   **Responsive Optimization**: Optimized layout breakpoints and the \"Refresh Quota\" button's responsive behavior.\n    *   **v4.0.15 (2026-02-03)**:\n        -   **[Core Optimization] Enhanced Warmup Functionality & False-Positive Fixes (PR #1466)**:\n            -   **Logic Optimization**: Removed the hardcoded model whitelist, enabling automatic warmup for all models reaching 100% quota based on account data.\n            -   **Accuracy Fix**: Fixed false-positive warmup status reporting, ensuring success records are only committed when the process truly completes.\n            -   **Extended Features**: Optimized traffic logging for warmup requests and implemented skip logic for 2.5 series models.\n        -   **[Core Optimization] Thinking Budget Global i18n & UX Polishing**:\n            -   **Multi-language Support**: Completed and optimized translations for English, Japanese, Korean, Russian, Spanish, Traditional Chinese, and Arabic.\n            -   **UX Refinement**: Polished settings hints (Auto Hint / Passthrough Warning) to guide users in configured optimal thinking token depth for diverse models.\n    *   **v4.0.14 (2026-02-02)**:\n        -   **[Core Fix] Fix API Key Regeneration in Web/Docker (Issue #1460)**:\n            -   **Resolution**: Resolved the bug where the API Key was regenerated on every page refresh when no config file existed.\n            -   **Consistency**: Improved the configuration loading flow to ensure the initial random key is persisted and environment variable overrides are correctly reflected in the Web UI.\n        -   **[Core Feature] Configurable Thinking Budget (PR #1456)**:\n            -   **Budget Control**: Added a \"Thinking Budget\" configuration setting in System Settings.\n            -   **Smart Adaptation**: Supports customizing the maximum thinking token limit for models like Claude 4.6+ and Gemini 2.0 Flash Thinking.\n            -   **Default Optimization**: The default setting is optimized to provide a complete thinking process in most scenarios while strictly adhering to upstream budget limits.\n    *   **v4.0.13 (2026-02-02)**:\n        -   **[Core Optimization] Load Balancing Algorithm Upgrade (P2C Algorithm) (PR #1433)**:\n            -   **Algorithm Upgrade**: Upgraded the scheduling algorithm from Round-Robin to P2C (Power of Two Choices).\n            -   **Performance Boost**: Significantly reduced request latency in high-concurrency scenarios and optimized load distribution across backend instances, preventing single-node overloads.\n        -   **[UI Upgrade] Responsive Navbar & Layout (PR #1429)**:\n            -   **Mobile Adaptation**: Redesigned responsive navigation bar, perfectly adapting to mobile devices and small screens.\n            -   **Visual Enhancement**: Added intuitive icons to navigation items, improving the overall visual experience and usability.\n        -   **[New Feature] Enhanced Account Quota Visibility (PR #1429)**:\n            -   **Show All Quotas**: Added a \"Show all quotas\" toggle in the Accounts page. When enabled, it displays real-time quota information for all dimensions (Ultra/Pro/Free/Image), not just the primary quota.\n        -   **[i18n] Comprehensive Language Support Update**:\n            -   **Coverage Boost**: Completed missing translation keys for 10 languages including Traditional Chinese, Japanese, Korean, Spanish, Arabic, etc.\n            -   **Polishing**: Fixed missing translations for \"Show all quotas\" and OAuth authorization prompts.\n        -   **[i18n] Background Task Translation Fix (PR #1421)**:\n            -   **Translation Fix**: Resolved missing translations for background tasks (e.g., title generation), ensuring proper localization across all supported languages.\n            -   **Root Cause**: Resolved a `ref` conflict introduced during merge that caused incorrect click detection on mobile/desktop.\n            -   **Outcome**: The language switcher menu now opens and interacts correctly.\n        -   **[Docker/Web Fix] Web IP Management Support (IP Security for Web)**:\n            -   **Feature Completion**: Fixed an issue where IP security features (Logs, Blacklist/Whitelist) were unavailable in Docker/Web mode due to missing backend routes.\n            -   **API Implementation**: Implemented proper RESTful management endpoints, ensuring the Web frontend can fully interact with the security module.\n            -   **UX Polish**: Optimized parameter handling for deletion operations, resolving issues where deleting blacklist/whitelist entries failed in certain browsers.\n    *   **v4.0.12 (2026-02-01)**:\n        -   **[Code Refactoring] Connector Service Optimization**:\n            -   **Deep Optimization**: Rewrote the core logic of the connector service (`connector.rs`) to eliminate inefficient legacy code.\n            -   **Performance Boost**: Optimized the connection establishment and handling process, improving overall system stability and response speed.\n    *   **v4.0.11 (2026-01-31)**:\n        -   **[Core Fix] Endpoint Reordering & Auto-Blocking (Fix 403 VALIDATION_REQUIRED)**:\n            -   **Endpoint Optimization**: Prioritized API endpoints as `Sandbox -> Daily -> Prod`. Using lenient environments first to reduce the occurrence of 403 errors.\n            -   **Smart Blocking**: Upon detecting `VALIDATION_REQUIRED` (403), the system temporarily blocks the account for 10 minutes. Requests will skip this account during the block period to prevent further flagging.\n            -   **Auto-Recovery**: The system automatically attempts to restore the account after the block period expires.\n        -   **[Core Fix] Account Hot-Reloading**:\n            -   **Unified Architecture**: Eliminated duplicate `TokenManager` instances, ensuring the Admin Dashboard and Proxy Service share a single account manager.\n            -   **Real-time Updates**: Fixed the issue where manual enabling/disabling, reordering, or bulk operations required an app restart. Changes now take effect immediately.\n        -   **[Core Fix] Quota Protection Logic Optimization (PR #1344 Patch)**:\n            -   Refined the differentiation between \"Disabled\" status and \"Quota Protected\" status in the quota protection logic, ensuring accurate logging and real-time state synchronization.\n        -   **[Core Fix] Restore Health Check Endpoint (PR #1364)**:\n            -   **Route Restoration**: Fixed the missing `/health` and `/healthz` routes that were lost during the 4.0.0 architecture migration.\n            -   **Enhanced Response**: The endpoint now returns a JSON containing `\"status\": \"ok\"` and the current application version, facilitating version matching and liveness checks for monitoring systems.\n        -   **[Core Fix] Fix Gemini Flash Thinking Budget Limit (Fix PR #1355)**:\n            -   **Automatic Capping**: Resolved an issue where the default or upstream `thinking_budget` (e.g., 32k) exceeded the limit (24k) for Gemini Flash thinking models (e.g., `gemini-2.0-flash-thinking`), resulting in `400 Bad Request` errors.\n            -   **Multi-Protocol Coverage**: This protection now covers **OpenAI, Claude, and Native Gemini protocols**, ensuring comprehensive safety against invalid budget configurations.\n            -   **Smart Truncation**: The system now automatically detects Flash series models and forcibly caps the thinking budget within safe limits (**24,576**), ensuring successful requests without requiring manual client configuration adjustments.\n        -   **[Core Feature] IP Security & Risk Control System (PR #1369 by @大黄)**:\n            -   **Visual Policy Management**: New \"Security Monitor\" module for graphical management of IP blacklists and whitelists.\n            -   **Smart Ban Policies**: Implemented CIDR-based subnet banning, auto-release scheduling, and ban reason annotation.\n            -   **Real-time Audit Logs**: Integrated IP-level real-time access log auditing, supporting filtering by IP and time range for quick anomaly detection.\n        -   **[UI Optimization] Premium Visual Experience**:\n            -   **Dialog Polish**: Completely upgraded button styles in IP Security module dialogs, adopting solid colors and shadow designs for clearer operation guidance.\n            -   **Layout Fixes**: Resolved scrollbar anomalies and layout misalignments in the Security Config page, optimizing the tab switching experience.\n        -   **[Core Feature] Debug Console (PR #1385)**:\n            -   **Real-time Log Streaming**: Introduced a full-featured debug console for real-time capture and display of backend logs.\n            -   **Filtering & Searching**: Supports filtering by log levels (Info, Debug, Warn, Error) and global keyword search.\n            -   **Interaction Polish**: Features one-click log clearing, auto-scroll toggle, and full support for both light and dark modes.\n            -   **Backend Bridge**: Implemented a high-performance log bridge to ensure log capture without impacting proxy performance.\n    *   **v4.0.9 (2026-01-30)**:\n        -   **[Core Feature] User-Agent Customization & Version Spoofing (PR #1325)**:\n            - **Dynamic Override**: Allows users to customize the `User-Agent` header for upstream requests in \"Service Configuration\". This enables simulation of any client version (Cheat Mode), effectively bypassing version blocks or risk controls in certain regions.\n            - **Smart Fallback**: Implemented a three-tier version fetching mechanism (Remote Fetch -> Cargo Version -> Hardcoded). When the primary version API is unavailable, the system automatically parses the official Changelog page to retrieve the latest version, ensuring the UA always masquerades as the latest client.\n            - **Hot Reload**: UA configuration changes take effect immediately without requiring a service restart.\n        -   **[Core Fix] Resolve Quota Protection State Sync Defect (Issue #1344)**:\n            - **Real-time State Sync**: Fixed a logic defect where `check_and_protect_quota()` would exit early when processing disabled accounts. Now, even if an account is disabled, the system still scans and updates its `protected_models` (model-level protection list) in real-time, ensuring accounts with insufficient quota cannot bypass protection mechanisms after being re-enabled.\n            - **Log Path Separation**: Extracted manual disable checks from the quota protection function to the caller, logging accurate messages based on different skip reasons (manual disable/quota protection) to eliminate user confusion.\n        -   **[Core Feature] Cache Management & One-click Clearing (PR #1346)**:\n            - **Backend Integration**: Introduced `src-tauri/src/modules/cache.rs` to calculate and manage temporary file distributions (e.g., translation cache, log fingerprints).\n            - **UI Implementation**: Added a \"Clear Cache\" feature in \"System Settings\". Users can view real-time cache size and perform one-click cleanup to reclaim disk space.\n        -   **[i18n] New Language Support (PR #1346)**:\n            - Added complete translation support for **Spanish (es)** and **Malay (my)**.\n        -   **[i18n] Full Language Coverage**:\n            - Added complete translation support for the new feature across 10 languages including En, Zh, Zh-TW, Ar, Ja, Ko, Pt, Ru, Tr, Vi.\n        -   **[i18n] Localize remaining UI strings (PR #1350)**:\n            - **Full Coverage**: Localized the remaining hardcoded strings and untranslated items in the UI, achieving full localization of the interface.\n            - **Removed Redundancy**: Removed all English fallbacks from the code, forcing all components to use i18n keys for localization.\n            - **Language Enhancement**: Improved translation accuracy for Japanese (ja) and ensured consistent display of new UI components across multiple languages.\n    *   **v4.0.8 (2026-01-30)**:\n        -   **[Core Feature] Window State Persistence (PR #1322)**: Automatically restores the window size and position from the previous session.\n        -   **[Core Fix] Graceful Shutdown for Admin Server (PR #1323)**: Fixed the port 8045 binding failure issue on Windows when restarting the app after exit.\n        -   **[Core Feature] Implement Full-link Debug Logging (PR #1308)**:\n            - **Backend Integration**: Introduced `debug_logger.rs` to capture and record raw request, transformed payload, and complete streaming response for OpenAI, Claude, and Gemini handlers.\n            - **Dynamic Configuration**: Supports hot-reloading for logging settings; enable/disable logging or change output directory without restarting the service.\n            - **Frontend Interaction**: Added a \"Debug Log\" toggle and a custom output directory selector in \"Advanced Settings\" for easier troubleshooting of protocol conversion and upstream communication.\n        -   **[UI Optimization] Optimize Chart Tooltip Floating Behavior (Issue #1263, PR #1307)**:\n            - **Overflow Defense**: Optimized the tooltip positioning algorithm in `TokenStats.tsx` to ensure floating information stays within the viewport on small windows or high zoom levels, preventing content from being buried by window boundaries.\n        -   **[Core Optimization] Robustness: Dynamic User-Agent Version Fetching with Multi-tier Fallback (PR #1316)**:\n            - **Dynamic Fetching**: Supports fetching the version dynamically from a remote endpoint for real-time UA accuracy.\n            - **Robust Fallback Chain**: Implements a three-tier fallback strategy (Remote Endpoint -> Cargo.toml -> Hardcoded), significantly improving initialization robustness.\n            - **Regex Pre-compilation**: Utilizes `LazyLock` for efficient version parsing, boosting performance and reducing memory jitter.\n            - **Enhanced Observability**: Added structured logging and a `VersionSource` enum, allowing developers to trace versioning origins and troubleshoot fetch failures effortlessly.\n        -   **[Core Fix] Resolve Gemini CLI \"Response stopped due to malformed function call.\" Error (PR #1312)**:\n            - **Parameter Field Alignment**: Renamed `parametersJsonSchema` to `parameters` in tool declarations to align with the latest Gemini API specifications.\n            - **Alignment Engine Enhancement**: Removed redundant parameter wrapping layers for more transparent and direct parameter passing.\n            - **Robustness Check**: Improved resilience against tool-call responses, effectively preventing output interruptions caused by parameter schema mismatches.\n        -   **[Core Fix] Resolve Issue where Port shows as 'undefined' in Docker/Headless Mode (Issue #1305)**: Fixed missing 'port' field and incorrect 'base_url' construction in the management API '/api/proxy/status', ensuring the frontend correctly displays the listening address.\n        -   **[Core Fix] Resolve Web Password Bypass in Docker/Headless Mode (Issue #1309)**:\n            - **Enhanced Default Auth**: Changed the default `auth_mode` to `auto`. In Docker or LAN-access environments, the system now automatically activates authentication to ensure `WEB_PASSWORD` is enforced.\n            - **Environment Variable Support**: Added support for `ABV_AUTH_MODE` and `AUTH_MODE` environment variables, allowing users to explicitly override the authentication mode at startup (supports `off`, `strict`, `all_except_health`, `auto`).\n    *   **v4.0.7 (2026-01-29)**:\n        -   **[Performance] Optimize Docker Build Process (Fix Issue #1271)**:\n            - **Native Architecture Build**: Split AMD64 and ARM64 build tasks into independent parallel jobs and removed the QEMU emulation layer, switching to native GitHub Runners for each architecture. This drastically reduces cross-platform build time from 3 hours to under 10 minutes.\n\n        -   **[Performance] Resolve Docker Version Lag and Crash with Large Datasets (Fix Issue #1269)**:\n            - **Asynchronous DB Operations**: Migrated all time-consuming database queries (traffic logs, token stats, etc.) to the background blocking thread pool (`spawn_blocking`). This eliminates UI freezes and proxy unavailability when viewing large log files (800MB+).\n            - **Smooth Monitoring Logic**: Optimized the monitoring state toggle logic to remove redundant restart logs, improving stability in Docker environments.\n        -   **[Core Fix] Resolve OpenAI Protocol 400 Invalid Argument Error (Fix Issue #1267)**:\n            - **Remove Aggressive Default**: Rolled back the default `maxOutputTokens: 81920` setting introduced in v4.0.6 for OpenAI/Claude protocols. This value exceeded the hard limits of many standard models (e.g., `gemini-3-pro-preview` or native Claude 3.5), causing immediate request rejection.\n            - **Smart Thinking Config**: Refined the thinking model detection logic to only inject `thinkingConfig` for models explicitly ending with `-thinking`. This prevents side effects on standard models (like `gemini-3-pro`) that do not support this parameter.\n        -   **[Compatibility] Fix OpenAI Codex (v0.92.0) Error (Fix Issue #1278)**:\n            - **Field Scrubbing**: Automatically filters out the non-standard `external_web_access` field injected by Codex clients in tool definitions, eliminating the 400 Invalid Argument error from Gemini API.\n            - **Enhanced Robustness**: Added mandatory validation for the tool `name` field. Invalid tool definitions missing a name will now be automatically skipped with a warning instead of failing the request.\n        -   **[Core Feature] Adaptive Circuit Breaker**:\n            - **Model-level Isolation**: Implemented compound key (`account_id:model`) rate limit tracking, ensuring that quota exhaustion of a single model does not lock the entire account.\n            - **Dynamic Backoff Strategy**: Supports user-defined multi-level backoff steps (e.g., `[60, 300, 1800, 7200]`), automatically increasing lock duration based on consecutive failures.\n            - **Live Configuration Refresh**: Integrated with `TokenManager` memory cache to apply configuration changes instantly to the proxy service without requiring a restart.\n            - **Management UI Integration**: Added a comprehensive control panel in the API Proxy page, supporting one-click toggle and manual clearing of rate limit records.\n        -   **[Core Optimization] Improved Log Cleanup & Reduction (Fix Issue #1280)**:\n            - **Automatic Space Recovery**: Introduced a size-based cleanup mechanism that triggers when the log directory exceeds 1GB, purging old logs until usage is below 512MB. This fundamentally prevents disk exhaustion from runaway logs.\n            - **Log Verbosity Reduction**: Downgraded high-frequency logs (OpenAI request/call bodies, TokenManager account pool polling) from INFO to DEBUG. INFO level now only contains concise request summaries.\n    *   **v4.0.6 (2026-01-28)**:\n        -   **[Core Fix] Resolve Google OAuth \"Account already exists\" Error**:\n            - **Persistence Upgrade**: Upgraded the authorization saving logic from \"add only\" to `upsert` (update or insert) mode. Re-authorizing an existing account now smoothly updates its tokens and project info without error.\n        -   **[Core Fix] Fix Manual OAuth Code Backfill Failure in Docker/Web Mode**:\n            - **Flow State Pre-initialization**: The backend now synchronizes and initializes the OAuth flow state when generating auth links in web mode. This ensures that manually pasted auth codes or URLs are correctly recognized and processed in environments like Docker where auto-redirect is unavailable.\n        -   **[UX Improvement] Unified OAuth Persistence Path**: Refactored `TokenManager` to ensure all platforms share the same robust account verification and storage logic.\n        -   **[Performance] Optimize Rate Limit Recovery Mechanism (PR #1247)**:\n            - **Auto-Cleanup Frequency**: Shortened the background auto-cleanup interval for rate limit records from 60s to 15s, significantly speeding up business recovery after 429 or 503 errors.\n            - **Smart Sync Clearing**: Optimized account refresh logic to immediately clear local rate limit locks when refreshing single or all accounts, allowing updated quotas to be used instantly.\n            - **Progressive Capacity Backoff**: Optimized the retry strategy for `ModelCapacityExhausted` errors (e.g., 503) from a fixed 15s wait to a tiered `[5s, 10s, 15s]` approach, significantly reducing wait times for transient capacity fluctuations.\n        -   **[Core Fix] Window Titlebar Dark Mode Adaptation (PR #1253)**: Fixed an issue where the titlebar did not follow the system theme when switching to dark mode, ensuring visual consistency.\n260:         -   **[Core Fix] Raise Default Output Limit for Opus 4.5 (Fix Issue #1244)**:\n261:             -   **Limit Breakthrough**: Increased the default `max_tokens` for Claude and OpenAI protocols from 16k to **81,920** (80k).\n262:             -   **Resolve Truncation**: Completely resolved the truncation issue where Opus 4.5 and similar models were capped at around 48k tokens when thinking mode was enabled due to default budget constraints. Users can now enjoy full long-context output capabilities without any configuration.\n        -   **[Core Fix] Fix Ghost Account Issue After Deletion**:\n            -   **Sync Reload**: Fixed a critical bug where deleted accounts would persist in the proxy service's memory cache.\n            -   **Immediate Effect**: Now, deleting single or multiple accounts triggers a mandatory reload of the proxy service, ensuring the deleted accounts are immediately removed from the active pool and no longer participate in request rotation.\n        -   **[Core Fix] Cloudflared Tunnel Startup Fixes (Fix PR #1238)**:\n            -   **Startup Crash Fix**: Removed unsupported command-line arguments (`--no-autoupdate` / `--loglevel`) that caused the cloudflared process to exit immediately.\n            -   **URL Parsing Correction**: Fixed an offset error in named tunnel URL extraction, ensuring correctly formatted access links.\n            -   **Windows Experience**: Added `DETACHED_PROCESS` flags for Windows, enabling fully silent background execution without popup windows.\n    *   **v4.0.5 (2026-01-28)**:\n        -   **[Core Fix] Resolve Google OAuth 400 Error in Docker/Web Mode (Google OAuth Fix)**:\n            - **Protocol Alignment**: Forced `localhost` as the OAuth redirect URI for all modes (including Docker/Web) to bypass Google's security restrictions on private IPs and non-HTTPS environments.\n            - **Workflow Optimization**: Leveraged the existing \"Manual Auth Code Submission\" feature to ensure successful account authorization even in remote server deployments.\n        -   **[Enhancement] Arabic Language Support & RTL Layout Adaptation (PR #1220)**:\n            - **i18n Expansion**: Added full Arabic (`ar`) language support.\n            - **RTL Layout**: Implemented automatic detection and adaptation for Right-to-Left (RTL) UI layouts.\n            - **Typography**: Integrated the Effra font family to significantly enhance the readability and aesthetics of Arabic text.\n        -   **[Enhancement] Manual Clear Rate Limit Records**:\n            - **Management UI Integration**: Added a \"Clear Rate Limit Records\" button in the \"Proxy Settings -> Account Rotation & Session Scheduling\" section, allowing manual clearing of local rate limit locks (429/503 records) across both Desktop and Web modes.\n            - **Smart Sync Linkage**: Implemented smart synchronization of quotas and limits. Refreshing account quotas (single or all) now automatically clears local rate limit states, ensuring immediate effect for updated quotas.\n            - **Backend Core**: Implemented manual and automatic clearing logic within `RateLimitTracker` and `TokenManager` to ensure state consistency under high concurrency.\n            - **API Support**: Added corresponding Tauri commands and Admin API (`DELETE /api/proxy/rate-limits`) to facilitate programmatic management and integration.\n            - **Force Retry**: Enables forcing the next request to ignore previous backoff times and attempt to connect to the upstream directly, facilitating immediate business recovery after network restoration.\n    *   **v4.0.4 (2026-01-27)**:\n        -   **[Enhancement] Deep Integration of Gemini Image Generation & Multi-Protocol Support (PR #1203)**:\n            - **OpenAI Compatibility**: Added support for calling Gemini 3 image models via the standard OpenAI Images API (`/v1/images/generate`), supporting parameters like `size` and `quality`.\n            - **Multi-Protocol Integration**: Enhanced Claude and OpenAI Chat interfaces to support direct image generation parameters, implementing automatic aspect ratio calculation and 4K/2K quality mapping.\n            - **Documentation**: Added `docs/gemini-3-image-guide.md` providing a complete guide for Gemini image generation integration.\n            - **Stability Optimization**: Optimized common utility functions (`common_utils.rs`) and Gemini/OpenAI mapping logic to ensure stable transmission of large payloads.\n        -   **[Core Fix] Align OpenAI Retry & Rate Limit Logic (PR #1204)**:\n            - **Logic Alignment**: Refactored the retry, rate limiting, and account rotation logic for the OpenAI handler to align with the Claude handler, significantly improving stability under high concurrency.\n            - **Hot Reload Optimization**: Ensured that OpenAI requests can accurately execute backoff strategies and automatically switch available accounts when triggering 429 or 503 errors.\n        -   **[Core Fix] Web OAuth Account Persistence Fix**:\n            - **Index Sync**: Resolved an issue where accounts added via Web OAuth were saved as files but not updated in the global account index (`accounts.json`), causing them to disappear after restart or be invisible to the desktop app.\n            - **Lock Unification**: Refactored `TokenManager` persistence logic to reuse `modules::account` core methods, ensuring atomicity of file locks and index updates.\n        -   **[Core Fix] Resolve Google OAuth Non-Localhost Callback Restriction (Fix Issue #1186)**:\n            -   **Issue Context**: Google does not support using non-localhost private IPs as callback URLs in OAuth flows, triggering \"Unsafe App\" warnings even with `device_id` injection.\n            -   **Solution**: Introduced a standardized \"Manual OAuth Submission\" flow. When the browser cannot auto-redirect to localhost (e.g., remote deployment), users can manually paste the callback URL or auth code to complete authorization.\n            - **Enhancement**: Refactored the manual submission UI with full i18n support (9 languages) and polished interactions, ensuring successful account addition in any network environment.\n        -   **[Core Fix] Resolve Google Cloud Code API 429 Errors (Fix Issue #1176)**:\n            - **Smart Fallback**: Migrated default API traffic to the more stable Daily/Sandbox environments, bypassing frequent 429 errors currently affecting the production environment (`cloudcode-pa.googleapis.com`).\n            - **Enhanced Robustness**: Implemented a three-level fallback strategy (Sandbox -> Daily -> Prod) to ensure high availability of core business flows under extreme network conditions.\n        -   **[Core Optimization] Account Scheduling Algorithm Upgrade**:\n            - **Health Score System**: Introduced a real-time health score (0.0 to 1.0). Failures (e.g., 429/5xx) significantly penalize the score to demote impaired accounts, while successful requests gradually restore scores for intelligent self-healing.\n            - **Tiered Smart Prioritization**: Re-engineered scheduling priority to `Subscription Tier > Remaining Quota > Health Score`. Ensures that among accounts with the same tier and quota, the most stable one is always prioritized.\n            - **Throttle Delay Mechanism**: In extreme rate-limiting scenarios, if all accounts are locked but one is due to recover within 2s, the system will automatically suspend the thread to wait instead of erroring out. This markedly improves high-concurrency stability and session stickiness.\n            - **Full Protocol Integration**: Refactored the `TokenManager` core interface and completed synchronized adaptation for all handlers (Claude, Gemini, OpenAI, Audio, Warmup), ensuring scheduling changes are transparent to business logic.\n        -   **[Core Fix] Persist Fixed Account Mode Setting (PR #1209)**:\n            -   **Issue**: The Fixed Account Mode setting was reset after service restart in previous versions.\n            -   **Fix**: Implemented persistent storage for the setting, ensuring user preference remains effective after restart.\n        -   **[Core Fix] Millisecond Parsing for Rate Limits (PR #1210)**:\n            -   **Issue**: Some upstream services return `Retry-After` or rate limit headers with decimal millisecond values, causing parsing failures.\n            -   **Fix**: Enhanced time parsing logic to support floating-point time formats, improving compatibility with non-standard upstreams.\n    *   **v4.0.3 (2026-01-27)**:\n        -   **[Enhancement] Increase Body Limit to Support Large Image Payloads (PR #1167)**:\n            - Increased the default request body limit from 2MB to **100MB** to resolve 413 (Payload Too Large) errors during multi-image transfers.\n            - Added environment variable `ABV_MAX_BODY_SIZE` to allow dynamic adjustment of the maximum limit.\n            - Transparently logs the effective Body Limit on startup for easier troubleshooting.\n        -   **[Core Fix] Resolve Google OAuth Authorization Failure Due to Missing 'state' Parameter (Issue #1168)**:\n            - Fixed the \"Agent execution terminated\" error when adding Google accounts.\n            - Implemented random `state` parameter generation and callback verification to enhance OAuth security and compatibility.\n            - Ensured authorization flows comply with OAuth 2.0 standards in both desktop and web modes.\n        -   **[Core Fix] Resolve Proxy Toggle and Account Changes Requiring Restart in Docker/Web Mode (Issue #1166)**:\n            - Implemented persistent storage for proxy toggle states, ensuring consistency across container restarts.\n            - Added automatic hot-reloading of the Token Manager after adding, deleting, switching, reordering, or importing accounts, making changes effective immediately in the proxy service.\n            - Optimized account switching logic to automatically clear legacy session bindings, ensuring requests are immediately routed to the new account.\n    *   **v4.0.2 (2026-01-26)**:\n        -   **[Core Fix] Session Persistence After Account Switch (Fix Issue #1159)**:\n            - Enhanced database injection logic to synchronize identity info (Email) and clear legacy UserID cache during account switching.\n            - Resolved session association failures caused by mismatches between the new Token and old identity metadata.\n        -   **[Core Fix] Model Mapping Persistence in Docker/Web Mode (Fix Issue #1149)**:\n            - Resolved an issue where model mapping configurations modified via API in Docker or Web deployment modes were not saved to disk.\n            - Ensured the `admin_update_model_mapping` interface correctly invokes persistence logic, so configurations remain effective after container restarts.\n        -   **[Architecture Optimization] MCP Tool Support Architecture Upgrade (Schema Cleaning & Tool Adapters)**:\n            - **Constraint Semantic Backfilling (Constraint Hints)**:\n                - Implemented intelligent constraint migration mechanism that converts unsupported constraint fields (`minLength`, `pattern`, `format`, etc.) into description hints before removal.\n                - Added `CONSTRAINT_FIELDS` constant and `move_constraints_to_description` function to ensure models can understand original constraints through descriptions.\n                - Example: `{\"minLength\": 5}` → `{\"description\": \"[Constraint: minLen: 5]\"}`\n            - **Enhanced anyOf/oneOf Intelligent Flattening**:\n                - Rewrote `extract_best_schema_from_union` function with scoring mechanism to select the best type (object > array > scalar).\n                - Automatically adds `\"Accepts: type1 | type2\"` hints to descriptions after merging, preserving all possible type information.\n                - Added `get_schema_type_name` function supporting explicit types and structural inference.\n            - **Pluggable Tool Adapter Layer (Tool Adapter System)**:\n                - Created `ToolAdapter` trait providing customized Schema processing capabilities for different MCP tools.\n                - Implemented `PencilAdapter` that automatically adds descriptions for Pencil drawing tool's visual properties (`cornerRadius`, `strokeWidth`) and path parameters.\n                - Established global adapter registry supporting tool-specific optimizations via `clean_json_schema_for_tool` function.\n            - **High-Performance Cache Layer (Schema Cache)**:\n                - Implemented SHA-256 hash-based Schema caching mechanism to avoid redundant cleaning of identical schemas.\n                - Uses LRU eviction strategy with max 1000 entries, memory usage < 10MB.\n                - Provides `clean_json_schema_cached` function and cache statistics, expected 60%+ performance improvement.\n            - **Impact**: \n                - ✅ Significantly improves Schema compatibility and model understanding for MCP tools (e.g., Pencil)\n                - ✅ Establishes pluggable foundation for adding more MCP tools (filesystem, database, etc.) in the future\n                - ✅ Fully backward compatible, all 25 tests passing\n        -   **[Security Enhancement] Web UI Management Password & API Key Separation (Fix Issue #1139)**:\n            - **Independent Password Configuration**: Support setting a separate management console login password via `ABV_WEB_PASSWORD` or `WEB_PASSWORD` environment variables.\n            - **Intelligent Authentication Logic**: \n                - Management interfaces prioritize validating the independent password, automatically falling back to `API_KEY` if not set (ensuring backward compatibility).\n                - AI Proxy interfaces strictly only allow `API_KEY` for authentication, achieving permission isolation.\n            - **Configuration UI Support**: Added a management password editing item in \"Dashboard - Service Config,\" supporting one-click retrieval or modification.\n            - **Log Guidance**: Headless mode startup clearly prints the status and retrieval methods for both API Key and Web UI Password.\n    *   **v4.0.1 (2026-01-26)**:\n        -   **[UX Optimization] Theme & Language Transition Smoothness**:\n            - Resolved the UI freezing issue during theme and language switching by decoupling configuration persistence from the state update loop.\n            - Optimized View Transition API usage in the Navbar to ensure non-blocking visual updates.\n            - Made window background sync calls asynchronous to prevent React render delays.\n        -   **[Core Fix] Proxy Service Startup Deadlock**:\n            - Fixed a race condition/deadlock where starting the proxy service would block status polling requests.\n            - Introduced an atomic startup flag and non-blocking status checks to ensure the UI remains responsive during service initialization.\n    *   **v4.0.0 (2026-01-25)**:\n        -   **[Major Architecture] Deep Migration to Tauri v2**:\n            - Fully adapted to Tauri v2 core APIs, including system tray, window management, and event systems.\n            - Resolved asynchronous Trait dynamic dispatch and lifecycle conflict issues, significantly enhancing backend performance and stability.\n        -   **[Deployment Revolution] Native Headless Docker Mode**:\n            - Implemented a \"pure backend\" Docker image, completely removing dependencies on VNC, noVNC, or XVFB, significantly reducing RAM and CPU usage.\n            - Supports direct hosting of frontend static resources; the management console is accessible via browser immediately after container startup.\n        -   **[Deployment Fix] Arch Linux Installation Script Fix (PR #1108)**:\n            - Fixed the extraction failure in `deploy/arch/PKGBUILD.template` caused by hardcoded `data.tar.zst`.\n            - Implemented dynamic compression format detection using wildcards, ensuring compatibility across different `.deb` package versions.\n        -   **[Management Upgrade] Full-Featured Web Console**:\n            - Refactored the management dashboard, enabling all core features (Account management, API proxy monitoring, OAuth authorization, model mapping) to be completed via browser.\n            - Completed OAuth callback handling for Web mode, supporting `ABV_PUBLIC_URL` customization, perfectly adapting to remote VPS or NAS deployment scenarios.\n        -   **[Normalization] Structural Cleanup & Unitization**:\n            - Cleaned up redundant `deploy` directories and legacy scripts, resulting in a more modern project structure.\n            - Standardized the Docker image name as `antigravity-manager` and integrated a dedicated `docker/` directory and manual.\n        -   **[API Enhancement] Traffic Logs & Monitoring**:\n            - Optimized the real-time monitoring experience for traffic logs, adding polling mechanisms and statistics endpoints for Web mode.\n            - Refined management API route placeholder naming for improved calling precision.\n        -   **[UX Improvement] Monitor Page Layout & Dark Mode Optimization (PR #1105)**:\n            -   **Layout Refactoring**: Optimized the container layout of the traffic log page with a fixed max-width and responsive margins. This resolves content stretching issues on large screens, offering a more comfortable visual experience.\n            -   **Dark Mode Consistency**: Migrated the color scheme of the log detail modal from hardcoded Slate colors to the Base theme. This ensures seamless integration with the global dark mode style and improves visual consistency.\n        -   **[UX Improvement] Auto-Update Fallback Mechanism**:\n            -   **Smart Fallback**: Fixed the issue where the update button would be unresponsive if the native package was not ready (e.g., Draft Release). The system now detects this state, notifies the user, and gracefully falls back to browser-based download.\n        -   **[Core Fix] Deep Optimization of Signature Cache & Rewind Detection (PR #1094)**:\n            -   **400 Error Self-healing**: Enhanced the cleaning logic for thinking block signatures. The system now automatically identifies \"orphaned signatures\" caused by server restarts and proactively strips them before sending to upstream, fundamentally preventing `400 Invalid signature` errors.\n            -   **Rewind Detection Mechanism**: Upgraded the cache layer to include Message Count validation. When a user rewinds the conversation history and resends, the system automatically resets the signature state to ensure dialogue flow validity.\n            -   **Full-chain Adaptation**: Optimized data links for Claude, Gemini, and z.ai (Anthropic) to ensure precise propagation of message counts in both streaming and non-streaming requests.\n        -   **[OpenAI Robustness] Enhanced Retry Policy & Model-level Isolation (PR #1093)**:\n            -   **Robust Retries**: Enforced a minimum of 2 attempts for OpenAI handlers to handle transient jitters; removed hard-stop on quota exhaustion to allow account rotation.\n            -   **Model-level Isolation**: Implemented fine-grained rate limiting for OpenAI requests, preventing model-specific limits from locking the whole account.\n            -   **API Fix**: Resolved an email/ID inconsistency in TokenManager's async interface, ensuring accurate rate-limit tracking.\n        -   **[System Robustness] Unified Retry & Backoff Hub**:\n            -   **Logic Normalization**: Abstracted retry logic from individual protocol handlers into `common.rs`, achieving system-wide logic normalization.\n            -   **Enforced Backoff Delays**: Completely fixed the issue where requests would retry immediately when a `Retry-After` header was missing. All handlers now execute physical wait times via the shared module before retrying, protecting IP reputation.\n            -   **Aggressive Parameter Tuning**: Significantly increased initial backoff times for 429 and 503 errors to **5s-10s**, drastically reducing production risk and prevent account bans.\n        -   **[CLI Sync Optimization] Resolved Token Conflict & Model Config Cleanup (PR #1054)**:\n            -   **Automatic Conflict Resolution**: Automatically removes the conflicting `ANTHROPIC_AUTH_TOKEN` when setting `ANTHROPIC_API_KEY`, resolving sync errors for the Claude CLI.\n            -   **Environment Variable Cleanup**: Proactively removes environment variables like `ANTHROPIC_MODEL` that might interfere with model defaults, ensuring consistent CLI behavior.\n            -   **Configuration Robustness**: Improved handling of empty API keys to prevent invalid configurations from affecting the sync process.\n\n    *   **v4.0.0 (2026-01-25)**:\n        -   **[Core Feature] Configurable Background Task Models**:\n            -   **Enhancement**: Users can now customize the model used for \"Background Tasks\" (e.g., title generation, summary compression), decoupled from the hardcoded `gemini-2.5-flash`.\n            -   **UI Update**: Added a \"Background Task Model\" setting in the \"Model Mapping\" page, allowing selection of any available model (e.g., `gemini-3-flash`) via dropdown.\n            -   **Routing Fix**: Resolved an issue where background tasks might bypass user custom mappings. `internal-background-task` now strictly adheres to user redirection rules.\n        -   **[Important Notice] Upstream Model Capacity Warning**:\n            -   **Capacity Exhausted**: We have received numerous reports that upstream Google `gemini-2.5-flash` and `gemini-2.5-flash-lite` models are currently experiencing severe capacity limitations (Rate Limited / Capacity Exhausted).\n            -   **Recommended Action**: To ensure service availability, we strongly recommend manually redirecting these models to alternatives (e.g., `gemini-3-flash` or `gemini-3-pro-high`) in \"Custom Mappings\" until upstream services recover.\n        -   **[Core Fix] Windows Startup Argument Support (PR #973)**:\n            -   **Fix**: Resolved an issue where startup arguments (e.g., tunneling configurations) were not correctly parsed and applied on the Windows platform. Thanks to @Mag1cFall for the contribution.\n        -   **[Core Fix] Enhanced Claude Signature Validation (PR #1009)**:\n            -   **Optimization**: Strengthened the signature validation logic for Claude models, fixing 400 errors in long conversations or complex tool-calling scenarios.\n            -   **Compatibility**: Introduced minimum signature length checks and a trust-on-length strategy for unknown signatures, significantly improving the stability of JSON tool calls.\n        -   **[i18n] Vietnamese Translation Optimization (PR #1017)**:\n            -   **Refinement**: Optimized Vietnamese translations for the About page and other UI elements for better clarity and conciseness.\n        -   **[i18n] Turkish Tray Translation Enhancement (PR #1023)**:\n            -   **Optimization**: Added full Turkish translation support for the system tray menu, improving the experience for Turkish-speaking users.\n            -   **[Enhancement] Multi-language Support & I18n Settings (PR #1029)**:\n            -   **New Language Support**: Added more comprehensive support for Portuguese, Japanese, Vietnamese, Turkish, Russian, and more.\n            -   **I18n Settings Panel**: Added a language selector in the Settings page, supporting instant switching of the application's display language.\n        -   **[i18n] Korean Support & UI Refinement (New)**:\n            -   **Korean Integration**: Added full Korean (`ko`) translation support, available for selection in Settings.\n            -   **UI Upgrade**: Refactored the language switcher in the top navigation bar from a single-click toggle to a more intuitive dropdown menu, displaying language abbreviations and full names for better usability.\n    *   **v3.3.49 (2026-01-22)**:\n        -   **[Core Fix] Thinking Interruption & 0-Token Defense (Fix Thinking Interruption)**:\n            -   **Issue**: Addressed an issue where Gemini models would unexpectedly terminate the stream after outputting \"Thinking\" content, causing Claude clients to receive 0-token responses and deadlock with errors.\n            -   **Defense Mechanism**:\n                - **State Tracking**: Real-time monitoring of streaming responses to detect \"Thinking-only\" states (Thinking sent, Content pending).\n                - **Auto-Recovery**: Upon detecting such interruptions, the system automatically closes the Thinking block, injects a system notice, and simulates valid Usage data to ensure the client terminates the session gracefully.\n        -   **[Core Fix] Removed Flash Lite Model to Fix 429 Errors**:\n            -   **Issue**: Observed that `gemini-2.5-flash-lite` is frequently returning 429 errors today due to **Upstream Google Container Capacity Exhausted** (MODEL_CAPACITY_EXHAUSTED), rather than standard account quota limits.\n            -   **Urgent Fix**: Replaced all internal `gemini-2.5-flash-lite` calls (e.g., background title generation, L3 summary compression) and preset mappings with the more stable `gemini-2.5-flash`.\n            -   **User Notice**: If you explicitly used `gemini-2.5-flash-lite` in \"Custom Mappings\" or \"Presets\", please update it to another model immediately, or you may continue to experience 429 errors.\n        -   **[UX Optimization] Immediate Effect of Settings (Fix PR #949)**:\n            -   **Instant Apply**: Fixed an issue where language changes required manual saving. Adjustments now apply immediately across the UI.\n        -   **[Code Cleanup] Backend Architecture Refactoring & Optimization (PR #950)**:\n            -   **Streamlining**: Deeply refactored mapping and handling logic within the proxy layer. Removed redundant modules (e.g., `openai/collector.rs`) to significantly improve maintainability.\n            -   **Stability Boost**: Optimized the conversion chains for OpenAI and Claude protocols, unified image configuration parsing, and hardened the context manager's robustness.\n        -   **[Core Fix] State Sync Strategy Update**:\n            -   **Consistency**: Improved the immediate application logic for themes and resolved conflicts between `App.tsx` and `Settings.tsx`, ensuring UI consistency during configuration loading.\n        -   **[Core Optimization] Context Compression & Token Savings**:\n            -   **Early Compression**: Since Claude CLI sends large chunks of history when resuming, compression thresholds are now configurable with lower defaults.\n            -   **L3 Pivot**: The L3 summary reset threshold has been lowered from 90% to 70%, triggering compression earlier to prevent massive token usage.\n            -   **UI Enhancement**: Added L1/L2/L3 compression threshold sliders in Experimental Settings for dynamic user customization.\n        -   **[Enhancement] API Monitor Dashboard Upgrade (PR #951)**:\n            -   **Account Filtering**: Added the ability to filter traffic logs by account, allowing for precise tracking of specific account usage in high-volume environments.\n            -   **Deep Detail Enhancement**: The monitor details page now displays critical metadata including request protocol (OpenAI/Anthropic/Gemini), account used, and mapped physical models.\n            -   **UI & i18n**: Optimized the layout of monitor details and completed translations for all 8 supported languages.\n        -   **[JSON Schema Optimization] Recursive $defs Collection & Improved Fallback (PR #953)**:\n            -   **Recursive Collection**: Added `collect_all_defs()` to gathered `$defs`/`definitions` from all schema levels, fixing missing nested definitions.\n            -   **Ref Flattening**: Always run `flatten_refs()` to catch and handle orphan `$ref` fields.\n            -   **Fallback Method**: Added fallback for unresolved `$ref`, converting them to string type with descriptive hints.\n            -   **Robustness**: Added new test cases for nested defs and unresolved refs to ensure schema processing stability.\n        -   **[Core Fix] Account Index Protection (Fix Issue #929)**:\n            -   **Security Hardening**: Removed automatic deletion logic on load failure, preventing accidental loss of account indexes during environment anomalies or upgrades.\n        -   **[Feature] Deep Optimization of Router & Model Mapping (PR #954)**:\n            -   **Deterministic Router Priority**: Resolved non-deterministic matching issues for multi-wildcard patterns by implementing a priority system based on pattern specificity.\n\n        -   **[Stability] OAuth Callback & Parsing Enhancement (Fix #931, #850, #778)**:\n            -   **Robust Parsing**: Optimized the local callback server's URL parsing logic to improve compatibility across different browsers.\n            -   **Detailed Logging**: Added raw request logging for authorization failures, enabling quicker debugging of network-level interceptions.\n        -   **[Optimization] OAuth Communication Quality (Issue #948, #887)**:\n            -   **Timeout Extension**: Increased auth request timeouts to 60 seconds to significantly improve token exchange success rates in proxy environments.\n            -   **Error Guidance**: Provided clear guidance for Google API connectivity issues, helping users troubleshoot proxy settings.\n        -   **[UX Enhancement] Upstream Proxy Validation & Restart Hint (Contributed by @zhiqianzheng)**:\n            -   **Config Validation**: When the user enables upstream proxy but leaves the URL empty, the save operation is blocked with a clear error message, preventing connection failures due to invalid configuration.\n            -   **Restart Reminder**: After successfully saving proxy settings, users are reminded to restart the app for changes to take effect, reducing troubleshooting time.\n            -   **i18n Support**: Added translations for Simplified Chinese, Traditional Chinese, English, and Japanese.\n\n    *   **v3.3.48 (2026-01-21)**:\n        -   **[Core Fix] Windows Console Flashing Fix (Fix PR #933)**:\n            -   **Problem**: On Windows, launching the application or executing background CLI commands would sometimes cause a command prompt window to briefly flash, disrupting the user experience.\n            -   **Fix**: Added the `CREATE_NO_WINDOW` flag to the `cloudflared` process creation logic, ensuring all background processes run silently without visible windows.\n            -   **Impact**: Resolved the window flashing issue for Windows users during app startup or CLI interactions.\n    *   **v3.3.47 (2026-01-21)**:\n        -   **[Core Fix] Image Generation API Parameter Mapping Enhancement (Fix Issue #911)**:\n            -   **Background**: The `/v1/images/generations` endpoint had two parameter mapping defects:\n                - The `size` parameter only supported hardcoded specific dimension strings; OpenAI standard sizes (like `1280x720`) were incorrectly fallback to `1:1` aspect ratio\n                - The `quality` parameter was only used for Prompt enhancement and not mapped to Gemini's `imageSize`, unable to control the physical resolution of output images\n            -   **Fix Details**:\n                - **Extended `common_utils.rs`**: Added `parse_image_config_with_params` function to support parsing image configuration from OpenAI parameters (`size`, `quality`)\n                - **Dynamic Aspect Ratio Calculation**: Added `calculate_aspect_ratio_from_size` function, using mathematical calculation instead of hardcoded matching, supporting any `WIDTHxHEIGHT` format\n                - **Unified Configuration Parsing**: Modified `handle_images_generations` function, removed hardcoded mapping, calling unified configuration parsing function\n                - **Parameter Mapping**: `quality: \"hd\"` → `imageSize: \"4K\"`, `quality: \"medium\"` → `imageSize: \"2K\"`\n            -   **Test Verification**: Added 8 unit tests covering OpenAI parameter parsing, dynamic calculation, backward compatibility scenarios, all passing\n            -   **Compatibility Guarantee**:\n                - ✅ Backward Compatible: Chat path (like `gemini-3-pro-image-16-9-4k`) still works normally\n                - ✅ Progressive Enhancement: Supports more OpenAI standard sizes, `quality` parameter correctly mapped\n                - ✅ No Breaking Changes: Claude, Vertex, Gemini protocols unaffected\n            -   **Impact**: Resolved OpenAI Images API parameter mapping issues, all protocols automatically benefit through `common_utils`\n        -   **[Core Optimization] 3-Layer Progressive Context Compression**:\n            -   **Background**: Long conversations frequently trigger \"Prompt is too long\" errors, manual `/compact` is tedious, and existing compression strategies break LLM's KV Cache, causing cost spikes\n            -   **Solution - Multi-Layer Progressive Compression Strategy**:\n                - **Layer 1 (60% pressure)**: Intelligent Tool Message Trimming\n                    - Removes old tool call/result messages, retains last 5 rounds of interaction\n                    - **Completely preserves KV Cache** (only deletes messages, doesn't modify content)\n                    - Compression rate: 60-90%\n                - **Layer 2 (75% pressure)**: Thinking Content Compression + Signature Preservation\n                    - Compresses Thinking block text content in `assistant` messages (replaces with \"...\")\n                    - **Fully preserves `signature` field**, resolves Issue #902 (signature loss causing 400 errors)\n                    - Protects last 4 messages from compression\n                    - Compression rate: 70-95%\n                - **Layer 3 (90% pressure)**: Fork Session + XML Summary\n                    - Uses `gemini-2.5-flash-lite` to generate 8-section XML structured summary (extremely low cost)\n                    - Extracts and preserves last valid Thinking signature\n                    - Creates new message sequence: `[User: XML Summary] + [Assistant: Confirmation] + [User's Latest Message]`\n                    - **Completely preserves Prompt Cache** (prefix stable, append-only)\n                    - Compression rate: 86-97%\n            -   **Technical Implementation**:\n                - **New Module**: `context_manager.rs` implements Token estimation, tool trimming, Thinking compression, signature extraction\n                - **Helper Function**: `call_gemini_sync()` - reusable synchronous upstream call function\n                - **XML Summary Template**: 8-section structured summary (goal, tech stack, file state, code changes, debugging history, plan, preferences, signature)\n                - **Progressive Triggering**: Auto-triggers by pressure level, re-estimates Token usage after each compression\n            -   **Cost Optimization**:\n                - Layer 1: Zero cost (doesn't break cache)\n                - Layer 2: Low cost (only breaks partial cache)\n                - Layer 3: Minimal cost (summary uses flash-lite, new session is fully cache-friendly)\n                - **Total Savings**: 86-97% Token cost while maintaining signature chain integrity\n            -   **User Experience**:\n                - Automated: No manual `/compact` needed, system handles automatically\n                - Transparent: Detailed logs record each layer's trigger and effect\n                - Fault-tolerant: Layer 3 returns friendly error on failure\n            -   **Impact**: Completely resolves context management issues in long conversations, significantly reduces API costs, ensures tool call chain integrity\n        -   **[Core Optimization] Context Estimation and Scaling Algorithm Enhancement (PR #925)**:\n            -   **Background**: In long conversation scenarios like Claude Code, the fixed Token estimation algorithm (3.5 chars/token) has huge errors in mixed Chinese-English content, causing the 3-layer compression logic to fail to trigger in time, ultimately still reporting \"Prompt is too long\" errors\n            -   **Solution - Dynamic Calibration + Multi-language Awareness**:\n                - **Multi-language Aware Estimation**:\n                    - **ASCII/English**: ~4 chars/Token (optimized for code and English docs)\n                    - **Unicode/CJK (Chinese/Japanese/Korean)**: ~1.5 chars/Token (optimized for Gemini/Claude tokenization)\n                    - **Safety Margin**: Additional 15% safety buffer on top of calculated results\n                - **Dynamic Calibrator (`estimation_calibrator.rs`)**:\n                    - **Self-learning Mechanism**: Records \"Estimated Token Count\" vs Google API's \"Actual Token Count\" for each request\n                    - **Calibration Factor**: Uses Exponential Moving Average (EMA, 60% old ratio + 40% new ratio) to maintain calibration coefficient\n                    - **Conservative Initialization**: Initial calibration coefficient is 2.0, ensuring extremely conservative compression triggering in early system operation\n                    - **Auto-convergence**: Automatically corrects based on actual data, making estimates increasingly accurate\n                - **Integration with 3-Layer Compression Framework**:\n                    - Uses calibrated Token counts in all estimation stages (initial estimation, re-estimation after Layer 1/2/3)\n                    - Records detailed calibration factor logs after each compression layer for debugging and monitoring\n            -   **Technical Implementation**:\n                - **New Module**: `estimation_calibrator.rs` - Global singleton calibrator, thread-safe\n                - **Modified Files**: `claude.rs`, `streaming.rs`, `context_manager.rs`\n                - **Calibration Data Flow**: Streaming response collector → Extract actual Token count → Update calibrator → Next request uses new coefficient\n            -   **User Experience**:\n                - **Transparency**: Logs show raw estimate, calibrated estimate, and calibration factor for understanding system behavior\n                - **Adaptive**: System automatically adjusts based on user's actual usage patterns (Chinese-English ratio, code volume, etc.)\n                - **Precise Triggering**: Compression logic based on more accurate estimates, significantly reducing \"false negatives\" and \"false positives\"\n            -   **Impact**: Significantly improves context management precision, resolves automatic compression failure issues reported in Issue #902 and #867, ensures long conversation stability\n        -   **[Critical Fix] Thinking Signature Recovery Logic Optimization**:\n            -   **Background**: In retry scenarios, signature check logic didn't check Session Cache, causing incorrect Thinking mode disabling, resulting in 0 token requests and response failures\n            -   **Symptoms**:\n                - Retry shows \"No valid signature found for function calls. Disabling thinking\"\n                - Traffic logs show `I: 0, O: 0` (actual request succeeded but tokens not recorded)\n                - Client may not receive response content\n            -   **Fix Details**:\n                - **Extended Signature Check Scope**: `has_valid_signature_for_function_calls()` now checks Session Cache\n                - **Check Priority**: Global Store → **Session Cache (NEW)** → Message History\n                - **Detailed Logging**: Added signature source tracking logs for debugging\n            -   **Technical Implementation**:\n                - Modified signature validation logic in `request.rs`\n                - Added `session_id` parameter passing to signature check function\n                - Added `[Signature-Check]` log series for tracking signature recovery process\n            -   **Impact**: Completely resolves Thinking mode degradation in retry scenarios, ensures Token statistics accuracy, improves long session stability\n        -   **[Core Fix] Universal Parameter Alignment Engine**:\n            -   **Background**: Completely resolves `400 Bad Request` errors from the Gemini API caused by parameter type mismatches (e.g., string instead of number) during Tool Use.\n            -   **Fix Details**:\n                - **Implementation**: Developed `fix_tool_call_args` in `json_schema.rs` to automatically coerce parameter types (strings to numbers/booleans) based on their JSON Schema definitions.\n                - **Protocol Refactoring**: Refactored both OpenAI and Claude protocol layers to use the unified alignment engine, eliminating scattered hardcoded logic.\n            -   **Resolved Issues**: Fixed failures in tools like `local_shell_call` and `apply_patch` when parameters were incorrectly formatted as strings by certain clients or proxy layers.\n            -   **Impact**: Significantly improves the stability of tool calls and reduces upstream API 400 errors.\n        -   **[Enhancement] Image Model Quota Protection Support (Fix Issue #912)**:\n            -   **Background**: Users reported that the image generation model (G3 Image) lacked quota protection, causing accounts with exhausted quotas to still be used for image requests\n            -   **Fix Details**:\n                - **Backend Configuration**: Added `gemini-3-pro-image` to `default_monitored_models()` in `config.rs`, aligning with Smart Warmup and Pinned Quota Models lists\n                - **Frontend UI**: Added image model option in `QuotaProtection.tsx`, adjusted layout to 4 models per row (consistent with Smart Warmup)\n            -   **Impact**: \n                - ✅ Backward Compatible: Existing configurations unaffected; new users or config resets will automatically include the image model\n                - ✅ Complete Protection: All 4 core models (Gemini 3 Flash, Gemini 3 Pro High, Claude 4.5 Sonnet, Gemini 3 Pro Image) are now monitored by quota protection\n                - ✅ Auto-trigger: When image model quota falls below threshold, accounts are automatically added to the protection list, preventing further consumption\n        -   **[Transport Layer Optimization] Streaming Response Anti-Buffering**:\n            -   **Background**: When deployed behind reverse proxies like Nginx, streaming responses may be buffered by the proxy, increasing client-side latency\n            -   **Fix Details**:\n                - **Added X-Accel-Buffering Header**: Injected `X-Accel-Buffering: no` header in all streaming responses\n                - **Multi-Protocol Coverage**: Claude (`/v1/messages`), OpenAI (`/v1/chat/completions`), and Gemini native protocol all supported\n            -   **Technical Details**:\n                - Modified files: `claude.rs:L877`, `openai.rs:L314`, `gemini.rs:L240`\n                - This header instructs Nginx and other reverse proxies not to buffer streaming responses, passing them directly to clients\n            -   **Impact**: Significantly reduces streaming response latency in reverse proxy scenarios, improving user experience\n        -   **[Error Recovery Enhancement] Multi-Protocol Signature Error Recovery Prompts**:\n            -   **Background**: When signature errors occur in Thinking mode, merely removing signatures may cause the model to generate empty responses or simple \"OK\" replies\n            -   **Fix Details**:\n                - **Claude Protocol Enhancement**: Added repair prompts to existing signature error retry logic, guiding the model to regenerate complete responses\n                - **OpenAI Protocol Implementation**: Added 400 signature error detection and repair prompt injection logic\n                - **Gemini Protocol Implementation**: Added 400 signature error detection and repair prompt injection logic\n            -   **Repair Prompt**:\n                ```\n                [System Recovery] Your previous output contained an invalid signature. \n                Please regenerate the response without the corrupted signature block.\n                ```\n            -   **Technical Details**:\n                - Claude: `claude.rs:L1012-1030` - Enhanced existing logic, supports String and Array message formats\n                - OpenAI: `openai.rs:L391-427` - Complete implementation, uses `OpenAIContentBlock::Text` type\n                - Gemini: `gemini.rs:L17, L299-329` - Modified function signature to support mutable body, injects repair prompts\n            -   **Impact**: \n                - ✅ Improved error recovery success rate: Model receives clear instructions, avoiding meaningless responses\n                - ✅ Multi-protocol consistency: All 3 protocols have the same error recovery capability\n                - ✅ Better user experience: Reduces conversation interruptions caused by signature errors\n    *   **v3.3.46 (2026-01-20)**:\n        -   **[Enhancement] Deep Optimization & i18n Standardization for Token Stats (PR #892)**:\n            -   **Unified UI/UX**: Implemented custom Tooltip components to unify hover styles across Area, Bar, and Pie charts, enhancing contrast and readability in Dark Mode.\n            -   **Visual Refinements**: Optimized chart cursors and grid lines, removing redundant hover overlays for a cleaner, more professional interface.\n            -   **Adaptive Layout**: Improved Flexbox layout for chart containers, ensuring they fill available vertical space across various window sizes and eliminating empty gaps.\n            -   **Per-Account Trend Statistics**: Added a \"By Account\" view mode, enabling intuitive analysis of token consumption shares and activity levels via pie and trend charts.\n            -   **i18n Standardization**: Completely resolved duplicate key warnings in `ja.json`, `zh-TW.json`, `vi.json`, `ru.json`, and `tr.json`. Added missing translations for `account_trend`, `by_model`, etc., ensuring consistent UI presentation across all 8 supported languages.\n        -   **[Core Fix] Remove [DONE] from Stop Sequences to Prevent Truncation (PR #889)**:\n            -   **Background**: `[DONE]` is a standard SSE (Server-Sent Events) protocol end signal that frequently appears in code and documentation. Including it as a `stopSequence` caused unexpected output truncation when the model explained SSE-related content.\n            -   **Fix Details**: Removed the `\"[DONE]\"` marker from the Gemini request's `stopSequences` array.\n            -   **Technical Notes**:\n                - Gemini stream termination is controlled by the `finishReason` field, not `stopSequence`\n                - SSE-level `\"data: [DONE]\"` is handled separately in `mod.rs`\n            -   **Impact**: Resolved the issue where model output was prematurely terminated when generating content containing SSE protocol explanations, code examples, etc. (Issue #888).\n        -   **[Deployment] Docker Build Dual-Mode Adaptation (Default/China Mode)**:\n            -   **Dual-Mode Architecture**: Introduced `ARG USE_CHINA_MIRROR` build argument. The default mode keeps the original Debian official sources (ideal for overseas/cloud builds); when enabled, it automatically switches to Tsinghua University (TUNA) mirrors (optimized for mainland China).\n            -   **Flexibility Boost**: Completely resolved slow builds in overseas environments caused by hardcoded mirrors, while preserving acceleration for users in China.\n        -   **[Stability] VNC & Container Startup Logic Hardening (PR #881)**:\n            -   **Zombie Process Cleanup**: Optimized cleanup logic in `start.sh` using `pkill` to precisely terminate Xtigervnc and websockify processes and clean up `/tmp/.X11-unix` lock files, resolving various VNC connection issues after restarts.\n            -   **Healthcheck Upgrade**: Expanded Healthcheck to include websockify and the main application, ensuring container status more accurately reflects service availability.\n            -   **Major Fix**: Resolved OpenAI protocol 404 errors and fixed a compatibility defect where Codex (`/v1/responses`) with complex object array `input` or custom tools like `apply_patch` (missing schema) caused upstream 400 (`INVALID_ARGUMENT`) errors.\n            -   **Thinking Model Optimization**: Resolved mandatory error issues with Claude 4.6 Thinking models when thought chains are missing in historical messages, implementing intelligent protocol fallback and placeholder block injection.\n            -   **Protocol Completion**: Enhanced OpenAI Legacy endpoints with Token usage statistics and Header injection. Added support for `input_text` content blocks and mapped the `developer` role to system instructions.\n            -   **requestId Unification**: Unified `requestId` prefix to `agent-` across all OpenAI paths to resolve ID recognition issues with some clients. interface response bodies, resolving the issue where token consumption was not displayed in traffic logs.\n        -   **[Core Fix] JSON Schema Array Recursive Cleaning Fix (Resolution of Gemini API 400 Errors)**:\n            -   **Issue**: Complex nested array schemas in tool definitions (like `apply_patch` or `local_shell_call`) were not being recursively cleaned, leading to 400 errors from Gemini API due to unsupported fields like `const` or `propertyNames`.\n            -   **Fix**: Implemented full recursive cleaning for all `Value::Array` types in the JSON Schema processor.\n            -   **Impact**: Significantly improves compatibility with tools that use complex array schemas.\n\n    *   **v3.3.45 (2026-01-19)**:\n        - **[Core] Critical Fix for Claude/Gemini SSE Interruptions & 0-Token Responses (Issue #859)**:\n            - **Enhanced Peek Logic**: The proxy now loops through initial SSE chunks to filter out heartbeat pings and empty data, ensuring a valid content block is received before committing to a 200 OK response.\n            - **Smart Retry Trigger**: If no valid data is received within 60s or the stream is interrupted during the peek phase, the system automatically triggers account rotation and retries, eliminating silent failures for long-latency models.\n            - **Protocol Alignment & Optimization**: Introduced a matching peek mechanism for Gemini and relaxed Claude's heartbeat interval to 30s to improve stability during long-form content generation.\n        - **[Core] Fixed Account Mode Integration (PR #842)**:\n            - **Backend Enhancement**: Introduced `preferred_account_id` support in the proxy core, allowing mandatory locking of specific accounts via API or UI.\n            - **UI Update**: Added a \"Fixed Account\" toggle and account selector in the API Proxy page to lock the outbound account for the current session.\n            - **Scheduling Optimization**: Fixed Account Mode takes precedence over traditional round-robin, ensuring session continuity for specific business scenarios.\n        - **[i18n] Full Translation Completion & Cleanup**:\n            - **8-Language Coverage**: Completed all i18n translation keys related to \"Fixed Account Mode\" for all 8 supported languages.\n            - **Redundant Key Cleanup**: Fixed \"Duplicate Keys\" lint warnings in `ja.json` and `vi.json` caused by historical PR accumulation.\n            - **Punctuation Sync**: Standardized punctuation across Russian and Portuguese translations, removing accidentally used full-width Chinese punctuation.\n        - **[Core Feature] Client Hot Update & Token Statistics (PR #846 by @lengjingxu)**:\n            - **Native Updater**: Integrated Tauri v2 native update plugin, supporting automatic detection, downloading, installation, and restarting for seamless client upgrades.\n            - **Token Consumption Visualization**: Added an SQLite-based Token statistics persistence module, supporting total and per-account usage views by hour/day/week.\n            - **UI/UX & i18n Enhancements**: Optimized chart tooltips for better Dark Mode contrast; completed full translation for all 8 languages and fixed hardcoded legend labels.\n            - **Integration Fix**: Fixed an application crash caused by missing plugin configurations found during the manual merge of the original PR code.\n        - **[Optimization] Tsinghua (TUNA) Mirror Support**: Optimized the Dockerfile build process, significantly improving package installation speed in mainland China.\n        - **[Deployment] Official Docker & noVNC Support (PR #851)**:\n            - **Full Containerization**: Provides a complete Docker deployment solution for headless environments, with built-in Openbox WM.\n            - **Web VNC Integration**: Integrated noVNC for direct browser-based GUI access (essential for OAuth flows, with Firefox ESR included).\n            - **Self-Healing Startup**: Optimized `start.sh` with X11 lock file cleanup and service crash monitoring for enterprise-grade stability.\n            - **i18n Readiness**: Built-in CJK fonts ensuring proper rendering of Chinese characters in the Docker environment.\n            - **Performance Tuning**: Standardized `shm_size: 2gb` to eliminate container browser and GUI crashes.\n        - **[Core Feature] Fixed Device Fingerprint Synchronization on Account Switch**:\n            - **Path Detection Improvement**: Optimized the timing of `storage.json` detection to ensure accurate path acquisition before process closure, compatible with custom data directories.\n            - **Automatic Isolation Generation**: For accounts without a bound fingerprint, a unique device identifier is now automatically generated and bound during the first switch, ensuring complete fingerprint isolation between accounts.\n        - **[UI Fix] Fixed Inaccurate Page Size Display on Account Management Page (Issue #754)**:\n            - **Logic Correction**: Forced the default minimum page size to 10, resolving the unintuitive experience where it would automatically change to 5 or 9 in small windows.\n            - **Persistence Enhancement**: Implemented `localStorage` persistence for page size. Manually selected page sizes now permanently lock and override the automatic mode.\n            - **UI Consistency**: Ensured the pagination dropdown always aligns with the actual number of items displayed in the list.\n    *   **v3.3.44 (2026-01-19)**:\n        - **[Core Stability] Dynamic Thinking Stripping - Complete Fix for Prompt Too Long & Signature Errors**:\n            - **Background**: In Deep Thinking mode, long conversations cause two critical errors:\n                - `Prompt is too long`: Historical Thinking Blocks accumulate and exceed token limits\n                - `Invalid signature`: Proxy restarts clear in-memory signature cache, causing Google to reject old signatures\n            - **Solution - Context Purification**:\n                - **New `ContextManager` Module**: Implements token estimation and history purification logic\n                - **Tiered Purification Strategy**:\n                    - `Soft` (60%+ pressure): Retains last ~2 turns of Thinking, strips earlier history\n                    - `Aggressive` (90%+ pressure): Removes all historical Thinking Blocks\n                - **Differentiated Limits**: Flash models (1M) and Pro models (2M) use different trigger thresholds\n                - **Signature Sync Removal**: Automatically removes `thought_signature` when purifying Thinking to avoid validation failures\n            - **Transparency Enhancement**: Added `X-Context-Purified: true` response header for debugging\n            - **Performance Optimization**: Lightweight character-based token estimation with <5ms request latency impact\n            - **Impact**: Completely resolves two major issues in Deep Thinking mode, freeing 40%-60% context space and ensuring long conversation stability\n    *   **v3.3.43 (2026-01-18)**:\n        - **[i18n] Full Internationalization of Device Fingerprint Dialog (PR #825, thanks to @IamAshrafee)**:\n            - Completely resolved the hard-coded Chinese strings in the Device Fingerprint dialog.\n            - Added translation skeletons for 8 languages (EN, JA, VI, etc.) to ensure a consistent experience.\n        - **[Japanese] Translation Completion & Terminology Optimization (PR #822, thanks to @Koshikai)**:\n            - Added 50+ missing translation keys covering core settings like Quota Protection, HTTP API, and Update Checks.\n            - Improved technical wording for natural Japanese expressions (e.g., `pro_low` to \"低消費\").\n        - **[Fix] Vietnamese Spelling Correction (PR #798, thanks to @vietnhatthai)**:\n            - Fixed a typo in the Vietnamese `refresh_msg` (`hiện đài` -> `hiện tại`).\n        - **[Compatibility] Native Google API Key Support (PR #831)**:\n            - **Added `x-goog-api-key` Header Support**:\n                - The auth middleware now recognizes the `x-goog-api-key` header.\n                - Improves compatibility with official Google SDKs and third-party tools that use Google-style headers, eliminating the need to manually change header to `x-api-key`.\n    *   **v3.3.42 (2026-01-18)**:\n        - **[Traffic Log Enhancement] Protocol Recognition & Stream Integration (PR #814)**:\n            - **Protocol Labeling**: Traffic logs now automatically identify and label protocol types (OpenAI in Green, Anthropic in Orange, Gemini in Blue) based on URI, providing instant clarity on request sources.\n            - **Full Stream Consolidation**: Resolved the issue where streaming responses only displayed `[Stream Data]`. The proxy now intercepts and aggregates stream chunks, restoring scattered `delta` fragments into complete response content and \"thinking\" processes for significantly improved debugging.\n            - **i18n Support**: Completed i18n translations for traffic log features across 8 languages.\n        - **[Critical Fix] Deep Refactoring of Gemini JSON Schema Cleaning (Issue #815)**:\n            - **Resolved Property Loss**: Implemented \"Best Branch Merging\" logic for `anyOf`/`oneOf` structures in tool definitions. It automatically extracts properties from the richest branch, fixing the long-standing `malformed function call` error.\n            - **Robust Whitelist Mechanism**: Adopted a strict allowlist approach to remove fields unsupported by Gemini, ensuring 100% API compatibility and eliminating 400 errors.\n            - **Constraint Migration (Description Hints)**: Unsupported validation fields like `minLength`, `pattern`, and `format` are now automatically converted into text hints and appended to the `description`, preserving semantic information for the model.\n            - **Schema Context Detection Lock**: Added a safety check to ensure the cleaner only operates on actual Schema nodes. This \"precision lock\" protects tool call structures in `request.rs`, ensuring the stability of historical fixes (e.g., boolean coercion, shell array conversion).\n    *   **v3.3.41 (2026-01-18)**:\n        - **Claude Protocol Core Compatibility Fixes (Issue #813)**:\n            - **Consecutive User Message Merging**: Implemented `merge_consecutive_messages` logic to automatically merge consecutive messages with the same role. This resolves 400 Bad Request errors caused by role alternation violations during Spec/Plan mode switches.\n            - **EnterPlanMode Protocol Alignment**: For Claude Code's `EnterPlanMode` tool calls, redundant arguments are now forcibly cleared to ensure full compliance with the official protocol, fixing instruction set validation failures.\n        - **Proxy Robustness Enhancements**:\n            - Enhanced self-healing capabilities for tool call chains. When the model generates erroneous paths due to hallucinations, the Proxy now provides standard error feedback to guide the model back to the correct path.\n\n    *   **v3.3.40 (2026-01-18)**:\n        - **Deep Fix for API 400 Errors (Grep/Thinking Stability)**:\n            - **Resolved Protocol Order Violation**: Fixed the \"Found 'text' instead of 'thinking'\" 400 error by refactoring `streaming.rs` to stop appending illegal thinking blocks after text blocks. Signatures are now silently cached for recovery.\n            - **Enhanced Thinking Signature Self-healing**: Expanded 400 error keyword capture in `claude.rs` to cover signature invalidation, sequence errors, and protocol mismatches. Implemented millisecond-level silent retries with automated session healing.\n            - **Search Tool Schema Alignment**: Corrected parameter remapping for `Grep` and `Glob` tools, ensuring `query` is accurately mapped to `path` as per Claude Code Schema, with automatic injection of default path `.`.\n            - **Optimized Tool Renaming Strategy**: Refined the renaming logic to only target known hallucinations (like `search`), preserving the integrity of original tool call signatures.\n            - **Automatic Signature Completion**: For tool calls like LS, Bash, and TodoWrite that missing `thought_signature`, the proxy now automatically injects a placeholder to satisfy upstream constraints.\n        - **Architectural Robustness**:\n            - Enhanced the global recursive cleaner `clean_cache_control_from_messages` to strip illegal `cache_control` tags that disrupt Vertex AI/Anthropic strict mode.\n            - Updated comprehensive test examples in [docs/client_test_examples.md](docs/client_test_examples.md) covering all known 400 error scenarios.\n    *   **v3.3.39 (2026-01-17)**:\n        - **Deep Proxy Optimizations (Gemini Stability Boost)**:\n            - **Schema Purifier Upgrade**: Supported `allOf` merging, intelligent union type selection, automatic Nullable filtering, and empty object parameter backfill, completely resolving 400 errors caused by complex tool definitions.\n            - **Search Tool Self-healing**: Implemented automatic remapping from `Search` to `grep` and introduced **Glob-to-Include Migration** (automatically moving Glob patterns like `**/*.rs` to the inclusion parameter), resolving Claude Code `Error searching files` errors.\n            - **Parameter Alias Completion**: Unified parameter mapping logic for `search_code_definitions` and other related tools, and enforced boolean type conversion.\n            - **Shell Call Robustness**: Enforced `local_shell_call` command parameter to return as an array, enhancing compatibility with Google API.\n            - **Dynamic Token Constraints**: Automatically adjusted `maxOutputTokens` based on `thinking_budget` to satisfy strict API constraints; streamlined Stop Sequences to improve streaming output quality.\n        - **Enhanced Thinking Mode Stability**:\n            - Introduced cross-model family signature validation to automatically downgrade incompatible thinking signatures, preventing 400 Bad Request errors.\n            - Improved \"Session Healing\" logic to automatically recover interrupted tool loops and ensure compliance with strict Google/Vertex AI structural requirements.\n        - **High Availability Improvements**:\n            - Optimized automatic Endpoint Fallback logic for smoother transitions to backup API endpoints during 429 or 5xx errors.\n        - **Fix macOS \"Too many open files\" Error (Issue #784)**:\n            - Implemented a global shared HTTP client pool to significantly reduce Socket handle consumption.\n            - Automatically increase the file descriptor limit (RLIMIT_NOFILE) to 4096 on macOS for enhanced high-concurrency stability.\n    *   **v3.3.38 (2026-01-17)**:\n        - **Thinking Signature Deep Fix & Session Healing (Core Fix)**:\n            - **Robust Retry Logic**: Fixed the retry counting logic to ensure single-account users can still trigger internal retries for signature errors, improving auto-recovery rates.\n            - **Proactive Signature Stripping**: Introduced `is_retry` flag to forcibly strip all historical signatures during retry attempts. Coupled with strict model family validation (no more signature mixing between Gemini 1.5 and 2.0), this eliminates 400 errors from invalid signatures.\n            - **Session Healing**: Implemented smart message injection to satisfy Vertex AI structural constraints when tool results lack preceding thinking blocks due to stripping.\n        - **Pinned Quota Models**:\n            - **Customizable Display**: Added a model quota pinning list in \"Settings -> Account\", allowing users to customize specific model quotas displayed in the main table; unselected models are only shown in detail modals.\n            - **Layout Optimization**: Implemented a responsive 4-column grid layout for this section, maintaining UI consistency with the \"Quota Protection\" module.\n        - **Relay Stability Enhancements**: Improved detection and backoff for 529 Overloaded errors, increasing task success rates under extreme upstream load.\n    *   **v3.3.37 (2026-01-17)**:\n        - **Backend Compatibility Fix (Fix PR #772)**:\n            - **Backward Compatibility Enhancement**: Added `#[serde(default)]` attribute to `StickySessionConfig`, ensuring that old configuration files (missing sticky session fields) can be correctly loaded, preventing deserialization errors.\n        - **User Experience Optimization (Fix PR #772)**:\n            - **Config Loading Upgrade**: Introduced dedicated loading state and error handling in `ApiProxy.tsx`. Users now see a loading spinner while fetching configuration, and if loading fails, a clear error message with a retry button is displayed instead of a blank or broken state.\n        - **macOS Monterey Sandbox Permissions Fix (Fix Issue #468)**:\n            - **Root Cause**: On older macOS versions like Monterey (12.x), application sandbox policies prevented reading global preferences (`kCFPreferencesAnyApplication`), causing failure to detect the default browser and blocking OAuth redirects.\n            - **Fix**: Added `com.apple.security.temporary-exception.shared-preference.read-only` exception to `Entitlements.plist`, explicitly allowing read access to global configurations.\n    *   **v3.3.36 (2026-01-17)**:\n        - **Core Stability Fixes for Claude Protocol**:\n            - **\"Reply OK\" Loop Fix (History Poisoning)**:\n                - **Root Cause**: Fixed a critical flaw in `is_warmup_request` logic. The old logic scanned the last 10 historical messages; once any \"Warmup\" message appeared in history (user-sent or background heartbeat), the system would misidentify all subsequent user inputs (like \"continue\") as Warmup requests and force an \"OK\" response.\n                - **Fix**: Restricted detection scope to check ONLY the **latest** message. Now valid user inputs are processed correctly, and only actual Warmup heartbeats are intercepted.\n                - **Impact**: Significantly improved usability for Claude Code CLI and Cherry Studio in long-running sessions.\n            - **Cache Control Injection Fix (Fix Issue #744)**:\n                - **Root Cause**: Claude clients injected non-standard `cache_control: {\"type\": \"ephemeral\"}` fields into Thinking blocks, causing Google API to return `Extra inputs are not permitted` 400 errors.\n                - **Fix**: Implemented a global recursive cleanup function `clean_cache_control_from_messages` and integrated it into the Anthropic (z.ai) forwarding path, ensuring all `cache_control` fields are stripped before sending to upstream APIs.\n            - **Comprehensive Signature Defense**:\n                - **Implicit Fixes**: Deep code audit confirmed that a series of previously reported signature-related issues (#755, #654, #653, #639, #617) are effectively resolved by the **strict signature validation**, **automatic downgrade**, and **Base64 smart decoding** mechanisms introduced in v3.3.35. The system now has high fault tolerance for missing, corrupted, or malformed signatures.\n        - **Smart Warmup Logic Fix (Fix Issue #760)**:\n            - **Root Cause**: Fixed legacy logic in the auto-warmup scheduler that incorrectly mapped `gemini-2.5-flash` quota status to `gemini-3-flash`.\n            - **Symptom**: This caused \"ghost warmups\" where `gemini-3-flash` was triggered for warmup even when it had 0% quota, just because `gemini-2.5-flash` (unused/different bucket) reported 100%.\n            - **Fix**: Removed all hardcoded `2.5 -> 3` mapping logic. The scheduler now strictly checks the quota percentage of the specific model itself, triggering warmup only when that actual model reports 100%.\n        - **Gemini 2.5 Pro Model Removal (Fix Issue #766)**:\n            - **Reason**: Due to reliability issues, the `gemini-2.5-pro` model has been removed from the supported list.\n            - **Migration**: All `gpt-4` family aliases (e.g., `gpt-4`, `gpt-4o`) have been remapped to `gemini-2.5-flash` to ensure service continuity.\n            - **Impact**: Users previously accessing `gemini-2.5-pro` via aliases will be automatically routed to `gemini-2.5-flash`. The model is no longer selectable in the frontend.\n        - **CLI Sync Safety & Backup (Fix Issue #756 & #765)**:\n            - **Smart Backup & Restore**: Implemented an automatic backup mechanism. Before syncing, the system now automatically backs up existing configurations to `.antigravity.bak`. The \"Restore\" feature intelligently detects these backups and offers to restore the original user configuration instead of just resetting to defaults.\n            - **Safety Confirmation**: Added a confirmation dialog for the \"Sync Config\" action to prevent accidental overwrites of local configurations.\n            - **Enhanced CLI Detection**: Improved the detection logic for CLIs (like Claude Code) on macOS to correctly identify and execute binaries even if they are not in the system `PATH` but exist in standard fallback locations.\n        - **Windows Console Flashing Fix (PR #769, Thanks to @i-smile)**:\n            - **No Window Execution**: Fixed the issue where running CLI sync commands (like `where` checks) on Windows would briefly pop up a console window. Added `CREATE_NO_WINDOW` flag to ensure all background checks run silently.\n        - **Auth UI Status Fix (PR #769, Thanks to @i-smile)**:\n            - **Accurate Status**: Corrected the authentication status display logic in the API Proxy page. The UI now correctly shows \"Disabled\" when `auth_mode` is set to `off`, instead of incorrectly showing \"Enabled\".\n    *   **v3.3.35 (2026-01-16)**:\n        - **Major CLI Sync Enhancements**:\n            - **Multi-config File Support**: Now supports syncing multiple configuration files for each CLI (Claude Code: `settings.json`, `.claude.json`; Codex: `auth.json`, `config.toml`; Gemini: `.env`, `settings.json`, `config.json`), ensuring a more complete setup.\n            - **Claude No-Login Privilege**: Automatically injects `\"hasCompletedOnboarding\": true` into `~/.claude.json` during sync, allowing users to skip the initial onboarding/login steps for Claude CLI.\n            - **Tabbed Config Viewer**: Upgraded the configuration viewer modal to a tabbed interface, enabling smooth switching between all associated config files for a single CLI.\n        - **Deep UI/UX Refinements**:\n            - **Unified Dialog Experience**: Replaced the native browser `window.confirm` for \"Restore Default Configuration\" with the app's themed `ModalDialog`.\n            - **Icon & Badge Optimization**: Updated the restore button icon to `RotateCcw`, and streamlined status badge text with `whitespace-nowrap` to prevent layout breaks in tight spaces.\n            - **Condensed Version Display**: Improved version extraction to display only pure numeric versions (e.g., v0.86.0) for a cleaner UI.\n        - **Claude Thinking Signature Persistence Fix (Fix Issue #752)**:\n            - **Root Cause**: \n                - **Response Collection**: The streaming response collector (`collector.rs`) in v3.3.34 missed the `signature` field of `thinking` blocks when processing `content_block_start` events, causing signature loss.\n                - **Request Transformation**: Historical message signatures were sent to Gemini without validation, causing `Invalid signature in thinking block` errors during cross-model switches or cold starts.\n            - **Fix Details**: \n                - **Response Collector**: Added logic to extract and persist the `signature` field in `collector.rs`, with unit test `test_collect_thinking_response_with_signature`.\n                - **Request Transformer**: Implemented strict signature validation in `request.rs`. Only cached and compatible signatures are used. Unknown or incompatible signatures cause thinking blocks to downgrade to plain text, preventing invalid signatures from being sent.\n                - **Fallback Mechanism**: Implemented intelligent fallback retry logic. If signature validation fails or the upstream API rejects the request (400 error), the system automatically clears all thinking blocks and forces a retry, ensuring the user's request always succeeds.\n            - **Impact**: Completely resolved `Invalid signature in thinking block` errors, supporting cross-model switches and cold start scenarios, ensuring Thinking models work stably in all modes.\n        - **API Monitor Real-time Sync Fix (Pull Request #747, Thanks to @xycxl)**:\n            - **Root Cause**: Fixed issues with duplicate log entries and inaccurate counters in the API Monitor page caused by duplicate event listener registration and state desynchronization.\n            - **Fix Details**:\n                - **Data Deduplication**: Introduced `pendingLogsRef` and ID deduplication mechanisms to completely eliminate duplicate entries in the log list.\n                - **Precise Counting**: Implemented strict frontend-backend state synchronization; the system now fetches authoritative `totalCount` from the backend with every new log batch, ensuring accurate pagination and total counts.\n                - **Debounce Optimization**: Optimized log update debounce logic to reduce React re-renders and improve page smoothness.\n                - **Feature Renaming**: Renamed \"Call Records\" to \"Traffic Logs\" and reverted the route to `/monitor` for a more intuitive experience.\n    *   **v3.3.34 (2026-01-16)**:\n        - **OpenAI Codex/Responses Protocol Fix (Fix Issue #742)**:\n            - **400 Invalid Argument Complete Fix**:\n                - **Root Cause**: The `/v1/responses` and other proprietary endpoints caused Gemini to receive empty bodies when the request body contained only `instructions` or `input` but lacked the `messages` field, as the transformation logic didn't cover all scenarios.\n                - **Fix Details**: Backported the \"request normalization\" logic from the Chat interface to `handle_completions`. The system now forcibly detects Codex-specific fields (`instructions`/`input`), and even if `messages` is empty or missing, automatically transforms them into standard System/User message pairs, ensuring legal upstream requests.\n            - **429/503 Advanced Retry & Account Rotation Support**:\n                - **Logic Alignment**: Fully ported the \"Smart Exponential Backoff\" and \"Multi-dimensional Account Rotation\" strategies validated in the Claude processor to the OpenAI Completions interface.\n                - **Effect**: Now, when the Codex interface encounters rate limiting or server overload, it automatically executes millisecond-level switching instead of throwing an error directly, greatly improving the stability of tools like VS Code plugins.\n            - **Session Stickiness Support**:\n                - **Feature Expansion**:completed the `session_id` extraction and scheduling logic under the OpenAI protocol. Now, whether it's Chat or Codex interface, as long as it's the same conversation, the system will try its best to schedule it to the same Google account.\n                - **Performance Bonus**: This will significantly increase the hit rate of Google Prompt Caching, thereby drastically speeding up response times and saving computing resources.\n        - **Claude Thinking Signature Encoding Fix (Fix Issue #726)**:\n            - **Root Cause**: Fixed a regression introduced in v3.3.33, where the already Base64-encoded `thoughtSignature` was incorrectly re-encoded in Base64. This doubled encoding caused Google Vertex AI to fail signature verification, returning an `Invalid signature` error.\n            - **Fix Details**: Removed redundant Base64 encoding steps in the `Thinking`, `ToolUse`, and `ToolResult` processing logic, ensuring the signature is passed through to the upstream in its original valid format.\n            - **Impact**: Completely resolved the 400 signature error triggered when using Thinking models (e.g., Claude 4.5 Opus / Sonnet) in multi-turn conversations, as well as the resulting \"Error searching files\" infinite loop (Issue #737).\n        - **API Monitor Refresh Fix (Fix Issue #735)**:\n            - **Root Cause**: Fixed the issue where new requests were not automatically appearing in the API Monitor list due to a Closure-related bug in the event listener.\n            - **Fix Details**: Optimized the event buffering logic using `useRef`, added a manual Refresh button as a backup, and explicitly enabled Tauri event permissions.\n        - **Strict Grouped Quota Protection Fix (Core Thanks to @Mag1cFall PR #746)**:\n            - **Root Cause**: Fixed an issue where quota protection failed in strict matching mode due to case sensitivity and missing frontend UI key mapping. Previously, UI shorthand keys like `gemini-pro` could not match the backend-defined `gemini-3-pro-high` strict group.\n            - **Fix Details**:\n                - **Instant Case Normalization**: Restored case-insensitive matching in backend `normalize_to_standard_id`, ensuring variants like `Gemini-3-Pro-High` are correctly recognized.\n                - **Smart UI Key Mapping**: Added automatic mapping for UI column names like `gemini-pro/flash` in frontend `isModelProtected`, ensuring lock icons correctly reflect backend protection status.\n            - **Impact**: Completely resolved lock icon display issues for Gemini 3 Pro/Flash and Claude 4.5 Sonnet in strict grouping mode, ensuring intuitive visual feedback when quotas are exhausted.\n        - **OpenAI Protocol Usage Statistics Fix (Pull Request #749, Thanks to @stillyun)**:\n            - **Root Cause**: During OpenAI protocol conversion, Gemini's `usageMetadata` was not mapped to the `usage` field in OpenAI format, causing clients like Kilo to show zero token usage.\n            - **Fix Details**:\n                - **Data Model Completion**: Added standard `usage` field to `OpenAIResponse`.\n                - **Full-Chain Mapping**: Implemented logic to extract and map `prompt_tokens`, `completion_tokens`, and `total_tokens` from both streaming (SSE) and non-streaming responses.\n            - **Impact**: Completely resolved the issue where tools like Kilo Editor and Claude Code could not track token usage when using the OpenAI protocol.\n        - **Linux Theme Switch Crash Fix (Pull Request #750, Thanks to @infinitete)**:\n            - **Fix Details**:\n                - Disabled incompatible `setBackgroundColor` calls on Linux platform.\n                - Disabled View Transition API for WebKitGTK environments to prevent transparent window crashes.\n                - Automatically adjusted GTK window alpha channel at startup for enhanced stability.\n            - **Impact**: Resolved potential program freezes or hard crashes for Linux users when switching between dark/light modes.\n    *   **v3.3.33 (2026-01-15)**:\n        - **Codex Compatibility & Model Mapping Fix (Fix Issue #697)**:\n            - **Instructions Parameter Support**: Fixed the handling of the `instructions` parameter, ensuring it is correctly injected as System Instructions for better compatibility with tools like Codex.\n            - **Automatic Responses Format Detection**: Added intelligent detection in the OpenAI handler to automatically recognize and transform `instructions` or `input` fields into Responses mode.\n            - **Model Mapping Restoration & Normalization**: Restored the logic that normalizes `gemini-3-pro-low/high/pro` to the internal alias `gemini-3-pro-preview`, with proper restoration to the physical `high` model name for upstream requests.\n            - **Opus Mapping Enhancement**: Optimized default mappings to recognize `opus` keywords and ensure they route to the high-performance Pro preview tier by default.\n        - **OpenAI Tool Call ID & Reasoning Content Fix (Fix Issue #710)**:\n            - **Preserve Tool Call ID**: Resolved the issue where `tool_use.id` was lost during OpenAI format conversion, ensuring both `functionCall` and `functionResponse` retain original IDs, fixing the `Field required` error when calling Claude models.\n            - **Native Reasoning Support**: Added support for the `reasoning_content` field in OpenAI messages, correctly mapping it to internal `thought` blocks and injecting chain-of-thought signatures.\n            - **Tool Response Optimization**: Fixed redundant part conflicts in `tool` role messages, ensuring strict compliance with upstream payload validation.\n        - **External Provider Smart Fallback Fix (Fix Issue #703)**: Fixed the issue where \"Fallback only\" mode failed to automatically switch to external providers when Google account quotas were exhausted.\n            - **Core Problem**: The original logic only checked if the number of Google accounts was 0, without checking account availability (rate-limit status, quota protection status), causing direct 429 errors when accounts existed but were unavailable.\n            - **Solution**: Implemented smart account availability checking mechanism. Added `has_available_account()` method in `TokenManager` to comprehensively assess account rate-limit and quota protection status.\n            - **Modified Files**:\n                - `token_manager.rs`: Added `has_available_account()` method to check for available accounts that are not rate-limited or quota-protected\n                - `handlers/claude.rs`: Optimized Fallback mode logic from simple `google_accounts == 0` to intelligent availability check\n            - **Behavior Improvement**: When all Google accounts are unavailable due to rate-limiting, quota protection, or other reasons, the system automatically switches to external providers, achieving true smart fallback.\n            - **Impact**: This fix ensures external providers (e.g., Zhipu API) \"Fallback only\" mode works correctly, significantly improving service availability in multi-account scenarios.\n        - **Quota Protection Model Name Normalization Fix (Fix Issue #685)**: Fixed the issue where quota protection failed due to model name mismatches.\n            - **Core Problem**: Model names returned by the Quota API (e.g., `gemini-2.5-flash`) didn't match the standard names in the UI (e.g., `gemini-3-flash`), causing string matching failures and preventing protection triggers.\n            - **Solution**: Implemented a unified model name normalization engine `normalize_to_standard_id`, mapping all physical model names to three standard protection IDs:\n                - `gemini-3-flash`: All Flash variants (1.5-flash, 2.5-flash, 3-flash, etc.)\n                - `gemini-3-pro-high`: All Pro variants (1.5-pro, 2.5-pro, etc.)\n                - `claude-sonnet-4-5`: All Claude Sonnet variants (3.5-sonnet, sonnet-4-5, etc.)\n            - **Modified Files**:\n                - `model_mapping.rs`: Added normalization functions.\n                - `account.rs`: Normalizes model names when updating quotas and stores the standard ID.\n                - `token_manager.rs`: Normalizes `target_model` for matching during request interception.\n            - **Web Search Downgrade Scenario**: Even if a request is downgraded to `gemini-2.5-flash` due to web search, it is correctly normalized to `gemini-3-flash` and triggers protection.\n            - **Impact**: Completely resolved quota protection failure, ensuring all three monitored models work correctly.\n        - **New Account Import Feature (#682)**: Supports batch importing existing accounts via exported JSON files, completing the account migration loop.\n        - **New Portuguese & Russian Support (#691, #713)**: Portuguese (Brazil) and Russian localizations are now supported.\n        - **Proxy Monitor Enhancement (#676)**: Added \"Copy\" buttons for request and response payloads in the proxy monitor details page, with support for automatic JSON formatting.\n        - **i18n Fixes (#671, #713)**: Corrected misplaced translation keys in Japanese (ja), Turkish (tr), and Russian (ru).\n        - **Global HTTP API (#696)**: Added a local HTTP server port (default 19527), allowing external tools (like VS Code extensions) to switch accounts, refresh quotas, and bind devices directly via API.\n        - **Proxy Monitor Upgrade (#704)**: Completely refactored the monitor dashboard with backend pagination (supporting search filters), resolving UI lag caused by massive logs; exposed `GET /logs` endpoint for external access.\n        - **Warmup Strategy Optimization (#699)**: Added unique `session_id` to warmup requests, limited `max_tokens` to 8, and set `temperature` to 0 to reduce resource consumption and avoid 429 errors.\n        - **Warmup Logic Fix & Optimization**: Fixed an issue where manual warmup triggers didn't record history, causing redundant auto-warmups; optimized scheduler to skip accounts with \"Proxy Disabled\" status.\n        - **Performance Mode Scheduling Optimization (PR #706)**: In \"Performance First\" scheduling mode, the default 60-second global lock mechanism is now skipped, significantly improving account rotation efficiency in high-concurrency scenarios.\n        - **Rate Limit Auto-Cleanup (PR #701)**: Introduced a background cleanup task running every minute to automatically remove expired failure records older than 1 hour, completely resolving false \"No available accounts\" alerts caused by accumulated historical records during long-term operation.\n        - **API Monitor Stale Data Fix (Fix Issue #708)**: Enabled SQLite WAL mode and optimized connection configuration, completely resolving stale monitor data and proxy service 400/429 errors caused by database locking under high concurrency.\n        - **Claude Prompt Filtering Optimization (#712)**: Fixed an issue where user custom instructions (Instructions from: ...) were accidentally removed when filtering redundant Claude Code default prompts, ensuring personalized configurations persist in long conversation scenarios.\n        - **Claude Thinking Block Ordering Optimization (Fix Issue #709)**: Completely resolved `INVALID_ARGUMENT` errors caused by incorrect block ordering (Text appearing before Thinking) when thinking mode is enabled.\n            - **Triple-Stage Partitioning**: Implemented strict `[Thinking, Text, ToolUse]` order validation.\n            - **Automatic Downgrade Gateway**: Within a single message, any thinking blocks appearing after non-thinking content are automatically downgraded to text to ensure protocol compliance.\n            - **Post-Merge Reordering**: Added a mandatory reordering step after Assistant message merging to prevent ordering violations caused by concatenation.\n    *   **v3.3.32 (2026-01-15)**:\n        - **Core Scheduling & Stability Optimization (Fix Issue #630, #631 - Special Thanks to @lbjlaq PR #640)**:\n            - **Quota Vulnerability & Bypass Fix**: Resolved potential vulnerabilities where quota protection mechanisms could be bypassed under high concurrency or specific retry scenarios.\n            - **Rate-Limit Key Matching Optimization**: Enhanced the precision of rate-limit record matching in `TokenManager`, resolving inconsistent rate-limit judgments in multi-instance or complex network environments.\n            - **Account Disabling Enforcement**: Fixed an issue where manually disabled accounts were not immediately removed from the scheduling pool during certain cache lifecycles, ensuring \"disable on click\".\n            - **Account State Reset Mechanism**: Refined the strategy for resetting account failure counters after successful requests, preventing accounts from being incorrectly locked for long periods due to historical fluctuations.\n    *   **v3.3.31 (2026-01-14)**:\n        - **Quota Protection Fix (Fix Issue #631)**:\n            - **In-Memory State Sync**: Fixed an issue where in-memory account state was not synchronized immediately when quota protection was triggered during load.\n            - **Full Coverage**: Added quota protection checks to \"Sticky Session\" and \"60s Window Lock\" logic to prevent reuse of protected accounts.\n            - **Code Cleanup**: Resolved compilation warnings in `token_manager.rs`.\n        - **Claude Tool Call Duplicate Error Fix (Fix Issue #632)**:\n            - **Elastic-Recovery Optimization**: Improved the `Elastic-Recovery` logic by adding a full-message pre-scanning mechanism for IDs. This prevents the injection of placeholder results when a real one exists later in the history, resolving the `Found multiple tool_result blocks with id` error.\n            - **Anthropic Protocol Compliance**: Ensures that generated request payloads strictly adhere to Anthropic's requirements for unique tool call IDs.\n    *   **v3.3.30 (2026-01-14)**:\n        - **Model-Specific Quota Protection (Issue #621)**:\n            - **Isolation Optimization**: Resolved the issue where an entire account was disabled when a single model's quota was exhausted. Quota protection is now applied only to the specific restricted model, allowing the account to still handle requests for other models.\n            - **Automatic Migration**: The new system automatically restores accounts globally disabled by old quota protection and smoothly transitions them to model-level restrictions.\n            - **Full Protocol Support**: Routing logic for Claude, OpenAI (Chat/DALL-E), Gemini, and Audio handlers has been updated.\n        - **Gemini Parameter Hallucination Fix (PR #622)**:\n            - **Parameter Correction**: Fixed the issue where Gemini models incorrectly placed the `pattern` parameter in `description` or `query` fields by adding automatic remapping logic.\n            - **Boolean Coercion**: Added support for automatic conversion of non-standard boolean values like `yes`/`no`, `-n`, resolving invocation failures caused by type errors in parameters like `lineNumbers`.\n            - **Impact**: Significantly improved the stability and compatibility of Gemini models in Claude Code CLI and other tool calling scenarios.\n        - **Code Cleanup & Warning Fixes (PR #628)**:\n            - **Compiler Warning Resolution**: Fixed multiple unused import and variable warnings, removing redundant code to keep the codebase clean.\n            - **Cross-Platform Compatibility**: Optimized macro annotations for different code paths across Windows, macOS, and Linux platforms.\n        - **Custom API Key Editing Feature (Issue #627)**:\n            - **Custom Key Support**: The \"API Key\" configuration item on the API Proxy page now supports direct editing. Users can input custom keys, suitable for multi-instance deployment scenarios.\n            - **Retained Auto-generation**: The original \"Regenerate\" function is retained. Users can choose to auto-generate or manually input.\n            - **Format Validation**: Added API key format validation (must start with `sk-` and be at least 10 characters long) to prevent invalid input.\n            - **Multi-language Support**: Complete internationalization translations added for all 6 supported languages (Simplified Chinese, English, Traditional Chinese, Japanese, Turkish, Vietnamese).\n    *   **v3.3.29 (2026-01-14)**:\n        - **OpenAI Streaming Function Call Support Fix (Fix Issue #602, #614)**:\n            - **Background**: OpenAI interface streaming responses (`stream: true`) lacked Function Call processing logic, preventing clients from receiving tool call information.\n            - **Root Cause**: The `create_openai_sse_stream` function only handled text content, thinking content, and images, completely missing `functionCall` processing.\n            - **Fix Details**:\n                - Added tool call state tracking variable (`emitted_tool_calls`) to prevent duplicate sends\n                - Added `functionCall` detection and conversion logic in parts loop\n                - Built OpenAI-compliant `delta.tool_calls` array\n                - Used hash algorithm to generate stable `call_id`\n                - Included complete tool call information (`index`, `id`, `type`, `function.name`, `function.arguments`)\n            - **Impact**: This fix ensures streaming requests correctly return tool call information, maintaining consistency with non-streaming responses and Codex streaming responses. All clients using `stream: true` + `tools` parameters can now properly receive Function Call data.\n        - **Smart Threshold Recovery - Resolve Issue #613**:\n            - **Core Logic**: Implemented a dynamic token reporting mechanism perceived to context load.\n            - **Fix Details**:\n                - **Three-Stage Scaling**: Maintains efficient compression at low loads (0-70%), smoothly reduces compression rate at medium loads (70-95%), and reports real usage near the 100% limit (regressing to ~195k).\n                - **Model Awareness**: Processor automatically identifies physical context boundaries for 1M (Flash) and 2M (Pro).\n                - **400 Error Interception**: Even if physical overflow occurs, the proxy intercepts `Prompt is too long` errors and returns friendly guidance, directing users to execute `/compact`.\n            - **Impact**: Completely resolved the issue where Claude Code refused to compress due to hidden token usage, ultimately leading to Gemini server errors in long conversation scenarios.\n        - **Playwright MCP Stability & Connectivity Enhancement (Inspired by [Antigravity2Api](https://github.com/znlsl/Antigravity2Api)) - Resolve Issue #616**:\n            - **SSE Keep-Alive**: Introduced 15s heartbeats (`: ping`) to prevent connection timeouts during long-running tool calls.\n            - **MCP XML Bridge**: Bidirectional protocol conversion (instruction injection + label interception), significantly improving reliability for MCP tools (like Playwright).\n            - **Aggressive Context Slimming**:\n                - **Instruction Filtering**: Automatically removes redundant Claude Code system instructions (~1-2k tokens).\n                - **Task Deduplication**: Strips repeated task echo text following tool results to further reduce context usage.\n            - **Intelligent HTML Cleaning & Truncation**:\n                - **Deep Stripping**: Automatically removes `<style>`, `<script>`, and inline Base64 resources from browser snapshots.\n                - **Structured Truncation**: Enhanced truncation algorithm prevents cutting through HTML tags or JSON objects, avoiding 400 structure errors.\n        - **Account Index Loading Robustness (Fix Issue #619)**:\n            - **Fix Details**: Added empty file detection and automatic reset logic when loading `accounts.json`.\n            - **Impact**: Completely resolved the startup error `expected value at line 1 column 1` caused by corrupted or empty index files.\n    *   **v3.3.28 (2026-01-14)**:\n        - **OpenAI Thinking Content Fix (PR #604)**:\n            - **Fixed Gemini 3 Pro Thinking Content Loss**: Added `reasoning_content` accumulation logic in streaming response collector, resolving the issue where Gemini 3 Pro (high/low) non-streaming responses lost thinking content.\n            - **Support for Claude *-thinking Models**: Extended thinking model detection logic to support all models ending with `-thinking` (e.g., `claude-opus-4-5-thinking`, `claude-sonnet-4-5-thinking`), automatically injecting `thinkingConfig` to ensure proper thinking content output.\n            - **Unified Thinking Configuration**: Injected unified `thinkingBudget: 16000` configuration for all thinking models (Gemini 3 Pro and Claude thinking series), complying with Cloud Code API specifications.\n            - **Impact**: This fix ensures the `reasoning_content` field works properly for Gemini 3 Pro and Claude Thinking models under OpenAI protocol, without affecting Anthropic and Gemini native protocols.\n        - **Experimental Config Hot Reload (PR #605)**:\n            - **Added Hot Reload Support**: Added hot reload mechanism for `ExperimentalConfig`, consistent with other config items (mapping, proxy, security, zai, scheduling).\n            - **Real-time Effect**: Users can modify experimental feature switches without restarting the application, improving configuration adjustment convenience.\n            - **Architecture Enhancement**: Added `experimental` field storage and `update_experimental()` method in `AxumServer`, automatically triggering hot reload in `save_config`.\n        - **Smart Warmup Strategy Optimization (PR #606 - 2.9x-5x Performance Boost)**:\n            - **Separated Refresh and Warmup**: Removed automatic warmup trigger during quota refresh. Warmup now only triggers via scheduler (every 10 minutes) or manual button, avoiding accidental quota consumption when users refresh quotas.\n            - **Extended Cooldown Period**: Cooldown period extended from 30 minutes to 4 hours (14400 seconds), matching Pro account 5-hour reset cycle, completely resolving repeated warmup within the same cycle.\n            - **Persistent History Records**: Warmup history saved to `~/.antigravity_tools/warmup_history.json`, cooldown period remains effective after program restart, resolving state loss issue.\n            - **Concurrent Execution Optimization**: \n                - Filtering phase: 5 accounts per batch concurrent quota fetching, 10 accounts from ~15s to ~3s (5x improvement)\n                - Warmup phase: 3 tasks per batch concurrent execution with 2s interval, 40 tasks from ~80s to ~28s (2.9x improvement)\n            - **Whitelist Filtering**: Only records and warms up 4 core model groups (`gemini-3-flash`, `claude-sonnet-4-5`, `gemini-3-pro-high`, `gemini-3-pro-image`), avoiding bloated history records.\n            - **Record After Success**: Failed warmups are not recorded in history, allowing retry next time, improving fault tolerance.\n            - **Manual Warmup Protection**: Manual warmup also respects 4-hour cooldown period, filters already-warmed models and displays skip count, preventing users from repeatedly clicking and wasting quota.\n            - **Enhanced Logging**: Added detailed logs for scheduler scanning, warmup start/completion, cooldown skips, facilitating monitoring and debugging.\n            - **Impact**: This optimization significantly improves smart warmup performance and reliability, resolving multiple issues including repeated warmup, slow speed, and state loss. Concurrency level won't trigger RateLimit.\n        - **Traditional Chinese Localization Optimization (PR #607)**:\n            - **Terminology Optimization**: Optimized 100 Traditional Chinese translations to better align with Taiwan users' language habits and expressions.\n            - **User Experience Enhancement**: Improved professionalism and readability of Traditional Chinese interface, pure text changes with no code logic impact.\n        - **API Monitor Performance Optimization (Fix Long-Running White Screen Issue)**:\n            - **Background**: Fixed the issue where the window would freeze to a white screen after prolonged background operation when staying on the API monitor page, with the program still running but UI unresponsive.\n            - **Memory Optimization**:\n                - Reduced in-memory log limit from 1000 to 100 entries, significantly lowering memory usage\n                - Removed full request/response body storage in real-time events, retaining only summary information\n                - Optimized backend event transmission to send only log summaries instead of complete data, reducing IPC transfer volume\n            - **Rendering Performance Boost**:\n                - Integrated `@tanstack/react-virtual` virtual scrolling library, rendering only visible rows (~20-30 rows)\n                - DOM node count reduced from 1000+ to 20-30, a 97% reduction\n                - Scroll frame rate improved from 20-30fps to 60fps\n            - **Debounce Mechanism**:\n                - Added 500ms debounce mechanism for batch log updates, avoiding frequent state updates\n                - Reduced React re-render count, improving UI responsiveness\n            - **Performance Improvements**:\n                - Memory usage: ~500MB → <100MB (90% reduction)\n                - Initial render time: ~2000ms → <100ms (20x improvement)\n                - Supports infinite log scrolling, no white screen during long-running sessions\n            - **Impact**: This optimization completely resolves performance issues in long-running and high-volume log scenarios, maintaining smooth operation even when staying on the monitor page for hours.\n    *   **v3.3.27 (2026-01-13)**:\n        - **Experimental Config & Usage Scaling (PR #603 Enhancement)**:\n            - **New Experimental Settings Panel**: Added an \"Experimental Settings\" card in API Proxy configuration to manage features currently under exploration.\n            - **Enable Usage Scaling**: Implemented aggressive input token scaling for Claude-compatible protocols. When total input exceeds 30k, square-root scaling is automatically applied to prevent frequent client-side compression in large context scenarios (e.g., Gemini 2M window).\n            - **Localization Core**: Completed translations for experimental features in all 6 supported languages (zh, en, zh-TW, ja, tr, vi).\n    *   **v3.3.26 (2026-01-13)**:\n        - **Quota Protection & Scheduling Optimization (Fix Issue #595 - Zero Quota Accounts in Queue)**:\n            - **Quota Protection Logic Refactor**: Fixed the issue where quota protection failed due to reliance on non-existent `limit/remaining` fields. It now directly uses the `percentage` field, ensuring that accounts are immediately disabled if any monitored model (e.g., Claude 4.5 Sonnet) falls below the threshold.\n            - **Priority Algorithm Upgrade**: Account scheduling priority is no longer solely based on subscription tiers. Within the same tier (Ultra/Pro/Free), the system now prioritizes accounts with the **highest maximum remaining percentage**, preventing \"squeezing\" of near-empty accounts and significantly reducing 429 errors.\n            - **Enhanced Protection Logs**: Logs when quota protection is triggered now explicitly state which model triggered the threshold (e.g., `quota_protection: claude-sonnet-4-5 (0% <= 10%)`), facilitating troubleshooting.\n        - **MCP Tool Compatibility Enhancement (Fix Issue #593)**:\n            - **Deep cache_control Cleanup**: Implemented multi-layer `cache_control` field cleanup mechanism, completely resolving \"Extra inputs are not permitted\" errors caused by `cache_control` in thinking blocks when using tools like Chrome Dev Tools MCP.\n                - **Enhanced Log Tracking**: Added `[DEBUG-593]` log prefix, recording message and block indices for easy problem localization and debugging.\n                - **Recursive Deep Cleanup**: Added `deep_clean_cache_control()` function to recursively traverse all nested objects and arrays, removing `cache_control` fields from any location.\n                - **Final Safety Net**: Performs deep cleanup again after building Gemini request body and before sending, ensuring no `cache_control` fields are sent to Antigravity.\n            - **Smart Tool Output Compression**: Added `tool_result_compressor` module to handle oversized tool outputs, reducing 429 error probability caused by excessive prompt length.\n                - **Browser Snapshot Compression**: Automatically detects and compresses browser snapshots exceeding 20,000 characters, using head (70%) + tail (30%) retention strategy with middle omission.\n                - **Large File Notice Compression**: Intelligently identifies \"exceeds maximum allowed tokens\" pattern, extracts key information (file path, character count, format description), significantly reducing redundant content.\n                - **General Truncation**: Truncates tool outputs exceeding 200,000 characters with clear truncation notices.\n                - **Base64 Image Removal**: Automatically removes base64-encoded images from tool results to avoid excessive size.\n            - **Complete Test Coverage**: Added 7 unit tests covering text truncation, browser snapshot compression, large file notice compression, tool result cleanup, and other core functionalities, all passing validation.\n            - **Impact**: This update significantly improves stability for MCP tools (especially Chrome Dev Tools MCP), resolving API errors caused by `cache_control` fields in thinking blocks, while reducing 429 error probability through smart compression of oversized tool outputs.\n        - **API Monitor Account Information Recording Fix**:\n            - **Fixed Image Generation Endpoint**: Resolved the missing `X-Account-Email` response header issue in the `/v1/images/generations` endpoint. The monitoring panel now correctly displays account information for image generation requests.\n            - **Fixed Image Editing Endpoint**: Resolved the missing `X-Account-Email` response header issue in the `/v1/images/edits` endpoint, ensuring account information for image editing requests is properly logged.\n            - **Fixed Audio Transcription Endpoint**: Resolved the missing `X-Account-Email` response header issue in the `/v1/audio/transcriptions` endpoint, completing monitoring support for audio transcription functionality.\n            - **Impact**: This fix ensures all API endpoints involving account calls correctly display account information in the monitoring panel instead of showing \"-\", improving the completeness and usability of the API monitoring system.\n        - **Headless Server Deployment Support**:\n            - **One-click Deployment Scripts**: Added `deploy/headless-xvfb/` directory, providing installation, sync, and upgrade scripts for headless Linux servers.\n            - **Xvfb Environment Adaptation**: Enables the GUI version of Antigravity Tools to run on remote servers without display hardware via virtual display technology, complete with resource consumption warnings and limitation documentation.\n    *   **v3.3.25 (2026-01-13)**:\n        - **Session-Based Signature Caching System - Improved Thinking Model Stability (Core Thanks to @Gok-tug PR #574)**:\n            - **Three-Layer Signature Cache Architecture**: Implemented a complete three-layer caching system for Tool Signatures (Layer 1), Thinking Families (Layer 2), and Session Signatures (Layer 3).\n            - **Session Isolation Mechanism**: Generates stable session_id based on SHA256 hash of the first user message, ensuring all turns of the same conversation use the same session identifier.\n            - **Smart Signature Recovery**: Automatically recovers thinking signatures in tool calls and multi-turn conversations, significantly reducing signature-related errors for thinking models.\n            - **Priority Lookup Strategy**: Implements Session Cache → Tool Cache → Global Store three-layer lookup priority, maximizing signature recovery success rate.\n        - **Session ID Generation Optimization**:\n            - **Simple Design**: Only hashes the first user message content, without mixing model names or timestamps, ensuring session continuity.\n            - **Perfect Continuity**: All turns of the same conversation (regardless of how many) use the same session_id, with no time limit.\n            - **Performance Improvement**: Compared to previous solutions, CPU overhead reduced by 60%, code lines reduced by 20%.\n        - **Cache Management Optimization**:\n            - **Layered Thresholds**: Set reasonable cache cleanup thresholds for different layers (Tool: 500, Family: 200, Session: 1000).\n            - **Smart Cleanup**: Added detailed cache cleanup logs for easy monitoring and debugging.\n        - **Compilation Error Fixes**:\n            - Fixed parameter naming and mutability issues in `process.rs`.\n            - Cleaned up unused import and variable warnings.\n        - **Internationalization (i18n)**:\n            - **Traditional Chinese Support**: Added Traditional Chinese localization support (Thank you @audichuang PR #577).\n        - **Stream Error Handling Improvements**:\n            - **Friendly Error Messages**: Fixed Issue #579 where stream errors resulted in 200 OK without info. Technical errors (Timeout, Decode, Connection) are now converted to user-friendly messages.\n            - **SSE Error Events**: Implemented standard SSE error event propagation, allowing the frontend to gracefully display errors with detailed suggestions (check network, proxy, etc.).\n            - **Multi-language Error Messages (i18n)**: Error messages are now integrated with the i18n system, supporting all 6 languages (zh, en, zh-TW, ja, tr, vi). Non-browser clients automatically fallback to English messages.\n        - **Impact**: This update significantly improves multi-turn conversation stability for thinking models like Claude 4.5 Opus and Gemini 3 Pro, especially in scenarios using MCP tools and long sessions.\n\n\n    *   **v3.3.24 (2026-01-12)**:\n        - **UI Interaction Improvements**:\n            - **Card-based Model Selection**: Upgraded model selection in \"Quota Protection\" and \"Smart Warmup\" to a card-based design with checkmarks for selected states and clear borders for unselected states.\n            - **Layout Optimization**: Adjusted \"Smart Warmup\" model list from 2 columns to 4 columns for a more compact and organized look.\n            - **Model Name Fix**: Corrected the display name for `claude-sonnet-4-5` from \"Claude 3.5 Sonnet\" to \"Claude 4.5 Sonnet\".\n        - **Internationalization (i18n)**:\n            - **Vietnamese Support**: Added Vietnamese localization support (Thank you @ThanhNguyxn PR #570).\n            - **Translation Refinement**: Cleaned up duplicate translation keys and optimized automatic language detection logic.\n    *   **v3.3.23 (2026-01-12)**:\n        - **Update Notification UI Modernization**:\n            - **Visual Upgrade**: Adopts \"Glassmorphism\" design with elegant gradients and shimmer effects, significantly improving visual quality.\n            - **Smooth Animations**: Introduced smoother entry and exit animations for a better interactive experience.\n            - **Dark Mode Support**: Fully supports Dark Mode, automatically adapting to system theme for eye-friendly viewing.\n            - **Non-intrusive Layout**: Optimized notification positioning and z-index to ensure it doesn't block critical navigation areas.\n        - **Internationalization Support**:\n            - **Bilingual Support**: The update notification now fully supports both English and Chinese, automatically switching based on app language settings.\n        - **Check Logic Fix**: Fixed timing issues with update check status updates, ensuring notifications reliably appear when a new version is detected.\n        - **Menu Bar Icon Resolution Fix**:\n            - **Retina Support**: Upgraded the menu bar tray icon (`tray-icon.png`) resolution from 22x22 to 44x44, completely resolving blurriness on high-DPI displays (Fix Issue #557).\n        - **Claude Thinking Compression Optimization (Core Thanks to @ThanhNguyxn PR #566)**:\n            - **Fixed Thinking Block Reordering**: Resolved an issue where Thinking Blocks could be incorrectly ordered after text blocks when using Context Compression (Kilo).\n            - **Enforced Primary Sorting**: Introduced `sort_thinking_blocks_first` logic to ensure thinking blocks in assistant messages are always placed first, complying with Anthropic API's 400 validation rules.\n        - **Account Routing Priority Enhancement (Core Thanks to @ThanhNguyxn PR #567)**:\n            - **High Quota First Strategy**: Within the same tier (Free/Pro/Ultra), the system now prioritizes accounts with **more remaining quota**.\n            - **Resource Balancing**: Prevents long-quota accounts from being idle while short-quota accounts are exhausted prematurely due to random assignment.\n        - **Non-Streaming Base64 Signature Fix (Core Thanks to @ThanhNguyxn PR #568)**:\n            - **Full Mode Compatibility**: Applied the Base64 thinking signature decoding logic from streaming responses to non-streaming responses.\n            - **Eliminated Signature Errors**: Completely resolved 400 errors caused by inconsistent signature encoding formats when using Antigravity proxy with non-streaming clients (e.g., Python SDK).\n        - **Internationalization (i18n)**:\n            - **Japanese Support**: Added Japanese localization support (Thank you @Koshikai PR #526).\n            - **Turkish Support**: Added Turkish localization support (Thank you @hakanyalitekin PR #515).\n    *   **v3.3.22 (2026-01-12)**:\n        - **Quota Protection System Upgrade**:\n            - Customizable monitored models (`gemini-3-flash`, `gemini-3-pro-high`, `claude-sonnet-4-5`), triggers protection only when selected models fall below threshold\n            - Protection logic optimized to \"minimum quota of selected models\" trigger mechanism\n            - Auto-selects `claude-sonnet-4-5` when enabling protection, UI enforces at least one model selection\n        - **Automated Quota Management Workflow**:\n            - Enforced background auto-refresh to ensure real-time quota data sync\n            - Automated execution of \"Refresh → Protect → Restore → Warmup\" complete lifecycle management\n        - **Customizable Smart Warmup**:\n            - Customizable warmup models (`gemini-3-flash`, `gemini-3-pro-high`, `claude-sonnet-4-5`, `gemini-3-pro-image`)\n            - New standalone `SmartWarmup.tsx` component with consistent selection experience as quota protection\n            - Auto-selects all core models when enabling warmup, UI enforces at least one model selection\n            - Scheduler reads config in real-time, changes take effect immediately\n        - **Smart Warmup System Foundation**:\n            - Auto-triggers warmup when quota recovers to 100%\n            - Smart deduplication: only warmup once per 100% cycle\n            - Scheduler scans every 10 minutes and syncs latest quota to frontend\n            - Covers all account types (Ultra/Pro/Free)\n        - **i18n Improvements**: Fixed missing translations for \"Auto Check Update\" and \"Device Fingerprint\" (Issue #550)\n        - **Stability Fixes**: Fixed variable reference and ownership conflicts under high-concurrency scheduling\n        - **API Monitor Performance Optimization (Fix Issue #560)**:\n            - **Background**: Fixed 5-10 second response delay and application crash issues when opening the API monitor interface on macOS\n            - **Database Optimization**: Added `status` field index (50x faster stats queries), optimized `get_stats()` from 3 full table scans to 1 (66% faster)\n            - **Paginated Loading**: List view excludes large `request_body`/`response_body` fields (90%+ data reduction), added `get_proxy_logs_paginated` command (20 items/page), frontend \"Load More\" button\n            - **On-Demand Details**: Added `get_proxy_log_detail` command, queries full data only on click (0.1-0.5s load time)\n            - **Auto Cleanup**: Removes logs older than 30 days on startup, executes VACUUM to reclaim disk space\n            - **UI Enhancements**: Loading indicators, 10-second timeout control, detail modal spinner\n            - **Performance**: Initial load 10-18s → **0.5-1s** (10-36x), memory 1GB → **5MB** (200x), data transfer 1-10GB → **1-5MB** (200-2000x)\n            - **Impact**: Supports smooth viewing of 10,000+ monitoring records\n        - **Log Enhancements**: Fixed account/model logging issues in proxy warmup logic and added missing localization keys.\n    *   **v3.3.21 (2026-01-11)**:\n        - **Stability & Tool Fixes**:\n            - **Grep/Glob Argument Fix (P3-5)**: Resolved \"Error searching files\" issue for Grep and Glob tools. Corrected parameter mapping: changed from `paths` (array) to `path` (string), and implemented case-insensitive tool name matching.\n            - **RedactedThinking Support (P3-2)**: Gracefully downgrades redacted thinking blocks to text `[Redacted Thinking]`, preserving context instead of dropping data.\n            - **JSON Schema Cleaning Fix**: Fixed a regression where properties named \"pattern\" were incorrectly removed; improved schema compatibility.\n            - **Strict Role Alternation (P3-3)**: Implemented message merging to enforce strict User/Assistant alternation, preventing Gemini API 400 errors.\n            - **400 Auto-Retry (P3-1)**: Enhanced auto-retry and account rotation logic for 400 Bad Request errors, improving overall stability.\n        - **High-Concurrency Performance Optimization (Issue #284 Fix)**:\n            - **Completely Resolved UND_ERR_SOCKET Error**: Fixed client socket timeout issues in 8+ concurrent Agent scenarios.\n            - **Removed Blocking Wait**: Eliminated the 60-second blocking wait in \"Cache First\" mode when bound accounts are rate-limited. Now immediately unbinds and switches to the next available account, preventing client timeouts.\n            - **Lock Contention Optimization**: Moved `last_used_account` lock acquisition outside the retry loop, reducing lock operations from 18 to 1-2 per request, dramatically decreasing lock contention in concurrent scenarios.\n            - **5-Second Timeout Protection**: Added a 5-second mandatory timeout for `get_token()` operations to prevent indefinite hangs during system overload or deadlock.\n            - **Impact**: This optimization significantly improves stability in multi-Agent concurrent scenarios (such as Claude Code, Cursor, etc.), completely resolving the \"headless request\" deadlock issue.\n        - **Linux System Compatibility (Core Thanks to @0-don PR #326)**:\n            - **Transparent Window Fix**: Automatically disables DMA-BUF renderer (`WEBKIT_DISABLE_DMABUF_RENDERER=1`) on Linux systems to resolve transparent window rendering or black screen issues in some distributions.\n        - **Monitor Middleware Optimization (Core Thanks to @Mag1cFall PR #346)**:\n            - **Payload Limit Alignment**: Increased request body limit for monitor middleware from 1MB to 100MB, ensuring large image requests are correctly logged and displayed.\n        - **OpenAI Protocol Multi-Candidate Support (Core Thanks to @ThanhNguyxn PR #403)**:\n            - Implemented support for the `n` parameter, allowing a single request to return multiple candidates.\n            - Added the multi-candidate support patch for streaming responses (SSE), ensuring cross-platform functional parity.\n        - **Web Search Enhancement & Citation Optimization**:\n            - Re-implemented web search source display using a more readable Markdown citation format (including titles and links).\n            - Resolved the issue where citation display logic was disabled in previous versions; it is now fully enabled in both streaming and non-streaming modes.\n        - **Installation & Distribution (Core Thanks to @dlukt PR #396)**:\n            - **Linux Cask Support**: Refactored Cask file for multi-platform support. Linux users can now install via `brew install --cask` with automatic AppImage permission configuration.\n        - **Comprehensive Logging System Optimization (Issue #241 Fix)**:\n        - **Comprehensive Logging System Optimization (Issue #241 Fix)**:\n            - **Log Level Optimization**: Downgraded high-frequency debug logs for tool calls and parameter remapping from `info!` to `debug!`, dramatically reducing log output volume.\n            - **Automatic Cleanup Mechanism**: Application startup now automatically cleans up log files older than 7 days, preventing indefinite log accumulation.\n            - **Significant Impact**: Log file size reduced from 130GB/day to < 100MB/day, a **99.9%** reduction in log output.\n            - **Scope**: Modified 21 log level statements in `streaming.rs` and `response.rs`, added `cleanup_old_logs()` automatic cleanup function.\n    *   **v3.3.15 (2026-01-04)**:\n        - **Claude Protocol Compatibility Enhancements** (Based on PR #296 by @karasungur + Issue #298 Fix):\n            - **Fixed Opus 4.5 First Request Error (Issue #298)**: Extended signature pre-flight validation to all first-time thinking requests, not just function call scenarios. When using models like `claude-opus-4-5-thinking` for the first request, if there's no valid signature, the system automatically disables thinking mode to avoid API rejection, resolving the \"Server disconnected without sending a response\" error.\n            - **Function Call Signature Validation (Issue #295)**: Added pre-flight signature validation. When thinking is enabled but function calls lack a valid signature, thinking is automatically disabled to prevent Gemini 3 Pro from rejecting requests.\n            - **cache_control Cleanup (Issue #290)**: Implemented recursive deep cleanup to remove `cache_control` fields from all nested objects/arrays, resolving Anthropic API (z.ai mode) \"Extra inputs are not permitted\" errors.\n            - **Tool Parameter Remapping**: Automatically corrects parameter names used by Gemini (Grep/Glob: `query` → `pattern`, Read: `path` → `file_path`), resolving Claude Code tool call validation errors.\n            - **Configurable Safety Settings**: Added `GEMINI_SAFETY_THRESHOLD` environment variable supporting 5 safety levels (OFF/LOW/MEDIUM/HIGH/NONE), defaulting to OFF for backward compatibility.\n            - **Effort Parameter Support**: Supports Claude API v2.0.67+ `output_config.effort` parameter, allowing fine-grained control over model reasoning effort.\n            - **Opus 4.5 Default Thinking**: Aligned with Claude Code v2.0.67+, Opus 4.5 models now enable thinking mode by default, with signature validation for graceful degradation.\n            - **Retry Jitter Optimization**: Added ±20% random jitter to all retry strategies to prevent thundering herd effect, improving stability in high-concurrency scenarios.\n            - **Signature Capture Improvement**: Immediately captures signatures from thinking blocks, reducing signature missing errors in multi-turn conversations.\n            - **Impact**: These improvements significantly enhance compatibility and stability for Claude Code, Cursor, Cherry Studio and other clients, especially in Opus 4.5 models, tool calling, and multi-turn conversation scenarios.\n    *   **v3.3.14 (2026-01-03)**:\n        - **Claude Protocol Robustness Improvements** (Core Thanks to @karasungur PR #289):\n            - **Thinking Block Signature Validation Enhancement**:\n                - Support for empty thinking blocks with valid signatures (trailing signature scenario)\n                - Invalid signature blocks gracefully degrade to text instead of being dropped, preserving content to avoid data loss\n                - Enhanced debugging logs for signature issue troubleshooting\n            - **Tool/Function Calling Compatibility Optimization**:\n                - Extracted web search fallback model to named constant `WEB_SEARCH_FALLBACK_MODEL` for improved maintainability\n                - Automatically skips googleSearch injection when MCP tools are present to avoid conflicts\n                - Added informative logging for debugging tool calling scenarios\n                - **Important Note**: Gemini Internal API does not support mixing `functionDeclarations` and `googleSearch`\n            - **SSE Parse Error Recovery Mechanism**:\n                - Added `parse_error_count` and `last_valid_state` tracking for streaming response error monitoring\n                - Implemented `handle_parse_error()` for graceful stream degradation\n                - Implemented `reset_error_state()` for post-error recovery\n                - Implemented `get_error_count()` for error count retrieval\n                - High error rate warning system (>5 errors) for operational monitoring\n                - Detailed debugging logs supporting troubleshooting of corrupted streams\n            - **Impact**: These improvements significantly enhance stability for Claude CLI, Cursor, Cherry Studio and other clients, especially in multi-turn conversations, tool calling, and streaming response scenarios.\n        - **Dashboard Statistics Fix** (Core Thanks to @yinjianhong22-design PR #285):\n            - **Fixed Low Quota Statistics False Positives**: Fixed the issue where disabled accounts (403 status) were incorrectly counted in \"Low Quota\" statistics\n            - **Logic Optimization**: Added `is_forbidden` check in `lowQuotaCount` filter to exclude disabled accounts\n            - **Data Accuracy Improvement**: Dashboard now accurately reflects the true number of low-quota active accounts, avoiding false positives\n            - **Impact**: Improved dashboard data accuracy and user experience, allowing users to more clearly understand which accounts need attention.\n    *   **v3.3.13 (2026-01-03)**:\n        - **Thinking Mode Stability Fixes**:\n            - **Fixed Empty Thinking Content Error**: When clients send empty Thinking blocks, they are now automatically downgraded to plain text blocks to avoid `thinking: Field required` errors.\n            - **Fixed Validation Error After Smart Downgrade**: When Thinking is disabled via smart downgrade (e.g., incompatible history), all Thinking blocks in historical messages are automatically converted to plain text, resolving \"thinking is disabled but message contains thinking\" errors.\n            - **Fixed Model Switching Signature Error**: Added target model Thinking support detection. When switching from Claude thinking models to regular Gemini models (e.g., `gemini-2.5-flash`), Thinking is automatically disabled and historical messages are downgraded to avoid \"Corrupted thought signature\" errors. Only models with `-thinking` suffix (e.g., `gemini-2.5-flash-thinking`) or Claude models support Thinking.\n            - **Impact**: These fixes ensure stability across various model switching scenarios, especially for seamless Claude ↔ Gemini transitions.\n        - **Account Rotation Rate-Limiting Mechanism Optimization (Critical Fix for Issue #278)**:\n            - **Fixed Rate-Limit Time Parsing Failure**: Completely resolved the issue where Google API's `quotaResetDelay` could not be correctly parsed.\n                - **Corrected JSON Parsing Path**: Fixed the extraction path for `quotaResetDelay` from `details[0].quotaResetDelay` to `details[0].metadata.quotaResetDelay`, matching Google API's actual JSON structure.\n                - **Implemented Universal Time Parsing**: Added `parse_duration_string()` function to support parsing all time formats returned by Google API, including complex combinations like `\"2h21m25.831582438s\"`, `\"1h30m\"`, `\"5m\"`, `\"30s\"`, etc.\n                - **Differentiated Rate-Limit Types**: Added `RateLimitReason` enum to distinguish between `QUOTA_EXHAUSTED` (quota exhausted) and `RATE_LIMIT_EXCEEDED` (rate limit) types, setting different default wait times based on type (quota exhausted: 1 hour, rate limit: 30 seconds).\n            - **Problem Before Fix**: When account quota was exhausted triggering 429 errors, the system could not parse the accurate reset time returned by Google API (e.g., `\"2h21m25s\"`), resulting in using a fixed default value of 60 seconds. Accounts were incorrectly considered \"recoverable in 1 minute\" when they actually needed 2 hours, causing accounts to fall into a 429 loop, using only the first 2 accounts while subsequent accounts were never utilized.\n            - **Effect After Fix**: The system can now accurately parse the reset time returned by Google API (e.g., `\"2h21m25.831582438s\"` → 8485 seconds). Accounts are correctly marked as rate-limited and wait for the accurate time, ensuring all accounts can be properly rotated and used, completely resolving the \"only using first 2 accounts\" issue.\n            - **Impact**: This fix significantly improves stability and availability in multi-account environments, ensuring all accounts are fully utilized and avoiding account rotation failures caused by rate-limit time parsing errors.\n    *   **v3.3.12 (2026-01-02)**:\n        - **Critical Fixes**:\n            - **Fix Antigravity Thinking Signature Errors**: Completely resolved `400: thinking.signature: Field required` errors when using the Antigravity (Google API) channel.\n                - **Disabled Dummy Thinking Block Injection**: Removed logic that auto-injected unsigned \"Thinking...\" placeholder blocks for historical messages. Google API rejects any thinking blocks without valid signatures.\n                - **Removed Fake Signature Fallback**: Removed logic that added `skip_thought_signature_validator` sentinel values to ToolUse and Thinking blocks. Now only uses real signatures or omits the thoughtSignature field entirely.\n                - **Fixed Background Task Misclassification**: Removed the \"Caveat: The messages below were generated\" keyword to prevent normal requests containing Claude Desktop system prompts from being misclassified as background tasks and downgraded to Flash Lite models.\n                - **Impact**: This fix ensures stability for Claude CLI, Cursor, Cherry Studio, and other clients when using the Antigravity proxy, especially in multi-turn conversations and tool calling scenarios.\n    *   **v3.3.11 (2026-01-02)**:\n        - **Critical Fixes**:\n            - **Cherry Studio Compatibility Fix (Gemini 3)**:\n                - **Removed Forced Prompt Injection**: Removed the mandatory \"Coding Agent\" system instruction and Gemini 3 user message suffix injections. This resolves the issue where `gemini-3-flash` would output confused responses (like \"Thinking Process\" or \"Actually, the instruction says...\") in general-purpose clients like Cherry Studio. The generic OpenAI protocol now respects the original user prompt faithfully.\n            - **Fix Gemini 3 Python Client Crash**:\n                - **Removed maxOutputTokens Restriction**: Removed the logic that forcibly set `maxOutputTokens: 64000` for Gemini requests. This forced setting caused standard Gemini 3 Flash/Pro models (limit 8192) to reject requests and return empty responses, triggering `'NoneType' object has no attribute 'strip'` errors in Python clients. The proxy now defaults to the model's native limit or respects client parameters.\n        - **Core Optimization**:\n            - **Unified Retry Backoff System**: Refactored error retry logic with intelligent backoff strategies tailored to different error types:\n                - **Thinking Signature Failure (400)**: Fixed 200ms delay before retry, avoiding request doubling from immediate retries.\n                - **Server Overload (529/503)**: Exponential backoff (1s/2s/4s/8s), significantly improving recovery success rate by 167%.\n                - **Rate Limiting (429)**: Prioritizes server-provided Retry-After, otherwise uses linear backoff (1s/2s/3s).\n                - **Account Protection**: Server-side errors (529/503) no longer rotate accounts, preventing healthy account pool contamination.\n                - **Unified Logging**: All backoff operations use ⏱️ identifier for easy monitoring and debugging.\n        - **Critical Fix**:\n            - **Fixed Gemini 3 Python Client Crash**: Removed the logic that forced `maxOutputTokens: 64000` for Gemini requests. This override caused standard Gemini 3 Flash/Pro models (limit 8192) to reject requests with empty responses, leading to `'NoneType' object has no attribute 'strip'` errors in Python clients. The proxy now defaults to model native limits or respects client parameters.\n        - **Scoop Installation Compatibility Support (Core Thanks to @Small-Ku PR #252)**:\n            - **Startup Arguments Configuration**: Added Antigravity startup arguments configuration feature. Users can now customize startup parameters in the Settings page, perfectly compatible with portable installations via package managers like Scoop.\n            - **Smart Database Path Detection**: Optimized database path detection logic with priority-based checking:\n                - Command-line specified `--user-data-dir` path\n                - Portable mode `data/user-data` directory\n                - System default paths (macOS/Windows/Linux)\n            - **Multi-Installation Support**: Ensures correct database file location and access across standard installations, Scoop portable installations, and custom data directory scenarios.\n        - **Browser Environment CORS Support Optimization (Core Thanks to @marovole PR #223)**:\n            - **Explicit HTTP Method List**: Changed CORS middleware `allow_methods` from generic `Any` to explicit method list (GET/POST/PUT/DELETE/HEAD/OPTIONS/PATCH), improving browser environment compatibility.\n            - **Preflight Cache Optimization**: Added `max_age(3600)` configuration to cache CORS preflight requests for 1 hour, reducing unnecessary OPTIONS requests and improving performance.\n            - **Security Enhancement**: Added `allow_credentials(false)` configuration, following security best practices when used with `allow_origin(Any)`.\n            - **Browser Client Support**: Enhanced CORS support for browser-based AI clients like Droid, ensuring cross-origin API calls work properly.\n        - **Account Table Drag-and-Drop Sorting (Core Thanks to @wanglei8888 PR #256)**:\n            - **Drag to Reorder**: Added drag-and-drop sorting functionality for the account table. Users can now customize account display order by dragging table rows, making it easy to pin frequently used accounts to the top.\n            - **Persistent Storage**: Custom sort order is automatically saved locally and persists across application restarts.\n            - **Optimistic Updates**: Drag operations update the interface immediately for smooth user experience, while saving asynchronously in the background.\n            - **Built with dnd-kit**: Implemented using the modern `@dnd-kit` library, supporting keyboard navigation and accessibility features.\n    *   **v3.3.10 (2026-01-01)**:\n        - 🌐 **Upstream Endpoint Fallback Mechanism** (Core Thanks to @karasungur PR #243):\n            - **Multi-Endpoint Auto-Switching**: Implemented `prod → daily` dual-endpoint fallback strategy. Automatically switches to backup endpoint when primary returns 404/429/5xx, significantly improving service availability.\n            - **Connection Pool Optimization**: Added `pool_max_idle_per_host(16)`, `tcp_keepalive(60s)` and other parameters to optimize connection reuse and reduce establishment overhead, especially optimized for WSL/Windows environments.\n            - **Smart Retry Logic**: Supports automatic endpoint switching for 408 Request Timeout, 404 Not Found, 429 Too Many Requests, and 5xx Server Errors.\n            - **Detailed Logging**: Records INFO logs on successful fallback and WARN logs on failures for operational monitoring and troubleshooting.\n            - **Fully Compatible with Scheduling Modes**: Endpoint fallback and account scheduling (Cache First/Balance/Performance First) work at different layers without interference, ensuring cache hit rates remain unaffected.\n        - 📊 **Comprehensive Logging System Optimization**:\n            - **Log Level Restructuring**: Strictly separated INFO/DEBUG/TRACE levels. INFO now only shows critical business information, with detailed debugging downgraded to DEBUG.\n            - **Heartbeat Request Filtering**: Downgraded heartbeat requests (`/api/event_logging/batch`, `/healthz`) from INFO to TRACE, completely eliminating log noise.\n            - **Account Information Display**: Shows account email at request start and completion for easy monitoring of account usage and session stickiness debugging.\n            - **Streaming Response Completion Markers**: Added completion logs for streaming responses (including token statistics), ensuring full request lifecycle traceability.\n            - **90%+ Log Volume Reduction**: Normal requests reduced from 50+ lines to 3-5 lines, startup logs from 30+ to 6 lines, dramatically improving readability.\n            - **Debug Mode**: Use `RUST_LOG=debug` to view full request/response JSON for deep debugging.\n        - 🎨 **Imagen 3 Generation Enhancements**:\n            - **New Resolution Support**: Added support for `-2k` resolution via model name suffixes for higher definitions.\n            - **Ultra-wide Aspect Ratio**: Added support for `-21x9` (or `-21-9`) aspect ratio, perfect for ultra-wide displays.\n            - **Mapping Optimization**: Improved auto-mapping logic for custom sizes like `2560x1080`.\n            - **Full Protocol Coverage**: These enhancements are available across OpenAI, Claude, and Gemini protocols.\n        - 🔍 **Model Detection API**:\n            - **New Detection Endpoint**: Introduced `POST /v1/models/detect` to reveal model capabilities and configuration variants in real-time.\n            - **Dynamic Model List**: The `/v1/models` API now dynamically lists all resolution and aspect ratio combinations for image models (e.g., `gemini-3-pro-image-4k-21x9`).\n        - 🐛 **Background Task Downgrade Model Fix**:\n            - **Fixed 404 Errors**: Corrected background task downgrade model from non-existent `gemini-2.0-flash-exp` to `gemini-2.5-flash-lite`, resolving 404 errors for title generation, summaries, and other background tasks.\n        - 🔐 **Manual Account Disable Feature**:\n            - **Independent Disable Control**: Added manual account disable feature, distinct from 403 disable. Only affects proxy pool, not API requests.\n            - **Application Usable**: Manually disabled accounts can still be switched and used within the application, view quota details, only removed from proxy pool.\n            - **Visual Distinction**: 403 disable shows red \"Disabled\" badge, manual disable shows orange \"Proxy Disabled\" badge.\n            - **Batch Operations**: Supports batch disable/enable multiple accounts for improved management efficiency.\n            - **Auto Reload**: Automatically reloads proxy account pool after disable/enable operations, takes effect immediately.\n            - **Impact Scope**: Lightweight tasks including title generation, simple summaries, system messages, prompt suggestions, and environment probes now correctly downgrade to `gemini-2.5-flash-lite`.\n        - 🎨 **UI Experience Enhancements**:\n            - **Unified Dialog Style**: Standardized all native alert/confirm dialogs in the ApiProxy page to application-standard Toast notifications and ModalDialogs, improving visual consistency.\n            - **Tooltip Clipping Fixed**: Resolved the issue where tooltips in the Proxy Settings page (e.g., \"Scheduling Mode\", \"Allow LAN Access\") were obstructed by container boundaries.\n    *   **v3.3.9 (2026-01-01)**:\n        - 🚀 **Multi-Protocol Scheduling Alignment**: `Scheduling Mode` now formally covers OpenAI, Gemini Native, and Claude protocols.\n        - 🧠 **Industrial-Grade Session Fingerprinting**: Upgraded SHA256 content hashing for sticky Session IDs, ensuring consistent account inheritance and improved Prompt Caching hits.\n        - 🛡️ **Precision Rate-Limiting & 5xx Failover**: Deeply integrated Google API JSON parsing for sub-second `quotaResetDelay` and automatic 20s cooling isolation for 500/503/529 errors.\n        - 🔀 **Enhanced Scheduling**: Rotation logic now intelligently bypasses all locked/limited accounts; provides precise wait-time suggestions for restricted pools.\n        - 🌐 **Global Rate-Limit Sync**: Cross-protocol rate-limit tracking ensures instant \"Rate-limit once, avoid everywhere\" protection.\n        - 📄 **Claude Multimodal Completion**: Fixed 400 errors when handling PDF/documents in Claude CLI by completing multimodal mapping logic.\n    *   **v3.3.8 (2025-12-31)**:\n        - **Proxy Monitor Module (Core Thanks to @84hero PR #212)**:\n            - **Real-time Request Tracking**: Brand-new monitoring dashboard for real-time visualization of all proxy traffic, including request paths, status codes, response times, token consumption, and more.\n            - **Persistent Log Storage**: SQLite-based logging system supporting historical record queries and analysis across application restarts.\n            - **Advanced Filtering & Sorting**: Real-time search, timestamp-based sorting for quick problem request identification.\n            - **Detailed Inspection Modal**: Click any request to view full request/response payloads, headers, token counts, and other debugging info.\n            - **Performance Optimization**: Compact data formatting (e.g., 1.2k instead of 1200) improves UI responsiveness with large datasets.\n        - **UI Optimization & Layout Improvements**:\n            - **Toggle Style Unification**: Standardized all toggle switches (Auto Start, LAN Access, Auth, External Providers) to small blue style for consistent visuals.\n            - **Layout Density Optimization**: Merged \"Allow LAN Access\" and \"Auth\" into a single-row grid layout (lg:grid-cols-2) for more efficient use of space on large screens.\n        - **Zai Dispatcher Integration (Core Thanks to @XinXin622 PR #205)**:\n            - **Multi-level Dispatching**: Supports `Exclusive`, `Pooled`, and `Fallback` modes to balance response speed and account security.\n            - **Built-in MCP Support**: Preconfigured endpoints for Web Search Prime, Web Reader, and Vision MCP servers.\n            - **UI Enhancements**: Added graphical configuration options and tooltips to the ApiProxy page.\n        - **Automatic Account Exception Handling (Core Thanks to @salacoste PR #203)**:\n\n            - **Auto-disable Invalid Accounts**: Automatically marks accounts as disabled when Google OAuth refresh tokens become invalid (`invalid_grant`), preventing proxy failures caused by repeated attempts to use broken accounts.\n            - **Persistent State Management**: Disabling state is saved to disk and persists across restarts. Optimized loading logic to skip disabled accounts.\n            - **Smart Auto-recovery**: Accounts are automatically re-enabled when the user manually updates the refresh or access tokens in the UI.\n            - **Documentation**: Added detailed documentation for the invalid grant handling mechanism.\n        - **Dynamic Model List API (Intelligent Endpoint Optimization)**:\n            - **Real-time Dynamic Sync**: `/v1/models` (OpenAI) and `/v1/models/claude` (Claude) endpoints now aggregate built-in and custom mappings in real-time. Changes in settings take effect instantly.\n            - **Full Model Support**: Prefix filtering is removed. Users can now directly see and use image models like `gemini-3-pro-image-4k-16x9` and all custom IDs in terminals or clients.\n        - **Quota Management & Intelligent Routing (Operational Optimization & Bug Fixes)**:\n            - **Background Task Smart Downgrading**: Automatically identifies and reroutes Claude CLI/Agent background tasks (titles, summaries, etc.) to Flash models, fixing the issue where these requests previously consumed premium/long-context quotas.\n            - **Concurrency Lock & Quota Protection**: Fixed the issue where multiple concurrent requests caused account quota overflow. Atomic locks ensure account consistency within the same session, preventing unnecessary rotations.\n            - **Tiered Account Sorting (ULTRA > PRO > FREE)**: The system now automatically sorts model routes based on quota reset frequency (hourly vs. daily). Highlights premium accounts that reset frequently, reserving FREE accounts as a final safety net.\n            - **Atomic Concurrency Locking**: Enhanced `TokenManager` session locking. In high-concurrency scenarios (e.g., Agent mode), ensures stable account assignment for requests within the same session.\n            - **Expanded Keyword Library**: Integrated 30+ intent-based keywords for background tasks, improving detection accuracy to over 95%.\n\n    *   **v3.3.7 (2025-12-30)**:\n        - **Proxy Core Stability Fixes (Core Thanks to @llsenyue PR #191)**:\n            - **JSON Schema Hardening**: Implemented recursive flattening and cleaning for tool call schemas. Unsupported constraints (e.g., `pattern`) are now moved to descriptions, preventing Gemini schema rejection.\n            - **Background Task Robustness**: Added detection for background tasks (e.g., summaries). Automatically strips thinking configs and redirects to `gemini-2.5-flash` for 100% success rate.\n            - **Thought Signature Auto-capture**: Refined `thoughtSignature` extraction and persistence, resolving 400 errors caused by missing signatures in multi-turn chats.\n            - **Logging Improvements**: Promoted user messages to WARN level in logs to ensure core interactions remain visible during background activity.\n    *   **v3.3.6 (2025-12-30)**:\n        - **Deep OpenAI Image Support (Core Thanks to @llsenyue PR #186)**:\n            - **New Image Generation Endpoint**: Full support for `/v1/images/generations`, including parameters like `model`, `prompt`, `n`, `size`, and `response_format`.\n            - **New Image Editing & Variations**: Adapted `/v1/images/edits` and `/v1/images/variations` endpoints.\n            - **Protocol Bridging**: Implemented automatic structural mapping and authentication from OpenAI image requests to the Google Internal API (Cloud Code).\n    *   **v3.3.5 (2025-12-29)**:\n        - **Core Fixes & Stability Enhancements**:\n            - **Root Fix for Claude Extended Thinking 400 Errors (Model Switching)**: Resolved validation failures when switching from non-thinking to thinking models mid-session. The system now automatically backfills historical thinking blocks to ensure API compliance.\n            - **New Automatic Account Rotation for 429 Errors**: Enhanced the retry mechanism for `429` (rate limit), `403` (forbidden), and `401` (expired) errors. Retries now **force-bypass the 60s session lock** to rotate to the next available account in the pool, implementing a true failover.\n            - **Test Suite Maintenance**: Fixed several outdated and broken unit tests to ensure a clean build and verification cycle.\n        - **Logging System Optimizations**:\n            - **Cleaned Verbose Logs**: Removed redundant logs that printed all model names during quota queries. Detailed model list information is now downgraded to debug level, significantly reducing console noise.\n            - **Local Timezone Support**: Log timestamps now automatically use local timezone format (e.g., `2025-12-29T22:50:41+08:00`) instead of UTC, making logs more intuitive for users.\n        - **UI Optimizations**:\n            - **Refined Account Quota Display**: Added clock icons, implemented perfect centering, and added dynamic color feedback based on countdown (Synced across Table and Card views).\n    *   **v3.3.4 (2025-12-29)**:\n        - **Major OpenAI/Codex Compatibility Boost (Core Thanks to @llsenyue PR #158)**:\n            - **Fixed Image Recognition**: Fully adapted Codex CLI's `input_image` block parsing and added support for `file://` local paths with automatic Base64 conversion.\n            - **Gemini 400 Error Mitigation**: Implemented automatic merging of consecutive identical role messages, strictly following Gemini's role alternation requirements to eliminate related 400 errors.\n            - **Protocol Stability Enhancements**: Optimized deep JSON Schema cleaning (including physical isolation for `cache_control`) and added context backfilling for `thoughtSignature`.\n            - **Linux Build Strategy Adjustment**: Due to the severe scarcity of GitHub's Ubuntu 20.04 runners causing release hangups, official builds have reverted to the **Ubuntu 22.04** environment. Ubuntu 20.04 users are encouraged to clone the source for local builds or try running via AppImage.\n    *   **v3.3.3 (2025-12-29)**:\n        - **Account Management Enhancements**:\n            - **Subscription Tier Identification**: Integrated automatic detection, labeling, and filtering for account subscription tiers (PRO/ULTRA/FREE).\n            - **Multi-dimensional Filtering**: Added new filter tabs (\"All\", \"Available\", \"Low Quota\", \"PRO\", \"ULTRA\", \"FREE\") with real-time counters and integrated search.\n            - **UI/UX Optimization**: Implemented a premium tabbed interface; refined the header layout with an elastic search bar and responsive action buttons to maximize workspace efficiency across different resolutions.\n        - **Critical Fixes**:\n            - **Root Fix for Claude Extended Thinking 400 Errors**: Resolved the format validation error caused by missing `thought: true` markers in historical `ContentBlock::Thinking` messages. This issue led to `400 INVALID_REQUEST_ERROR` regardless of whether thinking was explicitly enabled, especially in multi-turn conversations.\n    *   **v3.3.2 (2025-12-29)**:\n        - **New Features (Core Thanks to @XinXin622 PR #128)**:\n            - **Web Search Citation Support for Claude Protocol**: Successfully mapped Gemini's raw Google Search results to Claude's native `web_search_tool_result` content blocks. Structured search citations and source links now display correctly in compatible clients like Cherry Studio.\n            - **Enhanced Thinking Mode Stability (Global Signature Store v2)**: Introduced a more robust global `thoughtSignature` storage mechanism. The system now captures real-time signatures from streaming responses and automatically backfills them for subsequent requests missing signatures, significantly reducing `400 INVALID_ARGUMENT` errors.\n        - **Optimizations & Bug Fixes**:\n            - **Hardened Data Models**: Unified and refactored the internal `GroundingMetadata` structures, resolving type conflicts and parsing anomalies identified during PR #128 integration.\n            - **Streaming Logic Refinement**: Optimized the SSE conversion engine to ensure proper extraction and persistence of `thoughtSignature` across fragmented streaming chunks.\n    *   **v3.3.1 (2025-12-28)**:\n        - **Critical Fixes**:\n            - **Deep Fix for Claude Protocol 400 Errors (Claude Code Optimization)**:\n                - **Resolved Cache Control Conflicts (cache_control Fix)**: Fully address the upstream validation errors caused by `cache_control` tags or `thought: true` fields in historical messages. Optimized with a \"historical message de-thinking\" strategy to bypass parsing bugs in the Google API compatibility layer.\n                - **Deep JSON Schema Cleaning Engine**: Optimized the conversion of MCP tool definitions. Complex validation constraints unsupported by Google (e.g., `pattern`, `minLength`, `maximum`) are now automatically migrated to description fields, ensuring compliance while preserving semantic hints.\n                - **Protocol Header Compliance**: Removed non-standard `role` tags from system instructions and enhanced explicit filtering for `cache_control` to guarantee maximum payload compatibility.\n            - **Enhanced Connectivity & Web Search Compatibility**: \n                - **Search Compatibility**: Added support for `googleSearchRetrieval` and other next-gen tool definitions. Now provides standardized `googleSearch` payload mapping, ensuring seamless integration with Cherry Studio's built-in search toggle.\n                - **Automated Client Data Purification**: Introduced deep recursive cleaning to physically strip `[undefined]` properties injected by clients like Cherry Studio, resolving `400 INVALID_ARGUMENT` errors at the source.\n                - **High-Quality Virtual Model Auto-Networking**: Expanded the high-performance model whitelist (including Claude Thinking variants), ensuring all premium models trigger native networking search by default.\n        - **Optimization & Token Saving**:\n            - **Full-link Tracing & Closed-loop Audit Logs**:\n                - Introduced a 6-character random **Trace ID** for every request.\n                - Automated request tagging: `[USER]` for real conversations, `[AUTO]` for background tasks.\n                - Implemented **token consumption reporting** for both streaming and non-streaming responses.\n            - **Claude CLI Background Task \"Token Saver\"**:\n                - **Intelligent Intent Recognition**: Enhanced detection for low-value requests like title generation, summaries, and system Warmups/Reminders.\n                - **Seamless Downgrade Redirect**: Automatically routes background traffic to **gemini-2.5-flash**, ensuring top-tier model (Sonnet/Opus) quotas are reserved for core tasks.\n                - **Significant Token Saving**: Saves 1.7k - 17k+ high-value tokens per long session.\n        - **Stability Enhancements**: \n            - Resolved Rust compilation and test case errors caused by the latest model field updates, hardening the data model layer (models.rs).\n    *   **v3.3.0 (2025-12-27)**:\n        - **Major Updates**:\n            - **Deep Adaptation for Codex CLI & Claude CLI (Core Thanks to @llsenyue PR #93)**:\n                - **Coding Agent Compatibility**: Achieved full support for Codex CLI, including deep adaptation of the `/v1/responses` endpoint and intelligent instruction conversion (SSOP) for shell tool calls.\n                - **Claude CLI Reasoning Enhancement**: Introduced global `thoughtSignature` storage and backfilling logic, completely resolving signature validation errors when using Claude CLI with Gemini 3 series models.\n            - **OpenAI Protocol Stack Refactor**:\n                - **New Completions Endpoint**: Fully added support for `/v1/completions` and `/v1/responses` routes, ensuring compatibility with legacy OpenAI clients.\n                - **Fusion of Multimodal & Schema Cleaning**: Successfully integrated self-developed high-performance image parsing with community-contributed high-precision JSON Schema filtering strategies.\n            - **Privacy-First Network Binding Control (Core Thanks to @kiookp PR #91)**:\n                - **Default Localhost**: Proxy server defaults to listening on `127.0.0.1` (localhost-only), ensuring privacy and security by default.\n                - **Optional LAN Access**: Added `allow_lan_access` configuration toggle; when enabled, listens on `0.0.0.0` to allow LAN device access.\n                - **Security Warnings**: Frontend UI provides clear security warnings and status hints.\n        - **Frontend UX Upgrade**:\n                - **Protocol Endpoint Visualization**: Added endpoint details display on the API Proxy page, supporting independent quick-copy for Chat, Completions, and Responses endpoints.\n    *   **v3.2.8 (2025-12-26)**:\n        - **Bug Fixes**:\n            - **OpenAI Protocol Multi-modal & Vision Model Support**: Fixed the 400 error caused by `content` format mismatch when sending image requests to vision models (e.g., `gemini-3-pro-image`) via OpenAI protocol.\n            - **Full Vision Capability Enrichment**: The OpenAI protocol now supports automatic parsing of Base64 images and mapping them to upstream `inlineData`, providing the same image processing power as the Claude protocol.\n    *   **v3.2.7 (2025-12-26)**:\n        - **New Features**:\n            - **Launch at Startup**: Added auto-launch feature that allows users to enable/disable automatic startup of Antigravity Tools when the system boots, configurable from the \"General\" tab in Settings.\n            - **Account List Page Size Selector**: Added a page size selector in the pagination bar of the Accounts page, allowing users to directly choose items per page (10/20/50/100) without entering Settings, improving batch operation efficiency.\n        - **Bug Fixes**:\n            - **Comprehensive JSON Schema Cleanup Enhancement (MCP Tool Compatibility Fix)**:\n                - **Removed Advanced Schema Fields**: Added removal of `propertyNames`, `const`, `anyOf`, `oneOf`, `allOf`, `if/then/else`, `not` and other advanced JSON Schema fields commonly used by MCP tools but unsupported by Gemini, completely resolving 400 errors when using MCP tools with Claude Code v2.0.76+.\n                - **Optimized Recursion Order**: Adjusted to recursively clean child nodes before processing parent nodes, preventing nested objects from being incorrectly serialized into descriptions.\n                - **Protobuf Type Compatibility**: Forced union type arrays (e.g., `[\"string\", \"null\"]`) to downgrade to single types, resolving \"Proto field is not repeating\" errors.\n                - **Smart Field Recognition**: Enhanced type checking logic to ensure validation fields are only removed when values match the expected type, avoiding accidental deletion of property definitions named `pattern`, etc.\n            - **Custom Database Import Fix**: Fixed the \"Command not found\" error for the \"Import from Custom DB\" feature caused by the missing `import_custom_db` command registration. Users can now properly select custom `state.vscdb` files for account import.\n            - **Proxy Stability & Image Generation Optimization**:\n                - **Smart 429 Backoff Mechanism**: Deeply integrated `RetryInfo` parsing to strictly follow Google API retry instructions with added safety redundancy, effectively reducing account suspension risks.\n                - **Precise Error Triage**: Fixed the logic that misidentified rate limits as quota exhaustion (no longer incorrectly stopping on \"check quota\" errors), ensuring automatic account rotation during throttling.\n                - **Parallel Image Generation Acceleration**: Disabled the 60s time-window lock for `image_gen` requests, enabling high-speed rotation across multiple accounts and completely resolving Imagen 3 429 errors.\n    *   **v3.2.6 (2025-12-26)**:\n        - **Critical Fixes**:\n            - **Claude Protocol Deep Optimization (Enhanced Claude Code Experience)**:\n                - **Dynamic Identity Mapping**: Dynamically injects identity protection patches based on the requested model, locking in the native Anthropic identity and shielding it from baseline platform instruction interference.\n                - **Tool Empty Output Compensation**: Specifically for silent commands like `mkdir`, automatically maps empty outputs to explicit success signals, resolving task flow interruptions and hallucinations in Claude CLI.\n                - **Global Stop Sequence Configuration**: Optimized `stopSequences` for proxy links, precisely cutting off streaming output and completely resolving parsing errors caused by trailing redundancy.\n                - **Smart Payload Cleaning (Smart Panic Fix)**: Introduced mutual exclusion checks for `GoogleSearch` and `FunctionCall`, and implemented automatic tool stripping during background task redirection (Token Saver), completely eliminating **400 Tool Conflict (Multiple tools)** errors.\n                - **Proxy Reliability Enhancement (Core Thanks to @salacoste PR #79)**: \n                    - **Smart 429 Backoff**: Support parsing upstream `RetryInfo` to wait and retry automatically when rate-limited, reducing unnecessary account rotation.\n                    - **Resume Fallback**: Implemented auto-stripping of Thinking blocks for `/resume` 400 signature errors, improving session recovery success.\n                    - **Extended Schema Support**: Improved recursive JSON Schema cleaning and added filtering for `enumCaseInsensitive` and other extension fields.\n            - **Test Suite Hardening**: Fixed missing imports and duplicate attribute errors in `mappers` test modules, and added new tests for content block merging and empty output completion.\n    *   **v3.2.1 (2025-12-25)**:\n        - **New Features**:\n            - **Custom DB Import**: Support importing accounts from any `state.vscdb` file path, facilitating data recovery from backups or custom locations.\n            - **Real-time Project ID Sync & Persistence**: Captured and saved the latest `project_id` to the local database in real-time during quota refresh.\n            - **OpenAI & Gemini Protocol Reinforcement**:\n                - **Unified Model Routing**: Now **Gemini protocol also supports custom model mapping**. This completes the integration of smart routing logic across OpenAI, Anthropic, and Gemini protocols.\n                - **Full Tool Call Support**: Correctly handles and delivers `functionCall` results (e.g., search) for both streaming and non-streaming responses, completely resolving the \"empty output\" error.\n                - **Real-time Thought Display**: Automatically extracts and displays Gemini 2.0+ reasoning processes via `<thought>` tags, ensuring no loss of inference information.\n                - **Advanced Parameter Mapping**: Added full mapping support for `stop` sequences, `response_format` (JSON mode), and custom `tools`.\n        - **Bug Fixes**:\n            - **Single Account Switch Restriction Fix**: Resolved the issue where the switch button was hidden when only one account existed. Now, manual Token injection can be triggered for a single account by clicking the switch button.\n            - **OpenAI Custom Mapping 404 Fix**: Fixed model routing logic to ensure mapped upstream model IDs are used, resolving 404 errors during custom mapping.\n            - **Proxy Retry Logic Optimization**: Introduced smart error recognition and a retry limit. Implemented fail-fast protection for 404 and 429 (quota exhausted).\n            - **JSON Schema Deep Cleanup (Compatibility Enhancement)**: Established a unified cleanup mechanism to automatically filter out over 20 extension fields unsupported by Gemini (e.g., `multipleOf`, `exclusiveMinimum`, `pattern`, `const`, `if-then-else`), resolving 400 errors when CLI tools invoke tools via API.\n            - **Claude Thinking Chain Validation Fix**: Resolved the structural validation issue where `assistant` messages must start with a thinking block when Thinking is enabled. Now supports automatic injection of placeholder thinking blocks and automatic restoration of `<thought>` tags from text, ensuring stability for long conversations in advanced tools like Claude Code.\n            - **OpenAI Adaption Fix**: Resolved issues where some clients sending `system` messages caused errors.\n    *   **v3.2.0 (2025-12-24)**:\n        - **Core Architecture Refactor**:\n            - **Proxy Engine Rewrite**: Completely modularized `proxy` subsystem with decoupled `mappers`, `handlers`, and `middleware` for superior maintainability.\n            - **Linux Process Management**: Implemented smart process identification to distinguish Main/Helper processes, ensuring graceful exit via `SIGTERM` with `SIGKILL` fallback.\n        - **Homebrew Support**: Official support for macOS one-click installation via `brew install --cask antigravity`.\n        - **GUI UX Revolution**: Revamped Dashboard with average quota monitoring and \"Best Account Recommendation\" algorithm.\n        - **Protocol & Router Expansion**: Native support for OpenAI, Anthropic (Claude Code), and Gemini protocols with high-precision Model Router.\n        - **Multimodal Optimization**: Deep adaptation for Imagen 3 with 100MB payload capacity and aspect ratio controls.\n        - **Global Upstream Proxy**: Centralized request management supporting HTTP/SOCKS5 with hot-reloading.\n    *   See [Releases](https://github.com/lbjlaq/Antigravity-Manager/releases) for earlier history.\n\n    </details>\n## 👥 Contributors\n\n<a href=\"https://github.com/lbjlaq\"><img src=\"https://github.com/lbjlaq.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"lbjlaq\"/></a>\n<a href=\"https://github.com/XinXin622\"><img src=\"https://github.com/XinXin622.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"XinXin622\"/></a>\n<a href=\"https://github.com/llsenyue\"><img src=\"https://github.com/llsenyue.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"llsenyue\"/></a>\n<a href=\"https://github.com/salacoste\"><img src=\"https://github.com/salacoste.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"salacoste\"/></a>\n<a href=\"https://github.com/84hero\"><img src=\"https://github.com/84hero.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"84hero\"/></a>\n<a href=\"https://github.com/karasungur\"><img src=\"https://github.com/karasungur.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"karasungur\"/></a>\n<a href=\"https://github.com/marovole\"><img src=\"https://github.com/marovole.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"marovole\"/></a>\n<a href=\"https://github.com/wanglei8888\"><img src=\"https://github.com/wanglei8888.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"wanglei8888\"/></a>\n<a href=\"https://github.com/yinjianhong22-design\"><img src=\"https://github.com/yinjianhong22-design.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"yinjianhong22-design\"/></a>\n<a href=\"https://github.com/Mag1cFall\"><img src=\"https://github.com/Mag1cFall.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"Mag1cFall\"/></a>\n<a href=\"https://github.com/AmbitionsXXXV\"><img src=\"https://github.com/AmbitionsXXXV.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"AmbitionsXXXV\"/></a>\n<a href=\"https://github.com/fishheadwithchili\"><img src=\"https://github.com/fishheadwithchili.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"fishheadwithchili\"/></a>\n<a href=\"https://github.com/ThanhNguyxn\"><img src=\"https://github.com/ThanhNguyxn.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"ThanhNguyxn\"/></a>\n<a href=\"https://github.com/Stranmor\"><img src=\"https://github.com/Stranmor.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"Stranmor\"/></a>\n<a href=\"https://github.com/Jint8888\"><img src=\"https://github.com/Jint8888.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"Jint8888\"/></a>\n<a href=\"https://github.com/0-don\"><img src=\"https://github.com/0-don.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"0-don\"/></a>\n<a href=\"https://github.com/dlukt\"><img src=\"https://github.com/dlukt.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"dlukt\"/></a>\n<a href=\"https://github.com/Koshikai\"><img src=\"https://github.com/Koshikai.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"Koshikai\"/></a>\n<a href=\"https://github.com/hakanyalitekin\"><img src=\"https://github.com/hakanyalitekin.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"hakanyalitekin\"/></a>\n<a href=\"https://github.com/Gok-tug\"><img src=\"https://github.com/Gok-tug.png\" width=\"50px\" style=\"border-radius: 50%;\" alt=\"Gok-tug\"/></a>\n\nSpecial thanks to all developers who have contributed to this project.\n\n## 🤝 Special Thanks\n\nThis project has referenced or learned from the ideas or code of the following excellent open-source projects during its development (in no particular order):\n\n*   [learn-claude-code](https://github.com/shareAI-lab/learn-claude-code)\n*   [Practical-Guide-to-Context-Engineering](https://github.com/WakeUp-Jin/Practical-Guide-to-Context-Engineering)\n*   [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)\n*   [antigravity-claude-proxy](https://github.com/badrisnarayanan/antigravity-claude-proxy)\n*   [aistudio-gemini-proxy](https://github.com/zhongruichen/aistudio-gemini-proxy)\n*   [gcli2api](https://github.com/su-kaka/gcli2api)\n\n*   **License**: **CC BY-NC-SA 4.0**. Strictly for non-commercial use.\n*   **Security**: All account data is encrypted and stored locally in a SQLite database. Data never leaves your device unless sync is enabled.\n\n---\n\n<div align=\"center\">\n  <p>If you find this tool helpful, please give it a ⭐️ on GitHub!</p>\n  <p>Copyright © 2025 Antigravity Team.</p>\n</div>\n"
  },
  {
    "path": "deploy/arch/PKGBUILD.template",
    "content": "# Maintainer: Antigravity Team <https://github.com/lbjlaq/Antigravity-Manager>\npkgname=antigravity-tools-bin\npkgver=${_pkgver}\npkgrel=1\npkgdesc=\"Professional AI Account Management & Proxy System (Binary Version)\"\narch=('x86_64' 'aarch64')\nurl=\"https://github.com/lbjlaq/Antigravity-Manager\"\nlicense=('CC-BY-NC-SA-4.0')\ndepends=('gtk3' 'libappindicator-gtk3' 'openssl' 'webkit2gtk-4.1' 'libnm' 'xdg-utils' 'librsvg')\nprovides=('antigravity-tools')\nconflicts=('antigravity-tools')\noptions=('!strip' '!debug')\n\nsource_x86_64=(\"${pkgname}-${pkgver}-x86_64.deb::${_url_x86_64}\")\nsource_aarch64=(\"${pkgname}-${pkgver}-aarch64.deb::${_url_aarch64}\")\nsha256sums_x86_64=(\"${_sha256_x86_64}\")\nsha256sums_aarch64=(\"${_sha256_aarch64}\")\n\npackage() {\n  # Extract the data archive from the deb package\n  # The compression format can be .zst, .xz, or .gz depending on the build environment\n  # Using a wildcard to automatically match data.tar.*\n  local data_archive=$(ls data.tar.* 2>/dev/null | head -n 1)\n  \n  if [ -n \"$data_archive\" ]; then\n    msg2 \"Extracting data archive: $data_archive\"\n    tar -xf \"$data_archive\" -C \"${pkgdir}\"\n  else\n    error \"Could not find data archive (data.tar.*)\"\n    return 1\n  fi\n  \n  # Fix permissions if necessary\n  chmod -R 755 \"${pkgdir}/usr/bin\"\n}\n"
  },
  {
    "path": "deploy/arch/install.sh",
    "content": "#!/bin/bash\nset -e\n\n# Antigravity Tools - Arch Linux Self-Updating Installer\n# This script fetches the latest release from GitHub and installs it using makepkg.\n\necho \"🚀 Fetching latest release information...\"\nREPO=\"lbjlaq/Antigravity-Manager\"\nLATEST_RELEASE=$(curl -s \"https://api.github.com/repos/$REPO/releases/latest\")\nPKGVER=$(echo \"$LATEST_RELEASE\" | grep '\"tag_name\":' | sed -E 's/.*\"v([^\"]+)\".*/\\1/')\n\nif [ -z \"$PKGVER\" ]; then\n    echo \"❌ Error: Could not fetch latest version.\"\n    exit 1\nfi\n\necho \"📦 Latest version: v$PKGVER\"\n\n# Find asset URLs\nURL_X86_64=$(echo \"$LATEST_RELEASE\" | grep -oP '\"browser_download_url\": \"\\K[^\"]*amd64\\.deb' | head -n 1)\nURL_AARCH64=$(echo \"$LATEST_RELEASE\" | grep -oP '\"browser_download_url\": \"\\K[^\"]*arm64\\.deb' | head -n 1)\n\nif [ -z \"$URL_X86_64\" ] || [ -z \"$URL_AARCH64\" ]; then\n    echo \"❌ Error: Could not find .deb assets for v$PKGVER\"\n    exit 1\nfi\n\necho \"🔍 Downloading assets to calculate checksums...\"\nTEMP_DIR=$(mktemp -d)\ncd \"$TEMP_DIR\"\n\nwget -q \"$URL_X86_64\" -O x86_64.deb\nwget -q \"$URL_AARCH64\" -O aarch64.deb\n\nSHA_X86_64=$(sha256sum x86_64.deb | cut -d' ' -f1)\nSHA_AARCH64=$(sha256sum aarch64.deb | cut -d' ' -f1)\n\necho \"📝 Generating PKGBUILD...\"\n# Download PKGBUILD template\ncurl -sSL \"https://raw.githubusercontent.com/$REPO/main/deploy/arch/PKGBUILD.template\" -o PKGBUILD.template\n\n# Replace placeholders\nsed -e \"s/\\${_pkgver}/$PKGVER/g\" \\\n    -e \"s|\\${_url_x86_64}|$URL_X86_64|g\" \\\n    -e \"s|\\${_url_aarch64}|$URL_AARCH64|g\" \\\n    -e \"s/\\${_sha256_x86_64}/$SHA_X86_64/g\" \\\n    -e \"s/\\${_sha256_aarch64}/$SHA_AARCH64/g\" \\\n    PKGBUILD.template > PKGBUILD\n\necho \"🛠️ Starting installation via makepkg...\"\nmakepkg -si --noconfirm\n\necho \"✅ Installation complete!\"\ncd - > /dev/null\nrm -rf \"$TEMP_DIR\"\n"
  },
  {
    "path": "docker/Dockerfile",
    "content": "# --- Frontend Build Stage ---\nFROM node:20-slim AS frontend-builder\nARG USE_MIRROR=auto\nWORKDIR /app\n\n# Use npm mirror if needed\nRUN if [ \"$USE_MIRROR\" = \"true\" ] || ( [ \"$USE_MIRROR\" = \"auto\" ] && ! timeout 3 bash -c \"</dev/tcp/www.google.com/80\" 2>/dev/null ); then \\\n    echo \"Using npm mirror...\"; \\\n    npm config set registry https://registry.npmmirror.com; \\\n    else \\\n    echo \"Using default npm registry...\"; \\\n    fi\n\nCOPY package*.json ./\nRUN --mount=type=cache,target=/root/.npm \\\n    npm ci --legacy-peer-deps --prefer-offline --no-audit --no-fund\nCOPY tsconfig.json tsconfig.node.json vite.config.ts tailwind.config.js postcss.config.cjs index.html ./\nCOPY public ./public\nCOPY src ./src\nCOPY src-tauri/icons ./src-tauri/icons\nRUN npm run build\n\n# --- Backend Build Stage ---\nFROM rust:1-slim-bookworm AS backend-builder\nARG USE_MIRROR=auto\n\n# Conditionally use Aliyun mirror for APT\nRUN if [ \"$USE_MIRROR\" = \"true\" ] || ( [ \"$USE_MIRROR\" = \"auto\" ] && ! timeout 3 bash -c \"</dev/tcp/www.google.com/80\" 2>/dev/null ); then \\\n    echo \"Using Aliyun mirror for APT...\"; \\\n    sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources || \\\n    sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list; \\\n    else \\\n    echo \"Using default APT sources...\"; \\\n    fi\n\n# Install build dependencies\nRUN apt-get update && apt-get install -y \\\n    pkg-config \\\n    build-essential \\\n    curl \\\n    wget \\\n    file \\\n    libssl-dev \\\n    libgtk-3-dev \\\n    libwebkit2gtk-4.1-dev \\\n    libayatana-appindicator3-dev \\\n    librsvg2-dev \\\n    libsoup-3.0-dev \\\n    libjavascriptcoregtk-4.1-dev \\\n    perl \\\n    cmake \\\n    golang-go \\\n    clang \\\n    libclang-dev \\\n    git \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Use Aliyun mirror for Cargo if needed (Sparse Index)\nENV CARGO_HTTP_MULTIPLEXING=false\nRUN if [ \"$USE_MIRROR\" = \"true\" ] || ( [ \"$USE_MIRROR\" = \"auto\" ] && ! timeout 3 bash -c \"</dev/tcp/www.google.com/80\" 2>/dev/null ); then \\\n    echo \"Using Aliyun mirror for Cargo...\"; \\\n    mkdir -p /root/.cargo && \\\n    echo \"[source.crates-io]\\nreplace-with = 'aliyun'\\n\\n[source.aliyun]\\nregistry = \\\"sparse+https://mirrors.aliyun.com/crates.io-index/\\\"\" > /root/.cargo/config.toml; \\\n    else \\\n    echo \"Using default Cargo registry...\"; \\\n    fi\n\n# Verify Go installation for BoringSSL build\nRUN which go && go version || (echo \"ERROR: Go compiler not found\" && exit 1)\n\nWORKDIR /app\n# Copy only backend sources to keep frontend cache intact on backend-only changes\nCOPY src-tauri ./src-tauri\n\n# [FIX] Copy locales for Rust compilation (needed by i18n.rs include_str!)\nCOPY src/locales ./src/locales\n\n# Copy frontend dist from builder so Tauri can embed it\nCOPY --from=frontend-builder /app/dist ./dist\n\n# Build the Rust backend in release mode (with BuildKit cache)\nWORKDIR /app/src-tauri\nRUN --mount=type=cache,target=/root/.cargo/registry \\\n    --mount=type=cache,target=/root/.cargo/git \\\n    --mount=type=cache,target=/app/src-tauri/target \\\n    cargo build --release --bin antigravity_tools && \\\n    cp target/release/antigravity_tools /tmp/antigravity_tools\n\n# --- Final Runtime Stage ---\nFROM debian:bookworm-slim\nARG USE_MIRROR=auto\nWORKDIR /app\n\n# Conditionally use Aliyun mirror for APT\nRUN if [ \"$USE_MIRROR\" = \"true\" ] || ( [ \"$USE_MIRROR\" = \"auto\" ] && ! timeout 3 bash -c \"</dev/tcp/www.google.com/80\" 2>/dev/null ); then \\\n    echo \"Using Aliyun mirror for APT...\"; \\\n    sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources || \\\n    sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list; \\\n    else \\\n    echo \"Using default APT sources...\"; \\\n    fi\n\n# Install runtime dependencies\nRUN apt-get update && apt-get install -y \\\n    libssl3 \\\n    libsqlite3-0 \\\n    ca-certificates \\\n    libgtk-3-0 \\\n    libwebkit2gtk-4.1-0 \\\n    libayatana-appindicator3-1 \\\n    librsvg2-2 \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Copy binary from builder\nCOPY --from=backend-builder /tmp/antigravity_tools /app/antigravity-tools\n\n# Copy frontend dist from builder\nCOPY --from=frontend-builder /app/dist /app/dist\n\n# Set environment variables\nENV ABV_DIST_PATH=/app/dist\nENV RUST_LOG=info\nENV PORT=8045\n\n# Expose the proxy/admin port\nEXPOSE 8045\n\n# Run the application in headless mode\nENTRYPOINT [\"/app/antigravity-tools\", \"--headless\"]\n"
  },
  {
    "path": "docker/Dockerfile.backend",
    "content": "# --- Backend Build Stage (no frontend build) ---\nFROM rust:1-slim-bookworm AS backend-builder\nARG USE_MIRROR=auto\n\n# Conditionally use Aliyun mirror for APT\nRUN if [ \"$USE_MIRROR\" = \"true\" ] || ( [ \"$USE_MIRROR\" = \"auto\" ] && ! timeout 3 bash -c \"</dev/tcp/www.google.com/80\" 2>/dev/null ); then \\\n    echo \"Using Aliyun mirror for APT...\"; \\\n    sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources || \\\n    sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list; \\\n    else \\\n    echo \"Using default APT sources...\"; \\\n    fi\n\n# Install build dependencies\nRUN apt-get update && apt-get install -y \\\n    pkg-config \\\n    build-essential \\\n    curl \\\n    wget \\\n    file \\\n    libssl-dev \\\n    libgtk-3-dev \\\n    libwebkit2gtk-4.1-dev \\\n    libayatana-appindicator3-dev \\\n    librsvg2-dev \\\n    libsoup-3.0-dev \\\n    libjavascriptcoregtk-4.1-dev \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Use Aliyun mirror for Cargo if needed (Sparse Index)\nENV CARGO_HTTP_MULTIPLEXING=false\nRUN if [ \"$USE_MIRROR\" = \"true\" ] || ( [ \"$USE_MIRROR\" = \"auto\" ] && ! timeout 3 bash -c \"</dev/tcp/www.google.com/80\" 2>/dev/null ); then \\\n    echo \"Using Aliyun mirror for Cargo...\"; \\\n    mkdir -p /root/.cargo && \\\n    echo \"[source.crates-io]\\nreplace-with = 'aliyun'\\n\\n[source.aliyun]\\nregistry = \\\"sparse+https://mirrors.aliyun.com/crates.io-index/\\\"\" > /root/.cargo/config.toml; \\\n    else \\\n    echo \"Using default Cargo registry...\"; \\\n    fi\n\nWORKDIR /app\nCOPY src-tauri ./src-tauri\nCOPY src/locales ./src/locales\n\nWORKDIR /app/src-tauri\nRUN --mount=type=cache,target=/root/.cargo/registry \\\n    --mount=type=cache,target=/root/.cargo/git \\\n    --mount=type=cache,target=/app/src-tauri/target \\\n    cargo build --release --bin antigravity_tools && \\\n    cp target/release/antigravity_tools /tmp/antigravity_tools\n\n# --- Frontend Dist Stage (reuse prebuilt image) ---\nARG FRONTEND_IMAGE=antigravity-manager:latest\nFROM ${FRONTEND_IMAGE} AS frontend-prebuilt\n\n# --- Final Runtime Stage ---\nFROM debian:bookworm-slim\nARG USE_MIRROR=auto\nWORKDIR /app\n\n# Conditionally use Aliyun mirror for APT\nRUN if [ \"$USE_MIRROR\" = \"true\" ] || ( [ \"$USE_MIRROR\" = \"auto\" ] && ! timeout 3 bash -c \"</dev/tcp/www.google.com/80\" 2>/dev/null ); then \\\n    echo \"Using Aliyun mirror for APT...\"; \\\n    sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources || \\\n    sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list; \\\n    else \\\n    echo \"Using default APT sources...\"; \\\n    fi\n\n# Install runtime dependencies\nRUN apt-get update && apt-get install -y \\\n    libssl3 \\\n    libsqlite3-0 \\\n    ca-certificates \\\n    libgtk-3-0 \\\n    libwebkit2gtk-4.1-0 \\\n    libayatana-appindicator3-1 \\\n    librsvg2-2 \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Copy binary from builder\nCOPY --from=backend-builder /tmp/antigravity_tools /app/antigravity-tools\n\n# Copy frontend dist from prebuilt image\nCOPY --from=frontend-prebuilt /app/dist /app/dist\n\n# Set environment variables\nENV ABV_DIST_PATH=/app/dist\nENV RUST_LOG=info\nENV PORT=8045\n\n# Expose the proxy/admin port\nEXPOSE 8045\n\n# Run the application in headless mode\nENTRYPOINT [\"/app/antigravity-tools\", \"--headless\"]\n"
  },
  {
    "path": "docker/Dockerfile.backend.localdist",
    "content": "# --- Backend Build Stage (uses local dist/) ---\nFROM rust:1-slim-bookworm AS backend-builder\nARG USE_MIRROR=auto\n\n# Conditionally use Aliyun mirror for APT\nRUN if [ \"$USE_MIRROR\" = \"true\" ] || ( [ \"$USE_MIRROR\" = \"auto\" ] && ! timeout 3 bash -c \"</dev/tcp/www.google.com/80\" 2>/dev/null ); then \\\n    echo \"Using Aliyun mirror for APT...\"; \\\n    sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources || \\\n    sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list; \\\n    else \\\n    echo \"Using default APT sources...\"; \\\n    fi\n\n# Install build dependencies\nRUN apt-get update && apt-get install -y \\\n    pkg-config \\\n    build-essential \\\n    curl \\\n    wget \\\n    file \\\n    libssl-dev \\\n    libgtk-3-dev \\\n    libwebkit2gtk-4.1-dev \\\n    libayatana-appindicator3-dev \\\n    librsvg2-dev \\\n    libsoup-3.0-dev \\\n    libjavascriptcoregtk-4.1-dev \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Use Aliyun mirror for Cargo if needed (Sparse Index)\nENV CARGO_HTTP_MULTIPLEXING=false\nRUN if [ \"$USE_MIRROR\" = \"true\" ] || ( [ \"$USE_MIRROR\" = \"auto\" ] && ! timeout 3 bash -c \"</dev/tcp/www.google.com/80\" 2>/dev/null ); then \\\n    echo \"Using Aliyun mirror for Cargo...\"; \\\n    mkdir -p /root/.cargo && \\\n    echo \"[source.crates-io]\\nreplace-with = 'aliyun'\\n\\n[source.aliyun]\\nregistry = \\\"sparse+https://mirrors.aliyun.com/crates.io-index/\\\"\" > /root/.cargo/config.toml; \\\n    else \\\n    echo \"Using default Cargo registry...\"; \\\n    fi\n\nWORKDIR /app\nCOPY src-tauri ./src-tauri\nCOPY src/locales ./src/locales\n\nWORKDIR /app/src-tauri\nRUN --mount=type=cache,target=/root/.cargo/registry \\\n    --mount=type=cache,target=/root/.cargo/git \\\n    --mount=type=cache,target=/app/src-tauri/target \\\n    cargo build --release --bin antigravity_tools && \\\n    cp target/release/antigravity_tools /tmp/antigravity_tools\n\n# --- Final Runtime Stage ---\nFROM debian:bookworm-slim\nARG USE_MIRROR=auto\nWORKDIR /app\n\n# Conditionally use Aliyun mirror for APT\nRUN if [ \"$USE_MIRROR\" = \"true\" ] || ( [ \"$USE_MIRROR\" = \"auto\" ] && ! timeout 3 bash -c \"</dev/tcp/www.google.com/80\" 2>/dev/null ); then \\\n    echo \"Using Aliyun mirror for APT...\"; \\\n    sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources || \\\n    sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list; \\\n    else \\\n    echo \"Using default APT sources...\"; \\\n    fi\n\n# Install runtime dependencies\nRUN apt-get update && apt-get install -y \\\n    libssl3 \\\n    libsqlite3-0 \\\n    ca-certificates \\\n    libgtk-3-0 \\\n    libwebkit2gtk-4.1-0 \\\n    libayatana-appindicator3-1 \\\n    librsvg2-2 \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Copy binary from builder\nCOPY --from=backend-builder /tmp/antigravity_tools /app/antigravity-tools\n\n# Copy frontend dist from local build context\nCOPY dist /app/dist\n\n# Set environment variables\nENV ABV_DIST_PATH=/app/dist\nENV RUST_LOG=info\nENV PORT=8045\n\n# Expose the proxy/admin port\nEXPOSE 8045\n\n# Run the application in headless mode\nENTRYPOINT [\"/app/antigravity-tools\", \"--headless\"]\n"
  },
  {
    "path": "docker/README.md",
    "content": "# 🐋 Antigravity Manager 原生 Docker 部署手冊\n\n本目錄包含 Antigravity Manager 的原生 Headless Docker 部署方案。該方案支持完整的 Web 管理界面、API 反代以及數據持久化，無需複雜的 VNC 或桌面環境。\n\n## 🆕 本版本部署方案（本地前端構建復用）\n適用於「前端近期不改、後端經常調整」的場景。思路是先在本地生成 `dist/`，Docker 只編譯後端並直接拷貝 `dist/`，大幅縮短構建時間並降低前端構建風險。\n\n**步驟**\n1. 本地生成前端靜態資源：\n```bash\nnpm ci --legacy-peer-deps\nnpm run build\n```\n2. 使用本方案構建與啟動（後端-only + 復用 `dist/`）：\n```bash\ndocker compose -f docker/docker-compose.yml -f docker/docker-compose.localdist.yml build\ndocker compose -f docker/docker-compose.yml -f docker/docker-compose.localdist.yml up -d\n```\n或合併為單條命令：\n```bash\ndocker compose -f docker/docker-compose.yml -f docker/docker-compose.localdist.yml up -d --build\n```\n\n啟動後動態查看日誌：\n```bash\ndocker compose -f docker/docker-compose.yml -f docker/docker-compose.localdist.yml logs -f --tail=200\n```\n\n**更新方式**\n- 後端有改動：重跑上面的 `build` + `up -d`\n- 前端有改動：先在本地重新 `npm run build`，再重跑 `build` + `up -d`\n\n**Git 部署提醒**\n- 若服務器不在本地構建前端，請確保 `dist/` 已提交到倉庫（本版本已從 `.gitignore` 移除）。\n\n## 🚀 快速開始\n\n### 1. 直接拉取鏡像 (推薦)\n您可以直接從 Docker Hub 拉取已構建好的鏡像並啟动，無需獲取源碼：\n\n> [!IMPORTANT]\n> **安全警告**：從 v4.0.3 開始，Docker 版支持 **管理密碼與 API Key 分離**：\n> *   **API Key**：通過 `-e API_KEY=xxx` 設置，用於所有 AI 協議的 API 調用鑒權。\n> *   **Web 管理密碼**：通過 `-e WEB_PASSWORD=xxx` 設置，僅用於 Web UI 登錄。\n> *   **默認行為**：若未設置 `WEB_PASSWORD`，系統會自動回退使用 `API_KEY` 作為登錄密碼。若兩者皆未設置，則生成隨機 Key。\n> *   **查看方式**：執行 `docker logs antigravity-manager` 尋找 `Current API Key` 或 `Web UI Password`，或執行 `grep -E '\"api_key\"|\"admin_password\"' ~/.antigravity_tools/gui_config.json` 查看。\n\n```bash\n# 啟動容器 (請替换 your-secret-key 為強密鑰)\ndocker run -d \\\n  --name antigravity-manager \\\n  -p 8045:8045 \\\n  -e API_KEY=your-api-key \\\n  -e WEB_PASSWORD=your-login-password \\\n  -e ABV_MAX_BODY_SIZE=104857600 \\\n  -v ~/.antigravity_tools:/root/.antigravity_tools \\\n  lbjlaq/antigravity-manager:latest\n```\n\n#### 🔐 鑒權邏輯 (Security Scenarios)\n*   **場景 A：僅設置了 `API_KEY`**\n    - **Web 登錄**：使用 `API_KEY` 即可進入後台。\n    - **API 調用**：使用 `API_KEY` 進行 AI 請求鑒權。\n*   **場景 B：同時設置了 `API_KEY` 和 `WEB_PASSWORD` (推薦)**\n    - **Web 登錄**：**必須**使用 `WEB_PASSWORD`。此時輸入 API Key 將被拒絕，確保管理權限與調用權限隔離。\n    - **API 調用**：繼續使用 `API_KEY`。您可以放心地將 API Key 分發給團隊成員，而保留密碼僅供管理員使用。\n\n#### 🆙 舊版本升級指引\n如果您是從舊版本升級，默認沒有設置 `WEB_PASSWORD`。您可以通過以下方式添加：\n1.  **Web UI (推薦)**：使用原有的 `API_KEY` 登錄，在 **API 反代** 設置頁面中設置新的管理密碼。\n2.  **環境變量**：停止舊容器，啟動新容器時增加 `-e WEB_PASSWORD=您的新密碼`。\n\n> [!TIP]\n> **優先級邏輯 (Priority)**:\n> - **環境變量** (`ABV_WEB_PASSWORD` / `WEB_PASSWORD`) 具有最高優先級。如果設置了環境變量，程序將始終使用它，忽略配置文件中的值。\n> - **配置文件** (`gui_config.json`) 用於持久化存儲。當您通過 Web UI 修改密碼並保存時，新密碼會寫入此文件（JSON 字段名為 `admin_password`）。\n> - **回退機制**: 如果上述兩者皆未設置，則回退使用 `API_KEY`；若連 `API_KEY` 也未設置，則隨機生成。\n\n### 2. 使用 Docker Compose\n在 `docker` 目錄下執行：\n```bash\ndocker compose up -d\n```\n\n### 3. 手動構建鏡像 (開發者)\n如果您需要修改代碼或自定義構建，請在項目根目錄下執行：\n```bash\n# 默認構建最新標籤\ndocker build -t antigravity-manager:latest -f docker/Dockerfile .\n```\n\n#### 💡 構建參數\n本鏡像支持自動鏡像源切換，以提升国内構建速度：\n*   `USE_MIRROR`: \n    *   `auto` (默認): 自動檢測網絡環境，若無法訪問 Google 則切換至国内镜像（阿里云/NPM Mirror）。\n    *   `true`: 強制使用国内镜像源。\n    *   `false`: 強制使用官方默認源。\n\n示例：\n```bash\n# 強制使用国内镜像加速構建\ndocker build --build-arg USE_MIRROR=true -t antigravity-manager:latest -f docker/Dockerfile .\n```\n\n## ⚙️ 環境變量配置\n\n| 變量名 | 默認值 | 說明 |\n| :--- | :--- | :--- |\n| `PORT` | `8045` | 容器內服務監聽端口 |\n| `ABV_API_KEY` | - | **[重要]** 代理 API 密鑰。客戶端（如 Claude Code）訪問時需提供的 Key |\n| `ABV_WEB_PASSWORD` | - | **[安全]** Web 管理後台登錄密碼。若不設置則回退使用 API Key |\n| `ABV_MAX_BODY_SIZE` | `104857600` | **[性能]** 最大請求體限制 (Byte)。默認 100MB，用於解決大圖傳輸 413 錯誤 |\n| `LOG_LEVEL` | `info` | 日志等級 (debug, info, warn, error) |\n| `ABV_DIST_PATH` | `/app/dist` | 前端靜態資源託管路徑 (Dockerfile 已內置) |\n| `ABV_PUBLIC_URL` | - | 用於遠程 OAuth 回調的公網 URL (可選) |\n\n## 📂 數據持久化\n請務必將宿主機目錄掛載至容器內的 `/root/.antigravity_tools`，否則賬號和配置在容器重啟後會丟失。\n\n## 🌐 訪問位址\n*   **管理界面**: [http://localhost:8045](http://localhost:8045)\n*   **API Base**: [http://localhost:8045/v1](http://localhost:8045/v1)\n\n## 📦 Docker Hub 分發 (推薦)\n若要推送至你的倉庫：\n```bash\n# 打上版本標籤並推送\ndocker tag antigravity-manager:latest lbjlaq/antigravity-manager:latest\ndocker tag antigravity-manager:latest lbjlaq/antigravity-manager:4.1.20\ndocker push lbjlaq/antigravity-manager:latest\ndocker push lbjlaq/antigravity-manager:4.1.20\n```\n"
  },
  {
    "path": "docker/docker-compose.backend.yml",
    "content": "services:\n  antigravity-manager:\n    build:\n      dockerfile: docker/Dockerfile.backend\n      args:\n        FRONTEND_IMAGE: antigravity-manager:latest\n"
  },
  {
    "path": "docker/docker-compose.localdist.yml",
    "content": "services:\n  antigravity-manager:\n    build:\n      context: ..\n      dockerfile: docker/Dockerfile.backend.localdist\n"
  },
  {
    "path": "docker/docker-compose.yml",
    "content": "services:\n  antigravity-manager:\n    build:\n      context: ..\n      dockerfile: docker/Dockerfile\n    image: lbjlaq/antigravity-manager\n    container_name: antigravity-manager\n    network_mode: host  # 使用宿主机网络，可直接访问 localhost:8045\n    # ports:  # host 模式不需要端口映射\n    #   - \"8045:8045\"\n    volumes:\n      - ~/.antigravity_tools:/root/.antigravity_tools\n    environment:\n      - LOG_LEVEL=${LOG_LEVEL:-info}\n      - API_KEY=${API_KEY:-test}  # [重要] 請設置您的安全密鑰，若不設置則在日誌中查看隨機密鑰\n      - ABV_BIND_LOCAL_ONLY=${ABV_BIND_LOCAL_ONLY:-true}  # 僅綁定 127.0.0.1\n    restart: unless-stopped\n"
  },
  {
    "path": "docs/API_REFERENCE.md",
    "content": "# API Reference (v4.0.3)\n\n本文档详细介绍了 **Antigravity Tools** 暴露的 HTTP API 接口。\n\n> **注意**: 在 v4.0.1 版本中，所有的服务（包括 AI 反代和系统管理）均已整合至统一端口 **8045**。原有的 19527 端口已废弃。\n\n## 1. 概览 (Overview)\n\nAntigravity Gateway 是一个双重角色的服务器：\n1.  **AI Proxy Interface**: 兼容 OpenAI/Anthropic/Google 官方 SDK 的标准接口。\n2.  **Management Admin API**: 用于管理账号、配置系统、监控流量的 RESTful 接口。\n\n### 鉴权体系 (Authentication)\n\n| 接口类型 | 路径前缀 | 鉴权方式 | Header 示例 | 说明 |\n| :--- | :--- | :--- | :--- | :--- |\n| **AI Protocol** | `/v1/*`, `/v1beta/*` | API Key | `Authorization: Bearer <API_KEY>` | 用于 AI 客户端调用 |\n| **Admin API** | `/api/*` | Admin Token | `x-admin-token: <TOKEN>` | 用于管理后台或脚本控制 |\n\n> **提示**: 默认情况下，`Admin Token` 与 `API Key` 是同一个值（即您在 `.env` 或 Docker 环境变量中设置的 `API_KEY`）。\n\n---\n\n## 2. 管理接口 (Management API)\n\n**Base URL**: `http://<host>:8045/api`\n\n### 2.1 账号管理 (Account Management)\n\n| 方法 | 路径 | 说明 | 参数示例 |\n| :--- | :--- | :--- | :--- |\n| **GET** | `/accounts` | 获取账号列表 | - |\n| **GET** | `/accounts/current` | 获取当前活跃账号 | - |\n| **POST** | `/accounts` | 添加账号 (OAuth Refresh Token) | `{\"refreshToken\": \"...\"}` |\n| **DELETE**| `/accounts/:id` | 删除账号 | - |\n| **POST** | `/accounts/switch` | 切换活跃账号 | `{\"accountId\": \"acc_123\"}` |\n| **POST** | `/accounts/refresh` | **刷新所有账号配额** | - |\n| **GET** | `/accounts/:id/quota` | **查询特定账号配额** | - |\n| **POST** | `/accounts/:id/toggle-proxy` | 禁用/启用账号代理 | - |\n| **POST** | `/accounts/:id/bind-device` | 绑定设备指纹 | `{\"mode\": \"generate\"}` |\n| **POST** | `/accounts/bulk-delete` | 批量删除账号 | `{\"accountIds\": [\"id1\", \"id2\"]}` |\n| **POST** | `/accounts/reorder` | 账号排序 | `{\"accountIds\": [...]}` |\n\n### 2.2 系统配置 (System Config)\n| 方法 | 路径 | 说明 |\n| :--- | :--- | :--- |\n| **GET** | `/config` | 获取全量配置 |\n| **POST** | `/config` | 保存全量配置 |\n| **GET** | `/proxy/status` | 获取反代服务运行状态 |\n| **POST** | `/proxy/start` | 启动反代服务 |\n| **POST** | `/proxy/stop` | 停止反代服务 |\n| **POST** | `/proxy/mapping` | 更新模型映射规则 |\n| **GET** | `/health` | 系统健康检查 |\n\n### 2.3 监控与统计 (Monitoring & Stats)\n#### 流量日志\n*   **GET** `/logs`: 获取日志列表 (支持 `limit`, `offset`, `filter`, `errorsOnly` 参数)\n*   **GET** `/logs/count`: 获取日志总数\n*   **GET** `/logs/:id`: 获取日志详情\n*   **POST** `/logs/clear`: 清空日志\n\n#### Token 统计 (v4.0.1 New)\n*   **GET** `/stats/token/summary`: 获取 Token 消耗摘要 (今日/本周/总量)\n*   **GET** `/stats/token/hourly`: 获取按小时统计数据\n*   **GET** `/stats/token/daily`: 获取按日统计数据\n*   **GET** `/stats/token/by-account`: 按账号统计消耗占比\n*   **GET** `/stats/token/by-model`: 按模型统计消耗占比\n*   **POST** `/stats/token/clear`: 重置统计数据\n\n### 2.4 高级功能 (Advanced)\n*   **POST** `/proxy/cli/sync`: 执行 CLI (Claude/Codex) 配置文件同步\n*   **POST** `/accounts/import/db`: 从 v1 旧数据库导入账号\n*   **POST** `/accounts/oauth/start`: 发起 OAuth 授权流程 (Headless)\n*   **POST** `/proxy/cloudflared/start`: 启动 Cloudflare Tunnel\n\n---\n\n## 3. AI 协议接口 (AI Protocol Interface)\n\n**Base URL**: `http://<host>:8045`\n\n本服务完全兼容主流 AI 厂商的官方协议规范。您可以直接将本服务的地址填入到支持 OpenAI / Claude 的客户端中。\n\n### OpenAI Compatible\n*   **对话生成 (Chat Completions)**\n    *   **POST** `/v1/chat/completions`\n    *   **支持模型**: 任何映射后的模型 ID (如 `gpt-4o`, `gemini-1.5-pro`)\n    *   **兼容性**: 完全兼容 OpenAI 官方 Response 格式 (包括流式 SSE)。\n\n*   **图片生成 (Image Generation)**\n    *   **POST** `/v1/images/generations`\n    *   **支持模型**: `gemini-3-pro-image` (自动映射到 Imagen 3)\n    *   **参数扩展**: 支持 `size: \"1920x1080\"`, `quality: \"hd\"` 等高级参数。\n\n### Anthropic Compatible\n*   **Claude Messages**\n    *   **POST** `/v1/messages`\n    *   **用途**: 支持 Claude CLI (`claude`), Cursor, Cherry Studio 等客户端。\n    *   **特性**: 完整支持 Tool Use (工具调用) 和 Thinking (思维链) 模式。\n\n### Gemini Native\n*   **Google AI Studio**\n    *   **GET/POST** `/v1beta/models/*`\n    *   **用途**: 供使用 Google 官方 SDK (Python/Node.js) 的应用调用。\n"
  },
  {
    "path": "docs/CLAUDE_OPUS_46_INTEGRATION.md",
    "content": "# Claude Opus 4.6 Thinking Integration\n\n## Overview\nThis document describes the integration of Claude Opus 4.6 Thinking model into the Antigravity Proxy.\n\n## Changes Made\n\n### Backend (Rust)\n\n#### model_mapping.rs\n- Added direct mapping for `claude-opus-4-6-thinking`\n- Added alias mappings: `claude-opus-4-6`, `claude-opus-4-6-20260201`\n- Updated `normalize_to_standard_id()` to include the new model\n\n#### request.rs\n- Updated `should_enable_thinking_by_default()` to auto-enable thinking for Opus 4.6 models\n\n### Frontend (TypeScript/React)\n\n#### modelConfig.ts\n- Added `claude-opus-4-6-thinking` entry with `protectedKey: 'claude-opus'`\n\n#### useProxyModels.tsx\n- Added model to the models array with group `Claude 4.6`\n\n## Usage\n\n### API Request Example\n```json\n{\n  \"model\": \"claude-opus-4-6-thinking\",\n  \"messages\": [...]\n}\n```\n\n### Supported Aliases\n- `claude-opus-4-6-thinking` (canonical)\n- `claude-opus-4-6`\n- `claude-opus-4-6-20260201`\n\n## Notes\n- Thinking mode is auto-enabled for Opus 4.6 models\n- Quota protection uses the shared `claude-opus` key\n- No localization changes required (reuses existing keys)\n"
  },
  {
    "path": "docs/README.md",
    "content": "# Documentation index\n\nThis folder contains developer-focused documentation (architecture, implementation details, and validation steps).\n\n## Proxy\n- [`docs/proxy/auth.md`](proxy/auth.md) — proxy authorization modes, expected client behavior, and implementation pointers.\n- [`docs/proxy/accounts.md`](proxy/accounts.md) — account lifecycle in the proxy pool (including auto-disable on `invalid_grant`) and UI behavior.\n\n## z.ai (GLM) integration\n- [`docs/zai/implementation.md`](zai/implementation.md) — end-to-end “what’s implemented” and how to validate it.\n- [`docs/zai/mcp.md`](zai/mcp.md) — MCP endpoints exposed by the proxy (Search / Reader / Vision) and upstream behavior.\n- [`docs/zai/provider.md`](zai/provider.md) — Anthropic-compatible passthrough provider details and dispatch modes.\n- [`docs/zai/vision-mcp.md`](zai/vision-mcp.md) — built-in Vision MCP server protocol and tool implementations.\n- [`docs/zai/notes.md`](zai/notes.md) — research notes, constraints, and future follow-ups (budget/usage, additional endpoints).\n"
  },
  {
    "path": "docs/adaptive_mode_test_examples.md",
    "content": "# Claude 4.6 Adaptive Thinking Mode: 测试示例指南\n\n为了验证 Claude 4.6 Adaptive (自适应) Thinking 模式的集成效果，特别是 `effort` 参数的生效情况及 Token 限制的自动调整，请参考以下测试场景。\n\n## 1. 验证 Adaptive 模式激活与 Effort 控制\n\n此测试验证系统能否正确传递 `thinking: { type: \"adaptive\", effort: \"...\" }` 参数，并观察模型行为差异。\n\n### 前置条件\n*   确保使用支持 Adaptive Thinking 的模型 ID (如 `claude-opus-4-6-thinking` 或映射后的 ID)。\n*   在设置中将 \"Thinking Budget\" 模式设置为 **\"Adaptive (自适应)\"**。\n\n### 测试指令示例\n\n#### 场景 A: Low Effort (低强度思考)\n*   **配置**: 将 Effort 设置为 `Low`。\n*   **指令**: `写一个并通过 Rust 编译的 Hello World 程序。`\n*   **预期结果**:\n    *   Thinking 块应该比较简短，模型认为这是一个简单任务，不需要深入推理。\n    *   响应速度较快。\n\n#### 场景 B: High Effort (高强度思考)\n*   **配置**: 将 Effort 设置为 `High`。\n*   **指令**: `请详细分析 Rust 的 async/await 状态机生成机制，并对比 Go 的 Goroutine 调度模型。请通过思维链深入推导两者的内存开销差异。`\n*   **预期结果**:\n    *   Thinking 块应该非常长且详细（可能超过 5k tokens）。\n    *   模型会尝试进行深度的对比分析和推理。\n    *   **关键验证点**: 检查 Antigravity 日志，确认 `generationConfig` 中包含了 `thinkingConfig: { type: \"adaptive\", effort: \"high\" }`。\n\n---\n\n## 2. 验证多轮对话中的 Adaptive 状态维持\n\n验证在多轮对话中，Adaptive 模式是否能持续生效，且 Token 限制 (128k) 是否正常工作。\n\n### 场景：复杂算法设计迭代\n\n#### Round 1: 初始设计\n*   **指令**:\n    ```bash\n    claude \"设计一个分布式的高并发秒杀系统。需要考虑缓存一致性、库存防超卖、防刷接口等核心问题。请使用 High Effort 进行深度思考。\"\n    ```\n*   **验证点**:\n    *   生成了包含架构图和详细逻辑的设计文档。\n    *   Thinking 过程详细记录了对不同方案（如 Redis Lua vs 数据库悲观锁）的权衡。\n    *   验证响应头或日志中，确认 `maxOutputTokens` 被提升至 **128,000** (或更高)，以容纳长输出。\n\n#### Round 2: 方案挑战 (模拟用户反馈)\n*   **指令**:\n    ```bash\n    claude \"你的设计中，Redis 集群如果发生脑裂，如何保证库存数据的强一致性？请重新思考并修正方案。\"\n    ```\n*   **验证点**:\n    *   Thinking 块继续保持深度推理，分析 Redlock 或其他一致性算法的适用性。\n    *   **签名验证**: 确保多轮对话中 Thinking Block 的签名验证通过（无 `Invalid signature` 报错）。\n\n#### Round 3: 代码实现\n*   **指令**:\n    ```bash\n    claude \"请给出库存扣减核心逻辑的 Rust 代码实现。\"\n    ```\n*   **验证点**:\n    *   能生成符合之前设计思路的代码。\n    *   在高上下文压力下，系统是否自动触发了 Thinking 剥离（如果配置了动态剥离），或者能够正常携带完整历史继续生成。\n\n---\n\n## 3. 验证 Budget 模式与 Adaptive 模式的自动切换\n\n此测试验证当用户在“固定 Budget”与“Adaptive”模式间切换时，后端能否正确转换参数。\n\n### 测试流程\n1.  **设置为 Fixed Budget**: 在设置中选择 \"Custom\" 并设置 Budget 为 `16384`。\n    *   发送请求。\n    *   *验证*: 后端请求应只包含 `thinkingConfig: { budget: 16384 }`，**不应包含** `effort`。\n\n2.  **切换为 Adaptive**: 在设置中选择 \"Adaptive\" 并设置 Effort 为 `Medium`。\n    *   发送请求。\n    *   *验证*: 后端请求应只包含 `thinkingConfig: { type: \"adaptive\", effort: \"medium\" }`，**不应包含** `budget`。\n\n---\n\n## 4. 调试建议\n\n在运行上述测试时，建议开启 Debug 日志以观察参数传递：\n\n```bash\nRUST_LOG=debug npm run tauri dev\n```\n\n在日志中搜索关键词：\n*   `[Claude-Request]`: 查看转换后的请求体。\n*   `thinkingConfig`: 确认配置注入情况。\n*   `maxOutputTokens`: 确认 Token 上限调整情况。\n"
  },
  {
    "path": "docs/advanced_configuration.md",
    "content": "# 高级配置与实验性功能 (Advanced Configuration)\n\nAntigravity v3.3.35 引入了 `ExperimentalConfig`，这是一组默认开启的实验性功能开关，旨在提升系统的鲁棒性与兼容性。这些配置位于 `src-tauri/src/proxy/config.rs` 中，目前暂未暴露到 UI 界面。\n\n## 功能列表\n\n### 1. 双层签名缓存 (Signature Cache)\n*   **配置项**: `enable_signature_cache`\n*   **默认值**: `true`\n*   **说明**: 启用后，系统会缓存 `ToolUse ID` 与 `Thought Signature` 的映射关系。\n*   **作用**: 解决部分客户端（如 Claude Desktop CLI, Cherry Studio）在多轮对话中可能丢失历史 Tool Call 签名的问题。当上游 API 报错 \"Missing signature\" 时，系统可从缓存中自动恢复，避免对话中断。\n\n### 2. 工具循环自动恢复 (Tool Loop Recovery)\n*   **配置项**: `enable_tool_loop_recovery`\n*   **默认值**: `true`\n*   **说明**: 启用后，系统会实时监控对话状态，检测“死循环”模式。\n*   **触发条件**: 检测到连续的 `ToolUse` -> `ToolResult` 循环，且 `Assistant` 消息中缺少 `Thinking` 块（通常因签名校验失败被 stripping）。\n*   **行为**: 自动注入合成消息（`Assistant: Tool execution completed.` -> `User: Proceed.`）来打破死循环，强制模型进入下一轮思考。\n\n### 3. 跨模型兼容性检查 (Cross-Model Checks)\n*   **配置项**: `enable_cross_model_checks`\n*   **默认值**: `true`\n*   **说明**: 防止在同一会话中切换不同系列模型（如 Claude -> Gemini）时引发的签名错误。\n*   **作用**: 当检测到历史消息中的签名属于不兼容的模型家族（如 `claude-3-5` vs `gemini-2.0`）时，系统会自动丢弃旧签名，防止 API 拒绝请求。\n\n## 自定义配置\n\n目前这些配置项可通过修改 `src-tauri/src/proxy/config.rs` 中的 `default_true` 默认值来调整，或者等待未来版本集成到 \"Settings -> Advanced\" 界面。\n\n```rust\n// src-tauri/src/proxy/config.rs\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ExperimentalConfig {\n    #[serde(default = \"default_true\")]\n    pub enable_signature_cache: bool,\n    // ...\n}\n```\n"
  },
  {
    "path": "docs/client_test_examples.md",
    "content": "# 稳定性与搜索功能：测试示例指南\n\n为了验证近期对 API 400 错误及“搜索文件错误”的修复效果，您可以在 Claude CLI (Claude Code) 中运行以下指令进行实测。\n\n## 1. 验证搜索工具自愈 (Grep/Glob Fix)\n\n针对之前的 \"Error searching files\" 问题，这些指令将触发 `Grep` 和 `Glob` 工具调用，并验证参数映射是否正确。\n\n### 测试指令示例\n*   **指令 A**：`在当前目录中搜索包含 \"fn handle_messages\" 的 Rust 文件。`\n    *   *验证点*：检查代理是否能正确将 `query` 映射为 `pattern`，并注入默认的 `path: \".\"`。\n*   **指令 B**：`列出 src-tauri 目录下所有 .rs 文件。`\n    *   *验证点*：验证 `Glob` 工具名是否被正确识别，且路径过滤逻辑正常。\n\n---\n\n## 2. 验证协议顺序与签名稳定性 (Thinking/Signature Fix)\n\n针对之前的 `Found 'text'` 和 `Invalid signature` 400 错误。\n\n### 测试指令示例\n*   **指令 A（推理+搜索）**：`分析本项目中处理云端请求的核心逻辑，按调用顺序总结，并给出关键代码行的 Grep 搜索证据。`\n    *   *验证点*：验证在“思维 -> 工具调用 -> 结果 -> 继续思维”循环中，块顺序是否正确。\n*   **指令 B（历史记录重试）**：在长对话中频繁切换模型，观察系统是否在 400 报错时静默修复签名并重试。\n\n---\n\n## 附录：深度错误对照与修复方案\n\n| 错误类别 | 具体报错特征码 (Error Detail) | 代理采取的修复/应对逻辑 |\n| :--- | :--- | :--- |\n| **消息流顺序违规** | `If an assistant message contains any thinking blocks... Found 'text'.` | **已修复**：`streaming.rs` 不再允许在文字块之后非法追加思维块。 |\n| **思维签名不匹配** | `Invalid signature in thinking block` | **已修复**：优先保留原始名称以保护 Google 后端签名校验。 |\n| **思维签名缺失** | `Function call is missing a thought_signature` | **已修复**：自动注入 `skip_thought_signature_validator` 占位符。 |\n| **非法缓存标记** | `thinking.cache_control: Extra inputs are not permitted` | **已修复**：全局剔除历史消息中的 `cache_control` 标记。 |\n| **Plan Mode 报错**| `EnterPlanMode tool call: InputValidationError: Extra inputs are not permitted` | **已修复**：`streaming.rs` 强制清空工具参数以符合官方无参协议。 |\n| **连续 User 消息**| `Consecutive user messages are not allowed` | **已修复**：`merge_consecutive_messages` 自动合并相邻同角色消息。 |\n\n---\n\n## 3. 验证 Claude Code Plan Mode 与角色交替 (Issue #813)\n\n针对 Plan Mode 切换导致的协议报错问题。\n\n### A. 验证 Plan Mode 激活 (UI 状态)\n*   **指令**：`进入 Plan Mode 调研 src-tauri 的目录结构。`\n*   **预期结果**：\n    *   终端左下角应立即出现蓝色的 **`plan mode on`** 标签。\n    *   日志中应看到 `[Streaming] Tool Call: 'EnterPlanMode' Args: {}`。\n\n### B. 验证角色交替自愈 (Consecutive Messages)\n*   **指令**：`在 Plan Mode 下帮我分析 proxy/mappers/claude/request.rs 的逻辑，然后退出 Plan Mode 并给出一个简要总结。`\n*   **预期结果**：\n    *   模型切换模式（如从 Plan 到 Code）时不会因“连续两条 User 消息”而报 400 错误。\n    *   日志中会体现 `merge_consecutive_messages` 的合并动作。\n\n---\n\n## 4. QuotaData 字段逻辑解析\n\n设置页面中的“账号管理”列表，下方的进度条数据来源于 `QuotaData`。系统会在请求前检查账号配额，并在触及阈值时自动轮换。\n\n---\n\n## 调试建议\n```bash\nRUST_LOG=debug npm run tauri dev\n```\n在日志中搜索 `[Claude-Request]`，关注消息角色的排列顺序。\n\n---\n\n## 5. 验证 Thinking 签名持久化与重启容错 (Proxy Restart Test)\n\n此测试模拟代理服务主要逻辑：验证当代理重启（内存签名缓存丢失）后，携带旧签名的历史消息是否会导致 400 错误。这是复现 `Invalid signature` 最有效的方法。\n\n### 测试流程\n1.  **生成 Thinking (Step 1)**:\n    *   **指令**：`详细分析 proxy/mappers/claude/request.rs 的代码结构，特别是它是如何处理 Thinking Block 的。请展示思维过程。`\n    *   *状态*：Claude CLI 会接收到包含 Thinking 和 Signature 的响应。\n\n2.  **模拟环境变更 (Step 2)**:\n    *   **动作**：**保持当前 Claude CLI 会话不关闭**。\n    *   **动作**：在另一个终端完全重启 Antigravity (或 `npm run tauri dev`)。\n    *   *原理*：重启会清空代理内存中的“签名白名单”，这意味着 Step 1 中下发的签名现在对代理来说是“未知/不可信”的。\n\n3.  **触发历史重放 (Step 3)**:\n    *   **指令**：`根据上面的分析，总结一下签名验证的核心逻辑。`\n    *   *原理*：CLI 会将 Step 1 中的 Thinking Block + Signature 作为历史记录发送给重启后的代理。\n\n### 预期结果 (验证修复)\n*   **如果不通过**：报错 `Invalid signature in thinking block` (因为代理无法验证该签名，直接透传给了 Google，被 Google 拒收)。\n*   **如果通过 (当前版本)**：代理发现签名不在内存缓存中，**自动触发降级逻辑**（剥离 Thinking Block 或作为纯文本发送），对话正常继续，无报错。\n\n---\n\n## 6. 验证动态思维剥离 (Dynamic Thinking Stripping)\n\n此测试验证系统能否在**高 Context 压力**或**签名失效**场景下，自动剥离无用的历史 Thinking Block，从而解决 \"Prompt is too long\" 和 \"Invalid signature\" 错误。\n\n### 前置条件\n*   开启 Debug 日志: `RUST_LOG=debug npm run tauri dev`\n*   确保使用支持 Thinking 的模型 (如 `claude-3-7-sonnet` 或映射后的 `gemini-2.0-flash-thinking-exp`)\n\n### 验证场景 A: 模拟超长 Context 压力 (Simulate High Load)\n\n此场景验证当对话历史接近 Token 上限时，系统是否会自动清理旧的 Thinking。\n\n1.  **构造长对话**:\n    *   **方法 1 (自动生成)**: 运行 `docs/generate_long_payload.sh` 生成 2MB 测试文件。\n        ```bash\n        chmod +x docs/generate_long_payload.sh\n        ./docs/generate_long_payload.sh\n        cat docs/long_context_payload.txt | pbcopy\n        ```\n        然后将剪贴板内容多次粘贴给 Claude，直到感知到显著延迟或收到上下文警告。\n\n    *   **方法 2 (Deep Thinking 诱导 - 持续施压)**:\n        以下 Prompts 经过设计，能诱导模型进行极长的思维推理。可以轮流发送：\n\n        > **Round 1 (History)**: \"Please analyze the history of computing from the abacus to quantum computers. For every major milestone (at least 20), perform a deep 'thinking' block simulating the thought process of the inventors. Detailed thinking is required. Aim for maximum output tokens.\"\n\n        > **Round 2 (Math/Logic)**: \"Prove the Riemann Hypothesis. Just kidding. But please perform a deep, step-by-step derivation of the Navier-Stokes existence and smoothness problem's core challenges. Explore 10 different mathematical approaches, evaluating the pros and cons of each in extreme detail.\"\n\n        > **Round 3 (System Architecture)**: \"Design a distributed system capable of handling 100 billion requests per second. Detail the consensus execution flow (Paxos/Raft) for a single transaction across 5000 nodes. Simulate the network partition handling logic in your 'thinking' process for at least 50 failure scenarios.\"\n\n        > **Round 4 (Literature)**: \"Write a recursive story where the protagonist is a recursive function. The story must nest at least 20 levels deep, and for each level, you must 'think' about the symbolic meaning of that recursion depth before writing the narrative part.\"\n\n2.  **观察日志**:\n    *   在终端搜索 `[ContextManager]`。\n    *   **预期日志**:\n        ```\n        [INFO] [ContextManager] Context pressure: 95.0% (1900000 / 2000000), Strategy: Aggressive => Purifying history\n        [DEBUG] History purified successfully\n        ```\n\n3.  **验证结果**:\n    *   Request 成功发送给 Gemini，没有报 \"Prompt is too long\"。\n    *   HTTP 响应头包含 `X-Context-Purified: true`。\n    *   Claude CLI 用户侧无感（历史记录仍在 CLI 本地显示，但服务端已净化）。\n\n### 验证场景 B: 签名失效免疫 (Signature Immunity via Stripping)\n\n此场景验证即使不触发重试逻辑，高负载下的主动剥离也能顺带解决签名问题。\n\n1.  **生成带签名的 Thinking**:\n    *   **指令**: `思考一下 Rust 的所有权机制，写 500 字。`\n\n2.  **重启 Proxy 且注入虚假负载 (可选)**:\n    *   重启代理（清空签名缓存）。\n    *   继续对话。此时带有旧签名的 Thinking 会被发送给代理。\n\n3.  **预期结果**:\n    *   如果上下文压力较大触发了 Stripping，或者因签名报错触发了 RetriedWithoutThinking，系统会剥离 Thinking Block。\n    *   **关键点**: 一旦 Thinking Block 被剥离，`thought_signature` 也会随之消失。\n    *   Gemini 收到的是纯文本历史，**绝不会报 Invalid Signature**。\n\n---\n\n## 7. OpenCode (Claude Code CLI) 多协议接入测试\n\n**Antigravity 已全面支持 OpenCode 的多协议接入**，彻底解决了 `AI_TypeValidationError` 等兼容性问题。您可以根据需要选择以下任一方式接入。\n\n### 端点配置表\n\n| 协议类型 | Base URL (Antigravity) | 对应的 OpenCode Provider | 备注 |\n| :--- | :--- | :--- | :--- |\n| **Anthropic (原生)** | `http://localhost:8045/v1` | `anthropic` | **推荐**。支持 Thinking、工具调用、Artifacts 预览。 |\n| **OpenAI (标准)** | `http://localhost:8045/v1` | `openai` | 支持通用 OpenAI 客户端逻辑。 |\n| **OA-Compatible** | `http://localhost:8045/v1` | `openai-compatible` | 适用于强制指定非标准模型名称的场景。 |\n| **Google Gemini** | `http://localhost:8045/v1` | `gemini` | 直接使用 Gemini 协议，支持 Google 原生 SDK 特性。 |\n\n### A. 方式 1：Anthropic 原生协议 (推荐)\n\n此方式能获得最佳的 Claude 原生体验，支持 Thinking 签名保护与 Beta 特性。\n\n1.  **配置**:\n    ```bash\n    # 设置 Base URL (注意：OpenCode 的 anthropic provider 有时需要完整路径)\n    export ANTHROPIC_BASE_URL=\"http://localhost:8045/v1\"\n    # 设置 API Key (Antigravity 的密钥)\n    export ANTHROPIC_API_KEY=\"sk-antigravity-key\"\n    ```\n\n2.  **测试指令**:\n    ```bash\n    claude \"请使用思维链 (Thinking) 分析当前目录下的 Cargo.toml 依赖结构。\"\n    ```\n\n3.  **验证点**:\n    *   **Thinking**: 是否能看到蓝色的思维块输出？\n    *   **签名**: 检查 Antigravity 日志，应显示 `Cached signature to session ... [FIFO: true]`。\n    *   **无错**: 全程无 `Invalid signature` 报错。\n\n### B. 方式 2：OpenAI 协议 (含 Compatible)\n\n适用于习惯使用 OpenAI 生态或需要特定模型映射的用户。\n\n1.  **配置**:\n    ```bash\n    # 设置 Base URL\n    export OPENAI_BASE_URL=\"http://localhost:8045/v1\"\n    export OPENAI_API_KEY=\"sk-antigravity-key\"\n    ```\n\n2.  **启动 OpenCode**:\n    ```bash\n    claude --provider openai --model gemini-2.0-flash\n    # 或者使用 compatible 模式\n    claude --provider openai-compatible --model gemini-2.0-flash\n    ```\n\n3.  **验证点**:\n    *   **JSON 错误**: 尝试故意断网或使用无效 Key，OpenCode 应显示友好的 JSON 错误信息（如 `{\"error\": {\"message\": \"...\"}}`），而不再是 Crash。\n    *   **非流式兼容**: OpenCode 的某些工具调用可能会使用非流式请求，验证其是否能正常解析 JSON 响应。\n\n### C. 方式 3：Google Gemini 原生协议\n\nAntigravity v4.1.4 新增支持。\n\n1.  **配置**:\n    ```bash\n    export GEMINI_API_KEY=\"sk-antigravity-key\"\n    # 如果 OpenCode 支持 GEMINI_BASE_URL (通常需要反代工具如 cloudflared 或修改 config):\n    export GEMINI_BASE_URL=\"http://localhost:8045/v1\"\n    ```\n\n2.  **验证点**:\n    *   **适配器检测**: Antigravity 日志应显示 `[Gemini] Client Adapter detected`。\n    *   **Let It Crash**: 当遇到 403/404 错误时，响应应立即返回，而不是让 OpenCode 挂起等待重试。\n\n### D. 常见问题排查\n\n*   **Q: 报错 `AI_TypeValidationError`？**\n    *   **A**: 请确保升级 Antigravity 到 v4.1.2+。旧版本返回的错误格式（纯文本）无法通过 OpenCode 的 Zod 校验。\n\n*   **Q: Thinking 块显示为 `[Redacted]` 或直接消失？**\n    *   **A**: 这是正常现象。为了保护 Google 的签名不被破坏，Antigravity 可能会在特定情况下（如高上下文压力或签名验证失败时）主动剥离思维块。只要对话能继续，说明 \"Dynamic Stripping\" 机制正在工作。\n\n---\n\n## 8. 多轮连续对话压力测试 (Continuous Conversation Stress Test)\n\n此测试旨在验证高频、多轮交互下的 **Signed Session Stability**（签名会话稳定性）。请在一个 OpenCode 会话中**连续**执行以下步骤，不要重启或清空上下文。\n\n### 场景：Rust 项目重构实战\n\n#### 第 1 轮：深度代码审查 (Initial Analysis)\n*   **指令**:\n    ```bash\n    claude \"请详细审查 src-tauri/src/proxy/handlers/claude.rs 文件。关注其中的 handle_messages 函数，分析它是如何处理 Beta Headers 注入的。请使用思维链列出你的分析步骤。\"\n    ```\n*   **验证点**:\n    *   必须看到通过 `ClientAdapter` 注入 Header 的逻辑分析。\n    *   响应包含完整的 Thinking Block。\n\n#### 第 2 轮：模拟修改建议 (Refactoring Proposal)\n*   **指令**:\n    ```bash\n    claude \"基于你的分析，如果我要新增一个名为 'CherryStudio' 的适配器，应该在哪些文件中进行修改？请给出一个具体的实现计划，不要直接修改文件。\"\n    ```\n*   **验证点**:\n    *   Claude 能准确引用第 1 轮的上下文（证明 Session ID 传递正常）。\n    *   Thinking 签名未丢失（若报错 `Invalid signature`，说明签名缓存失效）。\n\n#### 第 3 轮：高频并发测试 (Concurrent Simulation)\n*   **背景**: 在此轮中，我们模拟快速连续的追问，测试 FIFO 签名队列的鲁棒性。\n*   **指令 (请连续快速执行 3 次)**:\n    ```bash\n    # 快速输入以下简短指令，模拟用户急促的追问\n    claude \"刚才的计划中，StreamingState 需要改吗？\"\n    claude \"那 ClientAdapter trait 呢？\"\n    claude \"Cargo.toml 需要加依赖吗？\"\n    ```\n*   **验证点**:\n    *   **乱序容忍**: 即使响应到达顺序可能与请求不一致，客户端不应崩溃。\n    *   **队列深度**: Antigravity 日志中应显示 Signature Cache 正常更新，未出现覆盖导致的前序签名失效。\n\n#### 第 4 轮：长文本生成 (Output Token Limit)\n*   **指令**:\n    ```bash\n    claude \"请为 ClientAdapter trait 编写一份详尽的开发者文档（Markdown格式），包含所有方法的详细注释、三个不同场景的最佳实践示例代码。字数要求 2000 字以上。\"\n    ```\n*   **验证点**:\n    *   验证在大输出量下，SSE 流是否稳定。\n    *   观察日志中是否触发了 `ContextManager` 的主动纯化（Purify），以及签名是否被安全剥离。\n"
  },
  {
    "path": "docs/fix-opus-ultra-priority.md",
    "content": "# 修复 Opus 4.6 调用报错 & UserToken 显示优化\n\n## 问题\n\n号池混着 Pro 和 Ultra 账号。Pro 没有 Opus 4.6 权限，Ultra 有。\n\n之前轮询按配额高低选账号，不管订阅等级。用户调 Opus 4.6 时，系统可能选到 Pro 账号，直接报错。\n\n## 改动\n\n### 1. Ultra 优先调度\n\n调 Opus 4.6/4.5 时，先按订阅等级排序：\n\n```\nUltra > Pro > Free\n```\n\n同等级再按配额排。其他模型还是老逻辑，配额优先。\n\n匹配规则：模型名包含 `claude-opus-4-6`、`claude-opus-4-5` 或 `opus` 就走 Ultra 优先。\n\n### 2. UserToken 编辑数据不显示\n\n点编辑 Token 时，IP 限制和宵禁时间显示空的。\n\n问题：\n- 前端传参用 `undefined`，Rust 需要 `null`\n- 读取用 `||`，0 和空字符串被吃掉了，改成 `??`\n\n### 3. 自定义过期时间\n\n创建 Token 多了个 Custom 选项，选日期时间，精确到小时。\n\n## 文件\n\n```\nsrc-tauri/src/proxy/token_manager.rs      # 排序逻辑\nsrc-tauri/src/proxy/tests/mod.rs          # 测试模块\nsrc-tauri/src/proxy/tests/ultra_priority_tests.rs  # Ultra 优先测试\nsrc-tauri/src/commands/user_token.rs      # 自定义过期参数\nsrc-tauri/src/modules/user_token_db.rs    # 数据库\nsrc/pages/UserToken.tsx                   # 前端\n```\n\n## 验证\n\n1. 调 Opus 4.6，看日志确认走的是 Ultra 账号\n2. 创建 Token 设置 IP 限制和宵禁，编辑时确认数据正常回显\n"
  },
  {
    "path": "docs/fix_claude_code_tool_use.md",
    "content": "# 针对 Claude Code \"Field required\" 错误的修复方案文档\n\n## 1. 问题背景\n在使用 Claude Code CLI 并通过 Antigravity-Manager 代理时，经常会出现以下报错：\n`messages.X.content.0.text.text: Field required`\n\n**根本原因**：\nClaude Code 在进行工具调用（Tool Use）时，发送或接收的消息块中可能包含空的 `text` 字段（例如 `{\"text\": \"\"}` 或 `{\"text\": \"  \"}`）。\n- **Google Gemini API**：严禁在请求的 `parts` 中包含空的文本块。\n- **Anthropic 协议转换**：在转换过程中，如果未能对空字符串进行 `trim()` 和有效性校验，就会导致上游 API 拒绝请求。\n\n## 2. 针对性修改方案\n\n本次修复主要涉及 `src-tauri/src/proxy/mappers/claude/` 目录下的两个核心文件：\n\n### A. 请求端过滤 (src-tauri/src/proxy/mappers/claude/request.rs)\n\n**修改说明**：在将 Claude 消息转换为 Google 格式时，对所有 `ContentBlock::Text` 和降级的思维块（Thinking）进行严格过滤。\n\n*   **修改点 1 (`build_contents` 函数)**：\n    *   **现状**：仅检查了是否等于 `(no content)` 占位符。\n    *   **修复**：引入 `!text.trim().is_empty()`，确保所有发送给 Google 的文本块都包含实际内容。\n*   **修改点 2 (思维块降级)**：\n    *   **现状**：当思维模式被禁用或块顺序异常时，思维块会被转换为 `text` 块。\n    *   **修复**：确保降级过程中排除掉仅含空格的文本，并对内容进行 `trim()` 处理。\n\n### B. 响应端优化 (src-tauri/src/proxy/mappers/claude/streaming.rs)\n\n**修改说明**：优化流式响应转换逻辑，防止向客户端发送诱发状态机异常的空块。\n\n*   **修改点 1 (`emit_finish` 函数)**：\n    *   **现状**：在处理 Web 搜索（Grounding）结果时，会初始化一个 `text` 块。\n    *   **修复**：只有当搜索摘要 `grounding_text.trim()` 确实非空时，才允许发送该内容块分片。\n*   **修改点 2 (`process_text` 处理器)**：\n    *   **修复**：增强了对 `trailing_signature`（签名暂存）场景下的逻辑鲁棒性，确保不会产生无意义的“幽灵”文本块。\n\n## 3. 已应用的代码详情 (Git Diff 摘要)\n\n### Request Mapper:\n```rust\n// 修复前\nif text != \"(no content)\" { ... }\n\n// 修复后\nif text != \"(no content)\" && !text.trim().is_empty() { ... }\n```\n\n### Streaming State:\n```rust\n// 修复前\nif !grounding_text.is_empty() { ... }\n\n// 修复后\nlet trimmed_grounding = grounding_text.trim();\nif !trimmed_grounding.is_empty() { ... }\n```\n\n## 4. 验证与部署建议\n\n1.  **分步确认**：通过代理日志观察，确保不再出现 `messages.X.text: Field required` 的 HTTP 400 警告。\n2.  **连续对话测试**：进行一次涉及文件读写的复杂任务（例如 `claude fix bug`），确认在 `tool_use` 返回后，Claude Code 能正常发送下一轮请求。\n3.  **编译命令**：\n    ```bash\n    npm run tauri dev\n    ```\n\n---\n*文档由 Antigravity AI 助手生成，用于记录 fix/claude-code-tool-use-empty-text 分支的变更细节。*\n"
  },
  {
    "path": "docs/gemini-3-image-guide.md",
    "content": "# Gemini 3 Pro Image 模型调用指南\n\n本文档详细说明了在 **Antigravity** 项目中调用 Google `gemini-3-pro-image` (Imagen 3) 模型的方法。本项目已对该模型进行了 OpenAI 协议的完全兼容封装，并扩展支持了原生的摄影宽高比、人物生成安全策略，以及**图生图 (Image-to-Image)** 功能。\n\n## 1. 基础信息\n\n*   **模型 ID**: `gemini-3-pro-image` (支持别名 `gemini-3-pro-image-preview`)\n*   **接口路径**:\n    *   `/v1/images/generations` (文生图 Text-to-Image)\n    *   `/v1/images/edits` (图生图 Image-to-Image / 编辑)\n    *   `/v1/chat/completions` (兼容模式)\n*   **底层模型**: Google Imagen 3 (Gemini Native)\n\n---\n\n## 2. 文生图 (Text-to-Image)\n\n调用 `/v1/images/generations`，支持以下参数：\n\n### 2.1 画幅与宽高比 (Size / Aspect Ratio)\n\n`size` 参数支持两种输入格式，系统会自动解析并映射到 Gemini 支持的标准比例：\n\n1.  **直接输入比例 (推荐)**：如 `\"16:9\"`, `\"4:3\"`, `\"1:1\"`。这种方式最直观，且 100% 准确映射。\n2.  **输入分辨率 (兼容)**：如 `\"1920x1080\"`, `\"1024x1024\"`。系统会自动计算其宽高比（例如 1920/1080 ≈ 1.77），并将其归一化为最接近的标准比例（16:9）。\n\n**⚠️ 重要说明**：Gemini (Imagen 3) **不支持自定义任意像素大小**。\n无论您在 `size` 中输入 `\"1920x1080\"` 还是 `\"16:9\"`，最终生成的**实际物理分辨率**仅由以下两个因素决定：\n1.  **宽高比**（由 `size` 解析得出）\n2.  **画质等级**（由 `quality` 参数决定：`1k`/`2k`/`4k`）\n\n*示例：输入 `size: \"1920x1080\"` (16:9) 且 `quality: \"standard\"` (1k)，实际生成的图片尺寸为 **1376x768** (16:9 下的 1K 分辨率)，而不是 1920x1080。*\n\n| 目标比例 | 适用场景 | `size` 参数示例 (分辨率) | 备注 |\n| :--- | :--- | :--- | :--- |\n| **16:9** | 宽屏、电影感 | `1920x1080`, `1280x720` | 标准宽屏 |\n| **9:16** | 手机壁纸、Stories | `1080x1920`, `720x1280` | 竖屏全屏 |\n| **1:1** | 头像、Instagram | `1024x1024` | 默认比例 |\n| **4:3** | 传统摄影、显示器 | `1024x768`, `800x600` | |\n| **3:4** | 纵向摄影 | `768x1024`, `600x800` | |\n| **21:9** | 超宽屏、电影 | `2560x1080` | 电影银幕 |\n| **3:2** | **[新增]** 全画幅单反 | `1500x1000` | 经典摄影比例 |\n| **2:3** | **[新增]** 竖构图摄影 | `1000x1500` | 海报、立绘 |\n| **5:4** | **[新增]** 大画幅 | `1280x1024` | 艺术摄影 |\n| **4:5** | **[新增]** 社交媒体竖图 | `1024x1280` | Ins 最佳展示比例 |\n\n> **提示**: 您不需要精确匹配像素值，只需宽高比接近上述比例（容差 0.05）即可自动识别。\n\n### 2.2 画质与分辨率 (Quality)\n\n通过 `quality` 参数控制生成的精细度。\n\n| 参数值 (`quality`) | 对应 Gemini 设置 | 说明 |\n| :--- | :--- | :--- |\n| `standard` / `1k` | Image Size: `1K` | 生成速度快，适合快速验证 (默认) |\n| `medium` / `2k` | Image Size: `2K` | 平衡质量与速度 |\n| `hd` / `4k` | Image Size: `4K` | **极高画质**，细节最丰富，耗时稍长 |\n\n#### 分辨率对照表 (Gemini 3 Pro Image)\n\n| 宽高比 | 1K 分辨率 (Standard) | 2K 分辨率 (Medium) | 4K 分辨率 (HD) |\n| :--- | :--- | :--- | :--- |\n| **1:1** | 1024x1024 | 2048x2048 | 4096x4096 |\n| **2:3** | 848x1264 | 1696x2528 | 3392x5056 |\n| **3:2** | 1264x848 | 2528x1696 | 5056x3392 |\n| **3:4** | 896x1200 | 1792x2400 | 3584x4800 |\n| **4:3** | 1200x896 | 2400x1792 | 4800x3584 |\n| **4:5** | 928x1152 | 1856x2304 | 3712x4608 |\n| **5:4** | 1152x928 | 2304x1856 | 4608x3712 |\n| **9:16** | 768x1376 | 1536x2752 | 3072x5504 |\n| **16:9** | 1376x768 | 2752x1536 | 5504x3072 |\n| **21:9** | 1584x672 | 3168x1344 | 6336x2688 |\n\n### 调用示例 (Python)\n\n```python\nimport requests\n\nurl = \"http://localhost:8045/v1/images/generations\"\nheaders = {\n    \"Content-Type\": \"application/json\",\n    \"Authorization\": \"Bearer <token>\"\n}\ndata = {\n    \"model\": \"gemini-3-pro-image\",\n    \"prompt\": \"A futuristic city with flying cars, cinematic lighting, 8k\",\n    \"size\": \"16:9\",\n    \"quality\": \"hd\",\n    \"n\": 1\n}\n\nresponse = requests.post(url, headers=headers, json=data)\nprint(response.json())\n```\n\n## 3. 图生图 (Image-to-Image / Edits) 🔥 [新增]\n\n调用 `/v1/images/edits` 接口，支持通过参考图生成。\n\n*   **Content-Type**: `multipart/form-data`\n*   **支持多图**: 可同时上传多张参考图。\n\n### 表单字段说明\n\n| 字段名 | 类型 | 必填 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `prompt` | String | 是 | 文本提示词 |\n| `image1`...`imageN` | File | 是 | **参考图文件**。支持 `image1`, `image2` 等任意名称的文件字段 (非 standard `image` 或 `mask`)。 |\n| `image` | File | 否 | (兼容 OpenAI 标准) 主图像 |\n| `mask` | File | 否 | (兼容 OpenAI 标准) 遮罩图像 |\n| `aspect_ratio` | String | 否 | 显式指定比例，如 `\"16:9\"` (优先级高于 `size`) |\n| `image_size` | String | 否 | 显式指定分辨率，如 `\"2K\"`, `\"4K\"` (优先级高于 `quality`) |\n| `style` | String | 否 | 风格描述，会自动追加到 Prompt 中 |\n| `n` | Integer | 否 | 生成数量 (默认 1) |\n| `model` | String | 否 | 模型名称 (默认 `gemini-3-pro-image`) |\n\n### 调用示例 (Python)\n\n```python\nimport requests\n\nurl = \"http://localhost:8045/v1/images/edits\"\nheaders = {\n    \"Authorization\": \"Bearer <token>\"\n}\n# 支持多张参考图 (image1, image2, ...)\nfiles = {\n    \"image1\": open(\"/path/to/reference_1.jpg\", \"rb\"),\n    \"image2\": open(\"/path/to/reference_2.jpg\", \"rb\")\n}\ndata = {\n    \"prompt\": \"A cyberpunk city street based on this layout\",\n    \"aspect_ratio\": \"16:9\",\n    \"image_size\": \"4K\",\n    \"style\": \"watercolor\"\n}\n\nresponse = requests.post(url, headers=headers, files=files, data=data)\nprint(response.json())\n```\n\n---\n\n## 4. 后缀魔法 (Magic Suffix)\n\n除了标准的 JSON 参数外，本项目还支持在 **模型名称** 中直接指定参数（方便在不支持自定义参数的客户端中使用）。\n\n**格式**: `gemini-3-pro-image-{比例}-{画质}`\n\n*   **比例后缀**: `-16x9`, `-9x16`, `-4x3`, `-3x4`, `-3x2`, `-2x3` 等。\n*   **画质后缀**: `-4k` (对应 hd), `-2k` (对应 medium)。\n\n**示例**:\n使用模型名 `gemini-3-pro-image-16x9-4k` 等同于：\n*   `size`: \"1920x1080\" (16:9)\n*   `quality`: \"hd\"\n\n> **注意**: 如果 JSON Body 中显式传递了 `size` 或 `quality`，Body 中的参数优先级 **高于** 模型名后缀。\n\n---\n\n## 5. 常见问题\n\n1.  **Q: 为什么我设置了 `size: \"1234x5678\"` 但生成的图片比例不对？**\n    *   **A**: 系统会将您输入的尺寸归一化为 Gemini 支持的 10 种标准比例（见 2.1 节）。如果您的比例非常特殊且不匹配任何标准比例（容差 > 0.05），系统将回退到默认的 **1:1**。建议直接使用示例中的分辨率。\n\n2.  **Q: 支持一次生成多张图片吗？**\n    *   **A**: 支持。虽然 Gemini 上游单次请求限制生成 1 张，但 Antigravity 代理层会自动并发处理 `n` 参数。例如设置 `n: 4`，系统会并行发起 4 个请求并合并结果返回。\n\n3.  **Q: `person_generation` 参数报错？**\n    *   **A**: 请确保该参数位于 JSON 的**根层级**（与 `prompt`, `model` 同级），而不是嵌套在其他字段中。支持 `snake_case` (`person_generation`) 和 `camelCase` (`personGeneration`)。\n"
  },
  {
    "path": "docs/model-remapping-logic.md",
    "content": "# 模型重映射逻辑（当前实现）\n\n最后更新：2026-03-02\n\n本文描述当前代理中的模型重映射链路（含 Gemini 3/3.1 Pro 调整后的行为）。\n\n## 1）整体流程\n\n无论是 OpenAI 协议还是 Gemini 原生协议，请求模型都会经过两段处理：\n\n1. 静态路由解析（全局规则）：\n   - 在选账号前执行一次。\n   - 代码：`src-tauri/src/proxy/common/model_mapping.rs` 的 `resolve_model_route`。\n2. 动态账号感知改写（条件回退）：\n   - 在选中账号后执行。\n   - 代码：`src-tauri/src/proxy/token_manager.rs` 的 `resolve_dynamic_model_for_account`。\n\n使用该流程的入口：\n- `src-tauri/src/proxy/handlers/openai.rs`\n- `src-tauri/src/proxy/handlers/gemini.rs`\n\n## 2）静态路由优先级\n\n`resolve_model_route(original_model, custom_mapping)` 的优先级从高到低为：\n\n1. 官方动态淘汰转发规则：\n   - `DYNAMIC_MODEL_FORWARDING_RULES`\n2. 用户自定义精确映射：\n   - `custom_mapping[original_model]`\n3. 用户自定义通配符映射：\n   - 按“非 `*` 字符数”比较，越具体优先级越高\n4. 系统内置默认映射：\n   - `map_claude_model_to_gemini`\n\n都不命中时，模型名原样透传。\n\n## 3）当前 Gemini Pro 内置映射策略\n\n当前策略是：具体模型 ID 直接透传；只有泛别名会归一化。\n\n具体 ID（不做跨版本强制改写）：\n- `gemini-3-pro-high -> gemini-3-pro-high`\n- `gemini-3-pro-low -> gemini-3-pro-low`\n- `gemini-3-pro-preview -> gemini-3-pro-preview`\n- `gemini-3.1-pro-high -> gemini-3.1-pro-high`\n- `gemini-3.1-pro-low -> gemini-3.1-pro-low`\n- `gemini-3.1-pro-preview -> gemini-3.1-pro-preview`\n\n泛别名（仍映射到 preview 入口）：\n- `gemini-3-pro -> gemini-3-pro-preview`\n- `gemini-3.1-pro -> gemini-3.1-pro-preview`\n\n代码位置：\n- `src-tauri/src/proxy/common/model_mapping.rs`\n\n## 4）动态账号感知改写（仅在需要时触发）\n\n选中账号后，系统会读取该账号本地 quota 里的可用模型，判断当前模型是否可用。\n\n行为如下：\n\n1. 读取账号 JSON：`quota.models[*].name`。\n2. 仅针对 Gemini 3/3.1 Pro 家族构造候选回退列表。\n3. 候选顺序：\n   - 先尝试当前模型\n   - 再按预设顺序尝试同家族其他兼容模型\n4. 选中第一个在账号可用集合里存在的模型。\n5. 若都不命中，则保持当前模型不变。\n\n关键点：\n- 如果请求模型本身可用，不会发生重映射。\n- 只有请求模型不可用且存在兼容候选时，才会重映射。\n\n代码位置：\n- `src-tauri/src/proxy/token_manager.rs`\n  - `get_available_models_from_json`\n  - `build_dynamic_model_candidates`\n  - `resolve_dynamic_model_for_account`\n\n## 5）日志观测点\n\n可通过日志判断每一步是否触发：\n\n- 静态映射日志：\n  - `[Router] 系统默认映射: <original> -> <mapped>`\n- 动态改写日志：\n  - `[Dynamic-Model-Rewrite] account=<id> <from> -> <to>`\n\n如果某次请求没有出现 `Dynamic-Model-Rewrite`，说明该账号直接使用了当前模型。\n\n## 6）示例\n\n示例 A（不改写）：\n- 请求：`gemini-3-pro-high`\n- 账号可用模型包含：`gemini-3-pro-high`\n- 最终上游模型：`gemini-3-pro-high`\n\n示例 B（发生回退改写）：\n- 请求：`gemini-3-pro-high`\n- 账号不可用：`gemini-3-pro-high`\n- 账号可用：`gemini-3.1-pro-high`\n- 最终上游模型：`gemini-3.1-pro-high`\n\n示例 C（泛别名）：\n- 请求：`gemini-3-pro`\n- 静态阶段先映射为：`gemini-3-pro-preview`\n- 动态阶段再根据账号可用模型决定是否继续回退。\n\n## 7）设计目标\n\n该设计同时满足三点：\n- 具体模型优先保持用户原始意图。\n- 泛别名保留历史兼容能力。\n- 多账号能力不一致时，通过动态回退提升可用性。\n"
  },
  {
    "path": "docs/proxy/accounts.md",
    "content": "# Proxy account pool & auto-disable behavior\n\n## What we wanted\n- Keep the proxy “always-on” even when some Google OAuth accounts become invalid.\n- Avoid repeatedly attempting to refresh a revoked `refresh_token` (noise + wasted requests).\n- Make failures actionable by surfacing account state clearly in the UI.\n\n## What we got\n### 1) Disabled accounts are skipped by the proxy pool\nAccount files can be marked as disabled on disk (`accounts/<id>.json`):\n- `disabled: true`\n- `disabled_at: <unix_ts>`\n- `disabled_reason: <string>`\n\nThe proxy token pool loader skips such accounts:\n- `TokenManager::load_single_account(...)` in [`src-tauri/src/proxy/token_manager.rs`](../../src-tauri/src/proxy/token_manager.rs)\n\n### 2) Automatic disable on OAuth `invalid_grant`\nIf an account refresh fails with `invalid_grant` during token refresh, the proxy marks it disabled and removes it from the in-memory pool:\n- Refresh/disable logic: `TokenManager::get_token(...)` in [`src-tauri/src/proxy/token_manager.rs`](../../src-tauri/src/proxy/token_manager.rs)\n- Persist disable flags to disk: `TokenManager::disable_account(...)` in [`src-tauri/src/proxy/token_manager.rs`](../../src-tauri/src/proxy/token_manager.rs)\n\nThis prevents endless rotation attempts against a dead account.\n\n### 3) Batch quota refresh skips disabled accounts\nWhen refreshing quotas for all accounts, disabled ones are skipped immediately:\n- `refresh_all_quotas(...)` in [`src-tauri/src/commands/mod.rs`](../../src-tauri/src/commands/mod.rs)\n\n### 4) UI surfaces disabled state and blocks actions\nThe accounts UI reads `disabled` fields and shows a “Disabled” badge and tooltip, and disables “switch / refresh” controls:\n- Account type includes `disabled*` fields: [`src/types/account.ts`](../../src/types/account.ts)\n- Card view: [`src/components/accounts/AccountCard.tsx`](../../src/components/accounts/AccountCard.tsx)\n- Table row view: [`src/components/accounts/AccountRow.tsx`](../../src/components/accounts/AccountRow.tsx)\n- Filters: “Available” excludes disabled accounts: [`src/pages/Accounts.tsx`](../../src/pages/Accounts.tsx)\n\nTranslations:\n- [`src/locales/en.json`](../../src/locales/en.json)\n- [`src/locales/zh.json`](../../src/locales/zh.json)\n\n### 5) API errors avoid leaking user emails\nToken refresh failures returned to API clients no longer include account emails:\n- Error message construction: `TokenManager::get_token(...)` in [`src-tauri/src/proxy/token_manager.rs`](../../src-tauri/src/proxy/token_manager.rs)\n- Proxy error mapping: `handle_messages(...)` in [`src-tauri/src/proxy/handlers/claude.rs`](../../src-tauri/src/proxy/handlers/claude.rs)\n\n## Operational guidance\n- If an account becomes disabled due to `invalid_grant`, it usually means the `refresh_token` was revoked or expired.\n- Re-authorize the account (or update the stored token) to restore it.\n\n## Validation\n1) Ensure at least one account file has `disabled: true`.\n2) Start the proxy and verify:\n   - The disabled account is not selected for requests.\n   - Batch quota refresh logs show “Skipping … (Disabled)”.\n   - The UI shows the Disabled badge and blocks actions.\n"
  },
  {
    "path": "docs/proxy/auth.md",
    "content": "# Proxy authorization (auth modes)\n\n## What we wanted\n- Allow running the proxy **open** for local-only workflows.\n- Allow enabling **request authentication** when exposing the proxy more widely (LAN, shared host, etc.).\n- Keep behavior predictable for tools that cannot add auth headers by providing a mode that keeps health checks open.\n- Apply changes **without restart** (hot reload).\n\n## What we got\nThe proxy supports `proxy.auth_mode` with four modes:\n- `off` — no auth required.\n- `strict` — auth required for all routes.\n- `all_except_health` — auth required for all routes except `GET /healthz`.\n- `auto` — derived policy: if `proxy.allow_lan_access=true` then `all_except_health`, otherwise `off`.\n\nImplementation:\n- Config enum and serialization: [`src-tauri/src/proxy/config.rs`](../../src-tauri/src/proxy/config.rs)\n  - `ProxyAuthMode` in [`src-tauri/src/proxy/config.rs`](../../src-tauri/src/proxy/config.rs)\n- Policy resolver (“effective mode”): [`src-tauri/src/proxy/security.rs`](../../src-tauri/src/proxy/security.rs)\n  - `ProxySecurityConfig::from_proxy_config(...)` in [`src-tauri/src/proxy/security.rs`](../../src-tauri/src/proxy/security.rs)\n- Request middleware enforcement: [`src-tauri/src/proxy/middleware/auth.rs`](../../src-tauri/src/proxy/middleware/auth.rs)\n  - `auth_middleware(...)` validates `Authorization: Bearer <proxy.api_key>`\n  - `OPTIONS` requests are allowed (CORS preflight)\n  - In `all_except_health`, `GET /healthz` bypasses auth\n\nHot reload:\n- Config save triggers running server updates in [`src-tauri/src/commands/mod.rs`](../../src-tauri/src/commands/mod.rs)\n  - `save_config(...)` calls `axum_server.update_security(&config.proxy).await`\n\n## Client contract\nWhen auth is enabled, clients should send:\n- `Authorization: Bearer <proxy.api_key>`\n\nNotes:\n- The proxy API key is **not** forwarded upstream to providers.\n- Health may remain open depending on the selected mode.\n\n## Validation\n1) Set `proxy.auth_mode=all_except_health` and `proxy.api_key` in the UI (`src/pages/ApiProxy.tsx`).\n   - UI: [`src/pages/ApiProxy.tsx`](../../src/pages/ApiProxy.tsx)\n2) Start the proxy.\n3) Verify:\n   - `GET /healthz` succeeds without auth.\n   - Other endpoints (e.g. `POST /v1/messages`) return 401 without auth and succeed with the header.\n"
  },
  {
    "path": "docs/proxy-invalid-grant.md",
    "content": "# Proxy: handling `invalid_grant` refresh failures\n\n## Problem\nWhen an OAuth `refresh_token` is revoked/expired, Google token refresh returns `invalid_grant`.\nPreviously the proxy could repeatedly pick the same broken account, repeatedly fail refresh, and eventually return a `503` error due to an effectively unusable token pool.\n\n## Behavior after this change\n### 1) Persistently disable the account on `invalid_grant`\n- When token refresh fails with `invalid_grant`, the proxy marks that account as disabled on disk:\n  - `disabled: true`\n  - `disabled_at: <unix timestamp>`\n  - `disabled_reason: \"invalid_grant: …\"` (truncated)\n- The account is also removed from the in-memory token pool, preventing retry storms.\n\n### 2) Skip disabled accounts when building the token pool\n- During `TokenManager::load_accounts`, account JSON files with `disabled: true` are skipped.\n- Reload clears the in-memory pool and re-reads the on-disk state so disables/enables take effect immediately.\n\n### 3) Immediate reload when accounts change (if proxy is running)\nAccount mutations that affect proxy availability trigger a best-effort token pool reload when the proxy is running:\n- Adding an account\n- Completing OAuth login\n- Updating tokens via the UI (account upsert)\n\n## Re-enabling an account\nIf a user updates credentials in the UI (token upsert) and changes either `refresh_token` or `access_token`, the account is automatically re-enabled by clearing:\n- `disabled`\n- `disabled_reason`\n- `disabled_at`\n\nThis supports the workflow where a revoked token is replaced manually without requiring a proxy restart.\n\n## Data model / compatibility\nAccounts gain three new fields:\n- `disabled` (`bool`, default `false`)\n- `disabled_reason` (`string | null`)\n- `disabled_at` (`number | null`)\n\nThese fields are optional and use defaults, so existing account files continue to load.\n\n## Operational notes\n- The `disabled_reason` is truncated to avoid bloating the account JSON.\n- No secrets are intentionally written into `disabled_reason`; it is derived from the refresh error string.\n- If desired, the UI can surface these fields to explain why an account is no longer used by the proxy.\n\n## Testing (suggested)\n- Reproduce: force an account to have a revoked/invalid `refresh_token` and trigger a proxy request that requires refresh.\n- Expected:\n  - Proxy logs show the `invalid_grant` failure and account disable.\n  - The account is removed from the token pool and will not be selected again.\n  - After updating the token via UI, the account is re-enabled and becomes eligible without restarting the proxy.\n"
  },
  {
    "path": "docs/proxy-monitor-technical.md",
    "content": "# Proxy Monitor Technical Reference\n\nThis document provides a detailed technical overview of the Proxy Monitor feature in Antigravity Manager, covering its implementation, data structures, and usage.\n\n## 1. Interface Overview\n\n### 1.1 Entrance (API Proxy Page)\nWhen the proxy service is running, an entry button to the monitor dashboard appears.\n> ![Entrance Screenshot](images/monitor/entrance.png)\n> *Note: Button appears next to the service status indicator.*\n\n### 1.2 Monitor Dashboard\nA full-screen dashboard showing real-time traffic, including quick filters and recording controls.\n> ![Dashboard Screenshot](images/monitor/dashboard.png)\n> *Note: Displays real-time request logs with status, model, and token usage.*\n\n### 1.3 Request Details (Detail Modal)\nClicking on any record opens a high-contrast modal showing the full request and response payloads.\n> ![Details Screenshot](images/monitor/details.png)\n> *Note: Formatted JSON view for deep analysis.*\n\n---\n\n## 2. System Architecture\n\n### 2.1 Data Flow\n`Client Request` -> `Axum Middleware` -> `ProxyMonitor (Internal)` -> `SQLite DB` & `Frontend (Tauri Event)`\n\n### 2.2 Storage Implementation (Rust)\nPersistence is handled via SQLite, stored in `proxy_logs.db` within the application data directory.\n*   **Table**: `request_logs`\n*   **Schema**:\n    *   `id`: Primary Key (UUID v4)\n    *   `timestamp`: Millisecond timestamp\n    *   `model`: Target model ID\n    *   `request_body` / `response_body`: Original JSON payloads\n    *   `input_tokens` / `output_tokens`: Token usage statistics\n\n### 2.3 SSE Interception Algorithm\nTo ensure the \"typewriter effect\" of AI responses remains smooth, the middleware uses a non-destructive stream wrapper:\n1.  Wraps the response body using `Body::into_data_stream`.\n2.  Buffers the last 8KB of data as it passes through.\n3.  Upon stream completion, parses the tail buffer for the SSE `data: ` block containing `usage` info.\n4.  Writes to the database asynchronously to avoid blocking the HTTP response.\n\n---\n\n## 3. Performance & Privacy\n\n*   **Zero-Overhead Mode**: When the \"Recording\" toggle is off, the middleware bypasses all processing via an atomic check.\n*   **Local Only**: All logs are stored locally on the user's machine; no data is sent to external servers.\n*   **Buffer Limits**: Requests are capped at 1MB and responses at 512KB to prevent memory exhaustion (OOM).\n"
  },
  {
    "path": "docs/test_503_issue.md",
    "content": "# 503 错误（Service Unavailable）修复验证指南\n\n本指南针对近期反馈的 503 错误（Issue #1794 及后端容量限制）提供测试验证示例。\n\n## 1. 验证 Project ID 获取失败后的自动回退 (Issue #1794)\n\n**场景描述**：\n部分账号（特别是 Free 账号或受限账号）在调用官方接口获取项目 ID 时会报错 `账号无资格获取官方 cloudaicompanionProject`。在修复前，系统会直接跳过该账号导致最终返回 503；修复后，系统将自动使用通用 Project ID (`bamboo-precept-lgxtn`)。\n\n### A. 使用 `curl` 进行基础连通性测试\n请使用一个之前报错 503 的账号对应的 API Key（或直接通过代理）：\n\n```bash\ncurl http://localhost:8045/v1/chat/completions \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer sk-antigravity-key\" \\\n  -d '{\n    \"model\": \"gemini-2.0-flash\",\n    \"messages\": [\n      {\"role\": \"user\", \"content\": \"你好，请确认你的工作状态。\"}\n    ],\n    \"stream\": false\n  }'\n```\n\n### B. 观察服务端日志 (npm run tauri dev)\n**预期现象**：\n当系统检测到权限问题时，日志中会出现如下 **Warn** 信息，但请求**不应报错 503**，而是继续执行：\n\n```text\nWARN Failed to fetch project_id for user@example.com, using fallback: Account is not eligible for official cloudaicompanionProject\nDEBUG [TokenManager] Using project_id: bamboo-precept-lgxtn for request\n```\n\n---\n\n## 2. 验证 Quota Protection（配额保护）对 503 的预防\n\n**场景描述**：\n当账号配额耗尽或后端因高负载返回 503 时，系统应正确识别并尝试轮换账号，而不是直接透传 503 给客户端。\n\n### 测试指令 (Claude CLI)\n```bash\nclaude \"这段代码哪里有 Bug？[附带一段长代码]\"\n```\n\n**验证点**：\n- 如果当前账号返回 503，日志中应显示 `[RetryStrategy] Status 503 detected, rotating account...`。\n- 系统应自动尝试下一个可用账号，直到获得成功响应或消耗完重试次数。\n\n---\n\n## 3. 区分“代码 Bug”与“后端容量限制” (Opus 4.6)\n\n**场景描述**：\n由于 `claude-opus-4-6-thinking` 模型目前处于试验阶段，Google 后端时常返回 `No capacity available` (503)。\n\n### 测试指令\n```bash\nclaude --model claude-opus-4-6-thinking \"执行一次深度的推理任务，比较 Rust 和 C++ 的异步内存模型。\"\n```\n\n**预期结果分析**：\n1. **如果返回 503 且消息包含 \"No capacity available\"**：\n   - 这是 **Google 后端容量限制**，并非本软件 Bug。\n   - 代理会自动通过重试策略尝试其他账号，但如果所有账号都遇到容量限制，最终会透传此 503。\n   - **建议**：在此负载高峰期切换到 `gemini-2.0-flash-thinking-exp` 或 `claude-3-7-sonnet` 进行测试。\n\n2. **如果返回成功**：\n   - 说明当前后端容量充足。\n\n---\n\n## 调试辅助技巧\n\n如果您想强制模拟 Project ID 失败的场景进行代码级验证，可以在 `src-tauri/src/proxy/token_manager.rs` 中暂时修改模拟逻辑。但在大多数情况下，通过观察日志中是否出现 `using fallback: ...` 字样即可确认修复生效。\n"
  },
  {
    "path": "docs/testing/context_compression_test_plan.md",
    "content": "# 专业版模型 1.5/2.5 Pro 自动对齐与分流测试 (v4.0.3)\n\n## 测试目标\n\n验证三层渐进式上下文压缩功能的正确性、稳定性和成本优化效果。\n\n## 前置准备\n\n1. **启动应用**：\n   ```bash\n   cd /Users/lbjlaq/Desktop/xin\n   npm run tauri dev\n   ```\n\n2. **启用调试日志**：\n   ```bash\n   export RUST_LOG=debug\n   ```\n\n3. **准备测试账号**：\n   - 至少 1 个 Google 账号（用于 Gemini API）\n   - 确保账号有足够配额\n\n## 测试场景\n\n### 场景 1：Layer 1 工具消息裁剪 (60% 压力)\n\n**目标**：验证工具消息智能裁剪功能\n\n**步骤**：\n1. 使用 Claude Code CLI 或 Cherry Studio\n2. 发起一个需要多次工具调用的任务（如代码搜索、文件读取）\n3. 持续对话直到触发 60% 上下文压力\n\n**预期结果**：\n- 日志中出现 `[Layer-1] Tool trimming triggered`\n- 保留最近 5 轮工具交互\n- 删除更早的工具消息\n- **无 400 错误**\n- **响应速度正常**\n\n**验证命令**：\n```bash\n# 查看日志\ntail -f ~/Library/Application\\ Support/com.antigravity.tools/logs/antigravity.log | grep \"Layer-1\"\n```\n\n---\n\n### 场景 2：Layer 2 Thinking 压缩 (75% 压力)\n\n**目标**：验证 Thinking 内容压缩 + 签名保留\n\n**步骤**：\n1. 使用 Claude 4.5 Opus/Sonnet Thinking 模型\n2. 发起复杂推理任务（如代码重构、算法设计）\n3. 持续对话直到触发 75% 上下文压力\n\n**预期结果**：\n- 日志中出现 `[Layer-2] Thinking compression triggered`\n- Thinking 块文本被压缩为 \"...\"\n- **`signature` 字段完整保留**\n- 最近 4 条消息不被压缩\n- **无 400 签名错误**\n\n**验证命令**：\n```bash\n# 查看签名保留情况\ntail -f ~/Library/Application\\ Support/com.antigravity.tools/logs/antigravity.log | grep -E \"(Layer-2|signature)\"\n```\n\n---\n\n### 场景 3：Layer 3 Fork 会话 + XML 摘要 (90% 压力)\n\n**目标**：验证 XML 摘要生成和会话 Fork\n\n**步骤**：\n1. 使用任意模型进行超长对话\n2. 持续对话直到触发 90% 上下文压力\n\n**预期结果**：\n- 日志中出现 `[Layer-3] Critical context pressure`\n- 调用 `gemini-2.5-flash-lite` 生成 XML 摘要\n- 创建新的消息序列：`[User: XML摘要] + [Assistant: 确认] + [用户最新消息]`\n- **压缩率 86-97%**\n- **无 Prompt Cache 破坏**\n- **签名链完整**\n\n**验证命令**：\n```bash\n# 查看 Layer 3 触发和摘要生成\ntail -f ~/Library/Application\\ Support/com.antigravity.tools/logs/antigravity.log | grep -E \"(Layer-3|XML summary|Fork)\"\n```\n\n---\n\n### 场景 4：渐进式触发测试\n\n**目标**：验证三层压缩的渐进式触发机制\n\n**步骤**：\n1. 从空对话开始\n2. 持续对话，观察压缩层级的触发顺序\n\n**预期结果**：\n- 触发顺序：Layer 1 (60%) → Layer 2 (75%) → Layer 3 (90%)\n- 每次压缩后重新估算 Token 用量\n- 日志中清晰记录每层的触发和效果\n\n**验证命令**：\n```bash\n# 查看所有层级的触发\ntail -f ~/Library/Application\\ Support/com.antigravity.tools/logs/antigravity.log | grep -E \"Layer-[123]\"\n```\n\n---\n\n### 场景 5：错误处理测试\n\n**目标**：验证 Layer 3 失败时的容错机制\n\n**步骤**：\n1. 临时禁用 Gemini 账号或网络\n2. 触发 Layer 3 压缩\n\n**预期结果**：\n- Layer 3 失败时返回 `BAD_REQUEST` 错误\n- 错误消息友好：`Context too long and automatic compression failed`\n- 提示用户使用 `/compact` 或切换模型\n\n**验证命令**：\n```bash\n# 查看错误处理\ntail -f ~/Library/Application\\ Support/com.antigravity.tools/logs/antigravity.log | grep -E \"(Layer-3.*failed|BAD_REQUEST)\"\n```\n\n---\n\n## 性能验证\n\n### Token 成本节省\n\n**测试方法**：\n1. 记录压缩前的 Token 用量（从日志中提取）\n2. 记录压缩后的 Token 用量\n3. 计算节省比例\n\n**预期结果**：\n- Layer 1: 60-90% 节省\n- Layer 2: 70-95% 节省\n- Layer 3: 86-97% 节省\n\n### 响应速度\n\n**测试方法**：\n1. 使用 `time` 命令测量响应时间\n2. 对比压缩前后的响应速度\n\n**预期结果**：\n- Layer 1/2: 响应速度无明显变化\n- Layer 3: 首次摘要生成可能增加 2-5 秒，后续请求正常\n\n---\n\n## 兼容性测试\n\n### 客户端兼容性\n\n测试以下客户端：\n- ✅ Claude Code CLI\n- ✅ Cherry Studio\n- ✅ Cursor\n- ✅ Python OpenAI SDK\n- ✅ Kilo Code\n\n### 模型兼容性\n\n测试以下模型：\n- ✅ Gemini 3 Flash\n- ✅ Gemini 3 Pro High\n- ✅ Claude 4.5 Sonnet\n- ✅ Claude 4.5 Opus Thinking\n\n---\n\n## 回归测试\n\n### 签名链完整性\n\n**验证点**：\n- Layer 2 压缩后签名不丢失\n- Layer 3 Fork 后签名正确恢复\n- 无 400 签名错误\n\n### 工具调用链\n\n**验证点**：\n- 工具调用在压缩后仍能正常工作\n- 工具结果正确传递\n- 无工具调用中断\n\n---\n\n## 日志分析\n\n### 关键日志模式\n\n```bash\n# Layer 1 触发\ngrep \"Layer-1.*Tool trimming\" antigravity.log\n\n# Layer 2 触发\ngrep \"Layer-2.*Thinking compression\" antigravity.log\n\n# Layer 3 触发\ngrep \"Layer-3.*Fork successful\" antigravity.log\n\n# Token 节省统计\ngrep \"Compression result.*saved\" antigravity.log\n```\n\n---\n\n## 测试报告模板\n\n```markdown\n## 测试结果\n\n### 场景 1: Layer 1 工具消息裁剪\n- [ ] 触发成功\n- [ ] 保留最近 5 轮\n- [ ] 无 400 错误\n- [ ] 响应速度正常\n\n### 场景 2: Layer 2 Thinking 压缩\n- [ ] 触发成功\n- [ ] 签名完整保留\n- [ ] 无签名错误\n- [ ] 压缩率达标\n\n### 场景 3: Layer 3 Fork 会话\n- [ ] 触发成功\n- [ ] XML 摘要生成\n- [ ] 压缩率 86-97%\n- [ ] 无 Cache 破坏\n\n### 场景 4: 渐进式触发\n- [ ] 顺序正确 (1→2→3)\n- [ ] Token 重新估算\n- [ ] 日志清晰\n\n### 场景 5: 错误处理\n- [ ] 失败时友好提示\n- [ ] 无崩溃\n- [ ] 建议明确\n\n### 性能验证\n- Token 节省: ____%\n- 响应速度: 正常/慢 (___ms)\n\n### 兼容性\n- Claude Code: ✅/❌\n- Cherry Studio: ✅/❌\n- Cursor: ✅/❌\n- Python SDK: ✅/❌\n\n### 回归测试\n- 签名链完整: ✅/❌\n- 工具调用正常: ✅/❌\n\n## 问题记录\n\n(记录测试中发现的问题)\n\n## 结论\n\n(总体评价和建议)\n```\n\n---\n\n## 快速测试脚本\n\n```bash\n#!/bin/bash\n# 快速测试三层压缩\n\necho \"=== 测试 Layer 1 (工具裁剪) ===\"\n# 使用 Claude Code 执行多次文件搜索\nclaude \"请搜索项目中所有 .rs 文件，然后读取其中 5 个文件的内容\"\n\necho \"=== 测试 Layer 2 (Thinking 压缩) ===\"\n# 使用 Thinking 模型进行复杂推理\nclaude --model claude-opus-4-5-thinking \"请详细分析这段代码的性能瓶颈并提出优化方案\"\n\necho \"=== 测试 Layer 3 (Fork 会话) ===\"\n# 超长对话触发 Fork\nfor i in {1..20}; do\n  claude \"继续上一个话题，请提供更多细节 (第 $i 轮)\"\ndone\n\necho \"=== 查看日志 ===\"\ntail -100 ~/Library/Application\\ Support/com.antigravity.tools/logs/antigravity.log | grep -E \"Layer-[123]\"\n```\n\n---\n\n## 注意事项\n\n1. **测试环境**：确保在干净的环境中测试，避免其他因素干扰\n2. **日志级别**：必须设置 `RUST_LOG=debug` 才能看到详细日志\n3. **账号配额**：测试前确保账号有足够配额\n4. **备份数据**：测试前备份重要数据\n5. **版本确认**：确认运行的是 v4.0.3 版本\n\n---\n\n## 问题排查\n\n### 问题 1：Layer 1 未触发\n- 检查对话是否达到 60% 压力\n- 查看 Token 估算是否准确\n\n### 问题 2：Layer 2 签名丢失\n- 检查 `compress_thinking_preserve_signature` 函数\n- 验证签名提取逻辑\n\n### 问题 3：Layer 3 摘要失败\n- 检查 Gemini 账号是否可用\n- 验证 `call_gemini_sync` 函数\n- 查看上游 API 错误\n\n### 问题 4：400 错误\n- 检查签名链是否完整\n- 验证工具调用参数\n- 查看上游 API 响应\n\n---\n\n## 联系方式\n\n如有问题，请在 GitHub 提 Issue：\nhttps://github.com/lbjlaq/Antigravity-Manager/issues\n"
  },
  {
    "path": "docs/testing/ip_security_test_report.md",
    "content": "# IP 安全监控功能测试报告\n\n## 功能概述\n\n本 PR 为 Antigravity Manager 增加了 IP 安全监控功能，包括：\n\n1. **IP 黑名单**：支持按单个 IP 或 CIDR 范围封禁恶意访问者\n2. **IP 白名单**：支持白名单模式和白名单优先模式\n3. **访问日志**：记录所有 API 请求，支持查询和统计\n4. **临时/永久封禁**：支持设置过期时间的临时封禁\n\n## 测试覆盖\n\n### 1. 单元测试 (security_ip_tests.rs)\n\n| 测试类别 | 测试数量 | 覆盖内容 |\n|---------|---------|---------|\n| 数据库初始化 | 2 | 初始化成功、幂等性 |\n| 黑名单基本操作 | 3 | 添加/检查/移除/详情获取 |\n| CIDR 匹配 | 3 | /24, /16, /32, /8, /0 各种掩码 |\n| 过期时间处理 | 3 | 已过期/未过期/永久封禁 |\n| 白名单操作 | 2 | 添加/检查/CIDR 匹配 |\n| 访问日志 | 2 | 保存/检索/过滤 |\n| 统计功能 | 1 | 请求数/唯一IP/封禁数统计 |\n| 清理功能 | 1 | 旧日志清理 |\n| 并发安全 | 1 | 多线程并发操作 |\n| 边界情况 | 4 | 重复条目/空模式/特殊字符/命中计数 |\n\n### 2. 集成测试 (security_integration_tests.rs)\n\n| 测试场景 | 描述 | 预期行为 |\n|---------|------|---------|\n| 黑名单阻止请求 | IP 在黑名单中 | 返回 403 Forbidden |\n| 白名单优先模式 | IP 同时在黑白名单 | 白名单优先放行 |\n| 临时封禁过期 | 过期的临时封禁 | 自动解除，请求放行 |\n| CIDR 范围封禁 | 封禁 192.168.1.0/24 | 整个子网被阻止 |\n| 封禁消息详情 | 被封禁时的响应 | 包含原因和剩余时间 |\n| 访问日志记录 | 被阻止的请求 | 记录 IP/时间/状态/原因 |\n| 性能影响 | 安全检查耗时 | < 5ms/次 |\n| 数据持久化 | 重启后数据保留 | 黑白名单数据持久化 |\n\n### 3. 压力测试 (security_integration_tests.rs)\n\n| 测试场景 | 规模 | 性能基准 |\n|---------|------|---------|\n| 大量黑名单条目 | 500 条 | 100 次查找 < 1s |\n| 大量访问日志 | 1000 条 | 写入 < 10s |\n| 并发操作 | 5 线程 x 20 操作 | 无死锁/数据一致 |\n\n## 运行测试\n\n```bash\n# 运行所有安全相关测试\ncd src-tauri\ncargo test --package antigravity-manager --lib proxy::tests::security\n\n# 运行单元测试\ncargo test --package antigravity-manager --lib proxy::tests::security_ip_tests\n\n# 运行集成测试\ncargo test --package antigravity-manager --lib proxy::tests::security_integration_tests\n\n# 运行性能基准测试 (带输出)\ncargo test --package antigravity-manager --lib benchmark -- --nocapture\n\n# 运行压力测试 (带输出)\ncargo test --package antigravity-manager --lib stress -- --nocapture\n```\n\n## 测试结果\n\n### 测试执行日期: ____\n\n### 测试环境\n- **OS**: Windows 11\n- **Rust**: 1.XX.X\n- **CPU**: \n- **RAM**: \n\n### 结果摘要\n\n```\ntest proxy::tests::security_ip_tests::ip_filter_middleware_tests::test_ip_extraction_priority ... ok\ntest proxy::tests::security_ip_tests::performance_benchmarks::benchmark_blacklist_lookup ... ok\ntest proxy::tests::security_ip_tests::performance_benchmarks::benchmark_cidr_matching ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_access_log_blocked_filter ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_access_log_save_and_retrieve ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_blacklist_add_and_check ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_blacklist_expiration ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_blacklist_get_entry_details ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_blacklist_not_yet_expired ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_blacklist_remove ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_cidr_edge_cases ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_cidr_matching_basic ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_cidr_matching_various_masks ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_cleanup_old_logs ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_concurrent_access ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_db_initialization ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_db_multiple_initializations ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_duplicate_blacklist_entry ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_empty_ip_pattern ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_hit_count_increment ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_ip_stats ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_permanent_blacklist ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_special_characters_in_reason ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_whitelist_add_and_check ... ok\ntest proxy::tests::security_ip_tests::security_db_tests::test_whitelist_cidr ... ok\n\n测试通过: 25 (单元测试) + 11 (集成/压力测试) = 36\n测试失败: 0\n```\n\n### 性能数据\n\n| 指标 | 测试值 | 基准值 | 状态 |\n|-----|-------|-------|-----|\n| 黑名单查找 (平均) | 2-3ms | < 5ms | ✅ |\n| CIDR 匹配 (平均) | 3-4ms | < 5ms | ✅ |\n| 安全检查总耗时 | ~2ms | < 5ms | ✅ |\n| 访问日志写入 | ~3.4ms | < 10ms | ✅ |\n| 大规模黑名单查找 (500条) | ~3ms/次 | < 10ms | ✅ |\n\n## 安全性验证\n\n### 1. 不影响主流程\n\n- [x] 安全检查是独立的中间件层\n- [x] 检查失败不会导致服务崩溃\n- [x] 数据库操作使用 WAL 模式确保并发安全\n- [x] 默认配置下安全功能被禁用，不影响现有用户\n\n### 2. 数据隔离\n\n- [x] 安全数据使用独立的 `security.db` 文件\n- [x] 不影响现有的 `proxy.db` 和 `accounts.db`\n- [x] 日志清理不影响其他数据\n\n### 3. 配置兼容性\n\n- [x] 新增字段有默认值，兼容旧配置\n- [x] `security_monitor.blacklist.enabled` 默认 `false`\n- [x] `security_monitor.whitelist.enabled` 默认 `false`\n\n## 代码质量\n\n### 新增代码统计\n\n| 文件 | 新增行数 | 功能 |\n|-----|---------|-----|\n| `modules/security_db.rs` | ~680 | 安全数据库操作 |\n| `proxy/middleware/ip_filter.rs` | ~190 | IP 过滤中间件 |\n| `proxy/config.rs` | ~70 | 安全配置定义 |\n| `commands/security.rs` | ~330 | Tauri 命令接口 |\n| `tests/security_*.rs` | ~600 | 测试代码 |\n\n### 代码审查清单\n\n- [x] 没有 `unwrap()` 在生产代码中 (除了测试)\n- [x] 所有公共函数有文档注释\n- [x] 使用参数化查询防止 SQL 注入\n- [x] 错误消息对用户友好\n- [x] 日志级别合理 (debug/info/warn/error)\n\n## 影响分析\n\n### 向后兼容性\n\n✅ **完全向后兼容**\n- 所有新功能默认禁用\n- 配置文件自动迁移\n- 无破坏性 API 变更\n\n### 风险评估\n\n| 风险 | 可能性 | 影响 | 缓解措施 |\n|-----|-------|-----|---------|\n| 误封正常用户 | 低 | 中 | 支持白名单覆盖 |\n| 性能影响 | 低 | 低 | 基准测试验证 < 5ms |\n| 数据库锁定 | 低 | 中 | WAL 模式 + 超时设置 |\n\n## 结论\n\n本 PR 的 IP 安全监控功能已通过全面的单元测试、集成测试和压力测试。测试结果表明：\n\n1. **功能正确性**：所有核心功能按预期工作\n2. **性能影响**：对正常请求的延迟增加 < 5ms\n3. **安全性**：独立的数据库和中间件层，不影响主流程\n4. **兼容性**：完全向后兼容，不影响现有用户\n\n建议合并此 PR。\n\n---\n\n## 附录：手动测试步骤\n\n如需手动验证，可按以下步骤操作：\n\n### A. 测试黑名单功能\n\n1. 启动应用，进入 \"安全\" 页面\n2. 添加测试 IP 到黑名单 (如 `192.168.1.100`)\n3. 启用黑名单功能\n4. 使用该 IP 发起 API 请求，验证返回 403\n5. 从黑名单移除，验证请求恢复正常\n\n### B. 测试 CIDR 封禁\n\n1. 添加 CIDR 范围到黑名单 (如 `10.0.0.0/8`)\n2. 使用 `10.x.x.x` 范围内的 IP 请求，验证被阻止\n3. 使用 `192.168.x.x` 请求，验证正常通过\n\n### C. 测试临时封禁\n\n1. 添加临时封禁 (设置 1 分钟后过期)\n2. 验证 IP 被阻止\n3. 等待过期后，验证 IP 恢复正常\n\n### D. 测试白名单优先\n\n1. 将同一 IP 同时添加到黑名单和白名单\n2. 启用白名单优先模式\n3. 验证该 IP 可以正常访问\n"
  },
  {
    "path": "docs/testing/opencode_sync_verification_checklist.md",
    "content": "# OpenCode Sync Verification Checklist\n\n> Manual test checklist for OpenCode sync feature PR\n\n## 1. Pre-check\n\n### 1.1 Backup Files Path\n- [ ] Verify backup suffix: `.antigravity-manager.bak` (new) and `.antigravity.bak` (legacy)\n- [ ] Verify backup location: `~/.config/opencode/opencode.json.antigravity-manager.bak`\n- [ ] Verify accounts backup: `~/.config/opencode/antigravity-accounts.json.antigravity-manager.bak`\n\n### 1.2 Plugin Installation Scenarios\n| Scenario | Expected Behavior |\n|----------|-------------------|\n| Plugin NOT installed | Sync button available, shows \"OpenCode not detected\" warning |\n| Plugin installed | Shows version, sync enabled |\n| Plugin path auto-detect | Finds opencode in PATH, npm, pnpm, yarn, nvm, fnm, Volta |\n\n---\n\n## 2. Sync Behavior Verification\n\n### 2.1 Provider Creation\n- [ ] `provider.antigravity-manager` created with correct structure\n- [ ] `npm`: `@ai-sdk/anthropic`\n- [ ] `name`: `Antigravity Manager`\n- [ ] `options.baseURL`: ends with `/v1` (auto-normalized)\n- [ ] `options.apiKey`: matches proxy API key\n\n### 2.2 Existing Providers Not Overwritten\n- [ ] `provider.google` preserved (if exists)\n- [ ] `provider.anthropic` preserved (if exists)\n- [ ] Other providers untouched\n\n### 2.3 Accounts Export File (v3 Structure)\n```json\n{\n  \"version\": 3,\n  \"accounts\": [...],\n  \"activeIndex\": 0,\n  \"activeIndexByFamily\": {\n    \"claude\": 0,\n    \"gemini\": 0\n  }\n}\n```\n- [ ] File created at `~/.config/opencode/antigravity-accounts.json`\n- [ ] `version` field = 3\n- [ ] `activeIndex` clamped to valid range\n- [ ] `activeIndexByFamily` contains `claude` and `gemini` keys\n- [ ] Disabled accounts excluded from export\n\n---\n\n## 3. Variants/Thinking Behavior Verification\n\n### 3.1 Claude Thinking Models\n```bash\nopencode run \"test\" --model antigravity-manager/claude-sonnet-4-6-thinking --variant high\n```\n- [ ] `--variant low` → `thinkingBudget: 8192`\n- [ ] `--variant medium` → `thinkingBudget: 16384`\n- [ ] `--variant high` → `thinkingBudget: 24576`\n- [ ] `--variant max` → `thinkingBudget: 32768`\n\n### 3.2 Gemini 3 Pro Models\n```bash\nopencode run \"test\" --model antigravity-manager/gemini-3-pro-high --variant low\n```\n- [ ] `--variant low` → `thinkingLevel: \"low\"`\n- [ ] `--variant high` → `thinkingLevel: \"high\"`\n\n### 3.3 Gemini 3 Flash Models\n- [ ] `--variant minimal` → `thinkingLevel: \"minimal\"`\n- [ ] `--variant low` → `thinkingLevel: \"low\"`\n- [ ] `--variant medium` → `thinkingLevel: \"medium\"`\n- [ ] `--variant high` → `thinkingLevel: \"high\"`\n\n### 3.4 Gemini 2.5 Flash Thinking\n- [ ] `--variant low` → `thinkingBudget: 8192`\n- [ ] `--variant medium` → `thinkingBudget: 12288`\n- [ ] `--variant high` → `thinkingBudget: 16384`\n- [ ] `--variant max` → `thinkingBudget: 24576`\n\n---\n\n## 4. Plugin Compatibility Verification\n\n### 4.1 Plugin Model Unaffected\n```bash\n# If opencode-antigravity-auth plugin installed\nopencode run \"test\" --model google/antigravity-claude-sonnet-4-6-thinking --variant max\n```\n- [ ] Plugin provider works independently\n- [ ] Manager sync does not interfere with plugin accounts\n- [ ] Both can coexist\n\n---\n\n## 5. Clear/Restore Verification\n\n### 5.1 Clear Config\n- [ ] Removes `provider.antigravity-manager`\n- [ ] Optional: clears legacy entries from `provider.google` and `provider.anthropic`\n- [ ] Preserves other providers\n\n### 5.2 Restore Function\n| Backup Type | Expected Result |\n|-------------|-----------------|\n| New suffix (`.antigravity-manager.bak`) | Restores successfully |\n| Old suffix (`.antigravity.bak`) | Restores successfully (backward compatible) |\n| Both exist | Prefers new suffix |\n| None exists | Shows \"No backup files found\" error |\n\n---\n\n## 6. Pass/Fail Summary Table\n\n| Test Category | Test Item | Status |\n|---------------|-----------|--------|\n| Pre-check | Backup path correct | ⬜ Pass / ⬜ Fail |\n| Pre-check | Plugin detection works | ⬜ Pass / ⬜ Fail |\n| Sync | Provider created correctly | ⬜ Pass / ⬜ Fail |\n| Sync | Existing providers preserved | ⬜ Pass / ⬜ Fail |\n| Sync | Accounts v3 structure valid | ⬜ Pass / ⬜ Fail |\n| Variants | Claude thinking budgets | ⬜ Pass / ⬜ Fail |\n| Variants | Gemini 3 Pro levels | ⬜ Pass / ⬜ Fail |\n| Variants | Gemini 3 Flash levels | ⬜ Pass / ⬜ Fail |\n| Variants | Gemini 2.5 thinking budgets | ⬜ Pass / ⬜ Fail |\n| Compatibility | Plugin unaffected | ⬜ Pass / ⬜ Fail |\n| Clear/Restore | Clear removes manager provider | ⬜ Pass / ⬜ Fail |\n| Clear/Restore | Restore with new suffix | ⬜ Pass / ⬜ Fail |\n| Clear/Restore | Restore with old suffix | ⬜ Pass / ⬜ Fail |\n\n---\n\n## 7. Troubleshooting Notes\n\n### Issue: Sync fails with \"Failed to get OpenCode config directory\"\n**Cause:** Cannot determine home directory  \n**Fix:** Ensure `HOME` (Unix) or `USERPROFILE` (Windows) env var is set\n\n### Issue: Variant not applied\n**Cause:** Model ID mismatch or variant type not defined  \n**Fix:** Check model ID in catalog matches request; verify `variant_type` in `build_model_catalog()`\n\n### Issue: Backup not created\n**Cause:** Backup file already exists (idempotent)  \n**Fix:** Delete existing `.bak` files manually if you need fresh backup\n\n### Issue: Accounts not exported\n**Cause:** All accounts disabled or `sync_accounts` not checked  \n**Fix:** Enable at least one account; check \"Sync accounts\" option in UI\n\n### Issue: Plugin conflicts with manager provider\n**Cause:** Both using same model IDs  \n**Fix:** Use different model IDs or disable one provider\n\n### Issue: Restore fails\n**Cause:** Backup files missing or permissions  \n**Check:** \n```bash\nls -la ~/.config/opencode/*.bak\n```\n\n---\n\n## Test Environment\n\n- **OS**: \n- **OpenCode Version**: \n- **Antigravity Manager Version**: \n- **Test Date**: \n- **Tester**: \n"
  },
  {
    "path": "docs/zai/implementation.md",
    "content": "# z.ai provider + MCP proxy (implemented)\n\nThis document describes the z.ai integration that is implemented on the `feat/zai-passthrough-mcp` branch: what was added, how it works internally, and how to validate it.\n\nRelated deep dives:\n- [`docs/zai/provider.md`](provider.md)\n- [`docs/zai/mcp.md`](mcp.md)\n- [`docs/zai/vision-mcp.md`](vision-mcp.md)\n- [`docs/proxy/auth.md`](../proxy/auth.md)\n- [`docs/proxy/accounts.md`](../proxy/accounts.md)\n\n## Scope (current)\n- z.ai is integrated as an **optional upstream** for **Anthropic/Claude protocol only** (`/v1/messages`, `/v1/messages/count_tokens`).\n- OpenAI and Gemini protocol handlers are unchanged and continue to use the existing Google-backed pool.\n- z.ai MCP (Search + Reader) is exposed via local proxy endpoints (reverse proxy) and injects the z.ai API key upstream.\n- Vision MCP is exposed via a **built-in MCP server** (local endpoint) and uses the stored z.ai API key to call the z.ai vision API.\n\n## Configuration\nAll settings are persisted in the existing data directory (same place as Google accounts and `gui_config.json`).\n\n### Proxy auth\n- `proxy.auth_mode` (`off` | `strict` | `all_except_health` | `auto`)\n  - `off`: no auth required\n  - `strict`: auth required for all routes\n  - `all_except_health`: auth required for all routes except `GET /healthz`\n  - `auto`: if `allow_lan_access=true` -> `all_except_health`, else `off`\n- `proxy.api_key`: required when auth is enabled\n\nImplementation:\n- Backend enum: [`src-tauri/src/proxy/config.rs`](../../src-tauri/src/proxy/config.rs) (`ProxyAuthMode`)\n- Effective policy resolver: [`src-tauri/src/proxy/security.rs`](../../src-tauri/src/proxy/security.rs)\n- Middleware enforcement: [`src-tauri/src/proxy/middleware/auth.rs`](../../src-tauri/src/proxy/middleware/auth.rs)\n\n### z.ai provider\nConfig lives under `proxy.zai` (`src-tauri/src/proxy/config.rs`):\n- `enabled: bool`\n- `base_url: string` (default `https://api.z.ai/api/anthropic`)\n- `api_key: string`\n- `dispatch_mode: off | exclusive | pooled | fallback`\n  - `off`: never use z.ai\n  - `exclusive`: all Claude protocol requests go to z.ai\n  - `pooled`: z.ai is treated as **one additional slot** in the shared pool (no priority, no strict guarantee)\n  - `fallback`: z.ai is used only when the Google pool has 0 accounts\n- `models`: defaults used when the incoming Anthropic request uses `claude-*` model ids\n  - `opus` default `glm-4.7`\n  - `sonnet` default `glm-4.7`\n  - `haiku` default `glm-4.5-air`\n- `model_mapping`: optional exact-match overrides (`{ \"<incoming_model>\": \"<glm-model-id>\" }`)\n  - When a key matches the incoming `model` string, it is replaced with the mapped z.ai model id before forwarding upstream.\n- `mcp` toggles:\n  - `enabled`\n  - `web_search_enabled`\n  - `web_reader_enabled`\n  - `vision_enabled`\n\nRuntime hot update:\n- `save_config` hot-updates `auth`, `upstream_proxy`, `model mappings`, and `z.ai` without restart.\n  - `src-tauri/src/commands/mod.rs` calls `axum_server.update_security(...)` and `axum_server.update_zai(...)`.\n\n## Request routing\n\n### `/v1/messages` (Anthropic messages)\nHandler: `src-tauri/src/proxy/handlers/claude.rs` (`handle_messages`)\n\nFlow:\n1. The handler receives `HeaderMap` + raw JSON `Value`.\n2. It decides whether to use z.ai or the existing Google flow:\n   - If z.ai is disabled -> use Google flow.\n   - If `dispatch_mode=exclusive` -> use z.ai.\n   - If `dispatch_mode=fallback` -> use z.ai only if Google pool size is 0.\n   - If `dispatch_mode=pooled` -> use round-robin across `(google_accounts + 1)` slots; slot `0` is z.ai, others are Google.\n3. If z.ai is selected:\n   - The raw JSON is forwarded to z.ai as-is (streaming is supported by byte passthrough).\n   - The request `model` may be rewritten:\n     - if `proxy.zai.model_mapping` contains an exact match, that mapping wins\n     - `glm-*` stays unchanged\n     - `claude-*` becomes one of `proxy.zai.models.{opus,sonnet,haiku}` based on name match\n4. Otherwise:\n   - The existing Claude→Gemini transform and Google-backed execution path runs as before.\n\n### `/v1/messages/count_tokens`\nHandler: `src-tauri/src/proxy/handlers/claude.rs` (`handle_count_tokens`)\n- If z.ai is enabled (mode != off), this request is forwarded to z.ai.\n- Otherwise it returns the existing placeholder `{input_tokens: 0, output_tokens: 0}`.\n\n## Upstream forwarding details (z.ai Anthropic)\nProvider: `src-tauri/src/proxy/providers/zai_anthropic.rs`\n\nSecurity / header handling:\n- The local proxy API key must **never** be forwarded upstream.\n- Only a conservative set of incoming headers is forwarded (e.g. `content-type`, `accept`, `anthropic-version`, `user-agent`).\n- z.ai auth is injected:\n  - If the client used `x-api-key`, it is replaced with z.ai key.\n  - If the client used `Authorization`, it is replaced with `Bearer <zai_key>`.\n  - If neither is present, `x-api-key: <zai_key>` is used.\n- Responses are streamed back to the client without parsing SSE.\n\nNetworking:\n- Respects the global upstream proxy config (`proxy.upstream_proxy`) for outbound HTTP calls.\n\n## MCP reverse proxy (Search + Reader)\nHandlers: `src-tauri/src/proxy/handlers/mcp.rs`\nRoutes: `src-tauri/src/proxy/server.rs`\n\nLocal endpoints:\n- `/mcp/web_search_prime/mcp` → `https://api.z.ai/api/mcp/web_search_prime/mcp`\n- `/mcp/web_reader/mcp` → `https://api.z.ai/api/mcp/web_reader/mcp`\n\nBehavior:\n- Controlled by `proxy.zai.mcp.*` flags:\n  - If `mcp.enabled=false` -> endpoints return 404.\n  - If per-server flag is false -> returns 404 for that endpoint.\n- z.ai key is injected upstream as `Authorization: Bearer <zai_key>`.\n- Response body is streamed back to the client.\n\nNote:\n- These endpoints are still subject to the proxy’s auth middleware depending on `proxy.auth_mode`.\n\n## Vision MCP (built-in server)\nHandlers:\n- [`src-tauri/src/proxy/handlers/mcp.rs`](../../src-tauri/src/proxy/handlers/mcp.rs) (`handle_zai_mcp_server`)\n- [`src-tauri/src/proxy/zai_vision_tools.rs`](../../src-tauri/src/proxy/zai_vision_tools.rs) (tool registry + z.ai vision API client)\n\nLocal endpoint:\n- `/mcp/zai-mcp-server/mcp`\n\nBehavior:\n- Controlled by `proxy.zai.mcp.enabled` and `proxy.zai.mcp.vision_enabled`.\n  - If `mcp.enabled=false` -> returns 404.\n  - If `vision_enabled=false` -> returns 404.\n- No z.ai key is required from MCP clients:\n  - the proxy injects the stored `proxy.zai.api_key` when calling the z.ai vision API.\n- Implements a minimal Streamable HTTP MCP flow:\n  - `POST /mcp` supports `initialize`, `tools/list`, `tools/call`\n  - `GET /mcp` returns an SSE stream with keep-alive events for an initialized session\n  - `DELETE /mcp` terminates a session\n\nUpstream calls:\n- z.ai vision endpoint: `https://api.z.ai/api/paas/v4/chat/completions`\n- Uses `Authorization: Bearer <zai_key>`\n- Default model: `glm-4.6v` (hardcoded for now)\n\nTool input and limits:\n- Images: `.png`, `.jpg`, `.jpeg` up to 5 MB (local files are encoded as `data:<mime>;base64,...`).\n- Videos: `.mp4`, `.mov`, `.m4v` up to 8 MB.\n- Supported tools:\n  - `ui_to_artifact`\n  - `extract_text_from_screenshot`\n  - `diagnose_error_screenshot`\n  - `understand_technical_diagram`\n  - `analyze_data_visualization`\n  - `ui_diff_check`\n  - `analyze_image`\n  - `analyze_video`\n\n## UI\nPage: `src/pages/ApiProxy.tsx`\n\nAdded controls:\n- Authorization toggle + mode selector (`off/strict/all_except_health/auto`)\n- z.ai block:\n  - enable toggle\n  - base_url\n  - dispatch mode\n  - api key input (stored locally)\n  - model mapping UI:\n    - fetch available model ids from the z.ai upstream (`GET <base_url>/v1/models`)\n    - configure default `opus/sonnet/haiku` mapping\n    - configure optional exact-match overrides\n  - MCP toggles + display of local MCP endpoints\n\nTranslations:\n- `src/locales/en.json`\n- `src/locales/zh.json`\n\n## Validation checklist\nBuild:\n- Frontend: `npm run build`\n- Backend: `cd src-tauri && cargo build`\n\nManual (example):\n1) Enable proxy auth (strict or all-except-health) and note `proxy.api_key`.\n2) Enable z.ai and set:\n   - `dispatch_mode=exclusive`\n   - `api_key=<your_z.ai.key>`\n3) Start proxy and call:\n   - `GET http://127.0.0.1:<port>/healthz` (should work without auth in all-except-health; always works in off)\n   - `POST http://127.0.0.1:<port>/v1/messages` with `Authorization: Bearer <proxy.api_key>` and a normal Anthropic request body.\n4) Enable MCP Search and call local `/mcp/web_search_prime/mcp` via an MCP client (the proxy injects z.ai auth upstream).\n5) Enable Vision MCP and verify the tool list:\n   - `POST http://127.0.0.1:<port>/mcp/zai-mcp-server/mcp` with a JSON-RPC `initialize`\n   - then `POST ...` with `tools/list` using the returned `Mcp-Session-Id` header.\n\n## Known limitations / follow-ups\n- Vision MCP currently implements the core methods needed for tool calls but is not yet a full feature-complete MCP server (prompts/resources, resumability, streaming tool output).\n- z.ai usage/budget (monitor endpoints) is not implemented yet.\n- Claude model list endpoint remains a static stub (`/v1/models/claude`) and is not yet provider-aware.\n"
  },
  {
    "path": "docs/zai/mcp.md",
    "content": "# z.ai MCP endpoints via local proxy\n\n## What we wanted\n- Allow apps to use z.ai MCP servers **without configuring z.ai keys** in those apps.\n- Keep secrets out of URLs (avoid query-string auth).\n- Make each MCP capability toggleable.\n\n## What we got\nWhen `proxy.zai.mcp.enabled=true`, the proxy can expose MCP endpoints under its own base URL.\n\n### 1) Web Search (remote reverse-proxy)\nLocal endpoint:\n- `/mcp/web_search_prime/mcp`\n\nUpstream:\n- `https://api.z.ai/api/mcp/web_search_prime/mcp`\n\nImplementation:\n- Handler: [`src-tauri/src/proxy/handlers/mcp.rs`](../../src-tauri/src/proxy/handlers/mcp.rs) (`handle_web_search_prime`)\n\n### 2) Web Reader (remote reverse-proxy)\nLocal endpoint:\n- `/mcp/web_reader/mcp`\n\nUpstream:\n- `https://api.z.ai/api/mcp/web_reader/mcp`\n\nImplementation:\n- Handler: [`src-tauri/src/proxy/handlers/mcp.rs`](../../src-tauri/src/proxy/handlers/mcp.rs) (`handle_web_reader`)\n\n### 3) Vision MCP (built-in server)\nLocal endpoint:\n- `/mcp/zai-mcp-server/mcp`\n\nImplementation:\n- Route wiring: [`src-tauri/src/proxy/server.rs`](../../src-tauri/src/proxy/server.rs)\n- Handler: [`src-tauri/src/proxy/handlers/mcp.rs`](../../src-tauri/src/proxy/handlers/mcp.rs) (`handle_zai_mcp_server`)\n- Session state: [`src-tauri/src/proxy/zai_vision_mcp.rs`](../../src-tauri/src/proxy/zai_vision_mcp.rs)\n- Tool execution: [`src-tauri/src/proxy/zai_vision_tools.rs`](../../src-tauri/src/proxy/zai_vision_tools.rs)\n\n## Auth model\n- Local proxy auth (if enabled) is handled by the proxy middleware:\n  - [`src-tauri/src/proxy/middleware/auth.rs`](../../src-tauri/src/proxy/middleware/auth.rs)\n- z.ai auth is always injected upstream by the proxy using `proxy.zai.api_key`.\n- No z.ai key needs to be configured in MCP clients that point at the local endpoints.\n\n## UI wiring\nThe MCP toggles and local endpoints are shown in:\n- [`src/pages/ApiProxy.tsx`](../../src/pages/ApiProxy.tsx)\n\n## Validation\n1) Enable `proxy.zai.enabled=true` and set `proxy.zai.api_key`.\n2) Enable:\n   - `proxy.zai.mcp.enabled=true`\n   - any subset of `{web_search_enabled, web_reader_enabled, vision_enabled}`\n3) Start the proxy and point an MCP client at the corresponding local endpoint(s).\n"
  },
  {
    "path": "docs/zai/notes.md",
    "content": "# z.ai (GLM) integration notes (Anthropic passthrough + MCP + usage)\n\nGoal: integrate z.ai as an upstream provider into Antigravity’s proxy/service, primarily via an Anthropic API-compatible passthrough, and optionally provide “budget/usage” visibility and MCP helpers (search/reader/vision).\n\nThis is a working note capturing findings, constraints, and a proposed implementation path. It intentionally does **not** copy full upstream documentation; it extracts what matters for implementation and corner cases.\n\n## 0) Product decisions / requirements (confirmed)\n- z.ai is configured and controlled from the **API Proxy** UI as an optional provider (enable/disable).\n- z.ai is used **inside Antigravity** (not via Google OAuth), but it must be able to serve **API Proxy** traffic alongside the existing account pool.\n- Storage: z.ai config/credentials are stored in the same **data directory** as the existing accounts and GUI config (same folder where Google account JSON lives). No Keychain/Vault.\n- Dispatch strategy is user-configurable:\n  - **z.ai handles all proxy requests** (exclusive mode), OR\n  - **z.ai participates in the shared rotation/queue** with other accounts and only gets requests when it is selected (pooled mode), OR\n  - (optional) **fallback-only** (only when the rest of the pool is unavailable).\n- z.ai MCP servers should be provided via Antigravity’s proxy as optional toggles (enable/disable) and be usable by apps **without requiring users to configure z.ai keys** in the apps.\n- Proxy authorization (if enabled) applies to the **entire proxy** (no per-route bypass).\n\n## 1) Key docs / entry points\n- Anthropic-compatible endpoint (Coding plan usage with existing clients): `https://docs.z.ai/devpack/tool/claude.md`\n- Scenario example (same content, shorter): `https://docs.z.ai/scenario-example/develop-tools/claude.md`\n- Vision MCP (local stdio, Node): `https://docs.z.ai/devpack/mcp/vision-mcp-server.md`\n- Web Search MCP (remote HTTP/SSE): `https://docs.z.ai/devpack/mcp/search-mcp-server.md`\n- Web Reader MCP (remote HTTP/SSE): `https://docs.z.ai/devpack/mcp/reader-mcp-server.md`\n- API reference intro (general vs coding endpoint): `https://docs.z.ai/api-reference/introduction.md`\n- Chat completions (OpenAI-like): `https://docs.z.ai/api-reference/llm/chat-completion.md`\n- Usage query plugin (reveals monitor endpoints + auth quirks): `https://docs.z.ai/devpack/extension/usage-query-plugin.md`\n\n## Implementation status\nDeveloper-facing implementation details for what is already built live in:\n- [`docs/zai/implementation.md`](implementation.md)\n- [`docs/zai/mcp.md`](mcp.md)\n- [`docs/zai/provider.md`](provider.md)\n- [`docs/zai/vision-mcp.md`](vision-mcp.md)\n\n## 2) What z.ai provides (relevant to our integration)\n### 2.1 Anthropic-compatible upstream (what we’ll passthrough to)\nDocs show clients can be configured with:\n- `ANTHROPIC_BASE_URL = https://api.z.ai/api/anthropic`\n- `ANTHROPIC_AUTH_TOKEN = <Z.AI API key>`\n\nThis implies z.ai runs an Anthropic-compatible API surface behind that base URL.\n\nPractical implication for Antigravity:\n- Add a new upstream provider “z.ai Anthropic” and forward `/v1/*` to `https://api.z.ai/api/anthropic/v1/*` (exact path joining must be verified via test calls).\n\n### 2.2 Model mapping defaults (client-side)\nDocs mention default mapping for “internal model env vars” to GLM:\n- `ANTHROPIC_DEFAULT_OPUS_MODEL` → `glm-4.7`\n- `ANTHROPIC_DEFAULT_SONNET_MODEL` → `glm-4.7`\n- `ANTHROPIC_DEFAULT_HAIKU_MODEL` → `glm-4.5-air`\n\nImplication:\n- If a client requests “Claude” model names, z.ai may already translate them to GLM on their side OR the client may send GLM model names directly (depending on the client’s model mapping config).\n- For Antigravity, the simplest first step is “treat `glm-*` as z.ai” (or require explicit `zai:` prefix), and forward model strings unchanged.\n\n### 2.3 OpenAI-like API (optional later)\nz.ai also provides OpenAI-style chat completions under:\n- `POST https://api.z.ai/api/paas/v4/chat/completions`\nand a dedicated “coding endpoint”:\n- `https://api.z.ai/api/coding/paas/v4` (doc note: use this for coding plan scenarios)\n\nWe can defer this for phase 2 if we want to stay strictly Anthropic passthrough.\n\n## 3) MCP ecosystem (the “3 servers”)\nImportant: MCP is not part of the Anthropic `/v1/messages` request itself. MCP is configured by the client (or we expose local endpoints that behave like MCP servers).\n\n### 3.1 Vision MCP server (local stdio process)\nDoc highlights:\n- NPM package: `@z_ai/mcp-server`\n- Requires Node.js `>= 22`\n- Uses env vars:\n  - `Z_AI_API_KEY` (required)\n  - `Z_AI_MODE=ZAI`\n- Installed as a local stdio MCP server via an MCP-compatible client.\n\nImplication:\n- This is a local process that clients spawn. Instead of requiring an extra runtime, the proxy now exposes a built-in Vision MCP endpoint (see `docs/zai/vision-mcp.md`), while still keeping compatibility with upstream behavior.\n\n### 3.2 Web Search MCP server (remote)\nEndpoints:\n- MCP over HTTP (recommended): `https://api.z.ai/api/mcp/web_search_prime/mcp`\n  - Header auth: `Authorization: Bearer <api_key>`\n- MCP over SSE (legacy/alternative): `https://api.z.ai/api/mcp/web_search_prime/sse?Authorization=<api_key>`\n\nQuota note in docs (plan-dependent):\n- Lite/Pro/Max include a number of web searches/readers and vision resource pool.\n\n### 3.3 Web Reader MCP server (remote)\nEndpoints:\n- MCP over HTTP (recommended): `https://api.z.ai/api/mcp/web_reader/mcp`\n  - Header auth: `Authorization: Bearer <api_key>`\n- MCP over SSE (alternative): `https://api.z.ai/api/mcp/web_reader/sse?Authorization=<api_key>`\n\n### 3.4 MCP-specific corner cases\n- Query-string auth for SSE is high risk (easy leakage into logs/history/screenshots). Prefer header-based auth.\n- If we proxy MCP endpoints locally, we should only expose header-based auth to the upstream and never include secrets in URLs.\n- Remote MCP endpoints may use “streamable-http” semantics; we should avoid buffering and proxy as a streaming response.\n\n## 4) Usage / budget integration (tokens + MCP quotas)\nThere are “monitor/usage” endpoints used by z.ai’s usage query tooling.\nThe reference script from `zai-org/zai-coding-plugins` uses:\n- `GET /api/monitor/usage/model-usage?startTime=...&endTime=...`\n- `GET /api/monitor/usage/tool-usage?startTime=...&endTime=...`\n- `GET /api/monitor/usage/quota/limit`\nbased on `ANTHROPIC_BASE_URL` domain (if it contains `api.z.ai`).\n\nAuth quirk:\n- The script sets `Authorization: <token>` (raw token) for these monitor endpoints (no `Bearer`).\n- Remote MCP endpoints use `Authorization: Bearer <token>`.\n- For z.ai’s general API reference (Bearer auth), it’s also `Authorization: Bearer <ZAI_API_KEY>`.\n\nImplication:\n- We must treat these as separate integration surfaces:\n  - Anthropic-compatible upstream auth format (to be validated)\n  - Monitor endpoints auth format (raw token per script)\n  - MCP remote auth format (Bearer)\n\n## 5) Our current proxy architecture constraints\nToday the proxy’s “upstream” client is hardwired to Google `v1internal` and uses:\n- token pool (per-account OAuth tokens)\n- `project_id` resolution\n- retry/rotation logic\n\nFor z.ai passthrough we should bypass all Google-specific logic:\n- z.ai uses a single API key (or a key pool later), not OAuth refresh tokens.\n- project_id is not relevant.\n\nTherefore phase 1 should introduce a provider-level router that can pick:\n- `provider=google` (existing flow)\n- `provider=zai` (passthrough flow)\n\n## 6) Proposed implementation approach (phase 1 “minimal but real”)\n### 6.1 New provider: z.ai Anthropic passthrough\n- Add config fields:\n  - `zai.enabled`\n  - `zai.api_key` (stored securely; never logged)\n  - `zai.base_url` (default `https://api.z.ai/api/anthropic`)\n  - optional: `zai.request_timeout_ms`\n- Routing:\n  - If resolved model starts with `glm-` OR mapping returns `zai:<model>` → use z.ai provider.\n  - Keep existing mappings intact; add support for values with a provider prefix (e.g. `zai:glm-4.7`).\n- Endpoint handling:\n  - Forward `POST /v1/messages` and other `/v1/*` requests by path passthrough.\n  - Streaming: proxy bytes end-to-end (do not parse/reshape SSE in phase 1).\n  - Error mapping: preserve upstream status/body; only wrap errors if needed for compatibility with current clients.\n\n### 6.2 MCP (remote) local reverse-proxy (phase 1.5)\nProvide local endpoints so clients do not store the z.ai key:\n- `GET/POST /mcp/web_search_prime/mcp` → upstream `https://api.z.ai/api/mcp/web_search_prime/mcp`\n- `GET/POST /mcp/web_reader/mcp` → upstream `https://api.z.ai/api/mcp/web_reader/mcp`\n\nBehavior:\n- Require Antigravity’s local proxy auth (existing `api_key`) for access.\n- Inject upstream header: `Authorization: Bearer <zai_api_key>`.\n- Stream responses.\n\nExplicitly avoid:\n- exposing SSE endpoints that require key-in-query.\n\n### 6.3 Vision MCP (local stdio) “setup helper” only (phase 1)\nStatus update:\n- Vision MCP is implemented directly inside the proxy and exposed at `/mcp/zai-mcp-server/mcp`.\n- Implementation details: `docs/zai/vision-mcp.md`.\n\n## 7) Corner cases checklist (must handle)\n- Auth header rewriting:\n  - never forward client-provided secrets upstream\n  - never log `Authorization`, `x-api-key`, cookies, tokens\n- Timeout differences:\n  - docs show `API_TIMEOUT_MS` set very high in some configs; we should allow per-provider timeout config\n- Streaming cancellation:\n  - client disconnect should abort upstream request\n- 401/429:\n  - surface meaningful error and do not retry blindly\n- Model naming:\n  - support both `glm-*` and “Claude-like” names if needed (either client-side mapping or server-side mapping)\n- Mixed mode:\n  - if z.ai disabled or misconfigured, either fail fast or fallback to google (config-driven)\n- Monitor endpoints:\n  - auth format differences (raw vs Bearer); cache results; avoid rate limit\n- Security:\n  - never accept key in querystring for local endpoints\n  - if we provide “test connection”, ensure it does not leak the key in logs\n\n## 8) Open questions to settle before coding\n1) Which surface is the priority: Anthropic passthrough only, or also OpenAI-like `chat/completions`?\n2) Do we want multi-key support for z.ai (rotation) or a single key per installation initially?\n3) Fallback policy when z.ai quota is near/at limit: error vs automatic fallback to other provider.\n4) Should the UI expose MCP helper endpoints/config snippets for clients?\n"
  },
  {
    "path": "docs/zai/provider.md",
    "content": "# z.ai provider (Anthropic-compatible passthrough)\n\n## Idea\nSupport z.ai (GLM) as an optional upstream for **Anthropic-compatible requests** (`/v1/messages`), without applying any Google/Gemini-specific transformations when z.ai is selected.\n\nThis keeps compatibility high (request/response shapes stay Anthropic-like) and avoids coupling z.ai traffic to the Google account pool.\n\n## Result\nWe added an optional “z.ai provider” that:\n- Is configured in proxy settings (`proxy.zai.*`).\n- Can be enabled/disabled and used via dispatch modes.\n- Forwards `/v1/messages` and `/v1/messages/count_tokens` to a z.ai Anthropic-compatible base URL.\n- Streams responses back without parsing SSE.\n\n## Configuration\nSchema: `src-tauri/src/proxy/config.rs`\n- `ZaiConfig` in `src-tauri/src/proxy/config.rs`\n- `ZaiDispatchMode` in `src-tauri/src/proxy/config.rs`\n\nKey fields:\n- `proxy.zai.enabled`\n- `proxy.zai.base_url` (default `https://api.z.ai/api/anthropic`)\n- `proxy.zai.api_key`\n- `proxy.zai.dispatch_mode`:\n  - `off`\n  - `exclusive`\n  - `pooled`\n  - `fallback`\n- `proxy.zai.models` default mapping for `claude-*` request models:\n  - `opus`, `sonnet`, `haiku`\n\n## Routing logic\nEntry point: [`src-tauri/src/proxy/handlers/claude.rs`](../../src-tauri/src/proxy/handlers/claude.rs)\n- `handle_messages(...)` decides whether to route the request to z.ai or to the existing Google-backed flow.\n- `pooled` mode uses round-robin across `(google_accounts + 1)` slots, where slot `0` is z.ai.\n\n## Upstream implementation\nProvider implementation: [`src-tauri/src/proxy/providers/zai_anthropic.rs`](../../src-tauri/src/proxy/providers/zai_anthropic.rs)\n- Forwarding is conservative about headers (does not forward the proxy’s own auth key).\n- Injects z.ai auth (`Authorization` / `x-api-key`) and forwards the request body as-is.\n- Uses the global upstream proxy config when configured.\n\n## Validation\n1) Enable z.ai in the UI (`src/pages/ApiProxy.tsx`) and set `dispatch_mode=exclusive`.\n   - UI: [`src/pages/ApiProxy.tsx`](../../src/pages/ApiProxy.tsx)\n2) Start the proxy.\n3) Send a normal Anthropic request to `POST /v1/messages`.\n4) Verify the request is served by z.ai (and Google accounts are not involved for this endpoint in exclusive mode).\n"
  },
  {
    "path": "docs/zai/vision-mcp.md",
    "content": "# Vision MCP (built-in server)\n\n## Why we implemented it this way\nThe upstream Vision MCP package (`@z_ai/mcp-server`) is designed as a **local stdio server**. In a desktop app + embedded proxy, requiring users (or the app) to manage a separate Node runtime/process increases operational complexity.\n\nInstead, we implement a **built-in Vision MCP server** directly in the proxy:\n- No extra runtime dependency.\n- Single place to store the z.ai key (proxy config).\n- Apps can talk to the local proxy using standard MCP over HTTP.\n\n## Local endpoint\n- `/mcp/zai-mcp-server/mcp`\n\nWired in:\n- [`src-tauri/src/proxy/server.rs`](../../src-tauri/src/proxy/server.rs) (router)\n\n## Protocol surface (minimal Streamable HTTP MCP)\nHandler:\n- [`src-tauri/src/proxy/handlers/mcp.rs`](../../src-tauri/src/proxy/handlers/mcp.rs) (`handle_zai_mcp_server`)\n\nImplemented methods:\n- `POST /mcp`:\n  - `initialize`\n  - `tools/list`\n  - `tools/call`\n- `GET /mcp`:\n  - returns an SSE stream (keepalive) for an existing session\n- `DELETE /mcp`:\n  - terminates a session\n\nSession storage:\n- [`src-tauri/src/proxy/zai_vision_mcp.rs`](../../src-tauri/src/proxy/zai_vision_mcp.rs)\n\nNotes:\n- This is intentionally minimal to support tool calls.\n- Prompts/resources, resumability, and streamed tool output can be added later if needed.\n\n## Tool set\nTool registry:\n- `tool_specs()` in [`src-tauri/src/proxy/zai_vision_tools.rs`](../../src-tauri/src/proxy/zai_vision_tools.rs)\n\nTool execution:\n- `call_tool(...)` in [`src-tauri/src/proxy/zai_vision_tools.rs`](../../src-tauri/src/proxy/zai_vision_tools.rs)\n\nSupported tools (mirrors the upstream package at a high level):\n- `ui_to_artifact`\n- `extract_text_from_screenshot`\n- `diagnose_error_screenshot`\n- `understand_technical_diagram`\n- `analyze_data_visualization`\n- `ui_diff_check`\n- `analyze_image`\n- `analyze_video`\n\n## Upstream calls\nVision tools call the z.ai vision chat completions endpoint:\n- `https://api.z.ai/api/paas/v4/chat/completions`\n\nImplementation:\n- `vision_chat_completion(...)` in [`src-tauri/src/proxy/zai_vision_tools.rs`](../../src-tauri/src/proxy/zai_vision_tools.rs)\n\nAuth:\n- Uses `Authorization: Bearer <proxy.zai.api_key>`\n\nPayload:\n- `model: glm-4.6v` (currently hardcoded)\n- `messages`: system prompt + a multimodal user message containing images/videos + text prompt\n- `stream: false` (currently returns a single tool result)\n\n## Local file handling\nTo support local file paths passed by MCP clients:\n- Images (`.png`, `.jpg`, `.jpeg`) are read and encoded as `data:<mime>;base64,...` (5 MB max)\n- Videos (`.mp4`, `.mov`, `.m4v`) are read and encoded as `data:<mime>;base64,...` (8 MB max)\n\nImplementation:\n- `image_source_to_content(...)` in [`src-tauri/src/proxy/zai_vision_tools.rs`](../../src-tauri/src/proxy/zai_vision_tools.rs)\n- `video_source_to_content(...)` in [`src-tauri/src/proxy/zai_vision_tools.rs`](../../src-tauri/src/proxy/zai_vision_tools.rs)\n\n## Quick validation (raw JSON-RPC)\n1) Initialize:\n   - `POST /mcp/zai-mcp-server/mcp` with `{\\\"jsonrpc\\\":\\\"2.0\\\",\\\"id\\\":1,\\\"method\\\":\\\"initialize\\\",\\\"params\\\":{\\\"protocolVersion\\\":\\\"2024-11-05\\\",\\\"capabilities\\\":{}}}`\n   - capture `Mcp-Session-Id` response header\n2) List tools:\n   - `POST /mcp/zai-mcp-server/mcp` with `Mcp-Session-Id: <id>` and `{\\\"jsonrpc\\\":\\\"2.0\\\",\\\"id\\\":2,\\\"method\\\":\\\"tools/list\\\"}`\n3) Call tool:\n   - `POST /mcp/zai-mcp-server/mcp` with `Mcp-Session-Id: <id>` and `{\\\"jsonrpc\\\":\\\"2.0\\\",\\\"id\\\":3,\\\"method\\\":\\\"tools/call\\\",\\\"params\\\":{\\\"name\\\":\\\"analyze_image\\\",\\\"arguments\\\":{\\\"image_source\\\":\\\"/path/to/file.png\\\",\\\"prompt\\\":\\\"Describe this image\\\"}}}`\n"
  },
  {
    "path": "index.html",
    "content": "<!doctype html>\n<html lang=\"en\" style=\"background-color: #1a1f2e;\">\n\n<head>\n  <meta charset=\"UTF-8\" />\n  <link rel=\"icon\" type=\"image/png\" href=\"/icon.png\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <title>Antigravity Tools</title>\n  <script>\n    (function () {\n      try {\n        const savedTheme = localStorage.getItem('app-theme-preference');\n        const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n\n        // Determine if should be dark for later use, but ALWAYS start with splash color\n        const shouldBeDark = savedTheme === 'dark' || ((!savedTheme || savedTheme === 'system') && systemDark);\n\n        // Set background color IMMEDIATELY to SPLASH COLOR (seamless transition)\n        document.documentElement.style.backgroundColor = '#1a1f2e';\n\n        if (shouldBeDark) {\n          document.documentElement.classList.add('dark');\n          document.documentElement.setAttribute('data-theme', 'dark');\n        } else {\n          document.documentElement.classList.remove('dark');\n          document.documentElement.setAttribute('data-theme', 'light');\n        }\n      } catch (e) {\n        console.error('Failed to apply theme during boot:', e);\n        document.documentElement.style.backgroundColor = '#1a1f2e';\n      }\n    })();\n  </script>\n  <style>\n    /* Critical CSS: Force splash color initially */\n    html {\n      background-color: #1a1f2e !important;\n    }\n\n    /* These specific overrides will apply when React loads and removes the splash screen logic, \n       but for now we want everything to look like the splash screen */\n\n    body {\n      margin: 0;\n      background-color: transparent;\n    }\n  </style>\n</head>\n\n<body style=\"margin: 0; background-color: #1a1f2e;\">\n\n\n\n\n  <div id=\"root\"></div>\n  <script type=\"module\" src=\"/src/main.tsx\"></script>\n</body>\n\n</html>"
  },
  {
    "path": "install.ps1",
    "content": "# Antigravity Tools Install Script for Windows\n# Usage: irm https://raw.githubusercontent.com/lbjlaq/Antigravity-Manager/main/install.ps1 | iex\n#\n# Parameters (set before running):\n#   $Version = \"4.1.30\"  # Install specific version\n#   $DryRun = $true      # Preview commands without executing\n\nif (-not $Version) { $Version = \"\" }\nif (-not $DryRun) { $DryRun = $false }\n\n$ErrorActionPreference = \"Continue\"\n\n$Repo = \"lbjlaq/Antigravity-Manager\"\n$AppName = \"Antigravity Tools\"\n$GithubApi = \"https://api.github.com/repos/$Repo/releases\"\n$script:ReleaseVersion = \"\"\n$script:DownloadUrl = \"\"\n$script:Filename = \"\"\n$script:HasError = $false\n\n# Colors helper\nfunction Write-ColorOutput {\n    param([string]$ForegroundColor, [string]$Message)\n    Write-Host $Message -ForegroundColor $ForegroundColor\n}\n\nfunction Info { Write-ColorOutput \"Cyan\" \"[INFO] $args\" }\nfunction Success { Write-ColorOutput \"Green\" \"[OK] $args\" }\nfunction Warn { Write-ColorOutput \"Yellow\" \"[WARN] $args\" }\nfunction Script-Error {\n    Write-ColorOutput \"Red\" \"[ERROR] $args\"\n    $script:HasError = $true\n}\n\nfunction Wait-AndExit {\n    param([int]$ExitCode = 0)\n    Write-Host \"\"\n    Write-Host \"Press any key to exit...\" -ForegroundColor Gray\n    $null = $Host.UI.RawUI.ReadKey(\"NoEcho,IncludeKeyDown\")\n    exit $ExitCode\n}\n\nfunction Get-ReleaseVersion {\n    if ($Version) {\n        $script:ReleaseVersion = $Version\n        Info \"Using specified version: v$($script:ReleaseVersion)\"\n        return $true\n    }\n\n    Info \"Fetching latest version...\"\n\n    # Method 1: Try GitHub API\n    try {\n        $release = Invoke-RestMethod -Uri \"$GithubApi/latest\" -Headers @{\n            \"User-Agent\" = \"Antigravity-Installer\"\n            \"Accept\"     = \"application/vnd.github.v3+json\"\n        } -TimeoutSec 10\n        $script:ReleaseVersion = $release.tag_name -replace \"^v\", \"\"\n        Info \"Latest version: v$($script:ReleaseVersion)\"\n        return $true\n    } catch {\n        Warn \"GitHub API failed (rate limit?), trying fallback...\"\n    }\n\n    # Method 2: Fallback - parse updater.json from releases (no API rate limit)\n    try {\n        $updaterJson = Invoke-RestMethod -Uri \"https://github.com/$Repo/releases/latest/download/updater.json\" -TimeoutSec 10\n        $script:ReleaseVersion = $updaterJson.version -replace \"^v\", \"\"\n        Info \"Latest version (from updater.json): v$($script:ReleaseVersion)\"\n        return $true\n    } catch {\n        Warn \"Fallback failed, trying redirect method...\"\n    }\n\n    # Method 3: Last resort - follow redirect from /releases/latest\n    try {\n        Invoke-WebRequest -Uri \"https://github.com/$Repo/releases/latest\" -MaximumRedirection 0 -ErrorAction SilentlyContinue -UseBasicParsing\n    } catch {\n        $redirectUrl = $_.Exception.Response.Headers.Location\n        if ($redirectUrl -and $redirectUrl -match \"/tag/v?(.+)$\") {\n            $script:ReleaseVersion = $Matches[1]\n            Info \"Latest version (from redirect): v$($script:ReleaseVersion)\"\n            return $true\n        }\n    }\n\n    Script-Error \"Failed to determine latest version. Try specifying version manually:\"\n    Write-Host '  $Version = \"4.1.30\"; irm https://raw.githubusercontent.com/lbjlaq/Antigravity-Manager/main/install.ps1 | iex' -ForegroundColor Yellow\n    return $false\n}\n\nfunction Get-DownloadUrl {\n    # NSIS installer: Antigravity.Tools_4.1.30_x64-setup.exe\n    $script:DownloadUrl = \"https://github.com/$Repo/releases/download/v$($script:ReleaseVersion)/Antigravity.Tools_$($script:ReleaseVersion)_x64-setup.exe\"\n    $script:Filename = \"Antigravity.Tools_$($script:ReleaseVersion)_x64-setup.exe\"\n\n    Info \"Download URL: $($script:DownloadUrl)\"\n}\n\nfunction Install-App {\n    $tempDir = [System.IO.Path]::GetTempPath()\n    $downloadPath = Join-Path $tempDir $script:Filename\n\n    Info \"Downloading $AppName v$($script:ReleaseVersion)...\"\n\n    if ($DryRun) {\n        Write-ColorOutput \"Yellow\" \"[DRY-RUN] Invoke-WebRequest -Uri $($script:DownloadUrl) -OutFile $downloadPath\"\n    } else {\n        try {\n            $ProgressPreference = 'Continue'\n            Invoke-WebRequest -Uri $script:DownloadUrl -OutFile $downloadPath -UseBasicParsing\n        } catch {\n            Script-Error \"Download failed: $_\"\n            Script-Error \"URL: $($script:DownloadUrl)\"\n            return $false\n        }\n    }\n\n    # Verify download\n    if (-not $DryRun -and -not (Test-Path $downloadPath)) {\n        Script-Error \"Downloaded file not found at $downloadPath\"\n        return $false\n    }\n\n    Success \"Downloaded to $downloadPath\"\n\n    Info \"Running installer...\"\n\n    if ($DryRun) {\n        Write-ColorOutput \"Yellow\" \"[DRY-RUN] Start-Process -FilePath $downloadPath -Wait\"\n    } else {\n        try {\n            Start-Process -FilePath $downloadPath -Wait\n        } catch {\n            Script-Error \"Installation failed: $_\"\n            return $false\n        }\n    }\n\n    # Cleanup\n    if (-not $DryRun -and (Test-Path $downloadPath)) {\n        Remove-Item $downloadPath -Force\n        Info \"Cleaned up installer file\"\n    }\n\n    return $true\n}\n\n# Main\nWrite-Host \"\"\nWrite-ColorOutput \"Cyan\" \"========================================\"\nWrite-ColorOutput \"Cyan\" \"    $AppName Installer\"\nWrite-ColorOutput \"Cyan\" \"========================================\"\nWrite-Host \"\"\n\n# Step 1: Get version\nif (-not (Get-ReleaseVersion)) {\n    Wait-AndExit 1\n}\n\n# Step 2: Build download URL\nGet-DownloadUrl\n\n# Step 3: Download and install\nif (-not (Install-App)) {\n    Wait-AndExit 1\n}\n\nif ($script:HasError) {\n    Wait-AndExit 1\n}\n\nWrite-Host \"\"\nSuccess \"Installation complete!\"\nWrite-Host \"\"\nInfo \"Launch '$AppName' from the Start Menu or desktop shortcut.\"\nWrite-Host \"\"\n\n# Only wait if running interactively\nif ($Host.Name -eq \"ConsoleHost\") {\n    Wait-AndExit 0\n}\n"
  },
  {
    "path": "install.sh",
    "content": "#!/usr/bin/env bash\n# Antigravity Tools Install Script (Linux + macOS)\n# Usage: curl -fsSL https://raw.githubusercontent.com/lbjlaq/Antigravity-Manager/main/install.sh | bash\n#\n# Environment variables:\n#   VERSION     - Install specific version (e.g., \"4.1.20\"), default: latest\n#   DRY_RUN     - Set to \"1\" to print commands without executing\n\nset -euo pipefail\n\n# Colors\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\nREPO=\"lbjlaq/Antigravity-Manager\"\nAPP_NAME=\"Antigravity Tools\"\nAPP_ID=\"com.lbjlaq.antigravity-tools\"\nGITHUB_API=\"https://api.github.com/repos/${REPO}/releases\"\n\n# Helper functions\ninfo() { echo -e \"${BLUE}[INFO]${NC} $1\"; }\nsuccess() { echo -e \"${GREEN}[OK]${NC} $1\"; }\nwarn() { echo -e \"${YELLOW}[WARN]${NC} $1\"; }\nerror() { echo -e \"${RED}[ERROR]${NC} $1\" >&2; exit 1; }\n\nrun() {\n    if [[ \"${DRY_RUN:-0}\" == \"1\" ]]; then\n        echo -e \"${YELLOW}[DRY-RUN]${NC} $*\"\n    else\n        \"$@\"\n    fi\n}\n\n# Show help\nshow_help() {\n    cat << EOF\n${APP_NAME} Install Script\n\nUsage:\n    curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | bash\n\n    # Install specific version\n    curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | VERSION=4.1.30 bash\n\nOptions:\n    --help      Show this help message\n    --version   Show script version\n\nEnvironment Variables:\n    VERSION     Install specific version (default: latest)\n    DRY_RUN     Set to \"1\" to preview commands without executing\n\nSupported Platforms:\n    - Linux x86_64:  .deb (Debian/Ubuntu), .rpm (Fedora/RHEL), .AppImage (Universal)\n    - Linux aarch64: .deb (Debian/Ubuntu), .rpm (Fedora/RHEL), .AppImage (Universal)\n    - macOS x86_64:  .dmg\n    - macOS arm64:   .dmg\n\nEOF\n    exit 0\n}\n\n# Detect OS and architecture\ndetect_platform() {\n    OS=\"$(uname -s)\"\n    ARCH=\"$(uname -m)\"\n\n    case \"$OS\" in\n        Linux)  PLATFORM=\"linux\" ;;\n        Darwin) PLATFORM=\"macos\" ;;\n        *)      error \"Unsupported OS: $OS. Use install.ps1 for Windows.\" ;;\n    esac\n\n    case \"$ARCH\" in\n        x86_64|amd64)   ARCH_LABEL=\"x86_64\"; DEB_ARCH=\"amd64\"; RPM_ARCH=\"x86_64\" ;;\n        aarch64|arm64)  ARCH_LABEL=\"aarch64\"; DEB_ARCH=\"arm64\"; RPM_ARCH=\"aarch64\" ;;\n        *)              error \"Unsupported architecture: $ARCH\" ;;\n    esac\n\n    info \"Detected: $PLATFORM ($ARCH_LABEL)\"\n}\n\n# Detect Linux package manager\ndetect_linux_distro() {\n    if [[ \"$PLATFORM\" != \"linux\" ]]; then\n        return\n    fi\n\n    if command -v apt-get &>/dev/null; then\n        PKG_MANAGER=\"apt\"\n        PKG_EXT=\"deb\"\n    elif command -v dnf &>/dev/null; then\n        PKG_MANAGER=\"dnf\"\n        PKG_EXT=\"rpm\"\n    elif command -v yum &>/dev/null; then\n        PKG_MANAGER=\"yum\"\n        PKG_EXT=\"rpm\"\n    else\n        PKG_MANAGER=\"appimage\"\n        PKG_EXT=\"AppImage\"\n        warn \"No supported package manager found, using AppImage\"\n    fi\n\n    info \"Package manager: $PKG_MANAGER ($PKG_EXT)\"\n}\n\n# Get latest or specific version\nget_version() {\n    if [[ -n \"${VERSION:-}\" ]]; then\n        RELEASE_VERSION=\"$VERSION\"\n        info \"Using specified version: v$RELEASE_VERSION\"\n        return\n    fi\n\n    info \"Fetching latest version...\"\n\n    # Method 1: Try GitHub API\n    local response\n    if response=$(curl -fsSL -H \"User-Agent: Antigravity-Installer\" \"${GITHUB_API}/latest\" 2>/dev/null); then\n        RELEASE_VERSION=$(echo \"$response\" | grep '\"tag_name\"' | sed -E 's/.*\"v([^\"]+)\".*/\\1/')\n        if [[ -n \"$RELEASE_VERSION\" ]]; then\n            info \"Latest version: v$RELEASE_VERSION\"\n            return\n        fi\n    fi\n\n    # Method 2: Fallback - parse from redirect URL (no rate limit)\n    info \"API rate limited, using fallback method...\"\n    local redirect_url\n    redirect_url=$(curl -fsSI \"https://github.com/${REPO}/releases/latest\" 2>/dev/null | grep -i \"^location:\" | tr -d '\\r' | awk '{print $2}')\n\n    if [[ -n \"$redirect_url\" ]]; then\n        RELEASE_VERSION=$(echo \"$redirect_url\" | sed -E 's|.*/tag/v||')\n    fi\n\n    if [[ -z \"${RELEASE_VERSION:-}\" ]]; then\n        error \"Failed to fetch latest version. Try specifying VERSION=x.x.x\"\n    fi\n\n    info \"Latest version: v$RELEASE_VERSION\"\n}\n\n# Build download URL based on platform and package manager\nbuild_download_url() {\n    local base_url=\"https://github.com/${REPO}/releases/download/v${RELEASE_VERSION}\"\n\n    case \"$PLATFORM\" in\n        linux)\n            case \"$PKG_EXT\" in\n                deb)\n                    # Antigravity.Tools_4.1.30_amd64.deb or _arm64.deb\n                    DOWNLOAD_URL=\"${base_url}/Antigravity.Tools_${RELEASE_VERSION}_${DEB_ARCH}.deb\"\n                    FILENAME=\"Antigravity.Tools_${RELEASE_VERSION}_${DEB_ARCH}.deb\"\n                    ;;\n                rpm)\n                    # Antigravity.Tools-4.1.30-1.x86_64.rpm or -1.aarch64.rpm\n                    DOWNLOAD_URL=\"${base_url}/Antigravity.Tools-${RELEASE_VERSION}-1.${RPM_ARCH}.rpm\"\n                    FILENAME=\"Antigravity.Tools-${RELEASE_VERSION}-1.${RPM_ARCH}.rpm\"\n                    ;;\n                AppImage)\n                    # Antigravity.Tools_4.1.30_amd64.AppImage or _aarch64.AppImage\n                    local appimage_arch\n                    if [[ \"$ARCH_LABEL\" == \"x86_64\" ]]; then\n                        appimage_arch=\"amd64\"\n                    else\n                        appimage_arch=\"aarch64\"\n                    fi\n                    DOWNLOAD_URL=\"${base_url}/Antigravity.Tools_${RELEASE_VERSION}_${appimage_arch}.AppImage\"\n                    FILENAME=\"Antigravity.Tools_${RELEASE_VERSION}_${appimage_arch}.AppImage\"\n                    ;;\n            esac\n            ;;\n        macos)\n            # Prefer universal DMG, fallback to arch-specific\n            DOWNLOAD_URL=\"${base_url}/Antigravity.Tools_${RELEASE_VERSION}_universal.dmg\"\n            FILENAME=\"Antigravity.Tools_${RELEASE_VERSION}_universal.dmg\"\n            ;;\n    esac\n\n    info \"Download URL: $DOWNLOAD_URL\"\n}\n\n# Download installer\ndownload_installer() {\n    TEMP_DIR=$(mktemp -d)\n    DOWNLOAD_PATH=\"${TEMP_DIR}/${FILENAME}\"\n\n    info \"Downloading ${APP_NAME} v${RELEASE_VERSION}...\"\n    run curl -fSL --progress-bar -o \"$DOWNLOAD_PATH\" \"$DOWNLOAD_URL\"\n\n    if [[ \"${DRY_RUN:-0}\" != \"1\" ]] && [[ ! -f \"$DOWNLOAD_PATH\" ]]; then\n        error \"Download failed. Check your network or try a different version.\"\n    fi\n\n    success \"Downloaded to $DOWNLOAD_PATH\"\n}\n\n# Install on Linux\ninstall_linux() {\n    info \"Installing ${APP_NAME}...\"\n\n    case \"$PKG_MANAGER\" in\n        apt)\n            run sudo dpkg -i \"$DOWNLOAD_PATH\"\n            run sudo apt-get install -f -y  # Fix dependencies if needed\n            ;;\n        dnf)\n            run sudo dnf install -y \"$DOWNLOAD_PATH\"\n            ;;\n        yum)\n            run sudo yum install -y \"$DOWNLOAD_PATH\"\n            ;;\n        appimage)\n            local install_dir=\"${HOME}/.local/bin\"\n            run mkdir -p \"$install_dir\"\n            run chmod +x \"$DOWNLOAD_PATH\"\n            run cp \"$DOWNLOAD_PATH\" \"${install_dir}/antigravity-tools\"\n\n            if [[ \":$PATH:\" != *\":${install_dir}:\"* ]]; then\n                warn \"Add ${install_dir} to your PATH to run antigravity-tools from anywhere\"\n\n                local shell_name rc_file export_line\n                shell_name=\"$(basename \"${SHELL:-/bin/bash}\")\"\n                case \"$shell_name\" in\n                    zsh)  rc_file=\"$HOME/.zshrc\" ;;\n                    fish) rc_file=\"$HOME/.config/fish/config.fish\" ;;\n                    *)    rc_file=\"$HOME/.bashrc\" ;;\n                esac\n\n                export_line=\"export PATH=\\\"${install_dir}:\\$PATH\\\"\"\n                [[ \"$shell_name\" == \"fish\" ]] && export_line=\"fish_add_path ${install_dir}\"\n\n                if [[ -f \"$rc_file\" ]] && grep -qF \"$install_dir\" \"$rc_file\" 2>/dev/null; then\n                    info \"PATH entry already in $rc_file\"\n                else\n                    run echo \"$export_line\" >> \"$rc_file\"\n                    info \"Added ${install_dir} to PATH in $rc_file\"\n                    warn \"Run: source $rc_file  (or restart terminal)\"\n                fi\n            fi\n            ;;\n    esac\n\n    success \"${APP_NAME} installed successfully!\"\n}\n\n# Install on macOS\ninstall_macos() {\n    info \"Installing ${APP_NAME}...\"\n\n    if [[ \"${DRY_RUN:-0}\" == \"1\" ]]; then\n        echo -e \"${YELLOW}[DRY-RUN]${NC} hdiutil attach $DOWNLOAD_PATH -nobrowse -noautoopen\"\n        echo -e \"${YELLOW}[DRY-RUN]${NC} cp -R <mount>/${APP_NAME}.app /Applications/\"\n        echo -e \"${YELLOW}[DRY-RUN]${NC} hdiutil detach <mount>\"\n        echo -e \"${YELLOW}[DRY-RUN]${NC} sudo xattr -rd com.apple.quarantine /Applications/${APP_NAME}.app\"\n        return\n    fi\n\n    # Mount DMG\n    local mount_output mount_point\n    mount_output=$(hdiutil attach \"$DOWNLOAD_PATH\" -nobrowse -noautoopen 2>&1)\n    mount_point=$(echo \"$mount_output\" | grep -o '/Volumes/.*' | head -n1)\n\n    if [[ -z \"$mount_point\" ]]; then\n        error \"Failed to mount DMG. Output: $mount_output\"\n    fi\n\n    # Copy app to /Applications\n    if [[ -d \"/Applications/${APP_NAME}.app\" ]]; then\n        info \"Removing existing installation...\"\n        rm -rf \"/Applications/${APP_NAME}.app\"\n    fi\n    cp -R \"${mount_point}/${APP_NAME}.app\" /Applications/\n\n    # Unmount DMG\n    hdiutil detach \"$mount_point\" -quiet 2>/dev/null || true\n\n    # Remove quarantine attribute to avoid \"app is damaged\" error\n    info \"Removing quarantine attribute...\"\n    sudo xattr -rd com.apple.quarantine \"/Applications/${APP_NAME}.app\" 2>/dev/null || true\n\n    success \"${APP_NAME} installed to /Applications!\"\n}\n\n# Cleanup\ncleanup() {\n    if [[ -n \"${TEMP_DIR:-}\" ]] && [[ -d \"$TEMP_DIR\" ]]; then\n        rm -rf \"$TEMP_DIR\"\n    fi\n}\n\n# Main\nmain() {\n    for arg in \"$@\"; do\n        case \"$arg\" in\n            --help|-h)    show_help ;;\n            --version|-v) echo \"install.sh v1.0.0\"; exit 0 ;;\n        esac\n    done\n\n    echo \"\"\n    echo -e \"${BLUE}========================================${NC}\"\n    echo -e \"${BLUE}    ${APP_NAME} Installer${NC}\"\n    echo -e \"${BLUE}========================================${NC}\"\n    echo \"\"\n\n    trap cleanup EXIT\n\n    detect_platform\n    detect_linux_distro\n    get_version\n    build_download_url\n    download_installer\n\n    case \"$PLATFORM\" in\n        linux) install_linux ;;\n        macos) install_macos ;;\n    esac\n\n    echo \"\"\n    success \"Installation complete!\"\n    echo \"\"\n    info \"Launch '${APP_NAME}' from your application menu or launcher.\"\n    echo \"\"\n}\n\nmain \"$@\"\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"version\": \"4.1.30\",\n  \"name\": \"antigravity-tools\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"tsc && vite build\",\n    \"preview\": \"vite preview\",\n    \"tauri\": \"tauri\",\n    \"tauri:debug\": \"RUST_LOG=debug npm run tauri dev\"\n  },\n  \"dependencies\": {\n    \"@ant-design/icons\": \"^5.6.1\",\n    \"@dnd-kit/core\": \"^6.3.1\",\n    \"@dnd-kit/sortable\": \"^10.0.0\",\n    \"@dnd-kit/utilities\": \"^3.2.2\",\n    \"@emotion/react\": \"^11.14.0\",\n    \"@emotion/styled\": \"^11.14.0\",\n    \"@lobehub/fluent-emoji\": \"^4.1.0\",\n    \"@lobehub/icons\": \"^4.2.0\",\n    \"@lobehub/ui\": \"^4.33.4\",\n    \"@tanstack/react-virtual\": \"^3.13.18\",\n    \"@tauri-apps/api\": \"^2\",\n    \"@tauri-apps/plugin-autostart\": \"^2.5.1\",\n    \"@tauri-apps/plugin-dialog\": \"^2.6.0\",\n    \"@tauri-apps/plugin-fs\": \"^2.4.5\",\n    \"@tauri-apps/plugin-opener\": \"^2\",\n    \"@tauri-apps/plugin-process\": \"^2.3.1\",\n    \"@tauri-apps/plugin-updater\": \"^2.9.0\",\n    \"antd\": \"^5.24.6\",\n    \"antd-style\": \"^3.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"daisyui\": \"^5.5.13\",\n    \"date-fns\": \"^4.1.0\",\n    \"framer-motion\": \"^11.13.1\",\n    \"i18next\": \"^25.7.2\",\n    \"i18next-browser-languagedetector\": \"^8.2.0\",\n    \"lucide-react\": \"^0.561.0\",\n    \"react\": \"^19.1.0\",\n    \"react-dom\": \"^19.1.0\",\n    \"react-i18next\": \"^16.5.0\",\n    \"react-is\": \"^19.1.0\",\n    \"react-router\": \"^7.12.0\",\n    \"react-router-dom\": \"^7.10.1\",\n    \"recharts\": \"^3.5.1\",\n    \"tailwind-merge\": \"^2.3.0\",\n    \"zustand\": \"^5.0.9\"\n  },\n  \"devDependencies\": {\n    \"@tailwindcss/container-queries\": \"^0.1.1\",\n    \"@tauri-apps/cli\": \"^2\",\n    \"@types/react\": \"^19.1.8\",\n    \"@types/react-dom\": \"^19.1.6\",\n    \"@vitejs/plugin-react\": \"^4.6.0\",\n    \"autoprefixer\": \"^10.4.22\",\n    \"postcss\": \"^8.5.6\",\n    \"tailwindcss\": \"^3.4.19\",\n    \"typescript\": \"~5.8.3\",\n    \"vite\": \"^7.0.4\",\n    \"vitepress\": \"^1.6.4\",\n    \"vue\": \"^3.5.27\"\n  }\n}"
  },
  {
    "path": "postcss.config.cjs",
    "content": "module.exports = {\n    plugins: {\n        tailwindcss: {},\n        autoprefixer: {},\n    },\n}\n"
  },
  {
    "path": "scripts/Fix_Damaged.command",
    "content": "#!/bin/bash\n\n# 获取当前脚本所在目录\nDIR=\"$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )\" && pwd )\"\nAPP_PATH=\"$DIR/Antigravity Tools.app\"\n\n# 定义颜色\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nNC='\\033[0m' # No Color\n\necho -e \"${GREEN}==============================================${NC}\"\necho -e \"${GREEN}   Antigravity Tools - 快速修复助手${NC}\"\necho -e \"${GREEN}==============================================${NC}\"\necho \"\"\n\nif [ -d \"$APP_PATH\" ]; then\n    echo \"📍 正在尝试修复应用: $APP_PATH\"\n    echo \"🔑 请输入您的开机密码以授予权限 (输入时不会显示)...\"\n    echo \"\"\n    \n    # 尝试移除隔离属性\n    sudo xattr -rd com.apple.quarantine \"$APP_PATH\"\n    \n    if [ $? -eq 0 ]; then\n        echo \"\"\n        echo -e \"${GREEN}✅ 修复成功!${NC}\"\n        echo \"您现在可以像往常一样打开应用了。\"\n        \n        # 尝试通过 AppleScript 弹窗提示成功\n        osascript -e 'display notification \"修复成功，现在可以打开应用了\" with title \"Antigravity Tools\" sound name \"Glass\"'\n    else\n        echo \"\"\n        echo -e \"${RED}❌ 修复失败${NC}\"\n        echo \"请检查密码是否输入正确，或稍后重试。\"\n    fi\nelse\n    echo -e \"${RED}⚠️  未找到应用文件${NC}\"\n    echo \"请确保将此修复脚本和 'Antigravity Tools.app' 放在同一个文件夹内 (通常是 /Applications)。\"\nfi\n\necho \"\"\necho \"按任意键退出...\"\nread -n 1 -s -r -p \"\"\n"
  },
  {
    "path": "scripts/MANUAL_PR_CLOSE_GUIDE.md",
    "content": "# 手动关闭已集成 PR 指南\n\n如果你不想使用 GitHub CLI，可以按照以下步骤手动关闭 PR。\n\n## 需要关闭的 PR 列表\n\n以下 PR 已被手动集成到 v3.3.43：\n\n1. **PR #825** - [Internationalization] Device Fingerprint Dialog localization (@IamAshrafee)\n2. **PR #822** - [Japanese] Add missing translations and refine terminology (@Koshikai)\n3. **PR #798** - [Translation Fix] Correct spelling error in Vietnamese settings (@vietnhatthai)\n\n---\n\n## 操作步骤\n\n对于每个 PR，执行以下步骤：\n\n### 1. 访问 PR 页面\n\n点击以下链接访问对应的 PR：\n\n- https://github.com/lbjlaq/Antigravity-Manager/pull/825\n- https://github.com/lbjlaq/Antigravity-Manager/pull/822\n- https://github.com/lbjlaq/Antigravity-Manager/pull/798\n\n### 2. 添加感谢评论\n\n在 PR 页面底部的评论框中，粘贴以下感谢消息：\n\n```markdown\n感谢您的贡献！🎉\n\n此 PR 的更改已被手动集成到 v3.3.43 版本中。\n\n相关更新已包含在以下文件中：\n- README.md 的版本更新日志\n- 贡献者列表\n\n再次感谢您对 Antigravity Tools 项目的支持！\n\n---\n\nThank you for your contribution! 🎉\n\nThe changes from this PR have been manually integrated into v3.3.43.\n\nThe updates are documented in:\n- README.md changelog\n- Contributors list\n\nThank you again for your support of the Antigravity Tools project!\n```\n\n### 3. 关闭 PR\n\n1. 点击评论框下方的 **\"Close pull request\"** 按钮\n2. 或者点击 **\"Close with comment\"** 按钮（如果你想同时添加评论）\n\n---\n\n## 快速操作清单\n\n- [ ] PR #825 - 添加评论 + 关闭\n- [ ] PR #822 - 添加评论 + 关闭\n- [ ] PR #798 - 添加评论 + 关闭\n\n---\n\n## 验证\n\n完成后，访问以下链接确认所有 PR 已关闭：\n\nhttps://github.com/lbjlaq/Antigravity-Manager/pulls?q=is%3Apr+is%3Aclosed\n"
  },
  {
    "path": "scripts/close_integrated_prs.sh",
    "content": "#!/bin/bash\n\n# 关闭已集成到 v4.0.3 的 PR 脚本\n# 使用前请确保已安装并登录 GitHub CLI: brew install gh && gh auth login\n\nREPO=\"lbjlaq/Antigravity-Manager\"\nVERSION=\"v4.0.3\"\n\n# 感谢消息模板\nTHANK_YOU_MESSAGE=\"感谢您的贡献！🎉\n\n此 PR 的更改已被手动集成到 ${VERSION} 版本中。\n\n相关更新已包含在以下文件中：\n- README.md 的版本更新日志\n- 贡献者列表\n\n再次感谢您对 Antigravity Tools 项目的支持！\n\n---\n\nThank you for your contribution! 🎉\n\nThe changes from this PR have been manually integrated into ${VERSION}.\n\nThe updates are documented in:\n- README.md changelog\n- Contributors list\n\nThank you again for your support of the Antigravity Tools project!\"\n\necho \"================================================\"\necho \"关闭已集成到 ${VERSION} 的 PR\"\necho \"================================================\"\necho \"\"\n\n# PR 列表：格式为 \"PR号|作者|标题\"\nPRS_LIST=(\n    \"825|IamAshrafee|[Internationalization] Device Fingerprint Dialog localization\"\n    \"822|Koshikai|[Japanese] Add missing translations and refine terminology\",\n    \"798|vietnhatthai|[Translation Fix] Correct spelling error in Vietnamese settings\",\n    \"846|lengjingxu|[核心功能] 客户端热更新与 Token 统计系统\",\n    \"949|lbjlaq|Streaming chunks order fix\",\n    \"950|lbjlaq|[Fix] Remove redundant code and update README\",\n    \"973|Mag1cFall|fix: 修复 Windows 平台启动参数不生效的问题\"\n)\n\n# 检查 GitHub CLI 是否已安装\nif ! command -v gh &> /dev/null; then\n    echo \"❌ GitHub CLI 未安装\"\n    echo \"\"\n    echo \"请先安装 GitHub CLI:\"\n    echo \"  brew install gh\"\n    echo \"\"\n    echo \"然后登录:\"\n    echo \"  gh auth login\"\n    echo \"\"\n    exit 1\nfi\n\n# 检查是否已登录\nif ! gh auth status &> /dev/null; then\n    echo \"❌ 未登录 GitHub CLI\"\n    echo \"\"\n    echo \"请先登录:\"\n    echo \"  gh auth login\"\n    echo \"\"\n    exit 1\nfi\n\necho \"✅ GitHub CLI 已就绪\"\necho \"\"\n\n# 遍历并处理每个 PR\nfor item in \"${PRS_LIST[@]}\"; do\n    PR_NUM=$(echo \"$item\" | cut -d'|' -f1)\n    AUTHOR=$(echo \"$item\" | cut -d'|' -f2)\n    TITLE=$(echo \"$item\" | cut -d'|' -f3)\n    \n    echo \"----------------------------------------\"\n    echo \"处理 PR #${PR_NUM}: ${TITLE}\"\n    echo \"作者: @${AUTHOR}\"\n    echo \"----------------------------------------\"\n    \n    # 添加感谢评论\n    echo \"📝 添加感谢评论...\"\n    gh pr comment ${PR_NUM} --repo ${REPO} --body \"${THANK_YOU_MESSAGE}\"\n    \n    if [ $? -eq 0 ]; then\n        echo \"✅ 评论已添加\"\n    else\n        echo \"❌ 评论添加失败\"\n        continue\n    fi\n    \n    # 关闭 PR\n    echo \"🔒 关闭 PR...\"\n    gh pr close ${PR_NUM} --repo ${REPO} --comment \"已集成到 ${VERSION}，关闭此 PR。\"\n    \n    if [ $? -eq 0 ]; then\n        echo \"✅ PR #${PR_NUM} 已关闭\"\n    else\n        echo \"❌ PR #${PR_NUM} 关闭失败\"\n    fi\n    \n    echo \"\"\n    sleep 2  # 避免 API 限流\ndone\n\necho \"================================================\"\necho \"✅ 所有 PR 处理完成！\"\necho \"================================================\"\necho \"\"\necho \"请访问以下链接查看结果：\"\necho \"https://github.com/${REPO}/pulls?q=is%3Apr+is%3Aclosed\"\n"
  },
  {
    "path": "scripts/fix_app.sh",
    "content": "#!/bin/bash\n\nAPP_PATH=\"/Applications/Antigravity Tools.app\"\n\necho \"🛠️  修复 'Antigravity Tools' 已损坏问题...\"\n\nif [ -d \"$APP_PATH\" ]; then\n    echo \"📍 找到应用: $APP_PATH\"\n    echo \"🔑 需要管理员权限来移除隔离属性 (Quarantine Attribute)...\"\n    \n    sudo xattr -rd com.apple.quarantine \"$APP_PATH\"\n    \n    if [ $? -eq 0 ]; then\n        echo \"✅ 修复成功！现在应该可以正常打开应用了。\"\n    else\n        echo \"❌ 修复失败，请检查密码是否正确或是否有权限。\"\n    fi\nelse\n    echo \"⚠️  未找到应用，请确认应用已安装在 '/Applications' 目录下。\"\n    echo \"   如果安装在其他位置，请手动运行: sudo xattr -rd com.apple.quarantine /path/to/app\"\nfi\n"
  },
  {
    "path": "scripts/package_dmg.sh",
    "content": "#!/bin/bash\n\n# Configuration\nAPP_NAME=\"Antigravity Tools\"\nVERSION=$(grep '\"version\":' package.json | head -n 1 | awk -F: '{ print $2 }' | sed 's/[\", ]//g')\nDMG_NAME=\"Antigravity_Tools_${VERSION}_ManualFix.dmg\"\nSRC_APP_PATH=\"src-tauri/target/release/bundle/macos/${APP_NAME}.app\"\nDIST_DIR=\"dist_dmg\"\n\necho \"📦 开始打包 DMG (带修复脚本)...\"\necho \"版本: $VERSION\"\n\n# 1. 检查构建是否存在\nif [ ! -d \"$SRC_APP_PATH\" ]; then\n    echo \"❌ 错误: 未找到构建好的 App。\"\n    echo \"请先运行: npm run tauri build\"\n    exit 1\nfi\n\n# 2. 准备临时目录\nrm -rf \"$DIST_DIR\"\nmkdir -p \"$DIST_DIR\"\n\n# 3. 复制文件\necho \"Checking source app...\"\ncp -R \"$SRC_APP_PATH\" \"$DIST_DIR/\"\necho \"Copying fix script...\"\ncp \"scripts/Fix_Damaged.command\" \"$DIST_DIR/\"\nchmod +x \"$DIST_DIR/Fix_Damaged.command\"\n\n# 4. 创建 /Applications 软连接\nln -s /Applications \"$DIST_DIR/Applications\"\n\n# 5. 打包 DMG\necho \"Creating DMG...\"\nrm -f \"$DMG_NAME\"\nhdiutil create -volname \"${APP_NAME}\" -srcfolder \"$DIST_DIR\" -ov -format UDZO \"$DMG_NAME\"\n\n# 6. 清理\nrm -rf \"$DIST_DIR\"\n\necho \"✅ 打包完成!\"\necho \"文件位置: $PWD/$DMG_NAME\"\n"
  },
  {
    "path": "src/App.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n/* 禁止过度滚动和橡皮筋效果 */\nhtml,\nbody {\n  overscroll-behavior: none;\n  height: 100%;\n  overflow-y: hidden;\n  overflow-x: hidden;\n  margin: 0;\n  padding: 0;\n  border: none;\n}\n\nhtml {\n  background-color: #FAFBFC;\n}\n\nhtml.dark {\n  background-color: #1d232a;\n}\n\n/* 全局样式 */\nbody {\n  margin: 0;\n  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',\n    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',\n    sans-serif;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  background-color: #FAFBFC;\n}\n\n/* Dark mode override for body strictly */\n.dark body {\n  background-color: #1d232a;\n  /* matches base-300 commonly used */\n}\n\n#root {\n  width: 100%;\n  height: 100%;\n  overflow-y: auto;\n  overflow-x: hidden;\n  overscroll-behavior: none;\n}\n\n/* 移除默认的 tap 高亮 */\n* {\n  -webkit-tap-highlight-color: transparent;\n}\n\n/* 只移除链接的默认下划线，不强制颜色 */\na {\n  text-decoration: none;\n}\n\n/* 滚动条优化 - 彻底隐藏但保留功能 */\n::-webkit-scrollbar {\n  width: 0px;\n  background: transparent;\n}\n\n::-webkit-scrollbar-track {\n  background-color: transparent;\n}\n\n::-webkit-scrollbar-thumb {\n  background-color: rgba(0, 0, 0, 0.1);\n  border-radius: 99px;\n  border: 3px solid transparent;\n  background-clip: content-box;\n  transition: background-color 0.2s;\n}\n\n::-webkit-scrollbar-thumb:hover {\n  background-color: rgba(0, 0, 0, 0.3);\n}\n\n/* View Transitions API 主题切换动画 */\n::view-transition-old(root),\n::view-transition-new(root) {\n  animation: none;\n  mix-blend-mode: normal;\n}\n\n::view-transition-old(root) {\n  z-index: 1;\n}\n\n::view-transition-new(root) {\n  z-index: 9999;\n}\n\n.dark::view-transition-old(root) {\n  z-index: 9999;\n}\n\n.dark::view-transition-new(root) {\n  z-index: 1;\n}\n\n/* 暗色模式下 select 下拉菜单样式 */\n.dark select,\n.dark select option,\n.dark select optgroup {\n  background-color: #1e293b;\n  color: #e2e8f0;\n}\n\n.dark select option:checked {\n  background-color: #3b82f6;\n  color: white;\n}\n\n/* Arabic Fonts - Effra */\n@font-face {\n  font-family: 'Effra';\n  src: url('/font/Effra/Effra-Regular.ttf') format('truetype');\n  font-weight: 400; /* Regular */\n  font-style: normal;\n}\n\n@font-face {\n  font-family: 'Effra';\n  src: url('/font/Effra/Effra-Medium.ttf') format('truetype');\n  font-weight: 500; /* Medium */\n  font-style: normal;\n}\n\n@font-face {\n  font-family: 'Effra';\n  src: url('/font/Effra/Effra-SemiBold.ttf') format('truetype');\n  font-weight: 600; /* SemiBold */\n  font-style: normal;\n}\n\n@font-face {\n  font-family: 'Effra';\n  src: url('/font/Effra/Effra-Bold.ttf') format('truetype');\n  font-weight: 700; /* Bold */\n  font-style: normal;\n}\n\n@font-face {\n  font-family: 'Effra';\n  src: url('/font/Effra/Effra-ExtraBold.ttf') format('truetype');\n  font-weight: 800; /* ExtraBold */\n  font-style: normal;\n}\n\n/* Apply Effra font ONLY when language direction is RTL (Arabic) */\n[dir=\"rtl\"] body {\n  font-family: 'Effra', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',\n    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',\n    sans-serif;\n}"
  },
  {
    "path": "src/App.tsx",
    "content": "import { createBrowserRouter, RouterProvider } from 'react-router-dom';\n\nimport Layout from './components/layout/Layout';\nimport Dashboard from './pages/Dashboard';\nimport Accounts from './pages/Accounts';\nimport Settings from './pages/Settings';\nimport ApiProxy from './pages/ApiProxy';\nimport Monitor from './pages/Monitor';\nimport TokenStats from './pages/TokenStats';\nimport Security from './pages/Security';\nimport ThemeManager from './components/common/ThemeManager';\nimport UserToken from './pages/UserToken';\nimport { UpdateNotification } from './components/UpdateNotification';\nimport DebugConsole from './components/debug/DebugConsole';\nimport { useEffect, useState } from 'react';\nimport { useConfigStore } from './stores/useConfigStore';\nimport { useAccountStore } from './stores/useAccountStore';\nimport { useTranslation } from 'react-i18next';\nimport { listen } from '@tauri-apps/api/event';\nimport { isTauri } from './utils/env';\nimport { request as invoke } from './utils/request';\nimport { AdminAuthGuard } from './components/common/AdminAuthGuard';\n\nconst router = createBrowserRouter([\n  {\n    path: '/',\n    element: <Layout />,\n    children: [\n      {\n        index: true,\n        element: <Dashboard />,\n      },\n      {\n        path: 'accounts',\n        element: <Accounts />,\n      },\n      {\n        path: 'api-proxy',\n        element: <ApiProxy />,\n      },\n      {\n        path: 'monitor',\n        element: <Monitor />,\n      },\n      {\n        path: 'token-stats',\n        element: <TokenStats />,\n      },\n      {\n        path: 'user-token',\n        element: <UserToken />,\n      },\n      {\n        path: 'security',\n        element: <Security />,\n      },\n      {\n        path: 'settings',\n        element: <Settings />,\n      },\n    ],\n  },\n]);\n\nfunction App() {\n  const { config, loadConfig } = useConfigStore();\n  const { fetchCurrentAccount, fetchAccounts } = useAccountStore();\n  const { i18n } = useTranslation();\n\n  useEffect(() => {\n    loadConfig();\n  }, [loadConfig]);\n\n  // Sync language from config\n  useEffect(() => {\n    if (config?.language) {\n      i18n.changeLanguage(config.language);\n      // Support RTL\n      if (config.language === 'ar') {\n        document.documentElement.dir = 'rtl';\n      } else {\n        document.documentElement.dir = 'ltr';\n      }\n    }\n  }, [config?.language, i18n]);\n\n  // Listen for tray events\n  useEffect(() => {\n    if (!isTauri()) return;\n    const unlistenPromises: Promise<() => void>[] = [];\n\n    // 监听托盘切换账号事件\n    unlistenPromises.push(\n      listen('tray://account-switched', () => {\n        console.log('[App] Tray account switched, refreshing...');\n        fetchCurrentAccount();\n        fetchAccounts();\n      })\n    );\n\n    // 监听托盘刷新事件\n    unlistenPromises.push(\n      listen('tray://refresh-current', () => {\n        console.log('[App] Tray refresh triggered, refreshing...');\n        fetchCurrentAccount();\n        fetchAccounts();\n      })\n    );\n\n    // 监听后端全量刷新事件 (Command / Scheduler)\n    unlistenPromises.push(\n      listen('accounts://refreshed', () => {\n        console.log('[App] Backend triggered quota refresh, syncing UI...');\n        fetchCurrentAccount();\n        fetchAccounts();\n      })\n    );\n\n    // Cleanup\n    return () => {\n      Promise.all(unlistenPromises).then(unlisteners => {\n        unlisteners.forEach(unlisten => unlisten());\n      });\n    };\n  }, [fetchCurrentAccount, fetchAccounts]);\n\n  // Update notification state\n  const [showUpdateNotification, setShowUpdateNotification] = useState(false);\n\n  // Check for updates on startup\n  useEffect(() => {\n    const checkUpdates = async () => {\n      try {\n        console.log('[App] Checking if we should check for updates...');\n        const shouldCheck = await invoke<boolean>('should_check_updates');\n        console.log('[App] Should check updates:', shouldCheck);\n\n        if (shouldCheck) {\n          setShowUpdateNotification(true);\n          // 我们这里只负责显示通知组件，通知组件内部会去调用 check_for_updates\n          // 我们在显示组件后，标记已经检查过了（即便失败或无更新，组件内部也会处理）\n          await invoke('update_last_check_time');\n          console.log('[App] Update check cycle initiated and last check time updated.');\n        }\n      } catch (error) {\n        console.error('Failed to check update settings:', error);\n      }\n    };\n\n    // Delay check to avoid blocking initial render\n    const timer = setTimeout(checkUpdates, 2000);\n    return () => clearTimeout(timer);\n  }, []);\n\n  return (\n    <AdminAuthGuard>\n      <ThemeManager />\n      <DebugConsole />\n      {showUpdateNotification && (\n        <UpdateNotification onClose={() => setShowUpdateNotification(false)} />\n      )}\n      <RouterProvider router={router} />\n    </AdminAuthGuard>\n  );\n}\n\nexport default App;"
  },
  {
    "path": "src/components/UpdateNotification.tsx",
    "content": "import React, { useEffect, useState, useRef } from 'react';\nimport { X, Sparkles, Loader2, CheckCircle, RotateCcw } from 'lucide-react';\nimport { request as invoke } from '../utils/request';\nimport { useTranslation } from 'react-i18next';\nimport { check as tauriCheck } from '@tauri-apps/plugin-updater';\nimport { relaunch as tauriRelaunch } from '@tauri-apps/plugin-process';\nimport { isTauri } from '../utils/env';\nimport { showToast } from './common/ToastContainer';\n\ninterface UpdateInfo {\n  has_update: boolean;\n  latest_version: string;\n  current_version: string;\n  download_url: string;\n  source?: string;\n}\n\ntype UpdateState = 'checking' | 'downloading' | 'ready' | 'error' | 'none';\n\ninterface UpdateNotificationProps {\n  onClose: () => void;\n}\n\nexport const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onClose }) => {\n  const { t } = useTranslation();\n  const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);\n  const [isVisible, setIsVisible] = useState(false);\n  const [isClosing, setIsClosing] = useState(false);\n  const [updateState, setUpdateState] = useState<UpdateState>('checking');\n  const [downloadProgress, setDownloadProgress] = useState(0);\n  const downloadStarted = useRef(false);\n\n  useEffect(() => {\n    checkAndDownload();\n  }, []);\n\n  const checkAndDownload = async () => {\n    try {\n      // 1. Check for updates via backend\n      const info = await invoke<UpdateInfo>('check_for_updates');\n      if (!info.has_update) {\n        onClose();\n        return;\n      }\n\n      setUpdateInfo(info);\n\n      // 2. If not in Tauri — no auto-update possible\n      if (!isTauri()) {\n        console.warn('Auto update is only available in Tauri environment');\n        onClose();\n        return;\n      }\n\n      // 3. Start background download immediately\n      if (downloadStarted.current) return;\n      downloadStarted.current = true;\n\n      setUpdateState('downloading');\n      setTimeout(() => setIsVisible(true), 100);\n\n      const update = await tauriCheck();\n      if (!update) {\n        // updater.json not ready yet or no update via native channel\n        console.warn('Native updater returned null');\n        showToast(t('update_notification.toast.not_ready'), 'info');\n        handleClose();\n        return;\n      }\n\n      let downloaded = 0;\n      let contentLength = 0;\n\n      await update.downloadAndInstall((event) => {\n        switch (event.event) {\n          case 'Started':\n            contentLength = event.data.contentLength || 0;\n            break;\n          case 'Progress':\n            downloaded += event.data.chunkLength;\n            if (contentLength > 0) {\n              setDownloadProgress(Math.round((downloaded / contentLength) * 100));\n            }\n            break;\n          case 'Finished':\n            break;\n        }\n      });\n\n      // 4. Download complete — show restart prompt\n      setUpdateState('ready');\n      setDownloadProgress(100);\n    } catch (error) {\n      const errorMsg = error instanceof Error ? error.message : String(error);\n      console.error('Auto update failed:', errorMsg);\n      setUpdateState('error');\n      showToast(`${t('update_notification.toast.failed')}: ${errorMsg}`, 'error');\n    }\n  };\n\n  const handleRestart = async () => {\n    try {\n      await tauriRelaunch();\n    } catch (error) {\n      console.error('Relaunch failed:', error);\n    }\n  };\n\n  const handleClose = () => {\n    setIsClosing(true);\n    setIsVisible(false);\n    setTimeout(onClose, 400);\n  };\n\n  if (updateState === 'none') {\n    return null;\n  }\n\n  return (\n    <div\n      className={`\n        fixed top-6 right-6 z-[100]\n        transition-all duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)]\n        ${isVisible && !isClosing ? 'translate-y-0 opacity-100 scale-100' : '-translate-y-4 opacity-0 scale-95'}\n      `}\n    >\n      <div className=\"\n        relative overflow-hidden\n        w-80 p-5\n        rounded-2xl\n        border border-white/20 dark:border-white/10\n        shadow-[0_8px_32px_0_rgba(31,38,135,0.15)]\n        backdrop-blur-xl\n        bg-white/70 dark:bg-slate-900/60\n        group\n      \">\n        <div className=\"absolute -top-10 -right-10 w-32 h-32 bg-blue-500/20 rounded-full blur-3xl pointer-events-none group-hover:bg-blue-500/30 transition-colors duration-500\" />\n        <div className=\"absolute -bottom-10 -left-10 w-32 h-32 bg-purple-500/20 rounded-full blur-3xl pointer-events-none group-hover:bg-purple-500/30 transition-colors duration-500\" />\n\n        <div className=\"relative z-10\">\n          <div className=\"flex items-start justify-between mb-3\">\n            <div className=\"flex items-center gap-2\">\n              <div className=\"p-1.5 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 shadow-sm\">\n                {updateState === 'ready' ? (\n                  <CheckCircle className=\"w-4 h-4 text-white\" />\n                ) : (\n                  <Sparkles className=\"w-4 h-4 text-white\" />\n                )}\n              </div>\n              <div>\n                <h3 className=\"font-bold text-gray-800 dark:text-white leading-tight\">\n                  {updateState === 'ready'\n                    ? t('update_notification.ready')\n                    : t('update_notification.title')}\n                </h3>\n                {updateInfo && (\n                  <p className=\"text-xs font-medium text-blue-600 dark:text-blue-400\">\n                    v{updateInfo.latest_version}\n                  </p>\n                )}\n              </div>\n            </div>\n\n            {(updateState === 'error' || updateState === 'ready') && (\n              <button\n                onClick={handleClose}\n                className=\"\n                  p-1 rounded-full \n                  text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300\n                  hover:bg-black/5 dark:hover:bg-white/10\n                  transition-all duration-200\n                \"\n                aria-label={t('common.cancel')}\n              >\n                <X className=\"w-4 h-4\" />\n              </button>\n            )}\n          </div>\n\n          {/* Status message */}\n          <div className=\"mb-4\">\n            <p className=\"text-sm text-gray-600 dark:text-gray-300 leading-relaxed\">\n              {updateState === 'downloading' && t('update_notification.downloading')}\n              {updateState === 'ready' && t('update_notification.restart_prompt')}\n              {updateState === 'error' && `${t('update_notification.toast.failed')}`}\n            </p>\n          </div>\n\n          {/* Progress bar during download */}\n          {updateState === 'downloading' && (\n            <div className=\"mb-4\">\n              <div className=\"w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2\">\n                <div\n                  className=\"bg-gradient-to-r from-blue-500 to-purple-600 h-2 rounded-full transition-all duration-300\"\n                  style={{ width: `${downloadProgress}%` }}\n                />\n              </div>\n              <div className=\"flex items-center justify-between mt-1\">\n                <p className=\"text-xs text-gray-500\">{downloadProgress}%</p>\n                <Loader2 className=\"w-3 h-3 animate-spin text-blue-500\" />\n              </div>\n            </div>\n          )}\n\n          {/* Restart button when ready */}\n          {updateState === 'ready' && (\n            <div className=\"flex gap-2\">\n              <button\n                onClick={handleRestart}\n                className=\"\n                  flex-1 group/btn\n                  relative overflow-hidden\n                  bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-500 hover:to-emerald-500\n                  text-white font-medium\n                  py-2.5 px-4 rounded-xl\n                  shadow-lg shadow-green-500/25\n                  transition-all duration-300\n                  flex items-center justify-center gap-2\n                  active:scale-[0.98]\n                \"\n              >\n                <RotateCcw className=\"w-4 h-4\" />\n                <span>{t('update_notification.btn_restart')}</span>\n                <div className=\"absolute inset-0 -translate-x-full group-hover/btn:animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent z-20 pointer-events-none\" />\n              </button>\n              <button\n                onClick={handleClose}\n                className=\"\n                  px-3 py-2.5 rounded-xl\n                  text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200\n                  hover:bg-black/5 dark:hover:bg-white/10\n                  transition-all duration-200\n                  text-sm font-medium\n                \"\n              >\n                {t('update_notification.btn_later')}\n              </button>\n            </div>\n          )}\n\n          {/* Error state — retry button */}\n          {updateState === 'error' && (\n            <button\n              onClick={() => {\n                downloadStarted.current = false;\n                setUpdateState('checking');\n                setDownloadProgress(0);\n                checkAndDownload();\n              }}\n              className=\"\n                w-full\n                bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500\n                text-white font-medium\n                py-2.5 px-4 rounded-xl\n                shadow-lg shadow-blue-500/25\n                transition-all duration-300\n                flex items-center justify-center gap-2\n                active:scale-[0.98]\n              \"\n            >\n              <RotateCcw className=\"w-4 h-4\" />\n              <span>{t('common.retry')}</span>\n            </button>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/accounts/AccountCard.tsx",
    "content": "import { useMemo, useState } from 'react';\nimport { ArrowRightLeft, RefreshCw, Trash2, Download, Info, Lock, Ban, Diamond, Gem, Circle, ToggleLeft, ToggleRight, Fingerprint, Sparkles, Tag, X, Check, Clock, Bot } from 'lucide-react';\nimport { Account } from '../../types/account';\nimport { cn } from '../../utils/cn';\nimport { useTranslation } from 'react-i18next';\nimport { useConfigStore } from '../../stores/useConfigStore';\nimport { QuotaItem } from './QuotaItem';\nimport { MODEL_CONFIG, sortModels } from '../../config/modelConfig';\n\ninterface AccountCardProps {\n    account: Account;\n    selected: boolean;\n    onSelect: () => void;\n    isCurrent: boolean;\n    isRefreshing: boolean;\n    isSwitching?: boolean;\n    onSwitch: () => void;\n    onRefresh: () => void;\n    onViewDevice: () => void;\n    onViewDetails: () => void;\n    onExport: () => void;\n    onDelete: () => void;\n    onToggleProxy: () => void;\n    onWarmup?: () => void;\n    onUpdateLabel?: (label: string) => void;\n    onViewError: () => void;\n}\n\n// 使用统一的模型配置\nconst DEFAULT_MODELS = Object.entries(MODEL_CONFIG).map(([id, config]) => ({\n    id,\n    label: config.label,\n    protectedKey: config.protectedKey,\n    Icon: config.Icon\n}));\n\nfunction AccountCard({ account, selected, onSelect, isCurrent: propIsCurrent, isRefreshing, isSwitching = false, onSwitch, onRefresh, onViewDetails, onExport, onDelete, onToggleProxy, onViewDevice, onWarmup, onUpdateLabel, onViewError }: AccountCardProps) {\n    const { t } = useTranslation();\n    const { config, showAllQuotas } = useConfigStore();\n    const isDisabled = Boolean(account.disabled);\n\n    // 自定义标签编辑状态\n    const [isEditingLabel, setIsEditingLabel] = useState(false);\n    const [labelInput, setLabelInput] = useState(account.custom_label || '');\n\n    // Use the prop directly from parent component\n    const isCurrent = propIsCurrent;\n\n    const handleSaveLabel = () => {\n        if (onUpdateLabel) {\n            onUpdateLabel(labelInput.trim());\n        }\n        setIsEditingLabel(false);\n    };\n\n    const handleCancelLabel = () => {\n        setLabelInput(account.custom_label || '');\n        setIsEditingLabel(false);\n    };\n\n    const handleKeyDown = (e: React.KeyboardEvent) => {\n        if (e.key === 'Enter') {\n            handleSaveLabel();\n        } else if (e.key === 'Escape') {\n            handleCancelLabel();\n        }\n    };\n\n    const displayModels = useMemo(() => {\n        // Build map of friendly labels and icons from DEFAULT_MODELS\n        const iconMap = new Map(DEFAULT_MODELS.map(m => [m.id, m.Icon]));\n\n        // Get all models from account (source of truth)\n        const accountModels = account.quota?.models?.map(m => {\n            // 注意：DEFAULT_MODELS 现在应该包含 shortLabel，我们需要确保它被正确映射\n            // 但 DEFAULT_MODELS 是从 MODEL_CONFIG 生成的，我们需要确保它包含 shortLabel\n            // 这里为了安全，直接从 MODEL_CONFIG 获取\n            const fullConfig = MODEL_CONFIG[m.name.toLowerCase()];\n            return {\n                id: m.name,\n                label: m.display_name || fullConfig?.shortLabel || fullConfig?.label || m.name,\n                protectedKey: fullConfig?.protectedKey || m.name,\n                Icon: iconMap.get(m.name) || Bot,\n                data: m\n            };\n        }) || [];\n\n        let models: typeof accountModels;\n\n        if (showAllQuotas) {\n            models = accountModels;\n        } else {\n            // Filter for pinned or defaults\n            const pinned = config?.pinned_quota_models?.models;\n            if (pinned && pinned.length > 0) {\n                models = accountModels.filter(m => pinned.includes(m.id));\n            } else {\n                // Default fallback: show known default models, plus we show all dynamic pinned models\n                // 暂时退化：如果没有 config 就不阻拦了？不，没有 pinned 就显示内置+有 display_name 的。\n                models = accountModels.filter(m => DEFAULT_MODELS.some(d => d.id === m.id) || m.data.display_name);\n            }\n        }\n\n        // 应用排序并过滤过期模型\n        return sortModels(models).filter(m => m.id !== 'claude-sonnet-4-6-thinking' && m.id !== 'claude-sonnet-4-5-thinking' && m.id !== 'claude-opus-4-5-thinking');\n    }, [config, account, showAllQuotas]);\n\n    const isModelProtected = (key?: string) => {\n        if (!key) return false;\n        return account.protected_models?.includes(key);\n    };\n\n    return (\n        <div className={cn(\n            \"flex flex-col p-3 rounded-xl border transition-all hover:shadow-md\",\n            isCurrent\n                ? \"bg-blue-50/30 border-blue-200 dark:bg-blue-900/10 dark:border-blue-900/30\"\n                : \"bg-white dark:bg-base-100 border-gray-200 dark:border-base-300\",\n            (isRefreshing || isDisabled) && \"opacity-70\"\n        )}>\n\n            {/* Header: Checkbox + Email + Badges */}\n            <div className=\"flex-none flex items-start gap-3 mb-2\">\n                <input\n                    type=\"checkbox\"\n                    className=\"mt-1 checkbox checkbox-xs rounded border-2 border-gray-400 dark:border-gray-500 checked:border-blue-600 checked:bg-blue-600 [--chkbg:theme(colors.blue.600)] [--chkfg:white]\"\n                    checked={selected}\n                    onChange={() => onSelect()}\n                    onClick={(e) => e.stopPropagation()}\n                />\n                <div className=\"flex-1 min-w-0 flex flex-col gap-1.5\">\n                    <h3 className={cn(\n                        \"font-semibold text-sm truncate w-full\",\n                        isCurrent ? \"text-blue-700 dark:text-blue-400\" : \"text-gray-900 dark:text-base-content\"\n                    )} title={account.email}>\n                        {account.email}\n                    </h3>\n                    <div className=\"flex items-center justify-between w-full gap-2\">\n                        <div className=\"flex items-center gap-1.5 flex-wrap\">\n                            {isCurrent && (\n                                <span className=\"px-1.5 py-0.5 rounded-md bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 text-[9px] font-bold shadow-sm border border-blue-200/50\">\n                                    {t('accounts.current').toUpperCase()}\n                                </span>\n                            )}\n                            {isDisabled && (\n                                <span\n                                    className=\"px-1.5 py-0.5 rounded-md bg-rose-100 dark:bg-rose-900/40 text-rose-700 dark:text-rose-300 text-[9px] font-bold flex items-center gap-1 shadow-sm border border-rose-200/50\"\n                                >\n                                    <Ban className=\"w-2.5 h-2.5\" />\n                                    {t('accounts.disabled').toUpperCase()}\n                                </span>\n                            )}\n                            {account.proxy_disabled && (\n                                <span\n                                    className=\"px-1.5 py-0.5 rounded-md bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 text-[9px] font-bold flex items-center gap-1 shadow-sm border border-orange-200/50\"\n                                >\n                                    <Ban className=\"w-2.5 h-2.5\" />\n                                    {t('accounts.proxy_disabled').toUpperCase()}\n                                </span>\n                            )}\n                            {account.quota?.is_forbidden && (\n                                <span className=\"px-1.5 py-0.5 rounded-md bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400 text-[9px] font-bold flex items-center gap-1 shadow-sm border border-red-200/50\">\n                                    <Lock className=\"w-2.5 h-2.5\" />\n                                    {t('accounts.forbidden').toUpperCase()}\n                                </span>\n                            )}\n                            {account.validation_blocked && (\n                                <span className=\"px-1.5 py-0.5 rounded-md bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-400 text-[9px] font-bold flex items-center gap-1 shadow-sm border border-amber-200/50\">\n                                    <Clock className=\"w-2.5 h-2.5\" />\n                                    {t('accounts.status.validation_required').toUpperCase()}\n                                </span>\n                            )}\n                            {/* 订阅类型徽章 */}\n                            {account.quota?.subscription_tier && (() => {\n                                const tier = account.quota.subscription_tier.toLowerCase();\n                                if (tier.includes('ultra')) {\n                                    return (\n                                        <span className=\"flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-gradient-to-r from-purple-600 to-pink-600 text-white text-[9px] font-bold shadow-sm\">\n                                            <Gem className=\"w-2.5 h-2.5 fill-current\" />\n                                            ULTRA\n                                        </span>\n                                    );\n                                } else if (tier.includes('pro')) {\n                                    return (\n                                        <span className=\"flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-gradient-to-r from-blue-600 to-indigo-600 text-white text-[9px] font-bold shadow-sm\">\n                                            <Diamond className=\"w-2.5 h-2.5 fill-current\" />\n                                            PRO\n                                        </span>\n                                    );\n                                } else {\n                                    return (\n                                        <span className=\"flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-gray-100 dark:bg-white/10 text-gray-500 dark:text-gray-400 text-[9px] font-bold shadow-sm border border-gray-200 dark:border-white/10\">\n                                            <Circle className=\"w-2.5 h-2.5\" />\n                                            FREE\n                                        </span>\n                                    );\n                                }\n                            })()}\n                            {/* 自定义标签 */}\n                            {account.custom_label && (\n                                <span className=\"flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 text-[9px] font-bold shadow-sm border border-orange-200/50 dark:border-orange-800/50\">\n                                    <Tag className=\"w-2.5 h-2.5\" />\n                                    {account.custom_label}\n                                </span>\n                            )}\n                        </div>\n                        <span className=\"text-[10px] text-gray-400 dark:text-gray-500 font-mono shrink-0 whitespace-nowrap\">\n                            {new Date(account.last_used * 1000).toLocaleString([], { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}\n                        </span>\n                    </div>\n                </div>\n            </div>\n\n\n            {/* 配额展示 */}\n            <div className=\"flex-1 px-2 mb-2 overflow-y-auto scrollbar-none\">\n                {isDisabled || account.quota?.is_forbidden || account.proxy_disabled || account.validation_blocked ? (\n                    <div className=\"flex flex-wrap items-center justify-center gap-x-3 gap-y-1 h-full py-4 text-center\">\n                        <div className={cn(\n                            \"flex items-center gap-1.5\",\n                            account.validation_blocked ? \"text-amber-600 dark:text-amber-400\" : \"text-red-600 dark:text-red-400\"\n                        )}>\n                            {account.validation_blocked ? <Clock className=\"w-4 h-4\" /> : (isDisabled || account.proxy_disabled ? <Ban className=\"w-4 h-4\" /> : <Lock className=\"w-4 h-4\" />)}\n                            <span className=\"text-[11px] font-bold\">\n                                {account.validation_blocked ? t('accounts.status.validation_required') : (isDisabled ? t('accounts.status.disabled') : account.proxy_disabled ? t('accounts.status.proxy_disabled') : t('accounts.forbidden_msg'))}\n                            </span>\n                        </div>\n                        <div className={cn(\n                            \"w-px h-3 hidden sm:block\",\n                            account.validation_blocked ? \"bg-amber-200 dark:bg-amber-800/50\" : \"bg-red-200 dark:bg-red-800/50\"\n                        )} />\n                        <button\n                            onClick={(e) => { e.stopPropagation(); onViewError(); }}\n                            className=\"text-[10px] text-blue-600 dark:text-blue-400 hover:underline font-medium\"\n                        >\n                            {t('accounts.view_error')}\n                        </button>\n                    </div>\n                ) : (\n                    <div className=\"grid grid-cols-1 gap-2 content-start\">\n                        {displayModels.map((model) => (\n                            <QuotaItem\n                                key={model.id}\n                                label={model.label}\n                                percentage={model.data?.percentage || 0}\n                                resetTime={model.data?.reset_time}\n                                isProtected={isModelProtected(model.protectedKey)}\n                                Icon={model.Icon}\n                            />\n                        ))}\n                    </div>\n                )}\n            </div>\n\n            {/* Footer: Actions Only */}\n            <div className=\"flex-none flex items-center justify-center pt-2 pb-1 border-t border-gray-100 dark:border-base-200\">\n                {/* 标签编辑弹出框 */}\n                {isEditingLabel && (\n                    <div className=\"absolute inset-0 bg-white/95 dark:bg-base-100/95 rounded-xl z-10 flex items-center justify-center p-4\">\n                        <div className=\"flex items-center gap-2 w-full max-w-xs\">\n                            <input\n                                type=\"text\"\n                                className=\"flex-1 px-2 py-1 text-sm border border-orange-300 dark:border-orange-700 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 bg-white dark:bg-base-200\"\n                                placeholder={t('accounts.custom_label_placeholder', 'Enter custom label')}\n                                value={labelInput}\n                                onChange={(e) => setLabelInput(e.target.value)}\n                                onKeyDown={handleKeyDown}\n                                autoFocus\n                                maxLength={15}\n                            />\n                            <button\n                                className=\"p-1.5 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30 rounded-lg transition-all\"\n                                onClick={handleSaveLabel}\n                                title={t('common.save', 'Save')}\n                            >\n                                <Check className=\"w-4 h-4\" />\n                            </button>\n                            <button\n                                className=\"p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-all\"\n                                onClick={handleCancelLabel}\n                                title={t('common.cancel', 'Cancel')}\n                            >\n                                <X className=\"w-4 h-4\" />\n                            </button>\n                        </div>\n                    </div>\n                )}\n                <div className=\"flex flex-wrap items-center justify-center gap-1 w-full\">\n                    <button\n                        className=\"p-1.5 text-gray-400 hover:text-sky-600 dark:hover:text-sky-400 hover:bg-sky-50 dark:hover:bg-sky-900/30 rounded-lg transition-all\"\n                        onClick={(e) => { e.stopPropagation(); onViewDetails(); }}\n                        title={t('common.details')}\n                    >\n                        <Info className=\"w-3.5 h-3.5\" />\n                    </button>\n                    <button\n                        className=\"p-1.5 text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-lg transition-all\"\n                        onClick={(e) => { e.stopPropagation(); onViewDevice(); }}\n                        title={t('accounts.device_fingerprint')}\n                    >\n                        <Fingerprint className=\"w-3.5 h-3.5\" />\n                    </button>\n                    {/* 自定义标签按钮 */}\n                    {onUpdateLabel && (\n                        <button\n                            className={cn(\n                                \"p-1.5 rounded-lg transition-all\",\n                                account.custom_label\n                                    ? \"text-orange-500 hover:text-orange-600 hover:bg-orange-50 dark:hover:bg-orange-900/30\"\n                                    : \"text-gray-400 hover:text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/30\"\n                            )}\n                            onClick={(e) => { e.stopPropagation(); setIsEditingLabel(true); }}\n                            title={t('accounts.edit_label', 'Edit Label')}\n                        >\n                            <Tag className=\"w-3.5 h-3.5\" />\n                        </button>\n                    )}\n                    <button\n                        className={`p-1.5 rounded-lg transition-all ${(isSwitching || isDisabled) ? 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/10 cursor-not-allowed' : 'text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30'}`}\n                        onClick={(e) => { e.stopPropagation(); onSwitch(); }}\n                        title={isDisabled ? t('accounts.disabled_tooltip') : (isSwitching ? t('common.loading') : t('common.switch'))}\n                        disabled={isSwitching || isDisabled}\n                    >\n                        <ArrowRightLeft className={`w-3.5 h-3.5 ${isSwitching ? 'animate-spin' : ''}`} />\n                    </button>\n                    {onWarmup && (\n                        <button\n                            className={`p-1.5 rounded-lg transition-all ${(isRefreshing || isDisabled) ? 'text-orange-600 bg-orange-50 dark:bg-orange-900/10 cursor-not-allowed' : 'text-gray-400 hover:text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/30'}`}\n                            onClick={(e) => { e.stopPropagation(); onWarmup(); }}\n                            title={isDisabled ? t('accounts.disabled_tooltip') : (isRefreshing ? t('common.loading') : t('accounts.warmup_this', '预热该账号'))}\n                            disabled={isRefreshing || isDisabled}\n                        >\n                            <Sparkles className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-pulse' : ''}`} />\n                        </button>\n                    )}\n                    <button\n                        className={`p-1.5 rounded-lg transition-all ${isRefreshing\n                            ? 'text-green-600 bg-green-50'\n                            : 'text-gray-400 hover:text-green-600 hover:bg-green-50'}`}\n                        onClick={(e) => { e.stopPropagation(); onRefresh(); }}\n                        disabled={isRefreshing || isDisabled}\n                        title={isDisabled ? t('accounts.disabled_tooltip') : t('common.refresh')}\n                    >\n                        <RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />\n                    </button>\n                    <button\n                        className=\"p-1.5 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-all\"\n                        onClick={(e) => { e.stopPropagation(); onExport(); }}\n                        title={t('common.export')}\n                    >\n                        <Download className=\"w-3.5 h-3.5\" />\n                    </button>\n                    <button\n                        className={cn(\n                            \"p-1.5 rounded-lg transition-all\",\n                            account.proxy_disabled\n                                ? \"text-gray-400 hover:text-green-600 hover:bg-green-50\"\n                                : \"text-gray-400 hover:text-orange-600 hover:bg-orange-50\"\n                        )}\n                        onClick={(e) => { e.stopPropagation(); onToggleProxy(); }}\n                        title={account.proxy_disabled ? t('accounts.enable_proxy') : t('accounts.disable_proxy')}\n                    >\n                        {account.proxy_disabled ? (\n                            <ToggleRight className=\"w-3.5 h-3.5\" />\n                        ) : (\n                            <ToggleLeft className=\"w-3.5 h-3.5\" />\n                        )}\n                    </button>\n                    <button\n                        className=\"p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all\"\n                        onClick={(e) => { e.stopPropagation(); onDelete(); }}\n                        title={t('common.delete')}\n                    >\n                        <Trash2 className=\"w-3.5 h-3.5\" />\n                    </button>\n                </div>\n            </div>\n        </div >\n    );\n}\n\nexport default AccountCard;\n"
  },
  {
    "path": "src/components/accounts/AccountDetailsDialog.tsx",
    "content": "import { X, Clock, AlertCircle, Bot } from 'lucide-react';\nimport { createPortal } from 'react-dom';\nimport { Account } from '../../types/account';\nimport { formatDate } from '../../utils/format';\nimport { useTranslation } from 'react-i18next';\nimport { MODEL_CONFIG, sortModels } from '../../config/modelConfig';\n\ninterface AccountDetailsDialogProps {\n    account: Account | null;\n    onClose: () => void;\n}\n\nexport default function AccountDetailsDialog({ account, onClose }: AccountDetailsDialogProps) {\n    const { t } = useTranslation();\n    if (!account) return null;\n\n    return createPortal(\n        <div className=\"modal modal-open z-[100]\">\n            {/* Draggable Top Region */}\n            <div data-tauri-drag-region className=\"fixed top-0 left-0 right-0 h-8 z-[110]\" />\n\n            <div className=\"modal-box relative max-w-3xl bg-white dark:bg-base-100 shadow-2xl rounded-2xl p-0 overflow-hidden\">\n                {/* Header */}\n                <div className=\"px-6 py-5 border-b border-gray-100 dark:border-base-200 bg-gray-50/50 dark:bg-base-200/50 flex justify-between items-center\">\n                    <div className=\"flex items-center gap-3\">\n                        <h3 className=\"font-bold text-lg text-gray-900 dark:text-base-content\">{t('accounts.details.title')}</h3>\n                        <div className=\"px-2.5 py-0.5 rounded-full bg-gray-100 dark:bg-base-200 border border-gray-200 dark:border-base-300 text-xs font-mono text-gray-500 dark:text-gray-400\">\n                            {account.email}\n                        </div>\n                        {account.quota?.subscription_tier && (\n                            <div className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${account.quota.subscription_tier === 'ultra' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' :\n                                account.quota.subscription_tier === 'pro' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-gray-100 text-gray-600 dark:bg-base-300 dark:text-gray-400'\n                                }`}>\n                                {account.quota.subscription_tier}\n                            </div>\n                        )}\n                    </div>\n                    <button\n                        onClick={onClose}\n                        className=\"btn btn-sm btn-circle btn-ghost text-gray-400 hover:bg-gray-100 dark:hover:bg-base-200 hover:text-gray-600 dark:hover:text-base-content transition-colors\"\n                    >\n                        <X size={18} />\n                    </button>\n                </div>\n\n                {/* Status Alerts */}\n                {(account.disabled || account.proxy_disabled) && (\n                    <div className=\"px-6 py-3 bg-red-50 dark:bg-red-950/20 border-b border-red-100 dark:border-red-900/30 flex flex-col gap-1\">\n                        {account.disabled && (\n                            <div className=\"flex items-center gap-2 text-xs text-red-700 dark:text-red-400\">\n                                <AlertCircle size={14} />\n                                <span className=\"font-semibold\">{t('accounts.status.disabled')}:</span>\n                                <span>{account.disabled_reason || t('common.unknown')}</span>\n                            </div>\n                        )}\n                        {account.proxy_disabled && (\n                            <div className=\"flex items-center gap-2 text-xs text-orange-700 dark:text-orange-400\">\n                                <AlertCircle size={14} />\n                                <span className=\"font-semibold\">{t('accounts.status.proxy_disabled')}:</span>\n                                <span>{account.proxy_disabled_reason || t('common.unknown')}</span>\n                            </div>\n                        )}\n                    </div>\n                )}\n\n                {/* Content */}\n                <div className=\"p-6 max-h-[60vh] overflow-y-auto\">\n                    {/* Protected Models Section */}\n                    {account.protected_models && account.protected_models.length > 0 && (\n                        <div className=\"mb-6\">\n                            <h4 className=\"text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-2\">\n                                <AlertCircle size={12} className=\"text-amber-500\" />\n                                {t('accounts.details.protected_models')}\n                            </h4>\n                            <div className=\"flex flex-wrap gap-2\">\n                                {account.protected_models.map(model => (\n                                    <span key={model} className=\"px-2 py-1 bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 text-[11px] font-mono border border-amber-100 dark:border-amber-900/40 rounded-md\">\n                                        {model}\n                                    </span>\n                                ))}\n                            </div>\n                        </div>\n                    )}\n\n                    <h4 className=\"text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-widest mb-3\">{t('accounts.details.model_quota')}</h4>\n                    <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n                        {(() => {\n                            const uniqueLabels = new Set<string>();\n                            return sortModels(\n                                (account.quota?.models || []).map(model => {\n                                    const config = MODEL_CONFIG[model.name.toLowerCase()];\n                                    const label = model.display_name || (config?.i18nKey ? t(config.i18nKey) : (config?.label || model.name));\n                                    return {\n                                        id: model.name.toLowerCase(),\n                                        label: label,\n                                        model\n                                    };\n                                })\n                            ).filter(m => {\n                                if (uniqueLabels.has(m.label)) return false;\n                                uniqueLabels.add(m.label);\n                                return true;\n                            }).map(({ model, label }) => (\n                                <div key={model.name} className=\"p-4 rounded-xl border border-gray-100 dark:border-base-200 bg-white dark:bg-base-100 hover:border-blue-100 dark:hover:border-blue-900 hover:shadow-sm transition-all group\">\n                                    <div className=\"flex justify-between items-start mb-3\">\n                                        <div className=\"flex flex-col gap-1\">\n                                            <div className=\"flex items-center gap-2\">\n                                                {(() => {\n                                                    const Icon = MODEL_CONFIG[model.name.toLowerCase()]?.Icon || Bot;\n                                                    return <Icon size={16} className=\"shrink-0\" />;\n                                                })()}\n                                                <span className=\"text-sm font-medium font-mono text-gray-700 dark:text-gray-300 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors\">\n                                                    {label}\n                                                </span>\n                                            </div>\n                                            {model.thinking_budget !== undefined && (\n                                                <span className=\"text-[10px] text-gray-500 font-mono bg-gray-100 dark:bg-base-200 px-1 rounded inline-block w-max mt-0.5\">\n                                                    {t('proxy.config.thinking_budget', 'Thinking Budget')}: {model.thinking_budget}\n                                                </span>\n                                            )}\n                                        </div>\n                                        <span\n                                            className={`text-xs font-bold px-2 py-0.5 rounded-md ${model.percentage >= 50 ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400' :\n                                                model.percentage >= 20 ? 'bg-orange-50 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' :\n                                                    'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'\n                                                }`}\n                                        >\n                                            {model.percentage}%\n                                        </span>\n                                    </div>\n\n                                    {/* Progress Bar */}\n                                    <div className=\"h-1.5 w-full bg-gray-100 dark:bg-base-200 rounded-full overflow-hidden mb-3\">\n                                        <div\n                                            className={`h-full rounded-full transition-all duration-500 ${model.percentage >= 50 ? 'bg-emerald-500' :\n                                                model.percentage >= 20 ? 'bg-orange-400' :\n                                                    'bg-red-500'\n                                                }`}\n                                            style={{ width: `${model.percentage}%` }}\n                                        ></div>\n                                    </div>\n\n                                    <div className=\"flex items-center gap-1.5 text-[10px] text-gray-400 dark:text-gray-500 font-mono\">\n                                        <Clock size={10} />\n                                        <span>{t('accounts.reset_time')}: {formatDate(model.reset_time) || t('common.unknown')}</span>\n                                    </div>\n                                </div>\n                            ));\n                        })() || (\n                                <div className=\"col-span-2 py-10 text-center text-gray-400 flex flex-col items-center\">\n                                    <AlertCircle className=\"w-8 h-8 mb-2 opacity-20\" />\n                                    <span>{t('accounts.no_data')}</span>\n                                </div>\n                            )}\n                    </div>\n                </div>\n            </div>\n            <div className=\"modal-backdrop bg-black/40 backdrop-blur-sm\" onClick={onClose}></div>\n        </div>,\n        document.body\n    );\n}\n"
  },
  {
    "path": "src/components/accounts/AccountErrorDialog.tsx",
    "content": "import { Ban, Lock, Clock, ExternalLink, Copy, FileText, Terminal, ChevronDown, ChevronRight } from 'lucide-react';\nimport { Account } from '../../types/account';\nimport { formatDate } from '../../utils/format';\nimport { useTranslation, Trans } from 'react-i18next';\nimport ModalDialog from '../common/ModalDialog';\nimport { useState } from 'react';\nimport { showToast } from '../common/ToastContainer';\n\ninterface AccountErrorDialogProps {\n    account: Account | null;\n    onClose: () => void;\n}\n\nexport default function AccountErrorDialog({ account, onClose }: AccountErrorDialogProps) {\n    const [showRaw, setShowRaw] = useState(false);\n    const [showGuide, setShowGuide] = useState(false);\n    const { t } = useTranslation();\n    if (!account) return null;\n\n    const isForbidden = !!account.quota?.is_forbidden;\n    const isDisabled = Boolean(account.disabled);\n    const isProxyDisabled = account.proxy_disabled;\n    const isValidationBlocked = account.validation_blocked;\n\n    const rawReason = account.validation_blocked_reason || account.disabled_reason || account.quota?.forbidden_reason || account.proxy_disabled_reason || '';\n\n    // 深度解析解析错误消息\n    const extractErrorMessage = (raw: string) => {\n        const trimmed = raw.trim();\n        if (!trimmed) return raw;\n        try {\n            const parsed = JSON.parse(trimmed);\n            let innerParsed = null;\n            if (typeof parsed?.error === 'string') {\n                try {\n                    innerParsed = JSON.parse(parsed.error);\n                } catch (_) { }\n            }\n            // 按照优先级尝试提取消息\n            const msg = innerParsed?.error?.message\n                || parsed?.error?.message\n                || (Array.isArray(parsed?.error?.details) ? parsed.error.details[0]?.message : null)\n                || parsed?.message\n                || raw;\n            return String(msg);\n        } catch (_) {\n            // 不处理\n        }\n        return raw;\n    };\n\n    const extractActionInfo = (raw: string): { url: string | null, label: string | null } => {\n        if (account.validation_url) {\n            return { url: account.validation_url, label: null };\n        }\n\n        const trimmed = raw.trim();\n        try {\n            const parsed = JSON.parse(trimmed);\n            // Google API 返回的链接通常在 metadata 中\n            const metadata = parsed?.error?.details?.[0]?.metadata;\n            let url = metadata?.appeal_url || metadata?.validation_url || parsed?.validation_url || parsed?.appeal_url;\n            let label = metadata?.appeal_url_link_text || metadata?.validation_url_link_text || parsed?.appeal_url_link_text || parsed?.validation_url_link_text;\n\n            if (!url && typeof parsed?.error === 'string') {\n                try {\n                    const innerParsed = JSON.parse(parsed.error);\n                    const innerMeta = innerParsed?.error?.details?.[0]?.metadata;\n                    url = innerMeta?.appeal_url || innerMeta?.validation_url;\n                    label = innerMeta?.appeal_url_link_text || innerMeta?.validation_url_link_text;\n                } catch (_) { }\n            }\n\n            if (url) return { url: String(url), label: label ? String(label) : null };\n        } catch (_) { }\n\n        // 最后降级到正则匹配\n        const urlRegex = /https:\\/\\/[^\\s\"']+/g;\n        const match = raw.match(urlRegex);\n        if (match) {\n            let extracted = match[0];\n            extracted = extracted.replace(/\\\\u0026/g, '&').replace(/\\\\\"/g, '').replace(/\\\\/g, '');\n            if (extracted.endsWith(',')) {\n                extracted = extracted.slice(0, -1);\n            }\n            return { url: extracted, label: null };\n        }\n        return { url: null, label: null };\n    };\n\n    const message = extractErrorMessage(rawReason);\n    const { url: actionUrl, label: actionLabel } = extractActionInfo(rawReason);\n\n    // 识别错误类型\n    const isViolation = rawReason.toLowerCase().includes('terms of service') || rawReason.toLowerCase().includes('violation');\n    const isVerificationNeeded = !isViolation && (rawReason.toLowerCase().includes('verify your account') || !!account.validation_url);\n\n    // 复制功能\n    const handleCopyUrl = (url: string) => {\n        navigator.clipboard.writeText(url);\n        showToast(t('accounts.validation_url_copied', '验证链接已复制到剪贴板'), 'success');\n    };\n\n    const handleCopyText = (text: string, msg: string) => {\n        navigator.clipboard.writeText(text);\n        showToast(msg, 'success');\n    };\n\n    const renderMessageWithLinks = (text: string) => {\n        const urlRegex = /(https?:\\/\\/[^\\s]+)/g;\n        const parts = text.split(urlRegex);\n        return parts.map((part, i) => {\n            if (part.match(urlRegex)) {\n                return (\n                    <a\n                        key={i}\n                        href={part}\n                        target=\"_blank\"\n                        rel=\"noopener noreferrer\"\n                        className=\"text-blue-600 dark:text-blue-400 underline hover:text-blue-700 dark:hover:text-blue-300 break-all inline-flex items-center gap-1\"\n                        onClick={(e) => e.stopPropagation()}\n                    >\n                        {t('accounts.click_to_verify', '点击去验证')}\n                        <ExternalLink className=\"w-3 h-3\" />\n                    </a>\n                );\n            }\n            return part;\n        });\n    };\n\n    return (\n        <ModalDialog\n            isOpen={true}\n            title={t('accounts.error_details')}\n            type=\"error\"\n            onConfirm={onClose}\n            confirmText={t('common.close')}\n        >\n            <div className=\"space-y-4 max-h-[75vh] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 pr-1 py-1\">\n                {/* Account Info */}\n                <div>\n                    <label className=\"text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider block mb-1.5 ml-1\">\n                        {t('accounts.account')}\n                    </label>\n                    <div className=\"text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-base-200/50 px-4 py-2.5 rounded-xl border border-gray-100 dark:border-base-200 shadow-sm\">\n                        {account.email}\n                    </div>\n                </div>\n\n                {/* Status */}\n                <div>\n                    <label className=\"text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider block mb-1.5 ml-1\">\n                        {t('accounts.error_status')}\n                    </label>\n                    <div className=\"flex flex-wrap gap-2\">\n                        {isForbidden && !isViolation && !isVerificationNeeded && !isValidationBlocked && (\n                            <span className=\"flex items-center gap-1.5 px-3 py-1 rounded-lg bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 text-xs font-bold ring-1 ring-red-200/50 dark:ring-red-900/20\">\n                                <Lock className=\"w-3 h-3\" />\n                                {t('accounts.status.forbidden')}\n                            </span>\n                        )}\n                        {isViolation && (\n                            <span className=\"flex items-center gap-1.5 px-3 py-1 rounded-lg bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 text-xs font-bold ring-1 ring-red-200/50 dark:ring-red-900/20\">\n                                <Lock className=\"w-3 h-3\" />\n                                {t('accounts.status.violation_blocked', '由于违规被禁用')}\n                            </span>\n                        )}\n                        {isDisabled && (\n                            <span className=\"flex items-center gap-1.5 px-3 py-1 rounded-lg bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400 text-xs font-bold ring-1 ring-rose-200/50 dark:ring-rose-900/20\">\n                                <Ban className=\"w-3 h-3\" />\n                                {t('accounts.status.disabled')}\n                            </span>\n                        )}\n                        {isProxyDisabled && (\n                            <span className=\"flex items-center gap-1.5 px-3 py-1 rounded-lg bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400 text-xs font-bold ring-1 ring-orange-200/50 dark:ring-orange-900/20\">\n                                <Ban className=\"w-3 h-3\" />\n                                {t('accounts.status.proxy_disabled')}\n                            </span>\n                        )}\n                        {(isValidationBlocked || isVerificationNeeded) && (\n                            <span className=\"flex items-center gap-1.5 px-3 py-1 rounded-lg bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 text-xs font-bold ring-1 ring-amber-200/50 dark:ring-amber-900/20\">\n                                <Clock className=\"w-3 h-3\" />\n                                {t('accounts.status.validation_required', '账号需验证')}\n                            </span>\n                        )}\n                    </div>\n                </div>\n\n                {/* Reason */}\n                <div>\n                    <div className=\"flex items-center justify-between mb-1.5 ml-1\">\n                        <label className=\"text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider block\">\n                            {t('common.reason', '原因')}\n                        </label>\n                        <button\n                            onClick={() => setShowRaw(!showRaw)}\n                            className=\"text-[10px] flex items-center gap-1 text-blue-500 hover:text-blue-600 transition-colors font-medium\"\n                        >\n                            <FileText className=\"w-2.5 h-2.5\" />\n                            {showRaw ? t('common.show_parsed', '显示解析后') : t('common.show_raw', '显示原始报文')}\n                        </button>\n                    </div>\n                    <div className=\"text-xs text-red-600 dark:text-red-400 bg-red-50/50 dark:bg-red-900/10 p-4 rounded-xl border border-red-100 dark:border-red-900/20 break-all leading-relaxed font-mono shadow-inner min-h-[80px] max-h-[40vh] overflow-y-auto scrollbar-thin scrollbar-thumb-red-200 dark:scrollbar-thumb-red-800\">\n                        {showRaw ? (\n                            <pre className=\"whitespace-pre-wrap break-all\">{rawReason}</pre>\n                        ) : (\n                            message ? renderMessageWithLinks(message) : t('common.unknown')\n                        )}\n                    </div>\n\n                    {/* Action Buttons for Verification / Appeal */}\n                    {actionUrl && !showRaw && (\n                        <div className=\"mt-3 flex gap-2\">\n                            <a\n                                href={actionUrl}\n                                target=\"_blank\"\n                                rel=\"noopener noreferrer\"\n                                className=\"flex-1 flex items-center justify-center gap-2 py-2 text-xs font-bold bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-all shadow-md shadow-blue-500/20 active:scale-[0.98]\"\n                            >\n                                <ExternalLink className=\"w-3 h-3\" />\n                                {actionLabel || (isViolation ? t('accounts.go_to_appeal', '前往申诉') : t('accounts.click_to_verify', '点击去验证'))}\n                            </a>\n                            <button\n                                onClick={() => handleCopyUrl(actionUrl)}\n                                className=\"flex-1 flex items-center justify-center gap-2 py-2 text-xs font-bold bg-gray-100 dark:bg-base-300 hover:bg-gray-200 dark:hover:bg-base-200 text-gray-700 dark:text-gray-300 rounded-lg transition-all active:scale-[0.98]\"\n                            >\n                                <Copy className=\"w-3 h-3\" />\n                                {isViolation ? t('accounts.copy_appeal_url', '复制申诉链接') : t('accounts.copy_validation_url', '复制验证链接')}\n                            </button>\n                        </div>\n                    )}\n\n                    {/* Terminal Fix Guide */}\n                    {(isForbidden || isVerificationNeeded) && !showRaw && (\n                        <div className=\"mt-4 border border-blue-100 dark:border-blue-900/40 rounded-xl overflow-hidden shadow-sm\">\n                            <button\n                                onClick={() => setShowGuide(!showGuide)}\n                                className=\"w-full flex items-center justify-between p-3 bg-blue-50/70 dark:bg-blue-900/20 hover:bg-blue-100/70 dark:hover:bg-blue-900/40 transition-colors\"\n                            >\n                                <div className=\"flex items-center gap-2 text-blue-700 dark:text-blue-400 font-bold text-xs\">\n                                    <Terminal className=\"w-4 h-4\" />\n                                    <span>{t('accounts.fix_guide.title', '终端一键自救指南 (解决部分 403 拦截)')}</span>\n                                </div>\n                                {showGuide ? <ChevronDown className=\"w-4 h-4 text-blue-500\" /> : <ChevronRight className=\"w-4 h-4 text-blue-500\" />}\n                            </button>\n                            {showGuide && (\n                                <div className=\"p-4 text-xs space-y-4 bg-white dark:bg-base-200 text-gray-700 dark:text-gray-300 max-h-[35vh] overflow-y-auto scrollbar-thin scrollbar-thumb-blue-200 dark:scrollbar-thumb-blue-800\">\n                                    <div>\n                                        <p className=\"mb-2 text-[11px] leading-relaxed\">\n                                            {t('accounts.fix_guide.step1_desc', '打开终端（Terminal），执行以下命令告诉 Google \"是我本人\"，可解决部分 403 拦截：')}\n                                        </p>\n                                        <div className=\"bg-gray-900 dark:bg-[#1e1e1e] text-green-400 p-2.5 rounded-lg font-mono text-[11px] flex justify-between items-center ring-1 ring-inset ring-gray-800\">\n                                            <code>gcloud auth login --update-adc</code>\n                                            <button\n                                                onClick={() => handleCopyText('gcloud auth login --update-adc', t('common.copied', '成功复制命令'))}\n                                                className=\"text-gray-400 hover:text-white transition-colors p-1\"\n                                                title={t('common.copy', '复制')}\n                                            >\n                                                <Copy className=\"w-3.5 h-3.5\" />\n                                            </button>\n                                        </div>\n                                        <ul className=\"mt-2 text-[11px] text-gray-500 dark:text-gray-400 list-disc pl-4 marker:text-gray-300 dark:marker:text-gray-600\">\n                                            <li><Trans i18nKey=\"accounts.fix_guide.step1_li1\" components={{ 1: <code /> }}>按回车执行，提示继续时输入 <code />。</Trans></li>\n                                            <li>{t('accounts.fix_guide.step1_li2')}</li>\n                                            <li><Trans i18nKey=\"accounts.fix_guide.step1_li3\" components={{ 1: <code /> }}>看到 <code /> 即大功告成！</Trans></li>\n                                        </ul>\n                                    </div>\n\n                                    <div className=\"border-t border-gray-100 dark:border-base-300/50 pt-3\">\n                                        <h4 className=\"font-bold text-gray-800 dark:text-gray-200 mb-1.5 flex items-center gap-1.5\">\n                                            {t('accounts.fix_guide.step2_title', '🧹 如果无效（清除缓存重来）')}\n                                        </h4>\n                                        <ol className=\"list-decimal pl-4 space-y-2 text-[11px] text-gray-600 dark:text-gray-400 marker:text-gray-400 font-medium\">\n                                            <li>\n                                                {t('accounts.fix_guide.step2_li1_prefix', '先执行清除命令退出旧认证：')}\n                                                <div className=\"bg-gray-100 dark:bg-base-300/50 mt-1 px-2 py-1.5 rounded text-red-600 dark:text-red-400 inline-block font-mono\">\n                                                    gcloud auth revoke {account.email || 'your-email@gmail.com'}\n                                                </div>\n                                            </li>\n                                            <li>{t('accounts.fix_guide.step2_li2_prefix', '再执行登录：')}<code className=\"bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-1 rounded ml-1\">gcloud auth login --update-adc</code></li>\n                                        </ol>\n                                    </div>\n\n                                    <div className=\"border-t border-gray-100 dark:border-base-300/50 pt-3\">\n                                        <h4 className=\"font-bold text-gray-800 dark:text-gray-200 mb-1.5 flex items-center gap-1.5\">\n                                            {t('accounts.fix_guide.tips_title', '💡 常见建议')}\n                                        </h4>\n                                        <ul className=\"list-disc pl-4 space-y-1.5 text-[11px] text-gray-500 dark:text-gray-400 font-medium marker:text-gray-300\">\n                                            <li><Trans i18nKey=\"accounts.fix_guide.tip1\" components={{ 1: <code /> }}>若仍 403，尝试先在终端执行 <code /> 重置环境变量。</Trans></li>\n                                            <li><Trans i18nKey=\"accounts.fix_guide.tip2\" components={{ 1: <strong /> }}>生产环境强烈建议改用 <strong /> 的 JSON 密钥，更稳定且免交互。</Trans></li>\n                                            <li><Trans i18nKey=\"accounts.fix_guide.tip3\" components={{ 1: <a href=\"https://console.cloud.google.com/\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-blue-500 hover:text-blue-600 hover:underline\" /> }}>若操作失败，请前往 <a /> 中的 Generative Language API 查看是否被冻结权限。若是，说明账号触发了风控，建议让账号冷却 72 小时后再次尝试。</Trans></li>\n                                            <li><Trans i18nKey=\"accounts.fix_guide.tip4\" components={{ 1: <code /> }}>你也可以尝试执行 <code />，只要不弹出错误，大概率在软件内删除账号重新授权即可。</Trans></li>\n                                        </ul>\n                                    </div>\n                                </div>\n                            )}\n                        </div>\n                    )}\n                </div>\n\n                {/* Time */}\n                <div className=\"flex items-center gap-2 text-[11px] text-gray-400 dark:text-gray-500 pl-1\">\n                    <Clock size={12} strokeWidth={2.5} />\n                    <span>\n                        {t('accounts.error_time')}: {account.disabled_at ? formatDate(account.disabled_at) : (account.quota?.last_updated ? formatDate(account.quota.last_updated) : t('common.unknown'))}\n                    </span>\n                </div>\n            </div>\n        </ModalDialog>\n    );\n}\n"
  },
  {
    "path": "src/components/accounts/AccountGrid.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { Account } from '../../types/account';\nimport AccountCard from './AccountCard';\n\ninterface AccountGridProps {\n    accounts: Account[];\n    selectedIds: Set<string>;\n    refreshingIds: Set<string>;\n    onToggleSelect: (id: string) => void;\n    currentAccountId: string | null;\n    switchingAccountId: string | null;\n    onSwitch: (accountId: string) => void;\n    onRefresh: (accountId: string) => void;\n    onViewDevice: (accountId: string) => void;\n    onViewDetails: (accountId: string) => void;\n    onExport: (accountId: string) => void;\n    onDelete: (accountId: string) => void;\n    onToggleProxy: (accountId: string) => void;\n    onWarmup?: (accountId: string) => void;\n    onUpdateLabel?: (accountId: string, label: string) => void;\n    onViewError: (accountId: string) => void;\n}\n\n\nfunction AccountGrid({ accounts, selectedIds, refreshingIds, onToggleSelect, currentAccountId, switchingAccountId, onSwitch, onRefresh, onViewDetails, onExport, onDelete, onToggleProxy, onViewDevice, onWarmup, onUpdateLabel, onViewError }: AccountGridProps) {\n    const { t } = useTranslation();\n    if (accounts.length === 0) {\n        return (\n            <div className=\"bg-white dark:bg-base-100 rounded-2xl p-12 shadow-sm border border-gray-100 dark:border-base-200 text-center\">\n                <p className=\"text-gray-400 mb-2\">{t('accounts.empty.title')}</p>\n                <p className=\"text-sm text-gray-400\">{t('accounts.empty.desc')}</p>\n            </div>\n        );\n    }\n\n    return (\n        <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4\">\n            {accounts.map((account) => (\n                <AccountCard\n                    key={account.id}\n                    account={account}\n                    selected={selectedIds.has(account.id)}\n                    isRefreshing={refreshingIds.has(account.id)}\n                    onSelect={() => onToggleSelect(account.id)}\n                    isCurrent={account.id === currentAccountId}\n                    isSwitching={account.id === switchingAccountId}\n                    onSwitch={() => onSwitch(account.id)}\n                    onRefresh={() => onRefresh(account.id)}\n                    onViewDevice={() => onViewDevice(account.id)}\n                    onViewDetails={() => onViewDetails(account.id)}\n                    onExport={() => onExport(account.id)}\n                    onDelete={() => onDelete(account.id)}\n                    onToggleProxy={() => onToggleProxy(account.id)}\n                    onWarmup={onWarmup ? () => onWarmup(account.id) : undefined}\n                    onUpdateLabel={onUpdateLabel ? (label: string) => onUpdateLabel(account.id, label) : undefined}\n                    onViewError={() => onViewError(account.id)}\n                />\n            ))}\n        </div>\n    );\n}\n\nexport default AccountGrid;\n"
  },
  {
    "path": "src/components/accounts/AccountRow.tsx",
    "content": "import { ArrowRightLeft, RefreshCw, Trash2, Download, Info, Lock, Ban, Diamond, Gem, Circle, Clock, ToggleLeft, ToggleRight, Fingerprint } from 'lucide-react';\nimport { Account } from '../../types/account';\nimport { getQuotaColor, formatTimeRemaining, getTimeRemainingColor } from '../../utils/format';\nimport { cn } from '../../utils/cn';\nimport { useTranslation } from 'react-i18next';\n\ninterface AccountRowProps {\n    account: Account;\n    selected: boolean;\n    onSelect: () => void;\n    isCurrent: boolean;\n    isRefreshing: boolean;\n    isSwitching?: boolean;\n    onSwitch: () => void;\n    onRefresh: () => void;\n    onViewDevice: () => void;\n    onViewDetails: () => void;\n    onExport: () => void;\n    onDelete: () => void;\n    onToggleProxy: () => void;\n}\n\n\n\nfunction AccountRow({ account, selected, onSelect, isCurrent, isRefreshing, isSwitching = false, onSwitch, onRefresh, onViewDetails, onExport, onDelete, onToggleProxy, onViewDevice }: AccountRowProps) {\n    const { t } = useTranslation();\n    // [重构] 按组聚合查找逻辑，优先显示组内配额最低的型号以与锁定状态（🔒）对齐\n    const geminiProModel = account.quota?.models\n        .filter(m =>\n            m.name.toLowerCase() === 'gemini-3-pro-high'\n            || m.name.toLowerCase() === 'gemini-3-pro-low'\n            || m.name.toLowerCase() === 'gemini-3.1-pro-high'\n            || m.name.toLowerCase() === 'gemini-3.1-pro-low'\n        )\n        .sort((a, b) => (a.percentage || 0) - (b.percentage || 0))[0];\n\n    const geminiFlashModel = account.quota?.models.find(m => m.name.toLowerCase() === 'gemini-3-flash');\n\n    const geminiImageModel = account.quota?.models.find(m => m.name.toLowerCase() === 'gemini-3-pro-image');\n\n    const claudeGroupNames = [\n        'claude-opus-4-6-thinking',\n        'claude'\n    ];\n    const claudeModel = account.quota?.models\n        .filter(m => claudeGroupNames.includes(m.name.toLowerCase()))\n        .sort((a, b) => (a.percentage || 0) - (b.percentage || 0))[0];\n    const isDisabled = Boolean(account.disabled);\n\n    // 颜色映射，避免动态类名被 Tailwind purge\n    const getColorClass = (percentage: number) => {\n        const color = getQuotaColor(percentage);\n        switch (color) {\n            case 'success': return 'bg-emerald-500';\n            case 'warning': return 'bg-amber-500';\n            case 'error': return 'bg-rose-500';\n            default: return 'bg-gray-500';\n        }\n    };\n\n    const getTimeColorClass = (resetTime: string | undefined) => {\n        const color = getTimeRemainingColor(resetTime);\n        switch (color) {\n            case 'success': return 'text-emerald-500 dark:text-emerald-400';\n            case 'warning': return 'text-amber-500 dark:text-amber-400';\n            default: return 'text-blue-600 dark:text-blue-400';\n        }\n    };\n\n    return (\n        <tr className={cn(\n            \"group hover:bg-gray-50 dark:hover:bg-base-200 transition-colors border-b border-gray-100 dark:border-base-200\",\n            isCurrent && \"bg-blue-50/50 dark:bg-blue-900/10\",\n            (isRefreshing || isDisabled) && \"opacity-70\"\n        )}>\n            {/* 序号 */}\n            <td className=\"pl-6 py-1 w-12\">\n                <input\n                    type=\"checkbox\"\n                    className=\"checkbox checkbox-xs rounded border-2 border-gray-400 dark:border-gray-500 checked:border-blue-600 checked:bg-blue-600 [--chkbg:theme(colors.blue.600)] [--chkfg:white]\"\n                    checked={selected}\n                    onChange={() => onSelect()}\n                    onClick={(e) => e.stopPropagation()}\n                />\n            </td>\n\n            {/* 邮箱 */}\n            <td className=\"px-4 py-1\">\n                <div className=\"flex items-center gap-3\">\n                    <span className={cn(\n                        \"font-medium text-sm truncate max-w-[180px] xl:max-w-none transition-colors\",\n                        isCurrent ? \"text-blue-700 dark:text-blue-400\" : \"text-gray-900 dark:text-base-content\"\n                    )} title={account.email}>\n                        {account.email}\n                    </span>\n\n                    <div className=\"flex items-center gap-1.5 shrink-0\">\n                        {isCurrent && (\n                            <span className=\"px-2 py-0.5 rounded-md bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 text-[10px] font-bold shadow-sm border border-blue-200/50 dark:border-blue-800/50\">\n                                {t('accounts.current').toUpperCase()}\n                            </span>\n                        )}\n\n                        {isDisabled && (\n                            <span\n                                className=\"px-2 py-0.5 rounded-md bg-rose-100 dark:bg-rose-900/50 text-rose-700 dark:text-rose-300 text-[10px] font-bold flex items-center gap-1 shadow-sm border border-rose-200/50\"\n                                title={account.disabled_reason || t('accounts.disabled_tooltip')}\n                            >\n                                <Ban className=\"w-2.5 h-2.5\" />\n                                <span>{t('accounts.disabled')}</span>\n                            </span>\n                        )}\n\n                        {account.proxy_disabled && (\n                            <span\n                                className=\"px-2 py-0.5 rounded-md bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300 text-[10px] font-bold flex items-center gap-1 shadow-sm border border-orange-200/50\"\n                                title={account.proxy_disabled_reason || t('accounts.proxy_disabled_tooltip')}\n                            >\n                                <Ban className=\"w-2.5 h-2.5\" />\n                                <span>{t('accounts.proxy_disabled')}</span>\n                            </span>\n                        )}\n\n                        {account.quota?.is_forbidden && (\n                            <span className=\"px-2 py-0.5 rounded-md bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-400 text-[10px] font-bold flex items-center gap-1 shadow-sm border border-red-200/50\" title={t('accounts.forbidden_tooltip')}>\n                                <Lock className=\"w-2.5 h-2.5\" />\n                                <span>{t('accounts.forbidden')}</span>\n                            </span>\n                        )}\n\n                        {/* 订阅类型徽章 */}\n                        {account.quota?.subscription_tier && (() => {\n                            const tier = account.quota.subscription_tier.toLowerCase();\n                            if (tier.includes('ultra')) {\n                                return (\n                                    <span className=\"flex items-center gap-1 px-2 py-0.5 rounded-md bg-gradient-to-r from-purple-600 to-pink-600 text-white text-[10px] font-bold shadow-sm hover:scale-105 transition-transform cursor-default\">\n                                        <Gem className=\"w-2.5 h-2.5 fill-current\" />\n                                        ULTRA\n                                    </span>\n                                );\n                            } else if (tier.includes('pro')) {\n                                return (\n                                    <span className=\"flex items-center gap-1 px-2 py-0.5 rounded-md bg-gradient-to-r from-blue-600 to-indigo-600 text-white text-[10px] font-bold shadow-sm hover:scale-105 transition-transform cursor-default\">\n                                        <Diamond className=\"w-2.5 h-2.5 fill-current\" />\n                                        PRO\n                                    </span>\n                                );\n                            } else {\n                                return (\n                                    <span className=\"flex items-center gap-1 px-2 py-0.5 rounded-md bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-400 text-[10px] font-bold shadow-sm border border-gray-200 dark:border-white/10 hover:bg-gray-200 transition-colors cursor-default\">\n                                        <Circle className=\"w-2.5 h-2.5\" />\n                                        FREE\n                                    </span>\n                                );\n                            }\n                        })()}\n                    </div>\n                </div>\n            </td>\n\n            {/* 模型配额 */}\n            <td className=\"px-4 py-1\">\n                {account.quota?.is_forbidden ? (\n                    <div className=\"flex items-center gap-2 text-xs text-red-500 dark:text-red-400 bg-red-50/50 dark:bg-red-900/10 p-1.5 rounded-lg border border-red-100 dark:border-red-900/30\">\n                        <Ban className=\"w-4 h-4 shrink-0\" />\n                        <span>{t('accounts.forbidden_msg')}</span>\n                    </div>\n                ) : (\n                    <div className=\"grid grid-cols-2 gap-x-4 gap-y-1 py-0\">\n                        {/* Gemini Pro */}\n                        <div className=\"relative h-[22px] flex items-center px-1.5 rounded-md overflow-hidden border border-gray-100/50 dark:border-white/5 bg-gray-50/30 dark:bg-white/5 group/quota\">\n                            {geminiProModel && (\n                                <div\n                                    className={`absolute inset-y-0 left-0 transition-all duration-700 ease-out opacity-15 dark:opacity-20 ${getColorClass(geminiProModel.percentage)}`}\n                                    style={{ width: `${geminiProModel.percentage}%` }}\n                                />\n                            )}\n                            <div className=\"relative z-10 w-full flex items-center text-[10px] font-mono leading-none\">\n                                <span className=\"w-[64px] text-gray-500 dark:text-gray-400 font-bold pr-1 flex items-center gap-1\" title=\"Gemini 3.1 Pro\">\n                                    {(account.protected_models?.includes('gemini-3-pro-high') || account.protected_models?.includes('gemini-3.1-pro-high')) && <Lock className=\"w-2.5 h-2.5 text-rose-500 shrink-0 z-10\" />}\n                                    <span className=\"truncate\">G3.1 Pro</span>\n                                </span>\n                                <div className=\"flex-1 flex justify-center\">\n                                    {geminiProModel?.reset_time ? (\n                                        <span className={cn(\"flex items-center gap-0.5 font-medium transition-colors\", getTimeColorClass(geminiProModel.reset_time))}>\n                                            <Clock className=\"w-2.5 h-2.5\" />\n                                            {formatTimeRemaining(geminiProModel.reset_time)}\n                                        </span>\n                                    ) : (\n                                        <span className=\"text-gray-300 dark:text-gray-600 italic scale-90\">N/A</span>\n                                    )}\n                                </div>\n                                <span className={cn(\"w-[36px] text-right font-bold transition-colors\",\n                                    getQuotaColor(geminiProModel?.percentage || 0) === 'success' ? 'text-emerald-600 dark:text-emerald-400' :\n                                        getQuotaColor(geminiProModel?.percentage || 0) === 'warning' ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400'\n                                )}>\n                                    {geminiProModel ? `${geminiProModel.percentage}%` : '-'}\n                                </span>\n                            </div>\n                        </div>\n\n                        {/* Gemini Flash */}\n                        <div className=\"relative h-[22px] flex items-center px-1.5 rounded-md overflow-hidden border border-gray-100/50 dark:border-white/5 bg-gray-50/30 dark:bg-white/5 group/quota\">\n                            {geminiFlashModel && (\n                                <div\n                                    className={`absolute inset-y-0 left-0 transition-all duration-700 ease-out opacity-15 dark:opacity-20 ${getColorClass(geminiFlashModel.percentage)}`}\n                                    style={{ width: `${geminiFlashModel.percentage}%` }}\n                                />\n                            )}\n                            <div className=\"relative z-10 w-full flex items-center text-[10px] font-mono leading-none\">\n                                <span className=\"w-[64px] text-gray-500 dark:text-gray-400 font-bold pr-1 flex items-center gap-1\" title=\"Gemini 3 Flash\">\n                                    {account.protected_models?.includes('gemini-3-flash') && <Lock className=\"w-2.5 h-2.5 text-rose-500 shrink-0 z-10\" />}\n                                    <span className=\"truncate\">G3 Flash</span>\n                                </span>\n                                <div className=\"flex-1 flex justify-center\">\n                                    {geminiFlashModel?.reset_time ? (\n                                        <span className={cn(\"flex items-center gap-0.5 font-medium transition-colors\", getTimeColorClass(geminiFlashModel.reset_time))}>\n                                            <Clock className=\"w-2.5 h-2.5\" />\n                                            {formatTimeRemaining(geminiFlashModel.reset_time)}\n                                        </span>\n                                    ) : (\n                                        <span className=\"text-gray-300 dark:text-gray-600 italic scale-90\">N/A</span>\n                                    )}\n                                </div>\n                                <span className={cn(\"w-[36px] text-right font-bold transition-colors\",\n                                    getQuotaColor(geminiFlashModel?.percentage || 0) === 'success' ? 'text-emerald-600 dark:text-emerald-400' :\n                                        getQuotaColor(geminiFlashModel?.percentage || 0) === 'warning' ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400'\n                                )}>\n                                    {geminiFlashModel ? `${geminiFlashModel.percentage}%` : '-'}\n                                </span>\n                            </div>\n                        </div>\n\n                        {/* Gemini Image */}\n                        <div className=\"relative h-[22px] flex items-center px-1.5 rounded-md overflow-hidden border border-gray-100/50 dark:border-white/5 bg-gray-50/30 dark:bg-white/5 group/quota\">\n                            {geminiImageModel && (\n                                <div\n                                    className={`absolute inset-y-0 left-0 transition-all duration-700 ease-out opacity-15 dark:opacity-20 ${getColorClass(geminiImageModel.percentage)}`}\n                                    style={{ width: `${geminiImageModel.percentage}%` }}\n                                />\n                            )}\n                            <div className=\"relative z-10 w-full flex items-center text-[10px] font-mono leading-none\">\n                                <span className=\"w-[64px] text-gray-500 dark:text-gray-400 font-bold pr-1 flex items-center gap-1\" title=\"Gemini 3 Pro Image\">\n                                    {account.protected_models?.includes('gemini-3-pro-image') && <Lock className=\"w-2.5 h-2.5 text-rose-500 shrink-0 z-10\" />}\n                                    <span className=\"truncate\">G3 Image</span>\n                                </span>\n                                <div className=\"flex-1 flex justify-center\">\n                                    {geminiImageModel?.reset_time ? (\n                                        <span className={cn(\"flex items-center gap-0.5 font-medium transition-colors\", getTimeColorClass(geminiImageModel.reset_time))}>\n                                            <Clock className=\"w-2.5 h-2.5\" />\n                                            {formatTimeRemaining(geminiImageModel.reset_time)}\n                                        </span>\n                                    ) : (\n                                        <span className=\"text-gray-300 dark:text-gray-600 italic scale-90\">N/A</span>\n                                    )}\n                                </div>\n                                <span className={cn(\"w-[36px] text-right font-bold transition-colors\",\n                                    getQuotaColor(geminiImageModel?.percentage || 0) === 'success' ? 'text-emerald-600 dark:text-emerald-400' :\n                                        getQuotaColor(geminiImageModel?.percentage || 0) === 'warning' ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400'\n                                )}>\n                                    {geminiImageModel ? `${geminiImageModel.percentage}%` : '-'}\n                                </span>\n                            </div>\n                        </div>\n\n                        {/* Claude */}\n                        <div className=\"relative h-[22px] flex items-center px-1.5 rounded-md overflow-hidden border border-gray-100/50 dark:border-white/5 bg-gray-50/30 dark:bg-white/5 group/quota\">\n                            {claudeModel && (\n                                <div\n                                    className={`absolute inset-y-0 left-0 transition-all duration-700 ease-out opacity-15 dark:opacity-20 ${getColorClass(claudeModel.percentage)}`}\n                                    style={{ width: `${claudeModel.percentage}%` }}\n                                />\n                            )}\n                            <div className=\"relative z-10 w-full flex items-center text-[10px] font-mono leading-none\">\n                                <span className=\"w-[64px] text-gray-500 dark:text-gray-400 font-bold pr-1 flex items-center gap-1\" title=\"Claude Series\">\n                                    {account.protected_models?.includes('claude') && <Lock className=\"w-2.5 h-2.5 text-rose-500 shrink-0 z-10\" />}\n                                    <span className=\"truncate\">Claude</span>\n                                </span>\n                                <div className=\"flex-1 flex justify-center\">\n                                    {claudeModel?.reset_time ? (\n                                        <span className={cn(\"flex items-center gap-0.5 font-medium transition-colors\", getTimeColorClass(claudeModel.reset_time))}>\n                                            <Clock className=\"w-2.5 h-2.5\" />\n                                            {formatTimeRemaining(claudeModel.reset_time)}\n                                        </span>\n                                    ) : (\n                                        <span className=\"text-gray-300 dark:text-gray-600 italic scale-90\">N/A</span>\n                                    )}\n                                </div>\n                                <span className={cn(\"w-[36px] text-right font-bold transition-colors\",\n                                    getQuotaColor(claudeModel?.percentage || 0) === 'success' ? 'text-emerald-600 dark:text-emerald-400' :\n                                        getQuotaColor(claudeModel?.percentage || 0) === 'warning' ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400'\n                                )}>\n                                    {claudeModel ? `${claudeModel.percentage}%` : '-'}\n                                </span>\n                            </div>\n                        </div>\n                    </div>\n                )}\n            </td>\n\n            {/* 最后使用 */}\n            <td className=\"px-4 py-1\">\n                <div className=\"flex flex-col\">\n                    <span className=\"text-xs font-medium text-gray-600 dark:text-gray-400 font-mono whitespace-nowrap\">\n                        {new Date(account.last_used * 1000).toLocaleDateString()}\n                    </span>\n                    <span className=\"text-[10px] text-gray-400 dark:text-gray-500 font-mono whitespace-nowrap leading-tight\">\n                        {new Date(account.last_used * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}\n                    </span>\n                </div>\n            </td>\n\n            {/* 操作 */}\n            <td className=\"px-4 py-1\">\n                <div className=\"flex items-center gap-0.5 opacity-60 group-hover:opacity-100 transition-opacity\">\n                    <button\n                        className=\"p-1.5 text-gray-500 dark:text-gray-400 hover:text-sky-600 dark:hover:text-sky-400 hover:bg-sky-50 dark:hover:bg-sky-900/30 rounded-lg transition-all\"\n                        onClick={(e) => { e.stopPropagation(); onViewDetails(); }}\n                        title={t('common.details')}\n                    >\n                        <Info className=\"w-3.5 h-3.5\" />\n                        <button\n                            className=\"p-1.5 text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-lg transition-all\"\n                            onClick={(e) => { e.stopPropagation(); onViewDevice(); }}\n                            title={t('accounts.device_fingerprint')}\n                        >\n                            <Fingerprint className=\"w-3.5 h-3.5\" />\n                        </button>\n                    </button>\n                    <button\n                        className={`p-1.5 text-gray-500 dark:text-gray-400 rounded-lg transition-all ${(isSwitching || isDisabled) ? 'bg-blue-50 dark:bg-blue-900/10 text-blue-600 dark:text-blue-400 cursor-not-allowed' : 'hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30'}`}\n                        onClick={(e) => { e.stopPropagation(); onSwitch(); }}\n                        title={isDisabled ? t('accounts.disabled_tooltip') : (isSwitching ? t('common.loading') : t('accounts.switch_to'))}\n                        disabled={isSwitching || isDisabled}\n                    >\n                        <ArrowRightLeft className={`w-3.5 h-3.5 ${isSwitching ? 'animate-spin' : ''}`} />\n                    </button>\n                    <button\n                        className={`p-1.5 text-gray-500 dark:text-gray-400 rounded-lg transition-all ${(isRefreshing || isDisabled) ? 'bg-green-50 dark:bg-green-900/10 text-green-600 dark:text-green-400 cursor-not-allowed' : 'hover:text-green-600 dark:hover:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/30'}`}\n                        onClick={(e) => { e.stopPropagation(); onRefresh(); }}\n                        title={isDisabled ? t('accounts.disabled_tooltip') : (isRefreshing ? t('common.refreshing') : t('common.refresh'))}\n                        disabled={isRefreshing || isDisabled}\n                    >\n                        <RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />\n                    </button>\n                    <button\n                        className=\"p-1.5 text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-lg transition-all\"\n                        onClick={(e) => { e.stopPropagation(); onExport(); }}\n                        title={t('common.export')}\n                    >\n                        <Download className=\"w-3.5 h-3.5\" />\n                    </button>\n                    <button\n                        className={cn(\n                            \"p-1.5 rounded-lg transition-all\",\n                            account.proxy_disabled\n                                ? \"text-gray-500 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/30\"\n                                : \"text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/30\"\n                        )}\n                        onClick={(e) => { e.stopPropagation(); onToggleProxy(); }}\n                        title={account.proxy_disabled ? t('accounts.enable_proxy') : t('accounts.disable_proxy')}\n                    >\n                        {account.proxy_disabled ? (\n                            <ToggleRight className=\"w-3.5 h-3.5\" />\n                        ) : (\n                            <ToggleLeft className=\"w-3.5 h-3.5\" />\n                        )}\n                    </button>\n                    <button\n                        className=\"p-1.5 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-all\"\n                        onClick={(e) => { e.stopPropagation(); onDelete(); }}\n                        title={t('common.delete')}\n                    >\n                        <Trash2 className=\"w-3.5 h-3.5\" />\n                    </button>\n                </div>\n            </td>\n        </tr>\n    );\n}\n\nexport default AccountRow;\n"
  },
  {
    "path": "src/components/accounts/AccountTable.tsx",
    "content": "/**\n * 账号表格组件\n * 支持拖拽排序功能，用户可以通过拖拽行来调整账号顺序\n */\nimport { useMemo, useState } from 'react';\nimport {\n    DndContext,\n    closestCenter,\n    KeyboardSensor,\n    PointerSensor,\n    useSensor,\n    useSensors,\n    DragEndEvent,\n    DragStartEvent,\n    DragOverlay,\n} from '@dnd-kit/core';\nimport {\n    arrayMove,\n    SortableContext,\n    sortableKeyboardCoordinates,\n    useSortable,\n    verticalListSortingStrategy,\n} from '@dnd-kit/sortable';\nimport { CSS } from '@dnd-kit/utilities';\nimport {\n    GripVertical,\n    ArrowRightLeft,\n    RefreshCw,\n    Trash2,\n    Download,\n    Fingerprint,\n    Info,\n    Lock,\n    Ban,\n    Diamond,\n    Gem,\n    Circle,\n    ToggleLeft,\n    ToggleRight,\n    Sparkles,\n    Tag,\n    X,\n    Check,\n    Clock,\n    Bot,\n} from 'lucide-react';\nimport { Account } from '../../types/account';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../../utils/cn';\n\nimport { useConfigStore } from '../../stores/useConfigStore';\nimport { QuotaItem } from './QuotaItem';\nimport { MODEL_CONFIG, sortModels } from '../../config/modelConfig';\n\n// ============================================================================\n// 类型定义\n// ============================================================================\n\ninterface AccountTableProps {\n    accounts: Account[];\n    selectedIds: Set<string>;\n    refreshingIds: Set<string>;\n    onToggleSelect: (id: string) => void;\n    onToggleAll: () => void;\n    currentAccountId: string | null;\n    switchingAccountId: string | null;\n    onSwitch: (accountId: string) => void;\n    onRefresh: (accountId: string) => void;\n    onViewDevice: (accountId: string) => void;\n    onViewDetails: (accountId: string) => void;\n    onExport: (accountId: string) => void;\n    onDelete: (accountId: string) => void;\n    onToggleProxy: (accountId: string) => void;\n    onWarmup?: (accountId: string) => void;\n    onUpdateLabel?: (accountId: string, label: string) => void;\n    /** 拖拽排序回调，当用户完成拖拽时触发 */\n    onReorder?: (accountIds: string[]) => void;\n    onViewError: (accountId: string) => void;\n}\n\ninterface SortableRowProps {\n    account: Account;\n    selected: boolean;\n    isRefreshing: boolean;\n    isCurrent: boolean;\n    isSwitching: boolean;\n    isDragging?: boolean;\n    onSelect: () => void;\n    onSwitch: () => void;\n    onRefresh: () => void;\n    onViewDevice: () => void;\n    onViewDetails: () => void;\n    onExport: () => void;\n    onDelete: () => void;\n    onToggleProxy: () => void;\n    onWarmup?: () => void;\n    onUpdateLabel?: (label: string) => void;\n    onViewError: () => void;\n}\n\ninterface AccountRowContentProps {\n    account: Account;\n    isCurrent: boolean;\n    isRefreshing: boolean;\n    isSwitching: boolean;\n    isDisabled: boolean;\n    onSwitch: () => void;\n    onRefresh: () => void;\n    onViewDevice: () => void;\n    onViewDetails: () => void;\n    onExport: () => void;\n    onDelete: () => void;\n    onToggleProxy: () => void;\n    onWarmup?: () => void;\n    onUpdateLabel?: (label: string) => void;\n    onViewError: () => void;\n}\n\n// ============================================================================\n// 辅助函数\n// ============================================================================\n\n\n\n// ============================================================================\n// 模型分组配置\n// ============================================================================\n\nconst MODEL_GROUPS = {\n    CLAUDE: [\n        'claude-opus-4-6-thinking',\n        'claude'\n    ],\n    GEMINI_PRO: [\n        'gemini-3.1-pro-high',\n        'gemini-3.1-pro-low',\n        'gemini-3.1-pro-preview',\n        'gemini-3-pro-high',\n        'gemini-3-pro-low',\n        'gemini-3-pro-preview'\n    ],\n    GEMINI_FLASH: [\n        'gemini-3-flash'\n    ]\n};\n\nconst MODEL_ID_ALIASES: Record<string, string[]> = {\n    'gemini-3-pro-high': ['gemini-3-pro-high', 'gemini-3.1-pro-high'],\n    'gemini-3-pro-low': ['gemini-3-pro-low', 'gemini-3.1-pro-low'],\n    'gemini-3-pro-preview': ['gemini-3-pro-preview', 'gemini-3.1-pro-preview'],\n    'gemini-3.1-pro-high': ['gemini-3.1-pro-high', 'gemini-3-pro-high'],\n    'gemini-3.1-pro-low': ['gemini-3.1-pro-low', 'gemini-3-pro-low'],\n    'gemini-3.1-pro-preview': ['gemini-3.1-pro-preview', 'gemini-3-pro-preview'],\n};\n\nfunction getModelAliases(modelId: string): string[] {\n    return MODEL_ID_ALIASES[modelId] || [modelId];\n}\n\nfunction isModelProtected(protectedModels: string[] | undefined, modelName: string): boolean {\n    if (!protectedModels || protectedModels.length === 0) return false;\n    const lowerName = modelName.toLowerCase();\n\n    // Helper to check if any model in the group is protected\n    const isGroupProtected = (group: string[]) => {\n        return group.some(m => protectedModels.includes(m));\n    };\n\n    // UI Column Keys Mapping (for backward compatibility with hardcoded UI calls)\n    if (lowerName === 'gemini-pro') return isGroupProtected(MODEL_GROUPS.GEMINI_PRO);\n    if (lowerName === 'gemini-flash') return isGroupProtected(MODEL_GROUPS.GEMINI_FLASH);\n    if (lowerName === 'claude-sonnet') return isGroupProtected(MODEL_GROUPS.CLAUDE);\n\n    // 1. Gemini Pro Group\n    if (MODEL_GROUPS.GEMINI_PRO.some(m => lowerName === m)) {\n        return isGroupProtected(MODEL_GROUPS.GEMINI_PRO);\n    }\n\n    // 2. Claude Group\n    if (MODEL_GROUPS.CLAUDE.some(m => lowerName === m)) {\n        return isGroupProtected(MODEL_GROUPS.CLAUDE);\n    }\n\n    // 3. Gemini Flash Group\n    if (MODEL_GROUPS.GEMINI_FLASH.some(m => lowerName === m)) {\n        return isGroupProtected(MODEL_GROUPS.GEMINI_FLASH);\n    }\n\n    // 兜底直接检查 (Strict check for exact match or normalized ID)\n    return protectedModels.includes(lowerName);\n}\n\n// ============================================================================\n// 子组件\n// ============================================================================\n\n/**\n * 可拖拽的表格行组件\n * 使用 @dnd-kit/sortable 实现拖拽功能\n */\nfunction SortableAccountRow({\n    account,\n    selected,\n    isRefreshing,\n    isCurrent,\n    isSwitching,\n    isDragging,\n    onSelect,\n    onSwitch,\n    onRefresh,\n    onViewDevice,\n    onViewDetails,\n    onExport,\n    onDelete,\n    onToggleProxy,\n    onWarmup,\n    onUpdateLabel,\n    onViewError,\n}: SortableRowProps) {\n    const { t } = useTranslation();\n    const {\n        attributes,\n        listeners,\n        setNodeRef,\n        transform,\n        transition,\n        isDragging: isSortableDragging,\n    } = useSortable({ id: account.id });\n\n    const style = {\n        transform: CSS.Transform.toString(transform),\n        transition,\n        opacity: isSortableDragging ? 0.5 : 1,\n        zIndex: isSortableDragging ? 1000 : 'auto',\n    };\n\n    return (\n        <tr\n            ref={setNodeRef}\n            style={style as React.CSSProperties}\n            className={cn(\n                \"group transition-colors border-b border-gray-100 dark:border-base-200\",\n                isCurrent && \"bg-blue-50/50 dark:bg-blue-900/10\",\n                isDragging && \"bg-blue-100 dark:bg-blue-900/30 shadow-lg\",\n                !isDragging && \"hover:bg-gray-50 dark:hover:bg-base-200\"\n            )}\n        >\n            {/* 拖拽手柄 */}\n            <td className=\"pl-2 py-1 w-8 align-middle\">\n                <div\n                    {...attributes}\n                    {...listeners}\n                    className=\"flex items-center justify-center w-6 h-6 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors rounded hover:bg-gray-100 dark:hover:bg-gray-700\"\n                    title={t('accounts.drag_to_reorder')}\n                >\n                    <GripVertical className=\"w-4 h-4\" />\n                </div>\n            </td>\n            {/* 复选框 */}\n            <td className=\"px-2 py-1 w-10 align-middle\">\n                <input\n                    type=\"checkbox\"\n                    className=\"checkbox checkbox-xs rounded border-2 border-gray-400 dark:border-gray-500 checked:border-blue-600 checked:bg-blue-600 [--chkbg:theme(colors.blue.600)] [--chkfg:white]\"\n                    checked={selected}\n                    onChange={onSelect}\n                    onClick={(e) => e.stopPropagation()}\n                />\n            </td>\n            <AccountRowContent\n                account={account}\n                isCurrent={isCurrent}\n                isRefreshing={isRefreshing}\n                isSwitching={isSwitching}\n                isDisabled={Boolean(account.disabled)}\n                onSwitch={onSwitch}\n                onRefresh={onRefresh}\n                onViewDevice={onViewDevice}\n                onViewDetails={onViewDetails}\n                onExport={onExport}\n                onDelete={onDelete}\n                onToggleProxy={onToggleProxy}\n                onWarmup={onWarmup}\n                onUpdateLabel={onUpdateLabel}\n                onViewError={onViewError}\n            />\n        </tr>\n    );\n}\n\n/**\n * 账号行内容组件\n * 渲染邮箱、配额、最后使用时间和操作按钮等列\n */\nfunction AccountRowContent({\n    account,\n    isCurrent,\n    isRefreshing,\n    isSwitching,\n    isDisabled,\n    onSwitch,\n    onRefresh,\n    onViewDevice,\n    onViewDetails,\n    onExport,\n    onDelete,\n    onToggleProxy,\n    onWarmup,\n    onUpdateLabel,\n    onViewError,\n}: AccountRowContentProps) {\n    const { t } = useTranslation();\n    const { config, showAllQuotas } = useConfigStore();\n\n    // 自定义标签编辑状态\n    const [isEditingLabel, setIsEditingLabel] = useState(false);\n    const [labelInput, setLabelInput] = useState(account.custom_label || '');\n\n    const handleSaveLabel = () => {\n        if (onUpdateLabel) {\n            onUpdateLabel(labelInput.trim());\n        }\n        setIsEditingLabel(false);\n    };\n\n    const handleCancelLabel = () => {\n        setLabelInput(account.custom_label || '');\n        setIsEditingLabel(false);\n    };\n\n    const handleKeyDown = (e: React.KeyboardEvent) => {\n        if (e.key === 'Enter') {\n            handleSaveLabel();\n        } else if (e.key === 'Escape') {\n            handleCancelLabel();\n        }\n    };\n\n    // 使用统一的模型配置\n\n    // 获取要显示的模型列表\n    const pinnedModels = config?.pinned_quota_models?.models || Object.keys(MODEL_CONFIG);\n\n    // 根据 show_all 状态决定显示哪些模型\n    const uniqueLabels = new Set<string>();\n    const displayModels = sortModels(\n        (showAllQuotas\n            ? (account.quota?.models || []).map(m => {\n                const config = MODEL_CONFIG[m.name.toLowerCase()];\n                const label = m.display_name || (config?.i18nKey ? t(config.i18nKey) : (config?.shortLabel || config?.label || m.name));\n                return {\n                    id: m.name.toLowerCase(),\n                    label: label,\n                    protectedKey: config?.protectedKey || m.name.toLowerCase(),\n                    data: m\n                };\n            })\n            : pinnedModels.map(modelId => {\n                const m = account.quota?.models.find(m => m.name === modelId || getModelAliases(modelId).includes(m.name.toLowerCase()));\n                const config = MODEL_CONFIG[modelId];\n                if (!config && !m) return null; // Safe guard for unknown models that aren't fetched\n                const label = m?.display_name || (config?.i18nKey ? t(config.i18nKey) : (config?.shortLabel || config?.label || modelId));\n                return {\n                    id: modelId,\n                    label: label,\n                    protectedKey: config?.protectedKey || modelId,\n                    data: m\n                };\n            }).filter(Boolean) as any[]\n        ).filter(m => {\n            // 过滤特定的 Claude/Gemini 思考变体 (在列表页隐藏)\n            const isHiddenThinking = m.id.includes('thinking');\n\n            if (isHiddenThinking) return false;\n\n            // 基于标签去重 (例如 G3.1 Pro 只显示一次)\n            // 优先显示有配额数据的 ID\n            const labelKey = `${m.label}-${m.protectedKey}`;\n            if (uniqueLabels.has(labelKey)) {\n                return false;\n            }\n            if (m.data) {\n                uniqueLabels.add(labelKey);\n                return true;\n            }\n            return true;\n        })\n    ).filter((m, index, self) => {\n        // 第二次过滤：确保即使没有数据的重复 Label 也只保留一个\n        const labelKey = `${m.label}-${m.protectedKey}`;\n        return self.findIndex(t => `${t.label}-${t.protectedKey}` === labelKey) === index;\n    });\n\n\n    return (\n        <>\n            {/* 邮箱列 */}\n            <td className=\"px-2 py-1 align-middle\">\n                <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1\">\n                    <span className={cn(\n                        \"font-medium text-sm break-all transition-colors\",\n                        isCurrent ? \"text-blue-700 dark:text-blue-400\" : \"text-gray-900 dark:text-base-content\"\n                    )} title={account.email}>\n                        {account.email}\n                    </span>\n\n                    <div className=\"flex items-center gap-1.5 shrink-0\">\n                        {isCurrent && (\n                            <span className=\"px-2 py-0.5 rounded-md bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 text-[10px] font-bold shadow-sm border border-blue-200/50 dark:border-blue-800/50\">\n                                {t('accounts.current').toUpperCase()}\n                            </span>\n                        )}\n                        {isDisabled && (\n                            <span\n                                className=\"px-2 py-0.5 rounded-md bg-rose-100 dark:bg-rose-900/50 text-rose-700 dark:text-rose-300 text-[10px] font-bold flex items-center gap-1 shadow-sm border border-rose-200/50\"\n                            >\n                                <Ban className=\"w-2.5 h-2.5\" />\n                                <span>{t('accounts.disabled')}</span>\n                            </span>\n                        )}\n\n                        {account.proxy_disabled && (\n                            <span\n                                className=\"px-2 py-0.5 rounded-md bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300 text-[10px] font-bold flex items-center gap-1 shadow-sm border border-orange-200/50\"\n                            >\n                                <Ban className=\"w-2.5 h-2.5\" />\n                                <span>{t('accounts.proxy_disabled')}</span>\n                            </span>\n                        )}\n\n                        {account.quota?.is_forbidden && (\n                            <span className=\"px-2 py-0.5 rounded-md bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-400 text-[10px] font-bold flex items-center gap-1 shadow-sm border border-red-200/50\">\n                                <Lock className=\"w-2.5 h-2.5\" />\n                                <span>{t('accounts.forbidden')}</span>\n                            </span>\n                        )}\n                        {account.validation_blocked && (\n                            <span className=\"px-2 py-0.5 rounded-md bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-400 text-[10px] font-bold flex items-center gap-1 shadow-sm border border-amber-200/50\">\n                                <Clock className=\"w-2.5 h-2.5\" />\n                                <span>{t('accounts.status.validation_required')}</span>\n                            </span>\n                        )}\n\n\n                        {/* 订阅类型徽章 */}\n                        {account.quota?.subscription_tier && (() => {\n                            const tier = account.quota.subscription_tier.toLowerCase();\n                            if (tier.includes('ultra')) {\n                                return (\n                                    <span className=\"flex items-center gap-1 px-2 py-0.5 rounded-md bg-gradient-to-r from-purple-600 to-pink-600 text-white text-[10px] font-bold shadow-sm hover:scale-105 transition-transform cursor-default\">\n                                        <Gem className=\"w-2.5 h-2.5 fill-current\" />\n                                        {t('accounts.ultra')}\n                                    </span>\n                                );\n                            } else if (tier.includes('pro')) {\n                                return (\n                                    <span className=\"flex items-center gap-1 px-2 py-0.5 rounded-md bg-gradient-to-r from-blue-600 to-indigo-600 text-white text-[10px] font-bold shadow-sm hover:scale-105 transition-transform cursor-default\">\n                                        <Diamond className=\"w-2.5 h-2.5 fill-current\" />\n                                        {t('accounts.pro')}\n                                    </span>\n                                );\n                            } else {\n                                return (\n                                    <span className=\"flex items-center gap-1 px-2 py-0.5 rounded-md bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-400 text-[10px] font-bold shadow-sm border border-gray-200 dark:border-white/10 hover:bg-gray-200 transition-colors cursor-default\">\n                                        <Circle className=\"w-2.5 h-2.5\" />\n                                        {t('accounts.free')}\n                                    </span>\n                                );\n                            }\n                        })()}\n                        {/* 自定义标签 */}\n                        {account.custom_label && !isEditingLabel && (\n                            <span className=\"flex items-center gap-1 px-2 py-0.5 rounded-md bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 text-[10px] font-bold shadow-sm border border-orange-200/50 dark:border-orange-800/50\">\n                                <Tag className=\"w-2.5 h-2.5\" />\n                                {account.custom_label}\n                            </span>\n                        )}\n                        {/* 标签编辑输入框 */}\n                        {isEditingLabel && (\n                            <div className=\"flex items-center gap-1\">\n                                <input\n                                    type=\"text\"\n                                    className=\"px-1.5 py-0.5 text-[10px] w-20 border border-orange-300 dark:border-orange-700 rounded focus:outline-none focus:ring-1 focus:ring-orange-500 bg-white dark:bg-base-200\"\n                                    placeholder={t('accounts.custom_label_placeholder', 'Label')}\n                                    value={labelInput}\n                                    onChange={(e) => setLabelInput(e.target.value)}\n                                    onKeyDown={handleKeyDown}\n                                    autoFocus\n                                    maxLength={15}\n                                    onClick={(e) => e.stopPropagation()}\n                                />\n                                <button\n                                    className=\"p-0.5 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-all\"\n                                    onClick={(e) => { e.stopPropagation(); handleSaveLabel(); }}\n                                >\n                                    <Check className=\"w-3 h-3\" />\n                                </button>\n                                <button\n                                    className=\"p-0.5 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-all\"\n                                    onClick={(e) => { e.stopPropagation(); handleCancelLabel(); }}\n                                >\n                                    <X className=\"w-3 h-3\" />\n                                </button>\n                            </div>\n                        )}\n                    </div>\n\n                </div>\n            </td>\n\n            {/* 模型配额列 */}\n            <td className=\"px-2 py-1 align-middle\">\n                {isDisabled || account.quota?.is_forbidden || account.validation_blocked ? (\n                    <div className={cn(\n                        \"flex items-center justify-center gap-3 py-1.5 px-4 rounded-xl border group/error\",\n                        account.validation_blocked ? \"bg-amber-50/50 dark:bg-amber-900/10 border-amber-100/50 dark:border-amber-900/20\" : \"bg-red-50/50 dark:bg-red-900/10 border-red-100/50 dark:border-red-900/20\"\n                    )}>\n                        <div className={cn(\n                            \"flex items-center gap-1.5\",\n                            account.validation_blocked ? \"text-amber-600 dark:text-amber-400\" : \"text-red-600 dark:text-red-400\"\n                        )}>\n                            {account.validation_blocked ? <Clock className=\"w-3.5 h-3.5\" /> : (account.quota?.is_forbidden ? <Lock className=\"w-3.5 h-3.5\" /> : <Ban className=\"w-3.5 h-3.5\" />)}\n                            <span className={cn(\n                                \"text-[11px] font-bold\",\n                                account.validation_blocked ? \"text-amber-700/80 dark:text-amber-400\" : \"text-red-700/80 dark:text-red-400\"\n                            )}>\n                                {account.validation_blocked ? t('accounts.status.validation_required') : (isDisabled ? t('accounts.status.disabled') : t('accounts.forbidden_msg'))}\n                            </span>\n                        </div>\n                        <div className={cn(\n                            \"w-px h-3\",\n                            account.validation_blocked ? \"bg-amber-200 dark:bg-amber-800/50\" : \"bg-red-200 dark:bg-red-800/50\"\n                        )} />\n                        <button\n                            onClick={(e) => { e.stopPropagation(); onViewError(); }}\n                            className=\"text-[10px] font-medium text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-0.5\"\n                        >\n                            {t('accounts.view_error')}\n                        </button>\n                    </div>\n                ) : (\n                    <div className={cn(\n                        \"grid gap-x-4 gap-y-1 py-0\",\n                        displayModels.length === 1 ? \"grid-cols-1\" : \"grid-cols-2\"\n                    )}>\n                        {displayModels.map((model) => {\n                            const modelData = model.data;\n\n                            return (\n                                <QuotaItem\n                                    key={model.id}\n                                    label={model.label}\n                                    percentage={modelData?.percentage || 0}\n                                    resetTime={modelData?.reset_time}\n                                    isProtected={isModelProtected(account.protected_models, model.protectedKey)}\n                                    Icon={MODEL_CONFIG[model.id]?.Icon || Bot}\n                                />\n                            );\n                        })}\n                    </div>\n                )}\n            </td>\n\n            {/* 最后使用时间列 */}\n            <td className=\"px-2 py-1 align-middle\">\n                <div className=\"flex flex-col\">\n                    <span className=\"text-xs font-medium text-gray-600 dark:text-gray-400 font-mono whitespace-nowrap\">\n                        {new Date(account.last_used * 1000).toLocaleDateString()}\n                    </span>\n                    <span className=\"text-[10px] text-gray-400 dark:text-gray-500 font-mono whitespace-nowrap leading-tight\">\n                        {new Date(account.last_used * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}\n                    </span>\n                </div>\n            </td>\n\n            {/* 操作列 */}\n            <td className={cn(\n                \"px-1 py-1 sticky right-0 z-10 shadow-[-12px_0_12px_-12px_rgba(0,0,0,0.1)] dark:shadow-[-12px_0_12px_-12px_rgba(255,255,255,0.05)] text-center align-middle\",\n                // 动态背景色处理\n                isCurrent\n                    ? \"bg-[#f1f6ff] dark:bg-[#1e2330]\" // 接近 blue-50/50 的实色\n                    : \"bg-white dark:bg-base-100\",\n                !isCurrent && \"group-hover:bg-gray-50 dark:group-hover:bg-base-200\"\n            )}>\n                <div className=\"flex flex-wrap items-center justify-center gap-1 opacity-60 group-hover:opacity-100 transition-opacity max-w-[180px] mx-auto\">\n                    <button\n                        className=\"p-1.5 text-gray-500 dark:text-gray-400 hover:text-sky-600 dark:hover:text-sky-400 hover:bg-sky-50 dark:hover:bg-sky-900/30 rounded-lg transition-all\"\n                        onClick={(e) => { e.stopPropagation(); onViewDetails(); }}\n                        title={t('common.details')}\n                    >\n                        <Info className=\"w-3.5 h-3.5\" />\n                    </button>\n                    <button\n                        className=\"p-1.5 text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-lg transition-all\"\n                        onClick={(e) => { e.stopPropagation(); onViewDevice(); }}\n                        title={t('accounts.device_fingerprint')}\n                    >\n                        <Fingerprint className=\"w-3.5 h-3.5\" />\n                    </button>\n                    {/* 自定义标签按钮 */}\n                    {onUpdateLabel && (\n                        <button\n                            className={cn(\n                                \"p-1.5 rounded-lg transition-all\",\n                                account.custom_label\n                                    ? \"text-orange-500 hover:text-orange-600 hover:bg-orange-50 dark:hover:bg-orange-900/30\"\n                                    : \"text-gray-500 dark:text-gray-400 hover:text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/30\"\n                            )}\n                            onClick={(e) => { e.stopPropagation(); setIsEditingLabel(true); }}\n                            title={t('accounts.edit_label', 'Edit Label')}\n                        >\n                            <Tag className=\"w-3.5 h-3.5\" />\n                        </button>\n                    )}\n                    <button\n                        className={`p-1.5 text-gray-500 dark:text-gray-400 rounded-lg transition-all ${(isSwitching || isDisabled) ? 'bg-blue-50 dark:bg-blue-900/10 text-blue-600 dark:text-blue-400 cursor-not-allowed' : 'hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30'}`}\n                        onClick={(e) => { e.stopPropagation(); onSwitch(); }}\n                        title={isDisabled ? t('accounts.disabled_tooltip') : (isSwitching ? t('common.loading') : t('accounts.switch_to'))}\n                        disabled={isSwitching || isDisabled}\n                    >\n                        <ArrowRightLeft className={`w-3.5 h-3.5 ${isSwitching ? 'animate-spin' : ''}`} />\n                    </button>\n                    {onWarmup && (\n                        <button\n                            className={`p-1.5 text-gray-500 dark:text-gray-400 rounded-lg transition-all ${(isRefreshing || isDisabled) ? 'bg-orange-50 dark:bg-orange-900/10 text-orange-600 dark:text-orange-400 cursor-not-allowed' : 'hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/30'}`}\n                            onClick={(e) => { e.stopPropagation(); onWarmup(); }}\n                            title={isDisabled ? t('accounts.disabled_tooltip') : (isRefreshing ? t('common.loading') : t('accounts.warmup_this', '预热该账号'))}\n                            disabled={isRefreshing || isDisabled}\n                        >\n                            <Sparkles className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-pulse' : ''}`} />\n                        </button>\n                    )}\n                    <button\n                        className={`p-1.5 text-gray-500 dark:text-gray-400 rounded-lg transition-all ${(isRefreshing || isDisabled) ? 'bg-green-50 dark:bg-green-900/10 text-green-600 dark:text-green-400 cursor-not-allowed' : 'hover:text-green-600 dark:hover:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/30'}`}\n                        onClick={(e) => { e.stopPropagation(); onRefresh(); }}\n                        title={isDisabled ? t('accounts.disabled_tooltip') : (isRefreshing ? t('common.refreshing') : t('common.refresh'))}\n                        disabled={isRefreshing || isDisabled}\n                    >\n                        <RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />\n                    </button>\n                    <button\n                        className=\"p-1.5 text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-lg transition-all\"\n                        onClick={(e) => { e.stopPropagation(); onExport(); }}\n                        title={t('common.export')}\n                    >\n                        <Download className=\"w-3.5 h-3.5\" />\n                    </button>\n                    <button\n                        className={cn(\n                            \"p-1.5 rounded-lg transition-all\",\n                            account.proxy_disabled\n                                ? \"text-gray-500 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/30\"\n                                : \"text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/30\"\n                        )}\n                        onClick={(e) => { e.stopPropagation(); onToggleProxy(); }}\n                        title={account.proxy_disabled ? t('accounts.enable_proxy') : t('accounts.disable_proxy')}\n                    >\n                        {account.proxy_disabled ? (\n                            <ToggleRight className=\"w-3.5 h-3.5\" />\n                        ) : (\n                            <ToggleLeft className=\"w-3.5 h-3.5\" />\n                        )}\n                    </button>\n                    <button\n                        className=\"p-1.5 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-all\"\n                        onClick={(e) => { e.stopPropagation(); onDelete(); }}\n                        title={t('common.delete')}\n                    >\n                        <Trash2 className=\"w-3.5 h-3.5\" />\n                    </button>\n                </div>\n            </td>\n        </>\n    );\n}\n\n// ============================================================================\n// 主组件\n// ============================================================================\n\n/**\n * 账号表格组件\n * 支持拖拽排序、多选、批量操作等功能\n */\nfunction AccountTable({\n    accounts,\n    selectedIds,\n    refreshingIds,\n    onToggleSelect,\n    onToggleAll,\n    currentAccountId,\n    switchingAccountId,\n    onSwitch,\n    onRefresh,\n    onViewDevice,\n    onViewDetails,\n    onExport,\n    onDelete,\n    onToggleProxy,\n    onReorder,\n    onWarmup,\n    onUpdateLabel,\n    onViewError,\n}: AccountTableProps) {\n    const { t } = useTranslation();\n\n    const [activeId, setActiveId] = useState<string | null>(null);\n    // showAllQuotas 已经在 useConfigStore 中解构获取\n\n    // 配置拖拽传感器\n    const sensors = useSensors(\n        useSensor(PointerSensor, {\n            activationConstraint: { distance: 8 }, // 需要移动 8px 才触发拖拽\n        }),\n        useSensor(KeyboardSensor, {\n            coordinateGetter: sortableKeyboardCoordinates,\n        })\n    );\n\n    const accountIds = useMemo(() => accounts.map(a => a.id), [accounts]);\n    const activeAccount = useMemo(() => accounts.find(a => a.id === activeId), [accounts, activeId]);\n\n    const handleDragStart = (event: DragStartEvent) => {\n        setActiveId(event.active.id as string);\n    };\n\n    const handleDragEnd = (event: DragEndEvent) => {\n        const { active, over } = event;\n        setActiveId(null);\n\n        if (over && active.id !== over.id) {\n            const oldIndex = accountIds.indexOf(active.id as string);\n            const newIndex = accountIds.indexOf(over.id as string);\n\n            if (oldIndex !== -1 && newIndex !== -1 && onReorder) {\n                onReorder(arrayMove(accountIds, oldIndex, newIndex));\n            }\n        }\n    };\n\n    if (accounts.length === 0) {\n        return (\n            <div className=\"bg-white dark:bg-base-100 rounded-2xl p-12 shadow-sm border border-gray-100 dark:border-base-200 text-center\">\n                <p className=\"text-gray-400 mb-2\">{t('accounts.empty.title')}</p>\n                <p className=\"text-sm text-gray-400\">{t('accounts.empty.desc')}</p>\n            </div>\n        );\n    }\n\n    return (\n        <DndContext\n            sensors={sensors}\n            collisionDetection={closestCenter}\n            onDragStart={handleDragStart}\n            onDragEnd={handleDragEnd}\n        >\n            <div className=\"overflow-x-auto\">\n                <table className=\"w-full\">\n                    <thead>\n                        <tr className=\"border-b border-gray-100 dark:border-base-200 bg-gray-50 dark:bg-base-200\">\n                            <th className=\"pl-2 py-2 text-left w-8\">\n                                <span className=\"sr-only\">{t('accounts.drag_to_reorder')}</span>\n                            </th>\n                            <th className=\"px-2 py-2 text-left w-10\">\n                                <input\n                                    type=\"checkbox\"\n                                    className=\"checkbox checkbox-sm rounded border-2 border-gray-400 dark:border-gray-500 checked:border-blue-600 checked:bg-blue-600 [--chkbg:theme(colors.blue.600)] [--chkfg:white]\"\n                                    checked={accounts.length > 0 && selectedIds.size === accounts.length}\n                                    onChange={onToggleAll}\n                                />\n                            </th>\n                            <th className=\"px-2 py-1 text-left rtl:text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-[300px] whitespace-nowrap\">{t('accounts.table.email')}</th>\n                            <th className=\"px-2 py-1 text-left rtl:text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider min-w-[380px] whitespace-nowrap\">\n                                {t('accounts.table.quota')}\n                            </th>\n                            <th className=\"px-2 py-1 text-left rtl:text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-[90px] whitespace-nowrap\">{t('accounts.table.last_used')}</th>\n                            <th className=\"px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider whitespace-nowrap sticky right-0 w-[180px] bg-gray-50 dark:bg-base-200 z-20 shadow-[-12px_0_12px_-12px_rgba(0,0,0,0.1)] dark:shadow-[-12px_0_12px_-12px_rgba(255,255,255,0.05)] text-center\">{t('accounts.table.actions')}</th>\n                        </tr >\n                    </thead >\n                    <SortableContext items={accountIds} strategy={verticalListSortingStrategy}>\n                        <tbody className=\"divide-y divide-gray-100 dark:divide-base-200\">\n                            {accounts.map((account) => (\n                                <SortableAccountRow\n                                    key={account.id}\n                                    account={account}\n                                    selected={selectedIds.has(account.id)}\n                                    isRefreshing={refreshingIds.has(account.id)}\n                                    isCurrent={account.id === currentAccountId}\n                                    isSwitching={account.id === switchingAccountId}\n                                    isDragging={account.id === activeId}\n                                    onSelect={() => onToggleSelect(account.id)}\n                                    onSwitch={() => onSwitch(account.id)}\n                                    onRefresh={() => onRefresh(account.id)}\n                                    onViewDevice={() => onViewDevice(account.id)}\n                                    onViewDetails={() => onViewDetails(account.id)}\n                                    onExport={() => onExport(account.id)}\n                                    onDelete={() => onDelete(account.id)}\n                                    onToggleProxy={() => onToggleProxy(account.id)}\n                                    onWarmup={onWarmup ? () => onWarmup(account.id) : undefined}\n                                    onUpdateLabel={onUpdateLabel ? (label: string) => onUpdateLabel(account.id, label) : undefined}\n                                    onViewError={() => onViewError(account.id)}\n                                />\n                            ))}\n                        </tbody>\n                    </SortableContext>\n                </table >\n            </div >\n\n            {/* 拖拽悬浮预览层 */}\n            <DragOverlay>\n                {\n                    activeAccount ? (\n                        <table className=\"w-full bg-white dark:bg-base-100 shadow-2xl rounded-lg border border-blue-200 dark:border-blue-800\">\n                            <tbody>\n                                <tr className=\"bg-blue-50 dark:bg-blue-900/30\">\n                                    <td className=\"pl-2 py-1 w-8\">\n                                        <div className=\"flex items-center justify-center w-6 h-6 text-blue-500\">\n                                            <GripVertical className=\"w-4 h-4\" />\n                                        </div>\n                                    </td>\n                                    <td className=\"px-2 py-1 w-10\">\n                                        <input\n                                            type=\"checkbox\"\n                                            className=\"checkbox checkbox-xs rounded border-2\"\n                                            checked={selectedIds.has(activeAccount.id)}\n                                            readOnly\n                                        />\n                                    </td>\n                                    <AccountRowContent\n                                        account={activeAccount}\n                                        isCurrent={activeAccount.id === currentAccountId}\n                                        isRefreshing={refreshingIds.has(activeAccount.id)}\n                                        isSwitching={activeAccount.id === switchingAccountId}\n                                        onSwitch={() => { }}\n                                        onRefresh={() => { }}\n                                        onViewDevice={() => { }}\n                                        onViewDetails={() => { }}\n                                        onExport={() => { }}\n                                        onDelete={() => { }}\n                                        onToggleProxy={() => { }}\n                                        isDisabled={Boolean(activeAccount.disabled)}\n                                        onViewError={() => { }}\n                                    />\n                                </tr>\n                            </tbody>\n                        </table>\n                    ) : null\n                }\n            </DragOverlay>\n        </DndContext>\n    );\n}\n\nexport default AccountTable;\n"
  },
  {
    "path": "src/components/accounts/AddAccountDialog.tsx",
    "content": "import { useState, useEffect, useRef } from 'react';\nimport { createPortal } from 'react-dom';\nimport { Plus, Database, Globe, FileClock, Loader2, CheckCircle2, XCircle, Copy, Check, Info, Link2 } from 'lucide-react';\nimport { useAccountStore } from '../../stores/useAccountStore';\nimport { useTranslation } from 'react-i18next';\nimport { listen } from '@tauri-apps/api/event';\nimport { open } from '@tauri-apps/plugin-dialog';\nimport { request as invoke } from '../../utils/request';\nimport { isTauri } from '../../utils/env';\nimport { copyToClipboard } from '../../utils/clipboard';\n\ninterface AddAccountDialogProps {\n    onAdd: (email: string, refreshToken: string) => Promise<void>;\n    showText?: boolean;\n}\n\ntype Status = 'idle' | 'loading' | 'success' | 'error';\n\nfunction AddAccountDialog({ onAdd, showText = true }: AddAccountDialogProps) {\n    const { t } = useTranslation();\n    const fetchAccounts = useAccountStore(state => state.fetchAccounts);\n    const [isOpen, setIsOpen] = useState(false);\n    const [activeTab, setActiveTab] = useState<'oauth' | 'token' | 'import'>(isTauri() ? 'oauth' : 'token');\n    const [refreshToken, setRefreshToken] = useState('');\n    const [oauthUrl, setOauthUrl] = useState('');\n    const [oauthUrlCopied, setOauthUrlCopied] = useState(false);\n    const [manualCode, setManualCode] = useState('');\n\n    // UI State\n    const [status, setStatus] = useState<Status>('idle');\n    const [message, setMessage] = useState('');\n\n    const { startOAuthLogin, completeOAuthLogin, cancelOAuthLogin, importFromDb, importV1Accounts, importFromCustomDb } = useAccountStore();\n\n    const oauthUrlRef = useRef(oauthUrl);\n    const statusRef = useRef(status);\n    const activeTabRef = useRef(activeTab);\n    const isOpenRef = useRef(isOpen);\n\n    useEffect(() => {\n        oauthUrlRef.current = oauthUrl;\n        statusRef.current = status;\n        activeTabRef.current = activeTab;\n        isOpenRef.current = isOpen;\n    }, [oauthUrl, status, activeTab, isOpen]);\n\n    // Reset state when dialog opens or tab changes\n    useEffect(() => {\n        if (isOpen) {\n            resetState();\n        }\n    }, [isOpen, activeTab]);\n\n    // Listen for OAuth URL\n    useEffect(() => {\n        if (!isTauri()) return;\n        let unlisten: (() => void) | undefined;\n\n        const setupListener = async () => {\n            unlisten = await listen('oauth-url-generated', (event) => {\n                setOauthUrl(event.payload as string);\n                // 自动复制到剪贴板? 可选，这里只设置状态让用户手动复制\n            });\n        };\n\n        setupListener();\n\n        return () => {\n            if (unlisten) unlisten();\n        };\n    }, []);\n\n    // Listen for OAuth callback completion (user may open the URL manually without clicking Start)\n    useEffect(() => {\n        if (!isTauri()) return;\n        let unlisten: (() => void) | undefined;\n\n        const setupListener = async () => {\n            unlisten = await listen('oauth-callback-received', async () => {\n                if (!isOpenRef.current) return;\n                if (activeTabRef.current !== 'oauth') return;\n                if (statusRef.current === 'loading' || statusRef.current === 'success') return;\n                if (!oauthUrlRef.current) return;\n\n                // Auto-complete: exchange code and save account (no browser open)\n                setStatus('loading');\n                setMessage(`${t('accounts.add.tabs.oauth')}...`);\n\n                try {\n                    await completeOAuthLogin();\n                    setStatus('success');\n                    setMessage(`${t('accounts.add.tabs.oauth')} ${t('common.success')}!`);\n                    setTimeout(() => {\n                        setIsOpen(false);\n                        resetState();\n                    }, 1500);\n                } catch (error) {\n                    setStatus('error');\n                    let errorMsg = String(error);\n                    if (errorMsg.includes('Refresh Token') || errorMsg.includes('refresh_token')) {\n                        setMessage(errorMsg);\n                    } else if (errorMsg.includes('Tauri') || errorMsg.toLowerCase().includes('environment') || errorMsg.includes('环境')) {\n                        setMessage(t('common.environment_error', { error: errorMsg }));\n                    } else {\n                        setMessage(`${t('accounts.add.tabs.oauth')} ${t('common.error')}: ${errorMsg}`);\n                    }\n                }\n            });\n        };\n\n        setupListener();\n\n        return () => {\n            if (unlisten) unlisten();\n        };\n    }, [completeOAuthLogin, t]);\n\n    // Pre-generate OAuth URL when dialog opens on OAuth tab (so URL is shown BEFORE \"Start OAuth\")\n    useEffect(() => {\n        if (!isOpen) return;\n        if (activeTab !== 'oauth') return;\n        if (oauthUrl) return;\n\n        invoke<any>('prepare_oauth_url')\n            .then((res) => {\n                const url = typeof res === 'string' ? res : res?.url;\n                if (url && url.length > 0) setOauthUrl(url);\n            })\n            .catch((e) => {\n                console.error('Failed to prepare OAuth URL:', e);\n            });\n    }, [isOpen, activeTab, oauthUrl]);\n\n    // If user navigates away from OAuth tab, cancel prepared flow to release the port.\n    useEffect(() => {\n        if (!isOpen) return;\n        if (activeTab === 'oauth') return;\n        if (!oauthUrl) return;\n\n        cancelOAuthLogin().catch(() => { });\n        setOauthUrl('');\n        setOauthUrlCopied(false);\n    }, [isOpen, activeTab]);\n\n    const resetState = () => {\n        setStatus('idle');\n        setMessage('');\n        setRefreshToken('');\n        setOauthUrl('');\n        setOauthUrlCopied(false);\n    };\n\n    const handleAction = async (\n        actionName: string,\n        actionFn: () => Promise<any>,\n        options?: { clearOauthUrl?: boolean }\n    ) => {\n        setStatus('loading');\n        setMessage(`${actionName}...`);\n        if (options?.clearOauthUrl !== false) {\n            setOauthUrl(''); // Clear previous URL\n        }\n        try {\n            await actionFn();\n            setStatus('success');\n            setMessage(`${actionName} ${t('common.success')}!`);\n\n            // 延迟关闭,让用户看到成功状态\n            setTimeout(() => {\n                setIsOpen(false);\n                resetState();\n            }, 1500);\n        } catch (error) {\n            setStatus('error');\n\n            // 改进错误信息显示\n            let errorMsg = String(error);\n\n            // 如果是 refresh_token 缺失错误,显示完整信息(包含解决方案)\n            if (errorMsg.includes('Refresh Token') || errorMsg.includes('refresh_token')) {\n                setMessage(errorMsg);\n            } else if (errorMsg.includes('Tauri') || errorMsg.toLowerCase().includes('environment') || errorMsg.includes('环境')) {\n                // 环境错误\n                setMessage(t('common.environment_error', { error: errorMsg }));\n            } else {\n                // 其他错误\n                setMessage(`${actionName} ${t('common.error')}: ${errorMsg}`);\n            }\n        }\n    };\n\n    const handleSubmit = async () => {\n        if (!refreshToken) {\n            setStatus('error');\n            setMessage(t('accounts.add.token.error_token'));\n            return;\n        }\n\n        setStatus('loading');\n\n        // 1. 尝试解析输入\n        let tokens: string[] = [];\n        const input = refreshToken.trim();\n\n        try {\n            // 尝试解析为 JSON\n            if (input.startsWith('[') && input.endsWith(']')) {\n                const parsed = JSON.parse(input);\n                if (Array.isArray(parsed)) {\n                    tokens = parsed\n                        .map((item: any) => item.refresh_token)\n                        .filter((t: any) => typeof t === 'string' && t.startsWith('1//'));\n                }\n            }\n        } catch (e) {\n            // JSON 解析失败,忽略\n            console.debug('JSON parse failed, falling back to regex', e);\n        }\n\n        // 2. 如果 JSON 解析没有结果,尝试正则提取 (或者输入不是 JSON)\n        if (tokens.length === 0) {\n            const regex = /1\\/\\/[a-zA-Z0-9_\\-]+/g;\n            const matches = input.match(regex);\n            if (matches) {\n                tokens = matches;\n            }\n        }\n\n        // 去重\n        tokens = [...new Set(tokens)];\n\n        if (tokens.length === 0) {\n            setStatus('error');\n            setMessage(t('accounts.add.token.error_token')); // 或者提示\"未找到有效 Token\"\n            return;\n        }\n\n        // 3. 批量添加\n        let successCount = 0;\n        let failCount = 0;\n\n        for (let i = 0; i < tokens.length; i++) {\n            const currentToken = tokens[i];\n            setMessage(t('accounts.add.token.batch_progress', { current: i + 1, total: tokens.length }));\n\n            try {\n                await onAdd(\"\", currentToken);\n                successCount++;\n            } catch (error) {\n                console.error(`Failed to add token ${i + 1}:`, error);\n                failCount++;\n            }\n            // 稍微延迟一下,避免太快\n            await new Promise(r => setTimeout(r, 100));\n        }\n\n        // 4. 结果反馈\n        if (successCount === tokens.length) {\n            setStatus('success');\n            setMessage(t('accounts.add.token.batch_success', { count: successCount }));\n            setTimeout(() => {\n                setIsOpen(false);\n                resetState();\n            }, 1500);\n        } else if (successCount > 0) {\n            // 部分成功\n            setStatus('success'); // 还是用绿色,但提示部分失败\n            setMessage(t('accounts.add.token.batch_partial', { success: successCount, fail: failCount }));\n            // 不自动关闭,让用户看到结果\n        } else {\n            // 全部失败\n            setStatus('error');\n            setMessage(t('accounts.add.token.batch_fail'));\n        }\n    };\n\n    const handleOAuthWeb = async () => {\n        try {\n            setStatus('loading');\n            setMessage(t('accounts.add.oauth.btn_start') + '...');\n\n            // 1. 获取 URL (指向 /auth/callback)\n            const res = await invoke<any>('prepare_oauth_url');\n            const url = typeof res === 'string' ? res : res.url;\n\n            if (!url) {\n                throw new Error(t('accounts.add.oauth.error_no_url', 'OAuth URLを取得できませんでした'));\n            }\n\n            setOauthUrl(url); // 确保链接在 UI 中可见，方便用户手动复制\n\n            // 2. 打开新标签页 (响应用户反馈：Web 端直接使用新标签体验更好)\n            const popup = window.open(url, '_blank');\n\n            if (!popup) {\n                setStatus('error');\n                setMessage(t('accounts.add.oauth.popup_blocked', 'ポップアップがブロックされました'));\n                return;\n            }\n\n            // 3. 监听消息\n            const handleMessage = async (event: MessageEvent) => {\n                // 安全检查: 如果定义了 ORIGIN 校验更好，这里暂时检查 data type\n                if (event.data?.type === 'oauth-success') {\n                    popup.close();\n                    window.removeEventListener('message', handleMessage);\n\n                    // 4. 成功后刷新列表\n                    await fetchAccounts();\n\n                    setStatus('success');\n                    setMessage(t('accounts.add.oauth_success') || t('common.success'));\n\n                    setTimeout(() => {\n                        setIsOpen(false);\n                        resetState();\n                    }, 1500);\n                }\n            };\n\n            window.addEventListener('message', handleMessage);\n\n            // 5. 检测窗口关闭 (用户手动关闭)\n            const timer = setInterval(() => {\n                if (popup.closed) {\n                    clearInterval(timer);\n                    window.removeEventListener('message', handleMessage);\n                    if (statusRef.current === 'loading') { // 如果还在 loading 状态就关闭了，说明取消了\n                        setStatus('idle');\n                        setMessage('');\n                    }\n                }\n            }, 1000);\n\n        } catch (error) {\n            console.error('OAuth Web Error:', error);\n            setStatus('error');\n            setMessage(`${t('common.error')}: ${error}`);\n        }\n    };\n\n    const handleOAuth = () => {\n        if (!isTauri()) {\n            handleOAuthWeb();\n            return;\n        }\n        // Default flow: opens the default browser and completes automatically.\n        // (If user opened the URL manually, completion is also triggered by oauth-callback-received.)\n        handleAction(t('accounts.add.tabs.oauth'), startOAuthLogin, { clearOauthUrl: false });\n    };\n\n    const handleCompleteOAuth = () => {\n        // Manual flow: user already authorized in their preferred browser, just finish the flow.\n        handleAction(t('accounts.add.tabs.oauth'), completeOAuthLogin, { clearOauthUrl: false });\n    };\n\n    const handleCopyUrl = async () => {\n        if (oauthUrl) {\n            const success = await copyToClipboard(oauthUrl);\n            if (success) {\n                setOauthUrlCopied(true);\n                window.setTimeout(() => setOauthUrlCopied(false), 1500);\n            }\n        }\n    };\n\n    const handleManualSubmit = async () => {\n        if (!manualCode.trim()) return;\n\n        setStatus('loading');\n        setMessage(t('accounts.add.oauth.manual_submitting', '認可コードを送信中...'));\n\n        try {\n            await invoke('submit_oauth_code', { code: manualCode.trim(), state: null });\n\n            // 提交成功反馈\n            setStatus('success');\n            setMessage(t('accounts.add.oauth.manual_submitted', '認可コードを送信しました。バックエンドで処理中です...'));\n\n            setManualCode('');\n\n            // 对齐 Web 模式下的刷新逻辑\n            if (!isTauri()) {\n                setTimeout(async () => {\n                    await fetchAccounts();\n                    setIsOpen(false);\n                    resetState();\n                }, 2000);\n            }\n        } catch (error) {\n            let errStr = String(error);\n            if (errStr.includes(\"No active OAuth flow\")) {\n                setMessage(t('accounts.add.oauth.error_no_flow'));\n                setStatus('error');\n            } else {\n                setMessage(`${t('common.error')}: ${errStr}`);\n                setStatus('error');\n            }\n        }\n    };\n\n    const handleImportDb = () => {\n        handleAction(t('accounts.add.tabs.import'), importFromDb);\n    };\n\n    const handleImportV1 = () => {\n        handleAction(t('accounts.add.import.btn_v1'), importV1Accounts);\n    };\n\n    const handleImportCustomDb = async () => {\n        try {\n            if (!isTauri()) {\n                alert(t('common.tauri_api_not_loaded') || 'Storage import only works in desktop app.');\n                return;\n            }\n            const selected = await open({\n                multiple: false,\n                filters: [{\n                    name: 'VSCode DB',\n                    extensions: ['vscdb']\n                }, {\n                    name: 'All Files',\n                    extensions: ['*']\n                }]\n            });\n\n            if (selected && typeof selected === 'string') {\n                handleAction(t('accounts.add.import.btn_custom_db') || 'Import Custom DB', () => importFromCustomDb(selected));\n            }\n        } catch (err) {\n            console.error('Failed to open dialog:', err);\n        }\n    };\n\n    // 状态提示组件\n    const StatusAlert = () => {\n        if (status === 'idle' || !message) return null;\n\n        const styles = {\n            loading: 'alert-info',\n            success: 'alert-success',\n            error: 'alert-error'\n        };\n\n        const icons = {\n            loading: <Loader2 className=\"w-5 h-5 animate-spin\" />,\n            success: <CheckCircle2 className=\"w-5 h-5\" />,\n            error: <XCircle className=\"w-5 h-5\" />\n        };\n\n        return (\n            <div className={`alert ${styles[status]} mb-4 text-sm py-2 shadow-sm`}>\n                {icons[status]}\n                <span>{message}</span>\n            </div>\n        );\n    };\n\n    return (\n        <>\n            <button\n                className=\"px-2.5 lg:px-4 py-2 bg-white dark:bg-base-100 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-base-200 transition-colors flex items-center gap-2 shadow-sm border border-gray-200/50 dark:border-base-300 relative z-[100]\"\n                onClick={() => {\n                    console.log('AddAccountDialog button clicked');\n                    setIsOpen(true);\n                }}\n                title={!showText ? t('accounts.add_account') : undefined}\n            >\n                <Plus className=\"w-4 h-4\" />\n                {showText && <span className=\"hidden lg:inline\">{t('accounts.add_account')}</span>}\n            </button>\n\n            {isOpen && createPortal(\n                <div\n                    className=\"fixed inset-0 z-[99999] flex items-center justify-center bg-black/50 backdrop-blur-sm\"\n                    style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0 }}\n                >\n                    {/* Draggable Top Region */}\n                    <div data-tauri-drag-region className=\"fixed top-0 left-0 right-0 h-8 z-[1]\" />\n\n                    {/* Click outside to close */}\n                    <div className=\"absolute inset-0 z-[0]\" onClick={() => setIsOpen(false)} />\n\n                    <div className=\"bg-white dark:bg-base-100 text-gray-900 dark:text-base-content rounded-2xl shadow-2xl w-full max-w-lg p-6 relative z-[10] m-4 max-h-[90vh] overflow-y-auto\">\n                        <h3 className=\"font-bold text-lg mb-4\">{t('accounts.add.title')}</h3>\n\n                        {/* Tab 导航 - 胶囊风格 */}\n\n                        <div className=\"bg-gray-100 dark:bg-base-200 p-1 rounded-xl mb-6 grid grid-cols-3 gap-1\">\n                            <button\n                                className={`py-2 px-3 rounded-lg text-sm font-medium transition-all duration-200 ${activeTab === 'oauth'\n                                    ? 'bg-white dark:bg-base-100 shadow-sm text-blue-600 dark:text-blue-400'\n                                    : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-200/50 dark:hover:bg-base-300'\n                                    } `}\n                                onClick={() => setActiveTab('oauth')}\n                            >\n                                {t('accounts.add.tabs.oauth')}\n                            </button>\n                            <button\n                                className={`py-2 px-3 rounded-lg text-sm font-medium transition-all duration-200 ${activeTab === 'token'\n                                    ? 'bg-white dark:bg-base-100 shadow-sm text-blue-600 dark:text-blue-400'\n                                    : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-200/50 dark:hover:bg-base-300'\n                                    } `}\n                                onClick={() => setActiveTab('token')}\n                            >\n                                {t('accounts.add.tabs.token')}\n                            </button>\n                            <button\n                                className={`py-2 px-3 rounded-lg text-sm font-medium transition-all duration-200 ${activeTab === 'import'\n                                    ? 'bg-white dark:bg-base-100 shadow-sm text-blue-600 dark:text-blue-400'\n                                    : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-200/50 dark:hover:bg-base-300'\n                                    } `}\n                                onClick={() => setActiveTab('import')}\n                            >\n                                {t('accounts.add.tabs.import')}\n                            </button>\n                        </div>\n\n                        {/* 添加 Web 模式提示 */}\n                        {!isTauri() && (\n                            <div className=\"alert alert-info mb-4 text-xs py-2 flex items-center gap-2 bg-blue-50 dark:bg-blue-900/10 text-blue-600 dark:text-blue-400 border-blue-100 dark:border-blue-800\">\n                                <Info className=\"w-4 h-4\" />\n                                <span>{t('accounts.add.oauth.web_hint', '将在新窗口中打开 Google 登录页')}</span>\n                            </div>\n                        )}\n\n                        {/* 状态提示区 */}\n                        <StatusAlert />\n\n                        <div className=\"min-h-[200px]\">\n                            {/* OAuth 授权 */}\n                            {activeTab === 'oauth' && (\n                                <div className=\"space-y-6 py-4\">\n                                    <div className=\"text-center space-y-3\">\n                                        <div className=\"bg-blue-50 dark:bg-blue-900/20 p-6 rounded-full w-20 h-20 mx-auto flex items-center justify-center\">\n                                            <Globe className=\"w-10 h-10 text-blue-500\" />\n                                        </div>\n                                        <div className=\"space-y-1\">\n                                            <h4 className=\"font-medium text-gray-900 dark:text-gray-100\">{t('accounts.add.oauth.recommend')}</h4>\n                                            <p className=\"text-sm text-gray-500 dark:text-gray-400 max-w-xs mx-auto\">\n                                                {t('accounts.add.oauth.desc')}\n                                            </p>\n                                        </div>\n                                    </div>\n                                    <div className=\"space-y-3\">\n                                        <button\n                                            className=\"w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-xl shadow-lg shadow-blue-500/20 transition-all flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed\"\n                                            onClick={handleOAuth}\n                                            disabled={status === 'loading' || status === 'success'}\n                                        >\n                                            {status === 'loading' ? t('accounts.add.oauth.btn_waiting') : t('accounts.add.oauth.btn_start')}\n                                        </button>\n\n                                        {oauthUrl && (\n                                            <div className=\"space-y-2\">\n                                                <div className=\"text-[11px] text-gray-500 dark:text-gray-400 text-left\">\n                                                    {t('accounts.add.oauth.link_label')}\n                                                </div>\n                                                <button\n                                                    type=\"button\"\n                                                    className=\"w-full px-4 py-2 bg-white dark:bg-base-100 text-gray-600 dark:text-gray-300 text-sm font-medium rounded-xl border border-dashed border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-base-200 transition-all flex items-center gap-2\"\n                                                    onClick={handleCopyUrl}\n                                                    title={t('accounts.add.oauth.link_click_to_copy')}\n                                                >\n                                                    {oauthUrlCopied ? (\n                                                        <Check className=\"w-3.5 h-3.5 text-emerald-600\" />\n                                                    ) : (\n                                                        <Copy className=\"w-3.5 h-3.5\" />\n                                                    )}\n                                                    <code className=\"text-[11px] font-mono truncate flex-1 text-left\">\n                                                        {oauthUrl}\n                                                    </code>\n                                                    <span className=\"text-[11px] whitespace-nowrap\">\n                                                        {oauthUrlCopied ? t('accounts.add.oauth.copied') : t('accounts.add.oauth.copy_link')}\n                                                    </span>\n                                                </button>\n\n                                                <button\n                                                    type=\"button\"\n                                                    className=\"w-full px-4 py-2 bg-white dark:bg-base-100 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-xl border border-gray-200 dark:border-base-300 hover:bg-gray-50 dark:hover:bg-base-200 transition-all flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed\"\n                                                    onClick={handleCompleteOAuth}\n                                                    disabled={status === 'loading' || status === 'success'}\n                                                >\n                                                    <CheckCircle2 className=\"w-4 h-4\" />\n                                                    {t('accounts.add.oauth.btn_finish')}\n                                                </button>\n                                            </div>\n                                        )}\n\n                                        {/* Manual Code Entry - Always enabled to rescue stuck flows */}\n                                        <div className=\"pt-4 mt-2 border-t border-gray-100 dark:border-base-200\">\n                                            <div className=\"text-[11px] font-medium text-gray-400 dark:text-gray-500 mb-2 uppercase tracking-wider\">\n                                                {t('accounts.add.oauth.manual_hint')}\n                                            </div>\n                                            <div className=\"relative group/manual flex gap-2\">\n                                                <div className=\"relative flex-1\">\n                                                    <input\n                                                        type=\"text\"\n                                                        className=\"w-full text-xs py-2 px-3 bg-white dark:bg-base-100 border border-gray-200 dark:border-base-300 rounded-xl focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all placeholder:text-gray-300 dark:placeholder:text-gray-600\"\n                                                        placeholder={t('accounts.add.oauth.manual_placeholder')}\n                                                        value={manualCode}\n                                                        onChange={(e) => setManualCode(e.target.value)}\n                                                    />\n                                                </div>\n                                                <button\n                                                    className=\"px-4 py-2 bg-neutral text-white dark:bg-white dark:text-neutral text-xs font-semibold rounded-xl hover:opacity-90 active:scale-95 transition-all disabled:opacity-50 disabled:scale-100 flex items-center gap-1.5\"\n                                                    onClick={handleManualSubmit}\n                                                    disabled={!manualCode.trim()}\n                                                >\n                                                    <Link2 className=\"w-3.5 h-3.5\" />\n                                                    {t('common.submit')}\n                                                </button>\n                                            </div>\n                                        </div>\n                                    </div>\n                                </div>\n                            )}\n\n                            {/* Refresh Token */}\n                            {activeTab === 'token' && (\n                                <div className=\"space-y-4 py-2\">\n                                    <div className=\"bg-gray-50 dark:bg-base-200 p-4 rounded-lg border border-gray-200 dark:border-base-300\">\n                                        <div className=\"flex justify-between items-center mb-2\">\n                                            <span className=\"text-sm font-medium text-gray-500 dark:text-gray-400\">{t('accounts.add.token.label')}</span>\n                                        </div>\n                                        <textarea\n                                            className=\"textarea textarea-bordered w-full h-32 font-mono text-xs leading-relaxed focus:outline-none focus:border-blue-500 transition-colors bg-white dark:bg-base-100 text-gray-900 dark:text-base-content border-gray-300 dark:border-base-300 placeholder:text-gray-400\"\n                                            placeholder={t('accounts.add.token.placeholder')}\n                                            value={refreshToken}\n                                            onChange={(e) => setRefreshToken(e.target.value)}\n                                            disabled={status === 'loading' || status === 'success'}\n                                        />\n                                        <p className=\"text-[10px] text-gray-400 mt-2\">\n                                            {t('accounts.add.token.hint')}\n                                        </p>\n                                    </div>\n                                </div>\n                            )}\n\n                            {/* 从数据库导入 */}\n                            {activeTab === 'import' && (\n                                <div className=\"space-y-6 py-2\">\n                                    <div className=\"space-y-2\">\n                                        <h4 className=\"font-semibold flex items-center gap-2 text-gray-800 dark:text-gray-200\">\n                                            <Database className=\"w-4 h-4 text-gray-600 dark:text-gray-400\" />\n                                            {t('accounts.add.import.scheme_a')}\n                                        </h4>\n                                        <p className=\"text-xs text-gray-500 dark:text-gray-400\">\n                                            {t('accounts.add.import.scheme_a_desc')}\n                                        </p>\n                                        <button\n                                            className=\"w-full px-4 py-3 bg-gray-50 dark:bg-base-200 text-gray-700 dark:text-gray-300 font-medium rounded-xl border border-gray-200 dark:border-base-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:border-blue-200 dark:hover:border-blue-800 hover:text-blue-600 dark:hover:text-blue-400 transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mb-2 shadow-sm\"\n                                            onClick={handleImportDb}\n                                            disabled={status === 'loading' || status === 'success'}\n                                        >\n                                            <CheckCircle2 className=\"w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity\" />\n                                            {t('accounts.add.import.btn_db')}\n                                        </button>\n                                        <button\n                                            className=\"w-full px-4 py-3 bg-gray-50 dark:bg-base-200 text-gray-700 dark:text-gray-300 font-medium rounded-xl border border-gray-200 dark:border-base-300 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 hover:border-indigo-200 dark:hover:border-indigo-800 hover:text-indigo-600 dark:hover:text-indigo-400 transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm\"\n                                            onClick={handleImportCustomDb}\n                                            disabled={status === 'loading' || status === 'success'}\n                                        >\n                                            <Database className=\"w-4 h-4\" />\n                                            {t('accounts.add.import.btn_custom_db') || 'Custom DB (state.vscdb)'}\n                                        </button>\n                                    </div>\n\n                                    <div className=\"divider text-xs text-gray-300 dark:text-gray-600\">{t('accounts.add.import.or')}</div>\n\n                                    <div className=\"space-y-2\">\n                                        <h4 className=\"font-semibold flex items-center gap-2 text-gray-800 dark:text-gray-200\">\n                                            <FileClock className=\"w-4 h-4 text-gray-600 dark:text-gray-400\" />\n                                            {t('accounts.add.import.scheme_b')}\n                                        </h4>\n                                        <p className=\"text-xs text-gray-500 dark:text-gray-400\">\n                                            {t('accounts.add.import.scheme_b_desc')}\n                                        </p>\n                                        <button\n                                            className=\"w-full px-4 py-3 bg-gray-50 dark:bg-base-200 text-gray-700 dark:text-gray-300 font-medium rounded-xl border border-gray-200 dark:border-base-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 hover:border-emerald-200 dark:hover:border-emerald-800 hover:text-emerald-600 dark:hover:text-emerald-400 transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm\"\n                                            onClick={handleImportV1}\n                                            disabled={status === 'loading' || status === 'success'}\n                                        >\n                                            <FileClock className=\"w-4 h-4\" />\n                                            {t('accounts.add.import.btn_v1')}\n                                        </button>\n                                    </div>\n                                </div>\n                            )}\n                        </div>\n\n                        <div className=\"flex gap-3 w-full mt-6\">\n                            <button\n                                className=\"flex-1 px-4 py-2.5 bg-gray-100 dark:bg-base-200 text-gray-700 dark:text-gray-300 font-medium rounded-xl hover:bg-gray-200 dark:hover:bg-base-300 transition-colors focus:outline-none focus:ring-2 focus:ring-200 dark:focus:ring-base-300\"\n                                onClick={async () => {\n                                    if (status === 'loading' && activeTab === 'oauth') {\n                                        await cancelOAuthLogin();\n                                    }\n                                    setIsOpen(false);\n                                }}\n                                disabled={status === 'success'} // Only disable on success, allow cancel on loading\n                            >\n                                {t('accounts.add.btn_cancel')}\n                            </button>\n                            {activeTab === 'token' && (\n                                <button\n                                    className=\"flex-1 px-4 py-2.5 text-white font-medium rounded-xl shadow-md transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 bg-blue-500 hover:bg-blue-600 focus:ring-blue-500 shadow-blue-100 dark:shadow-blue-900/30 flex justify-center items-center gap-2\"\n                                    onClick={handleSubmit}\n                                    disabled={status === 'loading' || status === 'success'}\n                                >\n                                    {status === 'loading' ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : null}\n                                    {t('accounts.add.btn_confirm')}\n                                </button>\n                            )}\n                        </div>\n                    </div>\n                </div >,\n                document.body\n            )\n            }\n        </>\n    );\n}\n\nexport default AddAccountDialog;\n"
  },
  {
    "path": "src/components/accounts/DeviceFingerprintDialog.tsx",
    "content": "import { createPortal } from 'react-dom';\nimport { useEffect, useState } from 'react';\nimport { Wand2, RotateCcw, FolderOpen, Trash2, X } from 'lucide-react';\nimport { Account, DeviceProfile, DeviceProfileVersion } from '../../types/account';\nimport * as accountService from '../../services/accountService';\nimport { useTranslation } from 'react-i18next';\nimport { isTauri } from '../../utils/env';\n\ninterface DeviceFingerprintDialogProps {\n    account: Account | null;\n    onClose: () => void;\n}\n\nexport default function DeviceFingerprintDialog({ account, onClose }: DeviceFingerprintDialogProps) {\n    const { t } = useTranslation();\n    const [deviceProfiles, setDeviceProfiles] = useState<{ current_storage?: DeviceProfile; history?: DeviceProfileVersion[]; baseline?: DeviceProfile } | null>(null);\n    const [loadingDevice, setLoadingDevice] = useState(false);\n    const [actionLoading, setActionLoading] = useState<string | null>(null);\n    const [actionMessage, setActionMessage] = useState<string | null>(null);\n    const [confirmProfile, setConfirmProfile] = useState<DeviceProfile | null>(null);\n    const [confirmType, setConfirmType] = useState<'generate' | 'restoreOriginal' | null>(null);\n\n    const fetchDevice = async (target?: Account | null) => {\n        if (!target) {\n            setDeviceProfiles(null);\n            return;\n        }\n        setLoadingDevice(true);\n        try {\n            const res = await accountService.getDeviceProfiles(target.id);\n            setDeviceProfiles(res);\n        } catch (e: any) {\n            const errorMsg = typeof e === 'string' ? e : e.message || '';\n            const translated = errorMsg === 'storage_json_not_found'\n                ? t('accounts.device_fingerprint_dialog.storage_json_not_found')\n                : (typeof e === 'string' ? e : t('accounts.device_fingerprint_dialog.failed_to_load_device_info'));\n            setActionMessage(translated);\n        } finally {\n            setLoadingDevice(false);\n        }\n    };\n\n    useEffect(() => {\n        fetchDevice(account);\n    }, [account]);\n\n    const handleGeneratePreview = async () => {\n        setActionLoading('preview');\n        try {\n            const profile = await accountService.previewGenerateProfile();\n            setConfirmProfile(profile);\n            setConfirmType('generate');\n        } catch (e: any) {\n            setActionMessage(typeof e === 'string' ? e : t('accounts.device_fingerprint_dialog.generation_failed'));\n        } finally {\n            setActionLoading(null);\n        }\n    };\n\n    const handleConfirmGenerate = async () => {\n        if (!account || !confirmProfile) return;\n        setActionLoading('generate');\n        try {\n            await accountService.bindDeviceProfileWithProfile(account.id, confirmProfile);\n            setActionMessage(t('accounts.device_fingerprint_dialog.generated_and_bound'));\n            setConfirmProfile(null);\n            setConfirmType(null);\n            await fetchDevice(account); // Refresh history\n        } catch (e: any) {\n            setActionMessage(typeof e === 'string' ? e : t('accounts.device_fingerprint_dialog.binding_failed'));\n        } finally {\n            setActionLoading(null);\n        }\n    };\n\n    const handleRestoreOriginalConfirm = () => {\n        if (!deviceProfiles?.baseline) {\n            setActionMessage(t('accounts.device_fingerprint_dialog.original_fingerprint_not_found'));\n            return;\n        }\n        setConfirmProfile(deviceProfiles.baseline);\n        setConfirmType('restoreOriginal');\n    };\n\n    const handleRestoreOriginal = async () => {\n        if (!account) return;\n        setActionLoading('restore');\n        try {\n            const msg = await accountService.restoreOriginalDevice();\n            setActionMessage(msg || t('accounts.device_fingerprint_dialog.restored'));\n            setConfirmProfile(null);\n            setConfirmType(null);\n            await fetchDevice(account);\n        } catch (e: any) {\n            setActionMessage(typeof e === 'string' ? e : t('accounts.device_fingerprint_dialog.restoration_failed'));\n        } finally {\n            setActionLoading(null);\n        }\n    };\n\n    const handleRestoreVersion = async (versionId: string) => {\n        if (!account) return;\n        setActionLoading(`restore-${versionId}`);\n        try {\n            await accountService.restoreDeviceVersion(account.id, versionId);\n            setActionMessage(t('accounts.device_fingerprint_dialog.restored'));\n            await fetchDevice(account);\n        } catch (e: any) {\n            setActionMessage(typeof e === 'string' ? e : t('accounts.device_fingerprint_dialog.restoration_failed'));\n        } finally {\n            setActionLoading(null);\n        }\n    };\n\n    const handleDeleteVersion = async (versionId: string, isCurrent?: boolean) => {\n        if (!account || isCurrent) return;\n        setActionLoading(`delete-${versionId}`);\n        try {\n            await accountService.deleteDeviceVersion(account.id, versionId);\n            setActionMessage(t('accounts.device_fingerprint_dialog.deleted'));\n            await fetchDevice(account);\n        } catch (e: any) {\n            setActionMessage(typeof e === 'string' ? e : t('accounts.device_fingerprint_dialog.deletion_failed'));\n        } finally {\n            setActionLoading(null);\n        }\n    };\n\n    const handleOpenFolder = async () => {\n        setActionLoading('open-folder');\n        try {\n            await accountService.openDeviceFolder();\n            setActionMessage(t('accounts.device_fingerprint_dialog.directory_opened'));\n        } catch (e: any) {\n            setActionMessage(typeof e === 'string' ? e : t('accounts.device_fingerprint_dialog.directory_open_failed'));\n        } finally {\n            setActionLoading(null);\n        }\n    };\n\n    const renderProfile = (profile?: DeviceProfile) => {\n        if (!profile) return <span className=\"text-xs text-gray-400\">{t('common.empty') || '空'}</span>;\n        return (\n            <div className=\"grid grid-cols-1 gap-2 text-xs font-mono text-gray-600 dark:text-gray-300\">\n                <div><span className=\"font-semibold\">machineId:</span> {profile.machine_id}</div>\n                <div><span className=\"font-semibold\">macMachineId:</span> {profile.mac_machine_id}</div>\n                <div><span className=\"font-semibold\">devDeviceId:</span> {profile.dev_device_id}</div>\n                <div><span className=\"font-semibold\">sqmId:</span> {profile.sqm_id}</div>\n            </div>\n        );\n    };\n\n    if (!account) return null;\n\n    return createPortal(\n        <div className=\"modal modal-open z-[120]\">\n            <div data-tauri-drag-region className=\"fixed top-0 left-0 right-0 h-8 z-[130]\" />\n            <div className=\"modal-box relative max-w-3xl bg-white dark:bg-base-100 shadow-2xl rounded-2xl p-0 overflow-hidden\">\n                <div className=\"px-6 py-5 border-b border-gray-100 dark:border-base-200 bg-gray-50/50 dark:bg-base-200/50 flex justify-between items-center\">\n                    <div className=\"flex items-center gap-3\">\n                        <h3 className=\"font-bold text-lg text-gray-900 dark:text-base-content\">{t('accounts.device_fingerprint_dialog.title')}</h3>\n                        <div className=\"px-2.5 py-0.5 rounded-full bg-gray-100 dark:bg-base-200 border border-gray-200 dark:border-base-300 text-xs font-mono text-gray-500 dark:text-gray-400\">\n                            {account.email}\n                        </div>\n                    </div>\n                    <button\n                        onClick={onClose}\n                        className=\"btn btn-sm btn-circle btn-ghost text-gray-400 hover:bg-gray-100 dark:hover:bg-base-200 hover:text-gray-600 dark:hover:text-base-content transition-colors\"\n                    >\n                        <X size={18} />\n                    </button>\n                </div>\n\n                <div className=\"p-6 space-y-3 max-h-[70vh] overflow-y-auto\">\n                    <div className=\"flex items-center justify-between mb-2\">\n                        <div className=\"text-sm font-semibold text-gray-800 dark:text-gray-200\">{t('accounts.device_fingerprint_dialog.operations')}</div>\n                        <div className=\"flex gap-2 flex-wrap\">\n                            <button className=\"btn btn-xs btn-outline\" disabled={loadingDevice || actionLoading === 'preview'} onClick={handleGeneratePreview}>\n                                <Wand2 size={14} className=\"mr-1\" />{t('accounts.device_fingerprint_dialog.generate_and_bind')}\n                            </button>\n                            <button className=\"btn btn-xs btn-outline btn-error\" disabled={loadingDevice || actionLoading === 'restore'} onClick={handleRestoreOriginalConfirm}>\n                                <RotateCcw size={14} className=\"mr-1\" />{t('accounts.device_fingerprint_dialog.restore_original')}\n                            </button>\n                            {isTauri() && (\n                                <button className=\"btn btn-xs btn-outline\" disabled={actionLoading === 'open-folder'} onClick={handleOpenFolder}>\n                                    <FolderOpen size={14} className=\"mr-1\" />{t('accounts.device_fingerprint_dialog.open_storage_directory')}\n                                </button>\n                            )}\n                        </div>\n                    </div>\n                    {actionMessage && <div className=\"text-xs text-blue-600 dark:text-blue-300\">{actionMessage}</div>}\n                    <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n                        <div className=\"p-4 rounded-xl border border-gray-100 dark:border-base-200 bg-white dark:bg-base-100 shadow-sm\">\n                            <div className=\"flex items-center justify-between mb-1\">\n                                <div className=\"text-xs font-semibold text-gray-600 dark:text-gray-300\">{t('accounts.device_fingerprint_dialog.current_storage')}</div>\n                                <span className=\"text-[10px] px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-300 border border-blue-100 dark:border-blue-400/40\">{t('accounts.device_fingerprint_dialog.effective')}</span>\n                            </div>\n                            <p className=\"text-[10px] text-gray-400 dark:text-gray-500 mb-2\">{t('accounts.device_fingerprint_dialog.current_storage_desc')}</p>\n                            {loadingDevice ? <div className=\"text-xs text-gray-400\">{t('accounts.device_fingerprint_dialog.loading')}</div> : renderProfile(deviceProfiles?.current_storage)}\n                        </div>\n                        <div className=\"p-4 rounded-xl border border-gray-100 dark:border-base-200 bg-white dark:bg-base-100 shadow-sm\">\n                            <div className=\"flex items-center justify-between mb-1\">\n                                <div className=\"text-xs font-semibold text-gray-600 dark:text-gray-300\">{t('accounts.device_fingerprint_dialog.account_binding')}</div>\n                                <span className=\"text-[10px] px-2 py-0.5 rounded-full bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-300 border border-amber-100 dark:border-amber-400/40\">{t('accounts.device_fingerprint_dialog.pending_application')}</span>\n                            </div>\n                            <p className=\"text-[10px] text-gray-400 dark:text-gray-500 mb-2\">{t('accounts.device_fingerprint_dialog.account_binding_desc')}</p>\n                            {/* Bound fingerprint = the one with is_current in current history */}\n                            {loadingDevice ? (\n                                <div className=\"text-xs text-gray-400\">{t('accounts.device_fingerprint_dialog.loading')}</div>\n                            ) : (\n                                renderProfile(deviceProfiles?.history?.find(h => h.is_current)?.profile)\n                            )}\n                        </div>\n                    </div>\n                    <div className=\"p-3 rounded-xl border border-gray-100 dark:border-base-200 bg-white dark:bg-base-100\">\n                        <div className=\"text-xs font-semibold text-gray-700 dark:text-gray-200 mb-2\">{t('accounts.device_fingerprint_dialog.historical_fingerprints')}</div>\n                        {loadingDevice ? (\n                            <div className=\"text-xs text-gray-400\">{t('accounts.device_fingerprint_dialog.loading')}</div>\n                        ) : (\n                            <div className=\"space-y-2\">\n                                {deviceProfiles?.history && deviceProfiles.history.map(v => (\n                                    <HistoryRow\n                                        id={v.id}\n                                        key={v.id}\n                                        label={v.label || v.id}\n                                        createdAt={v.created_at}\n                                        profile={v.profile}\n                                        isCurrent={v.is_current}\n                                        onRestore={() => handleRestoreVersion(v.id)}\n                                        onDelete={() => handleDeleteVersion(v.id, v.is_current)}\n                                        loadingKey={actionLoading}\n                                    />\n                                ))}\n                                {(!deviceProfiles?.history || deviceProfiles.history.length === 0) && !deviceProfiles?.baseline && (\n                                    <div className=\"text-xs text-gray-400\">{t('accounts.device_fingerprint_dialog.no_history')}</div>\n                                )}\n                            </div>\n                        )}\n                    </div>\n                </div>\n            </div>\n            <div className=\"modal-backdrop bg-black/40 backdrop-blur-sm\" onClick={onClose}></div>\n            {confirmProfile && confirmType && (\n                <ConfirmDialog\n                    profile={confirmProfile}\n                    type={confirmType}\n                    onCancel={() => {\n                        if (actionLoading) return;\n                        setConfirmProfile(null);\n                        setConfirmType(null);\n                    }}\n                    onConfirm={confirmType === 'generate' ? handleConfirmGenerate : handleRestoreOriginal}\n                    loading={!!actionLoading}\n                />\n            )}\n        </div>,\n        document.body\n    );\n}\n\ninterface HistoryRowProps {\n    id?: string;\n    label: string;\n    createdAt: number;\n    profile: DeviceProfile;\n    onRestore: () => void;\n    onDelete?: () => void;\n    isCurrent?: boolean;\n    loadingKey?: string | null;\n}\n\nfunction HistoryRow({ id, label, createdAt, profile, onRestore, onDelete, isCurrent, loadingKey }: HistoryRowProps) {\n    const { t } = useTranslation();\n    const key = id || label;\n    return (\n        <div className=\"flex items-start justify-between p-2 rounded-lg border border-gray-100 dark:border-base-200 hover:border-indigo-200 dark:hover:border-indigo-500/40 transition-colors\">\n            <div className=\"text-[11px] text-gray-600 dark:text-gray-300 flex-1\">\n                <div className=\"font-semibold\">{label}{isCurrent && <span className=\"ml-2 text-[10px] text-blue-500\">{t('accounts.device_fingerprint_dialog.current')}</span>}</div>\n                {createdAt > 0 && <div className=\"text-[10px] text-gray-400\">{new Date(createdAt * 1000).toLocaleString()}</div>}\n                <div className=\"mt-1 text-[10px] font-mono text-gray-500\">\n                    <div>machineId: {profile.machine_id}</div>\n                    <div>macMachineId: {profile.mac_machine_id}</div>\n                    <div>devDeviceId: {profile.dev_device_id}</div>\n                    <div>sqmId: {profile.sqm_id}</div>\n                </div>\n            </div>\n            <div className=\"flex gap-2 ml-2\">\n                <button className=\"btn btn-xs btn-outline\" disabled={loadingKey === `restore-${key}` || isCurrent} onClick={onRestore} title={t('accounts.device_fingerprint_dialog.restore')}>{t('accounts.device_fingerprint_dialog.restore')}</button>\n                {!isCurrent && onDelete && (\n                    <button className=\"btn btn-xs btn-outline btn-error\" disabled={loadingKey === `delete-${key}`} onClick={onDelete} title={t('accounts.device_fingerprint_dialog.delete_version')}>\n                        <Trash2 size={14} />\n                    </button>\n                )}\n            </div>\n        </div>\n    );\n}\n\nfunction ConfirmDialog({ profile, type, onConfirm, onCancel, loading }: { profile: DeviceProfile; type: 'generate' | 'restoreOriginal'; onConfirm: () => void; onCancel: () => void; loading?: boolean }) {\n    const { t } = useTranslation();\n    const title = type === 'generate' ? t('accounts.device_fingerprint_dialog.confirm_generate_title') : t('accounts.device_fingerprint_dialog.confirm_restore_title');\n    const desc =\n        type === 'generate'\n            ? t('accounts.device_fingerprint_dialog.confirm_generate_desc')\n            : t('accounts.device_fingerprint_dialog.confirm_restore_desc');\n    return createPortal(\n        <div className=\"modal modal-open z-[140]\">\n            <div className=\"modal-box max-w-sm bg-white dark:bg-base-100 rounded-2xl shadow-2xl p-6 text-center\">\n                <div className=\"mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-500 dark:bg-blue-500/10 dark:text-blue-300\">\n                    <svg xmlns=\"http://www.w3.org/2000/svg\" className=\"h-6 w-6\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\">\n                        <path d=\"M12 9v4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                        <path d=\"M12 17h.01\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                        <path d=\"M10 2h4l8 8v4l-8 8h-4l-8-8v-4z\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                    </svg>\n                </div>\n                <h3 className=\"font-bold text-lg text-gray-900 dark:text-base-content mb-1\">{title}</h3>\n                <p className=\"text-sm text-gray-500 dark:text-gray-400 mb-4\">{desc}</p>\n                <div className=\"text-xs font-mono text-gray-600 dark:text-gray-300 bg-gray-50 dark:bg-base-200/60 border border-gray-100 dark:border-base-200 rounded-lg p-3 text-left space-y-1\">\n                    <div><span className=\"font-semibold\">machineId:</span> {profile.machine_id}</div>\n                    <div><span className=\"font-semibold\">macMachineId:</span> {profile.mac_machine_id}</div>\n                    <div><span className=\"font-semibold\">devDeviceId:</span> {profile.dev_device_id}</div>\n                    <div><span className=\"font-semibold\">sqmId:</span> {profile.sqm_id}</div>\n                </div>\n                <div className=\"mt-5 flex gap-3 justify-center\">\n                    <button className=\"btn btn-sm min-w-[100px]\" onClick={onCancel} disabled={!!loading}>{t('accounts.device_fingerprint_dialog.cancel')}</button>\n                    <button className=\"btn btn-sm btn-primary min-w-[100px]\" onClick={onConfirm} disabled={!!loading}>{loading ? t('accounts.device_fingerprint_dialog.processing') : t('accounts.device_fingerprint_dialog.confirm')}</button>\n                </div>\n            </div>\n            <div className=\"modal-backdrop bg-black/30\" onClick={onCancel}></div>\n        </div>,\n        document.body\n    );\n}\n"
  },
  {
    "path": "src/components/accounts/QuotaItem.tsx",
    "content": "\nimport { Clock, Lock } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../../utils/cn';\nimport { getQuotaColor, formatTimeRemaining, getTimeRemainingColor } from '../../utils/format';\n\ninterface QuotaItemProps {\n    label: string;\n    percentage: number;\n    resetTime?: string;\n    isProtected?: boolean;\n    className?: string;\n    Icon?: React.ComponentType<{ size?: number; className?: string }>;\n}\n\nexport function QuotaItem({ label, percentage, resetTime, isProtected, className, Icon }: QuotaItemProps) {\n    const { t } = useTranslation();\n    const getBgColorClass = (p: number) => {\n        const color = getQuotaColor(p);\n        switch (color) {\n            case 'success': return 'bg-emerald-500';\n            case 'warning': return 'bg-amber-500';\n            case 'error': return 'bg-rose-500';\n            default: return 'bg-gray-500';\n        }\n    };\n\n    const getTextColorClass = (p: number) => {\n        const color = getQuotaColor(p);\n        switch (color) {\n            case 'success': return 'text-emerald-600 dark:text-emerald-400';\n            case 'warning': return 'text-amber-600 dark:text-amber-400';\n            case 'error': return 'text-rose-600 dark:text-rose-400';\n            default: return 'text-gray-500';\n        }\n    };\n\n    const getTimeColorClass = (time?: string) => {\n        if (!time) return 'text-gray-300 dark:text-gray-600';\n        const color = getTimeRemainingColor(time);\n        switch (color) {\n            case 'success': return 'text-emerald-600 dark:text-emerald-400';\n            case 'warning': return 'text-amber-600 dark:text-amber-400';\n            default: return 'text-blue-600 dark:text-blue-400';\n        }\n    };\n\n    return (\n        <div className={cn(\n            \"relative h-[22px] flex items-center px-1.5 rounded-md overflow-hidden border border-gray-100/50 dark:border-white/5 bg-gray-50/30 dark:bg-white/5 group/quota\",\n            className\n        )}>\n            {/* Background Progress Bar */}\n            <div\n                className={cn(\n                    \"absolute inset-y-0 left-0 transition-all duration-700 ease-out opacity-15 dark:opacity-20\",\n                    getBgColorClass(percentage)\n                )}\n                style={{ width: `${percentage}%` }}\n            />\n\n            {/* Content */}\n            <div className=\"relative z-10 w-full flex items-center text-[10px] font-mono leading-none gap-1.5\">\n                {/* Model Name */}\n                <span className=\"flex-1 min-w-0 text-gray-500 dark:text-gray-400 font-bold truncate text-left flex items-center gap-1\" title={label}>\n                    {Icon && <Icon size={12} className=\"shrink-0\" />}\n                    {label}\n                </span>\n\n                {/* Reset Time */}\n                <div className=\"w-[58px] flex justify-start shrink-0\">\n                    {resetTime ? (\n                        <span className={cn(\"flex items-center gap-0.5 font-medium transition-colors truncate\", getTimeColorClass(resetTime))}>\n                            <Clock className=\"w-2.5 h-2.5 shrink-0\" />\n                            {formatTimeRemaining(resetTime)}\n                        </span>\n                    ) : (\n                        <span className=\"text-gray-300 dark:text-gray-600 italic scale-90\">N/A</span>\n                    )}\n                </div>\n\n                {/* Percentage */}\n                <span className={cn(\"w-[28px] text-right font-bold transition-colors flex items-center justify-end gap-0.5 shrink-0\", getTextColorClass(percentage))}>\n                    {isProtected && (\n                        <span title={t('accounts.quota_protected')}><Lock className=\"w-2.5 h-2.5 text-amber-500\" /></span>\n                    )}\n                    {percentage}%\n                </span>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/components/common/AdminAuthGuard.tsx",
    "content": "import React, { useState, useEffect } from 'react';\nimport { Lock, Key, Globe, AlertCircle, Loader2 } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { isTauri } from '../../utils/env';\n\n/**\n * AdminAuthGuard\n * 针对 Docker/Web 模式的强制鉴权保护层。\n * 如果检测到没有存储的 API Key 或后端返回 401，将拦截 UI 并要求输入 Key。\n */\nexport const AdminAuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {\n    const { t, i18n } = useTranslation();\n    const [isAuthenticated, setIsAuthenticated] = useState(isTauri());\n    const [apiKey, setApiKey] = useState('');\n    const [showLangMenu, setShowLangMenu] = useState(false);\n    const [isLoading, setIsLoading] = useState(false);\n    const [error, setError] = useState('');\n\n    useEffect(() => {\n        if (isTauri()) return;\n\n        // 检查 Session 存储 (优先)\n        const sessionKey = sessionStorage.getItem('abv_admin_api_key');\n        if (sessionKey) {\n            setIsAuthenticated(true);\n            setApiKey(sessionKey);\n            return;\n        }\n\n        // 检查本地存储 (迁移逻辑)\n        const savedKey = localStorage.getItem('abv_admin_api_key');\n        if (savedKey) {\n            // 迁移到 sessionStorage 并清理 localStorage\n            sessionStorage.setItem('abv_admin_api_key', savedKey);\n            localStorage.removeItem('abv_admin_api_key');\n            setIsAuthenticated(true);\n            setApiKey(savedKey);\n        }\n\n        // 监听全局 401 事件\n        const handleUnauthorized = () => {\n            sessionStorage.removeItem('abv_admin_api_key');\n            localStorage.removeItem('abv_admin_api_key'); // 双重清理确保万一\n            setIsAuthenticated(false);\n        };\n\n        window.addEventListener('abv-unauthorized', handleUnauthorized);\n        return () => window.removeEventListener('abv-unauthorized', handleUnauthorized);\n    }, []);\n\n    const handleLogin = async (e: React.FormEvent) => {\n        e.preventDefault();\n        const trimmedKey = apiKey.trim();\n        if (!trimmedKey) return;\n\n        setIsLoading(true);\n        setError('');\n\n        try {\n            // 先临时存储 key，用于验证请求\n            sessionStorage.setItem('abv_admin_api_key', trimmedKey);\n\n            // 调用一个需要认证的 API 来验证密码是否正确\n            const response = await fetch('/api/accounts', {\n                method: 'GET',\n                headers: {\n                    'Content-Type': 'application/json',\n                    'Authorization': `Bearer ${trimmedKey}`,\n                    'x-api-key': trimmedKey\n                }\n            });\n\n            if (response.ok || response.status === 204) {\n                // 验证成功\n                localStorage.removeItem('abv_admin_api_key');\n                setIsAuthenticated(true);\n                window.location.reload();\n            } else if (response.status === 401) {\n                // 密码错误\n                sessionStorage.removeItem('abv_admin_api_key');\n                setError(t('login.error_invalid_key'));\n            } else {\n                // 其他错误，但可能密码是对的\n                setIsAuthenticated(true);\n                window.location.reload();\n            }\n        } catch (err) {\n            // 网络错误等\n            sessionStorage.removeItem('abv_admin_api_key');\n            setError(t('login.error_network'));\n        } finally {\n            setIsLoading(false);\n        }\n    };\n\n    const changeLanguage = (lng: string) => {\n        i18n.changeLanguage(lng);\n        setShowLangMenu(false);\n    };\n\n    const languages = [\n        { code: 'zh', name: '简体中文' },\n        { code: 'zh-TW', name: '繁體中文' },\n        { code: 'en', name: 'English' },\n        { code: 'ja', name: '日本語' },\n        { code: 'ko', name: '한국어' },\n        { code: 'ru', name: 'Русский' },\n        { code: 'tr', name: 'Türkçe' },\n        { code: 'vi', name: 'Tiếng Việt' },\n        { code: 'pt', name: 'Português' },\n        { code: 'ar', name: 'العربية' },\n        { code: 'es', name: 'Español' },\n        { code: 'my', name: 'Bahasa Melayu' },\n    ];\n\n    if (isAuthenticated) {\n        return <>{children}</>;\n    }\n\n    return (\n        <div className=\"min-h-screen bg-slate-50 dark:bg-base-300 flex items-center justify-center p-4 relative\">\n            {/* 语言切换按钮 */}\n            <div className=\"absolute top-8 right-8\">\n                <div className=\"relative\">\n                    <button\n                        onClick={() => setShowLangMenu(!showLangMenu)}\n                        className=\"flex items-center gap-2 px-4 py-2 bg-white dark:bg-base-100 rounded-2xl shadow-sm border border-slate-100 dark:border-white/5 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 transition-all\"\n                    >\n                        <Globe className=\"w-4 h-4\" />\n                        <span className=\"text-sm font-medium uppercase\">{i18n.language.split('-')[0]}</span>\n                    </button>\n\n                    {showLangMenu && (\n                        <div className=\"absolute right-0 mt-2 w-40 bg-white dark:bg-base-100 rounded-2xl shadow-xl border border-slate-100 dark:border-white/5 py-2 z-50 animate-in fade-in zoom-in duration-200\">\n                            {languages.map((lang) => (\n                                <button\n                                    key={lang.code}\n                                    onClick={() => changeLanguage(lang.code)}\n                                    className={`w-full text-left px-4 py-2 text-sm hover:bg-slate-50 dark:hover:bg-white/5 transition-colors ${i18n.language === lang.code ? 'text-blue-500 font-bold' : 'text-slate-600 dark:text-slate-300'\n                                        }`}\n                                >\n                                    {lang.name}\n                                </button>\n                            ))}\n                        </div>\n                    )}\n                </div>\n            </div>\n\n            <div className=\"max-w-md w-full bg-white dark:bg-base-100 rounded-3xl shadow-xl overflow-hidden border border-slate-100 dark:border-white/5\">\n                <div className=\"p-8\">\n                    <div className=\"w-16 h-16 bg-blue-50 dark:bg-blue-900/20 rounded-2xl flex items-center justify-center mb-6 mx-auto\">\n                        <Lock className=\"w-8 h-8 text-blue-500\" />\n                    </div>\n                    <h2 className=\"text-2xl font-bold text-center text-slate-900 dark:text-slate-100 mb-2 font-display\">{t('login.title')}</h2>\n                    <p className=\"text-center text-slate-500 dark:text-slate-400 mb-8 text-sm\">{t('login.desc')}</p>\n\n                    <form onSubmit={handleLogin} className=\"space-y-6\">\n                        <div className=\"relative\">\n                            <Key className=\"absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400\" />\n                            <input\n                                type=\"password\"\n                                placeholder={t('login.placeholder')}\n                                className={`w-full pl-12 pr-4 py-4 bg-slate-50 dark:bg-base-200 border-2 rounded-2xl focus:ring-2 focus:ring-blue-500 transition-all outline-none text-slate-900 dark:text-white ${error ? 'border-red-400' : 'border-transparent'}`}\n                                value={apiKey}\n                                onChange={(e) => { setApiKey(e.target.value); setError(''); }}\n                                autoFocus\n                                disabled={isLoading}\n                            />\n                        </div>\n                        {error && (\n                            <div className=\"flex items-center gap-2 text-red-500 text-sm\">\n                                <AlertCircle className=\"w-4 h-4\" />\n                                <span>{error}</span>\n                            </div>\n                        )}\n                        <button\n                            type=\"submit\"\n                            disabled={isLoading || !apiKey.trim()}\n                            className=\"w-full py-4 bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 disabled:cursor-not-allowed text-white font-bold rounded-2xl shadow-lg shadow-blue-500/30 transition-all active:scale-[0.98] flex items-center justify-center gap-2\"\n                        >\n                            {isLoading ? (\n                                <>\n                                    <Loader2 className=\"w-5 h-5 animate-spin\" />\n                                    {t('login.btn_verifying')}\n                                </>\n                            ) : (\n                                t('login.btn_login')\n                            )}\n                        </button>\n                    </form>\n\n                    <div className=\"mt-8 pt-6 border-t border-slate-50 dark:border-white/5 text-center\">\n                        <p className=\"text-[10px] text-slate-400 leading-relaxed\">\n                            {t('login.note')}\n                            <br />\n                            {t('login.lookup_hint')}\n                            <br />\n                            {t('login.config_hint')}\n                        </p>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/components/common/BackgroundTaskRunner.tsx",
    "content": "import { useEffect, useRef } from 'react';\nimport { useConfigStore } from '../../stores/useConfigStore';\nimport { useAccountStore } from '../../stores/useAccountStore';\n\nfunction BackgroundTaskRunner() {\n    const { config } = useConfigStore();\n    const { refreshAllQuotas } = useAccountStore();\n\n    // Use refs to track previous state to detect \"off -> on\" transitions\n    const prevAutoRefreshRef = useRef(false);\n    const prevAutoSyncRef = useRef(false);\n\n    // Auto Refresh Quota Effect\n    useEffect(() => {\n        if (!config) return;\n\n        let intervalId: ReturnType<typeof setTimeout> | null = null;\n        const { auto_refresh, refresh_interval } = config;\n\n        // Check if we just turned it on\n        if (auto_refresh && !prevAutoRefreshRef.current) {\n            console.log('[BackgroundTask] Auto-refresh enabled, executing immediately...');\n            refreshAllQuotas();\n        }\n        prevAutoRefreshRef.current = auto_refresh;\n\n        if (auto_refresh && refresh_interval > 0) {\n            console.log(`[BackgroundTask] Starting auto-refresh quota timer: ${refresh_interval} mins`);\n            intervalId = setInterval(() => {\n                console.log('[BackgroundTask] Auto-refreshing all quotas...');\n                refreshAllQuotas();\n            }, Math.min(refresh_interval * 60 * 1000, 2147483647));\n        }\n\n        return () => {\n            if (intervalId) {\n                console.log('[BackgroundTask] Clearing auto-refresh timer');\n                clearInterval(intervalId);\n            }\n        };\n    }, [config?.auto_refresh, config?.refresh_interval]);\n\n    // Auto Sync Current Account Effect\n    useEffect(() => {\n        if (!config) return;\n\n        let intervalId: ReturnType<typeof setTimeout> | null = null;\n        const { auto_sync, sync_interval } = config;\n        const { syncAccountFromDb } = useAccountStore.getState();\n\n        // Check if we just turned it on\n        if (auto_sync && !prevAutoSyncRef.current) {\n            console.log('[BackgroundTask] Auto-sync enabled, executing immediately...');\n            syncAccountFromDb();\n        }\n        prevAutoSyncRef.current = auto_sync;\n\n        if (auto_sync && sync_interval > 0) {\n            console.log(`[BackgroundTask] Starting auto-sync account timer: ${sync_interval} mins`);\n            intervalId = setInterval(() => {\n                console.log('[BackgroundTask] Auto-syncing current account from DB...');\n                syncAccountFromDb();\n            }, Math.min(sync_interval * 60 * 1000, 2147483647));\n        }\n\n        return () => {\n            if (intervalId) {\n                console.log('[BackgroundTask] Clearing auto-sync timer');\n                clearInterval(intervalId);\n            }\n        };\n    }, [config?.auto_sync, config?.sync_interval]);\n\n    // Render nothing\n    return null;\n}\n\nexport default BackgroundTaskRunner;\n"
  },
  {
    "path": "src/components/common/DebouncedSlider.tsx",
    "content": "import { useState, useEffect } from 'react';\n\ninterface DebouncedSliderProps {\n    value: number;\n    onChange: (value: number) => void;\n    min: number;\n    max: number;\n    step: number;\n    className?: string; // For passing 'range range-purple range-xs' etc.\n}\n\nexport default function DebouncedSlider({ value, onChange, min, max, step, className }: DebouncedSliderProps) {\n    const [localValue, setLocalValue] = useState(value);\n    const [isDragging, setIsDragging] = useState(false);\n\n    // Sync local value with prop value when not dragging (for external updates)\n    useEffect(() => {\n        if (!isDragging) {\n            setLocalValue(value);\n        }\n    }, [value, isDragging]);\n\n    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n        setLocalValue(parseFloat(e.target.value));\n    };\n\n    const handlePointerDown = () => {\n        setIsDragging(true);\n    };\n\n    const handlePointerUp = (e: React.PointerEvent<HTMLInputElement>) => {\n        setIsDragging(false);\n        const newValue = parseFloat((e.target as HTMLInputElement).value);\n        onChange(newValue);\n    };\n\n    // Also handle onMouseUp/onTouchEnd as backup if Pointer events behave oddly in some envs, \n    // but Pointer events are standard now. \n    // Actually, simple onChange + onMouseUp is robust enough for standard ranges.\n\n    return (\n        <div className=\"flex items-center gap-3 w-full\">\n            <input\n                type=\"range\"\n                min={min}\n                max={max}\n                step={step}\n                className={className}\n                value={localValue}\n                onChange={handleChange}\n                onPointerDown={handlePointerDown}\n                onPointerUp={handlePointerUp}\n            />\n            <span className=\"text-xs font-mono font-bold text-purple-600 dark:text-purple-400 w-10 text-right\">\n                {Math.round(localValue * 100)}%\n            </span>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/components/common/GroupedSelect.tsx",
    "content": "import { useState, useRef, useEffect } from 'react';\nimport { createPortal } from 'react-dom';\nimport { ChevronDown, Check, Edit3 } from 'lucide-react';\nimport { cn } from '../../utils/cn';\n\nexport interface SelectOption {\n    value: string;\n    label: string;\n    group?: string;\n}\n\ninterface GroupedSelectProps {\n    value: string;\n    onChange: (value: string) => void;\n    options: SelectOption[];\n    placeholder?: string;\n    className?: string;\n    disabled?: boolean;\n    allowCustomInput?: boolean; // 新增: 是否允许自定义输入\n}\n\nexport default function GroupedSelect({\n    value,\n    onChange,\n    options,\n    placeholder = 'Select...',\n    className = '',\n    disabled = false,\n    allowCustomInput = false // 新增: 默认不允许自定义输入\n}: GroupedSelectProps) {\n    const [isOpen, setIsOpen] = useState(false);\n    const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });\n    const [customInput, setCustomInput] = useState(''); // 新增: 自定义输入值\n    const containerRef = useRef<HTMLDivElement>(null);\n    const buttonRef = useRef<HTMLButtonElement>(null);\n    const dropdownRef = useRef<HTMLDivElement>(null); // 新增: 下拉菜单引用\n    const customInputRef = useRef<HTMLInputElement>(null); // 新增: 自定义输入框引用\n\n    // 按组分组选项\n    const groupedOptions = options.reduce((acc, option) => {\n        const group = option.group || 'Other';\n        if (!acc[group]) {\n            acc[group] = [];\n        }\n        acc[group].push(option);\n        return acc;\n    }, {} as Record<string, SelectOption[]>);\n\n    // 获取当前选中项的标签\n    const selectedOption = options.find(opt => opt.value === value);\n    const selectedLabel = selectedOption?.label || value || placeholder;\n\n    // 更新下拉菜单位置\n    const updateDropdownPosition = () => {\n        if (buttonRef.current) {\n            const rect = buttonRef.current.getBoundingClientRect();\n            setDropdownPosition({\n                top: rect.bottom + window.scrollY + 4,\n                left: rect.left + window.scrollX,\n                width: Math.max(rect.width * 1.1, 220) // 增加宽度到 1.1 倍,最小 220px\n            });\n        }\n    };\n\n    // 点击外部关闭下拉菜单\n    useEffect(() => {\n        const handleClickOutside = (event: MouseEvent) => {\n            // 修复: 检查点击是否在容器或下拉菜单内部\n            const target = event.target as Node;\n            const isClickInsideContainer = containerRef.current?.contains(target);\n            const isClickInsideDropdown = dropdownRef.current?.contains(target);\n\n            if (!isClickInsideContainer && !isClickInsideDropdown) {\n                setIsOpen(false);\n            }\n        };\n\n        if (isOpen) {\n            updateDropdownPosition();\n            document.addEventListener('mousedown', handleClickOutside);\n            window.addEventListener('scroll', updateDropdownPosition, true);\n            window.addEventListener('resize', updateDropdownPosition);\n        }\n\n        return () => {\n            document.removeEventListener('mousedown', handleClickOutside);\n            window.removeEventListener('scroll', updateDropdownPosition, true);\n            window.removeEventListener('resize', updateDropdownPosition);\n        };\n    }, [isOpen]);\n\n    const handleSelect = (optionValue: string) => {\n        console.log('[GroupedSelect] handleSelect called:', optionValue);\n        onChange(optionValue);\n        setIsOpen(false);\n    };\n\n    const handleCustomInputSubmit = () => {\n        if (customInput.trim()) {\n            console.log('[GroupedSelect] Custom input submitted:', customInput.trim());\n            onChange(customInput.trim());\n            setCustomInput('');\n            setIsOpen(false);\n        }\n    };\n\n    const handleToggle = () => {\n        if (!disabled) {\n            setIsOpen(!isOpen);\n            if (!isOpen) {\n                updateDropdownPosition();\n            }\n        }\n    };\n\n    return (\n        <div ref={containerRef} className={cn('relative', className)}>\n            {/* 触发按钮 */}\n            <button\n                ref={buttonRef}\n                type=\"button\"\n                onClick={handleToggle}\n                disabled={disabled}\n                className={cn(\n                    'w-full px-3 py-2 text-left text-xs font-mono',\n                    'bg-white dark:bg-gray-800',\n                    'border border-gray-300 dark:border-gray-600',\n                    'rounded-lg',\n                    'flex items-center justify-between gap-2',\n                    'transition-all duration-200',\n                    'hover:border-blue-400 dark:hover:border-blue-500',\n                    'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',\n                    disabled && 'opacity-50 cursor-not-allowed',\n                    isOpen && 'ring-2 ring-blue-500 border-transparent'\n                )}\n            >\n                <span className=\"truncate text-gray-900 dark:text-gray-100\">\n                    {selectedLabel}\n                </span>\n                <ChevronDown\n                    size={14}\n                    className={cn(\n                        'text-gray-500 dark:text-gray-400 transition-transform duration-200',\n                        isOpen && 'rotate-180'\n                    )}\n                />\n            </button>\n\n            {/* 下拉菜单 - 使用 Portal 渲染到 body */}\n            {isOpen && createPortal(\n                <div\n                    ref={dropdownRef}\n                    style={{\n                        position: 'absolute',\n                        top: `${dropdownPosition.top}px`,\n                        left: `${dropdownPosition.left}px`,\n                        width: `${dropdownPosition.width}px`,\n                        zIndex: 9999\n                    }}\n                    className={cn(\n                        'bg-white dark:bg-gray-800',\n                        'border border-gray-200 dark:border-gray-700',\n                        'rounded-lg shadow-2xl',\n                        'max-h-80 overflow-y-auto',\n                        'animate-in fade-in-0 zoom-in-95 duration-100'\n                    )}\n                >\n                    {Object.entries(groupedOptions).map(([group, groupOptions]) => (\n                        <div key={group}>\n                            {/* 分组标题 */}\n                            <div className=\"px-3 py-1.5 text-[9px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider bg-gray-50 dark:bg-gray-900/50 sticky top-0 z-10\">\n                                {group}\n                            </div>\n\n                            {/* 分组选项 */}\n                            {groupOptions.map((option) => (\n                                <button\n                                    key={option.value}\n                                    type=\"button\"\n                                    onClick={() => handleSelect(option.value)}\n                                    title={option.label}\n                                    className={cn(\n                                        'w-full px-3 py-1.5 text-left text-[10px] font-mono',\n                                        'flex items-center justify-between gap-2',\n                                        'transition-colors duration-150',\n                                        'hover:bg-blue-50 dark:hover:bg-blue-900/20',\n                                        option.value === value\n                                            ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'\n                                            : 'text-gray-900 dark:text-gray-100'\n                                    )}\n                                >\n                                    <span className=\"truncate\">{option.label}</span>\n                                    {option.value === value && (\n                                        <Check size={12} className=\"text-blue-600 dark:text-blue-400 flex-shrink-0\" />\n                                    )}\n                                </button>\n                            ))}\n                        </div>\n                    ))}\n\n                    {/* 自定义输入区域 */}\n                    {allowCustomInput && (\n                        <div className=\"border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 p-2\">\n                            <div className=\"flex items-center gap-1.5\">\n                                <Edit3 size={12} className=\"text-gray-400 dark:text-gray-500 flex-shrink-0\" />\n                                <input\n                                    ref={customInputRef}\n                                    type=\"text\"\n                                    value={customInput}\n                                    onChange={(e) => setCustomInput(e.target.value)}\n                                    onKeyDown={(e) => {\n                                        if (e.key === 'Enter') {\n                                            e.preventDefault();\n                                            handleCustomInputSubmit();\n                                        }\n                                    }}\n                                    placeholder=\"输入自定义模型 ID...\"\n                                    className={cn(\n                                        'flex-1 px-2 py-1 text-[10px] font-mono',\n                                        'bg-white dark:bg-gray-800',\n                                        'border border-gray-300 dark:border-gray-600',\n                                        'rounded focus:outline-none focus:ring-1 focus:ring-blue-500',\n                                        'text-gray-900 dark:text-gray-100',\n                                        'placeholder:text-gray-400 dark:placeholder:text-gray-500'\n                                    )}\n                                />\n                                <button\n                                    type=\"button\"\n                                    onClick={handleCustomInputSubmit}\n                                    disabled={!customInput.trim()}\n                                    className={cn(\n                                        'px-2 py-1 text-[10px] font-medium rounded',\n                                        'transition-colors duration-150',\n                                        customInput.trim()\n                                            ? 'bg-blue-500 hover:bg-blue-600 text-white'\n                                            : 'bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'\n                                    )}\n                                >\n                                    确定\n                                </button>\n                            </div>\n                        </div>\n                    )}\n                </div>,\n                document.body\n            )}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/components/common/HelpTooltip.tsx",
    "content": "import { CircleHelp } from 'lucide-react';\n\nexport type HelpTooltipPlacement = 'top' | 'right' | 'bottom' | 'left';\n\nexport type HelpTooltipProps = {\n    text: string;\n    placement?: HelpTooltipPlacement;\n    ariaLabel?: string;\n    iconSize?: number;\n    className?: string;\n};\n\nconst placementClasses: Record<HelpTooltipPlacement, string> = {\n    top: 'bottom-full mb-2 left-1/2 -translate-x-1/2',\n    right: 'left-full ml-2 top-1/2 -translate-y-1/2',\n    bottom: 'top-full mt-2 left-1/2 -translate-x-1/2',\n    left: 'right-full mr-2 top-1/2 -translate-y-1/2',\n};\n\nexport default function HelpTooltip({\n    text,\n    placement = 'top',\n    ariaLabel = 'Help',\n    iconSize = 14,\n    className,\n}: HelpTooltipProps) {\n    if (!text) return null;\n\n    return (\n        <span className={`relative inline-flex items-center group ${className || ''}`}>\n            <button\n                type=\"button\"\n                className=\"inline-flex items-center justify-center text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded\"\n                aria-label={ariaLabel}\n                onClick={(e) => {\n                    e.preventDefault();\n                    e.stopPropagation();\n                }}\n                onMouseDown={(e) => {\n                    e.preventDefault();\n                    e.stopPropagation();\n                }}\n            >\n                <CircleHelp size={iconSize} />\n            </button>\n            <span\n                className={`pointer-events-none absolute z-50 ${placementClasses[placement]} w-80 max-w-[90vw] sm:max-w-xs rounded-md bg-gray-900 text-white text-[11px] leading-snug px-2 py-1 shadow-lg opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-150`}\n            >\n                {text}\n            </span>\n        </span>\n    );\n}\n"
  },
  {
    "path": "src/components/common/ModalDialog.tsx",
    "content": "import { AlertTriangle, CheckCircle, XCircle, Info } from 'lucide-react';\nimport { createPortal } from 'react-dom';\nimport { useTranslation } from 'react-i18next';\n\nexport type ModalType = 'confirm' | 'success' | 'error' | 'info';\n\ninterface ModalDialogProps {\n    isOpen: boolean;\n    title: string;\n    message?: string;\n    children?: React.ReactNode;\n    type?: ModalType;\n    onConfirm: () => void;\n    onCancel?: () => void;\n    confirmText?: string;\n    cancelText?: string;\n    isDestructive?: boolean;\n}\n\nexport default function ModalDialog({\n    isOpen,\n    title,\n    message,\n    children,\n    type = 'confirm',\n    onConfirm,\n    onCancel,\n    confirmText,\n    cancelText,\n    isDestructive = false\n}: ModalDialogProps) {\n    const { t } = useTranslation();\n    const finalConfirmText = confirmText || t('common.confirm');\n    const finalCancelText = cancelText || t('common.cancel');\n\n    if (!isOpen) return null;\n\n    const getIcon = () => {\n        switch (type) {\n            case 'success':\n                return <CheckCircle className=\"w-7 h-7 text-green-500\" />;\n            case 'error':\n                return <XCircle className=\"w-7 h-7 text-red-500\" />;\n            case 'info':\n                return <Info className=\"w-7 h-7 text-blue-500\" />;\n            case 'confirm':\n            default:\n                return isDestructive ? <AlertTriangle className=\"w-7 h-7 text-red-500\" /> : <AlertTriangle className=\"w-7 h-7 text-blue-500\" />;\n        }\n    };\n\n    const getIconBg = () => {\n        switch (type) {\n            case 'success': return 'bg-green-50 dark:bg-green-900/20';\n            case 'error': return 'bg-red-50 dark:bg-red-900/20';\n            case 'info': return 'bg-blue-50 dark:bg-blue-900/20';\n            case 'confirm': default: return isDestructive ? 'bg-red-50 dark:bg-red-900/20' : 'bg-blue-50 dark:bg-blue-900/20';\n        }\n    };\n\n    const showCancel = type === 'confirm' && onCancel;\n\n    return createPortal(\n        <div className=\"modal modal-open z-[100]\">\n            {/* Draggable Top Region */}\n            <div data-tauri-drag-region className=\"fixed top-0 left-0 right-0 h-8 z-[110]\" />\n\n            <div className=\"modal-box relative max-w-sm bg-white dark:bg-base-100 shadow-2xl rounded-2xl p-0 overflow-hidden transform transition-all animate-in fade-in zoom-in-95 duration-200\">\n                <div className=\"flex flex-col items-center text-center p-6 pt-8\">\n                    <div className={`w-14 h-14 rounded-full flex items-center justify-center mb-4 shadow-sm ${getIconBg()}`}>\n                        {getIcon()}\n                    </div>\n\n                    <h3 className=\"text-xl font-bold text-gray-900 dark:text-base-content mb-2\">{title}</h3>\n\n                    {children ? (\n                        <div className=\"w-full text-left mb-8 px-1\">\n                            {children}\n                        </div>\n                    ) : (\n                        <p className=\"text-gray-500 dark:text-gray-400 text-sm mb-8 leading-relaxed px-4\">{message}</p>\n                    )}\n\n                    <div className=\"flex gap-3 w-full\">\n                        {showCancel && (\n                            <button\n                                className=\"flex-1 px-4 py-2.5 bg-gray-100 dark:bg-base-200 text-gray-700 dark:text-gray-300 font-medium rounded-xl hover:bg-gray-200 dark:hover:bg-base-300 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-200 dark:focus:ring-base-300\"\n                                onClick={onCancel}\n                            >\n                                {finalCancelText}\n                            </button>\n                        )}\n                        <button\n                            className={`flex-1 px-4 py-2.5 text-white font-medium rounded-xl shadow-md transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 ${isDestructive && type === 'confirm'\n                                ? 'bg-red-500 hover:bg-red-600 focus:ring-red-500 shadow-red-100'\n                                : 'bg-blue-500 hover:bg-blue-600 focus:ring-blue-500 shadow-blue-100'\n                                }`}\n                            onClick={onConfirm}\n                        >\n                            {finalConfirmText}\n                        </button>\n                    </div>\n                </div>\n            </div>\n            <div className=\"modal-backdrop bg-black/40 backdrop-blur-sm fixed inset-0 z-[-1]\" onClick={showCancel ? onCancel : undefined}></div>\n        </div>,\n        document.body\n    );\n}\n"
  },
  {
    "path": "src/components/common/NetworkMonitor.tsx",
    "content": "import React, { useState } from 'react';\nimport { useNetworkMonitorStore, NetworkRequest } from '../../stores/networkMonitorStore';\nimport { X, Play, Pause, Trash2, Activity, ChevronDown } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\n\nconst NetworkMonitor: React.FC = () => {\n    const { requests, isOpen, setIsOpen, isRecording, toggleRecording, clearRequests } = useNetworkMonitorStore();\n    const [selectedRequest, setSelectedRequest] = useState<NetworkRequest | null>(null);\n    const { t } = useTranslation();\n\n    // If not open, show a small floating button\n    if (!isOpen) {\n        return (\n            <div className=\"fixed bottom-4 right-4 z-50\">\n                <button\n                    onClick={() => setIsOpen(true)}\n                    className=\"btn btn-circle btn-primary shadow-lg\"\n                    title={t('monitor.network.open', 'ネットワークモニターを開く')}\n                >\n                    <Activity size={24} />\n                    {requests.filter(r => r.status === 'pending').length > 0 && (\n                        <span className=\"absolute -top-1 -right-1 flex h-3 w-3\">\n                            <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-secondary opacity-75\"></span>\n                            <span className=\"relative inline-flex rounded-full h-3 w-3 bg-secondary\"></span>\n                        </span>\n                    )}\n                </button>\n            </div>\n        );\n    }\n\n    return (\n        <div className=\"fixed inset-0 z-50 flex flex-col bg-base-100/95 backdrop-blur shadow-2xl transition-transform duration-300 pointer-events-auto border-t border-base-300 md:w-2/3 md:inset-y-0 md:right-0 md:left-auto md:border-t-0 md:border-l\">\n            {/* Header */}\n            <div className=\"flex items-center justify-between p-4 border-b border-base-300 bg-base-200/50\">\n                <div className=\"flex items-center gap-2\">\n                    <Activity className=\"text-primary\" size={20} />\n                    <h2 className=\"font-bold text-lg\">{t('monitor.network.title', 'ネットワークモニター')}</h2>\n                    <span className=\"badge badge-sm\">\n                        {t('monitor.network.requests_count', '{{count}} 件', { count: requests.length })}\n                    </span>\n                </div>\n                <div className=\"flex items-center gap-2\">\n                    <button\n                        onClick={toggleRecording}\n                        className={`btn btn-sm btn-circle ${isRecording ? 'btn-error' : 'btn-success'}`}\n                        title={isRecording\n                            ? t('monitor.network.stop_recording', '記録を停止')\n                            : t('monitor.network.start_recording', '記録を開始')}\n                    >\n                        {isRecording ? <Pause size={14} /> : <Play size={14} />}\n                    </button>\n                    <button\n                        onClick={clearRequests}\n                        className=\"btn btn-sm btn-circle btn-ghost\"\n                        title={t('monitor.network.clear_requests', 'リクエストをクリア')}\n                    >\n                        <Trash2 size={16} />\n                    </button>\n                    <button\n                        onClick={() => setIsOpen(false)}\n                        className=\"btn btn-sm btn-circle btn-ghost\"\n                    >\n                        <X size={20} />\n                    </button>\n                </div>\n            </div>\n\n            {/* Main Content */}\n            <div className=\"flex-1 flex overflow-hidden\">\n                {/* Request List */}\n                <div className={`flex-1 overflow-y-auto border-r border-base-300 ${selectedRequest ? 'hidden md:block md:w-1/2' : 'w-full'}`}>\n                    <table className=\"table table-xs table-pin-rows w-full\">\n                        <thead>\n                            <tr className=\"bg-base-200\">\n                                <th className=\"w-16\">{t('monitor.network.table.status', '状態')}</th>\n                                <th>{t('monitor.network.table.command', 'コマンド')}</th>\n                                <th className=\"w-20 text-right\">{t('monitor.network.table.time', '時刻')}</th>\n                                <th className=\"w-20 text-right\">{t('monitor.network.table.duration', '所要時間')}</th>\n                            </tr>\n                        </thead>\n                        <tbody>\n                            {requests.map((req) => (\n                                <tr\n                                    key={req.id}\n                                    className={`cursor-pointer hover:bg-base-200 ${selectedRequest?.id === req.id ? 'bg-primary/10' : ''}`}\n                                    onClick={() => setSelectedRequest(req)}\n                                >\n                                    <td>\n                                        <BadgeStatus status={req.status} />\n                                    </td>\n                                    <td className=\"font-mono text-xs truncate max-w-[200px]\" title={req.cmd}>\n                                        {req.cmd}\n                                    </td>\n                                    <td className=\"text-right text-xs opacity-70\">\n                                        {new Date(req.startTime).toLocaleTimeString()}\n                                    </td>\n                                    <td className=\"text-right text-xs opacity-70\">\n                                        {req.duration ? `${req.duration}ms` : '-'}\n                                    </td>\n                                </tr>\n                            ))}\n                            {requests.length === 0 && (\n                                <tr>\n                                    <td colSpan={4} className=\"text-center py-8 opacity-50\">\n                                        {t('monitor.network.empty', '記録されたリクエストはありません')}\n                                    </td>\n                                </tr>\n                            )}\n                        </tbody>\n                    </table>\n                </div>\n\n                {/* Details Panel */}\n                {selectedRequest && (\n                    <div className=\"flex-1 md:w-1/2 overflow-y-auto bg-base-100 flex flex-col absolute inset-0 md:static z-10 w-full\">\n                        <div className=\"flex items-center justify-between p-2 border-b border-base-300 bg-base-200/30 md:hidden\">\n                            <button onClick={() => setSelectedRequest(null)} className=\"btn btn-sm btn-ghost\">\n                                <ChevronDown size={16} className=\"rotate-90\" /> {t('common.back', '戻る')}\n                            </button>\n                            <span className=\"font-mono text-xs\">{selectedRequest.cmd}</span>\n                        </div>\n\n                        <div className=\"p-4 space-y-4\">\n                            <div>\n                                <h3 className=\"text-xs font-bold uppercase opacity-50 mb-1\">{t('monitor.network.sections.general', '概要')}</h3>\n                                <div className=\"bg-base-200 rounded p-2 text-xs space-y-1\">\n                                    <div className=\"flex justify-between\">\n                                        <span className=\"opacity-70\">ID:</span>\n                                        <span className=\"font-mono select-all\">{selectedRequest.id}</span>\n                                    </div>\n                                    <div className=\"flex justify-between\">\n                                        <span className=\"opacity-70\">{t('monitor.network.fields.status', '状態')}:</span>\n                                        <BadgeStatus status={selectedRequest.status} />\n                                    </div>\n                                    <div className=\"flex justify-between\">\n                                        <span className=\"opacity-70\">{t('monitor.network.fields.start_time', '開始時刻')}:</span>\n                                        <span>{new Date(selectedRequest.startTime).toLocaleString()}</span>\n                                    </div>\n                                    {selectedRequest.duration && (\n                                        <div className=\"flex justify-between\">\n                                            <span className=\"opacity-70\">{t('monitor.network.fields.duration', '所要時間')}:</span>\n                                            <span>{selectedRequest.duration}ms</span>\n                                        </div>\n                                    )}\n                                </div>\n                            </div>\n\n                            <div>\n                                <h3 className=\"text-xs font-bold uppercase opacity-50 mb-1\">{t('monitor.network.sections.request_args', 'リクエスト引数')}</h3>\n                                <JsonView data={selectedRequest.args} />\n                            </div>\n\n                            <div>\n                                <h3 className=\"text-xs font-bold uppercase opacity-50 mb-1\">\n                                    {selectedRequest.status === 'error'\n                                        ? t('monitor.network.sections.error_details', 'エラー詳細')\n                                        : t('monitor.network.sections.response', 'レスポンス')}\n                                </h3>\n                                {(selectedRequest.response || selectedRequest.error) ? (\n                                    <JsonView\n                                        data={selectedRequest.status === 'error' ? selectedRequest.error : selectedRequest.response}\n                                        isError={selectedRequest.status === 'error'}\n                                    />\n                                ) : (\n                                    <div className=\"text-xs opacity-50 italic\">{t('monitor.network.waiting', '応答待ち...')}</div>\n                                )}\n                            </div>\n                        </div>\n                    </div>\n                )}\n            </div>\n        </div>\n    );\n};\n\nconst BadgeStatus = ({ status }: { status: NetworkRequest['status'] }) => {\n    const { t } = useTranslation();\n    switch (status) {\n        case 'success':\n            return <span className=\"badge badge-xs badge-success\">200</span>;\n        case 'error':\n            return <span className=\"badge badge-xs badge-error\">{t('monitor.network.badge_error', 'エラー')}</span>;\n        case 'pending':\n            return <span className=\"loading loading-spinner loading-xs text-warning\"></span>;\n    }\n};\n\nconst JsonView = ({ data, isError = false }: { data: any, isError?: boolean }) => {\n    const { t } = useTranslation();\n    if (data === undefined || data === null) {\n        return <div className=\"text-xs opacity-50 italic\">{t('common.empty', '空')}</div>;\n    }\n\n    return (\n        <div className={`mockup-code bg-base-300 text-xs min-h-0 ${isError ? 'border border-error/50' : ''}`}>\n            <pre className=\"px-4 py-2 overflow-x-auto\">\n                <code>{JSON.stringify(data, null, 2)}</code>\n            </pre>\n        </div>\n    );\n};\n\nexport default NetworkMonitor;\n"
  },
  {
    "path": "src/components/common/Pagination.tsx",
    "content": "import { ChevronLeft, ChevronRight } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\n\ninterface PaginationProps {\n    currentPage: number;\n    totalPages: number;\n    onPageChange: (page: number) => void;\n    totalItems: number;\n    itemsPerPage: number;\n    onPageSizeChange?: (pageSize: number) => void;  // 新增:分页大小变更回调\n    pageSizeOptions?: number[];  // 新增:可选的分页大小选项\n}\n\nfunction Pagination({\n    currentPage,\n    totalPages,\n    onPageChange,\n    totalItems,\n    itemsPerPage,\n    onPageSizeChange,\n    pageSizeOptions = [10, 20, 50, 100]\n}: PaginationProps) {\n    const { t } = useTranslation();\n\n    if (totalPages <= 1 && !onPageSizeChange) return null;\n\n    // 计算显示的页码范围 (最多显示 5 个页码)\n    let startPage = Math.max(1, currentPage - 2);\n    let endPage = Math.min(totalPages, startPage + 4);\n\n    if (endPage - startPage < 4) {\n        startPage = Math.max(1, endPage - 4);\n    }\n\n    const pages = [];\n    for (let i = startPage; i <= endPage; i++) {\n        pages.push(i);\n    }\n\n    const startIndex = (currentPage - 1) * itemsPerPage + 1;\n    const endIndex = Math.min(currentPage * itemsPerPage, totalItems);\n\n    return (\n        <div className=\"flex items-center justify-between px-6 py-3\">\n            {/* Mobile View */}\n            <div className=\"flex flex-1 justify-between sm:hidden\">\n                <button\n                    onClick={() => onPageChange(currentPage - 1)}\n                    disabled={currentPage === 1}\n                    className={`relative inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium ${currentPage === 1\n                        ? 'bg-gray-100 dark:bg-gray-800 text-gray-400 cursor-not-allowed'\n                        : 'bg-white dark:bg-base-100 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-base-200'\n                        }`}\n                >\n                    {t('common.prev_page')}\n                </button>\n                <button\n                    onClick={() => onPageChange(currentPage + 1)}\n                    disabled={currentPage === totalPages}\n                    className={`relative ml-3 inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium ${currentPage === totalPages\n                        ? 'bg-gray-100 dark:bg-gray-800 text-gray-400 cursor-not-allowed'\n                        : 'bg-white dark:bg-base-100 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-base-200'\n                        }`}\n                >\n                    {t('common.next_page')}\n                </button>\n            </div>\n\n            {/* Desktop View */}\n            <div className=\"hidden sm:flex sm:flex-1 sm:items-center sm:justify-between\">\n                <div className=\"flex items-center gap-4\">\n                    <p className=\"text-sm text-gray-700 dark:text-gray-400\">\n                        {t('common.pagination_info', { start: startIndex, end: endIndex, total: totalItems })}\n                    </p>\n\n                    {/* 分页大小选择器 */}\n                    {onPageSizeChange && (\n                        <div className=\"flex items-center gap-2\">\n                            <span className=\"text-sm text-gray-600 dark:text-gray-400\">{t('common.per_page')}</span>\n                            <select\n                                value={itemsPerPage}\n                                onChange={(e) => onPageSizeChange(parseInt(e.target.value))}\n                                className=\"px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-base-100 text-gray-900 dark:text-base-content focus:outline-none focus:ring-2 focus:ring-blue-500\"\n                            >\n                                {pageSizeOptions.map(size => (\n                                    <option key={size} value={size}>{size} {t('common.items')}</option>\n                                ))}\n                            </select>\n                        </div>\n                    )}\n                </div>\n                <div>\n                    <nav className=\"isolate inline-flex -space-x-px rounded-md shadow-sm\" aria-label=\"Pagination\">\n                        <button\n                            onClick={() => onPageChange(currentPage - 1)}\n                            disabled={currentPage === 1}\n                            className={`relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-base-200 focus:z-20 focus:outline-offset-0 ${currentPage === 1 ? 'cursor-not-allowed opacity-50' : ''\n                                }`}\n                        >\n                            <span className=\"sr-only\">{t('common.prev_page')}</span>\n                            <ChevronLeft className=\"h-4 w-4\" aria-hidden=\"true\" />\n                        </button>\n\n                        {/* First Page Link if needed */}\n                        {startPage > 1 && (\n                            <>\n                                <button\n                                    onClick={() => onPageChange(1)}\n                                    className=\"relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-base-200 focus:z-20 focus:outline-offset-0\"\n                                >\n                                    1\n                                </button>\n                                {startPage > 2 && (\n                                    <span className=\"relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-400 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 focus:outline-offset-0\">\n                                        ...\n                                    </span>\n                                )}\n                            </>\n                        )}\n\n                        {pages.map(page => (\n                            <button\n                                key={page}\n                                onClick={() => onPageChange(page)}\n                                aria-current={page === currentPage ? 'page' : undefined}\n                                className={`relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20 focus:outline-offset-0 ${page === currentPage\n                                    ? 'z-10 bg-blue-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600'\n                                    : 'text-gray-900 dark:text-gray-200 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-base-200'\n                                    }`}\n                            >\n                                {page}\n                            </button>\n                        ))}\n\n                        {/* Last Page Link if needed */}\n                        {endPage < totalPages && (\n                            <>\n                                {endPage < totalPages - 1 && (\n                                    <span className=\"relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-400 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 focus:outline-offset-0\">\n                                        ...\n                                    </span>\n                                )}\n                                <button\n                                    onClick={() => onPageChange(totalPages)}\n                                    className=\"relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-base-200 focus:z-20 focus:outline-offset-0\"\n                                >\n                                    {totalPages}\n                                </button>\n                            </>\n                        )}\n\n                        <button\n                            onClick={() => onPageChange(currentPage + 1)}\n                            disabled={currentPage === totalPages}\n                            className={`relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-base-200 focus:z-20 focus:outline-offset-0 ${currentPage === totalPages ? 'cursor-not-allowed opacity-50' : ''\n                                }`}\n                        >\n                            <span className=\"sr-only\">{t('common.next_page')}</span>\n                            <ChevronRight className=\"h-4 w-4\" aria-hidden=\"true\" />\n                        </button>\n                    </nav>\n                </div>\n            </div>\n        </div>\n    );\n}\n\nexport default Pagination;\n"
  },
  {
    "path": "src/components/common/ThemeManager.tsx",
    "content": "\nimport { useEffect } from 'react';\nimport { useConfigStore } from '../../stores/useConfigStore';\nimport { getCurrentWindow } from '@tauri-apps/api/window';\n\nimport { isLinux } from '../../utils/env';\n\nexport default function ThemeManager() {\n    const { config, loadConfig } = useConfigStore();\n\n    // Load config on mount\n    useEffect(() => {\n        const init = async () => {\n            await loadConfig();\n            // Show window after a short delay to ensure React has painted\n            setTimeout(async () => {\n                if (typeof window !== 'undefined' && (window as any).__TAURI_INTERNALS__) {\n                    await getCurrentWindow().show();\n                }\n            }, 100);\n        };\n        init();\n    }, [loadConfig]);\n\n    // Apply theme when config changes\n    useEffect(() => {\n        if (!config) return;\n\n        const applyTheme = async (theme: string) => {\n            const root = document.documentElement;\n            const isDark = theme === 'dark';\n\n            // Set Tauri window background color\n            // Skip on Linux due to crash with transparent windows + softbuffer\n            try {\n                if (!isLinux() && (window as any).__TAURI_INTERNALS__) {\n                    const bgColor = isDark ? '#1d232a' : '#FAFBFC';\n                    // Don't await this, let it happen in background to avoid blocking React render\n                    getCurrentWindow().setBackgroundColor(bgColor).catch(e =>\n                        console.error('Failed to set window background color:', e)\n                    );\n\n                    // Sync Windows title bar theme (for minimize/maximize/close button colors)\n                    const { invoke } = await import('@tauri-apps/api/core');\n                    invoke('set_window_theme', { theme }).catch(() => {\n                        // Ignore errors on non-Windows platforms\n                    });\n                }\n            } catch (e) {\n                console.error('Window background sync failed:', e);\n            }\n\n            // Set DaisyUI theme\n            root.setAttribute('data-theme', theme);\n\n            // Set inline style for immediate visual feedback\n            root.style.backgroundColor = isDark ? '#1d232a' : '#FAFBFC';\n\n            // Set Tailwind dark mode class\n            if (isDark) {\n                root.classList.add('dark');\n            } else {\n                root.classList.remove('dark');\n            }\n        };\n\n        const theme = config.theme || 'system';\n\n        // Sync to localStorage for early boot check\n        localStorage.setItem('app-theme-preference', theme);\n\n        if (theme === 'system') {\n            const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n\n            const handleSystemChange = (e: MediaQueryListEvent | MediaQueryList) => {\n                const systemTheme = e.matches ? 'dark' : 'light';\n                applyTheme(systemTheme);\n            };\n\n            // Initial alignment\n            handleSystemChange(mediaQuery);\n\n            // Listen for changes\n            mediaQuery.addEventListener('change', handleSystemChange);\n            return () => mediaQuery.removeEventListener('change', handleSystemChange);\n        } else {\n            applyTheme(theme);\n        }\n    }, [config?.theme]);\n\n    return null; // This component handles side effects only\n}\n"
  },
  {
    "path": "src/components/common/Toast.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { CheckCircle, XCircle, Info, AlertTriangle, X } from 'lucide-react';\n\nexport type ToastType = 'success' | 'error' | 'info' | 'warning';\n\nexport interface ToastProps {\n    id: string;\n    message: string;\n    type: ToastType;\n    duration?: number;\n    onClose: (id: string) => void;\n}\n\nconst Toast = ({ id, message, type, duration = 3000, onClose }: ToastProps) => {\n    const [isVisible, setIsVisible] = useState(false);\n\n    useEffect(() => {\n        // Exciting entrance\n        requestAnimationFrame(() => setIsVisible(true));\n\n        if (duration > 0) {\n            const timer = setTimeout(() => {\n                setIsVisible(false);\n                setTimeout(() => onClose(id), 300); // Wait for transition\n            }, duration);\n            return () => clearTimeout(timer);\n        }\n    }, [duration, id, onClose]);\n\n    const getIcon = () => {\n        switch (type) {\n            case 'success': return <CheckCircle className=\"w-5 h-5 text-green-500\" />;\n            case 'error': return <XCircle className=\"w-5 h-5 text-red-500\" />;\n            case 'warning': return <AlertTriangle className=\"w-5 h-5 text-yellow-500\" />;\n            case 'info': default: return <Info className=\"w-5 h-5 text-blue-500\" />;\n        }\n    };\n\n    const getStyles = () => {\n        switch (type) {\n            case 'success': return 'border-green-100 dark:border-green-900/30 bg-white dark:bg-base-100';\n            case 'error': return 'border-red-100 dark:border-red-900/30 bg-white dark:bg-base-100';\n            case 'warning': return 'border-yellow-100 dark:border-yellow-900/30 bg-white dark:bg-base-100';\n            case 'info': default: return 'border-blue-100 dark:border-blue-900/30 bg-white dark:bg-base-100';\n        }\n    };\n\n    return (\n        <div\n            className={`flex items-center gap-3 px-4 py-3 rounded-xl shadow-lg border transition-all duration-300 transform ${getStyles()} ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'}`}\n            style={{ minWidth: '300px' }}\n        >\n            {getIcon()}\n            <p className=\"flex-1 text-sm font-medium text-gray-700 dark:text-base-content\">{message}</p>\n            <button\n                onClick={() => { setIsVisible(false); setTimeout(() => onClose(id), 300); }}\n                className=\"text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors\"\n            >\n                <X className=\"w-4 h-4\" />\n            </button>\n        </div>\n    );\n};\n\nexport default Toast;\n"
  },
  {
    "path": "src/components/common/ToastContainer.tsx",
    "content": "import { useState, useCallback, useEffect } from 'react';\nimport { createPortal } from 'react-dom';\nimport Toast, { ToastType } from './Toast';\n\nexport interface ToastItem {\n    id: string;\n    message: string;\n    type: ToastType;\n    duration?: number;\n}\n\nlet toastCounter = 0;\nlet addToastExternal: ((message: string, type: ToastType, duration?: number) => void) | null = null;\n\nexport const showToast = (message: string, type: ToastType = 'info', duration: number = 3000) => {\n    if (addToastExternal) {\n        addToastExternal(message, type, duration);\n    } else {\n        console.warn('ToastContainer not mounted');\n    }\n};\n\nconst ToastContainer = () => {\n    const [toasts, setToasts] = useState<ToastItem[]>([]);\n\n    const addToast = useCallback((message: string, type: ToastType, duration?: number) => {\n        const id = `toast-${Date.now()}-${toastCounter++}`;\n        setToasts(prev => [...prev, { id, message, type, duration }]);\n    }, []);\n\n    const removeToast = useCallback((id: string) => {\n        setToasts(prev => prev.filter(t => t.id !== id));\n    }, []);\n\n    useEffect(() => {\n        addToastExternal = addToast;\n        return () => {\n            addToastExternal = null;\n        };\n    }, [addToast]);\n\n    return createPortal(\n        <div className=\"fixed top-24 right-8 z-[200] flex flex-col gap-3 pointer-events-none\">\n            <div className=\"flex flex-col gap-3 pointer-events-auto\">\n                {toasts.map(toast => (\n                    <Toast\n                        key={toast.id}\n                        {...toast}\n                        onClose={removeToast}\n                    />\n                ))}\n            </div>\n        </div>,\n        document.body\n    );\n};\n\nexport default ToastContainer;\n"
  },
  {
    "path": "src/components/dashboard/BestAccounts.tsx",
    "content": "import { TrendingUp } from 'lucide-react';\nimport { Account } from '../../types/account';\n\ninterface BestAccountsProps {\n    accounts: Account[];\n    currentAccountId?: string;\n    onSwitch?: (accountId: string) => void;\n}\n\nimport { useTranslation } from 'react-i18next';\n\nfunction BestAccounts({ accounts, currentAccountId, onSwitch }: BestAccountsProps) {\n    const { t } = useTranslation();\n    // 1. 获取按配额排序的列表 (排除当前账号)\n    const geminiSorted = accounts\n        .filter(a => a.id !== currentAccountId)\n        .map(a => {\n            const proQuota = (a.quota?.models || [])\n                .filter(m =>\n                    m.name.toLowerCase() === 'gemini-3-pro-high'\n                    || m.name.toLowerCase() === 'gemini-3-pro-low'\n                    || m.name.toLowerCase() === 'gemini-3.1-pro-high'\n                    || m.name.toLowerCase() === 'gemini-3.1-pro-low'\n                )\n                .reduce((best, model) => Math.max(best, model.percentage || 0), 0);\n            const flashQuota = a.quota?.models.find(m => m.name.toLowerCase() === 'gemini-3-flash')?.percentage || 0;\n            // 综合评分：Pro 权重更高 (70%)，Flash 权重 30%\n            return {\n                ...a,\n                quotaVal: Math.round(proQuota * 0.7 + flashQuota * 0.3),\n            };\n        })\n        .filter(a => a.quotaVal > 0)\n        .sort((a, b) => b.quotaVal - a.quotaVal);\n\n    const claudeSorted = accounts\n        .filter(a => a.id !== currentAccountId)\n        .map(a => ({\n            ...a,\n            quotaVal: a.quota?.models.find(m => m.name.toLowerCase().includes('claude'))?.percentage || 0,\n        }))\n        .filter(a => a.quotaVal > 0)\n        .sort((a, b) => b.quotaVal - a.quotaVal);\n\n    let bestGemini = geminiSorted[0];\n    let bestClaude = claudeSorted[0];\n\n    // 2. 如果推荐是同一个账号，且有其他选择，尝试寻找最优的\"不同账号\"组合\n    if (bestGemini && bestClaude && bestGemini.id === bestClaude.id) {\n        const nextGemini = geminiSorted[1];\n        const nextClaude = claudeSorted[1];\n\n        // 方案A: 保持 Gemini 最优，换 Claude 次优\n        // 方案B: 换 Gemini 次优，保持 Claude 最优\n        // 比较标准：两者配额之和最大化 (或者优先保住 100% 的那个)\n\n        const scoreA = bestGemini.quotaVal + (nextClaude?.quotaVal || 0);\n        const scoreB = (nextGemini?.quotaVal || 0) + bestClaude.quotaVal;\n\n        if (nextClaude && (!nextGemini || scoreA >= scoreB)) {\n            // 选方案A：换 Claude\n            bestClaude = nextClaude;\n        } else if (nextGemini) {\n            // 选方案B：换 Gemini\n            bestGemini = nextGemini;\n        }\n        // 如果都没有次优解（例如只有一个账号），则保持原样\n    }\n\n    // 构造最终用于显示的视图模型 (兼容原有渲染逻辑)\n    const bestGeminiRender = bestGemini ? { ...bestGemini, geminiQuota: bestGemini.quotaVal } : undefined;\n    const bestClaudeRender = bestClaude ? { ...bestClaude, claudeQuota: bestClaude.quotaVal } : undefined;\n\n    return (\n        <div className=\"bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200 h-full flex flex-col\">\n            <h2 className=\"text-base font-semibold text-gray-900 dark:text-base-content mb-3 flex items-center gap-2\">\n                <TrendingUp className=\"w-4 h-4 text-blue-500 dark:text-blue-400\" />\n                {t('dashboard.best_accounts')}\n            </h2>\n\n            <div className=\"space-y-2 flex-1\">\n                {/* Gemini 最佳 */}\n                {bestGeminiRender && (\n                    <div className=\"flex items-center justify-between p-2.5 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-100 dark:border-green-900/30\">\n                        <div className=\"flex-1 min-w-0\">\n                            <div className=\"text-[10px] text-green-600 dark:text-green-400 font-medium mb-0.5\">{t('dashboard.for_gemini')}</div>\n                            <div className=\"font-medium text-sm text-gray-900 dark:text-base-content truncate\">\n                                {bestGeminiRender.email}\n                            </div>\n                        </div>\n                        <div className=\"ml-2 px-2 py-0.5 bg-green-500 text-white text-xs font-semibold rounded-full\">\n                            {bestGeminiRender.geminiQuota}%\n                        </div>\n                    </div>\n                )}\n\n                {/* Claude 最佳 */}\n                {bestClaudeRender && (\n                    <div className=\"flex items-center justify-between p-2.5 bg-cyan-50 dark:bg-cyan-900/20 rounded-lg border border-cyan-100 dark:border-cyan-900/30\">\n                        <div className=\"flex-1 min-w-0\">\n                            <div className=\"text-[10px] text-cyan-600 dark:text-cyan-400 font-medium mb-0.5\">{t('dashboard.for_claude')}</div>\n                            <div className=\"font-medium text-sm text-gray-900 dark:text-base-content truncate\">\n                                {bestClaudeRender.email}\n                            </div>\n                        </div>\n                        <div className=\"ml-2 px-2 py-0.5 bg-cyan-500 text-white text-xs font-semibold rounded-full\">\n                            {bestClaudeRender.claudeQuota}%\n                        </div>\n                    </div>\n                )}\n\n                {(!bestGeminiRender && !bestClaudeRender) && (\n                    <div className=\"text-center py-4 text-gray-400 text-sm\">\n                        {t('accounts.no_data')}\n                    </div>\n                )}\n            </div>\n\n            {(bestGeminiRender || bestClaudeRender) && onSwitch && (\n                <div className=\"mt-auto pt-3\">\n                    <button\n                        className=\"w-full px-3 py-1.5 bg-blue-500 text-white text-xs font-medium rounded-lg hover:bg-blue-600 transition-colors\"\n                        onClick={() => {\n                            // 优先切换到配额更高的账号\n                            let targetId = bestGeminiRender?.id;\n                            if (bestClaudeRender && (!bestGeminiRender || bestClaudeRender.claudeQuota > bestGeminiRender.geminiQuota)) {\n                                targetId = bestClaudeRender.id;\n                            }\n\n                            if (onSwitch && targetId) {\n                                onSwitch(targetId);\n                            }\n                        }}\n                    >\n                        {t('dashboard.switch_best')}\n                    </button>\n                </div>\n            )}\n        </div>\n    );\n\n}\n\nexport default BestAccounts;\n"
  },
  {
    "path": "src/components/dashboard/CurrentAccount.tsx",
    "content": "import { CheckCircle, Mail, Diamond, Gem, Circle, Tag, Lock } from 'lucide-react';\nimport { Account } from '../../types/account';\nimport { formatTimeRemaining } from '../../utils/format';\n\ninterface CurrentAccountProps {\n    account: Account | null;\n    onSwitch?: () => void;\n}\n\nimport { useTranslation } from 'react-i18next';\n\nfunction CurrentAccount({ account, onSwitch }: CurrentAccountProps) {\n    const { t } = useTranslation();\n    if (!account) {\n        return (\n            <div className=\"bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200\">\n                <h2 className=\"text-base font-semibold text-gray-900 dark:text-base-content mb-2 flex items-center gap-2\">\n                    <CheckCircle className=\"w-4 h-4 text-green-500\" />\n                    {t('dashboard.current_account')}\n                </h2>\n                <div className=\"text-center py-4 text-gray-400 dark:text-gray-500 text-sm\">\n                    {t('dashboard.no_active_account')}\n                </div>\n            </div>\n        );\n    }\n\n    const geminiProModel = account.quota?.models\n        .filter(m =>\n            m.name.toLowerCase() === 'gemini-3-pro-high'\n            || m.name.toLowerCase() === 'gemini-3-pro-low'\n            || m.name.toLowerCase() === 'gemini-3.1-pro-high'\n            || m.name.toLowerCase() === 'gemini-3.1-pro-low'\n        )\n        .sort((a, b) => (a.percentage || 0) - (b.percentage || 0))[0];\n\n    const geminiFlashModel = account.quota?.models.find(m => m.name.toLowerCase() === 'gemini-3-flash');\n\n    const geminiImageModel = account.quota?.models.find(m => m.name.toLowerCase() === 'gemini-3-pro-image');\n\n    const claudeGroupNames = [\n        'claude-opus-4-6-thinking',\n        'claude'\n    ];\n    const claudeModel = account.quota?.models\n        .filter(m => claudeGroupNames.includes(m.name.toLowerCase()))\n        .sort((a, b) => (a.percentage || 0) - (b.percentage || 0))[0];\n\n    return (\n        <div className=\"bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200 h-full flex flex-col\">\n            <h2 className=\"text-base font-semibold text-gray-900 dark:text-base-content mb-3 flex items-center gap-2\">\n                <CheckCircle className=\"w-4 h-4 text-green-500\" />\n                {t('dashboard.current_account')}\n            </h2>\n\n            <div className=\"space-y-4 flex-1\">\n                <div className=\"flex items-center gap-3 mb-1\">\n                    <div className=\"flex items-center gap-2 flex-1 min-w-0\">\n                        <Mail className=\"w-3.5 h-3.5 text-gray-400\" />\n                        <span className=\"text-sm font-medium text-gray-700 dark:text-gray-300 truncate\">{account.email}</span>\n                    </div>\n                    {/* 订阅类型 */}\n                    {account.quota?.subscription_tier && (() => {\n                        const tier = account.quota.subscription_tier.toLowerCase();\n                        if (tier.includes('ultra')) {\n                            return (\n                                <span className=\"flex items-center gap-1 px-2 py-0.5 rounded-md bg-gradient-to-r from-purple-600 to-pink-600 text-white text-[10px] font-bold shadow-sm shrink-0\">\n                                    <Gem className=\"w-2.5 h-2.5 fill-current\" />\n                                    ULTRA\n                                </span>\n                            );\n                        } else if (tier.includes('pro')) {\n                            return (\n                                <span className=\"flex items-center gap-1 px-2 py-0.5 rounded-md bg-gradient-to-r from-blue-600 to-indigo-600 text-white text-[10px] font-bold shadow-sm shrink-0\">\n                                    <Diamond className=\"w-2.5 h-2.5 fill-current\" />\n                                    PRO\n                                </span>\n                            );\n                        } else {\n                            return (\n                                <span className=\"flex items-center gap-1 px-2 py-0.5 rounded-md bg-gray-100 dark:bg-white/10 text-gray-500 dark:text-gray-400 text-[10px] font-bold shadow-sm border border-gray-200 dark:border-white/10 shrink-0\">\n                                    <Circle className=\"w-2.5 h-2.5\" />\n                                    FREE\n                                </span>\n                            );\n                        }\n                    })()}\n                    {/* 自定义标签 */}\n                    {account.custom_label && (\n                        <span className=\"flex items-center gap-1 px-2 py-0.5 rounded-md bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 text-[10px] font-bold shadow-sm shrink-0\">\n                            <Tag className=\"w-2.5 h-2.5\" />\n                            {account.custom_label}\n                        </span>\n                    )}\n                </div>\n\n                {/* Gemini Pro 配额 */}\n                {geminiProModel && (\n                    <div className=\"space-y-1.5\">\n                        <div className=\"flex justify-between items-baseline\">\n                            <span className=\"text-xs font-medium text-gray-600 dark:text-gray-400 flex items-center gap-1\">\n                                {(account.protected_models?.includes('gemini-3-pro-high') || account.protected_models?.includes('gemini-3.1-pro-high')) && <Lock className=\"w-2.5 h-2.5 text-rose-500\" />}\n                                Gemini 3.1 Pro\n                            </span>\n                            <div className=\"flex items-center gap-2\">\n                                <span className=\"text-[10px] text-gray-400 dark:text-gray-500\" title={`${t('accounts.reset_time')}: ${new Date(geminiProModel.reset_time).toLocaleString()}`}>\n                                    {geminiProModel.reset_time ? `R: ${formatTimeRemaining(geminiProModel.reset_time)}` : t('common.unknown')}\n                                </span>\n                                <span className={`text-xs font-bold ${geminiProModel.percentage >= 50 ? 'text-emerald-600 dark:text-emerald-400' :\n                                    geminiProModel.percentage >= 20 ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400'\n                                    }`}>\n                                    {geminiProModel.percentage}%\n                                </span>\n                            </div>\n                        </div>\n                        <div className=\"w-full bg-gray-100 dark:bg-base-300 rounded-full h-1.5 overflow-hidden\">\n                            <div\n                                className={`h-full rounded-full transition-all duration-700 ${geminiProModel.percentage >= 50 ? 'bg-gradient-to-r from-emerald-400 to-emerald-500' :\n                                    geminiProModel.percentage >= 20 ? 'bg-gradient-to-r from-amber-400 to-amber-500' :\n                                        'bg-gradient-to-r from-rose-400 to-rose-500'\n                                    }`}\n                                style={{ width: `${geminiProModel.percentage}%` }}\n                            ></div>\n                        </div>\n                    </div>\n                )}\n                {/* Gemini 3 Pro Image 配额 */}\n                {geminiImageModel && (\n                    <div className=\"space-y-1.5\">\n                        <div className=\"flex justify-between items-baseline\">\n                            <span className=\"text-xs font-medium text-gray-600 dark:text-gray-400 flex items-center gap-1\">\n                                {account.protected_models?.includes('gemini-3-pro-image') && <Lock className=\"w-2.5 h-2.5 text-rose-500\" />}\n                                Gemini 3 Pro Image\n                            </span>\n                            <div className=\"flex items-center gap-2\">\n                                <span className=\"text-[10px] text-gray-400 dark:text-gray-500\" title={`${t('accounts.reset_time')}: ${new Date(geminiImageModel.reset_time).toLocaleString()}`}>\n                                    {geminiImageModel.reset_time ? `R: ${formatTimeRemaining(geminiImageModel.reset_time)}` : t('common.unknown')}\n                                </span>\n                                <span className={`text-xs font-bold ${geminiImageModel.percentage >= 50 ? 'text-emerald-600 dark:text-emerald-400' :\n                                    geminiImageModel.percentage >= 20 ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400'\n                                    }`}>\n                                    {geminiImageModel.percentage}%\n                                </span>\n                            </div>\n                        </div>\n                        <div className=\"w-full bg-gray-100 dark:bg-base-300 rounded-full h-1.5 overflow-hidden\">\n                            <div\n                                className={`h-full rounded-full transition-all duration-700 ${geminiImageModel.percentage >= 50 ? 'bg-gradient-to-r from-emerald-400 to-emerald-500' :\n                                    geminiImageModel.percentage >= 20 ? 'bg-gradient-to-r from-amber-400 to-amber-500' :\n                                        'bg-gradient-to-r from-rose-400 to-rose-500'\n                                    }`}\n                                style={{ width: `${geminiImageModel.percentage}%` }}\n                            ></div>\n                        </div>\n                    </div>\n                )}\n\n                {/* Gemini Flash 配额 */}\n                {geminiFlashModel && (\n                    <div className=\"space-y-1.5\">\n                        <div className=\"flex justify-between items-baseline\">\n                            <span className=\"text-xs font-medium text-gray-600 dark:text-gray-400 flex items-center gap-1\">\n                                {account.protected_models?.includes('gemini-3-flash') && <Lock className=\"w-2.5 h-2.5 text-rose-500\" />}\n                                Gemini 3 Flash\n                            </span>\n                            <div className=\"flex items-center gap-2\">\n                                <span className=\"text-[10px] text-gray-400 dark:text-gray-500\" title={`${t('accounts.reset_time')}: ${new Date(geminiFlashModel.reset_time).toLocaleString()}`}>\n                                    {geminiFlashModel.reset_time ? `R: ${formatTimeRemaining(geminiFlashModel.reset_time)}` : t('common.unknown')}\n                                </span>\n                                <span className={`text-xs font-bold ${geminiFlashModel.percentage >= 50 ? 'text-emerald-600 dark:text-emerald-400' :\n                                    geminiFlashModel.percentage >= 20 ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400'\n                                    }`}>\n                                    {geminiFlashModel.percentage}%\n                                </span>\n                            </div>\n                        </div>\n                        <div className=\"w-full bg-gray-100 dark:bg-base-300 rounded-full h-1.5 overflow-hidden\">\n                            <div\n                                className={`h-full rounded-full transition-all duration-700 ${geminiFlashModel.percentage >= 50 ? 'bg-gradient-to-r from-emerald-400 to-emerald-500' :\n                                    geminiFlashModel.percentage >= 20 ? 'bg-gradient-to-r from-amber-400 to-amber-500' :\n                                        'bg-gradient-to-r from-rose-400 to-rose-500'\n                                    }`}\n                                style={{ width: `${geminiFlashModel.percentage}%` }}\n                            ></div>\n                        </div>\n                    </div>\n                )}\n\n                {/* Claude 配额 */}\n                {claudeModel && (\n                    <div className=\"space-y-1.5\">\n                        <div className=\"flex justify-between items-baseline\">\n                            <span className=\"text-xs font-medium text-gray-600 dark:text-gray-400 flex items-center gap-1\">\n                                {account.protected_models?.includes('claude') && <Lock className=\"w-2.5 h-2.5 text-rose-500\" />}\n                                Claude 系列\n                            </span>\n                            <div className=\"flex items-center gap-2\">\n                                <span className=\"text-[10px] text-gray-400 dark:text-gray-500\" title={`${t('accounts.reset_time')}: ${new Date(claudeModel.reset_time).toLocaleString()}`}>\n                                    {claudeModel.reset_time ? `R: ${formatTimeRemaining(claudeModel.reset_time)}` : t('common.unknown')}\n                                </span>\n                                <span className={`text-xs font-bold ${claudeModel.percentage >= 50 ? 'text-cyan-600 dark:text-cyan-400' :\n                                    claudeModel.percentage >= 20 ? 'text-orange-600 dark:text-orange-400' : 'text-rose-600 dark:text-rose-400'\n                                    }`}>\n                                    {claudeModel.percentage}%\n                                </span>\n                            </div>\n                        </div>\n                        <div className=\"w-full bg-gray-100 dark:bg-base-300 rounded-full h-1.5 overflow-hidden\">\n                            <div\n                                className={`h-full rounded-full transition-all duration-700 ${claudeModel.percentage >= 50 ? 'bg-gradient-to-r from-cyan-400 to-cyan-500' :\n                                    claudeModel.percentage >= 20 ? 'bg-gradient-to-r from-orange-400 to-orange-500' :\n                                        'bg-gradient-to-r from-rose-400 to-rose-500'\n                                    }`}\n                                style={{ width: `${claudeModel.percentage}%` }}\n                            ></div>\n                        </div>\n                    </div>\n                )}\n            </div>\n\n            {onSwitch && (\n                <div className=\"mt-auto pt-3\">\n                    <button\n                        className=\"w-full px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-base-300 rounded-lg hover:bg-gray-50 dark:hover:bg-base-200 transition-colors\"\n                        onClick={onSwitch}\n                    >\n                        {t('dashboard.switch_account')}\n                    </button>\n                </div>\n            )}\n        </div>\n    );\n}\n\nexport default CurrentAccount;\n"
  },
  {
    "path": "src/components/dashboard/StatsCard.tsx",
    "content": "import { LucideIcon } from 'lucide-react';\n\ninterface StatsCardProps {\n    icon: LucideIcon;\n    title: string;\n    value: string | number;\n    description?: string;\n    colorClass?: string;\n}\n\nfunction StatsCard({ icon: Icon, title, value, description, colorClass = 'primary' }: StatsCardProps) {\n    return (\n        <div className=\"stat bg-base-100 shadow rounded-lg\">\n            <div className={`stat-figure text-${colorClass}`}>\n                <Icon className=\"w-8 h-8\" />\n            </div>\n            <div className=\"stat-title\">{title}</div>\n            <div className={`stat-value text-${colorClass}`}>{value}</div>\n            {description && <div className=\"stat-desc\">{description}</div>}\n        </div>\n    );\n}\n\nexport default StatsCard;\n"
  },
  {
    "path": "src/components/debug/DebugConsole.tsx",
    "content": "import React, { useEffect, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { motion, AnimatePresence } from 'framer-motion';\nimport { Terminal, X, Trash2, Search, ArrowDownToLine, Pause, Play, Bug, Info, AlertTriangle, AlertOctagon } from 'lucide-react';\nimport { useDebugConsole, LogEntry, LogLevel } from '../../stores/useDebugConsole';\nimport { cn } from '../../utils/cn';\n\nconst LEVEL_CONFIG: Record<LogLevel, { color: string, icon: React.ReactNode, label: string }> = {\n    'ERROR': { color: 'text-red-500', icon: <AlertOctagon size={12} />, label: 'Error' },\n    'WARN': { color: 'text-amber-500', icon: <AlertTriangle size={12} />, label: 'Warn' },\n    'INFO': { color: 'text-blue-500', icon: <Info size={12} />, label: 'Info' },\n    'DEBUG': { color: 'text-zinc-400', icon: <Bug size={12} />, label: 'Debug' },\n    'TRACE': { color: 'text-zinc-600', icon: <Terminal size={12} />, label: 'Trace' },\n};\n\nconst LogRow = React.memo(({ log }: { log: LogEntry }) => {\n    const [expanded, setExpanded] = useState(false);\n    const date = new Date(log.timestamp);\n    const timeStr = date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) + '.' + date.getMilliseconds().toString().padStart(3, '0');\n\n    const hasFields = Object.keys(log.fields).length > 0;\n\n    return (\n        <div className=\"border-b border-zinc-100 dark:border-white/5 hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors\">\n            <div\n                className={cn(\"flex gap-2 px-2 py-1 items-start cursor-default text-[11px]\", hasFields && \"cursor-pointer\")}\n                onClick={() => hasFields && setExpanded(!expanded)}\n            >\n                <span className=\"text-zinc-400 dark:text-zinc-500 shrink-0 select-none min-w-[85px]\">{timeStr}</span>\n                <span className={cn(\"shrink-0 min-w-[50px] font-bold uppercase flex items-center gap-1\", LEVEL_CONFIG[log.level as LogLevel].color)}>\n                    {LEVEL_CONFIG[log.level as LogLevel].icon}\n                    {log.level}\n                </span>\n                <span className=\"text-zinc-500 dark:text-zinc-400 shrink-0 min-w-[120px] max-w-[120px] truncate font-medium\" title={log.target}>\n                    {log.target.split('::').slice(-2).join('::')}\n                </span>\n                <span className={cn(\"flex-1 break-words whitespace-pre-wrap font-medium\",\n                    // Adapt text color based on level slightly, or keep standard\n                    \"text-zinc-700 dark:text-zinc-300\"\n                )}>\n                    {log.message}\n                </span>\n            </div>\n\n            {expanded && hasFields && (\n                <div className=\"px-4 py-2 bg-zinc-50 dark:bg-black/20 text-zinc-600 dark:text-zinc-400 border-t border-zinc-100 dark:border-white/5 text-[11px]\">\n                    <div className=\"grid grid-cols-[auto_1fr] gap-x-4 gap-y-1\">\n                        {Object.entries(log.fields).map(([key, value]) => (\n                            <React.Fragment key={key}>\n                                <span className=\"text-zinc-400 dark:text-zinc-500 text-right\">{key}:</span>\n                                <span className=\"text-zinc-800 dark:text-zinc-300 break-all select-text font-medium\">{value}</span>\n                            </React.Fragment>\n                        ))}\n                    </div>\n                </div>\n            )}\n        </div>\n    );\n});\n\ninterface DebugConsoleProps {\n    embedded?: boolean;\n}\n\nconst DebugConsole: React.FC<DebugConsoleProps> = ({ embedded = false }) => {\n    const { t } = useTranslation();\n    const {\n        isOpen, close, logs, clearLogs,\n        filter, setFilter,\n        searchTerm, setSearchTerm,\n        autoScroll, setAutoScroll,\n        checkEnabled\n    } = useDebugConsole();\n\n    const scrollRef = useRef<HTMLDivElement>(null);\n    const [height, setHeight] = useState(320);\n\n    // Initial check\n    useEffect(() => {\n        checkEnabled();\n    }, []);\n\n    // Auto scroll\n    useEffect(() => {\n        if (autoScroll && scrollRef.current) {\n            scrollRef.current.scrollTop = scrollRef.current.scrollHeight;\n        }\n    }, [logs, autoScroll, isOpen]);\n\n    // Handle resize\n    const startResizing = (e: React.MouseEvent) => {\n        e.preventDefault();\n        document.addEventListener('mousemove', handleMouseMove);\n        document.addEventListener('mouseup', stopResizing);\n    };\n\n    const handleMouseMove = (e: MouseEvent) => {\n        const newHeight = window.innerHeight - e.clientY;\n        if (newHeight > 100 && newHeight < window.innerHeight - 100) {\n            setHeight(newHeight);\n        }\n    };\n\n    const stopResizing = () => {\n        document.removeEventListener('mousemove', handleMouseMove);\n        document.removeEventListener('mouseup', stopResizing);\n    };\n\n    const toggleLevel = (level: LogLevel) => {\n        if (filter.includes(level)) {\n            setFilter(filter.filter(l => l !== level));\n        } else {\n            setFilter([...filter, level]);\n        }\n    };\n\n    const scrollToBottom = () => {\n        if (scrollRef.current) {\n            scrollRef.current.scrollTop = scrollRef.current.scrollHeight;\n            setAutoScroll(true);\n        }\n    };\n\n    const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {\n        const element = e.currentTarget;\n        const isAtBottom = Math.abs(element.scrollHeight - element.scrollTop - element.clientHeight) < 20;\n        if (!isAtBottom && autoScroll) {\n            setAutoScroll(false);\n        } else if (isAtBottom && !autoScroll) {\n            setAutoScroll(true);\n        }\n    };\n\n    const filteredLogs = logs.filter(log => {\n        if (!filter.includes(log.level as LogLevel)) return false;\n        if (searchTerm && !log.message.toLowerCase().includes(searchTerm.toLowerCase()) &&\n            !log.target.toLowerCase().includes(searchTerm.toLowerCase())) return false;\n        return true;\n    });\n\n    const content = (\n        <div\n            className={cn(\n                \"flex flex-col font-sans transition-colors duration-200\",\n                \"bg-white dark:bg-[#1e1e1e]\",\n                \"text-zinc-700 dark:text-zinc-300\",\n                embedded\n                    ? \"h-full w-full rounded-xl border border-zinc-200 dark:border-white/10 shadow-sm overflow-hidden\"\n                    : \"fixed bottom-0 left-0 right-0 border-t border-zinc-200 dark:border-zinc-800 shadow-2xl z-[9999]\"\n            )}\n            style={embedded ? undefined : { height }}\n        >\n            {/* Resize Handle (only for non-embedded) */}\n            {!embedded && (\n                <div\n                    className=\"h-1 bg-zinc-200 dark:bg-zinc-800 hover:bg-blue-500 cursor-ns-resize transition-colors w-full\"\n                    onMouseDown={startResizing}\n                />\n            )}\n\n            {/* Toolbar */}\n            <div className={cn(\n                \"flex items-center justify-between px-3 py-2 select-none border-b\",\n                \"bg-zinc-50 dark:bg-[#252526]\",\n                \"border-zinc-200 dark:border-black/20\",\n                embedded && \"rounded-t-xl\"\n            )}>\n                <div className=\"flex items-center gap-3\">\n                    <span className=\"flex items-center gap-2 font-medium text-xs tracking-wide text-zinc-500 dark:text-zinc-400\">\n                        <Terminal size={14} className=\"opacity-70\" />\n                        CONSOLE\n                    </span>\n                    <div className=\"h-4 w-px bg-zinc-200 dark:bg-white/10 mx-1\" />\n\n                    {/* Filter Toggles */}\n                    <div className=\"flex rounded-md p-0.5 border bg-white dark:bg-black/20 border-zinc-200 dark:border-white/5\">\n                        {(Object.keys(LEVEL_CONFIG) as LogLevel[]).map(level => (\n                            <button\n                                key={level}\n                                onClick={() => toggleLevel(level)}\n                                className={cn(\n                                    \"px-2.5 py-0.5 text-[10px] uppercase font-bold rounded-[3px] transition-all\",\n                                    filter.includes(level)\n                                        ? LEVEL_CONFIG[level].color + \" bg-zinc-100 dark:bg-white/10 shadow-sm\"\n                                        : \"text-zinc-400 dark:text-zinc-600 hover:text-zinc-600 dark:hover:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-white/5\"\n                                )}\n                            >\n                                {level}\n                            </button>\n                        ))}\n                    </div>\n\n                    {/* Search */}\n                    <div className=\"relative group ml-2\">\n                        <Search size={13} className=\"absolute left-2.5 top-1.5 text-zinc-400 dark:text-zinc-500 group-focus-within:text-zinc-600 dark:group-focus-within:text-zinc-300 transition-colors\" />\n                        <input\n                            type=\"text\"\n                            value={searchTerm}\n                            onChange={e => setSearchTerm(e.target.value)}\n                            placeholder=\"Filter logs...\"\n                            className={cn(\n                                \"border border-transparent rounded-md pl-8 pr-3 py-1 text-xs w-40 focus:w-64 transition-all focus:outline-none placeholder:text-zinc-400\",\n                                \"bg-zinc-100 dark:bg-black/20\",\n                                \"text-zinc-800 dark:text-zinc-300\",\n                                \"focus:bg-white dark:focus:bg-black/40\",\n                                \"focus:border-zinc-200 dark:focus:border-white/10\"\n                            )}\n                        />\n                    </div>\n                </div>\n\n                <div className=\"flex items-center gap-1.5\">\n                    <button\n                        onClick={() => setAutoScroll(!autoScroll)}\n                        className={cn(\n                            \"p-1.5 rounded-md transition-all\",\n                            autoScroll\n                                ? \"text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-500/10 hover:bg-green-200 dark:hover:bg-green-500/20\"\n                                : \"text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/5\"\n                        )}\n                        title={autoScroll ? t('debug_console.pause_scroll', { defaultValue: 'Pause scroll' }) : t('debug_console.resume_scroll', { defaultValue: 'Resume scroll' })}\n                    >\n                        {autoScroll ? <Pause size={14} /> : <Play size={14} />}\n                    </button>\n\n                    <button\n                        onClick={() => {\n                            console.log('Clear button clicked');\n                            clearLogs();\n                        }}\n                        className=\"p-1.5 rounded-md text-zinc-400 dark:text-zinc-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-zinc-100 dark:hover:bg-white/5 transition-all\"\n                        title={t('debug_console.clear', { defaultValue: 'Clear' })}\n                    >\n                        <Trash2 size={14} />\n                    </button>\n\n                    {!embedded && (\n                        <button\n                            onClick={close}\n                            className=\"p-1.5 rounded-md text-zinc-400 dark:text-zinc-500 hover:text-zinc-800 dark:hover:text-white hover:bg-zinc-100 dark:hover:bg-white/5 transition-all ml-1\"\n                        >\n                            <X size={14} />\n                        </button>\n                    )}\n                </div>\n            </div>\n\n            {/* Log content */}\n            <div\n                ref={scrollRef}\n                onScroll={handleScroll}\n                className={cn(\n                    \"flex-1 overflow-y-auto overflow-x-hidden font-mono text-xs\",\n                    \"bg-white dark:bg-[#1e1e1e]\",\n                    // Custom scrollbar styling - Light/Dark\n                    \"[&::-webkit-scrollbar]:w-2\",\n                    \"[&::-webkit-scrollbar-track]:bg-transparent\",\n                    \"[&::-webkit-scrollbar-thumb]:bg-zinc-300 dark:[&::-webkit-scrollbar-thumb]:bg-[#424242]\",\n                    \"[&::-webkit-scrollbar-thumb]:rounded-full\",\n                    \"[&::-webkit-scrollbar-thumb]:border-2\",\n                    \"[&::-webkit-scrollbar-thumb]:border-white dark:[&::-webkit-scrollbar-thumb]:border-[#1e1e1e]\",\n                    \"hover:[&::-webkit-scrollbar-thumb]:bg-zinc-400 dark:hover:[&::-webkit-scrollbar-thumb]:bg-[#4f4f4f]\",\n                    embedded && \"rounded-b-none\"\n                )}\n            >\n                {filteredLogs.length === 0 ? (\n                    <div className=\"flex flex-col items-center justify-center h-full select-none text-zinc-400 dark:text-zinc-600\">\n                        <Terminal size={48} className=\"mb-4 opacity-20\" />\n                        <p className=\"text-sm font-medium opacity-50\">{t('debug_console.no_logs', { defaultValue: 'No logs to display' })}</p>\n                        <p className=\"text-xs mt-1 opacity-30\">{t('debug_console.no_logs_hint', { defaultValue: 'Logs will appear here in real-time' })}</p>\n                    </div>\n                ) : (\n                    <div className=\"py-1\">\n                        {filteredLogs.map(log => <LogRow key={log.id} log={log} />)}\n                    </div>\n                )}\n            </div>\n\n            {/* Footer */}\n            <div className={cn(\n                \"flex items-center justify-between px-3 py-1.5 border-t text-white text-[10px]\",\n                \"bg-[#007acc] border-[#007acc]\", // Keep VS Code blue for brand recognition/consistency, or adapt? Let's keep blue for now as it looks good in both.\n                embedded && \"rounded-b-lg\"\n            )}>\n                <div className=\"flex items-center gap-4\">\n                    {/* Level stats */}\n                    {(Object.keys(LEVEL_CONFIG) as LogLevel[]).map(level => {\n                        const count = logs.filter(l => l.level === level).length;\n                        if (count === 0) return null;\n                        const icon = LEVEL_CONFIG[level].icon; // FIX: Don't clone, just render new one or use generic\n                        // Since I cannot easily clone with correct types without casting, and the icon is simple\n                        // I will just use a mapping or switch, OR just render the icon node as is if it fits,\n                        // BUT I want to force size and color.\n                        // Simplest fix: Just allow the icon to be rendered, and assume it inherits size? No, Lucide icons have explicit size.\n                        // Let's just redefine a small helper to get the icon component type if possible, or just ignore the size override since 12 is small enough.\n                        // Actually, the LEVEL_CONFIG defines size=12. So I can just render it.\n                        return (\n                            <span\n                                key={level}\n                                className=\"font-medium flex items-center gap-1.5 select-none opacity-90\"\n                            >\n                                {icon}\n                                {count}\n                            </span>\n                        );\n                    })}\n                </div>\n\n                {/* Auto-scroll indicator & Status */}\n                <div className=\"flex items-center gap-3\">\n                    {!autoScroll && (\n                        <button\n                            onClick={scrollToBottom}\n                            className=\"flex items-center gap-1.5 px-2 py-0.5 rounded bg-black/20 hover:bg-black/30 font-medium transition-colors\"\n                        >\n                            <ArrowDownToLine size={10} />\n                            {t('debug_console.scroll_to_bottom', { defaultValue: 'Scroll' })}\n                        </button>\n                    )}\n                    <span className=\"opacity-80 flex items-center gap-1\">\n                        <div className=\"w-1.5 h-1.5 rounded-full bg-white animate-pulse\"></div>\n                        Live\n                    </span>\n                </div>\n            </div>\n        </div>\n    );\n\n    if (embedded) {\n        return content;\n    }\n\n    return (\n        <AnimatePresence>\n            {isOpen && (\n                <>\n                    {/* Backdrop */}\n                    <motion.div\n                        initial={{ opacity: 0 }}\n                        animate={{ opacity: 1 }}\n                        exit={{ opacity: 0 }}\n                        className=\"fixed inset-0 bg-black/10 z-[9998]\"\n                        onClick={close}\n                    />\n\n                    {/* Animated Panel */}\n                    <motion.div\n                        initial={{ y: \"100%\" }}\n                        animate={{ y: 0 }}\n                        exit={{ y: \"100%\" }}\n                        transition={{ type: \"spring\", stiffness: 300, damping: 30 }}\n                        className=\"fixed inset-x-0 bottom-0 z-[9999]\"\n                    >\n                        {content}\n                    </motion.div>\n                </>\n            )}\n        </AnimatePresence>\n    );\n};\n\nexport default DebugConsole;\n"
  },
  {
    "path": "src/components/debug/DebugConsoleButton.tsx",
    "content": "import React from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Terminal } from 'lucide-react';\nimport { useDebugConsole } from '../../stores/useDebugConsole';\nimport { cn } from '../../utils/cn';\n\nconst DebugConsoleButton: React.FC = () => {\n    const { t } = useTranslation();\n    const { isEnabled, isOpen, toggle, enable, logs } = useDebugConsole();\n\n    const handleClick = () => {\n        if (!isEnabled) {\n            enable();\n        }\n        toggle();\n    };\n\n    const errorCount = logs.filter(l => l.level === 'ERROR').length;\n    const warnCount = logs.filter(l => l.level === 'WARN').length;\n\n    return (\n        <button\n            onClick={handleClick}\n            className={cn(\n                \"relative flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-medium transition-all\",\n                isOpen\n                    ? \"bg-green-500/20 text-green-400 border border-green-500/30\"\n                    : \"bg-zinc-800/50 text-zinc-400 hover:text-white hover:bg-zinc-700/50 border border-transparent\"\n            )}\n            title={t('debug_console.toggle', { defaultValue: 'Toggle Debug Console' })}\n        >\n            <Terminal size={14} className={isOpen ? \"text-green-400\" : \"\"} />\n            <span className=\"hidden sm:inline\">Console</span>\n\n            {/* Error badge */}\n            {errorCount > 0 && (\n                <span className=\"absolute -top-1 -right-1 min-w-4 h-4 flex items-center justify-center px-1 rounded-full bg-red-500 text-white text-[9px] font-bold\">\n                    {errorCount > 99 ? '99+' : errorCount}\n                </span>\n            )}\n\n            {/* Warn badge (only if no errors) */}\n            {errorCount === 0 && warnCount > 0 && (\n                <span className=\"absolute -top-1 -right-1 min-w-4 h-4 flex items-center justify-center px-1 rounded-full bg-amber-500 text-white text-[9px] font-bold\">\n                    {warnCount > 99 ? '99+' : warnCount}\n                </span>\n            )}\n        </button>\n    );\n};\n\nexport default DebugConsoleButton;\n"
  },
  {
    "path": "src/components/layout/Layout.tsx",
    "content": "import { Outlet } from 'react-router-dom';\nimport { getCurrentWindow } from '@tauri-apps/api/window';\nimport Navbar from '../navbar/Navbar';\nimport BackgroundTaskRunner from '../common/BackgroundTaskRunner';\nimport ToastContainer from '../common/ToastContainer';\nimport { useViewStore } from '../../stores/useViewStore';\nimport MiniView from './MiniView';\nimport { useEffect } from 'react';\nimport { isTauri } from '../../utils/env';\nimport { ensureFullViewState } from '../../utils/windowManager';\n\nfunction Layout() {\n    const { isMiniView } = useViewStore();\n\n    // Ensure correct window state when in Full View (not Mini View)\n    // This handles the case where the app was closed in Mini View (small size, no decorations)\n    // and restarted (defaults to Full View state but keeps last window properties)\n    useEffect(() => {\n        if (!isMiniView && isTauri()) {\n            ensureFullViewState();\n        }\n    }, [isMiniView]);\n\n    if (isMiniView) {\n        return (\n            <>\n                <BackgroundTaskRunner />\n                <ToastContainer />\n                <MiniView />\n            </>\n        );\n    }\n\n    return (\n        <div className=\"h-screen flex flex-col bg-[#FAFBFC] dark:bg-base-300\">\n            {/* 全局窗口拖拽区域 - 使用 JS 手动触发拖拽，解决 HTML 属性失效问题 */}\n            <div\n                className=\"fixed top-0 left-0 right-0 h-9\"\n                style={{\n                    zIndex: 9999,\n                    backgroundColor: 'rgba(0,0,0,0.001)',\n                    cursor: 'default',\n                    userSelect: 'none',\n                    WebkitUserSelect: 'none'\n                }}\n                data-tauri-drag-region\n                onMouseDown={() => {\n                    getCurrentWindow().startDragging();\n                }}\n            />\n            <BackgroundTaskRunner />\n            <ToastContainer />\n            <Navbar />\n            <main className=\"flex-1 overflow-hidden flex flex-col relative\">\n                <Outlet />\n            </main>\n        </div>\n    );\n}\n\nexport default Layout;\n"
  },
  {
    "path": "src/components/layout/MiniView.tsx",
    "content": "import { useEffect, useState, useRef } from 'react';\nimport { Maximize2, RefreshCw, Clock, ShieldAlert, Tag, Activity } from 'lucide-react';\nimport { useViewStore } from '../../stores/useViewStore';\nimport { useAccountStore } from '../../stores/useAccountStore';\nimport { isTauri } from '../../utils/env';\nimport { getCurrentWindow } from '@tauri-apps/api/window';\nimport { motion, AnimatePresence } from 'framer-motion';\nimport { useTranslation } from 'react-i18next';\nimport clsx from 'clsx';\nimport { formatTimeRemaining, formatCompactNumber } from '../../utils/format';\nimport { enterMiniMode, exitMiniMode } from '../../utils/windowManager';\nimport { getVersion } from '@tauri-apps/api/app';\nimport { listen } from '@tauri-apps/api/event';\n\nimport { useConfigStore } from '../../stores/useConfigStore';\n\ninterface ProxyRequestLog {\n    id: string;\n    model?: string;\n    input_tokens?: number;\n    output_tokens?: number;\n    timestamp: number;\n    status: number;\n    duration: number;\n    mapped_model?: string\n}\n\nexport default function MiniView() {\n    const { setMiniView } = useViewStore();\n    const { currentAccount, refreshQuota, fetchCurrentAccount } = useAccountStore();\n    const { config } = useConfigStore();\n    const { t } = useTranslation();\n    const [isRefreshing, setIsRefreshing] = useState(false);\n    const containerRef = useRef<HTMLDivElement>(null);\n    const [appVersion, setAppVersion] = useState('0.0.0');\n    const [latestLog, setLatestLog] = useState<ProxyRequestLog | null>(null);\n\n    // Subscribe to proxy logs\n    useEffect(() => {\n        let unlistenFn: (() => void) | null = null;\n\n        const setupListener = async () => {\n            if (!isTauri()) return;\n            try {\n                unlistenFn = await listen<ProxyRequestLog>('proxy://request', (event) => {\n                    console.log(event)\n                    setLatestLog(event.payload);\n                });\n            } catch (e) {\n                console.error('Failed to setup log listener:', e);\n            }\n        };\n\n        setupListener();\n\n        return () => {\n            if (unlistenFn) unlistenFn();\n        };\n    }, []);\n\n    // Get app version\n    useEffect(() => {\n        const fetchVersion = async () => {\n            if (isTauri()) {\n                try {\n                    const version = await getVersion();\n                    setAppVersion(version);\n                } catch (e) {\n                    console.error('Failed to get app version:', e);\n                }\n            } else {\n                // Fallback for web mode if needed, or import from package.json\n                setAppVersion('4.1.30');\n            }\n        };\n        fetchVersion();\n    }, []);\n\n    // Auto-refresh logic based on config\n    useEffect(() => {\n        if (!config?.auto_refresh || !config?.refresh_interval || config.refresh_interval <= 0) return;\n\n        console.log(`[MiniView] Starting auto-refresh timer: ${config.refresh_interval} mins`);\n\n        const intervalId = setInterval(() => {\n            if (!isRefreshing && currentAccount) {\n                console.log('[MiniView] Auto-refreshing quota...');\n                handleRefresh();\n            }\n        }, config.refresh_interval * 60 * 1000);\n\n        return () => clearInterval(intervalId);\n    }, [config?.auto_refresh, config?.refresh_interval, currentAccount, isRefreshing]);\n\n    // Enter mini mode & Auto-resize based on content\n    useEffect(() => {\n        const adjustSize = async () => {\n            if (isTauri() && containerRef.current) {\n                // Get the content height\n                const height = containerRef.current.scrollHeight;\n                // Calculate content height for the utility (which adds 20px padding)\n                // We want final height to be approx (scroll height - header adjustment)\n                await enterMiniMode(height);\n            }\n        };\n\n        // Run initially and whenever account data (content) changes\n        // Use a small timeout to ensure rendering is complete\n        const timer = setTimeout(adjustSize, 50);\n        return () => clearTimeout(timer);\n    }, [currentAccount]);\n\n    const handleRefresh = async () => {\n        if (!currentAccount || isRefreshing) return;\n        setIsRefreshing(true);\n        try {\n            await refreshQuota(currentAccount.id);\n            await fetchCurrentAccount();\n        } finally {\n            setTimeout(() => setIsRefreshing(false), 800);\n        }\n    };\n\n    const handleMaximize = async () => {\n        await exitMiniMode();\n        setMiniView(false);\n    };\n\n\n    const handleMouseDown = () => {\n        if (isTauri()) {\n            getCurrentWindow().startDragging();\n        }\n    };\n\n\n    // Extract specific models to match AccountRow.tsx\n    const geminiProModel = currentAccount?.quota?.models\n        .filter(m =>\n            m.name.toLowerCase() === 'gemini-3-pro-high'\n            || m.name.toLowerCase() === 'gemini-3-pro-low'\n            || m.name.toLowerCase() === 'gemini-3.1-pro-high'\n            || m.name.toLowerCase() === 'gemini-3.1-pro-low'\n        )\n        .sort((a, b) => (a.percentage || 0) - (b.percentage || 0))[0];\n\n    const geminiFlashModel = currentAccount?.quota?.models.find(m => m.name.toLowerCase() === 'gemini-3-flash');\n\n    const claudeGroupNames = [\n        'claude-opus-4-6-thinking',\n        'claude'\n    ];\n    const claudeModel = currentAccount?.quota?.models\n        .filter(m => claudeGroupNames.includes(m.name.toLowerCase()))\n        .sort((a, b) => (a.percentage || 0) - (b.percentage || 0))[0];\n\n    // Helper to render a model row\n    const renderModelRow = (model: any, displayName: string, colorClass: string) => {\n        if (!model) return null;\n\n        // Determine status color based on percentage\n        const getStatusColor = (p: number) => {\n            if (p >= 50) return 'text-emerald-500';\n            if (p >= 20) return 'text-amber-500';\n            return 'text-rose-500';\n        };\n\n        const getBarColor = (p: number) => {\n            if (p >= 50) return colorClass === 'cyan' ? 'bg-gradient-to-r from-cyan-400 to-cyan-500' : 'bg-gradient-to-r from-emerald-400 to-emerald-500';\n            if (p >= 20) return colorClass === 'cyan' ? 'bg-gradient-to-r from-orange-400 to-orange-500' : 'bg-gradient-to-r from-amber-400 to-amber-500';\n            return 'bg-gradient-to-r from-rose-400 to-rose-500';\n        };\n\n        return (\n            <motion.div\n                layout\n                initial={{ opacity: 0, y: 10 }}\n                animate={{ opacity: 1, y: 0 }}\n                className=\"space-y-1.5\"\n            >\n                <div className=\"flex justify-between items-baseline\">\n                    <span className=\"text-xs font-medium text-gray-600 dark:text-gray-400\">{displayName}</span>\n                    <div className=\"flex items-center gap-2\">\n                        <span className=\"text-[10px] text-blue-600 dark:text-blue-400 font-mono\">\n                            {model.reset_time ? `R: ${formatTimeRemaining(model.reset_time)}` : t('common.unknown')}\n                        </span>\n                        <span className={clsx(\"text-xs font-bold\", getStatusColor(model.percentage))}>\n                            {model.percentage}%\n                        </span>\n                    </div>\n                </div>\n                <div className=\"w-full bg-gray-100 dark:bg-white/10 rounded-full h-1.5 overflow-hidden\">\n                    <motion.div\n                        initial={{ width: 0 }}\n                        animate={{ width: `${model.percentage}%` }}\n                        transition={{ duration: 0.8, ease: \"easeOut\" }}\n                        className={clsx(\"h-full rounded-full shadow-[0_0_8px_currentColor]\", getBarColor(model.percentage))}\n                    />\n                </div>\n            </motion.div>\n        );\n    };\n\n    return (\n        <div className=\"h-screen w-full flex items-center justify-center bg-transparent\">\n            {/* Main Container - 300px fixed width */}\n            <motion.div\n                ref={containerRef}\n                initial={{ opacity: 0, scale: 0.95 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, scale: 0.95 }}\n                className=\"w-[300px] flex flex-col bg-white/80 dark:bg-[#121212]/80 backdrop-blur-md shadow-2xl overflow-hidden border-x border-y border-gray-200/50 dark:border-white/10 sm:rounded-2xl\"\n            >\n                {/* Header / Drag Region */}\n                <div\n                    className=\"flex-none flex items-center justify-between px-4 py-1 bg-gray-50/50 dark:bg-white/5 border-b border-gray-100 dark:border-white/5 select-none\"\n                    onMouseDown={handleMouseDown}\n                    data-tauri-drag-region\n                >\n                    <div className=\"flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white overflow-hidden\">\n                        <div className=\"w-2 h-2 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)] animate-pulse shrink-0\" />\n                        <span className=\"truncate\" title={currentAccount?.email}>\n                            {currentAccount?.email?.split('@')[0] || 'No Account'}\n                        </span>\n                    </div>\n\n                    <div\n                        className=\"flex items-center gap-1 no-drag shrink-0\"\n                        onMouseDown={(e) => e.stopPropagation()}\n                    >\n                        <button\n                            onClick={handleRefresh}\n                            className={clsx(\n                                \"p-2 rounded-lg hover:bg-gray-200/50 dark:hover:bg-white/10 transition-colors\"\n                            )}\n                            title={t('common.refresh', 'Refresh')}\n                        >\n                            <RefreshCw size={14} className={clsx(isRefreshing && \"animate-spin text-blue-500\")} />\n                        </button>\n                        <div className=\"w-px h-3 bg-gray-300 dark:bg-white/20 mx-1\" />\n                        <button\n                            onClick={handleMaximize}\n                            className=\"p-2 rounded-lg hover:bg-gray-200/50 dark:hover:bg-white/10 transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white\"\n                            title={t('common.maximize', 'Full View')}\n                        >\n                            <Maximize2 size={14} />\n                        </button>\n                    </div>\n                </div>\n\n                {/* Content Scroll Area */}\n                <div className=\"flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-5 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-gray-200 dark:scrollbar-thumb-white/10\">\n                    {!currentAccount ? (\n                        <div className=\"h-full flex flex-col items-center justify-center text-center opacity-50 space-y-2\">\n                            <ShieldAlert size={32} />\n                            <p className=\"text-sm\">No account selected</p>\n                        </div>\n                    ) : (\n                        <div className=\"space-y-5\">\n                            {/* Account Info Card - Now simplified */}\n                            <div className=\"flex flex-col gap-2\">\n                                <div className=\"flex flex-wrap gap-2\">\n                                    {/* Custom Label */}\n                                    {currentAccount.custom_label && (\n                                        <span className=\"flex items-center gap-1 px-2 py-0.5 rounded-md bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 text-[10px] font-bold shadow-sm shrink-0\">\n                                            <Tag className=\"w-2.5 h-2.5\" />\n                                            {currentAccount.custom_label}\n                                        </span>\n                                    )}\n                                </div>\n                            </div>\n\n                            {/* Divider only if there was content above it */}\n                            {currentAccount.custom_label && <div className=\"w-full h-px bg-gray-100 dark:bg-white/5\" />}\n\n                            {/* Models List */}\n                            <AnimatePresence mode='popLayout'>\n                                <div className=\"space-y-4 !mt-0\">\n                                    {renderModelRow(geminiProModel, 'Gemini 3.1 Pro', 'emerald')}\n                                    {renderModelRow(geminiFlashModel, 'Gemini 3 Flash', 'emerald')}\n                                    {renderModelRow(claudeModel, t('common.claude_series', 'Claude 系列'), 'cyan')}\n\n                                    {!geminiProModel && !geminiFlashModel && !claudeModel && (\n                                        <div className=\"text-center py-4 text-xs text-gray-400\">\n                                            No quota data available\n                                        </div>\n                                    )}\n                                </div>\n                            </AnimatePresence>\n                        </div>\n                    )}\n                </div>\n\n                {/* Footer Status / Latest Log */}\n                <div className=\"flex-none h-8 bg-gray-50 dark:bg-black/20 flex items-center justify-between px-3 text-[10px] text-gray-500 dark:text-gray-400 border-t border-gray-100 dark:border-white/5 overflow-hidden\">\n                    {latestLog ? (\n                        <motion.div\n                            key={latestLog.id}\n                            initial={{ opacity: 0, y: 5 }}\n                            animate={{ opacity: 1, y: 0 }}\n                            className=\"flex items-center w-full gap-2\"\n                        >\n                            <span title={latestLog.status.toString()} className={`w-1.5 h-1.5 rounded-full ${latestLog.status >= 200 && latestLog.status < 400 ? 'bg-emerald-500' : 'bg-red-500'}`}></span>\n                            <span className=\"font-bold truncate max-w-[100px]\" title={latestLog.model}>\n                                {latestLog.mapped_model || latestLog.model}\n                            </span>\n\n                            <div className=\"flex-1 flex items-center justify-end gap-2\">\n                                <div className=\"flex items-center gap-1.5 text-[9px]\" title=\"Input/Output Tokens\">\n                                    <Activity size={10} className=\"text-blue-500\" />\n                                    <span className=\"flex items-center gap-0.5 text-gray-500 dark:text-gray-400\">\n                                        I:<span className=\"font-mono text-gray-900 dark:text-gray-200\">{formatCompactNumber(latestLog.input_tokens || 0)}</span>\n                                    </span>\n                                    <span className=\"text-gray-300 dark:text-gray-600\">/</span>\n                                    <span className=\"flex items-center gap-0.5 text-gray-500 dark:text-gray-400\">\n                                        O:<span className=\"font-mono text-gray-900 dark:text-gray-200\">{formatCompactNumber(latestLog.output_tokens || 0)}</span>\n                                    </span>\n                                </div>\n\n                                <div className=\"w-px h-2.5 bg-gray-300 dark:bg-white/10\" />\n\n                                <div className=\"flex items-center gap-0.5\" title=\"Duration\">\n                                    <Clock size={10} className=\"text-gray-400\" />\n                                    <span className=\"font-mono\">{(latestLog.duration / 1000).toFixed(2)}s</span>\n                                </div>\n                            </div>\n                        </motion.div>\n                    ) : (\n                        <>\n                            <div className=\"flex items-center gap-1.5\">\n                                <div className=\"w-1.5 h-1.5 rounded-full bg-emerald-500\" />\n                                <span>Connected</span>\n                            </div>\n                            <span className=\"font-mono opacity-50\">v{appVersion}</span>\n                        </>\n                    )}\n                </div>\n            </motion.div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/components/navbar/NavDropdowns.tsx",
    "content": "import { useState, useRef, useEffect } from 'react';\nimport { Link } from 'react-router-dom';\nimport { ChevronDown, MoreVertical, Sun, Moon, LogOut, Minimize2 } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport type { NavItem, Language } from './constants';\nimport { isTauri } from '../../utils/env';\nimport { useViewStore } from '../../stores/useViewStore';\n\n// useClickOutside Hook\nexport function useClickOutside(\n    ref: React.RefObject<HTMLElement | null>,\n    handler: () => void\n) {\n    useEffect(() => {\n        const listener = (event: MouseEvent) => {\n            if (!ref.current || ref.current.contains(event.target as Node)) {\n                return;\n            }\n            handler();\n        };\n\n        document.addEventListener('mousedown', listener);\n        return () => document.removeEventListener('mousedown', listener);\n    }, [ref, handler]);\n}\n\n// 语言下拉菜单组件\ninterface LanguageDropdownProps {\n    currentLanguage: string;\n    languages: Language[];\n    onLanguageChange: (langCode: string) => void;\n    className?: string;\n}\n\nexport function LanguageDropdown({\n    currentLanguage,\n    languages,\n    onLanguageChange,\n    className = ''\n}: LanguageDropdownProps) {\n    const [isOpen, setIsOpen] = useState(false);\n    const menuRef = useRef<HTMLDivElement>(null);\n    const { t } = useTranslation();\n\n    useClickOutside(menuRef, () => setIsOpen(false));\n\n    const handleLanguageChange = (langCode: string) => {\n        onLanguageChange(langCode);\n        setIsOpen(false);\n    };\n\n    return (\n        <div className={`relative ${className}`} ref={menuRef}>\n            <button\n                onClick={() => setIsOpen(!isOpen)}\n                className=\"w-10 h-10 rounded-full bg-gray-100 dark:bg-base-200 hover:bg-gray-200 dark:hover:bg-base-100 flex items-center justify-center transition-colors\"\n                title={t('settings.general.language')}\n            >\n                <span className=\"text-sm font-bold text-gray-700 dark:text-gray-300\">\n                    {languages.find(l => l.code === currentLanguage)?.short || 'EN'}\n                </span>\n            </button>\n\n            {/* 下拉菜单 */}\n            {isOpen && (\n                <div className=\"absolute ltr:right-0 rtl:left-0 mt-2 w-32 bg-white dark:bg-base-200 rounded-xl shadow-lg border border-gray-100 dark:border-base-100 py-1 overflow-hidden animate-in fade-in zoom-in-95 duration-200 ltr:origin-top-right rtl:origin-top-left\">\n                    {languages.map((lang) => (\n                        <button\n                            key={lang.code}\n                            onClick={() => handleLanguageChange(lang.code)}\n                            className={`w-full px-4 py-2 text-left text-sm flex items-center justify-between hover:bg-gray-50 dark:hover:bg-base-100 transition-colors ${currentLanguage === lang.code\n                                ? 'text-blue-500 font-medium bg-blue-50 dark:bg-blue-900/10'\n                                : 'text-gray-700 dark:text-gray-300'\n                                }`}\n                        >\n                            <div className=\"flex items-center gap-3\">\n                                <span className=\"font-mono font-bold w-6\">{lang.short}</span>\n                                <span className=\"text-xs opacity-70\">{lang.label}</span>\n                            </div>\n                            {currentLanguage === lang.code && (\n                                <span className=\"w-1.5 h-1.5 rounded-full bg-blue-500\"></span>\n                            )}\n                        </button>\n                    ))}\n                </div>\n            )}\n        </div>\n    );\n}\n\n// 导航下拉菜单组件 (< 375px)\ninterface NavigationDropdownProps {\n    navItems: NavItem[];\n    isActive: (path: string) => boolean;\n    getCurrentNavItem: () => NavItem | undefined;\n    onNavigate: () => void;\n    showLabel?: boolean; // 是否显示文字标签\n}\n\nexport function NavigationDropdown({\n    navItems,\n    isActive,\n    getCurrentNavItem,\n    onNavigate,\n    showLabel = true // 默认显示文字\n}: NavigationDropdownProps) {\n    const [isOpen, setIsOpen] = useState(false);\n    const menuRef = useRef<HTMLDivElement>(null);\n\n    useClickOutside(menuRef, () => setIsOpen(false));\n\n    const handleNavItemClick = () => {\n        setIsOpen(false);\n        onNavigate();\n    };\n\n    const currentItem = getCurrentNavItem();\n    const CurrentIcon = currentItem?.icon;\n\n    // 如果没有当前项,不渲染\n    if (!currentItem || !CurrentIcon) return null;\n\n    return (\n        <div className=\"relative\" ref={menuRef}>\n            <button\n                onClick={() => setIsOpen(!isOpen)}\n                className=\"flex items-center gap-2 px-3 py-2 rounded-full bg-gray-100 dark:bg-base-200 hover:bg-gray-200 dark:hover:bg-base-100 transition-colors\"\n            >\n                <CurrentIcon className=\"w-4 h-4 text-gray-700 dark:text-gray-300\" />\n                {/* 根据 showLabel 控制文字显示 */}\n                {showLabel && (\n                    <span className=\"text-sm font-medium text-gray-700 dark:text-gray-300\">\n                        {currentItem.label}\n                    </span>\n                )}\n                <ChevronDown className={`w-3 h-3 text-gray-700 dark:text-gray-300 transition-transform ${isOpen ? 'rotate-180' : ''}`} />\n            </button>\n\n            {/* 下拉菜单 */}\n            {isOpen && (\n                <div className=\"absolute left-1/2 -translate-x-1/2 mt-2 w-48 bg-white dark:bg-[#1a1a1a] rounded-xl shadow-xl border-2 border-gray-200 dark:border-gray-700 py-1 overflow-hidden animate-in fade-in zoom-in-95 duration-200 origin-top\">\n                    {navItems.map((item) => (\n                        <Link\n                            key={item.path}\n                            to={item.path}\n                            draggable=\"false\"\n                            onClick={handleNavItemClick}\n                            className={`w-full px-4 py-2.5 text-left text-sm flex items-center gap-3 hover:bg-gray-50 dark:hover:bg-base-100 transition-colors ${isActive(item.path)\n                                ? 'text-blue-500 font-medium bg-blue-50 dark:bg-blue-900/10'\n                                : 'text-gray-700 dark:text-gray-300'\n                                }`}\n                        >\n                            <item.icon className=\"w-4 h-4\" />\n                            <span>{item.label}</span>\n                        </Link>\n                    ))}\n                </div>\n            )}\n        </div>\n    );\n}\n\n// 更多菜单组件 (< 480px)\ninterface MoreDropdownProps {\n    theme: 'light' | 'dark';\n    currentLanguage: string;\n    languages: Language[];\n    onThemeToggle: (event: React.MouseEvent<HTMLButtonElement>) => void;\n    onLanguageChange: (langCode: string) => void;\n}\n\nexport function MoreDropdown({\n    theme,\n    currentLanguage,\n    languages,\n    onThemeToggle,\n    onLanguageChange\n}: MoreDropdownProps) {\n    const [isOpen, setIsOpen] = useState(false);\n    const menuRef = useRef<HTMLDivElement>(null);\n    const { t } = useTranslation();\n    const { setMiniView } = useViewStore();\n\n    useClickOutside(menuRef, () => setIsOpen(false));\n\n    const handleThemeToggle = (event: React.MouseEvent<HTMLButtonElement>) => {\n        onThemeToggle(event);\n        setIsOpen(false);\n    };\n\n    const handleLanguageChange = (langCode: string) => {\n        onLanguageChange(langCode);\n        setIsOpen(false);\n    };\n\n    const handleLogout = () => {\n        sessionStorage.removeItem('abv_admin_api_key');\n        localStorage.removeItem('abv_admin_api_key');\n        window.location.reload();\n    };\n\n    return (\n        <div className=\"relative\" ref={menuRef}>\n            <button\n                onClick={() => setIsOpen(!isOpen)}\n                className=\"w-10 h-10 rounded-full bg-gray-100 dark:bg-base-200 hover:bg-gray-200 dark:hover:bg-base-100 flex items-center justify-center transition-colors\"\n                title={t('nav.more', '更多')}\n            >\n                <MoreVertical className=\"w-5 h-5 text-gray-700 dark:text-gray-300\" />\n            </button>\n\n            {/* 下拉菜单 */}\n            {isOpen && (\n                <div className=\"absolute ltr:right-0 rtl:left-0 mt-2 w-40 bg-white dark:bg-base-200 rounded-xl shadow-lg border border-gray-100 dark:border-base-100 py-1 overflow-hidden animate-in fade-in zoom-in-95 duration-200 ltr:origin-top-right rtl:origin-top-left\">\n                    {/* 迷你视图 */}\n                    <button\n                        onClick={() => {\n                            setMiniView(true);\n                            setIsOpen(false);\n                        }}\n                        className=\"w-full px-4 py-2.5 text-left text-sm flex items-center gap-3 hover:bg-gray-50 dark:hover:bg-base-100 transition-colors text-gray-700 dark:text-gray-300\"\n                    >\n                        <Minimize2 className=\"w-4 h-4\" />\n                        <span>{t('nav.mini_view', 'Mini View')}</span>\n                    </button>\n\n                    {/* 主题切换 */}\n                    <button\n                        onClick={handleThemeToggle}\n                        className=\"w-full px-4 py-2.5 text-left text-sm flex items-center gap-3 hover:bg-gray-50 dark:hover:bg-base-100 transition-colors text-gray-700 dark:text-gray-300\"\n                    >\n                        {theme === 'light' ? (\n                            <Moon className=\"w-4 h-4\" />\n                        ) : (\n                            <Sun className=\"w-4 h-4\" />\n                        )}\n                        <span>{theme === 'light' ? t('nav.theme_to_dark') : t('nav.theme_to_light')}</span>\n                    </button>\n\n                    {/* 分隔线 */}\n                    <div className=\"my-1 border-t border-gray-100 dark:border-base-100\"></div>\n\n                    {/* 语言选择 */}\n                    {languages.map((lang) => (\n                        <button\n                            key={lang.code}\n                            onClick={() => handleLanguageChange(lang.code)}\n                            className={`w-full px-4 py-2 text-left text-sm flex items-center justify-between hover:bg-gray-50 dark:hover:bg-base-100 transition-colors ${currentLanguage === lang.code\n                                ? 'text-blue-500 font-medium bg-blue-50 dark:bg-blue-900/10'\n                                : 'text-gray-700 dark:text-gray-300'\n                                }`}\n                        >\n                            <div className=\"flex items-center gap-2\">\n                                <span className=\"font-mono font-bold text-xs\">{lang.short}</span>\n                                <span className=\"text-xs opacity-70\">{lang.label}</span>\n                            </div>\n                            {currentLanguage === lang.code && (\n                                <span className=\"w-1.5 h-1.5 rounded-full bg-blue-500\"></span>\n                            )}\n                        </button>\n                    ))}\n\n                    {/* 登出按钮 - 仅 Web 模式显示 */}\n                    {!isTauri() && (\n                        <>\n                            <div className=\"my-1 border-t border-gray-100 dark:border-base-100\"></div>\n                            <button\n                                onClick={handleLogout}\n                                className=\"w-full px-4 py-2.5 text-left text-sm flex items-center gap-3 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-red-600 dark:text-red-400\"\n                            >\n                                <LogOut className=\"w-4 h-4\" />\n                                <span>{t('nav.logout', '登出')}</span>\n                            </button>\n                        </>\n                    )}\n                </div>\n            )}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/components/navbar/NavLogo.tsx",
    "content": "import { Link } from 'react-router-dom';\nimport { useTranslation } from 'react-i18next';\nimport LogoIcon from '../../../src-tauri/icons/icon.png';\n\nexport function NavLogo() {\n    const { t } = useTranslation();\n\n    return (\n        <Link to=\"/\" draggable=\"false\" className=\"flex w-full min-w-0 items-center gap-2 text-xl font-semibold text-gray-900 dark:text-base-content\">\n            <div className=\"relative flex items-center justify-center\">\n                <img\n                    src={LogoIcon}\n                    alt=\"Logo\"\n                    className=\"w-8 h-8 cursor-pointer active:scale-95 transition-transform relative z-10\"\n                    draggable=\"false\"\n                />\n            </div>\n\n            {/* 父容器宽度 < 200px 隐藏 */}\n            <span className=\"hidden @[200px]/logo:inline text-nowrap\">{t('common.app_name', 'Antigravity Tools')}</span>\n        </Link>\n    );\n}\n"
  },
  {
    "path": "src/components/navbar/NavMenu.tsx",
    "content": "import { Link, useLocation } from 'react-router-dom';\nimport { NavigationDropdown } from './NavDropdowns';\nimport { isActive, getCurrentNavItem, type NavItem } from './constants';\nimport { useConfigStore } from '../../stores/useConfigStore';\n\ninterface NavMenuProps {\n    navItems: NavItem[];\n}\n\n/**\n * 导航菜单组件 - 独立处理响应式\n * \n * 响应式策略:\n * - ≥ 768px (md): 文字胶囊\n * - 640px - 768px: 图标胶囊 (Logo 显示文字)\n * - 480px - 640px: 图标胶囊 (Logo 隐藏文字)\n * - 375px - 480px: 图标+文字下拉\n * - < 375px: 图标下拉\n */\nexport function NavMenu({ navItems }: NavMenuProps) {\n    const location = useLocation();\n    const { isMenuItemHidden } = useConfigStore();\n\n    // 过滤隐藏的菜单项\n    const visibleNavItems = navItems.filter(item => !isMenuItemHidden(item.path));\n\n    return (\n        <>\n            {/* 文字胶囊 (≥ 1120px) */}\n            <nav className=\"max-[1119px]:hidden flex items-center gap-1 bg-gray-100 dark:bg-base-200 rounded-full p-1\">\n                {visibleNavItems.map((item) => (\n                    <Link\n                        key={item.path}\n                        to={item.path}\n                        draggable=\"false\"\n                        className={`\n                            px-4 xl:px-6\n                            py-2 \n                            rounded-full \n                            text-sm \n                            font-medium \n                            transition-all \n                            whitespace-nowrap\n                            ${isActive(location.pathname, item.path)\n                                ? 'bg-gray-900 text-white shadow-sm dark:bg-white dark:text-gray-900'\n                                : 'text-gray-700 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-base-content dark:hover:bg-base-100'\n                            }\n                        `}\n                    >\n                        {item.label}\n                    </Link>\n                ))}\n            </nav>\n\n            {/* 图标胶囊 (880px - 1120px) - Logo 显示文字 */}\n            <nav className=\"max-[879px]:hidden min-[1120px]:hidden flex items-center gap-1 bg-gray-100 dark:bg-base-200 rounded-full p-1\">\n                {visibleNavItems.map((item) => (\n                    <Link\n                        key={item.path}\n                        to={item.path}\n                        draggable=\"false\"\n                        className={`\n                            p-2\n                            rounded-full\n                            transition-all\n                            ${isActive(location.pathname, item.path)\n                                ? 'bg-gray-900 text-white shadow-sm dark:bg-white dark:text-gray-900'\n                                : 'text-gray-700 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-base-content dark:hover:bg-base-100'\n                            }\n                        `}\n                        title={item.label}\n                    >\n                        <item.icon className=\"w-5 h-5\" />\n                    </Link>\n                ))}\n            </nav>\n\n            {/* 图标胶囊 (640px - 880px) - Logo 隐藏文字 */}\n            <nav className=\"max-[639px]:hidden min-[880px]:hidden flex items-center gap-1 bg-gray-100 dark:bg-base-200 rounded-full p-1\">\n                {visibleNavItems.map((item) => (\n                    <Link\n                        key={item.path}\n                        to={item.path}\n                        draggable=\"false\"\n                        className={`\n                            p-2\n                            rounded-full\n                            transition-all\n                            ${isActive(location.pathname, item.path)\n                                ? 'bg-gray-900 text-white shadow-sm dark:bg-white dark:text-gray-900'\n                                : 'text-gray-700 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-base-content dark:hover:bg-base-100'\n                            }\n                        `}\n                        title={item.label}\n                    >\n                        <item.icon className=\"w-5 h-5\" />\n                    </Link>\n                ))}\n            </nav>\n\n            {/* 图标胶囊 (480px - 640px) */}\n            <nav className=\"max-[479px]:hidden min-[640px]:hidden flex items-center gap-1 bg-gray-100 dark:bg-base-200 rounded-full p-1\">\n                {visibleNavItems.map((item) => (\n                    <Link\n                        key={item.path}\n                        to={item.path}\n                        draggable=\"false\"\n                        className={`\n                            p-2\n                            rounded-full\n                            transition-all\n                            ${isActive(location.pathname, item.path)\n                                ? 'bg-gray-900 text-white shadow-sm dark:bg-white dark:text-gray-900'\n                                : 'text-gray-700 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-base-content dark:hover:bg-base-100'\n                            }\n                        `}\n                        title={item.label}\n                    >\n                        <item.icon className=\"w-5 h-5\" />\n                    </Link>\n                ))}\n            </nav>\n\n            {/* 图标+文字下拉 (375px - 480px) */}\n            <div className=\"max-[374px]:hidden min-[480px]:hidden block\">\n                <NavigationDropdown\n                    navItems={visibleNavItems}\n                    isActive={(path) => isActive(location.pathname, path)}\n                    getCurrentNavItem={() => getCurrentNavItem(location.pathname, visibleNavItems)}\n                    onNavigate={() => { }}\n                    showLabel={true}\n                />\n            </div>\n\n            {/* 图标下拉 (< 375px) */}\n            <div className=\"min-[375px]:hidden\">\n                <NavigationDropdown\n                    navItems={visibleNavItems}\n                    isActive={(path) => isActive(location.pathname, path)}\n                    getCurrentNavItem={() => getCurrentNavItem(location.pathname, visibleNavItems)}\n                    onNavigate={() => { }}\n                    showLabel={false}\n                />\n            </div>\n        </>\n    );\n}\n"
  },
  {
    "path": "src/components/navbar/NavSettings.tsx",
    "content": "import { Sun, Moon, LogOut, Minimize2 } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { LanguageDropdown, MoreDropdown } from './NavDropdowns';\nimport { LANGUAGES } from './constants';\nimport { isTauri } from '../../utils/env';\nimport { useViewStore } from '../../stores/useViewStore';\n\ninterface NavSettingsProps {\n    theme: 'light' | 'dark';\n    currentLanguage: string;\n    onThemeToggle: (event: React.MouseEvent<HTMLButtonElement>) => void;\n    onLanguageChange: (langCode: string) => void;\n}\n\n/**\n * 设置按钮组件 - 独立处理响应式\n *\n * 响应式策略:\n * - ≥ 768px (md): 独立按钮(主题 + 语言)\n * - < 768px: 更多下拉菜单\n */\nexport function NavSettings({\n    theme,\n    currentLanguage,\n    onThemeToggle,\n    onLanguageChange\n}: NavSettingsProps) {\n    const { t } = useTranslation();\n    const { setMiniView } = useViewStore();\n\n    const handleLogout = () => {\n        sessionStorage.removeItem('abv_admin_api_key');\n        localStorage.removeItem('abv_admin_api_key');\n        window.location.reload();\n    };\n\n    return (\n        <>\n            {/* 独立按钮 (≥ 480px) */}\n            <div className=\"hidden min-[480px]:flex items-center gap-2\">\n                {/* 迷你视图切换按钮 */}\n                <button\n                    onClick={() => setMiniView(true)}\n                    className=\"w-10 h-10 rounded-full bg-gray-100 dark:bg-base-200 hover:bg-gray-200 dark:hover:bg-base-100 flex items-center justify-center transition-colors\"\n                    title={t('nav.mini_view', 'Mini View')}\n                >\n                    <Minimize2 className=\"w-5 h-5 text-gray-700 dark:text-gray-300\" />\n                </button>\n\n                {/* 主题切换按钮 */}\n                <button\n                    onClick={onThemeToggle}\n                    className=\"w-10 h-10 rounded-full bg-gray-100 dark:bg-base-200 hover:bg-gray-200 dark:hover:bg-base-100 flex items-center justify-center transition-colors\"\n                    title={theme === 'light' ? t('nav.theme_to_dark') : t('nav.theme_to_light')}\n                >\n                    {theme === 'light' ? (\n                        <Moon className=\"w-5 h-5 text-gray-700 dark:text-gray-300\" />\n                    ) : (\n                        <Sun className=\"w-5 h-5 text-gray-700 dark:text-gray-300\" />\n                    )}\n                </button>\n\n                {/* 语言切换下拉菜单 */}\n                <LanguageDropdown\n                    currentLanguage={currentLanguage}\n                    languages={LANGUAGES}\n                    onLanguageChange={onLanguageChange}\n                />\n\n                {/* 登出按钮 - 仅 Web 模式显示 */}\n                {!isTauri() && (\n                    <button\n                        onClick={handleLogout}\n                        className=\"w-10 h-10 rounded-full bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40 flex items-center justify-center transition-colors\"\n                        title={t('nav.logout', '登出')}\n                    >\n                        <LogOut className=\"w-5 h-5 text-red-600 dark:text-red-400\" />\n                    </button>\n                )}\n            </div>\n\n            {/* 更多菜单 (< 480px) */}\n            <div className=\"min-[480px]:hidden\">\n                <MoreDropdown\n                    theme={theme}\n                    currentLanguage={currentLanguage}\n                    languages={LANGUAGES}\n                    onThemeToggle={onThemeToggle}\n                    onLanguageChange={onLanguageChange}\n                />\n            </div>\n        </>\n    );\n}\n"
  },
  {
    "path": "src/components/navbar/Navbar.tsx",
    "content": "import { LayoutDashboard, Users, Network, Activity, BarChart3, Settings, Lock } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { useConfigStore } from '../../stores/useConfigStore';\nimport { isTauri, isLinux } from '../../utils/env';\nimport { NavLogo } from './NavLogo';\nimport { NavMenu } from './NavMenu';\nimport { NavSettings } from './NavSettings';\nimport type { NavItem } from './constants';\n\n/**\n * Navbar 主组件\n * \n * 职责: 只负责布局和状态管理,不处理响应式细节\n * 响应式逻辑由各个子组件独立处理\n */\nfunction Navbar() {\n    const { t } = useTranslation();\n    const { config, saveConfig } = useConfigStore();\n\n    // 创建导航项(包含翻译后的标签)\n    const navItems: NavItem[] = [\n        { path: '/', label: t('nav.dashboard'), icon: LayoutDashboard, priority: 'high' },\n        { path: '/accounts', label: t('nav.accounts'), icon: Users, priority: 'high' },\n        { path: '/api-proxy', label: t('nav.proxy'), icon: Network, priority: 'high' },\n        { path: '/monitor', label: t('nav.call_records'), icon: Activity, priority: 'medium' },\n        { path: '/token-stats', label: t('nav.token_stats', 'Token 统计'), icon: BarChart3, priority: 'low' },\n        { path: '/user-token', label: t('nav.user_token', 'User Tokens'), icon: Users, priority: 'low' },\n        { path: '/security', label: t('nav.security'), icon: Lock, priority: 'low' },\n        { path: '/settings', label: t('nav.settings'), icon: Settings, priority: 'high' },\n    ];\n\n    // 主题切换逻辑(带 View Transition 动画)\n    const toggleTheme = async (event: React.MouseEvent<HTMLButtonElement>) => {\n        if (!config) return;\n\n        const newTheme = config.theme === 'light' ? 'dark' : 'light';\n\n        // Use View Transition API if supported, but skip on Linux (may cause crash)\n        if ('startViewTransition' in document && !isLinux()) {\n            const x = event.clientX;\n            const y = event.clientY;\n            const endRadius = Math.hypot(\n                Math.max(x, window.innerWidth - x),\n                Math.max(y, window.innerHeight - y)\n            );\n\n            // @ts-ignore\n            const transition = document.startViewTransition(async () => {\n                saveConfig({\n                    ...config,\n                    theme: newTheme,\n                    language: config.language\n                }, true);\n            });\n\n            transition.ready.then(() => {\n                const isDarkMode = newTheme === 'dark';\n                const clipPath = isDarkMode\n                    ? [`circle(${endRadius}px at ${x}px ${y}px)`, `circle(0px at ${x}px ${y}px)`]\n                    : [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`];\n\n                document.documentElement.animate(\n                    {\n                        clipPath: clipPath\n                    },\n                    {\n                        duration: 500,\n                        easing: 'ease-in-out',\n                        fill: 'forwards',\n                        pseudoElement: isDarkMode ? '::view-transition-old(root)' : '::view-transition-new(root)'\n                    }\n                );\n            });\n        } else {\n            // Fallback: direct switch (Linux or browsers without View Transition)\n            await saveConfig({\n                ...config,\n                theme: newTheme,\n                language: config.language\n            }, true);\n        }\n    };\n\n    // 语言切换逻辑\n    const handleLanguageChange = async (langCode: string) => {\n        if (!config) return;\n\n        await saveConfig({\n            ...config,\n            language: langCode,\n            theme: config.theme\n        }, true);\n    };\n\n    return (\n        <nav\n            style={{ position: 'sticky', top: 0, zIndex: 50 }}\n            className=\"pt-9 transition-all duration-200 bg-[#FAFBFC] dark:bg-base-300\"\n        >\n            {/* 窗口拖拽区域 - Tauri 专用 */}\n            {isTauri() && (\n                <div\n                    className=\"absolute top-9 left-0 right-0 h-16\"\n                    style={{ zIndex: 5, backgroundColor: 'rgba(0,0,0,0.001)' }}\n                    data-tauri-drag-region\n                />\n            )}\n\n            <div className=\"max-w-7xl mx-auto px-8 relative\" style={{ zIndex: 10 }}>\n                {/* Flexbox 布局 - 子组件自己处理响应式 */}\n                <div className=\"flex items-center h-16 gap-4\">\n                    {/* Logo - 使用父容器宽度做响应式 */}\n                    <div className=\"@container/logo basis-[200px] shrink min-w-0\">\n                        <NavLogo />\n                    </div>\n\n                    {/* 导航菜单 - 自己处理响应式 */}\n                    <div className=\"flex-1 flex justify-center\">\n                        <NavMenu navItems={navItems} />\n                    </div>\n\n                    {/* 设置按钮 - 自己处理响应式 */}\n                    <NavSettings\n                        theme={(config?.theme as 'light' | 'dark') || 'light'}\n                        currentLanguage={config?.language || 'en'}\n                        onThemeToggle={toggleTheme}\n                        onLanguageChange={handleLanguageChange}\n                    />\n                </div>\n            </div>\n        </nav>\n    );\n}\n\nexport default Navbar;\n"
  },
  {
    "path": "src/components/navbar/constants.ts",
    "content": "import type { LucideIcon } from 'lucide-react';\n\n// 类型定义\nexport interface NavItem {\n    path: string;\n    label: string;\n    icon: LucideIcon;\n    priority: 'high' | 'medium' | 'low';\n}\n\nexport interface Language {\n    code: string;\n    label: string;\n    short: string;\n}\n\n// 语言配置\nexport const LANGUAGES: Language[] = [\n    { code: 'zh', label: '简体中文', short: 'ZH' },\n    { code: 'zh-TW', label: '繁體中文', short: 'TW' },\n    { code: 'en', label: 'English', short: 'EN' },\n    { code: 'ja', label: '日本語', short: 'JA' },\n    { code: 'tr', label: 'Türkçe', short: 'TR' },\n    { code: 'vi', label: 'Tiếng Việt', short: 'VI' },\n    { code: 'pt', label: 'Português', short: 'PT' },\n    { code: 'ko', label: '한국어', short: 'KO' },\n    { code: 'ru', label: 'Русский', short: 'RU' },\n    { code: 'ar', label: 'العربية', short: 'AR' },\n    { code: 'es', label: 'Español', short: 'ES' },\n    { code: 'my', label: 'Bahasa Melayu', short: 'MY' },\n];\n\n// 工具函数\nexport const isActive = (pathname: string, itemPath: string): boolean => {\n    if (itemPath === '/') {\n        return pathname === '/';\n    }\n    return pathname.startsWith(itemPath);\n};\n\nexport const getCurrentNavItem = (pathname: string, navItems: NavItem[]): NavItem => {\n    return navItems.find(item => isActive(pathname, item.path)) || navItems[0];\n};\n"
  },
  {
    "path": "src/components/proxy/CliSyncCard.tsx",
    "content": "import { useState, useEffect, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n    Terminal,\n    CheckCircle2,\n    AlertCircle,\n    RefreshCw,\n    Cpu,\n    Globe,\n    CodeXml,\n    Loader2,\n    Eye,\n    RotateCcw,\n    Copy,\n    X,\n    Bot,\n    Trash2\n} from 'lucide-react';\nimport { copyToClipboard } from '../../utils/clipboard';\nimport { request as invoke } from '../../utils/request';\nimport { showToast } from '../common/ToastContainer';\nimport ModalDialog from '../common/ModalDialog';\nimport { cn } from '../../utils/cn';\nimport { DroidSyncModal } from './DroidSyncModal';\nimport { OpenCodeSyncModal } from './OpenCodeSyncModal';\nimport { useProxyModels } from '../../hooks/useProxyModels';\nimport GroupedSelect from '../common/GroupedSelect';\n\ninterface CliSyncCardProps {\n    proxyUrl: string;\n    apiKey: string;\n    className?: string;\n}\n\ntype CliAppType = 'Claude' | 'Codex' | 'Gemini' | 'OpenCode' | 'Droid';\n\ninterface CliStatus {\n    installed: boolean;\n    version: string | null;\n    is_synced: boolean;\n    has_backup: boolean;\n    current_base_url: string | null;\n    files: string[];\n    synced_count?: number;\n}\n\nexport const CliSyncCard = ({ proxyUrl, apiKey, className }: CliSyncCardProps) => {\n    const { t } = useTranslation();\n    const [statuses, setStatuses] = useState<Record<CliAppType, CliStatus | null>>({\n        Claude: null,\n        Codex: null,\n        Gemini: null,\n        OpenCode: null,\n        Droid: null\n    });\n    const [loading, setLoading] = useState<Record<CliAppType, boolean>>({\n        Claude: false,\n        Codex: false,\n        Gemini: false,\n        OpenCode: false,\n        Droid: false\n    });\n    const [syncing, setSyncing] = useState<Record<CliAppType, boolean>>({\n        Claude: false,\n        Codex: false,\n        Gemini: false,\n        OpenCode: false,\n        Droid: false\n    });\n    const [syncAccounts, setSyncAccounts] = useState(false);\n    const [droidSyncModal, setDroidSyncModal] = useState(false);\n    const [selectedModels, setSelectedModels] = useState<Record<CliAppType, string>>({\n        Claude: 'claude-3-5-sonnet-latest',\n        Codex: 'gpt-4o',\n        Gemini: 'gemini-1.5-pro',\n        OpenCode: '',\n        Droid: ''\n    });\n    const [viewingConfig, setViewingConfig] = useState<{\n        app: CliAppType,\n        content: string,\n        fileName: string,\n        allFiles: string[]\n    } | null>(null);\n    const [restoreConfirmApp, setRestoreConfirmApp] = useState<CliAppType | null>(null);\n    const [syncConfirmApp, setSyncConfirmApp] = useState<CliAppType | null>(null);\n    const [openCodeSyncModal, setOpenCodeSyncModal] = useState(false);\n    const [clearConfirmApp, setClearConfirmApp] = useState<CliAppType | null>(null);\n\n    const { models: proxyModels } = useProxyModels();\n\n    const modelOptions = proxyModels.map(m => ({\n        value: m.id,\n        label: m.name,\n        group: m.group || 'General'\n    }));\n\n    // 根据不同的 CLI 应用格式化 Proxy URL\n    const getFormattedProxyUrl = useCallback((app: CliAppType) => {\n        if (!proxyUrl) return '';\n        const base = proxyUrl.trimEnd().replace(/\\/+$/, '');\n        // Codex & OpenCode (OpenAI 协议) 通常需要带 /v1\n        if (app === 'Codex' || app === 'OpenCode') {\n            return base.endsWith('/v1') ? base : `${base}/v1`;\n        }\n        // Claude 和 Gemini 的 SDK 通常会自动处理版本路径或不需要 /v1\n        return base.replace(/\\/v1$/, '');\n    }, [proxyUrl]);\n\n    const checkStatus = useCallback(async (app: CliAppType) => {\n        setLoading(prev => ({ ...prev, [app]: true }));\n        try {\n            const formattedUrl = getFormattedProxyUrl(app);\n            let command: string;\n            let params: Record<string, unknown>;\n            if (app === 'Droid') {\n                command = 'get_droid_sync_status';\n                params = { proxyUrl: formattedUrl };\n            } else if (app === 'OpenCode') {\n                command = 'get_opencode_sync_status';\n                params = { proxyUrl: formattedUrl };\n            } else {\n                command = 'get_cli_sync_status';\n                params = { appType: app, proxyUrl: formattedUrl };\n            }\n\n            const status = await invoke<CliStatus>(command, params);\n            setStatuses(prev => ({ ...prev, [app]: status }));\n        } catch (error) {\n            console.error(`Failed to check ${app} status:`, error);\n        } finally {\n            setLoading(prev => ({ ...prev, [app]: false }));\n        }\n    }, [getFormattedProxyUrl]);\n\n    const handleSync = async (app: CliAppType) => {\n        if (app === 'Droid') {\n            setDroidSyncModal(true);\n            return;\n        }\n        if (app === 'OpenCode') {\n            setOpenCodeSyncModal(true);\n            return;\n        }\n        setSyncConfirmApp(app);\n    };\n\n    const executeSync = async () => {\n        const app = syncConfirmApp;\n        if (!app) return;\n        setSyncConfirmApp(null);\n\n        if (!proxyUrl || !apiKey) {\n            showToast(t('proxy.cli_sync.toast.config_missing', { defaultValue: '请先生成 API Key 并启动服务' }), 'error');\n            return;\n        }\n\n        try {\n            const formattedUrl = getFormattedProxyUrl(app);\n            const command = app === 'OpenCode' ? 'execute_opencode_sync' : 'execute_cli_sync';\n            const params = app === 'OpenCode'\n                ? { proxyUrl: formattedUrl, apiKey: apiKey, syncAccounts: syncAccounts }\n                : { appType: app, proxyUrl: formattedUrl, apiKey: apiKey, model: selectedModels[app] };\n\n            await invoke(command, params);\n            showToast(t(app === 'OpenCode' ? 'proxy.opencode_sync.toast.sync_success' : 'proxy.cli_sync.toast.sync_success', { name: app, defaultValue: `${app} synced successfully` }), 'success');\n            await checkStatus(app);\n        } catch (error: any) {\n            showToast(t(app === 'OpenCode' ? 'proxy.opencode_sync.toast.sync_error' : 'proxy.cli_sync.toast.sync_error', { name: app, error: error.toString(), defaultValue: `Sync failed: ${error.toString()}` }), 'error');\n        } finally {\n            setSyncing(prev => ({ ...prev, [app]: false }));\n        }\n    };\n\n    const handleRestore = (app: CliAppType) => {\n        setRestoreConfirmApp(app);\n    };\n\n    const executeRestore = async () => {\n        if (!restoreConfirmApp) return;\n        const app = restoreConfirmApp;\n        setRestoreConfirmApp(null);\n\n        setSyncing(prev => ({ ...prev, [app]: true }));\n        try {\n            const command = app === 'Droid' ? 'execute_droid_restore' : app === 'OpenCode' ? 'execute_opencode_restore' : 'execute_cli_restore';\n            const params = (app === 'Droid' || app === 'OpenCode') ? {} : { appType: app };\n            await invoke(command, params);\n            showToast(t('common.success'), 'success');\n            await checkStatus(app);\n        } catch (error: any) {\n            showToast(error.toString(), 'error');\n        } finally {\n            setSyncing(prev => ({ ...prev, [app]: false }));\n        }\n    };\n\n    const handleClear = (app: CliAppType) => {\n        setClearConfirmApp(app);\n    };\n\n    const executeClear = async () => {\n        if (!clearConfirmApp) return;\n        const app = clearConfirmApp;\n        setClearConfirmApp(null);\n\n        setSyncing(prev => ({ ...prev, [app]: true }));\n        try {\n            const formattedUrl = getFormattedProxyUrl(app);\n            await invoke('execute_opencode_clear', { proxyUrl: formattedUrl, clearLegacy: true });\n            showToast(t('proxy.opencode_sync.toast.clear_success', { defaultValue: 'OpenCode cleared successfully' }), 'success');\n            await checkStatus(app);\n        } catch (error: any) {\n            showToast(t('proxy.opencode_sync.toast.clear_error', { defaultValue: `Clear failed: ${error.toString()}` }), 'error');\n        } finally {\n            setSyncing(prev => ({ ...prev, [app]: false }));\n        }\n    };\n\n    const handleViewConfig = async (app: CliAppType, fileName?: string) => {\n        try {\n            const status = statuses[app];\n            if (!status) return;\n\n            const targetFile = fileName || status.files[0];\n            let command: string;\n            let params: Record<string, unknown>;\n            if (app === 'Droid') {\n                command = 'get_droid_config_content';\n                params = {};\n            } else if (app === 'OpenCode') {\n                command = 'get_opencode_config_content';\n                params = { request: { fileName: targetFile } };\n            } else {\n                command = 'get_cli_config_content';\n                params = { appType: app, fileName: targetFile };\n            }\n\n            const content = await invoke<string>(command, params);\n            setViewingConfig({\n                app,\n                content,\n                fileName: targetFile,\n                allFiles: status.files\n            });\n        } catch (error: any) {\n            showToast(error.toString(), 'error');\n        }\n    };\n\n    useEffect(() => {\n        checkStatus('Claude');\n        checkStatus('Codex');\n        checkStatus('Gemini');\n        checkStatus('OpenCode');\n        checkStatus('Droid');\n    }, [checkStatus]);\n\n    const renderCliItem = (app: CliAppType, icon: React.ReactNode, name: string) => {\n        const status = statuses[app];\n        const isAppLoading = loading[app];\n        const isAppSyncing = syncing[app];\n\n        return (\n            <div className=\"flex flex-col bg-white/50 dark:bg-gray-800/40 rounded-xl border border-gray-100 dark:border-white/5 p-4 shadow-sm hover:shadow-lg hover:border-blue-200/50 dark:hover:border-blue-500/30 transition-all duration-300 group\">\n                <div className=\"flex flex-col sm:flex-row items-start sm:items-center justify-between gap-y-3 gap-x-2 mb-4\">\n                    <div className=\"flex items-center gap-3 min-w-0\">\n                        <div className=\"p-2.5 bg-gray-50 dark:bg-base-300 rounded-lg shrink-0 group-hover:scale-110 transition-transform duration-300\">\n                            {icon}\n                        </div>\n                        <div className=\"min-w-0\">\n                            <h4 className=\"text-sm font-bold text-gray-900 dark:text-gray-100 leading-tight truncate\">\n                                {t('proxy.cli_sync.card_title', { name })}\n                            </h4>\n                            <div className=\"mt-1 flex items-center gap-1.5 overflow-hidden\">\n                                {isAppLoading ? (\n                                    <div className=\"flex items-center gap-1 text-[10px] text-gray-400\">\n                                        <Loader2 size={10} className=\"animate-spin\" />\n                                        {t('proxy.cli_sync.status.detecting')}\n                                    </div>\n                                ) : status?.installed ? (\n                                    <span className=\"text-[10px] px-1.5 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-bold whitespace-nowrap\">\n                                        {t('proxy.cli_sync.status.installed', { version: status.version })}\n                                    </span>\n                                ) : (\n                                    <span className=\"text-[10px] px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-400 font-medium whitespace-nowrap\">\n                                        {t('proxy.cli_sync.status.not_installed')}\n                                    </span>\n                                )}\n                            </div>\n                        </div>\n                    </div>\n\n                    {/* Show Sync Status if installed OR if it's OpenCode (which we now allow configuring even if not installed) */}\n                    {!isAppLoading && (status?.installed || app === 'OpenCode' && status?.current_base_url) && (\n                        <div className={cn(\n                            \"inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold tracking-wide transition-all h-6 shrink-0 whitespace-nowrap shadow-sm\",\n                            status.is_synced\n                                ? \"bg-gradient-to-r from-green-500 to-emerald-600 text-white\"\n                                : \"bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-500 border border-amber-200/50 dark:border-amber-800/30\"\n                        )}>\n                            {status.is_synced ? (\n                                <><CheckCircle2 size={12} className=\"shrink-0\" /> {t('proxy.cli_sync.status.synced', { defaultValue: '已同步' })}</>\n                            ) : (\n                                <><AlertCircle size={12} className=\"shrink-0\" /> {t('proxy.cli_sync.status.not_synced', { defaultValue: '未同步' })}</>\n                            )}\n                        </div>\n                    )}\n                </div>\n\n                <div className=\"mt-auto space-y-3\">\n                    <div className=\"p-2.5 bg-gray-50/80 dark:bg-gray-900/40 rounded-lg border border-dashed border-gray-200 dark:border-white/10\">\n                        <div className=\"flex justify-between items-start mb-1\">\n                            <div className=\"text-[9px] text-gray-400 dark:text-gray-500 uppercase font-bold tracking-wider\">{t('proxy.cli_sync.status.current_base_url')}</div>\n                        </div>\n                        <div className=\"text-[10px] font-mono truncate text-gray-500 dark:text-gray-400 italic\">\n                            {status?.current_base_url || '---'}\n                        </div>\n                    </div>\n\n                    {/* Claude, Codex, Gemini 的模型选择 */}\n                    {(status?.installed || app === 'OpenCode') && (app === 'Claude' || app === 'Codex' || app === 'Gemini') && (\n                        <div className=\"space-y-1\">\n                            <div className=\"text-[9px] text-gray-400 dark:text-gray-500 uppercase font-bold tracking-wider px-1\">\n                                {t('proxy.cli_sync.model_select', { defaultValue: 'Select Model' })}\n                            </div>\n                            <GroupedSelect\n                                value={selectedModels[app]}\n                                onChange={(val) => setSelectedModels(prev => ({ ...prev, [app]: val }))}\n                                options={modelOptions}\n                                className=\"w-full !h-8 !text-[11px] !rounded-lg\"\n                                allowCustomInput={true}\n                            />\n                        </div>\n                    )}\n\n                    {/* OpenCode 独有的账号同步选项 - Allow even if not installed */}\n                    {app === 'OpenCode' && (\n                        <div className=\"flex items-center gap-2 p-2 bg-gray-50/50 dark:bg-gray-900/20 rounded-lg\">\n                            <input\n                                type=\"checkbox\"\n                                id=\"opencode-sync-accounts\"\n                                checked={syncAccounts}\n                                onChange={(e) => setSyncAccounts(e.target.checked)}\n                                className=\"checkbox checkbox-xs checkbox-primary\"\n                            />\n                            <label htmlFor=\"opencode-sync-accounts\" className=\"text-[10px] text-gray-600 dark:text-gray-400 cursor-pointer select-none\">\n                                {t('proxy.opencode_sync.sync_accounts', { defaultValue: 'Sync accounts to antigravity-accounts.json' })}\n                            </label>\n                        </div>\n                    )}\n\n                    <div className=\"flex items-center gap-2\">\n                        {(status?.installed || app === 'OpenCode') && (\n                            <>\n                                {/* 对于 OpenCode，如果未同步，则不显示查看按钮（因为文件尚未生成，后端会报错） */}\n                                {(app !== 'OpenCode' || status?.is_synced) && (\n                                    <button\n                                        onClick={() => handleViewConfig(app)}\n                                        className=\"p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors\"\n                                        title={t(app === 'OpenCode' ? 'proxy.opencode_sync.btn_view' : 'proxy.cli_sync.btn_view', { defaultValue: 'View Config' })}\n                                    >\n                                        <Eye size={14} />\n                                    </button>\n                                )}\n                                <button\n                                    onClick={() => handleRestore(app)}\n                                    className=\"p-1 text-gray-400 hover:text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded transition-colors\"\n                                    title={t(app === 'OpenCode' ? 'proxy.opencode_sync.btn_restore' : 'proxy.cli_sync.btn_restore', { defaultValue: 'Restore' })}\n                                >\n                                    <RotateCcw size={14} />\n                                </button>\n                                {/* OpenCode 独有的 Clear 按钮 */}\n                                {app === 'OpenCode' && (\n                                    <button\n                                        onClick={() => handleClear(app)}\n                                        className=\"p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors\"\n                                        title={t('proxy.opencode_sync.btn_clear', { defaultValue: 'Clear' })}\n                                    >\n                                        <Trash2 size={14} />\n                                    </button>\n                                )}\n                            </>\n                        )}\n                        <button\n                            onClick={() => handleSync(app)}\n                            disabled={(app !== 'OpenCode' && !status?.installed) || isAppSyncing || isAppLoading}\n                            className={cn(\n                                \"btn btn-sm flex-1 gap-2 rounded-xl transition-all font-bold shadow-sm\",\n                                status?.is_synced\n                                    ? \"btn-ghost border-gray-200 dark:border-base-400 text-gray-500 hover:bg-gray-100\"\n                                    : \"btn-primary hover:shadow-lg shadow-blue-500/20\"\n                            )}\n                        >\n                            {isAppSyncing ? (\n                                <Loader2 size={14} className=\"animate-spin\" />\n                            ) : (\n                                <RefreshCw size={14} className={cn(isAppLoading && \"animate-spin-once\")} />\n                            )}\n                            {t('proxy.cli_sync.btn_sync')}\n                        </button>\n                    </div>\n                </div>\n            </div>\n        );\n    };\n\n    return (\n        <div className={cn(\"space-y-4\", className)}>\n            <div className=\"px-1 flex items-center justify-between\">\n                <div className=\"flex items-center gap-2 text-gray-400\">\n                    <Terminal size={14} />\n                    <span className=\"text-[10px] font-bold uppercase tracking-widest\">\n                        {t('proxy.cli_sync.title')}\n                    </span>\n                </div>\n                <p className=\"text-[10px] text-gray-400 dark:text-gray-500 italic\">\n                    {t('proxy.cli_sync.subtitle')}\n                </p>\n            </div>\n\n            <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4\">\n                {renderCliItem('Claude', <CodeXml size={20} className=\"text-purple-500\" />, 'Claude Code')}\n                {renderCliItem('Codex', <Cpu size={20} className=\"text-blue-500\" />, 'Codex AI')}\n                {renderCliItem('Gemini', <Globe size={20} className=\"text-green-500\" />, 'Gemini CLI')}\n                {renderCliItem('OpenCode', <CodeXml size={20} className=\"text-blue-500\" />, 'OpenCode')}\n                {renderCliItem('Droid', <Bot size={20} className=\"text-orange-500\" />, 'Droid')}\n            </div>\n\n            {/* Config Viewer Modal */}\n            {viewingConfig && (\n                <div className=\"fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200\">\n                    <div className=\"bg-white dark:bg-base-100 rounded-2xl shadow-2xl border border-gray-200 dark:border-base-300 w-full max-w-2xl overflow-hidden animate-in zoom-in-95 duration-200\">\n                        <div className=\"px-6 py-4 border-b border-gray-100 dark:border-base-200 flex items-center justify-between bg-gray-50/50 dark:bg-base-200/50\">\n                            <div>\n                                <h3 className=\"font-bold text-gray-900 dark:text-base-content flex items-center gap-2\">\n                                    <CodeXml size={18} className=\"text-blue-500\" />\n                                    {t('proxy.cli_sync.modal.view_title', { name: viewingConfig.app })}\n                                </h3>\n                                <div className=\"mt-2 flex gap-2\">\n                                    {viewingConfig.allFiles.map(file => (\n                                        <button\n                                            key={file}\n                                            onClick={() => handleViewConfig(viewingConfig.app, file)}\n                                            className={cn(\n                                                \"px-3 py-1 text-[10px] font-bold rounded-lg transition-all border\",\n                                                viewingConfig.fileName === file\n                                                    ? \"bg-blue-500 text-white border-blue-500\"\n                                                    : \"bg-white dark:bg-base-300 text-gray-400 border-gray-100 dark:border-base-400 hover:border-blue-200\"\n                                            )}\n                                        >\n                                            {file}\n                                        </button>\n                                    ))}\n                                </div>\n                            </div>\n                            <div className=\"flex items-center gap-2\">\n                                <button\n                                    onClick={async () => {\n                                        const success = await copyToClipboard(viewingConfig.content);\n                                        if (success) {\n                                            showToast(t('proxy.cli_sync.modal.copy_success'), 'success');\n                                        }\n                                    }}\n                                    className=\"btn btn-ghost btn-sm hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20\"\n                                >\n                                    <Copy size={16} />\n                                </button>\n                                <button\n                                    onClick={() => setViewingConfig(null)}\n                                    className=\"btn btn-ghost btn-sm hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20\"\n                                >\n                                    <X size={18} />\n                                </button>\n                            </div>\n                        </div>\n                        <div className=\"p-6\">\n                            <div className=\"bg-gray-900 rounded-xl p-4 overflow-auto max-h-[50vh] border border-gray-800 shadow-inner\">\n                                <pre className=\"text-xs font-mono text-gray-300 leading-relaxed\">\n                                    {viewingConfig.content}\n                                </pre>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            )}\n            {/* 恢复默认/备份确认弹窗 */}\n            <ModalDialog\n                isOpen={!!restoreConfirmApp}\n                title={statuses[restoreConfirmApp!]?.has_backup\n                    ? t('proxy.cli_sync.btn_restore_backup')\n                    : t('proxy.cli_sync.btn_restore') || t('proxy.cli_sync.title')}\n                message={restoreConfirmApp\n                    ? (statuses[restoreConfirmApp!]?.has_backup\n                        ? t('proxy.cli_sync.restore_backup_confirm')\n                        : t('proxy.cli_sync.restore_confirm', { name: restoreConfirmApp }))\n                    : ''}\n                onConfirm={executeRestore}\n                onCancel={() => setRestoreConfirmApp(null)}\n                isDestructive={true}\n            />\n\n            {/* 同步配置确认弹窗 (Issue #756) */}\n            <ModalDialog\n                isOpen={!!syncConfirmApp}\n                title={t('proxy.cli_sync.sync_confirm_title')}\n                message={syncConfirmApp ? t('proxy.cli_sync.sync_confirm_message', { name: syncConfirmApp }) : ''}\n                onConfirm={executeSync}\n                onCancel={() => setSyncConfirmApp(null)}\n                isDestructive={true}\n            />\n\n            {/* Clear 确认弹窗 - 仅 OpenCode */}\n            <ModalDialog\n                isOpen={!!clearConfirmApp}\n                title={t('proxy.opencode_sync.clear_confirm_title', { defaultValue: 'Clear OpenCode Configuration' })}\n                message={t('proxy.opencode_sync.clear_confirm_message', { defaultValue: 'This will clear all OpenCode configurations including legacy settings. Are you sure?' })}\n                onConfirm={executeClear}\n                onCancel={() => setClearConfirmApp(null)}\n                isDestructive={true}\n            />\n\n            {/* Droid 模型添加弹窗 */}\n            {droidSyncModal && (\n                <DroidSyncModal\n                    proxyUrl={proxyUrl}\n                    apiKey={apiKey}\n                    getFormattedProxyUrl={getFormattedProxyUrl}\n                    onClose={() => setDroidSyncModal(false)}\n                    onSyncDone={() => checkStatus('Droid')}\n                />\n            )}\n\n            {/* OpenCode 模型选择弹窗 */}\n            {openCodeSyncModal && (\n                <OpenCodeSyncModal\n                    proxyUrl={proxyUrl}\n                    apiKey={apiKey}\n                    getFormattedProxyUrl={getFormattedProxyUrl}\n                    onClose={() => setOpenCodeSyncModal(false)}\n                    onSyncDone={() => checkStatus('OpenCode')}\n                />\n            )}\n        </div>\n    );\n};\n\n\nexport default CliSyncCard;\n"
  },
  {
    "path": "src/components/proxy/DroidSyncModal.tsx",
    "content": "import { useState, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { RefreshCw, X, Bot } from 'lucide-react';\nimport {\n    DndContext, closestCenter, KeyboardSensor, PointerSensor,\n    useSensor, useSensors, type DragEndEvent,\n} from '@dnd-kit/core';\nimport {\n    arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy,\n} from '@dnd-kit/sortable';\nimport { cn } from '../../utils/cn';\nimport { request as invoke } from '../../utils/request';\nimport { showToast } from '../common/ToastContainer';\nimport { useProxyModels } from '../../hooks/useProxyModels';\nimport { SortableModelItem, type PreviewModelEntry } from './SortableModelItem';\n\ninterface DroidSyncModalProps {\n    proxyUrl: string;\n    apiKey: string;\n    getFormattedProxyUrl: (app: 'Claude' | 'Codex' | 'Gemini' | 'OpenCode' | 'Droid') => string;\n    onClose: () => void;\n    onSyncDone: () => void;\n}\n\nfunction buildDroidModel(modelId: string, modelName: string) {\n    const isClaude = modelId.startsWith('claude-');\n    const isThinking = modelId.includes('thinking');\n    if (isClaude) {\n        return {\n            model: modelId,\n            displayName: `AG-${modelName}`,\n            provider: 'anthropic',\n            noImageSupport: false,\n            maxOutputTokens: 64000,\n            ...(isThinking ? { extraArgs: { thinking: { type: 'enabled', budget_tokens: 32000 } } } : {}),\n        };\n    }\n    return {\n        model: modelId,\n        displayName: `AG-${modelName}`,\n        provider: 'generic-chat-completion-api',\n        noImageSupport: !modelId.includes('image'),\n    };\n}\n\nexport function DroidSyncModal({ apiKey, getFormattedProxyUrl, onClose, onSyncDone }: DroidSyncModalProps) {\n    const { t } = useTranslation();\n    const { models: antigravityModels } = useProxyModels();\n    const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());\n    const [previewModels, setPreviewModels] = useState<PreviewModelEntry[]>([]);\n    const [expanded, setExpanded] = useState<Set<string>>(new Set());\n    const [syncing, setSyncing] = useState(false);\n    const [configLoaded, setConfigLoaded] = useState(false);\n    const [currentConfig, setCurrentConfig] = useState<Record<string, unknown> | null>(null);\n\n    const sensors = useSensors(\n        useSensor(PointerSensor, { activationConstraint: { distance: 3 } }),\n        useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),\n    );\n\n    const rebuildPreview = useCallback((selectedIds: Set<string>, existingConfig: Record<string, unknown> | null) => {\n        const base = getFormattedProxyUrl('Droid').replace(/\\/+$/, '');\n        const existing = existingConfig ?? {};\n        const existingModels = Array.isArray((existing as Record<string, unknown>).customModels)\n            ? [...(existing as Record<string, unknown>).customModels as Record<string, unknown>[]]\n            : [];\n\n        const existingEntries: PreviewModelEntry[] = existingModels.map((m, i) => ({\n            ...(m as PreviewModelEntry),\n            _uid: `existing-${i}`,\n            isAg: ((m as Record<string, unknown>).id as string || '').startsWith('custom:AG-'),\n            index: i,\n        }));\n\n        const existingAgModels = new Set(existingEntries.filter(e => e.isAg).map(e => e.model));\n        const selected = antigravityModels.filter(m => selectedIds.has(m.id));\n        const newEntries: PreviewModelEntry[] = selected\n            .filter(m => {\n                const cfg = buildDroidModel(m.id, m.name);\n                return !existingAgModels.has(cfg.model);\n            })\n            .map((m, i) => {\n                const cfg = buildDroidModel(m.id, m.name);\n                const actualBase = cfg.provider === 'generic-chat-completion-api'\n                    ? (base.endsWith('/v1') ? base : `${base}/v1`) : base;\n                const entry: PreviewModelEntry = {\n                    _uid: `new-${i}`,\n                    model: cfg.model,\n                    id: `custom:${cfg.displayName.replace(/\\s/g, '-')}`,\n                    index: 0,\n                    baseUrl: actualBase,\n                    apiKey: apiKey,\n                    displayName: cfg.displayName,\n                    noImageSupport: cfg.noImageSupport ?? false,\n                    provider: cfg.provider,\n                    isAg: true,\n                };\n                if ('maxOutputTokens' in cfg) entry.maxOutputTokens = cfg.maxOutputTokens;\n                if ('extraArgs' in cfg) entry.extraArgs = cfg.extraArgs;\n                return entry;\n            });\n\n        const merged = [...existingEntries, ...newEntries];\n        merged.forEach((m, i) => {\n            m.index = i;\n            if (m.isAg) m.id = `custom:${m.displayName.replace(/\\s/g, '-')}-${i}`;\n        });\n        setPreviewModels(merged);\n    }, [antigravityModels, apiKey, getFormattedProxyUrl]);\n\n    // 初始加载 settings.json\n    if (!configLoaded) {\n        setConfigLoaded(true);\n        invoke<string>('get_droid_config_content', {})\n            .then(content => {\n                const parsed = JSON.parse(content);\n                setCurrentConfig(parsed);\n                rebuildPreview(new Set(), parsed);\n            })\n            .catch(() => rebuildPreview(new Set(), null));\n    }\n\n    const reindexId = (id: string, newIdx: number) => id.replace(/-\\d+$/, `-${newIdx}`);\n\n    const allSelected = antigravityModels.length > 0 && antigravityModels.every(m => selectedModels.has(m.id));\n    const toggleAll = () => {\n        const next = allSelected ? new Set<string>() : new Set(antigravityModels.map(m => m.id));\n        setSelectedModels(next);\n        rebuildPreview(next, currentConfig);\n    };\n\n    const toggleModel = (modelListId: string) => {\n        const next = new Set(selectedModels);\n        const adding = !next.has(modelListId);\n        if (adding) next.add(modelListId); else next.delete(modelListId);\n        setSelectedModels(next);\n\n        if (adding) {\n            const m = antigravityModels.find(x => x.id === modelListId);\n            if (!m) return;\n            const base = getFormattedProxyUrl('Droid').replace(/\\/+$/, '');\n            const cfg = buildDroidModel(m.id, m.name);\n            const actualBase = cfg.provider === 'generic-chat-completion-api'\n                ? (base.endsWith('/v1') ? base : `${base}/v1`) : base;\n            const newIdx = previewModels.length;\n            const entry: PreviewModelEntry = {\n                _uid: `new-${Date.now()}-${m.id}`,\n                model: cfg.model,\n                id: `custom:${cfg.displayName.replace(/\\s/g, '-')}-${newIdx}`,\n                index: newIdx,\n                baseUrl: actualBase,\n                apiKey: apiKey,\n                displayName: cfg.displayName,\n                noImageSupport: cfg.noImageSupport ?? false,\n                provider: cfg.provider,\n                isAg: true,\n            };\n            if ('maxOutputTokens' in cfg) entry.maxOutputTokens = cfg.maxOutputTokens;\n            if ('extraArgs' in cfg) entry.extraArgs = cfg.extraArgs;\n            setPreviewModels([...previewModels, entry]);\n        } else {\n            const m = antigravityModels.find(x => x.id === modelListId);\n            if (!m) return;\n            const cfg = buildDroidModel(m.id, m.name);\n            setPreviewModels(\n                previewModels.filter(e => !(e.isAg && e.model === cfg.model)).map((m, i) => ({\n                    ...m, index: i, id: reindexId(m.id, i),\n                }))\n            );\n        }\n    };\n\n    const handleDragEnd = (event: DragEndEvent) => {\n        const { active, over } = event;\n        if (!over || active.id === over.id) return;\n        const oldIdx = previewModels.findIndex(m => m._uid === active.id);\n        const newIdx = previewModels.findIndex(m => m._uid === over.id);\n        if (oldIdx < 0 || newIdx < 0) return;\n        setPreviewModels(arrayMove([...previewModels], oldIdx, newIdx).map((m, i) => ({\n            ...m, index: i, id: reindexId(m.id, i),\n        })));\n    };\n\n    const handleRemoveModel = (uid: string) => {\n        setPreviewModels(\n            previewModels.filter(m => m._uid !== uid).map((m, i) => ({\n                ...m, index: i, id: reindexId(m.id, i),\n            }))\n        );\n    };\n\n    const executeDroidSync = async () => {\n        if (!previewModels.some(m => m.isAg)) {\n            showToast(t('proxy.droid_sync.toast.no_models_selected', { defaultValue: '请至少选择一个模型' }), 'error');\n            return;\n        }\n        setSyncing(true);\n        try {\n            const customModels = previewModels.map(m => {\n                const { _uid, isAg, ...rest } = m;\n                return rest;\n            });\n            const added = await invoke<number>('execute_droid_sync', { customModels });\n            showToast(t('proxy.droid_sync.toast.sync_success_count', { count: added, defaultValue: `已添加 ${added} 个模型到 Droid` }), 'success');\n            onSyncDone();\n            onClose();\n        } catch (error: any) {\n            showToast(t('proxy.droid_sync.toast.sync_error', { error: error.toString(), defaultValue: `同步失败: ${error.toString()}` }), 'error');\n        } finally {\n            setSyncing(false);\n        }\n    };\n\n    const groups = [...new Set(antigravityModels.map(m => m.group))];\n    const existingCount = previewModels.filter(m => !m.isAg).length;\n    const agCount = previewModels.filter(m => m.isAg).length;\n\n    return (\n        <div className=\"fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200\">\n            <div className=\"bg-white dark:bg-base-100 rounded-2xl shadow-2xl border border-gray-200 dark:border-base-300 w-full max-w-2xl max-h-[85vh] overflow-hidden animate-in zoom-in-95 duration-200 flex flex-col\">\n                {/* Header */}\n                <div className=\"px-5 pt-4 pb-3 shrink-0\">\n                    <div className=\"flex items-center justify-between\">\n                        <div className=\"flex items-center gap-2.5\">\n                            <div className=\"p-2 bg-orange-50 dark:bg-orange-900/20 rounded-lg\">\n                                <Bot size={18} className=\"text-orange-500\" />\n                            </div>\n                            <div>\n                                <h3 className=\"text-sm font-bold text-gray-900 dark:text-base-content\">\n                                    {t('proxy.droid_sync.modal_title', { defaultValue: '添加模型到 Droid' })}\n                                </h3>\n                                <p className=\"text-[10px] text-gray-400 mt-0.5\">~/.factory/settings.json</p>\n                            </div>\n                        </div>\n                        <button onClick={onClose} className=\"p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-base-300 transition-colors\">\n                            <X size={16} className=\"text-gray-400\" />\n                        </button>\n                    </div>\n                </div>\n\n                {/* 模型选择区 */}\n                <div className=\"px-5 pb-3 shrink-0 border-b border-gray-100 dark:border-base-200\">\n                    <div className=\"flex items-center justify-between mb-2\">\n                        <span className=\"text-[10px] font-bold text-gray-400 uppercase tracking-wider\">\n                            {t('proxy.droid_sync.select_models', { defaultValue: '选择要添加的模型' })}\n                            <span className=\"ml-2 text-gray-300\">{selectedModels.size}/{antigravityModels.length}</span>\n                        </span>\n                        <button onClick={toggleAll} className=\"text-[10px] text-blue-500 hover:text-blue-600 font-medium transition-colors\">\n                            {allSelected ? t('common.deselect_all', { defaultValue: '取消全选' }) : t('common.select_all', { defaultValue: '全选' })}\n                        </button>\n                    </div>\n                    <div className=\"space-y-2 max-h-[25vh] overflow-auto\">\n                        {groups.map(group => {\n                            const groupModels = antigravityModels.filter(m => m.group === group);\n                            return (\n                                <div key={group}>\n                                    <div className=\"text-[9px] font-bold text-gray-400 uppercase tracking-widest mb-1\">{group}</div>\n                                    <div className=\"flex flex-wrap gap-1.5\">\n                                        {groupModels.map(m => {\n                                            const selected = selectedModels.has(m.id);\n                                            return (\n                                                <button\n                                                    key={m.id}\n                                                    onClick={() => toggleModel(m.id)}\n                                                    className={cn(\n                                                        \"px-2.5 py-1 rounded-md text-[11px] font-medium transition-all duration-150 border\",\n                                                        selected\n                                                            ? \"bg-orange-500 text-white border-orange-500\"\n                                                            : \"bg-gray-50 dark:bg-base-200 text-gray-500 dark:text-gray-400 border-gray-200 dark:border-base-300 hover:border-orange-300\"\n                                                    )}\n                                                >\n                                                    {m.name}\n                                                </button>\n                                            );\n                                        })}\n                                    </div>\n                                </div>\n                            );\n                        })}\n                    </div>\n                </div>\n\n                {/* Preview 主体区 */}\n                <div className=\"flex-1 min-h-0 flex flex-col\">\n                    <div className=\"px-5 py-2 flex items-center justify-between shrink-0\">\n                        <span className=\"text-[10px] font-bold text-gray-400 uppercase tracking-wider\">\n                            customModels Preview\n                            <span className=\"ml-2 font-normal\">\n                                {existingCount > 0 && <span className=\"text-gray-300\">{existingCount} existing</span>}\n                                {existingCount > 0 && agCount > 0 && <span className=\"text-gray-200 mx-1\">+</span>}\n                                {agCount > 0 && <span className=\"text-orange-400\">{agCount} new</span>}\n                            </span>\n                        </span>\n                        <span className=\"text-[9px] font-mono text-gray-300\">{previewModels.length} total</span>\n                    </div>\n                    <div className=\"px-4 pb-3 overflow-auto flex-1\">\n                        <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>\n                            <SortableContext items={previewModels.map(m => m._uid)} strategy={verticalListSortingStrategy}>\n                                <div className=\"space-y-1.5\">\n                                    {previewModels.map(entry => (\n                                        <SortableModelItem\n                                            key={entry._uid}\n                                            entry={entry}\n                                            collapsed={!expanded.has(entry._uid)}\n                                            onToggle={() => {\n                                                const next = new Set(expanded);\n                                                if (next.has(entry._uid)) next.delete(entry._uid); else next.add(entry._uid);\n                                                setExpanded(next);\n                                            }}\n                                            onRemove={() => handleRemoveModel(entry._uid)}\n                                        />\n                                    ))}\n                                </div>\n                            </SortableContext>\n                        </DndContext>\n                        {previewModels.length === 0 && (\n                            <div className=\"text-center text-xs text-gray-400 py-8\">\n                                {t('proxy.droid_sync.no_models', { defaultValue: '请在上方选择要添加的模型' })}\n                            </div>\n                        )}\n                    </div>\n                </div>\n\n                {/* Footer */}\n                <div className=\"px-5 py-3 border-t border-gray-100 dark:border-base-200 flex items-center justify-end gap-2 shrink-0\">\n                    <button className=\"px-3 py-1.5 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-base-300 transition-colors\" onClick={onClose}>\n                        {t('common.cancel', { defaultValue: '取消' })}\n                    </button>\n                    <button\n                        className={cn(\n                            \"px-4 py-1.5 text-xs font-bold rounded-lg transition-all flex items-center gap-1.5\",\n                            previewModels.some(m => m.isAg)\n                                ? \"bg-orange-500 hover:bg-orange-600 active:bg-orange-700 text-white shadow-sm\"\n                                : \"bg-gray-200 dark:bg-gray-700 text-gray-400 cursor-not-allowed\"\n                        )}\n                        disabled={!previewModels.some(m => m.isAg) || syncing}\n                        onClick={executeDroidSync}\n                    >\n                        <RefreshCw size={12} className={syncing ? 'animate-spin' : ''} />\n                        {t('proxy.droid_sync.btn_confirm_sync', { defaultValue: '写入配置' })}\n                    </button>\n                </div>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/components/proxy/OpenCodeSyncModal.tsx",
    "content": "import { useState, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { RefreshCw, X, CodeXml } from 'lucide-react';\nimport {\n    DndContext, closestCenter, KeyboardSensor, PointerSensor,\n    useSensor, useSensors, type DragEndEvent,\n} from '@dnd-kit/core';\nimport {\n    arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy,\n} from '@dnd-kit/sortable';\nimport { cn } from '../../utils/cn';\nimport { request as invoke } from '../../utils/request';\nimport { showToast } from '../common/ToastContainer';\nimport { useProxyModels } from '../../hooks/useProxyModels';\nimport { SortableModelItem, type PreviewModelEntry } from './SortableModelItem';\n\ninterface OpenCodeSyncModalProps {\n    proxyUrl: string;\n    apiKey: string;\n    getFormattedProxyUrl: (app: 'Claude' | 'Codex' | 'Gemini' | 'OpenCode' | 'Droid') => string;\n    onClose: () => void;\n    onSyncDone: () => void;\n}\n\nexport function OpenCodeSyncModal({ proxyUrl, apiKey, onClose, onSyncDone }: OpenCodeSyncModalProps) {\n    const { t } = useTranslation();\n    const { models: antigravityModels } = useProxyModels();\n    const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());\n    const [previewModels, setPreviewModels] = useState<PreviewModelEntry[]>([]);\n    const [syncing, setSyncing] = useState(false);\n    const [configLoaded, setConfigLoaded] = useState(false);\n    const [hasAuthPlugin, setHasAuthPlugin] = useState(false);\n    const [customBaseUrl, setCustomBaseUrl] = useState(proxyUrl);\n\n    const sensors = useSensors(\n        useSensor(PointerSensor, { activationConstraint: { distance: 3 } }),\n        useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),\n    );\n\n    const rebuildPreview = useCallback((selectedIds: Set<string>) => {\n        const selected = antigravityModels.filter(m => selectedIds.has(m.id));\n        const newEntries: PreviewModelEntry[] = selected.map((m, i) => ({\n            _uid: `new-${i}`,\n            model: m.id,\n            id: m.id,\n            index: i,\n            baseUrl: '', // OpenCode uses provider-level base URL\n            apiKey: apiKey,\n            displayName: m.name,\n            noImageSupport: false,\n            provider: m.id.includes('claude') ? 'anthropic' : 'google',\n            isAg: true,\n        }));\n        setPreviewModels(newEntries);\n    }, [antigravityModels, apiKey]);\n\n    // 初始加载 opencode.json\n    if (!configLoaded) {\n        setConfigLoaded(true);\n        invoke<string>('get_opencode_config_content', { request: { fileName: 'opencode.json' } })\n            .then(content => {\n                const parsed = JSON.parse(content);\n                const existingModelIds = new Set<string>();\n\n                // Priority 1: Read from antigravity-manager provider\n                if (parsed.provider?.['antigravity-manager']?.models) {\n                    Object.keys(parsed.provider['antigravity-manager'].models).forEach(k => existingModelIds.add(k));\n                }\n\n                // Fallback: legacy anthropic/google providers\n                if (existingModelIds.size === 0) {\n                    if (parsed.provider?.anthropic?.models) {\n                        Object.keys(parsed.provider.anthropic.models).forEach(k => existingModelIds.add(k));\n                    }\n                    if (parsed.provider?.google?.models) {\n                        Object.keys(parsed.provider.google.models).forEach(k => existingModelIds.add(k));\n                    }\n                }\n\n                // Detect auth plugin conflict\n                const plugins = parsed.plugin || [];\n                const hasAuth = plugins.some((p: string) => p.includes('opencode-antigravity-auth'));\n                setHasAuthPlugin(hasAuth);\n\n                // Try to extract existing baseURL from antigravity-manager provider\n                if (parsed.provider?.['antigravity-manager']?.options?.baseURL) {\n                    setCustomBaseUrl(parsed.provider['antigravity-manager'].options.baseURL);\n                }\n\n                setSelectedModels(existingModelIds);\n                rebuildPreview(existingModelIds);\n            })\n            .catch(() => rebuildPreview(new Set()));\n    }\n\n    const allSelected = antigravityModels.length > 0 && antigravityModels.every(m => selectedModels.has(m.id));\n    const toggleAll = () => {\n        const next = allSelected ? new Set<string>() : new Set(antigravityModels.map(m => m.id));\n        setSelectedModels(next);\n        rebuildPreview(next);\n    };\n\n    const toggleModel = (modelId: string) => {\n        const next = new Set(selectedModels);\n        if (next.has(modelId)) next.delete(modelId); else next.add(modelId);\n        setSelectedModels(next);\n        rebuildPreview(next);\n    };\n\n    const handleDragEnd = (event: DragEndEvent) => {\n        const { active, over } = event;\n        if (!over || active.id === over.id) return;\n        const oldIdx = previewModels.findIndex(m => m._uid === active.id);\n        const newIdx = previewModels.findIndex(m => m._uid === over.id);\n        if (oldIdx < 0 || newIdx < 0) return;\n        setPreviewModels(arrayMove([...previewModels], oldIdx, newIdx).map((m, i) => ({\n            ...m, index: i,\n        })));\n    };\n\n    const handleRemoveModel = (uid: string) => {\n        const nextPreviews = previewModels.filter(m => m._uid !== uid);\n        setPreviewModels(nextPreviews);\n        const nextSelected = new Set(nextPreviews.map(p => p.model));\n        setSelectedModels(nextSelected);\n    };\n\n    const executeOpenCodeSync = async () => {\n        setSyncing(true);\n        try {\n            const models = previewModels.map(m => m.model);\n            await invoke('execute_opencode_sync', {\n                proxyUrl: customBaseUrl || proxyUrl,\n                apiKey,\n                syncAccounts: true,\n                models\n            });\n            showToast(t('proxy.opencode_sync.toast.sync_success', { defaultValue: 'OpenCode 同步成功' }), 'success');\n            onSyncDone();\n            onClose();\n        } catch (error: any) {\n            showToast(error.toString(), 'error');\n        } finally {\n            setSyncing(false);\n        }\n    };\n\n    const groups = [...new Set(antigravityModels.map(m => m.group))];\n\n    return (\n        <div className=\"fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200\">\n            <div className=\"bg-white dark:bg-base-100 rounded-2xl shadow-2xl border border-gray-200 dark:border-base-300 w-full max-w-2xl max-h-[85vh] overflow-hidden animate-in zoom-in-95 duration-200 flex flex-col\">\n                {/* Header */}\n                <div className=\"px-5 pt-4 pb-3 shrink-0\">\n                    <div className=\"flex items-center justify-between\">\n                        <div className=\"flex items-center gap-2.5\">\n                            <div className=\"p-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg\">\n                                <CodeXml size={18} className=\"text-blue-500\" />\n                            </div>\n                            <div>\n                                <h3 className=\"text-sm font-bold text-gray-900 dark:text-base-content\">\n                                    {t('proxy.config.opencode_sync.modal_title', { defaultValue: '选择 OpenCode 模型' })}\n                                </h3>\n                                <p className=\"text-[10px] text-gray-400 mt-0.5\">~/.config/opencode/opencode.json</p>\n                            </div>\n                        </div>\n                        <button onClick={onClose} className=\"p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-base-300 transition-colors\">\n                            <X size={16} className=\"text-gray-400\" />\n                        </button>\n                    </div>\n                </div>\n\n                {/* Custom BaseURL Input */}\n                <div className=\"px-5 py-2 shrink-0 border-b border-gray-100 dark:border-base-200 bg-gray-50/50 dark:bg-base-200/30\">\n                    <div className=\"flex flex-col gap-1.5\">\n                        <div className=\"flex items-center justify-between\">\n                            <label className=\"text-[10px] font-bold text-gray-400 uppercase tracking-wider\">\n                                {t('proxy.config.opencode_sync.custom_base_url_label', { defaultValue: 'Custom Manager BaseURL' })}\n                            </label>\n                            <span className=\"text-[9px] text-gray-400 italic font-medium\">\n                                {t('proxy.config.opencode_sync.custom_base_url_desc', { defaultValue: 'For Docker Compose networking' })}\n                            </span>\n                        </div>\n                        <div className=\"relative group\">\n                            <input\n                                type=\"text\"\n                                value={customBaseUrl}\n                                onChange={(e) => setCustomBaseUrl(e.target.value)}\n                                placeholder=\"e.g. http://antigravity-manager:8045/v1\"\n                                className=\"w-full px-3 py-1.5 text-xs bg-white dark:bg-base-100 border border-gray-200 dark:border-base-300 rounded-lg focus:ring-1 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all\"\n                            />\n                            {customBaseUrl !== proxyUrl && (\n                                <button\n                                    onClick={() => setCustomBaseUrl(proxyUrl)}\n                                    className=\"absolute right-2 top-1/2 -translate-y-1/2 text-[9px] text-blue-500 hover:text-blue-600 font-medium\"\n                                >\n                                    {t('proxy.config.opencode_sync.custom_base_url_reset', { defaultValue: 'Reset' })}\n                                </button>\n                            )}\n                        </div>\n                    </div>\n                </div>\n\n                {/* 模型选择区 */}\n                <div className=\"px-5 pb-3 shrink-0 border-b border-gray-100 dark:border-base-200\">\n                    <div className=\"flex items-center justify-between mb-2\">\n                        <span className=\"text-[10px] font-bold text-gray-400 uppercase tracking-wider\">\n                            {t('proxy.config.opencode_sync.select_models', { defaultValue: '选择要同步的模型' })}\n                            <span className=\"ml-2 text-gray-300\">{selectedModels.size}/{antigravityModels.length}</span>\n                        </span>\n                        <button onClick={toggleAll} className=\"text-[10px] text-blue-500 hover:text-blue-600 font-medium transition-colors\">\n                            {allSelected ? t('common.deselect_all', { defaultValue: '取消全选' }) : t('common.select_all', { defaultValue: '全选' })}\n                        </button>\n                    </div>\n                    <div className=\"space-y-2 max-h-[25vh] overflow-auto\">\n                        {groups.map(group => {\n                            const groupModels = antigravityModels.filter(m => m.group === group);\n                            return (\n                                <div key={group}>\n                                    <div className=\"text-[9px] font-bold text-gray-400 uppercase tracking-widest mb-1\">{group}</div>\n                                    <div className=\"flex flex-wrap gap-1.5\">\n                                        {groupModels.map(m => {\n                                            const selected = selectedModels.has(m.id);\n                                            return (\n                                                <button\n                                                    key={m.id}\n                                                    onClick={() => toggleModel(m.id)}\n                                                    className={cn(\n                                                        \"px-2.5 py-1 rounded-md text-[11px] font-medium transition-all duration-150 border\",\n                                                        selected\n                                                            ? \"bg-blue-500 text-white border-blue-500\"\n                                                            : \"bg-gray-50 dark:bg-base-200 text-gray-500 dark:text-gray-400 border-gray-200 dark:border-base-300 hover:border-blue-300\"\n                                                    )}\n                                                >\n                                                    {m.name}\n                                                </button>\n                                            );\n                                        })}\n                                    </div>\n                                </div>\n                            );\n                        })}\n                    </div>\n                </div>\n\n                {/* Auth Plugin Warning */}\n                {hasAuthPlugin && (\n                    <div className=\"px-5 py-2 shrink-0 bg-amber-50 dark:bg-amber-900/20 border-y border-amber-100 dark:border-amber-900/30\">\n                        <p className=\"text-[10px] text-amber-700 dark:text-amber-400 leading-relaxed\">\n                            {t('proxy.config.opencode_sync.auth_plugin_warning', {\n                                defaultValue: 'Sync chỉ tạo provider antigravity-manager và không ghi đè google provider/plugin.'\n                            })}\n                        </p>\n                    </div>\n                )}\n\n                {/* Preview 主体区 */}\n                <div className=\"flex-1 min-h-0 flex flex-col\">\n                    <div className=\"px-5 py-2 flex items-center justify-between shrink-0\">\n                        <span className=\"text-[10px] font-bold text-gray-400 uppercase tracking-wider\">\n                            Sync Queue Preview\n                        </span>\n                        <span className=\"text-[9px] font-mono text-gray-300\">{previewModels.length} models</span>\n                    </div>\n                    <div className=\"px-4 pb-3 overflow-auto flex-1\">\n                        <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>\n                            <SortableContext items={previewModels.map(m => m._uid)} strategy={verticalListSortingStrategy}>\n                                <div className=\"space-y-1.5\">\n                                    {previewModels.map(entry => (\n                                        <SortableModelItem\n                                            key={entry._uid}\n                                            entry={entry}\n                                            collapsed={true}\n                                            onToggle={() => { }}\n                                            onRemove={() => handleRemoveModel(entry._uid)}\n                                        />\n                                    ))}\n                                </div>\n                            </SortableContext>\n                        </DndContext>\n                    </div>\n                </div>\n\n                {/* Footer */}\n                <div className=\"px-5 py-3 border-t border-gray-100 dark:border-base-200 flex items-center justify-end gap-2 shrink-0\">\n                    <button className=\"px-3 py-1.5 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-base-300 transition-colors\" onClick={onClose}>\n                        {t('common.cancel', { defaultValue: '取消' })}\n                    </button>\n                    <button\n                        className={cn(\n                            \"px-4 py-1.5 text-xs font-bold rounded-lg transition-all flex items-center gap-1.5\",\n                            previewModels.length > 0\n                                ? \"bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white shadow-sm\"\n                                : \"bg-gray-200 dark:bg-gray-700 text-gray-400 cursor-not-allowed\"\n                        )}\n                        disabled={previewModels.length === 0 || syncing}\n                        onClick={executeOpenCodeSync}\n                    >\n                        <RefreshCw size={12} className={syncing ? 'animate-spin' : ''} />\n                        {t('proxy.config.opencode_sync.btn_confirm_sync', { defaultValue: '确认同步' })}\n                    </button>\n                </div>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/components/proxy/ProxyMonitor.tsx",
    "content": "import React, { useEffect, useState, useRef, useMemo } from 'react';\nimport { listen } from '@tauri-apps/api/event';\nimport ModalDialog from '../common/ModalDialog';\nimport { useTranslation } from 'react-i18next';\nimport { request as invoke } from '../../utils/request';\nimport { Trash2, Search, X, Copy, CheckCircle, ChevronLeft, ChevronRight, RefreshCw, User } from 'lucide-react';\n\nimport { AppConfig } from '../../types/config';\nimport { formatCompactNumber } from '../../utils/format';\nimport { useAccountStore } from '../../stores/useAccountStore';\nimport { isTauri } from '../../utils/env';\nimport { copyToClipboard } from '../../utils/clipboard';\n\n\ninterface ProxyRequestLog {\n    id: string;\n    timestamp: number;\n    method: string;\n    url: string;\n    status: number;\n    duration: number;\n    model?: string;\n    mapped_model?: string;\n    error?: string;\n    request_body?: string;\n    response_body?: string;\n    input_tokens?: number;\n    output_tokens?: number;\n    account_email?: string;\n    protocol?: string;  // \"openai\" | \"anthropic\" | \"gemini\"\n}\n\ninterface ProxyStats {\n    total_requests: number;\n    success_count: number;\n    error_count: number;\n}\n\ninterface ProxyMonitorProps {\n    className?: string;\n}\n\n// Log Table Component\ninterface LogTableProps {\n    logs: ProxyRequestLog[];\n    loading: boolean;\n    onLogClick: (log: ProxyRequestLog) => void;\n    t: any;\n}\n\nconst LogTable: React.FC<LogTableProps> = ({\n    logs,\n    loading,\n    onLogClick,\n    t\n}) => {\n    return (\n        <div\n            className=\"flex-1 overflow-y-auto overflow-x-auto bg-white dark:bg-base-100\"\n        >\n            <table className=\"table table-xs w-full\">\n                <thead className=\"bg-gray-50 dark:bg-base-200 text-gray-500 sticky top-0 z-10\">\n                    <tr>\n                        <th style={{ width: '60px' }}>{t('monitor.table.status')}</th>\n                        <th style={{ width: '60px' }}>{t('monitor.table.method')}</th>\n                        <th style={{ width: '220px' }}>{t('monitor.table.model')}</th>\n                        <th style={{ width: '70px' }}>{t('monitor.table.protocol')}</th>\n                        <th style={{ width: '140px' }}>{t('monitor.table.account')}</th>\n                        <th style={{ width: '180px' }}>{t('monitor.table.path')}</th>\n                        <th className=\"text-right\" style={{ width: '90px' }}>{t('monitor.table.usage')}</th>\n                        <th className=\"text-right\" style={{ width: '80px' }}>{t('monitor.table.duration')}</th>\n                        <th className=\"text-right\" style={{ width: '80px' }}>{t('monitor.table.time')}</th>\n                    </tr>\n                </thead>\n                <tbody className=\"font-mono text-gray-700 dark:text-gray-300\">\n                    {logs.map((log) => (\n                        <tr\n                            key={log.id}\n                            className=\"hover:bg-blue-50 dark:hover:bg-blue-900/20 cursor-pointer\"\n                            onClick={() => onLogClick(log)}\n                        >\n                            <td style={{ width: '60px' }}>\n                                <span className={`badge badge-xs text-white border-none ${log.status >= 200 && log.status < 400 ? 'badge-success' : 'badge-error'}`}>\n                                    {log.status}\n                                </span>\n                            </td>\n                            <td className=\"font-bold\" style={{ width: '60px' }}>{log.method}</td>\n                            <td className=\"text-blue-600 truncate\" style={{ width: '220px', maxWidth: '220px' }}>\n                                {log.mapped_model && log.model !== log.mapped_model\n                                    ? `${log.model} => ${log.mapped_model}`\n                                    : (log.model || '-')}\n                            </td>\n                            <td style={{ width: '70px' }}>\n                                {log.protocol && (\n                                    <span className={`badge badge-xs text-white border-none ${log.protocol === 'openai' ? 'bg-green-500' :\n                                        log.protocol === 'anthropic' ? 'bg-orange-500' :\n                                            log.protocol === 'gemini' ? 'bg-blue-500' : 'bg-gray-400'\n                                        }`}>\n                                        {log.protocol === 'openai' ? 'OpenAI' :\n                                            log.protocol === 'anthropic' ? 'Claude' :\n                                                log.protocol === 'gemini' ? 'Gemini' : log.protocol}\n                                    </span>\n                                )}\n                            </td>\n                            <td className=\"text-gray-600 dark:text-gray-400 truncate text-[10px]\" style={{ width: '140px', maxWidth: '140px' }} title={log.account_email || ''}>\n                                {log.account_email ? log.account_email.replace(/(.{3}).*(@.*)/, '$1***$2') : '-'}\n                            </td>\n                            <td className=\"truncate\" style={{ width: '180px', maxWidth: '180px' }}>{log.url}</td>\n                            <td className=\"text-right text-[9px]\" style={{ width: '90px' }}>\n                                {log.input_tokens != null && <div>I: {formatCompactNumber(log.input_tokens)}</div>}\n                                {log.output_tokens != null && <div>O: {formatCompactNumber(log.output_tokens)}</div>}\n                            </td>\n                            <td className=\"text-right\" style={{ width: '80px' }}>{log.duration}ms</td>\n                            <td className=\"text-right text-[10px]\" style={{ width: '80px' }}>\n                                {new Date(log.timestamp).toLocaleTimeString()}\n                            </td>\n                        </tr>\n                    ))}\n                </tbody>\n            </table>\n\n            {/* Loading indicator */}\n            {loading && (\n                <div className=\"flex items-center justify-center p-4 bg-white dark:bg-base-100\">\n                    <div className=\"loading loading-spinner loading-md\"></div>\n                    <span className=\"ml-3 text-sm text-gray-500\">{t('common.loading')}</span>\n                </div>\n            )}\n\n            {/* Empty state */}\n            {!loading && logs.length === 0 && (\n                <div className=\"flex items-center justify-center p-8 text-gray-400\">\n                    {t('monitor.table.empty') || '暂无请求记录'}\n                </div>\n            )}\n        </div>\n    );\n};\n\n\nexport const ProxyMonitor: React.FC<ProxyMonitorProps> = ({ className }) => {\n    const { t } = useTranslation();\n    const [logs, setLogs] = useState<ProxyRequestLog[]>([]);\n    const [stats, setStats] = useState<ProxyStats>({ total_requests: 0, success_count: 0, error_count: 0 });\n    const [filter, setFilter] = useState('');\n    const [accountFilter, setAccountFilter] = useState('');\n    // [FIX] 使用 ref 存储最新的筛选条件，避免 setInterval 闭包问题\n    const filterRef = useRef(filter);\n    const accountFilterRef = useRef(accountFilter);\n    const currentPageRef = useRef(1);\n    const [selectedLog, setSelectedLog] = useState<ProxyRequestLog | null>(null);\n    const [isLoggingEnabled, setIsLoggingEnabled] = useState(false);\n    const [isClearConfirmOpen, setIsClearConfirmOpen] = useState(false);\n    const [copiedRequestId, setCopiedRequestId] = useState<string | null>(null);\n\n    const { accounts, fetchAccounts } = useAccountStore();\n\n    // Pagination state\n    const PAGE_SIZE_OPTIONS = [50, 100, 200, 500];\n    const [pageSize, setPageSize] = useState(100);\n    const [currentPage, setCurrentPage] = useState(1);\n    const [totalCount, setTotalCount] = useState(0);\n    const [loading, setLoading] = useState(false);\n    const [loadingDetail, setLoadingDetail] = useState(false);\n\n    const uniqueAccounts = useMemo(() => {\n        const emailSet = new Set<string>();\n        logs.forEach(log => {\n            if (log.account_email) {\n                emailSet.add(log.account_email);\n            }\n        });\n        accounts.forEach(acc => {\n            emailSet.add(acc.email);\n        });\n        return Array.from(emailSet).sort();\n    }, [logs, accounts]);\n\n    const loadData = async (page = 1, searchFilter = filter, accountEmailFilter = accountFilter) => {\n        if (loading) return;\n        setLoading(true);\n\n        try {\n            // Add timeout control (10 seconds)\n            const timeoutPromise = new Promise((_, reject) =>\n                setTimeout(() => reject(new Error('Request timeout')), 10000)\n            );\n\n            const config = await Promise.race([\n                invoke<AppConfig>('load_config'),\n                timeoutPromise\n            ]) as AppConfig;\n\n            if (config && config.proxy) {\n                setIsLoggingEnabled(config.proxy.enable_logging);\n                await invoke('set_proxy_monitor_enabled', { enabled: config.proxy.enable_logging });\n            }\n\n            const errorsOnly = searchFilter === '__ERROR__';\n            const baseFilter = errorsOnly ? '' : searchFilter;\n            const actualFilter = accountEmailFilter\n                ? (baseFilter ? `${baseFilter} ${accountEmailFilter}` : accountEmailFilter)\n                : baseFilter;\n\n            // Get count with filter\n            const count = await Promise.race([\n                invoke<number>('get_proxy_logs_count_filtered', {\n                    filter: actualFilter,\n                    errorsOnly: errorsOnly\n                }),\n                timeoutPromise\n            ]) as number;\n            setTotalCount(count);\n\n            // Use filtered paginated query\n            const offset = (page - 1) * pageSize;\n            const history = await Promise.race([\n                invoke<ProxyRequestLog[]>('get_proxy_logs_filtered', {\n                    filter: actualFilter,\n                    errorsOnly: errorsOnly,\n                    limit: pageSize,\n                    offset: offset\n                }),\n                timeoutPromise\n            ]) as ProxyRequestLog[];\n\n            if (Array.isArray(history)) {\n                setLogs(history);\n                // Clear pending logs to avoid duplicates (database data is authoritative)\n                pendingLogsRef.current = [];\n            }\n\n            const currentStats = await Promise.race([\n                invoke<ProxyStats>('get_proxy_stats'),\n                timeoutPromise\n            ]) as ProxyStats;\n\n            if (currentStats) setStats(currentStats);\n        } catch (e: any) {\n            console.error(\"Failed to load proxy data\", e);\n            if (e.message === 'Request timeout') {\n                // Show timeout error to user\n                console.error('Loading monitor data timeout, please try again later');\n            }\n        } finally {\n            setLoading(false);\n        }\n    };\n\n    const totalPages = Math.ceil(totalCount / pageSize);\n    const pageStart = totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1;\n    const pageEnd = totalCount === 0 ? 0 : Math.min(currentPage * pageSize, totalCount);\n\n    const goToPage = (page: number) => {\n        if (page >= 1 && page <= totalPages && page !== currentPage) {\n            setCurrentPage(page);\n            currentPageRef.current = page; // [FIX] 同步 ref\n            loadData(page, filter, accountFilter);\n        }\n    };\n\n    const toggleLogging = async () => {\n        const newState = !isLoggingEnabled;\n        try {\n            const config = await invoke<AppConfig>('load_config');\n            if (config && config.proxy) {\n                config.proxy.enable_logging = newState;\n                await invoke('save_config', { config });\n                await invoke('set_proxy_monitor_enabled', { enabled: newState });\n                setIsLoggingEnabled(newState);\n            }\n        } catch (e) {\n            console.error(\"Failed to toggle logging\", e);\n        }\n    };\n\n    const pendingLogsRef = useRef<ProxyRequestLog[]>([]);\n    const listenerSetupRef = useRef(false);\n    const isMountedRef = useRef(true);\n\n    useEffect(() => {\n        isMountedRef.current = true;\n        loadData();\n        fetchAccounts();\n\n        let unlistenFn: (() => void) | null = null;\n        let updateTimeout: number | null = null;\n\n        const setupListener = async () => {\n            if (!isTauri()) return;\n            // Prevent duplicate listener registration (React 18 StrictMode)\n            if (listenerSetupRef.current) {\n                console.debug('[ProxyMonitor] Listener already set up, skipping...');\n                return;\n            }\n            listenerSetupRef.current = true;\n\n            console.debug('[ProxyMonitor] Setting up event listener for proxy://request');\n            unlistenFn = await listen<ProxyRequestLog>('proxy://request', (event) => {\n                if (!isMountedRef.current) return;\n\n                const newLog = event.payload;\n\n                // 移除 body 以减少内存占用\n                const logSummary = {\n                    ...newLog,\n                    request_body: undefined,\n                    response_body: undefined\n                };\n\n                // Check if this log already exists (deduplicate at event level)\n                const alreadyExists = pendingLogsRef.current.some(log => log.id === newLog.id);\n                if (alreadyExists) {\n                    console.debug('[ProxyMonitor] Duplicate event ignored:', newLog.id);\n                    return;\n                }\n\n                pendingLogsRef.current.push(logSummary);\n\n                // 防抖:每 500ms 批量更新一次\n                if (updateTimeout) clearTimeout(updateTimeout);\n                updateTimeout = setTimeout(async () => {\n                    if (!isMountedRef.current) return;\n\n                    const currentPending = pendingLogsRef.current;\n                    if (currentPending.length > 0) {\n                        setLogs(prev => {\n                            // Deduplicate by id\n                            const existingIds = new Set(prev.map(log => log.id));\n                            const uniqueNewLogs = currentPending.filter(log => !existingIds.has(log.id));\n                            // Merge and sort by timestamp descending (newest first)\n                            const merged = [...uniqueNewLogs, ...prev];\n                            merged.sort((a, b) => b.timestamp - a.timestamp);\n                            return merged.slice(0, 100);\n                        });\n\n                        // Fetch stats and total count from backend instead of local calculation\n                        try {\n                            const [currentStats, count] = await Promise.all([\n                                invoke<ProxyStats>('get_proxy_stats'),\n                                invoke<number>('get_proxy_logs_count_filtered', { filter: '', errorsOnly: false })\n                            ]);\n                            if (isMountedRef.current) {\n                                if (currentStats) setStats(currentStats);\n                                setTotalCount(count);\n                            }\n                        } catch (e) {\n                            console.error('Failed to fetch stats:', e);\n                        }\n\n                        pendingLogsRef.current = [];\n                    }\n                }, 500);\n            });\n        };\n        setupListener();\n\n        // Web 模式補強：如果不是 Tauri 環境，則啟用定時輪詢\n        let pollInterval: number | null = null;\n        if (!isTauri()) {\n            console.debug('[ProxyMonitor] Web mode detected, starting auto-poll (10s)');\n            pollInterval = window.setInterval(() => {\n                if (isMountedRef.current && !loading) {\n                    // [FIX] 使用 ref.current 获取最新的筛选条件\n                    loadData(currentPageRef.current, filterRef.current, accountFilterRef.current);\n                }\n            }, 10000);\n        }\n\n        return () => {\n            isMountedRef.current = false;\n            listenerSetupRef.current = false;\n            if (unlistenFn) unlistenFn();\n            if (updateTimeout) clearTimeout(updateTimeout);\n            if (pollInterval) clearInterval(pollInterval);\n        };\n    }, []);\n\n    useEffect(() => {\n        setCopiedRequestId(null);\n    }, [selectedLog?.id]);\n\n    // Reload when pageSize changes\n    useEffect(() => {\n        setCurrentPage(1);\n        loadData(1, filter, accountFilter);\n    }, [pageSize]);\n\n    // Reload when filter changes (search based on all logs)\n    useEffect(() => {\n        setCurrentPage(1);\n        loadData(1, filter, accountFilter);\n        // [FIX] 同步 ref 值，供 setInterval 使用\n        filterRef.current = filter;\n        accountFilterRef.current = accountFilter;\n        currentPageRef.current = 1;\n    }, [filter, accountFilter]);\n\n    // Logs are already filtered and sorted by backend\n    // Apply account filter on frontend\n    const filteredLogs = useMemo(() => {\n        if (!accountFilter) return logs;\n        return logs.filter(log => log.account_email === accountFilter);\n    }, [logs, accountFilter]);\n\n    const quickFilters = [\n        { label: t('monitor.filters.all'), value: '' },\n        { label: t('monitor.filters.error'), value: '__ERROR__' },\n        { label: t('monitor.filters.chat'), value: 'completions' },\n        { label: t('monitor.filters.gemini'), value: 'gemini' },\n        { label: t('monitor.filters.claude'), value: 'claude' },\n        { label: t('monitor.filters.images'), value: 'images' }\n    ];\n\n    const clearLogs = () => {\n        setIsClearConfirmOpen(true);\n    };\n\n    const executeClearLogs = async () => {\n        setIsClearConfirmOpen(false);\n        try {\n            await invoke('clear_proxy_logs');\n            setLogs([]);\n            setStats({ total_requests: 0, success_count: 0, error_count: 0 });\n            setTotalCount(0);\n        } catch (e) {\n            console.error(\"Failed to clear logs\", e);\n        }\n    };\n\n    const formatBody = (body?: string) => {\n        if (!body) return <span className=\"text-gray-400 italic\">{t('monitor.details.payload_empty')}</span>;\n        try {\n            const obj = JSON.parse(body);\n            return <pre className=\"text-[10px] font-mono whitespace-pre-wrap text-gray-700 dark:text-gray-300\">{JSON.stringify(obj, null, 2)}</pre>;\n        } catch (e) {\n            return <pre className=\"text-[10px] font-mono whitespace-pre-wrap text-gray-700 dark:text-gray-300\">{body}</pre>;\n        }\n    };\n\n    const getCopyPayload = (body: string) => {\n        try {\n            const obj = JSON.parse(body);\n            return JSON.stringify(obj, null, 2);\n        } catch (e) {\n            return body;\n        }\n    };\n\n\n    return (\n        <div className={`flex flex-col bg-white dark:bg-base-100 rounded-xl shadow-sm border border-gray-100 dark:border-base-200 overflow-hidden ${className || 'flex-1'}`}>\n            <div className=\"p-3 border-b border-gray-100 dark:border-base-200 space-y-3 bg-gray-50/30 dark:bg-base-200/30\">\n                <div className=\"flex items-center gap-4\">\n                    <button\n                        onClick={toggleLogging}\n                        className={`btn btn-sm gap-2 px-4 border font-bold ${isLoggingEnabled\n                            ? 'bg-red-500 border-red-600 text-white animate-pulse'\n                            : 'bg-white dark:bg-base-200 border-gray-300 text-gray-600'\n                            }`}\n                    >\n                        <div className={`w-2.5 h-2.5 rounded-full ${isLoggingEnabled ? 'bg-white' : 'bg-gray-400'}`} />\n                        {isLoggingEnabled ? t('monitor.logging_status.active') : t('monitor.logging_status.paused')}\n                    </button>\n\n                    <div className=\"relative flex-1\">\n                        <Search className=\"absolute left-2.5 top-2 text-gray-400\" size={14} />\n                        <input\n                            type=\"text\"\n                            placeholder={t('monitor.filters.placeholder')}\n                            className=\"input input-sm input-bordered w-full pl-9 text-xs\"\n                            value={filter}\n                            onChange={(e) => setFilter(e.target.value)}\n                        />\n                    </div>\n\n                    <div className=\"relative\">\n                        <User className=\"absolute left-2.5 top-2 text-gray-400 z-10\" size={14} />\n                        <select\n                            className=\"select select-sm select-bordered pl-8 text-xs min-w-[140px] max-w-[220px]\"\n                            value={accountFilter}\n                            onChange={(e) => setAccountFilter(e.target.value)}\n                            title={t('monitor.filters.by_account')}\n                        >\n                            <option value=\"\">{t('monitor.filters.all_accounts')}</option>\n                            {uniqueAccounts.map(email => (\n                                <option key={email} value={email} title={email}>\n                                    {email}\n                                </option>\n                            ))}\n                        </select>\n                    </div>\n\n                    <div className=\"hidden lg:flex gap-4 text-[10px] font-bold uppercase\">\n                        <span className=\"text-blue-500\">{formatCompactNumber(stats.total_requests)} {t('monitor.stats.total')}</span>\n                        <span className=\"text-green-500\">{formatCompactNumber(stats.success_count)} {t('monitor.stats.ok')}</span>\n                        <span className=\"text-red-500\">{formatCompactNumber(stats.error_count)} {t('monitor.stats.err')}</span>\n                    </div>\n\n                    <button onClick={() => loadData(currentPage, filter)} className=\"btn btn-sm btn-ghost text-gray-400\" title={t('common.refresh')}>\n                        <RefreshCw size={16} className={loading ? 'animate-spin' : ''} />\n                    </button>\n                    <button onClick={clearLogs} className=\"btn btn-sm btn-ghost text-gray-400\">\n                        <Trash2 size={16} />\n                    </button>\n                </div>\n\n                <div className=\"flex flex-wrap items-center gap-2\">\n                    <span className=\"text-[10px] font-bold text-gray-400 uppercase\">{t('monitor.filters.quick_filters')}</span>\n                    {quickFilters.map(q => (\n                        <button key={q.label} onClick={() => setFilter(q.value)} className={`px-2 py-0.5 rounded-full text-[10px] border ${filter === q.value ? 'bg-blue-500 text-white' : 'bg-white dark:bg-base-200 text-gray-500'}`}>\n                            {q.label}\n                        </button>\n                    ))}\n                    {(filter || accountFilter) && <button onClick={() => { setFilter(''); setAccountFilter(''); }} className=\"text-[10px] text-blue-500\"> {t('monitor.filters.reset')} </button>}\n                </div>\n            </div>\n\n            <LogTable\n                logs={filteredLogs}\n                loading={loading}\n                onLogClick={async (log: ProxyRequestLog) => {\n                    setLoadingDetail(true);\n                    try {\n                        const detail = await invoke<ProxyRequestLog>('get_proxy_log_detail', { logId: log.id });\n                        setSelectedLog(detail);\n                    } catch (e) {\n                        console.error('Failed to load log detail', e);\n                        setSelectedLog(log);\n                    } finally {\n                        setLoadingDetail(false);\n                    }\n                }}\n                t={t}\n            />\n\n            {/* Pagination Controls */}\n            <div className=\"flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-base-200 border-t border-gray-200 dark:border-base-300 text-xs\">\n                <div className=\"flex items-center gap-2 whitespace-nowrap\">\n                    <span className=\"text-gray-500\">{t('common.per_page')}</span>\n                    <select\n                        value={pageSize}\n                        onChange={(e) => setPageSize(Number(e.target.value))}\n                        className=\"select select-xs select-bordered w-16\"\n                    >\n                        {PAGE_SIZE_OPTIONS.map(size => (\n                            <option key={size} value={size}>{size}</option>\n                        ))}\n                    </select>\n                </div>\n\n                <div className=\"flex items-center gap-3\">\n                    <button\n                        onClick={() => goToPage(currentPage - 1)}\n                        disabled={currentPage <= 1 || loading}\n                        className=\"btn btn-xs btn-ghost\"\n                    >\n                        <ChevronLeft size={14} />\n                    </button>\n                    <span className=\"text-gray-600 dark:text-gray-400 min-w-[80px] text-center\">\n                        {currentPage} / {totalPages || 1}\n                    </span>\n                    <button\n                        onClick={() => goToPage(currentPage + 1)}\n                        disabled={currentPage >= totalPages || loading}\n                        className=\"btn btn-xs btn-ghost\"\n                    >\n                        <ChevronRight size={14} />\n                    </button>\n                </div>\n\n                <div className=\"text-gray-500\">\n                    {t('common.pagination_info', { start: pageStart, end: pageEnd, total: totalCount })}\n                </div>\n            </div>\n\n            {selectedLog && (\n                <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4\" onClick={() => setSelectedLog(null)}>\n                    <div className=\"bg-white dark:bg-base-100 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden border border-gray-200 dark:border-base-300\" onClick={e => e.stopPropagation()}>\n                        {/* Modal Header */}\n                        <div className=\"px-4 py-3 border-b border-gray-100 dark:border-base-300 flex items-center justify-between bg-gray-50 dark:bg-base-200\">\n                            <div className=\"flex items-center gap-3\">\n                                {loadingDetail && <div className=\"loading loading-spinner loading-sm\"></div>}\n                                <span className={`badge badge-sm text-white border-none ${selectedLog.status >= 200 && selectedLog.status < 400 ? 'badge-success' : 'badge-error'}`}>{selectedLog.status}</span>\n                                <span className=\"font-mono font-bold text-gray-900 dark:text-base-content text-sm\">{selectedLog.method}</span>\n                                <span className=\"text-xs text-gray-500 dark:text-gray-400 font-mono truncate max-w-md hidden sm:inline\">{selectedLog.url}</span>\n                            </div>\n                            <button onClick={() => setSelectedLog(null)} className=\"btn btn-ghost btn-sm btn-circle text-gray-500 dark:text-gray-400 hover:dark:bg-base-300\"><X size={18} /></button>\n                        </div>\n\n                        {/* Modal Content */}\n                        <div className=\"flex-1 overflow-y-auto p-4 space-y-6 bg-white dark:bg-base-100\">\n                            {/* Metadata Section */}\n                            <div className=\"bg-gray-50 dark:bg-base-200 p-5 rounded-xl border border-gray-200 dark:border-base-300 shadow-inner\">\n                                <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-y-5 gap-x-10\">\n                                    <div className=\"space-y-1.5\">\n                                        <span className=\"block text-gray-500 dark:text-gray-400 uppercase font-black text-[10px] tracking-widest\">{t('monitor.details.time')}</span>\n                                        <span className=\"font-mono font-semibold text-gray-900 dark:text-base-content text-xs\">{new Date(selectedLog.timestamp).toLocaleString()}</span>\n                                    </div>\n                                    <div className=\"space-y-1.5\">\n                                        <span className=\"block text-gray-500 dark:text-gray-400 uppercase font-black text-[10px] tracking-widest\">{t('monitor.details.duration')}</span>\n                                        <span className=\"font-mono font-semibold text-gray-900 dark:text-base-content text-xs\">{selectedLog.duration}ms</span>\n                                    </div>\n                                    <div className=\"space-y-1.5\">\n                                        <span className=\"block text-gray-500 dark:text-gray-400 uppercase font-black text-[10px] tracking-widest\">{t('monitor.details.tokens')}</span>\n                                        <div className=\"font-mono text-[11px] flex gap-2\">\n                                            <span className=\"text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/40 px-2.5 py-1 rounded-md border border-blue-200 dark:border-blue-800/50 font-bold\">In: {formatCompactNumber(selectedLog.input_tokens ?? 0)}</span>\n                                            <span className=\"text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/40 px-2.5 py-1 rounded-md border border-green-200 dark:border-green-800/50 font-bold\">Out: {formatCompactNumber(selectedLog.output_tokens ?? 0)}</span>\n                                        </div>\n                                    </div>\n                                </div>\n                                <div className=\"mt-5 pt-5 border-t border-gray-200 dark:border-base-300\">\n                                    <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5\">\n                                        {selectedLog.protocol && (\n                                            <div className=\"space-y-1.5\">\n                                                <span className=\"block text-gray-500 dark:text-gray-400 uppercase font-black text-[10px] tracking-widest\">{t('monitor.details.protocol')}</span>\n                                                <span className={`inline-block px-2.5 py-1 rounded-md font-mono font-black text-xs uppercase ${selectedLog.protocol === 'openai' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800/50' :\n                                                    selectedLog.protocol === 'anthropic' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400 border border-orange-200 dark:border-orange-800/50' :\n                                                        selectedLog.protocol === 'gemini' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400 border border-blue-200 dark:border-blue-800/50' :\n                                                            'bg-gray-100 text-gray-700 dark:bg-gray-900/40 dark:text-gray-400'\n                                                    }`}>\n                                                    {selectedLog.protocol}\n                                                </span>\n                                            </div>\n                                        )}\n                                        <div className=\"space-y-1.5\">\n                                            <span className=\"block text-gray-500 dark:text-gray-400 uppercase font-black text-[10px] tracking-widest\">{t('monitor.details.model')}</span>\n                                            <span className=\"font-mono font-black text-blue-600 dark:text-blue-400 break-all text-sm\">{selectedLog.model || '-'}</span>\n                                        </div>\n                                        {selectedLog.mapped_model && selectedLog.model !== selectedLog.mapped_model && (\n                                            <div className=\"space-y-1.5\">\n                                                <span className=\"block text-gray-500 dark:text-gray-400 uppercase font-black text-[10px] tracking-widest\">{t('monitor.details.mapped_model')}</span>\n                                                <span className=\"font-mono font-black text-green-600 dark:text-green-400 break-all text-sm\">{selectedLog.mapped_model}</span>\n                                            </div>\n                                        )}\n                                    </div>\n                                </div>\n                                {selectedLog.account_email && (\n                                    <div className=\"mt-5 pt-5 border-t border-gray-200 dark:border-base-300\">\n                                        <span className=\"block text-gray-500 dark:text-gray-400 uppercase font-black text-[10px] tracking-widest mb-2\">{t('monitor.details.account_used')}</span>\n                                        <span className=\"font-mono font-semibold text-gray-900 dark:text-base-content text-xs\">{selectedLog.account_email}</span>\n                                    </div>\n                                )}\n                            </div>\n\n                            {/* Payloads */}\n                            <div className=\"space-y-4\">\n                                <div>\n                                    <div className=\"flex items-center justify-between mb-2\">\n                                        <h3 className=\"text-xs font-bold uppercase text-gray-400 flex items-center gap-2\">{t('monitor.details.request_payload')}</h3>\n                                        <button\n                                            type=\"button\"\n                                            className=\"btn btn-ghost btn-xs gap-1\"\n                                            onClick={async () => {\n                                                if (!selectedLog.request_body) return;\n                                                const success = await copyToClipboard(getCopyPayload(selectedLog.request_body));\n                                                if (success) {\n                                                    setCopiedRequestId(selectedLog.id);\n                                                    setTimeout(() => {\n                                                        setCopiedRequestId((current) => (current === selectedLog.id ? null : current));\n                                                    }, 2000);\n                                                }\n                                            }}\n                                            disabled={!selectedLog.request_body}\n                                            title={copiedRequestId === selectedLog.id ? t('proxy.config.btn_copied') : t('proxy.config.btn_copy')}\n                                            aria-label={t('proxy.config.btn_copy')}\n                                        >\n                                            {copiedRequestId === selectedLog.id ? (\n                                                <CheckCircle size={12} className=\"text-green-500\" />\n                                            ) : (\n                                                <Copy size={12} />\n                                            )}\n                                            <span className=\"text-[10px]\">\n                                                {copiedRequestId === selectedLog.id ? t('proxy.config.btn_copied') : t('proxy.config.btn_copy')}\n                                            </span>\n                                        </button>\n                                    </div>\n                                    <div className=\"bg-gray-50 dark:bg-base-300 rounded-lg p-3 border border-gray-100 dark:border-base-300 overflow-hidden\">{formatBody(selectedLog.request_body)}</div>\n                                </div>\n                                <div>\n                                    <div className=\"flex items-center justify-between mb-2\">\n                                        <h3 className=\"text-xs font-bold uppercase text-gray-400 flex items-center gap-2\">{t('monitor.details.response_payload')}</h3>\n                                        <button\n                                            type=\"button\"\n                                            className=\"btn btn-ghost btn-xs gap-1\"\n                                            onClick={async () => {\n                                                if (!selectedLog.response_body) return;\n                                                const success = await copyToClipboard(getCopyPayload(selectedLog.response_body));\n                                                if (success) {\n                                                    setCopiedRequestId(selectedLog.id ? `${selectedLog.id}-response` : null);\n                                                    setTimeout(() => {\n                                                        setCopiedRequestId((current) =>\n                                                            current === `${selectedLog.id}-response` ? null : current\n                                                        );\n                                                    }, 2000);\n                                                }\n                                            }}\n                                            disabled={!selectedLog.response_body}\n                                            title={copiedRequestId === `${selectedLog.id}-response` ? t('proxy.config.btn_copied') : t('proxy.config.btn_copy')}\n                                            aria-label={t('proxy.config.btn_copy')}\n                                        >\n                                            {copiedRequestId === `${selectedLog.id}-response` ? (\n                                                <CheckCircle size={12} className=\"text-green-500\" />\n                                            ) : (\n                                                <Copy size={12} />\n                                            )}\n                                            <span className=\"text-[10px]\">\n                                                {copiedRequestId === `${selectedLog.id}-response` ? t('proxy.config.btn_copied') : t('proxy.config.btn_copy')}\n                                            </span>\n                                        </button>\n                                    </div>\n                                    <div className=\"bg-gray-50 dark:bg-base-300 rounded-lg p-3 border border-gray-100 dark:border-base-300 overflow-hidden\">{formatBody(selectedLog.response_body)}</div>\n                                </div>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            )}\n\n            <ModalDialog\n                isOpen={isClearConfirmOpen}\n                title={t('monitor.dialog.clear_title')}\n                message={t('monitor.dialog.clear_msg')}\n                type=\"confirm\"\n                confirmText={t('common.delete')}\n                isDestructive={true}\n                onConfirm={executeClearLogs}\n                onCancel={() => setIsClearConfirmOpen(false)}\n            />\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/components/proxy/SortableModelItem.tsx",
    "content": "import { useSortable } from '@dnd-kit/sortable';\nimport { CSS } from '@dnd-kit/utilities';\nimport { GripVertical, ChevronDown, ChevronRight, Trash2 } from 'lucide-react';\nimport { cn } from '../../utils/cn';\n\nexport interface PreviewModelEntry {\n    _uid: string;\n    model: string;\n    id: string;\n    index: number;\n    baseUrl: string;\n    apiKey: string;\n    displayName: string;\n    noImageSupport: boolean;\n    provider: string;\n    isAg: boolean;\n    [key: string]: unknown;\n}\n\nexport function SortableModelItem({ entry, collapsed, onToggle, onRemove }: {\n    entry: PreviewModelEntry;\n    collapsed: boolean;\n    onToggle: () => void;\n    onRemove?: () => void;\n}) {\n    const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry._uid });\n    const style = {\n        transform: CSS.Translate.toString(transform),\n        transition: isDragging ? 'none' : transition,\n    };\n\n    return (\n        <div ref={setNodeRef} style={style} className={cn(\n            \"rounded-lg border\",\n            isDragging ? \"opacity-60 z-50 shadow-lg\" : \"\",\n            entry.isAg\n                ? \"border-orange-200 dark:border-orange-800/40 bg-orange-50/50 dark:bg-orange-900/10\"\n                : \"border-gray-200 dark:border-base-300 bg-white dark:bg-base-100\"\n        )}>\n            <div className=\"flex items-center gap-1.5 px-2.5 py-1.5\">\n                <button {...attributes} {...listeners} className=\"cursor-grab active:cursor-grabbing p-0.5 text-gray-300 hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-400 touch-none\">\n                    <GripVertical size={14} />\n                </button>\n                <span className=\"text-[10px] font-mono font-bold text-gray-400 w-5 text-center shrink-0\">{entry.index}</span>\n                <button onClick={onToggle} className=\"p-0.5 text-gray-400 hover:text-gray-600\">\n                    {collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}\n                </button>\n                <span className=\"text-xs font-medium text-gray-800 dark:text-gray-200 flex-1 truncate\">{entry.displayName}</span>\n                {entry.isAg && <img src=\"/icon.png\" alt=\"AG\" className=\"w-4 h-4 rounded shrink-0\" />}\n                <span className=\"text-[9px] font-mono text-gray-400 shrink-0 hidden sm:block\">{entry.provider}</span>\n                {onRemove && (\n                    <button onClick={onRemove} className=\"p-0.5 text-gray-300 hover:text-red-500 transition-colors\" title=\"Remove\">\n                        <Trash2 size={12} />\n                    </button>\n                )}\n            </div>\n            {!collapsed && (\n                <div className=\"px-3 pb-2 pt-0.5 border-t border-gray-100 dark:border-base-200\">\n                    <pre className=\"text-[9px] font-mono text-gray-500 dark:text-gray-400 leading-relaxed whitespace-pre-wrap\">\n                        {JSON.stringify((() => { const { _uid, isAg, ...rest } = entry; return rest; })(), null, 2)}\n                    </pre>\n                </div>\n            )}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/components/security/BlacklistManager.tsx",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { request as invoke } from '../../utils/request';\nimport { Trash2, AlertCircle, Plus, Search, X } from 'lucide-react';\n\ninterface IpBlacklistEntry {\n    ip_pattern: string;\n    reason?: string;\n    added_at: number;\n    expires_at?: number;\n    added_by?: string;\n}\n\ninterface Props {\n    refreshKey?: number;\n}\n\nexport const BlacklistManager: React.FC<Props> = ({ refreshKey }) => {\n    const { t } = useTranslation();\n    const [entries, setEntries] = useState<IpBlacklistEntry[]>([]);\n    const [loading, setLoading] = useState(false);\n    const [search, setSearch] = useState('');\n\n    // Add Modal State\n    const [isAddOpen, setIsAddOpen] = useState(false);\n    const [newIp, setNewIp] = useState('');\n    const [newReason, setNewReason] = useState('');\n    const [newExpires, setNewExpires] = useState('');\n\n    const loadBlacklist = async () => {\n        setLoading(true);\n        try {\n            const data = await invoke<IpBlacklistEntry[]>('get_ip_blacklist');\n            setEntries(data);\n        } catch (e) {\n            console.error('Failed to load blacklist', e);\n        } finally {\n            setLoading(false);\n        }\n    };\n\n    useEffect(() => {\n        loadBlacklist();\n    }, [refreshKey]);\n\n    const handleAdd = async () => {\n        try {\n            let expiresAt = undefined;\n            if (newExpires) {\n                // Parse expires (e.g. \"24h\", \"7d\", or timestamp)\n                // generic simple parser for hours\n                const hours = parseInt(newExpires);\n                if (!isNaN(hours)) {\n                    expiresAt = Math.floor(Date.now() / 1000) + hours * 3600;\n                }\n            }\n\n            await invoke('add_ip_to_blacklist', {\n                request: {\n                    ipPattern: newIp,\n                    reason: newReason || null,\n                    expiresAt: expiresAt\n                }\n            });\n            setIsAddOpen(false);\n            setNewIp('');\n            setNewReason('');\n            setNewExpires('');\n            loadBlacklist();\n        } catch (e) {\n            console.error('Failed to add to blacklist', e);\n            const errorMsg = String(e);\n            if (errorMsg.includes('UNIQUE constraint')) {\n                alert(t('security.blacklist.error_duplicate') || 'This IP is already in the blacklist');\n            } else if (errorMsg.includes('Invalid IP pattern')) {\n                alert(t('security.blacklist.error_invalid_ip') || 'Invalid IP format. Please use IP address or CIDR notation (e.g., 192.168.1.0/24)');\n            } else {\n                alert(t('security.blacklist.error_add_failed') || 'Failed to add IP: ' + e);\n            }\n        }\n    };\n\n    const handleRemove = async (ipPattern: string) => {\n        // 乐观更新：立即从UI中移除\n        setEntries(prev => prev.filter(e => e.ip_pattern !== ipPattern));\n\n        try {\n            await invoke('remove_ip_from_blacklist', { ipPattern: ipPattern });\n        } catch (e) {\n            console.error('Failed to remove from blacklist', e);\n            // 如果删除失败，重新加载数据恢复UI\n            loadBlacklist();\n        }\n    };\n\n    const filteredEntries = entries.filter(e =>\n        e.ip_pattern.includes(search) || (e.reason && e.reason.toLowerCase().includes(search.toLowerCase()))\n    );\n\n    return (\n        <div className=\"flex flex-col h-full bg-white dark:bg-base-100 rounded-xl\">\n            <div className=\"p-5 border-b border-gray-100 dark:border-base-200 flex items-center gap-4\">\n                <button\n                    onClick={() => setIsAddOpen(true)}\n                    className=\"px-4 py-2 bg-white dark:bg-base-100 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-base-200 transition-colors flex items-center gap-2 shadow-sm border border-gray-200/50 dark:border-base-300\"\n                >\n                    <Plus size={16} /> {t('security.blacklist.add_ip')}\n                </button>\n\n                <div className=\"relative flex-1 max-w-md\">\n                    <Search className=\"absolute left-3 top-2.5 text-gray-400\" size={16} />\n                    <input\n                        type=\"text\"\n                        placeholder={t('security.blacklist.search_placeholder')}\n                        className=\"input input-sm input-bordered w-full pl-9\"\n                        value={search}\n                        onChange={(e) => setSearch(e.target.value)}\n                    />\n                </div>\n\n                <div className=\"flex-1\"></div>\n            </div>\n\n            <div className=\"flex-1 overflow-auto p-4\">\n                <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4\">\n                    {filteredEntries.map(entry => (\n                        <div key={entry.ip_pattern} className=\"bg-white dark:bg-base-100 border border-gray-100 dark:border-base-200 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow relative group\">\n                            <div className=\"flex items-start justify-between mb-2\">\n                                <h3 className=\"font-mono font-bold text-lg\">{entry.ip_pattern}</h3>\n                                <button\n                                    onClick={() => handleRemove(entry.ip_pattern)}\n                                    className=\"btn btn-ghost btn-xs text-red-500 opacity-0 group-hover:opacity-100 transition-opacity\"\n                                >\n                                    <Trash2 size={14} />\n                                </button>\n                            </div>\n\n                            {entry.reason && (\n                                <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2 flex items-center gap-1\">\n                                    <AlertCircle size={12} /> {entry.reason}\n                                </p>\n                            )}\n\n                            <div className=\"text-xs text-gray-400 flex flex-col gap-1 mt-3 pt-3 border-t border-gray-50 dark:border-base-200\">\n                                <span>{t('security.blacklist.added_at')}: {new Date(entry.added_at * 1000).toLocaleString()}</span>\n                                {entry.expires_at && (\n                                    <span className=\"text-orange-500\">{t('security.blacklist.expires_at')}: {new Date(entry.expires_at * 1000).toLocaleString()}</span>\n                                )}\n                            </div>\n                        </div>\n                    ))}\n                    {!loading && filteredEntries.length === 0 && (\n                        <div className=\"col-span-full text-center py-10 text-gray-400\">\n                            {t('security.blacklist.no_data')}\n                        </div>\n                    )}\n                </div>\n            </div>\n\n            {/* Add Modal */}\n            {isAddOpen && (\n                <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm\">\n                    <div className=\"bg-white dark:bg-base-100 rounded-lg shadow-xl w-full max-w-md p-6\">\n                        <div className=\"flex justify-between items-center mb-4\">\n                            <h3 className=\"text-lg font-bold\">{t('security.blacklist.add_title')}</h3>\n                            <button onClick={() => setIsAddOpen(false)} className=\"btn btn-ghost btn-sm btn-circle\">\n                                <X size={18} />\n                            </button>\n                        </div>\n\n                        <div className=\"space-y-4\">\n                            <div>\n                                <label className=\"label\">{t('security.blacklist.ip_cidr_label')}</label>\n                                <input\n                                    type=\"text\"\n                                    className=\"input input-bordered w-full\"\n                                    placeholder={t('security.blacklist.ip_cidr_placeholder')}\n                                    value={newIp}\n                                    onChange={e => setNewIp(e.target.value)}\n                                />\n                            </div>\n                            <div>\n                                <label className=\"label\">{t('security.blacklist.reason_label')}</label>\n                                <input\n                                    type=\"text\"\n                                    className=\"input input-bordered w-full\"\n                                    placeholder={t('security.blacklist.reason_placeholder')}\n                                    value={newReason}\n                                    onChange={e => setNewReason(e.target.value)}\n                                />\n                            </div>\n                            <div>\n                                <label className=\"label\">{t('security.blacklist.expires_label')}</label>\n                                <input\n                                    type=\"number\"\n                                    className=\"input input-bordered w-full\"\n                                    placeholder={t('security.blacklist.expires_placeholder')}\n                                    value={newExpires}\n                                    onChange={e => setNewExpires(e.target.value)}\n                                />\n                            </div>\n\n                            <div className=\"flex justify-end gap-3 mt-6\">\n                                <button\n                                    className=\"px-4 py-2 bg-gray-100 dark:bg-base-200 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-lg hover:bg-gray-200 dark:hover:bg-base-300 transition-colors\"\n                                    onClick={() => setIsAddOpen(false)}\n                                >\n                                    {t('security.blacklist.cancel')}\n                                </button>\n                                <button\n                                    className=\"px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg shadow-lg shadow-blue-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed\"\n                                    onClick={handleAdd}\n                                    disabled={!newIp}\n                                >\n                                    {t('security.blacklist.add_btn')}\n                                </button>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            )}\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/components/security/IpAccessLogs.tsx",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { request as invoke } from '../../utils/request';\nimport { Search, AlertTriangle } from 'lucide-react';\n\ninterface IpAccessLog {\n    id: string;\n    client_ip: string;\n    timestamp: number;\n    method?: string;\n    path?: string;\n    user_agent?: string;\n    status?: number;\n    duration?: number;\n    api_key_hash?: string;\n    blocked: boolean;\n    block_reason?: string;\n    username?: string;\n}\n\ninterface IpAccessLogResponse {\n    logs: IpAccessLog[];\n    total: number;\n}\n\ninterface Props {\n    refreshKey?: number;\n}\n\nexport const IpAccessLogs: React.FC<Props> = ({ refreshKey }) => {\n    const { t } = useTranslation();\n    const [logs, setLogs] = useState<IpAccessLog[]>([]);\n    const [total, setTotal] = useState(0);\n    const [loading, setLoading] = useState(false);\n    const [page, setPage] = useState(1);\n    const [pageSize, setPageSize] = useState(50);\n    const [search, setSearch] = useState('');\n    const [blockedOnly, setBlockedOnly] = useState(false);\n\n    const loadLogs = async () => {\n        setLoading(true);\n        try {\n            const res = await invoke<IpAccessLogResponse>('get_ip_access_logs', {\n                page,\n                pageSize: pageSize,\n                search: search || undefined,\n                blockedOnly: blockedOnly,\n            });\n            setLogs(res.logs);\n            setTotal(res.total);\n        } catch (e) {\n            console.error('Failed to load logs', e);\n        } finally {\n            setLoading(false);\n        }\n    };\n\n    useEffect(() => {\n        loadLogs();\n    }, [page, pageSize, blockedOnly, refreshKey]);\n\n    // Handle search on enter or blur\n    const handleSearch = () => {\n        setPage(1);\n        loadLogs();\n    };\n\n    return (\n        <div className=\"flex flex-col h-full bg-white dark:bg-base-100 rounded-xl\">\n            {/* Toolbar */}\n            <div className=\"p-5 border-b border-gray-100 dark:border-base-200 flex flex-wrap items-center gap-6\">\n                <div className=\"relative flex-1 min-w-[200px] max-w-md\">\n                    <Search className=\"absolute left-3 top-2.5 text-gray-400\" size={16} />\n                    <input\n                        type=\"text\"\n                        placeholder={t('security.logs.search_placeholder')}\n                        className=\"input input-sm input-bordered w-full pl-9\"\n                        value={search}\n                        onChange={(e) => setSearch(e.target.value)}\n                        onKeyDown={(e) => e.key === 'Enter' && handleSearch()}\n                        onBlur={handleSearch}\n                    />\n                </div>\n\n                <label className=\"label cursor-pointer gap-2 shrink-0\">\n                    <span className=\"label-text text-xs font-bold text-gray-500 uppercase\">{t('security.logs.show_blocked_only')}</span>\n                    <input\n                        type=\"checkbox\"\n                        className=\"toggle toggle-sm toggle-error\"\n                        checked={blockedOnly}\n                        onChange={(e) => setBlockedOnly(e.target.checked)}\n                    />\n                </label>\n\n                <div className=\"flex-1\"></div>\n\n                <div className=\"flex items-center gap-3 shrink-0\">\n                    <select\n                        className=\"select select-sm select-bordered min-w-[100px]\"\n                        value={pageSize}\n                        onChange={(e) => setPageSize(Number(e.target.value))}\n                    >\n                        <option value=\"20\">20{t('security.logs.per_page_suffix')}</option>\n                        <option value=\"50\">50{t('security.logs.per_page_suffix')}</option>\n                        <option value=\"100\">100{t('security.logs.per_page_suffix')}</option>\n                    </select>\n                </div>\n            </div>\n\n            {/* Table */}\n            <div className=\"flex-1 overflow-auto\">\n                <table className=\"table table-xs w-full\">\n                    <thead className=\"sticky top-0 bg-gray-100 dark:bg-base-200 z-10 shadow-sm text-gray-600 dark:text-gray-400\">\n                        <tr>\n                            <th className=\"w-20\">{t('security.logs.status')}</th>\n                            <th className=\"w-32\">{t('security.logs.ip_address')}</th>\n                            <th className=\"w-24\">{t('security.logs.username', 'User')}</th>\n                            <th className=\"w-20\">{t('security.logs.method')}</th>\n                            <th className=\"\">{t('security.logs.path')}</th>\n                            <th className=\"w-24 text-right\">{t('security.logs.duration')}</th>\n                            <th className=\"w-32 text-right\">{t('security.logs.time')}</th>\n                            <th className=\"w-40\">{t('security.logs.reason')}</th>\n                        </tr>\n                    </thead>\n                    <tbody>\n                        {logs.map((log) => (\n                            <tr key={log.id} className=\"hover:bg-gray-50 dark:hover:bg-base-200\">\n                                <td>\n                                    {log.blocked ? (\n                                        <span className=\"badge badge-xs badge-error gap-1 text-white\">\n                                            <AlertTriangle size={10} /> {t('security.logs.blocked')}\n                                        </span>\n                                    ) : (\n                                        <span className={`badge badge-xs text-white border-none ${log.status && log.status >= 200 && log.status < 400 ? 'badge-success' : 'badge-warning'}`}>\n                                            {log.status || '-'}\n                                        </span>\n                                    )}\n                                </td>\n                                <td className=\"font-mono font-medium\">{log.client_ip}</td>\n                                <td className=\"font-medium text-blue-600 dark:text-blue-400\">{log.username || '-'}</td>\n                                <td className=\"font-bold text-xs\">{log.method || '-'}</td>\n                                <td className=\"max-w-xs truncate text-gray-600 dark:text-gray-400\" title={log.path}>{log.path || '-'}</td>\n                                <td className=\"text-right font-mono\">{log.duration ? `${log.duration}ms` : '-'}</td>\n                                <td className=\"text-right text-xs text-gray-500\">{new Date(log.timestamp * 1000).toLocaleString()}</td>\n                                <td className=\"text-xs text-red-500 truncate\" title={log.block_reason}>{log.block_reason}</td>\n                            </tr>\n                        ))}\n                        {!loading && logs.length === 0 && (\n                            <tr>\n                                <td colSpan={8} className=\"text-center py-10 text-gray-400\">\n                                    {t('security.logs.no_logs')}\n                                </td>\n                            </tr>\n                        )}\n                    </tbody>\n                </table>\n            </div>\n\n            {/* Pagination */}\n            <div className=\"p-3 border-t border-gray-100 dark:border-base-200 flex items-center justify-between text-xs text-gray-500 bg-gray-50 dark:bg-base-200\">\n                <span>{t('security.logs.total_records', { total })}</span>\n                <div className=\"flex gap-2\">\n                    <button\n                        className=\"btn btn-xs\"\n                        disabled={page <= 1}\n                        onClick={() => setPage(p => p - 1)}\n                    >\n                        {t('security.logs.prev_page')}\n                    </button>\n                    <button className=\"btn btn-xs btn-active\">{t('security.logs.page_num', { page })}</button>\n                    <button\n                        className=\"btn btn-xs\"\n                        disabled={logs.length < pageSize}\n                        onClick={() => setPage(p => p + 1)}\n                    >\n                        {t('security.logs.next_page')}\n                    </button>\n                </div>\n            </div>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/components/security/IpStatistics.tsx",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { request as invoke } from '../../utils/request';\nimport { Activity, ShieldAlert, Users, Globe } from 'lucide-react';\nimport { formatCompactNumber } from '../../utils/format';\n\ninterface IpRanking {\n    client_ip: string;\n    request_count: number;\n    last_seen: number;\n    is_blocked: boolean;\n}\n\ninterface IpStatsResponse {\n    total_requests: number;\n    unique_ips: number;\n    blocked_requests: number;\n    top_ips: IpRanking[];\n}\n\ninterface IpTokenStats {\n    client_ip: string;\n    total_tokens: number;\n    input_tokens: number;\n    output_tokens: number;\n    request_count: number;\n    username?: string;\n}\n\ninterface Props {\n    refreshKey?: number;\n}\n\nexport const IpStatistics: React.FC<Props> = ({ refreshKey }) => {\n    const { t } = useTranslation();\n    const [stats, setStats] = useState<IpStatsResponse | null>(null);\n    const [tokenStats, setTokenStats] = useState<IpTokenStats[]>([]);\n    const [loading, setLoading] = useState(false);\n    const [timeRange, setTimeRange] = useState<number>(24);\n\n    const loadStats = async () => {\n        setLoading(true);\n        try {\n            const [statsData, tokenData] = await Promise.all([\n                invoke<IpStatsResponse>('get_ip_stats'),\n                invoke<IpTokenStats[]>('get_ip_token_stats', { limit: 20, hours: timeRange })\n            ]);\n            setStats(statsData);\n            setTokenStats(tokenData || []);\n        } catch (e) {\n            console.error('Failed to load stats', e);\n        } finally {\n            setLoading(false);\n        }\n    };\n\n    useEffect(() => {\n        loadStats();\n    }, [timeRange, refreshKey]);\n\n    const getTimeRangeLabel = () => {\n        switch (timeRange) {\n            case 1: return t('security.stats.hour');\n            case 24: return t('security.stats.day');\n            case 168: return t('security.stats.week');\n            case 720: return t('security.stats.month');\n            default: return `${timeRange} h`;\n        }\n    };\n\n    if (loading && !stats) {\n        return <div className=\"p-10 text-center\"><span className=\"loading loading-spinner\"></span></div>;\n    }\n\n    if (!stats) {\n        return <div className=\"p-10 text-center text-gray-500\">{t('security.stats.no_data')}</div>;\n    }\n\n    const maxReqCount = Math.max(...tokenStats.map(ip => ip.request_count), 1);\n\n    return (\n        <div className=\"h-full flex flex-col overflow-hidden\">\n            <div className=\"flex-1 overflow-y-scroll p-6 space-y-6\">\n\n                {/* Overview Cards */}\n                <div className=\"grid grid-cols-1 md:grid-cols-3 gap-6\">\n                    <div className=\"stat bg-white dark:bg-base-200 shadow rounded-xl border border-gray-100 dark:border-base-300\">\n                        <div className=\"stat-figure text-blue-500\">\n                            <Activity size={32} />\n                        </div>\n                        <div className=\"stat-title\">{t('security.stats.total_requests')}</div>\n                        <div className=\"stat-value text-blue-500\">{formatCompactNumber(stats.total_requests)}</div>\n                        <div className=\"stat-desc\">{t('security.stats.total_requests_desc')}</div>\n                    </div>\n\n                    <div className=\"stat bg-white dark:bg-base-200 shadow rounded-xl border border-gray-100 dark:border-base-300\">\n                        <div className=\"stat-figure text-purple-500\">\n                            <Users size={32} />\n                        </div>\n                        <div className=\"stat-title\">{t('security.stats.unique_ips')}</div>\n                        <div className=\"stat-value text-purple-500\">{formatCompactNumber(stats.unique_ips)}</div>\n                        <div className=\"stat-desc\">{t('security.stats.unique_ips_desc')}</div>\n                    </div>\n\n                    <div className=\"stat bg-white dark:bg-base-200 shadow rounded-xl border border-gray-100 dark:border-base-300\">\n                        <div className=\"stat-figure text-red-500\">\n                            <ShieldAlert size={32} />\n                        </div>\n                        <div className=\"stat-title\">{t('security.stats.blocked_requests')}</div>\n                        <div className=\"stat-value text-red-500\">{formatCompactNumber(stats.blocked_requests)}</div>\n                        <div className=\"stat-desc\">{t('security.stats.blocked_requests_desc')}</div>\n                    </div>\n                </div>\n\n                <div className=\"w-full\">\n                    {/* Combined IP Stats */}\n                    <div className=\"bg-white dark:bg-base-200 rounded-xl shadow-sm border border-gray-100 dark:border-base-300 overflow-hidden\">\n                        <div className=\"p-4 border-b border-gray-100 dark:border-base-300 flex items-center justify-between gap-2\">\n                            <div className=\"flex items-center gap-2\">\n                                <Globe size={20} className=\"text-blue-500\" />\n                                <h3 className=\"font-bold text-lg\">{t('security.stats.ip_activity_token_usage')} ({getTimeRangeLabel()})</h3>\n                            </div>\n                            <div className=\"flex gap-1\">\n                                <button\n                                    className={`btn btn-xs min-w-[48px] ${timeRange === 1 ? 'btn-active btn-primary' : ''}`}\n                                    onClick={() => setTimeRange(1)}\n                                >{t('security.stats.hour')}</button>\n                                <button\n                                    className={`btn btn-xs min-w-[48px] ${timeRange === 24 ? 'btn-active btn-primary' : ''}`}\n                                    onClick={() => setTimeRange(24)}\n                                >{t('security.stats.day')}</button>\n                                <button\n                                    className={`btn btn-xs min-w-[48px] ${timeRange === 168 ? 'btn-active btn-primary' : ''}`}\n                                    onClick={() => setTimeRange(168)}\n                                >{t('security.stats.week')}</button>\n                                <button\n                                    className={`btn btn-xs min-w-[48px] ${timeRange === 720 ? 'btn-active btn-primary' : ''}`}\n                                    onClick={() => setTimeRange(720)}\n                                >{t('security.stats.month')}</button>\n                            </div>\n                        </div>\n                        <div className=\"overflow-x-auto\">\n                            <table className=\"table w-full\">\n                                <thead>\n                                    <tr>\n                                        <th className=\"w-12\">{t('security.stats.rank')}</th>\n                                        <th>{t('security.stats.ip_address')}</th>\n                                        <th className=\"w-24\">{t('security.logs.username')}</th>\n                                        <th className=\"w-1/4\">{t('security.stats.activity_reqs')}</th>\n                                        <th className=\"text-right\">{t('security.stats.total_token')}</th>\n                                        <th className=\"text-right text-xs text-gray-500\">{t('security.stats.prompt')}</th>\n                                        <th className=\"text-right text-xs text-gray-500\">{t('security.stats.completion')}</th>\n                                    </tr>\n                                </thead>\n                                <tbody>\n                                    {tokenStats.map((ip, index) => {\n                                        // Determine color based on usage magnitude\n                                        let colorClass = \"text-green-500\";\n                                        if (ip.total_tokens > 1000000) colorClass = \"text-red-500 font-bold\";\n                                        else if (ip.total_tokens > 100000) colorClass = \"text-yellow-500 font-bold\";\n                                        else if (ip.total_tokens > 10000) colorClass = \"text-blue-500\";\n\n                                        const percentage = Math.min(100, Math.max(0, (ip.request_count / maxReqCount) * 100)) || 0;\n\n                                        return (\n                                            <tr key={ip.client_ip} className=\"hover:bg-gray-50 dark:hover:bg-base-300\">\n                                                <td className=\"font-bold text-gray-400\">#{index + 1}</td>\n                                                <td className=\"font-mono font-medium\">\n                                                    {ip.client_ip}\n                                                </td>\n                                                <td className=\"font-medium text-blue-600 dark:text-blue-400\">{ip.username || '-'}</td>\n                                                <td>\n                                                    <div className=\"flex flex-col gap-1\">\n                                                        <div className=\"flex justify-between text-xs text-gray-500\">\n                                                            <span>{formatCompactNumber(ip.request_count)} reqs</span>\n                                                            <span>{Math.round(percentage)}%</span>\n                                                        </div>\n                                                        <div className=\"w-full bg-gray-100 dark:bg-base-300 rounded-full h-1.5\">\n                                                            <div\n                                                                className=\"bg-blue-500 h-1.5 rounded-full transition-all duration-500\"\n                                                                style={{ width: `${percentage}%` }}\n                                                            ></div>\n                                                        </div>\n                                                    </div>\n                                                </td>\n                                                <td className={`text-right font-mono text-lg ${colorClass}`}>\n                                                    {formatCompactNumber(ip.total_tokens)}\n                                                </td>\n                                                <td className=\"text-right font-mono text-gray-500 text-xs\">\n                                                    {formatCompactNumber(ip.input_tokens)}\n                                                </td>\n                                                <td className=\"text-right font-mono text-gray-500 text-xs\">\n                                                    {formatCompactNumber(ip.output_tokens)}\n                                                </td>\n                                            </tr>\n                                        );\n                                    })}\n                                    {tokenStats.length === 0 && (\n                                        <tr>\n                                            <td colSpan={7} className=\"text-center py-8 text-gray-500\">\n                                                {t('security.stats.no_data')}\n                                            </td>\n                                        </tr>\n                                    )}\n                                </tbody>\n                            </table>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/components/security/SecurityConfig.tsx",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { request as invoke } from '../../utils/request';\nimport { Save, AlertTriangle, Shield, ShieldCheck } from 'lucide-react';\nimport { showToast } from '../common/ToastContainer';\n\ninterface IpBlacklistConfig {\n    enabled: boolean;\n    block_message: string;\n}\n\ninterface IpWhitelistConfig {\n    enabled: boolean;\n    whitelist_priority: boolean;\n}\n\ninterface SecurityMonitorConfig {\n    blacklist: IpBlacklistConfig;\n    whitelist: IpWhitelistConfig;\n}\n\nexport const SecurityConfig: React.FC = () => {\n    const { t } = useTranslation();\n    const [config, setConfig] = useState<SecurityMonitorConfig | null>(null);\n    const [loading, setLoading] = useState(false);\n    const [saving, setSaving] = useState(false);\n\n    useEffect(() => {\n        loadConfig();\n    }, []);\n\n    const loadConfig = async () => {\n        setLoading(true);\n        try {\n            const data = await invoke<SecurityMonitorConfig>('get_security_config');\n            setConfig(data);\n        } catch (e) {\n            console.error('Failed to load security config', e);\n            showToast(t('security.config.load_error'), 'error');\n        } finally {\n            setLoading(false);\n        }\n    };\n\n    const handleSave = async () => {\n        if (!config) return;\n        setSaving(true);\n        try {\n            await invoke('update_security_config', { config });\n            showToast(t('security.config.save_success'), 'success');\n        } catch (e) {\n            console.error('Failed to save security config', e);\n            showToast(t('security.config.save_error'), 'error');\n        } finally {\n            setSaving(false);\n        }\n    };\n\n    if (loading) {\n        return <div className=\"p-10 text-center\"><span className=\"loading loading-spinner\"></span></div>;\n    }\n\n    if (!config) {\n        return <div className=\"p-10 text-center text-error\">{t('security.config.load_error')}</div>;\n    }\n\n    return (\n        <div className=\"p-6 max-w-4xl mx-auto space-y-8 h-full overflow-y-auto\">\n            <div className=\"flex items-center justify-between\">\n                <h2 className=\"text-xl font-bold\">{t('security.config.title')}</h2>\n                <button\n                    onClick={handleSave}\n                    className=\"btn btn-primary gap-2\"\n                    disabled={saving}\n                >\n                    {saving ? <span className=\"loading loading-spinner loading-xs\"></span> : <Save size={18} />}\n                    {saving ? t('security.config.saving') : t('security.config.save')}\n                </button>\n            </div>\n\n            {/* Blacklist Settings */}\n            <div className=\"card bg-base-100 border border-gray-200 dark:border-base-300 shadow-sm\">\n                <div className=\"card-body\">\n                    <h3 className=\"card-title flex items-center gap-2 text-red-500\">\n                        <Shield size={24} />\n                        {t('security.config.blacklist_title')}\n                    </h3>\n                    <p className=\"text-sm text-gray-500 mb-4\">{t('security.config.blacklist_desc')}</p>\n\n                    <div className=\"form-control\">\n                        <label className=\"label cursor-pointer justify-start gap-4\">\n                            <input\n                                type=\"checkbox\"\n                                className=\"toggle toggle-error\"\n                                checked={config.blacklist.enabled}\n                                onChange={(e) => setConfig({\n                                    ...config,\n                                    blacklist: { ...config.blacklist, enabled: e.target.checked }\n                                })}\n                            />\n                            <span className=\"label-text font-medium\">{t('security.config.enable_blacklist')}</span>\n                        </label>\n                    </div>\n\n                    <div className=\"form-control w-full mt-4\">\n                        <label className=\"label\">\n                            <span className=\"label-text\">{t('security.config.block_msg_label')}</span>\n                        </label>\n                        <input\n                            type=\"text\"\n                            className=\"input input-bordered w-full\"\n                            value={config.blacklist.block_message}\n                            onChange={(e) => setConfig({\n                                ...config,\n                                blacklist: { ...config.blacklist, block_message: e.target.value }\n                            })}\n                        />\n                        <label className=\"label\">\n                            <span className=\"label-text-alt text-gray-400\">{t('security.config.block_msg_desc')}</span>\n                        </label>\n                    </div>\n                </div>\n            </div>\n\n            {/* Whitelist Settings */}\n            <div className=\"card bg-base-100 border border-gray-200 dark:border-base-300 shadow-sm\">\n                <div className=\"card-body\">\n                    <h3 className=\"card-title flex items-center gap-2 text-green-500\">\n                        <ShieldCheck size={24} />\n                        {t('security.config.whitelist_title')}\n                    </h3>\n                    <p className=\"text-sm text-gray-500 mb-4\">{t('security.config.whitelist_desc')}</p>\n\n                    <div className=\"form-control\">\n                        <label className=\"label cursor-pointer justify-start gap-4\">\n                            <input\n                                type=\"checkbox\"\n                                className=\"toggle toggle-success\"\n                                checked={config.whitelist.enabled}\n                                onChange={(e) => setConfig({\n                                    ...config,\n                                    whitelist: { ...config.whitelist, enabled: e.target.checked }\n                                })}\n                            />\n                            <span className=\"label-text font-medium\">{t('security.config.enable_whitelist')}</span>\n                        </label>\n                        <div className=\"text-xs text-gray-500 ml-14 mt-1 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded flex items-start gap-2\">\n                            <AlertTriangle size={14} className=\"mt-0.5 text-yellow-600 dark:text-yellow-400 shrink-0\" />\n                            {t('security.config.whitelist_warning')}\n                        </div>\n                    </div>\n\n                    <div className=\"form-control mt-4\">\n                        <label className=\"label cursor-pointer justify-start gap-4\">\n                            <input\n                                type=\"checkbox\"\n                                className=\"checkbox checkbox-success\"\n                                checked={config.whitelist.whitelist_priority}\n                                disabled={!config.whitelist.enabled}\n                                onChange={(e) => setConfig({\n                                    ...config,\n                                    whitelist: { ...config.whitelist, whitelist_priority: e.target.checked }\n                                })}\n                            />\n                            <span className=\"label-text font-medium\">{t('security.config.whitelist_priority')}</span>\n                        </label>\n                        <label className=\"label ml-8 pt-0\">\n                            <span className=\"label-text-alt text-gray-400\">{t('security.config.whitelist_priority_desc')}</span>\n                        </label>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/components/security/WhitelistManager.tsx",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { request as invoke } from '../../utils/request';\nimport { Trash2, Check, Plus, Search, X, ShieldCheck } from 'lucide-react';\n\ninterface IpWhitelistEntry {\n    ip_pattern: string;\n    description?: string;\n    added_at: number;\n    added_by?: string;\n}\n\ninterface Props {\n    refreshKey?: number;\n}\n\nexport const WhitelistManager: React.FC<Props> = ({ refreshKey }) => {\n    const { t } = useTranslation();\n    const [entries, setEntries] = useState<IpWhitelistEntry[]>([]);\n    const [loading, setLoading] = useState(false);\n    const [search, setSearch] = useState('');\n\n    // Add Modal State\n    const [isAddOpen, setIsAddOpen] = useState(false);\n    const [newIp, setNewIp] = useState('');\n    const [newDescription, setNewDescription] = useState('');\n\n    const loadWhitelist = async () => {\n        setLoading(true);\n        try {\n            const data = await invoke<IpWhitelistEntry[]>('get_ip_whitelist');\n            setEntries(data);\n        } catch (e) {\n            console.error('Failed to load whitelist', e);\n        } finally {\n            setLoading(false);\n        }\n    };\n\n    useEffect(() => {\n        loadWhitelist();\n    }, [refreshKey]);\n\n    const handleAdd = async () => {\n        try {\n            await invoke('add_ip_to_whitelist', {\n                request: {\n                    ipPattern: newIp,\n                    description: newDescription || null,\n                }\n            });\n            setIsAddOpen(false);\n            setNewIp('');\n            setNewDescription('');\n            loadWhitelist();\n        } catch (e) {\n            console.error('Failed to add to whitelist', e);\n            alert('Failed to add IP: ' + e);\n        }\n    };\n\n    const handleRemove = async (ipPattern: string) => {\n        // 乐观更新：立即从UI中移除\n        setEntries(prev => prev.filter(e => e.ip_pattern !== ipPattern));\n\n        try {\n            await invoke('remove_ip_from_whitelist', { ipPattern: ipPattern });\n        } catch (e) {\n            console.error('Failed to remove from whitelist', e);\n            // 如果删除失败，重新加载数据恢复UI\n            loadWhitelist();\n        }\n    };\n\n    const filteredEntries = entries.filter(e =>\n        e.ip_pattern.includes(search) || (e.description && e.description.toLowerCase().includes(search.toLowerCase()))\n    );\n\n    return (\n        <div className=\"flex flex-col h-full bg-white dark:bg-base-100 rounded-xl\">\n            <div className=\"p-5 border-b border-gray-100 dark:border-base-200 flex items-center gap-4\">\n                <button\n                    onClick={() => setIsAddOpen(true)}\n                    className=\"px-4 py-2 bg-white dark:bg-base-100 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-base-200 transition-colors flex items-center gap-2 shadow-sm border border-gray-200/50 dark:border-base-300\"\n                >\n                    <Plus size={16} /> {t('security.whitelist.add_ip')}\n                </button>\n\n                <div className=\"relative flex-1 max-w-md\">\n                    <Search className=\"absolute left-3 top-2.5 text-gray-400\" size={16} />\n                    <input\n                        type=\"text\"\n                        placeholder={t('security.blacklist.search_placeholder')}\n                        className=\"input input-sm input-bordered w-full pl-9\"\n                        value={search}\n                        onChange={(e) => setSearch(e.target.value)}\n                    />\n                </div>\n\n                <div className=\"flex-1\"></div>\n            </div>\n\n            <div className=\"flex-1 overflow-auto p-4\">\n                <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4\">\n                    {filteredEntries.map(entry => (\n                        <div key={entry.ip_pattern} className=\"bg-white dark:bg-base-100 border border-green-100 dark:border-green-900/30 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow relative group\">\n                            <div className=\"absolute top-0 right-0 p-2 opacity-10\">\n                                <ShieldCheck size={64} className=\"text-green-500\" />\n                            </div>\n\n                            <div className=\"flex items-start justify-between mb-2 relative z-10\">\n                                <h3 className=\"font-mono font-bold text-lg text-green-700 dark:text-green-400\">{entry.ip_pattern}</h3>\n                                <button\n                                    onClick={() => handleRemove(entry.ip_pattern)}\n                                    className=\"btn btn-ghost btn-xs text-red-500 opacity-0 group-hover:opacity-100 transition-opacity\"\n                                >\n                                    <Trash2 size={14} />\n                                </button>\n                            </div>\n\n                            {entry.description && (\n                                <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2 flex items-center gap-1 relative z-10\">\n                                    <Check size={12} className=\"text-green-500\" /> {entry.description}\n                                </p>\n                            )}\n\n                            <div className=\"text-xs text-gray-400 flex flex-col gap-1 mt-3 pt-3 border-t border-gray-50 dark:border-base-200 relative z-10\">\n                                <span>{t('security.blacklist.added_at')}: {new Date(entry.added_at * 1000).toLocaleString()}</span>\n                            </div>\n                        </div>\n                    ))}\n                    {!loading && filteredEntries.length === 0 && (\n                        <div className=\"col-span-full text-center py-10 text-gray-400\">\n                            {t('security.whitelist.no_data')}\n                        </div>\n                    )}\n                </div>\n            </div>\n\n            {/* Add Modal */}\n            {isAddOpen && (\n                <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm\">\n                    <div className=\"bg-white dark:bg-base-100 rounded-lg shadow-xl w-full max-w-md p-6\">\n                        <div className=\"flex justify-between items-center mb-4\">\n                            <h3 className=\"text-lg font-bold\">{t('security.whitelist.add_title')}</h3>\n                            <button onClick={() => setIsAddOpen(false)} className=\"btn btn-ghost btn-sm btn-circle\">\n                                <X size={18} />\n                            </button>\n                        </div>\n\n                        <div className=\"space-y-4\">\n                            <div>\n                                <label className=\"label\">{t('security.blacklist.ip_cidr_label')}</label>\n                                <input\n                                    type=\"text\"\n                                    className=\"input input-bordered w-full\"\n                                    placeholder={t('security.blacklist.ip_cidr_placeholder')}\n                                    value={newIp}\n                                    onChange={e => setNewIp(e.target.value)}\n                                />\n                            </div>\n                            <div>\n                                <label className=\"label\">{t('security.whitelist.description_label')}</label>\n                                <input\n                                    type=\"text\"\n                                    className=\"input input-bordered w-full\"\n                                    placeholder={t('security.whitelist.description_placeholder')}\n                                    value={newDescription}\n                                    onChange={e => setNewDescription(e.target.value)}\n                                />\n                            </div>\n\n                            <div className=\"flex justify-end gap-3 mt-6\">\n                                <button\n                                    className=\"px-4 py-2 bg-gray-100 dark:bg-base-200 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-lg hover:bg-gray-200 dark:hover:bg-base-300 transition-colors\"\n                                    onClick={() => setIsAddOpen(false)}\n                                >\n                                    {t('security.whitelist.cancel')}\n                                </button>\n                                <button\n                                    className=\"px-4 py-2 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-lg shadow-lg shadow-emerald-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed\"\n                                    onClick={handleAdd}\n                                    disabled={!newIp}\n                                >\n                                    {t('security.whitelist.add_btn')}\n                                </button>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            )}\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/components/settings/AdvancedThinking.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { BrainCircuit } from \"lucide-react\";\nimport { ProxyConfig } from \"../../types/config\";\nimport ThinkingBudget from \"./ThinkingBudget\";\nimport GlobalSystemPrompt from \"./GlobalSystemPrompt\";\nimport ImageThinkingMode from \"./ImageThinkingMode\";\n\ninterface AdvancedThinkingProps {\n    config: ProxyConfig;\n    onChange: (config: ProxyConfig) => void;\n}\n\nexport default function AdvancedThinking({\n    config,\n    onChange,\n}: AdvancedThinkingProps) {\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"space-y-4\">\n            <div className=\"bg-white dark:bg-base-100 rounded-xl p-4 border border-gray-100 dark:border-base-200 shadow-sm\">\n                <div className=\"flex items-center gap-3 mb-4\">\n                    <div className=\"p-1.5 bg-indigo-50 dark:bg-indigo-900/20 rounded text-indigo-600 dark:text-indigo-400\">\n                        <BrainCircuit size={20} />\n                    </div>\n                    <div>\n                        <h3 className=\"text-base font-bold text-gray-900 dark:text-gray-100 leading-none\">\n                            {t(\"settings.advanced_thinking.title\", { defaultValue: \"高级思维与全局配置\" })}\n                        </h3>\n                        <p className=\"text-[11px] text-gray-500 dark:text-gray-400 mt-1\">\n                            {t(\"settings.advanced_thinking.description\", { defaultValue: \"集中管理思考能力、图像模式及全局指令。\" })}\n                        </p>\n                    </div>\n                </div>\n\n                <div className=\"space-y-4 divide-y divide-gray-100 dark:divide-gray-800\">\n                    {/* 1. 思考预算 (Thinking Budget) */}\n                    <div className=\"pt-0\">\n                        <ThinkingBudget\n                            config={config.thinking_budget || { mode: 'auto', custom_value: 24576 }}\n                            onChange={(newConfig) => onChange({ ...config, thinking_budget: newConfig })}\n                        />\n                    </div>\n\n                    {/* 2. 图像思维模式 (Image Thinking Mode) */}\n                    <div className=\"pt-4\">\n                        <ImageThinkingMode\n                            value={config.image_thinking_mode || 'enabled'}\n                            onChange={(newValue) => onChange({ ...config, image_thinking_mode: newValue })}\n                        />\n                    </div>\n\n                    {/* 3. 全局系统提示词 (Global System Prompt) */}\n                    <div className=\"pt-4\">\n                        <GlobalSystemPrompt\n                            config={config.global_system_prompt || { enabled: false, content: '' }}\n                            onChange={(newConfig) => onChange({ ...config, global_system_prompt: newConfig })}\n                        />\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/components/settings/CircuitBreaker.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { CircuitBreakerConfig } from \"../../types/config\";\nimport { ShieldAlert, Trash2, Plus, Minus, Clock } from \"lucide-react\";\n\ninterface CircuitBreakerProps {\n    config: CircuitBreakerConfig;\n    onChange: (config: CircuitBreakerConfig) => void;\n    onClearRateLimits?: () => void;\n}\n\nexport default function CircuitBreaker({\n    config,\n    onChange,\n    onClearRateLimits,\n}: CircuitBreakerProps) {\n    const { t } = useTranslation();\n\n    const handleLevelChange = (index: number, val: string) => {\n        let num = parseInt(val, 10);\n        if (isNaN(num)) num = 0;\n\n        const newSteps = [...config.backoff_steps];\n        newSteps[index] = Math.max(0, num);\n        onChange({ ...config, backoff_steps: newSteps });\n    };\n\n    const addLevel = () => {\n        const lastVal = config.backoff_steps[config.backoff_steps.length - 1] || 60;\n        onChange({\n            ...config,\n            backoff_steps: [...config.backoff_steps, lastVal * 2],\n        });\n    };\n\n    const removeLevel = (index: number) => {\n        if (config.backoff_steps.length <= 1) return;\n        const newSteps = config.backoff_steps.filter((_, i) => i !== index);\n        onChange({ ...config, backoff_steps: newSteps });\n    };\n\n    const getStepColorCls = (index: number) => {\n        if (index === 0) return \"border-yellow-200 dark:border-yellow-700/50 bg-yellow-50/30 dark:bg-yellow-900/10\";\n        if (index === 1) return \"border-orange-200 dark:border-orange-700/50 bg-orange-50/30 dark:bg-orange-900/10\";\n        if (index === 2) return \"border-red-200 dark:border-red-700/50 bg-red-50/30 dark:bg-red-900/10\";\n        return \"border-rose-200 dark:border-rose-700/50 bg-rose-50/30 dark:bg-rose-900/10\";\n    };\n\n    return (\n        <div className=\"space-y-6\">\n            <div className=\"bg-orange-50/50 dark:bg-orange-900/10 border border-orange-100 dark:border-orange-800/30 rounded-lg p-4\">\n                <div className=\"flex gap-3\">\n                    <ShieldAlert className=\"w-5 h-5 text-orange-500 shrink-0 mt-0.5\" />\n                    <div className=\"space-y-1\">\n                        <h4 className=\"font-medium text-sm text-gray-900 dark:text-gray-100\">\n                            {t(\"proxy.config.circuit_breaker.title\", { defaultValue: \"Adaptive Circuit Breaker\" })}\n                        </h4>\n                        <p className=\"text-xs text-gray-500 dark:text-gray-400 leading-relaxed\">\n                            {t(\"proxy.config.circuit_breaker.tooltip\", {\n                                defaultValue: \"Automatically increases lockout duration for accounts that repeatedly fail with quota exhaustion. This prevents wasting API calls on dead accounts while allowing transient errors to recover quickly.\",\n                            })}\n                        </p>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"space-y-4\">\n                <div className=\"flex justify-between items-center\">\n                    <label className=\"text-sm font-bold text-gray-700 dark:text-gray-300 flex items-center gap-2\">\n                        <Clock className=\"w-4 h-4 text-blue-500\" />\n                        {t(\"proxy.config.circuit_breaker.backoff_levels\", { defaultValue: \"Backoff Levels (Seconds)\" })}\n                    </label>\n                    <button\n                        onClick={(e) => { e.stopPropagation(); addLevel(); }}\n                        className=\"btn btn-xs btn-ghost text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 gap-1 h-7 min-h-0 px-2 rounded-md border border-blue-200 dark:border-blue-800/50 shadow-sm\"\n                    >\n                        <Plus size={14} />\n                        {t(\"common.add\", { defaultValue: \"Add\" })}\n                    </button>\n                </div>\n\n                <div className=\"grid grid-cols-2 md:grid-cols-4 gap-3\">\n                    {config.backoff_steps.map((seconds, idx) => (\n                        <div\n                            key={idx}\n                            className={`p-3 rounded-xl border transition-all hover:shadow-sm group relative ${getStepColorCls(idx)}`}\n                        >\n                            <div className=\"flex flex-col gap-2\">\n                                <div className=\"flex justify-between items-center\">\n                                    <span className=\"text-[10px] font-bold uppercase tracking-wider opacity-60\">\n                                        {t(\"proxy.config.circuit_breaker.level\", { level: idx + 1, defaultValue: `Lv ${idx + 1}` })}\n                                    </span>\n                                    {config.backoff_steps.length > 1 && (\n                                        <button\n                                            onClick={(e) => { e.stopPropagation(); removeLevel(idx); }}\n                                            className=\"opacity-0 group-hover:opacity-100 p-1 hover:text-red-500 transition-opacity\"\n                                            title={t(\"common.delete\", { defaultValue: \"Delete\" })}\n                                        >\n                                            <Minus size={12} />\n                                        </button>\n                                    )}\n                                </div>\n                                <div className=\"relative\">\n                                    <input\n                                        type=\"number\"\n                                        value={seconds}\n                                        onChange={(e) => handleLevelChange(idx, e.target.value)}\n                                        className=\"w-full bg-white dark:bg-base-100 border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm font-mono focus:ring-2 focus:ring-blue-500/20 outline-none transition-all [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"\n                                        min=\"0\"\n                                    />\n                                    <span className=\"absolute right-3 top-1/2 -translate-y-1/2 text-[10px] font-bold opacity-30 select-none pointer-events-none\">S</span>\n                                </div>\n                            </div>\n                        </div>\n                    ))}\n                </div>\n            </div>\n\n            {onClearRateLimits && (\n                <div className=\"pt-2 border-t border-gray-100 dark:border-gray-800\">\n                    <button\n                        onClick={onClearRateLimits}\n                        className=\"btn btn-sm btn-ghost text-gray-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 gap-2 w-full justify-start h-auto py-2 px-1\"\n                    >\n                        <Trash2 className=\"w-4 h-4\" />\n                        <span className=\"text-xs\">\n                            {t(\"proxy.config.circuit_breaker.clear_records\", { defaultValue: \"Clear All Rate Limit Records\" })}\n                        </span>\n                    </button>\n                </div>\n            )}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/components/settings/GlobalSystemPrompt.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { GlobalSystemPromptConfig } from \"../../types/config\";\n\ninterface GlobalSystemPromptProps {\n    config: GlobalSystemPromptConfig;\n    onChange: (config: GlobalSystemPromptConfig) => void;\n}\n\nconst DEFAULT_CONFIG: GlobalSystemPromptConfig = {\n    enabled: false,\n    content: '',\n};\n\nexport default function GlobalSystemPrompt({\n    config = DEFAULT_CONFIG,\n    onChange,\n}: GlobalSystemPromptProps) {\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"space-y-3\">\n            {/* 标题区域 (Compact) */}\n            <div className=\"flex items-center justify-between gap-3 bg-purple-50/30 dark:bg-purple-900/5 border border-purple-100/50 dark:border-purple-800/20 rounded-lg px-4 py-3\">\n                <div className=\"space-y-0.5\">\n                    <h4 className=\"font-bold text-sm text-gray-900 dark:text-gray-100\">\n                        {t(\"settings.global_system_prompt.title\", { defaultValue: \"全局系统提示词 (Global System Prompt)\" })}\n                    </h4>\n                    <p className=\"text-[10px] text-gray-500 dark:text-gray-400\">\n                        {t(\"settings.global_system_prompt.hint\", { defaultValue: \"自动注入所有请求的 systemInstruction\" })}\n                    </p>\n                </div>\n\n                <div className=\"flex items-center gap-3\">\n                    <span className={`text-[10px] font-medium ${config.enabled ? 'text-purple-600 dark:text-purple-400' : 'text-gray-400'}`}>\n                        {config.enabled ? t(\"common.enabled\", { defaultValue: \"已启用\" }) : t(\"common.disabled\", { defaultValue: \"已禁用\" })}\n                    </span>\n                    <label className=\"relative inline-flex items-center cursor-pointer shrink-0\">\n                        <input\n                            type=\"checkbox\"\n                            checked={config.enabled}\n                            onChange={(e) => onChange({ ...config, enabled: e.target.checked })}\n                            className=\"sr-only peer\"\n                        />\n                        <div className=\"w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-600 peer-checked:bg-purple-600\"></div>\n                    </label>\n                </div>\n            </div>\n\n            {/* 编辑区域 (仅在启用时显示) */}\n            {config.enabled && (\n                <div className=\"space-y-3\">\n                    <textarea\n                        value={config.content}\n                        onChange={(e) => onChange({ ...config, content: e.target.value })}\n                        placeholder={t(\"settings.global_system_prompt.placeholder\", {\n                            defaultValue: \"输入全局系统提示词...\\n例如：你是一位资深的全栈开发工程师，擅长 React 和 Rust。请使用简体中文回复。\",\n                        })}\n                        rows={6}\n                        className=\"w-full bg-white dark:bg-base-100 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm focus:ring-2 focus:ring-purple-500/20 outline-none transition-all resize-y min-h-[120px]\"\n                    />\n                    <div className=\"flex items-center justify-between\">\n                        <p className=\"text-xs text-gray-400 dark:text-gray-500\">\n                            {t(\"settings.global_system_prompt.char_count\", {\n                                defaultValue: \"{{count}} 字符\",\n                                count: config.content.length,\n                            })}\n                        </p>\n                    </div>\n                    {config.content.length > 2000 && (\n                        <div className=\"bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700/30 rounded-lg p-3\">\n                            <p className=\"text-xs text-amber-700 dark:text-amber-400\">\n                                {t(\"settings.global_system_prompt.long_prompt_warning\", {\n                                    defaultValue: \"提示词较长（超过 2000 字符），可能会占用较多的上下文窗口空间，影响模型可用的对话长度。\",\n                                })}\n                            </p>\n                        </div>\n                    )}\n                </div>\n            )}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/components/settings/ImageThinkingMode.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { Image } from \"lucide-react\";\n\ninterface ImageThinkingModeProps {\n    value?: 'enabled' | 'disabled';\n    onChange: (value: 'enabled' | 'disabled') => void;\n}\n\nexport default function ImageThinkingMode({\n    value = 'enabled',\n    onChange,\n}: ImageThinkingModeProps) {\n    const { t } = useTranslation();\n\n    const options = [\n        { value: 'enabled', label: 'enabled', desc: 'enabled_desc' },\n        { value: 'disabled', label: 'disabled', desc: 'disabled_desc' },\n    ] as const;\n\n    return (\n        <div className=\"space-y-3\">\n            <div className=\"flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-pink-50/30 dark:bg-pink-900/5 border border-pink-100/50 dark:border-pink-800/20 rounded-lg px-4 py-3\">\n                <div className=\"flex items-center gap-3\">\n                    <div className=\"p-1.5 bg-pink-100 dark:bg-pink-900/30 rounded text-pink-600 dark:text-pink-400\">\n                        <Image size={18} />\n                    </div>\n                    <div className=\"space-y-0.5\">\n                        <h4 className=\"font-bold text-sm text-gray-900 dark:text-gray-100\">\n                            {t(\"settings.image_thinking_mode.title\", { defaultValue: \"图像思维模式 (Image Thinking Mode)\" })}\n                        </h4>\n                        <p className=\"text-[10px] text-gray-500 dark:text-gray-400\">\n                            {t(\"settings.image_thinking_mode.hint\", { defaultValue: \"影响画质与生成流程\" })}\n                        </p>\n                    </div>\n                </div>\n\n                <div className=\"flex bg-gray-100 dark:bg-gray-800 p-1 rounded-lg\">\n                    {options.map((option) => (\n                        <button\n                            key={option.value}\n                            onClick={() => onChange(option.value)}\n                            className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${value === option.value\n                                ? 'bg-white dark:bg-gray-700 text-pink-600 dark:text-pink-400 shadow-sm'\n                                : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'\n                                }`}\n                        >\n                            {t(`settings.image_thinking_mode.options.${option.label}`, {\n                                defaultValue: option.value === 'enabled' ? \"开启\" : \"关闭\"\n                            })}\n                        </button>\n                    ))}\n                </div>\n            </div>\n\n            <div className=\"px-1\">\n                <p className=\"text-[10px] text-gray-400 dark:text-gray-500 italic leading-relaxed\">\n                    {value === 'enabled'\n                        ? t(\"settings.image_thinking_mode.options.enabled_desc\", { defaultValue: \"开启：保留思维链，返回草图 + 成品双图。\" })\n                        : t(\"settings.image_thinking_mode.options.disabled_desc\", { defaultValue: \"关闭：禁用思维链，直接生成单张超清图片（画质优先）。\" })\n                    }\n                </p>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/components/settings/PinnedQuotaModels.tsx",
    "content": "import { Pin, Check } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { PinnedQuotaModelsConfig } from '../../types/config';\nimport { MODEL_CONFIG } from '../../config/modelConfig';\nimport { useAccountStore } from '../../stores/useAccountStore';\n\ninterface PinnedQuotaModelsProps {\n    config: PinnedQuotaModelsConfig;\n    onChange: (config: PinnedQuotaModelsConfig) => void;\n}\n\nconst PinnedQuotaModels = ({ config, onChange }: PinnedQuotaModelsProps) => {\n    const { t } = useTranslation();\n\n    const toggleModel = (model: string) => {\n        const currentModels = config.models || [];\n        let newModels: string[];\n\n        if (currentModels.includes(model)) {\n            // 至少保留一个模型\n            if (currentModels.length <= 1) return;\n            newModels = currentModels.filter(m => m !== model);\n        } else {\n            newModels = [...currentModels, model];\n        }\n\n        onChange({ ...config, models: newModels });\n    };\n\n    const { accounts } = useAccountStore();\n    const uniqueIds = new Set<string>();\n\n    // 先收集所有已知模型的 id 和 protectedKey，防止他们作为未知的 \"动态抽出模型\" 出现\n    Object.entries(MODEL_CONFIG).forEach(([id, cfg]) => {\n        uniqueIds.add(id.toLowerCase());\n        if (cfg.protectedKey) {\n            uniqueIds.add(cfg.protectedKey.toLowerCase());\n        }\n    });\n\n    const addedDisplayLabels = new Set<string>();\n\n    // 基础内置配置模型\n    const baseModels = Object.entries(MODEL_CONFIG)\n        .filter(([id, cfg]) => {\n            // 隐藏思考变体\n            if (id.includes('thinking')) return false;\n\n            const labelKey = (cfg.shortLabel || cfg.label).toLowerCase();\n            // 在这一层，如果展示用的 labelKey 已经被加过了，就不要重复加到外派的选项里了\n            if (addedDisplayLabels.has(labelKey)) return false;\n            addedDisplayLabels.add(labelKey);\n            return true;\n        })\n        .map(([id, cfg]) => ({\n            id,\n            label: id,\n            desc: cfg.shortLabel || cfg.label || t(cfg.i18nDescKey || cfg.i18nKey, cfg.label)\n        }));\n\n    // 提取所有账号的历史动态模型\n    const dynamicModels = accounts.flatMap(a => a.quota?.models || [])\n        .filter(m => {\n            const id = m.name.toLowerCase();\n            if (id.includes('thinking')) return false;\n            // 查重：避免内置里已经包含的模型或同名 id 重复\n            if (uniqueIds.has(id)) return false;\n            uniqueIds.add(id);\n            return true;\n        })\n        .map(m => ({\n            id: m.name.toLowerCase(),\n            label: m.name.toLowerCase(),\n            desc: m.display_name || t('settings.pinned_quota_models.dynamic', 'Dynamic Extracted Model')\n        }));\n\n    const modelOptions = [...baseModels, ...dynamicModels];\n\n    // [FIX] Ensure previously pinned but unknown/hidden models are still rendered so users can un-pin them\n    const currentChecked = config.models || [];\n    currentChecked.forEach(modelId => {\n        if (!modelOptions.some(m => m.id === modelId)) {\n            // 尝试在历史配额中找到它的真实名字 (为了应对如 thinking 模型被隐藏但在关注列表里等情况)\n            const quotaModel = accounts.flatMap(a => a.quota?.models || []).find(m => m.name.toLowerCase() === modelId.toLowerCase());\n            const cfg = MODEL_CONFIG[modelId.toLowerCase()];\n\n            modelOptions.push({\n                id: modelId,\n                label: modelId,\n                desc: quotaModel?.display_name || cfg?.shortLabel || cfg?.label || t('common.unknown', '未知')\n            });\n        }\n    });\n\n    return (\n        <div className=\"animate-in fade-in duration-500\">\n            <div className=\"flex items-center gap-4\">\n                {/* 图标部分 - 使用蓝紫色调 */}\n                <div className=\"w-10 h-10 rounded-xl bg-indigo-50 dark:bg-indigo-900/20 flex items-center justify-center text-indigo-500 group-hover:bg-indigo-500 group-hover:text-white transition-all duration-300\">\n                    <Pin size={20} />\n                </div>\n                <div>\n                    <div className=\"font-bold text-gray-900 dark:text-gray-100\">\n                        {t('settings.pinned_quota_models.title')}\n                    </div>\n                    <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-0.5\">\n                        {t('settings.pinned_quota_models.desc')}\n                    </p>\n                </div>\n            </div>\n\n            {/* 模型选择区域 */}\n            <div className=\"mt-5 pt-5 border-t border-gray-100 dark:border-base-200 space-y-4\">\n                <div className=\"grid grid-cols-4 gap-2\">\n                    {modelOptions.map((model) => {\n                        const isSelected = config.models?.includes(model.id);\n                        return (\n                            <div\n                                key={model.id}\n                                onClick={() => toggleModel(model.id)}\n                                className={`\n                                    flex items-center justify-between p-2 rounded-lg border cursor-pointer transition-all duration-200\n                                    ${isSelected\n                                        ? 'bg-indigo-50 dark:bg-indigo-900/10 border-indigo-200 dark:border-indigo-800/50 text-indigo-700 dark:text-indigo-400'\n                                        : 'bg-gray-50/50 dark:bg-base-200/50 border-gray-100 dark:border-base-300/50 text-gray-500 hover:border-gray-200 dark:hover:border-base-300'}\n                                `}\n                            >\n                                <div className=\"flex flex-col min-w-0\">\n                                    <span className=\"text-[11px] font-bold truncate\">\n                                        {model.label}\n                                    </span>\n                                    <span className=\"text-[9px] text-gray-400 dark:text-gray-500 mt-0.5 truncate\">\n                                        {model.desc}\n                                    </span>\n                                </div>\n                                <div className={`\n                                    w-4 h-4 rounded-full flex items-center justify-center transition-all duration-300 flex-shrink-0 ml-1\n                                    ${isSelected ? 'bg-indigo-500 text-white scale-100' : 'bg-gray-200 dark:bg-base-300 text-transparent scale-75 opacity-0'}\n                                `}>\n                                    <Check size={10} strokeWidth={4} />\n                                </div>\n                            </div>\n                        );\n                    })}\n                </div>\n\n\n            </div>\n        </div>\n    );\n};\n\nexport default PinnedQuotaModels;\n"
  },
  {
    "path": "src/components/settings/ProxyPoolSettings.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { request } from '../../utils/request';\nimport { showToast } from '../common/ToastContainer';\nimport { Plus, Network, Upload, RefreshCw, Link2, SlidersHorizontal, Trash2, Power } from 'lucide-react';\nimport { ProxyPoolConfig, ProxyEntry } from '../../types/config';\nimport ProxyList from './proxy/ProxyList';\nimport ProxyEditModal from './proxy/ProxyEditModal';\nimport BatchImportModal from './proxy/BatchImportModal';\nimport ProxyBindingManager from './proxy/ProxyBindingManager';\nimport { useAccountStore } from '../../stores/useAccountStore';\n\ninterface ProxyPoolSettingsProps {\n    config: ProxyPoolConfig;\n    onChange: (config: ProxyPoolConfig, silent?: boolean) => void;\n}\n\nexport default function ProxyPoolSettings({ config, onChange }: ProxyPoolSettingsProps) {\n    const { t } = useTranslation();\n    const { accounts, fetchAccounts } = useAccountStore();\n    const [isAddModalOpen, setIsAddModalOpen] = useState(false);\n    const [isBatchImportOpen, setIsBatchImportOpen] = useState(false);\n    const [isBindingManagerOpen, setIsBindingManagerOpen] = useState(false);\n    const [isTesting, setIsTesting] = useState(false);\n    const [accountBindings, setAccountBindings] = useState<Record<string, string>>({});\n    const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());\n\n    // Fetch bindings and accounts on mount\n    useEffect(() => {\n        fetchBindings();\n        fetchAccounts();\n    }, []);\n\n    // Refresh bindings when manager closes\n    useEffect(() => {\n        if (!isBindingManagerOpen) {\n            fetchBindings();\n        }\n    }, [isBindingManagerOpen]);\n\n    // [FIX] Polling for proxy pool status\n    // Now only updates volatile status (is_healthy, latency) to avoid race condition regressions\n    useEffect(() => {\n        let interval: any;\n        if (config.enabled) { // Only poll if proxy pool is enabled\n            interval = setInterval(async () => {\n                try {\n                    const liveConfig = await request<ProxyPoolConfig>('get_proxy_pool_config');\n                    if (liveConfig && liveConfig.proxies) {\n                        // Create a map for quick lookups\n                        const liveMap = new Map(liveConfig.proxies.map(p => [p.id, p]));\n\n                        // Check if any status actually changed\n                        let hasChanges = false;\n                        const updatedProxies = config.proxies.map(p => {\n                            const live = liveMap.get(p.id);\n                            if (live && (live.is_healthy !== p.is_healthy || live.latency !== p.latency || live.last_check_time !== p.last_check_time)) {\n                                hasChanges = true;\n                                return { ...p, is_healthy: live.is_healthy, latency: live.latency, last_check_time: live.last_check_time };\n                            }\n                            return p;\n                        });\n\n                        if (hasChanges) {\n                            // Only update volatile status, DO NOT trigger heavy onChange which saves to disk\n                            // This internal change will eventually be captured by next manual save or \n                            // simply keep the UI fresh without risking rolling back user's structural changes (add/delete)\n                            onChange({ ...config, proxies: updatedProxies }, true); // Pass 'true' as silent flag if onChange supports it, or use a separate state\n                        }\n                    }\n                } catch (e) {\n                    // Ignore if service not running or other errors\n                    console.error('Failed to poll proxy pool config:', e);\n                }\n            }, 5000); // Poll every 5s\n        }\n        return () => clearInterval(interval);\n    }, [config.enabled, config.proxies]); // Depend on config.enabled and config.proxies to re-evaluate polling\n\n    const fetchBindings = async () => {\n        try {\n            const bindings = await request<Record<string, string>>('get_all_account_bindings');\n            if (bindings) setAccountBindings(bindings);\n        } catch (e) {\n            console.error('Fetch bindings failed:', e);\n        }\n    };\n\n    const safeConfig: ProxyPoolConfig = {\n        enabled: config?.enabled ?? false,\n        proxies: config?.proxies ?? [],\n        health_check_interval: config?.health_check_interval ?? 300,\n        auto_failover: config?.auto_failover ?? true,\n        strategy: config?.strategy ?? 'priority',\n    };\n\n    const handleUpdateProxies = (proxies: ProxyEntry[]) => {\n        onChange({ ...safeConfig, proxies });\n    };\n\n    const handleAddProxy = (entry: ProxyEntry) => {\n        onChange({\n            ...safeConfig,\n            proxies: [...safeConfig.proxies, entry]\n        });\n    };\n\n    const handleBatchImport = async (newProxies: ProxyEntry[]) => {\n        const updatedProxies = [...safeConfig.proxies, ...newProxies];\n        await onChange({\n            ...safeConfig,\n            proxies: updatedProxies\n        });\n\n        // Auto-trigger test after import is fully committed\n        handleTestAll();\n    };\n\n    const handleBatchDelete = () => {\n        if (selectedIds.size === 0) return;\n        if (confirm(t('settings.proxy_pool.confirm_batch_delete', 'Are you sure you want to delete selected proxies?'))) {\n            const newProxies = safeConfig.proxies.filter(p => !selectedIds.has(p.id));\n            onChange({ ...safeConfig, proxies: newProxies });\n            setSelectedIds(new Set());\n            showToast(t('common.deleted', 'Deleted successfully'), 'success');\n        }\n    };\n\n    const handleBatchToggleEnabled = (enabled: boolean) => {\n        if (selectedIds.size === 0) return;\n        const newProxies = safeConfig.proxies.map(p =>\n            selectedIds.has(p.id) ? { ...p, enabled } : p\n        );\n        onChange({ ...safeConfig, proxies: newProxies });\n        showToast(t(enabled ? 'common.enabled' : 'common.disabled', enabled ? 'Enabled' : 'Disabled'), 'success');\n    };\n\n    const handleTestAll = async () => {\n        setIsTesting(true);\n        try {\n            const liveConfig = await request<ProxyPoolConfig>('check_proxy_health');\n            if (liveConfig && liveConfig.proxies) {\n                // [FIX] Use incremental merge to prevent race condition rollbacks\n                const liveMap = new Map(liveConfig.proxies.map(p => [p.id, p]));\n\n                const updatedProxies = config.proxies.map(p => {\n                    const live = liveMap.get(p.id);\n                    if (live) {\n                        return {\n                            ...p,\n                            is_healthy: live.is_healthy,\n                            latency: live.latency,\n                            last_check_time: live.last_check_time\n                        };\n                    }\n                    return p;\n                });\n\n                // Update local UI state silently (syncing health stats only)\n                onChange({ ...config, proxies: updatedProxies }, true);\n            }\n            showToast(t('settings.proxy_pool.test_completed', 'Health check completed'), 'success');\n        } catch (error) {\n            console.error('Test all failed:', error);\n            showToast(t('settings.proxy_pool.test_failed', 'Health check failed'), 'error');\n        } finally {\n            setIsTesting(false);\n        }\n    };\n\n    return (\n        <div className=\"space-y-3\">\n            {/* Consolidated Header & Toolbar */}\n            <div className=\"flex flex-wrap items-center justify-between gap-4 p-1.5 bg-gray-100/30 dark:bg-gray-800/20 border border-gray-200 dark:border-gray-800/70 rounded-2xl\">\n                {/* Left: Component Identity & Feature Toggle */}\n                <div className=\"flex items-center gap-2 p-1.5 bg-white dark:bg-gray-900 border border-gray-200/50 dark:border-gray-800 shadow-sm rounded-xl\">\n                    <div className=\"flex items-center gap-3 pr-3 border-r border-gray-100 dark:border-gray-800\">\n                        <div className=\"p-1.5 bg-blue-600 text-white rounded-lg shadow-blue-500/20 shadow-lg\">\n                            <Network className=\"w-3.5 h-3.5\" />\n                        </div>\n                        <h3 className=\"text-xs font-black text-gray-900 dark:text-white whitespace-nowrap uppercase tracking-wider\">\n                            {t('settings.proxy_pool.title', 'Proxy Pool')}\n                        </h3>\n                        <label className=\"relative inline-flex items-center cursor-pointer ml-1\">\n                            <input\n                                type=\"checkbox\"\n                                checked={safeConfig.enabled}\n                                onChange={e => onChange({ ...safeConfig, enabled: e.target.checked })}\n                                className=\"sr-only peer\"\n                            />\n                            <div className=\"w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600\"></div>\n                        </label>\n                    </div>\n\n                    {/* Middle: Configuration Parameters (Strategy & Interval) */}\n                    <div className=\"flex items-center gap-4 px-2\">\n\n                        <div className=\"flex items-center gap-2 group\">\n                            <SlidersHorizontal size={12} className=\"text-gray-400 group-hover:text-blue-500 transition-colors\" />\n                            <select\n                                value={safeConfig.strategy}\n                                onChange={e => onChange({ ...safeConfig, strategy: e.target.value as any })}\n                                className=\"text-[10px] bg-transparent border-none p-0 pr-6 focus:ring-0 font-black uppercase tracking-tight text-gray-700 dark:text-gray-300 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors\"\n                            >\n                                <option value=\"priority\">{t('settings.proxy_pool.strategy_priority', 'Priority')}</option>\n                                <option value=\"round_robin\">{t('settings.proxy_pool.strategy_round_robin', 'Round Robin')}</option>\n                                <option value=\"random\">{t('settings.proxy_pool.strategy_random', 'Random')}</option>\n                                <option value=\"least_connections\">{t('settings.proxy_pool.strategy_least_connections', 'Least Connections')}</option>\n                            </select>\n                        </div>\n\n                        <div className=\"flex items-center gap-2 border-l border-gray-100 dark:border-gray-800 pl-4 group\">\n                            <RefreshCw size={12} className=\"text-gray-400 group-hover:text-emerald-500 transition-colors\" />\n                            <div className=\"flex items-center gap-1\">\n                                <input\n                                    type=\"number\"\n                                    defaultValue={safeConfig.health_check_interval}\n                                    onBlur={e => {\n                                        const val = parseInt(e.target.value) || 60;\n                                        if (val !== safeConfig.health_check_interval) {\n                                            onChange({ ...safeConfig, health_check_interval: val });\n                                        }\n                                    }}\n                                    className=\"w-10 text-[10px] bg-transparent border-none p-0 focus:ring-0 font-black text-gray-700 dark:text-gray-300 text-right group-hover:text-emerald-600 transition-colors\"\n                                />\n                                <span className=\"text-[9px] text-gray-400 font-black uppercase tracking-tighter\">{t('settings.proxy_pool.seconds', 'Sec')}</span>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n\n                {/* Right: Actions or Selection Toolbar */}\n                <div className=\"flex items-center gap-2\">\n                    {selectedIds.size > 0 ? (\n                        <div className=\"flex items-center gap-1.5 bg-blue-50/50 dark:bg-blue-900/20 px-2.5 py-1.5 rounded-xl border border-blue-100 dark:border-blue-800/50 animate-in zoom-in-95 duration-200\">\n                            <span className=\"text-[10px] font-black text-blue-700 dark:text-blue-300 mr-2 uppercase tracking-tight\">\n                                {selectedIds.size} {t('common.selected', 'Selected')}\n                            </span>\n                            <div className=\"flex items-center gap-1\">\n                                <button\n                                    onClick={() => handleBatchToggleEnabled(true)}\n                                    className=\"p-1.5 text-blue-600 hover:bg-white dark:hover:bg-blue-800 rounded-lg transition-all shadow-sm hover:shadow active:scale-90\"\n                                    title={t('common.enable', 'Enable')}\n                                >\n                                    <Power size={14} />\n                                </button>\n                                <button\n                                    onClick={() => handleBatchToggleEnabled(false)}\n                                    className=\"p-1.5 text-gray-400 hover:bg-white dark:hover:bg-gray-800 rounded-lg transition-all shadow-sm hover:shadow active:scale-90\"\n                                    title={t('common.disable', 'Disable')}\n                                >\n                                    <Power size={14} className=\"opacity-50\" />\n                                </button>\n                                <button\n                                    onClick={handleBatchDelete}\n                                    className=\"p-1.5 text-rose-500 hover:bg-white dark:hover:bg-rose-900/40 rounded-lg transition-all shadow-sm hover:shadow active:scale-90\"\n                                    title={t('common.delete', 'Delete')}\n                                >\n                                    <Trash2 size={14} />\n                                </button>\n                            </div>\n                            <div className=\"w-px h-4 bg-blue-200 dark:bg-blue-800 mx-1\"></div>\n                            <button\n                                onClick={() => setSelectedIds(new Set())}\n                                className=\"text-[10px] font-black text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 uppercase px-2 py-1\"\n                            >\n                                {t('common.cancel', 'Cancel')}\n                            </button>\n                        </div>\n                    ) : (\n                        <>\n                            <div className=\"flex items-center gap-1 p-1 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-100 dark:border-gray-800/50\">\n                                <button\n                                    onClick={handleTestAll}\n                                    disabled={isTesting}\n                                    className={`p-2 text-gray-400 hover:text-emerald-500 hover:bg-white dark:hover:bg-gray-800 rounded-lg transition-all ${isTesting ? 'animate-spin opacity-50' : 'active:scale-90'}`}\n                                    title={t('settings.proxy_pool.test_all', 'Test All')}\n                                >\n                                    <RefreshCw size={14} />\n                                </button>\n                                <button\n                                    onClick={() => setIsBindingManagerOpen(true)}\n                                    className=\"p-2 text-gray-400 hover:text-indigo-500 hover:bg-white dark:hover:bg-gray-800 rounded-lg transition-all active:scale-90\"\n                                    title={t('settings.proxy_pool.binding_manager', 'Manage Bindings')}\n                                >\n                                    <Link2 size={14} />\n                                </button>\n                            </div>\n\n                            <div className=\"flex items-center gap-2 ml-1\">\n                                <button\n                                    onClick={() => setIsBatchImportOpen(true)}\n                                    className=\"flex items-center gap-1.5 px-3.5 py-2 text-[11px] font-black uppercase tracking-wider text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white bg-gray-50 hover:bg-white dark:bg-gray-800/50 dark:hover:bg-gray-800 border border-gray-100 dark:border-gray-800/50 rounded-xl transition-all shadow-sm hover:shadow active:scale-95\"\n                                >\n                                    <Upload size={12} />\n                                    {t('settings.proxy_pool.batch_import', 'Import')}\n                                </button>\n                                <button\n                                    onClick={() => setIsAddModalOpen(true)}\n                                    className=\"flex items-center gap-1.5 px-4 py-2 text-[11px] font-black uppercase tracking-wider bg-gray-900 hover:bg-black dark:bg-white dark:hover:bg-gray-100 text-white dark:text-gray-900 rounded-xl transition-all shadow-lg hover:shadow-black/20 dark:hover:shadow-white/10 active:scale-95\"\n                                >\n                                    <Plus size={12} />\n                                    {t('settings.proxy_pool.add_proxy', 'Add')}\n                                </button>\n                            </div>\n                        </>\n                    )}\n                </div>\n            </div>\n\n            {/* Proxy List - Always visible, with status context */}\n            <div className=\"relative border border-gray-200 dark:border-gray-800 rounded-xl overflow-hidden bg-white dark:bg-gray-900 shadow-sm transition-all duration-300\">\n                {!safeConfig.enabled && (\n                    <div className=\"absolute inset-x-0 top-0 z-10 bg-amber-50/80 dark:bg-amber-900/10 backdrop-blur-[2px] px-3 py-1 flex items-center justify-center border-b border-amber-100/50 dark:border-amber-900/20\">\n                        <span className=\"text-[10px] font-black text-amber-600/80 dark:text-amber-500/80 uppercase tracking-[0.2em] pointer-events-none\">\n                            {t('settings.proxy_pool.inactive_notice', 'Proxy Pool Inactive')}\n                        </span>\n                    </div>\n                )}\n                <div className={!safeConfig.enabled ? 'pt-6 opacity-60' : ''}>\n                    <ProxyList\n                        proxies={safeConfig.proxies}\n                        onUpdate={handleUpdateProxies}\n                        accountBindings={accountBindings}\n                        accounts={accounts}\n                        selectedIds={selectedIds}\n                        onSelectionChange={setSelectedIds}\n                        isTesting={isTesting}\n                    />\n                </div>\n            </div>\n\n            {isAddModalOpen && (\n                <ProxyEditModal\n                    isOpen={isAddModalOpen}\n                    onClose={() => setIsAddModalOpen(false)}\n                    onSave={handleAddProxy}\n                    isEditing={false}\n                />\n            )}\n\n            {isBatchImportOpen && (\n                <BatchImportModal\n                    isOpen={isBatchImportOpen}\n                    onClose={() => setIsBatchImportOpen(false)}\n                    onImport={handleBatchImport}\n                />\n            )}\n\n            {isBindingManagerOpen && (\n                <ProxyBindingManager\n                    isOpen={isBindingManagerOpen}\n                    onClose={() => setIsBindingManagerOpen(false)}\n                    proxies={safeConfig.proxies}\n                />\n            )}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/components/settings/QuotaProtection.tsx",
    "content": "import { Shield, Check } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { QuotaProtectionConfig } from '../../types/config';\nimport { MODEL_CONFIG } from '../../config/modelConfig';\n\ninterface QuotaProtectionProps {\n    config: QuotaProtectionConfig;\n    onChange: (config: QuotaProtectionConfig) => void;\n}\n\nconst QuotaProtection = ({ config, onChange }: QuotaProtectionProps) => {\n    const { t } = useTranslation();\n\n    const handleEnabledChange = (enabled: boolean) => {\n        let newConfig = { ...config, enabled };\n        // 如果开启保护且勾选列表为空，则默认勾选 claude\n        if (enabled && (!config.monitored_models || config.monitored_models.length === 0)) {\n            newConfig.monitored_models = ['claude'];\n        }\n        onChange(newConfig);\n    };\n\n    const handlePercentageChange = (value: string) => {\n        const percentage = parseInt(value) || 10;\n        const clampedPercentage = Math.max(1, Math.min(99, percentage));\n        onChange({ ...config, threshold_percentage: clampedPercentage });\n    };\n\n    const toggleModel = (model: string) => {\n        const currentModels = config.monitored_models || [];\n        let newModels: string[];\n\n        if (currentModels.includes(model)) {\n            // 必须勾选其中一个，不能全取消\n            if (currentModels.length <= 1) return;\n            newModels = currentModels.filter(m => m !== model);\n        } else {\n            newModels = [...currentModels, model];\n        }\n\n        onChange({ ...config, monitored_models: newModels });\n    };\n\n    const uniqueLabels = new Set<string>();\n    const monitoredModelsOptions = Object.entries(MODEL_CONFIG)\n        .filter(([id, config]) => {\n            if (id.includes('thinking')) return false;\n            const label = config.shortLabel || config.label;\n            if (uniqueLabels.has(label)) return false;\n            uniqueLabels.add(label);\n            return true;\n        })\n        .map(([id, config]) => ({\n            id,\n            label: config.shortLabel || config.label\n        }));\n\n    // 计算示例值\n    const exampleTotal = 150;\n    const exampleThreshold = Math.floor(exampleTotal * config.threshold_percentage / 100);\n\n    return (\n        <div className=\"animate-in fade-in duration-500\">\n            <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-4\">\n                    {/* 图标部分 - 使用红色/玫瑰色调表示保护/警示 */}\n                    <div className=\"w-10 h-10 rounded-xl bg-rose-50 dark:bg-rose-900/20 flex items-center justify-center text-rose-500 group-hover:bg-rose-500 group-hover:text-white transition-all duration-300\">\n                        <Shield size={20} />\n                    </div>\n                    <div>\n                        <div className=\"font-bold text-gray-900 dark:text-gray-100\">\n                            {t('settings.quota_protection.title')}\n                        </div>\n                        <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-0.5\">\n                            {t('settings.quota_protection.enable_desc')}\n                        </p>\n                    </div>\n                </div>\n\n                {/* 开关部分 */}\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                    <input\n                        type=\"checkbox\"\n                        className=\"sr-only peer\"\n                        checked={config.enabled}\n                        onChange={(e) => handleEnabledChange(e.target.checked)}\n                    />\n                    <div className=\"w-11 h-6 bg-gray-200 dark:bg-base-300 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-rose-500 shadow-inner\"></div>\n                </label>\n            </div>\n\n            {/* 展开的详情设置部分 */}\n            {config.enabled && (\n                <div className=\"mt-5 pt-5 border-t border-gray-100 dark:border-base-200 space-y-6 animate-in slide-in-from-top-1 duration-200\">\n                    {/* 百分比设置 */}\n                    <div className=\"flex items-center gap-4\">\n                        <label className=\"text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                            {t('settings.quota_protection.threshold_label')}\n                        </label>\n                        <div className=\"relative flex items-center gap-2\">\n                            <input\n                                type=\"number\"\n                                className=\"w-24 px-3 py-2 bg-gray-50 dark:bg-base-200 border border-gray-200 dark:border-base-300 rounded-lg focus:ring-2 focus:ring-rose-500 outline-none text-sm font-bold text-rose-600 dark:text-rose-400\"\n                                min=\"1\"\n                                max=\"99\"\n                                value={config.threshold_percentage}\n                                onChange={(e) => handlePercentageChange(e.target.value)}\n                            />\n                            <span className=\"text-sm font-bold text-gray-400 dark:text-gray-500\">%</span>\n                        </div>\n                    </div>\n\n                    {/* 监控模型勾选 */}\n                    <div className=\"space-y-3\">\n                        <div className=\"flex flex-col gap-1\">\n                            <label className=\"text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                                {t('settings.quota_protection.monitored_models_label')}\n                            </label>\n                            <p className=\"text-[10px] text-gray-400 dark:text-gray-500\">\n                                {t('settings.quota_protection.monitored_models_desc')}\n                            </p>\n                        </div>\n                        <div className=\"grid grid-cols-4 gap-2\">\n                            {monitoredModelsOptions.map((model) => {\n                                const isSelected = config.monitored_models?.includes(model.id);\n                                return (\n                                    <div\n                                        key={model.id}\n                                        onClick={() => toggleModel(model.id)}\n                                        className={`\n                                            flex items-center justify-between p-2 rounded-lg border cursor-pointer transition-all duration-200\n                                            ${isSelected\n                                                ? 'bg-rose-50 dark:bg-rose-900/10 border-rose-200 dark:border-rose-800/50 text-rose-700 dark:text-rose-400'\n                                                : 'bg-gray-50/50 dark:bg-base-200/50 border-gray-100 dark:border-base-300/50 text-gray-500 hover:border-gray-200 dark:hover:border-base-300'}\n                                        `}\n                                    >\n                                        <span className=\"text-[11px] font-medium truncate pr-2\">\n                                            {model.label}\n                                        </span>\n                                        <div className={`\n                                            w-4 h-4 rounded-full flex items-center justify-center transition-all duration-300\n                                            ${isSelected ? 'bg-rose-500 text-white scale-100' : 'bg-gray-200 dark:bg-base-300 text-transparent scale-75 opacity-0'}\n                                        `}>\n                                            <Check size={10} strokeWidth={4} />\n                                        </div>\n                                    </div>\n                                );\n                            })}\n                        </div>\n                    </div>\n\n                    {/* 示例提示卡片 */}\n                    <div className=\"flex items-start gap-3 p-3 bg-blue-50/50 dark:bg-gray-800/50 rounded-xl border border-blue-100/50 dark:border-base-300\">\n                        <div className=\"text-blue-500 mt-0.5\">\n                            <span className=\"text-sm\">💡</span>\n                        </div>\n                        <div className=\"flex flex-col gap-1\">\n                            <p className=\"text-xs text-blue-700/80 dark:text-gray-300/90 leading-relaxed font-medium\">\n                                {t('settings.quota_protection.example', {\n                                    percentage: config.threshold_percentage,\n                                    total: exampleTotal,\n                                    threshold: exampleThreshold\n                                })}\n                            </p>\n                            <span className=\"block font-bold text-emerald-600 dark:text-emerald-400 text-[10px] uppercase tracking-wide\">\n                                ✓ {t('settings.quota_protection.auto_restore_info')}\n                            </span>\n                        </div>\n                    </div>\n                </div>\n            )}\n        </div>\n    );\n};\n\nexport default QuotaProtection;\n"
  },
  {
    "path": "src/components/settings/SmartWarmup.tsx",
    "content": "import React from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Sparkles, Check } from 'lucide-react';\nimport { ScheduledWarmupConfig } from '../../types/config';\nimport { MODEL_CONFIG } from '../../config/modelConfig';\n\ninterface SmartWarmupProps {\n    config: ScheduledWarmupConfig;\n    onChange: (config: ScheduledWarmupConfig) => void;\n}\n\nconst SmartWarmup: React.FC<SmartWarmupProps> = ({ config, onChange }) => {\n    const { t } = useTranslation();\n\n    const uniqueLabels = new Set<string>();\n    const warmupModelsOptions = Object.entries(MODEL_CONFIG)\n        .filter(([id, config]) => {\n            if (id.includes('thinking')) return false;\n            const label = config.shortLabel || config.label;\n            if (uniqueLabels.has(label)) return false;\n            uniqueLabels.add(label);\n            return true;\n        })\n        .map(([id, config]) => ({\n            id,\n            label: config.shortLabel || config.label\n        }));\n\n    const handleEnabledChange = (enabled: boolean) => {\n        let newConfig = { ...config, enabled };\n        // 如果开启预热且勾选列表为空，则默认勾选所有核心模型\n        if (enabled && (!config.monitored_models || config.monitored_models.length === 0)) {\n            newConfig.monitored_models = warmupModelsOptions.map(o => o.id);\n        }\n        onChange(newConfig);\n    };\n\n    const toggleModel = (model: string) => {\n        const currentModels = config.monitored_models || [];\n        let newModels: string[];\n\n        if (currentModels.includes(model)) {\n            // 必须勾选其中一个，不能全取消\n            if (currentModels.length <= 1) return;\n            newModels = currentModels.filter(m => m !== model);\n        } else {\n            newModels = [...currentModels, model];\n        }\n\n        onChange({ ...config, monitored_models: newModels });\n    };\n\n    return (\n        <div className=\"space-y-4\">\n            <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-4\">\n                    <div className={`w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 ${config.enabled\n                        ? 'bg-orange-500 text-white'\n                        : 'bg-orange-50 dark:bg-orange-900/20 text-orange-500 group-hover:bg-orange-500 group-hover:text-white'\n                        }`}>\n                        <Sparkles size={20} />\n                    </div>\n                    <div>\n                        <div className=\"font-bold text-gray-900 dark:text-gray-100\">\n                            {t('settings.warmup.title', '智能预热')}\n                        </div>\n                        <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-0.5\">\n                            {t('settings.warmup.desc')}\n                        </p>\n                    </div>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                    <input\n                        type=\"checkbox\"\n                        className=\"sr-only peer\"\n                        checked={config.enabled}\n                        onChange={(e) => handleEnabledChange(e.target.checked)}\n                    />\n                    <div className=\"w-11 h-6 bg-gray-200 dark:bg-base-300 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-orange-500 shadow-inner\"></div>\n                </label>\n            </div>\n\n            {config.enabled && (\n                <div className=\"mt-4 pt-4 border-t border-gray-50 dark:border-base-300 animate-in slide-in-from-top-2 duration-300\">\n                    <div className=\"space-y-3\">\n                        <div>\n                            <label className=\"text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-widest block mb-2\">\n                                {t('settings.quota_protection.monitored_models_label', '监控模型')}\n                            </label>\n                            <div className=\"grid grid-cols-4 gap-2\">\n                                {warmupModelsOptions.map((model) => {\n                                    const isSelected = config.monitored_models?.includes(model.id);\n                                    return (\n                                        <div\n                                            key={model.id}\n                                            onClick={() => toggleModel(model.id)}\n                                            className={`\n                                                flex items-center justify-between p-2 rounded-lg border cursor-pointer transition-all duration-200\n                                                ${isSelected\n                                                    ? 'bg-orange-50 dark:bg-orange-900/10 border-orange-200 dark:border-orange-800/50 text-orange-700 dark:text-orange-400'\n                                                    : 'bg-gray-50/50 dark:bg-base-200/50 border-gray-100 dark:border-base-300/50 text-gray-500 hover:border-gray-200 dark:hover:border-base-300'}\n                                            `}\n                                        >\n                                            <span className=\"text-[11px] font-medium truncate pr-2\">\n                                                {model.label}\n                                            </span>\n                                            <div className={`\n                                                w-4 h-4 rounded-full flex items-center justify-center transition-all duration-300\n                                                ${isSelected ? 'bg-orange-500 text-white scale-100' : 'bg-gray-200 dark:bg-base-300 text-transparent scale-75 opacity-0'}\n                                            `}>\n                                                <Check size={10} strokeWidth={4} />\n                                            </div>\n                                        </div>\n                                    );\n                                })}\n                            </div>\n                            <p className=\"text-[10px] text-gray-400 dark:text-gray-500 mt-2 leading-relaxed\">\n                                {t('settings.quota_protection.monitored_models_desc', '勾选需要监控的模型。当选中的任一模型利用率跌破阈值时，将触发保护')}\n                            </p>\n                        </div>\n                    </div>\n                </div>\n            )}\n        </div>\n    );\n};\n\nexport default SmartWarmup;\n"
  },
  {
    "path": "src/components/settings/ThinkingBudget.tsx",
    "content": "import { useState, useEffect } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ThinkingBudgetConfig, ThinkingBudgetMode, ThinkingEffort } from \"../../types/config\";\n\ninterface ThinkingBudgetProps {\n    config: ThinkingBudgetConfig;\n    onChange: (config: ThinkingBudgetConfig) => void;\n}\n\nconst DEFAULT_CONFIG: ThinkingBudgetConfig = {\n    mode: 'auto',\n    custom_value: 24576,\n};\n\nexport default function ThinkingBudget({\n    config = DEFAULT_CONFIG,\n    onChange,\n}: ThinkingBudgetProps) {\n    const { t } = useTranslation();\n\n    // 使用本地 state 管理输入值，允许临时的无效输入\n    const [inputValue, setInputValue] = useState(String(config.custom_value));\n\n    // 同步外部 config 变化\n    useEffect(() => {\n        setInputValue(String(config.custom_value));\n    }, [config.custom_value]);\n\n    const handleModeChange = (mode: ThinkingBudgetMode) => {\n        // 切换到 adaptive 模式时，如果未设置 effort，默认设置为 high\n        if (mode === 'adaptive' && !config.effort) {\n            onChange({ ...config, mode, effort: 'high' });\n        } else {\n            onChange({ ...config, mode });\n        }\n    };\n\n    const handleEffortChange = (effort: ThinkingEffort) => {\n        onChange({ ...config, effort });\n    };\n\n    // 输入时只更新本地 state\n    const handleInputChange = (val: string) => {\n        setInputValue(val);\n    };\n\n    // 失焦时校验并提交\n    const handleInputBlur = () => {\n        let num = parseInt(inputValue, 10);\n        if (isNaN(num) || num < 1024) num = 1024;\n        if (num > 65536) num = 65536;\n        setInputValue(String(num));\n        onChange({ ...config, custom_value: num });\n    };\n\n    const modes: ThinkingBudgetMode[] = ['auto', 'adaptive', 'passthrough', 'custom']; // Ensure adaptive is included\n    const efforts: ThinkingEffort[] = ['low', 'medium', 'high'];\n\n    return (\n        <div className=\"space-y-3\">\n            <div className=\"flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-blue-50/30 dark:bg-blue-900/5 border border-blue-100/50 dark:border-blue-800/20 rounded-lg px-4 py-3\">\n                <div className=\"space-y-0.5\">\n                    <h4 className=\"font-bold text-sm text-gray-900 dark:text-gray-100\">\n                        {t(\"settings.thinking_budget.title\", { defaultValue: \"思考预算 (Thinking Budget)\" })}\n                    </h4>\n                    <p className=\"text-[10px] text-gray-500 dark:text-gray-400\">\n                        {t(\"settings.thinking_budget.mode_label\", { defaultValue: \"处理模式\" })}\n                    </p>\n                </div>\n\n                <div className=\"flex bg-gray-100 dark:bg-gray-800 p-1 rounded-lg\">\n                    {modes.map((key) => (\n                        <button\n                            key={key}\n                            onClick={() => handleModeChange(key)}\n                            className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${config.mode === key\n                                ? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'\n                                : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'\n                                }`}\n                        >\n                            {t(`settings.thinking_budget.mode.${key}`)}\n                        </button>\n                    ))}\n                </div>\n            </div>\n\n            {/* Mode-specific UI (Compact) */}\n            <div className=\"px-1\">\n                {config.mode === 'auto' && (\n                    <p className=\"text-[10px] text-gray-400 dark:text-gray-500 italic\">\n                        {t(\"settings.thinking_budget.auto_hint\", {\n                            defaultValue: \"自动模式：对 Gemini/Thinking 及联网请求自动限制在 24576 以避免错误。\",\n                        })}\n                    </p>\n                )}\n\n                {config.mode === 'passthrough' && (\n                    <p className=\"text-[10px] text-amber-600 dark:text-amber-500/80\">\n                        {t(\"settings.thinking_budget.passthrough_warning\", {\n                            defaultValue: \"透传：直接使用调用方原始值，不支持高值可能导致失败。\",\n                        })}\n                    </p>\n                )}\n\n                {config.mode === 'adaptive' && (\n                    <div className=\"flex flex-col gap-2\">\n                        <div className=\"flex items-center gap-3\">\n                            <span className=\"text-xs text-gray-500 dark:text-gray-400\">\n                                {t(\"settings.thinking_budget.effort_label\", { defaultValue: \"思考强度\" })}:\n                            </span>\n                            <div className=\"flex bg-gray-100 dark:bg-gray-800 p-0.5 rounded-lg\">\n                                {efforts.map((effort) => (\n                                    <button\n                                        key={effort}\n                                        onClick={() => handleEffortChange(effort)}\n                                        className={`px-2 py-1 rounded-md text-[10px] font-medium transition-all ${config.effort === effort\n                                            ? 'bg-white dark:bg-gray-700 text-purple-600 dark:text-purple-400 shadow-sm'\n                                            : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'\n                                            }`}\n                                    >\n                                        {t(`settings.thinking_budget.effort.${effort}`)}\n                                    </button>\n                                ))}\n                            </div>\n                        </div>\n                        <p className=\"text-[10px] text-purple-600 dark:text-purple-400/80\">\n                            {t(\"settings.thinking_budget.adaptive_hint\", {\n                                defaultValue: \"自适应模式：由模型根据任务复杂度自动调整思考量。Claude 4.6+ 推荐使用此模式。\",\n                            })}\n                        </p>\n                    </div>\n                )}\n\n\n                {config.mode === 'custom' && (\n                    <div className=\"flex items-center gap-4\">\n                        <div className=\"flex items-center gap-2\">\n                            <input\n                                type=\"number\"\n                                value={inputValue}\n                                onChange={(e) => handleInputChange(e.target.value)}\n                                onBlur={handleInputBlur}\n                                className=\"w-24 bg-white dark:bg-base-100 border border-gray-200 dark:border-gray-700 rounded-md px-2 py-1 text-xs font-mono focus:ring-1 focus:ring-blue-500 outline-none transition-all [appearance:textfield]\"\n                                min={1024}\n                                max={65536}\n                                step={1024}\n                            />\n                            <span className=\"text-[10px] text-gray-400 font-mono\">TOKENS</span>\n                        </div>\n                        <p className=\"text-[10px] text-gray-500 dark:text-gray-500\">\n                            {t(\"settings.thinking_budget.custom_value_hint\", {\n                                defaultValue: \"推荐：24576 (Flash) 或 51200 (扩展)\",\n                            })}\n                        </p>\n                    </div>\n                )}\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/components/settings/proxy/BatchImportModal.tsx",
    "content": "import { useState } from 'react';\nimport { createPortal } from 'react-dom';\nimport { X, Upload, FileText, AlertCircle, CheckCircle2 } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { ProxyEntry } from '../../../types/config';\nimport { generateUUID } from '../../../utils/uuid';\n\ninterface BatchImportModalProps {\n    isOpen: boolean;\n    onClose: () => void;\n    onImport: (proxies: ProxyEntry[]) => void;\n}\n\nexport default function BatchImportModal({ isOpen, onClose, onImport }: BatchImportModalProps) {\n    const { t } = useTranslation();\n    const [rawText, setRawText] = useState('');\n    const [preview, setPreview] = useState<ProxyEntry[]>([]);\n    const [error, setError] = useState<string | null>(null);\n\n    if (!isOpen) return null;\n\n    const parseProxies = (text: string) => {\n        const lines = text.split('\\n').filter(line => line.trim() !== '');\n        const newProxies: ProxyEntry[] = [];\n        const urlRegex = /([a-zA-Z0-9]+:\\/\\/[^\\s]+)/; // Basic protocol://url matcher\n\n        lines.forEach((line, index) => {\n            try {\n                const trimmedLine = line.trim();\n                let url = '';\n                // Strategy 1: Regex search for protocol://...\n                const match = trimmedLine.match(urlRegex);\n                if (match) {\n                    url = match[0];\n                } else {\n                    // Check for host:port:user:pass or host:port\n                    // logic: split by space first to get the \"proxy part\"\n                    const firstWord = trimmedLine.split(/\\s+/)[0];\n                    const parts = firstWord.split(':');\n\n                    if (parts.length === 4) {\n                        // host:port:user:pass format\n                        // Reconstruct to http://user:pass@host:port\n                        const [host, port, user, pass] = parts;\n                        url = `http://${user}:${pass}@${host}:${port}`;\n                    } else if (parts.length === 2) {\n                        // host:port format\n                        const [host, port] = parts;\n                        // Basic sanity check on port\n                        if (!isNaN(Number(port))) {\n                            url = `http://${host}:${port}`;\n                        }\n                    }\n                }\n\n                if (!url) {\n                    // console.warn(`Line ${index + 1} skipped: no valid proxy found`);\n                    return;\n                }\n\n                // Validation\n                try {\n                    new URL(url);\n                } catch (e) {\n                    console.warn(`Line ${index + 1} invalid URL: ${url}`);\n                    return;\n                }\n\n                newProxies.push({\n                    id: generateUUID(),\n                    // Name will be assigned when adding to main list or just generic here\n                    name: `Imported Proxy`,\n                    url: url,\n                    enabled: true,\n                    priority: 1,\n                    tags: ['imported'],\n                    is_healthy: false,\n                    latency: undefined\n                });\n            } catch (e) {\n                console.error(\"Failed to parse line\", line, e);\n            }\n        });\n\n        // Fix names to be unique/sequential relative to this batch\n        newProxies.forEach((p, i) => {\n            p.name = `Proxy ${i + 1}`;\n        });\n\n        if (newProxies.length === 0 && lines.length > 0) {\n            setError(t('settings.proxy_pool.no_valid_proxies', 'No valid proxies found'));\n        } else {\n            setError(null);\n            setPreview(newProxies);\n        }\n    };\n\n    const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n        const text = e.target.value;\n        setRawText(text);\n        parseProxies(text);\n    };\n\n    const handleImport = () => {\n        if (preview.length > 0) {\n            onImport(preview);\n            onClose();\n            setRawText('');\n            setPreview([]);\n        }\n    };\n\n    return createPortal(\n        <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200\">\n            <div className=\"bg-white dark:bg-base-100 rounded-2xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col border border-gray-100 dark:border-base-300\">\n                <div className=\"flex items-center justify-between p-6 border-b border-gray-100 dark:border-base-200\">\n                    <h3 className=\"text-xl font-semibold text-gray-900 dark:text-base-content flex items-center gap-2\">\n                        <Upload size={20} className=\"text-blue-500\" />\n                        {t('settings.proxy_pool.import_title', 'Batch Import Proxies')}\n                    </h3>\n                    <button\n                        onClick={onClose}\n                        className=\"p-2 hover:bg-gray-100 dark:hover:bg-base-200 rounded-full transition-colors text-gray-500\"\n                    >\n                        <X size={20} />\n                    </button>\n                </div>\n\n                <div className=\"flex-1 overflow-y-auto p-6 space-y-6\">\n                    <div>\n                        <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\n                            {t('settings.proxy_pool.import_label', 'Paste Proxy List (One per line)')}\n                        </label>\n                        <div className=\"text-xs text-gray-500 mb-2\">\n                            {t('settings.proxy_pool.import_hint', 'Supported formats: protocol://user:pass@host:port, host:port:user:pass')}\n                        </div>\n                        <textarea\n                            className=\"w-full h-40 px-4 py-3 border border-gray-200 dark:border-base-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-gray-50 dark:bg-base-200 text-gray-900 dark:text-base-content font-mono text-sm resize-none\"\n                            placeholder=\"http://user:pass@127.0.0.1:8080&#10;127.0.0.1:8080:user:pass\"\n                            value={rawText}\n                            onChange={handleTextChange}\n                        />\n                    </div>\n\n                    {error && (\n                        <div className=\"p-4 bg-red-50 dark:bg-red-900/10 rounded-xl flex items-start gap-3 text-red-600 dark:text-red-400\">\n                            <AlertCircle size={18} className=\"mt-0.5 shrink-0\" />\n                            <p className=\"text-sm\">{error}</p>\n                        </div>\n                    )}\n\n                    {preview.length > 0 && (\n                        <div>\n                            <h4 className=\"text-sm font-medium text-gray-900 dark:text-base-content mb-3 flex items-center gap-2\">\n                                <FileText size={16} />\n                                {t('settings.proxy_pool.import_preview', 'Preview')}\n                                <span className=\"px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs\">\n                                    {preview.length} valid\n                                </span>\n                            </h4>\n                            <div className=\"bg-gray-50 dark:bg-base-200 rounded-xl border border-gray-200 dark:border-base-300 max-h-40 overflow-y-auto\">\n                                <table className=\"w-full text-sm\">\n                                    <thead className=\"bg-gray-100 dark:bg-base-300 sticky top-0\">\n                                        <tr>\n                                            <th className=\"px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-400 w-12\">#</th>\n                                            {/* Removed Name column from preview since it's generic now, or keep it? user said \"simpler naming\". Keeping it simple. */}\n                                            <th className=\"px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-400\">URL</th>\n                                        </tr>\n                                    </thead>\n                                    <tbody className=\"divide-y divide-gray-200 dark:divide-base-300\">\n                                        {preview.map((proxy, idx) => (\n                                            <tr key={idx} className=\"hover:bg-gray-100 dark:hover:bg-base-300/50\">\n                                                <td className=\"px-4 py-2 text-gray-500\">{idx + 1}</td>\n                                                <td className=\"px-4 py-2 text-gray-900 dark:text-base-content font-mono truncate max-w-[300px]\" title={proxy.url}>\n                                                    {proxy.url}\n                                                </td>\n                                            </tr>\n                                        ))}\n                                    </tbody>\n                                </table>\n                            </div>\n                        </div>\n                    )}\n                </div>\n\n                <div className=\"p-6 border-t border-gray-100 dark:border-base-200 flex justify-end gap-3 bg-gray-50 dark:bg-base-200/50 rounded-b-2xl\">\n                    <button\n                        onClick={onClose}\n                        className=\"px-5 py-2.5 rounded-xl border border-gray-200 dark:border-base-300 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-100 dark:hover:bg-base-200 transition-colors\"\n                    >\n                        {t('common.cancel', 'Cancel')}\n                    </button>\n                    <button\n                        onClick={handleImport}\n                        disabled={preview.length === 0}\n                        className=\"px-5 py-2.5 rounded-xl bg-blue-500 hover:bg-blue-600 active:scale-95 text-white font-medium shadow-sm shadow-blue-200 dark:shadow-none transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n                    >\n                        <CheckCircle2 size={18} />\n                        {t('settings.proxy_pool.import_confirm', 'Import {{count}} Proxies', { count: preview.length })}\n                    </button>\n                </div>\n            </div>\n        </div>,\n        document.body\n    );\n}\n"
  },
  {
    "path": "src/components/settings/proxy/ProxyBindingManager.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { createPortal } from 'react-dom';\nimport { useTranslation } from 'react-i18next';\nimport { X, RefreshCw, Link2, Unlink } from 'lucide-react';\nimport { request } from '../../../utils/request';\nimport { showToast } from '../../common/ToastContainer';\nimport { useAccountStore } from '../../../stores/useAccountStore';\nimport { ProxyEntry } from '../../../types/config';\n\ninterface ProxyBindingManagerProps {\n    isOpen: boolean;\n    onClose: () => void;\n    proxies: ProxyEntry[];\n}\n\nexport default function ProxyBindingManager({ isOpen, onClose, proxies }: ProxyBindingManagerProps) {\n    const { t } = useTranslation();\n    const { accounts, fetchAccounts } = useAccountStore();\n    const [bindings, setBindings] = useState<Record<string, string>>({});\n    const [isLoading, setIsLoading] = useState(false);\n    const [isSaving, setIsSaving] = useState<string | null>(null);\n\n    // Filter enabled proxies for selection\n    const availableProxies = proxies.filter(p => p.enabled);\n\n    useEffect(() => {\n        if (isOpen) {\n            refreshData();\n        }\n    }, [isOpen]);\n\n    const refreshData = async () => {\n        setIsLoading(true);\n        try {\n            await fetchAccounts();\n            const currentBindings = await request<Record<string, string>>('get_all_account_bindings');\n            setBindings(currentBindings || {});\n        } catch (error) {\n            console.error('Failed to load bindings:', error);\n            showToast(t('settings.proxy_pool.binding.load_failed', 'Failed to load bindings'), 'error');\n        } finally {\n            setIsLoading(false);\n        }\n    };\n\n    const handleBind = async (accountId: string, proxyId: string) => {\n        setIsSaving(accountId);\n        try {\n            if (proxyId === '') {\n                // Unbind\n                await request('unbind_account_proxy', { accountId });\n                const newBindings = { ...bindings };\n                delete newBindings[accountId];\n                setBindings(newBindings);\n                showToast(t('settings.proxy_pool.binding.unbind_success', 'Unbound successfully'), 'success');\n            } else {\n                // Bind\n                await request('bind_account_proxy', { accountId, proxyId });\n                setBindings({ ...bindings, [accountId]: proxyId });\n                showToast(t('settings.proxy_pool.binding.bind_success', 'Bound successfully'), 'success');\n            }\n        } catch (error) {\n            console.error('Failed to update binding:', error);\n            showToast(t('settings.proxy_pool.binding.update_failed', 'Failed to update binding'), 'error');\n        } finally {\n            setIsSaving(null);\n        }\n    };\n\n    if (!isOpen) return null;\n\n    return createPortal(\n        <div className=\"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200\">\n            <div className=\"bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col\">\n\n                {/* Header */}\n                <div className=\"flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700\">\n                    <h3 className=\"text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2\">\n                        <Link2 className=\"w-5 h-5\" />\n                        {t('settings.proxy_pool.binding.title', 'Account Proxy Bindings')}\n                    </h3>\n                    <div className=\"flex items-center gap-2\">\n                        <button\n                            onClick={refreshData}\n                            disabled={isLoading}\n                            className=\"p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors\"\n                            title={t('common.refresh', 'Refresh')}\n                        >\n                            <RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />\n                        </button>\n                        <button\n                            onClick={onClose}\n                            className=\"p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors\"\n                        >\n                            <X size={20} />\n                        </button>\n                    </div>\n                </div>\n\n                {/* Content */}\n                <div className=\"flex-1 overflow-y-auto p-4 custom-scrollbar\">\n                    {isLoading && accounts.length === 0 ? (\n                        <div className=\"flex justify-center py-8\">\n                            <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600\"></div>\n                        </div>\n                    ) : (\n                        <div className=\"space-y-3\">\n                            <div className=\"grid grid-cols-12 gap-4 px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                                <div className=\"col-span-5\">{t('settings.account.email', 'Email')}</div>\n                                <div className=\"col-span-7\">{t('settings.proxy_pool.binding.assigned_proxy', 'Assigned Proxy')}</div>\n                            </div>\n\n                            {accounts.map(account => {\n                                const currentProxyId = bindings[account.id] || '';\n                                return (\n                                    <div key={account.id} className=\"grid grid-cols-12 gap-4 items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors\">\n                                        <div className=\"col-span-5 truncate font-medium text-gray-900 dark:text-gray-200\" title={account.email}>\n                                            {account.email}\n                                        </div>\n                                        <div className=\"col-span-7 relative\">\n                                            <select\n                                                value={currentProxyId}\n                                                onChange={(e) => handleBind(account.id, e.target.value)}\n                                                disabled={isSaving === account.id}\n                                                className={`w-full appearance-none pl-3 pr-8 py-2 border rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white transition-colors\n                                                    ${bindings[account.id]\n                                                        ? 'border-blue-300 dark:border-blue-700 ring-1 ring-blue-100 dark:ring-blue-900/20'\n                                                        : 'border-gray-300 dark:border-gray-600'\n                                                    }`}\n                                            >\n                                                <option value=\"\">{t('settings.proxy_pool.binding.default_strategy', 'Default (Follow Strategy)')}</option>\n                                                <optgroup label={t('settings.proxy_pool.proxies', 'Proxies')}>\n                                                    {availableProxies.map(proxy => (\n                                                        <option key={proxy.id} value={proxy.id}>\n                                                            {proxy.name || proxy.url}\n                                                        </option>\n                                                    ))}\n                                                </optgroup>\n                                            </select>\n                                            <div className=\"absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none text-gray-500\">\n                                                {isSaving === account.id ? (\n                                                    <div className=\"animate-spin h-4 w-4 border-2 border-gray-500 border-t-transparent rounded-full\" />\n                                                ) : (\n                                                    bindings[account.id] ? <Link2 size={16} className=\"text-blue-500\" /> : <Unlink size={16} className=\"opacity-50\" />\n                                                )}\n                                            </div>\n                                        </div>\n                                    </div>\n                                );\n                            })}\n\n                            {accounts.length === 0 && (\n                                <div className=\"text-center py-8 text-gray-500 dark:text-gray-400\">\n                                    {t('settings.account.no_accounts', 'No accounts found')}\n                                </div>\n                            )}\n                        </div>\n                    )}\n                </div>\n\n                {/* Footer */}\n                <div className=\"p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 rounded-b-xl flex justify-end\">\n                    <button\n                        onClick={onClose}\n                        className=\"px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors\"\n                    >\n                        {t('common.close', 'Close')}\n                    </button>\n                </div>\n            </div>\n        </div>,\n        document.body\n    );\n}\n"
  },
  {
    "path": "src/components/settings/proxy/ProxyEditModal.tsx",
    "content": "\nimport { useState, useEffect } from 'react';\nimport { createPortal } from 'react-dom';\nimport { X, Save, Plus } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { ProxyEntry } from '../../../types/config';\nimport { generateUUID } from '../../../utils/uuid';\n\ninterface ProxyEditModalProps {\n    isOpen: boolean;\n    onClose: () => void;\n    onSave: (entry: ProxyEntry) => void;\n    initialData?: ProxyEntry;\n    isEditing: boolean;\n}\n\nexport default function ProxyEditModal({ isOpen, onClose, onSave, initialData, isEditing }: ProxyEditModalProps) {\n    const { t } = useTranslation();\n    const [formData, setFormData] = useState<ProxyEntry>({\n        id: generateUUID(),\n        name: '',\n        url: '',\n        priority: 0,\n        enabled: true,\n        tags: [],\n        auth: {\n            username: '',\n            password: ''\n        },\n        max_accounts: 0,\n        is_healthy: true,\n        health_check_url: ''\n    });\n\n    const [tagInput, setTagInput] = useState('');\n\n    useEffect(() => {\n        if (isOpen) {\n            if (isEditing && initialData) {\n                setFormData(JSON.parse(JSON.stringify(initialData)));\n            } else {\n                setFormData({\n                    id: generateUUID(),\n                    name: '',\n                    url: '',\n                    priority: 0,\n                    enabled: true,\n                    tags: [],\n                    auth: { username: '', password: '' },\n                    max_accounts: 0,\n                    is_healthy: true,\n                    health_check_url: ''\n                });\n            }\n        }\n    }, [isOpen, initialData, isEditing]);\n\n    const handleSave = () => {\n        if (!formData.name || !formData.url) {\n            // Basic validation\n            return;\n        }\n        // Clean up auth if empty\n        const entryToSave = { ...formData };\n        if (!entryToSave.auth?.username && !entryToSave.auth?.password) {\n            entryToSave.auth = undefined;\n        }\n        onSave(entryToSave);\n        onClose();\n    };\n\n    const addTag = () => {\n        if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {\n            setFormData(prev => ({\n                ...prev,\n                tags: [...prev.tags, tagInput.trim()]\n            }));\n            setTagInput('');\n        }\n    };\n\n    const removeTag = (tag: string) => {\n        setFormData(prev => ({\n            ...prev,\n            tags: prev.tags.filter(t => t !== tag)\n        }));\n    };\n\n    if (!isOpen) return null;\n\n    return createPortal(\n        <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm\">\n            <div className=\"bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4 flex flex-col max-h-[90vh]\">\n                <div className=\"flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700\">\n                    <h3 className=\"text-lg font-semibold text-gray-900 dark:text-white\">\n                        {isEditing ? t('settings.proxy_pool.edit_proxy', 'Edit Proxy') : t('settings.proxy_pool.add_proxy', 'Add Proxy')}\n                    </h3>\n                    <button onClick={onClose} className=\"text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200\">\n                        <X size={20} />\n                    </button>\n                </div>\n\n                <div className=\"p-6 space-y-4 overflow-y-auto\">\n                    {/* Basic Info */}\n                    <div className=\"grid grid-cols-2 gap-4\">\n                        <div className=\"col-span-2\">\n                            <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">\n                                {t('settings.proxy_pool.name', 'Name')}\n                            </label>\n                            <input\n                                type=\"text\"\n                                value={formData.name}\n                                onChange={e => setFormData({ ...formData, name: e.target.value })}\n                                className=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500\"\n                                placeholder={t('settings.proxy_pool.name', 'Name')}\n                            />\n                        </div>\n                        <div className=\"col-span-2\">\n                            <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">\n                                {t('settings.proxy_pool.url', 'Proxy URL')}\n                            </label>\n                            <input\n                                type=\"text\"\n                                value={formData.url}\n                                onChange={e => setFormData({ ...formData, url: e.target.value })}\n                                className=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500\"\n                                placeholder=\"http://127.0.0.1:7890\"\n                            />\n                        </div>\n                    </div>\n\n                    {/* Auth */}\n                    <div className=\"grid grid-cols-2 gap-4 border-t border-gray-200 dark:border-gray-700 pt-4\">\n                        <div>\n                            <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">\n                                {t('settings.proxy_pool.username', 'Username')} ({t('common.optional', 'Optional')})\n                            </label>\n                            <input\n                                type=\"text\"\n                                value={formData.auth?.username || ''}\n                                onChange={e => setFormData({\n                                    ...formData,\n                                    auth: { ...formData.auth!, username: e.target.value }\n                                })}\n                                className=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white\"\n                            />\n                        </div>\n                        <div>\n                            <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">\n                                {t('settings.proxy_pool.password', 'Password')} ({t('common.optional', 'Optional')})\n                            </label>\n                            <input\n                                type=\"password\"\n                                value={formData.auth?.password || ''}\n                                onChange={e => setFormData({\n                                    ...formData,\n                                    auth: { ...formData.auth!, password: e.target.value }\n                                })}\n                                className=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white\"\n                            />\n                        </div>\n                    </div>\n\n                    {/* Advanced */}\n                    <div className=\"grid grid-cols-2 gap-4 border-t border-gray-200 dark:border-gray-700 pt-4\">\n                        <div>\n                            <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">\n                                {t('settings.proxy_pool.priority', 'Priority')} ({t('settings.proxy_pool.priority_hint', 'Lower is better')})\n                            </label>\n                            <input\n                                type=\"number\"\n                                value={formData.priority}\n                                onChange={e => setFormData({ ...formData, priority: parseInt(e.target.value) || 0 })}\n                                className=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white\"\n                            />\n                        </div>\n                        <div>\n                            <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">\n                                {t('settings.proxy_pool.max_accounts', 'Max Accounts')} ({t('settings.proxy_pool.max_accounts_hint', '0 = Unlimited')})\n                            </label>\n                            <input\n                                type=\"number\"\n                                value={formData.max_accounts || 0}\n                                onChange={e => setFormData({ ...formData, max_accounts: parseInt(e.target.value) || 0 })}\n                                className=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white\"\n                            />\n                        </div>\n                        <div className=\"col-span-2\">\n                            <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">\n                                {t('settings.proxy_pool.health_check_url', 'Health Check URL')}\n                            </label>\n                            <input\n                                type=\"text\"\n                                value={formData.health_check_url || ''}\n                                onChange={e => setFormData({ ...formData, health_check_url: e.target.value })}\n                                className=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white\"\n                                placeholder=\"https://www.google.com\"\n                            />\n                        </div>\n                    </div>\n\n                    {/* Tags */}\n                    <div className=\"border-t border-gray-200 dark:border-gray-700 pt-4\">\n                        <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">\n                            {t('settings.proxy_pool.tags', 'Tags')}\n                        </label>\n                        <div className=\"flex gap-2 mb-2\">\n                            <input\n                                type=\"text\"\n                                value={tagInput}\n                                onChange={e => setTagInput(e.target.value)}\n                                onKeyDown={e => e.key === 'Enter' && addTag()}\n                                className=\"flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white\"\n                                placeholder={t('settings.proxy_pool.add_tag_placeholder', 'Add tag...')}\n                            />\n                            <button onClick={addTag} className=\"p-2 bg-blue-600 text-white rounded-md hover:bg-blue-700\">\n                                <Plus size={20} />\n                            </button>\n                        </div>\n                        <div className=\"flex flex-wrap gap-2\">\n                            {formData.tags.map(tag => (\n                                <span key={tag} className=\"px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-md text-sm flex items-center gap-1\">\n                                    {tag}\n                                    <button onClick={() => removeTag(tag)} className=\"text-red-500 hover:text-red-700\"><X size={14} /></button>\n                                </span>\n                            ))}\n                        </div>\n                    </div>\n\n                </div>\n\n                <div className=\"p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-lg flex justify-end gap-3\">\n                    <button\n                        onClick={onClose}\n                        className=\"px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors\"\n                    >\n                        {t('common.cancel', 'Cancel')}\n                    </button>\n                    <button\n                        onClick={handleSave}\n                        className=\"px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center gap-2\"\n                    >\n                        <Save size={18} />\n                        {t('common.save', 'Save')}\n                    </button>\n                </div>\n            </div>\n        </div>,\n        document.body\n    );\n}\n"
  },
  {
    "path": "src/components/settings/proxy/ProxyList.tsx",
    "content": "\nimport React, { useState } from 'react';\nimport { Edit2, Trash2, Power, Globe } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { ProxyEntry } from '../../../types/config';\nimport ProxyEditModal from './ProxyEditModal';\nimport { Account } from '../../../types/account';\n\ninterface ProxyListProps {\n    proxies: ProxyEntry[];\n    onUpdate: (proxies: ProxyEntry[]) => void;\n    accountBindings: Record<string, string>;\n    accounts: Account[];\n    selectedIds: Set<string>;\n    onSelectionChange: (ids: Set<string>) => void;\n    isTesting?: boolean;\n}\n\nexport default function ProxyList({ proxies, onUpdate, accountBindings, accounts, selectedIds, onSelectionChange, isTesting }: ProxyListProps) {\n    const { t } = useTranslation();\n    const [editingProxy, setEditingProxy] = useState<ProxyEntry | undefined>(undefined);\n    const [isEditModalOpen, setIsEditModalOpen] = useState(false);\n\n    const handleEdit = (proxy: ProxyEntry) => {\n        setEditingProxy(proxy);\n        setIsEditModalOpen(true);\n    };\n\n    const handleDelete = (id: string) => {\n        if (confirm(t('settings.proxy_pool.confirm_delete', 'Are you sure you want to delete this proxy?'))) {\n            onUpdate(proxies.filter(p => p.id !== id));\n        }\n    };\n\n    const handleSaveProxy = (entry: ProxyEntry) => {\n        if (editingProxy) {\n            onUpdate(proxies.map(p => p.id === entry.id ? entry : p));\n        }\n        setEditingProxy(undefined);\n    };\n\n    const handleToggleEnabled = (id: string) => {\n        onUpdate(proxies.map(p => p.id === id ? { ...p, enabled: !p.enabled } : p));\n    };\n\n    const sortedProxies = [...proxies].sort((a, b) => a.priority - b.priority);\n\n    const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {\n        if (e.target.checked) {\n            onSelectionChange(new Set(proxies.map(p => p.id)));\n        } else {\n            onSelectionChange(new Set());\n        }\n    };\n\n    const handleSelectOne = (id: string) => {\n        const newSelected = new Set(selectedIds);\n        if (newSelected.has(id)) {\n            newSelected.delete(id);\n        } else {\n            newSelected.add(id);\n        }\n        onSelectionChange(newSelected);\n    };\n\n    const isAllSelected = proxies.length > 0 && selectedIds.size === proxies.length;\n    const isSomeSelected = selectedIds.size > 0 && selectedIds.size < proxies.length;\n\n    // Helper to get bound accounts for a proxy\n    const getBoundAccounts = (proxyId: string) => {\n        const boundAccountIds = Object.entries(accountBindings)\n            .filter(([_, boundProxyId]) => boundProxyId === proxyId)\n            .map(([accountId]) => accountId);\n\n        return boundAccountIds.map(id => accounts.find(a => a.id === id)).filter(Boolean) as Account[];\n    };\n\n    return (\n        <div className=\"overflow-hidden\">\n            <table className=\"min-w-full divide-y divide-gray-100 dark:divide-gray-800\">\n                <thead className=\"bg-gray-50/50 dark:bg-gray-900/50\">\n                    <tr>\n                        <th scope=\"col\" className=\"px-4 py-3 text-left w-10 pl-6\">\n                            <input\n                                type=\"checkbox\"\n                                checked={isAllSelected}\n                                ref={input => {\n                                    if (input) input.indeterminate = isSomeSelected;\n                                }}\n                                onChange={handleSelectAll}\n                                className=\"w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600\"\n                            />\n                        </th>\n                        <th scope=\"col\" className=\"px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-widest min-w-[60px]\">\n                            {t('settings.proxy_pool.column_priority', 'PRI')}\n                        </th>\n                        <th scope=\"col\" className=\"px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-widest\">\n                            {t('settings.proxy_pool.column_status', 'Status')}\n                        </th>\n                        <th scope=\"col\" className=\"px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-widest w-1/3\">\n                            {t('settings.proxy_pool.column_details', 'Proxy Details')}\n                        </th>\n                        <th scope=\"col\" className=\"px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-widest\">\n                            {t('settings.proxy_pool.column_bindings', 'Bindings')}\n                        </th>\n                        <th scope=\"col\" className=\"px-4 py-3 text-right text-[10px] font-bold text-gray-400 uppercase tracking-widest pr-6\">\n                            {t('common.actions', 'Actions')}\n                        </th>\n                    </tr>\n                </thead>\n                <tbody className=\"bg-white dark:bg-gray-900 divide-y divide-gray-50 dark:divide-gray-800\">\n                    {sortedProxies.length === 0 ? (\n                        <td colSpan={6} className=\"px-6 py-12 text-center text-sm text-gray-500 dark:text-gray-400\">\n                            <span className=\"opacity-50\">{t('settings.proxy_pool.empty', 'No proxies available.')}</span>\n                        </td>\n                    ) : (\n                        sortedProxies.map((proxy) => {\n                            const boundAccounts = getBoundAccounts(proxy.id);\n\n                            return (\n                                <tr\n                                    key={proxy.id}\n                                    className={`group hover:bg-gray-50 dark:hover:bg-gray-800/20 transition-colors ${!proxy.enabled ? 'bg-gray-50/30 dark:bg-gray-900/50' : ''} ${selectedIds.has(proxy.id) ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}`}\n                                >\n                                    <td className=\"px-4 py-3 whitespace-nowrap pl-6\">\n                                        <input\n                                            type=\"checkbox\"\n                                            checked={selectedIds.has(proxy.id)}\n                                            onChange={() => handleSelectOne(proxy.id)}\n                                            className=\"w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600\"\n                                        />\n                                    </td>\n                                    <td className=\"px-4 py-3 whitespace-nowrap\">\n                                        <div className={`flex items-center justify-center w-6 h-6 rounded-full border text-[10px] font-black transition-all shadow-inner ${proxy.enabled\n                                            ? 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300'\n                                            : 'bg-gray-50 dark:bg-gray-900 border-gray-100 dark:border-gray-800 text-gray-400 dark:text-gray-600 opacity-50'\n                                            }`}>\n                                            {proxy.priority}\n                                        </div>\n                                    </td>\n                                    <td className=\"px-4 py-3 whitespace-nowrap\">\n                                        <div className=\"flex items-center gap-2\">\n                                            <div className=\"relative group\">\n                                                <button\n                                                    onClick={() => !isTesting && handleToggleEnabled(proxy.id)}\n                                                    className={`relative p-1 rounded-lg transition-all ${!proxy.enabled ? 'opacity-40 hover:opacity-100' : ''}`}\n                                                >\n                                                    <div className={`w-2 h-2 rounded-full transition-all duration-500 shadow-lg ${!proxy.enabled\n                                                        ? 'bg-gray-400'\n                                                        : proxy.latency !== undefined && proxy.latency !== null\n                                                            ? 'bg-emerald-500 shadow-emerald-500/50'\n                                                            : proxy.is_healthy\n                                                                ? 'bg-emerald-500 shadow-emerald-500/50'\n                                                                : isTesting && !proxy.latency\n                                                                    ? 'bg-blue-400 animate-pulse'\n                                                                    : 'bg-rose-500 shadow-rose-500/50'\n                                                        }`}></div>\n                                                </button>\n                                            </div>\n\n                                            {/* Status Pill Tag */}\n                                            <div className={`px-2 py-0.5 rounded-md text-[10px] font-black uppercase tracking-tighter border transition-all duration-300 ${!proxy.enabled\n                                                ? 'bg-gray-100 text-gray-400 border-gray-200 dark:bg-gray-800 dark:border-gray-700'\n                                                : proxy.latency !== undefined && proxy.latency !== null\n                                                    ? 'bg-emerald-50 text-emerald-600 border-emerald-100 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-800/50 shadow-sm'\n                                                    : isTesting\n                                                        ? 'bg-blue-50 text-blue-600 border-blue-100 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800/50 animate-pulse'\n                                                        : proxy.is_healthy\n                                                            ? 'bg-emerald-50 text-emerald-600 border-emerald-100 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-800/50'\n                                                            : 'bg-rose-50 text-rose-600 border-rose-100 dark:bg-rose-900/20 dark:text-rose-400 dark:border-rose-800/50 shadow-sm'\n                                                }`}>\n                                                {!proxy.enabled\n                                                    ? t('settings.proxy_pool.status.inactive', 'Inactive')\n                                                    : proxy.latency !== undefined && proxy.latency !== null\n                                                        ? `${proxy.latency}ms`\n                                                        : isTesting\n                                                            ? t('settings.proxy_pool.status.checking', 'Checking')\n                                                            : proxy.is_healthy\n                                                                ? t('settings.proxy_pool.status.healthy', 'Healthy')\n                                                                : t('settings.proxy_pool.status.timeout', 'Timeout')\n                                                }\n                                            </div>\n                                        </div>\n                                    </td>\n                                    <td className=\"px-4 py-3\">\n                                        <div className={`flex flex-col ${!proxy.enabled ? 'opacity-50 grayscale' : ''}`}>\n                                            <div className=\"flex items-center gap-2\">\n                                                <span className=\"font-semibold text-sm text-gray-900 dark:text-gray-100\">\n                                                    {proxy.name}\n                                                </span>\n                                                {proxy.tags.map(tag => (\n                                                    <span key={tag} className=\"px-1.5 py-0.5 rounded text-[10px] bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 font-medium tracking-wide\">\n                                                        {tag}\n                                                    </span>\n                                                ))}\n                                            </div>\n                                            <div className=\"flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500 font-mono mt-0.5 max-w-[240px] truncate\" title={proxy.url}>\n                                                <Globe size={10} />\n                                                {proxy.url}\n                                            </div>\n                                        </div>\n                                    </td>\n                                    <td className=\"px-4 py-3 whitespace-nowrap\">\n                                        {boundAccounts.length > 0 ? (\n                                            <div className=\"flex flex-wrap gap-1 max-w-[140px]\" title={`Bound to:\\n${boundAccounts.map(a => a.email).join('\\n')}`}>\n                                                {boundAccounts.slice(0, 2).map(acc => (\n                                                    <div key={acc.id} className=\"px-1.5 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 border border-indigo-100 dark:border-indigo-800/50 flex items-center gap-1\">\n                                                        <div className=\"w-1.5 h-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400\"></div>\n                                                        <span className=\"text-[10px] font-medium text-indigo-700 dark:text-indigo-300\">\n                                                            {acc.email.split('@')[0].substring(0, 4)}\n                                                        </span>\n                                                    </div>\n                                                ))}\n                                                {boundAccounts.length > 2 && (\n                                                    <div className=\"px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 flex items-center justify-center text-[10px] font-medium text-gray-500 dark:text-gray-400\">\n                                                        +{boundAccounts.length - 2}\n                                                    </div>\n                                                )}\n                                            </div>\n                                        ) : (\n                                            <span className=\"text-xs text-gray-300 dark:text-gray-700 italic pl-1\">{t('common.none', 'None')}</span>\n                                        )}\n                                    </td>\n                                    <td className=\"px-4 py-3 whitespace-nowrap text-right pr-6\">\n                                        <div className=\"flex items-center justify-end gap-1\">\n                                            <button\n                                                onClick={() => handleToggleEnabled(proxy.id)}\n                                                className={`p-1.5 transition-colors ${proxy.enabled\n                                                    ? 'text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'\n                                                    : 'text-gray-300 hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-400'\n                                                    }`}\n                                                title={proxy.enabled ? t('common.disable', 'Disable') : t('common.enable', 'Enable')}\n                                            >\n                                                <Power size={14} />\n                                            </button>\n                                            <button\n                                                onClick={() => handleEdit(proxy)}\n                                                className=\"p-1.5 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors\"\n                                                title={t('common.edit', 'Edit')}\n                                            >\n                                                <Edit2 size={14} />\n                                            </button>\n                                            <button\n                                                onClick={() => handleDelete(proxy.id)}\n                                                className=\"p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors\"\n                                                title={t('common.delete', 'Delete')}\n                                            >\n                                                <Trash2 size={14} />\n                                            </button>\n                                        </div>\n                                    </td>\n                                </tr>\n                            )\n                        })\n                    )}\n                </tbody>\n            </table>\n\n            {isEditModalOpen && editingProxy && (\n                <ProxyEditModal\n                    isOpen={isEditModalOpen}\n                    onClose={() => setIsEditModalOpen(false)}\n                    onSave={handleSaveProxy}\n                    initialData={editingProxy}\n                    isEditing={true}\n                />\n            )}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/config/modelConfig.ts",
    "content": "import { Gemini, Claude } from '@lobehub/icons';\n\n/**\n * 模型配置接口\n */\nexport interface ModelConfig {\n    /** 模型完整显示名称 (作为回退或默认展示) */\n    label: string;\n    /** 模型简短标签 (用于列表/卡片) */\n    shortLabel: string;\n    /** 保护模型的键名 */\n    protectedKey: string;\n    /** 模型图标组件 */\n    Icon: React.ComponentType<{ size?: number; className?: string }>;\n    /** 国际化键名 (用于动态名称) */\n    i18nKey: string;\n    /** 描述信息键名 (用于详细说明) */\n    i18nDescKey: string;\n    /** 所属系列/分组 */\n    group: string;\n    /** 选填标签 (用于筛选) */\n    tags?: string[];\n}\n\n/**\n * 模型配置映射\n * 键为模型 ID，值为模型配置\n */\nexport const MODEL_CONFIG: Record<string, ModelConfig> = {\n    // Gemini 3.x 系列\n    // [Migrate] Gemini 3 Pro High/Low -> Gemini 3.1 Pro High/Low\n    'gemini-3.1-pro-high': {\n        label: 'Gemini 3.1 Pro High',\n        shortLabel: 'G3.1 Pro',\n        protectedKey: 'gemini-pro',\n        Icon: Gemini.Color,\n        i18nKey: 'proxy.model.pro_high',\n        i18nDescKey: 'proxy.model.pro_high',\n        group: 'Gemini 3',\n        tags: ['pro', 'high'],\n    },\n    // Backward-compatible alias\n    'gemini-3-pro-high': {\n        label: 'Gemini 3.1 Pro High',\n        shortLabel: 'G3.1 Pro',\n        protectedKey: 'gemini-pro',\n        Icon: Gemini.Color,\n        i18nKey: 'proxy.model.pro_high',\n        i18nDescKey: 'proxy.model.pro_high',\n        group: 'Gemini 3',\n        tags: ['pro', 'high'],\n    },\n    'gemini-3-flash': {\n        label: 'Gemini 3 Flash',\n        shortLabel: 'G3 Flash',\n        protectedKey: 'gemini-flash',\n        Icon: Gemini.Color,\n        i18nKey: 'proxy.model.flash_preview',\n        i18nDescKey: 'proxy.model.flash_preview',\n        group: 'Gemini 3',\n        tags: ['flash'],\n    },\n    'gemini-3.1-flash-image': {\n        label: 'Gemini 3.1 Flash Image',\n        shortLabel: 'G3.1 Image',\n        protectedKey: 'gemini-3-pro-image',\n        Icon: Gemini.Color,\n        i18nKey: 'proxy.model.pro_image',\n        i18nDescKey: 'proxy.model.pro_image_1_1',\n        group: 'Gemini 3',\n        tags: ['image', 'flash'],\n    },\n    'gemini-3-pro-image': {\n        label: 'Gemini 3 Image',\n        shortLabel: 'G3 Image',\n        protectedKey: 'gemini-3-pro-image',\n        Icon: Gemini.Color,\n        i18nKey: 'proxy.model.pro_image',\n        i18nDescKey: 'proxy.model.pro_image_1_1',\n        group: 'Gemini 3',\n        tags: ['image'],\n    },\n    'gemini-3.1-pro-low': {\n        label: 'Gemini 3.1 Pro Low',\n        shortLabel: 'G3.1 Low',\n        protectedKey: 'gemini-pro',\n        Icon: Gemini.Color,\n        i18nKey: 'proxy.model.pro_low',\n        i18nDescKey: 'proxy.model.pro_low',\n        group: 'Gemini 3',\n        tags: ['pro', 'low'],\n    },\n    // Backward-compatible alias\n    'gemini-3-pro-low': {\n        label: 'Gemini 3.1 Pro Low',\n        shortLabel: 'G3.1 Low',\n        protectedKey: 'gemini-pro',\n        Icon: Gemini.Color,\n        i18nKey: 'proxy.model.pro_low',\n        i18nDescKey: 'proxy.model.pro_low',\n        group: 'Gemini 3',\n        tags: ['pro', 'low'],\n    },\n\n    // Gemini 2.5 系列\n    'gemini-2.5-flash': {\n        label: 'Gemini 2.5 Flash',\n        shortLabel: 'G2.5 Flash',\n        protectedKey: 'gemini-flash',\n        Icon: Gemini.Color,\n        i18nKey: 'proxy.model.gemini_2_5_flash',\n        i18nDescKey: 'proxy.model.gemini_2_5_flash',\n        group: 'Gemini 2.5',\n        tags: ['flash'],\n    },\n    'gemini-2.5-flash-lite': {\n        label: 'Gemini 2.5 Flash Lite',\n        shortLabel: 'G2.5 Lite',\n        protectedKey: 'gemini-flash',\n        Icon: Gemini.Color,\n        i18nKey: 'proxy.model.flash_lite',\n        i18nDescKey: 'proxy.model.flash_lite',\n        group: 'Gemini 2.5',\n        tags: ['flash', 'lite'],\n    },\n    'gemini-2.5-flash-thinking': {\n        label: 'Gemini 2.5 Flash Think',\n        shortLabel: 'G2.5 Think',\n        protectedKey: 'gemini-flash',\n        Icon: Gemini.Color,\n        i18nKey: 'proxy.model.flash_thinking',\n        i18nDescKey: 'proxy.model.flash_thinking',\n        group: 'Gemini 2.5',\n        tags: ['flash', 'thinking'],\n    },\n    'gemini-2.5-pro': {\n        label: 'Gemini 2.5 Pro',\n        shortLabel: 'G2.5 Pro',\n        protectedKey: 'gemini-pro',\n        Icon: Gemini.Color,\n        i18nKey: 'proxy.model.gemini_2_5_pro',\n        i18nDescKey: 'proxy.model.gemini_2_5_pro',\n        group: 'Gemini 2.5',\n        tags: ['pro'],\n    },\n\n    // Claude 系列\n    'claude-sonnet-4-6': {\n        label: 'Claude 4.6',\n        shortLabel: 'Claude 4.6',\n        protectedKey: 'claude',\n        Icon: Claude.Color,\n        i18nKey: 'proxy.model.claude_sonnet',\n        i18nDescKey: 'proxy.model.claude_sonnet',\n        group: 'Claude',\n        tags: ['sonnet'],\n    },\n    'claude-sonnet-4-6-thinking': {\n        label: 'Claude 4.6 TK',\n        shortLabel: 'Claude 4.6 TK',\n        protectedKey: 'claude',\n        Icon: Claude.Color,\n        i18nKey: 'proxy.model.claude_sonnet_thinking',\n        i18nDescKey: 'proxy.model.claude_sonnet_thinking',\n        group: 'Claude',\n        tags: ['sonnet', 'thinking'],\n    },\n    'claude-opus-4-6-thinking': {\n        label: 'Claude Opus 4.6 TK',\n        shortLabel: 'Claude Opus 4.6 TK',\n        protectedKey: 'claude',\n        Icon: Claude.Color,\n        i18nKey: 'proxy.model.claude_opus_thinking',\n        i18nDescKey: 'proxy.model.claude_opus_thinking',\n        group: 'Claude',\n        tags: ['opus', 'thinking'],\n    },\n};\n\n/**\n * 获取所有模型 ID 列表\n */\nexport const getAllModelIds = (): string[] => Object.keys(MODEL_CONFIG);\n\n/**\n * 根据模型 ID 获取配置\n */\nexport const getModelConfig = (modelId: string): ModelConfig | undefined => {\n    return MODEL_CONFIG[modelId.toLowerCase()];\n};\n\n/**\n * 模型排序权重配置\n * 数字越小，优先级越高\n */\nconst MODEL_SORT_WEIGHTS = {\n    // 系列权重 (第一优先级)\n    series: {\n        'gemini-3': 100,\n        'gemini-2.5': 200,\n        'gemini-2': 300,\n        'claude': 400,\n    },\n    // 性能级别权重 (第二优先级)\n    tier: {\n        'pro': 10,\n        'flash': 20,\n        'lite': 30,\n        'opus': 5,\n        'sonnet': 10,\n    },\n    // 特殊后缀权重 (第三优先级)\n    suffix: {\n        'thinking': 1,\n        'image': 2,\n        'high': 0,\n        'low': 3,\n    }\n};\n\n/**\n * 获取模型的排序权重\n */\nfunction getModelSortWeight(modelId: string): number {\n    const id = modelId.toLowerCase();\n    let weight = 0;\n\n    // 1. 系列权重 (x1000)\n    if (id.startsWith('gemini-3')) {\n        weight += MODEL_SORT_WEIGHTS.series['gemini-3'] * 1000;\n    } else if (id.startsWith('gemini-2.5')) {\n        weight += MODEL_SORT_WEIGHTS.series['gemini-2.5'] * 1000;\n    } else if (id.startsWith('gemini-2')) {\n        weight += MODEL_SORT_WEIGHTS.series['gemini-2'] * 1000;\n    } else if (id.startsWith('claude')) {\n        weight += MODEL_SORT_WEIGHTS.series['claude'] * 1000;\n    }\n\n    // 2. 性能级别权重 (x100)\n    if (id.includes('pro')) {\n        weight += MODEL_SORT_WEIGHTS.tier['pro'] * 100;\n    } else if (id.includes('flash')) {\n        weight += MODEL_SORT_WEIGHTS.tier['flash'] * 100;\n    } else if (id.includes('lite')) {\n        weight += MODEL_SORT_WEIGHTS.tier['lite'] * 100;\n    } else if (id.includes('opus')) {\n        weight += MODEL_SORT_WEIGHTS.tier['opus'] * 100;\n    } else if (id.includes('sonnet')) {\n        weight += MODEL_SORT_WEIGHTS.tier['sonnet'] * 100;\n    }\n\n    // 3. 特殊后缀权重 (x10)\n    if (id.includes('thinking')) {\n        weight += MODEL_SORT_WEIGHTS.suffix['thinking'] * 10;\n    } else if (id.includes('image')) {\n        weight += MODEL_SORT_WEIGHTS.suffix['image'] * 10;\n    } else if (id.includes('high')) {\n        weight += MODEL_SORT_WEIGHTS.suffix['high'] * 10;\n    } else if (id.includes('low')) {\n        weight += MODEL_SORT_WEIGHTS.suffix['low'] * 10;\n    }\n\n    return weight;\n}\n\n/**\n * 对模型列表进行排序\n * @param models 模型列表\n * @returns 排序后的模型列表\n */\nexport function sortModels<T extends { id: string }>(models: T[]): T[] {\n    return [...models].sort((a, b) => {\n        const weightA = getModelSortWeight(a.id);\n        const weightB = getModelSortWeight(b.id);\n\n        // 按权重升序排序\n        if (weightA !== weightB) {\n            return weightA - weightB;\n        }\n\n        // 权重相同时，按字母顺序排序\n        return a.id.localeCompare(b.id);\n    });\n}\n"
  },
  {
    "path": "src/hooks/useProxyModels.tsx",
    "content": "import { useMemo, useEffect } from 'react';\nimport { MODEL_CONFIG } from '../config/modelConfig';\nimport { useAccountStore } from '../stores/useAccountStore';\nimport { Bot } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\n\nexport const useProxyModels = () => {\n    const { t } = useTranslation();\n    const { accounts, fetchAccounts } = useAccountStore();\n\n    // 确保账号数据已加载（针对未触发 fetchAccounts 的页面，如 ApiProxy）\n    useEffect(() => {\n        if (accounts.length === 0) {\n            fetchAccounts();\n        }\n    }, []); // eslint-disable-line react-hooks/exhaustive-deps\n\n    const models = useMemo(() => {\n        // Step 1: 从所有账号中收集动态模型\n        // 以 name（小写）为 key 去重，优先保留含 display_name 的条目\n        const dynamicMap = new Map<string, { name: string; display_name?: string }>();\n        for (const account of accounts) {\n            for (const m of account.quota?.models ?? []) {\n                const key = m.name.toLowerCase();\n                if (!dynamicMap.has(key) || m.display_name) {\n                    dynamicMap.set(key, { name: m.name, display_name: m.display_name });\n                }\n            }\n        }\n\n        const result = [];\n        const seenIds = new Set<string>();\n\n        // Step 2: 优先展示来自账号的动态模型（display_name 为主名称，name 为 ID）\n        for (const [key, m] of dynamicMap) {\n            if (seenIds.has(key)) continue;\n            seenIds.add(key);\n\n            // 尝试匹配 MODEL_CONFIG 里的图标与分组\n            const cfgEntry = Object.entries(MODEL_CONFIG).find(\n                ([cfgId, cfg]) =>\n                    cfgId.toLowerCase() === key ||\n                    (cfg.protectedKey && cfg.protectedKey.toLowerCase() === key)\n            );\n\n            const primaryName = m.display_name || m.name;\n            const CfgIcon = cfgEntry?.[1].Icon;\n            const icon = CfgIcon\n                ? <CfgIcon size={16} />\n                : <Bot size={16} className=\"text-gray-400 dark:text-gray-500\" />;\n            const group = cfgEntry ? (cfgEntry[1].group || 'Other') : 'Dynamic';\n\n            result.push({\n                id: m.name,           // 原始模型 name，作为 ID 展示\n                name: primaryName,    // display_name（主要展示名称）\n                desc: primaryName,    // 描述栏同样用 display_name\n                group,\n                icon,\n            });\n        }\n\n        // Step 3: 对于 MODEL_CONFIG 里有但账号未下发的型号，作为静态兜底补充\n        const addedLabels = new Set<string>();\n        for (const [id, config] of Object.entries(MODEL_CONFIG)) {\n            const key = id.toLowerCase();\n            if (seenIds.has(key)) {\n                addedLabels.add((config.shortLabel || config.label).toLowerCase());\n                continue;\n            }\n            // 跳过 thinking 变体（这类模型的动态版本由账号数据中 supports_thinking 标记覆盖）\n            if (key.includes('-thinking')) continue;\n            // 跳过 label 重复的别名条目\n            const labelKey = (config.shortLabel || config.label).toLowerCase();\n            if (addedLabels.has(labelKey)) continue;\n            addedLabels.add(labelKey);\n            seenIds.add(key);\n\n            const displayName = config.i18nKey ? t(config.i18nKey, config.label) : config.label;\n            result.push({\n                id,\n                name: displayName,\n                desc: displayName,\n                group: config.group || 'Other',\n                icon: <config.Icon size={16} />,\n            });\n        }\n\n        return result;\n    }, [accounts, t]);\n\n    return { models };\n};\n"
  },
  {
    "path": "src/i18n.ts",
    "content": "import i18n from \"i18next\";\nimport { initReactI18next } from \"react-i18next\";\nimport LanguageDetector from \"i18next-browser-languagedetector\";\n\nimport en from \"./locales/en.json\";\nimport zh from \"./locales/zh.json\";\nimport zhTW from \"./locales/zh-TW.json\";\nimport ja from \"./locales/ja.json\";\nimport tr from \"./locales/tr.json\";\nimport vi from \"./locales/vi.json\";\nimport pt from \"./locales/pt.json\";\nimport ru from \"./locales/ru.json\";\nimport ko from \"./locales/ko.json\";\nimport ar from \"./locales/ar.json\";\nimport es from \"./locales/es.json\";\nimport my from \"./locales/my.json\";\n\ni18n\n    // detect user language\n    // learn more: https://github.com/i18next/i18next-browser-languagedetector\n    .use(LanguageDetector)\n    // pass the i18n instance to react-i18next.\n    .use(initReactI18next)\n    // init i18next\n    // for all options read: https://www.i18next.com/overview/configuration-options\n    .init({\n        resources: {\n            en: {\n                translation: en,\n            },\n            zh: {\n                translation: zh,\n            },\n            \"zh-TW\": {\n                translation: zhTW,\n            },\n            ja: {\n                translation: ja,\n            },\n            tr: {\n                translation: tr,\n            },\n            // Handling 'zh-CN' as 'zh'\n            \"zh-CN\": {\n                translation: zh,\n            },\n            vi: {\n                translation: vi,\n            },\n            \"vi-VN\": {\n                translation: vi,\n            },\n            pt: {\n                translation: pt,\n            },\n            \"pt-BR\": {\n                translation: pt,\n            },\n            ru: {\n                translation: ru,\n            },\n            ko: {\n                translation: ko,\n            },\n            ar: {\n                translation: ar,\n            },\n            es: {\n                translation: es,\n            },\n            \"es-ES\": {\n                translation: es,\n            },\n            \"es-MX\": {\n                translation: es,\n            },\n            my: {\n                translation: my,\n            },\n            \"ms\": {\n                translation: my,\n            },\n            \"ms-MY\": {\n                translation: my,\n            },\n        },\n        fallbackLng: \"en\",\n        debug: false, // Set to true for development\n\n        interpolation: {\n            escapeValue: false, // not needed for react as it escapes by default\n        },\n    });\n\nexport default i18n;\n"
  },
  {
    "path": "src/locales/ar.json",
    "content": "{\n    \"common\": {\n        \"loading\": \"جاري التحميل...\",\n        \"load_more\": \"تحميل المزيد\",\n        \"add\": \"إضافة\",\n        \"copy\": \"نسخ\",\n        \"action\": \"إجراء\",\n        \"save\": \"حفظ\",\n        \"saved\": \"تم الحفظ بنجاح\",\n        \"cancel\": \"إلغاء\",\n        \"confirm\": \"تأكيد\",\n        \"close\": \"إغلاق\",\n        \"delete\": \"حذف\",\n        \"edit\": \"تعديل\",\n        \"refresh\": \"تحديث\",\n        \"refreshing\": \"جاري التحديث...\",\n        \"export\": \"تصدير\",\n        \"import\": \"استيراد\",\n        \"success\": \"نجاح\",\n        \"error\": \"خطأ\",\n        \"unknown\": \"مجهول\",\n        \"warning\": \"تحذير\",\n        \"info\": \"معلومات\",\n        \"details\": \"التفاصيل\",\n        \"example\": \"Example\",\n        \"clear\": \"مسح\",\n        \"clearing\": \"جاري المسح...\",\n        \"prev_page\": \"السابق\",\n        \"next_page\": \"التالي\",\n        \"pagination_info\": \"عرض {{start}} إلى {{end}} من {{total}} مدخلاً\",\n        \"per_page\": \"لكل صفحة\",\n        \"items\": \"عناصر\",\n        \"accounts\": \"حسابات\",\n        \"enabled\": \"مفعل\",\n        \"disabled\": \"معطل\",\n        \"tauri_api_not_loaded\": \"Tauri API لم يتم تحميله، يرجى إعادة تشغيل التطبيق\",\n        \"environment_error\": \"خطأ في البيئة: {{error}}\",\n        \"submit\": \"إرسال\",\n        \"update\": \"تحديث\",\n        \"load_failed\": \"فشل التحميل\",\n        \"create_success\": \"تم الإنشاء بنجاح\",\n        \"update_success\": \"تم التحديث بنجاح\",\n        \"delete_success\": \"تم الحذف بنجاح\",\n        \"copied\": \"تم النسخ إلى الحافظة\"\n    },\n    \"nav\": {\n        \"dashboard\": \"الرئيسية\",\n        \"accounts\": \"الحسابات\",\n        \"proxy\": \"وكيل API\",\n        \"call_records\": \"سجلات المرور\",\n        \"security\": \"إدارة IP\",\n        \"token_stats\": \"إحصائيات Token\",\n        \"settings\": \"الإعدادات\",\n        \"theme_to_dark\": \"الوضع الداكن\",\n        \"theme_to_light\": \"الوضع الفاتح\",\n        \"switch_to_english\": \"Switch to English\",\n        \"switch_to_chinese\": \"Switch to Chinese\",\n        \"switch_to_traditional_chinese\": \"Switch to Traditional Chinese\",\n        \"switch_to_english_short\": \"EN\",\n        \"switch_to_chinese_short\": \"ZH\",\n        \"switch_to_traditional_chinese_short\": \"TW\",\n        \"switch_to_japanese\": \"Switch to Japanese\",\n        \"switch_to_japanese_short\": \"JA\",\n        \"switch_to_turkish\": \"Switch to Turkish\",\n        \"switch_to_turkish_short\": \"TR\",\n        \"switch_to_vietnamese\": \"Switch to Vietnamese\",\n        \"switch_to_vietnamese_short\": \"VI\",\n        \"switch_to_russian\": \"Switch to Russian\",\n        \"switch_to_russian_short\": \"RU\",\n        \"switch_to_portuguese\": \"Switch to Portuguese\",\n        \"switch_to_portuguese_short\": \"PT\",\n        \"switch_to_korean\": \"Switch to Korean\",\n        \"switch_to_korean_short\": \"KO\",\n        \"switch_to_spanish\": \"Switch to Spanish\",\n        \"switch_to_spanish_short\": \"ES\",\n        \"switch_to_malay\": \"Switch to Malay\",\n        \"switch_to_malay_short\": \"MY\",\n        \"user_token\": \"رموز المستخدم\"\n    },\n    \"dashboard\": {\n        \"hello\": \"مرحبًا، المستخدم 👋\",\n        \"refresh_quota\": \"تحديث الحصة\",\n        \"refreshing\": \"جاري التحديث...\",\n        \"total_accounts\": \"إجمالي الحسابات\",\n        \"avg_gemini\": \"معدل حصة Gemini\",\n        \"avg_gemini_image\": \"معدل حصة صور Gemini\",\n        \"avg_claude\": \"معدل حصة Claude\",\n        \"low_quota_accounts\": \"حسابات منخفضة الحصة\",\n        \"quota_sufficient\": \"الحصة كافية\",\n        \"quota_low\": \"حصة منخفضة\",\n        \"quota_desc\": \"الحصة < 20%\",\n        \"current_account\": \"الحساب الحالي\",\n        \"switch_account\": \"تبديل الحساب\",\n        \"no_active_account\": \"لا يوجد حساب نشط\",\n        \"best_accounts\": \"أفضل الحسابات\",\n        \"best_account_recommendation\": \"أفضل حساب\",\n        \"switch_best\": \"التبديل للأفضل\",\n        \"switch_successfully\": \"تم التبديل للأفضل\",\n        \"view_all_accounts\": \"عرض كل الحسابات\",\n        \"export_data\": \"تصدير البيانات\",\n        \"for_gemini\": \"لـ Gemini\",\n        \"for_claude\": \"لـ Claude\",\n        \"toast\": {\n            \"switch_success\": \"تم التبديل بنجاح!\",\n            \"switch_error\": \"فشل تبديل الحساب\",\n            \"refresh_success\": \"تم تحديث الحصة بنجاح\",\n            \"refresh_error\": \"فشل التحديث\",\n            \"export_no_accounts\": \"لا توجد حسابات للتصدير\",\n            \"export_success\": \"تم التصدير بنجاح! تم الحفظ في: {{path}}\",\n            \"export_error\": \"فشل التصدير\"\n        }\n    },\n    \"accounts\": {\n        \"account\": \"الحساب\",\n        \"search_placeholder\": \"بحث عن بريد إلكتروني...\",\n        \"all\": \"الكل\",\n        \"available\": \"متاح\",\n        \"low_quota\": \"حصة منخفضة\",\n        \"ultra\": \"ULTRA\",\n        \"pro\": \"PRO\",\n        \"free\": \"FREE\",\n        \"edit_label\": \"تعديل التصنيف\",\n        \"custom_label_placeholder\": \"أدخل تصنيفًا مخصصًا\",\n        \"label_updated\": \"تم تحديث التصنيف\",\n        \"add_account\": \"إضافة حساب\",\n        \"refresh_all\": \"تحديث الكل\",\n        \"refresh_selected\": \"تحديث ({{count}})\",\n        \"export_selected\": \"تصدير ({{count}})\",\n        \"import_json\": \"استيراد\",\n        \"import_success\": \"تم استيراد {{count}} حساب بنجاح\",\n        \"import_partial\": \"اكتمل الاستيراد: نجح {{success}}، فشل {{fail}}\",\n        \"import_fail\": \"فشل الاستيراد: {{error}}\",\n        \"import_invalid_format\": \"تنسيق JSON غير صالح، يرجى التأكد من احتواء الملف على حقول email و refresh_token\",\n        \"delete_selected\": \"حذف ({{count}})\",\n        \"current\": \"الحالي\",\n        \"current_badge\": \"الحالي\",\n        \"disabled\": \"معطل\",\n        \"disabled_tooltip\": \"الحساب معطل (مثلًا: تم إبطال/انتهاء صلاحية refresh_token). أعد التفويض أو حدث التوكن لإعادة التفعيل.\",\n        \"proxy_disabled\": \"الوكيل معطل\",\n        \"proxy_disabled_tooltip\": \"تم تعطيل الوكيل لهذا الحساب يدويًا، لن يعالج طلبات API ولكنه يظل قابلاً للاستخدام في التطبيق.\",\n        \"enable_proxy\": \"تفعيل الوكيل\",\n        \"disable_proxy\": \"تعطيل الوكيل\",\n        \"enable_proxy_selected\": \"تفعيل ({{count}})\",\n        \"disable_proxy_selected\": \"تعطيل ({{count}})\",\n        \"proxy_disabled_reason_manual\": \"تم التعطيل يدويًا بواسطة المستخدم\",\n        \"proxy_disabled_reason_batch\": \"تم التعطيل في الدفعة\",\n        \"forbidden\": \"403\",\n        \"forbidden_badge\": \"403\",\n        \"forbidden_tooltip\": \"أعاد API الخطأ 403 Forbidden، الحساب ليس لديه إذن لخدمة Gemini Code Assist\",\n        \"forbidden_msg\": \"ممنوع، تخطي التحديث التلقائي\",\n        \"status\": {\n            \"forbidden\": \"403 Forbidden\",\n            \"disabled\": \"الحساب معطل\",\n            \"proxy_disabled\": \"الوكيل معطل\"\n        },\n        \"error_details\": \"تفاصيل الخطأ\",\n        \"error_status\": \"حالة الخطأ\",\n        \"error_time\": \"وقت الكشف\",\n        \"view_error\": \"عرض السبب\",\n        \"click_to_verify\": \"انقر للتحقق\",\n        \"no_data\": \"لا توجد بيانات\",\n        \"last_used\": \"آخر استخدام\",\n        \"reset_time\": \"وقت إعادة التعيين\",\n        \"switch_to\": \"التبديل لهذا الحساب\",\n        \"actions\": \"إجراءات\",\n        \"device_fingerprint\": \"بصمة الجهاز\",\n        \"show_all_quotas\": \"إظهار جميع الحصص\",\n        \"device_fingerprint_dialog\": {\n            \"title\": \"بصمة الجهاز\",\n            \"operations\": \"عمليات بصمة الجهاز\",\n            \"generate_and_bind\": \"توليد وربط\",\n            \"restore_original\": \"استعادة الأصل\",\n            \"open_storage_directory\": \"فتح دليل التخزين\",\n            \"current_storage\": \"التخزين الحالي\",\n            \"effective\": \"فعال\",\n            \"current_storage_desc\": \"قراءة من storage.json (يتم التحديث بعد تطبيق الربط عند تبديل الحسابات)\",\n            \"account_binding\": \"ربط الحساب\",\n            \"pending_application\": \"في انتظار التطبيق\",\n            \"account_binding_desc\": \"تم الحفظ كربط بعد التوليد/الاستعادة، يكتب إلى storage.json عند تبديل الحسابات\",\n            \"historical_fingerprints\": \"بصمات تاريخية (استعادة/حذف اختياري)\",\n            \"no_history\": \"لا يوجد سجل\",\n            \"current\": \"الحالي\",\n            \"restore\": \"استعادة\",\n            \"delete_version\": \"حذف هذه النسخة\",\n            \"confirm_generate_title\": \"تأكيد التوليد والربط؟\",\n            \"confirm_generate_desc\": \"سيتم توليد مجموعة جديدة من بصمات الجهاز وتعيينها كبصمة حالية. هل تريد الاستمرار؟\",\n            \"confirm_restore_title\": \"تأكيد استعادة البصمة الأصلية؟\",\n            \"confirm_restore_desc\": \"سيتم استعادة البصمة الأصلية واستبدال البصمة الحالية. هل تريد الاستمرار؟\",\n            \"cancel\": \"إلغاء\",\n            \"confirm\": \"تأكيد\",\n            \"processing\": \"جاري المعالجة...\",\n            \"loading\": \"جاري التحميل...\",\n            \"failed_to_load_device_info\": \"فشل تحميل معلومات الجهاز\",\n            \"generation_failed\": \"فشل التوليد\",\n            \"binding_failed\": \"فشل الربط\",\n            \"restoration_failed\": \"فشل الاستعادة\",\n            \"deletion_failed\": \"فشل الحذف\",\n            \"directory_open_failed\": \"تعذر فتح المجلد\",\n            \"generated_and_bound\": \"تم التوليد والربط\",\n            \"restored\": \"تمت الاستعادة\",\n            \"deleted\": \"تم الحذف\",\n            \"directory_opened\": \"تم فتح مجلد التخزين\",\n            \"original_fingerprint_not_found\": \"البصمة الأصلية غير موجودة\"\n        },\n        \"warmup_all\": \"تنشيط بنقرة واحدة\",\n        \"warmup_selected\": \"تنشيط ({{count}})\",\n        \"warmup_this\": \"تنشيط هذا الحساب\",\n        \"warmup_now\": \"تنشيط الآن\",\n        \"warmup_batch_triggered\": \"تم إطلاق مهام التنشيط لـ {{count}} حساب\",\n        \"quota_protected\": \"محمي\",\n        \"details\": {\n            \"title\": \"تفاصيل الحصة\",\n            \"model_quota\": \"حصة النموذج\",\n            \"protected_models\": \"النماذج المحمية\"\n        },\n        \"toast\": {\n            \"proxy_enabled\": \"تم تفعيل الوكيل لـ {{count}} حساب\",\n            \"proxy_disabled\": \"تم تعطيل الوكيل لـ {{count}} حساب\"\n        },\n        \"add\": {\n            \"title\": \"إضافة حساب\",\n            \"tabs\": {\n                \"oauth\": \"OAuth\",\n                \"token\": \"Refresh Token\",\n                \"import\": \"استيراد DB\"\n            },\n            \"oauth\": {\n                \"recommend\": \"موصى به\",\n                \"desc\": \"يفتح المتصفح الافتراضي لتسجيل دخول Google لجلب وحفظ التوكن تلقائيًا.\",\n                \"btn_start\": \"بدء OAuth\",\n                \"btn_waiting\": \"بانتظار المصادقة...\",\n                \"btn_finish\": \"لقد قمت بالمصادقة\",\n                \"copy_link\": \"نسخ رابط المصادقة\",\n                \"copied\": \"تم النسخ\",\n                \"link_label\": \"رابط المصادقة\",\n                \"link_click_to_copy\": \"انقر للنسخ\",\n                \"manual_hint\": \"لـم يععد التوجيه؟ الصق رابط الاستدعاء الكامل أو الكود الخام هنا:\",\n                \"manual_placeholder\": \"الصق رابط الاستدعاء أو الكود...\",\n                \"error_no_flow\": \"يرجى النقر على 'بدء OAuth' أولاً\",\n                \"web_hint\": \"ستفتح صفحة تسجيل الدخول إلى Google في نافذة جديدة\",\n                \"error_no_url\": \"فشل في الحصول على عنوان URL لـ OAuth\",\n                \"popup_blocked\": \"تم حظر النافذة المنبثقة\",\n                \"manual_submitting\": \"جارٍ إرسال رمز التحقق...\",\n                \"manual_submitted\": \"تم إرسال رمز التحقق، جارٍ المعالجة في الخلفية...\"\n            },\n            \"token\": {\n                \"label\": \"Refresh Token\",\n                \"placeholder\": \"الصق Refresh Token هنا (دعم الدفعات)\\n\\nالتنسيقات المدعومة:\\n1. توكن مفرد (1//...)\\n2. مصفوفة JSON (مع حقل refresh_token)\\n3. أي نص يحتوي على توكنز (استخراج تلقائي)\",\n                \"hint\": \"تلميح: يمكنك لصق عدة توكنز أو مصفوفة JSON للاستيراد دفعة واحدة.\",\n                \"error_token\": \"يرجى إدخال Refresh Token\",\n                \"batch_progress\": \"جاري استيراد {{current}}/{{total}} حساب...\",\n                \"batch_success\": \"تم استيراد {{count}} حساب بنجاح\",\n                \"batch_partial\": \"انتهى الاستيراد: {{success}} نجاح، {{fail}} فشل\",\n                \"batch_fail\": \"فشل الاستيراد\"\n            },\n            \"import\": {\n                \"scheme_a\": \"الخطة أ: من قاعدة بيانات IDE\",\n                \"scheme_a_desc\": \"قراءة تلقائية للحساب المسجل حاليًا من قاعدة بيانات Antigravity المحلية.\",\n                \"btn_db\": \"استيراد الحساب الحالي\",\n                \"or\": \"أو\",\n                \"scheme_b\": \"الخطة ب: من نسخة احتياطية V1\",\n                \"scheme_b_desc\": \"فحص ~/.antigravity-agent لبيانات حسابات V1.\",\n                \"btn_v1\": \"استيراد جماعي V1\",\n                \"btn_custom_db\": \"استيراد DB مخصص\"\n            },\n            \"btn_cancel\": \"إلغاء\",\n            \"btn_confirm\": \"تأكيد\",\n            \"oauth_error\": \"فشل OAuth: {{error}}\",\n            \"status\": {\n                \"error_token\": \"يرجى إدخال Refresh Token\"\n            }\n        },\n        \"table\": {\n            \"email\": \"البريد الإلكتروني\",\n            \"quota\": \"حصة النموذج\",\n            \"last_used\": \"آخر استخدام\",\n            \"actions\": \"إجراءات\"\n        },\n        \"drag_to_reorder\": \"اسحب لإعادة الترتيب\",\n        \"empty\": {\n            \"title\": \"لا توجد حسابات\",\n            \"desc\": \"انقر على زر \\\"إضافة حساب\\\" أعلاه لإضافة حسابك الأول\"\n        },\n        \"views\": {\n            \"list\": \"عرض القائمة\",\n            \"grid\": \"عرض الشبكة\"\n        },\n        \"dialog\": {\n            \"add_title\": \"إضافة حساب\",\n            \"batch_delete_title\": \"تأكيد الحذف الجماعي\",\n            \"delete_title\": \"تأكيد الحذف\",\n            \"batch_delete_msg\": \"هل أنت متأكد من حذف {{count}} حساب المحدد؟ لا يمكن التراجع عن هذا الإجراء.\",\n            \"delete_msg\": \"هل أنت متأكد من حذف هذا الحساب؟ لا يمكن التراجع عن هذا الإجراء.\",\n            \"refresh_title\": \"تحديث الحصة\",\n            \"batch_refresh_title\": \"تحديث جماعي\",\n            \"refresh_msg\": \"هل أنت متأكد من تحديث الحصة للحساب الحالي؟\",\n            \"batch_refresh_msg\": \"هل أنت متأكد من تحديث الحصة لـ {{count}} حساب المحدد؟ قد يستغرق ذلك بعض الوقت.\",\n            \"disable_proxy_title\": \"تعطيل الوكيل\",\n            \"disable_proxy_msg\": \"هل أنت متأكد من تعطيل الوكيل لهذا الحساب؟ سيظل الحساب قابلاً للاستخدام في التطبيق.\",\n            \"enable_proxy_title\": \"تفعيل الوكيل\",\n            \"enable_proxy_msg\": \"هل أنت متأكد من إعادة تفعيل الوكيل لهذا الحساب؟\",\n            \"warmup_all_title\": \"تنشيط يدوي كامل\",\n            \"warmup_all_msg\": \"هل أنت متأكد من إطلاق مهام التنشيط لجميع الحسابات المؤهلة فورًا؟ سيرسل هذا الحد الأدنى من المرور لخدمات جوجل لإعادة تعيين دورات الحصة.\",\n            \"batch_warmup_title\": \"تنشيط يدوي جماعي\",\n            \"batch_warmup_msg\": \"هل أنت متأكد من إطلاق التنشيط لـ {{count}} حساب المحدد فورًا؟\"\n        }\n    },\n    \"settings\": {\n        \"save\": \"حفظ الإعدادات\",\n        \"tabs\": {\n            \"general\": \"عام\",\n            \"account\": \"الحساب\",\n            \"proxy\": \"إعدادات الوكيل\",\n            \"advanced\": \"متقدم\",\n            \"about\": \"حول\",\n            \"debug\": \"تصحيح الأخطاء\"\n        },\n        \"general\": {\n            \"title\": \"الإعدادات العامة\",\n            \"language\": \"اللغة\",\n            \"theme\": \"السمة\",\n            \"theme_light\": \"فاتح\",\n            \"theme_dark\": \"داكن\",\n            \"theme_system\": \"النظام\",\n            \"auto_launch\": \"تشغيل عند البدء\",\n            \"auto_launch_enabled\": \"مفعل\",\n            \"auto_launch_disabled\": \"معطل\",\n            \"auto_launch_desc\": \"تشغيل Antigravity Tools تلقائيًا عند بدء النظام\",\n            \"auto_check_update\": \"تحقق تلقائي من التحديثات\",\n            \"auto_check_update_desc\": \"التحقق تلقائيًا من الإصدارات الجديدة عند البدء\",\n            \"auto_check_update_enabled\": \"التحقق التلقائي مفعل\",\n            \"auto_check_update_disabled\": \"التحقق التلقائي معطل\",\n            \"update_check_interval\": \"فاصل التحقق (ساعات)\",\n            \"update_check_interval_desc\": \"تعيين فاصل التحقق التلقائي (1-168 ساعة)\",\n            \"update_check_interval_saved\": \"تم حفظ إعدادات فاصل التحقق\"\n        },\n        \"account\": {\n            \"title\": \"إعدادات الحساب\",\n            \"auto_refresh\": \"تحديث تلقائي في الخلفية\",\n            \"auto_refresh_desc\": \"تحديث جميع حصص الحسابات في الخلفية تلقائيًا. هذا مطلوب لحماية الحصة والتنشيط الذكي.\",\n            \"always_on\": \"دائم التشغيل\",\n            \"refresh_interval\": \"فاصل التحديث (دقائق)\",\n            \"auto_sync\": \"مزامنة تلقائية للحساب الحالي\",\n            \"auto_sync_desc\": \"مزامنة معلومات الحساب النشط الحالي بشكل دوري\",\n            \"sync_interval\": \"فاصل المزامنة (ثواني)\"\n        },\n        \"warmup\": {\n            \"title\": \"تنشيط ذكي\",\n            \"desc\": \"يراقب جميع النماذج تلقائيًا ويطلق التنشيط فورًا عندما تصل الحصة إلى 100%، مما يبقي النماذج نشطة\"\n        },\n        \"quota_protection\": {\n            \"title\": \"حماية الحصة\",\n            \"enable\": \"تفعيل حماية الحصة\",\n            \"enable_desc\": \"تعطيل الوكيل تلقائيًا عندما تنخفض حصة الحساب عن الحد الأدنى، واستعادة التشغيل تلقائيًا عند إعادة تعيين الحصة\",\n            \"threshold_label\": \"نسبة الحصة المحجوزة\",\n            \"monitored_models_label\": \"النماذج المراقبة (شروط الإطلاق)\",\n            \"monitored_models_desc\": \"اختر نموذجًا واحدًا على الأقل. تعمل الحماية إذا انخفضت حصة أي نموذج محدد عن الحد الأدنى\",\n            \"range\": \"النطاق\",\n            \"example\": \"مثال: عند {{percentage}}%، سيتم تعطيل حساب لديه {{total}} حصة عندما يصبح المتبقي ≤ {{threshold}}\",\n            \"auto_restore_info\": \"سيتم إعادة تفعيل الحساب تلقائيًا عند إعادة تعيين الحصة\"\n        },\n        \"pinned_quota_models\": {\n            \"title\": \"نماذج الحصة المثبتة\",\n            \"desc\": \"اختر حصص النماذج التي ستعرض في قائمة الحسابات. النماذج غير المحددة تظهر فقط في التفاصيل المنبثقة.\"\n        },\n        \"proxy\": {\n            \"title\": \"إعدادات الوكيل\"\n        },\n        \"proxy_pool\": {\n            \"title\": \"Proxy Pool\",\n            \"strategy_priority\": \"Priority\",\n            \"strategy_round_robin\": \"Round Robin\",\n            \"strategy_random\": \"Random\",\n            \"strategy_least_connections\": \"Least Connections\",\n            \"test_all\": \"Test All\",\n            \"batch_import\": \"Import\",\n            \"binding_manager\": \"Bindings\",\n            \"add_proxy\": \"Add Proxy\",\n            \"edit_proxy\": \"Edit Proxy\",\n            \"name\": \"Name\",\n            \"url\": \"Proxy URL\",\n            \"username\": \"Username\",\n            \"password\": \"Password\",\n            \"max_accounts\": \"Max Accounts\",\n            \"max_accounts_hint\": \"0 = Unlimited\",\n            \"priority\": \"Priority\",\n            \"priority_hint\": \"Lower is better\",\n            \"health_check_url\": \"Health Check URL\",\n            \"tags\": \"Tags\",\n            \"add_tag_placeholder\": \"Add tag...\",\n            \"seconds\": \"Sec\",\n            \"test_completed\": \"Health check completed\",\n            \"test_failed\": \"Health check failed\",\n            \"confirm_delete\": \"Are you sure you want to delete this proxy?\",\n            \"empty\": \"No proxies available\",\n            \"column_priority\": \"Priority\",\n            \"column_status\": \"Status\",\n            \"column_details\": \"Proxy Details\",\n            \"column_bindings\": \"Bindings\",\n            \"import_title\": \"Batch Import Proxies\",\n            \"import_label\": \"Paste Proxy List (One per line)\",\n            \"import_hint\": \"Supported formats: protocol://user:pass@host:port, host:port:user:pass\",\n            \"import_preview\": \"Preview\",\n            \"import_confirm\": \"Import {{count}} Proxies\",\n            \"no_valid_proxies\": \"No valid proxies found\",\n            \"binding\": {\n                \"title\": \"Account Proxy Bindings\",\n                \"load_failed\": \"Failed to load bindings\",\n                \"unbind_success\": \"Unbound successfully\",\n                \"bind_success\": \"Bound successfully\",\n                \"update_failed\": \"Failed to update binding\",\n                \"assigned_proxy\": \"Assigned Proxy\",\n                \"default_strategy\": \"Default (Follow Strategy)\"\n            },\n            \"status\": {\n                \"inactive\": \"Inactive\",\n                \"checking\": \"Checking\",\n                \"healthy\": \"Healthy\",\n                \"timeout\": \"Timeout\"\n            }\n        },\n        \"advanced\": {\n            \"title\": \"إعدادات متقدمة\",\n            \"export_path\": \"مسار التصدير الافتراضي\",\n            \"export_path_placeholder\": \"غير محدد (اسأل كل مرة)\",\n            \"default_export_path_desc\": \"سيتم حفظ الملفات مباشرة في هذا المجلد دون السؤال\",\n            \"select_btn\": \"تحديد\",\n            \"open_btn\": \"فتح\",\n            \"data_dir\": \"دليل البيانات\",\n            \"data_dir_desc\": \"موقع بيانات الحساب وملف التكوين\",\n            \"antigravity_path\": \"مسار Antigravity\",\n            \"antigravity_path_placeholder\": \"غير محدد (سيستخدم الكشف التلقائي)\",\n            \"antigravity_path_desc\": \"إذا قمت بتثبيت Antigravity في موقع غير قياسي، يمكنك تحديد مسار الملف التنفيذي يدويًا هنا (يشير إلى .app على macOS).\",\n            \"antigravity_path_select\": \"تحديد تنفيذي Antigravity\",\n            \"antigravity_path_detected\": \"تم تحديث المسار المكتشف\",\n            \"detect_btn\": \"كشف\",\n            \"antigravity_args\": \"وسائط بدء Antigravity\",\n            \"antigravity_args_placeholder\": \"--user-data-dir=/path/to/data --some-other-flag\",\n            \"antigravity_args_desc\": \"حدد وسائط البدء لـ Antigravity، مثل --user-data-dir لتحديد دليل بيانات المستخدم\",\n            \"detect_args_btn\": \"كشف\",\n            \"antigravity_args_detected\": \"تم تحديث وسائط البدء\",\n            \"antigravity_args_detect_error\": \"فشل كشف وسائط البدء\",\n            \"accounts_page_size\": \"حجم صفحة الحسابات\",\n            \"page_size_auto\": \"حساب تلقائي (موصى به)\",\n            \"page_size_desc\": \"تعيين عدد الحسابات المعروضة في الصفحة. اختر 'حساب تلقائي' للتعديل ديناميكيًا بناءً على حجم النافذة.\",\n            \"logs_title\": \"صيانة السجلات\",\n            \"logs_desc\": \"مسح ملفات التخزين المؤقت للسجلات. لا يؤثر على بيانات الحساب.\",\n            \"clear_logs\": \"مسح تخزين السجلات\",\n            \"clear_logs_title\": \"تأكيد مسح السجلات\",\n            \"clear_logs_msg\": \"هل أنت متأكد من رغبتك في مسح كافة ملفات تخزين السجلات؟\",\n            \"logs_cleared\": \"تم مسح تخزين السجلات\",\n            \"antigravity_cache_title\": \"تنظيف ذاكرة التخزين المؤقت لـ Antigravity\",\n            \"antigravity_cache_desc\": \"يمكن أن يؤدي مسح ذاكرة التخزين المؤقت لـ Antigravity إلى حل أخطاء تسجيل الدخول وأخطاء التحقق من الإصدار وأخطاء ترخيص OAuth.\",\n            \"antigravity_cache_warning\": \"يرجى التأكد من إغلاق Antigravity بالكامل قبل مسح ذاكرة التخزين المؤقت.\",\n            \"clear_antigravity_cache\": \"مسح ذاكرة التخزين المؤقت لـ Antigravity\",\n            \"clear_cache_confirm_title\": \"تأكيد مسح ذاكرة التخزين المؤقت لـ Antigravity\",\n            \"clear_cache_confirm_msg\": \"سيتم مسح دلائل ذاكرة التخزين المؤقت التالية:\",\n            \"cache_cleared_success\": \"تم مسح ذاكرة التخزين المؤقت، تم تحرير {{size}} ميجابايت\",\n            \"cache_not_found\": \"لم يتم العثور على دلائل ذاكرة التخزين المؤقت لـ Antigravity\",\n            \"debug_logs_title\": \"سجل التصحيح\",\n            \"debug_logs_enable_desc\": \"عند التفعيل، يتم تسجيل سلسلة الطلب والاستجابة الكاملة. يُنصح بالتفعيل فقط عند استكشاف الأخطاء وإصلاحها.\",\n            \"debug_logs_desc\": \"يسجل السلسلة الكاملة: المدخل الأصلي، طلب v1internal المحول، واستجابة المنبع. للاستكشاف فقط، قد يحتوي على بيانات حساسة.\",\n            \"debug_log_dir\": \"دليل إخراج سجل التصحيح\",\n            \"debug_log_dir_hint\": \"اتركه فارغاً لاستخدام الدليل الافتراضي: {{path}}/debug_logs\",\n            \"debug_log_dir_select\": \"اختر دليل إخراج سجل التصحيح\",\n            \"http_api_title\": \"خدمة HTTP API\",\n            \"http_api_desc\": \"توفر واجهة HTTP محلية للبرامج الخارجية (مثل إضافات VS Code).\",\n            \"http_api_enabled\": \"تفعيل HTTP API\",\n            \"http_api_enabled_desc\": \"عند التفعيل، يمكن للبرامج الخارجية إدارة الحسابات عبر واجهة HTTP\",\n            \"http_api_port\": \"منفذ الاستماع\",\n            \"http_api_port_desc\": \"مطلوب إعادة التشغيل بعد تغيير المنفذ. إذا حدث تعارض في المنفذ، يرجى استخدام منفذ آخر متاح.\",\n            \"http_api_port_placeholder\": \"المنفذ الافتراضي 19527\",\n            \"http_api_port_invalid\": \"رقم منفذ غير صالح (النطاق: 1024-65535)\",\n            \"http_api_settings_saved\": \"تم حفظ إعدادات HTTP API، مطلوب إعادة التشغيل للتطبيق\",\n            \"http_api_restart_required\": \"⚠️ مطلوب إعادة التشغيل للتطبيق\"\n        },\n        \"menu\": {\n            \"title\": \"إعدادات عرض القائمة\",\n            \"desc\": \"اختر العناصر المراد عرضها في شريط القائمة. يمكن إخفاء القوائم غير المستخدمة بشكل متكرر لتوفير المساحة.\",\n            \"selected_items_note\": \"ستظهر العناصر المحددة في شريط القائمة العلوي.\",\n            \"required\": \"مطلوب\"\n        },\n        \"about\": {\n            \"title\": \"حول\",\n            \"version\": \"إصدار التطبيق\",\n            \"tech_stack\": \"التقنيات\",\n            \"author\": \"المؤلف\",\n            \"wechat\": \"WeChat\",\n            \"github\": \"GitHub\",\n            \"view_code\": \"عرض الكود\",\n            \"copyright\": \"حقوق النشر © 2025-2026 Antigravity. جميع الحقوق محفوظة.\",\n            \"check_update\": \"تحقق من التحديثات\",\n            \"checking_update\": \"جاري التحقق...\",\n            \"latest_version\": \"أنت على أحدث إصدار\",\n            \"new_version_available\": \"إصدار جديد {{version}} متاح\",\n            \"download_update\": \"تنزيل\",\n            \"update_check_failed\": \"فشل التحقق من التحديث\",\n            \"support_btn\": \"دعم المؤلف\",\n            \"support_title\": \"تبرع ودعم\",\n            \"support_desc\": \"إذا وجدت هذا المشروع مفيدًا، لا تتردد في شراء قهوة لي! دعمك هو أكبر حافز لي لصيانة هذا المشروع.\",\n            \"support_alipay\": \"Alipay\",\n            \"support_wechat\": \"WeChat Pay\",\n            \"support_buymeacoffee\": \"Buy Me a Coffee\"\n        },\n        \"advanced_thinking\": {\n            \"title\": \"التفكير المتقدم والتكوين العالمي\",\n            \"description\": \"إدارة قدرات التفكير، وأوضاع الصور، والتعليمات العالمية بشكل مركزي.\"\n        },\n        \"thinking_budget\": {\n            \"title\": \"ميزانية التفكير (Thinking Budget)\",\n            \"description\": \"يتحكم في ميزانية التوكنز عند التفكير العميق للذكاء الاصطناعي. تقتصر بعض النماذج (مثل Flash، والنماذج التي تنتهي بـ -thinking) بحد أقصى 24576 من المنبع.\",\n            \"mode_label\": \"وضع المعالجة\",\n            \"mode\": {\n                \"auto\": \"تقييد تلقائي\",\n                \"passthrough\": \"تمرير مباشر\",\n                \"custom\": \"مخصص\"\n            },\n            \"auto_hint\": \"الوضع التلقائي: يتم تقييد نماذج Flash، والنماذج التي تنتهي بـ -thinking، وطلبات بحث الويب تلقائيًا عند 24576 لتجنب أخطاء API.\",\n            \"passthrough_warning\": \"التمرير المباشر: يستخدم القيمة الأصلية للطلب مباشرة؛ قد يؤدي عدم دعم القيم العالية إلى الفشل.\",\n            \"custom_value_hint\": \"الموصى به: 24576 (Flash) أو 51200 (موسع)\",\n            \"tokens\": \"tokens\"\n        },\n        \"image_thinking_mode\": {\n            \"title\": \"وضع تفكير الصور (Image Thinking Mode)\",\n            \"hint\": \"يؤثر على جودة الصورة وعملية التوليد\",\n            \"options\": {\n                \"enabled\": \"مفعل\",\n                \"disabled\": \"معطل\",\n                \"enabled_desc\": \"تفعيل: الاحتفاظ بسلسلة التفكير، وإرجاع صورتين (مسودة + نهائية).\",\n                \"disabled_desc\": \"تعطيل: تعطيل سلسلة التفكير، وتوليد صورة واحدة فائقة الوضوح مباشرة (الأولوية للجودة).\"\n            }\n        },\n        \"global_system_prompt\": {\n            \"title\": \"تعليمات النظام العالمية (Global System Prompt)\",\n            \"hint\": \"يتم حقنها تلقائيًا في systemInstruction لجميع الطلبات\",\n            \"placeholder\": \"أدخل تعليمات النظام العالمية...\\nمثال: أنت مهندس تطوير شامل رفيع المستوى، تتقن React و Rust. يرجى الرد باللغة العربية.\",\n            \"char_count\": \"{{count}} حرف\",\n            \"long_prompt_warning\": \"التعليمات طويلة (أكثر من 2000 حرف)، وقد تستهلك مساحة كبيرة من نافذة السياق.\"\n        }\n    },\n    \"tray\": {\n        \"current\": \"الحالي\",\n        \"quota\": \"الحصة\",\n        \"switch_next\": \"التبديل للحساب التالي\",\n        \"refresh_current\": \"تحديث الحصة الحالية\",\n        \"show_window\": \"إظهار النافذة الرئيسية\",\n        \"quit\": \"إنهاء التطبيق\",\n        \"no_account\": \"لا يوجد حساب\",\n        \"unknown_quota\": \"مجهول (انقر للتحديث)\",\n        \"forbidden\": \"الحساب محظور\"\n    },\n    \"proxy\": {\n        \"title\": \"خدمة وكيل API\",\n        \"status\": {\n            \"running\": \"الخدمة قيد التشغيل\",\n            \"stopped\": \"الخدمة متوقفة\",\n            \"accounts_available\": \"{{count}} حساب متاح\",\n            \"processing\": \"جاري المعالجة...\"\n        },\n        \"action\": {\n            \"start\": \"بدء الخدمة\",\n            \"stop\": \"إيقاف الخدمة\"\n        },\n        \"config\": {\n            \"title\": \"تكوين الخدمة\",\n            \"request\": {\n                \"user_agent\": \"User-Agent Override\",\n                \"user_agent_tooltip\": \"Override the User-Agent header sent to upstream APIs. Leave empty to use default.\",\n                \"user_agent_hint\": \"Current Default: antigravity/<version> <os>/<arch>\",\n                \"user_agent_placeholder\": \"Enter custom User-Agent string...\"\n            },\n            \"port\": \"منفذ الاستماع\",\n            \"port_tooltip\": \"منفذ TCP الذي يستمع إليه وكيل API المحلي. أوقف الخدمة لتغييره، ثم أعد التشغيل للتطبيق.\",\n            \"port_hint\": \"الافتراضي 8045، مطلوب إعادة التشغيل لتطبيق التغييرات\",\n            \"auto_start\": \"بدء تلقائي مع التطبيق\",\n            \"auto_start_tooltip\": \"يبدأ خدمة وكيل API المحلي تلقائيًا عند تشغيل التطبيق.\",\n            \"allow_lan_access\": \"السماح بالوصول عبر الشبكة المحلية (LAN)\",\n            \"allow_lan_access_tooltip\": \"عند التفعيل، ترتبط الخدمة بـ 0.0.0.0 بحيث يمكن للأجهزة الأخرى على الشبكة المحلية الوصول إليها. حافظ على تفعيل المصادقة واحمِ مفتاح API الخاص بك؛ مطلوب إعادة التشغيل للتطبيق.\",\n            \"allow_lan_access_hint_enabled\": \"🌐 الاستماع على 0.0.0.0، يمكن لأجهزة الشبكة المحلية الوصول\",\n            \"allow_lan_access_hint_disabled\": \"🔒 الاستماع على 127.0.0.1 فقط، الوصول المحلي فقط (الخصوصية أولاً)\",\n            \"allow_lan_access_warning\": \"⚠️ يمكن لأجهزة الشبكة المحلية الوصول عند التفعيل. حافظ على سرية مفتاح API الخاص بك\",\n            \"allow_lan_access_restart_hint\": \"ℹ️ مطلوب إعادة تشغيل الخدمة لتطبيق التغييرات\",\n            \"api_key\": \"مفتاح API\",\n            \"api_key_tooltip\": \"سر مشترك يستخدمه العملاء عند تمكين مصادقة الوكيل. إعادة توليد المفتاح تبطل القديم فورًا.\",\n            \"btn_regenerate\": \"إعادة توليد المفتاح\",\n            \"btn_edit\": \"تعديل\",\n            \"btn_save\": \"حفظ\",\n            \"btn_copy\": \"نسخ\",\n            \"btn_copied\": \"تم النسخ\",\n            \"warning_key\": \"ملاحظة: حافظ على سرية مفتاح API الخاص بك. لا تشاركه.\",\n            \"api_key_invalid\": \"تنسيق مفتاح API غير صالح، يجب أن يبدأ بـ sk- ويكون طوله 10 أحرف على الأقل\",\n            \"api_key_updated\": \"تم تحديث مفتاح API\",\n            \"admin_password\": \"كلمة مرور إدارة واجهة الويب\",\n            \"admin_password_tooltip\": \"كلمة المرور المستخدمة لتسجيل الدخول إلى وحدة تحكم إدارة الويب. إذا كانت فارغة، يتم استخدام مفتاح API افتراضيًا.\",\n            \"admin_password_default\": \"(نفس مفتاح API)\",\n            \"admin_password_placeholder\": \"أدخل كلمة مرور جديدة، اتركها فارغة لاستخدام مفتاح API\",\n            \"admin_password_hint\": \"تلميح: في سيناريوهات نشر Docker/Web، يمكنك تعيين كلمة مرور تسجيل دخول منفصلة لتحسين أمان مفتاح API الخاص بك.\",\n            \"admin_password_short\": \"كلمة المرور قصيرة جدًا (على الأقل 4 أحرف)\",\n            \"admin_password_updated\": \"تم تحديث كلمة مرور تسجيل دخول واجهة الويب\",\n            \"auth\": {\n                \"title\": \"المصادقة\",\n                \"title_tooltip\": \"يتحكم فيما إذا كانت الطلبات الواردة يجب المصادقة عليها، وأي المسارات محمية.\",\n                \"enabled\": \"مفعل\",\n                \"enabled_tooltip\": \"تشغيل/إيقاف المصادقة عن طريق تبديل وضع المصادقة. عند التفعيل، يجب على العملاء تضمين مفتاح API عبر Authorization: Bearer <API_KEY> أو x-api-key.\",\n                \"mode\": \"الوضع\",\n                \"mode_tooltip\": \"يحدد المسارات التي تتطلب مفتاح API: إيقاف = لا توجد مصادقة؛ الكل = حماية كل شيء؛ الكل باستثناء الصحة = /healthz يظل مفتوحًا؛ تلقائي = إيقاف للمحلي فقط، وإلا الكل باستثناء الصحة.\",\n                \"hint\": \"عند التفعيل، يجب على العملاء إرسال مفتاح API عبر Authorization: Bearer ... (باستثناء الصحة إذا تم تحديده).\",\n                \"modes\": {\n                    \"off\": \"إيقاف (مفتوح)\",\n                    \"strict\": \"الكل (صارم)\",\n                    \"all_except_health\": \"الكل باستثناء الصحة\",\n                    \"auto\": \"تلقائي (موصى به)\"\n                }\n            },\n            \"zai\": {\n                \"title\": \"موفر z.ai (GLM)\",\n                \"title_tooltip\": \"موفر اختياري متوافق مع Anthropic لبروتوكول Claude. يؤثر فقط على نقاط نهاية Anthropic؛ يبقى توجيه حساب Google دون تغيير.\",\n                \"subtitle\": \"موفر اختياري متوافق مع Anthropic لبروتوكول Claude فقط.\",\n                \"enabled\": \"مفعل\",\n                \"enabled_tooltip\": \"تفعيل توجيه z.ai لطلبات Anthropic وفقًا لوضع التوزيع المختار.\",\n                \"base_url\": \"عنوان URL الأساسي\",\n                \"base_url_tooltip\": \"عنوان URL أساسي متوافق مع Anthropic. يضيف الوكيل مسارات مثل /v1/messages. اترك الافتراضي إلا إذا كنت تستخدم بوابة مخصصة.\",\n                \"dispatch_mode\": \"وضع التوزيع\",\n                \"dispatch_mode_tooltip\": \"يتحكم متى يتم استخدام z.ai لطلبات Anthropic: إيقاف يعطله؛ جميع طلبات Anthropic يوجه كل شيء؛ مجمع يضيف z.ai كفتحة واحدة في التناوب مع حسابات Google؛ احتياطي يستخدم z.ai فقط عندما لا توجد حسابات Google.\",\n                \"api_key\": \"مفتاح API\",\n                \"api_key_tooltip\": \"مفتاح API المستخدم للمصادقة على الطلبات إلى z.ai. مخزن محليًا ومطلوب لـ z.ai وميزات MCP.\",\n                \"api_key_placeholder\": \"الصق مفتاح z.ai API الخاص بك هنا\",\n                \"warning\": \"ملاحظة: يتم تخزين هذا المفتاح محليًا في دليل بيانات التطبيق.\",\n                \"models\": {\n                    \"title\": \"خريطة النماذج\",\n                    \"title_tooltip\": \"جلب معرفات نماذج z.ai المتاحة وتكوين كيفية ترجمة أسماء نماذج Anthropic/Claude الواردة إلى معرفات نماذج z.ai.\",\n                    \"refresh\": \"جلب النماذج\",\n                    \"refreshing\": \"جاري الجلب...\",\n                    \"hint\": \"النماذج المتاحة: {{count}}. اختر اقتراحًا أو اكتب معرف نموذج مخصص.\",\n                    \"error\": \"فشل جلب النماذج: {{error}}\",\n                    \"select_placeholder\": \"اختر نموذجًا...\",\n                    \"opus\": \"عائلة Opus ← نموذج z.ai\",\n                    \"opus_tooltip\": \"معرف نموذج z.ai الافتراضي المستخدم عندما يحتوي النموذج الوارد على \\\"opus\\\" (مثل claude-opus-*).\",\n                    \"sonnet\": \"عائلة Sonnet ← نموذج z.ai\",\n                    \"sonnet_tooltip\": \"معرف نموذج z.ai الافتراضي المستخدم لنماذج Claude الأخرى (مثل claude-sonnet-* ومعظم طلبات claude-*).\",\n                    \"haiku\": \"عائلة Haiku ← نموذج z.ai\",\n                    \"haiku_tooltip\": \"معرف نموذج z.ai الافتراضي المستخدم عندما يحتوي النموذج الوارد على \\\"haiku\\\" (مثل claude-haiku-*).\",\n                    \"advanced_title\": \"تجاوزات متقدمة\",\n                    \"advanced_tooltip\": \"تجاوزات المطابقة الدقيقة الاختيارية. إذا كانت سلسلة النموذج الوارد تطابق مفتاح قاعدة، فسيتم استبدالها بمعرف نموذج z.ai المعين.\",\n                    \"from_label\": \"النموذج الوارد\",\n                    \"to_label\": \"نموذج z.ai\",\n                    \"add_rule\": \"إضافة\",\n                    \"empty\": \"لا توجد قواعد تجاوز معدة.\",\n                    \"from_placeholder\": \"من (مثل claude-3-opus)\",\n                    \"to_placeholder\": \"إلى (مثل glm-4)\"\n                },\n                \"modes\": {\n                    \"off\": \"إيقاف\",\n                    \"exclusive\": \"جميع طلبات Anthropic\",\n                    \"pooled\": \"مجمع (فتحة واحدة)\",\n                    \"fallback\": \"احتياطي فقط\"\n                },\n                \"mcp\": {\n                    \"title\": \"خوادم MCP (عبر الوكيل المحلي)\",\n                    \"title_tooltip\": \"يكشف نقاط نهاية /mcp/* اختيارية على هذا الوكيل المحلي لكي تتصل عملاء MCP. متاح فقط عندما تكون الخدمة قيد التشغيل، وتم تكوين z.ai، والمفاتيح المقابلة مفعلة.\",\n                    \"enabled\": \"تفعيل وكيل MCP\",\n                    \"enabled_tooltip\": \"المفتاح الرئيسي لنقاط نهاية MCP. عند الإيقاف، تعود جميع مسارات /mcp/* بـ 404.\",\n                    \"web_search\": \"بحث الويب\",\n                    \"web_search_tooltip\": \"يكشف /mcp/web_search_prime/mcp ويوجه الطلبات إلى موفر MCP لبحث الويب من z.ai.\",\n                    \"web_reader\": \"قارئ الويب\",\n                    \"web_reader_tooltip\": \"يكشف /mcp/web_reader/mcp ويوجه الطلبات إلى موفر MCP لقارئ الويب من z.ai.\",\n                    \"vision\": \"الرؤية\",\n                    \"vision_tooltip\": \"يكشف /mcp/zai-mcp-server/mcp (خادم MCP محلي) يوفر أدوات الرؤية المدعومة بـ z.ai.\",\n                    \"local_endpoints\": \"نقاط النهاية المحلية (قم بتكوين عميل MCP الخاص بك لاستخدام عناوين URL هذه):\",\n                    \"local_endpoints_tooltip\": \"استخدم عناوين URL هذه في عميل MCP الخاص بك. إنها تشارك نفس المضيف/المنفذ مثل وكيل API وتتبع سياسة مصادقة الوكيل.\"\n                }\n            },\n            \"request_timeout\": \"مهلة الطلب\",\n            \"request_timeout_tooltip\": \"الحد الأقصى للوقت (بالثواني) الذي ينتظره الوكيل لاستجابة المنبع، بما في ذلك التدفق. قم بالزيادة للأجيال الطويلة؛ مطلوب إعادة التشغيل للتطبيق.\",\n            \"request_timeout_hint\": \"الافتراضي 120 ثانية، النطاق 30-7200 ثانية. أعد تشغيل الخدمة لتطبيق التغييرات.\",\n            \"enable_logging\": \"تفعيل تسجيل الطلبات\",\n            \"enable_logging_hint\": \"سجل التاريخ للتصحيح (تكلفة أداء طفيفة)\",\n            \"upstream_proxy\": {\n                \"title\": \"وكيل المنبع العالمي (الوكيل العالمي)\",\n                \"desc\": \"عند التفعيل، سيتم توجيه جميع الطلبات الخارجية (وكيل API، تحديث التوكن، التحقق من الحصة، التحقق من التحديث) عبر هذا الوكيل.\",\n                \"desc_short\": \"بروكسي عالمي يستخدم كحل احتياطي عندما لا يتم العثور على حسابات مناسبة في مجموعة البروكسي.\",\n                \"enable\": \"تفعيل وكيل المنبع\",\n                \"url\": \"عنوان URL للوكيل\",\n                \"url_placeholder\": \"مثل http://127.0.0.1:7890 أو socks5://127.0.0.1:7890\",\n                \"tip\": \"يدعم HTTP و HTTPS و SOCKS5.\",\n                \"socks5h_hint\": \"لتجنب الحظر والحفاظ على دقة DNS عن بُعد (Remote DNS)، قم بتغيير البروتوكول يدويًا إلى socks5h://\",\n                \"validation_error\": \"عنوان URL للوكيل مطلوب عند تفعيل وكيل المنبع\",\n                \"restart_hint\": \"تم حفظ إعدادات الوكيل. أعد تشغيل التطبيق لتطبيق التغييرات.\"\n            },\n            \"scheduling\": {\n                \"title\": \"تناوب الحسابات والجدولة\",\n                \"title_tooltip\": \"يتحكم في كيفية ربط الجلسات بالحسابات وكيفية معالجة حدود المعدل.\",\n                \"subtitle\": \"يحسن التخزين المؤقت للموجه ومعالجة حدود المعدل لجميع البروتوكولات (OpenAI/Gemini/Claude).\",\n                \"mode\": \"وضع الجدولة\",\n                \"mode_tooltip\": \"التخزين المؤقت أولاً: ربط الجلسة بالحساب، الانتظار عند حد المعدل (تعظيم فائدة التخزين المؤقت)؛ التوازن: ربط الجلسة، تبديل الحساب عند حد المعدل؛ الأداء: تناوب قياسي.\",\n                \"modes\": {\n                    \"CacheFirst\": \"التخزين المؤقت أولاً\",\n                    \"Balance\": \"توازن\",\n                    \"PerformanceFirst\": \"الأداء\"\n                },\n                \"modes_desc\": {\n                    \"CacheFirst\": \"يربط الجلسة بالحساب، ينتظر بدقة إذا كان محدودًا (يعظم إصابات التخزين المؤقت للموجه).\",\n                    \"Balance\": \"يربط الجلسة، يبدل تلقائيًا إلى حساب متاح إذا كان محدودًا (توازن بين التخزين المؤقت والتوافر).\",\n                    \"PerformanceFirst\": \"لا يوجد ربط للجلسة، تناوب نقي (الأفضل للتزامن العالي).\"\n                },\n                \"max_wait\": \"الحد الأقصى للانتظار (ثانية)\",\n                \"max_wait_tooltip\": \"يستخدم فقط في وضع 'التخزين المؤقت أولاً': انتظر بدلاً من التبديل إذا كان وقت إعادة تعيين حد المعدل أقل من هذه القيمة.\",\n                \"clear_bindings\": \"مسح ربط الجلسات\",\n                \"clear_bindings_tooltip\": \"إعادة تعيين صب لكل ارتباطات الجلسة بالحساب، مما يجبر الحسابات على إعادة التعيين في الطلب التالي.\",\n                \"circuit_breaker\": {\n                    \"title\": \"قاطع الدائرة المتكيف\",\n                    \"tooltip\": \"يزيد تلقائيًا من مدة الحظر للحسابات التي تفشل بشكل متكرر بسبب استنفاد الحصة. يمنع هذا إهدار استدعاءات API على الحسابات المتوقفة مع السماح للأخطاء العابرة بالتعافي بسرعة.\",\n                    \"backoff_levels\": \"مستويات التراجع (بالثواني)\",\n                    \"level\": \"المستوى {{level}}\",\n                    \"input_placeholder\": \"60, 300, 1800, 7200\",\n                    \"invalid_format\": \"تنسيق غير صالح. استخدم أرقامًا مفصولة بفاصلة (مثال: 60, 300)\",\n                    \"clear_records\": \"مسح جميع سجلات حدود المعدل\"\n                }\n            },\n            \"experimental\": {\n                \"title\": \"إعدادات تجريبية\",\n                \"title_tooltip\": \"ميزات استكشافية قد يتم تعديلها أو إزالتها في الإصدارات المستقبلية.\",\n                \"enable_usage_scaling\": \"تفعيل توسيع الاستخدام\",\n                \"enable_usage_scaling_tooltip\": \"لبروتوكول Claude. يمكن توسيع قوي عندما يتجاوز إجمالي الإدخال 30 ألف توكن لمنع الضغط المتكرر من جانب العميل. ملاحظة: الاستخدام المبلغ عنه لن يعكس الفواتير الفعلية بعد التفعيل.\",\n                \"context_compression_threshold_l1\": \"عتبة ضغط L1 (تقليم الأدوات)\",\n                \"context_compression_threshold_l1_tooltip\": \"تقليم سجلات استدعاء الأدوات القديمة لتوفير المساحة. موصى به: 0.4 (40%)\",\n                \"context_compression_threshold_l2\": \"عتبة ضغط L2 (ضغط التفكير)\",\n                \"context_compression_threshold_l2_tooltip\": \"ضغط كتل التفكير المبكرة مع الحفاظ على التوقيعات. موصى به: 0.55 (55%)\",\n                \"context_compression_threshold_l3\": \"عتبة ضغط L3 (محور الملخص)\",\n                \"context_compression_threshold_l3_tooltip\": \"إعادة تعيين نهائية: يولد ملخص حالة XML ويتمحور إلى جلسة جديدة. الأكثر كفاءة في استهلاك التوكن. موصى به: 0.7 (70%)\"\n            }\n        },\n        \"cloudflared\": {\n            \"title\": \"الوصول العام (Cloudflared)\",\n            \"subtitle\": \"كشف الخدمة المحلية للإنترنت عبر نفق Cloudflare Tunnel\",\n            \"not_installed\": \"Cloudflared غير مثبت\",\n            \"install_hint\": \"Cloudflared هي أداة نفق مجانية من Cloudflare. تعرض وكيلك المحلي للإنترنت دون IP عام أو إعادة توجيه المنافذ. انقر فوق الزر أدناه للتثبيت.\",\n            \"install\": \"تثبيت الآن\",\n            \"installing\": \"جاري التثبيت...\",\n            \"install_success\": \"تم تثبيت Cloudflared بنجاح\",\n            \"install_failed\": \"فشل التثبيت: {{error}}\",\n            \"installed\": \"مثبت\",\n            \"version\": \"الإصدار\",\n            \"mode_label\": \"وضع النفق\",\n            \"mode_quick\": \"نفق سريع\",\n            \"mode_quick_desc\": \"عنوان URL مؤقت يتم إنشاؤه تلقائيًا (*.trycloudflare.com)، لا حاجة لحساب، يتغير عنوان URL عند إعادة التشغيل\",\n            \"mode_auth\": \"نفق مسمى\",\n            \"mode_auth_desc\": \"استخدم توكن حساب Cloudflare، يدعم المجال المخصص، عنوان URL دائم\",\n            \"token\": \"توكن النفق\",\n            \"token_placeholder\": \"الصق توكن نفق Cloudflare هنا\",\n            \"token_hint\": \"احصل عليه من لوحة معلومات Cloudflare Zero Trust\",\n            \"token_required\": \"التوكن مطلوب لوضع النفق المسمى\",\n            \"use_http2\": \"استخدام HTTP/2\",\n            \"use_http2_desc\": \"أكثر توافقًا، موصى به لبر الصين الرئيسي\",\n            \"status_label\": \"حالة النفق\",\n            \"status_stopped\": \"متوقف\",\n            \"status_starting\": \"جاري البدء...\",\n            \"status_running\": \"قيد التشغيل\",\n            \"status_stopping\": \"جاري الإيقاف...\",\n            \"status_error\": \"خطأ\",\n            \"public_url\": \"عنوان URL العام\",\n            \"public_url_placeholder\": \"سيظهر عنوان URL العام هنا بعد بدء النفق\",\n            \"copy_url\": \"نسخ URL\",\n            \"url_copied\": \"تم نسخ URL\",\n            \"start_tunnel\": \"بدء النفق\",\n            \"stop_tunnel\": \"إيقاف النفق\",\n            \"running\": \"النفق قيد التشغيل\",\n            \"started\": \"بدأ النفق\",\n            \"stopped\": \"توقف النفق\",\n            \"start_failed\": \"فشل البدء: {{error}}\",\n            \"stop_failed\": \"فشل الإيقاف: {{error}}\",\n            \"require_proxy_running\": \"يرجى بدء خدمة الوكيل المحلي أولاً\",\n            \"connection_info\": \"معلومات الاتصال\",\n            \"local_port\": \"المنفذ المحلي\",\n            \"tunnel_protocol\": \"بروتوكول النفق\"\n        },\n        \"example\": {\n            \"title\": \"أمثلة الاستخدام\",\n            \"curl\": \"cURL\",\n            \"python\": \"Python\",\n            \"python_anthropic\": \"from anthropic import Anthropic\\n\\nclient = Anthropic(\\n    # Recommended: 127.0.0.1\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\n# Note: Antigravity supports calling any model via the Anthropic SDK\\nresponse = client.messages.create(\\n    model=\\\"{{modelId}}\\\",\\n    max_tokens=1024,\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"مرحبًا\\\"}]\\n)\\n\\nprint(response.content[0].text)\",\n            \"python_gemini\": \"# Install: pip install google-generativeai\\nimport google.generativeai as genai\\n\\n# Use Antigravity proxy address (recommended 127.0.0.1)\\ngenai.configure(\\n    api_key=\\\"{{apiKey}}\\\",\\n    transport='rest',\\n    client_options={'api_endpoint': '{{rawBaseUrl}}'}\\n)\\n\\nmodel = genai.GenerativeModel('{{modelId}}')\\nresponse = model.generate_content(\\\"مرحبًا\\\")\\nprint(response.text)\",\n            \"python_openai_image\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    # Option 1: use size (recommended)\\n    # Supported: \\\"1024x1024\\\" (1:1), \\\"1280x720\\\" (16:9), \\\"720x1280\\\" (9:16), \\\"1216x896\\\" (4:3)\\n    extra_body={ \\\"size\\\": \\\"1024x1024\\\" },\\n\\n    # Option 2: use model suffix\\n    # e.g. gemini-3-pro-image-16-9, gemini-3-pro-image-4-3\\n    # model=\\\"gemini-3-pro-image-16-9\\\",\\n    messages=[{\\n        \\\"role\\\": \\\"user\\\",\\n        \\\"content\\\": \\\"ارسم مدينة مستقبلية\\\"\\n    }]\\n)\\n\\nprint(response.choices[0].message.content)\",\n            \"python_openai\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"مرحبًا\\\"}]\\n)\\n\\nprint(response.choices[0].message.content)\"\n        },\n        \"examples\": {\n            \"title\": \"أمثلة الاستخدام\"\n        },\n        \"dialog\": {\n            \"confirm_regenerate\": \"هل أنت متأكد من إعادة توليد مفتاح API؟ سيكون المفتاح القديم غير صالح فورًا.\",\n            \"operate_failed\": \"فشلت العملية: {{error}}\",\n            \"reset_mapping_title\": \"إعادة تعيين خريطة النماذج\",\n            \"reset_mapping_msg\": \"هل أنت متأكد من رغبتك في إعادة تعيين جميع خرائط النماذج إلى الإفتراضيات؟ لا يمكن التراجع عن هذا الإجراء.\",\n            \"regenerate_key_title\": \"إعادة توليد مفتاح API\",\n            \"regenerate_key_msg\": \"هل أنت متأكد من رغبتك في إعادة توليد مفتاح API؟ سيتم إبطال المفتاح القديم فورًا.\",\n            \"clear_bindings_title\": \"مسح ارتباطات الجلسة\",\n            \"clear_bindings_msg\": \"هل أنت متأكد من رغبتك في مسح كافة ارتباطات الجلسة بالحساب؟\"\n        },\n        \"model\": {\n            \"flash\": \"استجابة سريعة\",\n            \"flash_preview\": \"Flash Preview\",\n            \"flash_lite\": \"Lite & سريع\",\n            \"flash_thinking\": \"قدرة التفكير\",\n            \"pro_legacy\": \"Legacy Pro\",\n            \"pro_low\": \"أداء عالي\",\n            \"pro_high\": \"أفضل منطق\",\n            \"pro_image\": \"توليد صورة (1:1)\",\n            \"pro_image_16_9\": \"توليد صورة (16:9)\",\n            \"pro_image_9_16\": \"توليد صورة (9:16)\",\n            \"pro_image_4_3\": \"توليد صورة (4:3)\",\n            \"pro_image_3_4\": \"توليد صورة (3:4)\",\n            \"pro_image_1_1\": \"توليد صورة (1:1)\",\n            \"claude_sonnet\": \"تفكير برمجي\",\n            \"claude_sonnet_thinking\": \"سلسلة أفكار\",\n            \"claude_opus_thinking\": \"أقوى تفكير\"\n        },\n        \"mapping\": {\n            \"title\": \"خريطة نماذج Claude Code\",\n            \"description\": \"اربط نماذج Claude Code بنماذج Antigravity. تحسين التكلفة والسرعة عن طريق توجيه الطلبات بذكاء.\",\n            \"default\": \"افتراضي\",\n            \"sonnet_desc\": \"الأكثر قدرة للأعمال المعقدة\",\n            \"opus_desc\": \"الفئة المميزة\",\n            \"haiku_desc\": \"الأسرع للإجابات السريعة\",\n            \"maps_to\": \"يربط بـ Antigravity\",\n            \"apply_recommended\": \"تطبيق الموصى به\",\n            \"restore_defaults\": \"استعادة التكوين الافتراضي\",\n            \"reset_all\": \"إعادة تعيين الكل\"\n        },\n        \"router\": {\n            \"title\": \"موجه النماذج\",\n            \"subtitle\": \"توجيه النماذج حسب السلسلة أو إضافة تعيينات دقيقة مخصصة.\\nملاحظة: نماذج Claude الأصلية الممرة (مثل claude-opus-4-6-thinking) تتخطى مجموعات السلسلة افتراضيًا. استخدم \\\"توجيه مخصص خبير\\\" للتجاوز.\",\n            \"subtitle_simple\": \"تخصيص توجيه النماذج باستخدام أحرف البدل أو التعيينات الدقيقة\",\n            \"background_task_title\": \"نموذج المهام الخلفية\",\n            \"background_task_desc\": \"النموذج المستخدم لمهام الخلفية لـ Claude CLI مثل توليد العناوين، والملخص، وما إلى ذلك. (الافتراضي: gemini-2.5-flash)\",\n            \"use_default\": \"استخدم الافتراضي للنظام\",\n            \"current_model\": \"النموذج الحالي\",\n            \"apply_presets\": \"تطبيق الإعدادات المسبقة\",\n            \"presets_applied\": \"تم تطبيق الإعدادات المسبقة بنجاح\",\n            \"custom_mappings\": \"تعيينات مخصصة\",\n            \"group_title\": \"مجموعات السلاسل\",\n            \"groups\": {\n                \"claude_45\": {\n                    \"name\": \"سلسلة Claude 4.6 TK\",\n                    \"desc\": \"Opus 4.5 TK\"\n                },\n                \"claude_35\": {\n                    \"name\": \"سلسلة Claude 3.5\",\n                    \"desc\": \"Sonnet 3.5, Haiku 3.5\"\n                },\n                \"gpt_4\": {\n                    \"name\": \"سلسلة GPT-4 / o1\",\n                    \"desc\": \"GPT-4, Turbo, o1-preview\"\n                },\n                \"gpt_4o\": {\n                    \"name\": \"سلسلة GPT-4o / 3.5\",\n                    \"desc\": \"GPT-4o, Mini, 3.5 Turbo\"\n                },\n                \"gpt_5\": {\n                    \"name\": \"سلسلة GPT-5\",\n                    \"desc\": \"GPT-5.1, GPT-5.2 xhigh\"\n                }\n            },\n            \"expert_title\": \"توجيه مخصص خبير\",\n            \"expert_subtitle\": \"مطابقة دقيقة لأي معرف نموذج أصلي.\",\n            \"custom_mapping_tip\": \"💡 يدعم إدخال أي معرف نموذج يدويًا لتجربة النماذج غير المُصدرة (مثل claude-opus-4-6).\",\n            \"custom_mapping_warning\": \"ملاحظة: ليس جميع الحسابات تدعم النماذج غير المُصدرة.\",\n            \"money_saving_tip\": \"💰 نصيحة لتوفير التكلفة:\",\n            \"haiku_optimization_tip\": \"يستخدم Claude CLI {{model}} لمهام الخلفية افتراضيًا. اربطه بنموذج Flash أرخص لتوفير ~95% من التكاليف\",\n            \"haiku_optimization_btn\": \"تحسين سريع\",\n            \"haiku_tip_title\": \"💰 نصيحة لتوفير التكلفة:\",\n            \"haiku_tip_body_before\": \"يقوم Claude CLI افتراضيًا بـ\",\n            \"haiku_tip_body_after\": \"لمهام الخلفية؛ ربطه بنموذج Flash أرخص يمكن أن يوفر حوالي 95% من التكلفة.\",\n            \"haiku_tip_action\": \"تحسين\",\n            \"reset_confirm\": \"هل تريد إعادة تعيين جميع التعيينات إلى افتراضيات النظام؟\",\n            \"reset_mapping\": \"إعادة تعيين الخريطة\",\n            \"add_mapping\": \"إضافة تعيين\",\n            \"current_list\": \"قائمة مخصصة\",\n            \"no_custom_mapping\": \"لا توجد تعيينات مخصصة بعد\",\n            \"gemini3_only_warning\": \"⚠️ سلسلة Gemini 3 فقط\",\n            \"default_suffix\": \" (الافتراضي)\",\n            \"original_id\": \"المعرف الأصلي\",\n            \"route_to\": \"توجيه إلى\",\n            \"select_target_model\": \"اختر النموذج المستهدف\",\n            \"original_placeholder\": \"الأصلي (مثل gpt-4 أو gpt-4*)\"\n        },\n        \"multi_protocol\": {\n            \"title\": \"دعم متعدد البروتوكولات\",\n            \"subtitle\": \"تكامل بسلاسة مع أدوات AI المفضلة لديك و CLIs\",\n            \"description\": \"يدعم الوكيل المحلي بروتوكولات OpenAI، Anthropic، و Gemini، مما يضمن التوافق مع مجموعة واسعة من التطبيقات.\",\n            \"openai_label\": \"بروتوكول OpenAI\",\n            \"anthropic_label\": \"بروتوكول Anthropic\",\n            \"openai_tools\": \"Cherry Studio, NextChat\",\n            \"anthropic_tools\": \"Claude Code CLI\",\n            \"gemini_label\": \"بروتوكول Gemini\",\n            \"gemini_tools\": \"Google AI SDK, LangChain\",\n            \"quick_integration\": \"تكامل سريع\",\n            \"click_tip\": \"👆 انقر فوق نموذج لتحديث أمثلة الكود\",\n            \"copy_base\": \"نسخ القاعدة\"\n        },\n        \"supported_models\": {\n            \"title\": \"النماذج المدعومة والتكامل\",\n            \"model_name\": \"اسم النموذج\",\n            \"model_id\": \"معرف النموذج\",\n            \"description\": \"الوصف\",\n            \"action\": \"إجراء\"\n        },\n        \"cli_sync\": {\n            \"title\": \"مزامنة CLI بنقرة واحدة\",\n            \"subtitle\": \"مزامنة سريعة لنقاط نهاية API الحالية والمفاتيح لأدوات AI CLI المحلية الخاصة بك.\",\n            \"card_title\": \"تكوين {{name}}\",\n            \"status\": {\n                \"not_installed\": \"لم يتم اكتشافه\",\n                \"installed\": \"v{{version}}\",\n                \"synced\": \"موجه لهذا التطبيق\",\n                \"not_synced\": \"غير متزامن\",\n                \"detecting\": \"جاري الاكتشاف...\",\n                \"current_base_url\": \"URL الأساسي الحالي\"\n            },\n            \"btn_sync\": \"مزامنة التكوين الآن\",\n            \"btn_view\": \"عرض التكوين\",\n            \"btn_restore\": \"استعادة الافتراضيات\",\n            \"btn_restore_backup\": \"استعادة النسخة الاحتياطية\",\n            \"restore_confirm\": \"هل أنت متأكد من رغبتك في استعادة تكوين {{name}} إلى عنوان URL الافتراضي الرسمي؟\",\n            \"restore_backup_confirm\": \"تم العثور على تكوين احتياطي. هل أنت متأكد من رغبتك في استعادته؟\",\n            \"modal\": {\n                \"view_title\": \"محتوى تكوين {{name}}\",\n                \"copy_success\": \"تم نسخ محتوى التكوين\"\n            },\n            \"toast\": {\n                \"sync_success\": \"تمت المزامنة بنجاح! {{name}} جاهز.\",\n                \"sync_error\": \"فشلت المزامنة: {{error}}\"\n            },\n            \"sync_confirm_title\": \"تأكيد المزامنة\",\n            \"sync_confirm_message\": \"جاهز لمزامنة تكوين {{name}}. ⚠️ تحذير: سيؤدي هذا إلى الكتابة فوق ملفات التكوين المحلية الحالية (مثل توكنات تسجيل الدخول، مفاتيح API). هل أنت متأكد من رغبتك في الاستمرار؟\"\n        }\n    },\n    \"monitor\": {\n        \"page_title\": \"لوحة مراقبة API\",\n        \"page_subtitle\": \"تسجيل وتحليل الطلبات في الوقت الفعلي\",\n        \"open_monitor\": \"فتح المراقب\",\n        \"logging_status\": {\n            \"active\": \"جاري التسجيل\",\n            \"paused\": \"متوقف مؤقتًا\"\n        },\n        \"stats\": {\n            \"total\": \"الإجمالي\",\n            \"ok\": \"ناجح\",\n            \"err\": \"خطأ\"\n        },\n        \"filters\": {\n            \"placeholder\": \"تصفية حسب النموذج، المسار، أو الحالة...\",\n            \"quick_filters\": \"تصفية سريعة:\",\n            \"all\": \"الكل\",\n            \"error\": \"خطأ\",\n            \"chat\": \"محادثة\",\n            \"gemini\": \"Gemini\",\n            \"claude\": \"Claude\",\n            \"images\": \"صور\",\n            \"reset\": \"إعادة تعيين\",\n            \"by_account\": \"تصفية حسب الحساب\",\n            \"all_accounts\": \"كل الحسابات\"\n        },\n        \"table\": {\n            \"status\": \"الحالة\",\n            \"method\": \"الطريقة\",\n            \"model\": \"النموذج\",\n            \"protocol\": \"البروتوكول\",\n            \"account\": \"الحساب\",\n            \"path\": \"المسار\",\n            \"usage\": \"توكنز\",\n            \"duration\": \"المدة\",\n            \"time\": \"الوقت\",\n            \"empty\": \"لم يتم تسجيل أي طلبات\"\n        },\n        \"details\": {\n            \"title\": \"تفاصيل الطلب\",\n            \"request_payload\": \"حمولة الطلب\",\n            \"response_payload\": \"حمولة الاستجابة\",\n            \"duration\": \"المدة\",\n            \"tokens\": \"توكنز (دخول/خروج)\",\n            \"time\": \"الوقت\",\n            \"model\": \"النموذج\",\n            \"mapped_model\": \"النموذج المعين\",\n            \"protocol\": \"البروتوكول\",\n            \"account_used\": \"الحساب المستخدم\",\n            \"id\": \"معرف الطلب\",\n            \"payload_empty\": \"لا توجد بيانات\"\n        },\n        \"dialog\": {\n            \"clear_title\": \"مسح سجلات الوكيل\",\n            \"clear_msg\": \"هل أنت متأكد من رغبتك في مسح كافة سجلات الوكيل؟ لا يمكن التراجع عن هذا الإجراء.\"\n        }\n    },\n    \"update_notification\": {\n        \"title\": \"إصدار جديد متاح\",\n        \"message\": \"إصدار جديد جاهز مع تحسينات وإصلاحات. الحالي: v{{current}}\",\n        \"ready\": \"التحديث جاهز\",\n        \"downloading\": \"جاري تنزيل التحديث...\",\n        \"restarting\": \"جاري إعادة تشغيل التطبيق...\",\n        \"auto_update\": \"تحديث تلقائي\",\n        \"toast\": {\n            \"not_ready\": \"حزمة التحديث التلقائي ليست جاهزة، جاري توجيهك إلى صفحة التنزيل...\",\n            \"failed\": \"فشل التحديث التلقائي، جاري توجيهك إلى صفحة التنزيل...\"\n        }\n    },\n    \"login\": {\n        \"title\": \"تحكم وصول آمن\",\n        \"desc\": \"قيد التشغيل في وضع الويب. يرجى إدخال كلمة مرور الإدارة أو مفتاح API للوصول.\",\n        \"placeholder\": \"أدخل كلمة مرور الإدارة أو مفتاح API\",\n        \"btn_login\": \"تحقق ودخول\",\n        \"note\": \"ملاحظة: إذا تم تعيين كلمة مرور إدارة منفصلة، يرجى إدخالها؛ وإلا، أدخل مفتاح API.\",\n        \"lookup_hint\": \"إذا نسيت، قم بتشغيل docker logs antigravity-manager للعثور على مفتاح API الحالي أو كلمة مرور واجهة الويب\",\n        \"config_hint\": \"أو قم بتشغيل grep -E '\\\"api_key\\\"|\\\"admin_password\\\"' ~/.antigravity_tools/gui_config.json للعرض.\"\n    },\n    \"token_stats\": {\n        \"title\": \"إحصائيات استهلاك التوكن\",\n        \"hourly\": \"ساعة\",\n        \"daily\": \"يوم\",\n        \"weekly\": \"أسبوع\",\n        \"total_tokens\": \"إجمالي التوكنز\",\n        \"input_tokens\": \"توكنز الإدخال\",\n        \"output_tokens\": \"توكنز الإخراج\",\n        \"accounts_used\": \"حسابات نشطة\",\n        \"models_used\": \"نماذج مستخدمة\",\n        \"model_trend\": \"اتجاه استخدام النموذج\",\n        \"account_trend\": \"اتجاه استخدام الحساب\",\n        \"usage_trend\": \"اتجاه استخدام التوكن\",\n        \"by_account\": \"حسب الحساب\",\n        \"by_model\": \"حسب النموذج\",\n        \"by_account_view\": \"حسب الحساب\",\n        \"model_details\": \"تفصيل النموذج\",\n        \"account_details\": \"تفصيل الحساب\",\n        \"model\": \"النموذج\",\n        \"account\": \"الحساب\",\n        \"requests\": \"الطلبات\",\n        \"input\": \"إدخال\",\n        \"output\": \"إخراج\",\n        \"total\": \"الإجمالي\",\n        \"percentage\": \"حصة\",\n        \"no_data\": \"لا توجد بيانات متاحة\"\n    },\n    \"errors\": {\n        \"stream\": {\n            \"timeout_error\": \"مهلة الطلب، يرجى التحقق من اتصال الشبكة\",\n            \"connection_error\": \"فشل الاتصال، يرجى التحقق من إعدادات الشبكة أو الوكيل\",\n            \"decode_error\": \"الشبكة غير مستقرة، تم قطع نقل البيانات. حاول: 1) التحقق من الشبكة 2) تبديل الوكيل 3) إعادة المحاولة\",\n            \"stream_error\": \"خطأ في نقل التدفق، يرجى إعادة المحاولة لاحقًا\",\n            \"unknown_error\": \"حدث خطأ غير معروف، يرجى إعادة المحاولة لاحقًا\"\n        }\n    },\n    \"security\": {\n        \"title\": \"مراقبة الأمان\",\n        \"refresh_data\": \"تحديث البيانات\",\n        \"refresh\": \"تحديث\",\n        \"tab_logs\": \"سجلات الوصول\",\n        \"tab_stats\": \"تحليل الإحصائيات\",\n        \"tab_blacklist\": \"القائمة السوداء\",\n        \"tab_whitelist\": \"القائمة البيضاء\",\n        \"tab_config\": \"إعدادات الأمان\",\n        \"stats\": {\n            \"total_requests\": \"إجمالي الطلبات\",\n            \"total_requests_desc\": \"جميع الطلبات المسجلة\",\n            \"unique_ips\": \"عناوين IP الفريدة\",\n            \"unique_ips_desc\": \"عناوين IP المختلفة للعملاء\",\n            \"blocked_requests\": \"الطلبات المحظورة\",\n            \"blocked_requests_desc\": \"الطلبات المرفوضة بواسطة القواعد\",\n            \"ip_activity_token_usage\": \"نشاط IP واستخدام التوكن\",\n            \"hour\": \"ساعة\",\n            \"day\": \"يوم\",\n            \"week\": \"أسبوع\",\n            \"month\": \"شهر\",\n            \"rank\": \"الترتيب\",\n            \"ip_address\": \"عنوان IP\",\n            \"activity_reqs\": \"النشاط (الطلبات)\",\n            \"total_token\": \"إجمالي التوكن\",\n            \"prompt\": \"المطالبة\",\n            \"completion\": \"الإكمال\",\n            \"no_data\": \"لا توجد بيانات\"\n        },\n        \"logs\": {\n            \"search_placeholder\": \"البحث عن IP، المسار، User Agent...\",\n            \"username\": \"المستخدم\",\n            \"show_blocked_only\": \"إظهار المحظور فقط\",\n            \"status\": \"الحالة\",\n            \"ip_address\": \"عنوان IP\",\n            \"method\": \"الطريقة\",\n            \"path\": \"المسار\",\n            \"duration\": \"المدة\",\n            \"time\": \"الوقت\",\n            \"reason\": \"السبب\",\n            \"blocked\": \"محظور\",\n            \"no_logs\": \"لا توجد سجلات\",\n            \"total_records\": \"إجمالي {{total}} سجل\",\n            \"prev_page\": \"السابق\",\n            \"next_page\": \"التالي\",\n            \"page_num\": \"الصفحة {{page}}\",\n            \"per_page_suffix\": \"/صفحة\"\n        },\n        \"blacklist\": {\n            \"add_ip\": \"إضافة IP\",\n            \"search_placeholder\": \"بحث...\",\n            \"added_at\": \"تاريخ الإضافة\",\n            \"expires_at\": \"تاريخ الانتهاء\",\n            \"no_data\": \"لا توجد بيانات القائمة السوداء\",\n            \"add_title\": \"إضافة إلى القائمة السوداء\",\n            \"ip_cidr_label\": \"عنوان IP أو CIDR\",\n            \"ip_cidr_placeholder\": \"مثل 192.168.1.1 أو 10.0.0.0/24\",\n            \"reason_label\": \"السبب (اختياري)\",\n            \"reason_placeholder\": \"مثل: مسح ضار\",\n            \"expires_label\": \"وقت الانتهاء (ساعات، اختياري)\",\n            \"expires_placeholder\": \"اتركه فارغًا للدائم\",\n            \"cancel\": \"إلغاء\",\n            \"confirm\": \"إضافة\",\n            \"add_btn\": \"إضافة\",\n            \"error_duplicate\": \"IP موجود بالفعل في القائمة السوداء\",\n            \"error_invalid_ip\": \"تنسيق IP غير صالح. يرجى استخدام عنوان IP أو تدوين CIDR (مثل 192.168.1.0/24)\",\n            \"error_add_failed\": \"فشلت الإضافة\"\n        },\n        \"whitelist\": {\n            \"add_ip\": \"إضافة IP موثوق\",\n            \"no_data\": \"لا توجد بيانات القائمة البيضاء\",\n            \"add_title\": \"إضافة إلى القائمة البيضاء\",\n            \"description_label\": \"الوصف (اختياري)\",\n            \"description_placeholder\": \"مثل: خادم داخلي\",\n            \"cancel\": \"إلغاء\",\n            \"confirm\": \"إضافة\",\n            \"add_btn\": \"إضافة\"\n        },\n        \"config\": {\n            \"title\": \"إعدادات الأمان\",\n            \"save\": \"حفظ التغييرات\",\n            \"saving\": \"جاري الحفظ...\",\n            \"blacklist_title\": \"القائمة السوداء لـ IP\",\n            \"blacklist_desc\": \"إدارة عناوين IP المحظورة والقواعد\",\n            \"enable_blacklist\": \"تفعيل حماية القائمة السوداء\",\n            \"block_msg_label\": \"رسالة حظر مخصصة\",\n            \"block_msg_desc\": \"محتوى الاستجابة المرسل للعملاء المحظورين\",\n            \"whitelist_title\": \"القائمة البيضاء لـ IP\",\n            \"whitelist_desc\": \"إدارة عناوين IP الموثوقة\",\n            \"enable_whitelist\": \"تفعيل وضع القائمة البيضاء\",\n            \"whitelist_warning\": \"تحذير: تفعيل وضع القائمة البيضاء سيحظر جميع الطلبات من عناوين IP غير الموجودة في القائمة البيضاء. إذا كنت تصل عبر وكيل، كن حذرًا حتى لا تحظر نفسك.\",\n            \"whitelist_priority\": \"أولوية القائمة البيضاء (تتجاوز القائمة السوداء)\",\n            \"whitelist_priority_desc\": \"عند التفعيل، سيتم السماح لعناوين IP في القائمة البيضاء حتى لو تطابقت مع قواعد القائمة السوداء.\",\n            \"load_error\": \"فشل تحميل الإعدادات\",\n            \"save_success\": \"تم حفظ الإعدادات\",\n            \"save_error\": \"فشل حفظ الإعدادات\"\n        }\n    },\n    \"user_token\": {\n        \"title\": \"إدارة توكنات المستخدم\",\n        \"total_users\": \"إجمالي المستخدمين\",\n        \"active_tokens\": \"التوكنات النشطة\",\n        \"total_created\": \"الإجمالي المُنشأ\",\n        \"create\": \"إنشاء توكن\",\n        \"username\": \"اسم المستخدم\",\n        \"token\": \"التوكن\",\n        \"expires\": \"تنتهي الصلاحية\",\n        \"usage\": \"الاستخدام\",\n        \"ip_limit\": \"حد IP\",\n        \"created\": \"تاريخ الإنشاء\",\n        \"today_requests\": \"طلبات اليوم\",\n        \"never\": \"أبدًا\",\n        \"renew\": \"تجديد\",\n        \"renew_button\": \"تجديد\",\n        \"unlimited\": \"غير محدود\",\n        \"create_title\": \"إنشاء توكن جديد\",\n        \"description\": \"الوصف\",\n        \"curfew\": \"حظر التجول (وقت عدم توفر الخدمة)\",\n        \"edit_title\": \"تعديل التوكن\",\n        \"username_required\": \"اسم المستخدم مطلوب\",\n        \"renew_success\": \"تم التجديد بنجاح\",\n        \"expires_day\": \"يوم واحد\",\n        \"expires_week\": \"أسبوع واحد\",\n        \"expires_month\": \"شهر واحد\",\n        \"expires_never\": \"أبدًا\",\n        \"no_data\": \"لم يتم العثور على توكنات\",\n        \"placeholder_username\": \"مثل: user1\",\n        \"placeholder_desc\": \"ملاحظات اختيارية\",\n        \"placeholder_max_ips\": \"0 = غير محدود\",\n        \"hint_max_ips\": \"0 = غير محدود\",\n        \"hint_curfew\": \"اتركه فارغًا لتعطيله. بناءً على وقت الخادم.\"\n    }\n}"
  },
  {
    "path": "src/locales/en.json",
    "content": "{\n    \"common\": {\n        \"loading\": \"Loading...\",\n        \"empty\": \"Empty\",\n        \"load_more\": \"Load More\",\n        \"add\": \"Add\",\n        \"copy\": \"Copy\",\n        \"action\": \"Action\",\n        \"save\": \"Save\",\n        \"saved\": \"Saved successfully\",\n        \"cancel\": \"Cancel\",\n        \"confirm\": \"Confirm\",\n        \"close\": \"Close\",\n        \"delete\": \"Delete\",\n        \"edit\": \"Edit\",\n        \"refresh\": \"Refresh\",\n        \"refreshing\": \"Refreshing...\",\n        \"export\": \"Export\",\n        \"import\": \"Import\",\n        \"success\": \"Success\",\n        \"error\": \"Error\",\n        \"unknown\": \"Unknown\",\n        \"warning\": \"Warning\",\n        \"info\": \"Info\",\n        \"details\": \"Details\",\n        \"example\": \"Example\",\n        \"clear\": \"Clear\",\n        \"clearing\": \"Clearing...\",\n        \"prev_page\": \"Previous\",\n        \"next_page\": \"Next\",\n        \"pagination_info\": \"Showing {{start}} to {{end}} of {{total}} entries\",\n        \"per_page\": \"Per page\",\n        \"items\": \"items\",\n        \"accounts\": \"accounts\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"tauri_api_not_loaded\": \"Tauri API not loaded, please restart the app\",\n        \"environment_error\": \"Environment error: {{error}}\",\n        \"submit\": \"Submit\",\n        \"update\": \"Update\",\n        \"load_failed\": \"Load failed\",\n        \"create_success\": \"Created successfully\",\n        \"update_success\": \"Updated successfully\",\n        \"delete_success\": \"Deleted successfully\",\n        \"copied\": \"Copied to clipboard\",\n        \"retry\": \"Retry\",\n        \"back\": \"Back\",\n        \"reason\": \"Reason\",\n        \"show_raw\": \"Show Raw\",\n        \"show_parsed\": \"Show Parsed\"\n    },\n    \"nav\": {\n        \"dashboard\": \"Dashboard\",\n        \"accounts\": \"Accounts\",\n        \"proxy\": \"API Proxy\",\n        \"call_records\": \"Traffic Logs\",\n        \"token_stats\": \"Token Stats\",\n        \"security\": \"IP Management\",\n        \"security_logs\": \"IP Logs\",\n        \"settings\": \"Settings\",\n        \"theme_to_dark\": \"Switch to Dark Mode\",\n        \"theme_to_light\": \"Switch to Light Mode\",\n        \"switch_to_english\": \"Switch to English\",\n        \"switch_to_chinese\": \"Switch to Chinese\",\n        \"switch_to_traditional_chinese\": \"Switch to Traditional Chinese\",\n        \"switch_to_english_short\": \"EN\",\n        \"switch_to_chinese_short\": \"ZH\",\n        \"switch_to_traditional_chinese_short\": \"TW\",\n        \"switch_to_japanese\": \"Switch to Japanese\",\n        \"switch_to_japanese_short\": \"JA\",\n        \"switch_to_turkish\": \"Switch to Turkish\",\n        \"switch_to_turkish_short\": \"TR\",\n        \"switch_to_vietnamese\": \"Switch to Vietnamese\",\n        \"switch_to_vietnamese_short\": \"VI\",\n        \"switch_to_russian\": \"Switch to Russian\",\n        \"switch_to_russian_short\": \"RU\",\n        \"switch_to_portuguese\": \"Switch to Portuguese\",\n        \"switch_to_portuguese_short\": \"PT\",\n        \"switch_to_korean\": \"Switch to Korean\",\n        \"switch_to_korean_short\": \"KO\",\n        \"switch_to_spanish\": \"Switch to Spanish\",\n        \"switch_to_spanish_short\": \"ES\",\n        \"switch_to_malay\": \"Switch to Malay\",\n        \"switch_to_malay_short\": \"MY\",\n        \"user_token\": \"User Tokens\",\n        \"logout\": \"Logout\"\n    },\n    \"dashboard\": {\n        \"hello\": \"Hello, User 👋\",\n        \"refresh_quota\": \"Refresh Quota\",\n        \"refreshing\": \"Refreshing...\",\n        \"total_accounts\": \"Total Accounts\",\n        \"avg_gemini\": \"Avg. Gemini Quota\",\n        \"avg_gemini_image\": \"Avg. Gemini Image Quota\",\n        \"avg_claude\": \"Avg. Claude Quota\",\n        \"low_quota_accounts\": \"Low Quota Accounts\",\n        \"quota_sufficient\": \"Quota Sufficient\",\n        \"quota_low\": \"Low Quota\",\n        \"quota_desc\": \"Quota < 20%\",\n        \"current_account\": \"Current Account\",\n        \"switch_account\": \"Switch Account\",\n        \"no_active_account\": \"No Active Account\",\n        \"best_accounts\": \"Best Accounts\",\n        \"best_account_recommendation\": \"Best Account\",\n        \"switch_best\": \"Switch to Best\",\n        \"switch_successfully\": \"Switch to Best\",\n        \"view_all_accounts\": \"View All Accounts\",\n        \"export_data\": \"Export Data\",\n        \"for_gemini\": \"For Gemini\",\n        \"for_claude\": \"For Claude\",\n        \"toast\": {\n            \"switch_success\": \"Switch successful!\",\n            \"switch_error\": \"Switch account failed\",\n            \"refresh_success\": \"Quota refresh successful\",\n            \"refresh_error\": \"Refresh failed\",\n            \"export_no_accounts\": \"No accounts to export\",\n            \"export_success\": \"Export successful! File saved to: {{path}}\",\n            \"export_error\": \"Export failed\"\n        }\n    },\n    \"accounts\": {\n        \"account\": \"Account\",\n        \"search_placeholder\": \"Search email...\",\n        \"all\": \"All\",\n        \"available\": \"Available\",\n        \"low_quota\": \"Low Quota\",\n        \"ultra\": \"ULTRA\",\n        \"pro\": \"PRO\",\n        \"free\": \"FREE\",\n        \"edit_label\": \"Edit Label\",\n        \"custom_label_placeholder\": \"Enter custom label\",\n        \"label_updated\": \"Label updated\",\n        \"add_account\": \"Add Account\",\n        \"refresh_all\": \"Refresh All\",\n        \"refresh_selected\": \"Refresh ({{count}})\",\n        \"export_selected\": \"Export ({{count}})\",\n        \"import_json\": \"Import\",\n        \"import_success\": \"Successfully imported {{count}} accounts\",\n        \"import_partial\": \"Import completed: {{success}} succeeded, {{fail}} failed\",\n        \"import_fail\": \"Import failed: {{error}}\",\n        \"import_invalid_format\": \"Invalid JSON format, please ensure the file contains email and refresh_token fields\",\n        \"delete_selected\": \"Delete ({{count}})\",\n        \"current\": \"Current\",\n        \"current_badge\": \"Current\",\n        \"disabled\": \"Disabled\",\n        \"disabled_tooltip\": \"Account is disabled (e.g. refresh_token revoked/expired). Reauthorize or update token to re-enable.\",\n        \"proxy_disabled\": \"Proxy Disabled\",\n        \"proxy_disabled_tooltip\": \"This account has proxy disabled manually, it will not handle API requests but remains usable in the app.\",\n        \"enable_proxy\": \"Enable Proxy\",\n        \"disable_proxy\": \"Disable Proxy\",\n        \"enable_proxy_selected\": \"Enable ({{count}})\",\n        \"disable_proxy_selected\": \"Disable ({{count}})\",\n        \"proxy_disabled_reason_manual\": \"Disabled manually by user\",\n        \"proxy_disabled_reason_batch\": \"Disabled in batch\",\n        \"forbidden\": \"403\",\n        \"forbidden_badge\": \"403\",\n        \"forbidden_tooltip\": \"API returned 403 Forbidden, account has no permission for Gemini Code Assist\",\n        \"forbidden_msg\": \"Forbidden, skip auto-refresh\",\n        \"status\": {\n            \"forbidden\": \"403 Forbidden\",\n            \"disabled\": \"Account Disabled\",\n            \"proxy_disabled\": \"Proxy Disabled\",\n            \"validation_required\": \"Verification Required\",\n            \"violation_blocked\": \"Blocked due to Violation\"\n        },\n        \"error_details\": \"Error Details\",\n        \"error_status\": \"Error Status\",\n        \"error_time\": \"Detection Time\",\n        \"view_error\": \"View Reason\",\n        \"click_to_verify\": \"Click to Verify\",\n        \"copy_validation_url\": \"Copy Verification Link\",\n        \"validation_url_copied\": \"Verification link copied to clipboard\",\n        \"fix_guide\": {\n            \"title\": \"Terminal Quick Fix (Resolves internal 403s)\",\n            \"step1_title\": \"🚀 Quickest Solution\",\n            \"step1_desc\": \"Open your Terminal and run the following command to tell Google \\\"it's me\\\", which resolves most 403 blocks:\",\n            \"step1_li1\": \"Press Enter, input <1>Y</1> when prompted to continue.\",\n            \"step1_li2\": \"Your browser will open automatically, select your account and click \\\"Allow\\\".\",\n            \"step1_li3\": \"Once you see <1>You are now authenticated</1>, you're all set!\",\n            \"step2_title\": \"🧹 If that fails (Clear Cache)\",\n            \"step2_li1_prefix\": \"Run this to clear your old credentials:\",\n            \"step2_li2_prefix\": \"Then log in again:\",\n            \"tips_title\": \"💡 Common Tips\",\n            \"tip1\": \"If you still get 403, try running <1>unset GOOGLE_APPLICATION_CREDENTIALS</1> in terminal first.\",\n            \"tip2\": \"For production environments, using a <1>Service Account</1> JSON key is highly recommended for stability and headless operation.\",\n            \"tip3\": \"If it still fails, check the Generative Language API in <1>Google Cloud Console</1> to see if permissions are frozen. If so, your account is under risk control. Please let the account cool down for 72 hours before trying again.\",\n            \"tip4\": \"You can also try <1>npm install -g @google/gemini-cli</1>. If it doesn't throw errors, simply deleting and re-authorizing the account in the app will likely fix it.\"\n        },\n        \"no_data\": \"No Data\",\n        \"last_used\": \"Last Used\",\n        \"reset_time\": \"Reset Time\",\n        \"switch_to\": \"Switch to this account\",\n        \"actions\": \"Actions\",\n        \"device_fingerprint\": \"Device Fingerprint\",\n        \"show_all_quotas\": \"Show All Quotas\",\n        \"device_fingerprint_dialog\": {\n            \"title\": \"Device Fingerprint\",\n            \"operations\": \"Device Fingerprint Operations\",\n            \"generate_and_bind\": \"Generate and Bind\",\n            \"restore_original\": \"Restore Original\",\n            \"open_storage_directory\": \"Open Storage Directory\",\n            \"current_storage\": \"Current Storage\",\n            \"effective\": \"Effective\",\n            \"current_storage_desc\": \"Read from storage.json (updated after applying binding when switching accounts)\",\n            \"account_binding\": \"Account Binding\",\n            \"pending_application\": \"Pending Application\",\n            \"account_binding_desc\": \"Saved as binding after generation/restoration, written to storage.json when switching accounts\",\n            \"historical_fingerprints\": \"Historical Fingerprints (optional restore/delete)\",\n            \"no_history\": \"No History\",\n            \"current\": \"Current\",\n            \"restore\": \"Restore\",\n            \"delete_version\": \"Delete this version\",\n            \"confirm_generate_title\": \"Confirm generate and bind?\",\n            \"confirm_generate_desc\": \"Will generate a new set of device fingerprints and set as current fingerprint. Confirm continue?\",\n            \"confirm_restore_title\": \"Confirm restore original fingerprint?\",\n            \"confirm_restore_desc\": \"Will restore to original fingerprint and overwrite current fingerprint. Confirm continue?\",\n            \"cancel\": \"Cancel\",\n            \"confirm\": \"Confirm\",\n            \"processing\": \"Processing...\",\n            \"loading\": \"Loading...\",\n            \"failed_to_load_device_info\": \"Failed to load device info\",\n            \"generation_failed\": \"Generation failed\",\n            \"binding_failed\": \"Binding failed\",\n            \"restoration_failed\": \"Restoration failed\",\n            \"deletion_failed\": \"Deletion failed\",\n            \"directory_open_failed\": \"Unable to open directory\",\n            \"generated_and_bound\": \"Generated and bound\",\n            \"restored\": \"Restored\",\n            \"deleted\": \"Deleted\",\n            \"directory_opened\": \"Storage directory opened\",\n            \"original_fingerprint_not_found\": \"Original fingerprint not found\"\n        },\n        \"warmup_all\": \"One-click Warmup\",\n        \"warmup_selected\": \"Warmup ({{count}})\",\n        \"warmup_this\": \"Warmup this account\",\n        \"warmup_now\": \"Warmup Now\",\n        \"warmup_batch_triggered\": \"Warmup tasks triggered for {{count}} accounts\",\n        \"quota_protected\": \"Protected\",\n        \"details\": {\n            \"title\": \"Quota Details\",\n            \"model_quota\": \"Model Quota\",\n            \"protected_models\": \"Protected Models\"\n        },\n        \"toast\": {\n            \"proxy_enabled\": \"Enabled proxy for {{count}} accounts\",\n            \"proxy_disabled\": \"Disabled proxy for {{count}} accounts\"\n        },\n        \"add\": {\n            \"title\": \"Add Account\",\n            \"tabs\": {\n                \"oauth\": \"OAuth\",\n                \"token\": \"Refresh Token\",\n                \"import\": \"Import DB\"\n            },\n            \"oauth\": {\n                \"recommend\": \"Recommended\",\n                \"desc\": \"Opens default browser for Google login to auto-fetch and save Token.\",\n                \"btn_start\": \"Start OAuth\",\n                \"btn_waiting\": \"Waiting for auth...\",\n                \"btn_finish\": \"I already authorized\",\n                \"copy_link\": \"Copy Auth Link\",\n                \"copied\": \"Copied\",\n                \"link_label\": \"Authorization URL\",\n                \"link_click_to_copy\": \"Click to copy\",\n                \"manual_hint\": \"Browser didn't redirect? Paste the full callback URL or raw Code here:\",\n                \"manual_placeholder\": \"Paste callback URL or code...\",\n                \"error_no_flow\": \"Please click 'Start OAuth' first\",\n                \"web_hint\": \"Google login page will open in a new window\",\n                \"error_no_url\": \"Could not obtain OAuth URL\",\n                \"popup_blocked\": \"Popup blocked\",\n                \"manual_submitting\": \"Submitting authorization code...\",\n                \"manual_submitted\": \"Authorization code submitted. Backend is processing...\"\n            },\n            \"token\": {\n                \"label\": \"Refresh Token\",\n                \"placeholder\": \"Paste your Refresh Token here (Batch supported)\\n\\nSupported formats:\\n1. Single Token (1//...)\\n2. JSON Array (with refresh_token field)\\n3. Any text containing tokens (Auto-extraction)\",\n                \"hint\": \"Tip: You can paste multiple tokens or a JSON array to import in batch.\",\n                \"error_token\": \"Please enter Refresh Token\",\n                \"batch_progress\": \"Importing {{current}}/{{total}} accounts...\",\n                \"batch_success\": \"Successfully imported {{count}} accounts\",\n                \"batch_partial\": \"Import finished: {{success}} success, {{fail}} failed\",\n                \"batch_fail\": \"Import failed\"\n            },\n            \"import\": {\n                \"scheme_a\": \"Plan A: From IDE DB\",\n                \"scheme_a_desc\": \"Auto-read current logged-in account from local Antigravity DB.\",\n                \"btn_db\": \"Import Current Account\",\n                \"or\": \"OR\",\n                \"scheme_b\": \"Plan B: From V1 Backup\",\n                \"scheme_b_desc\": \"Scan ~/.antigravity-agent for V1 account data.\",\n                \"btn_v1\": \"Batch Import V1\",\n                \"btn_custom_db\": \"Import Custom DB\"\n            },\n            \"btn_cancel\": \"Cancel\",\n            \"btn_confirm\": \"Confirm\",\n            \"oauth_error\": \"OAuth failed: {{error}}\",\n            \"status\": {\n                \"error_token\": \"Please enter Refresh Token\"\n            }\n        },\n        \"table\": {\n            \"email\": \"Email\",\n            \"quota\": \"Model Quota\",\n            \"last_used\": \"Last Used\",\n            \"actions\": \"Actions\"\n        },\n        \"drag_to_reorder\": \"Drag to reorder\",\n        \"empty\": {\n            \"title\": \"No Accounts\",\n            \"desc\": \"Click the \\\"Add Account\\\" button above to add your first account\"\n        },\n        \"views\": {\n            \"list\": \"List View\",\n            \"grid\": \"Grid View\"\n        },\n        \"dialog\": {\n            \"add_title\": \"Add Account\",\n            \"batch_delete_title\": \"Batch Delete Confirmation\",\n            \"delete_title\": \"Delete Confirmation\",\n            \"batch_delete_msg\": \"Are you sure you want to delete the selected {{count}} accounts? This action cannot be undone.\",\n            \"delete_msg\": \"Are you sure you want to delete this account? This action cannot be undone.\",\n            \"refresh_title\": \"Refresh Quota\",\n            \"batch_refresh_title\": \"Batch Refresh\",\n            \"refresh_msg\": \"Are you sure you want to refresh the quota for the current account?\",\n            \"batch_refresh_msg\": \"Are you sure you want to refresh quotas for the selected {{count}} accounts? This may take some time.\",\n            \"disable_proxy_title\": \"Disable Proxy\",\n            \"disable_proxy_msg\": \"Are you sure you want to disable proxy for this account? The account will remain usable in the app.\",\n            \"enable_proxy_title\": \"Enable Proxy\",\n            \"enable_proxy_msg\": \"Are you sure you want to re-enable proxy for this account?\",\n            \"warmup_all_title\": \"Full Manual Warmup\",\n            \"warmup_all_msg\": \"Are you sure you want to trigger warmup tasks for all eligible accounts immediately? This will send minimal traffic to Google services to reset quota cycles.\",\n            \"batch_warmup_title\": \"Batch Manual Warmup\",\n            \"batch_warmup_msg\": \"Are you sure you want to trigger warmup for the selected {{count}} accounts immediately?\"\n        }\n    },\n    \"settings\": {\n        \"save\": \"Save Settings\",\n        \"tabs\": {\n            \"general\": \"General\",\n            \"account\": \"Account\",\n            \"proxy\": \"Proxy Settings\",\n            \"advanced\": \"Advanced\",\n            \"debug\": \"Debug\",\n            \"about\": \"About\"\n        },\n        \"general\": {\n            \"title\": \"General Settings\",\n            \"language\": \"Language\",\n            \"theme\": \"Theme\",\n            \"theme_light\": \"Light\",\n            \"theme_dark\": \"Dark\",\n            \"theme_system\": \"System\",\n            \"auto_launch\": \"Launch at Startup\",\n            \"auto_launch_enabled\": \"Enabled\",\n            \"auto_launch_disabled\": \"Disabled\",\n            \"auto_launch_desc\": \"Automatically launch Antigravity Tools when system starts\",\n            \"auto_check_update\": \"Auto Check for Updates\",\n            \"auto_check_update_desc\": \"Automatically check for new versions on startup\",\n            \"auto_check_update_enabled\": \"Auto check enabled\",\n            \"auto_check_update_disabled\": \"Auto check disabled\",\n            \"update_check_interval\": \"Check Interval (hours)\",\n            \"update_check_interval_desc\": \"Set auto-check interval (1-168 hours)\",\n            \"update_check_interval_saved\": \"Check interval settings saved\"\n        },\n        \"account\": {\n            \"title\": \"Account Settings\",\n            \"auto_refresh\": \"Background Auto Refresh\",\n            \"auto_refresh_desc\": \"Automatically refresh all account quotas in the background. This is required for quota protection and smart warmup.\",\n            \"always_on\": \"Always On\",\n            \"refresh_interval\": \"Refresh Interval (minutes)\",\n            \"auto_sync\": \"Auto Sync Current Account\",\n            \"auto_sync_desc\": \"Automatically sync current active account information periodically\",\n            \"sync_interval\": \"Sync Interval (minutes)\"\n        },\n        \"warmup\": {\n            \"title\": \"Smart Warmup\",\n            \"desc\": \"Automatically monitors all models and triggers warmup immediately when quota reaches 100%, keeping models warm\"\n        },\n        \"quota_protection\": {\n            \"title\": \"Quota Protection\",\n            \"enable\": \"Enable Quota Protection\",\n            \"enable_desc\": \"Automatically disable proxy when account quota falls below threshold, and auto-restore when quota resets\",\n            \"threshold_label\": \"Reserved Quota Percentage\",\n            \"monitored_models_label\": \"Monitored Models (Trigger Conditions)\",\n            \"monitored_models_desc\": \"Select at least one. Protection triggers if ANY selected model falls below threshold\",\n            \"range\": \"Range\",\n            \"example\": \"Example: At {{percentage}}%, an account with {{total}} quota will be disabled when remaining ≤ {{threshold}}\",\n            \"auto_restore_info\": \"Account will be automatically re-enabled when quota resets\"\n        },\n        \"pinned_quota_models\": {\n            \"title\": \"Pinned Quota Models\",\n            \"desc\": \"Choose which model quotas to display in the account list. Unselected models are only shown in the detail popup.\"\n        },\n        \"proxy\": {\n            \"title\": \"Proxy Settings\"\n        },\n        \"proxy_pool\": {\n            \"title\": \"Proxy Pool\",\n            \"strategy_priority\": \"Priority\",\n            \"strategy_round_robin\": \"Round Robin\",\n            \"strategy_random\": \"Random\",\n            \"strategy_least_connections\": \"Least Connections\",\n            \"test_all\": \"Test All\",\n            \"batch_import\": \"Import\",\n            \"binding_manager\": \"Bindings\",\n            \"add_proxy\": \"Add Proxy\",\n            \"edit_proxy\": \"Edit Proxy\",\n            \"name\": \"Name\",\n            \"url\": \"Proxy URL\",\n            \"username\": \"Username\",\n            \"password\": \"Password\",\n            \"max_accounts\": \"Max Accounts\",\n            \"max_accounts_hint\": \"0 = Unlimited\",\n            \"priority\": \"Priority\",\n            \"priority_hint\": \"Lower is better\",\n            \"health_check_url\": \"Health Check URL\",\n            \"tags\": \"Tags\",\n            \"add_tag_placeholder\": \"Add tag...\",\n            \"seconds\": \"Sec\",\n            \"test_completed\": \"Health check completed\",\n            \"test_failed\": \"Health check failed\",\n            \"confirm_delete\": \"Are you sure you want to delete this proxy?\",\n            \"empty\": \"No proxies available\",\n            \"column_priority\": \"Priority\",\n            \"column_status\": \"Status\",\n            \"column_details\": \"Proxy Details\",\n            \"column_bindings\": \"Bindings\",\n            \"import_title\": \"Batch Import Proxies\",\n            \"import_label\": \"Paste Proxy List (One per line)\",\n            \"import_hint\": \"Supported formats: protocol://user:pass@host:port, host:port:user:pass\",\n            \"import_preview\": \"Preview\",\n            \"import_confirm\": \"Import {{count}} Proxies\",\n            \"no_valid_proxies\": \"No valid proxies found\",\n            \"binding\": {\n                \"title\": \"Account Proxy Bindings\",\n                \"load_failed\": \"Failed to load bindings\",\n                \"unbind_success\": \"Unbound successfully\",\n                \"bind_success\": \"Bound successfully\",\n                \"update_failed\": \"Failed to update binding\",\n                \"assigned_proxy\": \"Assigned Proxy\",\n                \"default_strategy\": \"Default (Follow Strategy)\"\n            },\n            \"status\": {\n                \"inactive\": \"Inactive\",\n                \"checking\": \"Checking\",\n                \"healthy\": \"Healthy\",\n                \"timeout\": \"Timeout\"\n            }\n        },\n        \"advanced\": {\n            \"title\": \"Advanced Settings\",\n            \"export_path\": \"Default Export Path\",\n            \"export_path_placeholder\": \"Not set (Ask every time)\",\n            \"default_export_path_desc\": \"Files will be saved directly to this folder without asking\",\n            \"select_btn\": \"Select\",\n            \"open_btn\": \"Open\",\n            \"data_dir\": \"Data Directory\",\n            \"data_dir_desc\": \"Account data and config file location\",\n            \"antigravity_path\": \"Antigravity Path\",\n            \"antigravity_path_placeholder\": \"Not set (Will use auto-detection)\",\n            \"antigravity_path_desc\": \"If you installed Antigravity in a non-standard location, you can manually specify the executable path here (Points to .app on MacOS).\",\n            \"antigravity_path_select\": \"Select Antigravity Executable\",\n            \"antigravity_path_detected\": \"Detected path updated\",\n            \"detect_btn\": \"Detect\",\n            \"antigravity_args\": \"Antigravity Startup Arguments\",\n            \"antigravity_args_placeholder\": \"--user-data-dir=/path/to/data --some-other-flag\",\n            \"antigravity_args_desc\": \"Specify startup arguments for Antigravity, e.g. --user-data-dir to specify user data directory\",\n            \"detect_args_btn\": \"Detect\",\n            \"antigravity_args_detected\": \"Startup arguments updated\",\n            \"antigravity_args_detect_error\": \"Failed to detect startup arguments\",\n            \"accounts_page_size\": \"Accounts Page Size\",\n            \"page_size_auto\": \"Auto Calculate (Recommended)\",\n            \"page_size_desc\": \"Set the number of accounts displayed per page. Select 'Auto Calculate' to dynamically adjust based on window size.\",\n            \"logs_title\": \"Logs Maintenance\",\n            \"logs_desc\": \"Clear log cache files. Does not affect account data.\",\n            \"clear_logs\": \"Clear Logs Cache\",\n            \"clear_logs_title\": \"Clear Logs Confirmation\",\n            \"clear_logs_msg\": \"Are you sure you want to clear all log cache files?\",\n            \"logs_cleared\": \"Logs cache cleared\",\n            \"antigravity_cache_title\": \"Antigravity Cache Cleanup\",\n            \"antigravity_cache_desc\": \"Clear Antigravity cache to resolve login failures, version validation errors, and OAuth authorization issues.\",\n            \"antigravity_cache_warning\": \"Please ensure Antigravity is completely closed before clearing cache.\",\n            \"clear_antigravity_cache\": \"Clear Antigravity Cache\",\n            \"clear_cache_confirm_title\": \"Confirm Clear Antigravity Cache\",\n            \"clear_cache_confirm_msg\": \"The following cache directories will be cleared:\",\n            \"cache_cleared_success\": \"Cache cleared successfully, freed {{size}} MB\",\n            \"cache_not_found\": \"No Antigravity cache directories found\",\n            \"debug_logs_title\": \"Debug Logging\",\n            \"debug_logs_enable_desc\": \"When enabled, records the complete request and response chain. Recommended to enable only when troubleshooting issues.\",\n            \"debug_logs_desc\": \"Records the full chain: original input, transformed v1internal request, and upstream response. For troubleshooting only, may contain sensitive data.\",\n            \"debug_log_dir\": \"Debug Log Output Directory\",\n            \"debug_log_dir_hint\": \"Leave empty to use the default directory: {{path}}/debug_logs\",\n            \"debug_log_dir_select\": \"Select Debug Log Output Directory\",\n            \"http_api_title\": \"HTTP API Service\",\n            \"http_api_desc\": \"Provides local HTTP interface for external programs (e.g. VS Code plugins).\",\n            \"http_api_enabled\": \"Enable HTTP API\",\n            \"http_api_enabled_desc\": \"When enabled, external programs can manage accounts via HTTP interface\",\n            \"http_api_port\": \"Listen Port\",\n            \"http_api_port_desc\": \"Restart required after changing port. If port conflict occurs, please use another available port.\",\n            \"http_api_port_placeholder\": \"Default port 19527\",\n            \"http_api_port_invalid\": \"Invalid port number (range: 1024-65535)\",\n            \"http_api_settings_saved\": \"HTTP API settings saved, restart required to apply\",\n            \"http_api_restart_required\": \"⚠️ Restart required to apply\"\n        },\n        \"debug\": {\n            \"title\": \"Debug Console\",\n            \"desc\": \"View real-time application logs for debugging\",\n            \"enabled\": \"Enabled\",\n            \"disabled\": \"Disabled\",\n            \"disabled_hint\": \"Debug Console is Off\",\n            \"disabled_desc\": \"Enable to start recording application logs\",\n            \"console_title\": \"Debug Console\",\n            \"console_desc\": \"View real-time application logs to troubleshoot issues.\",\n            \"enable_desc\": \"Enable to capture and display backend logs.\",\n            \"open_btn\": \"Open Console\",\n            \"debug_logging\": \"Debug Logging\",\n            \"debug_logging_desc\": \"When enabled, records the complete request and response chain. Recommended to enable only when troubleshooting issues.\"\n        },\n        \"menu\": {\n            \"title\": \"Menu Display Settings\",\n            \"desc\": \"Select the function items to display in the menu bar. Hiding infrequently used menus can save space.\",\n            \"selected_items_note\": \"Selected items will be displayed in the top menu bar.\",\n            \"required\": \"Required\"\n        },\n        \"about\": {\n            \"title\": \"About\",\n            \"version\": \"App Version\",\n            \"tech_stack\": \"Tech Stack\",\n            \"author\": \"Author\",\n            \"wechat\": \"WeChat\",\n            \"telegram\": \"Telegram Channel\",\n            \"github\": \"GitHub\",\n            \"view_code\": \"View Code\",\n            \"copyright\": \"Copyright © 2025-2026 Antigravity. All rights reserved.\",\n            \"check_update\": \"Check for Updates\",\n            \"checking_update\": \"Checking...\",\n            \"latest_version\": \"You're up to date\",\n            \"new_version_available\": \"New version {{version}} available\",\n            \"download_update\": \"Download\",\n            \"brew_upgrade\": \"Update via Homebrew\",\n            \"brew_upgrading\": \"Upgrading...\",\n            \"brew_confirm_title\": \"Update via Homebrew\",\n            \"brew_confirm_desc\": \"The following command will be executed to update the app. A restart is required after the upgrade.\",\n            \"brew_quarantine_hint\": \"If you see an \\\"App is damaged\\\" error after update, run in terminal:\",\n            \"brew_confirm_btn\": \"Start Update\",\n            \"brew_success_title\": \"Upgrade Complete\",\n            \"brew_upgrade_success\": \"Homebrew upgrade succeeded. Please restart the app to load the new version.\",\n            \"brew_restart_btn\": \"Restart Now\",\n            \"brew_restart_failed\": \"Auto restart failed. Please close and reopen the app manually\",\n            \"brew_upgrade_failed\": \"Homebrew upgrade failed. Try running manually: brew upgrade --cask antigravity-tools\",\n            \"brew_error_brew_not_found\": \"Homebrew not found. Please ensure brew is installed\",\n            \"brew_error_brew_exec_failed\": \"Failed to execute brew command. Try running manually in terminal\",\n            \"brew_error_brew_timeout\": \"Homebrew upgrade timed out (3min). Try running manually: brew upgrade --cask antigravity-tools\",\n            \"brew_error_brew_already_latest\": \"Already up to date, no upgrade needed\",\n            \"brew_error_brew_not_supported\": \"Homebrew update is not supported on this platform\",\n            \"update_check_failed\": \"Update check failed\",\n            \"support_btn\": \"Support Author\",\n            \"support_title\": \"Donation & Support\",\n            \"support_desc\": \"If you find this project helpful, feel free to buy me a coffee! Your support is the biggest motivation for me to maintain this project.\",\n            \"support_alipay\": \"Alipay\",\n            \"support_wechat\": \"WeChat Pay\",\n            \"support_buymeacoffee\": \"Buy Me a Coffee\"\n        },\n        \"advanced_thinking\": {\n            \"title\": \"Advanced Thinking & Global Config\",\n            \"description\": \"Manage thinking capabilities, image modes, and global instructions.\"\n        },\n        \"thinking_budget\": {\n            \"title\": \"Thinking Budget\",\n            \"description\": \"Controls the token budget for AI deep thinking. Some models (e.g., Flash, -thinking suffix models) are limited to 24576 by upstream API.\",\n            \"mode_label\": \"Processing Mode\",\n            \"mode\": {\n                \"auto\": \"Auto Limit\",\n                \"passthrough\": \"Passthrough\",\n                \"custom\": \"Custom\",\n                \"adaptive\": \"Adaptive\"\n            },\n            \"effort_label\": \"Thinking Effort\",\n            \"effort\": {\n                \"low\": \"Low\",\n                \"medium\": \"Medium\",\n                \"high\": \"High\"\n            },\n            \"auto_hint\": \"Auto mode: Automatically caps budget to 24576 for Flash, -thinking suffix models, and Web Search requests.\",\n            \"adaptive_hint\": \"Adaptive mode: The model automatically adjusts thinking budget based on task complexity. Recommended for Claude 4.6+.\",\n            \"passthrough_warning\": \"Passthrough: Uses caller's value directly. High values may cause failures.\",\n            \"custom_value_hint\": \"Rec: 24576 (Flash) or 51200 (Extended)\",\n            \"tokens\": \"tokens\"\n        },\n        \"image_thinking_mode\": {\n            \"title\": \"Image Thinking Mode\",\n            \"hint\": \"Affects quality and generation flow\",\n            \"options\": {\n                \"enabled\": \"Enabled\",\n                \"disabled\": \"Disabled\",\n                \"enabled_desc\": \"On: Preserves thinking chain, returns sketch + final image.\",\n                \"disabled_desc\": \"Off: Disables thinking chain, generates single ultra-clear image (Quality First).\"\n            }\n        },\n        \"global_system_prompt\": {\n            \"title\": \"Global System Prompt\",\n            \"hint\": \"Automatically injected into all systemInstructions\",\n            \"placeholder\": \"Enter global system prompt...\\nExample: You are a senior full-stack developer. Please respond in English.\",\n            \"char_count\": \"{{count}} characters\",\n            \"long_prompt_warning\": \"Prompt is quite long (over 2000 chars) and may consume significant context space.\"\n        },\n        \"branding\": {\n            \"title\": \"Antigravity Tools\",\n            \"subtitle\": \"Professional Account Management\"\n        }\n    },\n    \"tray\": {\n        \"current\": \"Current\",\n        \"quota\": \"Quota\",\n        \"switch_next\": \"Switch to Next Account\",\n        \"refresh_current\": \"Refresh Current Quota\",\n        \"show_window\": \"Show Main Window\",\n        \"quit\": \"Quit Application\",\n        \"no_account\": \"No Account\",\n        \"unknown_quota\": \"Unknown (Click to Refresh)\",\n        \"forbidden\": \"Account Forbidden\"\n    },\n    \"proxy\": {\n        \"title\": \"API Proxy Service\",\n        \"error\": {\n            \"load_failed\": \"Failed to load configuration\"\n        },\n        \"status\": {\n            \"running\": \"Service Running\",\n            \"stopped\": \"Service Stopped\",\n            \"accounts_available\": \"{{count}} Accounts Available\",\n            \"processing\": \"Processing...\"\n        },\n        \"action\": {\n            \"start\": \"Start Service\",\n            \"stop\": \"Stop Service\"\n        },\n        \"config\": {\n            \"title\": \"Service Configuration\",\n            \"request\": {\n                \"user_agent\": \"User-Agent Override\",\n                \"user_agent_tooltip\": \"Override the User-Agent header sent to upstream APIs. Leave empty to use default.\",\n                \"user_agent_hint\": \"Current Default: antigravity/<version> <os>/<arch>\",\n                \"user_agent_placeholder\": \"Enter custom User-Agent string...\"\n            },\n            \"port\": \"Listen Port\",\n            \"port_tooltip\": \"TCP port the local API Proxy listens on. Stop the service to change it, then restart to apply.\",\n            \"port_hint\": \"Default 8045, restart required to apply changes\",\n            \"auto_start\": \"Auto Start with App\",\n            \"auto_start_tooltip\": \"Automatically starts the local API Proxy service when the app launches.\",\n            \"allow_lan_access\": \"Allow LAN Access\",\n            \"allow_lan_access_tooltip\": \"When enabled, the service binds to 0.0.0.0 so other devices on your LAN can access it. Keep authorization enabled and protect your API key; restart required to apply.\",\n            \"allow_lan_access_hint_enabled\": \"🌐 Listening on 0.0.0.0, LAN devices can access\",\n            \"allow_lan_access_hint_disabled\": \"🔒 Listening on 127.0.0.1 only, localhost access (Privacy First)\",\n            \"allow_lan_access_warning\": \"⚠️ LAN devices can access when enabled. Keep your API key secure\",\n            \"allow_lan_access_restart_hint\": \"ℹ️ Service restart required to apply changes\",\n            \"api_key\": \"API Key\",\n            \"api_key_tooltip\": \"Shared secret used by clients when proxy authorization is enabled. Regenerating the key immediately invalidates the old one.\",\n            \"btn_regenerate\": \"Regenerate Key\",\n            \"btn_edit\": \"Edit\",\n            \"btn_save\": \"Save\",\n            \"btn_copy\": \"Copy\",\n            \"btn_copied\": \"Copied\",\n            \"warning_key\": \"Note: Keep your API key secure. Do not share it.\",\n            \"api_key_invalid\": \"Invalid API key format, must start with sk- and be at least 10 characters long\",\n            \"api_key_updated\": \"API key updated\",\n            \"admin_password\": \"Web UI Management Password\",\n            \"admin_password_tooltip\": \"Password used to log in to the Web management console. If empty, the API Key is used by default.\",\n            \"admin_password_default\": \"(Same as API Key)\",\n            \"admin_password_placeholder\": \"Enter new password, leave empty to use API Key\",\n            \"admin_password_hint\": \"Tip: In Docker/Web deployment scenarios, you can set a separate login password to improve the security of your API Key.\",\n            \"admin_password_short\": \"Password too short (at least 4 characters)\",\n            \"admin_password_updated\": \"Web UI login password updated\",\n            \"auth\": {\n                \"title\": \"Authorization\",\n                \"title_tooltip\": \"Controls whether incoming requests must be authenticated, and which routes are protected.\",\n                \"enabled\": \"Enabled\",\n                \"enabled_tooltip\": \"Turns authorization on/off by switching the authorization mode. When enabled, clients must include the API key via Authorization: Bearer <API_KEY> or x-api-key.\",\n                \"mode\": \"Mode\",\n                \"mode_tooltip\": \"Selects which routes require the API key: Off = no auth; All = protect everything; All except Health = /healthz stays open; Auto = Off for localhost-only, otherwise All except Health.\",\n                \"hint\": \"When enabled, clients must send the API key via Authorization: Bearer ... (except health if selected).\",\n                \"modes\": {\n                    \"off\": \"Off (Open)\",\n                    \"strict\": \"All (Strict)\",\n                    \"all_except_health\": \"All except Health\",\n                    \"auto\": \"Auto (Recommended)\"\n                }\n            },\n            \"zai\": {\n                \"title\": \"z.ai (GLM) Provider\",\n                \"title_tooltip\": \"Optional Anthropic-compatible upstream for Claude protocol. Only affects Anthropic endpoints; Google account routing remains unchanged.\",\n                \"subtitle\": \"Optional Anthropic-compatible upstream for Claude protocol only.\",\n                \"enabled\": \"Enabled\",\n                \"enabled_tooltip\": \"Enables z.ai routing for Anthropic requests according to the selected dispatch mode.\",\n                \"base_url\": \"Base URL\",\n                \"base_url_tooltip\": \"Anthropic-compatible base URL. The proxy appends paths like /v1/messages. Leave the default unless you use a custom gateway.\",\n                \"dispatch_mode\": \"Dispatch Mode\",\n                \"dispatch_mode_tooltip\": \"Controls when to use z.ai for Anthropic requests: Off disables it; All Anthropic requests forwards everything; Pooled adds z.ai as one slot in round-robin with Google accounts; Fallback uses z.ai only when there are no Google accounts.\",\n                \"api_key\": \"API Key\",\n                \"api_key_tooltip\": \"API key used to authenticate requests to z.ai. Stored locally and required for z.ai and MCP features.\",\n                \"api_key_placeholder\": \"Paste your z.ai API key here\",\n                \"warning\": \"Note: This key is stored locally in the app data directory.\",\n                \"models\": {\n                    \"title\": \"Model Mapping\",\n                    \"title_tooltip\": \"Fetch available z.ai model ids and configure how incoming Anthropic/Claude model names are translated to z.ai model ids.\",\n                    \"refresh\": \"Fetch models\",\n                    \"refreshing\": \"Fetching...\",\n                    \"hint\": \"Available models: {{count}}. Select a suggestion or type a custom model id.\",\n                    \"error\": \"Failed to fetch models: {{error}}\",\n                    \"select_placeholder\": \"Select model...\",\n                    \"opus\": \"Opus family → z.ai model\",\n                    \"opus_tooltip\": \"Default z.ai model id used when the incoming model contains \\\"opus\\\" (e.g. claude-opus-*).\",\n                    \"sonnet\": \"Sonnet family → z.ai model\",\n                    \"sonnet_tooltip\": \"Default z.ai model id used for other Claude models (e.g. claude-sonnet-* and most claude-* requests).\",\n                    \"haiku\": \"Haiku family → z.ai model\",\n                    \"haiku_tooltip\": \"Default z.ai model id used when the incoming model contains \\\"haiku\\\" (e.g. claude-haiku-*).\",\n                    \"advanced_title\": \"Advanced overrides\",\n                    \"advanced_tooltip\": \"Optional exact-match overrides. If an incoming model string matches a rule key, it will be replaced with the mapped z.ai model id.\",\n                    \"from_label\": \"Incoming model\",\n                    \"to_label\": \"z.ai model\",\n                    \"add_rule\": \"Add\",\n                    \"empty\": \"No override rules configured.\",\n                    \"from_placeholder\": \"From (e.g. claude-3-opus)\",\n                    \"to_placeholder\": \"To (e.g. glm-4)\"\n                },\n                \"modes\": {\n                    \"off\": \"Off\",\n                    \"exclusive\": \"All Anthropic requests\",\n                    \"pooled\": \"Pooled (one slot)\",\n                    \"fallback\": \"Fallback only\"\n                },\n                \"mcp\": {\n                    \"title\": \"MCP Servers (via local proxy)\",\n                    \"title_tooltip\": \"Exposes optional /mcp/* endpoints on this local proxy so MCP clients can connect. Available only when the service is running, z.ai is configured, and the corresponding toggles are enabled.\",\n                    \"enabled\": \"Enable MCP proxy\",\n                    \"enabled_tooltip\": \"Master switch for MCP endpoints. When off, all /mcp/* routes return 404.\",\n                    \"web_search\": \"Web Search\",\n                    \"web_search_tooltip\": \"Exposes /mcp/web_search_prime/mcp and forwards requests to the z.ai Web Search MCP upstream.\",\n                    \"web_reader\": \"Web Reader\",\n                    \"web_reader_tooltip\": \"Exposes /mcp/web_reader/mcp and forwards requests to the z.ai Web Reader MCP upstream.\",\n                    \"vision\": \"Vision\",\n                    \"vision_tooltip\": \"Exposes /mcp/zai-mcp-server/mcp (local MCP server) that provides vision tools backed by z.ai.\",\n                    \"local_endpoints\": \"Local endpoints (configure your MCP client to use these URLs):\",\n                    \"local_endpoints_tooltip\": \"Use these URLs in your MCP client. They share the same host/port as the API Proxy and follow the proxy authorization policy.\"\n                }\n            },\n            \"request_timeout\": \"Request Timeout\",\n            \"request_timeout_tooltip\": \"Maximum time (seconds) the proxy waits for an upstream response, including streaming. Increase for long generations; restart required to apply.\",\n            \"request_timeout_hint\": \"Default 120s, range 30-7200s. Restart service to apply changes.\",\n            \"enable_logging\": \"Enable Request Logging\",\n            \"enable_logging_hint\": \"Record history for debugging (Minor perf cost)\",\n            \"upstream_proxy\": {\n                \"title\": \"Global Upstream Proxy (Global Proxy)\",\n                \"desc\": \"When enabled, all external requests (API Proxy, Token Refresh, Quota Check, Update Check) will be routed through this proxy.\",\n                \"desc_short\": \"Fallback proxy used when no suitable proxy is found in the pool.\",\n                \"enable\": \"Enable Upstream Proxy\",\n                \"url\": \"Proxy URL\",\n                \"url_placeholder\": \"e.g. http://127.0.0.1:7890 or socks5://127.0.0.1:7890\",\n                \"tip\": \"Supports HTTP, HTTPS and SOCKS5.\",\n                \"socks5h_hint\": \"To bypass upstream risk control and use Remote DNS resolution, manually change the protocol to socks5h://\",\n                \"validation_error\": \"Proxy URL is required when upstream proxy is enabled\",\n                \"restart_hint\": \"Proxy settings saved. Restart the app to apply changes.\"\n            },\n            \"scheduling\": {\n                \"title\": \"Account Rotation & Scheduling\",\n                \"title_tooltip\": \"Controls how sessions are bound to accounts and how rate limits are handled.\",\n                \"subtitle\": \"Optimizes Prompt Caching and rate limit handling for all protocols (OpenAI/Gemini/Claude).\",\n                \"mode\": \"Scheduling Mode\",\n                \"mode_tooltip\": \"Cache-First: Bind session to account, wait on rate limit (maximize cache utility); Balance: Bind session, switch account on rate limit; Performance: Standard Round-robin.\",\n                \"modes\": {\n                    \"CacheFirst\": \"Cache First\",\n                    \"Balance\": \"Balance\",\n                    \"PerformanceFirst\": \"Performance\"\n                },\n                \"modes_desc\": {\n                    \"CacheFirst\": \"Binds session to account, waits precisely if limited (Maximizes Prompt Cache hits).\",\n                    \"Balance\": \"Binds session, auto-switches to available account if limited (Balanced cache & availability).\",\n                    \"PerformanceFirst\": \"No session binding, pure round-robin rotation (Best for high concurrency).\"\n                },\n                \"max_wait\": \"Max Wait (sec)\",\n                \"max_wait_tooltip\": \"Only used in 'Cache First' mode: wait instead of switching if the rate limit reset time is below this value.\",\n                \"clear_bindings\": \"Clear Session Bindings\",\n                \"clear_bindings_tooltip\": \"Hard reset all session-account bindings, forcing accounts to be re-assigned on next request.\",\n                \"clear_rate_limits\": \"Clear Rate Limit Records\",\n                \"clear_rate_limits_tooltip\": \"Immediately clear local rate limit records for all accounts, forcing next requests to try upstream directly.\"\n            },\n            \"circuit_breaker\": {\n                \"title\": \"Adaptive Circuit Breaker\",\n                \"tooltip\": \"Automatically increases lockout duration for accounts that repeatedly fail with quota exhaustion. This prevents wasting API calls on dead accounts while allowing transient errors to recover quickly.\",\n                \"backoff_levels\": \"Backoff Levels (Seconds)\",\n                \"input_placeholder\": \"Enter backoff durations in seconds, separated by commas\",\n                \"level\": \"Lv {{level}}\",\n                \"invalid_format\": \"Invalid format. Use comma separated numbers (e.g. 60, 300)\",\n                \"clear_records\": \"Clear All Rate Limit Records\"\n            },\n            \"experimental\": {\n                \"title\": \"Experimental Settings\",\n                \"title_tooltip\": \"Exploratory features that may be adjusted or removed in future versions.\",\n                \"enable_usage_scaling\": \"Enable Usage Scaling\",\n                \"enable_usage_scaling_tooltip\": \"For Claude protocol. Enables aggressive scaling when total input exceeds 30k tokens to prevent frequent client-side compression. Note: Reported usage will not reflect actual billing after enabling.\",\n                \"context_compression_threshold_l1\": \"L1 Compression Threshold (Tool Trimming)\",\n                \"context_compression_threshold_l1_tooltip\": \"Trims old tool call records to save space. Recommended: 0.4 (40%)\",\n                \"context_compression_threshold_l2\": \"L2 Compression Threshold (Thinking Compression)\",\n                \"context_compression_threshold_l2_tooltip\": \"Compresses early thinking blocks while preserving signatures. Recommended: 0.55 (55%)\",\n                \"context_compression_threshold_l3\": \"L3 Compression Threshold (Summary Pivot)\",\n                \"context_compression_threshold_l3_tooltip\": \"Ultimate reset: generates an XML state summary and pivots to a fresh session. Most token-efficient. Recommended: 0.7 (70%)\"\n            },\n            \"opencode_sync\": {\n                \"card_title\": \"OpenCode\",\n                \"status\": {\n                    \"detecting\": \"Detecting...\",\n                    \"installed\": \"Installed ({{version}})\",\n                    \"not_installed\": \"Not Installed\",\n                    \"synced\": \"Synced\",\n                    \"not_synced\": \"Not Synced\",\n                    \"current_base_url\": \"Current Base URL\"\n                },\n                \"sync_accounts\": \"Sync accounts to antigravity-accounts.json\",\n                \"btn_sync\": \"Sync Config\",\n                \"btn_view\": \"View Config\",\n                \"btn_restore\": \"Restore\",\n                \"btn_restore_backup\": \"Restore Backup\",\n                \"btn_clear\": \"Clear Config\",\n                \"clear_confirm_title\": \"Confirm Clear Config\",\n                \"clear_confirm_message\": \"Are you sure you want to clear OpenCode configuration? This will remove the config file.\",\n                \"toast\": {\n                    \"config_missing\": \"Please generate API Key and start service first\",\n                    \"sync_success\": \"OpenCode config synced successfully\",\n                    \"sync_error\": \"OpenCode sync failed: {{error}}\",\n                    \"clear_success\": \"OpenCode config cleared successfully\",\n                    \"clear_error\": \"Failed to clear OpenCode config: {{error}}\"\n                },\n                \"modal\": {\n                    \"view_title\": \"OpenCode Config Viewer\",\n                    \"copy_success\": \"Config copied\"\n                },\n                \"sync_confirm_title\": \"Confirm Sync\",\n                \"sync_confirm_message\": \"OpenCode config will be overwritten based on current proxy settings. Continue?\",\n                \"restore_confirm\": \"Are you sure you want to restore OpenCode to default settings?\",\n                \"restore_backup_confirm\": \"Are you sure you want to restore OpenCode config from backup?\",\n                \"modal_title\": \"Select OpenCode Models\",\n                \"select_models\": \"Select models to sync\",\n                \"auth_plugin_warning\": \"Detected opencode-antigravity-auth plugin. Sync only creates provider antigravity-manager and will not overwrite the google provider/plugin.\",\n                \"btn_confirm_sync\": \"Confirm Sync\",\n                \"custom_base_url_label\": \"Custom Manager BaseURL\",\n                \"custom_base_url_desc\": \"For Docker Compose networking\",\n                \"custom_base_url_reset\": \"Reset\"\n            },\n            \"droid_sync\": {\n                \"modal_title\": \"Add models to Droid\",\n                \"modal_desc\": \"Selected models will be added as customModels to settings.json\",\n                \"selected\": \"selected\",\n                \"btn_confirm_sync\": \"Add selected models\",\n                \"toast\": {\n                    \"no_models_selected\": \"Please select at least one model\",\n                    \"sync_success_count\": \"Added {{count}} model(s) to Droid\",\n                    \"sync_error\": \"Sync failed: {{error}}\"\n                }\n            }\n        },\n        \"cloudflared\": {\n            \"title\": \"Public Access (Cloudflared)\",\n            \"subtitle\": \"Expose your local service to the internet via Cloudflare Tunnel\",\n            \"not_installed\": \"Cloudflared not installed\",\n            \"install_hint\": \"Cloudflared is a free tunnel tool from Cloudflare. It exposes your local proxy to the internet without a public IP or port forwarding. Click the button below to install.\",\n            \"install\": \"Install Now\",\n            \"installing\": \"Installing...\",\n            \"install_success\": \"Cloudflared installed successfully\",\n            \"install_failed\": \"Installation failed: {{error}}\",\n            \"installed\": \"Installed\",\n            \"version\": \"Version\",\n            \"mode_label\": \"Tunnel Mode\",\n            \"mode_quick\": \"Quick Tunnel\",\n            \"mode_quick_desc\": \"Auto-generated temporary URL (*.trycloudflare.com), no account needed, URL changes on restart\",\n            \"mode_auth\": \"Named Tunnel\",\n            \"mode_auth_desc\": \"Use Cloudflare account token, supports custom domain, persistent URL\",\n            \"token\": \"Tunnel Token\",\n            \"token_placeholder\": \"Paste your Cloudflare Tunnel Token here\",\n            \"token_hint\": \"Get from Cloudflare Zero Trust dashboard\",\n            \"token_required\": \"Token is required for Named Tunnel mode\",\n            \"use_http2\": \"Use HTTP/2\",\n            \"use_http2_desc\": \"More compatible, recommended for China mainland\",\n            \"status_label\": \"Tunnel Status\",\n            \"status_stopped\": \"Stopped\",\n            \"status_starting\": \"Starting...\",\n            \"status_running\": \"Running\",\n            \"status_stopping\": \"Stopping...\",\n            \"status_error\": \"Error\",\n            \"public_url\": \"Public URL\",\n            \"public_url_placeholder\": \"Public URL will appear here after tunnel starts\",\n            \"copy_url\": \"Copy URL\",\n            \"url_copied\": \"URL copied\",\n            \"start_tunnel\": \"Start Tunnel\",\n            \"stop_tunnel\": \"Stop Tunnel\",\n            \"running\": \"Tunnel Running\",\n            \"started\": \"Tunnel started\",\n            \"stopped\": \"Tunnel stopped\",\n            \"start_failed\": \"Start failed: {{error}}\",\n            \"stop_failed\": \"Stop failed: {{error}}\",\n            \"require_proxy_running\": \"Please start the local proxy service first\",\n            \"connection_info\": \"Connection Info\",\n            \"local_port\": \"Local Port\",\n            \"tunnel_protocol\": \"Tunnel Protocol\"\n        },\n        \"example\": {\n            \"title\": \"Usage Examples\",\n            \"curl\": \"cURL\",\n            \"python\": \"Python\",\n            \"python_anthropic\": \"from anthropic import Anthropic\\n\\nclient = Anthropic(\\n    # Recommended: 127.0.0.1\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\n# Note: Antigravity supports calling any model via the Anthropic SDK\\nresponse = client.messages.create(\\n    model=\\\"{{modelId}}\\\",\\n    max_tokens=1024,\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hello\\\"}]\\n)\\n\\nprint(response.content[0].text)\",\n            \"python_gemini\": \"# Install: pip install google-generativeai\\nimport google.generativeai as genai\\n\\n# Use Antigravity proxy address (recommended 127.0.0.1)\\ngenai.configure(\\n    api_key=\\\"{{apiKey}}\\\",\\n    transport='rest',\\n    client_options={'api_endpoint': '{{rawBaseUrl}}'}\\n)\\n\\nmodel = genai.GenerativeModel('{{modelId}}')\\nresponse = model.generate_content(\\\"Hello\\\")\\nprint(response.text)\",\n            \"python_openai_image\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    # Option 1: use size (recommended)\\n    # Supported: \\\"1024x1024\\\" (1:1), \\\"1280x720\\\" (16:9), \\\"720x1280\\\" (9:16), \\\"1216x896\\\" (4:3)\\n    extra_body={ \\\"size\\\": \\\"1024x1024\\\" },\\n\\n    # Option 2: use model suffix\\n    # e.g. gemini-3-pro-image-16-9, gemini-3-pro-image-4-3\\n    # model=\\\"gemini-3-pro-image-16-9\\\",\\n    messages=[{\\n        \\\"role\\\": \\\"user\\\",\\n        \\\"content\\\": \\\"Draw a futuristic city\\\"\\n    }]\\n)\\n\\nprint(response.choices[0].message.content)\",\n            \"python_openai\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hello\\\"}]\\n)\\n\\nprint(response.choices[0].message.content)\"\n        },\n        \"examples\": {\n            \"title\": \"Usage Examples\"\n        },\n        \"dialog\": {\n            \"confirm_regenerate\": \"Are you sure to regenerate API Key? The old key will be invalid immediately.\",\n            \"operate_failed\": \"Operation failed: {{error}}\",\n            \"reset_mapping_title\": \"Reset Model Mapping\",\n            \"reset_mapping_msg\": \"Are you sure you want to reset all model mappings to system defaults? This action cannot be undone.\",\n            \"regenerate_key_title\": \"Regenerate API Key\",\n            \"regenerate_key_msg\": \"Are you sure you want to regenerate the API Key? The old key will be invalidated immediately.\",\n            \"clear_bindings_title\": \"Clear Session Bindings\",\n            \"clear_bindings_msg\": \"Are you sure you want to clear all session-account bindings?\",\n            \"clear_rate_limits_title\": \"Clear Rate Limit Records\",\n            \"clear_rate_limits_confirm\": \"Are you sure you want to clear all local rate limit records?\"\n        },\n        \"model\": {\n            \"flash\": \"Fast Response\",\n            \"flash_preview\": \"Flash Preview (Flash 3.1)\",\n            \"flash_lite\": \"Lite & Fast (Lite)\",\n            \"flash_thinking\": \"Thinking Capability (Thinking)\",\n            \"pro_legacy\": \"Legacy Pro\",\n            \"pro_low\": \"3.1 Pro Low\",\n            \"pro_high\": \"3.1 Pro High\",\n            \"pro_image\": \"Image Generation (1:1)\",\n            \"pro_image_16_9\": \"Image Generation (16:9)\",\n            \"pro_image_9_16\": \"Image Generation (9:16)\",\n            \"pro_image_4_3\": \"Image Generation (4:3)\",\n            \"pro_image_3_4\": \"Image Generation (3:4)\",\n            \"pro_image_1_1\": \"Image Generation (1:1)\",\n            \"claude_sonnet\": \"Code Reasoning (Claude 4.6)\",\n            \"claude_sonnet_thinking\": \"Chain of Thought (4.6 Think)\",\n            \"claude_opus_thinking\": \"Strongest Thinking (Opus Think)\",\n            \"gemini_2_5_flash\": \"Flash Model (2.5 Flash)\",\n            \"gemini_2_5_pro\": \"High Performance (2.5 Pro)\",\n            \"claude_4_6\": \"Latest Reasoning (4.6)\"\n        },\n        \"mapping\": {\n            \"title\": \"Claude Code Model Mapping\",\n            \"description\": \"Map Claude Code models to Antigravity models. Optimize cost and speed by routing requests intelligently.\",\n            \"default\": \"Default\",\n            \"sonnet_desc\": \"Most capable for complex work\",\n            \"opus_desc\": \"Premium tier\",\n            \"haiku_desc\": \"Fastest for quick answers\",\n            \"maps_to\": \"Maps to Antigravity\",\n            \"apply_recommended\": \"Apply Recommended\",\n            \"restore_defaults\": \"Restore Default Configuration\",\n            \"reset_all\": \"Reset All\"\n        },\n        \"models\": {\n            \"flash\": \"Ultra-fast\",\n            \"flash_thinking\": \"Thinking Capability\",\n            \"pro_high\": \"Best Reasoning\",\n            \"pro_low\": \"Low Quota\",\n            \"sonnet\": \"Code Reasoning\",\n            \"sonnet_thinking\": \"Chain of Thought\",\n            \"opus_thinking\": \"Strongest Thinking\"\n        },\n        \"router\": {\n            \"title\": \"Model Router\",\n            \"subtitle\": \"Route models by series or add custom exact mappings.\\nNote: Native Claude pass-through models (e.g. claude-sonnet-4-6-thinking, claude-opus-4-6-thinking) bypass series groups by default. Use \\\"Expert Custom Routing\\\" to override.\",\n            \"subtitle_simple\": \"Customize model routing with wildcards or exact mappings\",\n            \"background_task_title\": \"Background Task Model\",\n            \"background_task_desc\": \"Model used for Claude CLI background tasks like title generation, summary, etc. (Default: gemini-2.5-flash)\",\n            \"use_default\": \"Use System Default\",\n            \"current_model\": \"Current Model\",\n            \"apply_presets\": \"Apply Presets\",\n            \"presets_applied\": \"Presets applied successfully\",\n            \"preset_default\": \"Default Preset\",\n            \"preset_default_desc\": \"GPT-4 → Gemini Pro, Claude → Opus\",\n            \"preset_performance\": \"Performance First\",\n            \"preset_performance_desc\": \"Use high-performance models for all\",\n            \"preset_cost\": \"Cost Optimized\",\n            \"preset_cost_desc\": \"Prioritize cost-effective models\",\n            \"preset_balanced\": \"Balanced Mode\",\n            \"preset_balanced_desc\": \"Balance performance and cost\",\n            \"built_in_presets\": \"Built-in Presets\",\n            \"custom_presets\": \"Custom Presets\",\n            \"apply_selected\": \"Apply Selected\",\n            \"add_preset\": \"Save Current Mapping\",\n            \"delete_preset\": \"Delete Current Preset\",\n            \"cannot_delete_builtin\": \"Cannot delete built-in presets\",\n            \"no_mapping_to_save\": \"No mapping configuration to save\",\n            \"preset_name_required\": \"Preset name is required\",\n            \"preset_saved\": \"Preset saved successfully\",\n            \"manage_presets_title\": \"Manage Custom Presets\",\n            \"save_current_as_preset\": \"Save Current Configuration\",\n            \"preset_name_placeholder\": \"Enter preset name...\",\n            \"save_hint\": \"Saves the currently active model mappings as a reusable preset.\",\n            \"your_presets\": \"Your Presets\",\n            \"no_custom_presets\": \"No custom presets yet\",\n            \"mappings_count\": \"mappings\",\n            \"custom_preset_desc\": \"User defined preset\",\n            \"custom_mappings\": \"Custom Mappings\",\n            \"group_title\": \"Series Groups\",\n            \"gemini3_group_label\": \"Gemini 3 (Recommended)\",\n            \"gemini3_option_high\": \"gemini-3.1-pro-high (High Quality)\",\n            \"gemini3_option_low\": \"gemini-3.1-pro-low (Balanced)\",\n            \"gemini3_option_flash\": \"gemini-3-flash (Fast)\",\n            \"groups\": {\n                \"claude_45\": {\n                    \"name\": \"Opus 4.6 TK Series\",\n                    \"desc\": \"Opus 4.5 TK (Thinking)\"\n                },\n                \"claude_35\": {\n                    \"name\": \"Claude 3.5 Series\",\n                    \"desc\": \"Sonnet 3.5, Haiku 3.5\"\n                },\n                \"gpt_4\": {\n                    \"name\": \"GPT-4 / o1 Series\",\n                    \"desc\": \"GPT-4, Turbo, o1-preview\"\n                },\n                \"gpt_4o\": {\n                    \"name\": \"GPT-4o / 3.5 Series\",\n                    \"desc\": \"GPT-4o, Mini, 3.5 Turbo\"\n                },\n                \"gpt_5\": {\n                    \"name\": \"GPT-5 Series\",\n                    \"desc\": \"GPT-5.1, GPT-5.2 xhigh\"\n                }\n            },\n            \"expert_title\": \"Expert Custom Routing\",\n            \"expert_subtitle\": \"Precise matching for any original model ID.\",\n            \"custom_mapping_tip\": \"💡 Supports manual input of any model ID to experience unreleased models (e.g. claude-opus-4-6).\",\n            \"custom_mapping_warning\": \"Note: Not all accounts support unreleased models.\",\n            \"money_saving_tip\": \"💰 Cost-saving tip:\",\n            \"haiku_optimization_tip\": \"Claude CLI uses {{model}} for background tasks by default. Map it to a cheaper Flash model to save ~95% costs\",\n            \"haiku_optimization_btn\": \"Quick Optimize\",\n            \"haiku_tip_title\": \"💰 Cost-saving tip:\",\n            \"haiku_tip_body_before\": \"Claude CLI defaults to\",\n            \"haiku_tip_body_after\": \"for background tasks; mapping it to a cheaper Flash model can save about 95% of the cost.\",\n            \"haiku_tip_action\": \"Optimize\",\n            \"reset_confirm\": \"Reset all mappings to system defaults?\",\n            \"reset_mapping\": \"Reset Mapping\",\n            \"add_mapping\": \"Add Mapping\",\n            \"current_list\": \"Custom List\",\n            \"no_custom_mapping\": \"No custom mappings yet\",\n            \"gemini3_only_warning\": \"⚠️ Gemini 3 series only\",\n            \"default_suffix\": \" (Default)\",\n            \"original_id\": \"Original ID\",\n            \"route_to\": \"Route To\",\n            \"select_target_model\": \"Select Target Model\",\n            \"original_placeholder\": \"Original (e.g. gpt-4 or gpt-4*)\"\n        },\n        \"multi_protocol\": {\n            \"title\": \"Multi-Protocol Support\",\n            \"subtitle\": \"Seamlessly integrate with your favorite AI tools and CLIs\",\n            \"description\": \"The local proxy supports OpenAI, Anthropic, and Gemini protocols, ensuring compatibility with a wide range of applications.\",\n            \"openai_label\": \"OpenAI Protocol\",\n            \"anthropic_label\": \"Anthropic Protocol\",\n            \"openai_tools\": \"Cherry Studio, NextChat\",\n            \"anthropic_tools\": \"Claude Code CLI\",\n            \"gemini_label\": \"Gemini Protocol\",\n            \"gemini_tools\": \"Google AI SDK, LangChain\",\n            \"quick_integration\": \"Quick Integration\",\n            \"click_tip\": \"👆 Click a model to update code examples\",\n            \"copy_base\": \"Copy Base\"\n        },\n        \"supported_models\": {\n            \"title\": \"Supported Models & Integration\",\n            \"model_name\": \"Model Name\",\n            \"model_id\": \"Model ID\",\n            \"description\": \"Description\",\n            \"action\": \"Action\"\n        },\n        \"cli_sync\": {\n            \"title\": \"One-click CLI Sync\",\n            \"subtitle\": \"Quickly sync current API endpoints and keys to your local AI CLI tools.\",\n            \"card_title\": \"{{name}} Config\",\n            \"status\": {\n                \"not_installed\": \"Not detected\",\n                \"installed\": \"v{{version}}\",\n                \"synced\": \"Pointed to this app\",\n                \"not_synced\": \"Not synced\",\n                \"detecting\": \"Detecting...\",\n                \"current_base_url\": \"Current Base URL\"\n            },\n            \"model_select\": \"Select Model\",\n            \"btn_sync\": \"Sync Config Now\",\n            \"btn_view\": \"View Config\",\n            \"btn_restore\": \"Restore Defaults\",\n            \"btn_restore_backup\": \"Restore Backup\",\n            \"restore_confirm\": \"Are you sure you want to restore the configuration for {{name}} to the official default URL?\",\n            \"restore_backup_confirm\": \"Backup configuration found. Are you sure you want to restore it?\",\n            \"modal\": {\n                \"view_title\": \"{{name}} Config Content\",\n                \"copy_success\": \"Config content copied\"\n            },\n            \"toast\": {\n                \"sync_success\": \"Sync successful! {{name}} is ready.\",\n                \"sync_error\": \"Sync failed: {{error}}\"\n            },\n            \"sync_confirm_title\": \"Sync Confirmation\",\n            \"sync_confirm_message\": \"Ready to sync {{name}} configuration. ⚠️ Warning: This will overwrite your existing local configuration files (e.g. login tokens, API Keys). Are you sure you want to continue?\"\n        }\n    },\n    \"monitor\": {\n        \"page_title\": \"API Monitor Dashboard\",\n        \"page_subtitle\": \"Real-time request logging and analysis\",\n        \"open_monitor\": \"Open Monitor\",\n        \"logging_status\": {\n            \"active\": \"Recording\",\n            \"paused\": \"Paused\"\n        },\n        \"stats\": {\n            \"total\": \"Total\",\n            \"ok\": \"OK\",\n            \"err\": \"ERR\"\n        },\n        \"filters\": {\n            \"placeholder\": \"Filter by model, path, or status...\",\n            \"quick_filters\": \"Quick Filters:\",\n            \"all\": \"All\",\n            \"error\": \"Error\",\n            \"chat\": \"Chat\",\n            \"gemini\": \"Gemini\",\n            \"claude\": \"Claude\",\n            \"images\": \"Images\",\n            \"reset\": \"Reset\",\n            \"by_account\": \"Filter by account\",\n            \"all_accounts\": \"All Accounts\"\n        },\n        \"table\": {\n            \"status\": \"Status\",\n            \"method\": \"Method\",\n            \"model\": \"Model\",\n            \"protocol\": \"Protocol\",\n            \"account\": \"Account\",\n            \"path\": \"Path\",\n            \"usage\": \"Tokens\",\n            \"duration\": \"Duration\",\n            \"time\": \"Time\",\n            \"empty\": \"No requests recorded\"\n        },\n        \"details\": {\n            \"title\": \"Request Details\",\n            \"request_payload\": \"Request Payload\",\n            \"response_payload\": \"Response Payload\",\n            \"duration\": \"Duration\",\n            \"tokens\": \"Tokens (I/O)\",\n            \"time\": \"Time\",\n            \"model\": \"Model\",\n            \"mapped_model\": \"Mapped Model\",\n            \"protocol\": \"Protocol\",\n            \"account_used\": \"Account Used\",\n            \"id\": \"Request ID\",\n            \"payload_empty\": \"No data\"\n        },\n        \"dialog\": {\n            \"clear_title\": \"Clear Proxy Logs\",\n            \"clear_msg\": \"Are you sure you want to clear all proxy logs? This action cannot be undone.\"\n        },\n        \"network\": {\n            \"title\": \"Network Monitor\",\n            \"open\": \"Open Network Monitor\",\n            \"requests_count\": \"{{count}} requests\",\n            \"start_recording\": \"Start Recording\",\n            \"stop_recording\": \"Stop Recording\",\n            \"clear_requests\": \"Clear Requests\",\n            \"empty\": \"No requests recorded\",\n            \"waiting\": \"Waiting for response...\",\n            \"badge_error\": \"Error\",\n            \"table\": {\n                \"status\": \"Status\",\n                \"command\": \"Command\",\n                \"time\": \"Time\",\n                \"duration\": \"Duration\"\n            },\n            \"sections\": {\n                \"general\": \"General\",\n                \"request_args\": \"Request Args\",\n                \"error_details\": \"Error Details\",\n                \"response\": \"Response\"\n            },\n            \"fields\": {\n                \"status\": \"Status\",\n                \"start_time\": \"Start Time\",\n                \"duration\": \"Duration\"\n            }\n        }\n    },\n    \"update_notification\": {\n        \"title\": \"Updating...\",\n        \"message\": \"A new version is ready with optimizations and improvements. Current: v{{current}}\",\n        \"ready\": \"Update Ready!\",\n        \"downloading\": \"Downloading update in background...\",\n        \"restarting\": \"Restarting application...\",\n        \"auto_update\": \"Auto Update\",\n        \"restart_prompt\": \"Update downloaded and ready to install. Restart now?\",\n        \"btn_restart\": \"Restart\",\n        \"btn_later\": \"Later\",\n        \"toast\": {\n            \"not_ready\": \"Update artifacts are not ready yet. Will retry later.\",\n            \"failed\": \"Auto-update failed\"\n        }\n    },\n    \"login\": {\n        \"title\": \"Secure Access Control\",\n        \"desc\": \"Running in Web mode. Please enter management password or API Key to access.\",\n        \"placeholder\": \"Enter management password or API Key\",\n        \"btn_login\": \"Verify and Enter\",\n        \"btn_verifying\": \"Verifying...\",\n        \"error_invalid_key\": \"Invalid password or API Key, please try again\",\n        \"error_network\": \"Network connection failed, please check if the service is running\",\n        \"note\": \"Note: If a separate management password is set, please enter it; otherwise, enter API_KEY.\",\n        \"lookup_hint\": \"If forgotten, run docker logs antigravity-manager to find Current API Key or Web UI Password\",\n        \"config_hint\": \"Or run grep -E '\\\"api_key\\\"|\\\"admin_password\\\"' ~/.antigravity_tools/gui_config.json to view.\"\n    },\n    \"token_stats\": {\n        \"title\": \"Token Consumption Stats\",\n        \"hourly\": \"Hour\",\n        \"daily\": \"Day\",\n        \"weekly\": \"Week\",\n        \"total_tokens\": \"Total Tokens\",\n        \"input_tokens\": \"Input Tokens\",\n        \"output_tokens\": \"Output Tokens\",\n        \"accounts_used\": \"Active Accounts\",\n        \"models_used\": \"Models Used\",\n        \"model_trend\": \"Model Usage Trend\",\n        \"account_trend\": \"Account Usage Trend\",\n        \"usage_trend\": \"Token Usage Trend\",\n        \"by_account\": \"By Account\",\n        \"by_model\": \"By Model\",\n        \"by_account_view\": \"By Account\",\n        \"model_details\": \"Model Breakdown\",\n        \"account_details\": \"Account Breakdown\",\n        \"model\": \"Model\",\n        \"account\": \"Account\",\n        \"requests\": \"Requests\",\n        \"input\": \"Input\",\n        \"output\": \"Output\",\n        \"total\": \"Total\",\n        \"percentage\": \"Share\",\n        \"no_data\": \"No data available\"\n    },\n    \"errors\": {\n        \"stream\": {\n            \"timeout_error\": \"Request timeout, please check your network connection\",\n            \"connection_error\": \"Connection failed, please check your network or proxy settings\",\n            \"decode_error\": \"Network unstable, data transmission interrupted. Try: 1) Check network 2) Switch proxy 3) Retry\",\n            \"stream_error\": \"Stream transmission error, please retry later\",\n            \"unknown_error\": \"Unknown error occurred, please retry later\"\n        }\n    },\n    \"security\": {\n        \"title\": \"Security Monitor\",\n        \"refresh_data\": \"Refresh Data\",\n        \"refresh\": \"Refresh\",\n        \"tab_logs\": \"Access Logs\",\n        \"tab_stats\": \"Statistics\",\n        \"tab_blacklist\": \"Blacklist\",\n        \"tab_whitelist\": \"Whitelist\",\n        \"tab_config\": \"Security Config\",\n        \"stats\": {\n            \"total_requests\": \"Total Requests\",\n            \"total_requests_desc\": \"All recorded requests\",\n            \"unique_ips\": \"Unique IPs\",\n            \"unique_ips_desc\": \"Distinct client IP addresses\",\n            \"blocked_requests\": \"Blocked Requests\",\n            \"blocked_requests_desc\": \"Requests rejected by rules\",\n            \"ip_activity_token_usage\": \"IP Activity & Token Usage\",\n            \"hour\": \"Hr\",\n            \"day\": \"Day\",\n            \"week\": \"Wk\",\n            \"month\": \"Mo\",\n            \"rank\": \"Rank\",\n            \"ip_address\": \"IP Address\",\n            \"activity_reqs\": \"Activity (Reqs)\",\n            \"total_token\": \"Total Token\",\n            \"prompt\": \"Prompt\",\n            \"completion\": \"Completion\",\n            \"no_data\": \"No Data\"\n        },\n        \"logs\": {\n            \"search_placeholder\": \"Search IP, Path, User Agent...\",\n            \"username\": \"User\",\n            \"show_blocked_only\": \"Show Blocked Only\",\n            \"status\": \"Status\",\n            \"ip_address\": \"IP Address\",\n            \"method\": \"Method\",\n            \"path\": \"Path\",\n            \"duration\": \"Duration\",\n            \"time\": \"Time\",\n            \"reason\": \"Reason\",\n            \"blocked\": \"Blocked\",\n            \"no_logs\": \"No logs available\",\n            \"total_records\": \"Total {{total}} records\",\n            \"prev_page\": \"Previous\",\n            \"next_page\": \"Next\",\n            \"page_num\": \"Page {{page}}\",\n            \"per_page_suffix\": \"/page\"\n        },\n        \"blacklist\": {\n            \"add_ip\": \"Add IP\",\n            \"search_placeholder\": \"Search...\",\n            \"added_at\": \"Added At\",\n            \"expires_at\": \"Expires At\",\n            \"no_data\": \"No blacklist data\",\n            \"add_title\": \"Add to Blacklist\",\n            \"ip_cidr_label\": \"IP Address or CIDR\",\n            \"ip_cidr_placeholder\": \"e.g. 192.168.1.1 or 10.0.0.0/24\",\n            \"reason_label\": \"Reason (Optional)\",\n            \"reason_placeholder\": \"e.g. Malicious scanning\",\n            \"expires_label\": \"Expire Time (Hours, Optional)\",\n            \"expires_placeholder\": \"Leave empty for permanent\",\n            \"cancel\": \"Cancel\",\n            \"confirm\": \"Add\",\n            \"add_btn\": \"Add\"\n        },\n        \"whitelist\": {\n            \"add_ip\": \"Add Trusted IP\",\n            \"no_data\": \"No whitelist data\",\n            \"add_title\": \"Add to Whitelist\",\n            \"description_label\": \"Description (Optional)\",\n            \"description_placeholder\": \"e.g. Internal Server\",\n            \"cancel\": \"Cancel\",\n            \"confirm\": \"Add\",\n            \"add_btn\": \"Add\"\n        },\n        \"config\": {\n            \"title\": \"Security Settings\",\n            \"save\": \"Save Changes\",\n            \"saving\": \"Saving...\",\n            \"blacklist_title\": \"IP Blacklist\",\n            \"blacklist_desc\": \"Manage blocked IP addresses and rules.\",\n            \"enable_blacklist\": \"Enable Blacklist Protection\",\n            \"block_msg_label\": \"Custom Block Message\",\n            \"block_msg_desc\": \"Response content returned to blocked clients.\",\n            \"whitelist_title\": \"IP Whitelist\",\n            \"whitelist_desc\": \"Manage trusted IP addresses.\",\n            \"enable_whitelist\": \"Enable Whitelist Mode\",\n            \"whitelist_warning\": \"Warning: Enabling whitelist mode will block ALL requests from IPs not in the whitelist. If you access via proxy, be careful not to lock yourself out.\",\n            \"whitelist_priority\": \"Whitelist Priority (Overrides Blacklist)\",\n            \"whitelist_priority_desc\": \"If enabled, whitelisted IPs will be allowed even if they match blacklist rules.\",\n            \"load_error\": \"Failed to load configuration\",\n            \"save_success\": \"Configuration saved\",\n            \"save_error\": \"Failed to save configuration\"\n        }\n    },\n    \"user_token\": {\n        \"title\": \"User Tokens\",\n        \"total_users\": \"Total Users\",\n        \"active_tokens\": \"Active Tokens\",\n        \"total_created\": \"Total Created\",\n        \"create\": \"Create Token\",\n        \"username\": \"Username\",\n        \"token\": \"Token\",\n        \"expires\": \"Expires\",\n        \"usage\": \"Usage\",\n        \"ip_limit\": \"IP Limit\",\n        \"created\": \"Created\",\n        \"today_requests\": \"Today Requests\",\n        \"never\": \"Never\",\n        \"renew\": \"Renew\",\n        \"renew_button\": \"Renew\",\n        \"unlimited\": \"Unlimited\",\n        \"create_title\": \"Create New Token\",\n        \"description\": \"Description\",\n        \"curfew\": \"Curfew (Service Unavailable Time)\",\n        \"edit_title\": \"Edit Token\",\n        \"username_required\": \"Username is required\",\n        \"renew_success\": \"Renewed successfully\",\n        \"expires_day\": \"1 Day\",\n        \"expires_week\": \"1 Week\",\n        \"expires_month\": \"1 Month\",\n        \"expires_never\": \"Never\",\n        \"no_data\": \"No tokens found\",\n        \"placeholder_username\": \"e.g. user1\",\n        \"placeholder_desc\": \"Optional notes\",\n        \"placeholder_max_ips\": \"0 = Unlimited\",\n        \"hint_max_ips\": \"0 = Unlimited\",\n        \"hint_curfew\": \"Leave empty to disable. Based on server time.\"\n    }\n}"
  },
  {
    "path": "src/locales/es.json",
    "content": "{\n    \"common\": {\n        \"loading\": \"Cargando...\",\n        \"load_more\": \"Cargar más\",\n        \"add\": \"Agregar\",\n        \"copy\": \"Copiar\",\n        \"action\": \"Acción\",\n        \"save\": \"Guardar\",\n        \"saved\": \"Guardado exitosamente\",\n        \"cancel\": \"Cancelar\",\n        \"confirm\": \"Confirmar\",\n        \"close\": \"Cerrar\",\n        \"delete\": \"Eliminar\",\n        \"edit\": \"Editar\",\n        \"refresh\": \"Actualizar\",\n        \"refreshing\": \"Actualizando...\",\n        \"export\": \"Exportar\",\n        \"import\": \"Importar\",\n        \"success\": \"Éxito\",\n        \"error\": \"Error\",\n        \"unknown\": \"Desconocido\",\n        \"warning\": \"Advertencia\",\n        \"info\": \"Información\",\n        \"details\": \"Detalles\",\n        \"clear\": \"Limpiar\",\n        \"clearing\": \"Limpiando...\",\n        \"prev_page\": \"Anterior\",\n        \"next_page\": \"Siguiente\",\n        \"pagination_info\": \"Mostrando {{start}} a {{end}} de {{total}} entradas\",\n        \"per_page\": \"Por página\",\n        \"items\": \"elementos\",\n        \"accounts\": \"cuentas\",\n        \"enabled\": \"Habilitado\",\n        \"disabled\": \"Deshabilitado\",\n        \"tauri_api_not_loaded\": \"API de Tauri no cargada, por favor reinicie la aplicación\",\n        \"environment_error\": \"Error de entorno: {{error}}\",\n        \"submit\": \"Enviar\",\n        \"update\": \"Actualizar\",\n        \"load_failed\": \"Error al cargar\",\n        \"create_success\": \"Creado exitosamente\",\n        \"update_success\": \"Actualizado exitosamente\",\n        \"delete_success\": \"Eliminado exitosamente\",\n        \"copied\": \"Copiado al portapapeles\"\n    },\n    \"nav\": {\n        \"dashboard\": \"Panel\",\n        \"accounts\": \"Cuentas\",\n        \"proxy\": \"Proxy API\",\n        \"call_records\": \"Registros de Tráfico\",\n        \"token_stats\": \"Estadísticas de Tokens\",\n        \"settings\": \"Configuración\",\n        \"theme_to_dark\": \"Cambiar a Modo Oscuro\",\n        \"theme_to_light\": \"Cambiar a Modo Claro\",\n        \"switch_to_english\": \"Cambiar a Inglés\",\n        \"switch_to_chinese\": \"Cambiar a Chino\",\n        \"switch_to_traditional_chinese\": \"Cambiar a Chino Tradicional\",\n        \"switch_to_english_short\": \"EN\",\n        \"switch_to_chinese_short\": \"ZH\",\n        \"switch_to_traditional_chinese_short\": \"TW\",\n        \"switch_to_japanese\": \"Cambiar a Japonés\",\n        \"switch_to_japanese_short\": \"JA\",\n        \"switch_to_turkish\": \"Cambiar a Turco\",\n        \"switch_to_turkish_short\": \"TR\",\n        \"switch_to_vietnamese\": \"Cambiar a Vietnamita\",\n        \"switch_to_vietnamese_short\": \"VI\",\n        \"switch_to_russian\": \"Cambiar a Ruso\",\n        \"switch_to_russian_short\": \"RU\",\n        \"switch_to_portuguese\": \"Cambiar a Portugués\",\n        \"switch_to_portuguese_short\": \"PT\",\n        \"switch_to_korean\": \"Cambiar a Coreano\",\n        \"switch_to_korean_short\": \"KO\",\n        \"switch_to_spanish\": \"Cambiar a Español\",\n        \"switch_to_spanish_short\": \"ES\",\n        \"switch_to_malay\": \"Cambiar a Malayo\",\n        \"switch_to_malay_short\": \"MY\",\n        \"user_token\": \"Tokens de usuario\"\n    },\n    \"dashboard\": {\n        \"hello\": \"Hola, Usuario 👋\",\n        \"refresh_quota\": \"Actualizar Cuota\",\n        \"refreshing\": \"Actualizando...\",\n        \"total_accounts\": \"Total de Cuentas\",\n        \"avg_gemini\": \"Cuota Promedio Gemini\",\n        \"avg_gemini_image\": \"Cuota Promedio Imagen Gemini\",\n        \"avg_claude\": \"Cuota Promedio Claude\",\n        \"low_quota_accounts\": \"Cuentas con Cuota Baja\",\n        \"quota_sufficient\": \"Cuota Suficiente\",\n        \"quota_low\": \"Cuota Baja\",\n        \"quota_desc\": \"Cuota < 20%\",\n        \"current_account\": \"Cuenta Actual\",\n        \"switch_account\": \"Cambiar Cuenta\",\n        \"no_active_account\": \"Sin Cuenta Activa\",\n        \"best_accounts\": \"Mejores Cuentas\",\n        \"best_account_recommendation\": \"Mejor Cuenta\",\n        \"switch_best\": \"Cambiar a la Mejor\",\n        \"switch_successfully\": \"Cambiar a la Mejor\",\n        \"view_all_accounts\": \"Ver Todas las Cuentas\",\n        \"export_data\": \"Exportar Datos\",\n        \"for_gemini\": \"Para Gemini\",\n        \"for_claude\": \"Para Claude\",\n        \"toast\": {\n            \"switch_success\": \"¡Cambio exitoso!\",\n            \"switch_error\": \"Error al cambiar cuenta\",\n            \"refresh_success\": \"Actualización de cuota exitosa\",\n            \"refresh_error\": \"Error al actualizar\",\n            \"export_no_accounts\": \"No hay cuentas para exportar\",\n            \"export_success\": \"¡Exportación exitosa! Archivo guardado en: {{path}}\",\n            \"export_error\": \"Error al exportar\"\n        }\n    },\n    \"accounts\": {\n        \"account\": \"Cuenta\",\n        \"search_placeholder\": \"Buscar correo...\",\n        \"all\": \"Todas\",\n        \"available\": \"Disponibles\",\n        \"low_quota\": \"Cuota Baja\",\n        \"ultra\": \"ULTRA\",\n        \"pro\": \"PRO\",\n        \"free\": \"GRATIS\",\n        \"edit_label\": \"Editar etiqueta\",\n        \"custom_label_placeholder\": \"Ingrese etiqueta personalizada\",\n        \"label_updated\": \"Etiqueta actualizada\",\n        \"add_account\": \"Agregar Cuenta\",\n        \"refresh_all\": \"Actualizar Todas\",\n        \"refresh_selected\": \"Actualizar ({{count}})\",\n        \"export_selected\": \"Exportar ({{count}})\",\n        \"import_json\": \"Importar\",\n        \"import_success\": \"Se importaron {{count}} cuentas exitosamente\",\n        \"import_partial\": \"Importación completada: {{success}} exitosas, {{fail}} fallidas\",\n        \"import_fail\": \"Error de importación: {{error}}\",\n        \"import_invalid_format\": \"Formato JSON inválido, asegúrese de que el archivo contenga los campos email y refresh_token\",\n        \"delete_selected\": \"Eliminar ({{count}})\",\n        \"current\": \"Actual\",\n        \"current_badge\": \"Actual\",\n        \"disabled\": \"Deshabilitada\",\n        \"disabled_tooltip\": \"Cuenta deshabilitada (ej. refresh_token revocado/expirado). Reautorizar o actualizar token para reactivar.\",\n        \"proxy_disabled\": \"Proxy Deshabilitado\",\n        \"proxy_disabled_tooltip\": \"Esta cuenta tiene el proxy deshabilitado manualmente, no manejará solicitudes API pero permanece utilizable en la app.\",\n        \"enable_proxy\": \"Habilitar Proxy\",\n        \"disable_proxy\": \"Deshabilitar Proxy\",\n        \"enable_proxy_selected\": \"Habilitar ({{count}})\",\n        \"disable_proxy_selected\": \"Deshabilitar ({{count}})\",\n        \"proxy_disabled_reason_manual\": \"Deshabilitado manualmente por el usuario\",\n        \"proxy_disabled_reason_batch\": \"Deshabilitado en lote\",\n        \"forbidden\": \"403\",\n        \"forbidden_badge\": \"403\",\n        \"forbidden_tooltip\": \"API devolvió 403 Prohibido, la cuenta no tiene permiso para Gemini Code Assist\",\n        \"forbidden_msg\": \"Prohibido, saltar actualización automática\",\n        \"status\": {\n            \"forbidden\": \"403 Prohibido\",\n            \"disabled\": \"Cuenta deshabilitada\",\n            \"proxy_disabled\": \"Proxy deshabilitado\"\n        },\n        \"error_details\": \"Detalles del error\",\n        \"error_status\": \"Estado del error\",\n        \"error_time\": \"Hora de detección\",\n        \"view_error\": \"Ver motivo\",\n        \"click_to_verify\": \"Haga clic para verificar\",\n        \"no_data\": \"Sin Datos\",\n        \"last_used\": \"Último Uso\",\n        \"reset_time\": \"Tiempo de Reinicio\",\n        \"switch_to\": \"Cambiar a esta cuenta\",\n        \"actions\": \"Acciones\",\n        \"device_fingerprint\": \"Huella Digital del Dispositivo\",\n        \"show_all_quotas\": \"Mostrar todas las cuotas\",\n        \"device_fingerprint_dialog\": {\n            \"title\": \"Huella Digital del Dispositivo\",\n            \"operations\": \"Operaciones de Huella Digital\",\n            \"generate_and_bind\": \"Generar y Vincular\",\n            \"restore_original\": \"Restaurar Original\",\n            \"open_storage_directory\": \"Abrir Directorio de Almacenamiento\",\n            \"current_storage\": \"Almacenamiento Actual\",\n            \"effective\": \"Efectivo\",\n            \"current_storage_desc\": \"Leído de storage.json (actualizado después de aplicar vinculación al cambiar cuentas)\",\n            \"account_binding\": \"Vinculación de Cuenta\",\n            \"pending_application\": \"Aplicación Pendiente\",\n            \"account_binding_desc\": \"Guardado como vinculación después de generación/restauración, escrito en storage.json al cambiar cuentas\",\n            \"historical_fingerprints\": \"Huellas Históricas (restaurar/eliminar opcional)\",\n            \"no_history\": \"Sin Historial\",\n            \"current\": \"Actual\",\n            \"restore\": \"Restaurar\",\n            \"delete_version\": \"Eliminar esta versión\",\n            \"confirm_generate_title\": \"¿Confirmar generar y vincular?\",\n            \"confirm_generate_desc\": \"Se generará un nuevo conjunto de huellas digitales y se establecerá como huella actual. ¿Confirmar continuar?\",\n            \"confirm_restore_title\": \"¿Confirmar restaurar huella original?\",\n            \"confirm_restore_desc\": \"Se restaurará a la huella original y sobrescribirá la huella actual. ¿Confirmar continuar?\",\n            \"cancel\": \"Cancelar\",\n            \"confirm\": \"Confirmar\",\n            \"processing\": \"Procesando...\",\n            \"loading\": \"Cargando...\",\n            \"failed_to_load_device_info\": \"Error al cargar información del dispositivo\",\n            \"generation_failed\": \"Error en generación\",\n            \"binding_failed\": \"Error en vinculación\",\n            \"restoration_failed\": \"Error en restauración\",\n            \"deletion_failed\": \"Error en eliminación\",\n            \"directory_open_failed\": \"No se puede abrir el directorio\",\n            \"generated_and_bound\": \"Generado y vinculado\",\n            \"restored\": \"Restaurado\",\n            \"deleted\": \"Eliminado\",\n            \"directory_opened\": \"Directorio de almacenamiento abierto\",\n            \"original_fingerprint_not_found\": \"Huella original no encontrada\"\n        },\n        \"warmup_all\": \"Calentamiento con Un Clic\",\n        \"warmup_selected\": \"Calentar ({{count}})\",\n        \"warmup_this\": \"Calentar esta cuenta\",\n        \"warmup_now\": \"Calentar Ahora\",\n        \"warmup_batch_triggered\": \"Tareas de calentamiento activadas para {{count}} cuentas\",\n        \"quota_protected\": \"Protegida\",\n        \"details\": {\n            \"title\": \"Detalles de Cuota\",\n            \"model_quota\": \"Cuota de Modelo\",\n            \"protected_models\": \"Modelos Protegidos\"\n        },\n        \"toast\": {\n            \"proxy_enabled\": \"Proxy habilitado para {{count}} cuentas\",\n            \"proxy_disabled\": \"Proxy deshabilitado para {{count}} cuentas\"\n        },\n        \"add\": {\n            \"title\": \"Agregar Cuenta\",\n            \"tabs\": {\n                \"oauth\": \"OAuth\",\n                \"token\": \"Token de Actualización\",\n                \"import\": \"Importar DB\"\n            },\n            \"oauth\": {\n                \"recommend\": \"Recomendado\",\n                \"desc\": \"Abre el navegador predeterminado para iniciar sesión en Google y obtener y guardar el Token automáticamente.\",\n                \"btn_start\": \"Iniciar OAuth\",\n                \"btn_waiting\": \"Esperando autorización...\",\n                \"btn_finish\": \"Ya autoricé\",\n                \"copy_link\": \"Copiar Enlace de Autorización\",\n                \"copied\": \"Copiado\",\n                \"link_label\": \"URL de Autorización\",\n                \"link_click_to_copy\": \"Clic para copiar\",\n                \"manual_hint\": \"¿El navegador no redirigió? Pegue la URL de callback completa o el código aquí:\",\n                \"manual_placeholder\": \"Pegar URL de callback o código...\",\n                \"error_no_flow\": \"Por favor haga clic en 'Iniciar OAuth' primero\",\n                \"web_hint\": \"La página de inicio de sesión de Google se abrirá en una nueva ventana\",\n                \"error_no_url\": \"No se pudo obtener la URL de OAuth\",\n                \"popup_blocked\": \"Ventana emergente bloqueada\",\n                \"manual_submitting\": \"Enviando código de autorización...\",\n                \"manual_submitted\": \"Código de autorización enviado, procesando en segundo plano...\"\n            },\n            \"token\": {\n                \"label\": \"Token de Actualización\",\n                \"placeholder\": \"Pegue su Token de Actualización aquí (Soporte por lotes)\\n\\nFormatos soportados:\\n1. Token único (1//...)\\n2. Array JSON (con campo refresh_token)\\n3. Cualquier texto que contenga tokens (Extracción automática)\",\n                \"hint\": \"Consejo: Puede pegar múltiples tokens o un array JSON para importar en lote.\",\n                \"error_token\": \"Por favor ingrese el Token de Actualización\",\n                \"batch_progress\": \"Importando {{current}}/{{total}} cuentas...\",\n                \"batch_success\": \"Se importaron {{count}} cuentas exitosamente\",\n                \"batch_partial\": \"Importación finalizada: {{success}} exitosas, {{fail}} fallidas\",\n                \"batch_fail\": \"Error de importación\"\n            },\n            \"import\": {\n                \"scheme_a\": \"Plan A: Desde DB del IDE\",\n                \"scheme_a_desc\": \"Leer automáticamente la cuenta actual desde la DB local de Antigravity.\",\n                \"btn_db\": \"Importar Cuenta Actual\",\n                \"or\": \"O\",\n                \"scheme_b\": \"Plan B: Desde Respaldo V1\",\n                \"scheme_b_desc\": \"Escanear ~/.antigravity-agent para datos de cuentas V1.\",\n                \"btn_v1\": \"Importar Lote V1\",\n                \"btn_custom_db\": \"Importar DB Personalizada\"\n            },\n            \"btn_cancel\": \"Cancelar\",\n            \"btn_confirm\": \"Confirmar\",\n            \"oauth_error\": \"Error de OAuth: {{error}}\",\n            \"status\": {\n                \"error_token\": \"Por favor ingrese el Token de Actualización\"\n            }\n        },\n        \"table\": {\n            \"email\": \"Correo\",\n            \"quota\": \"Cuota del Modelo\",\n            \"last_used\": \"Último Uso\",\n            \"actions\": \"Acciones\"\n        },\n        \"drag_to_reorder\": \"Arrastrar para reordenar\",\n        \"empty\": {\n            \"title\": \"Sin Cuentas\",\n            \"desc\": \"Haga clic en el botón \\\"Agregar Cuenta\\\" arriba para agregar su primera cuenta\"\n        },\n        \"views\": {\n            \"list\": \"Vista de Lista\",\n            \"grid\": \"Vista de Cuadrícula\"\n        },\n        \"dialog\": {\n            \"add_title\": \"Agregar Cuenta\",\n            \"batch_delete_title\": \"Confirmación de Eliminación en Lote\",\n            \"delete_title\": \"Confirmación de Eliminación\",\n            \"batch_delete_msg\": \"¿Está seguro de que desea eliminar las {{count}} cuentas seleccionadas? Esta acción no se puede deshacer.\",\n            \"delete_msg\": \"¿Está seguro de que desea eliminar esta cuenta? Esta acción no se puede deshacer.\",\n            \"refresh_title\": \"Actualizar Cuota\",\n            \"batch_refresh_title\": \"Actualización en Lote\",\n            \"refresh_msg\": \"¿Está seguro de que desea actualizar la cuota de la cuenta actual?\",\n            \"batch_refresh_msg\": \"¿Está seguro de que desea actualizar las cuotas de las {{count}} cuentas seleccionadas? Esto puede tomar tiempo.\",\n            \"disable_proxy_title\": \"Deshabilitar Proxy\",\n            \"disable_proxy_msg\": \"¿Está seguro de que desea deshabilitar el proxy para esta cuenta? La cuenta seguirá siendo utilizable en la app.\",\n            \"enable_proxy_title\": \"Habilitar Proxy\",\n            \"enable_proxy_msg\": \"¿Está seguro de que desea volver a habilitar el proxy para esta cuenta?\",\n            \"warmup_all_title\": \"Calentamiento Manual Completo\",\n            \"warmup_all_msg\": \"¿Está seguro de que desea activar tareas de calentamiento para todas las cuentas elegibles inmediatamente? Esto enviará tráfico mínimo a los servicios de Google para reiniciar los ciclos de cuota.\",\n            \"batch_warmup_title\": \"Calentamiento Manual en Lote\",\n            \"batch_warmup_msg\": \"¿Está seguro de que desea activar el calentamiento para las {{count}} cuentas seleccionadas inmediatamente?\"\n        }\n    },\n    \"settings\": {\n        \"save\": \"Guardar Configuración\",\n        \"tabs\": {\n            \"general\": \"General\",\n            \"account\": \"Cuenta\",\n            \"proxy\": \"Configuración de Proxy\",\n            \"advanced\": \"Avanzado\",\n            \"about\": \"Acerca de\",\n            \"debug\": \"Depuración\"\n        },\n        \"general\": {\n            \"title\": \"Configuración General\",\n            \"language\": \"Idioma\",\n            \"theme\": \"Tema\",\n            \"theme_light\": \"Claro\",\n            \"theme_dark\": \"Oscuro\",\n            \"theme_system\": \"Sistema\",\n            \"auto_launch\": \"Iniciar al Arrancar\",\n            \"auto_launch_enabled\": \"Habilitado\",\n            \"auto_launch_disabled\": \"Deshabilitado\",\n            \"auto_launch_desc\": \"Iniciar automáticamente Antigravity Tools al arrancar el sistema\",\n            \"auto_check_update\": \"Verificar Actualizaciones Automáticamente\",\n            \"auto_check_update_desc\": \"Verificar automáticamente nuevas versiones al iniciar\",\n            \"auto_check_update_enabled\": \"Verificación automática habilitada\",\n            \"auto_check_update_disabled\": \"Verificación automática deshabilitada\",\n            \"update_check_interval\": \"Intervalo de Verificación (horas)\",\n            \"update_check_interval_desc\": \"Establecer intervalo de verificación automática (1-168 horas)\",\n            \"update_check_interval_saved\": \"Configuración de intervalo de verificación guardada\"\n        },\n        \"account\": {\n            \"title\": \"Configuración de Cuenta\",\n            \"auto_refresh\": \"Actualización Automática en Segundo Plano\",\n            \"auto_refresh_desc\": \"Actualizar automáticamente las cuotas de todas las cuentas en segundo plano. Esto es necesario para la protección de cuota y calentamiento inteligente.\",\n            \"always_on\": \"Siempre Activo\",\n            \"refresh_interval\": \"Intervalo de Actualización (minutos)\",\n            \"auto_sync\": \"Sincronización Automática de Cuenta Actual\",\n            \"auto_sync_desc\": \"Sincronizar automáticamente la información de la cuenta activa periódicamente\",\n            \"sync_interval\": \"Intervalo de Sincronización (segundos)\"\n        },\n        \"warmup\": {\n            \"title\": \"Calentamiento Inteligente\",\n            \"desc\": \"Monitorea automáticamente todos los modelos y activa el calentamiento inmediatamente cuando la cuota alcanza el 100%, manteniendo los modelos activos\"\n        },\n        \"quota_protection\": {\n            \"title\": \"Protección de Cuota\",\n            \"enable\": \"Habilitar Protección de Cuota\",\n            \"enable_desc\": \"Deshabilitar automáticamente el proxy cuando la cuota de la cuenta cae por debajo del umbral, y restaurar automáticamente cuando la cuota se reinicie\",\n            \"threshold_label\": \"Porcentaje de Cuota Reservada\",\n            \"monitored_models_label\": \"Modelos Monitoreados (Condiciones de Activación)\",\n            \"monitored_models_desc\": \"Seleccione al menos uno. La protección se activa si CUALQUIER modelo seleccionado cae por debajo del umbral\",\n            \"range\": \"Rango\",\n            \"example\": \"Ejemplo: Al {{percentage}}%, una cuenta con {{total}} de cuota será deshabilitada cuando el restante ≤ {{threshold}}\",\n            \"auto_restore_info\": \"La cuenta se volverá a habilitar automáticamente cuando la cuota se reinicie\"\n        },\n        \"pinned_quota_models\": {\n            \"title\": \"Modelos de Cuota Fijados\",\n            \"desc\": \"Elija qué cuotas de modelo mostrar en la lista de cuentas. Los modelos no seleccionados solo se muestran en el popup de detalles.\"\n        },\n        \"proxy\": {\n            \"title\": \"Configuración de Proxy\"\n        },\n        \"proxy_pool\": {\n            \"title\": \"Proxy Pool\",\n            \"strategy_priority\": \"Priority\",\n            \"strategy_round_robin\": \"Round Robin\",\n            \"strategy_random\": \"Random\",\n            \"strategy_least_connections\": \"Least Connections\",\n            \"test_all\": \"Test All\",\n            \"batch_import\": \"Import\",\n            \"binding_manager\": \"Bindings\",\n            \"add_proxy\": \"Add Proxy\",\n            \"edit_proxy\": \"Edit Proxy\",\n            \"name\": \"Name\",\n            \"url\": \"Proxy URL\",\n            \"username\": \"Username\",\n            \"password\": \"Password\",\n            \"max_accounts\": \"Max Accounts\",\n            \"max_accounts_hint\": \"0 = Unlimited\",\n            \"priority\": \"Priority\",\n            \"priority_hint\": \"Lower is better\",\n            \"health_check_url\": \"Health Check URL\",\n            \"tags\": \"Tags\",\n            \"add_tag_placeholder\": \"Add tag...\",\n            \"seconds\": \"Sec\",\n            \"test_completed\": \"Health check completed\",\n            \"test_failed\": \"Health check failed\",\n            \"confirm_delete\": \"Are you sure you want to delete this proxy?\",\n            \"empty\": \"No proxies available\",\n            \"column_priority\": \"Priority\",\n            \"column_status\": \"Status\",\n            \"column_details\": \"Proxy Details\",\n            \"column_bindings\": \"Bindings\",\n            \"import_title\": \"Batch Import Proxies\",\n            \"import_label\": \"Paste Proxy List (One per line)\",\n            \"import_hint\": \"Supported formats: protocol://user:pass@host:port, host:port:user:pass\",\n            \"import_preview\": \"Preview\",\n            \"import_confirm\": \"Import {{count}} Proxies\",\n            \"no_valid_proxies\": \"No valid proxies found\",\n            \"binding\": {\n                \"title\": \"Account Proxy Bindings\",\n                \"load_failed\": \"Failed to load bindings\",\n                \"unbind_success\": \"Unbound successfully\",\n                \"bind_success\": \"Bound successfully\",\n                \"update_failed\": \"Failed to update binding\",\n                \"assigned_proxy\": \"Assigned Proxy\",\n                \"default_strategy\": \"Default (Follow Strategy)\"\n            },\n            \"status\": {\n                \"inactive\": \"Inactive\",\n                \"checking\": \"Checking\",\n                \"healthy\": \"Healthy\",\n                \"timeout\": \"Timeout\"\n            }\n        },\n        \"advanced\": {\n            \"title\": \"Configuración Avanzada\",\n            \"export_path\": \"Ruta de Exportación Predeterminada\",\n            \"export_path_placeholder\": \"No establecido (Preguntar cada vez)\",\n            \"default_export_path_desc\": \"Los archivos se guardarán directamente en esta carpeta sin preguntar\",\n            \"select_btn\": \"Seleccionar\",\n            \"open_btn\": \"Abrir\",\n            \"data_dir\": \"Directorio de Datos\",\n            \"data_dir_desc\": \"Ubicación de datos de cuenta y archivo de configuración\",\n            \"antigravity_path\": \"Ruta de Antigravity\",\n            \"antigravity_path_placeholder\": \"No establecido (Usará detección automática)\",\n            \"antigravity_path_desc\": \"Si instaló Antigravity en una ubicación no estándar, puede especificar manualmente la ruta del ejecutable aquí (Apunta a .app en MacOS).\",\n            \"antigravity_path_select\": \"Seleccionar Ejecutable de Antigravity\",\n            \"antigravity_path_detected\": \"Ruta detectada actualizada\",\n            \"detect_btn\": \"Detectar\",\n            \"antigravity_args\": \"Argumentos de Inicio de Antigravity\",\n            \"antigravity_args_placeholder\": \"--user-data-dir=/ruta/a/datos --otra-bandera\",\n            \"antigravity_args_desc\": \"Especifique argumentos de inicio para Antigravity, ej. --user-data-dir para especificar el directorio de datos del usuario\",\n            \"detect_args_btn\": \"Detectar\",\n            \"antigravity_args_detected\": \"Argumentos de inicio actualizados\",\n            \"antigravity_args_detect_error\": \"Error al detectar argumentos de inicio\",\n            \"accounts_page_size\": \"Tamaño de Página de Cuentas\",\n            \"page_size_auto\": \"Cálculo Automático (Recomendado)\",\n            \"page_size_desc\": \"Establecer el número de cuentas mostradas por página. Seleccione 'Cálculo Automático' para ajustar dinámicamente según el tamaño de la ventana.\",\n            \"logs_title\": \"Mantenimiento de Registros\",\n            \"logs_desc\": \"Limpiar archivos de caché de registros. No afecta los datos de cuenta.\",\n            \"clear_logs\": \"Limpiar Caché de Registros\",\n            \"clear_logs_title\": \"Confirmación de Limpieza de Registros\",\n            \"clear_logs_msg\": \"¿Está seguro de que desea limpiar todos los archivos de caché de registros?\",\n            \"logs_cleared\": \"Caché de registros limpiada\",\n            \"antigravity_cache_title\": \"Limpieza de Caché de Antigravity\",\n            \"antigravity_cache_desc\": \"Limpiar la caché de Antigravity para resolver errores de inicio de sesión, validación de versión y problemas de autorización OAuth.\",\n            \"antigravity_cache_warning\": \"Por favor asegúrese de que Antigravity esté completamente cerrado antes de limpiar la caché.\",\n            \"clear_antigravity_cache\": \"Limpiar Caché de Antigravity\",\n            \"clear_cache_confirm_title\": \"Confirmar Limpieza de Caché de Antigravity\",\n            \"clear_cache_confirm_msg\": \"Se limpiarán los siguientes directorios de caché:\",\n            \"cache_cleared_success\": \"Caché limpiada exitosamente, se liberaron {{size}} MB\",\n            \"cache_not_found\": \"No se encontraron directorios de caché de Antigravity\",\n            \"debug_logs_title\": \"Registro de depuración\",\n            \"debug_logs_enable_desc\": \"Al activar, se registra la cadena completa de solicitud y respuesta. Se recomienda activar solo al solucionar problemas.\",\n            \"debug_logs_desc\": \"Registra la cadena completa: entrada original, solicitud v1internal transformada y respuesta ascendente. Solo para solución de problemas, puede contener datos sensibles.\",\n            \"debug_log_dir\": \"Directorio de salida del registro de depuración\",\n            \"debug_log_dir_hint\": \"Dejar vacío para usar el directorio predeterminado: {{path}}/debug_logs\",\n            \"debug_log_dir_select\": \"Seleccionar directorio de salida del registro de depuración\",\n            \"http_api_title\": \"Servicio API HTTP\",\n            \"http_api_desc\": \"Proporciona interfaz HTTP local para programas externos (ej. plugins de VS Code).\",\n            \"http_api_enabled\": \"Habilitar API HTTP\",\n            \"http_api_enabled_desc\": \"Cuando está habilitado, los programas externos pueden administrar cuentas a través de la interfaz HTTP\",\n            \"http_api_port\": \"Puerto de Escucha\",\n            \"http_api_port_desc\": \"Se requiere reinicio después de cambiar el puerto. Si hay conflicto de puertos, use otro puerto disponible.\",\n            \"http_api_port_placeholder\": \"Puerto predeterminado 19527\",\n            \"http_api_port_invalid\": \"Número de puerto inválido (rango: 1024-65535)\",\n            \"http_api_settings_saved\": \"Configuración de API HTTP guardada, se requiere reinicio para aplicar\",\n            \"http_api_restart_required\": \"⚠️ Se requiere reinicio para aplicar\"\n        },\n        \"menu\": {\n            \"title\": \"Configuración de visualización del menú\",\n            \"desc\": \"Seleccione los elementos de función a mostrar en la barra de menú. Ocultar los menús poco utilizados puede ahorrar espacio.\",\n            \"selected_items_note\": \"Los elementos seleccionados se mostrarán en la barra de menú superior.\",\n            \"required\": \"Obligatorio\"\n        },\n        \"about\": {\n            \"title\": \"Acerca de\",\n            \"version\": \"Versión de la App\",\n            \"tech_stack\": \"Stack Tecnológico\",\n            \"author\": \"Autor\",\n            \"wechat\": \"WeChat\",\n            \"github\": \"GitHub\",\n            \"view_code\": \"Ver Código\",\n            \"copyright\": \"Copyright © 2025-2026 Antigravity. Todos los derechos reservados.\",\n            \"check_update\": \"Buscar Actualizaciones\",\n            \"checking_update\": \"Verificando...\",\n            \"latest_version\": \"Estás actualizado\",\n            \"new_version_available\": \"Nueva versión {{version}} disponible\",\n            \"download_update\": \"Descargar\",\n            \"update_check_failed\": \"Error al verificar actualizaciones\",\n            \"support_btn\": \"Apoyar al Autor\",\n            \"support_title\": \"Donación y Apoyo\",\n            \"support_desc\": \"Si encuentras útil este proyecto, ¡siéntete libre de invitarme un café! Tu apoyo es la mayor motivación para mantener este proyecto.\",\n            \"support_alipay\": \"Alipay\",\n            \"support_wechat\": \"WeChat Pay\",\n            \"support_buymeacoffee\": \"Buy Me a Coffee\"\n        },\n        \"advanced_thinking\": {\n            \"title\": \"Pensamiento Avanzado y Configuración Global\",\n            \"description\": \"Administre las capacidades de pensamiento, los modos de imagen y las instrucciones globales de forma centralizada.\"\n        },\n        \"thinking_budget\": {\n            \"title\": \"Presupuesto de Pensamiento (Thinking Budget)\",\n            \"description\": \"Controla el presupuesto de tokens para el pensamiento profundo de la IA. Algunos modelos (p. ej., Flash, modelos con sufijo -thinking) están limitados a 24576 por la API ascendente.\",\n            \"mode_label\": \"Modo de Procesamiento\",\n            \"mode\": {\n                \"auto\": \"Límite Automático\",\n                \"passthrough\": \"Paso Directo\",\n                \"custom\": \"Personalizado\"\n            },\n            \"auto_hint\": \"Modo Automático: Limita automáticamente el presupuesto a 24576 para modelos Flash, modelos con sufijo -thinking y solicitudes de búsqueda web para evitar errores de API.\",\n            \"passthrough_warning\": \"Paso Directo: Utiliza directamente el valor original de la solicitud; el no soporte de valores altos puede causar fallos.\",\n            \"custom_value_hint\": \"Recomendado: 24576 (límite Flash) o 51200 (Extendido)\",\n            \"tokens\": \"tokens\"\n        },\n        \"image_thinking_mode\": {\n            \"title\": \"Modo de Pensamiento de Imagen (Image Thinking Mode)\",\n            \"hint\": \"Afecta la calidad de la imagen y el proceso de generación\",\n            \"options\": {\n                \"enabled\": \"Activado\",\n                \"disabled\": \"Desactivado\",\n                \"enabled_desc\": \"Encendido: Mantiene la cadena de pensamiento y devuelve dos imágenes (boceto + final).\",\n                \"disabled_desc\": \"Apagado: Desactiva la cadena de pensamiento y genera una imagen única de alta calidad (prioridad calidad).\"\n            }\n        },\n        \"global_system_prompt\": {\n            \"title\": \"Instrucciones del Sistema Globales (Global System Prompt)\",\n            \"hint\": \"Se inyecta automáticamente en systemInstruction para todas las solicitudes\",\n            \"placeholder\": \"Ingrese las instrucciones globales del sistema...\\nEjemplo: Eres un desarrollador full-stack senior experto en React y Rust. Responde en español.\",\n            \"char_count\": \"{{count}} caracteres\",\n            \"long_prompt_warning\": \"Las instrucciones son muy largas (más de 2000 caracteres) y pueden consumir mucho espacio del contexto.\"\n        }\n    },\n    \"tray\": {\n        \"current\": \"Actual\",\n        \"quota\": \"Cuota\",\n        \"switch_next\": \"Cambiar a la Siguiente Cuenta\",\n        \"refresh_current\": \"Actualizar Cuota Actual\",\n        \"show_window\": \"Mostrar Ventana Principal\",\n        \"quit\": \"Salir de la Aplicación\",\n        \"no_account\": \"Sin Cuenta\",\n        \"unknown_quota\": \"Desconocido (Clic para Actualizar)\",\n        \"forbidden\": \"Cuenta Prohibida\"\n    },\n    \"proxy\": {\n        \"title\": \"Servicio de Proxy API\",\n        \"status\": {\n            \"running\": \"Servicio Activo\",\n            \"stopped\": \"Servicio Detenido\",\n            \"accounts_available\": \"{{count}} Cuentas Disponibles\",\n            \"processing\": \"Procesando...\"\n        },\n        \"action\": {\n            \"start\": \"Iniciar Servicio\",\n            \"stop\": \"Detener Servicio\"\n        },\n        \"config\": {\n            \"title\": \"Configuración del Servicio\",\n            \"port\": \"Puerto de Escucha\",\n            \"port_tooltip\": \"Puerto TCP en el que escucha el Proxy API local. Detenga el servicio para cambiarlo, luego reinicie para aplicar.\",\n            \"port_hint\": \"Predeterminado 8045, se requiere reinicio para aplicar cambios\",\n            \"auto_start\": \"Inicio Automático con la App\",\n            \"auto_start_tooltip\": \"Inicia automáticamente el servicio de Proxy API local cuando se lanza la aplicación.\",\n            \"allow_lan_access\": \"Permitir Acceso LAN\",\n            \"allow_lan_access_tooltip\": \"Cuando está habilitado, el servicio se vincula a 0.0.0.0 para que otros dispositivos en su LAN puedan acceder. Mantenga la autorización habilitada y proteja su clave API; se requiere reinicio para aplicar.\",\n            \"allow_lan_access_hint_enabled\": \"🌐 Escuchando en 0.0.0.0, dispositivos LAN pueden acceder\",\n            \"allow_lan_access_hint_disabled\": \"🔒 Escuchando solo en 127.0.0.1, acceso localhost (Privacidad Primero)\",\n            \"allow_lan_access_warning\": \"⚠️ Los dispositivos LAN pueden acceder cuando está habilitado. Proteja su clave API\",\n            \"allow_lan_access_restart_hint\": \"ℹ️ Se requiere reinicio del servicio para aplicar cambios\",\n            \"api_key\": \"Clave API\",\n            \"api_key_tooltip\": \"Secreto compartido usado por los clientes cuando la autorización del proxy está habilitada. Regenerar la clave invalida inmediatamente la anterior.\",\n            \"btn_regenerate\": \"Regenerar Clave\",\n            \"btn_edit\": \"Editar\",\n            \"btn_save\": \"Guardar\",\n            \"btn_copy\": \"Copiar\",\n            \"btn_copied\": \"Copiado\",\n            \"warning_key\": \"Nota: Mantenga su clave API segura. No la comparta.\",\n            \"api_key_invalid\": \"Formato de clave API inválido, debe comenzar con sk- y tener al menos 10 caracteres\",\n            \"api_key_updated\": \"Clave API actualizada\",\n            \"admin_password\": \"Contraseña de Administración Web UI\",\n            \"admin_password_tooltip\": \"Contraseña usada para iniciar sesión en la consola de administración Web. Si está vacía, se usa la Clave API por defecto.\",\n            \"admin_password_default\": \"(Igual que la Clave API)\",\n            \"admin_password_placeholder\": \"Ingrese nueva contraseña, deje vacío para usar Clave API\",\n            \"admin_password_hint\": \"Consejo: En escenarios de despliegue Docker/Web, puede establecer una contraseña de inicio de sesión separada para mejorar la seguridad de su Clave API.\",\n            \"admin_password_short\": \"Contraseña muy corta (al menos 4 caracteres)\",\n            \"admin_password_updated\": \"Contraseña de inicio de sesión Web UI actualizada\",\n            \"auth\": {\n                \"title\": \"Autorización\",\n                \"title_tooltip\": \"Controla si las solicitudes entrantes deben ser autenticadas, y qué rutas están protegidas.\",\n                \"enabled\": \"Habilitado\",\n                \"enabled_tooltip\": \"Activa/desactiva la autorización cambiando el modo de autorización. Cuando está habilitado, los clientes deben incluir la clave API vía Authorization: Bearer <API_KEY> o x-api-key.\",\n                \"mode\": \"Modo\",\n                \"mode_tooltip\": \"Selecciona qué rutas requieren la clave API: Off = sin auth; All = proteger todo; All except Health = /healthz permanece abierto; Auto = Off solo para localhost, de lo contrario All except Health.\",\n                \"hint\": \"Cuando está habilitado, los clientes deben enviar la clave API vía Authorization: Bearer ... (excepto health si está seleccionado).\",\n                \"modes\": {\n                    \"off\": \"Off (Abierto)\",\n                    \"strict\": \"Todo (Estricto)\",\n                    \"all_except_health\": \"Todo excepto Health\",\n                    \"auto\": \"Auto (Recomendado)\"\n                }\n            },\n            \"zai\": {\n                \"title\": \"Proveedor z.ai (GLM)\",\n                \"title_tooltip\": \"Upstream compatible con Anthropic opcional para el protocolo Claude. Solo afecta los endpoints de Anthropic; el enrutamiento de cuentas de Google permanece sin cambios.\",\n                \"subtitle\": \"Upstream compatible con Anthropic opcional solo para el protocolo Claude.\",\n                \"enabled\": \"Habilitado\",\n                \"enabled_tooltip\": \"Habilita el enrutamiento z.ai para solicitudes Anthropic según el modo de despacho seleccionado.\",\n                \"base_url\": \"URL Base\",\n                \"base_url_tooltip\": \"URL base compatible con Anthropic. El proxy agrega rutas como /v1/messages. Deje el predeterminado a menos que use una puerta de enlace personalizada.\",\n                \"dispatch_mode\": \"Modo de Despacho\",\n                \"dispatch_mode_tooltip\": \"Controla cuándo usar z.ai para solicitudes Anthropic: Off lo deshabilita; All Anthropic requests reenvía todo; Pooled agrega z.ai como un slot en round-robin con cuentas de Google; Fallback usa z.ai solo cuando no hay cuentas de Google.\",\n                \"api_key\": \"Clave API\",\n                \"api_key_tooltip\": \"Clave API usada para autenticar solicitudes a z.ai. Almacenada localmente y requerida para funciones z.ai y MCP.\",\n                \"api_key_placeholder\": \"Pegue su clave API de z.ai aquí\",\n                \"warning\": \"Nota: Esta clave se almacena localmente en el directorio de datos de la aplicación.\",\n                \"models\": {\n                    \"title\": \"Mapeo de Modelos\",\n                    \"title_tooltip\": \"Obtener IDs de modelos z.ai disponibles y configurar cómo los nombres de modelos Anthropic/Claude entrantes se traducen a IDs de modelos z.ai.\",\n                    \"refresh\": \"Obtener modelos\",\n                    \"refreshing\": \"Obteniendo...\",\n                    \"hint\": \"Modelos disponibles: {{count}}. Seleccione una sugerencia o escriba un ID de modelo personalizado.\",\n                    \"error\": \"Error al obtener modelos: {{error}}\",\n                    \"select_placeholder\": \"Seleccionar modelo...\",\n                    \"opus\": \"Familia Opus → modelo z.ai\",\n                    \"opus_tooltip\": \"ID de modelo z.ai predeterminado usado cuando el modelo entrante contiene \\\"opus\\\" (ej. claude-opus-*).\",\n                    \"sonnet\": \"Familia Sonnet → modelo z.ai\",\n                    \"sonnet_tooltip\": \"ID de modelo z.ai predeterminado usado para otros modelos Claude (ej. claude-sonnet-* y la mayoría de solicitudes claude-*).\",\n                    \"haiku\": \"Familia Haiku → modelo z.ai\",\n                    \"haiku_tooltip\": \"ID de modelo z.ai predeterminado usado cuando el modelo entrante contiene \\\"haiku\\\" (ej. claude-haiku-*).\",\n                    \"advanced_title\": \"Sobreescrituras avanzadas\",\n                    \"advanced_tooltip\": \"Sobreescrituras de coincidencia exacta opcionales. Si una cadena de modelo entrante coincide con una clave de regla, será reemplazada con el ID de modelo z.ai mapeado.\",\n                    \"from_label\": \"Modelo entrante\",\n                    \"to_label\": \"Modelo z.ai\",\n                    \"add_rule\": \"Agregar\",\n                    \"empty\": \"No hay reglas de sobreescritura configuradas.\",\n                    \"from_placeholder\": \"De (ej. claude-3-opus)\",\n                    \"to_placeholder\": \"A (ej. glm-4)\"\n                },\n                \"modes\": {\n                    \"off\": \"Off\",\n                    \"exclusive\": \"Todas las solicitudes Anthropic\",\n                    \"pooled\": \"Pooled (un slot)\",\n                    \"fallback\": \"Solo Fallback\"\n                },\n                \"mcp\": {\n                    \"title\": \"Servidores MCP (vía proxy local)\",\n                    \"title_tooltip\": \"Expone endpoints /mcp/* opcionales en este proxy local para que los clientes MCP puedan conectarse. Disponible solo cuando el servicio está activo, z.ai está configurado, y los toggles correspondientes están habilitados.\",\n                    \"enabled\": \"Habilitar proxy MCP\",\n                    \"enabled_tooltip\": \"Interruptor principal para endpoints MCP. Cuando está off, todas las rutas /mcp/* devuelven 404.\",\n                    \"web_search\": \"Búsqueda Web\",\n                    \"web_search_tooltip\": \"Expone /mcp/web_search_prime/mcp y reenvía solicitudes al upstream MCP de Búsqueda Web de z.ai.\",\n                    \"web_reader\": \"Lector Web\",\n                    \"web_reader_tooltip\": \"Expone /mcp/web_reader/mcp y reenvía solicitudes al upstream MCP de Lector Web de z.ai.\",\n                    \"vision\": \"Visión\",\n                    \"vision_tooltip\": \"Expone /mcp/zai-mcp-server/mcp (servidor MCP local) que proporciona herramientas de visión respaldadas por z.ai.\",\n                    \"local_endpoints\": \"Endpoints locales (configure su cliente MCP para usar estas URLs):\",\n                    \"local_endpoints_tooltip\": \"Use estas URLs en su cliente MCP. Comparten el mismo host/puerto que el Proxy API y siguen la política de autorización del proxy.\"\n                }\n            },\n            \"request_timeout\": \"Tiempo de Espera de Solicitud\",\n            \"request_timeout_tooltip\": \"Tiempo máximo (segundos) que el proxy espera una respuesta upstream, incluyendo streaming. Aumente para generaciones largas; se requiere reinicio para aplicar.\",\n            \"request_timeout_hint\": \"Predeterminado 120s, rango 30-7200s. Reinicie el servicio para aplicar cambios.\",\n            \"enable_logging\": \"Habilitar Registro de Solicitudes\",\n            \"enable_logging_hint\": \"Registrar historial para depuración (Costo de rendimiento menor)\",\n            \"upstream_proxy\": {\n                \"title\": \"Proxy Upstream Global (Proxy Global)\",\n                \"desc\": \"Cuando está habilitado, todas las solicitudes externas (Proxy API, Actualización de Token, Verificación de Cuota, Verificación de Actualizaciones) se enrutarán a través de este proxy.\",\n                \"desc_short\": \"Proxy global utilizado como solución de respaldo cuando no se encuentran cuentas adecuadas en el grupo de proxies.\",\n                \"enable\": \"Habilitar Proxy Upstream\",\n                \"url\": \"URL del Proxy\",\n                \"url_placeholder\": \"ej. http://127.0.0.1:7890 o socks5://127.0.0.1:7890\",\n                \"tip\": \"Soporta HTTP, HTTPS y SOCKS5.\",\n                \"socks5h_hint\": \"Para evitar bloqueos y mantener la resolución DNS remota (Remote DNS), cambie manualmente el protocolo a socks5h://\",\n                \"validation_error\": \"Se requiere URL del Proxy cuando el proxy upstream está habilitado\",\n                \"restart_hint\": \"Configuración del proxy guardada. Reinicie la app para aplicar cambios.\"\n            },\n            \"scheduling\": {\n                \"title\": \"Rotación y Programación de Cuentas\",\n                \"title_tooltip\": \"Controla cómo las sesiones se vinculan a cuentas y cómo se manejan los límites de tasa.\",\n                \"subtitle\": \"Optimiza el Caché de Prompts y el manejo de límites de tasa para todos los protocolos (OpenAI/Gemini/Claude).\",\n                \"mode\": \"Modo de Programación\",\n                \"mode_tooltip\": \"Cache-First: Vincular sesión a cuenta, esperar en límite de tasa (maximizar utilidad de caché); Balance: Vincular sesión, cambiar cuenta en límite de tasa; Performance: Round-robin estándar.\",\n                \"modes\": {\n                    \"CacheFirst\": \"Caché Primero\",\n                    \"Balance\": \"Balance\",\n                    \"PerformanceFirst\": \"Rendimiento\"\n                },\n                \"modes_desc\": {\n                    \"CacheFirst\": \"Vincula sesión a cuenta, espera precisamente si está limitado (Maximiza hits de Caché de Prompts).\",\n                    \"Balance\": \"Vincula sesión, cambia automáticamente a cuenta disponible si está limitado (Balance entre caché y disponibilidad).\",\n                    \"PerformanceFirst\": \"Sin vinculación de sesión, rotación round-robin pura (Mejor para alta concurrencia).\"\n                },\n                \"max_wait\": \"Espera Máxima (seg)\",\n                \"max_wait_tooltip\": \"Solo usado en modo 'Caché Primero': esperar en lugar de cambiar si el tiempo de reinicio del límite de tasa está por debajo de este valor.\",\n                \"clear_bindings\": \"Limpiar Vinculaciones de Sesión\",\n                \"clear_bindings_tooltip\": \"Reinicio completo de todas las vinculaciones sesión-cuenta, forzando que las cuentas se reasignen en la próxima solicitud.\",\n                \"clear_rate_limits\": \"Limpiar Registros de Límites de Tasa\",\n                \"clear_rate_limits_tooltip\": \"Limpiar inmediatamente los registros locales de límites de tasa para todas las cuentas, forzando que las próximas solicitudes intenten el upstream directamente.\"\n            },\n            \"circuit_breaker\": {\n                \"title\": \"Disyuntor Adaptativo\",\n                \"tooltip\": \"Aumenta automáticamente la duración del bloqueo para cuentas que fallan repetidamente por agotamiento de cuota. Esto evita desperdiciar llamadas API en cuentas muertas mientras permite que los errores transitorios se recuperen rápidamente.\",\n                \"backoff_levels\": \"Niveles de Backoff (Segundos)\",\n                \"input_placeholder\": \"Ingrese duraciones de backoff en segundos, separadas por comas\",\n                \"level\": \"Nivel {{level}}\",\n                \"invalid_format\": \"Formato inválido. Use números separados por comas (ej. 60, 300)\",\n                \"clear_records\": \"Limpiar Todos los Registros de Límites de Tasa\"\n            },\n            \"experimental\": {\n                \"title\": \"Configuración Experimental\",\n                \"title_tooltip\": \"Funciones exploratorias que pueden ajustarse o eliminarse en versiones futuras.\",\n                \"enable_usage_scaling\": \"Habilitar Escalado de Uso\",\n                \"enable_usage_scaling_tooltip\": \"Para el protocolo Claude. Habilita escalado agresivo cuando el input total excede 30k tokens para prevenir compresión frecuente del lado del cliente. Nota: El uso reportado no reflejará la facturación real después de habilitar.\",\n                \"context_compression_threshold_l1\": \"Umbral de Compresión L1 (Recorte de Herramientas)\",\n                \"context_compression_threshold_l1_tooltip\": \"Recorta registros antiguos de llamadas a herramientas para ahorrar espacio. Recomendado: 0.4 (40%)\",\n                \"context_compression_threshold_l2\": \"Umbral de Compresión L2 (Compresión de Pensamiento)\",\n                \"context_compression_threshold_l2_tooltip\": \"Comprime bloques de pensamiento tempranos mientras preserva firmas. Recomendado: 0.55 (55%)\",\n                \"context_compression_threshold_l3\": \"Umbral de Compresión L3 (Pivote de Resumen)\",\n                \"context_compression_threshold_l3_tooltip\": \"Reinicio definitivo: genera un resumen de estado XML y pivotea a una nueva sesión. Más eficiente en tokens. Recomendado: 0.7 (70%)\"\n            }\n        },\n        \"cloudflared\": {\n            \"title\": \"Acceso Público (Cloudflared)\",\n            \"subtitle\": \"Exponga su servicio local a internet a través de Cloudflare Tunnel\",\n            \"not_installed\": \"Cloudflared no instalado\",\n            \"install_hint\": \"Cloudflared es una herramienta de túnel gratuita de Cloudflare. Expone su proxy local a internet sin una IP pública o reenvío de puertos. Haga clic en el botón de abajo para instalar.\",\n            \"install\": \"Instalar Ahora\",\n            \"installing\": \"Instalando...\",\n            \"install_success\": \"Cloudflared instalado exitosamente\",\n            \"install_failed\": \"Instalación fallida: {{error}}\",\n            \"installed\": \"Instalado\",\n            \"version\": \"Versión\",\n            \"mode_label\": \"Modo de Túnel\",\n            \"mode_quick\": \"Túnel Rápido\",\n            \"mode_quick_desc\": \"URL temporal auto-generada (*.trycloudflare.com), no requiere cuenta, la URL cambia al reiniciar\",\n            \"mode_auth\": \"Túnel con Nombre\",\n            \"mode_auth_desc\": \"Usar token de cuenta Cloudflare, soporta dominio personalizado, URL persistente\",\n            \"token\": \"Token del Túnel\",\n            \"token_placeholder\": \"Pegue su Token de Túnel de Cloudflare aquí\",\n            \"token_hint\": \"Obtener del panel de Cloudflare Zero Trust\",\n            \"token_required\": \"Se requiere Token para el modo Túnel con Nombre\",\n            \"use_http2\": \"Usar HTTP/2\",\n            \"use_http2_desc\": \"Más compatible, recomendado para China continental\",\n            \"status_label\": \"Estado del Túnel\",\n            \"status_stopped\": \"Detenido\",\n            \"status_starting\": \"Iniciando...\",\n            \"status_running\": \"Activo\",\n            \"status_stopping\": \"Deteniendo...\",\n            \"status_error\": \"Error\",\n            \"public_url\": \"URL Pública\",\n            \"public_url_placeholder\": \"La URL pública aparecerá aquí después de iniciar el túnel\",\n            \"copy_url\": \"Copiar URL\",\n            \"url_copied\": \"URL copiada\",\n            \"start_tunnel\": \"Iniciar Túnel\",\n            \"stop_tunnel\": \"Detener Túnel\",\n            \"running\": \"Túnel Activo\",\n            \"started\": \"Túnel iniciado\",\n            \"stopped\": \"Túnel detenido\",\n            \"start_failed\": \"Error al iniciar: {{error}}\",\n            \"stop_failed\": \"Error al detener: {{error}}\",\n            \"require_proxy_running\": \"Por favor inicie el servicio de proxy local primero\",\n            \"connection_info\": \"Información de Conexión\",\n            \"local_port\": \"Puerto Local\",\n            \"tunnel_protocol\": \"Protocolo del Túnel\"\n        },\n        \"example\": {\n            \"title\": \"Ejemplos de Uso\",\n            \"curl\": \"cURL\",\n            \"python\": \"Python\",\n            \"python_anthropic\": \"from anthropic import Anthropic\\n\\nclient = Anthropic(\\n    # Recomendado: 127.0.0.1\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\n# Nota: Antigravity soporta llamar cualquier modelo vía el SDK de Anthropic\\nresponse = client.messages.create(\\n    model=\\\"{{modelId}}\\\",\\n    max_tokens=1024,\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hola\\\"}]\\n)\\n\\nprint(response.content[0].text)\",\n            \"python_gemini\": \"# Instalar: pip install google-generativeai\\nimport google.generativeai as genai\\n\\n# Usar dirección de proxy de Antigravity (recomendado 127.0.0.1)\\ngenai.configure(\\n    api_key=\\\"{{apiKey}}\\\",\\n    transport='rest',\\n    client_options={'api_endpoint': '{{rawBaseUrl}}'}\\n)\\n\\nmodel = genai.GenerativeModel('{{modelId}}')\\nresponse = model.generate_content(\\\"Hola\\\")\\nprint(response.text)\",\n            \"python_openai_image\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    # Opción 1: usar tamaño (recomendado)\\n    # Soportados: \\\"1024x1024\\\" (1:1), \\\"1280x720\\\" (16:9), \\\"720x1280\\\" (9:16), \\\"1216x896\\\" (4:3)\\n    extra_body={ \\\"size\\\": \\\"1024x1024\\\" },\\n\\n    # Opción 2: usar sufijo de modelo\\n    # ej. gemini-3-pro-image-16-9, gemini-3-pro-image-4-3\\n    # model=\\\"gemini-3-pro-image-16-9\\\",\\n    messages=[{\\n        \\\"role\\\": \\\"user\\\",\\n        \\\"content\\\": \\\"Dibuja una ciudad futurista\\\"\\n    }]\\n)\\n\\nprint(response.choices[0].message.content)\",\n            \"python_openai\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hola\\\"}]\\n)\\n\\nprint(response.choices[0].message.content)\"\n        },\n        \"examples\": {\n            \"title\": \"Ejemplos de Uso\"\n        },\n        \"dialog\": {\n            \"confirm_regenerate\": \"¿Está seguro de regenerar la Clave API? La clave anterior se invalidará inmediatamente.\",\n            \"operate_failed\": \"Operación fallida: {{error}}\",\n            \"reset_mapping_title\": \"Restablecer Mapeo de Modelos\",\n            \"reset_mapping_msg\": \"¿Está seguro de que desea restablecer todos los mapeos de modelos a los valores predeterminados del sistema? Esta acción no se puede deshacer.\",\n            \"regenerate_key_title\": \"Regenerar Clave API\",\n            \"regenerate_key_msg\": \"¿Está seguro de que desea regenerar la Clave API? La clave anterior se invalidará inmediatamente.\",\n            \"clear_bindings_title\": \"Limpiar Vinculaciones de Sesión\",\n            \"clear_bindings_msg\": \"¿Está seguro de que desea limpiar todas las vinculaciones sesión-cuenta?\",\n            \"clear_rate_limits_title\": \"Limpiar Registros de Límites de Tasa\",\n            \"clear_rate_limits_confirm\": \"¿Está seguro de que desea limpiar todos los registros locales de límites de tasa?\"\n        },\n        \"model\": {\n            \"flash\": \"Respuesta Rápida\",\n            \"flash_preview\": \"Vista Previa Flash\",\n            \"flash_lite\": \"Ligero y Rápido\",\n            \"flash_thinking\": \"Capacidad de Pensamiento\",\n            \"pro_legacy\": \"Pro Legado\",\n            \"pro_low\": \"Alto Rendimiento\",\n            \"pro_high\": \"Mejor Razonamiento\",\n            \"pro_image\": \"Generación de Imagen (1:1)\",\n            \"pro_image_16_9\": \"Generación de Imagen (16:9)\",\n            \"pro_image_9_16\": \"Generación de Imagen (9:16)\",\n            \"pro_image_4_3\": \"Generación de Imagen (4:3)\",\n            \"pro_image_3_4\": \"Generación de Imagen (3:4)\",\n            \"pro_image_1_1\": \"Generación de Imagen (1:1)\",\n            \"claude_sonnet\": \"Razonamiento de Código\",\n            \"claude_sonnet_thinking\": \"Cadena de Pensamiento\",\n            \"claude_opus_thinking\": \"Pensamiento Más Fuerte\"\n        },\n        \"mapping\": {\n            \"title\": \"Mapeo de Modelos Claude Code\",\n            \"description\": \"Mapee modelos de Claude Code a modelos de Antigravity. Optimice costo y velocidad enrutando solicitudes inteligentemente.\",\n            \"default\": \"Predeterminado\",\n            \"sonnet_desc\": \"Más capaz para trabajo complejo\",\n            \"opus_desc\": \"Nivel premium\",\n            \"haiku_desc\": \"Más rápido para respuestas rápidas\",\n            \"maps_to\": \"Mapea a Antigravity\",\n            \"apply_recommended\": \"Aplicar Recomendado\",\n            \"restore_defaults\": \"Restaurar Configuración Predeterminada\",\n            \"reset_all\": \"Restablecer Todo\"\n        },\n        \"router\": {\n            \"title\": \"Enrutador de Modelos\",\n            \"subtitle\": \"Enrute modelos por serie o agregue mapeos exactos personalizados.\\nNota: Los modelos nativos de paso Claude (ej. claude-opus-4-6-thinking) omiten los grupos de series por defecto. Use \\\"Enrutamiento Personalizado Experto\\\" para sobreescribir.\",\n            \"subtitle_simple\": \"Personalice el enrutamiento de modelos con comodines o mapeos exactos\",\n            \"background_task_title\": \"Modelo de Tarea en Segundo Plano\",\n            \"background_task_desc\": \"Modelo usado para tareas en segundo plano de Claude CLI como generación de título, resumen, etc. (Predeterminado: gemini-2.5-flash)\",\n            \"use_default\": \"Usar Predeterminado del Sistema\",\n            \"current_model\": \"Modelo Actual\",\n            \"apply_presets\": \"Aplicar Preajustes\",\n            \"presets_applied\": \"Preajustes aplicados exitosamente\",\n            \"custom_mappings\": \"Mapeos Personalizados\",\n            \"group_title\": \"Grupos de Series\",\n            \"groups\": {\n                \"claude_45\": {\n                    \"name\": \"Serie Claude 4.6 TK\",\n                    \"desc\": \"Opus 4.5 TK\"\n                },\n                \"claude_35\": {\n                    \"name\": \"Serie Claude 3.5\",\n                    \"desc\": \"Sonnet 3.5, Haiku 3.5\"\n                },\n                \"gpt_4\": {\n                    \"name\": \"Serie GPT-4 / o1\",\n                    \"desc\": \"GPT-4, Turbo, o1-preview\"\n                },\n                \"gpt_4o\": {\n                    \"name\": \"Serie GPT-4o / 3.5\",\n                    \"desc\": \"GPT-4o, Mini, 3.5 Turbo\"\n                },\n                \"gpt_5\": {\n                    \"name\": \"Serie GPT-5\",\n                    \"desc\": \"GPT-5.1, GPT-5.2 xhigh\"\n                }\n            },\n            \"expert_title\": \"Enrutamiento Personalizado Experto\",\n            \"expert_subtitle\": \"Coincidencia precisa para cualquier ID de modelo original.\",\n            \"custom_mapping_tip\": \"💡 Permite ingresar manualmente cualquier ID de modelo para experimentar con modelos no lanzados (ej. claude-opus-4-6).\",\n            \"custom_mapping_warning\": \"Nota: No todas las cuentas soportan modelos no lanzados.\",\n            \"money_saving_tip\": \"💰 Consejo para ahorrar:\",\n            \"haiku_optimization_tip\": \"Claude CLI usa {{model}} para tareas en segundo plano por defecto. Mapéelo a un modelo Flash más económico para ahorrar ~95% de costos\",\n            \"haiku_optimization_btn\": \"Optimización Rápida\",\n            \"haiku_tip_title\": \"💰 Consejo para ahorrar:\",\n            \"haiku_tip_body_before\": \"Claude CLI usa por defecto\",\n            \"haiku_tip_body_after\": \"para tareas en segundo plano; mapearlo a un modelo Flash más económico puede ahorrar alrededor del 95% de los costos.\",\n            \"haiku_tip_action\": \"Optimizar\",\n            \"reset_confirm\": \"¿Restablecer todos los mapeos a los valores predeterminados del sistema?\",\n            \"reset_mapping\": \"Restablecer Mapeo\",\n            \"add_mapping\": \"Agregar Mapeo\",\n            \"current_list\": \"Lista Personalizada\",\n            \"no_custom_mapping\": \"Sin mapeos personalizados aún\",\n            \"gemini3_only_warning\": \"⚠️ Solo serie Gemini 3\",\n            \"default_suffix\": \" (Predeterminado)\",\n            \"original_id\": \"ID Original\",\n            \"route_to\": \"Enrutar A\",\n            \"select_target_model\": \"Seleccionar Modelo Destino\",\n            \"original_placeholder\": \"Original (ej. gpt-4 o gpt-4*)\"\n        },\n        \"multi_protocol\": {\n            \"title\": \"Soporte Multi-Protocolo\",\n            \"subtitle\": \"Integración perfecta con sus herramientas y CLIs de IA favoritas\",\n            \"description\": \"El proxy local soporta protocolos OpenAI, Anthropic y Gemini, asegurando compatibilidad con una amplia gama de aplicaciones.\",\n            \"openai_label\": \"Protocolo OpenAI\",\n            \"anthropic_label\": \"Protocolo Anthropic\",\n            \"openai_tools\": \"Cherry Studio, NextChat\",\n            \"anthropic_tools\": \"Claude Code CLI\",\n            \"gemini_label\": \"Protocolo Gemini\",\n            \"gemini_tools\": \"Google AI SDK, LangChain\",\n            \"quick_integration\": \"Integración Rápida\",\n            \"click_tip\": \"👆 Clic en un modelo para actualizar ejemplos de código\",\n            \"copy_base\": \"Copiar Base\"\n        },\n        \"supported_models\": {\n            \"title\": \"Modelos Soportados e Integración\",\n            \"model_name\": \"Nombre del Modelo\",\n            \"model_id\": \"ID del Modelo\",\n            \"description\": \"Descripción\",\n            \"action\": \"Acción\"\n        },\n        \"cli_sync\": {\n            \"title\": \"Sincronización CLI con Un Clic\",\n            \"subtitle\": \"Sincronice rápidamente endpoints API actuales y claves a sus herramientas CLI de IA locales.\",\n            \"card_title\": \"Configuración de {{name}}\",\n            \"status\": {\n                \"not_installed\": \"No detectado\",\n                \"installed\": \"v{{version}}\",\n                \"synced\": \"Apuntando a esta app\",\n                \"not_synced\": \"No sincronizado\",\n                \"detecting\": \"Detectando...\",\n                \"current_base_url\": \"URL Base Actual\"\n            },\n            \"btn_sync\": \"Sincronizar Config Ahora\",\n            \"btn_view\": \"Ver Config\",\n            \"btn_restore\": \"Restaurar Predeterminados\",\n            \"btn_restore_backup\": \"Restaurar Respaldo\",\n            \"restore_confirm\": \"¿Está seguro de que desea restaurar la configuración de {{name}} a la URL predeterminada oficial?\",\n            \"restore_backup_confirm\": \"Se encontró configuración de respaldo. ¿Está seguro de que desea restaurarla?\",\n            \"modal\": {\n                \"view_title\": \"Contenido de Configuración de {{name}}\",\n                \"copy_success\": \"Contenido de configuración copiado\"\n            },\n            \"toast\": {\n                \"sync_success\": \"¡Sincronización exitosa! {{name}} está listo.\",\n                \"sync_error\": \"Error de sincronización: {{error}}\"\n            },\n            \"sync_confirm_title\": \"Confirmación de Sincronización\",\n            \"sync_confirm_message\": \"Listo para sincronizar configuración de {{name}}. ⚠️ Advertencia: Esto sobrescribirá sus archivos de configuración locales existentes (ej. tokens de inicio de sesión, Claves API). ¿Está seguro de que desea continuar?\"\n        }\n    },\n    \"monitor\": {\n        \"page_title\": \"Panel de Monitor API\",\n        \"page_subtitle\": \"Registro y análisis de solicitudes en tiempo real\",\n        \"open_monitor\": \"Abrir Monitor\",\n        \"logging_status\": {\n            \"active\": \"Grabando\",\n            \"paused\": \"Pausado\"\n        },\n        \"stats\": {\n            \"total\": \"Total\",\n            \"ok\": \"OK\",\n            \"err\": \"ERR\"\n        },\n        \"filters\": {\n            \"placeholder\": \"Filtrar por modelo, ruta o estado...\",\n            \"quick_filters\": \"Filtros Rápidos:\",\n            \"all\": \"Todos\",\n            \"error\": \"Error\",\n            \"chat\": \"Chat\",\n            \"gemini\": \"Gemini\",\n            \"claude\": \"Claude\",\n            \"images\": \"Imágenes\",\n            \"reset\": \"Restablecer\",\n            \"by_account\": \"Filtrar por cuenta\",\n            \"all_accounts\": \"Todas las Cuentas\"\n        },\n        \"table\": {\n            \"status\": \"Estado\",\n            \"method\": \"Método\",\n            \"model\": \"Modelo\",\n            \"protocol\": \"Protocolo\",\n            \"account\": \"Cuenta\",\n            \"path\": \"Ruta\",\n            \"usage\": \"Tokens\",\n            \"duration\": \"Duración\",\n            \"time\": \"Hora\",\n            \"empty\": \"No hay solicitudes registradas\"\n        },\n        \"details\": {\n            \"title\": \"Detalles de Solicitud\",\n            \"request_payload\": \"Payload de Solicitud\",\n            \"response_payload\": \"Payload de Respuesta\",\n            \"duration\": \"Duración\",\n            \"tokens\": \"Tokens (E/S)\",\n            \"time\": \"Hora\",\n            \"model\": \"Modelo\",\n            \"mapped_model\": \"Modelo Mapeado\",\n            \"protocol\": \"Protocolo\",\n            \"account_used\": \"Cuenta Usada\",\n            \"id\": \"ID de Solicitud\",\n            \"payload_empty\": \"Sin datos\"\n        },\n        \"dialog\": {\n            \"clear_title\": \"Limpiar Registros de Proxy\",\n            \"clear_msg\": \"¿Está seguro de que desea limpiar todos los registros del proxy? Esta acción no se puede deshacer.\"\n        }\n    },\n    \"update_notification\": {\n        \"title\": \"Nueva versión disponible\",\n        \"message\": \"Una nueva versión está lista con optimizaciones y mejoras. Actual: v{{current}}\",\n        \"ready\": \"Actualización lista\",\n        \"downloading\": \"Descargando actualización...\",\n        \"restarting\": \"Reiniciando la aplicación...\",\n        \"auto_update\": \"Actualización automática\",\n        \"toast\": {\n            \"not_ready\": \"El paquete de actualización automática no está listo, redirigiendo a la página de descarga...\",\n            \"failed\": \"Falló la actualización automática, redirigiendo a la página de descarga...\"\n        }\n    },\n    \"login\": {\n        \"title\": \"Control de Acceso Seguro\",\n        \"desc\": \"Ejecutando en modo Web. Por favor ingrese la contraseña de administración o Clave API para acceder.\",\n        \"placeholder\": \"Ingrese contraseña de administración o Clave API\",\n        \"btn_login\": \"Verificar y Entrar\",\n        \"note\": \"Nota: Si se estableció una contraseña de administración separada, ingrésela; de lo contrario, ingrese la API_KEY.\",\n        \"lookup_hint\": \"Si olvidó, ejecute docker logs antigravity-manager para encontrar la Current API Key o Web UI Password\",\n        \"config_hint\": \"O ejecute grep -E '\\\"api_key\\\"|\\\"admin_password\\\"' ~/.antigravity_tools/gui_config.json para ver.\"\n    },\n    \"token_stats\": {\n        \"title\": \"Estadísticas de Consumo de Tokens\",\n        \"hourly\": \"Hora\",\n        \"daily\": \"Día\",\n        \"weekly\": \"Semana\",\n        \"total_tokens\": \"Tokens Totales\",\n        \"input_tokens\": \"Tokens de Entrada\",\n        \"output_tokens\": \"Tokens de Salida\",\n        \"accounts_used\": \"Cuentas Activas\",\n        \"models_used\": \"Modelos Usados\",\n        \"model_trend\": \"Tendencia de Uso de Modelos\",\n        \"account_trend\": \"Tendencia de Uso de Cuentas\",\n        \"usage_trend\": \"Tendencia de Uso de Tokens\",\n        \"by_account\": \"Por Cuenta\",\n        \"by_model\": \"Por Modelo\",\n        \"by_account_view\": \"Por Cuenta\",\n        \"model_details\": \"Desglose de Modelos\",\n        \"account_details\": \"Desglose de Cuentas\",\n        \"model\": \"Modelo\",\n        \"account\": \"Cuenta\",\n        \"requests\": \"Solicitudes\",\n        \"input\": \"Entrada\",\n        \"output\": \"Salida\",\n        \"total\": \"Total\",\n        \"percentage\": \"Porcentaje\",\n        \"no_data\": \"Sin datos disponibles\"\n    },\n    \"errors\": {\n        \"stream\": {\n            \"timeout_error\": \"Tiempo de espera agotado, por favor verifique su conexión de red\",\n            \"connection_error\": \"Conexión fallida, por favor verifique su red o configuración de proxy\",\n            \"decode_error\": \"Red inestable, transmisión de datos interrumpida. Intente: 1) Verificar red 2) Cambiar proxy 3) Reintentar\",\n            \"stream_error\": \"Error de transmisión de stream, por favor reintente más tarde\",\n            \"unknown_error\": \"Error desconocido ocurrido, por favor reintente más tarde\"\n        }\n    },\n    \"user_token\": {\n        \"title\": \"Gestión de Tokens de Usuario\",\n        \"total_users\": \"Total de Usuarios\",\n        \"active_tokens\": \"Tokens Activos\",\n        \"total_created\": \"Total Creados\",\n        \"create\": \"Crear Token\",\n        \"username\": \"Nombre de usuario\",\n        \"token\": \"Token\",\n        \"expires\": \"Expira\",\n        \"usage\": \"Uso\",\n        \"ip_limit\": \"Límite de IP\",\n        \"created\": \"Creado\",\n        \"today_requests\": \"Solicitudes Hoy\",\n        \"never\": \"Nunca\",\n        \"renew\": \"Renovar\",\n        \"renew_button\": \"Renovar\",\n        \"unlimited\": \"Ilimitado\",\n        \"create_title\": \"Crear Nuevo Token\",\n        \"description\": \"Descripción\",\n        \"curfew\": \"Toque de queda (Horario no disponible)\",\n        \"edit_title\": \"Editar Token\",\n        \"username_required\": \"Nombre de usuario requerido\",\n        \"renew_success\": \"Renovado exitosamente\",\n        \"expires_day\": \"1 Día\",\n        \"expires_week\": \"1 Semana\",\n        \"expires_month\": \"1 Mes\",\n        \"expires_never\": \"Nunca\",\n        \"no_data\": \"No se encontraron tokens\",\n        \"placeholder_username\": \"ej. user1\",\n        \"placeholder_desc\": \"Notas opcionales\",\n        \"placeholder_max_ips\": \"0 = Ilimitado\",\n        \"hint_max_ips\": \"0 significa ilimitado\",\n        \"hint_curfew\": \"Dejar vacío para desactivar. Basado en la hora del servidor.\"\n    }\n}"
  },
  {
    "path": "src/locales/ja.json",
    "content": "{\n    \"common\": {\n        \"empty\": \"空\",\n        \"loading\": \"読み込み中...\",\n        \"add\": \"追加\",\n        \"copy\": \"コピー\",\n        \"action\": \"アクション\",\n        \"save\": \"保存\",\n        \"saved\": \"正常に保存されました\",\n        \"cancel\": \"キャンセル\",\n        \"confirm\": \"確認\",\n        \"close\": \"閉じる\",\n        \"delete\": \"削除\",\n        \"edit\": \"編集\",\n        \"refresh\": \"更新\",\n        \"refreshing\": \"更新中...\",\n        \"export\": \"エクスポート\",\n        \"import\": \"インポート\",\n        \"success\": \"成功\",\n        \"error\": \"エラー\",\n        \"unknown\": \"不明\",\n        \"warning\": \"警告\",\n        \"info\": \"情報\",\n        \"details\": \"詳細\",\n        \"example\": \"Example\",\n        \"clear\": \"クリア\",\n        \"clearing\": \"クリア中...\",\n        \"prev_page\": \"前へ\",\n        \"next_page\": \"次へ\",\n        \"pagination_info\": \"{{total}} 件中 {{start}} から {{end}} を表示\",\n        \"per_page\": \"1ページあたり\",\n        \"items\": \"項目\",\n        \"accounts\": \"アカウント\",\n        \"enabled\": \"有効\",\n        \"disabled\": \"無効\",\n        \"tauri_api_not_loaded\": \"Tauri APIが読み込まれていません。アプリを再起動してください\",\n        \"environment_error\": \"環境エラー: {{error}}\",\n        \"load_more\": \"もっと読み込む\",\n        \"submit\": \"送信\",\n        \"update\": \"更新\",\n        \"load_failed\": \"読み込み失敗\",\n        \"create_success\": \"作成成功\",\n        \"update_success\": \"更新成功\",\n        \"delete_success\": \"削除成功\",\n        \"copied\": \"クリップボードにコピーしました\",\n        \"retry\": \"再試行\",\n        \"back\": \"戻る\"\n    },\n    \"nav\": {\n        \"dashboard\": \"ダッシュボード\",\n        \"accounts\": \"アカウント\",\n        \"proxy\": \"APIプロキシ\",\n        \"token_stats\": \"統計\",\n        \"call_records\": \"トラフィックログ\",\n        \"security\": \"IP管理\",\n        \"security_logs\": \"IPログ\",\n        \"settings\": \"設定\",\n        \"theme_to_dark\": \"ダークモードに切り替え\",\n        \"theme_to_light\": \"ライトモードに切り替え\",\n        \"switch_to_english\": \"Englishに切り替え\",\n        \"switch_to_chinese\": \"简体中文に切り替え\",\n        \"switch_to_english_short\": \"EN\",\n        \"switch_to_chinese_short\": \"ZH\",\n        \"switch_to_japanese\": \"日本語に切り替え\",\n        \"switch_to_japanese_short\": \"JA\",\n        \"switch_to_turkish\": \"トルコ語に切り替え\",\n        \"switch_to_turkish_short\": \"TR\",\n        \"switch_to_vietnamese\": \"ベトナム語に切り替え\",\n        \"switch_to_vietnamese_short\": \"VI\",\n        \"switch_to_russian\": \"ロシア語に切り替え\",\n        \"switch_to_russian_short\": \"RU\",\n        \"switch_to_portuguese\": \"ポルトガル語に切り替え\",\n        \"switch_to_portuguese_short\": \"PT\",\n        \"switch_to_traditional_chinese\": \"繁体字中国語に切り替え\",\n        \"switch_to_traditional_chinese_short\": \"繁中\",\n        \"switch_to_korean\": \"韓国語に切り替え\",\n        \"switch_to_korean_short\": \"KO\",\n        \"switch_to_spanish\": \"スペイン語に切り替え\",\n        \"switch_to_spanish_short\": \"ES\",\n        \"switch_to_malay\": \"マレー語に切り替え\",\n        \"switch_to_malay_short\": \"MY\",\n        \"user_token\": \"ユーザートークン\",\n        \"logout\": \"ログアウト\"\n    },\n    \"dashboard\": {\n        \"hello\": \"こんにちは 👋\",\n        \"refresh_quota\": \"クォータ更新\",\n        \"refreshing\": \"更新中...\",\n        \"total_accounts\": \"合計アカウント数\",\n        \"avg_gemini\": \"平均Geminiクォータ\",\n        \"avg_gemini_image\": \"平均Gemini画像クォータ\",\n        \"avg_claude\": \"平均Claudeクォータ\",\n        \"low_quota_accounts\": \"クォータ不足のアカウント\",\n        \"quota_sufficient\": \"クォータ残量あり\",\n        \"quota_low\": \"クォータ不足\",\n        \"quota_desc\": \"クォータ < 20%\",\n        \"current_account\": \"現在のアカウント\",\n        \"switch_account\": \"アカウント切り替え\",\n        \"no_active_account\": \"アクティブなアカウントなし\",\n        \"best_accounts\": \"おすすめアカウント\",\n        \"best_account_recommendation\": \"最適アカウント\",\n        \"switch_best\": \"最適に切り替え\",\n        \"switch_successfully\": \"最適に切り替えました\",\n        \"view_all_accounts\": \"すべてのアカウントを表示\",\n        \"export_data\": \"データエクスポート\",\n        \"for_gemini\": \"Gemini用\",\n        \"for_claude\": \"Claude用\",\n        \"toast\": {\n            \"switch_success\": \"切り替えに成功しました！\",\n            \"switch_error\": \"アカウントの切り替えに失敗しました\",\n            \"refresh_success\": \"クォータの更新に成功しました\",\n            \"refresh_error\": \"更新に失敗しました\",\n            \"export_no_accounts\": \"エクスポートするアカウントがありません\",\n            \"export_success\": \"エクスポートに成功しました！保存先: {{path}}\",\n            \"export_error\": \"エクスポートに失敗しました\"\n        },\n        \"branding\": {\n            \"title\": \"Antigravity Tools\",\n            \"subtitle\": \"プロフェッショナルなアカウント管理\"\n        }\n    },\n    \"accounts\": {\n        \"account\": \"アカウント\",\n        \"search_placeholder\": \"メールを検索...\",\n        \"all\": \"すべて\",\n        \"available\": \"利用可能\",\n        \"low_quota\": \"低クォータ\",\n        \"ultra\": \"ULTRA\",\n        \"pro\": \"PRO\",\n        \"free\": \"FREE\",\n        \"edit_label\": \"ラベルを編集\",\n        \"custom_label_placeholder\": \"カスタムラベルを入力\",\n        \"label_updated\": \"ラベルが更新されました\",\n        \"add_account\": \"アカウント追加\",\n        \"refresh_all\": \"すべて更新\",\n        \"refresh_selected\": \"更新 ({{count}})\",\n        \"export_selected\": \"エクスポート ({{count}})\",\n        \"delete_selected\": \"削除 ({{count}})\",\n        \"current\": \"現在\",\n        \"current_badge\": \"現在\",\n        \"disabled\": \"無効\",\n        \"disabled_tooltip\": \"アカウントが無効です（refresh_tokenが失効または期限切れ）。再認証するかトークンを更新して再度有効にしてください。\",\n        \"proxy_disabled\": \"プロキシ無効\",\n        \"proxy_disabled_tooltip\": \"このアカウントは手動でプロキシが無効に設定されています。APIリクエストは処理しませんが、アプリ内では引き続き使用可能です。\",\n        \"enable_proxy\": \"プロキシを有効化\",\n        \"disable_proxy\": \"プロキシを無効化\",\n        \"enable_proxy_selected\": \"有効化 ({{count}})\",\n        \"disable_proxy_selected\": \"無効化 ({{count}})\",\n        \"proxy_disabled_reason_manual\": \"ユーザーにより手動で無効化\",\n        \"proxy_disabled_reason_batch\": \"一括で無効化\",\n        \"forbidden\": \"403\",\n        \"forbidden_badge\": \"403\",\n        \"forbidden_tooltip\": \"APIが403 Forbiddenを返しました。このアカウントにはGemini Code Assistの権限がありません\",\n        \"forbidden_msg\": \"閲覧禁止、自動更新をスキップ\",\n        \"status\": {\n            \"forbidden\": \"403 閲覧禁止\",\n            \"disabled\": \"アカウント無効\",\n            \"proxy_disabled\": \"プロキシ無効\"\n        },\n        \"error_details\": \"エラーの詳細\",\n        \"error_status\": \"エラーステータス\",\n        \"error_time\": \"検出時間\",\n        \"view_error\": \"原因を表示\",\n        \"click_to_verify\": \"クリックして確認\",\n        \"no_data\": \"データなし\",\n        \"last_used\": \"最終使用\",\n        \"reset_time\": \"リセット時間\",\n        \"switch_to\": \"このアカウントに切り替え\",\n        \"actions\": \"操作\",\n        \"device_fingerprint\": \"デバイス指紋\",\n        \"show_all_quotas\": \"すべてのクォータを表示\",\n        \"device_fingerprint_dialog\": {\n            \"title\": \"デバイス指紋\",\n            \"operations\": \"デバイス指紋操作\",\n            \"generate_and_bind\": \"生成してバインド\",\n            \"restore_original\": \"オリジナルを復元\",\n            \"open_storage_directory\": \"ストレージディレクトリを開く\",\n            \"current_storage\": \"現在のストレージ\",\n            \"effective\": \"有効\",\n            \"current_storage_desc\": \"storage.jsonから読み込み、アカウント切り替え時にバインド適用後に更新されます\",\n            \"account_binding\": \"アカウントバインド\",\n            \"pending_application\": \"適用待ち\",\n            \"account_binding_desc\": \"生成/復元後にバインドとして保存、アカウント切り替え時にstorage.jsonに書き込み\",\n            \"historical_fingerprints\": \"履歴指紋（復元/削除可能）\",\n            \"no_history\": \"履歴なし\",\n            \"current\": \"現在\",\n            \"restore\": \"復元\",\n            \"delete_version\": \"このバージョンを削除\",\n            \"confirm_generate_title\": \"生成してバインドしますか？\",\n            \"confirm_generate_desc\": \"新しいデバイス指紋セットを生成し、現在指紋として設定します。続行しますか？\",\n            \"confirm_restore_title\": \"オリジナル指紋を復元しますか？\",\n            \"confirm_restore_desc\": \"オリジナル指紋を復元し、現在指紋を上書きします。続行しますか？\",\n            \"cancel\": \"キャンセル\",\n            \"confirm\": \"確認\",\n            \"processing\": \"処理中...\",\n            \"loading\": \"読み込み中...\",\n            \"failed_to_load_device_info\": \"デバイス情報の読み込みに失敗しました\",\n            \"generation_failed\": \"生成に失敗しました\",\n            \"binding_failed\": \"バインドに失敗しました\",\n            \"restoration_failed\": \"復元に失敗しました\",\n            \"deletion_failed\": \"削除に失敗しました\",\n            \"directory_open_failed\": \"ディレクトリを開けません\",\n            \"generated_and_bound\": \"生成してバインドしました\",\n            \"restored\": \"復元しました\",\n            \"deleted\": \"削除しました\",\n            \"directory_opened\": \"ストレージディレクトリを開きました\",\n            \"original_fingerprint_not_found\": \"オリジナル指紋が見つかりません\",\n            \"storage_json_not_found\": \"storage.jsonが見つかりません。Antigravityが実行され、設定ファイルが生成されていることを確認してください\"\n        },\n        \"quota_protected\": \"保護中\",\n        \"details\": {\n            \"title\": \"クォータ詳細\",\n            \"model_quota\": \"モデルクォータ\",\n            \"protected_models\": \"保護されたモデル\"\n        },\n        \"toast\": {\n            \"proxy_enabled\": \"{{count}} 個のアカウントのプロキシを有効にしました\",\n            \"proxy_disabled\": \"{{count}} 個のアカウントのプロキシを無効にしました\"\n        },\n        \"add\": {\n            \"title\": \"アカウント追加\",\n            \"tabs\": {\n                \"oauth\": \"OAuth\",\n                \"token\": \"リフレッシュトークン\",\n                \"import\": \"DBインポート\"\n            },\n            \"oauth\": {\n                \"recommend\": \"推奨\",\n                \"desc\": \"デフォルトブラウザを開いてGoogleログインを行い、トークンを自動で取得・保存します。\",\n                \"btn_start\": \"OAuth開始\",\n                \"btn_waiting\": \"認証待ち...\",\n                \"btn_finish\": \"認証完了\",\n                \"copy_link\": \"認証リンクをコピー\",\n                \"copied\": \"コピーしました\",\n                \"link_label\": \"認証URL\",\n                \"link_click_to_copy\": \"クリックしてコピー\",\n                \"manual_hint\": \"ブラウザがリダイレクトされませんでしたか？ここにコールバックURLまたはコードを貼り付けてください：\",\n                \"manual_placeholder\": \"コールバックURLまたはコードを貼り付け...\",\n                \"error_no_flow\": \"まず「OAuth認証を開始」をクリックしてください\",\n                \"web_hint\": \"Googleログインページが新しいウィンドウで開きます\",\n                \"error_no_url\": \"OAuth URLを取得できませんでした\",\n                \"popup_blocked\": \"ポップアップがブロックされました\",\n                \"manual_submitting\": \"認可コードを送信中...\",\n                \"manual_submitted\": \"認可コードを送信しました。バックエンドで処理中です...\"\n            },\n            \"token\": {\n                \"label\": \"リフレッシュトークン\",\n                \"placeholder\": \"リフレッシュトークンをここに貼り付けてください（一括対応）\\n\\n対応形式:\\n1. 単一トークン (1//...)\\n2. JSON配列 (refresh_tokenフィールドを含む)\\n3. トークンを含む任意のテキスト (自動抽出)\",\n                \"hint\": \"ヒント: 複数のトークンやJSON配列を貼り付けて一括インポートできます。\",\n                \"error_token\": \"リフレッシュトークンを入力してください\",\n                \"batch_progress\": \"{{total}} 個中 {{current}} 個のアカウントをインポート中...\",\n                \"batch_success\": \"{{count}} 個のアカウントを正常にインポートしました\",\n                \"batch_partial\": \"インポート完了: 成功 {{success}}, 失敗 {{fail}}\",\n                \"batch_fail\": \"インポートに失敗しました\"\n            },\n            \"import\": {\n                \"scheme_a\": \"プランA: IDEのDBから\",\n                \"scheme_a_desc\": \"ローカルのAntigravity DBから現在ログイン中のアカウントを自動読み込みします。\",\n                \"btn_db\": \"現在のアカウントをインポート\",\n                \"or\": \"または\",\n                \"scheme_b\": \"プランB: V1のバックアップから\",\n                \"scheme_b_desc\": \"~/.antigravity-agentのスキャンを行いV1のアカウントデータを取得します。\",\n                \"btn_v1\": \"V1から一括インポート\",\n                \"btn_custom_db\": \"カスタムDBをインポート\"\n            },\n            \"btn_cancel\": \"キャンセル\",\n            \"btn_confirm\": \"確定\",\n            \"oauth_error\": \"OAuth失敗: {{error}}\",\n            \"status\": {\n                \"error_token\": \"リフレッシュトークンを入力してください\"\n            }\n        },\n        \"table\": {\n            \"email\": \"メールアドレス\",\n            \"quota\": \"モデルクォータ\",\n            \"last_used\": \"最終使用\",\n            \"actions\": \"操作\"\n        },\n        \"drag_to_reorder\": \"ドラッグして並べ替え\",\n        \"empty\": {\n            \"title\": \"アカウントなし\",\n            \"desc\": \"上の「アカウント追加」ボタンをクリックして最初のアカウントを追加してください\"\n        },\n        \"views\": {\n            \"list\": \"リスト表示\",\n            \"grid\": \"グリッド表示\"\n        },\n        \"dialog\": {\n            \"add_title\": \"アカウント追加\",\n            \"batch_delete_title\": \"一括削除の確認\",\n            \"delete_title\": \"削除の確認\",\n            \"batch_delete_msg\": \"選択した {{count}} 個のアカウントを削除してもよろしいですか？この操作は取り消せません。\",\n            \"delete_msg\": \"このアカウントを削除してもよろしいですか？この操作は取り消せません。\",\n            \"refresh_title\": \"クォータ更新\",\n            \"batch_refresh_title\": \"一括更新\",\n            \"refresh_msg\": \"現在のアカウントのクォータを更新してもよろしいですか？\",\n            \"batch_refresh_msg\": \"選択した {{count}} 個のアカウントのクォータを更新してもよろしいですか？これには時間がかかる場合があります。\",\n            \"disable_proxy_title\": \"プロキシ無効化\",\n            \"disable_proxy_msg\": \"このアカウントのプロキシを無効にしてもよろしいですか？アカウントはアプリ内で引き続き使用可能です。\",\n            \"enable_proxy_title\": \"プロキシ有効化\",\n            \"enable_proxy_msg\": \"このアカウントのプロキシを再度有効にしてもよろしいですか？\",\n            \"batch_warmup_msg\": \"選択した{{count}}個のアカウントに対して、直ちにウォームアップを開始してもよろしいですか？\",\n            \"batch_warmup_title\": \"一括手動ウォームアップ\",\n            \"warmup_all_msg\": \"対象となるすべてのアカウントに対して、直ちにウォームアップタスクを開始してもよろしいですか？これにより、Googleサービスへの最小限のトラフィックが送信され、クォータサイクルがリセットされます。\",\n            \"warmup_all_title\": \"完全手動ウォームアップ\"\n        },\n        \"import_fail\": \"インポートに失敗しました: {{error}}\",\n        \"import_invalid_format\": \"無効なJSON形式です。ファイルにemailとrefresh_tokenフィールドが含まれていることを確認してください\",\n        \"import_json\": \"インポート\",\n        \"import_partial\": \"インポート完了: {{success}}件成功、{{fail}}件失敗\",\n        \"import_success\": \"{{count}}件のアカウントを正常にインポートしました\",\n        \"warmup_all\": \"ワンクリックウォームアップ\",\n        \"warmup_batch_triggered\": \"{{count}}個のアカウントのウォームアップタスクを開始しました\",\n        \"warmup_now\": \"今すぐウォームアップ\",\n        \"warmup_selected\": \"ウォームアップ ({{count}})\",\n        \"warmup_this\": \"このアカウントをウォームアップ\"\n    },\n    \"settings\": {\n        \"save\": \"設定を保存\",\n        \"tabs\": {\n            \"general\": \"一般\",\n            \"account\": \"アカウント\",\n            \"proxy\": \"プロキシ設定\",\n            \"advanced\": \"詳細設定\",\n            \"debug\": \"デバッグ\",\n            \"about\": \"情報\"\n        },\n        \"general\": {\n            \"title\": \"一般設定\",\n            \"language\": \"言語\",\n            \"theme\": \"テーマ\",\n            \"theme_light\": \"ライト\",\n            \"theme_dark\": \"ダーク\",\n            \"theme_system\": \"システム\",\n            \"auto_launch\": \"起動時に実行\",\n            \"auto_launch_enabled\": \"有効\",\n            \"auto_launch_disabled\": \"無効\",\n            \"auto_launch_desc\": \"システム起動時にAntigravity Toolsを自動的に起動する\",\n            \"auto_check_update\": \"更新を自動確認\",\n            \"auto_check_update_desc\": \"起動時に新しいバージョンを自動的に確認します\",\n            \"auto_check_update_disabled\": \"自動確認は無効です\",\n            \"auto_check_update_enabled\": \"自動確認は有効です\",\n            \"update_check_interval\": \"確認間隔（時間）\",\n            \"update_check_interval_desc\": \"自動確認の間隔を設定します（1-168時間）\",\n            \"update_check_interval_saved\": \"確認間隔の設定を保存しました\"\n        },\n        \"account\": {\n            \"title\": \"アカウント設定\",\n            \"auto_refresh\": \"クォータの自動更新\",\n            \"auto_refresh_desc\": \"すべてのアカウントのクォータ情報を定期的に自動更新する\",\n            \"refresh_interval\": \"更新間隔 (分)\",\n            \"auto_sync\": \"現在のアカウントの自動同期\",\n            \"auto_sync_desc\": \"現在アクティブなアカウントの情報を定期的に自動同期する\",\n            \"sync_interval\": \"同期間隔 (秒)\",\n            \"always_on\": \"常にオン\"\n        },\n        \"pinned_quota_models\": {\n            \"title\": \"ピン留めするクォータモデル\",\n            \"desc\": \"アカウントリストに表示するモデル配額を選択します。選択していないモデルは詳細ポップアップでのみ表示されます\"\n        },\n        \"proxy\": {\n            \"title\": \"APIプロキシサービス\"\n        },\n        \"proxy_pool\": {\n            \"title\": \"プロキシプール\",\n            \"strategy_priority\": \"優先度順\",\n            \"strategy_round_robin\": \"ラウンドロビン\",\n            \"strategy_random\": \"ランダム\",\n            \"strategy_least_connections\": \"最小接続数\",\n            \"test_all\": \"すべてテスト\",\n            \"batch_import\": \"インポート\",\n            \"binding_manager\": \"バインディング\",\n            \"add_proxy\": \"プロキシを追加\",\n            \"edit_proxy\": \"プロキシを編集\",\n            \"name\": \"名前\",\n            \"url\": \"プロキシURL\",\n            \"username\": \"ユーザー名\",\n            \"password\": \"パスワード\",\n            \"max_accounts\": \"最大アカウント数\",\n            \"max_accounts_hint\": \"0 = 無制限\",\n            \"priority\": \"優先度\",\n            \"priority_hint\": \"小さいほど優先\",\n            \"health_check_url\": \"ヘルスチェックURL\",\n            \"tags\": \"タグ\",\n            \"add_tag_placeholder\": \"タグを追加...\",\n            \"seconds\": \"秒\",\n            \"test_completed\": \"ヘルスチェック完了\",\n            \"test_failed\": \"ヘルスチェック失敗\",\n            \"confirm_delete\": \"このプロキシを削除してもよろしいですか？\",\n            \"empty\": \"プロキシがありません\",\n            \"column_priority\": \"優先度\",\n            \"column_status\": \"ステータス\",\n            \"column_details\": \"プロキシ詳細\",\n            \"column_bindings\": \"バインディング\",\n            \"import_title\": \"プロキシの一括インポート\",\n            \"import_label\": \"プロキシリストを貼り付け（1行に1つ）\",\n            \"import_hint\": \"対応形式: protocol://user:pass@host:port, host:port:user:pass\",\n            \"import_preview\": \"プレビュー\",\n            \"import_confirm\": \"{{count}} 個のプロキシをインポート\",\n            \"no_valid_proxies\": \"有効なプロキシが見つかりません\",\n            \"binding\": {\n                \"title\": \"アカウントプロキシバインディング\",\n                \"load_failed\": \"バインディングの読み込みに失敗しました\",\n                \"unbind_success\": \"バインド解除しました\",\n                \"bind_success\": \"バインドしました\",\n                \"update_failed\": \"バインディングの更新に失敗しました\",\n                \"assigned_proxy\": \"割り当て済みプロキシ\",\n                \"default_strategy\": \"デフォルト（戦略に従う）\"\n            },\n            \"status\": {\n                \"inactive\": \"非アクティブ\",\n                \"checking\": \"確認中\",\n                \"healthy\": \"正常\",\n                \"timeout\": \"タイムアウト\"\n            }\n        },\n        \"advanced\": {\n            \"title\": \"詳細設定\",\n            \"export_path\": \"デフォルトのエクスポート先\",\n            \"export_path_placeholder\": \"未設定 (毎回確認)\",\n            \"default_export_path_desc\": \"確認なしで直接このフォルダにファイルを保存します\",\n            \"select_btn\": \"選択\",\n            \"open_btn\": \"開く\",\n            \"data_dir\": \"データディレクトリ\",\n            \"data_dir_desc\": \"アカウントデータと設定ファイルの保存場所\",\n            \"antigravity_path\": \"Antigravityのパス\",\n            \"antigravity_path_placeholder\": \"未設定 (自動検出を使用)\",\n            \"antigravity_path_desc\": \"Antigravityを非標準の場所にインストールした場合は、実行ファイルのパスをここで手動指定できます (MacOSでは.appを指します)。\",\n            \"antigravity_path_select\": \"Antigravity実行ファイルを選択\",\n            \"antigravity_path_detected\": \"検出されたパスを更新しました\",\n            \"detect_btn\": \"検出\",\n            \"antigravity_args\": \"Antigravityの起動引数\",\n            \"antigravity_args_placeholder\": \"--user-data-dir=/path/to/data --some-other-flag\",\n            \"antigravity_args_desc\": \"Antigravityの起動引数を指定します。例: --user-data-dir でユーザーデータディレクトリを指定\",\n            \"detect_args_btn\": \"検出\",\n            \"antigravity_args_detected\": \"起動引数を更新しました\",\n            \"antigravity_args_detect_error\": \"起動引数の検出に失敗しました\",\n            \"accounts_page_size\": \"アカウントの表示数\",\n            \"page_size_auto\": \"自動計算 (推奨)\",\n            \"page_size_desc\": \"1ページに表示するアカウント数を設定します。「自動計算」を選択するとウィンドウサイズに合わせて動的に調整します。\",\n            \"logs_title\": \"ログのメンテナンス\",\n            \"logs_desc\": \"ログキャッシュファイルをクリアします。アカウントデータには影響しません。\",\n            \"clear_logs\": \"ログキャッシュをクリア\",\n            \"clear_logs_title\": \"ログクリアの確認\",\n            \"clear_logs_msg\": \"すべてのログキャッシュファイルをクリアしてもよろしいですか？\",\n            \"logs_cleared\": \"ログキャッシュをクリアしました\",\n            \"antigravity_cache_title\": \"Antigravityキャッシュクリア\",\n            \"antigravity_cache_desc\": \"Antigravityアプリのキャッシュをクリアすると、ログイン失敗、バージョン検証エラー、OAuth認証失敗などの問題を解決できます。\",\n            \"antigravity_cache_warning\": \"キャッシュをクリアする前にAntigravityアプリを完全に終了してください。\",\n            \"clear_antigravity_cache\": \"Antigravityキャッシュをクリア\",\n            \"clear_cache_confirm_title\": \"Antigravityキャッシュのクリアを確認\",\n            \"clear_cache_confirm_msg\": \"以下のキャッシュディレクトリがクリアされます：\",\n            \"cache_cleared_success\": \"キャッシュをクリアしました。{{size}} MB 解放されました\",\n            \"cache_not_found\": \"Antigravityキャッシュディレクトリが見つかりません\",\n            \"debug_logs_title\": \"デバッグログ\",\n            \"debug_logs_enable_desc\": \"有効にすると、完全なリクエストとレスポンスチェーンが記録されます。問題のトラブルシューティング時のみ有効にすることをお勧めします。\",\n            \"debug_logs_desc\": \"完全なチェーンを記録：元の入力、変換後のv1internalリクエスト、およびアップストリームレスポンス。トラブルシューティング専用で、機密データが含まれる可能性があります。\",\n            \"debug_log_dir\": \"デバッグログ出力ディレクトリ\",\n            \"debug_log_dir_hint\": \"未入力の場合はデフォルトディレクトリを使用：{{path}}/debug_logs\",\n            \"debug_log_dir_select\": \"デバッグログ出力ディレクトリを選択\",\n            \"http_api_desc\": \"外部プログラム（VS Codeプラグインなど）用のローカルHTTPインターフェースを提供します。\",\n            \"http_api_enabled\": \"HTTP APIを有効にする\",\n            \"http_api_enabled_desc\": \"有効にすると、外部プログラムがHTTPインターフェースを介してアカウントを管理できるようになります\",\n            \"http_api_port\": \"リッスンポート\",\n            \"http_api_port_desc\": \"ポートを変更した後は再起動が必要です。ポートが競合する場合は、別の利用可能なポートを使用してください。\",\n            \"http_api_port_invalid\": \"無効なポート番号です（範囲: 1024-65535）\",\n            \"http_api_port_placeholder\": \"デフォルトポート 19527\",\n            \"http_api_restart_required\": \"⚠️ 適用するには再起動が必要です\",\n            \"http_api_settings_saved\": \"HTTP API設定を保存しました。適用するには再起動が必要です\",\n            \"http_api_title\": \"HTTP APIサービス\"\n        },\n        \"debug\": {\n            \"title\": \"デバッグコンソール\",\n            \"desc\": \"デバッグやトラブルシューティング用のリアルタイムアプリログを表示\",\n            \"enabled\": \"有効\",\n            \"disabled\": \"無効\",\n            \"disabled_hint\": \"デバッグコンソールはオフです\",\n            \"disabled_desc\": \"有効にするとアプリログのリアルタイム記録を開始します\",\n            \"console_title\": \"デバッグコンソール\",\n            \"console_desc\": \"問題のトラブルシューティング用にリアルタイムアプリログを表示します。\",\n            \"enable_desc\": \"有効にするとバックエンドログをキャプチャして表示します。\",\n            \"open_btn\": \"コンソールを開く\"\n        },\n        \"menu\": {\n            \"title\": \"メニュー表示設定\",\n            \"desc\": \"メニューバーに表示する機能項目を選択します。使用頻度の低いメニューを非表示にしてスペースを節約できます。\",\n            \"selected_items_note\": \"選択された項目はトップメニューバーに表示されます。\",\n            \"required\": \"必須\"\n        },\n        \"about\": {\n            \"title\": \"情報\",\n            \"version\": \"アプリバージョン\",\n            \"tech_stack\": \"技術スタック\",\n            \"author\": \"開発者\",\n            \"wechat\": \"WeChat\",\n            \"github\": \"GitHub\",\n            \"view_code\": \"コードを表示\",\n            \"copyright\": \"Copyright © 2025-2026 Antigravity. All rights reserved.\",\n            \"check_update\": \"アップデートを確認\",\n            \"checking_update\": \"確認中...\",\n            \"latest_version\": \"最新バージョンです\",\n            \"new_version_available\": \"新バージョン {{version}} が利用可能です\",\n            \"download_update\": \"ダウンロード\",\n            \"update_check_failed\": \"アップデートの確認に失敗しました\",\n            \"support_btn\": \"作者をサポート\",\n            \"support_title\": \"寄付とサポート\",\n            \"support_desc\": \"このプロジェクトがお役に立てば、コーヒーを一杯ご馳走してください！皆様のサポートが、プロジェクトを継続する最大の動機です。\",\n            \"support_alipay\": \"Alipay\",\n            \"support_wechat\": \"WeChat Pay\",\n            \"support_buymeacoffee\": \"コーヒーを奢る\"\n        },\n        \"advanced_thinking\": {\n            \"title\": \"推論とグローバル設定\",\n            \"description\": \"推論能力、画像モード、グローバル命令を集中管理します。\"\n        },\n        \"thinking_budget\": {\n            \"title\": \"思考予算 (Thinking Budget)\",\n            \"description\": \"AI の思考 Token 予算を制御します。一部のモデル（Flash、-thinking サフィックスなど）は、アップストリームにより 24576 の上限があります。\",\n            \"mode_label\": \"処理模式\",\n            \"mode\": {\n                \"auto\": \"自動制限\",\n                \"passthrough\": \"パススルー\",\n                \"custom\": \"カスタム\"\n            },\n            \"auto_hint\": \"自動モード: Gemini/Thinking および Web 検索リクエストを、エラーを避けるため自動的に 24576 に制限します。\",\n            \"passthrough_warning\": \"パススルー: 呼び出し元の値を直接使用します。高い値は失敗の原因となる可能性があります。\",\n            \"custom_value_hint\": \"推奨: 24576 (Flash) または 51200 (拡張思考)\",\n            \"tokens\": \"tokens\"\n        },\n        \"image_thinking_mode\": {\n            \"title\": \"画像思考モード (Image Thinking Mode)\",\n            \"hint\": \"画質と生成プロセスに影響します\",\n            \"options\": {\n                \"enabled\": \"有効\",\n                \"disabled\": \"無効\",\n                \"enabled_desc\": \"オン: 思考プロセスを保持し、スケッチと最終画像を返します。\",\n                \"disabled_desc\": \"オフ: 思考プロセスを無効にし、1枚の超鮮明な画像を生成します (画質優先)。\"\n            }\n        },\n        \"global_system_prompt\": {\n            \"title\": \"グローバルシステムプロンプト (Global System Prompt)\",\n            \"hint\": \"すべてのリクエストの systemInstruction に自動的に注入されます\",\n            \"placeholder\": \"グローバルシステムプロンプトを入力してください...\\n例: あなたは React と Rust に精通したシニアフルスタックエンジニアです。日本語で回答してください。\",\n            \"char_count\": \"{{count}} 文字\",\n            \"long_prompt_warning\": \"プロンプトがかなり長く（2000文字以上）、コンテキスト スペースを大幅に消費する可能性があります。\"\n        },\n        \"quota_protection\": {\n            \"auto_restore_info\": \"クォータがリセットされると、アカウントは自動的に再度有効になります\",\n            \"enable\": \"クォータ保護を有効にする\",\n            \"enable_desc\": \"アカウントのクォータがしきい値を下回った場合に自動的にプロキシを無効にし、クォータがリセットされたときに自動的に復元します\",\n            \"example\": \"例: {{percentage}}%の場合、クォータ合計が{{total}}のアカウントは、残りが{{threshold}}以下になると無効になります\",\n            \"monitored_models_desc\": \"少なくとも1つ選択してください。選択したモデルのいずれかがしきい値を下回ると保護がトリガーされます\",\n            \"monitored_models_label\": \"監視対象モデル（トリガー条件）\",\n            \"range\": \"範囲\",\n            \"threshold_label\": \"予約クォータの割合\",\n            \"title\": \"クォータ保護\"\n        },\n        \"warmup\": {\n            \"desc\": \"すべてのモデルを自動的に監視し、クォータが100%に達すると直ちにウォームアップをトリガーして、モデルをウォーム状態に保ちます\",\n            \"title\": \"スマートウォームアップ\"\n        },\n        \"branding\": {\n            \"title\": \"Antigravity Tools\",\n            \"subtitle\": \"プロフェッショナルなアカウント管理\"\n        }\n    },\n    \"tray\": {\n        \"current\": \"現在\",\n        \"quota\": \"クォータ\",\n        \"switch_next\": \"次のアカウントに切り替え\",\n        \"refresh_current\": \"現在のクォータを更新\",\n        \"show_window\": \"メインウィンドウを表示\",\n        \"quit\": \"アプリを終了\",\n        \"no_account\": \"アカウントなし\",\n        \"unknown_quota\": \"不明 (クリックして更新)\",\n        \"forbidden\": \"アカウント使用不可\"\n    },\n    \"proxy\": {\n        \"title\": \"APIプロキシサービス\",\n        \"error\": {\n            \"load_failed\": \"設定の読み込みに失敗しました\"\n        },\n        \"status\": {\n            \"running\": \"サービス稼働中\",\n            \"stopped\": \"サービス停止中\",\n            \"accounts_available\": \"{{count}} 個のアカウントが利用可能\",\n            \"processing\": \"処理中...\"\n        },\n        \"action\": {\n            \"start\": \"サービス開始\",\n            \"stop\": \"サービス停止\"\n        },\n        \"config\": {\n            \"title\": \"サービス構成\",\n            \"request\": {\n                \"user_agent\": \"User-Agent オーバーライド\",\n                \"user_agent_tooltip\": \"アップストリームAPIに送信するUser-Agentヘッダーを上書きします。空欄の場合はデフォルトを使用します。\",\n                \"user_agent_hint\": \"現在のデフォルト: antigravity/<version> <os>/<arch>\",\n                \"user_agent_placeholder\": \"カスタムUser-Agent文字列を入力...\"\n            },\n            \"port\": \"リスニングポート\",\n            \"port_tooltip\": \"ローカルAPIプロキシがリッスンするTCPポート。変更するにはサービスを停止し、再起動して適用してください。\",\n            \"port_hint\": \"デフォルト 8045、適用には再起動が必要\",\n            \"auto_start\": \"アプリ起動時に自動開始\",\n            \"auto_start_tooltip\": \"アプリの起動時にローカルAPIプロキシサービスを自動的に開始します。\",\n            \"allow_lan_access\": \"LANアクセスの許可\",\n            \"allow_lan_access_tooltip\": \"有効にすると、サービスは 0.0.0.0 にバインドされ、LAN内の他のデバイスからアクセスできるようになります。認証を有効にし、APIキーを保護してください。適用には再起動が必要です。\",\n            \"allow_lan_access_hint_enabled\": \"🌐 0.0.0.0 で待機中、LAN内のデバイスがアクセス可能\",\n            \"allow_lan_access_hint_disabled\": \"🔒 127.0.0.1 のみで待機中、localhostアクセス (プライバシー優先)\",\n            \"allow_lan_access_warning\": \"⚠️ 有効にするとLAN内のデバイスがアクセス可能になります。APIキーを安全に保ってください\",\n            \"allow_lan_access_restart_hint\": \"ℹ️ 適用にはサービスの再起動が必要です\",\n            \"api_key\": \"APIキー\",\n            \"api_key_tooltip\": \"プロキシ認証が有効な場合にクライアントが使用する共通の秘密キー。キーを再生成すると古いキーは即座に無効になります。\",\n            \"btn_regenerate\": \"キーを再生成\",\n            \"btn_edit\": \"編集\",\n            \"btn_save\": \"保存\",\n            \"btn_copy\": \"コピー\",\n            \"btn_copied\": \"コピーしました\",\n            \"warning_key\": \"注意: APIキーを安全に保管してください。他人に共有しないでください。\",\n            \"api_key_invalid\": \"APIキーの形式が無効です。sk- で始まり、10文字以上である必要があります\",\n            \"api_key_updated\": \"APIキーが更新されました\",\n            \"admin_password\": \"Web UI 管理パスワード\",\n            \"admin_password_tooltip\": \"Web管理コンソールにログインするためのパスワード。空の場合、デフォルトでAPIキーが使用されます。\",\n            \"admin_password_default\": \"（APIキーと同じ）\",\n            \"admin_password_placeholder\": \"新しいパスワードを入力してください。空欄の場合はAPIキーを使用します\",\n            \"admin_password_hint\": \"ヒント：Docker/Webデプロイシナリオでは、独立したログインパスワードを設定してAPIキーのセキュリティを向上させることができます。\",\n            \"admin_password_short\": \"パスワードが短すぎます（最低4文字）\",\n            \"admin_password_updated\": \"Web UI ログインパスワードが更新されました\",\n            \"auth\": {\n                \"title\": \"認証\",\n                \"title_tooltip\": \"着信リクエストの認証が必要かどうか、およびどのルートを保護するかを制御します。\",\n                \"enabled\": \"有効\",\n                \"enabled_tooltip\": \"認証モードを切り替えて認証をオン/オフにします。有効な場合、クライアントは Authorization: Bearer <API_KEY> または x-api-key を含める必要があります。\",\n                \"mode\": \"モード\",\n                \"mode_tooltip\": \"APIキーが必要なルートを選択します: Off = 認証なし; All = すべて保護; All except Health = /healthz 以外を保護; Auto = localhost以外は All except Health。\",\n                \"hint\": \"有効な場合、クライアントは Authorization: Bearer ... でAPIキーを送る必要があります（ヘルスチェック以外）。\",\n                \"modes\": {\n                    \"off\": \"オフ (開放)\",\n                    \"strict\": \"すべて (厳格)\",\n                    \"all_except_health\": \"ヘルスチェック以外すべて\",\n                    \"auto\": \"自動 (推奨)\"\n                }\n            },\n            \"zai\": {\n                \"title\": \"z.ai (GLM) プロバイダー\",\n                \"title_tooltip\": \"Claudeプロトコル用のオプションのAnthropic互換アップストリーム。Anthropicエンドポイントにのみ影響し、Googleアカウントのルーティングは変更されません。\",\n                \"subtitle\": \"Claudeプロトコル専用のオプションのAnthropic互換アップストリーム。\",\n                \"enabled\": \"有効\",\n                \"enabled_tooltip\": \"選択したディスパッチモードに従って、Anthropicリクエストのz.aiルーティングを有効にします。\",\n                \"base_url\": \"ベースURL\",\n                \"base_url_tooltip\": \"Anthropic互換のベースURL。プロキシは /v1/messages などのパスを追加します。カスタムゲートウェイを使用しない限りデフォルトのままで構いません。\",\n                \"dispatch_mode\": \"ディスパッチモード\",\n                \"dispatch_mode_tooltip\": \"Anthropicリクエストにz.aiを使用するタイミングを制御します: Off は無効; All Anthropic requests はすべて転送; Pooled はGoogleアカウントとのラウンドロビンにz.aiを追加; Fallback はGoogleアカウントがない場合のみz.aiを使用。\",\n                \"api_key\": \"APIキー\",\n                \"api_key_tooltip\": \"z.aiへのリクエスト認証に使用するAPIキー。ローカルに保存され、z.aiとMCP機能に必要です。\",\n                \"api_key_placeholder\": \"z.aiのAPIキーをここに貼り付けてください\",\n                \"warning\": \"注意: このキーはアプリデータディレクトリにローカル保存されます。\",\n                \"models\": {\n                    \"title\": \"モデルマッピング\",\n                    \"title_tooltip\": \"利用可能なz.aiモデルIDを取得し、Claudeモデル名がどのようにz.aiモデルIDに翻訳されるかを構成します。\",\n                    \"refresh\": \"モデルを取得\",\n                    \"btn_edit\": \"編集\",\n                    \"btn_save\": \"保存\",\n                    \"refreshing\": \"取得中...\",\n                    \"hint\": \"利用可能なモデル: {{count}}。提案を選択するか、カスタムモデルIDを入力してください。\",\n                    \"error\": \"モデルの取得に失敗しました: {{error}}\",\n                    \"select_placeholder\": \"モデルを選択...\",\n                    \"opus\": \"Opusファミリー → z.aiモデル\",\n                    \"opus_tooltip\": \"リクエストモデルに \\\"opus\\\" が含まれる場合 (例: claude-opus-*) に使用されるデフォルトのz.aiモデルID。\",\n                    \"sonnet\": \"Sonnetファミリー → z.aiモデル\",\n                    \"sonnet_tooltip\": \"その他のClaudeモデル (例: claude-sonnet-* やほとんどの claude-* リクエスト) に使用されるデフォルトのz.aiモデルID。\",\n                    \"haiku\": \"Haikuファミリー → z.aiモデル\",\n                    \"haiku_tooltip\": \"リクエストモデルに \\\"haiku\\\" が含まれる場合 (例: claude-haiku-*) に使用されるデフォルトのz.aiモデルID。\",\n                    \"advanced_title\": \"高度なオーバーライド\",\n                    \"advanced_tooltip\": \"オプションの完全一致オーバーライド。リクエストモデル文字列がルールキーと一致する場合、マップされたz.aiモデルIDに置き換えられます。\",\n                    \"from_label\": \"リクエストモデル\",\n                    \"to_label\": \"z.aiモデル\",\n                    \"add_rule\": \"追加\",\n                    \"empty\": \"カスタムルールは設定されていません。\",\n                    \"from_placeholder\": \"変換元 (例: claude-3-opus)\",\n                    \"to_placeholder\": \"変換先 (例: glm-4)\"\n                },\n                \"modes\": {\n                    \"off\": \"オフ\",\n                    \"exclusive\": \"すべてのAnthropicリクエスト\",\n                    \"pooled\": \"プール (1スロット)\",\n                    \"fallback\": \"フォールバックのみ\"\n                },\n                \"mcp\": {\n                    \"title\": \"MCPサーバー (ローカルプロキシ経由)\",\n                    \"title_tooltip\": \"MCPクライアントが接続できるように、このローカルプロキシでオプションの /mcp/* エンドポイントを公開します。サービス稼働中、z.ai構成済み、およびトグル有効時に利用可能です。\",\n                    \"enabled\": \"MCPプロキシを有効化\",\n                    \"enabled_tooltip\": \"MCPエンドポイントのマスタースイッチ。オフの場合、すべての /mcp/* ルートは 404 を返します。\",\n                    \"web_search\": \"Web検索\",\n                    \"web_search_tooltip\": \"/mcp/web_search_prime/mcp を公開し、リクエストを z.ai Web Search MCP に転送します。\",\n                    \"web_reader\": \"Webリーダー\",\n                    \"web_reader_tooltip\": \"/mcp/web_reader/mcp を公開し、リクエストを z.ai Web Reader MCP に転送します。\",\n                    \"vision\": \"Vision\",\n                    \"vision_tooltip\": \"z.aiをバックエンドとするVisionツールを提供する /mcp/zai-mcp-server/mcp (ローカルMCPサーバー) を公開します。\",\n                    \"local_endpoints\": \"ローカルエンドポイント (MCPクライアントに設定するURL):\",\n                    \"local_endpoints_tooltip\": \"これらのURLをMCPクライアントで使用してください。APIプロキシと同じホスト/ポートを共有し、プロキシ認証ポリシーに従います。\"\n                }\n            },\n            \"request_timeout\": \"リクエストタイムアウト\",\n            \"request_timeout_tooltip\": \"プロキシがアップストリームの応答を待機する最大時間（秒）。長い生成の場合は増やしてください。適用には再起動が必要です。\",\n            \"request_timeout_hint\": \"デフォルト 120秒、範囲 30-7200秒。適用には再起動が必要。\",\n            \"enable_logging\": \"リクエストのログ記録を有効化\",\n            \"enable_logging_hint\": \"デバッグ用に履歴を記録 (わずかなパフォーマンス低下あり)\",\n            \"upstream_proxy\": {\n                \"title\": \"グローバルアップストリームプロキシ (グローバルプロキシ)\",\n                \"desc\": \"有効にすると、すべての外部リクエスト (APIプロキシ、トークン更新、クォータ確認、アップデート確認) はこのプロキシ経由で行われます。\",\n                \"desc_short\": \"プロキシプールに適合するアカウントがない場合の共通出口またはフォールバック方案として利用されます。\",\n                \"enable\": \"アップストリームプロキシを有効化\",\n                \"url\": \"プロキシURL\",\n                \"url_placeholder\": \"例: http://127.0.0.1:7890 や socks5://127.0.0.1:7890\",\n                \"tip\": \"HTTP, HTTPS, SOCKS5 をサポートしています。\",\n                \"socks5h_hint\": \"上游のリスク管理を回避し、リモートDNS解析（Remote DNS）を維持したい場合は、プロトコルを手動で socks5h:// に変更してください。\",\n                \"validation_error\": \"プロキシを有効にする場合、プロキシURLは必須です\",\n                \"restart_hint\": \"プロキシ設定が保存されました。変更を適用するにはアプリを再起動してください。\"\n            },\n            \"scheduling\": {\n                \"title\": \"アカウントローテーションとスケジューリング\",\n                \"title_tooltip\": \"セッションをアカウントにバインドする方法と、レート制限の処理方法を制御します。\",\n                \"subtitle\": \"すべてのプロトコル (OpenAI/Gemini/Claude) のプロンプトキャッシュとレート制限処理を最適化します。\",\n                \"mode\": \"スケジューリングモード\",\n                \"mode_tooltip\": \"キャッシュ優先: セッションをアカウントに固定し、レート制限時は待機 (キャッシュ効率最大); バランス: セッションを固定しつつレート制限時は切り替え; パフォーマンス: 標準的なラウンドロビン。\",\n                \"modes\": {\n                    \"CacheFirst\": \"キャッシュ優先\",\n                    \"Balance\": \"バランス\",\n                    \"PerformanceFirst\": \"パフォーマンス\"\n                },\n                \"modes_desc\": {\n                    \"CacheFirst\": \"セッションをアカウントに固定し、制限時は正確に待機します (プロンプトキャッシュのヒット率を最大化)。\",\n                    \"Balance\": \"セッションを固定しつつ、制限時は利用可能なアカウントに自動切り替えします (キャッシュと可用性のバランス)。\",\n                    \"PerformanceFirst\": \"セッション固定なしの純粋なラウンドロビン方式 (高並列リクエストに最適)。\"\n                },\n                \"max_wait\": \"最大待機時間 (秒)\",\n                \"max_wait_tooltip\": \"「キャッシュ優先」モードでのみ使用: レートリミットのリセット時間がこの値以下の場合、切り替えずに待機します。\",\n                \"clear_bindings\": \"セッションバインディングをクリア\",\n                \"clear_bindings_tooltip\": \"すべてのセッションとアカウントの紐付けを強制リセットし、次のリクエストでアカウントを再割り当てします。\",\n                \"clear_rate_limits\": \"レート制限の記録をクリア\",\n                \"clear_rate_limits_tooltip\": \"すべてのアカウントのローカルレート制限記録を即座にクリアし、次のリクエストで直接アップストリームを試行するように強制します。\",\n                \"fixed_account\": \"固定アカウントモード\",\n                \"fixed_account_tooltip\": \"有効にすると、すべてのAPIリクエストは、アカウントを切り替える代わりに、選択されたアカウントのみを使用します。\",\n                \"round_robin_set\": \"ラウンドロビンモードが有効\",\n                \"fixed_account_set\": \"固定アカウントモードが有効\",\n                \"account_changed\": \"アカウントが次へ変更されました：{{email}}\",\n                \"circuit_breaker\": {\n                    \"title\": \"アダプティブサーキットブレーカー\",\n                    \"tooltip\": \"クォータを使い果たして繰り返し失敗するアカウントのロックアウト時間を自動的に増やします。これにより、デッドアカウントでのAPI呼び出しの浪費を防ぎ、一時的なエラーから迅速に回復できるようになります。\",\n                    \"backoff_levels\": \"バックオフレベル (秒)\",\n                    \"level\": \"Lv {{level}}\",\n                    \"input_placeholder\": \"60, 300, 1800, 7200\",\n                    \"invalid_format\": \"無効な形式。カンマ区切りの数字を使用してください (例: 60, 300)\",\n                    \"clear_records\": \"レート制限の記録をすべてクリア\"\n                }\n            },\n            \"experimental\": {\n                \"title\": \"実験設定 (Experimental)\",\n                \"title_tooltip\": \"探索的な機能であり、将来のバージョンで調整または削除される可能性があります。\",\n                \"enable_usage_scaling\": \"使用量スケーリングを有効にする\",\n                \"enable_usage_scaling_tooltip\": \"Claudeプロトコル向け。入力が30kトークンを超えた際に積極的なスケーリングを行い、頻繁なクライアント側圧縮を防止します。注意：有効化後はクライアントに表示される使用量が実際の課金とは異なります。\",\n                \"context_compression_threshold_l1\": \"L1 圧縮しきい値 (ツール履歴のクリーンアップ)\",\n                \"context_compression_threshold_l1_tooltip\": \"古いツール実行記録を削除してスペースを節約します。推奨値: 0.4 (40%)\",\n                \"context_compression_threshold_l2\": \"L2 圧縮しきい値 (思考プロセスの圧縮)\",\n                \"context_compression_threshold_l2_tooltip\": \"署名を保持したまま、初期の思考ブロックを圧縮します。推奨値: 0.55 (55%)\",\n                \"context_compression_threshold_l3\": \"L3 圧縮しきい値 (要約によるリセット)\",\n                \"context_compression_threshold_l3_tooltip\": \"XML状態要約を生成してセッションをリセットします。最もトークン効率が良い手段です。推奨値: 0.7 (70%)\"\n            },\n            \"circuit_breaker\": {\n                \"title\": \"アダプティブ・サーキットブレーカー\",\n                \"tooltip\": \"クォータ不足によるエラーが頻発するアカウントのロックアウト期間を自動的に延長します。これにより、回復不能なエラーが発生しているアカウントでのAPI呼び出しの無駄を省きつつ、一時的なエラーからの迅速な復旧を可能にします。\",\n                \"backoff_levels\": \"バックオフ・レベル（秒）\",\n                \"input_placeholder\": \"バックオフ期間を秒単位でカンマ区切りで入力してください\",\n                \"level\": \"Lv {{level}}\",\n                \"invalid_format\": \"形式が正しくありません。カンマ区切りの数値（例: 60, 300）を使用してください\",\n                \"clear_records\": \"すべてのレート制限記録を消去\"\n            },\n            \"opencode_sync\": {\n                \"card_title\": \"OpenCode\",\n                \"status\": {\n                    \"detecting\": \"検出中...\",\n                    \"installed\": \"インストール済み ({{version}})\",\n                    \"not_installed\": \"未インストール\",\n                    \"synced\": \"同期済み\",\n                    \"not_synced\": \"未同期\",\n                    \"current_base_url\": \"現在のベースURL\"\n                },\n                \"sync_accounts\": \"アカウント情報を antigravity-accounts.json に同期\",\n                \"btn_sync\": \"設定を同期\",\n                \"btn_view\": \"設定を表示\",\n                \"btn_restore\": \"復元\",\n                \"btn_restore_backup\": \"バックアップから復元\",\n                \"btn_clear\": \"設定をクリア\",\n                \"clear_confirm_title\": \"設定のクリアを確認\",\n                \"clear_confirm_message\": \"OpenCode の設定をクリアしてもよろしいですか？設定ファイルが削除されます。\",\n                \"toast\": {\n                    \"config_missing\": \"APIキーを生成し、サービスを先に起動してください\",\n                    \"sync_success\": \"OpenCode の設定を同期しました\",\n                    \"sync_error\": \"OpenCode の同期に失敗しました: {{error}}\",\n                    \"clear_success\": \"OpenCode の設定をクリアしました\",\n                    \"clear_error\": \"OpenCode の設定クリアに失敗しました: {{error}}\"\n                },\n                \"modal\": {\n                    \"view_title\": \"OpenCode 設定ビューアー\",\n                    \"copy_success\": \"設定をコピーしました\"\n                },\n                \"sync_confirm_title\": \"同期の確認\",\n                \"sync_confirm_message\": \"現在のプロキシ設定に基づき、OpenCode の設定が上書きされます。続行しますか？\",\n                \"restore_confirm\": \"OpenCode をデフォルト設定に復元しますか？\",\n                \"restore_backup_confirm\": \"バックアップから OpenCode の設定を復元しますか？\",\n                \"auth_plugin_warning\": \"opencode-antigravity-auth プラグインを検出しました。同期では antigravity-manager provider のみ作成し、google provider/plugin は上書きしません。\"\n            },\n            \"droid_sync\": {\n                \"modal_title\": \"Droid にモデルを追加\",\n                \"modal_desc\": \"選択したモデルは customModels として settings.json に書き込まれます\",\n                \"selected\": \"選択済み\",\n                \"btn_confirm_sync\": \"選択したモデルを追加\",\n                \"toast\": {\n                    \"no_models_selected\": \"少なくとも1つのモデルを選択してください\",\n                    \"sync_success_count\": \"{{count}} 個のモデルを Droid に追加しました\",\n                    \"sync_error\": \"同期に失敗しました: {{error}}\"\n                }\n            }\n        },\n        \"cloudflared\": {\n            \"status_stopping\": \"停止中...\",\n            \"public_url_placeholder\": \"トンネル開始後に公開 URL が表示されます\",\n            \"copy_url\": \"URL をコピー\",\n            \"url_copied\": \"URL をコピーしました\",\n            \"running\": \"トンネル実行中\",\n            \"started\": \"トンネルを開始しました\",\n            \"stopped\": \"トンネルを停止しました\",\n            \"require_proxy_running\": \"先にローカルプロキシサービスを起動してください\",\n            \"connection_info\": \"接続情報\",\n            \"local_port\": \"ローカルポート\",\n            \"tunnel_protocol\": \"トンネルプロトコル\",\n            \"title\": \"外部公開 (Cloudflared)\",\n            \"subtitle\": \"Cloudflare Tunnel を介してローカルサービスをインターネットに公開します\",\n            \"not_installed\": \"Cloudflared がインストールされていません\",\n            \"install_hint\": \"Cloudflared は Cloudflare の無料トンネルツールです。パブリック IP やポート開放なしで、ローカルプロキシをインターネットに公開できます。下のボタンをクリックしてインストールしてください。\",\n            \"install\": \"今すぐインストール\",\n            \"installing\": \"インストール中...\",\n            \"install_success\": \"Cloudflared のインストールに成功しました\",\n            \"install_failed\": \"インストールに失敗しました: {{error}}\",\n            \"installed\": \"インストール済み\",\n            \"version\": \"バージョン\",\n            \"mode_label\": \"トンネルモード\",\n            \"mode_quick\": \"クイックトンネル\",\n            \"mode_quick_desc\": \"自動生成された一時的な URL (*.trycloudflare.com) を使用します。アカウント不要ですが、再起動ごとに URL が変わります。\",\n            \"mode_auth\": \"名前付きトンネル\",\n            \"mode_auth_desc\": \"Cloudflare アカウントのトークンを使用します。カスタムドメインや固定 URL が利用可能です。\",\n            \"token\": \"トンネルトークン\",\n            \"token_placeholder\": \"Cloudflare トンネルトークンをここに貼り付けてください\",\n            \"token_hint\": \"Cloudflare Zero Trust ダッシュボードから取得してください\",\n            \"token_required\": \"名前付きトンネルモードではトークンが必須です\",\n            \"use_http2\": \"HTTP/2 を使用\",\n            \"use_http2_desc\": \"互換性が高く、中国本土などで推奨されます\",\n            \"status_label\": \"トンネルステータス\",\n            \"status_stopped\": \"停止中\",\n            \"status_starting\": \"開始中...\",\n            \"status_running\": \"実行中\",\n            \"status_error\": \"エラー\",\n            \"public_url\": \"公開 URL\",\n            \"start_tunnel\": \"トンネルを開始\",\n            \"stop_tunnel\": \"トンネルを停止\",\n            \"start_failed\": \"開始に失敗しました: {{error}}\",\n            \"stop_failed\": \"停止に失敗しました: {{error}}\"\n        },\n        \"example\": {\n            \"title\": \"使用例\",\n            \"curl\": \"cURL\",\n            \"python\": \"Python\",\n            \"python_anthropic\": \"from anthropic import Anthropic\\n\\nclient = Anthropic(\\n    # 推奨: 127.0.0.1\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\n# 注意: AntigravityはAnthropic SDK経由でのあらゆるモデル呼び出しをサポートします\\nresponse = client.messages.create(\\n    model=\\\"{{modelId}}\\\",\\n    max_tokens=1024,\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"こんにちは\\\"}]\\n)\\n\\nprint(response.content[0].text)\",\n            \"python_gemini\": \"# インストール: pip install google-generativeai\\nimport google.generativeai as genai\\n\\n# Antigravityプロキシアドレスを使用 (推奨 127.0.0.1)\\ngenai.configure(\\n    api_key=\\\"{{apiKey}}\\\",\\n    transport='rest',\\n    client_options={'api_endpoint': '{{rawBaseUrl}}'}\\n)\\n\\nmodel = genai.GenerativeModel('{{modelId}}')\\nresponse = model.generate_content(\\\"こんにちは\\\")\\nprint(response.text)\",\n            \"python_openai_image\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    # オプション1: サイズ指定 (推奨)\\n    # サポート: \\\"1024x1024\\\" (1:1), \\\"1280x720\\\" (16:9), \\\"720x1280\\\" (9:16), \\\"1216x896\\\" (4:3)\\n    extra_body={ \\\"size\\\": \\\"1024x1024\\\" },\\n\\n    # オプション2: モデルサフィックス使用\\n    # 例: gemini-3-pro-image-16-9, gemini-3-pro-image-4-3\\n    # model=\\\"gemini-3-pro-image-16-9\\\",\\n    messages=[{\\n        \\\"role\\\": \\\"user\\\",\\n        \\\"content\\\": \\\"近未来の都市を描いてください\\\"\\n    }]\\n)\\n\\nprint(response.choices[0].message.content)\",\n            \"python_openai\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"こんにちは\\\"}]\\n)\\n\\nprint(response.choices[0].message.content)\"\n        },\n        \"examples\": {\n            \"title\": \"使用例\"\n        },\n        \"dialog\": {\n            \"confirm_regenerate\": \"APIキーを再生成してもよろしいですか？古いキーは即座に無効になります。\",\n            \"operate_failed\": \"操作に失敗しました: {{error}}\",\n            \"reset_mapping_title\": \"モデルマッピングをリセット\",\n            \"reset_mapping_msg\": \"すべてのモデルマッピングをシステムデフォルトにリセットしてもよろしいですか？この操作は取り消せません。\",\n            \"regenerate_key_title\": \"APIキーを再生成\",\n            \"regenerate_key_msg\": \"APIキーを再生成してもよろしいですか？古いキーは即座に無効になります。\",\n            \"clear_bindings_title\": \"セッションバインディングをクリア\",\n            \"clear_bindings_msg\": \"すべてのセッションとアカウントの紐付けをクリアしてもよろしいですか？\",\n            \"clear_rate_limits_title\": \"レート制限の記録をクリア\",\n            \"clear_rate_limits_confirm\": \"すべてのローカルレート制限記録をクリアしてもよろしいですか？\"\n        },\n        \"model\": {\n            \"flash\": \"高速レスポンス\",\n            \"flash_preview\": \"Flashプレビュー\",\n            \"flash_lite\": \"軽量・高速\",\n            \"flash_thinking\": \"思考能力\",\n            \"pro_legacy\": \"レガシーPro\",\n            \"pro_low\": \"ハイパフォーマンス\",\n            \"pro_high\": \"最高推論\",\n            \"pro_image\": \"画像生成 (1:1)\",\n            \"pro_image_16_9\": \"画像生成 (16:9)\",\n            \"pro_image_9_16\": \"画像生成 (9:16)\",\n            \"pro_image_4_3\": \"画像生成 (4:3)\",\n            \"pro_image_3_4\": \"画像生成 (3:4)\",\n            \"pro_image_1_1\": \"画像生成 (1:1)\",\n            \"claude_sonnet\": \"コーディング推論\",\n            \"claude_sonnet_thinking\": \"思考の連鎖\",\n            \"claude_opus_thinking\": \"最強の思考\"\n        },\n        \"mapping\": {\n            \"title\": \"Claude Code モデルマッピング\",\n            \"description\": \"Claude CodeモデルをAntigravityモデルにマッピングします。リクエストを賢くルーティングしてコストと速度を最適化します。\",\n            \"default\": \"デフォルト\",\n            \"sonnet_desc\": \"複雑な作業に最適\",\n            \"opus_desc\": \"プレミアムティア\",\n            \"haiku_desc\": \"クイックな回答に最速\",\n            \"maps_to\": \"Antigravityにマップ\",\n            \"apply_recommended\": \"推奨設定を適用\",\n            \"restore_defaults\": \"デフォルト設定に戻す\",\n            \"reset_all\": \"すべてリセット\"\n        },\n        \"router\": {\n            \"title\": \"モデルルーター\",\n            \"subtitle\": \"シリーズごとにモデルをルーティングするか、カスタムマッピングを追加します。\\n注: ネイティブなClaudeパススルーモデル (例: claude-opus-4-6-thinking) はデフォルトでシリーズグループをバイパスします。上書きするには「エキスパートカスタムルーティング」を使用してください。\",\n            \"subtitle_simple\": \"ワイルドカードまたは完全一致マッピングでモデルルーティングをカスタマイズ\",\n            \"background_task_title\": \"バックグラウンドタスク用モデル\",\n            \"background_task_desc\": \"タイトル生成や要約など、Claude CLIのバックグラウンドタスクに使用されるモデル (デフォルト: gemini-2.5-flash)\",\n            \"use_default\": \"システムデフォルトを使用\",\n            \"current_model\": \"現在のモデル\",\n            \"apply_presets\": \"プリセットを適用\",\n            \"presets_applied\": \"プリセットを正常に適用しました\",\n            \"preset_default\": \"デフォルト\",\n            \"preset_default_desc\": \"GPT-4 → Gemini Pro, Claude → Opus\",\n            \"preset_performance\": \"性能優先\",\n            \"preset_performance_desc\": \"すべてに高性能モデルを使用\",\n            \"preset_cost\": \"コスト最適化\",\n            \"preset_cost_desc\": \"経済的なモデルを優先\",\n            \"preset_balanced\": \"バランス\",\n            \"preset_balanced_desc\": \"性能とコストのバランス\",\n            \"built_in_presets\": \"組み込みプリセット\",\n            \"custom_presets\": \"カスタムプリセット\",\n            \"apply_selected\": \"選択したものを適用\",\n            \"add_preset\": \"現在のマッピングを保存\",\n            \"delete_preset\": \"現在のプリセットを削除\",\n            \"cannot_delete_builtin\": \"組み込みプリセットは削除できません\",\n            \"no_mapping_to_save\": \"保存するマッピング設定がありません\",\n            \"preset_name_required\": \"プリセット名が必要です\",\n            \"preset_saved\": \"プリセットが正常に保存されました\",\n            \"manage_presets_title\": \"カスタムプリセットの管理\",\n            \"save_current_as_preset\": \"現在の設定を保存\",\n            \"preset_name_placeholder\": \"プリセット名を入力...\",\n            \"save_hint\": \"現在アクティブなモデルマッピングを再利用可能なプリセットとして保存します。\",\n            \"your_presets\": \"あなたのプリセット\",\n            \"no_custom_presets\": \"カスタムプリセットはまだありません\",\n            \"mappings_count\": \"個のマッピング\",\n            \"custom_preset_desc\": \"ユーザー定義プリセット\",\n            \"custom_mappings\": \"カスタムマッピング\",\n            \"group_title\": \"シリーズグループ\",\n            \"groups\": {\n                \"claude_45\": {\n                    \"name\": \"Claude 4.6 TK シリーズ\",\n                    \"desc\": \"Opus 4.5 TK\"\n                },\n                \"claude_35\": {\n                    \"name\": \"Claude 3.5 シリーズ\",\n                    \"desc\": \"Sonnet 3.5, Haiku 3.5\"\n                },\n                \"gpt_4\": {\n                    \"name\": \"GPT-4 / o1 シリーズ\",\n                    \"desc\": \"GPT-4, Turbo, o1-preview\"\n                },\n                \"gpt_4o\": {\n                    \"name\": \"GPT-4o / 3.5 シリーズ\",\n                    \"desc\": \"GPT-4o, Mini, 3.5 Turbo\"\n                },\n                \"gpt_5\": {\n                    \"name\": \"GPT-5 シリーズ\",\n                    \"desc\": \"GPT-5.1, GPT-5.2 xhigh\"\n                }\n            },\n            \"expert_title\": \"エキスパートカスタムルーティング\",\n            \"expert_subtitle\": \"任意のモデルIDに精密にマッチングさせます。\",\n            \"custom_mapping_tip\": \"💡 任意のモデルIDを手動入力して、リリース前のモデルを体験できます（例: claude-opus-4-6）。\",\n            \"custom_mapping_warning\": \"注意: すべてのアカウントがリリース前のモデルをサポートしているわけではありません。\",\n            \"money_saving_tip\": \"💰 節約ヒント:\",\n            \"haiku_optimization_tip\": \"Claude CLIはデフォルトで、バックグラウンドタスクに {{model}} を使用します。安価なFlashモデルにマップすることで、コストを約95%節約できます\",\n            \"haiku_optimization_btn\": \"クイック最適化\",\n            \"haiku_tip_title\": \"💰 節約ヒント:\",\n            \"haiku_tip_body_before\": \"Claude CLIはデフォルトで\",\n            \"haiku_tip_body_after\": \"をバックグラウンドタスクに使用します。これをより安価なFlashモデルにマッピングすると、コストを約95%節約できます。\",\n            \"haiku_tip_action\": \"最適化\",\n            \"reset_confirm\": \"すべてのマッピングをシステムデフォルトにリセットしますか？\",\n            \"reset_mapping\": \"マッピングをリセット\",\n            \"add_mapping\": \"マッピングを追加\",\n            \"current_list\": \"カスタムリスト\",\n            \"no_custom_mapping\": \"カスタムマッピングはまだありません\",\n            \"gemini3_only_warning\": \"⚠️ Gemini 3 シリーズのみ\",\n            \"default_suffix\": \" (デフォルト)\",\n            \"original_id\": \"元のID\",\n            \"route_to\": \"ルーティング先\",\n            \"select_target_model\": \"ターゲットモデルを選択\",\n            \"original_placeholder\": \"元の名称 (例: gpt-4 または gpt-4*)\"\n        },\n        \"multi_protocol\": {\n            \"title\": \"マルチプロトコルサポート\",\n            \"subtitle\": \"お気に入りの AI ツールや CLI とシームレスに統合\",\n            \"description\": \"ローカルプロキシは OpenAI、Anthropic、および Gemini プロトコルをサポートしており、幅広いアプリケーションとの互換性を確保しています。\",\n            \"openai_label\": \"OpenAI プロトコル\",\n            \"anthropic_label\": \"Anthropic プロトコル\",\n            \"openai_tools\": \"Cherry Studio, NextChat\",\n            \"anthropic_tools\": \"Claude Code CLI\",\n            \"gemini_label\": \"Gemini プロトコル\",\n            \"gemini_tools\": \"Google AI SDK, LangChain\",\n            \"quick_integration\": \"クイック統合\",\n            \"click_tip\": \"👆 モデルをクリックしてコード例を更新\",\n            \"copy_base\": \"ベースURLをコピー\"\n        },\n        \"supported_models\": {\n            \"title\": \"サポートモデルと統合\",\n            \"model_name\": \"モデル名\",\n            \"model_id\": \"モデルID\",\n            \"description\": \"説明\",\n            \"action\": \"操作\"\n        },\n        \"cli_sync\": {\n            \"title\": \"CLI 設定の一括同期\",\n            \"subtitle\": \"現在の API エンドポイントとキーをローカルの AI CLI ツールに素早く同期します。\",\n            \"card_title\": \"{{name}} 設定\",\n            \"status\": {\n                \"not_installed\": \"未インストール\",\n                \"installed\": \"v{{version}}\",\n                \"synced\": \"同期済み\",\n                \"not_synced\": \"未同期\",\n                \"detecting\": \"検出中...\",\n                \"current_base_url\": \"現在のベースURL\"\n            },\n            \"btn_sync\": \"今すぐ同期\",\n            \"btn_view\": \"設定を表示\",\n            \"btn_restore\": \"デフォルトに戻す\",\n            \"btn_restore_backup\": \"バックアップを復元\",\n            \"restore_backup_confirm\": \"バックアップ設定が見つかりました。復元してもよろしいですか？\",\n            \"sync_confirm_title\": \"同期の確認\",\n            \"sync_confirm_message\": \"{{name}} の設定を同期する準備ができました。⚠️ 注意：これにより、既存のローカル設定ファイル（ログイントークン、APIキーなど）が上書きされます。続行してもよろしいですか？\",\n            \"restore_confirm\": \"{{name}} の設定を公式のデフォルトURLに復元してもよろしいですか？\",\n            \"modal\": {\n                \"view_title\": \"{{name}} 設定ファイルの内容\",\n                \"copy_success\": \"設定内容をコピーしました\"\n            },\n            \"toast\": {\n                \"sync_success\": \"同期に成功しました！{{name}} の準備が整いました。\",\n                \"sync_error\": \"同期に失敗しました：{{error}}\"\n            }\n        }\n    },\n    \"monitor\": {\n        \"page_title\": \"APIモニターダッシュボード\",\n        \"page_subtitle\": \"リアルタイムのリクエストログと分析\",\n        \"open_monitor\": \"モニターを開く\",\n        \"logging_status\": {\n            \"active\": \"記録中\",\n            \"paused\": \"一時停止中\"\n        },\n        \"stats\": {\n            \"total\": \"合計\",\n            \"ok\": \"成功\",\n            \"err\": \"エラー\"\n        },\n        \"filters\": {\n            \"placeholder\": \"モデル、パス、またはステータスでフィルタリング...\",\n            \"quick_filters\": \"クイックフィルタ:\",\n            \"all\": \"すべて\",\n            \"error\": \"エラー\",\n            \"chat\": \"チャット\",\n            \"gemini\": \"Gemini\",\n            \"claude\": \"Claude\",\n            \"images\": \"画像\",\n            \"reset\": \"リセット\",\n            \"by_account\": \"アカウントでフィルタ\",\n            \"all_accounts\": \"すべてのアカウント\"\n        },\n        \"table\": {\n            \"status\": \"ステータス\",\n            \"method\": \"メソッド\",\n            \"model\": \"モデル\",\n            \"protocol\": \"プロトコル\",\n            \"account\": \"アカウント\",\n            \"path\": \"パス\",\n            \"usage\": \"トークン\",\n            \"duration\": \"所要時間\",\n            \"time\": \"時間\",\n            \"empty\": \"記録されたリクエストはありません\"\n        },\n        \"details\": {\n            \"title\": \"リクエスト詳細\",\n            \"request_payload\": \"リクエストペイロード\",\n            \"response_payload\": \"レスポンスペイロード\",\n            \"duration\": \"所要時間\",\n            \"tokens\": \"トークン (I/O)\",\n            \"time\": \"時間\",\n            \"model\": \"モデル\",\n            \"id\": \"リクエストID\",\n            \"protocol\": \"プロトコル\",\n            \"mapped_model\": \"マッピング後のモデル\",\n            \"account_used\": \"使用アカウント\",\n            \"payload_empty\": \"ペイロードなし\"\n        },\n        \"dialog\": {\n            \"clear_title\": \"プロキシログをクリア\",\n            \"clear_msg\": \"すべてのプロキシログをクリアしてもよろしいですか？この操作は取り消せません。\"\n        },\n        \"network\": {\n            \"title\": \"ネットワークモニター\",\n            \"open\": \"ネットワークモニターを開く\",\n            \"requests_count\": \"{{count}} 件\",\n            \"start_recording\": \"記録を開始\",\n            \"stop_recording\": \"記録を停止\",\n            \"clear_requests\": \"リクエストをクリア\",\n            \"empty\": \"記録されたリクエストはありません\",\n            \"waiting\": \"応答待ち...\",\n            \"badge_error\": \"エラー\",\n            \"table\": {\n                \"status\": \"状態\",\n                \"command\": \"コマンド\",\n                \"time\": \"時刻\",\n                \"duration\": \"所要時間\"\n            },\n            \"sections\": {\n                \"general\": \"概要\",\n                \"request_args\": \"リクエスト引数\",\n                \"error_details\": \"エラー詳細\",\n                \"response\": \"レスポンス\"\n            },\n            \"fields\": {\n                \"status\": \"状態\",\n                \"start_time\": \"開始時刻\",\n                \"duration\": \"所要時間\"\n            }\n        }\n    },\n    \"update_notification\": {\n        \"title\": \"新しいバージョンが利用可能です\",\n        \"message\": \"最適化と改善が含まれた新しいバージョンの準備ができました。現在のバージョン: v{{current}}\",\n        \"ready\": \"更新の準備ができました\",\n        \"downloading\": \"更新をダウンロード中...\",\n        \"restarting\": \"アプリを再起動中...\",\n        \"auto_update\": \"自動更新\",\n        \"toast\": {\n            \"not_ready\": \"自動更新パッケージの準備ができていません。ダウンロードページにリダイレクトします...\",\n            \"failed\": \"自動更新に失敗しました。ダウンロードページにリダイレクトします...\"\n        }\n    },\n    \"login\": {\n        \"title\": \"セキュアアクセス制御\",\n        \"desc\": \"現在 Web モードで実行されています。管理パスワードまたは API キーを入力してアクセスしてください。\",\n        \"placeholder\": \"管理パスワードまたは API キーを入力してください\",\n        \"btn_login\": \"認証してアクセス\",\n        \"btn_verifying\": \"認証中...\",\n        \"error_invalid_key\": \"パスワードまたは API キーが無効です。再試行してください\",\n        \"error_network\": \"ネットワーク接続に失敗しました。サービスが実行中か確認してください\",\n        \"note\": \"注意：独立した管理パスワードが設定されている場合は管理パスワードを入力してください。そうでない場合は API_KEY を入力してください。\",\n        \"lookup_hint\": \"お忘れの場合は、docker logs antigravity-manager を実行して Current API Key または Web UI Password を探してください\",\n        \"config_hint\": \"または grep -E '\\\"api_key\\\"|\\\"admin_password\\\"' ~/.antigravity_tools/gui_config.json を実行して確認してください。\"\n    },\n    \"token_stats\": {\n        \"title\": \"トークン統計\",\n        \"hourly\": \"時間単位\",\n        \"daily\": \"日単位\",\n        \"weekly\": \"週単位\",\n        \"total_tokens\": \"総トークン\",\n        \"input_tokens\": \"入力トークン\",\n        \"output_tokens\": \"出力トークン\",\n        \"accounts_used\": \"アクティブアカウント\",\n        \"models_used\": \"使用モデル\",\n        \"model_trend\": \"モデル別使用傾向\",\n        \"account_trend\": \"アカウント別使用傾向\",\n        \"usage_trend\": \"トークン使用傾向\",\n        \"by_account\": \"アカウント別\",\n        \"by_model\": \"モデル別\",\n        \"by_account_view\": \"アカウント別\",\n        \"model_details\": \"モデル別詳細統計\",\n        \"account_details\": \"アカウント別詳細統計\",\n        \"model\": \"モデル\",\n        \"account\": \"アカウント\",\n        \"requests\": \"リクエスト数\",\n        \"input\": \"入力\",\n        \"output\": \"出力\",\n        \"total\": \"合計\",\n        \"percentage\": \"割合\",\n        \"no_data\": \"データなし\"\n    },\n    \"security\": {\n        \"title\": \"セキュリティ監視\",\n        \"refresh_data\": \"データ更新\",\n        \"refresh\": \"更新\",\n        \"tab_logs\": \"アクセスログ\",\n        \"tab_stats\": \"統計分析\",\n        \"tab_blacklist\": \"ブラックリスト\",\n        \"tab_whitelist\": \"ホワイトリスト\",\n        \"tab_config\": \"セキュリティ設定\",\n        \"stats\": {\n            \"total_requests\": \"総リクエスト数\",\n            \"total_requests_desc\": \"記録されたすべてのリクエスト\",\n            \"unique_ips\": \"ユニークIP数\",\n            \"unique_ips_desc\": \"異なるクライアントIPアドレス\",\n            \"blocked_requests\": \"ブロックされたリクエスト\",\n            \"blocked_requests_desc\": \"ルールによって拒否されたリクエスト\",\n            \"ip_activity_token_usage\": \"IPアクティビティとトークン使用量\",\n            \"hour\": \"時\",\n            \"day\": \"日\",\n            \"week\": \"週\",\n            \"month\": \"月\",\n            \"rank\": \"ランク\",\n            \"ip_address\": \"IPアドレス\",\n            \"activity_reqs\": \"アクティビティ (リクエスト)\",\n            \"total_token\": \"総トークン\",\n            \"prompt\": \"プロンプト\",\n            \"completion\": \"完了\",\n            \"no_data\": \"データなし\"\n        },\n        \"logs\": {\n            \"search_placeholder\": \"IP、パス、UAで検索...\",\n            \"username\": \"ユーザー\",\n            \"show_blocked_only\": \"ブロックのみ表示\",\n            \"status\": \"ステータス\",\n            \"ip_address\": \"IPアドレス\",\n            \"method\": \"メソッド\",\n            \"path\": \"パス\",\n            \"duration\": \"所要時間\",\n            \"time\": \"時間\",\n            \"reason\": \"理由\",\n            \"blocked\": \"ブロック\",\n            \"no_logs\": \"ログなし\",\n            \"total_records\": \"全 {{total}} 件\",\n            \"prev_page\": \"前へ\",\n            \"next_page\": \"次へ\",\n            \"page_num\": \"ページ {{page}}\",\n            \"per_page_suffix\": \"/ページ\"\n        },\n        \"blacklist\": {\n            \"add_ip\": \"IPを追加\",\n            \"search_placeholder\": \"検索...\",\n            \"added_at\": \"追加日時\",\n            \"expires_at\": \"有効期限\",\n            \"no_data\": \"ブラックリストデータなし\",\n            \"add_title\": \"ブラックリストに追加\",\n            \"ip_cidr_label\": \"IPアドレスまたはCIDR\",\n            \"ip_cidr_placeholder\": \"例: 192.168.1.1 または 10.0.0.0/24\",\n            \"reason_label\": \"理由 (任意)\",\n            \"reason_placeholder\": \"例: 悪意のあるスキャン\",\n            \"expires_label\": \"有効期限 (時間、任意)\",\n            \"expires_placeholder\": \"空欄で無期限\",\n            \"cancel\": \"キャンセル\",\n            \"confirm\": \"追加\",\n            \"add_btn\": \"追加\"\n        },\n        \"whitelist\": {\n            \"add_ip\": \"信頼IPを追加\",\n            \"no_data\": \"ホワイトリストデータなし\",\n            \"add_title\": \"ホワイトリストに追加\",\n            \"description_label\": \"説明 (任意)\",\n            \"description_placeholder\": \"例: 内部サーバー\",\n            \"cancel\": \"キャンセル\",\n            \"confirm\": \"追加\",\n            \"add_btn\": \"追加\"\n        },\n        \"config\": {\n            \"title\": \"セキュリティ設定\",\n            \"save\": \"変更を保存\",\n            \"saving\": \"保存中...\",\n            \"blacklist_title\": \"IPブラックリスト\",\n            \"blacklist_desc\": \"ブロックされたIPアドレスとルールを管理します。\",\n            \"enable_blacklist\": \"ブラックリスト保護を有効化\",\n            \"block_msg_label\": \"カスタムブロックメッセージ\",\n            \"block_msg_desc\": \"ブロックされたクライアントに返されるレスポンス内容。\",\n            \"whitelist_title\": \"IPホワイトリスト\",\n            \"whitelist_desc\": \"信頼されたIPアドレスを管理します。\",\n            \"enable_whitelist\": \"ホワイトリストモードを有効化\",\n            \"whitelist_warning\": \"警告: ホワイトリストモードを有効にすると、ホワイトリストにないIPからのすべてのリクエストがブロックされます。プロキシ経由でアクセスしている場合は、自分自身を締め出さないように注意してください。\",\n            \"whitelist_priority\": \"ホワイトリスト優先 (ブラックリストを上書き)\",\n            \"whitelist_priority_desc\": \"有効にすると、ブラックリストルールに一致してもホワイトリストIPは許可されます。\",\n            \"load_error\": \"設定の読み込みに失敗しました\",\n            \"save_success\": \"設定を保存しました\",\n            \"save_error\": \"設定の保存に失敗しました\"\n        }\n    },\n    \"errors\": {\n        \"stream\": {\n            \"timeout_error\": \"リクエストがタイムアウトしました。ネットワーク接続を確認してください\",\n            \"connection_error\": \"接続に失敗しました。ネットワークまたはプロキシ設定を確認してください\",\n            \"decode_error\": \"ネットワークが不安定なため、データ転送が中断されました。1) ネットワークを確認 2) プロキシを切り替え 3) 再試行 してください\",\n            \"stream_error\": \"ストリーム転送エラーが発生しました。後でもう一度お試しください\",\n            \"unknown_error\": \"不明なエラーが発生しました。後でもう一度お試しください\"\n        }\n    },\n    \"user_token\": {\n        \"title\": \"ユーザー Token 管理\",\n        \"total_users\": \"総ユーザー数\",\n        \"active_tokens\": \"アクティブな Token\",\n        \"total_created\": \"累計作成数\",\n        \"create\": \"Token を作成\",\n        \"username\": \"ユーザー名\",\n        \"token\": \"Token\",\n        \"expires\": \"有効期限\",\n        \"usage\": \"使用量\",\n        \"ip_limit\": \"IP 制限\",\n        \"created\": \"作成日時\",\n        \"today_requests\": \"本日のリクエスト数\",\n        \"never\": \"期限なし\",\n        \"renew\": \"更新\",\n        \"renew_button\": \"更新\",\n        \"unlimited\": \"制限なし\",\n        \"create_title\": \"新しい Token の作成\",\n        \"description\": \"説明\",\n        \"curfew\": \"夜間制限 (サービス停止時間帯)\",\n        \"edit_title\": \"Token の編集\",\n        \"username_required\": \"ユーザー名は必須です\",\n        \"renew_success\": \"更新に成功しました\",\n        \"expires_day\": \"1 日\",\n        \"expires_week\": \"1 週間\",\n        \"expires_month\": \"1 ヶ月\",\n        \"expires_never\": \"期限なし\",\n        \"no_data\": \"データがありません\",\n        \"placeholder_username\": \"例: user1\",\n        \"placeholder_desc\": \"備考 (任意)\",\n        \"placeholder_max_ips\": \"0 = 無制限\",\n        \"hint_max_ips\": \"0 は制限なしを意味します\",\n        \"hint_curfew\": \"空欄で無効。サーバー時間に基づきます。\"\n    }\n}"
  },
  {
    "path": "src/locales/ko.json",
    "content": "{\n    \"common\": {\n        \"loading\": \"로딩 중...\",\n        \"load_more\": \"더 보기\",\n        \"add\": \"추가\",\n        \"copy\": \"복사\",\n        \"action\": \"작업\",\n        \"save\": \"저장\",\n        \"saved\": \"저장되었습니다\",\n        \"cancel\": \"취소\",\n        \"confirm\": \"확인\",\n        \"close\": \"닫기\",\n        \"delete\": \"삭제\",\n        \"edit\": \"편집\",\n        \"refresh\": \"새로고침\",\n        \"refreshing\": \"새로고침 중...\",\n        \"export\": \"내보내기\",\n        \"import\": \"가져오기\",\n        \"success\": \"성공\",\n        \"error\": \"오류\",\n        \"unknown\": \"알 수 없음\",\n        \"warning\": \"경고\",\n        \"info\": \"정보\",\n        \"details\": \"상세\",\n        \"example\": \"Example\",\n        \"clear\": \"지우기\",\n        \"clearing\": \"삭제 중...\",\n        \"prev_page\": \"이전\",\n        \"next_page\": \"다음\",\n        \"pagination_info\": \"전체 {{total}}개 중 {{start}} - {{end}} 표시\",\n        \"per_page\": \"페이지 당\",\n        \"items\": \"항목\",\n        \"accounts\": \"계정\",\n        \"enabled\": \"활성화됨\",\n        \"disabled\": \"비활성화됨\",\n        \"tauri_api_not_loaded\": \"Tauri API가 로드되지 않았습니다. 앱을 다시 시작해주세요.\",\n        \"environment_error\": \"환경 오류: {{error}}\",\n        \"submit\": \"제출\",\n        \"update\": \"업데이트\",\n        \"load_failed\": \"로드 실패\",\n        \"create_success\": \"생성 성공\",\n        \"update_success\": \"업데이트 성공\",\n        \"delete_success\": \"삭제 성공\",\n        \"copied\": \"클립보드에 복사됨\"\n    },\n    \"nav\": {\n        \"dashboard\": \"대시보드\",\n        \"accounts\": \"계정 관리\",\n        \"proxy\": \"API 프록시\",\n        \"call_records\": \"트래픽 로그\",\n        \"token_stats\": \"토큰 통계\",\n        \"security\": \"IP 관리\",\n        \"security_logs\": \"IP 로그\",\n        \"settings\": \"설정\",\n        \"theme_to_dark\": \"다크 모드로 전환\",\n        \"theme_to_light\": \"라이트 모드로 전환\",\n        \"switch_to_english\": \"영어로 전환\",\n        \"switch_to_chinese\": \"중국어로 전환\",\n        \"switch_to_traditional_chinese\": \"번체 중국어로 전환\",\n        \"switch_to_english_short\": \"EN\",\n        \"switch_to_chinese_short\": \"ZH\",\n        \"switch_to_traditional_chinese_short\": \"TW\",\n        \"switch_to_japanese\": \"일본어로 전환\",\n        \"switch_to_japanese_short\": \"JA\",\n        \"switch_to_turkish\": \"터키어로 전환\",\n        \"switch_to_turkish_short\": \"TR\",\n        \"switch_to_vietnamese\": \"베트남어로 전환\",\n        \"switch_to_vietnamese_short\": \"VI\",\n        \"switch_to_russian\": \"러시아어로 전환\",\n        \"switch_to_russian_short\": \"RU\",\n        \"switch_to_portuguese\": \"포르투갈어로 전환\",\n        \"switch_to_portuguese_short\": \"PT\",\n        \"switch_to_korean\": \"한국어로 전환\",\n        \"switch_to_korean_short\": \"KO\",\n        \"switch_to_spanish\": \"스페인어로 전환\",\n        \"switch_to_spanish_short\": \"ES\",\n        \"switch_to_malay\": \"말레이어로 전환\",\n        \"switch_to_malay_short\": \"MY\",\n        \"user_token\": \"사용자 토큰\"\n    },\n    \"dashboard\": {\n        \"hello\": \"안녕하세요, 사용자님 👋\",\n        \"refresh_quota\": \"할당량 새로고침\",\n        \"refreshing\": \"새로고침 중...\",\n        \"total_accounts\": \"총 계정 수\",\n        \"avg_gemini\": \"평균 Gemini 할당량\",\n        \"avg_gemini_image\": \"평균 Gemini 이미지 할당량\",\n        \"avg_claude\": \"평균 Claude 할당량\",\n        \"low_quota_accounts\": \"할당량 부족 계정\",\n        \"quota_sufficient\": \"할당량 충분\",\n        \"quota_low\": \"할당량 부족\",\n        \"quota_desc\": \"할당량 < 20%\",\n        \"current_account\": \"현재 계정\",\n        \"switch_account\": \"계정 전환\",\n        \"no_active_account\": \"활성 계정 없음\",\n        \"best_accounts\": \"최적 계정\",\n        \"best_account_recommendation\": \"추천 최적 계정\",\n        \"switch_best\": \"최적 계정으로 전환\",\n        \"switch_successfully\": \"전환 성공\",\n        \"view_all_accounts\": \"모든 계정 보기\",\n        \"export_data\": \"데이터 내보내기\",\n        \"for_gemini\": \"Gemini 용\",\n        \"for_claude\": \"Claude 용\",\n        \"toast\": {\n            \"switch_success\": \"전환 성공!\",\n            \"switch_error\": \"계정 전환 실패\",\n            \"refresh_success\": \"할당량 새로고침 성공\",\n            \"refresh_error\": \"새로고침 실패\",\n            \"export_no_accounts\": \"내보낼 계정이 없습니다\",\n            \"export_success\": \"내보내기 성공! 저장 위치: {{path}}\",\n            \"export_error\": \"내보내기 실패\"\n        }\n    },\n    \"accounts\": {\n        \"account\": \"계정\",\n        \"search_placeholder\": \"이메일 검색...\",\n        \"all\": \"전체\",\n        \"available\": \"사용 가능\",\n        \"low_quota\": \"할당량 부족\",\n        \"ultra\": \"ULTRA\",\n        \"pro\": \"PRO\",\n        \"free\": \"FREE\",\n        \"edit_label\": \"라벨 편집\",\n        \"custom_label_placeholder\": \"사용자 정의 라벨 입력\",\n        \"label_updated\": \"라벨이 업데이트되었습니다\",\n        \"add_account\": \"계정 추가\",\n        \"refresh_all\": \"전체 새로고침\",\n        \"refresh_selected\": \"선택 새로고침 ({{count}})\",\n        \"export_selected\": \"선택 내보내기 ({{count}})\",\n        \"import_json\": \"가져오기\",\n        \"import_success\": \"{{count}}개 계정 가져오기 성공\",\n        \"import_partial\": \"가져오기 완료: {{success}} 성공, {{fail}} 실패\",\n        \"import_fail\": \"가져오기 실패: {{error}}\",\n        \"import_invalid_format\": \"잘못된 JSON 형식입니다. 이메일과 refresh_token 필드가 포함되어 있는지 확인해주세요.\",\n        \"delete_selected\": \"선택 삭제 ({{count}})\",\n        \"current\": \"현재\",\n        \"current_badge\": \"현재\",\n        \"disabled\": \"비활성화됨\",\n        \"disabled_tooltip\": \"계정이 비활성화되었습니다 (예: refresh_token 취소/만료). 다시 인증하거나 토큰을 업데이트하여 다시 활성화하세요.\",\n        \"proxy_disabled\": \"프록시 비활성화됨\",\n        \"proxy_disabled_tooltip\": \"이 계정은 수동으로 프록시가 비활성화되었습니다. API 요청을 처리하지 않지만 앱 내에서는 계속 사용할 수 있습니다.\",\n        \"enable_proxy\": \"프록시 활성화\",\n        \"disable_proxy\": \"프록시 비활성화\",\n        \"enable_proxy_selected\": \"활성화 ({{count}})\",\n        \"disable_proxy_selected\": \"비활성화 ({{count}})\",\n        \"proxy_disabled_reason_manual\": \"사용자에 의해 수동으로 비활성화됨\",\n        \"proxy_disabled_reason_batch\": \"일괄 비활성화됨\",\n        \"forbidden\": \"403\",\n        \"forbidden_badge\": \"403\",\n        \"forbidden_tooltip\": \"API가 403 Forbidden을 반환했습니다. 계정에 Gemini Code Assist 권한이 없습니다.\",\n        \"forbidden_msg\": \"권한 없음, 자동 새로고침 건너뜀\",\n        \"status\": {\n            \"forbidden\": \"403 금지됨\",\n            \"disabled\": \"계정 비활성화됨\",\n            \"proxy_disabled\": \"프록시 비활성화됨\"\n        },\n        \"error_details\": \"오류 상세 정보\",\n        \"error_status\": \"오류 상태\",\n        \"error_time\": \"감지 시간\",\n        \"view_error\": \"사유 보기\",\n        \"click_to_verify\": \"확인하려면 클릭\",\n        \"no_data\": \"데이터 없음\",\n        \"last_used\": \"최근 사용\",\n        \"reset_time\": \"초기화 시간\",\n        \"switch_to\": \"이 계정으로 전환\",\n        \"actions\": \"작업\",\n        \"device_fingerprint\": \"디바이스 지문\",\n        \"show_all_quotas\": \"모든 할당량 표시\",\n        \"device_fingerprint_dialog\": {\n            \"title\": \"디바이스 지문\",\n            \"operations\": \"디바이스 지문 작업\",\n            \"generate_and_bind\": \"생성 및 바인딩\",\n            \"restore_original\": \"원본 복원\",\n            \"open_storage_directory\": \"저장소 디렉토리 열기\",\n            \"current_storage\": \"현재 저장소\",\n            \"effective\": \"유효함\",\n            \"current_storage_desc\": \"storage.json에서 읽음 (계정 전환 시 바인딩 적용 후 업데이트됨)\",\n            \"account_binding\": \"계정 바인딩\",\n            \"pending_application\": \"적용 대기 중\",\n            \"account_binding_desc\": \"생성/복원 후 바인딩으로 저장됨, 계정 전환 시 storage.json에 기록됨\",\n            \"historical_fingerprints\": \"히스토리 지문 (선택적 복원/삭제)\",\n            \"no_history\": \"히스토리 없음\",\n            \"current\": \"현재\",\n            \"restore\": \"복원\",\n            \"delete_version\": \"이 버전 삭제\",\n            \"confirm_generate_title\": \"생성 및 바인딩 확인?\",\n            \"confirm_generate_desc\": \"새로운 디바이스 지문 세트를 생성하고 현재 지문으로 설정합니다. 계속하시겠습니까?\",\n            \"confirm_restore_title\": \"원본 지문 복원 확인?\",\n            \"confirm_restore_desc\": \"원본 지문으로 복원하고 현재 지문을 덮어씁니다. 계속하시겠습니까?\",\n            \"cancel\": \"취소\",\n            \"confirm\": \"확인\",\n            \"processing\": \"처리 중...\",\n            \"loading\": \"로딩 중...\",\n            \"failed_to_load_device_info\": \"디바이스 정보를 불러오는 데 실패했습니다\",\n            \"generation_failed\": \"생성 실패\",\n            \"binding_failed\": \"바인딩 실패\",\n            \"restoration_failed\": \"복원 실패\",\n            \"deletion_failed\": \"삭제 실패\",\n            \"directory_open_failed\": \"디렉토리를 열 수 없습니다\",\n            \"generated_and_bound\": \"생성 및 바인딩됨\",\n            \"restored\": \"복원됨\",\n            \"deleted\": \"삭제됨\",\n            \"directory_opened\": \"저장소 디렉토리가 열렸습니다\",\n            \"original_fingerprint_not_found\": \"원본 지문을 찾을 수 없습니다\"\n        },\n        \"warmup_all\": \"원클릭 웜업\",\n        \"warmup_selected\": \"웜업 ({{count}})\",\n        \"warmup_this\": \"이 계정 웜업\",\n        \"warmup_now\": \"지금 웜업\",\n        \"warmup_batch_triggered\": \"{{count}}개 계정에 대해 웜업 작업이 트리거되었습니다\",\n        \"quota_protected\": \"보호됨\",\n        \"details\": {\n            \"title\": \"할당량 상세\",\n            \"model_quota\": \"모델 할당량\",\n            \"protected_models\": \"보호된 모델\"\n        },\n        \"toast\": {\n            \"proxy_enabled\": \"{{count}}개 계정의 프록시를 활성화했습니다\",\n            \"proxy_disabled\": \"{{count}}개 계정의 프록시를 비활성화했습니다\"\n        },\n        \"add\": {\n            \"title\": \"계정 추가\",\n            \"tabs\": {\n                \"oauth\": \"OAuth\",\n                \"token\": \"Refresh Token\",\n                \"import\": \"DB 가져오기\"\n            },\n            \"oauth\": {\n                \"recommend\": \"추천\",\n                \"desc\": \"기본 브라우저를 열어 Google 로그인을 통해 토큰을 자동으로 가져오고 저장합니다.\",\n                \"btn_start\": \"OAuth 시작\",\n                \"btn_waiting\": \"인증 대기 중...\",\n                \"btn_finish\": \"이미 인증했습니다\",\n                \"copy_link\": \"인증 링크 복사\",\n                \"copied\": \"복사됨\",\n                \"link_label\": \"인증 URL\",\n                \"link_click_to_copy\": \"클릭하여 복사\",\n                \"manual_hint\": \"브라우저가 리다이렉트되지 않았나요? 여기에 콜백 URL 또는 코드를 붙여넣으세요:\",\n                \"manual_placeholder\": \"콜백 URL 또는 코드 붙여넣기...\",\n                \"error_no_flow\": \"먼저 'OAuth 인증 시작'을 클릭하세요\",\n                \"web_hint\": \"Google 로그인 페이지가 새 창에서 열립니다\",\n                \"error_no_url\": \"OAuth URL을 가져올 수 없습니다\",\n                \"popup_blocked\": \"팝업이 차단되었습니다\",\n                \"manual_submitting\": \"인증 코드 제출 중...\",\n                \"manual_submitted\": \"인증 코드가 제출되었습니다. 백엔드에서 처리 중입니다...\"\n            },\n            \"token\": {\n                \"label\": \"Refresh Token\",\n                \"placeholder\": \"여기에 Refresh Token을 붙여넣으세요 (일괄 지원)\\n\\n지원 형식:\\n1. 단일 토큰 (1//...)\\n2. JSON 배열 (refresh_token 필드 포함)\\n3. 토큰이 포함된 모든 텍스트 (자동 추출)\",\n                \"hint\": \"팁: 여러 토큰이나 JSON 배열을 붙여넣어 일괄 가져오기를 할 수 있습니다.\",\n                \"error_token\": \"Refresh Token을 입력해주세요\",\n                \"batch_progress\": \"{{current}}/{{total}} 계정 가져오는 중...\",\n                \"batch_success\": \"{{count}}개 계정 가져오기 성공\",\n                \"batch_partial\": \"가져오기 완료: {{success}} 성공, {{fail}} 실패\",\n                \"batch_fail\": \"가져오기 실패\"\n            },\n            \"import\": {\n                \"scheme_a\": \"플랜 A: IDE DB에서\",\n                \"scheme_a_desc\": \"로컬 Antigravity DB에서 현재 로그인된 계정을 자동으로 읽습니다.\",\n                \"btn_db\": \"현재 계정 가져오기\",\n                \"or\": \"또는\",\n                \"scheme_b\": \"플랜 B: V1 백업에서\",\n                \"scheme_b_desc\": \"~/.antigravity-agent에서 V1 계정 데이터를 스캔합니다.\",\n                \"btn_v1\": \"V1 일괄 가져오기\",\n                \"btn_custom_db\": \"사용자 지정 DB 가져오기\"\n            },\n            \"btn_cancel\": \"취소\",\n            \"btn_confirm\": \"확인\",\n            \"oauth_error\": \"OAuth 실패: {{error}}\",\n            \"status\": {\n                \"error_token\": \"Refresh Token을 입력해주세요\"\n            }\n        },\n        \"table\": {\n            \"email\": \"이메일\",\n            \"quota\": \"모델 할당량\",\n            \"last_used\": \"최근 사용\",\n            \"actions\": \"작업\"\n        },\n        \"drag_to_reorder\": \"드래그하여 순서 변경\",\n        \"empty\": {\n            \"title\": \"계정 없음\",\n            \"desc\": \"위의 \\\"계정 추가\\\" 버튼을 클릭하여 첫 번째 계정을 추가하세요\"\n        },\n        \"views\": {\n            \"list\": \"목록 보기\",\n            \"grid\": \"그리드 보기\"\n        },\n        \"dialog\": {\n            \"add_title\": \"계정 추가\",\n            \"batch_delete_title\": \"일괄 삭제 확인\",\n            \"delete_title\": \"삭제 확인\",\n            \"batch_delete_msg\": \"선택한 {{count}}개 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.\",\n            \"delete_msg\": \"이 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.\",\n            \"refresh_title\": \"할당량 새로고침\",\n            \"batch_refresh_title\": \"일괄 새로고침\",\n            \"refresh_msg\": \"현재 계정의 할당량을 새로 고치시겠습니까?\",\n            \"batch_refresh_msg\": \"선택한 {{count}}개 계정의 할당량을 새로 고치시겠습니까? 시간이 다소 걸릴 수 있습니다.\",\n            \"disable_proxy_title\": \"프록시 비활성화\",\n            \"disable_proxy_msg\": \"이 계정의 프록시를 비활성화하시겠습니까? 계정은 앱 내에서 계속 사용할 수 있습니다.\",\n            \"enable_proxy_title\": \"프록시 활성화\",\n            \"enable_proxy_msg\": \"이 계정의 프록시를 다시 활성화하시겠습니까?\",\n            \"warmup_all_title\": \"전체 수동 웜업\",\n            \"warmup_all_msg\": \"모든 대상 계정에 대해 즉시 웜업 작업을 트리거하시겠습니까? 할당량 주기를 재설정하기 위해 Google 서비스에 최소한의 트래픽을 보냅니다.\",\n            \"batch_warmup_title\": \"일괄 수동 웜업\",\n            \"batch_warmup_msg\": \"선택한 {{count}}개 계정에 대해 즉시 웜업을 트리거하시겠습니까?\"\n        }\n    },\n    \"settings\": {\n        \"save\": \"설정 저장\",\n        \"tabs\": {\n            \"general\": \"일반\",\n            \"account\": \"계정\",\n            \"proxy\": \"프록시 설정\",\n            \"advanced\": \"고급\",\n            \"about\": \"정보\",\n            \"debug\": \"디버그\"\n        },\n        \"general\": {\n            \"title\": \"일반 설정\",\n            \"language\": \"언어\",\n            \"theme\": \"테마\",\n            \"theme_light\": \"라이트\",\n            \"theme_dark\": \"다크\",\n            \"theme_system\": \"시스템\",\n            \"auto_launch\": \"시작 시 실행\",\n            \"auto_launch_enabled\": \"활성화됨\",\n            \"auto_launch_disabled\": \"비활성화됨\",\n            \"auto_launch_desc\": \"시스템 시작 시 Antigravity Tools 자동 실행\",\n            \"auto_check_update\": \"자동 업데이트 확인\",\n            \"auto_check_update_desc\": \"시작 시 새 버전을 자동으로 확인\",\n            \"auto_check_update_enabled\": \"자동 확인 활성화됨\",\n            \"auto_check_update_disabled\": \"자동 확인 비활성화됨\",\n            \"update_check_interval\": \"확인 간격 (시간)\",\n            \"update_check_interval_desc\": \"자동 확인 간격 설정 (1-168 시간)\",\n            \"update_check_interval_saved\": \"확인 간격 설정 저장됨\"\n        },\n        \"account\": {\n            \"title\": \"계정 설정\",\n            \"auto_refresh\": \"백그라운드 자동 새로고침\",\n            \"auto_refresh_desc\": \"백그라운드에서 모든 계정 할당량을 자동으로 새로 고칩니다. 할당량 보호 및 스마트 웜업에 필요합니다.\",\n            \"always_on\": \"항상 켜짐\",\n            \"refresh_interval\": \"새로고침 간격 (분)\",\n            \"auto_sync\": \"현재 계정 자동 동기화\",\n            \"auto_sync_desc\": \"현재 활성 계정 정보를 주기적으로 자동 동기화\",\n            \"sync_interval\": \"동기화 간격 (초)\"\n        },\n        \"warmup\": {\n            \"title\": \"스마트 웜업\",\n            \"desc\": \"모든 모델을 자동으로 모니터링하고 할당량이 100%에 도달하면 즉시 웜업을 트리거하여 모델을 따뜻하게 유지합니다.\"\n        },\n        \"quota_protection\": {\n            \"title\": \"할당량 보호\",\n            \"enable\": \"할당량 보호 활성화\",\n            \"enable_desc\": \"계정 할당량이 임계값 미만으로 떨어지면 프록시를 자동으로 비활성화하고, 할당량이 재설정되면 자동으로 복원합니다.\",\n            \"threshold_label\": \"예비 할당량 비율\",\n            \"monitored_models_label\": \"모니터링 모델 (트리거 조건)\",\n            \"monitored_models_desc\": \"하나 이상 선택하세요. 선택한 모델 중 하나라도 임계값 미만이면 보호가 트리거됩니다.\",\n            \"range\": \"범위\",\n            \"example\": \"예: {{percentage}}%일 때, 총 할당량이 {{total}}인 계정은 남은 할당량이 {{threshold}} 이하일 때 비활성화됩니다.\",\n            \"auto_restore_info\": \"할당량이 재설정되면 계정이 자동으로 다시 활성화됩니다.\"\n        },\n        \"pinned_quota_models\": {\n            \"title\": \"고정된 할당량 모델\",\n            \"desc\": \"계정 목록에 표시할 모델 할당량을 선택하세요. 선택하지 않은 모델은 상세 팝업에만 표시됩니다.\"\n        },\n        \"proxy\": {\n            \"title\": \"프록시 설정\"\n        },\n        \"proxy_pool\": {\n            \"title\": \"Proxy Pool\",\n            \"strategy_priority\": \"Priority\",\n            \"strategy_round_robin\": \"Round Robin\",\n            \"strategy_random\": \"Random\",\n            \"strategy_least_connections\": \"Least Connections\",\n            \"test_all\": \"Test All\",\n            \"batch_import\": \"Import\",\n            \"binding_manager\": \"Bindings\",\n            \"add_proxy\": \"Add Proxy\",\n            \"edit_proxy\": \"Edit Proxy\",\n            \"name\": \"Name\",\n            \"url\": \"Proxy URL\",\n            \"username\": \"Username\",\n            \"password\": \"Password\",\n            \"max_accounts\": \"Max Accounts\",\n            \"max_accounts_hint\": \"0 = Unlimited\",\n            \"priority\": \"Priority\",\n            \"priority_hint\": \"Lower is better\",\n            \"health_check_url\": \"Health Check URL\",\n            \"tags\": \"Tags\",\n            \"add_tag_placeholder\": \"Add tag...\",\n            \"seconds\": \"Sec\",\n            \"test_completed\": \"Health check completed\",\n            \"test_failed\": \"Health check failed\",\n            \"confirm_delete\": \"Are you sure you want to delete this proxy?\",\n            \"empty\": \"No proxies available\",\n            \"column_priority\": \"Priority\",\n            \"column_status\": \"Status\",\n            \"column_details\": \"Proxy Details\",\n            \"column_bindings\": \"Bindings\",\n            \"import_title\": \"Batch Import Proxies\",\n            \"import_label\": \"Paste Proxy List (One per line)\",\n            \"import_hint\": \"Supported formats: protocol://user:pass@host:port, host:port:user:pass\",\n            \"import_preview\": \"Preview\",\n            \"import_confirm\": \"Import {{count}} Proxies\",\n            \"no_valid_proxies\": \"No valid proxies found\",\n            \"binding\": {\n                \"title\": \"Account Proxy Bindings\",\n                \"load_failed\": \"Failed to load bindings\",\n                \"unbind_success\": \"Unbound successfully\",\n                \"bind_success\": \"Bound successfully\",\n                \"update_failed\": \"Failed to update binding\",\n                \"assigned_proxy\": \"Assigned Proxy\",\n                \"default_strategy\": \"Default (Follow Strategy)\"\n            },\n            \"status\": {\n                \"inactive\": \"Inactive\",\n                \"checking\": \"Checking\",\n                \"healthy\": \"Healthy\",\n                \"timeout\": \"Timeout\"\n            }\n        },\n        \"advanced\": {\n            \"title\": \"고급 설정\",\n            \"export_path\": \"기본 내보내기 경로\",\n            \"export_path_placeholder\": \"설정되지 않음 (매번 묻기)\",\n            \"default_export_path_desc\": \"파일은 확인 없이 이 폴더에 직접 저장됩니다\",\n            \"select_btn\": \"선택\",\n            \"open_btn\": \"열기\",\n            \"data_dir\": \"데이터 디렉토리\",\n            \"data_dir_desc\": \"계정 데이터 및 설정 파일 위치\",\n            \"antigravity_path\": \"Antigravity 경로\",\n            \"antigravity_path_placeholder\": \"설정되지 않음 (자동 감지 사용)\",\n            \"antigravity_path_desc\": \"Antigravity를 비표준 위치에 설치한 경우, 여기서 실행 파일 경로를 수동으로 지정할 수 있습니다 (MacOS의 경우 .app을 가리킴).\",\n            \"antigravity_path_select\": \"Antigravity 실행 파일 선택\",\n            \"antigravity_path_detected\": \"감지된 경로가 업데이트되었습니다\",\n            \"detect_btn\": \"감지\",\n            \"antigravity_args\": \"Antigravity 시작 인수\",\n            \"antigravity_args_placeholder\": \"--user-data-dir=/path/to/data --some-other-flag\",\n            \"antigravity_args_desc\": \"Antigravity 시작 인수를 지정하세요. 예: 사용자 데이터 디렉토리를 지정하려면 --user-data-dir 사용\",\n            \"detect_args_btn\": \"감지\",\n            \"antigravity_args_detected\": \"시작 인수가 업데이트되었습니다\",\n            \"antigravity_args_detect_error\": \"시작 인수를 감지하지 못했습니다\",\n            \"accounts_page_size\": \"계정 페이지 크기\",\n            \"page_size_auto\": \"자동 계산 (권장)\",\n            \"page_size_desc\": \"페이지 당 표시되는 계정 수를 설정합니다. '자동 계산'을 선택하면 창 크기에 따라 동적으로 조정됩니다.\",\n            \"logs_title\": \"로그 유지 관리\",\n            \"logs_desc\": \"로그 캐시 파일을 지웁니다. 계정 데이터에는 영향을 미치지 않습니다.\",\n            \"clear_logs\": \"로그 캐시 지우기\",\n            \"clear_logs_title\": \"로그 지우기 확인\",\n            \"clear_logs_msg\": \"모든 로그 캐시 파일을 지우시겠습니까?\",\n            \"logs_cleared\": \"로그 캐시가 지워졌습니다\",\n            \"antigravity_cache_title\": \"Antigravity 캐시 정리\",\n            \"antigravity_cache_desc\": \"Antigravity 앱 캐시를 정리하면 로그인 실패, 버전 확인 오류, OAuth 인증 실패 등의 문제를 해결할 수 있습니다.\",\n            \"antigravity_cache_warning\": \"캐시를 정리하기 전에 Antigravity 앱이 완전히 종료되었는지 확인하세요.\",\n            \"clear_antigravity_cache\": \"Antigravity 캐시 정리\",\n            \"clear_cache_confirm_title\": \"Antigravity 캐시 정리 확인\",\n            \"clear_cache_confirm_msg\": \"다음 캐시 디렉토리가 정리됩니다:\",\n            \"cache_cleared_success\": \"캐시가 정리되었습니다. {{size}} MB 해제됨\",\n            \"cache_not_found\": \"Antigravity 캐시 디렉토리를 찾을 수 없습니다\",\n            \"debug_logs_title\": \"디버그 로그\",\n            \"debug_logs_enable_desc\": \"활성화하면 전체 요청 및 응답 체인이 기록됩니다. 문제 해결 시에만 활성화하는 것을 권장합니다.\",\n            \"debug_logs_desc\": \"전체 체인 기록: 원본 입력, 변환된 v1internal 요청, 업스트림 응답. 문제 해결 전용이며 민감한 데이터가 포함될 수 있습니다.\",\n            \"debug_log_dir\": \"디버그 로그 출력 디렉토리\",\n            \"debug_log_dir_hint\": \"미입력 시 기본 디렉토리 사용: {{path}}/debug_logs\",\n            \"debug_log_dir_select\": \"디버그 로그 출력 디렉토리 선택\",\n            \"http_api_title\": \"HTTP API 서비스\",\n            \"http_api_desc\": \"외부 프로그램(예: VS Code 플러그인)을 위한 로컬 HTTP 인터페이스를 제공합니다.\",\n            \"http_api_enabled\": \"HTTP API 활성화\",\n            \"http_api_enabled_desc\": \"활성화되면 외부 프로그램이 HTTP 인터페이스를 통해 계정을 관리할 수 있습니다\",\n            \"http_api_port\": \"수신 포트\",\n            \"http_api_port_desc\": \"포트 변경 후 다시 시작해야 합니다. 포트 충돌이 발생하면 다른 사용 가능한 포트를 사용하세요.\",\n            \"http_api_port_placeholder\": \"기본 포트 19527\",\n            \"http_api_port_invalid\": \"잘못된 포트 번호 (범위: 1024-65535)\",\n            \"http_api_settings_saved\": \"HTTP API 설정이 저장되었습니다. 적용하려면 다시 시작해야 합니다\",\n            \"http_api_restart_required\": \"⚠️ 적용하려면 다시 시작해야 합니다\"\n        },\n        \"menu\": {\n            \"title\": \"메뉴 표시 설정\",\n            \"desc\": \"메뉴 바에 표시할 기능 항목을 선택하세요. 자주 사용하지 않는 메뉴를 숨겨 공간을 절약할 수 있습니다.\",\n            \"selected_items_note\": \"선택된 항목이 상단 메뉴 바에 표시됩니다.\",\n            \"required\": \"필수\"\n        },\n        \"about\": {\n            \"title\": \"정보\",\n            \"version\": \"앱 버전\",\n            \"tech_stack\": \"기술 스택\",\n            \"author\": \"제작자\",\n            \"wechat\": \"WeChat\",\n            \"github\": \"GitHub\",\n            \"view_code\": \"코드 보기\",\n            \"copyright\": \"Copyright © 2025-2026 Antigravity. All rights reserved.\",\n            \"check_update\": \"업데이트 확인\",\n            \"checking_update\": \"확인 중...\",\n            \"latest_version\": \"최신 버전입니다\",\n            \"new_version_available\": \"새 버전 {{version}} 사용 가능\",\n            \"download_update\": \"다운로드\",\n            \"update_check_failed\": \"업데이트 확인 실패\",\n            \"support_btn\": \"저자 후원\",\n            \"support_title\": \"기부 및 후원\",\n            \"support_desc\": \"이 프로젝트가 도움이 되었다면 커피 한 잔 사주세요! 여러분의 후원은 프로젝트를 유지하는 데 큰 힘이 됩니다.\",\n            \"support_alipay\": \"알리페이\",\n            \"support_wechat\": \"위챗 페이\",\n            \"support_buymeacoffee\": \"Buy Me a Coffee\"\n        },\n        \"advanced_thinking\": {\n            \"title\": \"고급 사고 및 전역 설정\",\n            \"description\": \"사고 능력, 이미지 모드 및 전역 지침을 중앙에서 관리합니다.\"\n        },\n        \"thinking_budget\": {\n            \"title\": \"사고 예산 (Thinking Budget)\",\n            \"description\": \"AI 딥러닝 사고 시의 토큰 예산을 제어합니다. 일부 모델(Flash, -thinking 접미사 모델 등)은 상위 API에 의해 24576 제한을 받습니다.\",\n            \"mode_label\": \"처리 모드\",\n            \"mode\": {\n                \"auto\": \"자동 제한\",\n                \"passthrough\": \"패스스루\",\n                \"custom\": \"사용자 정의\"\n            },\n            \"auto_hint\": \"자동 모드: Flash 모델, -thinking 접미사 모델 및 웹 검색이 활성화된 요청에 대해 API 오류를 방지하기 위해 자동으로 24576으로 제한합니다.\",\n            \"passthrough_warning\": \"패스스루: 호출자의 원래 값을 직접 사용하며, 높은 값은 실패로 이어질 수 있습니다.\",\n            \"custom_value_hint\": \"권장: 24576 (Flash) 또는 51200 (성능)\",\n            \"tokens\": \"tokens\"\n        },\n        \"image_thinking_mode\": {\n            \"title\": \"이미지 사고 모드 (Image Thinking Mode)\",\n            \"hint\": \"이미지 품질 및 생성 프로세스에 영향\",\n            \"options\": {\n                \"enabled\": \"활성화\",\n                \"disabled\": \"비활성화\",\n                \"enabled_desc\": \"켜짐: 사고 체인을 유지하고 스케치와 최종 완성본 두 장의 이미지를 반환합니다.\",\n                \"disabled_desc\": \"꺼짐: 사고 체인을 비활성화하고 고화질 단일 이미지를 생성합니다 (품질 우선).\"\n            }\n        },\n        \"global_system_prompt\": {\n            \"title\": \"전역 시스템 프롬프트 (Global System Prompt)\",\n            \"hint\": \"모든 요청의 systemInstruction에 자동으로 주입됩니다\",\n            \"placeholder\": \"전역 시스템 프롬프트를 입력하세요...\\n예: 당신은 React와 Rust에 능숙한 시니어 풀스택 개발자입니다. 한국어로 답변해 주세요.\",\n            \"char_count\": \"{{count}} 자\",\n            \"long_prompt_warning\": \"프롬프트가 너무 깁니다 (2000자 초과). 컨텍스트 공간을 많이 차지할 수 있습니다.\"\n        }\n    },\n    \"tray\": {\n        \"current\": \"현재\",\n        \"quota\": \"할당량\",\n        \"switch_next\": \"다음 계정으로 전환\",\n        \"refresh_current\": \"현재 할당량 새로고침\",\n        \"show_window\": \"메인 창 보이기\",\n        \"quit\": \"애플리케이션 종료\",\n        \"no_account\": \"계정 없음\",\n        \"unknown_quota\": \"알 수 없음 (클릭하여 새로고침)\",\n        \"forbidden\": \"계정 금지됨\"\n    },\n    \"proxy\": {\n        \"title\": \"API 프록시 서비스\",\n        \"status\": {\n            \"running\": \"서비스 실행 중\",\n            \"stopped\": \"서비스 중지됨\",\n            \"accounts_available\": \"{{count}}개 계정 사용 가능\",\n            \"processing\": \"처리 중...\"\n        },\n        \"action\": {\n            \"start\": \"서비스 시작\",\n            \"stop\": \"서비스 중지\"\n        },\n        \"config\": {\n            \"title\": \"서비스 구성\",\n            \"request\": {\n                \"user_agent\": \"User-Agent Override\",\n                \"user_agent_tooltip\": \"Override the User-Agent header sent to upstream APIs. Leave empty to use default.\",\n                \"user_agent_hint\": \"Current Default: antigravity/<version> <os>/<arch>\",\n                \"user_agent_placeholder\": \"Enter custom User-Agent string...\"\n            },\n            \"port\": \"수신 포트\",\n            \"port_tooltip\": \"로컬 API 프록시가 수신 대기하는 TCP 포트입니다. 변경하려면 서비스를 중지한 다음 다시 시작하여 적용하세요.\",\n            \"port_hint\": \"기본값 8045, 변경 사항 적용을 위해 다시 시작 필요\",\n            \"auto_start\": \"앱과 함께 자동 시작\",\n            \"auto_start_tooltip\": \"앱 실행 시 로컬 API 프록시 서비스를 자동으로 시작합니다.\",\n            \"allow_lan_access\": \"LAN 액세스 허용\",\n            \"allow_lan_access_tooltip\": \"활성화하면 서비스가 0.0.0.0에 바인딩되어 LAN의 다른 장치가 액세스할 수 있습니다. 인증을 활성화 상태로 유지하고 API 키를 보호하세요. 적용하려면 다시 시작해야 합니다.\",\n            \"allow_lan_access_hint_enabled\": \"🌐 0.0.0.0에서 수신 대기 중, LAN 장치 액세스 가능\",\n            \"allow_lan_access_hint_disabled\": \"🔒 127.0.0.1에서만 수신 대기, localhost 액세스 (개인정보 보호 우선)\",\n            \"allow_lan_access_warning\": \"⚠️ 활성화 시 LAN 장치에서 액세스할 수 있습니다. API 키를 안전하게 보관하세요\",\n            \"allow_lan_access_restart_hint\": \"ℹ️ 변경 사항을 적용하려면 서비스 다시 시작 필요\",\n            \"api_key\": \"API 키\",\n            \"api_key_tooltip\": \"프록시 인증이 활성화된 경우 클라이언트가 사용하는 공유 비밀 키입니다. 키를 재생성하면 이전 키는 즉시 무효화됩니다.\",\n            \"btn_regenerate\": \"키 재생성\",\n            \"btn_edit\": \"편집\",\n            \"btn_save\": \"저장\",\n            \"btn_copy\": \"복사\",\n            \"btn_copied\": \"복사됨\",\n            \"warning_key\": \"참고: API 키를 안전하게 보관하세요. 공유하지 마세요.\",\n            \"api_key_invalid\": \"잘못된 API 키 형식입니다. sk-로 시작하고 10자 이상이어야 합니다.\",\n            \"api_key_updated\": \"API 키 업데이트됨\",\n            \"admin_password\": \"Web UI 관리자 비밀번호\",\n            \"admin_password_tooltip\": \"Web 관리 콘솔에 로그인하는 데 사용되는 비밀번호입니다. 비어 있으면 기본적으로 API 키가 사용됩니다.\",\n            \"admin_password_default\": \"(API 키와 동일)\",\n            \"admin_password_placeholder\": \"새 비밀번호를 입력하세요. 비워두면 API 키를 사용합니다\",\n            \"admin_password_hint\": \"팁: Docker/Web 배포 시나리오에서는 별도의 로그인 비밀번호를 설정하여 API 키의 보안을 강화할 수 있습니다.\",\n            \"admin_password_short\": \"비밀번호가 너무 짧습니다(최소 4자)\",\n            \"admin_password_updated\": \"Web UI 로그인 비밀번호가 업데이트되었습니다\",\n            \"auth\": {\n                \"title\": \"인증\",\n                \"title_tooltip\": \"들어오는 요청을 인증해야 하는지, 어떤 경로를 보호할지 제어합니다.\",\n                \"enabled\": \"활성화됨\",\n                \"enabled_tooltip\": \"인증 모드를 전환하여 인증을 켜거나 끕니다. 활성화되면 클라이언트는 Authorization: Bearer <API_KEY> 또는 x-api-key를 통해 API 키를 포함해야 합니다.\",\n                \"mode\": \"모드\",\n                \"mode_tooltip\": \"API 키가 필요한 경로 선택: 끄기 = 인증 없음; 모두 = 모든 것 보호; 헬스 체크 제외 모두 = /healthz는 개방; 자동 = localhost 전용은 끄기, 그 외에는 헬스 체크 제외 모두.\",\n                \"hint\": \"활성화되면 클라이언트는 API 키를 전송해야 합니다 (선택된 경우 헬스 체크 제외).\",\n                \"modes\": {\n                    \"off\": \"끄기 (개방)\",\n                    \"strict\": \"모두 (엄격)\",\n                    \"all_except_health\": \"헬스 체크 제외 모두\",\n                    \"auto\": \"자동 (권장)\"\n                }\n            },\n            \"zai\": {\n                \"title\": \"z.ai (GLM) 공급자\",\n                \"title_tooltip\": \"Claude 프로토콜을 위한 선택적 Anthropic 호환 업스트림입니다. Anthropic 엔드포인트에만 영향을 미치며 Google 계정 라우팅은 변경되지 않습니다.\",\n                \"subtitle\": \"Claude 프로토콜 전용 선택적 Anthropic 호환 업스트림.\",\n                \"enabled\": \"활성화됨\",\n                \"enabled_tooltip\": \"선택한 디스패치 모드에 따라 Anthropic 요청에 대해 z.ai 라우팅을 활성화합니다.\",\n                \"base_url\": \"기본 URL\",\n                \"base_url_tooltip\": \"Anthropic 호환 기본 URL입니다. 프록시는 /v1/messages와 같은 경로를 추가합니다. 사용자 지정 게이트웨이를 사용하지 않는 한 기본값을 유지하세요.\",\n                \"dispatch_mode\": \"디스패치 모드\",\n                \"dispatch_mode_tooltip\": \"Anthropic 요청에 z.ai를 사용하는 시점을 제어합니다: 끄기 = 사용 안 함; 모든 Anthropic 요청 = 모든 것을 전달; 풀링됨 = Google 계정과 라운드 로빈으로 z.ai를 하나의 슬롯으로 추가; 대체 = Google 계정이 없을 때만 z.ai 사용.\",\n                \"api_key\": \"API 키\",\n                \"api_key_tooltip\": \"z.ai 요청 인증에 사용되는 API 키입니다. 로컬에 저장되며 z.ai 및 MCP 기능에 필요합니다.\",\n                \"api_key_placeholder\": \"여기에 z.ai API 키를 붙여넣으세요\",\n                \"warning\": \"참고: 이 키는 앱 데이터 디렉토리에 로컬로 저장됩니다.\",\n                \"models\": {\n                    \"title\": \"모델 매핑\",\n                    \"title_tooltip\": \"사용 가능한 z.ai 모델 ID를 가져오고 들어오는 Anthropic/Claude 모델 이름이 z.ai 모델 ID로 변환되는 방식을 구성합니다.\",\n                    \"refresh\": \"모델 가져오기\",\n                    \"refreshing\": \"가져오는 중...\",\n                    \"hint\": \"사용 가능한 모델: {{count}}. 제안을 선택하거나 사용자 지정 모델 ID를 입력하세요.\",\n                    \"error\": \"모델 가져오기 실패: {{error}}\",\n                    \"select_placeholder\": \"모델 선택...\",\n                    \"opus\": \"Opus 제품군 → z.ai 모델\",\n                    \"opus_tooltip\": \"들어오는 모델에 \\\"opus\\\"가 포함된 경우 (예: claude-opus-*) 사용되는 기본 z.ai 모델 ID입니다.\",\n                    \"sonnet\": \"Sonnet 제품군 → z.ai 모델\",\n                    \"sonnet_tooltip\": \"다른 Claude 모델 (예: claude-sonnet-* 및 대부분의 claude-* 요청)에 사용되는 기본 z.ai 모델 ID입니다.\",\n                    \"haiku\": \"Haiku 제품군 → z.ai 모델\",\n                    \"haiku_tooltip\": \"들어오는 모델에 \\\"haiku\\\"가 포함된 경우 (예: claude-haiku-*) 사용되는 기본 z.ai 모델 ID입니다.\",\n                    \"advanced_title\": \"고급 재정의\",\n                    \"advanced_tooltip\": \"선택적 정확한 일치 재정의. 들어오는 모델 문자열이 규칙 키와 일치하면 매핑된 z.ai 모델 ID로 대체됩니다.\",\n                    \"from_label\": \"들어오는 모델\",\n                    \"to_label\": \"z.ai 모델\",\n                    \"add_rule\": \"추가\",\n                    \"empty\": \"구성된 재정의 규칙이 없습니다.\",\n                    \"from_placeholder\": \"From (예: claude-3-opus)\",\n                    \"to_placeholder\": \"To (예: glm-4)\"\n                },\n                \"modes\": {\n                    \"off\": \"끄기\",\n                    \"exclusive\": \"모든 Anthropic 요청\",\n                    \"pooled\": \"풀링됨 (한 슬롯)\",\n                    \"fallback\": \"대체 전용\"\n                },\n                \"mcp\": {\n                    \"title\": \"MCP 서버 (로컬 프록시 경유)\",\n                    \"title_tooltip\": \"MCP 클라이언트가 연결할 수 있도록 이 로컬 프록시에 선택적 /mcp/* 엔드포인트를 노출합니다. 서비스가 실행 중이고, z.ai가 구성되어 있고, 해당 토글이 활성화된 경우에만 사용할 수 있습니다.\",\n                    \"enabled\": \"MCP 프록시 활성화\",\n                    \"enabled_tooltip\": \"MCP 엔드포인트의 마스터 스위치입니다. 꺼져 있으면 모든 /mcp/* 경로가 404를 반환합니다.\",\n                    \"web_search\": \"웹 검색\",\n                    \"web_search_tooltip\": \"/mcp/web_search_prime/mcp를 노출하고 z.ai 웹 검색 MCP 업스트림으로 요청을 전달합니다.\",\n                    \"web_reader\": \"웹 리더\",\n                    \"web_reader_tooltip\": \"/mcp/web_reader/mcp를 노출하고 z.ai 웹 리더 MCP 업스트림으로 요청을 전달합니다.\",\n                    \"vision\": \"비전\",\n                    \"vision_tooltip\": \"z.ai가 지원하는 비전 도구를 제공하는 /mcp/zai-mcp-server/mcp (로컬 MCP 서버)를 노출합니다.\",\n                    \"local_endpoints\": \"로컬 엔드포인트 (MCP 클라이언트가 이 URL을 사용하도록 설정):\",\n                    \"local_endpoints_tooltip\": \"MCP 클라이언트에서 이 URL을 사용하세요. API 프록시와 동일한 호스트/포트를 공유하며 프록시 인증 정책을 따릅니다.\"\n                }\n            },\n            \"request_timeout\": \"요청 시간 초과\",\n            \"request_timeout_tooltip\": \"스트리밍을 포함하여 프록시가 업스트림 응답을 기다리는 최대 시간(초)입니다. 긴 생성을 위해 늘리십시오. 적용하려면 다시 시작해야 합니다.\",\n            \"request_timeout_hint\": \"기본값 120초, 범위 30-7200초. 변경 사항 적용을 위해 서비스 다시 시작 필요.\",\n            \"enable_logging\": \"요청 로깅 활성화\",\n            \"enable_logging_hint\": \"디버깅을 위한 히스토리 기록 (약간의 성능 비용)\",\n            \"upstream_proxy\": {\n                \"title\": \"글로벌 업스트림 프록시 (글로벌 프록시)\",\n                \"desc\": \"활성화되면 모든 외부 요청(API 프록시, 토큰 새로고침, 할당량 확인, 업데이트 확인)이 이 프록시를 통해 라우팅됩니다.\",\n                \"desc_short\": \"프록시 풀에서 적절한 계정을 찾지 못했을 때 사용하는 보조 또는 대체용 글로벌 프록시입니다.\",\n                \"enable\": \"업스트림 프록시 활성화\",\n                \"url\": \"프록시 URL\",\n                \"url_placeholder\": \"예: http://127.0.0.1:7890 또는 socks5://127.0.0.1:7890\",\n                \"tip\": \"HTTP, HTTPS 및 SOCKS5 지원.\",\n                \"socks5h_hint\": \"업스트림 차단을 방지하고 원격 DNS (Remote DNS)를 유지하려면 프로토콜을 socks5h://로 수동 변경하십시오.\",\n                \"validation_error\": \"업스트림 프록시가 활성화된 경우 프록시 URL이 필요합니다\",\n                \"restart_hint\": \"프록시 설정이 저장되었습니다. 변경 사항을 적용하려면 앱을 다시 시작하세요.\"\n            },\n            \"scheduling\": {\n                \"title\": \"계정 로테이션 및 스케줄링\",\n                \"title_tooltip\": \"세션이 계정에 바인딩되는 방식과 속도 제한이 처리되는 방식을 제어합니다.\",\n                \"subtitle\": \"모든 프로토콜(OpenAI/Gemini/Claude)에 대한 프롬프트 캐싱 및 속도 제한 처리를 최적화합니다.\",\n                \"mode\": \"스케줄링 모드\",\n                \"mode_tooltip\": \"캐시 우선: 세션을 계정에 바인딩하고 속도 제한 시 대기 (캐시 활용 극대화); 균형: 세션을 바인딩하지만 속도 제한 시 계정 전환; 성능: 표준 라운드 로빈 로테이션.\",\n                \"modes\": {\n                    \"CacheFirst\": \"캐시 우선\",\n                    \"Balance\": \"균형\",\n                    \"PerformanceFirst\": \"성능\"\n                },\n                \"modes_desc\": {\n                    \"CacheFirst\": \"세션을 계정에 바인딩하고, 제한 시 정확히 대기합니다 (프롬프트 캐시 적중 극대화).\",\n                    \"Balance\": \"세션을 바인딩하지만, 제한 시 사용 가능한 계정으로 자동 전환합니다 (캐시와 가용성의 균형).\",\n                    \"PerformanceFirst\": \"세션 바인딩 없음, 순수 라운드 로빈 로테이션 (동시성 높음).\"\n                },\n                \"max_wait\": \"최대 대기 (초)\",\n                \"max_wait_tooltip\": \"'캐시 우선' 모드에서만 사용: 속도 제한 재설정 시간이 이 값보다 작으면 전환하는 대신 대기합니다.\",\n                \"clear_bindings\": \"세션 바인딩 지우기\",\n                \"clear_bindings_tooltip\": \"모든 세션-계정 바인딩을 하드 리셋하여 다음 요청 시 계정이 다시 할당되도록 합니다.\",\n                \"clear_rate_limits\": \"속도 제한 기록 지우기\",\n                \"clear_rate_limits_tooltip\": \"모든 계정의 로컬 속도 제한을 즉시 삭제하고, 다음 요청 시 직접 시도하도록 강제합니다.\"\n            },\n            \"circuit_breaker\": {\n                \"title\": \"적응형 서킷 브레이커\",\n                \"tooltip\": \"할당량 초과로 인하여 반복적으로 실패하는 계정의 잠금 시간을 자동으로 늘립니다. 이는 작동하지 않는 계정에 대한 불필요한 API 호출을 방지하고, 일시적인 오류는 빠르게 복구될 수 있게 합니다.\",\n                \"backoff_levels\": \"백오프 레벨 (초)\",\n                \"input_placeholder\": \"백오프 기간을 초 단위로 쉼표로 구분하여 입력하세요\",\n                \"level\": \"레벨 {{level}}\",\n                \"invalid_format\": \"잘못된 형식입니다. 쉼표로 구분된 숫자(예: 60, 300)를 사용하세요.\",\n                \"clear_records\": \"모든 제한 기록 삭제\"\n            },\n            \"experimental\": {\n                \"title\": \"실험적 설정\",\n                \"title_tooltip\": \"향후 버전에서 조정되거나 제거될 수 있는 탐색적 기능입니다.\",\n                \"enable_usage_scaling\": \"사용량 스케일링 활성화\",\n                \"enable_usage_scaling_tooltip\": \"Claude 프로토콜용. 총 입력이 30k 토큰을 초과할 때 공격적인 스케일링을 활성화하여 잦은 클라이언트 측 압축을 방지합니다. 참고: 활성화 후 보고된 사용량은 실제 청구 내역을 반영하지 않습니다.\",\n                \"context_compression_threshold_l1\": \"L1 압축 임계값 (도구 트리밍)\",\n                \"context_compression_threshold_l1_tooltip\": \"공간을 절약하기 위해 오래된 도구 호출 기록을 트리밍합니다. 권장: 0.4 (40%)\",\n                \"context_compression_threshold_l2\": \"L2 압축 임계값 (Thinking 압축)\",\n                \"context_compression_threshold_l2_tooltip\": \"서명을 보존하면서 초기 생각 블록을 압축합니다. 권장: 0.55 (55%)\",\n                \"context_compression_threshold_l3\": \"L3 압축 임계값 (요약 피벗)\",\n                \"context_compression_threshold_l3_tooltip\": \"궁극적인 재설정: XML 상태 요약을 생성하고 새 세션으로 피벗합니다. 가장 효율적인 토큰 사용. 권장: 0.7 (70%)\"\n            }\n        },\n        \"cloudflared\": {\n            \"title\": \"공개 액세스 (Cloudflared)\",\n            \"subtitle\": \"Cloudflare 터널을 통해 로컬 서비스를 인터넷에 노출\",\n            \"not_installed\": \"Cloudflared가 설치되지 않음\",\n            \"install_hint\": \"Cloudflared는 Cloudflare의 무료 터널 도구입니다. 공용 IP나 포트 포워딩 없이 로컬 프록시를 인터넷에 노출합니다. 아래 버튼을 클릭하여 설치하세요.\",\n            \"install\": \"지금 설치\",\n            \"installing\": \"설치 중...\",\n            \"install_success\": \"Cloudflared 설치 성공\",\n            \"install_failed\": \"설치 실패: {{error}}\",\n            \"installed\": \"설치됨\",\n            \"version\": \"버전\",\n            \"mode_label\": \"터널 모드\",\n            \"mode_quick\": \"빠른 터널\",\n            \"mode_quick_desc\": \"자동 생성된 임시 URL (*.trycloudflare.com), 계정 필요 없음, 재시작 시 URL 변경됨\",\n            \"mode_auth\": \"네임드 터널\",\n            \"mode_auth_desc\": \"Cloudflare 계정 토큰 사용, 사용자 지정 도메인 지원, 영구 URL\",\n            \"token\": \"터널 토큰\",\n            \"token_placeholder\": \"여기에 Cloudflare 터널 토큰을 붙여넣으세요\",\n            \"token_hint\": \"Cloudflare Zero Trust 대시보드에서 가져오기\",\n            \"token_required\": \"네임드 터널 모드에는 토큰이 필요합니다\",\n            \"use_http2\": \"HTTP/2 사용\",\n            \"use_http2_desc\": \"더 높은 호환성, 중국 본토 권장\",\n            \"status_label\": \"터널 상태\",\n            \"status_stopped\": \"중지됨\",\n            \"status_starting\": \"시작 중...\",\n            \"status_running\": \"실행 중\",\n            \"status_stopping\": \"중지 중...\",\n            \"status_error\": \"오류\",\n            \"public_url\": \"공개 URL\",\n            \"public_url_placeholder\": \"터널이 시작되면 여기에 공개 URL이 표시됩니다\",\n            \"copy_url\": \"URL 복사\",\n            \"url_copied\": \"URL 복사됨\",\n            \"start_tunnel\": \"터널 시작\",\n            \"stop_tunnel\": \"터널 중지\",\n            \"running\": \"터널 실행 중\",\n            \"started\": \"터널 시작됨\",\n            \"stopped\": \"터널 중지됨\",\n            \"start_failed\": \"시작 실패: {{error}}\",\n            \"stop_failed\": \"중지 실패: {{error}}\",\n            \"require_proxy_running\": \"먼저 로컬 프록시 서비스를 시작해주세요\",\n            \"connection_info\": \"연결 정보\",\n            \"local_port\": \"로컬 포트\",\n            \"tunnel_protocol\": \"터널 프로토콜\"\n        },\n        \"example\": {\n            \"title\": \"사용 예제\",\n            \"curl\": \"cURL\",\n            \"python\": \"파이썬 (Python)\",\n            \"python_anthropic\": \"from anthropic import Anthropic\\n\\nclient = Anthropic(\\n    # 권장: 127.0.0.1\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\n# 참고: Antigravity는 Anthropic SDK를 통해 모든 모델 호출 지원\\nresponse = client.messages.create(\\n    model=\\\"{{modelId}}\\\",\\n    max_tokens=1024,\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"안녕하세요\\\"}]\\n)\\n\\nprint(response.content[0].text)\",\n            \"python_gemini\": \"# 설치: pip install google-generativeai\\nimport google.generativeai as genai\\n\\n# Antigravity 프록시 주소 사용 (권장 127.0.0.1)\\ngenai.configure(\\n    api_key=\\\"{{apiKey}}\\\",\\n    transport='rest',\\n    client_options={'api_endpoint': '{{rawBaseUrl}}'}\\n)\\n\\nmodel = genai.GenerativeModel('{{modelId}}')\\nresponse = model.generate_content(\\\"안녕하세요\\\")\\nprint(response.text)\",\n            \"python_openai_image\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    # 옵션 1: 크기 사용 (권장)\\n    # 지원: \\\"1024x1024\\\" (1:1), \\\"1280x720\\\" (16:9), \\\"720x1280\\\" (9:16), \\\"1216x896\\\" (4:3)\\n    extra_body={ \\\"size\\\": \\\"1024x1024\\\" },\\n\\n    # 옵션 2: 모델 접미사 사용\\n    # 예: gemini-3-pro-image-16-9, gemini-3-pro-image-4-3\\n    # model=\\\"gemini-3-pro-image-16-9\\\",\\n    messages=[{\\n        \\\"role\\\": \\\"user\\\",\\n        \\\"content\\\": \\\"미래 도시를 그려줘\\\"\\n    }]\\n)\\n\\nprint(response.choices[0].message.content)\",\n            \"python_openai\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"안녕하세요\\\"}]\\n)\\n\\nprint(response.choices[0].message.content)\"\n        },\n        \"examples\": {\n            \"title\": \"사용 예제\"\n        },\n        \"dialog\": {\n            \"confirm_regenerate\": \"API 키를 재생성하시겠습니까? 이전 키는 즉시 무효화됩니다.\",\n            \"operate_failed\": \"작업 실패: {{error}}\",\n            \"reset_mapping_title\": \"모델 매핑 초기화\",\n            \"reset_mapping_msg\": \"모든 모델 매핑을 시스템 기본값으로 초기화하시겠습니까? 이 작업은 되돌릴 수 없습니다.\",\n            \"regenerate_key_title\": \"API 키 재생성\",\n            \"regenerate_key_msg\": \"API 키를 재생성하시겠습니까? 이전 키는 즉시 무효화됩니다.\",\n            \"clear_bindings_title\": \"세션 바인딩 지우기\",\n            \"clear_bindings_msg\": \"모든 세션-계정 바인딩을 지우시겠습니까?\",\n            \"clear_rate_limits_title\": \"속도 제한 기록 지우기\",\n            \"clear_rate_limits_confirm\": \"모든 로컬 속도 제한 기록을 지우시겠습니까?\"\n        },\n        \"model\": {\n            \"flash\": \"빠른 응답 (Flash)\",\n            \"flash_preview\": \"Flash 미리보기\",\n            \"flash_lite\": \"가볍고 빠름 (Lite)\",\n            \"flash_thinking\": \"생각 능력 (Thinking)\",\n            \"pro_legacy\": \"레거시 Pro\",\n            \"pro_low\": \"고성능\",\n            \"pro_high\": \"최고의 추론\",\n            \"pro_image\": \"이미지 생성 (1:1)\",\n            \"pro_image_16_9\": \"이미지 생성 (16:9)\",\n            \"pro_image_9_16\": \"이미지 생성 (9:16)\",\n            \"pro_image_4_3\": \"이미지 생성 (4:3)\",\n            \"pro_image_3_4\": \"이미지 생성 (3:4)\",\n            \"pro_image_1_1\": \"이미지 생성 (1:1)\",\n            \"claude_sonnet\": \"코드 추론 (Sonnet)\",\n            \"claude_sonnet_thinking\": \"생각의 사슬 (Thinking)\",\n            \"claude_opus_thinking\": \"가장 강력한 생각 (Opus Thinking)\"\n        },\n        \"mapping\": {\n            \"title\": \"Claude Code 모델 매핑\",\n            \"description\": \"Claude Code 모델을 Antigravity 모델에 매핑합니다. 비용과 속도를 최적화하세요.\",\n            \"default\": \"기본\",\n            \"sonnet_desc\": \"복잡한 작업에 가장 적합\",\n            \"opus_desc\": \"프리미엄 계층\",\n            \"haiku_desc\": \"빠른 답변에 최적\",\n            \"maps_to\": \"Antigravity 매핑\",\n            \"apply_recommended\": \"권장 설정 적용\",\n            \"restore_defaults\": \"기본 구성 복원\",\n            \"reset_all\": \"모두 초기화\"\n        },\n        \"router\": {\n            \"title\": \"모델 라우터\",\n            \"subtitle\": \"시리즈별로 모델을 라우팅하거나 사용자 지정 정확한 매핑을 추가합니다.\\n참고: 네이티브 Claude 패스스루 모델(예: claude-opus-4-6-thinking)은 기본적으로 시리즈 그룹을 우회합니다. 재정의하려면 \\\"전문가 사용자 지정 라우팅\\\"을 사용하세요.\",\n            \"subtitle_simple\": \"와일드카드 또는 정확한 매핑으로 모델 라우팅 사용자 지정\",\n            \"background_task_title\": \"백그라운드 작업 모델\",\n            \"background_task_desc\": \"제목 생성, 요약 등 Claude CLI 백그라운드 작업에 사용되는 모델 (기본값: gemini-2.5-flash)\",\n            \"use_default\": \"시스템 기본값 사용\",\n            \"current_model\": \"현재 모델\",\n            \"apply_presets\": \"프리셋 적용\",\n            \"presets_applied\": \"프리셋이 성공적으로 적용되었습니다\",\n            \"preset_default\": \"기본\",\n            \"preset_default_desc\": \"GPT-4 → Gemini Pro, Claude → Opus\",\n            \"preset_performance\": \"성능 우선\",\n            \"preset_performance_desc\": \"모두 고성능 모델 사용\",\n            \"preset_cost\": \"비용 최적화\",\n            \"preset_cost_desc\": \"경제적인 모델 우선\",\n            \"preset_balanced\": \"균형\",\n            \"preset_balanced_desc\": \"성능과 비용의 균형\",\n            \"built_in_presets\": \"내장 프리셋\",\n            \"custom_presets\": \"사용자 정의 프리셋\",\n            \"apply_selected\": \"선택 사항 적용\",\n            \"add_preset\": \"현재 매핑 저장\",\n            \"delete_preset\": \"현재 프리셋 삭제\",\n            \"cannot_delete_builtin\": \"내장 프리셋은 삭제할 수 없습니다\",\n            \"no_mapping_to_save\": \"저장할 매핑 설정이 없습니다\",\n            \"preset_name_required\": \"프리셋 이름이 필요합니다\",\n            \"preset_saved\": \"프리셋이 성공적으로 저장되었습니다\",\n            \"manage_presets_title\": \"사용자 정의 프리셋 관리\",\n            \"save_current_as_preset\": \"현재 설정 저장\",\n            \"preset_name_placeholder\": \"프리셋 이름 입력...\",\n            \"save_hint\": \"현재 활성화된 모델 매핑을 재사용 가능한 프리셋으로 저장합니다.\",\n            \"your_presets\": \"내 프리셋\",\n            \"no_custom_presets\": \"아직 사용자 정의 프리셋이 없습니다\",\n            \"mappings_count\": \"개의 매핑\",\n            \"custom_preset_desc\": \"사용자 정의 프리셋\",\n            \"custom_mappings\": \"사용자 지정 매핑\",\n            \"group_title\": \"시리즈 그룹\",\n            \"groups\": {\n                \"claude_45\": {\n                    \"name\": \"Claude 4.6 TK 시리즈\",\n                    \"desc\": \"Opus 4.5 TK\"\n                },\n                \"claude_35\": {\n                    \"name\": \"Claude 3.5 시리즈\",\n                    \"desc\": \"Sonnet 3.5, Haiku 3.5\"\n                },\n                \"gpt_4\": {\n                    \"name\": \"GPT-4 / o1 시리즈\",\n                    \"desc\": \"GPT-4, Turbo, o1-preview\"\n                },\n                \"gpt_4o\": {\n                    \"name\": \"GPT-4o / 3.5 시리즈\",\n                    \"desc\": \"GPT-4o, Mini, 3.5 Turbo\"\n                },\n                \"gpt_5\": {\n                    \"name\": \"GPT-5 시리즈\",\n                    \"desc\": \"GPT-5.1, GPT-5.2 xhigh\"\n                }\n            },\n            \"expert_title\": \"전문가 사용자 지정 라우팅\",\n            \"expert_subtitle\": \"모든 원본 모델 ID에 대한 정밀 매칭.\",\n            \"custom_mapping_tip\": \"💡 임의의 모델 ID를 수동으로 입력하여 출시되지 않은 모델을 경험할 수 있습니다 (예: claude-opus-4-6).\",\n            \"custom_mapping_warning\": \"주의: 모든 계정이 출시되지 않은 모델을 지원하는 것은 아닙니다.\",\n            \"money_saving_tip\": \"💰 비용 절약 팁:\",\n            \"haiku_optimization_tip\": \"Claude CLI는 기본적으로 백그라운드 작업에 {{model}}을 사용합니다. 더 저렴한 Flash 모델에 매핑하여 비용을 ~95% 절약하세요\",\n            \"haiku_optimization_btn\": \"빠른 최적화\",\n            \"haiku_tip_title\": \"💰 비용 절약 팁:\",\n            \"haiku_tip_body_before\": \"Claude CLI는 백그라운드 작업에 기본적으로\",\n            \"haiku_tip_body_after\": \"를 사용합니다; 더 저렴한 Flash 모델에 매핑하면 비용을 약 95% 절약할 수 있습니다.\",\n            \"haiku_tip_action\": \"최적화\",\n            \"reset_confirm\": \"모든 매핑을 시스템 기본값으로 초기화하시겠습니까?\",\n            \"reset_mapping\": \"매핑 초기화\",\n            \"add_mapping\": \"매핑 추가\",\n            \"current_list\": \"사용자 지정 목록\",\n            \"no_custom_mapping\": \"사용자 지정 매핑 없음\",\n            \"gemini3_only_warning\": \"⚠️ Gemini 3 시리즈 전용\",\n            \"default_suffix\": \" (기본)\",\n            \"original_id\": \"원본 ID\",\n            \"route_to\": \"라우팅 대상\",\n            \"select_target_model\": \"대상 모델 선택\",\n            \"original_placeholder\": \"원본 (예: gpt-4 또는 gpt-4*)\"\n        },\n        \"multi_protocol\": {\n            \"title\": \"다중 프로토콜 지원\",\n            \"subtitle\": \"즐겨 사용하는 AI 도구 및 CLI와 원활하게 통합\",\n            \"description\": \"로컬 프록시는 OpenAI, Anthropic 및 Gemini 프로토콜을 지원하여 광범위한 애플리케이션과의 호환성을 보장합니다.\",\n            \"openai_label\": \"OpenAI 프로토콜\",\n            \"anthropic_label\": \"Anthropic 프로토콜\",\n            \"openai_tools\": \"Cherry Studio, NextChat\",\n            \"anthropic_tools\": \"Claude Code CLI\",\n            \"gemini_label\": \"Gemini 프로토콜\",\n            \"gemini_tools\": \"Google AI SDK, LangChain\",\n            \"quick_integration\": \"빠른 통합\",\n            \"click_tip\": \"👆 모델을 클릭하여 코드 예제 업데이트\",\n            \"copy_base\": \"기본 주소 복사\"\n        },\n        \"supported_models\": {\n            \"title\": \"지원되는 모델 및 통합\",\n            \"model_name\": \"모델 이름\",\n            \"model_id\": \"모델 ID\",\n            \"description\": \"설명\",\n            \"action\": \"작업\"\n        },\n        \"cli_sync\": {\n            \"title\": \"원클릭 CLI 동기화\",\n            \"subtitle\": \"현재 API 엔드포인트와 키를 로컬 AI CLI 도구에 빠르게 동기화합니다.\",\n            \"card_title\": \"{{name}} 구성\",\n            \"status\": {\n                \"not_installed\": \"감지되지 않음\",\n                \"installed\": \"v{{version}}\",\n                \"synced\": \"이 앱을 가리킴\",\n                \"not_synced\": \"동기화되지 않음\",\n                \"detecting\": \"감지 중...\",\n                \"current_base_url\": \"현재 기본 URL\"\n            },\n            \"btn_sync\": \"지금 설정 동기화\",\n            \"btn_view\": \"구성 보기\",\n            \"btn_restore\": \"기본값 복원\",\n            \"btn_restore_backup\": \"백업 복원\",\n            \"restore_confirm\": \"{{name}}의 구성을 공식 기본 URL로 복원하시겠습니까?\",\n            \"restore_backup_confirm\": \"백업 구성을 찾았습니다. 복원하시겠습니까?\",\n            \"modal\": {\n                \"view_title\": \"{{name}} 구성 내용\",\n                \"copy_success\": \"구성 내용 복사됨\"\n            },\n            \"toast\": {\n                \"sync_success\": \"동기화 성공! {{name}}이(가) 준비되었습니다.\",\n                \"sync_error\": \"동기화 실패: {{error}}\"\n            },\n            \"sync_confirm_title\": \"동기화 확인\",\n            \"sync_confirm_message\": \"{{name}} 구성을 동기화할 준비가 되었습니다. ⚠️ 경고: 이 작업은 기존 로컬 구성 파일(예: 로그인 토큰, API 키)을 덮어씁니다. 계속하시겠습니까?\"\n        }\n    },\n    \"monitor\": {\n        \"page_title\": \"API 모니터 대시보드\",\n        \"page_subtitle\": \"실시간 요청 로깅 및 분석\",\n        \"open_monitor\": \"모니터 열기\",\n        \"logging_status\": {\n            \"active\": \"기록 중\",\n            \"paused\": \"일시 중지됨\"\n        },\n        \"stats\": {\n            \"total\": \"전체\",\n            \"ok\": \"성공\",\n            \"err\": \"오류\"\n        },\n        \"filters\": {\n            \"placeholder\": \"모델, 경로 또는 상태로 필터링...\",\n            \"quick_filters\": \"빠른 필터:\",\n            \"all\": \"모두\",\n            \"error\": \"오류\",\n            \"chat\": \"채팅\",\n            \"gemini\": \"Gemini\",\n            \"claude\": \"Claude\",\n            \"images\": \"이미지\",\n            \"reset\": \"초기화\",\n            \"by_account\": \"계정별 필터\",\n            \"all_accounts\": \"모든 계정\"\n        },\n        \"table\": {\n            \"status\": \"상태\",\n            \"method\": \"메서드\",\n            \"model\": \"모델\",\n            \"protocol\": \"프로토콜\",\n            \"account\": \"계정\",\n            \"path\": \"경로\",\n            \"usage\": \"토큰\",\n            \"duration\": \"소요 시간\",\n            \"time\": \"시간\",\n            \"empty\": \"기록된 요청 없음\"\n        },\n        \"details\": {\n            \"title\": \"요청 상세\",\n            \"request_payload\": \"요청 페이로드\",\n            \"response_payload\": \"응답 페이로드\",\n            \"duration\": \"소요 시간\",\n            \"tokens\": \"토큰 (입력/출력)\",\n            \"time\": \"시간\",\n            \"model\": \"모델\",\n            \"mapped_model\": \"매핑된 모델\",\n            \"protocol\": \"프로토콜\",\n            \"account_used\": \"사용된 계정\",\n            \"id\": \"요청 ID\",\n            \"payload_empty\": \"데이터 없음\"\n        },\n        \"dialog\": {\n            \"clear_title\": \"프록시 로그 지우기\",\n            \"clear_msg\": \"모든 프록시 로그를 지우시겠습니까? 이 작업은 되돌릴 수 없습니다.\"\n        }\n    },\n    \"update_notification\": {\n        \"title\": \"새 버전 사용 가능\",\n        \"message\": \"최적화 및 개선 사항이 포함된 새 버전이 준비되었습니다. 현재: v{{current}}\",\n        \"ready\": \"업데이트 준비 완료\",\n        \"downloading\": \"업데이트 다운로드 중...\",\n        \"restarting\": \"애플리케이션 재시작 중...\",\n        \"auto_update\": \"자동 업데이트\",\n        \"toast\": {\n            \"not_ready\": \"자동 업데이트 패키지가 준비되지 않았습니다. 다운로드 페이지로 이동합니다...\",\n            \"failed\": \"자동 업데이트에 실패했습니다. 다운로드 페이지로 이동합니다...\"\n        }\n    },\n    \"login\": {\n        \"title\": \"안전한 액세스 제어\",\n        \"desc\": \"현재 Web 모드에서 실행 중입니다. 관리 비밀번호 또는 API 키를 입력하여 액세스하십시오.\",\n        \"placeholder\": \"관리 비밀번호 또는 API 키를 입력하십시오\",\n        \"btn_login\": \"인증 및 입장\",\n        \"btn_verifying\": \"인증 중...\",\n        \"error_invalid_key\": \"비밀번호 또는 API 키가 잘못되었습니다. 다시 시도하십시오\",\n        \"error_network\": \"네트워크 연결에 실패했습니다. 서비스가 실행 중인지 확인하십시오\",\n        \"note\": \"참고: 별도의 관리 비밀번호가 설정된 경우 관리 비밀번호를 입력하십시오. 그렇지 않으면 API_KEY를 입력하십시오.\",\n        \"lookup_hint\": \"잊으신 경우 docker logs antigravity-manager를 실행하여 Current API Key 또는 Web UI Password를 찾으십시오.\",\n        \"config_hint\": \"또는 grep -E '\\\"api_key\\\"|\\\"admin_password\\\"' ~/.antigravity_tools/gui_config.json을 실행하여 확인하십시오.\"\n    },\n    \"token_stats\": {\n        \"title\": \"토큰 사용 통계\",\n        \"hourly\": \"시간\",\n        \"daily\": \"일\",\n        \"weekly\": \"주\",\n        \"total_tokens\": \"총 토큰\",\n        \"input_tokens\": \"입력 토큰\",\n        \"output_tokens\": \"출력 토큰\",\n        \"accounts_used\": \"활성 계정\",\n        \"models_used\": \"사용된 모델\",\n        \"model_trend\": \"모델 사용 추세\",\n        \"account_trend\": \"계정 사용 추세\",\n        \"usage_trend\": \"토큰 사용 추세\",\n        \"by_account\": \"계정별\",\n        \"by_model\": \"모델별\",\n        \"by_account_view\": \"계정별\",\n        \"model_details\": \"모델 분석\",\n        \"account_details\": \"계정 분석\",\n        \"model\": \"모델\",\n        \"account\": \"계정\",\n        \"requests\": \"요청\",\n        \"input\": \"입력\",\n        \"output\": \"출력\",\n        \"total\": \"합계\",\n        \"percentage\": \"점유율\",\n        \"no_data\": \"사용 가능한 데이터 없음\"\n    },\n    \"errors\": {\n        \"stream\": {\n            \"timeout_error\": \"요청 시간 초과, 네트워크 연결을 확인해주세요\",\n            \"connection_error\": \"연결 실패, 네트워크 또는 프록시 설정을 확인해주세요\",\n            \"decode_error\": \"네트워크 불안정, 데이터 전송이 중단되었습니다. 시도: 1) 네트워크 확인 2) 프록시 전환 3) 재시도\",\n            \"stream_error\": \"스트림 전송 오류, 나중에 다시 시도해주세요\",\n            \"unknown_error\": \"알 수 없는 오류 발생, 나중에 다시 시도해주세요\"\n        }\n    },\n    \"security\": {\n        \"title\": \"보안 모니터\",\n        \"refresh_data\": \"데이터 새로고침\",\n        \"refresh\": \"새로고침\",\n        \"tab_logs\": \"액세스 로그\",\n        \"tab_stats\": \"통계 분석\",\n        \"tab_blacklist\": \"블랙리스트\",\n        \"tab_whitelist\": \"화이트리스트\",\n        \"tab_config\": \"보안 설정\",\n        \"stats\": {\n            \"total_requests\": \"총 요청 수\",\n            \"total_requests_desc\": \"기록된 모든 요청\",\n            \"unique_ips\": \"고유 IP 수\",\n            \"unique_ips_desc\": \"서로 다른 클라이언트 IP 주소\",\n            \"blocked_requests\": \"차단된 요청\",\n            \"blocked_requests_desc\": \"규칙에 의해 거부된 요청\",\n            \"ip_activity_token_usage\": \"IP 활동 및 토큰 사용량\",\n            \"hour\": \"시간\",\n            \"day\": \"일\",\n            \"week\": \"주\",\n            \"month\": \"월\",\n            \"rank\": \"순위\",\n            \"ip_address\": \"IP 주소\",\n            \"activity_reqs\": \"활동 (요청 수)\",\n            \"total_token\": \"총 토큰\",\n            \"prompt\": \"프롬프트\",\n            \"completion\": \"완성\",\n            \"no_data\": \"데이터 없음\"\n        },\n        \"logs\": {\n            \"search_placeholder\": \"IP, 경로, 사용자 에이전트 검색...\",\n            \"username\": \"사용자\",\n            \"show_blocked_only\": \"차단된 항목만 표시\",\n            \"status\": \"상태\",\n            \"ip_address\": \"IP 주소\",\n            \"method\": \"메서드\",\n            \"path\": \"경로\",\n            \"duration\": \"소요 시간\",\n            \"time\": \"시간\",\n            \"reason\": \"사유\",\n            \"blocked\": \"차단됨\",\n            \"no_logs\": \"로그 없음\",\n            \"total_records\": \"총 {{total}}개 레코드\",\n            \"prev_page\": \"이전\",\n            \"next_page\": \"다음\",\n            \"page_num\": \"{{page}} 페이지\",\n            \"per_page_suffix\": \"/페이지\"\n        },\n        \"blacklist\": {\n            \"add_ip\": \"IP 추가\",\n            \"search_placeholder\": \"검색...\",\n            \"added_at\": \"추가 일시\",\n            \"expires_at\": \"만료 일시\",\n            \"no_data\": \"블랙리스트 데이터 없음\",\n            \"add_title\": \"블랙리스트에 추가\",\n            \"ip_cidr_label\": \"IP 주소 또는 CIDR\",\n            \"ip_cidr_placeholder\": \"예: 192.168.1.1 또는 10.0.0.0/24\",\n            \"reason_label\": \"사유 (선택)\",\n            \"reason_placeholder\": \"예: 악성 스캔\",\n            \"expires_label\": \"만료 시간 (시간, 선택)\",\n            \"expires_placeholder\": \"영구적으로 비워두기\",\n            \"cancel\": \"취소\",\n            \"confirm\": \"추가\",\n            \"add_btn\": \"추가\"\n        },\n        \"whitelist\": {\n            \"add_ip\": \"신뢰할 수 있는 IP 추가\",\n            \"no_data\": \"화이트리스트 데이터 없음\",\n            \"add_title\": \"화이트리스트에 추가\",\n            \"description_label\": \"설명 (선택)\",\n            \"description_placeholder\": \"예: 내부 서버\",\n            \"cancel\": \"취소\",\n            \"confirm\": \"추가\",\n            \"add_btn\": \"추가\"\n        },\n        \"config\": {\n            \"title\": \"보안 설정\",\n            \"save\": \"변경 사항 저장\",\n            \"saving\": \"저장 중...\",\n            \"blacklist_title\": \"IP 블랙리스트\",\n            \"blacklist_desc\": \"차단된 IP 주소 및 규칙 관리\",\n            \"enable_blacklist\": \"블랙리스트 보호 활성화\",\n            \"block_msg_label\": \"사용자 지정 차단 메시지\",\n            \"block_msg_desc\": \"차단된 클라이언트에 반환되는 응답 내용\",\n            \"whitelist_title\": \"IP 화이트리스트\",\n            \"whitelist_desc\": \"신뢰할 수 있는 IP 주소 관리\",\n            \"enable_whitelist\": \"화이트리스트 모드 활성화\",\n            \"whitelist_warning\": \"경고: 화이트리스트 모드를 활성화하면 화이트리스트에 없는 IP의 모든 요청이 차단됩니다. 프록시를 통해 액세스하는 경우 자신을 차단하지 않도록 주의하십시오.\",\n            \"whitelist_priority\": \"화이트리스트 우선 (블랙리스트보다 우선)\",\n            \"whitelist_priority_desc\": \"활성화하면 블랙리스트 규칙과 일치하더라도 화이트리스트 IP가 허용됩니다.\",\n            \"load_error\": \"설정 로드 실패\",\n            \"save_success\": \"설정 저장됨\",\n            \"save_error\": \"설정 저장 실패\"\n        }\n    },\n    \"user_token\": {\n        \"title\": \"사용자 토큰 관리\",\n        \"total_users\": \"총 사용자 수\",\n        \"active_tokens\": \"활성 토큰\",\n        \"total_created\": \"총 생성됨\",\n        \"create\": \"토큰 생성\",\n        \"username\": \"사용자 이름\",\n        \"token\": \"토큰\",\n        \"expires\": \"만료\",\n        \"usage\": \"사용량\",\n        \"ip_limit\": \"IP 제한\",\n        \"created\": \"생성됨\",\n        \"today_requests\": \"오늘의 요청\",\n        \"never\": \"없음\",\n        \"renew\": \"갱신\",\n        \"renew_button\": \"갱신\",\n        \"unlimited\": \"무제한\",\n        \"create_title\": \"새 토큰 생성\",\n        \"description\": \"설명\",\n        \"curfew\": \"통행금지 (서비스 불가 시간)\",\n        \"edit_title\": \"토큰 편집\",\n        \"username_required\": \"사용자 이름은 필수입니다\",\n        \"renew_success\": \"성공적으로 갱신됨\",\n        \"expires_day\": \"1일\",\n        \"expires_week\": \"1주\",\n        \"expires_month\": \"1개월\",\n        \"expires_never\": \"없음\",\n        \"no_data\": \"토큰을 찾을 수 없습니다\",\n        \"placeholder_username\": \"예: user1\",\n        \"placeholder_desc\": \"선택 사항 메모\",\n        \"placeholder_max_ips\": \"0 = 무제한\",\n        \"hint_max_ips\": \"0 = 무제한\",\n        \"hint_curfew\": \"비워두면 비활성화됩니다. 서버 시간 기준.\"\n    }\n}"
  },
  {
    "path": "src/locales/my.json",
    "content": "{\n    \"common\": {\n        \"loading\": \"Memuatkan...\",\n        \"load_more\": \"Muat Lagi\",\n        \"add\": \"Tambah\",\n        \"copy\": \"Salin\",\n        \"action\": \"Tindakan\",\n        \"save\": \"Simpan\",\n        \"saved\": \"Berjaya disimpan\",\n        \"cancel\": \"Batal\",\n        \"confirm\": \"Sahkan\",\n        \"close\": \"Tutup\",\n        \"delete\": \"Padam\",\n        \"edit\": \"Sunting\",\n        \"refresh\": \"Muat Semula\",\n        \"refreshing\": \"Memuat semula...\",\n        \"export\": \"Eksport\",\n        \"import\": \"Import\",\n        \"success\": \"Berjaya\",\n        \"error\": \"Ralat\",\n        \"unknown\": \"Tidak diketahui\",\n        \"warning\": \"Amaran\",\n        \"info\": \"Maklumat\",\n        \"details\": \"Butiran\",\n        \"clear\": \"Kosongkan\",\n        \"clearing\": \"Mengosongkan...\",\n        \"prev_page\": \"Sebelumnya\",\n        \"next_page\": \"Seterusnya\",\n        \"pagination_info\": \"Menunjukkan {{start}} hingga {{end}} daripada {{total}} entri\",\n        \"per_page\": \"Setiap halaman\",\n        \"items\": \"item\",\n        \"accounts\": \"akaun\",\n        \"enabled\": \"Diaktifkan\",\n        \"disabled\": \"Dinyahaktifkan\",\n        \"tauri_api_not_loaded\": \"API Tauri tidak dimuatkan, sila mulakan semula aplikasi\",\n        \"environment_error\": \"Ralat persekitaran: {{error}}\",\n        \"submit\": \"Hantar\",\n        \"update\": \"Kemas kini\",\n        \"load_failed\": \"Gagal dimuatkan\",\n        \"create_success\": \"Berjaya dicipta\",\n        \"update_success\": \"Berjaya dikemas kini\",\n        \"delete_success\": \"Berjaya dipadam\",\n        \"copied\": \"Disalin ke papan klip\"\n    },\n    \"nav\": {\n        \"dashboard\": \"Papan Pemuka\",\n        \"accounts\": \"Akaun\",\n        \"proxy\": \"Proksi API\",\n        \"call_records\": \"Log Trafik\",\n        \"token_stats\": \"Statistik Token\",\n        \"settings\": \"Tetapan\",\n        \"theme_to_dark\": \"Tukar ke Mod Gelap\",\n        \"theme_to_light\": \"Tukar ke Mod Cerah\",\n        \"switch_to_english\": \"Tukar ke Bahasa Inggeris\",\n        \"switch_to_chinese\": \"Tukar ke Bahasa Cina\",\n        \"switch_to_traditional_chinese\": \"Tukar ke Bahasa Cina Tradisional\",\n        \"switch_to_english_short\": \"EN\",\n        \"switch_to_chinese_short\": \"ZH\",\n        \"switch_to_traditional_chinese_short\": \"TW\",\n        \"switch_to_japanese\": \"Tukar ke Bahasa Jepun\",\n        \"switch_to_japanese_short\": \"JA\",\n        \"switch_to_turkish\": \"Tukar ke Bahasa Turki\",\n        \"switch_to_turkish_short\": \"TR\",\n        \"switch_to_vietnamese\": \"Tukar ke Bahasa Vietnam\",\n        \"switch_to_vietnamese_short\": \"VI\",\n        \"switch_to_russian\": \"Tukar ke Bahasa Rusia\",\n        \"switch_to_russian_short\": \"RU\",\n        \"switch_to_portuguese\": \"Tukar ke Bahasa Portugis\",\n        \"switch_to_portuguese_short\": \"PT\",\n        \"switch_to_korean\": \"Tukar ke Bahasa Korea\",\n        \"switch_to_korean_short\": \"KO\",\n        \"switch_to_spanish\": \"Tukar ke Bahasa Sepanyol\",\n        \"switch_to_spanish_short\": \"ES\",\n        \"switch_to_malay\": \"Tukar ke Bahasa Melayu\",\n        \"switch_to_malay_short\": \"MY\",\n        \"user_token\": \"Token Pengguna\"\n    },\n    \"dashboard\": {\n        \"hello\": \"Hai, Pengguna 👋\",\n        \"refresh_quota\": \"Muat Semula Kuota\",\n        \"refreshing\": \"Memuat semula...\",\n        \"total_accounts\": \"Jumlah Akaun\",\n        \"avg_gemini\": \"Purata Kuota Gemini\",\n        \"avg_gemini_image\": \"Purata Kuota Imej Gemini\",\n        \"avg_claude\": \"Purata Kuota Claude\",\n        \"low_quota_accounts\": \"Akaun Kuota Rendah\",\n        \"quota_sufficient\": \"Kuota Mencukupi\",\n        \"quota_low\": \"Kuota Rendah\",\n        \"quota_desc\": \"Kuota < 20%\",\n        \"current_account\": \"Akaun Semasa\",\n        \"switch_account\": \"Tukar Akaun\",\n        \"no_active_account\": \"Tiada Akaun Aktif\",\n        \"best_accounts\": \"Akaun Terbaik\",\n        \"best_account_recommendation\": \"Akaun Terbaik\",\n        \"switch_best\": \"Tukar ke Terbaik\",\n        \"switch_successfully\": \"Tukar ke Terbaik\",\n        \"view_all_accounts\": \"Lihat Semua Akaun\",\n        \"export_data\": \"Eksport Data\",\n        \"for_gemini\": \"Untuk Gemini\",\n        \"for_claude\": \"Untuk Claude\",\n        \"toast\": {\n            \"switch_success\": \"Penukaran berjaya!\",\n            \"switch_error\": \"Penukaran akaun gagal\",\n            \"refresh_success\": \"Muat semula kuota berjaya\",\n            \"refresh_error\": \"Muat semula gagal\",\n            \"export_no_accounts\": \"Tiada akaun untuk dieksport\",\n            \"export_success\": \"Eksport berjaya! Fail disimpan ke: {{path}}\",\n            \"export_error\": \"Eksport gagal\"\n        }\n    },\n    \"accounts\": {\n        \"account\": \"အကောင့်\",\n        \"search_placeholder\": \"အီးမေးလ် ရှာရန်...\",\n        \"all\": \"Semua\",\n        \"available\": \"Tersedia\",\n        \"low_quota\": \"Kuota Rendah\",\n        \"ultra\": \"ULTRA\",\n        \"pro\": \"PRO\",\n        \"free\": \"PERCUMA\",\n        \"edit_label\": \"Sunting Label\",\n        \"custom_label_placeholder\": \"Masukkan label tersuai\",\n        \"label_updated\": \"Label dikemas kini\",\n        \"add_account\": \"Tambah Akaun\",\n        \"refresh_all\": \"Muat Semula Semua\",\n        \"refresh_selected\": \"Muat Semula ({{count}})\",\n        \"export_selected\": \"Eksport ({{count}})\",\n        \"import_json\": \"Import\",\n        \"import_success\": \"Berjaya mengimport {{count}} akaun\",\n        \"import_partial\": \"Import selesai: {{success}} berjaya, {{fail}} gagal\",\n        \"import_fail\": \"Import gagal: {{error}}\",\n        \"import_invalid_format\": \"Format JSON tidak sah, sila pastikan fail mengandungi medan email dan refresh_token\",\n        \"delete_selected\": \"Padam ({{count}})\",\n        \"current\": \"Semasa\",\n        \"current_badge\": \"Semasa\",\n        \"disabled\": \"Dinyahaktifkan\",\n        \"disabled_tooltip\": \"Akaun dinyahaktifkan (cth. refresh_token dibatalkan/tamat tempoh). Kebenaran semula atau kemas kini token untuk mengaktifkan semula.\",\n        \"proxy_disabled\": \"Proksi Dinyahaktifkan\",\n        \"proxy_disabled_tooltip\": \"Akaun ini telah dinyahaktifkan proksi secara manual, ia tidak akan mengendalikan permintaan API tetapi masih boleh digunakan dalam aplikasi.\",\n        \"enable_proxy\": \"Aktifkan Proksi\",\n        \"disable_proxy\": \"Nyahaktifkan Proksi\",\n        \"enable_proxy_selected\": \"Aktifkan ({{count}})\",\n        \"disable_proxy_selected\": \"Nyahaktifkan ({{count}})\",\n        \"proxy_disabled_reason_manual\": \"Dinyahaktifkan secara manual oleh pengguna\",\n        \"proxy_disabled_reason_batch\": \"Dinyahaktifkan secara berkelompok\",\n        \"forbidden\": \"403\",\n        \"forbidden_badge\": \"403\",\n        \"forbidden_tooltip\": \"API mengembalikan 403 Forbidden, akaun tiada kebenaran untuk Gemini Code Assist\",\n        \"forbidden_msg\": \"တားမြစ်ထားသည်၊ အလိုအလျောက် အသစ်တင်ခြင်းကို ကျော်ရန်\",\n        \"status\": {\n            \"forbidden\": \"403 တားမြစ်ထားသည်\",\n            \"disabled\": \"အကောင့် ပိတ်ထားသည်\",\n            \"proxy_disabled\": \"ပရောက်စီ ပိတ်ထားသည်\"\n        },\n        \"error_details\": \"အမှား အသေးစိတ်\",\n        \"error_status\": \"အမှား အခြေအနေ\",\n        \"error_time\": \"ရှာဖွေတွေ့ရှိချိန်\",\n        \"view_error\": \"အကြောင်းပြချက် ကြည့်ရန်\",\n        \"click_to_verify\": \"အတည်ပြုရန် နှိပ်ပါ\",\n        \"no_data\": \"ဒေတာမရှိပါ\",\n        \"last_used\": \"Terakhir Digunakan\",\n        \"reset_time\": \"Masa Reset\",\n        \"switch_to\": \"Tukar ke akaun ini\",\n        \"actions\": \"Tindakan\",\n        \"device_fingerprint\": \"Cap Jari Peranti\",\n        \"show_all_quotas\": \"Tunjukkan semua kuota\",\n        \"device_fingerprint_dialog\": {\n            \"title\": \"Cap Jari Peranti\",\n            \"operations\": \"Operasi Cap Jari Peranti\",\n            \"generate_and_bind\": \"Jana dan Ikat\",\n            \"restore_original\": \"Pulihkan Asal\",\n            \"open_storage_directory\": \"Buka Direktori Storan\",\n            \"current_storage\": \"Storan Semasa\",\n            \"effective\": \"Berkesan\",\n            \"current_storage_desc\": \"Dibaca dari storage.json (dikemas kini selepas menggunakan pengikatan semasa menukar akaun)\",\n            \"account_binding\": \"Pengikatan Akaun\",\n            \"pending_application\": \"Menunggu Penggunaan\",\n            \"account_binding_desc\": \"Disimpan sebagai pengikatan selepas penjanaan/pemulihan, ditulis ke storage.json semasa menukar akaun\",\n            \"historical_fingerprints\": \"Cap Jari Sejarah (pilihan pulih/padam)\",\n            \"no_history\": \"Tiada Sejarah\",\n            \"current\": \"Semasa\",\n            \"restore\": \"Pulihkan\",\n            \"delete_version\": \"Padam versi ini\",\n            \"confirm_generate_title\": \"Sahkan jana dan ikat?\",\n            \"confirm_generate_desc\": \"Akan menjana set cap jari peranti baharu dan ditetapkan sebagai cap jari semasa. Sahkan teruskan?\",\n            \"confirm_restore_title\": \"Sahkan pulihkan cap jari asal?\",\n            \"confirm_restore_desc\": \"Akan pulihkan ke cap jari asal dan tulis ganti cap jari semasa. Sahkan teruskan?\",\n            \"cancel\": \"Batal\",\n            \"confirm\": \"Sahkan\",\n            \"processing\": \"Memproses...\",\n            \"loading\": \"Memuatkan...\",\n            \"failed_to_load_device_info\": \"Gagal memuatkan maklumat peranti\",\n            \"generation_failed\": \"Penjanaan gagal\",\n            \"binding_failed\": \"Pengikatan gagal\",\n            \"restoration_failed\": \"Pemulihan gagal\",\n            \"deletion_failed\": \"Pemadaman gagal\",\n            \"directory_open_failed\": \"Tidak dapat membuka direktori\",\n            \"generated_and_bound\": \"Dijana dan diikat\",\n            \"restored\": \"Dipulihkan\",\n            \"deleted\": \"Dipadam\",\n            \"directory_opened\": \"Direktori storan dibuka\",\n            \"original_fingerprint_not_found\": \"Cap jari asal tidak ditemui\"\n        },\n        \"warmup_all\": \"Pemanasan Satu-klik\",\n        \"warmup_selected\": \"Pemanasan ({{count}})\",\n        \"warmup_this\": \"Pemanaskan akaun ini\",\n        \"warmup_now\": \"Pemanasan Sekarang\",\n        \"warmup_batch_triggered\": \"Tugas pemanasan dicetuskan untuk {{count}} akaun\",\n        \"quota_protected\": \"Dilindungi\",\n        \"details\": {\n            \"title\": \"Butiran Kuota\",\n            \"model_quota\": \"Kuota Model\",\n            \"protected_models\": \"Model Dilindungi\"\n        },\n        \"toast\": {\n            \"proxy_enabled\": \"Proksi diaktifkan untuk {{count}} akaun\",\n            \"proxy_disabled\": \"Proksi dinyahaktifkan untuk {{count}} akaun\"\n        },\n        \"add\": {\n            \"title\": \"Tambah Akaun\",\n            \"tabs\": {\n                \"oauth\": \"OAuth\",\n                \"token\": \"Token Muat Semula\",\n                \"import\": \"Import DB\"\n            },\n            \"oauth\": {\n                \"recommend\": \"Disyorkan\",\n                \"desc\": \"Membuka pelayar lalai untuk log masuk Google bagi mendapatkan dan menyimpan Token secara automatik.\",\n                \"btn_start\": \"Mula OAuth\",\n                \"btn_waiting\": \"Menunggu kebenaran...\",\n                \"btn_finish\": \"Saya sudah memberikan kebenaran\",\n                \"copy_link\": \"Salin Pautan Kebenaran\",\n                \"copied\": \"Disalin\",\n                \"link_label\": \"URL Kebenaran\",\n                \"link_click_to_copy\": \"Klik untuk salin\",\n                \"manual_hint\": \"Pelayar tidak dialihkan? Tampal URL panggil balik penuh atau Kod mentah di sini:\",\n                \"manual_placeholder\": \"Tampal URL panggil balik atau kod...\",\n                \"error_no_flow\": \"Sila klik 'Mula OAuth' dahulu\",\n                \"web_hint\": \"Halaman log masuk Google akan dibuka dalam tetingkap baru\",\n                \"error_no_url\": \"Gagal mendapatkan URL OAuth\",\n                \"popup_blocked\": \"Pop timbul disekat\",\n                \"manual_submitting\": \"Menghantar kod pengesahan...\",\n                \"manual_submitted\": \"Kod pengesahan dihantar, sedang diproses di latar belakang...\"\n            },\n            \"token\": {\n                \"label\": \"Token Muat Semula\",\n                \"placeholder\": \"Tampal Token Muat Semula anda di sini (Sokongan kelompok)\\n\\nFormat yang disokong:\\n1. Token Tunggal (1//...)\\n2. Array JSON (dengan medan refresh_token)\\n3. Sebarang teks yang mengandungi token (Pengekstrakan automatik)\",\n                \"hint\": \"Tip: Anda boleh tampal berbilang token atau array JSON untuk import secara kelompok.\",\n                \"error_token\": \"Sila masukkan Token Muat Semula\",\n                \"batch_progress\": \"Mengimport {{current}}/{{total}} akaun...\",\n                \"batch_success\": \"Berjaya mengimport {{count}} akaun\",\n                \"batch_partial\": \"Import selesai: {{success}} berjaya, {{fail}} gagal\",\n                \"batch_fail\": \"Import gagal\"\n            },\n            \"import\": {\n                \"scheme_a\": \"Pelan A: Dari DB IDE\",\n                \"scheme_a_desc\": \"Baca automatik akaun yang sedang log masuk dari DB Antigravity tempatan.\",\n                \"btn_db\": \"Import Akaun Semasa\",\n                \"or\": \"ATAU\",\n                \"scheme_b\": \"Pelan B: Dari Sandaran V1\",\n                \"scheme_b_desc\": \"Imbas ~/.antigravity-agent untuk data akaun V1.\",\n                \"btn_v1\": \"Import Kelompok V1\",\n                \"btn_custom_db\": \"Import DB Tersuai\"\n            },\n            \"btn_cancel\": \"Batal\",\n            \"btn_confirm\": \"Sahkan\",\n            \"oauth_error\": \"OAuth gagal: {{error}}\",\n            \"status\": {\n                \"error_token\": \"Sila masukkan Token Muat Semula\"\n            }\n        },\n        \"table\": {\n            \"email\": \"E-mel\",\n            \"quota\": \"Kuota Model\",\n            \"last_used\": \"Terakhir Digunakan\",\n            \"actions\": \"Tindakan\"\n        },\n        \"drag_to_reorder\": \"Seret untuk menyusun semula\",\n        \"empty\": {\n            \"title\": \"Tiada Akaun\",\n            \"desc\": \"Klik butang \\\"Tambah Akaun\\\" di atas untuk menambah akaun pertama anda\"\n        },\n        \"views\": {\n            \"list\": \"Paparan Senarai\",\n            \"grid\": \"Paparan Grid\"\n        },\n        \"dialog\": {\n            \"add_title\": \"Tambah Akaun\",\n            \"batch_delete_title\": \"Pengesahan Padam Kelompok\",\n            \"delete_title\": \"Pengesahan Padam\",\n            \"batch_delete_msg\": \"Adakah anda pasti mahu memadam {{count}} akaun yang dipilih? Tindakan ini tidak boleh dibatalkan.\",\n            \"delete_msg\": \"Adakah anda pasti mahu memadam akaun ini? Tindakan ini tidak boleh dibatalkan.\",\n            \"refresh_title\": \"Muat Semula Kuota\",\n            \"batch_refresh_title\": \"Muat Semula Kelompok\",\n            \"refresh_msg\": \"Adakah anda pasti mahu memuat semula kuota untuk akaun semasa?\",\n            \"batch_refresh_msg\": \"Adakah anda pasti mahu memuat semula kuota untuk {{count}} akaun yang dipilih? Ini mungkin mengambil masa.\",\n            \"disable_proxy_title\": \"Nyahaktifkan Proksi\",\n            \"disable_proxy_msg\": \"Adakah anda pasti mahu menyahaktifkan proksi untuk akaun ini? Akaun akan kekal boleh digunakan dalam aplikasi.\",\n            \"enable_proxy_title\": \"Aktifkan Proksi\",\n            \"enable_proxy_msg\": \"Adakah anda pasti mahu mengaktifkan semula proksi untuk akaun ini?\",\n            \"warmup_all_title\": \"Pemanasan Manual Penuh\",\n            \"warmup_all_msg\": \"Adakah anda pasti mahu mencetuskan tugas pemanasan untuk semua akaun yang layak dengan segera? Ini akan menghantar trafik minimum ke perkhidmatan Google untuk menetapkan semula kitaran kuota.\",\n            \"batch_warmup_title\": \"Pemanasan Manual Kelompok\",\n            \"batch_warmup_msg\": \"Adakah anda pasti mahu mencetuskan pemanasan untuk {{count}} akaun yang dipilih dengan segera?\"\n        }\n    },\n    \"settings\": {\n        \"save\": \"Simpan Tetapan\",\n        \"tabs\": {\n            \"general\": \"Umum\",\n            \"account\": \"Akaun\",\n            \"proxy\": \"Tetapan Proksi\",\n            \"advanced\": \"Lanjutan\",\n            \"about\": \"Perihal\",\n            \"debug\": \"Nyahpepijat\"\n        },\n        \"general\": {\n            \"title\": \"Tetapan Umum\",\n            \"language\": \"Bahasa\",\n            \"theme\": \"Tema\",\n            \"theme_light\": \"Cerah\",\n            \"theme_dark\": \"Gelap\",\n            \"theme_system\": \"Sistem\",\n            \"auto_launch\": \"Lancar pada Permulaan\",\n            \"auto_launch_enabled\": \"Diaktifkan\",\n            \"auto_launch_disabled\": \"Dinyahaktifkan\",\n            \"auto_launch_desc\": \"Lancarkan Antigravity Tools secara automatik apabila sistem bermula\",\n            \"auto_check_update\": \"Semak Kemas Kini Automatik\",\n            \"auto_check_update_desc\": \"Semak versi baharu secara automatik pada permulaan\",\n            \"auto_check_update_enabled\": \"Semakan automatik diaktifkan\",\n            \"auto_check_update_disabled\": \"Semakan automatik dinyahaktifkan\",\n            \"update_check_interval\": \"Selang Semakan (jam)\",\n            \"update_check_interval_desc\": \"Tetapkan selang semakan automatik (1-168 jam)\",\n            \"update_check_interval_saved\": \"Tetapan selang semakan disimpan\"\n        },\n        \"account\": {\n            \"title\": \"Tetapan Akaun\",\n            \"auto_refresh\": \"Muat Semula Automatik Latar Belakang\",\n            \"auto_refresh_desc\": \"Muat semula kuota semua akaun secara automatik di latar belakang. Ini diperlukan untuk perlindungan kuota dan pemanasan pintar.\",\n            \"always_on\": \"Sentiasa Hidup\",\n            \"refresh_interval\": \"Selang Muat Semula (minit)\",\n            \"auto_sync\": \"Segerak Automatik Akaun Semasa\",\n            \"auto_sync_desc\": \"Segerakkan maklumat akaun aktif semasa secara berkala secara automatik\",\n            \"sync_interval\": \"Selang Segerak (saat)\"\n        },\n        \"warmup\": {\n            \"title\": \"Pemanasan Pintar\",\n            \"desc\": \"Memantau semua model secara automatik dan mencetuskan pemanasan dengan segera apabila kuota mencapai 100%, memastikan model sentiasa panas\"\n        },\n        \"quota_protection\": {\n            \"title\": \"Perlindungan Kuota\",\n            \"enable\": \"Aktifkan Perlindungan Kuota\",\n            \"enable_desc\": \"Nyahaktifkan proksi secara automatik apabila kuota akaun jatuh di bawah ambang, dan pulihkan automatik apabila kuota ditetapkan semula\",\n            \"threshold_label\": \"Peratusan Kuota Terpelihara\",\n            \"monitored_models_label\": \"Model Dipantau (Syarat Pencetus)\",\n            \"monitored_models_desc\": \"Pilih sekurang-kurangnya satu. Perlindungan dicetuskan jika MANA-MANA model yang dipilih jatuh di bawah ambang\",\n            \"range\": \"Julat\",\n            \"example\": \"Contoh: Pada {{percentage}}%, akaun dengan {{total}} kuota akan dinyahaktifkan apabila baki ≤ {{threshold}}\",\n            \"auto_restore_info\": \"Akaun akan diaktifkan semula secara automatik apabila kuota ditetapkan semula\"\n        },\n        \"pinned_quota_models\": {\n            \"title\": \"Model Kuota Disemat\",\n            \"desc\": \"Pilih kuota model mana yang hendak dipaparkan dalam senarai akaun. Model yang tidak dipilih hanya ditunjukkan dalam popup butiran.\"\n        },\n        \"proxy\": {\n            \"title\": \"Tetapan Proksi\"\n        },\n        \"proxy_pool\": {\n            \"title\": \"Proxy Pool\",\n            \"strategy_priority\": \"Priority\",\n            \"strategy_round_robin\": \"Round Robin\",\n            \"strategy_random\": \"Random\",\n            \"strategy_least_connections\": \"Least Connections\",\n            \"test_all\": \"Test All\",\n            \"batch_import\": \"Import\",\n            \"binding_manager\": \"Bindings\",\n            \"add_proxy\": \"Add Proxy\",\n            \"edit_proxy\": \"Edit Proxy\",\n            \"name\": \"Name\",\n            \"url\": \"Proxy URL\",\n            \"username\": \"Username\",\n            \"password\": \"Password\",\n            \"max_accounts\": \"Max Accounts\",\n            \"max_accounts_hint\": \"0 = Unlimited\",\n            \"priority\": \"Priority\",\n            \"priority_hint\": \"Lower is better\",\n            \"health_check_url\": \"Health Check URL\",\n            \"tags\": \"Tags\",\n            \"add_tag_placeholder\": \"Add tag...\",\n            \"seconds\": \"Sec\",\n            \"test_completed\": \"Health check completed\",\n            \"test_failed\": \"Health check failed\",\n            \"confirm_delete\": \"Are you sure you want to delete this proxy?\",\n            \"empty\": \"No proxies available\",\n            \"column_priority\": \"Priority\",\n            \"column_status\": \"Status\",\n            \"column_details\": \"Proxy Details\",\n            \"column_bindings\": \"Bindings\",\n            \"import_title\": \"Batch Import Proxies\",\n            \"import_label\": \"Paste Proxy List (One per line)\",\n            \"import_hint\": \"Supported formats: protocol://user:pass@host:port, host:port:user:pass\",\n            \"import_preview\": \"Preview\",\n            \"import_confirm\": \"Import {{count}} Proxies\",\n            \"no_valid_proxies\": \"No valid proxies found\",\n            \"binding\": {\n                \"title\": \"Account Proxy Bindings\",\n                \"load_failed\": \"Failed to load bindings\",\n                \"unbind_success\": \"Unbound successfully\",\n                \"bind_success\": \"Bound successfully\",\n                \"update_failed\": \"Failed to update binding\",\n                \"assigned_proxy\": \"Assigned Proxy\",\n                \"default_strategy\": \"Default (Follow Strategy)\"\n            },\n            \"status\": {\n                \"inactive\": \"Inactive\",\n                \"checking\": \"Checking\",\n                \"healthy\": \"Healthy\",\n                \"timeout\": \"Timeout\"\n            }\n        },\n        \"advanced\": {\n            \"title\": \"Tetapan Lanjutan\",\n            \"export_path\": \"Laluan Eksport Lalai\",\n            \"export_path_placeholder\": \"Tidak ditetapkan (Tanya setiap kali)\",\n            \"default_export_path_desc\": \"Fail akan disimpan terus ke folder ini tanpa bertanya\",\n            \"select_btn\": \"Pilih\",\n            \"open_btn\": \"Buka\",\n            \"data_dir\": \"Direktori Data\",\n            \"data_dir_desc\": \"Lokasi data akaun dan fail konfigurasi\",\n            \"antigravity_path\": \"Laluan Antigravity\",\n            \"antigravity_path_placeholder\": \"Tidak ditetapkan (Akan guna pengesanan automatik)\",\n            \"antigravity_path_desc\": \"Jika anda memasang Antigravity di lokasi bukan standard, anda boleh nyatakan laluan executable secara manual di sini (Tunjuk ke .app pada MacOS).\",\n            \"antigravity_path_select\": \"Pilih Executable Antigravity\",\n            \"antigravity_path_detected\": \"Laluan dikesan dikemas kini\",\n            \"detect_btn\": \"Kesan\",\n            \"antigravity_args\": \"Argumen Permulaan Antigravity\",\n            \"antigravity_args_placeholder\": \"--user-data-dir=/path/to/data --some-other-flag\",\n            \"antigravity_args_desc\": \"Nyatakan argumen permulaan untuk Antigravity, cth. --user-data-dir untuk menentukan direktori data pengguna\",\n            \"detect_args_btn\": \"Kesan\",\n            \"antigravity_args_detected\": \"Argumen permulaan dikemas kini\",\n            \"antigravity_args_detect_error\": \"Gagal mengesan argumen permulaan\",\n            \"accounts_page_size\": \"Saiz Halaman Akaun\",\n            \"page_size_auto\": \"Kira Automatik (Disyorkan)\",\n            \"page_size_desc\": \"Tetapkan bilangan akaun yang dipaparkan setiap halaman. Pilih 'Kira Automatik' untuk menyesuaikan secara dinamik berdasarkan saiz tetingkap.\",\n            \"logs_title\": \"Penyelenggaraan Log\",\n            \"logs_desc\": \"Kosongkan fail cache log. Tidak menjejaskan data akaun.\",\n            \"clear_logs\": \"Kosongkan Cache Log\",\n            \"clear_logs_title\": \"Pengesahan Kosongkan Log\",\n            \"clear_logs_msg\": \"Adakah anda pasti mahu mengosongkan semua fail cache log?\",\n            \"logs_cleared\": \"Cache log dikosongkan\",\n            \"antigravity_cache_title\": \"Pembersihan Cache Antigravity\",\n            \"antigravity_cache_desc\": \"Kosongkan cache Antigravity untuk menyelesaikan kegagalan log masuk, ralat pengesahan versi, dan isu kebenaran OAuth.\",\n            \"antigravity_cache_warning\": \"Sila pastikan Antigravity ditutup sepenuhnya sebelum mengosongkan cache.\",\n            \"clear_antigravity_cache\": \"Kosongkan Cache Antigravity\",\n            \"clear_cache_confirm_title\": \"Sahkan Kosongkan Cache Antigravity\",\n            \"clear_cache_confirm_msg\": \"Direktori cache berikut akan dikosongkan:\",\n            \"cache_cleared_success\": \"Cache berjaya dikosongkan, membebaskan {{size}} MB\",\n            \"cache_not_found\": \"Tiada direktori cache Antigravity ditemui\",\n            \"debug_logs_title\": \"Log Nyahpepijat\",\n            \"debug_logs_enable_desc\": \"Apabila diaktifkan, rantaian permintaan dan respons penuh akan direkodkan. Disyorkan untuk diaktifkan hanya semasa menyelesaikan masalah.\",\n            \"debug_logs_desc\": \"Merekod rantaian penuh: input asal, permintaan v1internal yang ditukar, dan respons huluan. Untuk penyelesaian masalah sahaja, mungkin mengandungi data sensitif.\",\n            \"debug_log_dir\": \"Direktori output log nyahpepijat\",\n            \"debug_log_dir_hint\": \"Biarkan kosong untuk menggunakan direktori lalai: {{path}}/debug_logs\",\n            \"debug_log_dir_select\": \"Pilih direktori output log nyahpepijat\",\n            \"http_api_title\": \"Perkhidmatan API HTTP\",\n            \"http_api_desc\": \"Menyediakan antara muka HTTP tempatan untuk program luaran (cth. plugin VS Code).\",\n            \"http_api_enabled\": \"Aktifkan API HTTP\",\n            \"http_api_enabled_desc\": \"Apabila diaktifkan, program luaran boleh mengurus akaun melalui antara muka HTTP\",\n            \"http_api_port\": \"Port Dengar\",\n            \"http_api_port_desc\": \"Mulakan semula diperlukan selepas menukar port. Jika konflik port berlaku, sila gunakan port lain yang tersedia.\",\n            \"http_api_port_placeholder\": \"Port lalai 19527\",\n            \"http_api_port_invalid\": \"Nombor port tidak sah (julat: 1024-65535)\",\n            \"http_api_settings_saved\": \"Tetapan API HTTP disimpan, mulakan semula diperlukan untuk digunakan\",\n            \"http_api_restart_required\": \"⚠️ Mulakan semula diperlukan untuk digunakan\"\n        },\n        \"menu\": {\n            \"title\": \"Tetapan Paparan Menu\",\n            \"desc\": \"Pilih item fungsi untuk dipaparkan di bar menu. Menyembunyikan menu yang jarang digunakan dapat menjimatkan ruang.\",\n            \"selected_items_note\": \"Item yang dipilih akan dipaparkan di bar menu atas.\",\n            \"required\": \"Wajib\"\n        },\n        \"about\": {\n            \"title\": \"Perihal\",\n            \"version\": \"Versi Aplikasi\",\n            \"tech_stack\": \"Tumpukan Teknologi\",\n            \"author\": \"Pengarang\",\n            \"wechat\": \"WeChat\",\n            \"github\": \"GitHub\",\n            \"view_code\": \"Lihat Kod\",\n            \"copyright\": \"Hak Cipta © 2025-2026 Antigravity. Semua hak terpelihara.\",\n            \"check_update\": \"Semak Kemas Kini\",\n            \"checking_update\": \"Menyemak...\",\n            \"latest_version\": \"Anda sudah dikemas kini\",\n            \"new_version_available\": \"Versi baharu {{version}} tersedia\",\n            \"download_update\": \"Muat Turun\",\n            \"update_check_failed\": \"Semakan kemas kini gagal\",\n            \"support_btn\": \"Sokong Pengarang\",\n            \"support_title\": \"Derma & Sokongan\",\n            \"support_desc\": \"Jika anda mendapati projek ini berguna, sila belanja saya kopi! Sokongan anda adalah motivasi terbesar untuk saya menyelenggara projek ini.\",\n            \"support_alipay\": \"Alipay\",\n            \"support_wechat\": \"WeChat Pay\",\n            \"support_buymeacoffee\": \"Buy Me a Coffee\"\n        },\n        \"advanced_thinking\": {\n            \"title\": \"အဆင့်မြင့်တွေးခေါ်မှုနှင့် ကမ္ဘာလုံးဆိုင်ရာဖွဲ့စည်းမှု\",\n            \"description\": \"တွေးခေါ်နိုင်စွမ်း၊ ရုပ်ပုံမုဒ်များနှင့် ကမ္ဘာလုံးဆိုင်ရာညွှန်ကြားချက်များကို ဗဟိုမှစီမံခန့်ခွဲပါ။\"\n        },\n        \"thinking_budget\": {\n            \"title\": \"Bajet Pemikiran (Thinking Budget)\",\n            \"description\": \"AI ၏ နက်နဲသောတွေးခေါ်မှုအတွက် တိုကင်ဘတ်ဂျက်ကို ထိန်းချုပ်ပါ။ Flash မော်ဒယ်များ (ဥပမာ -thinking နောက်ဆက်တွဲပါသော မော်ဒယ်များ) သည် upstream API မှ ၂၄၅၇၆ အထိသာ ကန့်သတ်ထားပါသည်။\",\n            \"mode_label\": \"လုပ်ဆောင်မှုမုဒ်\",\n            \"mode\": {\n                \"auto\": \"အလိုအလျောက်ကန့်သတ်ချက်\",\n                \"passthrough\": \"တိုက်ရိုက်ပေးပို့ခြင်း\",\n                \"custom\": \"စိတ်ကြိုက်\"\n            },\n            \"auto_hint\": \"အလိုအလျောက်မုဒ်- API အမှားများကို ရှောင်ရှားရန် Flash မော်ဒယ်များ၊ -thinking နောက်ဆက်တွဲပါသော မော်ဒယ်များနှင့် ဝဘ်ရှာဖွေမှုတောင်းဆိုမှုများအတွက် ၂၄၅၇၆ သို့ အလိုအလျောက်ကန့်သတ်ပေးပါသည်။\",\n            \"passthrough_warning\": \"တိုက်ရိုက်ပေးပို့ခြင်း- ခေါ်ဆိုသူ၏ မူလတန်ဖိုးကို တိုက်ရိုက်အသုံးပြုသည်။ တန်ဖိုးမြင့်မားမှုများကို အားမပေးပါက ကျရှုံးနိုင်သည်။\",\n            \"custom_value_hint\": \"အကြံပြုချက်- ၂၄၅၇၆ (Flash) သို့မဟုတ် ၅၁၂၀၀ (တိုးချဲ့)\",\n            \"tokens\": \"tokens\"\n        },\n        \"image_thinking_mode\": {\n            \"title\": \"ရုပ်ပုံတွေးခေါ်မှုမုဒ် (Image Thinking Mode)\",\n            \"hint\": \"ရုပ်ပုံအရည်အသွေးနှင့် ဖန်တီးမှုလုပ်ငန်းစဉ်အပေါ် သက်ရောက်မှုရှိသည်\",\n            \"options\": {\n                \"enabled\": \"ဖွင့်ထားသည်\",\n                \"disabled\": \"ပိတ်ထားသည်\",\n                \"enabled_desc\": \"ဖွင့်ရန်: တွေးခေါ်မှုအစဉ်ကို ထိန်းသိမ်းပြီး ပုံကြမ်းနှင့် နောက်ဆုံးပုံနှစ်ပုံကို ပြန်ပေးသည်။\",\n                \"disabled_desc\": \"ပိတ်ရန်: တွေးခေါ်မှုအစဉ်ကို ပိတ်ထားပြီး အရည်အသွေးမြင့် ရုပ်ပုံတစ်ပုံတည်းကို တိုက်ရိုက်ဖန်တီးပေးသည် (အရည်အသွေးဦးစားပေး)။\"\n            }\n        },\n        \"global_system_prompt\": {\n            \"title\": \"ကမ္ဘာလုံးဆိုင်ရာ စနစ်ပရွမ်း (Global System Prompt)\",\n            \"hint\": \"တောင်းဆိုမှုအားလုံး၏ systemInstruction ထဲသို့ အလိုအလျောက် ထည့်သွင်းပေးမည်\",\n            \"placeholder\": \"ကမ္ဘာလုံးဆိုင်ရာ စနစ်ပရွမ်းကို ရိုက်ထည့်ပါ...\\nဥပမာ- သင်သည် React နှင့် Rust ကျွမ်းကျင်သော စီနီယာ developer တစ်ဦးဖြစ်သည်။ မြန်မာဘာသာဖြင့် ဖြေဆိုပေးပါ။\",\n            \"char_count\": \"{{count}} လုံး\",\n            \"long_prompt_warning\": \"ပရွမ်းသည် ရှည်လွန်းနေသည် (စာလုံးရေ ၂၀၀၀ ထက်များနေသည်)၊ ၎င်းသည် စကားပြောဆိုမှုနယ်ပယ်ကို ပိုမိုနေရာယူနိုင်သည်။\"\n        }\n    },\n    \"tray\": {\n        \"current\": \"Semasa\",\n        \"quota\": \"Kuota\",\n        \"switch_next\": \"Tukar ke Akaun Seterusnya\",\n        \"refresh_current\": \"Muat Semula Kuota Semasa\",\n        \"show_window\": \"Tunjukkan Tetingkap Utama\",\n        \"quit\": \"Keluar Aplikasi\",\n        \"no_account\": \"Tiada Akaun\",\n        \"unknown_quota\": \"Tidak diketahui (Klik untuk Muat Semula)\",\n        \"forbidden\": \"Akaun Dilarang\"\n    },\n    \"proxy\": {\n        \"title\": \"Perkhidmatan Proksi API\",\n        \"status\": {\n            \"running\": \"Perkhidmatan Berjalan\",\n            \"stopped\": \"Perkhidmatan Berhenti\",\n            \"accounts_available\": \"{{count}} Akaun Tersedia\",\n            \"processing\": \"Memproses...\"\n        },\n        \"action\": {\n            \"start\": \"Mulakan Perkhidmatan\",\n            \"stop\": \"Hentikan Perkhidmatan\"\n        },\n        \"config\": {\n            \"title\": \"Konfigurasi Perkhidmatan\",\n            \"port\": \"Port Dengar\",\n            \"port_tooltip\": \"Port TCP yang didengari Proksi API tempatan. Hentikan perkhidmatan untuk menukarnya, kemudian mulakan semula untuk digunakan.\",\n            \"port_hint\": \"Lalai 8045, mulakan semula diperlukan untuk menggunakan perubahan\",\n            \"auto_start\": \"Mula Automatik dengan Aplikasi\",\n            \"auto_start_tooltip\": \"Mulakan perkhidmatan Proksi API tempatan secara automatik apabila aplikasi dilancarkan.\",\n            \"allow_lan_access\": \"Benarkan Akses LAN\",\n            \"allow_lan_access_tooltip\": \"Apabila diaktifkan, perkhidmatan diikat ke 0.0.0.0 supaya peranti lain di LAN anda boleh mengaksesnya. Kekalkan kebenaran diaktifkan dan lindungi kunci API anda; mulakan semula diperlukan untuk digunakan.\",\n            \"allow_lan_access_hint_enabled\": \"🌐 Mendengar di 0.0.0.0, peranti LAN boleh mengakses\",\n            \"allow_lan_access_hint_disabled\": \"🔒 Mendengar di 127.0.0.1 sahaja, akses localhost (Privasi Utama)\",\n            \"allow_lan_access_warning\": \"⚠️ Peranti LAN boleh mengakses apabila diaktifkan. Lindungi kunci API anda\",\n            \"allow_lan_access_restart_hint\": \"ℹ️ Mulakan semula perkhidmatan diperlukan untuk menggunakan perubahan\",\n            \"api_key\": \"Kunci API\",\n            \"api_key_tooltip\": \"Rahsia dikongsi yang digunakan oleh klien apabila kebenaran proksi diaktifkan. Menjana semula kunci serta-merta membatalkan yang lama.\",\n            \"btn_regenerate\": \"Jana Semula Kunci\",\n            \"btn_edit\": \"Sunting\",\n            \"btn_save\": \"Simpan\",\n            \"btn_copy\": \"Salin\",\n            \"btn_copied\": \"Disalin\",\n            \"warning_key\": \"Nota: Lindungi kunci API anda. Jangan kongsi.\",\n            \"api_key_invalid\": \"Format kunci API tidak sah, mesti bermula dengan sk- dan sekurang-kurangnya 10 aksara\",\n            \"api_key_updated\": \"Kunci API dikemas kini\",\n            \"admin_password\": \"Kata Laluan Pengurusan UI Web\",\n            \"admin_password_tooltip\": \"Kata laluan digunakan untuk log masuk ke konsol pengurusan Web. Jika kosong, Kunci API digunakan secara lalai.\",\n            \"admin_password_default\": \"(Sama dengan Kunci API)\",\n            \"admin_password_placeholder\": \"Masukkan kata laluan baharu, biarkan kosong untuk guna Kunci API\",\n            \"admin_password_hint\": \"Tip: Dalam senario penggunaan Docker/Web, anda boleh menetapkan kata laluan log masuk berasingan untuk meningkatkan keselamatan Kunci API anda.\",\n            \"admin_password_short\": \"Kata laluan terlalu pendek (sekurang-kurangnya 4 aksara)\",\n            \"admin_password_updated\": \"Kata laluan log masuk UI Web dikemas kini\",\n            \"auth\": {\n                \"title\": \"Kebenaran\",\n                \"title_tooltip\": \"Mengawal sama ada permintaan masuk mesti disahkan, dan laluan mana yang dilindungi.\",\n                \"enabled\": \"Diaktifkan\",\n                \"enabled_tooltip\": \"Menghidupkan/mematikan kebenaran dengan menukar mod kebenaran. Apabila diaktifkan, klien mesti menyertakan kunci API melalui Authorization: Bearer <API_KEY> atau x-api-key.\",\n                \"mode\": \"Mod\",\n                \"mode_tooltip\": \"Memilih laluan mana yang memerlukan kunci API: Off = tiada auth; All = lindungi semua; All except Health = /healthz kekal terbuka; Auto = Off untuk localhost sahaja, jika tidak All except Health.\",\n                \"hint\": \"Apabila diaktifkan, klien mesti menghantar kunci API melalui Authorization: Bearer ... (kecuali health jika dipilih).\",\n                \"modes\": {\n                    \"off\": \"Off (Terbuka)\",\n                    \"strict\": \"Semua (Ketat)\",\n                    \"all_except_health\": \"Semua kecuali Health\",\n                    \"auto\": \"Auto (Disyorkan)\"\n                }\n            },\n            \"zai\": {\n                \"title\": \"Pembekal z.ai (GLM)\",\n                \"title_tooltip\": \"Upstream serasi Anthropic pilihan untuk protokol Claude. Hanya menjejaskan endpoint Anthropic; penghalaan akaun Google kekal tidak berubah.\",\n                \"subtitle\": \"Upstream serasi Anthropic pilihan untuk protokol Claude sahaja.\",\n                \"enabled\": \"Diaktifkan\",\n                \"enabled_tooltip\": \"Mengaktifkan penghalaan z.ai untuk permintaan Anthropic mengikut mod penghantaran yang dipilih.\",\n                \"base_url\": \"URL Asas\",\n                \"base_url_tooltip\": \"URL asas serasi Anthropic. Proksi menambah laluan seperti /v1/messages. Biarkan lalai melainkan anda menggunakan gerbang tersuai.\",\n                \"dispatch_mode\": \"Mod Penghantaran\",\n                \"dispatch_mode_tooltip\": \"Mengawal bila untuk menggunakan z.ai untuk permintaan Anthropic: Off menyahaktifkannya; All Anthropic requests memajukan semua; Pooled menambah z.ai sebagai satu slot dalam round-robin dengan akaun Google; Fallback menggunakan z.ai hanya apabila tiada akaun Google.\",\n                \"api_key\": \"Kunci API\",\n                \"api_key_tooltip\": \"Kunci API digunakan untuk mengesahkan permintaan ke z.ai. Disimpan secara tempatan dan diperlukan untuk ciri z.ai dan MCP.\",\n                \"api_key_placeholder\": \"Tampal kunci API z.ai anda di sini\",\n                \"warning\": \"Nota: Kunci ini disimpan secara tempatan dalam direktori data aplikasi.\",\n                \"models\": {\n                    \"title\": \"Pemetaan Model\",\n                    \"title_tooltip\": \"Dapatkan id model z.ai yang tersedia dan konfigurasikan cara nama model Anthropic/Claude masuk diterjemahkan ke id model z.ai.\",\n                    \"refresh\": \"Dapatkan model\",\n                    \"refreshing\": \"Mendapatkan...\",\n                    \"hint\": \"Model tersedia: {{count}}. Pilih cadangan atau taip id model tersuai.\",\n                    \"error\": \"Gagal mendapatkan model: {{error}}\",\n                    \"select_placeholder\": \"Pilih model...\",\n                    \"opus\": \"Keluarga Opus → model z.ai\",\n                    \"opus_tooltip\": \"Id model z.ai lalai digunakan apabila model masuk mengandungi \\\"opus\\\" (cth. claude-opus-*).\",\n                    \"sonnet\": \"Keluarga Sonnet → model z.ai\",\n                    \"sonnet_tooltip\": \"Id model z.ai lalai digunakan untuk model Claude lain (cth. claude-sonnet-* dan kebanyakan permintaan claude-*).\",\n                    \"haiku\": \"Keluarga Haiku → model z.ai\",\n                    \"haiku_tooltip\": \"Id model z.ai lalai digunakan apabila model masuk mengandungi \\\"haiku\\\" (cth. claude-haiku-*).\",\n                    \"advanced_title\": \"Penggantian lanjutan\",\n                    \"advanced_tooltip\": \"Penggantian padanan tepat pilihan. Jika rentetan model masuk sepadan dengan kunci peraturan, ia akan digantikan dengan id model z.ai yang dipetakan.\",\n                    \"from_label\": \"Model masuk\",\n                    \"to_label\": \"Model z.ai\",\n                    \"add_rule\": \"Tambah\",\n                    \"empty\": \"Tiada peraturan penggantian dikonfigurasikan.\",\n                    \"from_placeholder\": \"Dari (cth. claude-3-opus)\",\n                    \"to_placeholder\": \"Ke (cth. glm-4)\"\n                },\n                \"modes\": {\n                    \"off\": \"Off\",\n                    \"exclusive\": \"Semua permintaan Anthropic\",\n                    \"pooled\": \"Pooled (satu slot)\",\n                    \"fallback\": \"Fallback sahaja\"\n                },\n                \"mcp\": {\n                    \"title\": \"Pelayan MCP (melalui proksi tempatan)\",\n                    \"title_tooltip\": \"Mendedahkan endpoint /mcp/* pilihan pada proksi tempatan ini supaya klien MCP boleh menyambung. Tersedia hanya apabila perkhidmatan berjalan, z.ai dikonfigurasikan, dan togol yang sepadan diaktifkan.\",\n                    \"enabled\": \"Aktifkan proksi MCP\",\n                    \"enabled_tooltip\": \"Suis utama untuk endpoint MCP. Apabila off, semua laluan /mcp/* mengembalikan 404.\",\n                    \"web_search\": \"Carian Web\",\n                    \"web_search_tooltip\": \"Mendedahkan /mcp/web_search_prime/mcp dan memajukan permintaan ke upstream MCP Carian Web z.ai.\",\n                    \"web_reader\": \"Pembaca Web\",\n                    \"web_reader_tooltip\": \"Mendedahkan /mcp/web_reader/mcp dan memajukan permintaan ke upstream MCP Pembaca Web z.ai.\",\n                    \"vision\": \"Penglihatan\",\n                    \"vision_tooltip\": \"Mendedahkan /mcp/zai-mcp-server/mcp (pelayan MCP tempatan) yang menyediakan alat penglihatan disokong oleh z.ai.\",\n                    \"local_endpoints\": \"Endpoint tempatan (konfigurasikan klien MCP anda untuk menggunakan URL ini):\",\n                    \"local_endpoints_tooltip\": \"Gunakan URL ini dalam klien MCP anda. Mereka berkongsi hos/port yang sama dengan Proksi API dan mengikuti polisi kebenaran proksi.\"\n                }\n            },\n            \"request_timeout\": \"Tamat Masa Permintaan\",\n            \"request_timeout_tooltip\": \"Masa maksimum (saat) proksi menunggu respons upstream, termasuk penstriman. Tingkatkan untuk penjanaan panjang; mulakan semula diperlukan untuk digunakan.\",\n            \"request_timeout_hint\": \"Lalai 120s, julat 30-7200s. Mulakan semula perkhidmatan untuk menggunakan perubahan.\",\n            \"enable_logging\": \"Aktifkan Pengelogan Permintaan\",\n            \"enable_logging_hint\": \"Rekod sejarah untuk nyahpepijat (Kos prestasi kecil)\",\n            \"upstream_proxy\": {\n                \"title\": \"Proksi Upstream Global (Global Proxy)\",\n                \"desc\": \"Apabila diaktifkan, semua permintaan luaran (Proksi API, Muat Semula Token, Semakan Kuota, Semakan Kemas Kini) akan dihalakan melalui proksi ini.\",\n                \"desc_short\": \"Proksi global yang digunakan sebagai penyelesaian sandaran apabila tiada akaun yang sesuai ditemui dalam pool proksi.\",\n                \"enable\": \"Aktifkan Proksi Upstream\",\n                \"url\": \"URL Proksi\",\n                \"url_placeholder\": \"cth. http://127.0.0.1:7890 atau socks5://127.0.0.1:7890\",\n                \"tip\": \"Menyokong HTTP, HTTPS dan SOCKS5.\",\n                \"socks5h_hint\": \"Untuk mengelakkan sekatan dan mengekalkan resolusi DNS jauh (Remote DNS), tukar protokol kepada socks5h:// secara manual.\",\n                \"validation_error\": \"URL Proksi diperlukan apabila proksi upstream diaktifkan\",\n                \"restart_hint\": \"Tetapan proksi disimpan. Mulakan semula aplikasi untuk menggunakan perubahan.\"\n            },\n            \"scheduling\": {\n                \"title\": \"Putaran & Penjadualan Akaun\",\n                \"title_tooltip\": \"Mengawal bagaimana sesi diikat kepada akaun dan bagaimana had kadar dikendalikan.\",\n                \"subtitle\": \"Mengoptimumkan Caching Prompt dan pengendalian had kadar untuk semua protokol (OpenAI/Gemini/Claude).\",\n                \"mode\": \"Mod Penjadualan\",\n                \"mode_tooltip\": \"Cache-First: Ikat sesi ke akaun, tunggu pada had kadar (maksimumkan utiliti cache); Balance: Ikat sesi, tukar akaun pada had kadar; Performance: Round-robin standard.\",\n                \"modes\": {\n                    \"CacheFirst\": \"Cache First\",\n                    \"Balance\": \"Seimbang\",\n                    \"PerformanceFirst\": \"Prestasi\"\n                },\n                \"modes_desc\": {\n                    \"CacheFirst\": \"Mengikat sesi ke akaun, menunggu dengan tepat jika terhad (Memaksimumkan hit Prompt Cache).\",\n                    \"Balance\": \"Mengikat sesi, tukar automatik ke akaun tersedia jika terhad (Cache & ketersediaan seimbang).\",\n                    \"PerformanceFirst\": \"Tiada pengikatan sesi, putaran round-robin tulen (Terbaik untuk konkurensi tinggi).\"\n                },\n                \"max_wait\": \"Tunggu Maks (saat)\",\n                \"max_wait_tooltip\": \"Hanya digunakan dalam mod 'Cache First': tunggu bukannya menukar jika masa reset had kadar adalah di bawah nilai ini.\",\n                \"clear_bindings\": \"Kosongkan Pengikatan Sesi\",\n                \"clear_bindings_tooltip\": \"Reset keras semua pengikatan sesi-akaun, memaksa akaun ditugaskan semula pada permintaan seterusnya.\",\n                \"clear_rate_limits\": \"Kosongkan Rekod Had Kadar\",\n                \"clear_rate_limits_tooltip\": \"Kosongkan serta-merta rekod had kadar tempatan untuk semua akaun, memaksa permintaan seterusnya mencuba upstream secara langsung.\"\n            },\n            \"circuit_breaker\": {\n                \"title\": \"Pemutus Litar Adaptif\",\n                \"tooltip\": \"Meningkatkan tempoh kunci secara automatik untuk akaun yang berulang kali gagal dengan kehabisan kuota. Ini menghalang pembaziran panggilan API pada akaun mati sambil membenarkan ralat sementara pulih dengan cepat.\",\n                \"backoff_levels\": \"Tahap Backoff (Saat)\",\n                \"input_placeholder\": \"Masukkan tempoh backoff dalam saat, dipisahkan dengan koma\",\n                \"level\": \"Tahap {{level}}\",\n                \"invalid_format\": \"Format tidak sah. Gunakan nombor dipisahkan koma (cth. 60, 300)\",\n                \"clear_records\": \"Kosongkan Semua Rekod Had Kadar\"\n            },\n            \"experimental\": {\n                \"title\": \"Tetapan Eksperimen\",\n                \"title_tooltip\": \"Ciri penerokaan yang mungkin diselaraskan atau dibuang dalam versi akan datang.\",\n                \"enable_usage_scaling\": \"Aktifkan Penskalaan Penggunaan\",\n                \"enable_usage_scaling_tooltip\": \"Untuk protokol Claude. Mengaktifkan penskalaan agresif apabila jumlah input melebihi 30k token untuk mengelakkan pemampatan sisi klien yang kerap. Nota: Penggunaan yang dilaporkan tidak akan mencerminkan pengebilan sebenar selepas diaktifkan.\",\n                \"context_compression_threshold_l1\": \"Ambang Pemampatan L1 (Pemangkasan Alat)\",\n                \"context_compression_threshold_l1_tooltip\": \"Memangkas rekod panggilan alat lama untuk menjimatkan ruang. Disyorkan: 0.4 (40%)\",\n                \"context_compression_threshold_l2\": \"Ambang Pemampatan L2 (Pemampatan Pemikiran)\",\n                \"context_compression_threshold_l2_tooltip\": \"Memampatkan blok pemikiran awal sambil mengekalkan tandatangan. Disyorkan: 0.55 (55%)\",\n                \"context_compression_threshold_l3\": \"Ambang Pemampatan L3 (Pivot Ringkasan)\",\n                \"context_compression_threshold_l3_tooltip\": \"Reset muktamad: menjana ringkasan keadaan XML dan pivot ke sesi baharu. Paling cekap token. Disyorkan: 0.7 (70%)\"\n            }\n        },\n        \"cloudflared\": {\n            \"title\": \"Akses Awam (Cloudflared)\",\n            \"subtitle\": \"Dedahkan perkhidmatan tempatan anda ke internet melalui Cloudflare Tunnel\",\n            \"not_installed\": \"Cloudflared tidak dipasang\",\n            \"install_hint\": \"Cloudflared adalah alat terowong percuma dari Cloudflare. Ia mendedahkan proksi tempatan anda ke internet tanpa IP awam atau penghalaan port. Klik butang di bawah untuk memasang.\",\n            \"install\": \"Pasang Sekarang\",\n            \"installing\": \"Memasang...\",\n            \"install_success\": \"Cloudflared berjaya dipasang\",\n            \"install_failed\": \"Pemasangan gagal: {{error}}\",\n            \"installed\": \"Dipasang\",\n            \"version\": \"Versi\",\n            \"mode_label\": \"Mod Terowong\",\n            \"mode_quick\": \"Terowong Pantas\",\n            \"mode_quick_desc\": \"URL sementara dijana automatik (*.trycloudflare.com), tiada akaun diperlukan, URL berubah pada mulakan semula\",\n            \"mode_auth\": \"Terowong Bernama\",\n            \"mode_auth_desc\": \"Gunakan token akaun Cloudflare, menyokong domain tersuai, URL berterusan\",\n            \"token\": \"Token Terowong\",\n            \"token_placeholder\": \"Tampal Token Terowong Cloudflare anda di sini\",\n            \"token_hint\": \"Dapatkan dari papan pemuka Cloudflare Zero Trust\",\n            \"token_required\": \"Token diperlukan untuk mod Terowong Bernama\",\n            \"use_http2\": \"Gunakan HTTP/2\",\n            \"use_http2_desc\": \"Lebih serasi, disyorkan untuk tanah besar China\",\n            \"status_label\": \"Status Terowong\",\n            \"status_stopped\": \"Berhenti\",\n            \"status_starting\": \"Bermula...\",\n            \"status_running\": \"Berjalan\",\n            \"status_stopping\": \"Berhenti...\",\n            \"status_error\": \"Ralat\",\n            \"public_url\": \"URL Awam\",\n            \"public_url_placeholder\": \"URL awam akan muncul di sini selepas terowong bermula\",\n            \"copy_url\": \"Salin URL\",\n            \"url_copied\": \"URL disalin\",\n            \"start_tunnel\": \"Mulakan Terowong\",\n            \"stop_tunnel\": \"Hentikan Terowong\",\n            \"running\": \"Terowong Berjalan\",\n            \"started\": \"Terowong dimulakan\",\n            \"stopped\": \"Terowong dihentikan\",\n            \"start_failed\": \"Permulaan gagal: {{error}}\",\n            \"stop_failed\": \"Penghentian gagal: {{error}}\",\n            \"require_proxy_running\": \"Sila mulakan perkhidmatan proksi tempatan dahulu\",\n            \"connection_info\": \"Maklumat Sambungan\",\n            \"local_port\": \"Port Tempatan\",\n            \"tunnel_protocol\": \"Protokol Terowong\"\n        },\n        \"example\": {\n            \"title\": \"Contoh Penggunaan\",\n            \"curl\": \"cURL\",\n            \"python\": \"Python\",\n            \"python_anthropic\": \"from anthropic import Anthropic\\n\\nclient = Anthropic(\\n    # Disyorkan: 127.0.0.1\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\n# Nota: Antigravity menyokong memanggil mana-mana model melalui SDK Anthropic\\nresponse = client.messages.create(\\n    model=\\\"{{modelId}}\\\",\\n    max_tokens=1024,\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hello\\\"}]\\n)\\n\\nprint(response.content[0].text)\",\n            \"python_gemini\": \"# Pasang: pip install google-generativeai\\nimport google.generativeai as genai\\n\\n# Gunakan alamat proksi Antigravity (disyorkan 127.0.0.1)\\ngenai.configure(\\n    api_key=\\\"{{apiKey}}\\\",\\n    transport='rest',\\n    client_options={'api_endpoint': '{{rawBaseUrl}}'}\\n)\\n\\nmodel = genai.GenerativeModel('{{modelId}}')\\nresponse = model.generate_content(\\\"Hello\\\")\\nprint(response.text)\",\n            \"python_openai_image\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    # Pilihan 1: gunakan saiz (disyorkan)\\n    # Disokong: \\\"1024x1024\\\" (1:1), \\\"1280x720\\\" (16:9), \\\"720x1280\\\" (9:16), \\\"1216x896\\\" (4:3)\\n    extra_body={ \\\"size\\\": \\\"1024x1024\\\" },\\n\\n    # Pilihan 2: gunakan akhiran model\\n    # cth. gemini-3-pro-image-16-9, gemini-3-pro-image-4-3\\n    # model=\\\"gemini-3-pro-image-16-9\\\",\\n    messages=[{\\n        \\\"role\\\": \\\"user\\\",\\n        \\\"content\\\": \\\"Lukis bandar futuristik\\\"\\n    }]\\n)\\n\\nprint(response.choices[0].message.content)\",\n            \"python_openai\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hello\\\"}]\\n)\\n\\nprint(response.choices[0].message.content)\"\n        },\n        \"examples\": {\n            \"title\": \"Contoh Penggunaan\"\n        },\n        \"dialog\": {\n            \"confirm_regenerate\": \"Adakah anda pasti mahu menjana semula Kunci API? Kunci lama akan dibatalkan serta-merta.\",\n            \"operate_failed\": \"Operasi gagal: {{error}}\",\n            \"reset_mapping_title\": \"Reset Pemetaan Model\",\n            \"reset_mapping_msg\": \"Adakah anda pasti mahu mereset semua pemetaan model ke lalai sistem? Tindakan ini tidak boleh dibatalkan.\",\n            \"regenerate_key_title\": \"Jana Semula Kunci API\",\n            \"regenerate_key_msg\": \"Adakah anda pasti mahu menjana semula Kunci API? Kunci lama akan dibatalkan serta-merta.\",\n            \"clear_bindings_title\": \"Kosongkan Pengikatan Sesi\",\n            \"clear_bindings_msg\": \"Adakah anda pasti mahu mengosongkan semua pengikatan sesi-akaun?\",\n            \"clear_rate_limits_title\": \"Kosongkan Rekod Had Kadar\",\n            \"clear_rate_limits_confirm\": \"Adakah anda pasti mahu mengosongkan semua rekod had kadar tempatan?\"\n        },\n        \"model\": {\n            \"flash\": \"Respons Pantas\",\n            \"flash_preview\": \"Pratonton Flash\",\n            \"flash_lite\": \"Ringan & Pantas\",\n            \"flash_thinking\": \"Keupayaan Pemikiran\",\n            \"pro_legacy\": \"Pro Warisan\",\n            \"pro_low\": \"Prestasi Tinggi\",\n            \"pro_high\": \"Penaakulan Terbaik\",\n            \"pro_image\": \"Penjanaan Imej (1:1)\",\n            \"pro_image_16_9\": \"Penjanaan Imej (16:9)\",\n            \"pro_image_9_16\": \"Penjanaan Imej (9:16)\",\n            \"pro_image_4_3\": \"Penjanaan Imej (4:3)\",\n            \"pro_image_3_4\": \"Penjanaan Imej (3:4)\",\n            \"pro_image_1_1\": \"Penjanaan Imej (1:1)\",\n            \"claude_sonnet\": \"Penaakulan Kod\",\n            \"claude_sonnet_thinking\": \"Rantaian Pemikiran\",\n            \"claude_opus_thinking\": \"Pemikiran Terkuat\"\n        },\n        \"mapping\": {\n            \"title\": \"Pemetaan Model Claude Code\",\n            \"description\": \"Petakan model Claude Code ke model Antigravity. Optimumkan kos dan kelajuan dengan menghalakan permintaan secara pintar.\",\n            \"default\": \"Lalai\",\n            \"sonnet_desc\": \"Paling berkemampuan untuk kerja kompleks\",\n            \"opus_desc\": \"Tahap premium\",\n            \"haiku_desc\": \"Terpantas untuk jawapan cepat\",\n            \"maps_to\": \"Dipetakan ke Antigravity\",\n            \"apply_recommended\": \"Gunakan Disyorkan\",\n            \"restore_defaults\": \"Pulihkan Konfigurasi Lalai\",\n            \"reset_all\": \"Reset Semua\"\n        },\n        \"router\": {\n            \"title\": \"Penghalaan Model\",\n            \"subtitle\": \"Halakan model mengikut siri atau tambah pemetaan tepat tersuai.\\nNota: Model pass-through Claude asli (cth. claude-opus-4-6-thinking) memintas kumpulan siri secara lalai. Gunakan \\\"Penghalaan Tersuai Pakar\\\" untuk mengatasi.\",\n            \"subtitle_simple\": \"Sesuaikan penghalaan model dengan wildcard atau pemetaan tepat\",\n            \"background_task_title\": \"Model Tugas Latar Belakang\",\n            \"background_task_desc\": \"Model digunakan untuk tugas latar belakang Claude CLI seperti penjanaan tajuk, ringkasan, dll. (Lalai: gemini-2.5-flash)\",\n            \"use_default\": \"Gunakan Lalai Sistem\",\n            \"current_model\": \"Model Semasa\",\n            \"apply_presets\": \"Gunakan Pratetap\",\n            \"presets_applied\": \"Pratetap berjaya digunakan\",\n            \"custom_mappings\": \"Pemetaan Tersuai\",\n            \"group_title\": \"Kumpulan Siri\",\n            \"groups\": {\n                \"claude_45\": {\n                    \"name\": \"Siri Claude 4.6 TK\",\n                    \"desc\": \"Opus 4.5 TK\"\n                },\n                \"claude_35\": {\n                    \"name\": \"Siri Claude 3.5\",\n                    \"desc\": \"Sonnet 3.5, Haiku 3.5\"\n                },\n                \"gpt_4\": {\n                    \"name\": \"Siri GPT-4 / o1\",\n                    \"desc\": \"GPT-4, Turbo, o1-preview\"\n                },\n                \"gpt_4o\": {\n                    \"name\": \"Siri GPT-4o / 3.5\",\n                    \"desc\": \"GPT-4o, Mini, 3.5 Turbo\"\n                },\n                \"gpt_5\": {\n                    \"name\": \"Siri GPT-5\",\n                    \"desc\": \"GPT-5.1, GPT-5.2 xhigh\"\n                }\n            },\n            \"expert_title\": \"Penghalaan Tersuai Pakar\",\n            \"expert_subtitle\": \"Padanan tepat untuk mana-mana ID model asal.\",\n            \"money_saving_tip\": \"💰 Tip jimat kos:\",\n            \"haiku_optimization_tip\": \"Claude CLI menggunakan {{model}} untuk tugas latar belakang secara lalai. Petakan ke model Flash yang lebih murah untuk jimat ~95% kos\",\n            \"haiku_optimization_btn\": \"Optimum Pantas\",\n            \"haiku_tip_title\": \"💰 Tip jimat kos:\",\n            \"haiku_tip_body_before\": \"Claude CLI menggunakan\",\n            \"haiku_tip_body_after\": \"untuk tugas latar belakang secara lalai; memetakannya ke model Flash yang lebih murah boleh menjimatkan kira-kira 95% kos.\",\n            \"haiku_tip_action\": \"Optimum\",\n            \"reset_confirm\": \"Reset semua pemetaan ke lalai sistem?\",\n            \"reset_mapping\": \"Reset Pemetaan\",\n            \"add_mapping\": \"Tambah Pemetaan\",\n            \"current_list\": \"Senarai Tersuai\",\n            \"no_custom_mapping\": \"Tiada pemetaan tersuai lagi\",\n            \"gemini3_only_warning\": \"⚠️ Siri Gemini 3 sahaja\",\n            \"default_suffix\": \" (Lalai)\",\n            \"original_id\": \"ID Asal\",\n            \"route_to\": \"Halakan Ke\",\n            \"select_target_model\": \"Pilih Model Sasaran\",\n            \"original_placeholder\": \"Asal (cth. gpt-4 atau gpt-4*)\",\n            \"custom_mapping_tip\": \"💡 Sokong input manual sebarang ID model untuk mencuba model yang belum dikeluarkan (cth: claude-opus-4-6).\",\n            \"custom_mapping_warning\": \"Perhatian: Bukan semua akaun menyokong model yang belum dikeluarkan\"\n        },\n        \"multi_protocol\": {\n            \"title\": \"Sokongan Pelbagai Protokol\",\n            \"subtitle\": \"Integrasi lancar dengan alat dan CLI AI kegemaran anda\",\n            \"description\": \"Proksi tempatan menyokong protokol OpenAI, Anthropic, dan Gemini, memastikan keserasian dengan pelbagai aplikasi.\",\n            \"openai_label\": \"Protokol OpenAI\",\n            \"anthropic_label\": \"Protokol Anthropic\",\n            \"openai_tools\": \"Cherry Studio, NextChat\",\n            \"anthropic_tools\": \"Claude Code CLI\",\n            \"gemini_label\": \"Protokol Gemini\",\n            \"gemini_tools\": \"Google AI SDK, LangChain\",\n            \"quick_integration\": \"Integrasi Pantas\",\n            \"click_tip\": \"👆 Klik model untuk kemas kini contoh kod\",\n            \"copy_base\": \"Salin Base\"\n        },\n        \"supported_models\": {\n            \"title\": \"Model Disokong & Integrasi\",\n            \"model_name\": \"Nama Model\",\n            \"model_id\": \"ID Model\",\n            \"description\": \"Penerangan\",\n            \"action\": \"Tindakan\"\n        },\n        \"cli_sync\": {\n            \"title\": \"Segerak CLI Satu-klik\",\n            \"subtitle\": \"Segerakkan endpoint API semasa dan kunci ke alat CLI AI tempatan anda dengan cepat.\",\n            \"card_title\": \"Konfigurasi {{name}}\",\n            \"status\": {\n                \"not_installed\": \"Tidak dikesan\",\n                \"installed\": \"v{{version}}\",\n                \"synced\": \"Ditunjuk ke aplikasi ini\",\n                \"not_synced\": \"Tidak disegerakkan\",\n                \"detecting\": \"Mengesan...\",\n                \"current_base_url\": \"URL Asas Semasa\"\n            },\n            \"btn_sync\": \"Segerak Konfigurasi Sekarang\",\n            \"btn_view\": \"Lihat Konfigurasi\",\n            \"btn_restore\": \"Pulihkan Lalai\",\n            \"btn_restore_backup\": \"Pulihkan Sandaran\",\n            \"restore_confirm\": \"Adakah anda pasti mahu memulihkan konfigurasi untuk {{name}} ke URL lalai rasmi?\",\n            \"restore_backup_confirm\": \"Konfigurasi sandaran ditemui. Adakah anda pasti mahu memulihkannya?\",\n            \"modal\": {\n                \"view_title\": \"Kandungan Konfigurasi {{name}}\",\n                \"copy_success\": \"Kandungan konfigurasi disalin\"\n            },\n            \"toast\": {\n                \"sync_success\": \"Segerak berjaya! {{name}} sudah sedia.\",\n                \"sync_error\": \"Segerak gagal: {{error}}\"\n            },\n            \"sync_confirm_title\": \"Pengesahan Segerak\",\n            \"sync_confirm_message\": \"Bersedia untuk menyegerakkan konfigurasi {{name}}. ⚠️ Amaran: Ini akan menimpa fail konfigurasi tempatan sedia ada anda (cth. token log masuk, Kunci API). Adakah anda pasti mahu meneruskan?\"\n        }\n    },\n    \"monitor\": {\n        \"page_title\": \"Papan Pemuka Monitor API\",\n        \"page_subtitle\": \"Pengelogan dan analisis permintaan masa nyata\",\n        \"open_monitor\": \"Buka Monitor\",\n        \"logging_status\": {\n            \"active\": \"Merekod\",\n            \"paused\": \"Dijeda\"\n        },\n        \"stats\": {\n            \"total\": \"Jumlah\",\n            \"ok\": \"OK\",\n            \"err\": \"RALAT\"\n        },\n        \"filters\": {\n            \"placeholder\": \"Tapis mengikut model, laluan, atau status...\",\n            \"quick_filters\": \"Penapis Pantas:\",\n            \"all\": \"Semua\",\n            \"error\": \"Ralat\",\n            \"chat\": \"Chat\",\n            \"gemini\": \"Gemini\",\n            \"claude\": \"Claude\",\n            \"images\": \"Imej\",\n            \"reset\": \"Reset\",\n            \"by_account\": \"Tapis mengikut akaun\",\n            \"all_accounts\": \"Semua Akaun\"\n        },\n        \"table\": {\n            \"status\": \"Status\",\n            \"method\": \"Kaedah\",\n            \"model\": \"Model\",\n            \"protocol\": \"Protokol\",\n            \"account\": \"Akaun\",\n            \"path\": \"Laluan\",\n            \"usage\": \"Token\",\n            \"duration\": \"Tempoh\",\n            \"time\": \"Masa\",\n            \"empty\": \"Tiada permintaan direkod\"\n        },\n        \"details\": {\n            \"title\": \"Butiran Permintaan\",\n            \"request_payload\": \"Payload Permintaan\",\n            \"response_payload\": \"Payload Respons\",\n            \"duration\": \"Tempoh\",\n            \"tokens\": \"Token (I/O)\",\n            \"time\": \"Masa\",\n            \"model\": \"Model\",\n            \"mapped_model\": \"Model Dipetakan\",\n            \"protocol\": \"Protokol\",\n            \"account_used\": \"Akaun Digunakan\",\n            \"id\": \"ID Permintaan\",\n            \"payload_empty\": \"Tiada data\"\n        },\n        \"dialog\": {\n            \"clear_title\": \"Kosongkan Log Proksi\",\n            \"clear_msg\": \"Adakah anda pasti mahu mengosongkan semua log proksi? Tindakan ini tidak boleh dibatalkan.\"\n        }\n    },\n    \"update_notification\": {\n        \"title\": \"Versi Baharu Tersedia\",\n        \"message\": \"Versi baharu sudah sedia dengan pengoptimuman dan penambahbaikan. Semasa: v{{current}}\",\n        \"ready\": \"Kemas Kini Sedia\",\n        \"downloading\": \"Memuat turun kemas kini...\",\n        \"restarting\": \"Memulakan semula aplikasi...\",\n        \"auto_update\": \"Kemas Kini Automatik\",\n        \"toast\": {\n            \"not_ready\": \"Pakej kemas kini automatik belum sedia, mengarahkan anda ke halaman muat turun...\",\n            \"failed\": \"Kemas kini automatik gagal, mengarahkan anda ke halaman muat turun...\"\n        }\n    },\n    \"login\": {\n        \"title\": \"Kawalan Akses Selamat\",\n        \"desc\": \"Berjalan dalam mod Web. Sila masukkan kata laluan pengurusan atau Kunci API untuk mengakses.\",\n        \"placeholder\": \"Masukkan kata laluan pengurusan atau Kunci API\",\n        \"btn_login\": \"Sahkan dan Masuk\",\n        \"note\": \"Nota: Jika kata laluan pengurusan berasingan ditetapkan, sila masukkan; jika tidak, masukkan API_KEY.\",\n        \"lookup_hint\": \"Jika terlupa, jalankan docker logs antigravity-manager untuk mencari Current API Key atau Web UI Password\",\n        \"config_hint\": \"Atau jalankan grep -E '\\\"api_key\\\"|\\\"admin_password\\\"' ~/.antigravity_tools/gui_config.json untuk melihat.\"\n    },\n    \"token_stats\": {\n        \"title\": \"Statistik Penggunaan Token\",\n        \"hourly\": \"Jam\",\n        \"daily\": \"Hari\",\n        \"weekly\": \"Minggu\",\n        \"total_tokens\": \"Jumlah Token\",\n        \"input_tokens\": \"Token Input\",\n        \"output_tokens\": \"Token Output\",\n        \"accounts_used\": \"Akaun Aktif\",\n        \"models_used\": \"Model Digunakan\",\n        \"model_trend\": \"Trend Penggunaan Model\",\n        \"account_trend\": \"Trend Penggunaan Akaun\",\n        \"usage_trend\": \"Trend Penggunaan Token\",\n        \"by_account\": \"Mengikut Akaun\",\n        \"by_model\": \"Mengikut Model\",\n        \"by_account_view\": \"Mengikut Akaun\",\n        \"model_details\": \"Pecahan Model\",\n        \"account_details\": \"Pecahan Akaun\",\n        \"model\": \"Model\",\n        \"account\": \"Akaun\",\n        \"requests\": \"Permintaan\",\n        \"input\": \"Input\",\n        \"output\": \"Output\",\n        \"total\": \"Jumlah\",\n        \"percentage\": \"Bahagian\",\n        \"no_data\": \"Tiada data tersedia\"\n    },\n    \"errors\": {\n        \"stream\": {\n            \"timeout_error\": \"Tamat masa permintaan, sila semak sambungan rangkaian anda\",\n            \"connection_error\": \"Sambungan gagal, sila semak tetapan rangkaian atau proksi anda\",\n            \"decode_error\": \"Rangkaian tidak stabil, penghantaran data terganggu. Cuba: 1) Semak rangkaian 2) Tukar proksi 3) Cuba semula\",\n            \"stream_error\": \"Ralat penghantaran strim, sila cuba semula kemudian\",\n            \"unknown_error\": \"Ralat tidak diketahui berlaku, sila cuba semula kemudian\"\n        }\n    },\n    \"user_token\": {\n        \"title\": \"Pengurusan Token Pengguna\",\n        \"total_users\": \"Jumlah Pengguna\",\n        \"active_tokens\": \"Token Aktif\",\n        \"total_created\": \"Jumlah Dicipta\",\n        \"create\": \"Cipta Token\",\n        \"username\": \"Nama pengguna\",\n        \"token\": \"Token\",\n        \"expires\": \"Tamat Tempoh\",\n        \"usage\": \"Penggunaan\",\n        \"ip_limit\": \"Had IP\",\n        \"created\": \"Dicipta pada\",\n        \"today_requests\": \"Permintaan Hari Ini\",\n        \"never\": \"Selasma\",\n        \"renew\": \"Perbaharui\",\n        \"renew_button\": \"Perbaharui\",\n        \"unlimited\": \"Tanpa had\",\n        \"create_title\": \"Cipta Token Baharu\",\n        \"description\": \"Penerangan\",\n        \"curfew\": \"Waktu perintah berkurung (Perkhidmatan tidak tersedia)\",\n        \"edit_title\": \"Sunting Token\",\n        \"username_required\": \"Nama pengguna diperlukan\",\n        \"renew_success\": \"Berjaya diperbaharui\",\n        \"expires_day\": \"1 Hari\",\n        \"expires_week\": \"1 Minggu\",\n        \"expires_month\": \"1 Bulan\",\n        \"expires_never\": \"Selasma\",\n        \"no_data\": \"Tiada token ditemui\",\n        \"placeholder_username\": \"cth. user1\",\n        \"placeholder_desc\": \"Nota pilihan\",\n        \"placeholder_max_ips\": \"0 = Tanpa had\",\n        \"hint_max_ips\": \"0 bermaksud tanpa had\",\n        \"hint_curfew\": \"Biarkan kosong untuk nyahaktifkan. Berdasarkan masa pelayan.\"\n    }\n}"
  },
  {
    "path": "src/locales/pt.json",
    "content": "{\n    \"common\": {\n        \"loading\": \"Carregando...\",\n        \"load_more\": \"Carregar mais\",\n        \"add\": \"Adicionar\",\n        \"copy\": \"Copiar\",\n        \"action\": \"Ação\",\n        \"save\": \"Salvar\",\n        \"saved\": \"Salvo com sucesso\",\n        \"cancel\": \"Cancelar\",\n        \"confirm\": \"Confirmar\",\n        \"close\": \"Fechar\",\n        \"delete\": \"Excluir\",\n        \"edit\": \"Editar\",\n        \"refresh\": \"Atualizar\",\n        \"refreshing\": \"Atualizando...\",\n        \"export\": \"Exportar\",\n        \"import\": \"Importar\",\n        \"success\": \"Sucesso\",\n        \"error\": \"Erro\",\n        \"unknown\": \"Desconhecido\",\n        \"warning\": \"Aviso\",\n        \"info\": \"Informação\",\n        \"details\": \"Detalhes\",\n        \"example\": \"Example\",\n        \"clear\": \"Limpar\",\n        \"clearing\": \"Limpando...\",\n        \"prev_page\": \"Anterior\",\n        \"next_page\": \"Próxima\",\n        \"pagination_info\": \"Mostrando {{start}} a {{end}} de {{total}} entradas\",\n        \"per_page\": \"Por página\",\n        \"items\": \"itens\",\n        \"accounts\": \"contas\",\n        \"enabled\": \"Habilitado\",\n        \"disabled\": \"Desabilitado\",\n        \"tauri_api_not_loaded\": \"API Tauri não carregada, por favor reinicie o aplicativo\",\n        \"environment_error\": \"Erro de ambiente: {{error}}\",\n        \"submit\": \"Enviar\",\n        \"update\": \"Atualizar\",\n        \"load_failed\": \"Falha ao carregar\",\n        \"create_success\": \"Criado com sucesso\",\n        \"update_success\": \"Atualizado com sucesso\",\n        \"delete_success\": \"Excluído com sucesso\",\n        \"copied\": \"Copiado para a área de transferência\"\n    },\n    \"nav\": {\n        \"dashboard\": \"Painel\",\n        \"accounts\": \"Contas\",\n        \"proxy\": \"Proxy da API\",\n        \"call_records\": \"Registros de Tráfego\",\n        \"security\": \"Gestão de IP\",\n        \"settings\": \"Configurações\",\n        \"theme_to_dark\": \"Alternar para Modo Escuro\",\n        \"theme_to_light\": \"Alternar para Modo Claro\",\n        \"switch_to_english\": \"Alternar para Inglês\",\n        \"switch_to_chinese\": \"Alternar para Chinês\",\n        \"switch_to_traditional_chinese\": \"Alternar para Chinês Tradicional\",\n        \"switch_to_english_short\": \"EN\",\n        \"switch_to_chinese_short\": \"ZH\",\n        \"switch_to_traditional_chinese_short\": \"TW\",\n        \"switch_to_japanese\": \"Alternar para Japonês\",\n        \"switch_to_japanese_short\": \"JA\",\n        \"switch_to_turkish\": \"Alternar para Turco\",\n        \"switch_to_turkish_short\": \"TR\",\n        \"switch_to_vietnamese\": \"Alternar para Vietnamita\",\n        \"switch_to_vietnamese_short\": \"VI\",\n        \"switch_to_portuguese\": \"Alternar para Português\",\n        \"switch_to_portuguese_short\": \"PT\",\n        \"switch_to_russian\": \"Alternar para Russo\",\n        \"switch_to_russian_short\": \"RU\",\n        \"switch_to_korean\": \"Alternar para Coreano\",\n        \"switch_to_korean_short\": \"KO\",\n        \"switch_to_spanish\": \"Alternar para Espanhol\",\n        \"switch_to_spanish_short\": \"ES\",\n        \"switch_to_malay\": \"Alternar para Malaio\",\n        \"switch_to_malay_short\": \"MY\",\n        \"user_token\": \"Tokens de usuário\"\n    },\n    \"dashboard\": {\n        \"hello\": \"Olá, Usuário 👋\",\n        \"refresh_quota\": \"Atualizar Cota\",\n        \"refreshing\": \"Atualizando...\",\n        \"total_accounts\": \"Total de Contas\",\n        \"avg_gemini\": \"Cota Média Gemini\",\n        \"avg_gemini_image\": \"Cota Média Gemini Imagem\",\n        \"avg_claude\": \"Cota Média Claude\",\n        \"low_quota_accounts\": \"Contas com Cota Baixa\",\n        \"quota_sufficient\": \"Cota Suficiente\",\n        \"quota_low\": \"Cota Baixa\",\n        \"quota_desc\": \"Cota < 20%\",\n        \"current_account\": \"Conta Atual\",\n        \"switch_account\": \"Alternar Conta\",\n        \"no_active_account\": \"Nenhuma Conta Ativa\",\n        \"best_accounts\": \"Melhores Contas\",\n        \"best_account_recommendation\": \"Melhor Conta\",\n        \"switch_best\": \"Alternar para Melhor\",\n        \"switch_successfully\": \"Alternar para Melhor\",\n        \"view_all_accounts\": \"Ver Todas as Contas\",\n        \"export_data\": \"Exportar Dados\",\n        \"for_gemini\": \"Para Gemini\",\n        \"for_claude\": \"Para Claude\",\n        \"toast\": {\n            \"switch_success\": \"Alternância bem-sucedida!\",\n            \"switch_error\": \"Falha ao alternar conta\",\n            \"refresh_success\": \"Atualização de cota bem-sucedida\",\n            \"refresh_error\": \"Falha na atualização\",\n            \"export_no_accounts\": \"Nenhuma conta para exportar\",\n            \"export_success\": \"Exportação bem-sucedida! Arquivo salvo em: {{path}}\",\n            \"export_error\": \"Falha na exportação\"\n        }\n    },\n    \"accounts\": {\n        \"account\": \"Conta\",\n        \"search_placeholder\": \"Pesquisar e-mail...\",\n        \"all\": \"Todas\",\n        \"available\": \"Disponíveis\",\n        \"low_quota\": \"Cota Baixa\",\n        \"ultra\": \"ULTRA\",\n        \"pro\": \"PRO\",\n        \"free\": \"GRÁTIS\",\n        \"edit_label\": \"Editar rótulo\",\n        \"custom_label_placeholder\": \"Digite um rótulo personalizado\",\n        \"label_updated\": \"Rótulo atualizado\",\n        \"add_account\": \"Adicionar Conta\",\n        \"refresh_all\": \"Atualizar Todas\",\n        \"refresh_selected\": \"Atualizar ({{count}})\",\n        \"export_selected\": \"Exportar ({{count}})\",\n        \"delete_selected\": \"Excluir ({{count}})\",\n        \"current\": \"Atual\",\n        \"current_badge\": \"Atual\",\n        \"disabled\": \"Desabilitado\",\n        \"disabled_tooltip\": \"Conta está desabilitada (ex: refresh_token revogado/expirado). Reautorize ou atualize o token para reabilitar.\",\n        \"proxy_disabled\": \"Proxy Desabilitado\",\n        \"proxy_disabled_tooltip\": \"Esta conta teve o proxy desabilitado manualmente, não processará solicitações da API, mas permanece utilizável no aplicativo.\",\n        \"enable_proxy\": \"Habilitar Proxy\",\n        \"disable_proxy\": \"Desabilitar Proxy\",\n        \"enable_proxy_selected\": \"Habilitar ({{count}})\",\n        \"disable_proxy_selected\": \"Desabilitar ({{count}})\",\n        \"proxy_disabled_reason_manual\": \"Desabilitado manualmente pelo usuário\",\n        \"proxy_disabled_reason_batch\": \"Desabilitado em lote\",\n        \"forbidden\": \"403\",\n        \"forbidden_badge\": \"403\",\n        \"forbidden_tooltip\": \"API retornou 403 Forbidden, conta não tem permissão para Gemini Code Assist\",\n        \"forbidden_msg\": \"Proibido, ignorar atualização automática\",\n        \"status\": {\n            \"forbidden\": \"403 Proibido\",\n            \"disabled\": \"Conta desativada\",\n            \"proxy_disabled\": \"Proxy desativado\"\n        },\n        \"error_details\": \"Detalhes do erro\",\n        \"error_status\": \"Status do erro\",\n        \"error_time\": \"Hora de detecção\",\n        \"view_error\": \"Ver motivo\",\n        \"click_to_verify\": \"Clique para verificar\",\n        \"no_data\": \"Nenhum dado\",\n        \"last_used\": \"Último Uso\",\n        \"reset_time\": \"Tempo de Reset\",\n        \"switch_to\": \"Alternar para esta conta\",\n        \"actions\": \"Ações\",\n        \"device_fingerprint\": \"Impressão Digital do Dispositivo\",\n        \"show_all_quotas\": \"Mostrar todas as cotas\",\n        \"device_fingerprint_dialog\": {\n            \"title\": \"Impressão Digital do Dispositivo\",\n            \"operations\": \"Operações de Impressão Digital do Dispositivo\",\n            \"generate_and_bind\": \"Gerar e Vincular\",\n            \"restore_original\": \"Restaurar Original\",\n            \"open_storage_directory\": \"Abrir Diretório de Armazenamento\",\n            \"current_storage\": \"Armazenamento Atual\",\n            \"effective\": \"Efetivo\",\n            \"current_storage_desc\": \"Lido do storage.json (atualizado após aplicar vinculação ao trocar contas)\",\n            \"account_binding\": \"Vinculação de Conta\",\n            \"pending_application\": \"Aplicação Pendente\",\n            \"account_binding_desc\": \"Salvo como vinculação após geração/restauração, escrito no storage.json ao trocar contas\",\n            \"historical_fingerprints\": \"Impressões Digitais Históricas (restauração/exclusão opcional)\",\n            \"no_history\": \"Sem Histórico\",\n            \"current\": \"Atual\",\n            \"restore\": \"Restaurar\",\n            \"delete_version\": \"Excluir esta versão\",\n            \"confirm_generate_title\": \"Confirmar gerar e vincular?\",\n            \"confirm_generate_desc\": \"Gerará um novo conjunto de impressões digitais do dispositivo e definirá como impressão atual. Confirmar continuação?\",\n            \"confirm_restore_title\": \"Confirmar restaurar impressão digital original?\",\n            \"confirm_restore_desc\": \"Restaurará a impressão digital original e sobrescreverá a impressão atual. Confirmar continuação?\",\n            \"cancel\": \"Cancelar\",\n            \"confirm\": \"Confirmar\",\n            \"processing\": \"Processando...\",\n            \"loading\": \"Carregando...\",\n            \"failed_to_load_device_info\": \"Falha ao carregar informações do dispositivo\",\n            \"generation_failed\": \"Falha na geração\",\n            \"binding_failed\": \"Falha na vinculação\",\n            \"restoration_failed\": \"Falha na restauração\",\n            \"deletion_failed\": \"Falha na exclusão\",\n            \"directory_open_failed\": \"Não foi possível abrir o diretório\",\n            \"generated_and_bound\": \"Gerado e vinculado\",\n            \"restored\": \"Restaurado\",\n            \"deleted\": \"Excluído\",\n            \"directory_opened\": \"Diretório de armazenamento aberto\",\n            \"original_fingerprint_not_found\": \"Impressão digital original não encontrada\"\n        },\n        \"warmup_all\": \"Aquecimento com Um Clique\",\n        \"warmup_selected\": \"Aquecer ({{count}})\",\n        \"warmup_this\": \"Aquecer esta conta\",\n        \"warmup_now\": \"Aquecer Agora\",\n        \"warmup_batch_triggered\": \"Tarefas de aquecimento acionadas para {{count}} contas\",\n        \"quota_protected\": \"Protegido\",\n        \"details\": {\n            \"title\": \"Detalhes da Cota\",\n            \"model_quota\": \"Cota do Modelo\",\n            \"protected_models\": \"Modelos Protegidos\"\n        },\n        \"toast\": {\n            \"proxy_enabled\": \"Proxy habilitado para {{count}} contas\",\n            \"proxy_disabled\": \"Proxy desabilitado para {{count}} contas\"\n        },\n        \"add\": {\n            \"title\": \"Adicionar Conta\",\n            \"tabs\": {\n                \"oauth\": \"OAuth\",\n                \"token\": \"Refresh Token\",\n                \"import\": \"Importar DB\"\n            },\n            \"oauth\": {\n                \"recommend\": \"Recomendado\",\n                \"desc\": \"Abre o navegador padrão para login do Google e busca automaticamente o Token.\",\n                \"btn_start\": \"Iniciar OAuth\",\n                \"btn_waiting\": \"Aguardando autenticação...\",\n                \"btn_finish\": \"Já autorizei\",\n                \"copy_link\": \"Copiar Link de Autenticação\",\n                \"copied\": \"Copiado\",\n                \"link_label\": \"URL de Autorização\",\n                \"link_click_to_copy\": \"Clique para copiar\",\n                \"manual_hint\": \"O navegador não redirecionou automaticamente? Cole o link de retorno ou o Código de Autorização aqui:\",\n                \"manual_placeholder\": \"Cole o link ou o código aqui...\",\n                \"error_no_flow\": \"Nenhum fluxo de autenticação ativo encontrado. Por favor, reinicie o OAuth.\",\n                \"web_hint\": \"A página de login do Google será aberta em uma nova janela\",\n                \"error_no_url\": \"Não foi possível obter a URL OAuth\",\n                \"popup_blocked\": \"Popup bloqueado\",\n                \"manual_submitting\": \"Enviando código de autorização...\",\n                \"manual_submitted\": \"Código de autorização enviado, processando em segundo plano...\"\n            },\n            \"token\": {\n                \"label\": \"Refresh Token\",\n                \"placeholder\": \"Cole seu Refresh Token aqui (Suporte a lote)\\n\\nFormatos suportados:\\n1. Token Único (1//...)\\n2. Array JSON (com campo refresh_token)\\n3. Qualquer texto contendo tokens (Extração automática)\",\n                \"hint\": \"Dica: Você pode colar múltiplos tokens ou um array JSON para importar em lote.\",\n                \"error_token\": \"Por favor, insira o Refresh Token\",\n                \"batch_progress\": \"Importando {{current}}/{{total}} contas...\",\n                \"batch_success\": \"{{count}} contas importadas com sucesso\",\n                \"batch_partial\": \"Importação concluída: {{success}} sucesso, {{fail}} falhou\",\n                \"batch_fail\": \"Importação falhou\"\n            },\n            \"import\": {\n                \"scheme_a\": \"Plano A: Do DB do IDE\",\n                \"scheme_a_desc\": \"Lê automaticamente a conta atualmente logada do banco de dados local do Antigravity.\",\n                \"btn_db\": \"Importar Conta Atual\",\n                \"or\": \"OU\",\n                \"scheme_b\": \"Plano B: Do Backup V1\",\n                \"scheme_b_desc\": \"Escaneia ~/.antigravity-agent para dados de conta V1.\",\n                \"btn_v1\": \"Importar V1 em Lote\",\n                \"btn_custom_db\": \"Importar DB Personalizado\"\n            },\n            \"btn_cancel\": \"Cancelar\",\n            \"btn_confirm\": \"Confirmar\",\n            \"oauth_error\": \"OAuth falhou: {{error}}\",\n            \"status\": {\n                \"error_token\": \"Por favor, insira o Refresh Token\"\n            }\n        },\n        \"table\": {\n            \"email\": \"Email\",\n            \"quota\": \"Cota do Modelo\",\n            \"last_used\": \"Último Uso\",\n            \"actions\": \"Ações\"\n        },\n        \"drag_to_reorder\": \"Arraste para reordenar\",\n        \"empty\": {\n            \"title\": \"Sem Contas\",\n            \"desc\": \"Clique no botão \\\"Adicionar Conta\\\" acima para adicionar sua primeira conta\"\n        },\n        \"views\": {\n            \"list\": \"Visualização em Lista\",\n            \"grid\": \"Visualização em Grade\"\n        },\n        \"dialog\": {\n            \"add_title\": \"Adicionar Conta\",\n            \"batch_delete_title\": \"Confirmação de Exclusão em Lote\",\n            \"delete_title\": \"Confirmação de Exclusão\",\n            \"batch_delete_msg\": \"Tem certeza de que deseja excluir as {{count}} contas selecionadas? Esta ação não pode ser desfeita.\",\n            \"delete_msg\": \"Tem certeza de que deseja excluir esta conta? Esta ação não pode ser desfeita.\",\n            \"refresh_title\": \"Atualizar Cota\",\n            \"batch_refresh_title\": \"Atualização em Lote\",\n            \"refresh_msg\": \"Tem certeza de que deseja atualizar a cota da conta atual?\",\n            \"batch_refresh_msg\": \"Tem certeza de que deseja atualizar as cotas das {{count}} contas selecionadas? Isso pode levar algum tempo.\",\n            \"disable_proxy_title\": \"Desabilitar Proxy\",\n            \"disable_proxy_msg\": \"Tem certeza de que deseja desabilitar o proxy para esta conta? A conta permanecerá utilizável no aplicativo.\",\n            \"enable_proxy_title\": \"Habilitar Proxy\",\n            \"enable_proxy_msg\": \"Tem certeza de que deseja reabilitar o proxy para esta conta?\",\n            \"warmup_all_title\": \"Aquecimento Manual Completo\",\n            \"warmup_all_msg\": \"Tem certeza de que deseja acionar tarefas de aquecimento para todas as contas elegíveis imediatamente? Isso enviará tráfego mínimo para os serviços do Google para redefinir ciclos de cota.\",\n            \"batch_warmup_title\": \"Aquecimento Manual em Lote\",\n            \"batch_warmup_msg\": \"Tem certeza de que deseja acionar o aquecimento para as {{count}} contas selecionadas imediatamente?\"\n        }\n    },\n    \"settings\": {\n        \"save\": \"Salvar Configurações\",\n        \"tabs\": {\n            \"general\": \"Geral\",\n            \"account\": \"Conta\",\n            \"proxy\": \"Configurações do Proxy\",\n            \"advanced\": \"Avançado\",\n            \"about\": \"Sobre\",\n            \"debug\": \"Depuração\"\n        },\n        \"general\": {\n            \"title\": \"Configurações Gerais\",\n            \"language\": \"Idioma\",\n            \"theme\": \"Tema\",\n            \"theme_light\": \"Claro\",\n            \"theme_dark\": \"Escuro\",\n            \"theme_system\": \"Sistema\",\n            \"auto_launch\": \"Iniciar na Inicialização\",\n            \"auto_launch_enabled\": \"Habilitado\",\n            \"auto_launch_disabled\": \"Desabilitado\",\n            \"auto_launch_desc\": \"Iniciar automaticamente o Antigravity Tools quando o sistema iniciar\",\n            \"auto_check_update\": \"Verificar Atualizações Automaticamente\",\n            \"auto_check_update_desc\": \"Verificar automaticamente novas versões na inicialização\",\n            \"auto_check_update_enabled\": \"Verificação automática habilitada\",\n            \"auto_check_update_disabled\": \"Verificação automática desabilitada\",\n            \"update_check_interval\": \"Intervalo de Verificação (horas)\",\n            \"update_check_interval_desc\": \"Definir intervalo de verificação automática (1-168 horas)\",\n            \"update_check_interval_saved\": \"Configurações de intervalo de verificação salvas\"\n        },\n        \"account\": {\n            \"title\": \"Configurações de Conta\",\n            \"auto_refresh\": \"Atualização Automática em Segundo Plano\",\n            \"auto_refresh_desc\": \"Atualizar automaticamente todas as cotas de conta em segundo plano. Isso é necessário para proteção de cota e aquecimento inteligente.\",\n            \"always_on\": \"Sempre Ativo\",\n            \"refresh_interval\": \"Intervalo de Atualização (minutos)\",\n            \"auto_sync\": \"Sincronização Automática da Conta Atual\",\n            \"auto_sync_desc\": \"Sincronizar automaticamente as informações da conta ativa atual periodicamente\",\n            \"sync_interval\": \"Intervalo de Sincronização (segundos)\"\n        },\n        \"warmup\": {\n            \"title\": \"Aquecimento Inteligente\",\n            \"desc\": \"Monitora automaticamente todos os modelos e aciona o aquecimento imediatamente quando a cota atinge 100%, mantendo os modelos aquecidos\"\n        },\n        \"quota_protection\": {\n            \"title\": \"Proteção de Cota\",\n            \"enable\": \"Habilitar Proteção de Cota\",\n            \"enable_desc\": \"Desabilitar automaticamente o proxy quando a cota da conta cair abaixo do limite e restaurar automaticamente quando a cota for redefinida\",\n            \"threshold_label\": \"Percentual de Cota Reservada\",\n            \"monitored_models_label\": \"Modelos Monitorados (Condições de Acionamento)\",\n            \"monitored_models_desc\": \"Selecione pelo menos um. A proteção é acionada se QUALQUER modelo selecionado cair abaixo do limite\",\n            \"range\": \"Intervalo\",\n            \"example\": \"Exemplo: Em {{percentage}}%, uma conta com {{total}} de cota será desabilitada quando restante ≤ {{threshold}}\",\n            \"auto_restore_info\": \"A conta será automaticamente reabilitada quando a cota for redefinida\"\n        },\n        \"pinned_quota_models\": {\n            \"title\": \"Modelos de Cota Fixados\",\n            \"desc\": \"Selecione quais cotas de modelo exibir na lista de contas. Modelos não selecionados são mostrados apenas no pop-up de detalhes\"\n        },\n        \"proxy\": {\n            \"title\": \"Configurações do Proxy\"\n        },\n        \"proxy_pool\": {\n            \"title\": \"Proxy Pool\",\n            \"strategy_priority\": \"Priority\",\n            \"strategy_round_robin\": \"Round Robin\",\n            \"strategy_random\": \"Random\",\n            \"strategy_least_connections\": \"Least Connections\",\n            \"test_all\": \"Test All\",\n            \"batch_import\": \"Import\",\n            \"binding_manager\": \"Bindings\",\n            \"add_proxy\": \"Add Proxy\",\n            \"edit_proxy\": \"Edit Proxy\",\n            \"name\": \"Name\",\n            \"url\": \"Proxy URL\",\n            \"username\": \"Username\",\n            \"password\": \"Password\",\n            \"max_accounts\": \"Max Accounts\",\n            \"max_accounts_hint\": \"0 = Unlimited\",\n            \"priority\": \"Priority\",\n            \"priority_hint\": \"Lower is better\",\n            \"health_check_url\": \"Health Check URL\",\n            \"tags\": \"Tags\",\n            \"add_tag_placeholder\": \"Add tag...\",\n            \"seconds\": \"Sec\",\n            \"test_completed\": \"Health check completed\",\n            \"test_failed\": \"Health check failed\",\n            \"confirm_delete\": \"Are you sure you want to delete this proxy?\",\n            \"empty\": \"No proxies available\",\n            \"column_priority\": \"Priority\",\n            \"column_status\": \"Status\",\n            \"column_details\": \"Proxy Details\",\n            \"column_bindings\": \"Bindings\",\n            \"import_title\": \"Batch Import Proxies\",\n            \"import_label\": \"Paste Proxy List (One per line)\",\n            \"import_hint\": \"Supported formats: protocol://user:pass@host:port, host:port:user:pass\",\n            \"import_preview\": \"Preview\",\n            \"import_confirm\": \"Import {{count}} Proxies\",\n            \"no_valid_proxies\": \"No valid proxies found\",\n            \"binding\": {\n                \"title\": \"Account Proxy Bindings\",\n                \"load_failed\": \"Failed to load bindings\",\n                \"unbind_success\": \"Unbound successfully\",\n                \"bind_success\": \"Bound successfully\",\n                \"update_failed\": \"Failed to update binding\",\n                \"assigned_proxy\": \"Assigned Proxy\",\n                \"default_strategy\": \"Default (Follow Strategy)\"\n            },\n            \"status\": {\n                \"inactive\": \"Inactive\",\n                \"checking\": \"Checking\",\n                \"healthy\": \"Healthy\",\n                \"timeout\": \"Timeout\"\n            }\n        },\n        \"advanced\": {\n            \"title\": \"Configurações Avançadas\",\n            \"export_path\": \"Caminho de Exportação Padrão\",\n            \"export_path_placeholder\": \"Não definido (Perguntar sempre)\",\n            \"default_export_path_desc\": \"Os arquivos serão salvos diretamente nesta pasta sem perguntar\",\n            \"select_btn\": \"Selecionar\",\n            \"open_btn\": \"Abrir\",\n            \"data_dir\": \"Diretório de Dados\",\n            \"data_dir_desc\": \"Localização dos dados da conta e arquivo de configuração\",\n            \"antigravity_path\": \"Caminho do Antigravity\",\n            \"antigravity_path_placeholder\": \"Não definido (Usará detecção automática)\",\n            \"antigravity_path_desc\": \"Se você instalou o Antigravity em um local não padrão, pode especificar manualmente o caminho do executável aqui (Aponta para .app no MacOS).\",\n            \"antigravity_path_select\": \"Selecionar Executável do Antigravity\",\n            \"antigravity_path_detected\": \"Caminho detectado atualizado\",\n            \"detect_btn\": \"Detectar\",\n            \"antigravity_args\": \"Argumentos de Inicialização do Antigravity\",\n            \"antigravity_args_placeholder\": \"--user-data-dir=/path/to/data --some-other-flag\",\n            \"antigravity_args_desc\": \"Especificar argumentos de inicialização para o Antigravity, ex: --user-data-dir para especificar o diretório de dados do usuário\",\n            \"detect_args_btn\": \"Detectar\",\n            \"antigravity_args_detected\": \"Argumentos de inicialização atualizados\",\n            \"antigravity_args_detect_error\": \"Falha ao detectar argumentos de inicialização\",\n            \"accounts_page_size\": \"Tamanho da Página de Contas\",\n            \"page_size_auto\": \"Calcular Automaticamente (Recomendado)\",\n            \"page_size_desc\": \"Definir o número de contas exibidas por página. Selecione 'Calcular Automaticamente' para ajustar dinamicamente com base no tamanho da janela.\",\n            \"logs_title\": \"Manutenção de Logs\",\n            \"logs_desc\": \"Limpar arquivos de cache de log. Não afeta os dados da conta.\",\n            \"clear_logs\": \"Limpar Cache de Logs\",\n            \"clear_logs_title\": \"Confirmação de Limpeza de Logs\",\n            \"clear_logs_msg\": \"Tem certeza de que deseja limpar todos os arquivos de cache de log?\",\n            \"logs_cleared\": \"Cache de logs limpo\",\n            \"antigravity_cache_title\": \"Limpeza de Cache do Antigravity\",\n            \"antigravity_cache_desc\": \"Limpar o cache do Antigravity pode resolver falhas de login, erros de validação de versão e falhas de autorização OAuth.\",\n            \"antigravity_cache_warning\": \"Certifique-se de que o Antigravity está completamente fechado antes de limpar o cache.\",\n            \"clear_antigravity_cache\": \"Limpar Cache do Antigravity\",\n            \"clear_cache_confirm_title\": \"Confirmar Limpeza do Cache do Antigravity\",\n            \"clear_cache_confirm_msg\": \"Os seguintes diretórios de cache serão limpos:\",\n            \"cache_cleared_success\": \"Cache limpo com sucesso, {{size}} MB liberados\",\n            \"cache_not_found\": \"Diretórios de cache do Antigravity não encontrados\",\n            \"debug_logs_title\": \"Log de depuração\",\n            \"debug_logs_enable_desc\": \"Quando ativado, registra a cadeia completa de requisição e resposta. Recomenda-se ativar apenas ao solucionar problemas.\",\n            \"debug_logs_desc\": \"Registra a cadeia completa: entrada original, requisição v1internal transformada e resposta upstream. Apenas para solução de problemas, pode conter dados sensíveis.\",\n            \"debug_log_dir\": \"Diretório de saída do log de depuração\",\n            \"debug_log_dir_hint\": \"Deixe vazio para usar o diretório padrão: {{path}}/debug_logs\",\n            \"debug_log_dir_select\": \"Selecionar diretório de saída do log de depuração\",\n            \"http_api_title\": \"Serviço HTTP API\",\n            \"http_api_desc\": \"Fornece interface HTTP local para programas externos (por exemplo, plugins do VS Code).\",\n            \"http_api_enabled\": \"Habilitar HTTP API\",\n            \"http_api_enabled_desc\": \"Quando habilitado, programas externos podem gerenciar contas via interface HTTP\",\n            \"http_api_port\": \"Porta de Escuta\",\n            \"http_api_port_desc\": \"Reinício necessário após alterar a porta. Se ocorrer conflito de porta, use outra porta disponível.\",\n            \"http_api_port_placeholder\": \"Porta padrão 19527\",\n            \"http_api_port_invalid\": \"Número de porta inválido (intervalo: 1024-65535)\",\n            \"http_api_settings_saved\": \"Configurações HTTP API salvas, reinício necessário para aplicar\",\n            \"http_api_restart_required\": \"⚠️ Reinício necessário para aplicar\"\n        },\n        \"menu\": {\n            \"title\": \"Configurações de exibição do menu\",\n            \"desc\": \"Selecione os itens de função a exibir na barra de menu. Ocultar menus pouco utilizados pode economizar espaço.\",\n            \"selected_items_note\": \"Os itens selecionados serão exibidos na barra de menu superior.\",\n            \"required\": \"Obrigatório\"\n        },\n        \"about\": {\n            \"title\": \"Sobre\",\n            \"version\": \"Versão do Aplicativo\",\n            \"tech_stack\": \"Stack Tecnológico\",\n            \"author\": \"Autor\",\n            \"wechat\": \"WeChat\",\n            \"github\": \"GitHub\",\n            \"view_code\": \"Ver Código\",\n            \"copyright\": \"Copyright © 2025-2026 Antigravity. Todos os direitos reservados.\",\n            \"check_update\": \"Verificar Atualizações\",\n            \"checking_update\": \"Verificando...\",\n            \"latest_version\": \"Você está atualizado\",\n            \"new_version_available\": \"Nova versão {{version}} disponível\",\n            \"download_update\": \"Baixar\",\n            \"update_check_failed\": \"Falha na verificação de atualização\",\n            \"support_btn\": \"Apoiar Autor\",\n            \"support_title\": \"Doação e Suporte\",\n            \"support_desc\": \"Se você acha este projeto útil, sinta-se à vontade para me pagar um café! Seu apoio é a maior motivação para eu manter este projeto.\",\n            \"support_alipay\": \"Alipay\",\n            \"support_wechat\": \"WeChat Pay\",\n            \"support_buymeacoffee\": \"Buy Me a Coffee\"\n        },\n        \"advanced_thinking\": {\n            \"title\": \"Pensamento Avançado e Configuração Global\",\n            \"description\": \"Gerencie capacidades de pensamento, modos de imagem e instruções globais centralmente.\"\n        },\n        \"thinking_budget\": {\n            \"title\": \"Orçamento de Pensamento (Thinking Budget)\",\n            \"description\": \"Controla o orçamento de tokens para o pensamento profundo da IA. Alguns modelos (ex: Flash, modelos com sufixo -thinking) são limitados a 24576 pela API upstream.\",\n            \"mode_label\": \"Modo de Processamento\",\n            \"mode\": {\n                \"auto\": \"Limite Automático\",\n                \"passthrough\": \"Passagem Direta\",\n                \"custom\": \"Personalizado\"\n            },\n            \"auto_hint\": \"Modo Automático: Limita automaticamente o orçamento em 24576 para modelos Flash, modelos com sufixo -thinking e solicitações de pesquisa na web para evitar erros de API.\",\n            \"passthrough_warning\": \"Passagem Direta: Usa diretamente o valor original do chamador. A falta de suporte para valores altos pode causar falhas.\",\n            \"custom_value_hint\": \"Recomendado: 24576 (Flash) ou 51200 (Estendido)\",\n            \"tokens\": \"tokens\"\n        },\n        \"image_thinking_mode\": {\n            \"title\": \"Modo de Pensamento de Iimagem (Image Thinking Mode)\",\n            \"hint\": \"Afeta a qualidade da imagem e o processo de geração\",\n            \"options\": {\n                \"enabled\": \"Ativado\",\n                \"disabled\": \"Desativado\",\n                \"enabled_desc\": \"Ligado: Mantém a cadeia de pensamento e retorna duas imagens (esboço + final).\",\n                \"disabled_desc\": \"Desligado: Desativa a cadeia de pensamento e gera uma imagem única de alta qualidade (prioridade qualidade).\"\n            }\n        },\n        \"global_system_prompt\": {\n            \"title\": \"Instruções do Sistema Globais (Global System Prompt)\",\n            \"hint\": \"Injetado automaticamente em systemInstruction para todas as solicitações\",\n            \"placeholder\": \"Insira as instruções globais do sistema...\\nExemplo: Você é um desenvolvedor full-stack sênior especialista em React e Rust. Responda em português.\",\n            \"char_count\": \"{{count}} caracteres\",\n            \"long_prompt_warning\": \"As instruções são muito longas (mais de 2000 caracteres) e podem consumir muito espaço do contexto.\"\n        }\n    },\n    \"tray\": {\n        \"current\": \"Atual\",\n        \"quota\": \"Cota\",\n        \"switch_next\": \"Alternar para Próxima Conta\",\n        \"refresh_current\": \"Atualizar Cota Atual\",\n        \"show_window\": \"Mostrar Janela Principal\",\n        \"quit\": \"Sair do Aplicativo\",\n        \"no_account\": \"Sem Conta\",\n        \"unknown_quota\": \"Desconhecido (Clique para Atualizar)\",\n        \"forbidden\": \"Conta Proibida\"\n    },\n    \"proxy\": {\n        \"title\": \"Serviço de Proxy da API\",\n        \"status\": {\n            \"running\": \"Serviço em Execução\",\n            \"stopped\": \"Serviço Parado\",\n            \"accounts_available\": \"{{count}} Contas Disponíveis\",\n            \"processing\": \"Processando...\"\n        },\n        \"action\": {\n            \"start\": \"Iniciar Serviço\",\n            \"stop\": \"Parar Serviço\"\n        },\n        \"config\": {\n            \"title\": \"Configuração do Serviço\",\n            \"request\": {\n                \"user_agent\": \"User-Agent Override\",\n                \"user_agent_tooltip\": \"Override the User-Agent header sent to upstream APIs. Leave empty to use default.\",\n                \"user_agent_hint\": \"Current Default: antigravity/<version> <os>/<arch>\",\n                \"user_agent_placeholder\": \"Enter custom User-Agent string...\"\n            },\n            \"port\": \"Porta de Escuta\",\n            \"port_tooltip\": \"Porta TCP que o Proxy da API local escuta. Pare o serviço para alterá-la, depois reinicie para aplicar.\",\n            \"port_hint\": \"Padrão 8045, reinício necessário para aplicar alterações\",\n            \"auto_start\": \"Iniciar Automaticamente com o App\",\n            \"auto_start_tooltip\": \"Inicia automaticamente o serviço de Proxy da API local quando o aplicativo é iniciado.\",\n            \"allow_lan_access\": \"Permitir Acesso LAN\",\n            \"allow_lan_access_tooltip\": \"Quando habilitado, o serviço se vincula a 0.0.0.0 para que outros dispositivos na sua LAN possam acessá-lo. Mantenha a autorização habilitada e proteja sua chave de API; reinício necessário para aplicar.\",\n            \"allow_lan_access_hint_enabled\": \"🌐 Escutando em 0.0.0.0, dispositivos LAN podem acessar\",\n            \"allow_lan_access_hint_disabled\": \"🔒 Escutando apenas em 127.0.0.1, acesso localhost (Privacidade Primeiro)\",\n            \"allow_lan_access_warning\": \"⚠️ Dispositivos LAN podem acessar quando habilitado. Mantenha sua chave de API segura\",\n            \"allow_lan_access_restart_hint\": \"ℹ️ Reinício do serviço necessário para aplicar alterações\",\n            \"api_key\": \"Chave da API\",\n            \"api_key_tooltip\": \"Segredo compartilhado usado pelos clientes quando a autorização do proxy está habilitada. Regenerar a chave invalida imediatamente a antiga.\",\n            \"btn_regenerate\": \"Regenerar Chave\",\n            \"btn_edit\": \"Editar\",\n            \"btn_save\": \"Salvar\",\n            \"btn_copy\": \"Copiar\",\n            \"btn_copied\": \"Copiado\",\n            \"warning_key\": \"Nota: Mantenha sua chave de API segura. Não a compartilhe.\",\n            \"api_key_invalid\": \"Formato de chave de API inválido, deve começar com sk- e ter pelo menos 10 caracteres\",\n            \"api_key_updated\": \"Chave de API atualizada\",\n            \"admin_password\": \"Senha do Administrador Web UI\",\n            \"admin_password_tooltip\": \"Senha usada para fazer login no console de gerenciamento Web. Se estiver vazia, a Chave de API será usada por padrão.\",\n            \"admin_password_default\": \"(Mesmo que a Chave de API)\",\n            \"admin_password_placeholder\": \"Digite a nova senha, deixe em branco para usar a Chave de API\",\n            \"admin_password_hint\": \"Dica: Em cenários de implantação Docker/Web, você pode definir uma senha de login separada para melhorar a segurança da sua Chave de API.\",\n            \"admin_password_short\": \"Senha muito curta (mínimo de 4 caracteres)\",\n            \"admin_password_updated\": \"Senha de login Web UI atualizada\",\n            \"auth\": {\n                \"title\": \"Autorização\",\n                \"title_tooltip\": \"Controla se as solicitações recebidas devem ser autenticadas e quais rotas são protegidas.\",\n                \"enabled\": \"Habilitado\",\n                \"enabled_tooltip\": \"Liga/desliga a autorização alternando o modo de autorização. Quando habilitado, os clientes devem incluir a chave de API via Authorization: Bearer <API_KEY> ou x-api-key.\",\n                \"mode\": \"Modo\",\n                \"mode_tooltip\": \"Seleciona quais rotas exigem a chave de API: Off = sem autenticação; All = protege tudo; All except Health = /healthz permanece aberto; Auto = Off para apenas localhost, caso contrário All except Health.\",\n                \"hint\": \"Quando habilitado, os clientes devem enviar a chave de API via Authorization: Bearer ... (exceto health se selecionado).\",\n                \"modes\": {\n                    \"off\": \"Desligado (Aberto)\",\n                    \"strict\": \"Tudo (Estrito)\",\n                    \"all_except_health\": \"Tudo exceto Health\",\n                    \"auto\": \"Automático (Recomendado)\"\n                }\n            },\n            \"zai\": {\n                \"title\": \"Provedor z.ai (GLM)\",\n                \"title_tooltip\": \"Upstream compatível com Anthropic opcional para protocolo Claude. Afeta apenas endpoints Anthropic; o roteamento de conta do Google permanece inalterado.\",\n                \"subtitle\": \"Upstream compatível com Anthropic opcional apenas para protocolo Claude.\",\n                \"enabled\": \"Habilitado\",\n                \"enabled_tooltip\": \"Habilita o roteamento z.ai para solicitações Anthropic de acordo com o modo de despacho selecionado.\",\n                \"base_url\": \"URL Base\",\n                \"base_url_tooltip\": \"URL base compatível com Anthropic. O proxy anexa caminhos como /v1/messages. Deixe o padrão a menos que use um gateway personalizado.\",\n                \"dispatch_mode\": \"Modo de Despacho\",\n                \"dispatch_mode_tooltip\": \"Controla quando usar z.ai para solicitações Anthropic: Off desabilita; All Anthropic requests encaminha tudo; Pooled adiciona z.ai como um slot em round-robin com contas do Google; Fallback usa z.ai apenas quando não há contas do Google.\",\n                \"api_key\": \"Chave da API\",\n                \"api_key_tooltip\": \"Chave da API usada para autenticar solicitações para z.ai. Armazenada localmente e necessária para recursos z.ai e MCP.\",\n                \"api_key_placeholder\": \"Cole sua chave de API z.ai aqui\",\n                \"warning\": \"Nota: Esta chave é armazenada localmente no diretório de dados do aplicativo.\",\n                \"models\": {\n                    \"title\": \"Mapeamento de Modelos\",\n                    \"title_tooltip\": \"Buscar IDs de modelos z.ai disponíveis e configurar como os nomes de modelos Anthropic/Claude recebidos são traduzidos para IDs de modelos z.ai.\",\n                    \"refresh\": \"Buscar modelos\",\n                    \"refreshing\": \"Buscando...\",\n                    \"hint\": \"Modelos disponíveis: {{count}}. Selecione uma sugestão ou digite um ID de modelo personalizado.\",\n                    \"error\": \"Falha ao buscar modelos: {{error}}\",\n                    \"select_placeholder\": \"Selecionar modelo...\",\n                    \"opus\": \"Família Opus → modelo z.ai\",\n                    \"opus_tooltip\": \"ID de modelo z.ai padrão usado quando o modelo recebido contém \\\"opus\\\" (ex: claude-opus-*).\",\n                    \"sonnet\": \"Família Sonnet → modelo z.ai\",\n                    \"sonnet_tooltip\": \"ID de modelo z.ai padrão usado para outros modelos Claude (ex: claude-sonnet-* e a maioria das solicitações claude-*).\",\n                    \"haiku\": \"Família Haiku → modelo z.ai\",\n                    \"haiku_tooltip\": \"ID de modelo z.ai padrão usado quando o modelo recebido contém \\\"haiku\\\" (ex: claude-haiku-*).\",\n                    \"advanced_title\": \"Substituições avançadas\",\n                    \"advanced_tooltip\": \"Substituições de correspondência exata opcionais. Se uma string de modelo recebida corresponder a uma chave de regra, será substituída pelo ID de modelo z.ai mapeado.\",\n                    \"from_label\": \"Modelo recebido\",\n                    \"to_label\": \"Modelo z.ai\",\n                    \"add_rule\": \"Adicionar\",\n                    \"empty\": \"Nenhuma regra de substituição configurada.\",\n                    \"from_placeholder\": \"Do (ex: claude-3-opus)\",\n                    \"to_placeholder\": \"Para (ex: glm-4)\"\n                },\n                \"modes\": {\n                    \"off\": \"Desligado\",\n                    \"exclusive\": \"Todas as solicitações Anthropic\",\n                    \"pooled\": \"Em pool (um slot)\",\n                    \"fallback\": \"Apenas fallback\"\n                },\n                \"mcp\": {\n                    \"title\": \"Servidores MCP (via proxy local)\",\n                    \"title_tooltip\": \"Expõe endpoints opcionais /mcp/* neste proxy local para que clientes MCP possam se conectar. Disponível apenas quando o serviço está em execução, z.ai está configurado e os toggles correspondentes estão habilitados.\",\n                    \"enabled\": \"Habilitar proxy MCP\",\n                    \"enabled_tooltip\": \"Interruptor mestre para endpoints MCP. Quando desligado, todas as rotas /mcp/* retornam 404.\",\n                    \"web_search\": \"Pesquisa na Web\",\n                    \"web_search_tooltip\": \"Expõe /mcp/web_search_prime/mcp e encaminha solicitações para o upstream MCP Web Search do z.ai.\",\n                    \"web_reader\": \"Leitor Web\",\n                    \"web_reader_tooltip\": \"Expõe /mcp/web_reader/mcp e encaminha solicitações para o upstream MCP Web Reader do z.ai.\",\n                    \"vision\": \"Visão\",\n                    \"vision_tooltip\": \"Expõe /mcp/zai-mcp-server/mcp (servidor MCP local) que fornece ferramentas de visão apoiadas por z.ai.\",\n                    \"local_endpoints\": \"Endpoints locais (configure seu cliente MCP para usar estes URLs):\",\n                    \"local_endpoints_tooltip\": \"Use estes URLs no seu cliente MCP. Eles compartilham o mesmo host/porta do Proxy da API e seguem a política de autorização do proxy.\"\n                }\n            },\n            \"request_timeout\": \"Tempo Limite da Solicitação\",\n            \"request_timeout_tooltip\": \"Tempo máximo (segundos) que o proxy aguarda por uma resposta upstream, incluindo streaming. Aumente para gerações longas; reinício necessário para aplicar.\",\n            \"request_timeout_hint\": \"Padrão 120s, intervalo 30-7200s. Reinicie o serviço para aplicar alterações.\",\n            \"enable_logging\": \"Habilitar Registro de Solicitações\",\n            \"enable_logging_hint\": \"Registrar histórico para depuração (Custo de desempenho menor)\",\n            \"upstream_proxy\": {\n                \"title\": \"Proxy de upstream global (Global Proxy)\",\n                \"desc\": \"Quando habilitado, todas as solicitações externas (API Proxy, atualização de token, verificação de cota, verificação de atualização) serão roteadas por meio desse proxy.\",\n                \"desc_short\": \"Proxy global usado como solução de fallback quando nenhuma conta adequada é encontrada no pool de proxies.\",\n                \"enable\": \"Habilitar proxy de upstream\",\n                \"url\": \"URL do proxy\",\n                \"url_placeholder\": \"ex: http://127.0.0.1:7890 ou socks5://127.0.0.1:7890\",\n                \"tip\": \"Suporta HTTP, HTTPS e SOCKS5.\",\n                \"socks5h_hint\": \"Para evitar bloqueos e manter a resolução DNS remota (Remote DNS), altere manualmente o protocolo para socks5h://\",\n                \"validation_error\": \"A URL do proxy é necessária quando o proxy de upstream está habilitado\",\n                \"restart_hint\": \"Configurações de proxy salvas. Reinicie o aplicativo para aplicar as alterações.\"\n            },\n            \"scheduling\": {\n                \"title\": \"Rotação de Conta e Agendamento\",\n                \"title_tooltip\": \"Controla como as sessões são vinculadas a contas e como os limites de taxa são tratados.\",\n                \"subtitle\": \"Otimiza Prompt Caching e tratamento de limite de taxa para todos os protocolos (OpenAI/Gemini/Claude).\",\n                \"mode\": \"Modo de Agendamento\",\n                \"mode_tooltip\": \"Cache-First: Vincular sessão à conta, aguardar no limite de taxa (maximizar utilidade do cache); Balance: Vincular sessão, alternar conta no limite de taxa; Performance: Round-robin padrão.\",\n                \"modes\": {\n                    \"CacheFirst\": \"Cache Primeiro\",\n                    \"Balance\": \"Equilíbrio\",\n                    \"PerformanceFirst\": \"Desempenho\"\n                },\n                \"modes_desc\": {\n                    \"CacheFirst\": \"Vincula sessão à conta, aguarda precisamente se limitado (Maximiza acertos de Prompt Cache).\",\n                    \"Balance\": \"Vincula sessão, alterna automaticamente para conta disponível se limitado (Equilibra cache e disponibilidade).\",\n                    \"PerformanceFirst\": \"Sem vinculação de sessão, rotação round-robin pura (Melhor para alta concorrência).\"\n                },\n                \"max_wait\": \"Tempo Máximo de Espera (seg)\",\n                \"max_wait_tooltip\": \"Usado apenas no modo 'Cache Primeiro': aguardar em vez de alternar se o tempo de reset do limite de taxa estiver abaixo deste valor.\",\n                \"clear_bindings\": \"Limpar Vinculações de Sessão\",\n                \"clear_bindings_tooltip\": \"Redefinir todas as vinculações sessão-conta, forçando contas a serem reatribuídas na próxima solicitação.\",\n                \"circuit_breaker\": {\n                    \"title\": \"Disjuntor Adaptativo\",\n                    \"tooltip\": \"Aumenta automaticamente a duração do bloqueio para contas que falham repetidamente com esgotamento de cota. Isso evita o desperdício de chamadas de API em contas mortas, permitindo que erros transitórios se recuperem rapidamente.\",\n                    \"backoff_levels\": \"Níveis de Recuo (Segundos)\",\n                    \"level\": \"Nível {{level}}\",\n                    \"input_placeholder\": \"60, 300, 1800, 7200\",\n                    \"invalid_format\": \"Formato inválido. Use números separados por vírgula (ex: 60, 300)\",\n                    \"clear_records\": \"Limpar Todos os Registros de Limite de Taxa\"\n                }\n            },\n            \"experimental\": {\n                \"title\": \"Configurações Experimentais\",\n                \"title_tooltip\": \"Recursos exploratórios que podem ser ajustados ou removidos em versões futuras.\",\n                \"enable_usage_scaling\": \"Ativar Escalonamento de Uso\",\n                \"enable_usage_scaling_tooltip\": \"Para protocolo Claude. Ativa escalonamento agressivo quando a entrada total excede 30k tokens para evitar compressão frequente no lado do cliente em grandes contextos. Nota: O uso relatado não refletirá o faturamento real após ativar.\",\n                \"context_compression_threshold_l1\": \"Limiar de Compressão L1 (Aparagem de Ferramentas)\",\n                \"context_compression_threshold_l1_tooltip\": \"Apara registros antigos de chamadas de ferramentas para economizar espaço. Recomendado: 0.4 (40%)\",\n                \"context_compression_threshold_l2\": \"Limiar de Compressão L2 (Compressão de Pensamento)\",\n                \"context_compression_threshold_l2_tooltip\": \"Comprime blocos de pensamento iniciais mantendo as assinaturas. Recomendado: 0.55 (55%)\",\n                \"context_compression_threshold_l3\": \"Limiar de Compressão L3 (Pivô de Resumo)\",\n                \"context_compression_threshold_l3_tooltip\": \"Gera um resumo de estado XML e pivota para uma nova sessão. Recomendado: 0.7 (70%)\"\n            },\n            \"cloudflared\": {\n                \"title\": \"Acesso Público (Cloudflared)\",\n                \"subtitle\": \"Exponha seu serviço local à internet via Cloudflare Tunnel\",\n                \"not_installed\": \"Cloudflared não instalado\",\n                \"install_hint\": \"Cloudflared é uma ferramenta de túnel gratuita da Cloudflare. Expõe seu proxy local à internet sem IP público ou encaminhamento de porta. Clique no botão abaixo para instalar.\",\n                \"install\": \"Instalar Agora\",\n                \"installing\": \"Instalando...\",\n                \"install_success\": \"Cloudflared instalado com sucesso\",\n                \"install_failed\": \"Instalação falhou: {{error}}\",\n                \"installed\": \"Instalado\",\n                \"version\": \"Versão\",\n                \"mode_label\": \"Modo de Túnel\",\n                \"mode_quick\": \"Túnel Rápido\",\n                \"mode_quick_desc\": \"URL temporária gerada automaticamente (*.trycloudflare.com), sem necessidade de conta, URL muda ao reiniciar\",\n                \"mode_auth\": \"Túnel Nomeado\",\n                \"mode_auth_desc\": \"Use token de conta Cloudflare, suporta domínio personalizado, URL persistente\",\n                \"token\": \"Token do Túnel\",\n                \"token_placeholder\": \"Cole seu Cloudflare Tunnel Token aqui\",\n                \"token_hint\": \"Obtenha no painel Cloudflare Zero Trust\",\n                \"token_required\": \"Token é necessário para modo de Túnel Nomeado\",\n                \"use_http2\": \"Usar HTTP/2\",\n                \"use_http2_desc\": \"Mais compatível, recomendado para China continental\",\n                \"status_label\": \"Status do Túnel\",\n                \"status_stopped\": \"Parado\",\n                \"status_starting\": \"Iniciando...\",\n                \"status_running\": \"Executando\",\n                \"status_error\": \"Erro\",\n                \"public_url\": \"URL Pública\",\n                \"public_url_copied\": \"URL copiada\",\n                \"start_tunnel\": \"Iniciar Túnel\",\n                \"stop_tunnel\": \"Parar Túnel\",\n                \"restart_tunnel\": \"Reiniciar Túnel\",\n                \"starting\": \"Iniciando...\",\n                \"stopping\": \"Parando...\",\n                \"start_success\": \"Túnel iniciado com sucesso\",\n                \"stop_success\": \"Túnel parado\",\n                \"start_failed\": \"Falha ao iniciar túnel: {{error}}\",\n                \"stop_failed\": \"Falha ao parar túnel: {{error}}\",\n                \"logs\": \"Logs\",\n                \"clear_logs\": \"Limpar Logs\",\n                \"auto_start\": \"Iniciar automaticamente com proxy\",\n                \"auto_start_desc\": \"Inicia automaticamente o túnel quando o serviço de proxy API inicia\",\n                \"warning_quick_mode\": \"⚠️ Modo Rápido: URL muda a cada reinicialização\",\n                \"warning_token_storage\": \"💡 Token é armazenado localmente com segurança\"\n            }\n        },\n        \"example\": {\n            \"title\": \"Exemplos de Uso\",\n            \"curl\": \"cURL\",\n            \"python\": \"Python\",\n            \"python_anthropic\": \"from anthropic import Anthropic\\n\\nclient = Anthropic(\\n    # Recomendado: 127.0.0.1\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\n# Nota: Antigravity suporta chamar qualquer modelo via SDK Anthropic\\nresponse = client.messages.create(\\n    model=\\\"{{modelId}}\\\",\\n    max_tokens=1024,\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hello\\\"}]\\n)\\n\\nprint(response.content[0].text)\",\n            \"python_gemini\": \"# Instalar: pip install google-generativeai\\nimport google.generativeai as genai\\n\\n# Usar endereço proxy Antigravity (recomendado 127.0.0.1)\\ngenai.configure(\\n    api_key=\\\"{{apiKey}}\\\",\\n    transport='rest',\\n    client_options={'api_endpoint': '{{rawBaseUrl}}'}\\n)\\n\\nmodel = genai.GenerativeModel('{{modelId}}')\\nresponse = model.generate_content(\\\"Hello\\\")\\nprint(response.text)\",\n            \"python_openai_image\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    # Opção 1: usar tamanho (recomendado)\\n    # Suportado: \\\"1024x1024\\\" (1:1), \\\"1280x720\\\" (16:9), \\\"720x1280\\\" (9:16), \\\"1216x896\\\" (4:3)\\n    extra_body={ \\\"size\\\": \\\"1024x1024\\\" },\\n\\n    # Opção 2: usar sufixo do modelo\\n    # ex: gemini-3-pro-image-16-9, gemini-3-pro-image-4-3\\n    # model=\\\"gemini-3-pro-image-16-9\\\",\\n    messages=[{\\n        \\\"role\\\": \\\"user\\\",\\n        \\\"content\\\": \\\"Desenhe uma cidade futurista\\\"\\n    }]\\n)\\n\\nprint(response.choices[0].message.content)\",\n            \"python_openai\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hello\\\"}]\\n)\\n\\nprint(response.choices[0].message.content)\"\n        },\n        \"examples\": {\n            \"title\": \"Exemplos de Uso\"\n        },\n        \"dialog\": {\n            \"confirm_regenerate\": \"Tem certeza de regenerar a Chave da API? A chave antiga será invalidada imediatamente.\",\n            \"operate_failed\": \"Operação falhou: {{error}}\",\n            \"reset_mapping_title\": \"Redefinir Mapeamento de Modelos\",\n            \"reset_mapping_msg\": \"Tem certeza de que deseja redefinir todos os mapeamentos de modelos para os padrões do sistema? Esta ação não pode ser desfeita.\",\n            \"regenerate_key_title\": \"Regenerar Chave da API\",\n            \"regenerate_key_msg\": \"Tem certeza de que deseja regenerar a Chave da API? A chave antiga será invalidada imediatamente.\",\n            \"clear_bindings_title\": \"Limpar Vinculações de Sessão\",\n            \"clear_bindings_msg\": \"Tem certeza de que deseja limpar todas as vinculações sessão-conta?\"\n        },\n        \"model\": {\n            \"flash\": \"Resposta Rápida\",\n            \"flash_preview\": \"Visualização Flash\",\n            \"flash_lite\": \"Leve e Rápido\",\n            \"flash_thinking\": \"Capacidade de Pensamento\",\n            \"pro_legacy\": \"Pro Legado\",\n            \"pro_low\": \"Alto Desempenho\",\n            \"pro_high\": \"Melhor Raciocínio\",\n            \"pro_image\": \"Geração de Imagem (1:1)\",\n            \"pro_image_16_9\": \"Geração de Imagem (16:9)\",\n            \"pro_image_9_16\": \"Geração de Imagem (9:16)\",\n            \"pro_image_4_3\": \"Geração de Imagem (4:3)\",\n            \"pro_image_3_4\": \"Geração de Imagem (3:4)\",\n            \"pro_image_1_1\": \"Geração de Imagem (1:1)\",\n            \"claude_sonnet\": \"Raciocínio de Código\",\n            \"claude_sonnet_thinking\": \"Cadeia de Pensamento\",\n            \"claude_opus_thinking\": \"Pensamento Mais Forte\"\n        },\n        \"mapping\": {\n            \"title\": \"Mapeamento de Modelos Claude Code\",\n            \"description\": \"Mapear modelos Claude Code para modelos Antigravity. Otimize custo e velocidade roteando solicitações inteligentemente.\",\n            \"default\": \"Padrão\",\n            \"sonnet_desc\": \"Mais capaz para trabalho complexo\",\n            \"opus_desc\": \"Nível premium\",\n            \"haiku_desc\": \"Mais rápido para respostas rápidas\",\n            \"maps_to\": \"Mapeia para Antigravity\",\n            \"apply_recommended\": \"Aplicar Recomendado\",\n            \"restore_defaults\": \"Restaurar Configuração Padrão\",\n            \"reset_all\": \"Redefinir Tudo\"\n        },\n        \"router\": {\n            \"title\": \"Roteador de Modelos\",\n            \"subtitle\": \"Rotear modelos por série ou adicionar mapeamentos exatos personalizados.\\nNota: Modelos de passagem nativa Claude (ex: claude-opus-4-6-thinking) ignoram grupos de série por padrão. Use \\\"Roteamento Personalizado Especialista\\\" para substituir.\",\n            \"subtitle_simple\": \"Personalizar roteamento de modelos com curingas ou mapeamentos exatos\",\n            \"background_task_title\": \"Modelo de Tarefa em Segundo Plano\",\n            \"background_task_desc\": \"Modelo usado para tarefas em segundo plano do Claude CLI, como geração de título, resumo, etc. (Padrão: gemini-2.5-flash)\",\n            \"use_default\": \"Usar Padrão do Sistema\",\n            \"current_model\": \"Modelo Atual\",\n            \"apply_presets\": \"Aplicar Predefinições\",\n            \"presets_applied\": \"Predefinições aplicadas com sucesso\",\n            \"custom_mappings\": \"Mapeamentos Personalizados\",\n            \"group_title\": \"Grupos de Séries\",\n            \"groups\": {\n                \"claude_45\": {\n                    \"name\": \"Série Claude 4.6 TK\",\n                    \"desc\": \"Opus 4.5 TK\"\n                },\n                \"claude_35\": {\n                    \"name\": \"Série Claude 3.5\",\n                    \"desc\": \"Sonnet 3.5, Haiku 3.5\"\n                },\n                \"gpt_4\": {\n                    \"name\": \"Série GPT-4 / o1\",\n                    \"desc\": \"GPT-4, Turbo, o1-preview\"\n                },\n                \"gpt_4o\": {\n                    \"name\": \"Série GPT-4o / 3.5\",\n                    \"desc\": \"GPT-4o, Mini, 3.5 Turbo\"\n                },\n                \"gpt_5\": {\n                    \"name\": \"Série GPT-5\",\n                    \"desc\": \"GPT-5.1, GPT-5.2 xhigh\"\n                }\n            },\n            \"expert_title\": \"Roteamento Personalizado Especialista\",\n            \"expert_subtitle\": \"Correspondência precisa para qualquer ID de modelo original.\",\n            \"custom_mapping_tip\": \"💡 Permite inserir manualmente qualquer ID de modelo para experimentar modelos não lançados (ex: claude-opus-4-6).\",\n            \"custom_mapping_warning\": \"Nota: Nem todas as contas suportam modelos não lançados.\",\n            \"money_saving_tip\": \"💰 Dica de economia:\",\n            \"haiku_optimization_tip\": \"Claude CLI usa {{model}} para tarefas em segundo plano por padrão. Mapeie para um modelo Flash mais barato para economizar ~95% dos custos\",\n            \"haiku_optimization_btn\": \"Otimizar Rapidamente\",\n            \"haiku_tip_title\": \"💰 Dica de economia:\",\n            \"haiku_tip_body_before\": \"Claude CLI usa por padrão\",\n            \"haiku_tip_body_after\": \"para tarefas em segundo plano; mapear para um modelo Flash mais barato pode economizar cerca de 95% do custo.\",\n            \"haiku_tip_action\": \"Otimizar\",\n            \"reset_confirm\": \"Redefinir todos os mapeamentos para os padrões do sistema?\",\n            \"reset_mapping\": \"Redefinir Mapeamento\",\n            \"add_mapping\": \"Adicionar Mapeamento\",\n            \"current_list\": \"Lista Personalizada\",\n            \"no_custom_mapping\": \"Ainda não há mapeamentos personalizados\",\n            \"gemini3_only_warning\": \"⚠️ Apenas série Gemini 3\",\n            \"default_suffix\": \" (Padrão)\",\n            \"original_id\": \"ID Original\",\n            \"route_to\": \"Rotear Para\",\n            \"select_target_model\": \"Selecionar Modelo de Destino\",\n            \"original_placeholder\": \"Original (ex: gpt-4 ou gpt-4*)\"\n        },\n        \"multi_protocol\": {\n            \"title\": \"Suporte Multi-Protocolo\",\n            \"subtitle\": \"Sincronize API e chaves com suas ferramentas\",\n            \"description\": \"O proxy suporta protocolos OpenAI, Anthropic e Gemini para fácil integração.\",\n            \"openai_label\": \"OpenAI\",\n            \"anthropic_label\": \"Anthropic\",\n            \"openai_tools\": \"Cherry Studio, NextChat\",\n            \"anthropic_tools\": \"Claude Code CLI\",\n            \"gemini_label\": \"Gemini\",\n            \"gemini_tools\": \"Google AI SDK, LangChain\",\n            \"quick_integration\": \"Integração Rápida\",\n            \"click_tip\": \"👆 Clique no modelo para atualizar o código\",\n            \"copy_base\": \"Copiar Base\"\n        },\n        \"cli_sync\": {\n            \"title\": \"Sincronização de CLI\",\n            \"subtitle\": \"Sincronize a URL e chave da API com suas ferramentas de CLI de IA\",\n            \"card_title\": \"Config {{name}}\",\n            \"status\": {\n                \"not_installed\": \"Não instalado\",\n                \"installed\": \"v{{version}}\",\n                \"synced\": \"Apontado para este app\",\n                \"not_synced\": \"Não sincronizado\",\n                \"detecting\": \"Detectando...\",\n                \"current_base_url\": \"URL Base Atual\"\n            },\n            \"btn_sync\": \"Sincronizar Agora\",\n            \"btn_view\": \"Ver Config\",\n            \"btn_restore\": \"Restaurar Padrão\",\n            \"btn_restore_backup\": \"Restaurar Backup\",\n            \"restore_backup_confirm\": \"Configuração de backup encontrada. Tem certeza de que deseja restaurá-la?\",\n            \"sync_confirm_title\": \"Confirmação de Sincronização\",\n            \"sync_confirm_message\": \"Pronto para sincronizar a configuração do {{name}}. ⚠️ Aviso: Isso substituirá seus arquivos de configuração local existentes (como tokens de login, chaves de API). Tem certeza de que deseja continuar?\",\n            \"restore_confirm\": \"Tem certeza que deseja restaurar a config de {{name}} para o padrão?\",\n            \"modal\": {\n                \"view_title\": \"Conteúdo do arquivo {{name}}\",\n                \"copy_success\": \"Copiado para a área de transferência\"\n            },\n            \"toast\": {\n                \"config_missing\": \"Gere uma chave de API e inicie o serviço primeiro\",\n                \"sync_success\": \"Sucesso! {{name}} está pronto.\",\n                \"sync_error\": \"Erro ao sincronizar {{name}}: {{error}}\"\n            }\n        },\n        \"supported_models\": {\n            \"title\": \"Modelos Suportados e Integração\",\n            \"model_name\": \"Nome do Modelo\",\n            \"model_id\": \"ID do Modelo\",\n            \"description\": \"Descrição\",\n            \"action\": \"Ação\"\n        }\n    },\n    \"monitor\": {\n        \"page_title\": \"Painel de Monitoramento da API\",\n        \"page_subtitle\": \"Registro e análise de solicitações em tempo real\",\n        \"open_monitor\": \"Abrir Monitor\",\n        \"logging_status\": {\n            \"active\": \"Gravando\",\n            \"paused\": \"Pausado\"\n        },\n        \"stats\": {\n            \"total\": \"Total\",\n            \"ok\": \"OK\",\n            \"err\": \"ERR\"\n        },\n        \"filters\": {\n            \"placeholder\": \"Filtrar por modelo, caminho ou status...\",\n            \"quick_filters\": \"Filtros Rápidos:\",\n            \"all\": \"Todos\",\n            \"error\": \"Erro\",\n            \"chat\": \"Chat\",\n            \"gemini\": \"Gemini\",\n            \"claude\": \"Claude\",\n            \"images\": \"Imagens\",\n            \"reset\": \"Redefinir\",\n            \"by_account\": \"Filtrar por conta\",\n            \"all_accounts\": \"Todas as Contas\"\n        },\n        \"table\": {\n            \"status\": \"Status\",\n            \"method\": \"Método\",\n            \"model\": \"Modelo\",\n            \"protocol\": \"Protocolo\",\n            \"account\": \"Conta\",\n            \"path\": \"Caminho\",\n            \"usage\": \"Tokens\",\n            \"duration\": \"Duração\",\n            \"time\": \"Tempo\",\n            \"empty\": \"Nenhuma solicitação registrada\"\n        },\n        \"details\": {\n            \"title\": \"Detalhes da Solicitação\",\n            \"request_payload\": \"Carga da Solicitação\",\n            \"response_payload\": \"Carga da Resposta\",\n            \"duration\": \"Duração\",\n            \"tokens\": \"Tokens (E/S)\",\n            \"time\": \"Tempo\",\n            \"model\": \"Modelo\",\n            \"id\": \"ID da Solicitação\",\n            \"protocol\": \"Protocolo\",\n            \"mapped_model\": \"Modelo Mapeado\",\n            \"account_used\": \"Conta Utilizada\",\n            \"payload_empty\": \"Sem Carga\"\n        },\n        \"dialog\": {\n            \"clear_title\": \"Limpar Logs do Proxy\",\n            \"clear_msg\": \"Tem certeza de que deseja limpar todos os logs do proxy? Esta ação não pode ser desfeita.\"\n        }\n    },\n    \"update_notification\": {\n        \"title\": \"Nova versão disponível\",\n        \"message\": \"Uma nova versão está pronta com otimizações e melhorias. Atual: v{{current}}\",\n        \"ready\": \"Atualização pronta\",\n        \"downloading\": \"Baixando atualização...\",\n        \"restarting\": \"Reiniciando o aplicativo...\",\n        \"auto_update\": \"Atualização automática\",\n        \"toast\": {\n            \"not_ready\": \"O pacote de atualização automática não está pronto, redirecionando para a página de download...\",\n            \"failed\": \"A atualização automática falhou, redirecionando para a página de download...\"\n        }\n    },\n    \"login\": {\n        \"title\": \"Controle de Acesso Seguro\",\n        \"desc\": \"Executando no modo Web. Por favor, insira a senha de administrador ou a Chave de API para acessar.\",\n        \"placeholder\": \"Insira a senha de administrador ou a Chave de API\",\n        \"btn_login\": \"Verificar e Entrar\",\n        \"note\": \"Nota: Se uma senha de administração separada estiver definida, insira-a; caso contrário, insira a API_KEY.\",\n        \"lookup_hint\": \"Se esqueceu, execute docker logs antigravity-manager para encontrar a Chave de API atual ou a senha da Web UI.\",\n        \"config_hint\": \"Ou execute grep -E '\\\"api_key\\\"|\\\"admin_password\\\"' ~/.antigravity_tools/gui_config.json para visualizar.\"\n    },\n    \"errors\": {\n        \"stream\": {\n            \"timeout_error\": \"Tempo limite da solicitação, por favor verifique sua conexão de rede\",\n            \"connection_error\": \"Falha na conexão, por favor verifique sua rede ou configurações de proxy\",\n            \"decode_error\": \"Rede instável, transmissão de dados interrompida. Tente: 1) Verificar rede 2) Alternar proxy 3) Tentar novamente\",\n            \"stream_error\": \"Erro na transmissão de stream, por favor tente novamente mais tarde\",\n            \"unknown_error\": \"Erro desconhecido ocorreu, por favor tente novamente mais tarde\"\n        }\n    },\n    \"token_stats\": {\n        \"title\": \"Estatísticas de Consumo de Tokens\",\n        \"hourly\": \"Hora\",\n        \"daily\": \"Dia\",\n        \"weekly\": \"Semana\",\n        \"total_tokens\": \"Total de Tokens\",\n        \"input_tokens\": \"Tokens de Entrada\",\n        \"output_tokens\": \"Tokens de Saída\",\n        \"accounts_used\": \"Contas Ativas\",\n        \"models_used\": \"Modelos Usados\",\n        \"model_trend\": \"Tendência de Uso por Modelo\",\n        \"account_trend\": \"Tendência de Uso por Conta\",\n        \"usage_trend\": \"Tendência de Uso de Tokens\",\n        \"by_account\": \"Por Conta\",\n        \"by_model\": \"Por Modelo\",\n        \"by_account_view\": \"Por Conta\",\n        \"model_details\": \"Detalhes por Modelo\",\n        \"account_details\": \"Detalhes por Conta\",\n        \"model\": \"Modelo\",\n        \"account\": \"Conta\",\n        \"requests\": \"Requisições\",\n        \"input\": \"Entrada\",\n        \"output\": \"Saída\",\n        \"total\": \"Total\",\n        \"percentage\": \"Porcentagem\",\n        \"no_data\": \"Sem dados\"\n    },\n    \"security\": {\n        \"title\": \"Monitor de Segurança\",\n        \"refresh_data\": \"Atualizar Dados\",\n        \"refresh\": \"Atualizar\",\n        \"tab_logs\": \"Logs de Acesso\",\n        \"tab_stats\": \"Estatísticas\",\n        \"tab_blacklist\": \"Lista Negra\",\n        \"tab_whitelist\": \"Lista Branca\",\n        \"tab_config\": \"Configurações\",\n        \"stats\": {\n            \"total_requests\": \"Total de Requisições\",\n            \"total_requests_desc\": \"Todas as requisições registradas\",\n            \"unique_ips\": \"IPs Únicos\",\n            \"unique_ips_desc\": \"Endereços IP distintos\",\n            \"blocked_requests\": \"Requisições Bloqueadas\",\n            \"blocked_requests_desc\": \"Requisições rejeitadas por regras\",\n            \"ip_activity_token_usage\": \"Atividade de IP e Uso de Tokens\",\n            \"hour\": \"Hr\",\n            \"day\": \"Dia\",\n            \"week\": \"Sem\",\n            \"month\": \"Mês\",\n            \"rank\": \"Rank\",\n            \"ip_address\": \"Endereço IP\",\n            \"activity_reqs\": \"Atividade (Reqs)\",\n            \"total_token\": \"Total de Tokens\",\n            \"prompt\": \"Prompt\",\n            \"completion\": \"Conclusão\",\n            \"no_data\": \"Sem dados\"\n        },\n        \"logs\": {\n            \"search_placeholder\": \"Buscar IP, Caminho, User Agent...\",\n            \"show_blocked_only\": \"Exibir Apenas Bloqueados\",\n            \"status\": \"Status\",\n            \"ip_address\": \"Endereço IP\",\n            \"method\": \"Método\",\n            \"path\": \"Caminho\",\n            \"duration\": \"Duração\",\n            \"time\": \"Tempo\",\n            \"reason\": \"Motivo\",\n            \"blocked\": \"Bloqueado\",\n            \"no_logs\": \"Sem logs\",\n            \"total_records\": \"Total de {{total}} registros\",\n            \"prev_page\": \"Anterior\",\n            \"next_page\": \"Próximo\",\n            \"page_num\": \"Página {{page}}\",\n            \"per_page_suffix\": \"/pág\"\n        },\n        \"blacklist\": {\n            \"add_ip\": \"Adicionar IP\",\n            \"search_placeholder\": \"Buscar...\",\n            \"added_at\": \"Adicionado em\",\n            \"expires_at\": \"Expira em\",\n            \"no_data\": \"Sem dados na lista negra\",\n            \"add_title\": \"Adicionar à Lista Negra\",\n            \"ip_cidr_label\": \"Endereço IP ou CIDR\",\n            \"ip_cidr_placeholder\": \"ex: 192.168.1.1 ou 10.0.0.0/24\",\n            \"reason_label\": \"Motivo (Opcional)\",\n            \"reason_placeholder\": \"ex: Varredura maliciosa\",\n            \"expires_label\": \"Expira em (Horas, Opcional)\",\n            \"expires_placeholder\": \"Deixe vazio para permanente\",\n            \"cancel\": \"Cancelar\",\n            \"confirm\": \"Adicionar\",\n            \"add_btn\": \"Adicionar\"\n        },\n        \"whitelist\": {\n            \"add_ip\": \"Adicionar IP Confiável\",\n            \"no_data\": \"Sem dados na lista branca\",\n            \"add_title\": \"Adicionar à Lista Branca\",\n            \"description_label\": \"Descrição (Opcional)\",\n            \"description_placeholder\": \"ex: Servidor Interno\",\n            \"cancel\": \"Cancelar\",\n            \"confirm\": \"Adicionar\",\n            \"add_btn\": \"Adicionar\"\n        },\n        \"config\": {\n            \"title\": \"Configurações de Segurança\",\n            \"save\": \"Salvar Alterações\",\n            \"saving\": \"Salvando...\",\n            \"blacklist_title\": \"Lista Negra de IP\",\n            \"blacklist_desc\": \"Gerenciar endereços IP e regras bloqueadas.\",\n            \"enable_blacklist\": \"Habilitar Proteção de Lista Negra\",\n            \"block_msg_label\": \"Mensagem de Bloqueio Personalizada\",\n            \"block_msg_desc\": \"Conteúdo retornado para clientes bloqueados.\",\n            \"whitelist_title\": \"Lista Branca de IP\",\n            \"whitelist_desc\": \"Gerenciar endereços IP confiáveis.\",\n            \"enable_whitelist\": \"Habilitar Modo Lista Branca\",\n            \"whitelist_warning\": \"Aviso: Habilitar o modo lista branca bloqueará TODAS as requisições de IPs que não estejam na lista branca. Se estiver acessando via proxy, tenha cuidado para não se bloquear.\",\n            \"whitelist_priority\": \"Prioridade da Lista Branca (Substitui Lista Negra)\",\n            \"whitelist_priority_desc\": \"Se habilitado, IPs na lista branca serão permitidos mesmo que correspondam a regras da lista negra.\",\n            \"load_error\": \"Falha ao carregar configurações\",\n            \"save_success\": \"Configuração salva\",\n            \"save_error\": \"Falha ao salvar configuração\"\n        }\n    },\n    \"user_token\": {\n        \"title\": \"Gestão de Tokens de Usuário\",\n        \"total_users\": \"Total de Usuários\",\n        \"active_tokens\": \"Tokens Ativos\",\n        \"total_created\": \"Total Criados\",\n        \"create\": \"Criar Token\",\n        \"username\": \"Nome de usuário\",\n        \"token\": \"Token\",\n        \"expires\": \"Expira em\",\n        \"usage\": \"Uso\",\n        \"ip_limit\": \"Limite de IP\",\n        \"created\": \"Criado em\",\n        \"today_requests\": \"Solicitações de Hoje\",\n        \"never\": \"Nunca\",\n        \"renew\": \"Renovar\",\n        \"renew_button\": \"Renovar\",\n        \"unlimited\": \"Ilimitado\",\n        \"create_title\": \"Criar Novo Token\",\n        \"description\": \"Descrição\",\n        \"curfew\": \"Toque de recolher (Horário indisponível)\",\n        \"edit_title\": \"Editar Token\",\n        \"username_required\": \"Nome de usuário obrigatório\",\n        \"renew_success\": \"Renovado com sucesso\",\n        \"expires_day\": \"1 Dia\",\n        \"expires_week\": \"1 Semana\",\n        \"expires_month\": \"1 Mês\",\n        \"expires_never\": \"Nunca\",\n        \"no_data\": \"Nenhum token encontrado\",\n        \"placeholder_username\": \"ex: user1\",\n        \"placeholder_desc\": \"Notas opcionais\",\n        \"placeholder_max_ips\": \"0 = Ilimitado\",\n        \"hint_max_ips\": \"0 significa ilimitado\",\n        \"hint_curfew\": \"Deixe em branco para desativar. Baseado na hora do servidor.\"\n    }\n}"
  },
  {
    "path": "src/locales/ru.json",
    "content": "{\n    \"common\": {\n        \"empty\": \"Пусто\",\n        \"loading\": \"Загрузка...\",\n        \"load_more\": \"Загрузить еще\",\n        \"add\": \"Добавить\",\n        \"copy\": \"Копировать\",\n        \"action\": \"Действие\",\n        \"save\": \"Сохранить\",\n        \"saved\": \"Успешно сохранено\",\n        \"cancel\": \"Отмена\",\n        \"confirm\": \"Подтвердить\",\n        \"close\": \"Закрыть\",\n        \"delete\": \"Удалить\",\n        \"edit\": \"Редактировать\",\n        \"refresh\": \"Обновить\",\n        \"refreshing\": \"Обновление...\",\n        \"export\": \"Экспорт\",\n        \"import\": \"Импорт\",\n        \"success\": \"Успех\",\n        \"error\": \"Ошибка\",\n        \"unknown\": \"Неизвестно\",\n        \"warning\": \"Предупреждение\",\n        \"info\": \"Информация\",\n        \"details\": \"Детали\",\n        \"example\": \"Example\",\n        \"clear\": \"Очистить\",\n        \"clearing\": \"Очистка...\",\n        \"prev_page\": \"Предыдущая\",\n        \"next_page\": \"Следующая\",\n        \"pagination_info\": \"Показано {{start}}-{{end}} из {{total}} записей\",\n        \"per_page\": \"На странице\",\n        \"items\": \"элементов\",\n        \"accounts\": \"аккаунтов\",\n        \"enabled\": \"Включено\",\n        \"disabled\": \"Отключено\",\n        \"tauri_api_not_loaded\": \"API Tauri не загружен, пожалуйста перезапустите приложение\",\n        \"environment_error\": \"Ошибка окружения: {{error}}\",\n        \"submit\": \"Отправить\",\n        \"update\": \"Обновить\",\n        \"load_failed\": \"Ошибка загрузки\",\n        \"create_success\": \"Успешно создано\",\n        \"update_success\": \"Успешно обновлено\",\n        \"delete_success\": \"Успешно удалено\",\n        \"copied\": \"Скопировано в буфер обмена\"\n    },\n    \"nav\": {\n        \"dashboard\": \"Дашборд\",\n        \"accounts\": \"Аккаунты\",\n        \"proxy\": \"API Прокси\",\n        \"call_records\": \"Логи трафика\",\n        \"security\": \"Управление IP\",\n        \"token_stats\": \"Статистика\",\n        \"settings\": \"Настройки\",\n        \"theme_to_dark\": \"Переключить на темную тему\",\n        \"theme_to_light\": \"Переключить на светлую тему\",\n        \"switch_to_english\": \"Переключить на английский\",\n        \"switch_to_chinese\": \"Переключить на китайский\",\n        \"switch_to_traditional_chinese\": \"Переключить на традиционный китайский\",\n        \"switch_to_english_short\": \"EN\",\n        \"switch_to_chinese_short\": \"ZH\",\n        \"switch_to_traditional_chinese_short\": \"TW\",\n        \"switch_to_japanese\": \"Переключить на японский\",\n        \"switch_to_japanese_short\": \"JA\",\n        \"switch_to_turkish\": \"Переключить на турецкий\",\n        \"switch_to_turkish_short\": \"TR\",\n        \"switch_to_vietnamese\": \"Переключить на вьетнамский\",\n        \"switch_to_vietnamese_short\": \"VI\",\n        \"switch_to_russian\": \"Переключить на русский\",\n        \"switch_to_russian_short\": \"RU\",\n        \"switch_to_portuguese\": \"Переключить на португальский\",\n        \"switch_to_portuguese_short\": \"PT\",\n        \"switch_to_korean\": \"Переключить на корейский\",\n        \"switch_to_korean_short\": \"KO\",\n        \"switch_to_spanish\": \"Переключить на испанский\",\n        \"switch_to_spanish_short\": \"ES\",\n        \"switch_to_malay\": \"Переключить на малайский\",\n        \"switch_to_malay_short\": \"MY\",\n        \"user_token\": \"Токены пользователей\"\n    },\n    \"dashboard\": {\n        \"hello\": \"Привет, Пользователь 👋\",\n        \"refresh_quota\": \"Обновить квоту\",\n        \"refreshing\": \"Обновление...\",\n        \"total_accounts\": \"Всего аккаунтов\",\n        \"avg_gemini\": \"Средняя квота Gemini\",\n        \"avg_gemini_image\": \"Средняя квота изображений Gemini\",\n        \"avg_claude\": \"Средняя квота Claude\",\n        \"low_quota_accounts\": \"Аккаунты с низкой квотой\",\n        \"quota_sufficient\": \"Квота достаточная\",\n        \"quota_low\": \"Низкая квота\",\n        \"quota_desc\": \"Квота < 20%\",\n        \"current_account\": \"Текущий аккаунт\",\n        \"switch_account\": \"Переключить аккаунт\",\n        \"no_active_account\": \"Нет активного аккаунта\",\n        \"best_accounts\": \"Лучшие аккаунты\",\n        \"best_account_recommendation\": \"Лучший аккаунт\",\n        \"switch_best\": \"Переключить на лучший\",\n        \"switch_successfully\": \"Переключено на лучший\",\n        \"view_all_accounts\": \"Посмотреть все аккаунты\",\n        \"export_data\": \"Экспорт данных\",\n        \"for_gemini\": \"Для Gemini\",\n        \"for_claude\": \"Для Claude\",\n        \"toast\": {\n            \"switch_success\": \"Успешное переключение!\",\n            \"switch_error\": \"Не удалось переключить аккаунт\",\n            \"refresh_success\": \"Квота успешно обновлена\",\n            \"refresh_error\": \"Не удалось обновить\",\n            \"export_no_accounts\": \"Нет аккаунтов для экспорта\",\n            \"export_success\": \"Экспорт успешен! Файл сохранен в: {{path}}\",\n            \"export_error\": \"Не удалось выполнить экспорт\"\n        }\n    },\n    \"accounts\": {\n        \"account\": \"Аккаунт\",\n        \"search_placeholder\": \"Поиск почты...\",\n        \"all\": \"Все\",\n        \"available\": \"Доступны\",\n        \"low_quota\": \"Низкая квота\",\n        \"ultra\": \"ULTRA\",\n        \"pro\": \"PRO\",\n        \"free\": \"FREE\",\n        \"edit_label\": \"Редактировать метку\",\n        \"custom_label_placeholder\": \"Введите пользовательскую метку\",\n        \"label_updated\": \"Метка обновлена\",\n        \"add_account\": \"Добавить аккаунт\",\n        \"refresh_all\": \"Обновить все\",\n        \"refresh_selected\": \"Обновить ({{count}})\",\n        \"export_selected\": \"Экспортировать ({{count}})\",\n        \"delete_selected\": \"Удалить ({{count}})\",\n        \"current\": \"Текущий\",\n        \"current_badge\": \"Текущий\",\n        \"disabled\": \"Отключен\",\n        \"disabled_tooltip\": \"Аккаунт отключен (например, refresh_token отозван или истек). Пересоздайте авторизацию или обновите токен для повторного включения.\",\n        \"proxy_disabled\": \"Прокси отключен\",\n        \"proxy_disabled_tooltip\": \"Для этого аккаунта прокси отключен вручную. Он не будет обрабатывать API-запросы, но остается доступным в приложении.\",\n        \"enable_proxy\": \"Включить прокси\",\n        \"disable_proxy\": \"Отключить прокси\",\n        \"enable_proxy_selected\": \"Включить ({{count}})\",\n        \"disable_proxy_selected\": \"Отключить ({{count}})\",\n        \"proxy_disabled_reason_manual\": \"Отключен вручную пользователем\",\n        \"proxy_disabled_reason_batch\": \"Отключен пакетно\",\n        \"forbidden\": \"403\",\n        \"forbidden_badge\": \"403\",\n        \"forbidden_tooltip\": \"API вернул 403 Forbidden, у аккаунта нет прав для Gemini Code Assist\",\n        \"forbidden_msg\": \"Запрещено, пропуск автообновления\",\n        \"status\": {\n            \"forbidden\": \"403 Запрещено\",\n            \"disabled\": \"Аккаунт отключен\",\n            \"proxy_disabled\": \"Прокси отключен\"\n        },\n        \"error_details\": \"Детали ошибки\",\n        \"error_status\": \"Статус ошибки\",\n        \"error_time\": \"Время обнаружения\",\n        \"view_error\": \"Посмотреть причину\",\n        \"click_to_verify\": \"Нажмите для проверки\",\n        \"no_data\": \"Нет данных\",\n        \"last_used\": \"Последнее использование\",\n        \"reset_time\": \"Время сброса\",\n        \"switch_to\": \"Переключить на этот аккаунт\",\n        \"actions\": \"Действия\",\n        \"device_fingerprint\": \"Отпечаток Устройства\",\n        \"show_all_quotas\": \"Показать все квоты\",\n        \"device_fingerprint_dialog\": {\n            \"title\": \"Отпечаток Устройства\",\n            \"operations\": \"Операции с Отпечатком Устройства\",\n            \"generate_and_bind\": \"Создать и Привязать\",\n            \"restore_original\": \"Восстановить Оригинал\",\n            \"open_storage_directory\": \"Открыть Каталог Хранения\",\n            \"current_storage\": \"Текущее Хранение\",\n            \"effective\": \"Действующий\",\n            \"current_storage_desc\": \"Читается из storage.json (обновляется после применения привязки при переключении аккаунтов)\",\n            \"account_binding\": \"Привязка Аккаунта\",\n            \"pending_application\": \"Ожидает Применения\",\n            \"account_binding_desc\": \"Сохраняется как привязка после создания/восстановления, записывается в storage.json при переключении аккаунтов\",\n            \"historical_fingerprints\": \"Исторические Отпечатки (опционально восстановить/удалить)\",\n            \"no_history\": \"Нет Истории\",\n            \"current\": \"Текущий\",\n            \"restore\": \"Восстановить\",\n            \"delete_version\": \"Удалить эту версию\",\n            \"confirm_generate_title\": \"Подтвердить создание и привязку?\",\n            \"confirm_generate_desc\": \"Будет создан новый набор отпечатков устройства и установлен как текущий отпечаток. Подтвердить продолжение?\",\n            \"confirm_restore_title\": \"Подтвердить восстановление оригинального отпечатка?\",\n            \"confirm_restore_desc\": \"Будет восстановлен оригинальный отпечаток и перезаписан текущий отпечаток. Подтвердить продолжение?\",\n            \"cancel\": \"Отмена\",\n            \"confirm\": \"Подтвердить\",\n            \"processing\": \"Обработка...\",\n            \"loading\": \"Загрузка...\",\n            \"failed_to_load_device_info\": \"Не удалось загрузить информацию об устройстве\",\n            \"generation_failed\": \"Ошибка создания\",\n            \"binding_failed\": \"Ошибка привязки\",\n            \"restoration_failed\": \"Ошибка восстановления\",\n            \"deletion_failed\": \"Ошибка удаления\",\n            \"directory_open_failed\": \"Не удается открыть каталог\",\n            \"generated_and_bound\": \"Создано и привязано\",\n            \"restored\": \"Восстановлено\",\n            \"deleted\": \"Удалено\",\n            \"directory_opened\": \"Каталог хранения открыт\",\n            \"original_fingerprint_not_found\": \"Оригинальный отпечаток не найден\",\n            \"storage_json_not_found\": \"storage.json не найден, убедитесь, что Antigravity был запущен для создания файла конфигурации\"\n        },\n        \"warmup_all\": \"Разогреть все одним кликом\",\n        \"warmup_selected\": \"Разогреть ({{count}})\",\n        \"warmup_this\": \"Разогреть этот аккаунт\",\n        \"warmup_now\": \"Разогреть сейчас\",\n        \"warmup_batch_triggered\": \"Задачи разогрева запущены для {{count}} аккаунтов\",\n        \"quota_protected\": \"Защищено\",\n        \"details\": {\n            \"title\": \"Детали квоты\",\n            \"model_quota\": \"Квота модели\",\n            \"protected_models\": \"Защищенные модели\"\n        },\n        \"toast\": {\n            \"proxy_enabled\": \"Включен прокси для {{count}} аккаунтов\",\n            \"proxy_disabled\": \"Отключен прокси для {{count}} аккаунтов\"\n        },\n        \"add\": {\n            \"title\": \"Добавить аккаунт\",\n            \"tabs\": {\n                \"oauth\": \"OAuth\",\n                \"token\": \"Refresh Token\",\n                \"import\": \"Импорт БД\"\n            },\n            \"oauth\": {\n                \"recommend\": \"Рекомендуется\",\n                \"desc\": \"Открывает браузер по умолчанию для входа в Google для автоматического получения и сохранения Token.\",\n                \"btn_start\": \"Начать OAuth\",\n                \"btn_waiting\": \"Ожидание авторизации...\",\n                \"btn_finish\": \"Я уже авторизовался\",\n                \"copy_link\": \"Копировать ссылку авторизации\",\n                \"copied\": \"Скопировано\",\n                \"link_label\": \"URL авторизации\",\n                \"link_click_to_copy\": \"Нажмите для копирования\",\n                \"manual_hint\": \"Браузер не перенаправил автоматически? Вставьте ссылку обратного вызова или код авторизации здесь:\",\n                \"manual_placeholder\": \"Вставьте ссылку или код здесь...\",\n                \"error_no_flow\": \"Активный поток аутентификации не найден. Пожалуйста, перезапустите OAuth.\",\n                \"web_hint\": \"Страница входа Google откроется в новом окне\",\n                \"error_no_url\": \"Не удалось получить URL OAuth\",\n                \"popup_blocked\": \"Всплывающее окно заблокировано\",\n                \"manual_submitting\": \"Отправка кода авторизации...\",\n                \"manual_submitted\": \"Код авторизации отправлен, обработка в фоновом режиме...\"\n            },\n            \"token\": {\n                \"label\": \"Refresh Token\",\n                \"placeholder\": \"Вставьте ваш Refresh Token сюда (Пакетная поддерживается)\\n\\nПоддерживаемые форматы:\\n1. Одиночный Token (1//...)\\n2. JSON массив (с полем refresh_token)\\n3. Любой текст, содержащий токены (Авто-извлечение)\",\n                \"hint\": \"Совет: Вы можете вставить несколько токенов или JSON массив для пакетного импорта.\",\n                \"error_token\": \"Пожалуйста, введите Refresh Token\",\n                \"batch_progress\": \"Импорт {{current}}/{{total}} аккаунтов...\",\n                \"batch_success\": \"Успешно импортировано {{count}} аккаунтов\",\n                \"batch_partial\": \"Импорт завершен: {{success}} успешно, {{fail}} с ошибками\",\n                \"batch_fail\": \"Не удалось выполнить импорт\"\n            },\n            \"import\": {\n                \"scheme_a\": \"План А: Из БД IDE\",\n                \"scheme_a_desc\": \"Автоматическое чтение текущего вошедшего аккаунта из локальной БД Antigravity.\",\n                \"btn_db\": \"Импортировать текущий аккаунт\",\n                \"or\": \"ИЛИ\",\n                \"scheme_b\": \"План Б: Из резервной копии V1\",\n                \"scheme_b_desc\": \"Сканирование ~/.antigravity-agent для данных аккаунтов V1.\",\n                \"btn_v1\": \"Пакетный импорт V1\",\n                \"btn_custom_db\": \"Импортировать пользовательскую БД\"\n            },\n            \"btn_cancel\": \"Отмена\",\n            \"btn_confirm\": \"Подтвердить\",\n            \"oauth_error\": \"Ошибка OAuth: {{error}}\",\n            \"status\": {\n                \"error_token\": \"Пожалуйста, введите Refresh Token\"\n            }\n        },\n        \"table\": {\n            \"email\": \"Email\",\n            \"quota\": \"Квота модели\",\n            \"last_used\": \"Последнее использование\",\n            \"actions\": \"Действия\"\n        },\n        \"drag_to_reorder\": \"Перетащите для изменения порядка\",\n        \"empty\": {\n            \"title\": \"Нет аккаунтов\",\n            \"desc\": \"Нажмите кнопку \\\"Добавить аккаунт\\\" выше, чтобы добавить ваш первый аккаунт\"\n        },\n        \"views\": {\n            \"list\": \"Список\",\n            \"grid\": \"Сетка\"\n        },\n        \"dialog\": {\n            \"add_title\": \"Добавить аккаунт\",\n            \"batch_delete_title\": \"Подтверждение пакетного удаления\",\n            \"delete_title\": \"Подтверждение удаления\",\n            \"batch_delete_msg\": \"Вы уверены, что хотите удалить выбранные {{count}} аккаунтов? Это действие нельзя отменить.\",\n            \"delete_msg\": \"Вы уверены, что хотите удалить этот аккаунт? Это действие нельзя отменить.\",\n            \"refresh_title\": \"Обновить квоту\",\n            \"batch_refresh_title\": \"Пакетное обновление\",\n            \"refresh_msg\": \"Вы уверены, что хотите обновить квоту для текущего аккаунта?\",\n            \"batch_refresh_msg\": \"Вы уверены, что хотите обновить квоты для выбранных {{count}} аккаунтов? Это может занять некоторое время.\",\n            \"disable_proxy_title\": \"Отключить прокси\",\n            \"disable_proxy_msg\": \"Вы уверены, что хотите отключить прокси для этого аккаунта? Аккаунт останется доступным в приложении.\",\n            \"enable_proxy_title\": \"Включить прокси\",\n            \"enable_proxy_msg\": \"Вы уверены, что хотите снова включить прокси для этого аккаунта?\",\n            \"warmup_all_title\": \"Полный ручной разогрев\",\n            \"warmup_all_msg\": \"Вы уверены, что хотите запустить задачи разогрева для всех подходящих аккаунтов немедленно? Это отправит минимальный трафик сервисам Google для сброса циклов квоты.\",\n            \"batch_warmup_title\": \"Пакетный ручной разогрев\",\n            \"batch_warmup_msg\": \"Вы уверены, что хотите запустить разогрев для выбранных {{count}} аккаунтов немедленно?\"\n        }\n    },\n    \"settings\": {\n        \"save\": \"Сохранить настройки\",\n        \"tabs\": {\n            \"general\": \"Общие\",\n            \"account\": \"Аккаунт\",\n            \"proxy\": \"Настройки прокси\",\n            \"advanced\": \"Дополнительно\",\n            \"about\": \"О программе\",\n            \"debug\": \"Отладка\"\n        },\n        \"general\": {\n            \"title\": \"Общие настройки\",\n            \"language\": \"Язык\",\n            \"theme\": \"Тема\",\n            \"theme_light\": \"Светлая\",\n            \"theme_dark\": \"Темная\",\n            \"theme_system\": \"Системная\",\n            \"auto_launch\": \"Запуск при старте системы\",\n            \"auto_launch_enabled\": \"Включено\",\n            \"auto_launch_disabled\": \"Отключено\",\n            \"auto_launch_desc\": \"Автоматически запускать Antigravity Tools при старте системы\",\n            \"auto_check_update\": \"Автоматическая проверка обновлений\",\n            \"auto_check_update_desc\": \"Автоматически проверять наличие новой версии при запуске\",\n            \"auto_check_update_enabled\": \"Автопроверка включена\",\n            \"auto_check_update_disabled\": \"Автопроверка отключена\",\n            \"update_check_interval\": \"Интервал проверки (часы)\",\n            \"update_check_interval_desc\": \"Установите интервал автопроверки (1-168 часов)\",\n            \"update_check_interval_saved\": \"Настройки интервала проверки сохранены\"\n        },\n        \"account\": {\n            \"title\": \"Настройки аккаунта\",\n            \"auto_refresh\": \"Фоновое автообновление\",\n            \"auto_refresh_desc\": \"Автоматически обновлять квоты всех аккаунтов в фоне. Это необходимо для защиты квоты и умного разогрева.\",\n            \"always_on\": \"Всегда включено\",\n            \"refresh_interval\": \"Интервал обновления (минуты)\",\n            \"auto_sync\": \"Автосинхронизация текущего аккаунта\",\n            \"auto_sync_desc\": \"Автоматически синхронизировать информацию о текущем активном аккаунте периодически\",\n            \"sync_interval\": \"Интервал синхронизации (секунды)\"\n        },\n        \"warmup\": {\n            \"title\": \"Умный разогрев\",\n            \"desc\": \"Автоматически отслеживает все модели и запускает разогрев немедленно, когда квота достигает 100%, сохраняя модели в активном состоянии\"\n        },\n        \"quota_protection\": {\n            \"title\": \"Защита квоты\",\n            \"enable\": \"Включить защиту квоты\",\n            \"enable_desc\": \"Автоматически отключать прокси, когда квота аккаунта падает ниже порогового значения, и автоматически восстанавливать при сбросе квоты\",\n            \"threshold_label\": \"Зарезервированный процент квоты\",\n            \"monitored_models_label\": \"Мониторинг моделей (Условия запуска)\",\n            \"monitored_models_desc\": \"Выберите хотя бы одну. Защита срабатывает, если ЛЮБАЯ выбранная модель падает ниже порога\",\n            \"range\": \"Диапазон\",\n            \"example\": \"Пример: При {{percentage}}%, аккаунт с квотой {{total}} будет отключен, когда осталось ≤ {{threshold}}\",\n            \"auto_restore_info\": \"Аккаунт будет автоматически включен снова при сбросе квоты\"\n        },\n        \"pinned_quota_models\": {\n            \"title\": \"Закрепленные модели\",\n            \"desc\": \"Выберите какие модели отображать в списке аккаунтов. Невыбранные модели отображаются только во всплывающем окне.\"\n        },\n        \"proxy\": {\n            \"title\": \"Настройки прокси\"\n        },\n        \"proxy_pool\": {\n            \"title\": \"Proxy Pool\",\n            \"strategy_priority\": \"Priority\",\n            \"strategy_round_robin\": \"Round Robin\",\n            \"strategy_random\": \"Random\",\n            \"strategy_least_connections\": \"Least Connections\",\n            \"test_all\": \"Test All\",\n            \"batch_import\": \"Import\",\n            \"binding_manager\": \"Bindings\",\n            \"add_proxy\": \"Add Proxy\",\n            \"edit_proxy\": \"Edit Proxy\",\n            \"name\": \"Name\",\n            \"url\": \"Proxy URL\",\n            \"username\": \"Username\",\n            \"password\": \"Password\",\n            \"max_accounts\": \"Max Accounts\",\n            \"max_accounts_hint\": \"0 = Unlimited\",\n            \"priority\": \"Priority\",\n            \"priority_hint\": \"Lower is better\",\n            \"health_check_url\": \"Health Check URL\",\n            \"tags\": \"Tags\",\n            \"add_tag_placeholder\": \"Add tag...\",\n            \"seconds\": \"Sec\",\n            \"test_completed\": \"Health check completed\",\n            \"test_failed\": \"Health check failed\",\n            \"confirm_delete\": \"Are you sure you want to delete this proxy?\",\n            \"empty\": \"No proxies available\",\n            \"column_priority\": \"Priority\",\n            \"column_status\": \"Status\",\n            \"column_details\": \"Proxy Details\",\n            \"column_bindings\": \"Bindings\",\n            \"import_title\": \"Batch Import Proxies\",\n            \"import_label\": \"Paste Proxy List (One per line)\",\n            \"import_hint\": \"Supported formats: protocol://user:pass@host:port, host:port:user:pass\",\n            \"import_preview\": \"Preview\",\n            \"import_confirm\": \"Import {{count}} Proxies\",\n            \"no_valid_proxies\": \"No valid proxies found\",\n            \"binding\": {\n                \"title\": \"Account Proxy Bindings\",\n                \"load_failed\": \"Failed to load bindings\",\n                \"unbind_success\": \"Unbound successfully\",\n                \"bind_success\": \"Bound successfully\",\n                \"update_failed\": \"Failed to update binding\",\n                \"assigned_proxy\": \"Assigned Proxy\",\n                \"default_strategy\": \"Default (Follow Strategy)\"\n            },\n            \"status\": {\n                \"inactive\": \"Inactive\",\n                \"checking\": \"Checking\",\n                \"healthy\": \"Healthy\",\n                \"timeout\": \"Timeout\"\n            }\n        },\n        \"advanced\": {\n            \"title\": \"Дополнительные настройки\",\n            \"export_path\": \"Путь экспорта по умолчанию\",\n            \"export_path_placeholder\": \"Не задано (Спрашивать каждый раз)\",\n            \"default_export_path_desc\": \"Файлы будут сохраняться прямо в эту папку без запроса\",\n            \"select_btn\": \"Выбрать\",\n            \"open_btn\": \"Открыть\",\n            \"data_dir\": \"Каталог данных\",\n            \"data_dir_desc\": \"Расположение данных аккаунтов и файла конфигурации\",\n            \"antigravity_path\": \"Путь к Antigravity\",\n            \"antigravity_path_placeholder\": \"Не задано (Будет использоваться автоопределение)\",\n            \"antigravity_path_desc\": \"Если вы установили Antigravity в нестандартное место, вы можете вручную указать путь к исполняемому файлу здесь (Указывает на .app на macOS).\",\n            \"antigravity_path_select\": \"Выбрать исполняемый файл Antigravity\",\n            \"antigravity_path_detected\": \"Обнаруженный путь обновлен\",\n            \"detect_btn\": \"Обнаружить\",\n            \"antigravity_args\": \"Аргументы запуска Antigravity\",\n            \"antigravity_args_placeholder\": \"--user-data-dir=/path/to/data --some-other-flag\",\n            \"antigravity_args_desc\": \"Укажите аргументы запуска для Antigravity, например --user-data-dir для указания каталога пользовательских данных\",\n            \"detect_args_btn\": \"Обнаружить\",\n            \"antigravity_args_detected\": \"Аргументы запуска обновлены\",\n            \"antigravity_args_detect_error\": \"Не удалось обнаружить аргументы запуска\",\n            \"accounts_page_size\": \"Размер страницы аккаунтов\",\n            \"page_size_auto\": \"Автоматический расчет (Рекомендуется)\",\n            \"page_size_desc\": \"Установите количество аккаунтов, отображаемых на странице. Выберите 'Автоматический расчет' для динамической настройки в зависимости от размера окна.\",\n            \"logs_title\": \"Обслуживание логов\",\n            \"logs_desc\": \"Очистка файлов кэша логов. Не влияет на данные аккаунтов.\",\n            \"clear_logs\": \"Очистить кэш логов\",\n            \"clear_logs_title\": \"Подтверждение очистки логов\",\n            \"clear_logs_msg\": \"Вы уверены, что хотите очистить все файлы кэша логов?\",\n            \"logs_cleared\": \"Кэш логов очищен\",\n            \"antigravity_cache_title\": \"Очистка кэша Antigravity\",\n            \"antigravity_cache_desc\": \"Очистка кэша Antigravity может решить проблемы с входом, ошибки проверки версии и сбои авторизации OAuth.\",\n            \"antigravity_cache_warning\": \"Перед очисткой кэша убедитесь, что Antigravity полностью закрыт.\",\n            \"clear_antigravity_cache\": \"Очистить кэш Antigravity\",\n            \"clear_cache_confirm_title\": \"Подтверждение очистки кэша Antigravity\",\n            \"clear_cache_confirm_msg\": \"Следующие каталоги кэша будут очищены:\",\n            \"cache_cleared_success\": \"Кэш очищен, освобождено {{size}} МБ\",\n            \"cache_not_found\": \"Каталоги кэша Antigravity не найдены\",\n            \"debug_logs_title\": \"Журнал отладки\",\n            \"debug_logs_enable_desc\": \"При включении записывается полная цепочка запросов и ответов. Рекомендуется включать только при устранении неполадок.\",\n            \"debug_logs_desc\": \"Записывает полную цепочку: исходный ввод, преобразованный запрос v1internal и ответ вышестоящего сервера. Только для устранения неполадок, может содержать конфиденциальные данные.\",\n            \"debug_log_dir\": \"Каталог вывода журнала отладки\",\n            \"debug_log_dir_hint\": \"Оставьте пустым для использования каталога по умолчанию: {{path}}/debug_logs\",\n            \"debug_log_dir_select\": \"Выбрать каталог вывода журнала отладки\"\n        },\n        \"menu\": {\n            \"title\": \"Настройки отображения меню\",\n            \"desc\": \"Выберите элементы для отображения в панели меню. Скрытие редко используемых меню поможет сэкономить место.\",\n            \"selected_items_note\": \"Выбранные элементы будут отображаться в верхней панели меню.\",\n            \"required\": \"Обязательно\"\n        },\n        \"about\": {\n            \"title\": \"О программе\",\n            \"version\": \"Версия приложения\",\n            \"tech_stack\": \"Технологический стек\",\n            \"author\": \"Автор\",\n            \"wechat\": \"WeChat\",\n            \"github\": \"GitHub\",\n            \"view_code\": \"Просмотреть код\",\n            \"copyright\": \"Copyright © 2025-2026 Antigravity. Все права защищены.\",\n            \"check_update\": \"Проверить обновления\",\n            \"checking_update\": \"Проверка...\",\n            \"latest_version\": \"У вас актуальная версия\",\n            \"new_version_available\": \"Доступна новая версия {{version}}\",\n            \"download_update\": \"Скачать\",\n            \"update_check_failed\": \"Не удалось проверить обновления\",\n            \"support_btn\": \"Поддержать автора\",\n            \"support_title\": \"Донат и поддержка\",\n            \"support_desc\": \"Если вы считаете этот проект полезным, не стесняйтесь купить мне кофе! Ваша поддержка — главная мотивация для меня поддерживать этот проект.\",\n            \"support_alipay\": \"Alipay\",\n            \"support_wechat\": \"WeChat Pay\",\n            \"support_buymeacoffee\": \"Buy Me a Coffee\"\n        },\n        \"advanced_thinking\": {\n            \"title\": \"Расширенное мышление и глобальная конфигурация\",\n            \"description\": \"Централизованное управление способностями мышления, режимами изображений и глобальными инструкциями.\"\n        },\n        \"thinking_budget\": {\n            \"title\": \"Бюджет размышлений (Thinking Budget)\",\n            \"description\": \"Управляет бюджетом токенов для глубокого мышления ИИ. Некоторые модели (например, Flash, модели с суффиксом -thinking) ограничены 24576 токенами со стороны API.\",\n            \"mode_label\": \"Режим обработки\",\n            \"mode\": {\n                \"auto\": \"Автоограничение\",\n                \"passthrough\": \"Сквозной\",\n                \"custom\": \"Пользовательский\"\n            },\n            \"auto_hint\": \"Автоматический режим: автоматически ограничивает бюджет до 24576 для моделей Flash, моделей с суффиксом -thinking и поисковых веб-запросов во избежание ошибок API.\",\n            \"passthrough_warning\": \"Сквозной режим: использует исходное значение вызывающей стороны напрямую. Отсутствие поддержки высоких значений может привести к сбоям.\",\n            \"custom_value_hint\": \"Рекомендуется: 24576 (Flash) или 51200 (Расширенное)\",\n            \"tokens\": \"tokens\"\n        },\n        \"image_thinking_mode\": {\n            \"title\": \"Режим мышления при генерации изображений (Image Thinking Mode)\",\n            \"hint\": \"Влияет на качество изображения и процесс генерации\",\n            \"options\": {\n                \"enabled\": \"Включено\",\n                \"disabled\": \"Выключено\",\n                \"enabled_desc\": \"Вкл: сохраняет цепочку рассуждений и возвращает два изображения (эскиз + финал).\",\n                \"disabled_desc\": \"Выкл: отключает цепочку рассуждений и создает одно высококачественное изображение (приоритет качества).\"\n            }\n        },\n        \"global_system_prompt\": {\n            \"title\": \"Глобальные системные инструкции (Global System Prompt)\",\n            \"hint\": \"Автоматически добавляется в systemInstruction для всех запросов\",\n            \"placeholder\": \"Введите глобальные системные инструкции...\\nПример: Вы — старший full-stack разработчик, эксперт по React и Rust. Отвечайте на русском языке.\",\n            \"char_count\": \"{{count}} символов\",\n            \"long_prompt_warning\": \"Инструкции слишком длинные (более 2000 символов), они могут занимать много места в контексте.\"\n        }\n    },\n    \"tray\": {\n        \"current\": \"Текущий\",\n        \"quota\": \"Квота\",\n        \"switch_next\": \"Переключить на следующий аккаунт\",\n        \"refresh_current\": \"Обновить текущую квоту\",\n        \"show_window\": \"Показать главное окно\",\n        \"quit\": \"Выйти из приложения\",\n        \"no_account\": \"Нет аккаунта\",\n        \"unknown_quota\": \"Неизвестно (Нажмите для обновления)\",\n        \"forbidden\": \"Аккаунт запрещен\"\n    },\n    \"proxy\": {\n        \"title\": \"Сервис API Прокси\",\n        \"status\": {\n            \"running\": \"Сервис запущен\",\n            \"stopped\": \"Сервис остановлен\",\n            \"accounts_available\": \"{{count}} аккаунтов доступно\",\n            \"processing\": \"Обработка...\"\n        },\n        \"action\": {\n            \"start\": \"Запустить сервис\",\n            \"stop\": \"Остановить сервис\"\n        },\n        \"config\": {\n            \"title\": \"Конфигурация сервиса\",\n            \"request\": {\n                \"user_agent\": \"User-Agent Override\",\n                \"user_agent_tooltip\": \"Override the User-Agent header sent to upstream APIs. Leave empty to use default.\",\n                \"user_agent_hint\": \"Current Default: antigravity/<version> <os>/<arch>\",\n                \"user_agent_placeholder\": \"Enter custom User-Agent string...\"\n            },\n            \"port\": \"Порт прослушивания\",\n            \"port_tooltip\": \"TCP порт, на котором локальный API Прокси слушает. Остановите сервис для изменения, затем перезапустите для применения.\",\n            \"port_hint\": \"По умолчанию 8045, требуется перезапуск для применения изменений\",\n            \"auto_start\": \"Автозапуск с приложением\",\n            \"auto_start_tooltip\": \"Автоматически запускает локальный сервис API Прокси при запуске приложения.\",\n            \"allow_lan_access\": \"Разрешить доступ из локальной сети\",\n            \"allow_lan_access_tooltip\": \"Когда включено, сервис привязывается к 0.0.0.0, чтобы другие устройства в вашей локальной сети могли получить к нему доступ. Оставьте авторизацию включенной и защищайте свой API ключ; требуется перезапуск для применения.\",\n            \"allow_lan_access_hint_enabled\": \"🌐 Прослушивание на 0.0.0.0, устройства LAN могут получить доступ\",\n            \"allow_lan_access_hint_disabled\": \"🔒 Прослушивание только на 127.0.0.1, доступ по localhost (Приоритет конфиденциальности)\",\n            \"allow_lan_access_warning\": \"⚠️ Устройства LAN могут получить доступ при включении. Защитите свой API ключ\",\n            \"allow_lan_access_restart_hint\": \"ℹ️ Требуется перезапуск сервиса для применения изменений\",\n            \"api_key\": \"API ключ\",\n            \"api_key_tooltip\": \"Общий секрет, используемый клиентами, когда авторизация прокси включена. Перегенерация ключа немедленно аннулирует старый.\",\n            \"btn_regenerate\": \"Перегенерировать ключ\",\n            \"btn_edit\": \"Редактировать\",\n            \"btn_save\": \"Сохранить\",\n            \"btn_copy\": \"Копировать\",\n            \"btn_copied\": \"Скопировано\",\n            \"warning_key\": \"Примечание: Храните свой API ключ в безопасности. Не передавайте его.\",\n            \"api_key_invalid\": \"Неверный формат API ключа, должен начинаться с sk- и содержать минимум 10 символов\",\n            \"api_key_updated\": \"API ключ обновлен\",\n            \"admin_password\": \"Пароль администратора Web UI\",\n            \"admin_password_tooltip\": \"Пароль для входа в веб-консоль управления. Если оставить пустым, по умолчанию используется API-ключ.\",\n            \"admin_password_default\": \"(совпадает с API-ключом)\",\n            \"admin_password_placeholder\": \"Введите новый пароль, оставьте пустым для использования API-ключа\",\n            \"admin_password_hint\": \"Совет: В сценариях развертывания Docker/Web вы можете установить отдельный пароль для входа, чтобы повысить безопасность вашего API-ключа.\",\n            \"admin_password_short\": \"Пароль слишком короткий (минимум 4 символа)\",\n            \"admin_password_updated\": \"Пароль для входа в Web UI обновлен\",\n            \"auth\": {\n                \"title\": \"Авторизация\",\n                \"title_tooltip\": \"Управляет тем, должны ли входящие запросы быть авторизованы, и какие маршруты защищены.\",\n                \"enabled\": \"Включено\",\n                \"enabled_tooltip\": \"Включает/выключает авторизацию переключением режима авторизации. Когда включено, клиенты должны включать API ключ через Authorization: Bearer <API_KEY> или x-api-key.\",\n                \"mode\": \"Режим\",\n                \"mode_tooltip\": \"Выбирает, какие маршруты требуют API ключ: Off = без авторизации; All = защищать все; All except Health = /healthz остается открытым; Auto = Off для только localhost, иначе All except Health.\",\n                \"hint\": \"Когда включено, клиенты должны отправлять API ключ через Authorization: Bearer ... (кроме health, если выбрано).\",\n                \"modes\": {\n                    \"off\": \"Отключено (Открыто)\",\n                    \"strict\": \"Все (Строгий)\",\n                    \"all_except_health\": \"Все кроме Health\",\n                    \"auto\": \"Авто (Рекомендуется)\"\n                }\n            },\n            \"zai\": {\n                \"title\": \"Провайдер z.ai (GLM)\",\n                \"title_tooltip\": \"Необязательный совместимый с Anthropic апстрим для протокола Claude. Влияет только на конечные точки Anthropic; маршрутизация аккаунтов Google остается неизменной.\",\n                \"subtitle\": \"Необязательный совместимый с Anthropic апстрим только для протокола Claude.\",\n                \"enabled\": \"Включено\",\n                \"enabled_tooltip\": \"Включает маршрутизацию z.ai для запросов Anthropic в соответствии с выбранным режимом диспетчеризации.\",\n                \"base_url\": \"Базовый URL\",\n                \"base_url_tooltip\": \"Базовый URL, совместимый с Anthropic. Приложение добавляет пути, такие как /v1/messages. Оставьте по умолчанию, если вы не используете пользовательский шлюз.\",\n                \"dispatch_mode\": \"Режим диспетчеризации\",\n                \"dispatch_mode_tooltip\": \"Управляет тем, когда использовать z.ai для запросов Anthropic: Off отключает его; All Anthropic requests перенаправляет все; Pooled добавляет z.ai как один слот в круговой очереди с аккаунтами Google; Fallback использует z.ai только когда нет аккаунтов Google.\",\n                \"api_key\": \"API ключ\",\n                \"api_key_tooltip\": \"API ключ, используемый для авторизации запросов к z.ai. Хранится локально и требуется для функций z.ai и MCP.\",\n                \"api_key_placeholder\": \"Вставьте ваш z.ai API ключ сюда\",\n                \"warning\": \"Примечание: Этот ключ хранится локально в каталоге данных приложения.\",\n                \"models\": {\n                    \"title\": \"Отображение моделей\",\n                    \"title_tooltip\": \"Получить доступные идентификаторы моделей z.ai и настроить, как имена входящих моделей Anthropic/Claude переводятся в идентификаторы моделей z.ai.\",\n                    \"refresh\": \"Получить модели\",\n                    \"refreshing\": \"Получение...\",\n                    \"hint\": \"Доступные модели: {{count}}. Выберите предложение или введите пользовательский идентификатор модели.\",\n                    \"error\": \"Не удалось получить модели: {{error}}\",\n                    \"select_placeholder\": \"Выберите модель...\",\n                    \"opus\": \"Семейство Opus → модель z.ai\",\n                    \"opus_tooltip\": \"Идентификатор модели z.ai по умолчанию, когда входящая модель содержит \\\"opus\\\" (например, claude-opus-*).\",\n                    \"sonnet\": \"Семейство Sonnet → модель z.ai\",\n                    \"sonnet_tooltip\": \"Идентификатор модели z.ai по умолчанию для других моделей Claude (например, claude-sonnet-* и большинство запросов claude-*).\",\n                    \"haiku\": \"Семейство Haiku → модель z.ai\",\n                    \"haiku_tooltip\": \"Идентификатор модели z.ai по умолчанию, когда входящая модель содержит \\\"haiku\\\" (например, claude-haiku-*).\",\n                    \"advanced_title\": \"Расширенные переопределения\",\n                    \"advanced_tooltip\": \"Необязательные переопределения точного совпадения. Если строка входящей модели совпадает с ключом правила, она будет заменена на отображенный идентификатор модели z.ai.\",\n                    \"from_label\": \"Входящая модель\",\n                    \"to_label\": \"Модель z.ai\",\n                    \"add_rule\": \"Добавить\",\n                    \"empty\": \"Правила переопределения не настроены.\",\n                    \"from_placeholder\": \"От (например, claude-3-opus)\",\n                    \"to_placeholder\": \"В (например, glm-4)\"\n                },\n                \"modes\": {\n                    \"off\": \"Отключено\",\n                    \"exclusive\": \"Все запросы Anthropic\",\n                    \"pooled\": \"Объединенный (один слот)\",\n                    \"fallback\": \"Только резервный\"\n                },\n                \"mcp\": {\n                    \"title\": \"MCP серверы (через локальный прокси)\",\n                    \"title_tooltip\": \"Открывает необязательные конечные точки /mcp/* на этом локальном прокси, чтобы клиенты MCP могли подключиться. Доступно только когда сервис запущен, z.ai настроен и соответствующие переключатели включены.\",\n                    \"enabled\": \"Включить MCP прокси\",\n                    \"enabled_tooltip\": \"Главный переключатель для конечных точек MCP. Когда выключено, все маршруты /mcp/* возвращают 404.\",\n                    \"web_search\": \"Веб-поиск\",\n                    \"web_search_tooltip\": \"Открывает /mcp/web_search_prime/mcp и перенаправляет запросы в апстрим MCP Web Search z.ai.\",\n                    \"web_reader\": \"Веб-ридер\",\n                    \"web_reader_tooltip\": \"Открывает /mcp/web_reader/mcp и перенаправляет запросы в апстрим MCP Web Reader z.ai.\",\n                    \"vision\": \"Визуализация\",\n                    \"vision_tooltip\": \"Открывает /mcp/zai-mcp-server/mcp (локальный MCP сервер), который предоставляет инструменты визуализации поддерживаемые z.ai.\",\n                    \"local_endpoints\": \"Локальные конечные точки (настройте ваш MCP клиент для использования этих URL):\",\n                    \"local_endpoints_tooltip\": \"Используйте эти URL в вашем MCP клиенте. Они используют тот же хост/порт, что и API Прокси, и следуют политике авторизации прокси.\"\n                }\n            },\n            \"request_timeout\": \"Таймаут запроса\",\n            \"request_timeout_tooltip\": \"Максимальное время (секунды), которое прокси ждет ответа от апстрима, включая стриминг. Увеличьте для долгих генераций; требуется перезапуск для применения.\",\n            \"request_timeout_hint\": \"По умолчанию 120с, диапазон 30-7200с. Перезапустите сервис для применения изменений.\",\n            \"enable_logging\": \"Включить логирование запросов\",\n            \"enable_logging_hint\": \"Записывать историю для отладки (Небольшое снижение производительности)\",\n            \"upstream_proxy\": {\n                \"title\": \"Глобальный апстрим прокси (Глобальный прокси)\",\n                \"desc\": \"Когда включено, все внешние запросы (API Прокси, Обновление токена, Проверка квоты, Проверка обновлений) будут направлены через этот прокси.\",\n                \"desc_short\": \"Глобальный прокси-сервер, используемый в качестве резервного варианта, когда в пуле нет подходящих аккаунтов.\",\n                \"enable\": \"Включить апстрим прокси\",\n                \"url\": \"URL прокси\",\n                \"url_placeholder\": \"например, http://127.0.0.1:7890 или socks5://127.0.0.1:7890\",\n                \"tip\": \"Поддерживает HTTP, HTTPS и SOCKS5.\",\n                \"socks5h_hint\": \"Чтобы избежать блокировок и сохранить удаленное разрешение DNS (Remote DNS), вручную измените протокол на socks5h://\",\n                \"validation_error\": \"URL прокси обязателен при включенном апстрим прокси\",\n                \"restart_hint\": \"Настройки прокси сохранены. Перезапустите приложение для применения изменений.\"\n            },\n            \"scheduling\": {\n                \"title\": \"Ротация аккаунтов и планирование\",\n                \"title_tooltip\": \"Управляет тем, как сессии привязаны к аккаунтам и как обрабатываются ограничения скорости.\",\n                \"subtitle\": \"Оптимизирует кэширование подсказок и обработку ограничений скорости для всех протоколов (OpenAI/Gemini/Claude).\",\n                \"mode\": \"Режим планирования\",\n                \"mode_tooltip\": \"Cache-First: Привязать сессию к аккаунту, ждать при ограничении скорости (максимизировать использование кэша); Balance: Привязать сессию, переключить аккаунт при ограничении скорости; Performance: Стандартная круговая очередь.\",\n                \"modes\": {\n                    \"CacheFirst\": \"Кэш в приоритете\",\n                    \"Balance\": \"Баланс\",\n                    \"PerformanceFirst\": \"Производительность\"\n                },\n                \"modes_desc\": {\n                    \"CacheFirst\": \"Привязывает сессию к аккаунту, ждет если ограничен (Максимизирует попадания в кэш подсказок).\",\n                    \"Balance\": \"Привязывает сессию, автоматически переключается на доступный аккаунт если ограничен (Балансирует кэш и доступность).\",\n                    \"PerformanceFirst\": \"Без привязки сессий, чистая круговая ротация (Лучше для высокой конкурентности).\"\n                },\n                \"max_wait\": \"Макс. ожидание (сек)\",\n                \"max_wait_tooltip\": \"Используется только в режиме 'Кэш в приоритете': ждать вместо переключения, если время сброса ограничения скорости ниже этого значения.\",\n                \"clear_bindings\": \"Очистить привязки сессий\",\n                \"clear_bindings_tooltip\": \"Жесткий сброс всех привязок сессия-аккаунт, принудительное переназначение аккаунтов при следующем запросе.\",\n                \"clear_rate_limits\": \"Очистить записи лимитов\",\n                \"clear_rate_limits_tooltip\": \"Немедленно очистить локальные записи ограничений скорости для всех аккаунтов, заставляя следующие запросы пробовать апстрим напрямую.\",\n                \"fixed_account\": \"Режим фиксированной учетной записи\",\n                \"fixed_account_tooltip\": \"При включении все запросы API будут использовать только выбранную учетную запись вместо чередования между учетными записями.\",\n                \"round_robin_set\": \"Режим циклического перебора включен\",\n                \"fixed_account_set\": \"Режим фиксированной учетной записи включен\",\n                \"circuit_breaker\": {\n                    \"title\": \"Адаптивный выключатель\",\n                    \"tooltip\": \"Автоматически увеличивает длительность блокировки для аккаунтов, которые повторно завершаются с ошибкой из-за исчерпания квоты. Это предотвращает бесполезные вызовы API для неактивных аккаунтов, позволяя быстро восстановиться после временных ошибок.\",\n                    \"backoff_levels\": \"Уровни задержки (в секундах)\",\n                    \"level\": \"Ур. {{level}}\",\n                    \"input_placeholder\": \"60, 300, 1800, 7200\",\n                    \"invalid_format\": \"Неверный формат. Используйте числа, разделенные запятыми (например, 60, 300)\",\n                    \"clear_records\": \"Очистить все записи ограничений скорости\"\n                }\n            },\n            \"experimental\": {\n                \"title\": \"Экспериментальные настройки\",\n                \"title_tooltip\": \"Исследовательские функции, которые могут быть изменены или удалены в будущих версиях.\",\n                \"enable_usage_scaling\": \"Включить масштабирование использования\",\n                \"enable_usage_scaling_tooltip\": \"Для протокола Claude. Включает агрессивное масштабирование, когда общий ввод превышает 30k токенов для предотвращения частого сжатия на стороне клиента. Примечание: Сообщаемое использование не будет отражать реальное выставление счетов после включения.\",\n                \"context_compression_threshold_l1\": \"Порог сжатия L1 (Обрезка инструментов)\",\n                \"context_compression_threshold_l1_tooltip\": \"Обрезает старые записи вызовов инструментов для экономии места. Рекомендуется: 0.4 (40%)\",\n                \"context_compression_threshold_l2\": \"Порог сжатия L2 (Сжатие мышления)\",\n                \"context_compression_threshold_l2_tooltip\": \"Сжимает ранние блоки мышления, сохраняя подписи. Рекомендуется: 0.55 (55%)\",\n                \"context_compression_threshold_l3\": \"Порог сжатия L3 (Сброс сводки)\",\n                \"context_compression_threshold_l3_tooltip\": \"Генерирует сводку состояния XML и переходит к свежему сеансу. Рекомендуется: 0.7 (70%)\"\n            }\n        },\n        \"cloudflared\": {\n            \"title\": \"Публичный доступ (Cloudflared)\",\n            \"subtitle\": \"Предоставьте доступ к локальному сервису через интернет с помощью Cloudflare Tunnel\",\n            \"not_installed\": \"Cloudflared не установлен\",\n            \"install_hint\": \"Cloudflared — это бесплатный инструмент туннелирования от Cloudflare. Он предоставляет доступ к вашему локальному прокси через интернет без публичного IP или проброса портов. Нажмите кнопку ниже для установки.\",\n            \"install\": \"Установить сейчас\",\n            \"installing\": \"Установка...\",\n            \"install_success\": \"Cloudflared успешно установлен\",\n            \"install_failed\": \"Ошибка установки: {{error}}\",\n            \"installed\": \"Установлен\",\n            \"version\": \"Версия\",\n            \"mode_label\": \"Режим туннеля\",\n            \"mode_quick\": \"Быстрый туннель\",\n            \"mode_quick_desc\": \"Автоматически сгенерированный временный URL (*.trycloudflare.com), аккаунт не требуется, URL меняется при перезапуске\",\n            \"mode_auth\": \"Именованный туннель\",\n            \"mode_auth_desc\": \"Используйте токен аккаунта Cloudflare, поддержка пользовательского домена, постоянный URL\",\n            \"token\": \"Токен туннеля\",\n            \"token_placeholder\": \"Вставьте ваш Cloudflare Tunnel Token сюда\",\n            \"token_hint\": \"Получите в панели Cloudflare Zero Trust\",\n            \"token_required\": \"Токен требуется для режима Именованного туннеля\",\n            \"use_http2\": \"Использовать HTTP/2\",\n            \"use_http2_desc\": \"Более совместимо, рекомендуется для материкового Китая\",\n            \"status_label\": \"Статус туннеля\",\n            \"status_stopped\": \"Остановлен\",\n            \"status_starting\": \"Запуск...\",\n            \"status_running\": \"Работает\",\n            \"status_error\": \"Ошибка\",\n            \"public_url\": \"Публичный URL\",\n            \"public_url_copied\": \"URL скопирован\",\n            \"start_tunnel\": \"Запустить туннель\",\n            \"stop_tunnel\": \"Остановить туннель\",\n            \"restart_tunnel\": \"Перезапустить туннель\",\n            \"starting\": \"Запуск...\",\n            \"stopping\": \"Остановка...\",\n            \"start_success\": \"Туннель успешно запущен\",\n            \"stop_success\": \"Туннель остановлен\",\n            \"start_failed\": \"Не удалось запустить туннель: {{error}}\",\n            \"stop_failed\": \"Не удалось остановить туннель: {{error}}\",\n            \"logs\": \"Логи\",\n            \"clear_logs\": \"Очистить логи\",\n            \"auto_start\": \"Автозапуск с прокси\",\n            \"auto_start_desc\": \"Автоматически запускать туннель при запуске сервиса API прокси\",\n            \"warning_quick_mode\": \"⚠️ Быстрый режим: URL меняется при каждом перезапуске\",\n            \"warning_token_storage\": \"💡 Токен безопасно хранится локально\"\n        },\n        \"example\": {\n            \"title\": \"Примеры использования\",\n            \"curl\": \"cURL\",\n            \"python\": \"Python\",\n            \"python_anthropic\": \"from anthropic import Anthropic\\n\\nclient = Anthropic(\\n    # Рекомендуется: 127.0.0.1\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\n# Примечание: Antigravity поддерживает вызов любой модели через Anthropic SDK\\nresponse = client.messages.create(\\n    model=\\\"{{modelId}}\\\",\\n    max_tokens=1024,\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Привет\\\"}]\\n)\\n\\nprint(response.content[0].text)\",\n            \"python_gemini\": \"# Установка: pip install google-generativeai\\nimport google.generativeai as genai\\n\\n# Использовать адрес прокси Antigravity (рекомендуется 127.0.0.1)\\ngenai.configure(\\n    api_key=\\\"{{apiKey}}\\\",\\n    transport='rest',\\n    client_options={'api_endpoint': '{{rawBaseUrl}}'}\\n)\\n\\nmodel = genai.GenerativeModel('{{modelId}}')\\nresponse = model.generate_content(\\\"Привет\\\")\\nprint(response.text)\",\n            \"python_openai_image\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    # Вариант 1: использовать размер (рекомендуется)\\n    # Поддерживается: \\\"1024x1024\\\" (1:1), \\\"1280x720\\\" (16:9), \\\"720x1280\\\" (9:16), \\\"1216x896\\\" (4:3)\\n    extra_body={ \\\"size\\\": \\\"1024x1024\\\" },\\n\\n    # Вариант 2: использовать суффикс модели\\n    # например, gemini-3-pro-image-16-9, gemini-3-pro-image-4-3\\n    # model=\\\"gemini-3-pro-image-16-9\\\",\\n    messages=[{\\n        \\\"role\\\": \\\"user\\\",\\n        \\\"content\\\": \\\"Нарисовать футуристический город\\\"\\n    }]\\n)\\n\\nprint(response.choices[0].message.content)\",\n            \"python_openai\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Привет\\\"}]\\n)\\n\\nprint(response.choices[0].message.content)\"\n        },\n        \"examples\": {\n            \"title\": \"Примеры использования\"\n        },\n        \"dialog\": {\n            \"confirm_regenerate\": \"Вы уверены, что хотите перегенерировать API ключ? Старый ключ будет немедленно недействителен.\",\n            \"operate_failed\": \"Операция не удалась: {{error}}\",\n            \"reset_mapping_title\": \"Сбросить отображение моделей\",\n            \"reset_mapping_msg\": \"Вы уверены, что хотите сбросить все отображения моделей к значениям по умолчанию? Это действие нельзя отменить.\",\n            \"regenerate_key_title\": \"Перегенерировать API ключ\",\n            \"regenerate_key_msg\": \"Вы уверены, что хотите перегенерировать API ключ? Старый ключ будет немедленно недействителен.\",\n            \"clear_bindings_title\": \"Очистить привязки сессий\",\n            \"clear_bindings_msg\": \"Вы уверены, что хотите очистить все привязки сессия-аккаунт?\",\n            \"clear_rate_limits_title\": \"Очистить записи лимитов\",\n            \"clear_rate_limits_confirm\": \"Вы уверены, что хотите очистить все локальные записи ограничений скорости?\"\n        },\n        \"model\": {\n            \"flash\": \"Быстрый ответ\",\n            \"flash_preview\": \"Предпросмотр Flash\",\n            \"flash_lite\": \"Легкий и быстрый\",\n            \"flash_thinking\": \"Способность размышления\",\n            \"pro_legacy\": \"Устаревший Pro\",\n            \"pro_low\": \"Высокая производительность\",\n            \"pro_high\": \"Лучшее мышление\",\n            \"pro_image\": \"Генерация изображений (1:1)\",\n            \"pro_image_16_9\": \"Генерация изображений (16:9)\",\n            \"pro_image_9_16\": \"Генерация изображений (9:16)\",\n            \"pro_image_4_3\": \"Генерация изображений (4:3)\",\n            \"pro_image_3_4\": \"Генерация изображений (3:4)\",\n            \"pro_image_1_1\": \"Генерация изображений (1:1)\",\n            \"claude_sonnet\": \"Кодовое мышление\",\n            \"claude_sonnet_thinking\": \"Цепочка рассуждений\",\n            \"claude_opus_thinking\": \"Сильнейшее мышление\"\n        },\n        \"mapping\": {\n            \"title\": \"Отображение моделей Claude Code\",\n            \"description\": \"Отобразить модели Claude Code на модели Antigravity. Оптимизируйте стоимость и скорость, маршрутизируя запросы разумно.\",\n            \"default\": \"По умолчанию\",\n            \"sonnet_desc\": \"Наиболее способная для сложной работы\",\n            \"opus_desc\": \"Премиум уровень\",\n            \"haiku_desc\": \"Самая быстрая для быстрых ответов\",\n            \"maps_to\": \"Отображается на Antigravity\",\n            \"apply_recommended\": \"Применить рекомендуемые\",\n            \"restore_defaults\": \"Восстановить конфигурацию по умолчанию\",\n            \"reset_all\": \"Сбросить все\"\n        },\n        \"router\": {\n            \"title\": \"Маршрутизатор моделей\",\n            \"subtitle\": \"Маршрутизировать модели по сериям или добавить пользовательские точные отображения.\\nПримечание: Нативные модели проходного Claude (например, claude-opus-4-6-thinking) по умолчанию обходят группы серий. Используйте \\\"Экспертную пользовательскую маршрутизацию\\\" для переопределения.\",\n            \"subtitle_simple\": \"Настроить маршрутизацию моделей с подстановочными символами или точными отображениями\",\n            \"background_task_title\": \"Модель фоновых задач\",\n            \"background_task_desc\": \"Модель, используемая для фоновых задач Claude CLI, таких как генерация заголовков, резюме и т. д. (По умолчанию: gemini-2.5-flash)\",\n            \"use_default\": \"Использовать системное значение по умолчанию\",\n            \"current_model\": \"Текущая модель\",\n            \"apply_presets\": \"Применить пресеты\",\n            \"presets_applied\": \"Пресеты успешно применены\",\n            \"custom_mappings\": \"Пользовательские отображения\",\n            \"group_title\": \"Группы серий\",\n            \"groups\": {\n                \"claude_45\": {\n                    \"name\": \"Серия Claude 4.6 TK\",\n                    \"desc\": \"Opus 4.5 TK\"\n                },\n                \"claude_35\": {\n                    \"name\": \"Серия Claude 3.5\",\n                    \"desc\": \"Sonnet 3.5, Haiku 3.5\"\n                },\n                \"gpt_4\": {\n                    \"name\": \"Серия GPT-4 / o1\",\n                    \"desc\": \"GPT-4, Turbo, o1-preview\"\n                },\n                \"gpt_4o\": {\n                    \"name\": \"Серия GPT-4o / 3.5\",\n                    \"desc\": \"GPT-4o, Mini, 3.5 Turbo\"\n                },\n                \"gpt_5\": {\n                    \"name\": \"Серия GPT-5\",\n                    \"desc\": \"GPT-5.1, GPT-5.2 xhigh\"\n                }\n            },\n            \"expert_title\": \"Экспертная пользовательская маршрутизация\",\n            \"expert_subtitle\": \"Точное соответствие для любого исходного идентификатора модели.\",\n            \"custom_mapping_tip\": \"💡 Поддерживает ручной ввод любого ID модели для тестирования нереализованных моделей (например, claude-opus-4-6).\",\n            \"custom_mapping_warning\": \"Внимание: не все аккаунты поддерживают нереализованные модели.\",\n            \"money_saving_tip\": \"💰 Совет по экономии:\",\n            \"haiku_optimization_tip\": \"Claude CLI использует {{model}} для фоновых задач по умолчанию. Отобразите его на более дешевую модель Flash для экономии ~95% стоимости\",\n            \"haiku_optimization_btn\": \"Быстрая оптимизация\",\n            \"haiku_tip_title\": \"💰 Совет по экономии:\",\n            \"haiku_tip_body_before\": \"Claude CLI по умолчанию использует\",\n            \"haiku_tip_body_after\": \"для фоновых задач; отображение его на более дешевую модель Flash может сэкономить около 95% стоимости.\",\n            \"haiku_tip_action\": \"Оптимизировать\",\n            \"reset_confirm\": \"Сбросить все отображения к значениям системы по умолчанию?\",\n            \"reset_mapping\": \"Сбросить отображение\",\n            \"add_mapping\": \"Добавить отображение\",\n            \"current_list\": \"Пользовательский список\",\n            \"no_custom_mapping\": \"Пользовательских отображений пока нет\",\n            \"gemini3_only_warning\": \"⚠️ Только серия Gemini 3\",\n            \"default_suffix\": \" (По умолчанию)\",\n            \"original_id\": \"Исходный ID\",\n            \"route_to\": \"Маршрутизировать в\",\n            \"select_target_model\": \"Выбрать целевую модель\",\n            \"original_placeholder\": \"Оригинал (например, gpt-4 или gpt-4*)\"\n        },\n        \"multi_protocol\": {\n            \"title\": \"Поддержка протоколов\",\n            \"subtitle\": \"Синхронизируйте API и ключи с вашими инструментами\",\n            \"description\": \"Прокси поддерживает протоколы OpenAI, Anthropic и Gemini для легкой интеграции.\",\n            \"openai_label\": \"OpenAI\",\n            \"anthropic_label\": \"Anthropic\",\n            \"openai_tools\": \"Cherry Studio, NextChat\",\n            \"anthropic_tools\": \"Claude Code CLI\",\n            \"gemini_label\": \"Gemini\",\n            \"gemini_tools\": \"Google AI SDK, LangChain\",\n            \"quick_integration\": \"Быстрая интеграция\",\n            \"click_tip\": \"👆 Кликните на модель для обновления кода\",\n            \"copy_base\": \"Копировать Base\"\n        },\n        \"cli_sync\": {\n            \"title\": \"Синхронизация CLI\",\n            \"subtitle\": \"Синхронизируйте текущий API URL и ключ с вашими AI CLI инструментами\",\n            \"card_title\": \"Конфиг {{name}}\",\n            \"status\": {\n                \"not_installed\": \"Не установлено\",\n                \"installed\": \"v{{version}}\",\n                \"synced\": \"Указывает на это приложение\",\n                \"not_synced\": \"Не синхронизировано\",\n                \"detecting\": \"Определение...\",\n                \"current_base_url\": \"Текущий Base URL\"\n            },\n            \"btn_sync\": \"Синхронизировать\",\n            \"btn_view\": \"Просмотр конфига\",\n            \"btn_restore\": \"Восстановить\",\n            \"btn_restore_backup\": \"Восстановить резервную копию\",\n            \"restore_backup_confirm\": \"Обнаружена резервная копия конфигурации. Вы уверены, что хотите восстановить ее?\",\n            \"sync_confirm_title\": \"Подтверждение синхронизации\",\n            \"sync_confirm_message\": \"Готово к синхронизации конфигурации {{name}}. ⚠️ Внимание: это перезапишет ваши существующие локальные файлы конфигурации (например, токены входа, API ключи). Вы уверены, что хотите продолжить?\",\n            \"restore_confirm\": \"Вы уверены, что хотите сбросить конфиг {{name}} на значения по умолчанию?\",\n            \"modal\": {\n                \"view_title\": \"Содержимое конфига {{name}}\",\n                \"copy_success\": \"Скопировано в буфер обмена\"\n            },\n            \"toast\": {\n                \"config_missing\": \"Сначала сгенерируйте API ключ и запустите сервис\",\n                \"sync_success\": \"Успешно! {{name}} готов к работе.\",\n                \"sync_error\": \"Ошибка синхронизации {{name}}: {{error}}\"\n            }\n        },\n        \"supported_models\": {\n            \"title\": \"Поддерживаемые модели и интеграция\",\n            \"model_name\": \"Название модели\",\n            \"model_id\": \"ID модели\",\n            \"description\": \"Описание\",\n            \"action\": \"Действие\"\n        }\n    },\n    \"monitor\": {\n        \"page_title\": \"Дашборд API Монитора\",\n        \"page_subtitle\": \"Логирование и анализ запросов в реальном времени\",\n        \"open_monitor\": \"Открыть монитор\",\n        \"logging_status\": {\n            \"active\": \"Запись\",\n            \"paused\": \"Приостановлено\"\n        },\n        \"stats\": {\n            \"total\": \"Всего\",\n            \"ok\": \"ОК\",\n            \"err\": \"ОШБ\"\n        },\n        \"filters\": {\n            \"placeholder\": \"Фильтр по модели, пути или статусу...\",\n            \"quick_filters\": \"Быстрые фильтры:\",\n            \"all\": \"Все\",\n            \"error\": \"Ошибка\",\n            \"chat\": \"Чат\",\n            \"gemini\": \"Gemini\",\n            \"claude\": \"Claude\",\n            \"images\": \"Изображения\",\n            \"reset\": \"Сброс\",\n            \"by_account\": \"Фильтр по аккаунту\",\n            \"all_accounts\": \"Все аккаунты\"\n        },\n        \"table\": {\n            \"status\": \"Статус\",\n            \"method\": \"Метод\",\n            \"model\": \"Модель\",\n            \"protocol\": \"Протокол\",\n            \"account\": \"Аккаунт\",\n            \"path\": \"Путь\",\n            \"usage\": \"Токены\",\n            \"duration\": \"Длительность\",\n            \"time\": \"Время\",\n            \"empty\": \"Запросов не записано\"\n        },\n        \"details\": {\n            \"title\": \"Детали запроса\",\n            \"request_payload\": \"Полезная нагрузка запроса\",\n            \"response_payload\": \"Полезная нагрузка ответа\",\n            \"duration\": \"Длительность\",\n            \"tokens\": \"Токены (В/И)\",\n            \"time\": \"Время\",\n            \"model\": \"Модель\",\n            \"id\": \"ID запроса\",\n            \"protocol\": \"Протокол\",\n            \"mapped_model\": \"Сопоставленная модель\",\n            \"account_used\": \"Использованный аккаунт\",\n            \"payload_empty\": \"Нет данных\"\n        },\n        \"dialog\": {\n            \"clear_title\": \"Очистить логи прокси\",\n            \"clear_msg\": \"Вы уверены, что хотите очистить все логи прокси? Это действие нельзя отменить.\"\n        }\n    },\n    \"update_notification\": {\n        \"title\": \"Обновление...\",\n        \"message\": \"Подготовлена новая версия с оптимизациями и улучшениями. Текущая: v{{current}}\",\n        \"ready\": \"Обновление готово!\",\n        \"downloading\": \"Загрузка обновления в фоне...\",\n        \"restarting\": \"Перезапуск приложения...\",\n        \"auto_update\": \"Авто-обновление\",\n        \"restart_prompt\": \"Обновление загружено и готово к установке. Перезапустить?\",\n        \"btn_restart\": \"Перезапустить\",\n        \"btn_later\": \"Позже\",\n        \"toast\": {\n            \"not_ready\": \"Артефакты обновления ещё не готовы. Повторим позже.\",\n            \"failed\": \"Автообновление не удалось\"\n        }\n    },\n    \"login\": {\n        \"title\": \"Управление безопасным доступом\",\n        \"desc\": \"В данный момент работает в режиме Web. Пожалуйста, введите пароль администратора или ключ API для доступа.\",\n        \"placeholder\": \"Введите пароль администратора или ключ API\",\n        \"btn_login\": \"Проверить и войти\",\n        \"note\": \"Примечание: если установлен отдельный пароль управления, введите его; в противном случае введите API_KEY.\",\n        \"lookup_hint\": \"Если вы забыли, запустите docker logs antigravity-manager, чтобы найти текущий ключ API или пароль Web UI.\",\n        \"config_hint\": \"Или выполните grep -E '\\\"api_key\\\"|\\\"admin_password\\\"' ~/.antigravity_tools/gui_config.json для просмотра.\"\n    },\n    \"token_stats\": {\n        \"title\": \"Статистика токенов\",\n        \"hourly\": \"Почасовая\",\n        \"daily\": \"Ежедневная\",\n        \"weekly\": \"Еженедельная\",\n        \"total_tokens\": \"Всего токенов\",\n        \"input_tokens\": \"Входящие токены\",\n        \"output_tokens\": \"Исходящие токены\",\n        \"accounts_used\": \"Активные аккаунты\",\n        \"models_used\": \"Использованные модели\",\n        \"model_trend\": \"Тренд использования моделей\",\n        \"account_trend\": \"Тренд использования по аккаунтам\",\n        \"usage_trend\": \"Тренд использования токенов\",\n        \"by_account\": \"По аккаунтам\",\n        \"by_model\": \"По моделям\",\n        \"by_account_view\": \"По аккаунтам\",\n        \"model_details\": \"Детали по моделям\",\n        \"account_details\": \"Детали по аккаунтам\",\n        \"model\": \"Модель\",\n        \"account\": \"Аккаунт\",\n        \"requests\": \"Запросы\",\n        \"input\": \"Вход\",\n        \"output\": \"Выход\",\n        \"total\": \"Всего\",\n        \"percentage\": \"Доля\",\n        \"no_data\": \"Нет данных\"\n    },\n    \"security\": {\n        \"title\": \"Мониторинг безопасности\",\n        \"refresh_data\": \"Обновить данные\",\n        \"refresh\": \"Обновить\",\n        \"tab_logs\": \"Журналы доступа\",\n        \"tab_stats\": \"Статистика\",\n        \"tab_blacklist\": \"Черный список\",\n        \"tab_whitelist\": \"Белый список\",\n        \"tab_config\": \"Настройки\",\n        \"stats\": {\n            \"total_requests\": \"Всего запросов\",\n            \"total_requests_desc\": \"Все зарегистрированные запросы\",\n            \"unique_ips\": \"Уникальные IP\",\n            \"unique_ips_desc\": \"Различные IP адреса клиентов\",\n            \"blocked_requests\": \"Заблокировано\",\n            \"blocked_requests_desc\": \"Запросы, отклоненные правилами\",\n            \"ip_activity_token_usage\": \"Активность IP и использование токенов\",\n            \"hour\": \"Час\",\n            \"day\": \"День\",\n            \"week\": \"Нед\",\n            \"month\": \"Мес\",\n            \"rank\": \"Ранг\",\n            \"ip_address\": \"IP Адрес\",\n            \"activity_reqs\": \"Активность (Запр)\",\n            \"total_token\": \"Всего токенов\",\n            \"prompt\": \"Промпт\",\n            \"completion\": \"Ответ\",\n            \"no_data\": \"Нет данных\"\n        },\n        \"logs\": {\n            \"search_placeholder\": \"Поиск IP, пути, User Agent...\",\n            \"show_blocked_only\": \"Только заблокированные\",\n            \"status\": \"Статус\",\n            \"ip_address\": \"IP Адрес\",\n            \"method\": \"Метод\",\n            \"path\": \"Путь\",\n            \"duration\": \"Длительн.\",\n            \"time\": \"Время\",\n            \"reason\": \"Причина\",\n            \"blocked\": \"Блок\",\n            \"no_logs\": \"Нет журналов\",\n            \"total_records\": \"Всего {{total}} записей\",\n            \"prev_page\": \"Назад\",\n            \"next_page\": \"Вперед\",\n            \"page_num\": \"Стр {{page}}\",\n            \"per_page_suffix\": \"/стр\"\n        },\n        \"blacklist\": {\n            \"add_ip\": \"Добавить IP\",\n            \"search_placeholder\": \"Поиск...\",\n            \"added_at\": \"Добавлено\",\n            \"expires_at\": \"Истекает\",\n            \"no_data\": \"Черный список пуст\",\n            \"add_title\": \"Добавить в черный список\",\n            \"ip_cidr_label\": \"IP адрес или CIDR\",\n            \"ip_cidr_placeholder\": \"напр. 192.168.1.1 или 10.0.0.0/24\",\n            \"reason_label\": \"Причина (опционально)\",\n            \"reason_placeholder\": \"напр. Вредоносное сканирование\",\n            \"expires_label\": \"Истекает через (часов, опционально)\",\n            \"expires_placeholder\": \"Оставьте пустым для бессрочного\",\n            \"cancel\": \"Отмена\",\n            \"confirm\": \"Добавить\",\n            \"add_btn\": \"Добавить\"\n        },\n        \"whitelist\": {\n            \"add_ip\": \"Добавить доверенный IP\",\n            \"no_data\": \"Белый список пуст\",\n            \"add_title\": \"Добавить в белый список\",\n            \"description_label\": \"Описание (опционально)\",\n            \"description_placeholder\": \"напр. Внутренний сервер\",\n            \"cancel\": \"Отмена\",\n            \"confirm\": \"Добавить\",\n            \"add_btn\": \"Добавить\"\n        },\n        \"config\": {\n            \"title\": \"Настройки безопасности\",\n            \"save\": \"Сохранить\",\n            \"saving\": \"Сохранение...\",\n            \"blacklist_title\": \"Черный список IP\",\n            \"blacklist_desc\": \"Управление заблокированными IP адресами и правилами.\",\n            \"enable_blacklist\": \"Включить защиту черным списком\",\n            \"block_msg_label\": \"Сообщение блокировки\",\n            \"block_msg_desc\": \"Контент ответа, возвращаемый заблокированным клиентам.\",\n            \"whitelist_title\": \"Белый список IP\",\n            \"whitelist_desc\": \"Управление доверенными IP адресами.\",\n            \"enable_whitelist\": \"Включить режим белого списка\",\n            \"whitelist_warning\": \"Внимание: Включение режима белого списка заблокирует ВСЕ запросы с IP, которых нет в белом списке. Если вы используете прокси, будьте осторожны, чтобы не заблокировать себя.\",\n            \"whitelist_priority\": \"Приоритет белого списка (переопределяет черный)\",\n            \"whitelist_priority_desc\": \"Если включено, IP из белого списка будут разрешены, даже если они совпадают с правилами черного списка.\",\n            \"load_error\": \"Ошибка загрузки настроек\",\n            \"save_success\": \"Настройки сохранены\",\n            \"save_error\": \"Ошибка сохранения настроек\"\n        }\n    },\n    \"user_token\": {\n        \"title\": \"Управление токенами пользователей\",\n        \"total_users\": \"Всего пользователей\",\n        \"active_tokens\": \"Активные токены\",\n        \"total_created\": \"Всего создано\",\n        \"create\": \"Создать токен\",\n        \"username\": \"Имя пользователя\",\n        \"token\": \"Токен\",\n        \"expires\": \"Истекает\",\n        \"usage\": \"Использование\",\n        \"ip_limit\": \"Лимит IP\",\n        \"created\": \"Создан\",\n        \"today_requests\": \"Запросы за сегодня\",\n        \"never\": \"Никогда\",\n        \"renew\": \"Продлить\",\n        \"renew_button\": \"Продлить\",\n        \"unlimited\": \"Безлимитно\",\n        \"create_title\": \"Создание нового токена\",\n        \"description\": \"Описание\",\n        \"curfew\": \"Комендантский час (время недоступности)\",\n        \"edit_title\": \"Редактирование токена\",\n        \"username_required\": \"Имя пользователя обязательно\",\n        \"renew_success\": \"Успешно продлен\",\n        \"expires_day\": \"1 день\",\n        \"expires_week\": \"1 неделя\",\n        \"expires_month\": \"1 месяц\",\n        \"expires_never\": \"Никогда\",\n        \"no_data\": \"Токены не найдены\",\n        \"placeholder_username\": \"напр. user1\",\n        \"placeholder_desc\": \"Необязательные заметки\",\n        \"placeholder_max_ips\": \"0 = Безлимитно\",\n        \"hint_max_ips\": \"0 означает безлимитно\",\n        \"hint_curfew\": \"Оставьте пустым, чтобы отключить. По времени сервера.\"\n    }\n}"
  },
  {
    "path": "src/locales/tr.json",
    "content": "{\n    \"common\": {\n        \"empty\": \"Boş\",\n        \"loading\": \"Yükleniyor...\",\n        \"add\": \"Ekle\",\n        \"copy\": \"Kopyala\",\n        \"action\": \"İşlem\",\n        \"save\": \"Kaydet\",\n        \"saved\": \"Başarıyla kaydedildi\",\n        \"cancel\": \"İptal\",\n        \"confirm\": \"Onayla\",\n        \"close\": \"Kapat\",\n        \"delete\": \"Sil\",\n        \"edit\": \"Düzenle\",\n        \"refresh\": \"Yenile\",\n        \"refreshing\": \"Yenileniyor...\",\n        \"export\": \"Dışa Aktar\",\n        \"import\": \"İçe Aktar\",\n        \"success\": \"Başarılı\",\n        \"error\": \"Hata\",\n        \"unknown\": \"Bilinmiyor\",\n        \"warning\": \"Uyarı\",\n        \"info\": \"Bilgi\",\n        \"details\": \"Detaylar\",\n        \"example\": \"Example\",\n        \"clear\": \"Temizle\",\n        \"prev_page\": \"Önceki\",\n        \"next_page\": \"Sonraki\",\n        \"pagination_info\": \"{{total}} kayıttan {{start}} - {{end}} arası gösteriliyor\",\n        \"per_page\": \"Sayfa başına\",\n        \"items\": \"öğe\",\n        \"accounts\": \"hesap\",\n        \"enabled\": \"Etkin\",\n        \"disabled\": \"Devre Dışı\",\n        \"tauri_api_not_loaded\": \"Tauri API yüklenemedi, lütfen uygulamayı yeniden başlatın\",\n        \"environment_error\": \"Ortam hatası: {{error}}\",\n        \"submit\": \"Gönder\",\n        \"update\": \"Güncelle\",\n        \"load_failed\": \"Yükleme başarısız\",\n        \"create_success\": \"Başarıyla oluşturuldu\",\n        \"update_success\": \"Başarıyla güncellendi\",\n        \"delete_success\": \"Başarıyla silindi\",\n        \"copied\": \"Panoya kopyalandı\"\n    },\n    \"nav\": {\n        \"dashboard\": \"Kontrol Paneli\",\n        \"accounts\": \"Hesaplar\",\n        \"proxy\": \"API Proxy\",\n        \"token_stats\": \"İstatistikler\",\n        \"call_records\": \"Trafik Günlükleri\",\n        \"security\": \"IP Yönetimi\",\n        \"settings\": \"Ayarlar\",\n        \"theme_to_dark\": \"Karanlık Moda Geç\",\n        \"theme_to_light\": \"Aydınlık Moda Geç\",\n        \"switch_to_english\": \"İngilizce'ye Geç\",\n        \"switch_to_chinese\": \"Çince'ye Geç\",\n        \"switch_to_english_short\": \"EN\",\n        \"switch_to_chinese_short\": \"ZH\",\n        \"switch_to_japanese\": \"Japonca'ya Geç\",\n        \"switch_to_japanese_short\": \"JA\",\n        \"switch_to_turkish\": \"Türkçe'ye Geç\",\n        \"switch_to_turkish_short\": \"TR\",\n        \"switch_to_vietnamese\": \"Vietnamca'ya Geç\",\n        \"switch_to_vietnamese_short\": \"VI\",\n        \"switch_to_russian\": \"Rusça'ya Geç\",\n        \"switch_to_russian_short\": \"RU\",\n        \"switch_to_portuguese\": \"Portekizce'ye Geç\",\n        \"switch_to_portuguese_short\": \"PT\",\n        \"switch_to_traditional_chinese\": \"Geleneksel Çince'ye Geç\",\n        \"switch_to_traditional_chinese_short\": \"TW\",\n        \"switch_to_korean\": \"Korece'ye Geç\",\n        \"switch_to_korean_short\": \"KO\",\n        \"switch_to_spanish\": \"İspanyolca'ya Geç\",\n        \"switch_to_spanish_short\": \"ES\",\n        \"switch_to_malay\": \"Malayca'ya Geç\",\n        \"switch_to_malay_short\": \"MY\",\n        \"user_token\": \"Kullanıcı Tokenları\"\n    },\n    \"dashboard\": {\n        \"hello\": \"Merhaba, Kullanıcı 👋\",\n        \"refresh_quota\": \"Kotayı Yenile\",\n        \"refreshing\": \"Yenileniyor...\",\n        \"total_accounts\": \"Toplam Hesap\",\n        \"avg_gemini\": \"Ort. Gemini Kotası\",\n        \"avg_gemini_image\": \"Ort. Gemini Görsel Kotası\",\n        \"avg_claude\": \"Ort. Claude Kotası\",\n        \"low_quota_accounts\": \"Düşük Kotalı Hesaplar\",\n        \"quota_sufficient\": \"Kota Yeterli\",\n        \"quota_low\": \"Düşük Kota\",\n        \"quota_desc\": \"Kota < %20\",\n        \"current_account\": \"Mevcut Hesap\",\n        \"switch_account\": \"Hesap Değiştir\",\n        \"no_active_account\": \"Aktif Hesap Yok\",\n        \"best_accounts\": \"En İyi Hesaplar\",\n        \"best_account_recommendation\": \"En İyi Hesap\",\n        \"switch_best\": \"En İyisine Geç\",\n        \"switch_successfully\": \"En İyisine Geç\",\n        \"view_all_accounts\": \"Tüm Hesapları Görüntüle\",\n        \"export_data\": \"Veri Dışa Aktar\",\n        \"for_gemini\": \"Gemini İçin\",\n        \"for_claude\": \"Claude İçin\",\n        \"toast\": {\n            \"switch_success\": \"Geçiş başarılı!\",\n            \"switch_error\": \"Hesap geçişi başarısız\",\n            \"refresh_success\": \"Kota yenileme başarılı\",\n            \"refresh_error\": \"Yenileme başarısız\",\n            \"export_no_accounts\": \"Dışa aktarılacak hesap yok\",\n            \"export_success\": \"Dışa aktarma başarılı! Dosya kaydedildi: {{path}}\",\n            \"export_error\": \"Dışa aktarma başarısız\"\n        }\n    },\n    \"accounts\": {\n        \"account\": \"Hesap\",\n        \"search_placeholder\": \"E-posta ara...\",\n        \"all\": \"Tümü\",\n        \"available\": \"Kullanılabilir\",\n        \"low_quota\": \"Düşük Kota\",\n        \"ultra\": \"ULTRA\",\n        \"pro\": \"PRO\",\n        \"free\": \"ÜCRETSİZ\",\n        \"edit_label\": \"Etiketi Düzenle\",\n        \"custom_label_placeholder\": \"Özel etiket girin\",\n        \"label_updated\": \"Etiket güncellendi\",\n        \"add_account\": \"Hesap Ekle\",\n        \"refresh_all\": \"Tümünü Yenile\",\n        \"refresh_selected\": \"Yenile ({{count}})\",\n        \"export_selected\": \"Dışa Aktar ({{count}})\",\n        \"delete_selected\": \"Sil ({{count}})\",\n        \"current\": \"Mevcut\",\n        \"current_badge\": \"Mevcut\",\n        \"disabled\": \"Devre Dışı\",\n        \"disabled_tooltip\": \"Hesap devre dışı (örn. refresh_token iptal edildi/süresi doldu). Yeniden yetkilendirin veya token'ı güncelleyin.\",\n        \"proxy_disabled\": \"Proxy Devre Dışı\",\n        \"proxy_disabled_tooltip\": \"Bu hesabın proxy'si manuel olarak devre dışı bırakıldı, API isteklerini işlemez ancak uygulamada kullanılabilir durumda kalır.\",\n        \"enable_proxy\": \"Proxy'yi Etkinleştir\",\n        \"disable_proxy\": \"Proxy'yi Devre Dışı Bırak\",\n        \"enable_proxy_selected\": \"Etkinleştir ({{count}})\",\n        \"disable_proxy_selected\": \"Devre Dışı Bırak ({{count}})\",\n        \"proxy_disabled_reason_manual\": \"Kullanıcı tarafından manuel olarak devre dışı bırakıldı\",\n        \"proxy_disabled_reason_batch\": \"Toplu işlemle devre dışı bırakıldı\",\n        \"forbidden\": \"403\",\n        \"forbidden_badge\": \"403\",\n        \"forbidden_tooltip\": \"API 403 Forbidden döndürdü, hesap Gemini Code Assist için izne sahip değil\",\n        \"forbidden_msg\": \"Yasaklı, otomatik yenileme atlandı\",\n        \"status\": {\n            \"forbidden\": \"403 Yasaklandı\",\n            \"disabled\": \"Hesap Devre Dışı\",\n            \"proxy_disabled\": \"Proxy Devre Dışı\"\n        },\n        \"error_details\": \"Hata Detayları\",\n        \"error_status\": \"Hata Durumu\",\n        \"error_time\": \"Tespit Süresi\",\n        \"view_error\": \"Nedeni Görüntüle\",\n        \"click_to_verify\": \"Doğrulamak için tıklayın\",\n        \"no_data\": \"Veri Yok\",\n        \"last_used\": \"Son Kullanım\",\n        \"reset_time\": \"Sıfırlama Zamanı\",\n        \"switch_to\": \"Bu hesaba geç\",\n        \"actions\": \"İşlemler\",\n        \"device_fingerprint\": \"Cihaz Parmak İzi\",\n        \"show_all_quotas\": \"Tüm kotaları göster\",\n        \"device_fingerprint_dialog\": {\n            \"title\": \"Cihaz Parmak İzi\",\n            \"operations\": \"Cihaz Parmak İzi İşlemleri\",\n            \"generate_and_bind\": \"Oluştur ve Bağla\",\n            \"restore_original\": \"Orijinali Geri Yükle\",\n            \"open_storage_directory\": \"Depolama Dizini Aç\",\n            \"current_storage\": \"Mevcut Depolama\",\n            \"effective\": \"Etkin\",\n            \"current_storage_desc\": \"storage.json'den oku (hesap değiştirirken bağlama uygulandıktan sonra güncellenir)\",\n            \"account_binding\": \"Hesap Bağlama\",\n            \"pending_application\": \"Uygulama Bekliyor\",\n            \"account_binding_desc\": \"Oluşturma/geri yükleme sonrasında bağlama olarak kaydedilir, hesap değiştirirken storage.json'e yazılır\",\n            \"historical_fingerprints\": \"Geçmiş Parmak İzleri (isteğe bağlı geri yükle/sil)\",\n            \"no_history\": \"Geçmiş Yok\",\n            \"current\": \"Mevcut\",\n            \"restore\": \"Geri Yükle\",\n            \"delete_version\": \"Bu sürümü sil\",\n            \"confirm_generate_title\": \"Oluşturup bağlamak istediğinize emin misiniz?\",\n            \"confirm_generate_desc\": \"Yeni bir cihaz parmak izi seti oluşturacak ve mevcut parmak izi olarak ayarlayacak. Devam etmek istediğinize emin misiniz?\",\n            \"confirm_restore_title\": \"Orijinal parmak izini geri yüklemek istediğinize emin misiniz?\",\n            \"confirm_restore_desc\": \"Orijinal parmak izini geri yükleyecek ve mevcut parmak izini üzerine yazacak. Devam etmek istediğinize emin misiniz?\",\n            \"cancel\": \"İptal\",\n            \"confirm\": \"Onayla\",\n            \"processing\": \"İşleniyor...\",\n            \"loading\": \"Yükleniyor...\",\n            \"failed_to_load_device_info\": \"Cihaz bilgileri yüklenemedi\",\n            \"generation_failed\": \"Oluşturma başarısız\",\n            \"binding_failed\": \"Bağlama başarısız\",\n            \"restoration_failed\": \"Geri yükleme başarısız\",\n            \"deletion_failed\": \"Silme başarısız\",\n            \"directory_open_failed\": \"Dizin açılamıyor\",\n            \"generated_and_bound\": \"Oluşturuldu ve bağlandı\",\n            \"restored\": \"Geri yüklendi\",\n            \"deleted\": \"Silindi\",\n            \"directory_opened\": \"Depolama dizini açıldı\",\n            \"original_fingerprint_not_found\": \"Orijinal parmak izi bulunamadı\",\n            \"storage_json_not_found\": \"storage.json bulunamadı, lütfen Antigravity'nin yapılandırma dosyasını oluşturmak için çalıştırıldığından emin olun\"\n        },\n        \"quota_protected\": \"Korumalı\",\n        \"details\": {\n            \"title\": \"Kota Detayları\",\n            \"model_quota\": \"Model Kotası\",\n            \"protected_models\": \"Korumalı Modeller\"\n        },\n        \"toast\": {\n            \"proxy_enabled\": \"{{count}} hesap için proxy etkinleştirildi\",\n            \"proxy_disabled\": \"{{count}} hesap için proxy devre dışı bırakıldı\"\n        },\n        \"add\": {\n            \"title\": \"Hesap Ekle\",\n            \"tabs\": {\n                \"oauth\": \"OAuth\",\n                \"token\": \"Refresh Token\",\n                \"import\": \"DB İçe Aktar\"\n            },\n            \"oauth\": {\n                \"recommend\": \"Önerilen\",\n                \"desc\": \"Google girişi için varsayılan tarayıcıyı açar ve Token'ı otomatik olarak alıp kaydeder.\",\n                \"btn_start\": \"OAuth Başlat\",\n                \"btn_waiting\": \"Yetkilendirme bekleniyor...\",\n                \"btn_finish\": \"Zaten yetkilendirdim\",\n                \"copy_link\": \"Yetkilendirme Bağlantısını Kopyala\",\n                \"copied\": \"Kopyalandı\",\n                \"link_label\": \"Yetkilendirme URL'si\",\n                \"link_click_to_copy\": \"Kopyalamak için tıklayın\",\n                \"manual_hint\": \"Tarayıcı otomatik olarak yönlendirilmedi mi? Lütfen geri dönüş bağlantısını veya Yetkilendirme Kodunu buraya yapıştırın:\",\n                \"manual_placeholder\": \"Bağlantıyı veya kodu buraya yapıştırın...\",\n                \"error_no_flow\": \"Aktif kimlik doğrulama akışı bulunamadı. Lütfen OAuth'u yeniden başlatın.\",\n                \"web_hint\": \"Google giriş sayfası yeni bir pencerede açılacak\",\n                \"error_no_url\": \"OAuth URL'si alınamadı\",\n                \"popup_blocked\": \"Açılır pencere engellendi\",\n                \"manual_submitting\": \"Yetkilendirme kodu gönderiliyor...\",\n                \"manual_submitted\": \"Yetkilendirme kodu gönderildi, arka planda işleniyor...\"\n            },\n            \"token\": {\n                \"label\": \"Refresh Token\",\n                \"placeholder\": \"Refresh Token'ınızı buraya yapıştırın (Toplu işlem desteklenir)\\n\\nDesteklenen formatlar:\\n1. Tek Token (1//...)\\n2. JSON Dizisi (refresh_token alanı ile)\\n3. Token içeren herhangi bir metin (Otomatik çıkarma)\",\n                \"hint\": \"İpucu: Toplu içe aktarmak için birden fazla token veya JSON dizisi yapıştırabilirsiniz.\",\n                \"error_token\": \"Lütfen Refresh Token girin\",\n                \"batch_progress\": \"{{current}}/{{total}} hesap içe aktarılıyor...\",\n                \"batch_success\": \"{{count}} hesap başarıyla içe aktarıldı\",\n                \"batch_partial\": \"İçe aktarma tamamlandı: {{success}} başarılı, {{fail}} başarısız\",\n                \"batch_fail\": \"İçe aktarma başarısız\"\n            },\n            \"import\": {\n                \"scheme_a\": \"Plan A: IDE DB'den\",\n                \"scheme_a_desc\": \"Yerel Antigravity DB'den mevcut giriş yapmış hesabı otomatik olarak okur.\",\n                \"btn_db\": \"Mevcut Hesabı İçe Aktar\",\n                \"or\": \"VEYA\",\n                \"scheme_b\": \"Plan B: V1 Yedekten\",\n                \"scheme_b_desc\": \"V1 hesap verileri için ~/.antigravity-agent tarar.\",\n                \"btn_v1\": \"V1'i Toplu İçe Aktar\",\n                \"btn_custom_db\": \"Özel DB İçe Aktar\"\n            },\n            \"btn_cancel\": \"İptal\",\n            \"btn_confirm\": \"Onayla\",\n            \"oauth_error\": \"OAuth başarısız: {{error}}\",\n            \"status\": {\n                \"error_token\": \"Lütfen Refresh Token girin\"\n            }\n        },\n        \"table\": {\n            \"email\": \"E-posta\",\n            \"quota\": \"Model Kotası\",\n            \"last_used\": \"Son Kullanım\",\n            \"actions\": \"İşlemler\"\n        },\n        \"drag_to_reorder\": \"Yeniden sıralamak için sürükleyin\",\n        \"empty\": {\n            \"title\": \"Hesap Yok\",\n            \"desc\": \"İlk hesabınızı eklemek için yukarıdaki \\\"Hesap Ekle\\\" düğmesine tıklayın\"\n        },\n        \"views\": {\n            \"list\": \"Liste Görünümü\",\n            \"grid\": \"Izgara Görünümü\"\n        },\n        \"dialog\": {\n            \"add_title\": \"Hesap Ekle\",\n            \"batch_delete_title\": \"Toplu Silme Onayı\",\n            \"delete_title\": \"Silme Onayı\",\n            \"batch_delete_msg\": \"Seçili {{count}} hesabı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.\",\n            \"delete_msg\": \"Bu hesabı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.\",\n            \"refresh_title\": \"Kotayı Yenile\",\n            \"batch_refresh_title\": \"Toplu Yenileme\",\n            \"refresh_msg\": \"Mevcut hesap için kotayı yenilemek istediğinizden emin misiniz?\",\n            \"batch_refresh_msg\": \"Seçili {{count}} hesap için kotaları yenilemek istediğinizden emin misiniz? Bu biraz zaman alabilir.\",\n            \"disable_proxy_title\": \"Proxy'yi Devre Dışı Bırak\",\n            \"disable_proxy_msg\": \"Bu hesap için proxy'yi devre dışı bırakmak istediğinizden emin misiniz? Hesap uygulamada kullanılabilir durumda kalacaktır.\",\n            \"enable_proxy_title\": \"Proxy'yi Etkinleştir\",\n            \"enable_proxy_msg\": \"Bu hesap için proxy'yi yeniden etkinleştirmek istediğinizden emin misiniz?\"\n        }\n    },\n    \"settings\": {\n        \"save\": \"Ayarları Kaydet\",\n        \"tabs\": {\n            \"general\": \"Genel\",\n            \"account\": \"Hesap\",\n            \"proxy\": \"Proxy Ayarları\",\n            \"advanced\": \"Gelişmiş\",\n            \"about\": \"Hakkında\",\n            \"debug\": \"Hata Ayıklama\"\n        },\n        \"general\": {\n            \"title\": \"Genel Ayarlar\",\n            \"language\": \"Dil\",\n            \"theme\": \"Tema\",\n            \"theme_light\": \"Aydınlık\",\n            \"theme_dark\": \"Karanlık\",\n            \"theme_system\": \"Sistem\",\n            \"auto_launch\": \"Başlangıçta Çalıştır\",\n            \"auto_launch_enabled\": \"Etkin\",\n            \"auto_launch_disabled\": \"Devre Dışı\",\n            \"auto_launch_desc\": \"Sistem başladığında Antigravity Tools'u otomatik olarak başlat\"\n        },\n        \"account\": {\n            \"title\": \"Hesap Ayarları\",\n            \"auto_refresh\": \"Otomatik Kota Yenileme\",\n            \"auto_refresh_desc\": \"Tüm hesaplar için kota bilgilerini periyodik olarak otomatik yenile\",\n            \"refresh_interval\": \"Yenileme Aralığı (dakika)\",\n            \"auto_sync\": \"Mevcut Hesabı Otomatik Senkronize Et\",\n            \"auto_sync_desc\": \"Mevcut aktif hesap bilgilerini periyodik olarak otomatik senkronize et\",\n            \"sync_interval\": \"Senkronizasyon Aralığı (saniye)\"\n        },\n        \"pinned_quota_models\": {\n            \"title\": \"Sabitlenmiş Kota Modelleri\",\n            \"desc\": \"Hesap listesinde hangi model kotalarının görüntüleneceğini seçin. Seçilmeyen modeller yalnızca detay açılır penceresinde gösterilir\"\n        },\n        \"proxy\": {\n            \"title\": \"Proxy Ayarları\"\n        },\n        \"proxy_pool\": {\n            \"title\": \"Proxy Pool\",\n            \"strategy_priority\": \"Priority\",\n            \"strategy_round_robin\": \"Round Robin\",\n            \"strategy_random\": \"Random\",\n            \"strategy_least_connections\": \"Least Connections\",\n            \"test_all\": \"Test All\",\n            \"batch_import\": \"Import\",\n            \"binding_manager\": \"Bindings\",\n            \"add_proxy\": \"Add Proxy\",\n            \"edit_proxy\": \"Edit Proxy\",\n            \"name\": \"Name\",\n            \"url\": \"Proxy URL\",\n            \"username\": \"Username\",\n            \"password\": \"Password\",\n            \"max_accounts\": \"Max Accounts\",\n            \"max_accounts_hint\": \"0 = Unlimited\",\n            \"priority\": \"Priority\",\n            \"priority_hint\": \"Lower is better\",\n            \"health_check_url\": \"Health Check URL\",\n            \"tags\": \"Tags\",\n            \"add_tag_placeholder\": \"Add tag...\",\n            \"seconds\": \"Sec\",\n            \"test_completed\": \"Health check completed\",\n            \"test_failed\": \"Health check failed\",\n            \"confirm_delete\": \"Are you sure you want to delete this proxy?\",\n            \"empty\": \"No proxies available\",\n            \"column_priority\": \"Priority\",\n            \"column_status\": \"Status\",\n            \"column_details\": \"Proxy Details\",\n            \"column_bindings\": \"Bindings\",\n            \"import_title\": \"Batch Import Proxies\",\n            \"import_label\": \"Paste Proxy List (One per line)\",\n            \"import_hint\": \"Supported formats: protocol://user:pass@host:port, host:port:user:pass\",\n            \"import_preview\": \"Preview\",\n            \"import_confirm\": \"Import {{count}} Proxies\",\n            \"no_valid_proxies\": \"No valid proxies found\",\n            \"binding\": {\n                \"title\": \"Account Proxy Bindings\",\n                \"load_failed\": \"Failed to load bindings\",\n                \"unbind_success\": \"Unbound successfully\",\n                \"bind_success\": \"Bound successfully\",\n                \"update_failed\": \"Failed to update binding\",\n                \"assigned_proxy\": \"Assigned Proxy\",\n                \"default_strategy\": \"Default (Follow Strategy)\"\n            },\n            \"status\": {\n                \"inactive\": \"Inactive\",\n                \"checking\": \"Checking\",\n                \"healthy\": \"Healthy\",\n                \"timeout\": \"Timeout\"\n            }\n        },\n        \"advanced\": {\n            \"title\": \"Gelişmiş Ayarlar\",\n            \"export_path\": \"Varsayılan Dışa Aktarma Yolu\",\n            \"export_path_placeholder\": \"Ayarlanmadı (Her seferinde sor)\",\n            \"default_export_path_desc\": \"Dosyalar sormadan doğrudan bu klasöre kaydedilecek\",\n            \"select_btn\": \"Seç\",\n            \"open_btn\": \"Aç\",\n            \"data_dir\": \"Veri Dizini\",\n            \"data_dir_desc\": \"Hesap verileri ve yapılandırma dosyası konumu\",\n            \"antigravity_path\": \"Antigravity Yolu\",\n            \"antigravity_path_placeholder\": \"Ayarlanmadı (Otomatik algılama kullanılacak)\",\n            \"antigravity_path_desc\": \"Antigravity'yi standart olmayan bir konuma yüklediyseniz, buradan çalıştırılabilir dosya yolunu manuel olarak belirtebilirsiniz (MacOS'ta .app'i işaret eder).\",\n            \"antigravity_path_select\": \"Antigravity Çalıştırılabilir Dosyasını Seç\",\n            \"antigravity_path_detected\": \"Algılanan yol güncellendi\",\n            \"detect_btn\": \"Algıla\",\n            \"antigravity_args\": \"Antigravity Başlangıç Parametreleri\",\n            \"antigravity_args_placeholder\": \"--user-data-dir=/path/to/data --some-other-flag\",\n            \"antigravity_args_desc\": \"Antigravity için başlangıç parametreleri belirtin, örn. veri dizinini belirtmek için --user-data-dir\",\n            \"detect_args_btn\": \"Algıla\",\n            \"antigravity_args_detected\": \"Başlangıç parametreleri güncellendi\",\n            \"antigravity_args_detect_error\": \"Başlangıç parametreleri algılanamadı\",\n            \"accounts_page_size\": \"Hesaplar Sayfa Boyutu\",\n            \"page_size_auto\": \"Otomatik Hesapla (Önerilen)\",\n            \"page_size_desc\": \"Sayfa başına gösterilecek hesap sayısını ayarlayın. Pencere boyutuna göre dinamik ayarlama için 'Otomatik Hesapla'yı seçin.\",\n            \"logs_title\": \"Log Bakımı\",\n            \"logs_desc\": \"Log önbellek dosyalarını temizle. Hesap verilerini etkilemez.\",\n            \"clear_logs\": \"Log Önbelleğini Temizle\",\n            \"clear_logs_title\": \"Log Temizleme Onayı\",\n            \"clear_logs_msg\": \"Tüm log önbellek dosyalarını temizlemek istediğinizden emin misiniz?\",\n            \"logs_cleared\": \"Log önbelleği temizlendi\",\n            \"antigravity_cache_title\": \"Antigravity Önbellek Temizliği\",\n            \"antigravity_cache_desc\": \"Antigravity önbelleğini temizlemek giriş hatalarını, sürüm doğrulama hatalarını ve OAuth yetkilendirme hatalarını çözebilir.\",\n            \"antigravity_cache_warning\": \"Önbelleği temizlemeden önce Antigravity'nin tamamen kapatıldığından emin olun.\",\n            \"clear_antigravity_cache\": \"Antigravity Önbelleğini Temizle\",\n            \"clear_cache_confirm_title\": \"Antigravity Önbellek Temizliğini Onayla\",\n            \"clear_cache_confirm_msg\": \"Aşağıdaki önbellek dizinleri temizlenecek:\",\n            \"cache_cleared_success\": \"Önbellek temizlendi, {{size}} MB boşaltıldı\",\n            \"cache_not_found\": \"Antigravity önbellek dizinleri bulunamadı\",\n            \"debug_logs_title\": \"Hata ayıklama günlüğü\",\n            \"debug_logs_enable_desc\": \"Etkinleştirildiğinde, tam istek ve yanıt zinciri kaydedilir. Yalnızca sorun giderme sırasında etkinleştirmeniz önerilir.\",\n            \"debug_logs_desc\": \"Tam zinciri kaydeder: orijinal girdi, dönüştürülmüş v1internal isteği ve yukarı akış yanıtı. Yalnızca sorun giderme içindir, hassas veriler içerebilir.\",\n            \"debug_log_dir\": \"Hata ayıklama günlüğü çıktı dizini\",\n            \"debug_log_dir_hint\": \"Boş bırakılırsa varsayılan dizin kullanılır: {{path}}/debug_logs\",\n            \"debug_log_dir_select\": \"Hata ayıklama günlüğü çıktı dizinini seç\",\n            \"http_api_title\": \"HTTP API Servisi\",\n            \"http_api_desc\": \"Harici programlar (örn. VS Code eklentileri) için yerel HTTP arayüzü sağlar.\",\n            \"http_api_enabled\": \"HTTP API'yi Etkinleştir\",\n            \"http_api_enabled_desc\": \"Etkinleştirildiğinde, harici programlar HTTP arayüzü üzerinden hesapları yönetebilir\",\n            \"http_api_port\": \"Dinleme Portu\",\n            \"http_api_port_desc\": \"Port değiştirildikten sonra uygulamanın yeniden başlatılması gerekir. Port çakışması durumunda başka bir kullanılabilir port kullanın.\",\n            \"http_api_port_placeholder\": \"Varsayılan port 19527\",\n            \"http_api_port_invalid\": \"Geçersiz port numarası (aralık: 1024-65535)\",\n            \"http_api_settings_saved\": \"HTTP API ayarları kaydedildi, uygulamak için yeniden başlatma gerekli\",\n            \"http_api_restart_required\": \"⚠️ Uygulamak için yeniden başlatma gerekli\"\n        },\n        \"menu\": {\n            \"title\": \"Menü Görünüm Ayarları\",\n            \"desc\": \"Menü çubuğunda gösterilecek işlev öğelerini seçin. Sık kullanılmayan menüleri gizleyerek alan tasarrufu yapabilirsiniz.\",\n            \"selected_items_note\": \"Seçili öğeler üst menü çubuğunda görüntülenecektir.\",\n            \"required\": \"Zorunlu\"\n        },\n        \"about\": {\n            \"title\": \"Hakkında\",\n            \"version\": \"Uygulama Sürümü\",\n            \"tech_stack\": \"Teknoloji Yığını\",\n            \"author\": \"Yazar\",\n            \"wechat\": \"WeChat\",\n            \"github\": \"GitHub\",\n            \"view_code\": \"Kodu Görüntüle\",\n            \"copyright\": \"Telif Hakkı © 2025-2026 Antigravity. Tüm hakları saklıdır.\",\n            \"check_update\": \"Güncellemeleri Kontrol Et\",\n            \"checking_update\": \"Kontrol ediliyor...\",\n            \"latest_version\": \"Güncelsiniz\",\n            \"new_version_available\": \"Yeni sürüm {{version}} mevcut\",\n            \"download_update\": \"İndir\",\n            \"update_check_failed\": \"Güncelleme kontrolü başarısız\",\n            \"support_btn\": \"Yazarı Destekle\",\n            \"support_title\": \"Bağış ve Destek\",\n            \"support_desc\": \"Bu projeyi yararlı buluyorsanız, yazara bir kahve ısmarlayabilirsiniz! Desteğiniz, bu projeyi sürdürmek için en büyük motivasyon kaynağımdır.\",\n            \"support_alipay\": \"Alipay\",\n            \"support_wechat\": \"WeChat Pay\",\n            \"support_buymeacoffee\": \"Buy Me a Coffee\"\n        },\n        \"advanced_thinking\": {\n            \"title\": \"Gelişmiş Düşünme ve Küresel Yapılandırma\",\n            \"description\": \"Düşünme yeteneklerini, görüntü modlarını ve küresel talimatları merkezi olarak yönetin.\"\n        },\n        \"thinking_budget\": {\n            \"title\": \"Düşünme Bütçesi (Thinking Budget)\",\n            \"description\": \"Yapay zeka derin düşüncesi için token bütçesini kontrol eder. Bazı modeller (örn: Flash, -thinking ekine sahip modeller) üst akış API'si tarafından 24576 ile sınırlandırılmıştır.\",\n            \"mode_label\": \"İşleme Modu\",\n            \"mode\": {\n                \"auto\": \"Otomatik Sınır\",\n                \"passthrough\": \"Doğrudan Geçiş\",\n                \"custom\": \"Özel\"\n            },\n            \"auto_hint\": \"Otomatik Mod: API hatalarını önlemek için Flash modelleri, -thinking ekine sahip modeller ve web arama istekleri için bütçeyi otomatik olarak 24576 ile sınırlar.\",\n            \"passthrough_warning\": \"Doğrudan Geçiş: Çağıranın orijinal değerini doğrudan kullanır. Yüksek değerlerin desteklenmemesi hatalara neden olabilir.\",\n            \"custom_value_hint\": \"Önerilen: 24576 (Flash) veya 51200 (Genişletilmiş)\",\n            \"tokens\": \"tokens\"\n        },\n        \"image_thinking_mode\": {\n            \"title\": \"Görüntü Düşünme Modu (Image Thinking Mode)\",\n            \"hint\": \"Görüntü kalitesini ve oluşturma sürecini etkiler\",\n            \"options\": {\n                \"enabled\": \"Etkin\",\n                \"disabled\": \"Devre Dışı\",\n                \"enabled_desc\": \"Açık: Düşünce zincirini korur ve iki görüntü döndürür (taslak + final).\",\n                \"disabled_desc\": \"Kapalı: Düşünce zincirini devre dışı bırakır ve tek bir yüksek kaliteli görüntü oluşturur (kalite önceliği).\"\n            }\n        },\n        \"global_system_prompt\": {\n            \"title\": \"Küresel Sistem Talimatları (Global System Prompt)\",\n            \"hint\": \"Tüm istekler için systemInstruction'a otomatik olarak eklenir\",\n            \"placeholder\": \"Küresel sistem talimatlarını buraya girin...\\nÖrn: Sen React ve Rust konusunda uzman, deneyimli bir full-stack geliştiricisin. Türkçe cevap ver.\",\n            \"char_count\": \"{{count}} karakter\",\n            \"long_prompt_warning\": \"Talimatlar çok uzun (2000 karakterden fazla) ve çok fazla bağlam alanı tüketebilir.\"\n        }\n    },\n    \"tray\": {\n        \"current\": \"Mevcut\",\n        \"quota\": \"Kota\",\n        \"switch_next\": \"Sonraki Hesaba Geç\",\n        \"refresh_current\": \"Mevcut Kotayı Yenile\",\n        \"show_window\": \"Ana Pencereyi Göster\",\n        \"quit\": \"Uygulamadan Çık\",\n        \"no_account\": \"Hesap Yok\",\n        \"unknown_quota\": \"Bilinmiyor (Yenilemek için tıklayın)\",\n        \"forbidden\": \"Hesap Yasaklı\"\n    },\n    \"proxy\": {\n        \"title\": \"API Proxy Hizmeti\",\n        \"status\": {\n            \"running\": \"Hizmet Çalışıyor\",\n            \"stopped\": \"Hizmet Durduruldu\",\n            \"accounts_available\": \"{{count}} Hesap Kullanılabilir\",\n            \"processing\": \"İşleniyor...\"\n        },\n        \"action\": {\n            \"start\": \"Hizmeti Başlat\",\n            \"stop\": \"Hizmeti Durdur\"\n        },\n        \"config\": {\n            \"title\": \"Hizmet Yapılandırması\",\n            \"request\": {\n                \"user_agent\": \"User-Agent Override\",\n                \"user_agent_tooltip\": \"Override the User-Agent header sent to upstream APIs. Leave empty to use default.\",\n                \"user_agent_hint\": \"Current Default: antigravity/<version> <os>/<arch>\",\n                \"user_agent_placeholder\": \"Enter custom User-Agent string...\"\n            },\n            \"port\": \"Dinleme Portu\",\n            \"port_tooltip\": \"Yerel API Proxy'nin dinlediği TCP portu. Değiştirmek için hizmeti durdurun, ardından uygulamak için yeniden başlatın.\",\n            \"port_hint\": \"Varsayılan 8045, değişiklikleri uygulamak için yeniden başlatma gerekir\",\n            \"auto_start\": \"Uygulama ile Otomatik Başlat\",\n            \"auto_start_tooltip\": \"Uygulama başladığında yerel API Proxy hizmetini otomatik olarak başlatır.\",\n            \"allow_lan_access\": \"LAN Erişimine İzin Ver\",\n            \"allow_lan_access_tooltip\": \"Etkinleştirildiğinde, hizmet 0.0.0.0'a bağlanır böylece LAN'ınızdaki diğer cihazlar erişebilir. Yetkilendirmeyi etkin tutun ve API anahtarınızı koruyun; uygulamak için yeniden başlatma gerekir.\",\n            \"allow_lan_access_hint_enabled\": \"🌐 0.0.0.0 dinleniyor, LAN cihazları erişebilir\",\n            \"allow_lan_access_hint_disabled\": \"🔒 Sadece 127.0.0.1 dinleniyor, localhost erişimi (Gizlilik Öncelikli)\",\n            \"allow_lan_access_warning\": \"⚠️ Etkinleştirildiğinde LAN cihazları erişebilir. API anahtarınızı güvende tutun\",\n            \"allow_lan_access_restart_hint\": \"ℹ️ Değişiklikleri uygulamak için hizmet yeniden başlatması gerekir\",\n            \"api_key\": \"API Anahtarı\",\n            \"api_key_tooltip\": \"Proxy yetkilendirmesi etkinleştirildiğinde istemciler tarafından kullanılan paylaşılan gizli anahtar. Anahtarı yeniden oluşturmak eskisini hemen geçersiz kılar.\",\n            \"btn_regenerate\": \"Anahtarı Yeniden Oluştur\",\n            \"btn_edit\": \"Düzenle\",\n            \"btn_save\": \"Kaydet\",\n            \"btn_copy\": \"Kopyala\",\n            \"btn_copied\": \"Kopyalandı\",\n            \"warning_key\": \"Not: API anahtarınızı güvende tutun. Paylaşmayın.\",\n            \"api_key_invalid\": \"Geçersiz API anahtarı formatı, sk- ile başlamalı ve en az 10 karakter uzunluğunda olmalıdır\",\n            \"api_key_updated\": \"API anahtarı güncellendi\",\n            \"admin_password\": \"Web UI Yönetici Parolası\",\n            \"admin_password_tooltip\": \"Web yönetim konsolunda oturum açmak için kullanılan parola. Boş bırakılırsa varsayılan olarak API Anahtarı kullanılır.\",\n            \"admin_password_default\": \"(API Anahtarı ile aynı)\",\n            \"admin_password_placeholder\": \"Yeni parolanızı girin, API Anahtarı kullanmak için boş bırakın\",\n            \"admin_password_hint\": \"İpucu: Docker/Web dağıtım senaryolarında, API Anahtarınızın güvenliğini artırmak için ayrı bir oturum açma parolası ayarlayabilirsiniz.\",\n            \"admin_password_short\": \"Parola çok kısa (en az 4 karakter)\",\n            \"admin_password_updated\": \"Web UI oturum açma parolası güncellendi\",\n            \"auth\": {\n                \"title\": \"Yetkilendirme\",\n                \"title_tooltip\": \"Gelen isteklerin kimlik doğrulamasının gerekli olup olmadığını ve hangi rotaların korunduğunu kontrol eder.\",\n                \"enabled\": \"Etkin\",\n                \"enabled_tooltip\": \"Yetkilendirme modunu değiştirerek yetkilendirmeyi açar/kapatır. Etkinleştirildiğinde, istemciler API anahtarını Authorization: Bearer <API_KEY> veya x-api-key ile dahil etmelidir.\",\n                \"mode\": \"Mod\",\n                \"mode_tooltip\": \"Hangi rotaların API anahtarı gerektirdiğini seçer: Off = yetkilendirme yok; All = her şeyi koru; All except Health = /healthz açık kalır; Auto = Sadece localhost için Off, aksi takdirde All except Health.\",\n                \"hint\": \"Etkinleştirildiğinde, istemciler API anahtarını Authorization: Bearer ... ile göndermelidir (health seçiliyse hariç).\",\n                \"modes\": {\n                    \"off\": \"Kapalı (Açık)\",\n                    \"strict\": \"Tümü (Katı)\",\n                    \"all_except_health\": \"Health Hariç Tümü\",\n                    \"auto\": \"Otomatik (Önerilen)\"\n                }\n            },\n            \"zai\": {\n                \"title\": \"z.ai (GLM) Sağlayıcısı\",\n                \"title_tooltip\": \"Sadece Claude protokolü için isteğe bağlı Anthropic-uyumlu upstream. Yalnızca Anthropic endpoint'lerini etkiler; Google hesap yönlendirmesi değişmeden kalır.\",\n                \"subtitle\": \"Sadece Claude protokolü için isteğe bağlı Anthropic-uyumlu upstream.\",\n                \"enabled\": \"Etkin\",\n                \"enabled_tooltip\": \"Seçilen dağıtım moduna göre Anthropic istekleri için z.ai yönlendirmesini etkinleştirir.\",\n                \"base_url\": \"Temel URL\",\n                \"base_url_tooltip\": \"Anthropic-uyumlu temel URL. Proxy /v1/messages gibi yolları ekler. Özel bir ağ geçidi kullanmıyorsanız varsayılanı bırakın.\",\n                \"dispatch_mode\": \"Dağıtım Modu\",\n                \"dispatch_mode_tooltip\": \"Anthropic istekleri için z.ai'nin ne zaman kullanılacağını kontrol eder: Off devre dışı bırakır; All Anthropic requests her şeyi yönlendirir; Pooled Google hesaplarıyla round-robin'de bir slot olarak z.ai ekler; Fallback sadece Google hesabı olmadığında z.ai kullanır.\",\n                \"api_key\": \"API Anahtarı\",\n                \"api_key_tooltip\": \"z.ai'ye istekleri doğrulamak için kullanılan API anahtarı. Yerel olarak saklanır ve z.ai ve MCP özellikleri için gereklidir.\",\n                \"api_key_placeholder\": \"z.ai API anahtarınızı buraya yapıştırın\",\n                \"warning\": \"Not: Bu anahtar yerel olarak uygulama veri dizininde saklanır.\",\n                \"models\": {\n                    \"title\": \"Model Eşleme\",\n                    \"title_tooltip\": \"Mevcut z.ai model kimliklerini alın ve gelen Anthropic/Claude model adlarının z.ai model kimliklerine nasıl çevrileceğini yapılandırın.\",\n                    \"refresh\": \"Modelleri getir\",\n                    \"btn_edit\": \"Düzenle\",\n                    \"btn_save\": \"Kaydet\",\n                    \"refreshing\": \"Getiriliyor...\",\n                    \"hint\": \"Kullanılabilir modeller: {{count}}. Bir öneri seçin veya özel model kimliği yazın.\",\n                    \"error\": \"Modeller getirilemedi: {{error}}\",\n                    \"select_placeholder\": \"Model seçin...\",\n                    \"opus\": \"Opus ailesi → z.ai modeli\",\n                    \"opus_tooltip\": \"Gelen model \\\"opus\\\" içerdiğinde kullanılan varsayılan z.ai model kimliği (örn. claude-opus-*).\",\n                    \"sonnet\": \"Sonnet ailesi → z.ai modeli\",\n                    \"sonnet_tooltip\": \"Diğer Claude modelleri için kullanılan varsayılan z.ai model kimliği (örn. claude-sonnet-* ve çoğu claude-* isteği).\",\n                    \"haiku\": \"Haiku ailesi → z.ai modeli\",\n                    \"haiku_tooltip\": \"Gelen model \\\"haiku\\\" içerdiğinde kullanılan varsayılan z.ai model kimliği (örn. claude-haiku-*).\",\n                    \"advanced_title\": \"Gelişmiş geçersiz kılmalar\",\n                    \"advanced_tooltip\": \"İsteğe bağlı tam eşleşme geçersiz kılmaları. Gelen bir model dizesi bir kural anahtarıyla eşleşirse, eşlenen z.ai model kimliğiyle değiştirilir.\",\n                    \"from_label\": \"Gelen model\",\n                    \"to_label\": \"z.ai modeli\",\n                    \"add_rule\": \"Ekle\",\n                    \"empty\": \"Geçersiz kılma kuralı yapılandırılmadı.\",\n                    \"from_placeholder\": \"Örn: claude-3-opus\",\n                    \"to_placeholder\": \"Örn: glm-4\"\n                },\n                \"modes\": {\n                    \"off\": \"Kapalı\",\n                    \"exclusive\": \"Tüm Anthropic istekleri\",\n                    \"pooled\": \"Havuzlanmış (bir slot)\",\n                    \"fallback\": \"Sadece Yedek\"\n                },\n                \"mcp\": {\n                    \"title\": \"MCP Sunucuları (yerel proxy üzerinden)\",\n                    \"title_tooltip\": \"MCP istemcilerinin bağlanabilmesi için bu yerel proxy'de isteğe bağlı /mcp/* endpoint'lerini açığa çıkarır. Yalnızca hizmet çalışırken, z.ai yapılandırıldığında ve ilgili anahtarlar etkinleştirildiğinde kullanılabilir.\",\n                    \"enabled\": \"MCP proxy'yi etkinleştir\",\n                    \"enabled_tooltip\": \"MCP endpoint'leri için ana anahtar. Kapalı olduğunda, tüm /mcp/* rotaları 404 döndürür.\",\n                    \"web_search\": \"Web Araması\",\n                    \"web_search_tooltip\": \"/mcp/web_search_prime/mcp açığa çıkarır ve istekleri z.ai Web Search MCP upstream'ine yönlendirir.\",\n                    \"web_reader\": \"Web Okuyucu\",\n                    \"web_reader_tooltip\": \"/mcp/web_reader/mcp açığa çıkarır ve istekleri z.ai Web Reader MCP upstream'ine yönlendirir.\",\n                    \"vision\": \"Görsel\",\n                    \"vision_tooltip\": \"z.ai tarafından desteklenen görsel araçları sağlayan /mcp/zai-mcp-server/mcp (yerel MCP sunucusu) açığa çıkarır.\",\n                    \"local_endpoints\": \"Yerel endpoint'ler (MCP istemcinizi bu URL'leri kullanacak şekilde yapılandırın):\",\n                    \"local_endpoints_tooltip\": \"MCP istemcinizde bu URL'leri kullanın. API Proxy ile aynı host/port'u paylaşırlar ve proxy yetkilendirme politikasını takip ederler.\"\n                }\n            },\n            \"request_timeout\": \"İstek Zaman Aşımı\",\n            \"request_timeout_tooltip\": \"Proxy'nin streaming dahil upstream yanıtı için beklediği maksimum süre (saniye). Uzun üretimler için artırın; uygulamak için yeniden başlatma gerekir.\",\n            \"request_timeout_hint\": \"Varsayılan 120s, aralık 30-7200s. Değişiklikleri uygulamak için hizmeti yeniden başlatın.\",\n            \"enable_logging\": \"İstek Loglamayı Etkinleştir\",\n            \"enable_logging_hint\": \"Hata ayıklama için geçmişi kaydet (Küçük performans maliyeti)\",\n            \"upstream_proxy\": {\n                \"title\": \"Global Upstream Proxy (Global Proxy)\",\n                \"desc\": \"Etkinleştirildiğinde, tüm harici istekler (API Proxy, jeton yenileme, kota kontrolü, güncelleme kontrolü) bu proxy üzerinden yönlendirilecektir.\",\n                \"desc_short\": \"Proxy havuzunda uygun hesap bulunamadığında yedek çözüm olarak kullanılan genel proxy.\",\n                \"enable\": \"Üst düzey proxy'yi etkinleştir\",\n                \"url\": \"Proxy URL'si\",\n                \"url_placeholder\": \"örn. http://127.0.0.1:7890 veya socks5://127.0.0.1:7890\",\n                \"tip\": \"HTTP, HTTPS ve SOCKS5 desteği.\",\n                \"socks5h_hint\": \"Engellemeleri önlemek ve uzaktan DNS çözümlemesini (Remote DNS) korumak için protokolü manuel olarak socks5h:// şeklinde değiştirin.\",\n                \"validation_error\": \"Üst düzey proxy etkinleştirildiğinde proxy URL'si gereklidir\",\n                \"restart_hint\": \"Proxy ayarları kaydedildi. Değişiklikleri uygulamak için uygulamayı yeniden başlatın.\"\n            },\n            \"scheduling\": {\n                \"title\": \"Hesap Rotasyonu ve Zamanlama\",\n                \"title_tooltip\": \"Oturumların hesaplara nasıl bağlandığını ve oran limitlerinin nasıl işlendiğini kontrol eder.\",\n                \"subtitle\": \"Tüm protokoller (OpenAI/Gemini/Claude) için Prompt Önbelleğini ve oran limiti işlemeyi optimize eder.\",\n                \"mode\": \"Zamanlama Modu\",\n                \"mode_tooltip\": \"Cache-First: Oturumu hesaba bağla, oran limitinde bekle (önbellek kullanımını maksimize et); Balance: Oturumu bağla, oran limitinde hesap değiştir; Performance: Standart Round-robin.\",\n                \"modes\": {\n                    \"CacheFirst\": \"Önbellek Öncelikli\",\n                    \"Balance\": \"Dengeli\",\n                    \"PerformanceFirst\": \"Performans\"\n                },\n                \"modes_desc\": {\n                    \"CacheFirst\": \"Oturumu hesaba bağlar, sınırlandırıldığında hassas şekilde bekler (Prompt Önbellek isabetlerini maksimize eder).\",\n                    \"Balance\": \"Oturumu bağlar, sınırlandırıldığında otomatik olarak kullanılabilir hesaba geçer (Dengeli önbellek ve kullanılabilirlik).\",\n                    \"PerformanceFirst\": \"Oturum bağlama yok, saf round-robin rotasyon (Yüksek eşzamanlılık için en iyi).\"\n                },\n                \"max_wait\": \"Maks Bekleme (sn)\",\n                \"max_wait_tooltip\": \"Yalnızca 'Önbellek Öncelikli' modunda kullanılır: oran limiti sıfırlama zamanı bu değerin altındaysa geçiş yapmak yerine bekle.\",\n                \"clear_bindings\": \"Oturum Bağlantılarını Temizle\",\n                \"clear_bindings_tooltip\": \"Tüm oturum ve hesap bağlantılarını hemen kesin, bir sonraki istekte hesapları yeniden atanmaya zorlayın.\",\n                \"fixed_account\": \"Sabit Hesap Modu\",\n                \"fixed_account_tooltip\": \"Etkinleştirildiğinde, tüm API istekleri hesaplar arasında geçiş yapmak yerine yalnızca seçilen hesabı kullanacaktır.\",\n                \"round_robin_set\": \"Round-robin modu etkinleştirildi\",\n                \"fixed_account_set\": \"Sabit hesap modu etkinleştirildi\",\n                \"account_changed\": \"Hesap şuna değiştirildi: {{email}}\",\n                \"circuit_breaker\": {\n                    \"title\": \"Uyarlanabilir Devre Kesici\",\n                    \"tooltip\": \"Kota tükenmesi nedeniyle art arda başarısız olan hesaplar için kilitleme süresini otomatik olarak artırır. Bu, geçici hataların hızla düzelmesine izin verirken, ölü hesaplarda API çağrılarının boşa harcanmasını önler.\",\n                    \"backoff_levels\": \"Geri Çekilme Seviyeleri (Saniye)\",\n                    \"level\": \"Seviye {{level}}\",\n                    \"input_placeholder\": \"60, 300, 1800, 7200\",\n                    \"invalid_format\": \"Geçersiz format. Virgülle ayrılmış sayılar kullanın (örn. 60, 300)\",\n                    \"clear_records\": \"Tüm Hız Sınırı Kayıtlarını Temizle\"\n                }\n            },\n            \"experimental\": {\n                \"title\": \"Deneysel Ayarlar\",\n                \"title_tooltip\": \"Gelecek sürümlerde ayarlanabilecek veya kaldırılabilecek keşifsel özellikler.\",\n                \"enable_usage_scaling\": \"Kullanım Ölçeklendirmeyi Etkinleştir\",\n                \"enable_usage_scaling_tooltip\": \"Claude protokolü için. Toplam giriş 30 bin jetonu aştığında, büyük bağlamlarda sık istemci tarafı sıkıştırmayı önlemek için agresif ölçeklendirmeyi etkinleştirir. Not: Etkinleştirildikten sonra bildirilen kullanım gerçek faturalandırmayı yansıtmayacaktır.\",\n                \"context_compression_threshold_l1\": \"L1 Sıkıştırma Eşiği (Araç Kırpma)\",\n                \"context_compression_threshold_l1_tooltip\": \"Yer kazanmak için eski araç çağrı kayıtlarını kırpar. Önerilen: 0.4 (40%)\",\n                \"context_compression_threshold_l2\": \"L2 Sıkıştırma Eşiği (Düşünce Sıkıştırma)\",\n                \"context_compression_threshold_l2_tooltip\": \"İmzaları korurken erken düşünce bloklarını sıkıştırır. Önerilen: 0.55 (55%)\",\n                \"context_compression_threshold_l3\": \"L3 Sıkıştırma Eşiği (Özet Pivotu)\",\n                \"context_compression_threshold_l3_tooltip\": \"XML durum özeti oluşturur ve taze bir oturuma geçer. Önerilen: 0.7 (70%)\"\n            },\n            \"cloudflared\": {\n                \"title\": \"Genel Erişim (Cloudflared)\",\n                \"subtitle\": \"Cloudflare Tunnel aracılığıyla yerel hizmetinizi internete açın\",\n                \"not_installed\": \"Cloudflared yüklü değil\",\n                \"install_hint\": \"Cloudflared, Cloudflare'in ücretsiz tünel aracıdır. Yerel proxy'nizi genel IP veya port yönlendirmesi olmadan internete açar. Yüklemek için aşağıdaki düğmeye tıklayın.\",\n                \"install\": \"Şimdi Yükle\",\n                \"installing\": \"Yükleniyor...\",\n                \"install_success\": \"Cloudflared başarıyla yüklendi\",\n                \"install_failed\": \"Yükleme başarısız: {{error}}\",\n                \"installed\": \"Yüklü\",\n                \"version\": \"Sürüm\",\n                \"mode_label\": \"Tünel Modu\",\n                \"mode_quick\": \"Hızlı Tünel\",\n                \"mode_quick_desc\": \"Otomatik oluşturulan geçici URL (*.trycloudflare.com), hesap gerekmez, yeniden başlatmada URL değişir\",\n                \"mode_auth\": \"Adlandırılmış Tünel\",\n                \"mode_auth_desc\": \"Cloudflare hesap token'ı kullan, özel domain destekler, kalıcı URL\",\n                \"token\": \"Tünel Token'ı\",\n                \"token_placeholder\": \"Cloudflare Tunnel Token'ınızı buraya yapıştırın\",\n                \"token_hint\": \"Cloudflare Zero Trust panosundan alın\",\n                \"token_required\": \"Adlandırılmış Tünel modu için token gereklidir\",\n                \"use_http2\": \"HTTP/2 Kullan\",\n                \"use_http2_desc\": \"Daha uyumlu, Çin anakarası için önerilir\",\n                \"status_label\": \"Tünel Durumu\",\n                \"status_stopped\": \"Durduruldu\",\n                \"status_starting\": \"Başlatılıyor...\",\n                \"status_running\": \"Çalışıyor\",\n                \"status_error\": \"Hata\",\n                \"public_url\": \"Genel URL\",\n                \"public_url_copied\": \"URL kopyalandı\",\n                \"start_tunnel\": \"Tüneli Başlat\",\n                \"stop_tunnel\": \"Tüneli Durdur\",\n                \"restart_tunnel\": \"Tüneli Yeniden Başlat\",\n                \"starting\": \"Başlatılıyor...\",\n                \"stopping\": \"Durduruluyor...\",\n                \"start_success\": \"Tünel başarıyla başlatıldı\",\n                \"stop_success\": \"Tünel durduruldu\",\n                \"start_failed\": \"Tünel başlatma başarısız: {{error}}\",\n                \"stop_failed\": \"Tünel durdurma başarısız: {{error}}\",\n                \"logs\": \"Loglar\",\n                \"clear_logs\": \"Logları Temizle\",\n                \"auto_start\": \"Proxy ile otomatik başlat\",\n                \"auto_start_desc\": \"API Proxy hizmeti başladığında tüneli otomatik olarak başlat\",\n                \"warning_quick_mode\": \"⚠️ Hızlı Mod: URL her yeniden başlatmada değişir\",\n                \"warning_token_storage\": \"💡 Token yerel olarak güvenli bir şekilde saklanır\"\n            }\n        },\n        \"example\": {\n            \"title\": \"Kullanım Örnekleri\",\n            \"curl\": \"cURL\",\n            \"python\": \"Python\",\n            \"python_anthropic\": \"from anthropic import Anthropic\\n\\nclient = Anthropic(\\n    # Önerilen: 127.0.0.1\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\n# Not: Antigravity, Anthropic SDK ile herhangi bir modeli çağırmayı destekler\\nresponse = client.messages.create(\\n    model=\\\"{{modelId}}\\\",\\n    max_tokens=1024,\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Merhaba\\\"}]\\n)\\n\\nprint(response.content[0].text)\",\n            \"python_gemini\": \"# Kurulum: pip install google-generativeai\\nimport google.generativeai as genai\\n\\n# Antigravity proxy adresini kullan (önerilen 127.0.0.1)\\ngenai.configure(\\n    api_key=\\\"{{apiKey}}\\\",\\n    transport='rest',\\n    client_options={'api_endpoint': '{{rawBaseUrl}}'}\\n)\\n\\nmodel = genai.GenerativeModel('{{modelId}}')\\nresponse = model.generate_content(\\\"Merhaba\\\")\\nprint(response.text)\",\n            \"python_openai_image\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    # Seçenek 1: boyut kullan (önerilen)\\n    # Desteklenen: \\\"1024x1024\\\" (1:1), \\\"1280x720\\\" (16:9), \\\"720x1280\\\" (9:16), \\\"1216x896\\\" (4:3)\\n    extra_body={ \\\"size\\\": \\\"1024x1024\\\" },\\n\\n    # Seçenek 2: model eki kullan\\n    # örn. gemini-3-pro-image-16-9, gemini-3-pro-image-4-3\\n    # model=\\\"gemini-3-pro-image-16-9\\\",\\n    messages=[{\\n        \\\"role\\\": \\\"user\\\",\\n        \\\"content\\\": \\\"Fütüristik bir şehir çiz\\\"\\n    }]\\n)\\n\\nprint(response.choices[0].message.content)\",\n            \"python_openai\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Merhaba\\\"}]\\n)\\n\\nprint(response.choices[0].message.content)\"\n        },\n        \"examples\": {\n            \"title\": \"Kullanım Örnekleri\"\n        },\n        \"dialog\": {\n            \"confirm_regenerate\": \"API Anahtarını yeniden oluşturmak istediğinizden emin misiniz? Eski anahtar hemen geçersiz olacak.\",\n            \"operate_failed\": \"İşlem başarısız: {{error}}\",\n            \"reset_mapping_title\": \"Model Eşlemeyi Sıfırla\",\n            \"reset_mapping_msg\": \"Tüm model eşlemelerini sistem varsayılanlarına sıfırlamak istediğinizden emin misiniz? Bu işlem geri alınamaz.\",\n            \"regenerate_key_title\": \"API Anahtarını Yeniden Oluştur\",\n            \"regenerate_key_msg\": \"API Anahtarını yeniden oluşturmak istediğinizden emin misiniz? Eski anahtar hemen geçersiz kılınacak.\",\n            \"clear_bindings_title\": \"Oturum Bağlantılarını Temizle\",\n            \"clear_bindings_msg\": \"Tüm oturum-hesap bağlantılarını temizlemek istediğinizden emin misiniz?\"\n        },\n        \"model\": {\n            \"flash\": \"Hızlı Yanıt\",\n            \"flash_preview\": \"Flash Önizleme\",\n            \"flash_lite\": \"Hafif ve Hızlı\",\n            \"flash_thinking\": \"Düşünme Yeteneği\",\n            \"pro_legacy\": \"Eski Pro\",\n            \"pro_low\": \"Yüksek Performans\",\n            \"pro_high\": \"En İyi Akıl Yürütme\",\n            \"pro_image\": \"Görsel Oluşturma (1:1)\",\n            \"pro_image_16_9\": \"Görsel Oluşturma (16:9)\",\n            \"pro_image_9_16\": \"Görsel Oluşturma (9:16)\",\n            \"pro_image_4_3\": \"Görsel Oluşturma (4:3)\",\n            \"pro_image_3_4\": \"Görsel Oluşturma (3:4)\",\n            \"pro_image_1_1\": \"Görsel Oluşturma (1:1)\",\n            \"claude_sonnet\": \"Kod Akıl Yürütme\",\n            \"claude_sonnet_thinking\": \"Düşünce Zinciri\",\n            \"claude_opus_thinking\": \"En Güçlü Düşünme\"\n        },\n        \"mapping\": {\n            \"title\": \"Claude Code Model Eşleme\",\n            \"description\": \"Claude Code modellerini Antigravity modellerine eşleyin. İstekleri akıllıca yönlendirerek maliyeti ve hızı optimize edin.\",\n            \"default\": \"Varsayılan\",\n            \"sonnet_desc\": \"Karmaşık işler için en yetenekli\",\n            \"opus_desc\": \"Premium katman\",\n            \"haiku_desc\": \"Hızlı cevaplar için en hızlı\",\n            \"maps_to\": \"Antigravity'ye Eşlenir\",\n            \"apply_recommended\": \"Önerileni Uygula\",\n            \"restore_defaults\": \"Varsayılan Yapılandırmayı Geri Yükle\",\n            \"reset_all\": \"Tümünü Sıfırla\"\n        },\n        \"router\": {\n            \"title\": \"Model Yönlendirici\",\n            \"subtitle\": \"Modelleri serilere göre yönlendirin veya özel tam eşlemeler ekleyin.\\nNot: Yerel Claude geçiş modelleri (örn. claude-opus-4-6-thinking) varsayılan olarak seri gruplarını atlar. Geçersiz kılmak için \\\"Uzman Özel Yönlendirme\\\" kullanın.\",\n            \"subtitle_simple\": \"Joker karakterlerle veya tam eşlemelerle model yönlendirmeyi özelleştirin\",\n            \"background_task_title\": \"Arka Plan Görev Modeli\",\n            \"background_task_desc\": \"Başlık oluşturma, özetleme vb. Claude CLI arka plan görevleri için kullanılan model (Varsayılan: gemini-2.5-flash)\",\n            \"use_default\": \"Sistem Varsayılanını Kullan\",\n            \"current_model\": \"Mevcut Model\",\n            \"apply_presets\": \"Ön Ayarları Uygula\",\n            \"presets_applied\": \"Ön ayarlar başarıyla uygulandı\",\n            \"custom_mappings\": \"Özel Eşlemeler\",\n            \"group_title\": \"Seri Grupları\",\n            \"groups\": {\n                \"claude_45\": {\n                    \"name\": \"Claude 4.6 TK Serisi\",\n                    \"desc\": \"Opus 4.5 TK\"\n                },\n                \"claude_35\": {\n                    \"name\": \"Claude 3.5 Serisi\",\n                    \"desc\": \"Sonnet 3.5, Haiku 3.5\"\n                },\n                \"gpt_4\": {\n                    \"name\": \"GPT-4 / o1 Serisi\",\n                    \"desc\": \"GPT-4, Turbo, o1-preview\"\n                },\n                \"gpt_4o\": {\n                    \"name\": \"GPT-4o / 3.5 Serisi\",\n                    \"desc\": \"GPT-4o, Mini, 3.5 Turbo\"\n                },\n                \"gpt_5\": {\n                    \"name\": \"GPT-5 Serisi\",\n                    \"desc\": \"GPT-5.1, GPT-5.2 xhigh\"\n                }\n            },\n            \"expert_title\": \"Uzman Özel Yönlendirme\",\n            \"expert_subtitle\": \"Herhangi bir orijinal model kimliği için hassas eşleştirme.\",\n            \"custom_mapping_tip\": \"💡 Herhangi bir model kimliğini manuel olarak girmenizi sağlar ve yayınlanmamış modelleri denemenizi sağlar (ör: claude-opus-4-6).\",\n            \"custom_mapping_warning\": \"Not: Tüm hesaplar yayınlanmamış modelleri desteklemez.\",\n            \"money_saving_tip\": \"💰 Maliyet tasarrufu ipucu:\",\n            \"haiku_optimization_tip\": \"Claude CLI varsayılan olarak arka plan görevleri için {{model}} kullanır. Daha ucuz bir Flash modeline eşleyin ve ~%95 tasarruf edin\",\n            \"haiku_optimization_btn\": \"Hızlı Optimize Et\",\n            \"haiku_tip_title\": \"💰 Maliyet tasarrufu ipucu:\",\n            \"haiku_tip_body_before\": \"Claude CLI arka plan görevleri için varsayılan olarak\",\n            \"haiku_tip_body_after\": \"kullanır; bunu daha ucuz bir Flash modeline eşlemek maliyetin yaklaşık %95'ini tasarruf edebilir.\",\n            \"haiku_tip_action\": \"Optimize Et\",\n            \"reset_confirm\": \"Tüm eşlemeleri sistem varsayılanlarına sıfırla?\",\n            \"reset_mapping\": \"Eşlemeyi Sıfırla\",\n            \"add_mapping\": \"Eşleme Ekle\",\n            \"current_list\": \"Özel Liste\",\n            \"no_custom_mapping\": \"Henüz özel eşleme yok\",\n            \"gemini3_only_warning\": \"⚠️ Sadece Gemini 3 serisi\",\n            \"default_suffix\": \" (Varsayılan)\",\n            \"original_id\": \"Orijinal Kimlik\",\n            \"route_to\": \"Şuna Yönlendir\",\n            \"select_target_model\": \"Hedef Model Seç\",\n            \"original_placeholder\": \"Orijinal (örn: gpt-4 veya gpt-4*)\"\n        },\n        \"multi_protocol\": {\n            \"title\": \"Çoklu Protokol Desteği\",\n            \"subtitle\": \"Protokolleri ve anahtarları araçlarınızla senkronize edin\",\n            \"description\": \"Proxy, kolay entegrasyon için OpenAI, Anthropic ve Gemini protokollerini destekler.\",\n            \"openai_label\": \"OpenAI\",\n            \"anthropic_label\": \"Anthropic\",\n            \"openai_tools\": \"Cherry Studio, NextChat\",\n            \"anthropic_tools\": \"Claude Code CLI\",\n            \"gemini_label\": \"Gemini\",\n            \"gemini_tools\": \"Google AI SDK, LangChain\",\n            \"quick_integration\": \"Hızlı Entegrasyon\",\n            \"click_tip\": \"👆 Model kodunu güncellemek için modele tıklayın\",\n            \"copy_base\": \"Base Kopyala\"\n        },\n        \"cli_sync\": {\n            \"title\": \"CLI Senkronizasyonu\",\n            \"subtitle\": \"Mevcut API URL'sini ve anahtarını AI CLI araçlarınızla senkronize edin\",\n            \"card_title\": \"{{name}} Yapılandırması\",\n            \"status\": {\n                \"not_installed\": \"Yüklü Değil\",\n                \"installed\": \"v{{version}}\",\n                \"synced\": \"Bu uygulamaya yönlendirilmiş\",\n                \"not_synced\": \"Senkronize Değil\",\n                \"detecting\": \"Algılanıyor...\",\n                \"current_base_url\": \"Mevcut Base URL\"\n            },\n            \"btn_sync\": \"Şimdi Senkronize Et\",\n            \"btn_view\": \"Yapılandırmayı Görüntüle\",\n            \"btn_restore\": \"Varsayılana Dön\",\n            \"btn_restore_backup\": \"Yedeği Geri Yükle\",\n            \"restore_backup_confirm\": \"Yedek yapılandırma bulundu. Geri yüklemek istediğinizden emin misiniz?\",\n            \"sync_confirm_title\": \"Senkronizasyon Onayı\",\n            \"sync_confirm_message\": \"{{name}} yapılandırmasını senkronize etmeye hazır. ⚠️ Uyarı: Bu işlem mevcut yerel yapılandırma dosyalarınızın (ör. oturum belirteçleri, API Anahtarları) üzerine yazacaktır. Devam etmek istediğinizden emin misiniz?\",\n            \"restore_confirm\": \"{{name}} yapılandırmasını varsayılan değerlere sıfırlamak istediğinizden emin misiniz?\",\n            \"modal\": {\n                \"view_title\": \"{{name}} Yapılandırma İçeriği\",\n                \"copy_success\": \"Panoya kopyalandı\"\n            },\n            \"toast\": {\n                \"config_missing\": \"Lütfen önce bir API anahtarı oluşturun ve hizmeti başlatın\",\n                \"sync_success\": \"Başarılı! {{name}} kullanıma hazır.\",\n                \"sync_error\": \"{{name}} senkronizasyon hatası: {{error}}\"\n            }\n        },\n        \"supported_models\": {\n            \"title\": \"Desteklenen Modeller ve Entegrasyon\",\n            \"model_name\": \"Model Adı\",\n            \"model_id\": \"Model Kimliği\",\n            \"description\": \"Açıklama\",\n            \"action\": \"İşlem\"\n        }\n    },\n    \"monitor\": {\n        \"page_title\": \"API İzleme Paneli\",\n        \"page_subtitle\": \"Gerçek zamanlı istek loglama ve analiz\",\n        \"open_monitor\": \"İzlemeyi Aç\",\n        \"logging_status\": {\n            \"active\": \"Kaydediliyor\",\n            \"paused\": \"Duraklatıldı\"\n        },\n        \"stats\": {\n            \"total\": \"Toplam\",\n            \"ok\": \"BAŞARILI\",\n            \"err\": \"HATA\"\n        },\n        \"filters\": {\n            \"placeholder\": \"Model, yol veya duruma göre filtrele...\",\n            \"quick_filters\": \"Hızlı Filtreler:\",\n            \"all\": \"Tümü\",\n            \"error\": \"Hata\",\n            \"chat\": \"Sohbet\",\n            \"gemini\": \"Gemini\",\n            \"claude\": \"Claude\",\n            \"images\": \"Görseller\",\n            \"reset\": \"Sıfırla\",\n            \"by_account\": \"Hesaba göre filtrele\",\n            \"all_accounts\": \"Tüm Hesaplar\"\n        },\n        \"table\": {\n            \"status\": \"Durum\",\n            \"method\": \"Metot\",\n            \"model\": \"Model\",\n            \"protocol\": \"Protokol\",\n            \"account\": \"Hesap\",\n            \"path\": \"Yol\",\n            \"usage\": \"Token'lar\",\n            \"duration\": \"Süre\",\n            \"time\": \"Zaman\",\n            \"empty\": \"Kayıtlı istek yok\"\n        },\n        \"details\": {\n            \"title\": \"İstek Detayları\",\n            \"request_payload\": \"İstek Yükü\",\n            \"response_payload\": \"Yanıt Yükü\",\n            \"duration\": \"Süre\",\n            \"token_stats\": \"Token Stats\",\n            \"settings\": \"Settings\",\n            \"tokens\": \"Token'lar (G/Ç)\",\n            \"time\": \"Zaman\",\n            \"model\": \"Model\",\n            \"id\": \"İstek Kimliği\",\n            \"protocol\": \"Protokol\",\n            \"mapped_model\": \"Eşlenen Model\",\n            \"account_used\": \"Kullanılan Hesap\",\n            \"payload_empty\": \"Yük Yok\"\n        },\n        \"dialog\": {\n            \"clear_title\": \"Proxy Loglarını Temizle\",\n            \"clear_msg\": \"Tüm proxy loglarını temizlemek istediğinizden emin misiniz? Bu işlem geri alınamaz.\"\n        }\n    },\n    \"update_notification\": {\n        \"title\": \"Yeni versiyon mevcut\",\n        \"message\": \"Optimizasyonlar ve iyileştirmeler içeren yeni bir versiyon hazır. Mevcut: v{{current}}\",\n        \"ready\": \"Güncelleme hazır\",\n        \"downloading\": \"Güncelleme indiriliyor...\",\n        \"restarting\": \"Uygulama yeniden başlatılıyor...\",\n        \"auto_update\": \"Otomatik güncelleme\",\n        \"toast\": {\n            \"not_ready\": \"Otomatik güncelleme paketi hazır değil, indirme sayfasına yönlendiriliyorsunuz...\",\n            \"failed\": \"Otomatik güncelleme başarısız oldu, indirme sayfasına yönlendiriliyorsunuz...\"\n        }\n    },\n    \"login\": {\n        \"title\": \"Güvenli Erişim Kontrolü\",\n        \"desc\": \"Şu anda Web modunda çalışıyor. Erişmek için lütfen yönetici parolasını veya API Anahtarını girin.\",\n        \"placeholder\": \"Yönetici parolasını veya API Anahtarını girin\",\n        \"btn_login\": \"Doğrula ve Gir\",\n        \"note\": \"Not: Ayarlanmış ayrı bir yönetici parolası varsa, lütfen onu girin; aksi takdirde API_KEY girin.\",\n        \"lookup_hint\": \"Unuttuysanız, Mevcut API Anahtarını veya Web Kullanıcı Arayüzü Parolasını bulmak için docker logs antigravity-manager komutunu çalıştırın.\",\n        \"config_hint\": \"Veya görüntülemek için grep -E '\\\"api_key\\\"|\\\"admin_password\\\"' ~/.antigravity_tools/gui_config.json komutunu çalıştırın.\"\n    },\n    \"token_stats\": {\n        \"title\": \"Token İstatistikleri\",\n        \"hourly\": \"Saatlik\",\n        \"daily\": \"Günlük\",\n        \"weekly\": \"Haftalık\",\n        \"total_tokens\": \"Toplam Token\",\n        \"input_tokens\": \"Giriş Tokenleri\",\n        \"output_tokens\": \"Çıkış Tokenleri\",\n        \"accounts_used\": \"Aktif Hesaplar\",\n        \"models_used\": \"Kullanılan Modeller\",\n        \"model_trend\": \"Model Kullanım Trendi\",\n        \"account_trend\": \"Hesaba Göre Kullanım Trendi\",\n        \"usage_trend\": \"Token Kullanım Trendi\",\n        \"by_account\": \"Hesaba Göre\",\n        \"by_model\": \"Modele Göre\",\n        \"by_account_view\": \"Hesaba Göre\",\n        \"model_details\": \"Model Detayları\",\n        \"account_details\": \"Hesap Detayları\",\n        \"model\": \"Model\",\n        \"account\": \"Hesap\",\n        \"requests\": \"İstekler\",\n        \"input\": \"Giriş\",\n        \"output\": \"Çıkış\",\n        \"total\": \"Toplam\",\n        \"percentage\": \"Oran\",\n        \"no_data\": \"Veri yok\"\n    },\n    \"security\": {\n        \"title\": \"Güvenlik İzleme\",\n        \"refresh_data\": \"Verileri Gücelle\",\n        \"refresh\": \"Yenile\",\n        \"tab_logs\": \"Erişim Günlükleri\",\n        \"tab_stats\": \"İstatistik Analizi\",\n        \"tab_blacklist\": \"Kara Liste\",\n        \"tab_whitelist\": \"Beyaz Liste\",\n        \"tab_config\": \"Güvenlik Ayarları\",\n        \"stats\": {\n            \"total_requests\": \"Toplam İstek\",\n            \"total_requests_desc\": \"Tüm kayıtlı istekler\",\n            \"unique_ips\": \"Benzersiz IP'ler\",\n            \"unique_ips_desc\": \"Farklı istemci IP adresleri\",\n            \"blocked_requests\": \"Engellenen İstekler\",\n            \"blocked_requests_desc\": \"Kurallar tarafından reddedilen istekler\",\n            \"ip_activity_token_usage\": \"IP Aktivitesi ve Token Kullanımı\",\n            \"hour\": \"S\",\n            \"day\": \"G\",\n            \"week\": \"H\",\n            \"month\": \"A\",\n            \"rank\": \"Sıra\",\n            \"ip_address\": \"IP Adresi\",\n            \"activity_reqs\": \"Aktivite (İstek)\",\n            \"total_token\": \"Toplam Token\",\n            \"prompt\": \"İstem\",\n            \"completion\": \"Tamamlama\",\n            \"no_data\": \"Veri Yok\"\n        },\n        \"logs\": {\n            \"search_placeholder\": \"IP, Yol, Kullanıcı Aracısı Ara...\",\n            \"show_blocked_only\": \"Sadece Engellenenler\",\n            \"status\": \"Durum\",\n            \"ip_address\": \"IP Adresi\",\n            \"method\": \"Yöntem\",\n            \"path\": \"Yol\",\n            \"duration\": \"Süre\",\n            \"time\": \"Zaman\",\n            \"reason\": \"Sebep\",\n            \"blocked\": \"Engellendi\",\n            \"no_logs\": \"Günlük Yok\",\n            \"total_records\": \"Toplam {{total}} kayıt\",\n            \"prev_page\": \"Önceki\",\n            \"next_page\": \"Sonraki\",\n            \"page_num\": \"Sayfa {{page}}\",\n            \"per_page_suffix\": \"/sayfa\"\n        },\n        \"blacklist\": {\n            \"add_ip\": \"IP Ekle\",\n            \"search_placeholder\": \"Ara...\",\n            \"added_at\": \"Eklenme Tarihi\",\n            \"expires_at\": \"Bitiş Tarihi\",\n            \"no_data\": \"Kara liste verisi yok\",\n            \"add_title\": \"Kara Listeye Ekle\",\n            \"ip_cidr_label\": \"IP Adresi veya CIDR\",\n            \"ip_cidr_placeholder\": \"örn. 192.168.1.1 veya 10.0.0.0/24\",\n            \"reason_label\": \"Sebep (İsteğe Bağlı)\",\n            \"reason_placeholder\": \"örn. Kötü amaçlı tarama\",\n            \"expires_label\": \"Bitiş Süresi (Saat, İsteğe Bağlı)\",\n            \"expires_placeholder\": \"Kalıcı olması için boş bırakın\",\n            \"cancel\": \"İptal\",\n            \"confirm\": \"Ekle\",\n            \"add_btn\": \"Ekle\"\n        },\n        \"whitelist\": {\n            \"add_ip\": \"Güvenilir IP Ekle\",\n            \"no_data\": \"Beyaz liste verisi yok\",\n            \"add_title\": \"Beyaz Listeye Ekle\",\n            \"description_label\": \"Açıklama (İsteğe Bağlı)\",\n            \"description_placeholder\": \"örn. Dahili Sunucu\",\n            \"cancel\": \"İptal\",\n            \"confirm\": \"Ekle\",\n            \"add_btn\": \"Ekle\"\n        },\n        \"config\": {\n            \"title\": \"Güvenlik Ayarları\",\n            \"save\": \"Değişiklikleri Kaydet\",\n            \"saving\": \"Kaydediliyor...\",\n            \"blacklist_title\": \"IP Kara Listesi\",\n            \"blacklist_desc\": \"Engellenen IP adreslerini ve kuralları yönetin.\",\n            \"enable_blacklist\": \"Kara Liste Korumasını Etkinleştir\",\n            \"block_msg_label\": \"Özel Engelleme Mesajı\",\n            \"block_msg_desc\": \"Engellenen istemcilere döndürülen yanıt içeriği.\",\n            \"whitelist_title\": \"IP Beyaz Listesi\",\n            \"whitelist_desc\": \"Güvenilir IP adreslerini yönetin.\",\n            \"enable_whitelist\": \"Beyaz Liste Modunu Etkinleştir\",\n            \"whitelist_warning\": \"Uyarı: Beyaz liste modunu etkinleştirmek, beyaz listede olmayan IP'lerden gelen TÜM istekleri engelleyecektir. Proxy üzerinden erişiyorsanız, kendinizi dışarıda bırakmamaya dikkat edin.\",\n            \"whitelist_priority\": \"Beyaz Liste Önceliği (Kara Listeyi Geçersiz Kılar)\",\n            \"whitelist_priority_desc\": \"Etkinleştirilirse, beyaz listedeki IP'lere kara liste kurallarıyla eşleşseler bile izin verilir.\",\n            \"load_error\": \"Yapılandırma yüklenemedi\",\n            \"save_success\": \"Yapılandırma kaydedildi\",\n            \"save_error\": \"Yapılandırma kaydedilemedi\"\n        }\n    },\n    \"user_token\": {\n        \"title\": \"Kullanıcı Token Yönetimi\",\n        \"total_users\": \"Toplam Kullanıcı\",\n        \"active_tokens\": \"Aktif Tokenlar\",\n        \"total_created\": \"Toplam Oluşturulan\",\n        \"create\": \"Token Oluştur\",\n        \"username\": \"Kullanıcı adı\",\n        \"token\": \"Token\",\n        \"expires\": \"Süre Sonu\",\n        \"usage\": \"Kullanım\",\n        \"ip_limit\": \"IP Sınırı\",\n        \"created\": \"Oluşturulma\",\n        \"today_requests\": \"Bugünkü İstekler\",\n        \"never\": \"Asla\",\n        \"renew\": \"Yenile\",\n        \"renew_button\": \"Yenile\",\n        \"unlimited\": \"Sınırsız\",\n        \"create_title\": \"Yeni Token Oluştur\",\n        \"description\": \"Açıklama\",\n        \"curfew\": \"Sokağa çıkma yasağı (Kullanılamaz zaman)\",\n        \"edit_title\": \"Tokenı Düzenle\",\n        \"username_required\": \"Kullanıcı adı zorunludur\",\n        \"renew_success\": \"Başarıyla yenilendi\",\n        \"expires_day\": \"1 Gün\",\n        \"expires_week\": \"1 Hafta\",\n        \"expires_month\": \"1 Ay\",\n        \"expires_never\": \"Asla\",\n        \"no_data\": \"Token bulunamadı\",\n        \"placeholder_username\": \"örn: user1\",\n        \"placeholder_desc\": \"İsteğe bağlı notlar\",\n        \"placeholder_max_ips\": \"0 = Sınırsız\",\n        \"hint_max_ips\": \"0 sınırsız demektir\",\n        \"hint_curfew\": \"Devre dışı bırakmak için boş bırakın. Sunucu saatine göredir.\"\n    }\n}"
  },
  {
    "path": "src/locales/vi.json",
    "content": "{\n    \"common\": {\n        \"empty\": \"Trống\",\n        \"loading\": \"Đang tải...\",\n        \"load_more\": \"Tải thêm\",\n        \"add\": \"Thêm mới\",\n        \"copy\": \"Sao chép\",\n        \"action\": \"Hành động\",\n        \"save\": \"Lưu\",\n        \"saved\": \"Đã lưu\",\n        \"cancel\": \"Hủy\",\n        \"confirm\": \"Xác nhận\",\n        \"close\": \"Đóng\",\n        \"delete\": \"Xóa\",\n        \"edit\": \"Chỉnh sửa\",\n        \"refresh\": \"Làm mới\",\n        \"refreshing\": \"Đang làm mới...\",\n        \"export\": \"Xuất dữ liệu\",\n        \"import\": \"Nhập dữ liệu\",\n        \"success\": \"Thành công\",\n        \"error\": \"Lỗi\",\n        \"unknown\": \"Không xác định\",\n        \"warning\": \"Cảnh báo\",\n        \"info\": \"Thông tin\",\n        \"details\": \"Chi tiết\",\n        \"example\": \"Example\",\n        \"clear\": \"Xóa\",\n        \"clearing\": \"Đang xóa...\",\n        \"prev_page\": \"Trang trước\",\n        \"next_page\": \"Trang sau\",\n        \"pagination_info\": \"Hiển thị {{start}} đến {{end}} trong tổng số {{total}} mục\",\n        \"per_page\": \"Mỗi trang\",\n        \"items\": \"mục\",\n        \"accounts\": \"tài khoản\",\n        \"enabled\": \"Đã bật\",\n        \"disabled\": \"Đã tắt\",\n        \"tauri_api_not_loaded\": \"Tauri API chưa được tải, vui lòng khởi động lại ứng dụng\",\n        \"environment_error\": \"Lỗi môi trường: {{error}}\",\n        \"submit\": \"Gửi\",\n        \"update\": \"Cập nhật\",\n        \"load_failed\": \"Tải thất bại\",\n        \"create_success\": \"Tạo thành công\",\n        \"update_success\": \"Cập nhật thành công\",\n        \"delete_success\": \"Xóa thành công\",\n        \"copied\": \"Đã sao chép vào bộ nhớ tạm\"\n    },\n    \"nav\": {\n        \"dashboard\": \"Tổng quan\",\n        \"accounts\": \"Tài khoản\",\n        \"proxy\": \"API Proxy\",\n        \"token_stats\": \"Thống kê\",\n        \"call_records\": \"Nhật ký lưu lượng\",\n        \"security\": \"Quản lý IP\",\n        \"settings\": \"Cài đặt\",\n        \"theme_to_dark\": \"Chuyển sang Chế độ Tối\",\n        \"theme_to_light\": \"Chuyển sang Chế độ Sáng\",\n        \"switch_to_english\": \"Chuyển sang Tiếng Anh\",\n        \"switch_to_chinese\": \"Chuyển sang Tiếng Trung\",\n        \"switch_to_english_short\": \"EN\",\n        \"switch_to_chinese_short\": \"ZH\",\n        \"switch_to_japanese\": \"Chuyển sang Tiếng Nhật\",\n        \"switch_to_japanese_short\": \"JA\",\n        \"switch_to_turkish\": \"Chuyển sang Tiếng Thổ Nhĩ Kỳ\",\n        \"switch_to_turkish_short\": \"TR\",\n        \"switch_to_vietnamese\": \"Chuyển sang Tiếng Việt\",\n        \"switch_to_vietnamese_short\": \"VI\",\n        \"switch_to_russian\": \"Chuyển sang tiếng Nga\",\n        \"switch_to_russian_short\": \"RU\",\n        \"switch_to_portuguese\": \"Chuyển sang tiếng Bồ Đào Nha\",\n        \"switch_to_portuguese_short\": \"PT\",\n        \"switch_to_traditional_chinese\": \"Chuyển sang Tiếng Trung Phồn thể\",\n        \"switch_to_traditional_chinese_short\": \"TW\",\n        \"switch_to_korean\": \"Chuyển sang Tiếng Hàn\",\n        \"switch_to_korean_short\": \"KO\",\n        \"switch_to_spanish\": \"Chuyển sang Tiếng Tây Ban Nha\",\n        \"switch_to_spanish_short\": \"ES\",\n        \"switch_to_malay\": \"Chuyển sang Tiếng Mã Lai\",\n        \"switch_to_malay_short\": \"MY\",\n        \"user_token\": \"Token người dùng\"\n    },\n    \"dashboard\": {\n        \"hello\": \"Xin chào, Bạn 👋\",\n        \"refresh_quota\": \"Làm mới Hạn mức\",\n        \"refreshing\": \"Đang làm mới...\",\n        \"total_accounts\": \"Tổng số Tài khoản\",\n        \"avg_gemini\": \"Hạn mức Gemini TB\",\n        \"avg_gemini_image\": \"Hạn mức Ảnh Gemini TB\",\n        \"avg_claude\": \"Hạn mức Claude TB\",\n        \"low_quota_accounts\": \"Tài khoản Hạn mức Thấp\",\n        \"quota_sufficient\": \"Hạn mức Tốt\",\n        \"quota_low\": \"Hạn mức Thấp\",\n        \"quota_desc\": \"Hạn mức < 20%\",\n        \"current_account\": \"Tài khoản Hiện tại\",\n        \"switch_account\": \"Chuyển Tài khoản\",\n        \"no_active_account\": \"Chưa chọn tài khoản\",\n        \"best_accounts\": \"Tài khoản Tốt nhất\",\n        \"best_account_recommendation\": \"Đề xuất Tốt nhất\",\n        \"switch_best\": \"Chuyển sang Tốt nhất\",\n        \"switch_successfully\": \"Chuyển thành công\",\n        \"view_all_accounts\": \"Xem tất cả\",\n        \"export_data\": \"Xuất Dữ liệu\",\n        \"for_gemini\": \"Cho Gemini\",\n        \"for_claude\": \"Cho Claude\",\n        \"toast\": {\n            \"switch_success\": \"Chuyển tài khoản thành công!\",\n            \"switch_error\": \"Chuyển tài khoản thất bại\",\n            \"refresh_success\": \"Làm mới hạn mức thành công\",\n            \"refresh_error\": \"Làm mới thất bại\",\n            \"export_no_accounts\": \"Không có tài khoản để xuất\",\n            \"export_success\": \"Xuất thành công! Đã lưu tại: {{path}}\",\n            \"export_error\": \"Xuất thất bại\"\n        }\n    },\n    \"accounts\": {\n        \"account\": \"Tài khoản\",\n        \"search_placeholder\": \"Tìm kiếm email...\",\n        \"all\": \"Tất cả\",\n        \"available\": \"Khả dụng\",\n        \"low_quota\": \"Hạn mức thấp\",\n        \"ultra\": \"ULTRA\",\n        \"pro\": \"PRO\",\n        \"free\": \"MIỄN PHÍ\",\n        \"edit_label\": \"Chỉnh sửa nhãn\",\n        \"custom_label_placeholder\": \"Nhập nhãn tùy chỉnh\",\n        \"label_updated\": \"Đã cập nhật nhãn\",\n        \"add_account\": \"Thêm Tài khoản\",\n        \"refresh_all\": \"Làm mới Tất cả\",\n        \"refresh_selected\": \"Làm mới ({{count}})\",\n        \"export_selected\": \"Xuất ({{count}})\",\n        \"delete_selected\": \"Xóa ({{count}})\",\n        \"current\": \"Hiện tại\",\n        \"current_badge\": \"Đang dùng\",\n        \"disabled\": \"Đã vô hiệu\",\n        \"disabled_tooltip\": \"Tài khoản bị vô hiệu hóa (ví dụ: refresh_token bị thu hồi/hết hạn). Cần xác thực lại hoặc cập nhật token.\",\n        \"proxy_disabled\": \"Proxy Đã tắt\",\n        \"proxy_disabled_tooltip\": \"Tài khoản này đã bị tắt thủ công khỏi proxy, sẽ không xử lý request API nhưng vẫn dùng được trong app.\",\n        \"enable_proxy\": \"Bật Proxy\",\n        \"disable_proxy\": \"Tắt Proxy\",\n        \"enable_proxy_selected\": \"Bật ({{count}})\",\n        \"disable_proxy_selected\": \"Tắt ({{count}})\",\n        \"proxy_disabled_reason_manual\": \"Đã tắt thủ công bởi người dùng\",\n        \"proxy_disabled_reason_batch\": \"Đã tắt hàng loạt\",\n        \"forbidden\": \"403\",\n        \"forbidden_badge\": \"403\",\n        \"forbidden_tooltip\": \"API trả về 403 Forbidden, tài khoản không có quyền truy cập Gemini Code Assist\",\n        \"forbidden_msg\": \"Bị cấm, bỏ qua tự động làm mới\",\n        \"status\": {\n            \"forbidden\": \"403 Bị cấm\",\n            \"disabled\": \"Tài khoản bị vô hiệu hóa\",\n            \"proxy_disabled\": \"Proxy bị vô hiệu hóa\"\n        },\n        \"error_details\": \"Chi tiết lỗi\",\n        \"error_status\": \"Trạng thái lỗi\",\n        \"error_time\": \"Thời gian phát hiện\",\n        \"view_error\": \"Xem lý do\",\n        \"click_to_verify\": \"Nhấp để xác minh\",\n        \"no_data\": \"Không có dữ liệu\",\n        \"last_used\": \"Dùng lần cuối\",\n        \"reset_time\": \"Thời gian reset\",\n        \"switch_to\": \"Chuyển sang tài khoản này\",\n        \"actions\": \"Thao tác\",\n        \"device_fingerprint\": \"Vân Tay Thiết Bị\",\n        \"show_all_quotas\": \"Hiển thị tất cả hạn ngạch\",\n        \"device_fingerprint_dialog\": {\n            \"title\": \"Vân Tay Thiết Bị\",\n            \"operations\": \"Các Thao Tác Vân Tay Thiết Bị\",\n            \"generate_and_bind\": \"Tạo và Liên Kết\",\n            \"restore_original\": \"Khôi Phục Bản Gốc\",\n            \"open_storage_directory\": \"Mở Thư Mục Lưu Trữ\",\n            \"current_storage\": \"Lưu Trữ Hiện Tại\",\n            \"effective\": \"Hiệu Lực\",\n            \"current_storage_desc\": \"Đọc từ storage.json (cập nhật sau khi áp dụng liên kết khi chuyển tài khoản)\",\n            \"account_binding\": \"Liên Kết Tài Khoản\",\n            \"pending_application\": \"Đang Chờ Áp Dụng\",\n            \"account_binding_desc\": \"Được lưu như liên kết sau tạo/khôi phục, ghi vào storage.json khi chuyển tài khoản\",\n            \"historical_fingerprints\": \"Vân Tay Lịch Sử (tùy chọn khôi phục/xóa)\",\n            \"no_history\": \"Không Có Lịch Sử\",\n            \"current\": \"Hiện Tại\",\n            \"restore\": \"Khôi Phục\",\n            \"delete_version\": \"Xóa phiên bản này\",\n            \"confirm_generate_title\": \"Xác nhận tạo và liên kết?\",\n            \"confirm_generate_desc\": \"Sẽ tạo một bộ vân tay thiết bị mới và đặt làm vân tay hiện tại. Xác nhận tiếp tục?\",\n            \"confirm_restore_title\": \"Xác nhận khôi phục vân tay gốc?\",\n            \"confirm_restore_desc\": \"Sẽ khôi phục vân tay gốc và ghi đè lên vân tay hiện tại. Xác nhận tiếp tục?\",\n            \"cancel\": \"Hủy\",\n            \"confirm\": \"Xác Nhận\",\n            \"processing\": \"Đang Xử Lý...\",\n            \"loading\": \"Đang Tải...\",\n            \"failed_to_load_device_info\": \"Không thể tải thông tin thiết bị\",\n            \"generation_failed\": \"Tạo thất bại\",\n            \"binding_failed\": \"Liên kết thất bại\",\n            \"restoration_failed\": \"Khôi phục thất bại\",\n            \"deletion_failed\": \"Xóa thất bại\",\n            \"directory_open_failed\": \"Không thể mở thư mục\",\n            \"generated_and_bound\": \"Đã tạo và liên kết\",\n            \"restored\": \"Đã khôi phục\",\n            \"deleted\": \"Đã xóa\",\n            \"directory_opened\": \"Đã mở thư mục lưu trữ\",\n            \"original_fingerprint_not_found\": \"Không tìm thấy vân tay gốc\",\n            \"storage_json_not_found\": \"Không tìm thấy storage.json, vui lòng đảm bảo Antigravity đã được chạy để tạo tệp cấu hình\"\n        },\n        \"warmup_all\": \"Làm nóng (Warmup) Tất cả\",\n        \"warmup_selected\": \"Làm nóng ({{count}})\",\n        \"warmup_this\": \"Làm nóng tài khoản này\",\n        \"warmup_now\": \"Làm nóng Ngay\",\n        \"warmup_batch_triggered\": \"Đã kích hoạt làm nóng cho {{count}} tài khoản\",\n        \"quota_protected\": \"Được bảo vệ\",\n        \"details\": {\n            \"title\": \"Chi tiết Hạn mức\",\n            \"model_quota\": \"Hạn mức Mô hình\",\n            \"protected_models\": \"Mô hình Được Bảo vệ\"\n        },\n        \"toast\": {\n            \"proxy_enabled\": \"Đã bật proxy cho {{count}} tài khoản\",\n            \"proxy_disabled\": \"Đã tắt proxy cho {{count}} tài khoản\"\n        },\n        \"add\": {\n            \"title\": \"Thêm Tài khoản\",\n            \"tabs\": {\n                \"oauth\": \"OAuth\",\n                \"token\": \"Refresh Token\",\n                \"import\": \"Nhập DB\"\n            },\n            \"oauth\": {\n                \"recommend\": \"Khuyên dùng\",\n                \"desc\": \"Mở trình duyệt mặc định để đăng nhập Google, tự động lấy và lưu Token.\",\n                \"btn_start\": \"Bắt đầu OAuth\",\n                \"btn_waiting\": \"Đang chờ xác thực...\",\n                \"btn_finish\": \"Tôi đã xác thực xong\",\n                \"copy_link\": \"Sao chép Link xác thực\",\n                \"copied\": \"Đã sao chép\",\n                \"link_label\": \"URL Xác thực\",\n                \"link_click_to_copy\": \"Nhấn để sao chép\",\n                \"manual_hint\": \"Trình duyệt không tự động chuyển hướng? Vui lòng dán liên kết phản hồi hoặc Mã xác thực tại đây:\",\n                \"manual_placeholder\": \"Dán liên kết hoặc mã tại đây...\",\n                \"error_no_flow\": \"Không tìm thấy luồng xác thực hoạt động. Vui lòng bắt đầu lại OAuth.\",\n                \"web_hint\": \"Trang đăng nhập Google sẽ mở trong cửa sổ mới\",\n                \"error_no_url\": \"Không thể lấy URL OAuth\",\n                \"popup_blocked\": \"Cửa sổ bật lên bị chặn\",\n                \"manual_submitting\": \"Đang gửi mã xác thực...\",\n                \"manual_submitted\": \"Mã xác thực đã gửi, đang xử lý ngầm...\"\n            },\n            \"token\": {\n                \"label\": \"Refresh Token\",\n                \"placeholder\": \"Dán Refresh Token vào đây (Hỗ trợ hàng loạt)\\n\\nĐịnh dạng hỗ trợ:\\n1. Token đơn (1//...)\\n2. Mảng JSON (có trường refresh_token)\\n3. Bất kỳ văn bản nào chứa token (Tự động trích xuất)\",\n                \"hint\": \"Mẹo: Bạn có thể dán nhiều token hoặc mảng JSON để nhập hàng loạt.\",\n                \"error_token\": \"Vui lòng nhập Refresh Token\",\n                \"batch_progress\": \"Đang nhập {{current}}/{{total}} tài khoản...\",\n                \"batch_success\": \"Đã nhập thành công {{count}} tài khoản\",\n                \"batch_partial\": \"Nhập xong: {{success}} thành công, {{fail}} thất bại\",\n                \"batch_fail\": \"Nhập thất bại\"\n            },\n            \"import\": {\n                \"scheme_a\": \"Cách A: Từ DB IDE\",\n                \"scheme_a_desc\": \"Tự động đọc tài khoản đang đăng nhập từ DB cục bộ của Antigravity.\",\n                \"btn_db\": \"Nhập Tài khoản Hiện tại\",\n                \"or\": \"HOẶC\",\n                \"scheme_b\": \"Cách B: Từ Sao lưu V1\",\n                \"scheme_b_desc\": \"Quét ~/.antigravity-agent để tìm dữ liệu tài khoản V1.\",\n                \"btn_v1\": \"Nhập hàng loạt V1\",\n                \"btn_custom_db\": \"Nhập DB Tùy chỉnh\"\n            },\n            \"btn_cancel\": \"Hủy\",\n            \"btn_confirm\": \"Xác nhận\",\n            \"oauth_error\": \"OAuth thất bại: {{error}}\",\n            \"status\": {\n                \"error_token\": \"Vui lòng nhập Refresh Token\"\n            }\n        },\n        \"table\": {\n            \"email\": \"Email\",\n            \"quota\": \"Hạn mức Model\",\n            \"last_used\": \"Dùng lần cuối\",\n            \"actions\": \"Thao tác\"\n        },\n        \"drag_to_reorder\": \"Kéo để sắp xếp lại\",\n        \"empty\": {\n            \"title\": \"Chưa có Tài khoản\",\n            \"desc\": \"Nhấn nút \\\"Thêm Tài khoản\\\" ở trên để thêm tài khoản đầu tiên\"\n        },\n        \"views\": {\n            \"list\": \"Dạng Danh sách\",\n            \"grid\": \"Dạng Lưới\"\n        },\n        \"dialog\": {\n            \"add_title\": \"Thêm Tài khoản\",\n            \"batch_delete_title\": \"Xác nhận Xóa Hàng loạt\",\n            \"delete_title\": \"Xác nhận Xóa\",\n            \"batch_delete_msg\": \"Bạn có chắc chắn muốn xóa {{count}} tài khoản đã chọn? Hành động này không thể hoàn tác.\",\n            \"delete_msg\": \"Bạn có chắc chắn muốn xóa tài khoản này? Hành động này không thể hoàn tác.\",\n            \"refresh_title\": \"Làm mới Hạn mức\",\n            \"batch_refresh_title\": \"Làm mới Hàng loạt\",\n            \"refresh_msg\": \"Bạn có chắc chắn muốn làm mới hạn mức cho tài khoản hiện tại?\",\n            \"batch_refresh_msg\": \"Bạn có chắc muốn làm mới hạn mức cho {{count}} tài khoản đã chọn? Việc này có thể mất chút thời gian.\",\n            \"disable_proxy_title\": \"Tắt Proxy\",\n            \"disable_proxy_msg\": \"Bạn có chắc muốn tắt proxy cho tài khoản này? Tài khoản vẫn dùng được trong app nhưng sẽ không nhận traffic từ proxy.\",\n            \"enable_proxy_title\": \"Bật Proxy\",\n            \"enable_proxy_msg\": \"Bạn có chắc muốn bật lại proxy cho tài khoản này?\",\n            \"warmup_all_title\": \"Làm nóng Thủ công Toàn bộ\",\n            \"warmup_all_msg\": \"Bạn có chắc muốn chạy tác vụ làm nóng cho tất cả tài khoản đủ điều kiện ngay bây giờ? Việc này sẽ gửi một lượng traffic nhỏ đến Google để reset chu kỳ hạn mức.\",\n            \"batch_warmup_title\": \"Làm nóng Thủ công Hàng loạt\",\n            \"batch_warmup_msg\": \"Bạn có chắc muốn chạy tác vụ làm nóng cho {{count}} tài khoản đã chọn ngay bây giờ?\"\n        }\n    },\n    \"settings\": {\n        \"save\": \"Lưu Cài đặt\",\n        \"tabs\": {\n            \"general\": \"Chung\",\n            \"account\": \"Tài khoản\",\n            \"proxy\": \"Cài đặt Proxy\",\n            \"advanced\": \"Nâng cao\",\n            \"about\": \"Giới thiệu\",\n            \"debug\": \"Gỡ lỗi\"\n        },\n        \"general\": {\n            \"title\": \"Cài đặt Chung\",\n            \"language\": \"Ngôn ngữ\",\n            \"theme\": \"Giao diện\",\n            \"theme_light\": \"Sáng\",\n            \"theme_dark\": \"Tối\",\n            \"theme_system\": \"Hệ thống\",\n            \"auto_launch\": \"Mở khi đăng nhập\",\n            \"auto_launch_enabled\": \"Đã bật\",\n            \"auto_launch_disabled\": \"Đã tắt\",\n            \"auto_launch_desc\": \"Tự động mở Antigravity Tools khi bạn đăng nhập vào máy\",\n            \"auto_check_update\": \"Tự động kiểm tra cập nhật\",\n            \"auto_check_update_desc\": \"Tự động kiểm tra phiên bản mới khi mở app\",\n            \"auto_check_update_enabled\": \"Đã bật tự động kiểm tra\",\n            \"auto_check_update_disabled\": \"Đã tắt tự động kiểm tra\",\n            \"update_check_interval\": \"Chu kỳ kiểm tra (giờ)\",\n            \"update_check_interval_desc\": \"Đặt khoảng thời gian tự động kiểm tra (1-168 giờ)\",\n            \"update_check_interval_saved\": \"Đã lưu cài đặt chu kỳ kiểm tra\"\n        },\n        \"account\": {\n            \"title\": \"Cài đặt Tài khoản\",\n            \"auto_refresh\": \"Tự động làm mới ngầm\",\n            \"auto_refresh_desc\": \"Tự động làm mới hạn mức tất cả tài khoản trong nền. Cần thiết cho tính năng bảo vệ hạn mức và làm nóng thông minh.\",\n            \"always_on\": \"Luôn bật\",\n            \"refresh_interval\": \"Chu kỳ làm mới (phút)\",\n            \"auto_sync\": \"Tự động cập nhật tài khoản hiện tại\",\n            \"auto_sync_desc\": \"Tự động cập nhật thông tin tài khoản đang hoạt động theo chu kỳ\",\n            \"sync_interval\": \"Chu kỳ đồng bộ (giây)\"\n        },\n        \"warmup\": {\n            \"title\": \"Làm nóng Thông minh (Smart Warmup)\",\n            \"desc\": \"Tự động theo dõi và kích hoạt làm nóng ngay khi hạn mức hồi phục về 100%, giữ cho model luôn sẵn sàng (warm).\"\n        },\n        \"quota_protection\": {\n            \"title\": \"Bảo vệ Hạn mức\",\n            \"enable\": \"Bật Bảo vệ Hạn mức\",\n            \"enable_desc\": \"Tự động tắt proxy khi tài khoản còn ít hạn mức, và tự động bật lại khi hạn mức hồi phục\",\n            \"threshold_label\": \"Tỷ lệ hạn mức dự trữ\",\n            \"monitored_models_label\": \"Model theo dõi (Điều kiện kích hoạt)\",\n            \"monitored_models_desc\": \"Chọn ít nhất một. Bảo vệ sẽ kích hoạt nếu BẤT KỲ model nào được chọn giảm xuống dưới ngưỡng\",\n            \"range\": \"Phạm vi\",\n            \"example\": \"Ví dụ: Tại {{percentage}}%, tài khoản có tổng hạn mức {{total}} sẽ bị vô hiệu hóa khi còn lại ≤ {{threshold}}\",\n            \"auto_restore_info\": \"Tài khoản sẽ tự động được bật lại khi hạn mức hồi phục\"\n        },\n        \"pinned_quota_models\": {\n            \"title\": \"Model Hạn mức Đã ghim\",\n            \"desc\": \"Chọn hạn mức model nào hiển thị trong danh sách tài khoản. Model không được chọn chỉ hiển thị trong popup chi tiết\"\n        },\n        \"proxy\": {\n            \"title\": \"Cài đặt Proxy\"\n        },\n        \"proxy_pool\": {\n            \"title\": \"Proxy Pool\",\n            \"strategy_priority\": \"Priority\",\n            \"strategy_round_robin\": \"Round Robin\",\n            \"strategy_random\": \"Random\",\n            \"strategy_least_connections\": \"Least Connections\",\n            \"test_all\": \"Test All\",\n            \"batch_import\": \"Import\",\n            \"binding_manager\": \"Bindings\",\n            \"add_proxy\": \"Add Proxy\",\n            \"edit_proxy\": \"Edit Proxy\",\n            \"name\": \"Name\",\n            \"url\": \"Proxy URL\",\n            \"username\": \"Username\",\n            \"password\": \"Password\",\n            \"max_accounts\": \"Max Accounts\",\n            \"max_accounts_hint\": \"0 = Unlimited\",\n            \"priority\": \"Priority\",\n            \"priority_hint\": \"Lower is better\",\n            \"health_check_url\": \"Health Check URL\",\n            \"tags\": \"Tags\",\n            \"add_tag_placeholder\": \"Add tag...\",\n            \"seconds\": \"Sec\",\n            \"test_completed\": \"Health check completed\",\n            \"test_failed\": \"Health check failed\",\n            \"confirm_delete\": \"Are you sure you want to delete this proxy?\",\n            \"empty\": \"No proxies available\",\n            \"column_priority\": \"Priority\",\n            \"column_status\": \"Status\",\n            \"column_details\": \"Proxy Details\",\n            \"column_bindings\": \"Bindings\",\n            \"import_title\": \"Batch Import Proxies\",\n            \"import_label\": \"Paste Proxy List (One per line)\",\n            \"import_hint\": \"Supported formats: protocol://user:pass@host:port, host:port:user:pass\",\n            \"import_preview\": \"Preview\",\n            \"import_confirm\": \"Import {{count}} Proxies\",\n            \"no_valid_proxies\": \"No valid proxies found\",\n            \"binding\": {\n                \"title\": \"Account Proxy Bindings\",\n                \"load_failed\": \"Failed to load bindings\",\n                \"unbind_success\": \"Unbound successfully\",\n                \"bind_success\": \"Bound successfully\",\n                \"update_failed\": \"Failed to update binding\",\n                \"assigned_proxy\": \"Assigned Proxy\",\n                \"default_strategy\": \"Default (Follow Strategy)\"\n            },\n            \"status\": {\n                \"inactive\": \"Inactive\",\n                \"checking\": \"Checking\",\n                \"healthy\": \"Healthy\",\n                \"timeout\": \"Timeout\"\n            }\n        },\n        \"advanced\": {\n            \"title\": \"Cài đặt Nâng cao\",\n            \"export_path\": \"Đường dẫn Xuất mặc định\",\n            \"export_path_placeholder\": \"Chưa đặt (Hỏi mỗi lần xuất)\",\n            \"default_export_path_desc\": \"File sẽ được lưu trực tiếp vào thư mục này mà không cần hỏi lại\",\n            \"select_btn\": \"Chọn\",\n            \"open_btn\": \"Mở\",\n            \"data_dir\": \"Thư mục Dữ liệu\",\n            \"data_dir_desc\": \"Vị trí lưu file cấu hình và dữ liệu tài khoản\",\n            \"antigravity_path\": \"Đường dẫn Antigravity\",\n            \"antigravity_path_placeholder\": \"Chưa đặt (Sử dụng tự động phát hiện)\",\n            \"antigravity_path_desc\": \"Nếu bạn cài Antigravity ở vị trí không chuẩn, hãy chỉ định đường dẫn file thực thi tại đây (Trỏ tới .app trên MacOS).\",\n            \"antigravity_path_select\": \"Chọn file thực thi Antigravity\",\n            \"antigravity_path_detected\": \"Đã cập nhật đường dẫn được phát hiện\",\n            \"detect_btn\": \"Phát hiện\",\n            \"antigravity_args\": \"Tham số Khởi động Antigravity\",\n            \"antigravity_args_placeholder\": \"--user-data-dir=/path/to/data --some-other-flag\",\n            \"antigravity_args_desc\": \"Chỉ định tham số khởi động cho Antigravity, ví dụ --user-data-dir để chỉ định thư mục dữ liệu người dùng\",\n            \"detect_args_btn\": \"Phát hiện\",\n            \"antigravity_args_detected\": \"Đã cập nhật tham số khởi động\",\n            \"antigravity_args_detect_error\": \"Không thể phát hiện tham số khởi động\",\n            \"accounts_page_size\": \"Số tài khoản mỗi trang\",\n            \"page_size_auto\": \"Tự động tính toán (Khuyên dùng)\",\n            \"page_size_desc\": \"Đặt số lượng tài khoản hiển thị trên mỗi trang. Chọn 'Tự động tính toán' để điều chỉnh theo kích thước cửa sổ.\",\n            \"logs_title\": \"Bảo trì Logs\",\n            \"logs_desc\": \"Xóa file cache logs. Không ảnh hưởng đến dữ liệu tài khoản.\",\n            \"clear_logs\": \"Dọn dẹp Cache Logs\",\n            \"clear_logs_title\": \"Xác nhận Dọn dẹp Logs\",\n            \"clear_logs_msg\": \"Bạn có chắc muốn xóa tất cả file cache logs?\",\n            \"logs_cleared\": \"Đã dọn dẹp cache logs\",\n            \"antigravity_cache_title\": \"Dọn dẹp Cache Antigravity\",\n            \"antigravity_cache_desc\": \"Dọn dẹp cache Antigravity có thể khắc phục lỗi đăng nhập, lỗi xác minh phiên bản và lỗi ủy quyền OAuth.\",\n            \"antigravity_cache_warning\": \"Vui lòng đảm bảo Antigravity đã đóng hoàn toàn trước khi dọn dẹp cache.\",\n            \"clear_antigravity_cache\": \"Dọn dẹp Cache Antigravity\",\n            \"clear_cache_confirm_title\": \"Xác nhận Dọn dẹp Cache Antigravity\",\n            \"clear_cache_confirm_msg\": \"Các thư mục cache sau sẽ được dọn dẹp:\",\n            \"cache_cleared_success\": \"Đã dọn dẹp cache, giải phóng {{size}} MB\",\n            \"cache_not_found\": \"Không tìm thấy thư mục cache Antigravity\",\n            \"debug_logs_title\": \"Nhật ký gỡ lỗi\",\n            \"debug_logs_enable_desc\": \"Khi bật, sẽ ghi lại toàn bộ chuỗi yêu cầu và phản hồi. Khuyến nghị chỉ bật khi khắc phục sự cố.\",\n            \"debug_logs_desc\": \"Ghi lại toàn bộ chuỗi: đầu vào gốc, yêu cầu v1internal đã chuyển đổi và phản hồi upstream. Chỉ dùng để khắc phục sự cố, có thể chứa dữ liệu nhạy cảm.\",\n            \"debug_log_dir\": \"Thư mục xuất nhật ký gỡ lỗi\",\n            \"debug_log_dir_hint\": \"Để trống sẽ sử dụng thư mục mặc định: {{path}}/debug_logs\",\n            \"debug_log_dir_select\": \"Chọn thư mục xuất nhật ký gỡ lỗi\",\n            \"http_api_title\": \"Dịch vụ HTTP API\",\n            \"http_api_desc\": \"Cung cấp giao diện HTTP cục bộ cho các chương trình bên ngoài (ví dụ: tiện ích mở rộng VS Code).\",\n            \"http_api_enabled\": \"Bật HTTP API\",\n            \"http_api_enabled_desc\": \"Khi bật, các chương trình bên ngoài có thể quản lý tài khoản qua giao diện HTTP\",\n            \"http_api_port\": \"Cổng Lắng nghe\",\n            \"http_api_port_desc\": \"Cần khởi động lại ứng dụng sau khi thay đổi cổng. Nếu xảy ra xung đột cổng, vui lòng sử dụng cổng khả dụng khác.\",\n            \"http_api_port_placeholder\": \"Cổng mặc định 19527\",\n            \"http_api_port_invalid\": \"Số cổng không hợp lệ (phạm vi: 1024-65535)\",\n            \"http_api_settings_saved\": \"Đã lưu cài đặt HTTP API, cần khởi động lại để áp dụng\",\n            \"http_api_restart_required\": \"⚠️ Cần khởi động lại ứng dụng để áp dụng\"\n        },\n        \"menu\": {\n            \"title\": \"Cài đặt hiển thị menu\",\n            \"desc\": \"Chọn các mục chức năng hiển thị trên thanh menu. Ẩn các menu ít dùng có thể tiết kiệm không gian.\",\n            \"selected_items_note\": \"Các mục được chọn sẽ hiển thị trên thanh menu phía trên.\",\n            \"required\": \"Bắt buộc\"\n        },\n        \"about\": {\n            \"title\": \"Giới thiệu\",\n            \"version\": \"Phiên bản\",\n            \"tech_stack\": \"Công nghệ\",\n            \"author\": \"Tác giả\",\n            \"wechat\": \"WeChat\",\n            \"github\": \"GitHub\",\n            \"view_code\": \"Xem Mã nguồn\",\n            \"copyright\": \"Bản quyền © 2025-2026 Antigravity. Đã đăng ký bản quyền.\",\n            \"check_update\": \"Kiểm tra Cập nhật\",\n            \"checking_update\": \"Đang kiểm tra...\",\n            \"latest_version\": \"Bạn đang dùng bản mới nhất\",\n            \"new_version_available\": \"Có phiên bản mới {{version}}\",\n            \"download_update\": \"Tải xuống\",\n            \"update_check_failed\": \"Kiểm tra cập nhật thất bại\",\n            \"support_btn\": \"Hỗ trợ tác giả\",\n            \"support_title\": \"Quyên góp & Hỗ trợ\",\n            \"support_desc\": \"Nếu bạn thấy dự án này hữu ích, hãy mời tác giả một ly cà phê! Sự hỗ trợ của bạn là động lực lớn nhất để tôi duy trì dự án này.\",\n            \"support_alipay\": \"Alipay\",\n            \"support_wechat\": \"WeChat Pay\",\n            \"support_buymeacoffee\": \"Buy Me a Coffee\"\n        },\n        \"advanced_thinking\": {\n            \"title\": \"Tư duy Nâng cao & Cấu hình Toàn cầu\",\n            \"description\": \"Quản lý tập trung các khả năng tư duy, chế độ hình ảnh và hướng dẫn toàn cầu.\"\n        },\n        \"thinking_budget\": {\n            \"title\": \"Ngân sách Suy nghĩ (Thinking Budget)\",\n            \"description\": \"Kiểm soát ngân sách token cho suy nghĩ sâu của AI. Một số mô hình (ví dụ: Flash, mô hình có hậu tố -thinking) bị giới hạn ở mức 24576 bởi API upstream.\",\n            \"mode_label\": \"Chế độ Xử lý\",\n            \"mode\": {\n                \"auto\": \"Giới hạn Tự động\",\n                \"passthrough\": \"Chuyển tiếp\",\n                \"custom\": \"Tùy chỉnh\"\n            },\n            \"auto_hint\": \"Chế độ Tự động: Tự động giới hạn ngân sách ở mức 24576 cho các mô hình Flash, mô hình có hậu tố -thinking và yêu cầu tìm kiếm web để tránh lỗi API.\",\n            \"passthrough_warning\": \"Chế độ Chuyển tiếp: Sử dụng trực tiếp giá trị gốc của người gọi. Việc thiếu hỗ trợ cho các giá trị cao có thể gây ra lỗi.\",\n            \"custom_value_hint\": \"Khuyến nghị: 24576 (Flash) hoặc 51200 (Mở rộng)\",\n            \"tokens\": \"tokens\"\n        },\n        \"image_thinking_mode\": {\n            \"title\": \"Chế độ Tư duy Hình ảnh (Image Thinking Mode)\",\n            \"hint\": \"Ảnh hưởng đến chất lượng hình ảnh và quy trình tạo\",\n            \"options\": {\n                \"enabled\": \"Đã bật\",\n                \"disabled\": \"Đã tắt\",\n                \"enabled_desc\": \"Bật: Giữ lại chuỗi tư duy và trả về hai hình ảnh (bản phác thảo + bản cuối).\",\n                \"disabled_desc\": \"Tắt: Vô hiệu hóa chuỗi tư duy và tạo một hình ảnh chất lượng cao duy nhất (ưu tiên chất lượng).\"\n            }\n        },\n        \"global_system_prompt\": {\n            \"title\": \"Hướng dẫn Hệ thống Toàn cầu (Global System Prompt)\",\n            \"hint\": \"Tự động được chèn vào systemInstruction cho tất cả các yêu cầu\",\n            \"placeholder\": \"Nhập hướng dẫn hệ thống toàn cầu...\\nVí dụ: Bạn là một nhà phát triển full-stack kỳ cựu chuyên về React và Rust. Trả lời bằng tiếng Việt.\",\n            \"char_count\": \"{{count}} ký tự\",\n            \"long_prompt_warning\": \"Hướng dẫn quá dài (hơn 2000 ký tự) và có thể tiêu tốn nhiều không gian ngữ cảnh.\"\n        }\n    },\n    \"tray\": {\n        \"current\": \"Hiện tại\",\n        \"quota\": \"Hạn mức\",\n        \"switch_next\": \"Chuyển sang Tài khoản Kế\",\n        \"refresh_current\": \"Làm mới Hạn mức Hiện tại\",\n        \"show_window\": \"Hiện Cửa sổ Chính\",\n        \"quit\": \"Thoát Ứng dụng\",\n        \"no_account\": \"Không có Tài khoản\",\n        \"unknown_quota\": \"Chưa rõ (Click để Làm mới)\",\n        \"forbidden\": \"Tài khoản Bị chặn (403)\"\n    },\n    \"proxy\": {\n        \"title\": \"Dịch vụ API Proxy\",\n        \"status\": {\n            \"running\": \"Dịch vụ Đang chạy\",\n            \"stopped\": \"Dịch vụ Đã dừng\",\n            \"accounts_available\": \"{{count}} Tài khoản Khả dụng\",\n            \"processing\": \"Đang xử lý...\"\n        },\n        \"action\": {\n            \"start\": \"Bắt đầu Dịch vụ\",\n            \"stop\": \"Dừng Dịch vụ\"\n        },\n        \"config\": {\n            \"title\": \"Cấu hình Dịch vụ\",\n            \"request\": {\n                \"user_agent\": \"User-Agent Override\",\n                \"user_agent_tooltip\": \"Override the User-Agent header sent to upstream APIs. Leave empty to use default.\",\n                \"user_agent_hint\": \"Current Default: antigravity/<version> <os>/<arch>\",\n                \"user_agent_placeholder\": \"Enter custom User-Agent string...\"\n            },\n            \"port\": \"Cổng lắng nghe (Port)\",\n            \"port_tooltip\": \"Cổng TCP mà API Proxy cục bộ sẽ lắng nghe. Dừng dịch vụ để thay đổi, sau đó khởi động lại để áp dụng.\",\n            \"port_hint\": \"Mặc định 8045, cần khởi động lại để áp dụng\",\n            \"auto_start\": \"Tự động chạy cùng App\",\n            \"auto_start_tooltip\": \"Tự động bắt đầu dịch vụ API Proxy cục bộ khi mở ứng dụng.\",\n            \"allow_lan_access\": \"Cho phép truy cập qua LAN\",\n            \"allow_lan_access_tooltip\": \"Khi bật, dịch vụ sẽ bind vào 0.0.0.0 để các thiết bị khác trong mạng LAN có thể truy cập. Hãy giữ xác thực được bật và bảo vệ API key của bạn; cần khởi động lại để áp dụng.\",\n            \"allow_lan_access_hint_enabled\": \"🌐 Đang lắng nghe trên 0.0.0.0, thiết bị LAN có thể truy cập\",\n            \"allow_lan_access_hint_disabled\": \"🔒 Chỉ lắng nghe trên 127.0.0.1 (Localhost), Bảo mật tối đa\",\n            \"allow_lan_access_warning\": \"⚠️ Thiết bị LAN có thể truy cập khi bật. Hãy giữ API key của bạn an toàn\",\n            \"allow_lan_access_restart_hint\": \"ℹ️ Cần khởi động lại dịch vụ để áp dụng thay đổi\",\n            \"api_key\": \"API Key\",\n            \"api_key_tooltip\": \"Khóa bí mật dùng chung (Shared secret) để clients xác thực. Bấm tạo mới sẽ làm khóa cũ mất hiệu lực ngay lập tức.\",\n            \"btn_regenerate\": \"Tạo mới Key\",\n            \"btn_copy\": \"Sao chép\",\n            \"btn_copied\": \"Đã chép\",\n            \"btn_edit\": \"Chỉnh sửa\",\n            \"btn_save\": \"Lưu\",\n            \"warning_key\": \"Lưu ý: Giữ API key của bạn an toàn. Đừng chia sẻ nó.\",\n            \"api_key_invalid\": \"Định dạng khóa API không hợp lệ, phải bắt đầu bằng sk- và có ít nhất 10 ký tự\",\n            \"api_key_updated\": \"Đã cập nhật khóa API\",\n            \"admin_password\": \"Mật khẩu quản trị Web UI\",\n            \"admin_password_tooltip\": \"Mật khẩu dùng để đăng nhập vào bảng điều khiển quản lý Web. Nếu để trống, API Key sẽ được sử dụng theo mặc định.\",\n            \"admin_password_default\": \"(Giống với API Key)\",\n            \"admin_password_placeholder\": \"Nhập mật khẩu mới, để trống để dùng API Key\",\n            \"admin_password_hint\": \"Mẹo: Trong các kịch bản triển khai Docker/Web, bạn có thể thiết lập mật khẩu đăng nhập riêng để tăng cường tính bảo mật cho API Key.\",\n            \"admin_password_short\": \"Mật khẩu quá ngắn (tối thiểu 4 ký tự)\",\n            \"admin_password_updated\": \"Mật khẩu đăng nhập Web UI đã được cập nhật\",\n            \"auth\": {\n                \"title\": \"Xác thực (Authorization)\",\n                \"title_tooltip\": \"Kiểm soát xem request đến có cần xác thực hay không, và route nào được bảo vệ.\",\n                \"enabled\": \"Đã bật\",\n                \"enabled_tooltip\": \"Bật/tắt xác thực bằng cách chuyển đổi chế độ. Khi bật, client phải gửi kèm API key qua 'Authorization: Bearer <API_KEY>' hoặc 'x-api-key'.\",\n                \"mode\": \"Chế độ\",\n                \"mode_tooltip\": \"Chọn route nào cần API key: Tắt = không cần auth; Tất cả = bảo vệ mọi thứ; Tất cả trừ Health = /healthz mở công khai; Tự động = Tắt cho localhost, bật cho LAN.\",\n                \"hint\": \"Khi bật, client phải gửi API key qua Authorization: Bearer ... (trừ health nếu chọn trừ).\",\n                \"modes\": {\n                    \"off\": \"Tắt (Mở công khai)\",\n                    \"strict\": \"Tất cả (Nghiêm ngặt)\",\n                    \"all_except_health\": \"Tất cả trừ Health\",\n                    \"auto\": \"Tự động (Khuyên dùng)\"\n                }\n            },\n            \"zai\": {\n                \"title\": \"Nhà cung cấp z.ai (GLM)\",\n                \"title_tooltip\": \"Upstream tương thích Anthropic tùy chọn cho giao thức Claude. Chỉ ảnh hưởng đến endpoint Anthropic; định tuyến tài khoản Google giữ nguyên.\",\n                \"subtitle\": \"Upstream tương thích Anthropic cho giao thức Claude.\",\n                \"enabled\": \"Đã bật\",\n                \"enabled_tooltip\": \"Bật định tuyến z.ai cho các request Anthropic theo chế độ điều phối đã chọn.\",\n                \"base_url\": \"Base URL\",\n                \"base_url_tooltip\": \"Base URL tương thích Anthropic. Proxy sẽ nối thêm đường dẫn như /v1/messages. Để mặc định trừ khi bạn dùng gateway riêng.\",\n                \"dispatch_mode\": \"Chế độ Điều phối\",\n                \"dispatch_mode_tooltip\": \"Kiểm soát khi nào dùng z.ai cho request Anthropic: Tắt = không dùng; Tất cả request Anthropic = chuyển toàn bộ; Pooled = z.ai là 1 slot xoay vòng cùng tài khoản Google; Fallback = chỉ dùng z.ai khi không còn tài khoản Google nào.\",\n                \"api_key\": \"API Key\",\n                \"api_key_tooltip\": \"API key để xác thực với z.ai. Lưu cục bộ và cần thiết cho tính năng z.ai và MCP.\",\n                \"api_key_placeholder\": \"Dán API key z.ai của bạn vào đây\",\n                \"warning\": \"Lưu ý: Key này được lưu cục bộ trong thư mục dữ liệu ứng dụng.\",\n                \"models\": {\n                    \"title\": \"Ánh xạ Model\",\n                    \"title_tooltip\": \"Lấy danh sách model ID z.ai khả dụng và cấu hình cách dịch tên model Claude đầu vào sang model z.ai.\",\n                    \"refresh\": \"Lấy danh sách model\",\n                    \"refreshing\": \"Đang lấy...\",\n                    \"hint\": \"Model khả dụng: {{count}}. Chọn từ gợi ý hoặc nhập custom model id.\",\n                    \"error\": \"Lỗi lấy danh sách model: {{error}}\",\n                    \"select_placeholder\": \"Chọn model...\",\n                    \"opus\": \"Họ Opus → z.ai model\",\n                    \"opus_tooltip\": \"Model z.ai mặc định khi tên model đầu vào chứa \\\"opus\\\" (ví dụ: claude-opus-*).\",\n                    \"sonnet\": \"Họ Sonnet → z.ai model\",\n                    \"sonnet_tooltip\": \"Model z.ai mặc định cho các model Claude khác (ví dụ: claude-sonnet-* và hầu hết request claude-*).\",\n                    \"haiku\": \"Họ Haiku → z.ai model\",\n                    \"haiku_tooltip\": \"Model z.ai mặc định khi tên model đầu vào chứa \\\"haiku\\\" (ví dụ: claude-haiku-*).\",\n                    \"advanced_title\": \"Ghi đè Nâng cao\",\n                    \"advanced_tooltip\": \"Ghi đè khớp chính xác tùy chọn. Nếu tên model đầu vào khớp chính xác với khóa quy tắc, nó sẽ được thay thế bằng model z.ai được ánh xạ.\",\n                    \"from_label\": \"Model đầu vào\",\n                    \"to_label\": \"Model z.ai\",\n                    \"add_rule\": \"Thêm\",\n                    \"empty\": \"Chưa có quy tắc ghi đè nào.\",\n                    \"from_placeholder\": \"Ví dụ: claude-3-opus\",\n                    \"to_placeholder\": \"Ví dụ: glm-4\"\n                },\n                \"modes\": {\n                    \"off\": \"Tắt\",\n                    \"exclusive\": \"Tất cả request Anthropic\",\n                    \"pooled\": \"Gộp chung (1 slot xoay vòng)\",\n                    \"fallback\": \"Chỉ dự phòng (Fallback)\"\n                },\n                \"mcp\": {\n                    \"title\": \"MCP Servers (qua local proxy)\",\n                    \"title_tooltip\": \"Mở thêm các endpoint /mcp/* trên proxy local để MCP client kết nối. Chỉ khả dụng khi dịch vụ đang chạy, z.ai đã cấu hình, và các toggle tương ứng được bật.\",\n                    \"enabled\": \"Bật MCP proxy\",\n                    \"enabled_tooltip\": \"Công tắc tổng cho các endpoint MCP. Khi tắt, mọi route /mcp/* sẽ trả về 404.\",\n                    \"web_search\": \"Tìm kiếm Web (Web Search)\",\n                    \"web_search_tooltip\": \"Mở /mcp/web_search_prime/mcp và chuyển tiếp request đến upstream Web Search MCP của z.ai.\",\n                    \"web_reader\": \"Đọc Web (Web Reader)\",\n                    \"web_reader_tooltip\": \"Mở /mcp/web_reader/mcp và chuyển tiếp request đến upstream Web Reader MCP của z.ai.\",\n                    \"vision\": \"Thị giác (Vision)\",\n                    \"vision_tooltip\": \"Mở /mcp/zai-mcp-server/mcp (server MCP local) cung cấp công cụ thị giác hỗ trợ bởi z.ai.\",\n                    \"local_endpoints\": \"Endpoint Cục bộ (cấu hình MCP client của bạn dùng các URL này):\",\n                    \"local_endpoints_tooltip\": \"Sử dụng các URL này trong MCP client của bạn. Chúng dùng chung host/port với API Proxy và tuân theo chính sách xác thực của proxy.\"\n                }\n            },\n            \"request_timeout\": \"Timeout Request\",\n            \"request_timeout_tooltip\": \"Thời gian tối đa (giây) proxy chờ phản hồi từ upstream, bao gồm cả streaming. Tăng lên nếu tạo nội dung dài; cần khởi động lại để áp dụng.\",\n            \"request_timeout_hint\": \"Mặc định 120s, khoảng 30-7200s. Khởi động lại dịch vụ để áp dụng.\",\n            \"enable_logging\": \"Bật Ghi Log Request\",\n            \"enable_logging_hint\": \"Ghi lại lịch sử để debug (Ảnh hưởng nhẹ hiệu năng)\",\n            \"upstream_proxy\": {\n                \"title\": \"Proxy Upstream Toàn cầu\",\n                \"desc\": \"Khi bật, tất cả request ra ngoài (API Proxy, Refresh Token, Check Hạn mức, Check Update) sẽ đi qua proxy này.\",\n                \"desc_short\": \"Proxy toàn cầu được sử dụng như một giải pháp dự phòng khi không tìm thấy tài khoản phù hợp trong nhóm proxy.\",\n                \"enable\": \"Bật Upstream Proxy\",\n                \"url\": \"URL Proxy\",\n                \"url_placeholder\": \"ví dụ: http://127.0.0.1:7890 hoặc socks5://127.0.0.1:7890\",\n                \"tip\": \"Hỗ trợ HTTP, HTTPS và SOCKS5.\",\n                \"socks5h_hint\": \"Để tránh bị chặn và duy trì việc phân giải DNS từ xa (Remote DNS), hãy thay đổi giao thức thành socks5h:// một cách thủ công.\",\n                \"validation_error\": \"URL proxy là bắt buộc khi bật upstream proxy\",\n                \"restart_hint\": \"Cài đặt proxy đã lưu. Khởi động lại ứng dụng để áp dụng thay đổi.\"\n            },\n            \"scheduling\": {\n                \"title\": \"Điều phối & Xoay vòng Tài khoản\",\n                \"title_tooltip\": \"Kiểm soát cách phiên (session) gắn với tài khoản và cách xử lý giới hạn tốc độ (rate limit).\",\n                \"subtitle\": \"Tối ưu hóa Prompt Caching và xử lý rate limit cho mọi giao thức (OpenAI/Gemini/Claude).\",\n                \"mode\": \"Chế độ Điều phối\",\n                \"mode_tooltip\": \"Ưu tiên Cache: Cố định session với tài khoản, chờ nếu bị rate limit (tối đa hóa cache); Cân bằng: Cố định session, đổi tài khoản nếu bị rate limit; Hiệu năng: Xoay vòng (Round-robin) thuần túy.\",\n                \"modes\": {\n                    \"CacheFirst\": \"Ưu tiên Cache\",\n                    \"Balance\": \"Cân bằng\",\n                    \"PerformanceFirst\": \"Hiệu năng\"\n                },\n                \"modes_desc\": {\n                    \"CacheFirst\": \"Gắn session với tài khoản, chờ đợi chính xác nếu bị giới hạn (Tối đa hóa Prompt Cache hits).\",\n                    \"PerformanceFirst\": \"Không gắn session, xoay vòng thuần túy (Tốt nhất cho tải cao/đồng thời). \"\n                },\n                \"max_wait\": \"Chờ Tối đa (giây)\",\n                \"max_wait_tooltip\": \"Chỉ dùng trong chế độ 'Ưu tiên Cache': chờ thay vì đổi tài khoản nếu thời gian reset rate limit thấp hơn giá trị này.\",\n                \"clear_bindings\": \"Xóa liên kết phiên\",\n                \"fixed_account\": \"Chế độ Tài khoản Cố định\",\n                \"fixed_account_tooltip\": \"Khi được bật, tất cả các yêu cầu API sẽ chỉ sử dụng tài khoản đã chọn thay vì luân phiên giữa các tài khoản.\",\n                \"round_robin_set\": \"Chế độ luân phiên đã bật\",\n                \"fixed_account_set\": \"Chế độ tài khoản cố định đã bật\",\n                \"account_changed\": \"Tài khoản đã thay đổi thành: {{email}}\",\n                \"clear_bindings_tooltip\": \"Ngắt ngay lập tức tất cả các mối quan hệ liên kết giữa phiên và tài khoản, buộc lần yêu cầu tiếp theo phải phân bổ lại tài khoản.\",\n                \"circuit_breaker\": {\n                    \"title\": \"Bộ ngắt mạch thích ứng\",\n                    \"tooltip\": \"Tự động tăng thời gian khóa cho các tài khoản liên tục thất bại do cạn kiệt hạn ngạch. Điều này ngăn chặn việc lãng phí các cuộc gọi API trên các tài khoản đã chết trong khi cho phép các lỗi tạm thời phục hồi nhanh chóng.\",\n                    \"backoff_levels\": \"Cấp độ lùi (Giây)\",\n                    \"level\": \"Cấp {{level}}\",\n                    \"input_placeholder\": \"60, 300, 1800, 7200\",\n                    \"invalid_format\": \"Định dạng không hợp lệ. Sử dụng các số cách nhau bởi dấu phẩy (ví dụ: 60, 300)\",\n                    \"clear_records\": \"Xóa tất cả hồ sơ giới hạn tốc độ\"\n                }\n            },\n            \"experimental\": {\n                \"title\": \"Cài đặt thử nghiệm\",\n                \"title_tooltip\": \"Các tính năng mang tính khám phá, có thể được điều chỉnh hoặc loại bỏ trong các phiên bản tương lai.\",\n                \"enable_usage_scaling\": \"Bật thu phóng dữ liệu sử dụng\",\n                \"enable_usage_scaling_tooltip\": \"Dành cho giao thức tương thích với Claude. Khi tổng đầu vào vượt quá 30k Token, hãy bật tính năng thu phóng linh hoạt để ngăn việc kích hoạt nén phía máy khách thường xuyên trong ngữ cảnh lớn. Lưu ý: Sau khi bật, lượng dữ liệu sử dụng hiển thị trên máy khách sẽ không còn đại diện cho điểm thanh toán thực tế.\",\n                \"context_compression_threshold_l1\": \"Ngưỡng nén L1 (Cắt tỉa công cụ)\",\n                \"context_compression_threshold_l1_tooltip\": \"Cắt tỉa lịch sử gọi công cụ cũ để tiết kiệm không gian. Khuyên dùng: 0.4 (40%)\",\n                \"context_compression_threshold_l2\": \"Ngưỡng nén L2 (Nén tư duy)\",\n                \"context_compression_threshold_l2_tooltip\": \"Nén các khối tư duy sớm trong khi vẫn giữ chữ ký. Khuyên dùng: 0.55 (55%)\",\n                \"context_compression_threshold_l3\": \"Ngưỡng nén L3 (Đặt lại tóm tắt)\",\n                \"context_compression_threshold_l3_tooltip\": \"Tạo bản tóm tắt trạng thái XML và chuyển sang phiên họp mới. Khuyên dùng: 0.7 (70%)\"\n            },\n            \"cloudflared\": {\n                \"title\": \"Truy cập Công khai (Cloudflared)\",\n                \"subtitle\": \"Phơi bày dịch vụ cục bộ ra internet qua Cloudflare Tunnel\",\n                \"not_installed\": \"Cloudflared chưa được cài đặt\",\n                \"install_hint\": \"Cloudflared là công cụ tunnel miễn phí từ Cloudflare. Nó phơi bày proxy cục bộ của bạn ra internet mà không cần IP công khai hoặc chuyển tiếp cổng. Nhấp vào nút bên dưới để cài đặt.\",\n                \"install\": \"Cài đặt Ngay\",\n                \"installing\": \"Đang cài đặt...\",\n                \"install_success\": \"Đã cài đặt Cloudflared thành công\",\n                \"install_failed\": \"Cài đặt thất bại: {{error}}\",\n                \"installed\": \"Đã cài đặt\",\n                \"version\": \"Phiên bản\",\n                \"mode_label\": \"Chế độ Tunnel\",\n                \"mode_quick\": \"Tunnel Nhanh\",\n                \"mode_quick_desc\": \"URL tạm thời tự động tạo (*.trycloudflare.com), không cần tài khoản, URL thay đổi khi khởi động lại\",\n                \"mode_auth\": \"Tunnel Có tên\",\n                \"mode_auth_desc\": \"Sử dụng token tài khoản Cloudflare, hỗ trợ domain tùy chỉnh, URL vĩnh viễn\",\n                \"token\": \"Token Tunnel\",\n                \"token_placeholder\": \"Dán Cloudflare Tunnel Token của bạn vào đây\",\n                \"token_hint\": \"Lấy từ bảng điều khiển Cloudflare Zero Trust\",\n                \"token_required\": \"Token là bắt buộc cho chế độ Tunnel Có tên\",\n                \"use_http2\": \"Sử dụng HTTP/2\",\n                \"use_http2_desc\": \"Tương thích hơn, được khuyến nghị cho Trung Quốc đại lục\",\n                \"status_label\": \"Trạng thái Tunnel\",\n                \"status_stopped\": \"Đã dừng\",\n                \"status_starting\": \"Đang khởi động...\",\n                \"status_running\": \"Đang chạy\",\n                \"status_error\": \"Lỗi\",\n                \"public_url\": \"URL Công khai\",\n                \"public_url_copied\": \"Đã sao chép URL\",\n                \"start_tunnel\": \"Khởi động Tunnel\",\n                \"stop_tunnel\": \"Dừng Tunnel\",\n                \"restart_tunnel\": \"Khởi động lại Tunnel\",\n                \"starting\": \"Đang khởi động...\",\n                \"stopping\": \"Đang dừng...\",\n                \"start_success\": \"Tunnel đã khởi động thành công\",\n                \"stop_success\": \"Tunnel đã dừng\",\n                \"start_failed\": \"Khởi động tunnel thất bại: {{error}}\",\n                \"stop_failed\": \"Dừng tunnel thất bại: {{error}}\",\n                \"logs\": \"Nhật ký\",\n                \"clear_logs\": \"Xóa Nhật ký\",\n                \"auto_start\": \"Tự động khởi động cùng proxy\",\n                \"auto_start_desc\": \"Tự động khởi động tunnel khi dịch vụ API Proxy khởi động\",\n                \"warning_quick_mode\": \"⚠️ Chế độ Nhanh: URL thay đổi mỗi lần khởi động lại\",\n                \"warning_token_storage\": \"💡 Token được lưu trữ an toàn cục bộ\"\n            }\n        },\n        \"example\": {\n            \"title\": \"Ví dụ Sử dụng\",\n            \"curl\": \"cURL\",\n            \"python\": \"Python\",\n            \"python_anthropic\": \"from anthropic import Anthropic\\n\\nclient = Anthropic(\\n    # Khuyên dùng: 127.0.0.1\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\n# Lưu ý: Antigravity hỗ trợ gọi mọi model qua SDK Anthropic\\nresponse = client.messages.create(\\n    model=\\\"{{modelId}}\\\",\\n    max_tokens=1024,\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Xin chào\\\"}]\\n)\\n\\nprint(response.content[0].text)\",\n            \"python_gemini\": \"# Cài đặt: pip install google-generativeai\\nimport google.generativeai as genai\\n\\n# Sử dụng địa chỉ proxy Antigravity (khuyên dùng 127.0.0.1)\\ngenai.configure(\\n    api_key=\\\"{{apiKey}}\\\",\\n    transport='rest',\\n    client_options={'api_endpoint': '{{rawBaseUrl}}'}\\n)\\n\\nmodel = genai.GenerativeModel('{{modelId}}')\\nresponse = model.generate_content(\\\"Xin chào\\\")\\nprint(response.text)\",\n            \"python_openai_image\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    # Cách 1: dùng size (khuyên dùng)\\n    # Hỗ trợ: \\\"1024x1024\\\" (1:1), \\\"1280x720\\\" (16:9), \\\"720x1280\\\" (9:16), \\\"1216x896\\\" (4:3)\\n    extra_body={ \\\"size\\\": \\\"1024x1024\\\" },\\n\\n    # Cách 2: dùng hậu tố model\\n    # ví dụ: gemini-3-pro-image-16-9, gemini-3-pro-image-4-3\\n    # model=\\\"gemini-3-pro-image-16-9\\\",\\n    messages=[{\\n        \\\"role\\\": \\\"user\\\",\\n        \\\"content\\\": \\\"Vẽ một thành phố tương lai\\\"\\n    }]\\n)\\n\\nprint(response.choices[0].message.content)\",\n            \"python_openai\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Xin chào\\\"}]\\n)\\n\\nprint(response.choices[0].message.content)\"\n        },\n        \"examples\": {\n            \"title\": \"Ví dụ\"\n        },\n        \"dialog\": {\n            \"confirm_regenerate\": \"Bạn có chắc chắn muốn tạo lại API Key? Key cũ sẽ mất hiệu lực ngay lập tức.\",\n            \"operate_failed\": \"Thao tác thất bại: {{error}}\",\n            \"reset_mapping_title\": \"Đặt lại Ánh xạ Model\",\n            \"reset_mapping_msg\": \"Bạn có chắc muốn đặt lại tất cả ánh xạ model về mặc định? Hành động này không thể hoàn tác.\",\n            \"regenerate_key_title\": \"Tạo lại API Key\",\n            \"regenerate_key_msg\": \"Bạn có chắc muốn tạo lại API Key? Key cũ sẽ bị vô hiệu ngay lập tức.\",\n            \"clear_bindings_title\": \"Xóa Liên kết Session\",\n            \"clear_bindings_msg\": \"Bạn có chắc muốn xóa tất cả liên kết session-tài khoản?\"\n        },\n        \"model\": {\n            \"flash\": \"Phản hồi Nhanh\",\n            \"flash_preview\": \"Flash Preview\",\n            \"flash_lite\": \"Nhanh & Nhẹ\",\n            \"flash_thinking\": \"Khả năng Tư duy\",\n            \"pro_legacy\": \"Pro Cũ\",\n            \"pro_low\": \"Hiệu năng Cao\",\n            \"pro_high\": \"Tư duy Tốt nhất\",\n            \"pro_image\": \"Tạo ảnh (1:1)\",\n            \"pro_image_16_9\": \"Tạo ảnh (16:9)\",\n            \"pro_image_9_16\": \"Tạo ảnh (9:16)\",\n            \"pro_image_4_3\": \"Tạo ảnh (4:3)\",\n            \"pro_image_3_4\": \"Tạo ảnh (3:4)\",\n            \"pro_image_1_1\": \"Tạo ảnh (1:1)\",\n            \"claude_sonnet\": \"Tư duy Code\",\n            \"claude_sonnet_thinking\": \"Chuỗi Tư duy\",\n            \"claude_opus_thinking\": \"Tư duy Mạnh nhất\"\n        },\n        \"mapping\": {\n            \"title\": \"Ánh xạ Model Claude Code\",\n            \"description\": \"Ánh xạ các model Claude Code sang Antigravity model. Tối ưu chi phí và tốc độ bằng cách điều hướng request thông minh.\",\n            \"default\": \"Mặc định\",\n            \"sonnet_desc\": \"Mạnh nhất cho công việc phức tạp\",\n            \"opus_desc\": \"Hạng Premium\",\n            \"haiku_desc\": \"Nhanh nhất cho câu trả lời ngắn\",\n            \"maps_to\": \"Ánh xạ sang Antigravity\",\n            \"apply_recommended\": \"Áp dụng Khuyên dùng\",\n            \"restore_defaults\": \"Khôi phục Mặc định\",\n            \"reset_all\": \"Đặt lại Tất cả\"\n        },\n        \"router\": {\n            \"title\": \"Bộ Định tuyến Model (Router)\",\n            \"subtitle\": \"Định tuyến model theo nhóm (Series) hoặc thêm ánh xạ chính xác.\\nLưu ý: Các native model Claude (ví dụ: claude-opus-4-6-thinking) mặc định sẽ bỏ qua nhóm series và đi thẳng. Dùng \\\"Định tuyến Tùy chỉnh Cao cấp\\\" để ghi đè.\",\n            \"subtitle_simple\": \"Tùy chỉnh định tuyến model với ký tự đại diện hoặc ánh xạ chính xác\",\n            \"background_task_title\": \"Mô hình tác vụ nền\",\n            \"background_task_desc\": \"Mô hình được sử dụng cho các tác vụ nền của Claude CLI như tạo tiêu đề, tóm tắt, v.v. (Mặc định: gemini-2.5-flash)\",\n            \"use_default\": \"Sử dụng mặc định hệ thống\",\n            \"current_model\": \"Mô hình hiện tại\",\n            \"apply_presets\": \"Áp dụng Cài sẵn\",\n            \"presets_applied\": \"Đã áp dụng cài đặt sẵn thành công\",\n            \"custom_mappings\": \"Ánh xạ Tùy chỉnh\",\n            \"group_title\": \"Nhóm Series (Đích đến)\",\n            \"groups\": {\n                \"claude_45\": {\n                    \"name\": \"Dòng Claude 4.6 TK\",\n                    \"desc\": \"Opus 4.5 TK\"\n                },\n                \"claude_35\": {\n                    \"name\": \"Dòng Claude 3.5\",\n                    \"desc\": \"Sonnet 3.5, Haiku 3.5\"\n                },\n                \"gpt_4\": {\n                    \"name\": \"Dòng GPT-4 / o1\",\n                    \"desc\": \"GPT-4, Turbo, o1-preview\"\n                },\n                \"gpt_4o\": {\n                    \"name\": \"Dòng GPT-4o / 3.5\",\n                    \"desc\": \"GPT-4o, Mini, 3.5 Turbo\"\n                },\n                \"gpt_5\": {\n                    \"name\": \"Dòng GPT-5\",\n                    \"desc\": \"GPT-5.1, GPT-5.2 xhigh\"\n                }\n            },\n            \"expert_title\": \"Định tuyến Tùy chỉnh Cao cấp\",\n            \"expert_subtitle\": \"Khớp chính xác bất kỳ Model ID gốc nào.\",\n            \"custom_mapping_tip\": \"💡 Hỗ trợ nhập thủ công bất kỳ ID model nào để trải nghiệm các model chưa phát hành (vd: claude-opus-4-6).\",\n            \"custom_mapping_warning\": \"Lưu ý: Không phải tất cả tài khoản đều hỗ trợ các model chưa phát hành.\",\n            \"money_saving_tip\": \"💰 Mẹo tiết kiệm:\",\n            \"haiku_optimization_tip\": \"Claude CLI dùng {{model}} cho các tác vụ nền. Ánh xạ nó sang Flash model rẻ hơn có thể tiết kiệm ~95% chi phí.\",\n            \"haiku_optimization_btn\": \"Tối ưu Nhanh\",\n            \"haiku_tip_title\": \"💰 Mẹo tiết kiệm:\",\n            \"haiku_tip_body_before\": \"Claude CLI mặc định dùng\",\n            \"haiku_tip_body_after\": \"cho các tác vụ nền; ánh xạ nó sang model Flash rẻ hơn có thể tiết kiệm khoảng 95% chi phí.\",\n            \"haiku_tip_action\": \"Tối ưu hóa\",\n            \"reset_confirm\": \"Đặt lại tất cả ánh xạ về mặc định hệ thống?\",\n            \"reset_mapping\": \"Đặt lại Ánh xạ\",\n            \"add_mapping\": \"Thêm Ánh xạ\",\n            \"current_list\": \"Danh sách Tùy chỉnh\",\n            \"no_custom_mapping\": \"Chưa có ánh xạ tùy chỉnh\",\n            \"gemini3_only_warning\": \"⚠️ Chỉ áp dụng cho dòng Gemini 3\",\n            \"default_suffix\": \" (Mặc định)\",\n            \"original_id\": \"ID Gốc\",\n            \"route_to\": \"Điều hướng tới\",\n            \"select_target_model\": \"Chọn Model Đích\",\n            \"original_placeholder\": \"Gốc (ví dụ: gpt-4 hoặc gpt-4*)\"\n        },\n        \"multi_protocol\": {\n            \"title\": \"Hỗ trợ đa giao thức\",\n            \"subtitle\": \"Đồng bộ API và Key với công cụ của bạn\",\n            \"description\": \"Proxy hỗ trợ các giao thức OpenAI, Anthropic và Gemini để dễ dàng tích hợp.\",\n            \"openai_label\": \"OpenAI\",\n            \"anthropic_label\": \"Anthropic\",\n            \"openai_tools\": \"Cherry Studio, NextChat\",\n            \"anthropic_tools\": \"Claude Code CLI\",\n            \"gemini_label\": \"Gemini\",\n            \"gemini_tools\": \"Google AI SDK, LangChain\",\n            \"quick_integration\": \"Tích hợp nhanh\",\n            \"click_tip\": \"👆 Click vào model để cập nhật mã mẫu\",\n            \"copy_base\": \"Sao chép Base\"\n        },\n        \"cli_sync\": {\n            \"title\": \"Đồng bộ CLI\",\n            \"subtitle\": \"Đồng bộ URL API và Key hiện tại với các công cụ AI CLI của bạn\",\n            \"card_title\": \"Cấu hình {{name}}\",\n            \"status\": {\n                \"not_installed\": \"Chưa cài đặt\",\n                \"installed\": \"v{{version}}\",\n                \"synced\": \"Đã trỏ đến app này\",\n                \"not_synced\": \"Chưa đồng bộ\",\n                \"detecting\": \"Đang kiểm tra...\",\n                \"current_base_url\": \"Base URL hiện tại\"\n            },\n            \"btn_sync\": \"Đồng bộ ngay\",\n            \"btn_view\": \"Xem cấu hình\",\n            \"btn_restore\": \"Khôi phục\",\n            \"btn_restore_backup\": \"Khôi phục bản sao lưu\",\n            \"restore_backup_confirm\": \"Tìm thấy cấu hình sao lưu. Bạn có chắc chắn muốn khôi phục không?\",\n            \"sync_confirm_title\": \"Xác nhận đồng bộ\",\n            \"sync_confirm_message\": \"Đã sẵn sàng đồng bộ cấu hình {{name}}. ⚠️ Cảnh báo: Thao tác này sẽ ghi đè các tệp cấu hình cục bộ hiện có của bạn (ví dụ: token đăng nhập, API Key). Bạn có chắc chắn muốn tiếp tục không?\",\n            \"restore_confirm\": \"Bạn có chắc muốn khôi phục cấu hình {{name}} về mặc định không?\",\n            \"modal\": {\n                \"view_title\": \"Nội dung cấu hình {{name}}\",\n                \"copy_success\": \"Đã sao chép vào bộ nhớ tạm\"\n            },\n            \"toast\": {\n                \"config_missing\": \"Vui lòng tạo API Key và khởi chạy dịch vụ trước\",\n                \"sync_success\": \"Thành công! {{name}} đã sẵn sàng.\",\n                \"sync_error\": \"Lỗi đồng bộ {{name}}: {{error}}\"\n            }\n        },\n        \"supported_models\": {\n            \"title\": \"Model Hỗ trợ & Tích hợp\",\n            \"model_name\": \"Tên Model\",\n            \"model_id\": \"ID Model\",\n            \"description\": \"Mô tả\",\n            \"action\": \"Thao tác\"\n        }\n    },\n    \"monitor\": {\n        \"page_title\": \"Bảng Theo dõi API Monitor\",\n        \"page_subtitle\": \"Ghi log và phân tích request thời gian thực\",\n        \"open_monitor\": \"Mở Monitor\",\n        \"logging_status\": {\n            \"active\": \"Đang ghi\",\n            \"paused\": \"Tạm dừng\"\n        },\n        \"stats\": {\n            \"total\": \"Tổng\",\n            \"ok\": \"Thành công\",\n            \"err\": \"Lỗi\"\n        },\n        \"filters\": {\n            \"placeholder\": \"Lọc theo model, path, hoặc trạng thái...\",\n            \"quick_filters\": \"Lọc nhanh:\",\n            \"all\": \"Tất cả\",\n            \"error\": \"Lỗi\",\n            \"chat\": \"Chat\",\n            \"gemini\": \"Gemini\",\n            \"claude\": \"Claude\",\n            \"images\": \"Ảnh\",\n            \"reset\": \"Đặt lại\",\n            \"by_account\": \"Lọc theo tài khoản\",\n            \"all_accounts\": \"Tất cả tài khoản\"\n        },\n        \"table\": {\n            \"status\": \"Trạng thái\",\n            \"method\": \"Method\",\n            \"model\": \"Model\",\n            \"protocol\": \"Giao thức\",\n            \"account\": \"Tài khoản\",\n            \"path\": \"Đường dẫn (Path)\",\n            \"usage\": \"Tokens\",\n            \"duration\": \"Thời gian\",\n            \"time\": \"Thời điểm\",\n            \"empty\": \"Chưa có request nào được ghi lại\"\n        },\n        \"details\": {\n            \"title\": \"Chi tiết Request\",\n            \"request_payload\": \"Payload Yêu cầu\",\n            \"response_payload\": \"Payload Phản hồi\",\n            \"duration\": \"Thời gian xử lý\",\n            \"token_stats\": \"Token Stats\",\n            \"tokens\": \"Tokens (I/O)\",\n            \"time\": \"Thời điểm\",\n            \"model\": \"Model\",\n            \"id\": \"Request ID\",\n            \"protocol\": \"Giao thức\",\n            \"mapped_model\": \"Model Đã Ánh xạ\",\n            \"account_used\": \"Tài khoản Sử dụng\",\n            \"payload_empty\": \"Không có Payload\"\n        },\n        \"token_stats\": {\n            \"title\": \"Token Usage Stats\",\n            \"hourly\": \"Hourly\",\n            \"daily\": \"Daily\",\n            \"weekly\": \"Weekly\",\n            \"total_tokens\": \"Total Tokens\",\n            \"input_tokens\": \"Input Tokens\",\n            \"output_tokens\": \"Output Tokens\",\n            \"accounts_used\": \"Active Accounts\",\n            \"usage_trend\": \"Usage Trend\",\n            \"by_account\": \"Stats by Account\",\n            \"account_details\": \"Account Details\",\n            \"account\": \"Account\",\n            \"requests\": \"Requests\",\n            \"input\": \"Input\",\n            \"output\": \"Output\",\n            \"total\": \"Total\",\n            \"no_data\": \"No Data\"\n        },\n        \"dialog\": {\n            \"clear_title\": \"Xóa Logs Proxy\",\n            \"clear_msg\": \"Bạn có chắc muốn xóa tất cả logs proxy? Hành động này không thể hoàn tác.\"\n        }\n    },\n    \"update_notification\": {\n        \"title\": \"Có phiên bản mới\",\n        \"message\": \"Một phiên bản mới đã sẵn sàng với các tối ưu hóa và cải tiến. Hiện tại: v{{current}}\",\n        \"ready\": \"Cập nhật đã sẵn sàng\",\n        \"downloading\": \"Đang tải bản cập nhật...\",\n        \"restarting\": \"Đang khởi động lại ứng dụng...\",\n        \"auto_update\": \"Cập nhật tự động\",\n        \"toast\": {\n            \"not_ready\": \"Gói cập nhật tự động chưa sẵn sàng, đang chuyển hướng bạn đến trang tải xuống...\",\n            \"failed\": \"Cập nhật tự động thất bại, đang chuyển hướng bạn đến trang tải xuống...\"\n        }\n    },\n    \"login\": {\n        \"title\": \"Kiểm soát truy cập an toàn\",\n        \"desc\": \"Hiện đang chạy ở chế độ Web. Vui lòng nhập mật khẩu quản trị hoặc API Key để truy cập.\",\n        \"placeholder\": \"Nhập mật khẩu quản trị hoặc API Key\",\n        \"btn_login\": \"Xác minh và Vào\",\n        \"note\": \"Lưu ý: Nếu đã thiết lập mật khẩu quản trị riêng, vui lòng nhập mật khẩu đó; nếu không, hãy nhập API_KEY.\",\n        \"lookup_hint\": \"Nếu quên, hãy chạy lệnh docker logs antigravity-manager để tìm API Key hiện tại hoặc Mật khẩu Web UI.\",\n        \"config_hint\": \"Hoặc chạy lệnh grep -E '\\\"api_key\\\"|\\\"admin_password\\\"' ~/.antigravity_tools/gui_config.json để xem.\"\n    },\n    \"token_stats\": {\n        \"title\": \"Thống kê Token\",\n        \"hourly\": \"Hàng giờ\",\n        \"daily\": \"Hàng ngày\",\n        \"weekly\": \"Hàng tuần\",\n        \"total_tokens\": \"Tổng Token\",\n        \"input_tokens\": \"Token đầu vào\",\n        \"output_tokens\": \"Token đầu ra\",\n        \"accounts_used\": \"Tài khoản hoạt động\",\n        \"models_used\": \"Model đã dùng\",\n        \"model_trend\": \"Xu hướng sử dụng theo Model\",\n        \"account_trend\": \"Xu hướng sử dụng theo tài khoản\",\n        \"usage_trend\": \"Xu hướng sử dụng Token\",\n        \"by_account\": \"Theo tài khoản\",\n        \"by_model\": \"Theo mô hình\",\n        \"by_account_view\": \"Theo tài khoản\",\n        \"model_details\": \"Chi tiết theo Model\",\n        \"account_details\": \"Chi tiết theo tài khoản\",\n        \"model\": \"Model\",\n        \"account\": \"Tài khoản\",\n        \"requests\": \"Số yêu cầu\",\n        \"input\": \"Đầu vào\",\n        \"output\": \"Đầu ra\",\n        \"total\": \"Tổng\",\n        \"percentage\": \"Tỷ lệ\",\n        \"no_data\": \"Không có dữ liệu\"\n    },\n    \"security\": {\n        \"title\": \"Giám sát An ninh\",\n        \"refresh_data\": \"Làm mới Dữ liệu\",\n        \"refresh\": \"Làm mới\",\n        \"tab_logs\": \"Nhật ký Truy cập\",\n        \"tab_stats\": \"Phân tích Thống kê\",\n        \"tab_blacklist\": \"Danh sách Đen\",\n        \"tab_whitelist\": \"Danh sách Trắng\",\n        \"tab_config\": \"Cấu hình Bảo mật\",\n        \"stats\": {\n            \"total_requests\": \"Tổng Yêu cầu\",\n            \"total_requests_desc\": \"Tất cả các yêu cầu đã ghi nhận\",\n            \"unique_ips\": \"IP Duy nhất\",\n            \"unique_ips_desc\": \"Các địa chỉ IP máy khách khác nhau\",\n            \"blocked_requests\": \"Yêu cầu bị Chặn\",\n            \"blocked_requests_desc\": \"Các yêu cầu bị từ chối bởi quy tắc\",\n            \"ip_activity_token_usage\": \"Hoạt động IP & Tiêu thụ Token\",\n            \"hour\": \"Giờ\",\n            \"day\": \"Ngày\",\n            \"week\": \"Tuần\",\n            \"month\": \"Tháng\",\n            \"rank\": \"Hạng\",\n            \"ip_address\": \"Địa chỉ IP\",\n            \"activity_reqs\": \"Hoạt động (Yêu cầu)\",\n            \"total_token\": \"Tổng Token\",\n            \"prompt\": \"Lời nhắc\",\n            \"completion\": \"Hoàn thành\",\n            \"no_data\": \"Không có dữ liệu\"\n        },\n        \"logs\": {\n            \"search_placeholder\": \"Tìm kiếm IP, Đường dẫn, User Agent...\",\n            \"show_blocked_only\": \"Chỉ hiện bị chặn\",\n            \"status\": \"Trạng thái\",\n            \"ip_address\": \"Địa chỉ IP\",\n            \"method\": \"Phương thức\",\n            \"path\": \"Đường dẫn\",\n            \"duration\": \"Thời gian\",\n            \"time\": \"Thời điểm\",\n            \"reason\": \"Lý do\",\n            \"blocked\": \"Đã chặn\",\n            \"no_logs\": \"Không có nhật ký\",\n            \"total_records\": \"Tổng {{total}} bản ghi\",\n            \"prev_page\": \"Trước\",\n            \"next_page\": \"Sau\",\n            \"page_num\": \"Trang {{page}}\",\n            \"per_page_suffix\": \"/trang\"\n        },\n        \"blacklist\": {\n            \"add_ip\": \"Thêm IP\",\n            \"search_placeholder\": \"Tìm kiếm...\",\n            \"added_at\": \"Thêm lúc\",\n            \"expires_at\": \"Hết hạn lúc\",\n            \"no_data\": \"Không có dữ liệu danh sách đen\",\n            \"add_title\": \"Thêm vào Danh sách Đen\",\n            \"ip_cidr_label\": \"Địa chỉ IP hoặc CIDR\",\n            \"ip_cidr_placeholder\": \"ví dụ 192.168.1.1 hoặc 10.0.0.0/24\",\n            \"reason_label\": \"Lý do (Tùy chọn)\",\n            \"reason_placeholder\": \"ví dụ: Quét độc hại\",\n            \"expires_label\": \"Hết hạn trong (Giờ, Tùy chọn)\",\n            \"expires_placeholder\": \"Để trống nếu vĩnh viễn\",\n            \"cancel\": \"Hủy\",\n            \"confirm\": \"Thêm\",\n            \"add_btn\": \"Thêm\"\n        },\n        \"whitelist\": {\n            \"add_ip\": \"Thêm IP Tin cậy\",\n            \"no_data\": \"Không có dữ liệu danh sách trắng\",\n            \"add_title\": \"Thêm vào Danh sách Trắng\",\n            \"description_label\": \"Mô tả (Tùy chọn)\",\n            \"description_placeholder\": \"ví dụ: Máy chủ Nội bộ\",\n            \"cancel\": \"Hủy\",\n            \"confirm\": \"Thêm\",\n            \"add_btn\": \"Thêm\"\n        },\n        \"config\": {\n            \"title\": \"Cài đặt An ninh\",\n            \"save\": \"Lưu Thay đổi\",\n            \"saving\": \"Đang lưu...\",\n            \"blacklist_title\": \"Danh sách Đen IP\",\n            \"blacklist_desc\": \"Quản lý các địa chỉ IP bị chặn và quy tắc.\",\n            \"enable_blacklist\": \"Bật Bảo vệ Danh sách Đen\",\n            \"block_msg_label\": \"Thông báo Chặn Tùy chỉnh\",\n            \"block_msg_desc\": \"Nội dung phản hồi trả về cho máy khách bị chặn.\",\n            \"whitelist_title\": \"Danh sách Trắng IP\",\n            \"whitelist_desc\": \"Quản lý các địa chỉ IP tin cậy.\",\n            \"enable_whitelist\": \"Bật Chế độ Danh sách Trắng\",\n            \"whitelist_warning\": \"Cảnh báo: Bật chế độ danh sách trắng sẽ chặn TẤT CẢ các yêu cầu từ các IP không nằm trong danh sách trắng. Nếu bạn đang truy cập qua proxy, hãy cẩn thận để không tự chặn mình.\",\n            \"whitelist_priority\": \"Ưu tiên Danh sách Trắng (Ghi đè Danh sách Đen)\",\n            \"whitelist_priority_desc\": \"Nếu bật, các IP trong danh sách trắng sẽ được phép ngay cả khi chúng khớp với quy tắc danh sách đen.\",\n            \"load_error\": \"Không thể tải cấu hình\",\n            \"save_success\": \"Đã lưu cấu hình\",\n            \"save_error\": \"Không thể lưu cấu hình\"\n        }\n    },\n    \"user_token\": {\n        \"title\": \"Quản lý Token Người dùng\",\n        \"total_users\": \"Tổng số Người dùng\",\n        \"active_tokens\": \"Token Đang hoạt động\",\n        \"total_created\": \"Tổng số đã tạo\",\n        \"create\": \"Tạo Token\",\n        \"username\": \"Tên người dùng\",\n        \"token\": \"Token\",\n        \"expires\": \"Hết hạn\",\n        \"usage\": \"Sử dụng\",\n        \"ip_limit\": \"Giới hạn IP\",\n        \"created\": \"Đã tạo lúc\",\n        \"today_requests\": \"Yêu cầu Hôm nay\",\n        \"never\": \"Không bao giờ\",\n        \"renew\": \"Gia hạn\",\n        \"renew_button\": \"Gia hạn\",\n        \"unlimited\": \"Không giới hạn\",\n        \"create_title\": \"Tạo Token Mới\",\n        \"description\": \"Mô tả\",\n        \"curfew\": \"Giờ giới nghiêm (Thời gian không khả dụng)\",\n        \"edit_title\": \"Chỉnh sửa Token\",\n        \"username_required\": \"Yêu cầu tên người dùng\",\n        \"renew_success\": \"Gia hạn thành công\",\n        \"expires_day\": \"1 Ngày\",\n        \"expires_week\": \"1 Tuần\",\n        \"expires_month\": \"1 Tháng\",\n        \"expires_never\": \"Không bao giờ\",\n        \"no_data\": \"Không tìm thấy token\",\n        \"placeholder_username\": \"vd: user1\",\n        \"placeholder_desc\": \"Ghi chú tùy chọn\",\n        \"placeholder_max_ips\": \"0 = Không giới hạn\",\n        \"hint_max_ips\": \"0 nghĩa là không giới hạn\",\n        \"hint_curfew\": \"Để trống để tắt. Dựa trên giờ máy chủ.\"\n    }\n}"
  },
  {
    "path": "src/locales/zh-TW.json",
    "content": "{\n    \"common\": {\n        \"empty\": \"空\",\n        \"loading\": \"載入中...\",\n        \"load_more\": \"載入更多\",\n        \"add\": \"新增\",\n        \"copy\": \"複製\",\n        \"action\": \"操作\",\n        \"save\": \"儲存\",\n        \"saved\": \"儲存成功\",\n        \"cancel\": \"取消\",\n        \"confirm\": \"確認\",\n        \"close\": \"關閉\",\n        \"delete\": \"刪除\",\n        \"edit\": \"編輯\",\n        \"refresh\": \"重新整理\",\n        \"refreshing\": \"重新整理中...\",\n        \"export\": \"匯出\",\n        \"import\": \"匯入\",\n        \"success\": \"成功\",\n        \"error\": \"錯誤\",\n        \"unknown\": \"未知\",\n        \"warning\": \"警告\",\n        \"info\": \"提示\",\n        \"details\": \"詳情\",\n        \"example\": \"範例\",\n        \"clear\": \"清除\",\n        \"clearing\": \"清理中...\",\n        \"prev_page\": \"上一頁\",\n        \"next_page\": \"下一頁\",\n        \"pagination_info\": \"顯示第 {{start}} 到 {{end}} 條，共 {{total}} 條\",\n        \"per_page\": \"每頁\",\n        \"items\": \"條\",\n        \"accounts\": \"個帳號\",\n        \"enabled\": \"已啟用\",\n        \"disabled\": \"已停用\",\n        \"tauri_api_not_loaded\": \"Tauri API 未正確載入,請重啟應用\",\n        \"environment_error\": \"環境錯誤: {{error}}\",\n        \"submit\": \"提交\",\n        \"update\": \"更新\",\n        \"load_failed\": \"載入失敗\",\n        \"create_success\": \"建立成功\",\n        \"update_success\": \"更新成功\",\n        \"delete_success\": \"刪除成功\",\n        \"copied\": \"已複製到剪貼簿\"\n    },\n    \"nav\": {\n        \"dashboard\": \"儀表板\",\n        \"accounts\": \"帳號管理\",\n        \"proxy\": \"API 反向代理\",\n        \"token_stats\": \"統計\",\n        \"call_records\": \"流量日誌\",\n        \"security\": \"IP 管理\",\n        \"security_logs\": \"IP 日志\",\n        \"settings\": \"設定\",\n        \"theme_to_dark\": \"切換到深色模式\",\n        \"theme_to_light\": \"切換到淺色模式\",\n        \"switch_to_english\": \"切換到英文\",\n        \"switch_to_chinese\": \"切換到簡體中文\",\n        \"switch_to_traditional_chinese\": \"切換到繁體中文\",\n        \"switch_to_english_short\": \"EN\",\n        \"switch_to_chinese_short\": \"ZH\",\n        \"switch_to_traditional_chinese_short\": \"TW\",\n        \"switch_to_japanese\": \"切換到日文\",\n        \"switch_to_japanese_short\": \"JA\",\n        \"switch_to_turkish\": \"切換到土耳其文\",\n        \"switch_to_turkish_short\": \"TR\",\n        \"switch_to_vietnamese\": \"切換到越南文\",\n        \"switch_to_vietnamese_short\": \"VI\",\n        \"switch_to_russian\": \"切換到俄文\",\n        \"switch_to_russian_short\": \"RU\",\n        \"switch_to_portuguese\": \"切換到葡萄牙文\",\n        \"switch_to_portuguese_short\": \"PT\",\n        \"switch_to_korean\": \"切換到韓語\",\n        \"switch_to_korean_short\": \"KO\",\n        \"switch_to_spanish\": \"切換到西班牙語\",\n        \"switch_to_spanish_short\": \"ES\",\n        \"switch_to_malay\": \"切換到馬來語\",\n        \"switch_to_malay_short\": \"MY\",\n        \"user_token\": \"用戶 Token\"\n    },\n    \"dashboard\": {\n        \"hello\": \"你好, 使用者 👋\",\n        \"refresh_quota\": \"重新整理配額\",\n        \"refreshing\": \"重新整理中...\",\n        \"total_accounts\": \"總帳號數\",\n        \"avg_gemini\": \"Gemini 平均配額\",\n        \"avg_gemini_image\": \"Gemini 繪圖平均配額\",\n        \"avg_claude\": \"Claude 平均配額\",\n        \"low_quota_accounts\": \"低配額帳號\",\n        \"quota_sufficient\": \"✓ 配額充足\",\n        \"quota_low\": \"⚠ 配額較低\",\n        \"quota_desc\": \"配額 < 20%\",\n        \"current_account\": \"當前帳號\",\n        \"switch_account\": \"切換帳號\",\n        \"no_active_account\": \"暫無活躍帳號\",\n        \"best_accounts\": \"最佳帳號推薦\",\n        \"best_account_recommendation\": \"最佳帳號推薦\",\n        \"switch_best\": \"一鍵切換最佳\",\n        \"switch_successfully\": \"一鍵切換最佳\",\n        \"view_all_accounts\": \"檢視所有帳號\",\n        \"export_data\": \"匯出帳號資料\",\n        \"for_gemini\": \"用於 Gemini\",\n        \"for_claude\": \"用於 Claude\",\n        \"toast\": {\n            \"switch_success\": \"切換成功!\",\n            \"switch_error\": \"切換帳號失敗\",\n            \"refresh_success\": \"配額重新整理成功\",\n            \"refresh_error\": \"重新整理失敗\",\n            \"export_no_accounts\": \"沒有可匯出的帳號\",\n            \"export_success\": \"匯出成功! 檔案已儲存至: {{path}}\",\n            \"export_error\": \"匯出失敗\"\n        }\n    },\n    \"accounts\": {\n        \"account\": \"帳號\",\n        \"search_placeholder\": \"搜尋信箱...\",\n        \"all\": \"全部\",\n        \"available\": \"可用\",\n        \"low_quota\": \"低配額\",\n        \"ultra\": \"ULTRA\",\n        \"pro\": \"PRO\",\n        \"free\": \"FREE\",\n        \"edit_label\": \"編輯標籤\",\n        \"custom_label_placeholder\": \"輸入自訂標籤\",\n        \"label_updated\": \"標籤已更新\",\n        \"add_account\": \"新增帳號\",\n        \"refresh_all\": \"重新整理所有\",\n        \"refresh_selected\": \"重新整理 ({{count}})\",\n        \"export_selected\": \"匯出 ({{count}})\",\n        \"delete_selected\": \"刪除 ({{count}})\",\n        \"current\": \"當前\",\n        \"current_badge\": \"當前\",\n        \"disabled\": \"已停用\",\n        \"disabled_tooltip\": \"帳號已被停用（例如 refresh_token 被撤銷/過期）。重新授權或更新 Token 後可恢復。\",\n        \"proxy_disabled\": \"反向代理已停用\",\n        \"proxy_disabled_tooltip\": \"此帳號已被手動停用反向代理功能,不參與 API 請求,但仍可在應用程式中使用\",\n        \"enable_proxy\": \"啟用反向代理\",\n        \"disable_proxy\": \"停用反向代理\",\n        \"enable_proxy_selected\": \"啟用 ({{count}})\",\n        \"disable_proxy_selected\": \"停用 ({{count}})\",\n        \"proxy_disabled_reason_manual\": \"使用者手動停用\",\n        \"proxy_disabled_reason_batch\": \"批次停用\",\n        \"forbidden\": \"403\",\n        \"forbidden_badge\": \"403\",\n        \"forbidden_tooltip\": \"API 返回 403 Forbidden，帳號無權使用 Gemini Code Assist\",\n        \"forbidden_msg\": \"帳號無權限，已跳過自動重新整理\",\n        \"status\": {\n            \"forbidden\": \"403 Forbidden\",\n            \"disabled\": \"帳號已停用\",\n            \"proxy_disabled\": \"反向代理已停用\"\n        },\n        \"error_details\": \"錯誤詳情\",\n        \"error_status\": \"錯誤狀態\",\n        \"error_time\": \"檢測時間\",\n        \"view_error\": \"檢視原因\",\n        \"click_to_verify\": \"點選去驗證\",\n        \"no_data\": \"無資料\",\n        \"last_used\": \"最後使用\",\n        \"reset_time\": \"重置時間\",\n        \"switch_to\": \"切換到此帳號\",\n        \"actions\": \"操作\",\n        \"device_fingerprint\": \"裝置指紋\",\n        \"show_all_quotas\": \"顯示所有配額\",\n        \"device_fingerprint_dialog\": {\n            \"title\": \"裝置指紋\",\n            \"operations\": \"裝置指紋操作\",\n            \"generate_and_bind\": \"生成並綁定\",\n            \"restore_original\": \"恢復原始\",\n            \"open_storage_directory\": \"開啟儲存目錄\",\n            \"current_storage\": \"目前儲存\",\n            \"effective\": \"已生效\",\n            \"current_storage_desc\": \"從 storage.json 讀取（切換帳號時應用綁定後更新）\",\n            \"account_binding\": \"帳號綁定\",\n            \"pending_application\": \"待應用\",\n            \"account_binding_desc\": \"生成/恢復後儲存為綁定，切換帳號時寫入 storage.json\",\n            \"historical_fingerprints\": \"歷史指紋（可選恢復/刪除）\",\n            \"no_history\": \"暫無歷史\",\n            \"current\": \"目前\",\n            \"restore\": \"恢復\",\n            \"delete_version\": \"刪除此版本\",\n            \"confirm_generate_title\": \"確認生成並綁定？\",\n            \"confirm_generate_desc\": \"將生成一套新的裝置指紋並設定為目前指紋。確認繼續？\",\n            \"confirm_restore_title\": \"確認恢復原始指紋？\",\n            \"confirm_restore_desc\": \"將恢復為原始指紋並覆蓋目前指紋。確認繼續？\",\n            \"cancel\": \"取消\",\n            \"confirm\": \"確認\",\n            \"processing\": \"處理中...\",\n            \"loading\": \"載入中...\",\n            \"failed_to_load_device_info\": \"載入裝置資訊失敗\",\n            \"generation_failed\": \"生成失敗\",\n            \"binding_failed\": \"綁定失敗\",\n            \"restoration_failed\": \"恢復失敗\",\n            \"deletion_failed\": \"刪除失敗\",\n            \"directory_open_failed\": \"無法開啟目錄\",\n            \"generated_and_bound\": \"已生成並綁定\",\n            \"restored\": \"已恢復\",\n            \"deleted\": \"已刪除\",\n            \"directory_opened\": \"已開啟裝置儲存目錄\",\n            \"original_fingerprint_not_found\": \"未找到原始指紋\",\n            \"storage_json_not_found\": \"未找到 storage.json，請確認 Antigravity 已運行過並生成配置文件\"\n        },\n        \"warmup_all\": \"一鍵預熱\",\n        \"warmup_selected\": \"預熱 ({{count}})\",\n        \"warmup_this\": \"預熱該帳號\",\n        \"warmup_now\": \"立即預熱\",\n        \"warmup_batch_triggered\": \"已成功為 {{count}} 個帳號觸發預熱任務\",\n        \"quota_protected\": \"受保護\",\n        \"details\": {\n            \"title\": \"配額詳情\",\n            \"model_quota\": \"模型配額\",\n            \"protected_models\": \"受保護模型\"\n        },\n        \"toast\": {\n            \"proxy_enabled\": \"成功啟用 {{count}} 個帳號的反向代理功能\",\n            \"proxy_disabled\": \"成功停用 {{count}} 個帳號的反向代理功能\"\n        },\n        \"add\": {\n            \"title\": \"新增新帳號\",\n            \"tabs\": {\n                \"oauth\": \"OAuth 授權\",\n                \"token\": \"Refresh Token\",\n                \"import\": \"從資料庫匯入\"\n            },\n            \"oauth\": {\n                \"recommend\": \"推薦方式\",\n                \"desc\": \"將開啟預設瀏覽器進行 Google 登入授權，自動獲取並儲存 Token。\",\n                \"btn_start\": \"開始 OAuth 授權\",\n                \"btn_waiting\": \"正在等待授權...\",\n                \"btn_finish\": \"我已授權，繼續\",\n                \"copy_link\": \"複製授權連結\",\n                \"copied\": \"已複製\",\n                \"link_label\": \"授權連結\",\n                \"link_click_to_copy\": \"點選複製\",\n                \"manual_hint\": \"瀏覽器沒有自動跳轉？請在此處貼上回調連結或 Authorization Code：\",\n                \"manual_placeholder\": \"在此處貼上連結或代碼...\",\n                \"error_no_flow\": \"未找到活躍的授權流程，請重新開始 OAuth 授權。\",\n                \"web_hint\": \"將在新視窗中開啟 Google 登入頁\",\n                \"error_no_url\": \"無法獲取 OAuth URL\",\n                \"popup_blocked\": \"彈窗被攔截，請允許彈出視窗\",\n                \"manual_submitting\": \"正在提交認證碼...\",\n                \"manual_submitted\": \"認證碼已提交，後台處理中...\"\n            },\n            \"token\": {\n                \"label\": \"Refresh Token\",\n                \"placeholder\": \"在此處貼上您的 Refresh Token (支援批次)\\n\\n支援格式:\\n1. 單個 Token (1//...)\\n2. JSON 陣列 (含 refresh_token 欄位)\\n3. 任意包含 Token 的文字 (自動提取)\",\n                \"hint\": \"提示: 支援一次性貼上多個 Token 或 JSON 陣列，系統將自動識別並批次匯入。\",\n                \"error_token\": \"請填寫 Refresh Token\",\n                \"batch_progress\": \"正在匯入第 {{current}}/{{total}} 個帳戶...\",\n                \"batch_success\": \"成功匯入 {{count}} 個帳戶\",\n                \"batch_partial\": \"匯入完成: {{success}} 個成功, {{fail}} 個失敗\",\n                \"batch_fail\": \"匯入失敗\"\n            },\n            \"import\": {\n                \"scheme_a\": \"方案 A: 從當前 IDE 資料庫\",\n                \"scheme_a_desc\": \"自動從本地 Antigravity 資料庫讀取當前登入的帳號資訊。\",\n                \"btn_db\": \"匯入當前登入帳號\",\n                \"or\": \"或者\",\n                \"scheme_b\": \"方案 B: 從 V1 版本備份\",\n                \"scheme_b_desc\": \"掃描 ~/.antigravity-agent 目錄，批次匯入舊版本的帳號資料。\",\n                \"btn_v1\": \"從 V1 備份批次匯入\",\n                \"btn_custom_db\": \"從自定義 DB 匯入\"\n            },\n            \"btn_cancel\": \"取消\",\n            \"btn_confirm\": \"確認新增\",\n            \"oauth_error\": \"OAuth 授權失敗: {{error}}\",\n            \"status\": {\n                \"error_token\": \"請填寫 Refresh Token\"\n            }\n        },\n        \"table\": {\n            \"email\": \"信箱\",\n            \"quota\": \"模型配額\",\n            \"last_used\": \"最後使用\",\n            \"actions\": \"操作\"\n        },\n        \"drag_to_reorder\": \"拖拽排序\",\n        \"empty\": {\n            \"title\": \"暫無帳號\",\n            \"desc\": \"點選上方\\\"新增帳號\\\"按鈕新增第一個帳號\"\n        },\n        \"views\": {\n            \"list\": \"列表檢視\",\n            \"grid\": \"網格檢視\"\n        },\n        \"dialog\": {\n            \"add_title\": \"新增新帳號\",\n            \"batch_delete_title\": \"批次刪除確認\",\n            \"delete_title\": \"刪除確認\",\n            \"batch_delete_msg\": \"確定要刪除選中的 {{count}} 個帳號嗎？此操作無法撤銷。\",\n            \"delete_msg\": \"確定要刪除這個帳號嗎？此操作無法撤銷。\",\n            \"refresh_title\": \"重新整理配額\",\n            \"batch_refresh_title\": \"批次重新整理\",\n            \"refresh_msg\": \"確定要重新整理當前帳號的配額嗎？\",\n            \"batch_refresh_msg\": \"確定要重新整理選中的 {{count}} 個帳號的配額嗎？這可能需要一些時間。\",\n            \"disable_proxy_title\": \"停用反向代理\",\n            \"disable_proxy_msg\": \"確定要停用此帳號的反向代理功能嗎？帳號仍可在應用程式中使用。\",\n            \"enable_proxy_title\": \"啟用反向代理\",\n            \"enable_proxy_msg\": \"確定要重新啟用此帳號的反向代理功能嗎？\",\n            \"warmup_all_title\": \"全量手動預熱\",\n            \"warmup_all_msg\": \"確定要立即為所有符合條件的帳號觸發預熱任務嗎？這將向 Google 服務傳送極小流量以重置配額配額週期。\",\n            \"batch_warmup_title\": \"批次手動預熱\",\n            \"batch_warmup_msg\": \"確定要為選中的 {{count}} 個帳號立即觸發預熱嗎？\"\n        }\n    },\n    \"settings\": {\n        \"save\": \"儲存設定\",\n        \"tabs\": {\n            \"general\": \"通用\",\n            \"account\": \"帳號\",\n            \"proxy\": \"代理設定\",\n            \"advanced\": \"進階\",\n            \"about\": \"關於\",\n            \"debug\": \"除錯\"\n        },\n        \"general\": {\n            \"title\": \"通用設定\",\n            \"language\": \"語言\",\n            \"theme\": \"主題\",\n            \"theme_light\": \"淺色\",\n            \"theme_dark\": \"深色\",\n            \"theme_system\": \"跟隨系統\",\n            \"auto_launch\": \"開機自動啟動\",\n            \"auto_launch_enabled\": \"啟用\",\n            \"auto_launch_disabled\": \"停用\",\n            \"auto_launch_desc\": \"系統啟動時自動執行 Antigravity Tools\",\n            \"auto_check_update\": \"自動檢查更新\",\n            \"auto_check_update_desc\": \"啟動時自動檢查新版本\",\n            \"auto_check_update_enabled\": \"已啟用自動檢查更新\",\n            \"auto_check_update_disabled\": \"已停用自動檢查更新\",\n            \"update_check_interval\": \"檢查間隔(小時)\",\n            \"update_check_interval_desc\": \"設定自動檢查更新的時間間隔(1-168 小時)\",\n            \"update_check_interval_saved\": \"已儲存檢查間隔設定\"\n        },\n        \"account\": {\n            \"title\": \"帳號設定\",\n            \"auto_refresh\": \"于背景自動重新整理\",\n            \"auto_refresh_desc\": \"于背景自動重新整理所有帳號的配額資訊，這是額度保護和智慧預熱的基礎。\",\n            \"always_on\": \"始終開啟\",\n            \"refresh_interval\": \"重新整理間隔（分鐘）\",\n            \"auto_sync\": \"自動獲取當前帳號\",\n            \"auto_sync_desc\": \"定期自動重新整理當前活動帳號的資訊\",\n            \"sync_interval\": \"同步間隔（分鐘）\"\n        },\n        \"warmup\": {\n            \"title\": \"智慧預熱\",\n            \"desc\": \"自動監控所有模型，當額度恢復到 100% 時立即觸發預熱，保持模型熱狀態\"\n        },\n        \"quota_protection\": {\n            \"title\": \"配額保護\",\n            \"enable\": \"啟用配額保護\",\n            \"enable_desc\": \"當帳號剩餘配額低於閾值時自動停用反向代理功能，配額重置後將自動恢復帳號\",\n            \"threshold_label\": \"保留配額百分比\",\n            \"monitored_models_label\": \"監控模型 (觸發條件)\",\n            \"monitored_models_desc\": \"至少選擇一個核心模型。任一勾選模型額度低於閾值將觸發保護\",\n            \"range\": \"範圍\",\n            \"example\": \"示例：設定 {{percentage}}% 時，{{total}} 次配額的帳號剩餘 ≤ {{threshold}} 次時將被自動停用\",\n            \"auto_restore_info\": \"配額重置後將自動重新啟用帳號\"\n        },\n        \"pinned_quota_models\": {\n            \"title\": \"配額關注列表\",\n            \"desc\": \"選擇要在帳號列表外層顯示的模型配額，未選中的模型只在詳情彈窗中顯示\"\n        },\n        \"proxy\": {\n            \"title\": \"代理設定\"\n        },\n        \"proxy_pool\": {\n            \"title\": \"代理池\",\n            \"strategy_priority\": \"按權重隨機\",\n            \"strategy_round_robin\": \"順序循環\",\n            \"strategy_random\": \"隨機\",\n            \"strategy_least_connections\": \"最少連線\",\n            \"test_all\": \"全量檢測\",\n            \"batch_import\": \"匯入\",\n            \"binding_manager\": \"綁定\",\n            \"add_proxy\": \"新增代理\",\n            \"edit_proxy\": \"編輯代理\",\n            \"name\": \"名稱\",\n            \"url\": \"代理地址\",\n            \"username\": \"使用者名稱\",\n            \"password\": \"密碼\",\n            \"max_accounts\": \"最大帳號數\",\n            \"max_accounts_hint\": \"0 = 不限制\",\n            \"priority\": \"優先級\",\n            \"priority_hint\": \"越小越優先\",\n            \"health_check_url\": \"健康檢查地址\",\n            \"tags\": \"標籤\",\n            \"add_tag_placeholder\": \"新增標籤...\",\n            \"seconds\": \"秒\",\n            \"confirm_delete\": \"確定要刪除此代理嗎？\",\n            \"empty\": \"暫無代理\",\n            \"column_priority\": \"權重\",\n            \"column_status\": \"狀態\",\n            \"column_details\": \"代理詳情\",\n            \"column_bindings\": \"綁定\",\n            \"test_completed\": \"健康檢查已完成\",\n            \"test_failed\": \"健康檢查失敗\",\n            \"import_title\": \"批次匯入代理\",\n            \"import_label\": \"貼上代理列表 (每行一個)\",\n            \"import_hint\": \"支援格式: protocol://user:pass@host:port, host:port:user:pass\",\n            \"import_preview\": \"預覽\",\n            \"import_confirm\": \"匯入 {{count}} 個代理\",\n            \"no_valid_proxies\": \"未找到有效代理\",\n            \"binding\": {\n                \"title\": \"帳號代理綁定\",\n                \"load_failed\": \"載入綁定失敗\",\n                \"unbind_success\": \"解綁成功\",\n                \"bind_success\": \"綁定成功\",\n                \"update_failed\": \"更新綁定失敗\",\n                \"assigned_proxy\": \"已分配代理\",\n                \"default_strategy\": \"預設 (跟隨策略)\"\n            },\n            \"status\": {\n                \"inactive\": \"未啟用\",\n                \"checking\": \"正在檢測\",\n                \"healthy\": \"正常\",\n                \"timeout\": \"已逾時\"\n            }\n        },\n        \"advanced\": {\n            \"title\": \"進階設定\",\n            \"export_path\": \"預設匯出路徑\",\n            \"export_path_placeholder\": \"未設定 (每次詢問)\",\n            \"default_export_path_desc\": \"設定後，匯出檔案將直接儲存到該目錄，不再彈出選擇框\",\n            \"select_btn\": \"選擇\",\n            \"open_btn\": \"開啟\",\n            \"data_dir\": \"資料目錄\",\n            \"data_dir_desc\": \"帳號資料和配置檔案的儲存位置\",\n            \"antigravity_path\": \"Antigravity 程式路徑\",\n            \"antigravity_path_placeholder\": \"未設定 (將使用自動偵測)\",\n            \"antigravity_path_desc\": \"如果您將 Antigravity 應用安裝在非標準位置，可在此手動指定可執行檔案路徑（MacOS 指向 .app 目錄）。\",\n            \"antigravity_path_select\": \"選擇 Antigravity 程式可執行檔案\",\n            \"antigravity_path_detected\": \"已更新偵測到的路徑\",\n            \"detect_btn\": \"偵測\",\n            \"antigravity_args\": \"Antigravity 程式啟動參數\",\n            \"antigravity_args_placeholder\": \"--user-data-dir=/path/to/data --some-other-flag\",\n            \"antigravity_args_desc\": \"為 Antigravity 程式指定啟動參數，例如 --user-data-dir 用於指定使用者資料目錄\",\n            \"detect_args_btn\": \"偵測\",\n            \"antigravity_args_detected\": \"啟動參數已更新\",\n            \"antigravity_args_detect_error\": \"偵測啟動參數失敗\",\n            \"accounts_page_size\": \"帳號列表分頁大小\",\n            \"page_size_auto\": \"自動計算 (推薦)\",\n            \"page_size_desc\": \"設定每頁顯示的帳號數量。選擇自動計算將根據視窗大小動態調整。\",\n            \"logs_title\": \"紀錄維護\",\n            \"logs_desc\": \"清理應用產生的紀錄快取檔案，不會影響帳號資料。\",\n            \"clear_logs\": \"清理紀錄快取\",\n            \"clear_logs_title\": \"清理紀錄確認\",\n            \"clear_logs_msg\": \"確定要清理所有紀錄快取檔案嗎？\",\n            \"logs_cleared\": \"紀錄快取已清理\",\n            \"antigravity_cache_title\": \"Antigravity 快取清理\",\n            \"antigravity_cache_desc\": \"清理 Antigravity 應用程式的快取可以解決登入失敗、版本驗證錯誤、OAuth 授權失敗等問題。\",\n            \"antigravity_cache_warning\": \"請確保 Antigravity 應用程式已完全關閉後再執行清理操作。\",\n            \"clear_antigravity_cache\": \"清理 Antigravity 快取\",\n            \"clear_cache_confirm_title\": \"確認清理 Antigravity 快取\",\n            \"clear_cache_confirm_msg\": \"將清理以下快取目錄：\",\n            \"cache_cleared_success\": \"快取清理完成，釋放 {{size}} MB 空間\",\n            \"cache_not_found\": \"未找到 Antigravity 快取目錄\",\n            \"debug_logs_title\": \"除錯日誌\",\n            \"debug_logs_enable_desc\": \"啟用後會記錄完整請求與回應鏈路，建議僅在排查問題時開啟。\",\n            \"debug_logs_desc\": \"記錄完整鏈路：原始輸入、轉換後的 v1internal 請求、以及上游回應。僅用於問題排查，可能包含敏感資料。\",\n            \"debug_log_dir\": \"除錯日誌輸出目錄\",\n            \"debug_log_dir_hint\": \"不填寫則使用預設目錄：{{path}}/debug_logs\",\n            \"debug_log_dir_select\": \"選擇除錯日誌輸出目錄\",\n            \"http_api_title\": \"HTTP API 服務\",\n            \"http_api_desc\": \"為外部程式(如 VS Code 外掛)提供本地 HTTP 介面。\",\n            \"http_api_enabled\": \"啟用 HTTP API\",\n            \"http_api_enabled_desc\": \"啟用後,外部程式可透過 HTTP 介面管理帳號\",\n            \"http_api_port\": \"監聽通訊埠\",\n            \"http_api_port_desc\": \"修改通訊埠後需要重啟應用才能生效。如遇通訊埠衝突,請更換為其他未被佔用的通訊埠。\",\n            \"http_api_port_placeholder\": \"預設通訊埠 19527\",\n            \"http_api_port_invalid\": \"通訊埠號無效(範圍:1024-65535)\",\n            \"http_api_settings_saved\": \"HTTP API 設定已儲存,重啟應用後生效\",\n            \"http_api_restart_required\": \"⚠️ 需要重啟應用後生效\"\n        },\n        \"about\": {\n            \"title\": \"關於\",\n            \"version\": \"應用版本\",\n            \"tech_stack\": \"技術堆疊\",\n            \"author\": \"作者\",\n            \"wechat\": \"微信服務賬號\",\n            \"github\": \"開源地址\",\n            \"view_code\": \"檢視程式碼\",\n            \"copyright\": \"Copyright © 2025-2026 Antigravity. All rights reserved.\",\n            \"check_update\": \"檢查更新\",\n            \"checking_update\": \"檢查中...\",\n            \"latest_version\": \"已是最新版本\",\n            \"new_version_available\": \"發現新版本 {{version}}\",\n            \"download_update\": \"前往下載\",\n            \"update_check_failed\": \"檢查更新失敗\",\n            \"support_btn\": \"贊助作者\",\n            \"support_title\": \"贊助支持\",\n            \"support_desc\": \"如果您覺得本工具對您有幫助，歡迎掃碼請作者喝杯咖啡！您的支持是我持續維護項目的最大動力。\",\n            \"support_alipay\": \"Alipay\",\n            \"support_wechat\": \"Weixin Pay\",\n            \"support_buymeacoffee\": \"Buy Me a Coffee\"\n        },\n        \"debug\": {\n            \"debug_logging\": \"除錯日誌\",\n            \"debug_logging_desc\": \"啟用後會記錄完整請求與回應鏈路，建議僅在排查問題時開啟。\"\n        },\n        \"menu\": {\n            \"title\": \"選單顯示設定\",\n            \"desc\": \"選擇要在選單列中顯示的功能項目。隱藏不常用的選單可以節省空間。\",\n            \"selected_items_note\": \"被選中的項目將顯示在頂部選單列中。\",\n            \"required\": \"必選\"\n        },\n        \"advanced_thinking\": {\n            \"title\": \"高級思維与全局配置\",\n            \"description\": \"集中管理思維能力、圖像模式及全局指令。\"\n        },\n        \"thinking_budget\": {\n            \"title\": \"思考預算 (Thinking Budget)\",\n            \"description\": \"控制 AI 深度思考時的 Token 預算。某些模型（如 Flash、帶 -thinking 後綴的模型）受上游 24576 上限限制。\",\n            \"mode_label\": \"處理模式\",\n            \"mode\": {\n                \"auto\": \"自動限制\",\n                \"passthrough\": \"透傳\",\n                \"custom\": \"自定義\"\n            },\n            \"auto_hint\": \"自動模式：對 Flash 模型、-thinking 後綴模型、以及啟用 Web Search 的請求自動限制在 24576 以避免 API 錯誤。\",\n            \"passthrough_warning\": \"透傳：直接使用調用方原始值，不支持高值可能導致失敗。\",\n            \"custom_value_hint\": \"推薦：24576 (Flash) 或 51200 (擴展)\",\n            \"tokens\": \"tokens\"\n        },\n        \"image_thinking_mode\": {\n            \"title\": \"圖像思維模式 (Image Thinking Mode)\",\n            \"hint\": \"影響畫質與生成流程\",\n            \"options\": {\n                \"enabled\": \"開啟\",\n                \"disabled\": \"關閉\",\n                \"enabled_desc\": \"開啟：保留思維鏈，返回草圖 + 成品雙圖。\",\n                \"disabled_desc\": \"關閉：禁用思維鏈，直接生成單張超清圖片（畫質優先）。\"\n            }\n        },\n        \"global_system_prompt\": {\n            \"title\": \"全局系統提示詞 (Global System Prompt)\",\n            \"hint\": \"自動注入所有請求的 systemInstruction\",\n            \"placeholder\": \"輸入全局系統提示詞...\\n例如：你是一位資深的全棧開發工程師，擅長 React 和 Rust。請使用繁體中文回复。\",\n            \"char_count\": \"{{count}} 字符\",\n            \"long_prompt_warning\": \"提示詞較長（超過 2000 字符），可能會佔用較多的上下文窗口空間。\"\n        }\n    },\n    \"tray\": {\n        \"current\": \"當前\",\n        \"quota\": \"額度\",\n        \"switch_next\": \"切換下一個帳號\",\n        \"refresh_current\": \"重新整理當前帳號額度\",\n        \"show_window\": \"顯示主視窗\",\n        \"quit\": \"退出應用 (Exit)\",\n        \"no_account\": \"無帳號\",\n        \"unknown_quota\": \"未知 (點選重新整理)\",\n        \"forbidden\": \"帳號被封禁\"\n    },\n    \"proxy\": {\n        \"title\": \"API 反向代理服務\",\n        \"status\": {\n            \"running\": \"服務執行中\",\n            \"stopped\": \"服務已停止\",\n            \"accounts_available\": \"{{count}} 個帳號可用\",\n            \"processing\": \"處理中...\"\n        },\n        \"action\": {\n            \"start\": \"啟動服務\",\n            \"stop\": \"停止服務\"\n        },\n        \"config\": {\n            \"title\": \"服務配置\",\n            \"request\": {\n                \"user_agent\": \"User-Agent 覆蓋\",\n                \"user_agent_tooltip\": \"自定義發送給上游 API 的 User-Agent 請求頭。留空則使用默認值。\",\n                \"user_agent_hint\": \"當前默認值: antigravity/<version> <os>/<arch>\",\n                \"user_agent_placeholder\": \"輸入自定義 User-Agent 字串...\"\n            },\n            \"port\": \"監聽通訊埠\",\n            \"port_tooltip\": \"本地 API 代理監聽的通訊埠。需要先停止服務再修改，修改後需重啟生效。\",\n            \"port_hint\": \"預設 8045，修改通訊埠需重啟服務\",\n            \"auto_start\": \"跟隨應用自動啟動\",\n            \"auto_start_tooltip\": \"應用啟動時自動啟動本地 API 代理服務。\",\n            \"allow_lan_access\": \"允許區域網路存取\",\n            \"allow_lan_access_tooltip\": \"開啟後繫結到 0.0.0.0，區域網路其他裝置也能存取。建議同時開啟鑑權並妥善保管 API 金鑰；修改後需重啟生效。\",\n            \"allow_lan_access_hint_enabled\": \"🌐 監聽 0.0.0.0，區域網路裝置可存取\",\n            \"allow_lan_access_hint_disabled\": \"🔒 僅監聽 127.0.0.1，僅本機可存取（隱私優先）\",\n            \"allow_lan_access_warning\": \"⚠️ 開啟後區域網路內其他裝置可存取，請確保 API 金鑰安全\",\n            \"allow_lan_access_restart_hint\": \"ℹ️ 需要重啟服務後生效\",\n            \"api_key\": \"API 金鑰\",\n            \"api_key_tooltip\": \"啟用鑑權後，客戶端存取代理所需的共享金鑰。重新生成會立即使舊金鑰失效。\",\n            \"btn_regenerate\": \"重新生成金鑰\",\n            \"btn_edit\": \"編輯\",\n            \"btn_save\": \"保存\",\n            \"btn_copy\": \"複製\",\n            \"btn_copied\": \"已複製\",\n            \"warning_key\": \"注意：請妥善保管您的 API 金鑰，不要洩露給他人。\",\n            \"api_key_invalid\": \"API 密鑰格式無效,必須以 sk- 開頭且長度至少 10 個字符\",\n            \"api_key_updated\": \"API 密鑰已更新\",\n            \"admin_password\": \"Web UI 管理後台密碼\",\n            \"admin_password_tooltip\": \"用於登錄 Web 管理後台的密碼。如果為空，則默認使用 API 密鑰（API Key）。\",\n            \"admin_password_default\": \"（同 API 密鑰）\",\n            \"admin_password_placeholder\": \"輸入新密碼，留空則使用 API 密鑰\",\n            \"admin_password_hint\": \"提示：在 Docker/Web 部署場景中，您可以設置一個獨立的登錄密碼，提高 API 密鑰的安全性。\",\n            \"admin_password_short\": \"密碼太短（最少 4 個字符）\",\n            \"admin_password_updated\": \"Web UI 登錄密碼已更新\",\n            \"auth\": {\n                \"title\": \"存取授權\",\n                \"title_tooltip\": \"控制代理是否需要鑑權，以及哪些路由需要提供 API 金鑰。\",\n                \"enabled\": \"已啟用\",\n                \"enabled_tooltip\": \"快速開關鑑權（透過切換鑑權模式實現）。開啟後客戶端需在請求頭提供 Authorization: Bearer <API_KEY> 或 x-api-key。\",\n                \"mode\": \"模式\",\n                \"mode_tooltip\": \"選擇鑑權覆蓋範圍：關閉=不鑑權；全域=所有介面都需金鑰；除健康檢查外=/healthz 不鑑權；自動=本機模式預設關閉，區域網路模式預設“除健康檢查外”。\",\n                \"hint\": \"開啟後客戶端需透過 Authorization: Bearer ... 傳入 API 金鑰（如選擇“除健康檢查外”則 /healthz 免鑑權）。\",\n                \"modes\": {\n                    \"off\": \"關閉（開放）\",\n                    \"strict\": \"全域（嚴格）\",\n                    \"all_except_health\": \"全域（除健康檢查外）\",\n                    \"auto\": \"自動（推薦）\"\n                }\n            },\n            \"zai\": {\n                \"title\": \"z.ai（GLM）提供商\",\n                \"title_tooltip\": \"為 Claude 協定提供可選的 Anthropic 相容上游（z.ai）。只影響 Claude 協定請求，Google 帳號池仍按原邏輯工作。\",\n                \"subtitle\": \"可選的 Anthropic 相容上游，僅用於 Claude 協定請求。\",\n                \"enabled\": \"已啟用\",\n                \"enabled_tooltip\": \"啟用後，Claude 協定請求將按“分發模式”路由到 z.ai。\",\n                \"base_url\": \"Base URL\",\n                \"base_url_tooltip\": \"z.ai Anthropic 相容介面的基礎地址。預設 https://api.z.ai/api/anthropic，代理會在其後拼接 /v1/messages 等路徑。\",\n                \"dispatch_mode\": \"分發模式\",\n                \"dispatch_mode_tooltip\": \"控制何時使用 z.ai：關閉=不使用；全部 Claude 請求=所有 /v1/messages 等都轉發到 z.ai；加入佇列=把 z.ai 當作佇列中的 1 個槽位按輪詢分配；僅作備援=僅當沒有可用 Google 帳號時才使用。\",\n                \"api_key\": \"API Key\",\n                \"api_key_tooltip\": \"用於呼叫 z.ai 上游的 API Key（本地儲存）。啟用 z.ai 或 MCP 功能前必須配置。\",\n                \"api_key_placeholder\": \"在此貼上 z.ai API Key\",\n                \"warning\": \"提示：該 Key 將儲存在本機應用資料目錄中。\",\n                \"models\": {\n                    \"title\": \"模型對映\",\n                    \"title_tooltip\": \"從 z.ai 擷取可用模型 ID，並配置如何把 Claude/Anthropic 的 model 名稱轉換為 z.ai 的模型 ID。\",\n                    \"refresh\": \"擷取模型\",\n                    \"refreshing\": \"擷取中...\",\n                    \"hint\": \"可用模型：{{count}}。可從建議中選擇，或手動輸入自定義模型 ID。\",\n                    \"error\": \"擷取模型失敗：{{error}}\",\n                    \"select_placeholder\": \"選擇模型...\",\n                    \"opus\": \"Opus 家族 → z.ai 模型\",\n                    \"opus_tooltip\": \"當請求的 model 包含“opus”（如 claude-opus-*）時預設使用的 z.ai 模型 ID。\",\n                    \"sonnet\": \"Sonnet 家族 → z.ai 模型\",\n                    \"sonnet_tooltip\": \"其他 Claude 模型（如 claude-sonnet-* 以及大多數 claude-*）預設使用的 z.ai 模型 ID。\",\n                    \"haiku\": \"Haiku 家族 → z.ai 模型\",\n                    \"haiku_tooltip\": \"當請求的 model 包含“haiku”（如 claude-haiku-*）時預設使用的 z.ai 模型 ID。\",\n                    \"advanced_title\": \"進階覆蓋規則\",\n                    \"advanced_tooltip\": \"可選的精確比對覆蓋規則：如果 incoming model 字串與規則鍵完全一致，則替換為對應的 z.ai 模型 ID。\",\n                    \"from_label\": \"incoming model\",\n                    \"to_label\": \"z.ai 模型\",\n                    \"add_rule\": \"新增\",\n                    \"empty\": \"尚未配置自定義替換規則。\",\n                    \"from_placeholder\": \"源模型 (如 claude-3-opus)\",\n                    \"to_placeholder\": \"映射至 (如 glm-4)\"\n                },\n                \"modes\": {\n                    \"off\": \"關閉\",\n                    \"exclusive\": \"全部 Claude 請求走 z.ai\",\n                    \"pooled\": \"加入佇列（佔 1 個槽位）\",\n                    \"fallback\": \"僅作備援\"\n                },\n                \"mcp\": {\n                    \"title\": \"MCP 服務（透過本地代理）\",\n                    \"title_tooltip\": \"在本地代理上暴露可選的 /mcp/* 端點，供 MCP 客戶端直連。僅在服務執行、z.ai 配置完成且對應開關開啟時可用。\",\n                    \"enabled\": \"啟用 MCP 反向代理\",\n                    \"enabled_tooltip\": \"MCP 端點總開關；關閉時所有 /mcp/* 路由將返回 404。\",\n                    \"web_search\": \"網頁搜尋\",\n                    \"web_search_tooltip\": \"啟用後開放 /mcp/web_search_prime/mcp，並轉發到 z.ai 的 Web Search MCP 上游。\",\n                    \"web_reader\": \"網頁閱讀\",\n                    \"web_reader_tooltip\": \"啟用後開放 /mcp/web_reader/mcp，並轉發到 z.ai 的 Web Reader MCP 上游。\",\n                    \"vision\": \"視覺\",\n                    \"vision_tooltip\": \"啟用後開放 /mcp/zai-mcp-server/mcp（本地 MCP 服務），提供視覺相關工具能力，並透過 z.ai 執行。\",\n                    \"local_endpoints\": \"本地地址（在 MCP 客戶端中配置以下 URL）：\",\n                    \"local_endpoints_tooltip\": \"把這些 URL 填到你的 MCP 客戶端裡即可。它們使用同一個代理通訊埠，並遵循當前的鑑權策略。\"\n                }\n            },\n            \"request_timeout\": \"請求逾時\",\n            \"request_timeout_tooltip\": \"代理等待上游響應的最大時間（秒），包含流式輸出。長文字/長推理可適當調大；修改後需重啟生效。\",\n            \"request_timeout_hint\": \"預設 120 秒，範圍 30-7200 秒。修改後需重啟服務生效。\",\n            \"enable_logging\": \"啟用請求紀錄\",\n            \"enable_logging_hint\": \"記錄歷史紀錄以便除錯 (微小效能損耗)\",\n            \"upstream_proxy\": {\n                \"title\": \"全域上游代理 (Global Proxy)\",\n                \"desc\": \"開啟後，應用內所有外部請求（API 反向代理、Token 重新整理、配額查詢、更新檢測）都將透過此代理。\",\n                \"desc_short\": \"用於無法匹配代理池帳號時的通用出口或降級方案。\",\n                \"enable\": \"啟用上游代理\",\n                \"url\": \"代理地址\",\n                \"url_placeholder\": \"例如: http://127.0.0.1:7890 或 socks5://127.0.0.1:7890\",\n                \"tip\": \"支援 HTTP、HTTPS 和 SOCKS5 協定。\",\n                \"socks5h_hint\": \"若需避開上游風控並保留原始功能變數名稱解析 (Remote DNS)，請手動將協定改為 socks5h://\",\n                \"validation_error\": \"啟用上游代理時必須填寫代理地址\",\n                \"restart_hint\": \"代理設定已儲存，重新啟動應用程式後生效\"\n            },\n            \"scheduling\": {\n                \"title\": \"帳號輪換與會話排程\",\n                \"title_tooltip\": \"控制會話如何繫結到帳號，以及觸發限流時的行為。\",\n                \"subtitle\": \"針對全協定客戶端最佳化 Prompt Caching 和限流處理 (OpenAI/Gemini/Claude)。\",\n                \"mode\": \"排程模式\",\n                \"mode_tooltip\": \"快取優先：繫結帳號，限流時強制等待（最大化快取）；平衡：繫結帳號，限流時自動熱切；效能優先：不繫結，全自動輪換。\",\n                \"modes\": {\n                    \"CacheFirst\": \"快取優先 (Cache First)\",\n                    \"Balance\": \"平衡輪換 (Balance)\",\n                    \"PerformanceFirst\": \"效能優先 (Performance)\"\n                },\n                \"modes_desc\": {\n                    \"CacheFirst\": \"繫結會話與帳號，限流時精準等待（最大化 Prompt Cache 命中率）。\",\n                    \"Balance\": \"繫結會話，限流時自動熱切換至可用帳號（兼顧快取與可用性）。\",\n                    \"PerformanceFirst\": \"無會話繫結，純隨機輪換（適合高併發，不考慮快取）。\"\n                },\n                \"max_wait\": \"最大等待時長 (秒)\",\n                \"max_wait_tooltip\": \"僅在“快取優先”模式下生效：如果帳號限流重置時間小於此值，則原地等待而非切換帳號。\",\n                \"clear_bindings\": \"清除會話繫結\",\n                \"clear_bindings_tooltip\": \"立即斷開所有會話與帳號的繫結關係，強制下一次請求重新分配帳號。\",\n                \"clear_rate_limits\": \"清除限流記錄\",\n                \"clear_rate_limits_tooltip\": \"立即清除所有帳號的本地限流記錄，強制下一次請求嘗試直接呼叫上游。\",\n                \"fixed_account\": \"固定帳戶模式\",\n                \"fixed_account_tooltip\": \"啟用後，所有 API 請求將只使用所選帳戶，而不會在帳戶之間輪換。\",\n                \"round_robin_set\": \"輪詢模式已啟用\"\n            },\n            \"circuit_breaker\": {\n                \"title\": \"自適應熔斷器\",\n                \"tooltip\": \"當帳號因配額耗盡反覆失敗時，自動增加鎖定時間。這可以防止在死帳號上浪費 API 呼叫，同時允許瞬態錯誤快速恢復。\",\n                \"backoff_levels\": \"退避等級 (秒)\",\n                \"input_placeholder\": \"輸入退避時長 (秒)，以逗號分隔\",\n                \"level\": \"等級 {{level}}\",\n                \"invalid_format\": \"格式無效。請使用逗號分隔的數字 (例如 60, 300)\",\n                \"clear_records\": \"清除所有限流記錄\"\n            },\n            \"experimental\": {\n                \"title\": \"實驗性設定 (Experimental)\",\n                \"title_tooltip\": \"探索性功能，可能在未來版本中調整或移除。\",\n                \"enable_usage_scaling\": \"啟用用量縮放\",\n                \"enable_usage_scaling_tooltip\": \"針對 Claude 協定。當總輸入超過 30k Token 時開啟激進縮放，防止在大上下文下頻繁觸發用戶端壓縮。注意：開啟後用戶端顯示的用量不再代表實際計費點數。\"\n            },\n            \"cloudflared\": {\n                \"title\": \"公網訪問 (Cloudflared)\",\n                \"subtitle\": \"透過 Cloudflare Tunnel 將本地服務暴露到網際網路\",\n                \"not_installed\": \"Cloudflared 未安裝\",\n                \"install_hint\": \"Cloudflared 是 Cloudflare 提供的免費隧道工具，可將本地代理暴露到網際網路，無需公網 IP 或埠轉發。點擊下方按鈕安裝。\",\n                \"install\": \"立即安裝\",\n                \"installing\": \"安裝中...\",\n                \"install_success\": \"Cloudflared 安裝成功\",\n                \"install_failed\": \"安裝失敗: {{error}}\",\n                \"installed\": \"已安裝\",\n                \"version\": \"版本\",\n                \"mode_label\": \"隧道模式\",\n                \"mode_quick\": \"快速隧道\",\n                \"mode_quick_desc\": \"自動生成臨時 URL (*.trycloudflare.com)，無需帳號，重啟後 URL 會變更\",\n                \"mode_auth\": \"命名隧道\",\n                \"mode_auth_desc\": \"使用 Cloudflare 帳號令牌，支援自訂網域，永久 URL\",\n                \"token\": \"隧道令牌\",\n                \"token_placeholder\": \"在此貼上您的 Cloudflare Tunnel Token\",\n                \"token_hint\": \"從 Cloudflare Zero Trust 控制台取得\",\n                \"token_required\": \"命名隧道模式需要令牌\",\n                \"use_http2\": \"使用 HTTP/2\",\n                \"use_http2_desc\": \"相容性更好，建議中國大陸使用\",\n                \"status_label\": \"隧道狀態\",\n                \"status_stopped\": \"已停止\",\n                \"status_starting\": \"啟動中...\",\n                \"status_running\": \"執行中\",\n                \"status_error\": \"錯誤\",\n                \"public_url\": \"公網 URL\",\n                \"public_url_copied\": \"已複製 URL\",\n                \"start_tunnel\": \"啟動隧道\",\n                \"stop_tunnel\": \"停止隧道\",\n                \"restart_tunnel\": \"重啟隧道\",\n                \"starting\": \"啟動中...\",\n                \"stopping\": \"停止中...\",\n                \"start_success\": \"隧道已成功啟動\",\n                \"stop_success\": \"隧道已停止\",\n                \"start_failed\": \"隧道啟動失敗: {{error}}\",\n                \"stop_failed\": \"隧道停止失敗: {{error}}\",\n                \"logs\": \"日誌\",\n                \"clear_logs\": \"清除日誌\",\n                \"auto_start\": \"隨代理自動啟動\",\n                \"auto_start_desc\": \"API 代理服務啟動時自動啟動隧道\",\n                \"warning_quick_mode\": \"⚠️ 快速模式: URL 會在每次重啟時變更\",\n                \"warning_token_storage\": \"💡 令牌會安全地儲存在本地\"\n            }\n        },\n        \"example\": {\n            \"title\": \"使用示例\",\n            \"curl\": \"cURL\",\n            \"python\": \"Python\",\n            \"python_anthropic\": \"from anthropic import Anthropic\\n\\nclient = Anthropic(\\n    # 推薦使用 127.0.0.1\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\n# 注意: Antigravity 支援使用 Anthropic SDK 呼叫任意模型\\nresponse = client.messages.create(\\n    model=\\\"{{modelId}}\\\",\\n    max_tokens=1024,\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hello\\\"}]\\n)\\n\\nprint(response.content[0].text)\",\n            \"python_gemini\": \"# 需要安裝: pip install google-generativeai\\nimport google.generativeai as genai\\n\\n# 使用 Antigravity 代理地址 (推薦 127.0.0.1)\\ngenai.configure(\\n    api_key=\\\"{{apiKey}}\\\",\\n    transport='rest',\\n    client_options={'api_endpoint': '{{rawBaseUrl}}'}\\n)\\n\\nmodel = genai.GenerativeModel('{{modelId}}')\\nresponse = model.generate_content(\\\"Hello\\\")\\nprint(response.text)\",\n            \"python_openai_image\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    # 方式 1: 使用 size 參數 (推薦)\\n    # 支援: \\\"1024x1024\\\" (1:1), \\\"1280x720\\\" (16:9), \\\"720x1280\\\" (9:16), \\\"1216x896\\\" (4:3)\\n    extra_body={ \\\"size\\\": \\\"1024x1024\\\" },\\n\\n    # 方式 2: 使用模型後綴\\n    # 例如: gemini-3-pro-image-16-9, gemini-3-pro-image-4-3\\n    # model=\\\"gemini-3-pro-image-16-9\\\",\\n    messages=[{\\n        \\\"role\\\": \\\"user\\\",\\n        \\\"content\\\": \\\"畫一個未來城市\\\"\\n    }]\\n)\\n\\nprint(response.choices[0].message.content)\",\n            \"python_openai\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hello\\\"}]\\n)\\n\\nprint(response.choices[0].message.content)\"\n        },\n        \"dialog\": {\n            \"confirm_regenerate\": \"確定要生成新的 API Key 嗎？舊的 Key 將立即失效。\",\n            \"operate_failed\": \"操作失敗: {{error}}\",\n            \"reset_mapping_title\": \"重置模型對映\",\n            \"reset_mapping_msg\": \"確定要重置所有模型對映為系統預設嗎？此操作無法撤銷。\",\n            \"regenerate_key_title\": \"重新生成 API Key\",\n            \"regenerate_key_msg\": \"確定要重新生成 API Key 嗎？舊的 Key 將立即失效。\",\n            \"clear_bindings_title\": \"清除會話繫結\",\n            \"clear_bindings_msg\": \"確定要清除所有會話與帳號的繫結對映嗎？\",\n            \"clear_rate_limits_title\": \"清除限流記錄\",\n            \"clear_rate_limits_confirm\": \"確定要清除所有本地限流記錄嗎？\"\n        },\n        \"model\": {\n            \"flash\": \"極速回應\",\n            \"flash_preview\": \"極速預覽\",\n            \"flash_lite\": \"輕量極速\",\n            \"flash_thinking\": \"思考能力\",\n            \"pro_legacy\": \"經典版\",\n            \"pro_low\": \"標準效能\",\n            \"pro_high\": \"最強推理\",\n            \"pro_image\": \"圖片生成 (1:1)\",\n            \"pro_image_16_9\": \"圖片生成 (16:9)\",\n            \"pro_image_9_16\": \"圖片生成 (9:16)\",\n            \"pro_image_4_3\": \"圖片生成 (4:3)\",\n            \"pro_image_3_4\": \"圖片生成 (3:4)\",\n            \"pro_image_1_1\": \"圖片生成 (1:1)\",\n            \"claude_sonnet\": \"程式碼推理\",\n            \"claude_sonnet_thinking\": \"思維鏈\",\n            \"claude_opus_thinking\": \"最強思維\"\n        },\n        \"mapping\": {\n            \"title\": \"Claude Code 模型對映\",\n            \"description\": \"將 Claude Code 模型對映到 Antigravity 模型,智慧路由請求以最佳化成本和速度。\",\n            \"default\": \"預設\",\n            \"sonnet_desc\": \"最強複雜任務\",\n            \"opus_desc\": \"進階層級\",\n            \"haiku_desc\": \"極速回應\",\n            \"maps_to\": \"對映到 Antigravity\",\n            \"apply_recommended\": \"應用推薦配置\",\n            \"restore_defaults\": \"恢復預設配置\",\n            \"reset_all\": \"重置全部\"\n        },\n        \"router\": {\n            \"title\": \"模型路由中心 (Model Router)\",\n            \"subtitle\": \"按“規格家族”統一路由 OpenAI/Claude 模型，或新增最高優先順序的“精確對映”。\\n注意：Claude 原生直通模型（claude-opus-4-6-thinking 等 3 個）預設直通，需在“專家精確對映”中新增規則才能修改。\",\n            \"subtitle_simple\": \"透過萬用字元或精確對映自定義模型路由規則\",\n            \"background_task_title\": \"後台任務模型\",\n            \"background_task_desc\": \"用於 Claude CLI 後台任務的模型,如標題生成、總結等。(預設: gemini-2.5-flash)\",\n            \"use_default\": \"使用系統預設\",\n            \"current_model\": \"目前模型\",\n            \"apply_presets\": \"應用預設對映\",\n            \"presets_applied\": \"預設對映已應用\",\n            \"preset_default\": \"預設預設\",\n            \"preset_default_desc\": \"GPT-4 → Gemini Pro, Claude → Opus\",\n            \"preset_performance\": \"性能優先\",\n            \"preset_performance_desc\": \"全部使用高性能模型\",\n            \"preset_cost\": \"成本優化\",\n            \"preset_cost_desc\": \"優先使用經濟型模型\",\n            \"preset_balanced\": \"均衡模式\",\n            \"preset_balanced_desc\": \"平衡性能和成本\",\n            \"built_in_presets\": \"內置預設\",\n            \"custom_presets\": \"自定義預設\",\n            \"apply_selected\": \"應用所选\",\n            \"add_preset\": \"保存當前映射\",\n            \"delete_preset\": \"刪除當前預設\",\n            \"cannot_delete_builtin\": \"無法刪除內置預設\",\n            \"no_mapping_to_save\": \"沒有可保存的映射配置\",\n            \"preset_name_required\": \"請先輸入預設名稱\",\n            \"preset_saved\": \"預設保存成功\",\n            \"manage_presets_title\": \"管理自定義預設\",\n            \"save_current_as_preset\": \"保存當前配置\",\n            \"preset_name_placeholder\": \"輸入預設名稱...\",\n            \"save_hint\": \"將當前激活的模型映射保存為可重複使用的預設。\",\n            \"your_presets\": \"您的預設\",\n            \"no_custom_presets\": \"暫無自定義預設\",\n            \"mappings_count\": \"個映射\",\n            \"custom_preset_desc\": \"用戶自定義預設\",\n            \"custom_mappings\": \"自定義對映 (Custom Mappings)\",\n            \"original_id\": \"原始模型 ID\",\n            \"route_to\": \"路由目標\",\n            \"group_title\": \"模型家族分組 (Series Groups)\",\n            \"groups\": {\n                \"claude_45\": {\n                    \"name\": \"Claude 4.6 TK 系列\",\n                    \"desc\": \"Opus 4.5 TK (推理)\"\n                },\n                \"claude_35\": {\n                    \"name\": \"Claude 3.5 系列\",\n                    \"desc\": \"Sonnet 3.5, Haiku 3.5\"\n                },\n                \"gpt_4\": {\n                    \"name\": \"GPT-4 / o1 系列\",\n                    \"desc\": \"GPT-4, Turbo, o1-preview\"\n                },\n                \"gpt_4o\": {\n                    \"name\": \"GPT-4o / 3.5 系列\",\n                    \"desc\": \"GPT-4o, Mini, 3.5 Turbo\"\n                },\n                \"gpt_5\": {\n                    \"name\": \"GPT-5 系列\",\n                    \"desc\": \"GPT-5.1, GPT-5.2 xhigh\"\n                }\n            },\n            \"expert_title\": \"專家精確對映\",\n            \"expert_subtitle\": \"精確比對支援從任何協定傳來的原始模型 ID。\",\n            \"custom_mapping_tip\": \"💡 支援手動輸入任意模型 ID，可體驗未發布模型（如 claude-opus-4-6）。\",\n            \"custom_mapping_warning\": \"注意：並非所有帳號都支援未發布模型。\",\n            \"money_saving_tip\": \"💰 省錢提示:\",\n            \"haiku_optimization_tip\": \"Claude CLI 預設使用 {{model}} 處理背景任務,建議對映到廉價 Flash 模型可節省約 95% 成本\",\n            \"haiku_optimization_btn\": \"一鍵最佳化\",\n            \"haiku_tip_title\": \"💰 省錢提示:\",\n            \"haiku_tip_body_before\": \"Claude CLI 預設使用\",\n            \"haiku_tip_body_after\": \"處理背景任務,建議對映到廉價 Flash 模型可節省約 95% 成本\",\n            \"haiku_tip_action\": \"一鍵最佳化\",\n            \"reset_confirm\": \"確定要重置所有模型對映為系統預設嗎？\",\n            \"reset_mapping\": \"重置對映\",\n            \"add_mapping\": \"新增對映 (Add Mapping)\",\n            \"current_list\": \"當前對映列表 (Custom List)\",\n            \"no_custom_mapping\": \"暫無自定義精確對映\",\n            \"gemini3_only_warning\": \"⚠️ 僅支援 Gemini 3 系列\",\n            \"default_suffix\": \"（預設）\",\n            \"select_target_model\": \"選擇目標模型\",\n            \"original_placeholder\": \"原始名 (如 gpt-4 或 gpt-4*)\"\n        },\n        \"examples\": {\n            \"title\": \"使用示例\"\n        },\n        \"multi_protocol\": {\n            \"title\": \"多協定支援 (Multi-Protocol Support)\",\n            \"subtitle\": \"快速同步 API 地址與金鑰到本地 AI 工具\",\n            \"description\": \"反代服務支援 OpenAI、Anthropic 和 Gemini 協定，滿足不同工具的整合需求\",\n            \"openai_label\": \"OpenAI 協定\",\n            \"anthropic_label\": \"Anthropic 協定\",\n            \"openai_tools\": \"Cherry Studio, NextChat\",\n            \"anthropic_tools\": \"Claude Code CLI\",\n            \"gemini_label\": \"Gemini 協定\",\n            \"gemini_tools\": \"Google AI SDK, LangChain\",\n            \"quick_integration\": \"快速整合 (Quick Integration)\",\n            \"click_tip\": \"👆 點擊模型以更新程式碼範例\",\n            \"copy_base\": \"複製 Base\"\n        },\n        \"cli_sync\": {\n            \"title\": \"CLI 配置一鍵同步\",\n            \"subtitle\": \"快速將當前 API 服務地址與 API 密鑰同步到本地 AI CLI 工具中\",\n            \"card_title\": \"{{name}} 配置\",\n            \"status\": {\n                \"not_installed\": \"未檢測到安裝\",\n                \"installed\": \"v{{version}}\",\n                \"synced\": \"已指向本項目\",\n                \"not_synced\": \"未同步\",\n                \"detecting\": \"檢測中...\",\n                \"current_base_url\": \"當前介面地址 (Base URL)\"\n            },\n            \"btn_sync\": \"立即同步配置\",\n            \"btn_view\": \"檢視當前配置\",\n            \"btn_restore\": \"恢復預設配置\",\n            \"btn_restore_backup\": \"還原原有配置\",\n            \"restore_backup_confirm\": \"檢測到舊的配置檔案備份，確定要還原嗎？\",\n            \"sync_confirm_title\": \"同步確認\",\n            \"sync_confirm_message\": \"即將為您同步 {{name}} 的配置。⚠️ 注意：此操作將覆蓋您本地已有的配置檔案（如登入狀態、API Key 等）。確定要繼續嗎？\",\n            \"restore_confirm\": \"確定要將 {{name}} 的配置恢復為官方預設 URL 嗎？\",\n            \"modal\": {\n                \"view_title\": \"{{name}} 配置文件內容\",\n                \"copy_success\": \"配置內容已複製到剪貼簿\"\n            },\n            \"toast\": {\n                \"config_missing\": \"請先生成 API Key 並啟動服務\",\n                \"sync_success\": \"同步成功！{{name}} 已準備就緒。\",\n                \"sync_error\": \"同步 {{name}} 失敗: {{error}}\"\n            }\n        },\n        \"supported_models\": {\n            \"title\": \"支援模型與整合 (Supported Models & Integration)\",\n            \"model_name\": \"模型名稱\",\n            \"model_id\": \"模型 ID\",\n            \"description\": \"描述\",\n            \"action\": \"操作\"\n        }\n    },\n    \"monitor\": {\n        \"page_title\": \"API 監控看板\",\n        \"page_subtitle\": \"即時請求紀錄與分析\",\n        \"open_monitor\": \"開啟監控\",\n        \"logging_status\": {\n            \"active\": \"正在錄製\",\n            \"paused\": \"已暫停\"\n        },\n        \"stats\": {\n            \"total\": \"總計\",\n            \"ok\": \"正常\",\n            \"err\": \"錯誤\"\n        },\n        \"filters\": {\n            \"placeholder\": \"搜尋模型 (gemini, claude)、路徑 (chat, images) 或狀態碼...\",\n            \"quick_filters\": \"快速過濾:\",\n            \"all\": \"全部\",\n            \"error\": \"僅錯誤\",\n            \"chat\": \"聊天\",\n            \"gemini\": \"Gemini\",\n            \"claude\": \"Claude\",\n            \"images\": \"繪圖\",\n            \"reset\": \"重置\",\n            \"by_account\": \"按帳號篩選\",\n            \"all_accounts\": \"全部帳號\"\n        },\n        \"table\": {\n            \"status\": \"狀態\",\n            \"method\": \"方法\",\n            \"model\": \"模型\",\n            \"protocol\": \"協定\",\n            \"account\": \"帳號\",\n            \"path\": \"路徑\",\n            \"usage\": \"Token 消耗\",\n            \"duration\": \"耗時\",\n            \"time\": \"時間\",\n            \"empty\": \"暫無請求紀錄\"\n        },\n        \"details\": {\n            \"title\": \"請求詳情\",\n            \"request_payload\": \"請求封包 (Request)\",\n            \"response_payload\": \"回應封包 (Response)\",\n            \"duration\": \"耗時\",\n            \"tokens\": \"Token 消耗 (輸入/輸出)\",\n            \"time\": \"請求時間\",\n            \"model\": \"使用模型\",\n            \"id\": \"請求 ID\",\n            \"protocol\": \"協定類型\",\n            \"mapped_model\": \"路由後模型\",\n            \"account_used\": \"使用帳號\",\n            \"payload_empty\": \"無封包資料\"\n        },\n        \"dialog\": {\n            \"clear_title\": \"清除監控紀錄\",\n            \"clear_msg\": \"確定要清除所有監控紀錄嗎？此操作無法撤銷。\"\n        }\n    },\n    \"update_notification\": {\n        \"title\": \"發現新版本\",\n        \"message\": \"新版本已準備就緒，包含諸多優化與改進。目前版本: v{{current}}\",\n        \"ready\": \"更新準備就緒\",\n        \"downloading\": \"正在下載更新...\",\n        \"restarting\": \"正在重新啟動應用程式...\",\n        \"auto_update\": \"自動更新\",\n        \"toast\": {\n            \"not_ready\": \"自動更新包尚未就緒，為您跳轉到下載頁面...\",\n            \"failed\": \"自動更新失敗，為您跳轉到下載頁面...\"\n        }\n    },\n    \"login\": {\n        \"title\": \"安全存取控制\",\n        \"desc\": \"當前運行在 Web 模式下，請輸入管理密碼或 API Key 以進入後台。\",\n        \"placeholder\": \"請輸入管理密碼或 API Key\",\n        \"btn_login\": \"驗證並進入\",\n        \"btn_verifying\": \"驗證中...\",\n        \"error_invalid_key\": \"密碼或 API Key 錯誤，請重試\",\n        \"error_network\": \"網路連線失敗，請檢查服務是否正常運行\",\n        \"note\": \"注意：如果設置了獨立的管理密碼，請輸入管理密碼；否則請輸入 API_KEY。\",\n        \"lookup_hint\": \"如果您忘記了，請運行 docker logs antigravity-manager 尋找 Current API Key 或 Web UI Password\",\n        \"config_hint\": \"或執行 grep -E '\\\"api_key\\\"|\\\"admin_password\\\"' ~/.antigravity_tools/gui_config.json 查看。\"\n    },\n    \"token_stats\": {\n        \"title\": \"Token 消費統計\",\n        \"hourly\": \"每小時\",\n        \"daily\": \"每日\",\n        \"weekly\": \"每週\",\n        \"total_tokens\": \"總 Token\",\n        \"input_tokens\": \"輸入 Token\",\n        \"output_tokens\": \"輸出 Token\",\n        \"accounts_used\": \"活躍帳號\",\n        \"models_used\": \"使用模型\",\n        \"model_trend\": \"分模型使用趨勢\",\n        \"account_trend\": \"分帳號使用趨勢\",\n        \"usage_trend\": \"Token 使用趨勢\",\n        \"by_account\": \"分帳號統計\",\n        \"by_model\": \"按模型\",\n        \"by_account_view\": \"按帳號\",\n        \"model_details\": \"分模型詳細統計\",\n        \"account_details\": \"帳號詳細統計\",\n        \"model\": \"模型\",\n        \"account\": \"帳號\",\n        \"requests\": \"請求數\",\n        \"input\": \"輸入\",\n        \"output\": \"輸出\",\n        \"total\": \"合計\",\n        \"percentage\": \"佔比\",\n        \"no_data\": \"暫無資料\"\n    },\n    \"security\": {\n        \"title\": \"安全監控\",\n        \"refresh_data\": \"重新整理資料\",\n        \"refresh\": \"重新整理\",\n        \"tab_logs\": \"存取日誌\",\n        \"tab_stats\": \"統計分析\",\n        \"tab_blacklist\": \"黑名單管理\",\n        \"tab_whitelist\": \"白名單管理\",\n        \"tab_config\": \"安全設定\",\n        \"stats\": {\n            \"total_requests\": \"總請求數\",\n            \"total_requests_desc\": \"所有記錄的請求\",\n            \"unique_ips\": \"獨立 IP 數\",\n            \"unique_ips_desc\": \"不同的客戶端 IP 位址\",\n            \"blocked_requests\": \"攔截請求數\",\n            \"blocked_requests_desc\": \"被規則拒絕的請求\",\n            \"ip_activity_token_usage\": \"IP 活躍度 & Token 消耗\",\n            \"hour\": \"時\",\n            \"day\": \"日\",\n            \"week\": \"週\",\n            \"month\": \"月\",\n            \"rank\": \"排名\",\n            \"ip_address\": \"IP 位址\",\n            \"activity_reqs\": \"活躍度 (請求數)\",\n            \"total_token\": \"總 Token\",\n            \"prompt\": \"提問\",\n            \"completion\": \"回答\",\n            \"no_data\": \"暫無資料\"\n        },\n        \"logs\": {\n            \"search_placeholder\": \"搜尋 IP, 路徑, User Agent...\",\n            \"username\": \"用戶\",\n            \"show_blocked_only\": \"僅顯示被攔截\",\n            \"status\": \"狀態\",\n            \"ip_address\": \"IP 位址\",\n            \"method\": \"方法\",\n            \"path\": \"路徑\",\n            \"duration\": \"耗時\",\n            \"time\": \"時間\",\n            \"reason\": \"原因\",\n            \"blocked\": \"攔截\",\n            \"no_logs\": \"暫無日誌\",\n            \"total_records\": \"共 {{total}} 條記錄\",\n            \"prev_page\": \"上一頁\",\n            \"next_page\": \"下一頁\",\n            \"page_num\": \"第 {{page}} 頁\",\n            \"per_page_suffix\": \"/頁\"\n        },\n        \"blacklist\": {\n            \"add_ip\": \"新增 IP\",\n            \"search_placeholder\": \"搜尋...\",\n            \"added_at\": \"新增時間\",\n            \"expires_at\": \"過期時間\",\n            \"no_data\": \"暫無黑名單資料\",\n            \"add_title\": \"新增到黑名單\",\n            \"ip_cidr_label\": \"IP 位址或 CIDR\",\n            \"ip_cidr_placeholder\": \"例如 192.168.1.1 或 10.0.0.0/24\",\n            \"reason_label\": \"原因 (可選)\",\n            \"reason_placeholder\": \"例如：惡意掃描\",\n            \"expires_label\": \"過期時間 (小時，可選)\",\n            \"expires_placeholder\": \"留空則永久生效\",\n            \"cancel\": \"取消\",\n            \"confirm\": \"新增\",\n            \"add_btn\": \"新增\"\n        },\n        \"whitelist\": {\n            \"add_ip\": \"新增信任 IP\",\n            \"no_data\": \"暫無白名單資料\",\n            \"add_title\": \"新增到白名單\",\n            \"description_label\": \"備註 (可選)\",\n            \"description_placeholder\": \"例如：內部伺服器\",\n            \"cancel\": \"取消\",\n            \"confirm\": \"新增\",\n            \"add_btn\": \"新增\"\n        },\n        \"config\": {\n            \"title\": \"安全設定\",\n            \"save\": \"儲存變更\",\n            \"saving\": \"儲存中...\",\n            \"blacklist_title\": \"IP 黑名單\",\n            \"blacklist_desc\": \"管理被攔截的 IP 位址和規則。\",\n            \"enable_blacklist\": \"啟用黑名單保護\",\n            \"block_msg_label\": \"自定義攔截訊息\",\n            \"block_msg_desc\": \"返回給被攔截客戶端的響應內容。\",\n            \"whitelist_title\": \"IP 白名單\",\n            \"whitelist_desc\": \"管理信任的 IP 位址。\",\n            \"enable_whitelist\": \"啟用白名單模式\",\n            \"whitelist_warning\": \"警告: 啟用白名單模式將攔截所有不在白名單中的 IP 請求。如果您透過代理存取，請務必小心不要將自己鎖在外面。\",\n            \"whitelist_priority\": \"白名單優先 (覆蓋黑名單)\",\n            \"whitelist_priority_desc\": \"啟用後，白名單 IP 將被允許存取，即使它們匹配黑名單規則。\",\n            \"load_error\": \"載入設定失敗\",\n            \"save_success\": \"設定已儲存\",\n            \"save_error\": \"儲存設定失敗\"\n        }\n    },\n    \"user_token\": {\n        \"title\": \"用戶 Token 管理\",\n        \"total_users\": \"用戶總數\",\n        \"active_tokens\": \"活躍 Token\",\n        \"total_created\": \"累計建立\",\n        \"create\": \"建立 Token\",\n        \"username\": \"用戶名\",\n        \"token\": \"Token\",\n        \"expires\": \"過期時間\",\n        \"usage\": \"使用量\",\n        \"ip_limit\": \"IP 限制\",\n        \"created\": \"建立時間\",\n        \"today_requests\": \"今日請求數\",\n        \"never\": \"永不過期\",\n        \"renew\": \"續期\",\n        \"renew_button\": \"續期\",\n        \"unlimited\": \"不限\",\n        \"create_title\": \"建立新 Token\",\n        \"description\": \"描述\",\n        \"curfew\": \"宵禁時間 (服務不可用時段)\",\n        \"edit_title\": \"編輯 Token\",\n        \"username_required\": \"用戶名不能為空\",\n        \"renew_success\": \"續期成功\",\n        \"expires_day\": \"1 天\",\n        \"expires_week\": \"1 週\",\n        \"expires_month\": \"1 個月\",\n        \"expires_never\": \"永不過期\",\n        \"no_data\": \"暫無數據\",\n        \"placeholder_username\": \"例如: user1\",\n        \"placeholder_desc\": \"選填備註\",\n        \"placeholder_max_ips\": \"0 = 不限制\",\n        \"hint_max_ips\": \"0 表示不限制\",\n        \"hint_curfew\": \"留空則禁用。基於伺服器時間。\"\n    }\n}"
  },
  {
    "path": "src/locales/zh.json",
    "content": "{\n    \"common\": {\n        \"loading\": \"加载中...\",\n        \"load_more\": \"加载更多\",\n        \"add\": \"添加\",\n        \"copy\": \"复制\",\n        \"action\": \"操作\",\n        \"save\": \"保存\",\n        \"saved\": \"保存成功\",\n        \"cancel\": \"取消\",\n        \"confirm\": \"确认\",\n        \"close\": \"关闭\",\n        \"delete\": \"删除\",\n        \"edit\": \"编辑\",\n        \"refresh\": \"刷新\",\n        \"refreshing\": \"刷新中...\",\n        \"export\": \"导出\",\n        \"import\": \"导入\",\n        \"success\": \"成功\",\n        \"error\": \"错误\",\n        \"unknown\": \"未知\",\n        \"warning\": \"警告\",\n        \"info\": \"提示\",\n        \"details\": \"详情\",\n        \"example\": \"示例\",\n        \"clear\": \"清除\",\n        \"clearing\": \"清理中...\",\n        \"prev_page\": \"上一页\",\n        \"next_page\": \"下一页\",\n        \"pagination_info\": \"显示第 {{start}} 到 {{end}} 条，共 {{total}} 条\",\n        \"per_page\": \"每页\",\n        \"items\": \"条\",\n        \"accounts\": \"个账号\",\n        \"enabled\": \"已启用\",\n        \"disabled\": \"已禁用\",\n        \"tauri_api_not_loaded\": \"Tauri API 未正确加载,请重启应用\",\n        \"environment_error\": \"环境错误: {{error}}\",\n        \"submit\": \"提交\",\n        \"update\": \"更新\",\n        \"load_failed\": \"加载失败\",\n        \"create_success\": \"创建成功\",\n        \"update_success\": \"更新成功\",\n        \"delete_success\": \"删除成功\",\n        \"copied\": \"已复制到剪贴板\",\n        \"reason\": \"原因\",\n        \"show_raw\": \"显示原始报文\",\n        \"show_parsed\": \"显示解析后\"\n    },\n    \"nav\": {\n        \"dashboard\": \"仪表盘\",\n        \"accounts\": \"账号管理\",\n        \"proxy\": \"API 反代\",\n        \"call_records\": \"流量日志\",\n        \"token_stats\": \"Token 统计\",\n        \"security\": \"IP 管理\",\n        \"security_logs\": \"IP 日志\",\n        \"settings\": \"设置\",\n        \"theme_to_dark\": \"切换到深色模式\",\n        \"theme_to_light\": \"切换到浅色模式\",\n        \"switch_to_english\": \"切换到英文\",\n        \"switch_to_chinese\": \"切换到中文\",\n        \"switch_to_traditional_chinese\": \"切换到繁体中文\",\n        \"switch_to_english_short\": \"EN\",\n        \"switch_to_chinese_short\": \"ZH\",\n        \"switch_to_traditional_chinese_short\": \"TW\",\n        \"switch_to_japanese\": \"切换到日文\",\n        \"switch_to_japanese_short\": \"JA\",\n        \"switch_to_turkish\": \"切换到土耳其文\",\n        \"switch_to_turkish_short\": \"TR\",\n        \"switch_to_vietnamese\": \"切换到越南文\",\n        \"switch_to_vietnamese_short\": \"VI\",\n        \"switch_to_russian\": \"切换到俄文\",\n        \"switch_to_russian_short\": \"RU\",\n        \"switch_to_portuguese\": \"切换到葡萄牙文\",\n        \"switch_to_portuguese_short\": \"PT\",\n        \"switch_to_korean\": \"切换到韩语\",\n        \"switch_to_korean_short\": \"KO\",\n        \"switch_to_spanish\": \"切换到西班牙语\",\n        \"switch_to_spanish_short\": \"ES\",\n        \"switch_to_malay\": \"切换到马来语\",\n        \"switch_to_malay_short\": \"MY\",\n        \"user_token\": \"用户 Token\",\n        \"logout\": \"登出\"\n    },\n    \"dashboard\": {\n        \"hello\": \"你好, 用户 👋\",\n        \"refresh_quota\": \"刷新配额\",\n        \"refreshing\": \"刷新中...\",\n        \"total_accounts\": \"总账号数\",\n        \"avg_gemini\": \"Gemini 平均配额\",\n        \"avg_gemini_image\": \"Gemini 绘图平均配额\",\n        \"avg_claude\": \"Claude 平均配额\",\n        \"low_quota_accounts\": \"低配额账号\",\n        \"quota_sufficient\": \"✓ 配额充足\",\n        \"quota_low\": \"⚠ 配额较低\",\n        \"quota_desc\": \"配额 < 20%\",\n        \"current_account\": \"当前账号\",\n        \"switch_account\": \"切换账号\",\n        \"no_active_account\": \"暂无活跃账号\",\n        \"best_accounts\": \"最佳账号推荐\",\n        \"best_account_recommendation\": \"最佳账号推荐\",\n        \"switch_best\": \"一键切换最佳\",\n        \"switch_successfully\": \"一键切换最佳\",\n        \"view_all_accounts\": \"查看所有账号\",\n        \"export_data\": \"导出账号数据\",\n        \"for_gemini\": \"用于 Gemini\",\n        \"for_claude\": \"用于 Claude\",\n        \"toast\": {\n            \"switch_success\": \"切换成功!\",\n            \"switch_error\": \"切换账号失败\",\n            \"refresh_success\": \"配额刷新成功\",\n            \"refresh_error\": \"刷新失败\",\n            \"export_no_accounts\": \"没有可导出的账号\",\n            \"export_success\": \"导出成功! 文件已保存至: {{path}}\",\n            \"export_error\": \"导出失败\"\n        }\n    },\n    \"accounts\": {\n        \"account\": \"账号\",\n        \"search_placeholder\": \"搜索邮箱...\",\n        \"all\": \"全部\",\n        \"available\": \"可用\",\n        \"low_quota\": \"低配额\",\n        \"ultra\": \"ULTRA\",\n        \"pro\": \"PRO\",\n        \"free\": \"FREE\",\n        \"edit_label\": \"编辑标签\",\n        \"custom_label_placeholder\": \"输入自定义标签\",\n        \"label_updated\": \"标签已更新\",\n        \"add_account\": \"添加账号\",\n        \"refresh_all\": \"刷新所有\",\n        \"refresh_selected\": \"刷新 ({{count}})\",\n        \"export_selected\": \"导出 ({{count}})\",\n        \"import_json\": \"导入\",\n        \"import_success\": \"成功导入 {{count}} 个账号\",\n        \"import_partial\": \"导入完成: {{success}} 个成功, {{fail}} 个失败\",\n        \"import_fail\": \"导入失败: {{error}}\",\n        \"import_invalid_format\": \"无效的 JSON 格式，请确保文件包含 email 和 refresh_token 字段\",\n        \"delete_selected\": \"删除 ({{count}})\",\n        \"current\": \"当前\",\n        \"current_badge\": \"当前\",\n        \"disabled\": \"已禁用\",\n        \"disabled_tooltip\": \"账号已被禁用（例如 refresh_token 被撤销/过期）。重新授权或更新 Token 后可恢复。\",\n        \"proxy_disabled\": \"反代已禁用\",\n        \"proxy_disabled_tooltip\": \"此账号已被手动禁用反代功能,不参与 API 请求,但仍可在应用中使用\",\n        \"enable_proxy\": \"启用反代\",\n        \"disable_proxy\": \"禁用反代\",\n        \"enable_proxy_selected\": \"启用 ({{count}})\",\n        \"disable_proxy_selected\": \"禁用 ({{count}})\",\n        \"proxy_disabled_reason_manual\": \"用户手动禁用\",\n        \"proxy_disabled_reason_batch\": \"批量禁用\",\n        \"forbidden\": \"403\",\n        \"forbidden_badge\": \"403\",\n        \"forbidden_tooltip\": \"API 返回 403 Forbidden，账号无权使用 Gemini Code Assist\",\n        \"forbidden_msg\": \"账号无权限，已跳过自动刷新\",\n        \"status\": {\n            \"forbidden\": \"403 Forbidden\",\n            \"disabled\": \"账号已禁用\",\n            \"proxy_disabled\": \"反代已禁用\",\n            \"validation_required\": \"账号需验证\",\n            \"violation_blocked\": \"由于违规被禁用\"\n        },\n        \"error_details\": \"错误详情\",\n        \"error_status\": \"错误状态\",\n        \"error_time\": \"检测时间\",\n        \"view_error\": \"查看原因\",\n        \"click_to_verify\": \"点击去验证\",\n        \"copy_validation_url\": \"复制验证链接\",\n        \"validation_url_copied\": \"验证链接已复制到剪贴板\",\n        \"go_to_appeal\": \"前往申诉\",\n        \"copy_appeal_url\": \"复制申诉链接\",\n        \"fix_guide\": {\n            \"title\": \"终端一键自救指南 (解决部分 403 拦截)\",\n            \"step1_title\": \"🚀 终极杀招（最快方案）\",\n            \"step1_desc\": \"打开终端（Terminal），执行以下命令告诉 Google \\\"是我本人\\\"，可解决部分 403 拦截：\",\n            \"step1_li1\": \"按回车执行，提示继续时输入 <1>Y</1>。\",\n            \"step1_li2\": \"浏览器自动打开，选择账号并“允许”。\",\n            \"step1_li3\": \"看到 <1>You are now authenticated</1> 即大功告成！\",\n            \"step2_title\": \"🧹 如果无效（清除缓存重来）\",\n            \"step2_li1_prefix\": \"先执行清除命令退出旧认证：\",\n            \"step2_li2_prefix\": \"再执行登录：\",\n            \"tips_title\": \"💡 常见建议\",\n            \"tip1\": \"若仍 403，尝试先在终端执行 <1>unset GOOGLE_APPLICATION_CREDENTIALS</1> 重置环境变量。\",\n            \"tip2\": \"生产环境强烈建议改用 <1>服务账户 (Service Account)</1> 的 JSON 密钥，更稳定且免交互。\",\n            \"tip3\": \"若操作失败，请前往 <1>Google Cloud Console</1> 中的 Generative Language API 查看是否被冻结权限。若是，说明账号触发了风控，建议让账号冷却 72 小时后再次尝试。\",\n            \"tip4\": \"你也可以尝试执行 <1>npm install -g @google/gemini-cli</1>，只要不弹出错误，大概率在软件内删除账号重新授权即可。\"\n        },\n        \"no_data\": \"无数据\",\n        \"last_used\": \"最后使用\",\n        \"reset_time\": \"重置时间\",\n        \"switch_to\": \"切换到此账号\",\n        \"actions\": \"操作\",\n        \"device_fingerprint\": \"设备指纹\",\n        \"show_all_quotas\": \"显示所有配额\",\n        \"device_fingerprint_dialog\": {\n            \"title\": \"设备指纹\",\n            \"operations\": \"设备指纹操作\",\n            \"generate_and_bind\": \"生成并绑定\",\n            \"restore_original\": \"恢复原始\",\n            \"open_storage_directory\": \"打开存储目录\",\n            \"current_storage\": \"当前存储\",\n            \"effective\": \"已生效\",\n            \"current_storage_desc\": \"读取自 storage.json（切换账号时应用绑定后更新）\",\n            \"account_binding\": \"账号绑定\",\n            \"pending_application\": \"待应用\",\n            \"account_binding_desc\": \"生成/恢复后保存为绑定，切换账号时写入 storage.json\",\n            \"historical_fingerprints\": \"历史指纹（可选恢复/删除）\",\n            \"no_history\": \"暂无历史\",\n            \"current\": \"当前\",\n            \"restore\": \"恢复\",\n            \"delete_version\": \"删除此版本\",\n            \"confirm_generate_title\": \"确认生成并绑定？\",\n            \"confirm_generate_desc\": \"将生成一套新的设备指纹并设置为当前指纹。确认继续？\",\n            \"confirm_restore_title\": \"确认恢复原始指纹？\",\n            \"confirm_restore_desc\": \"将恢复为原始指纹并覆盖当前指纹。确认继续？\",\n            \"cancel\": \"取消\",\n            \"confirm\": \"确认\",\n            \"processing\": \"处理中...\",\n            \"loading\": \"加载中...\",\n            \"failed_to_load_device_info\": \"加载设备信息失败\",\n            \"generation_failed\": \"生成失败\",\n            \"binding_failed\": \"绑定失败\",\n            \"restoration_failed\": \"恢复失败\",\n            \"deletion_failed\": \"删除失败\",\n            \"directory_open_failed\": \"无法打开目录\",\n            \"generated_and_bound\": \"已生成并绑定\",\n            \"restored\": \"已恢复\",\n            \"deleted\": \"已删除\",\n            \"directory_opened\": \"已打开设备存储目录\",\n            \"original_fingerprint_not_found\": \"未找到原始指纹\"\n        },\n        \"warmup_all\": \"一键预热\",\n        \"warmup_selected\": \"预热 ({{count}})\",\n        \"warmup_this\": \"预热该账号\",\n        \"warmup_now\": \"立即预热\",\n        \"warmup_batch_triggered\": \"已成功为 {{count}} 个账号触发预热任务\",\n        \"quota_protected\": \"受保护\",\n        \"details\": {\n            \"title\": \"配额详情\",\n            \"model_quota\": \"模型配额\",\n            \"protected_models\": \"受保护模型\"\n        },\n        \"toast\": {\n            \"proxy_enabled\": \"成功启用 {{count}} 个账号的反代功能\",\n            \"proxy_disabled\": \"成功禁用 {{count}} 个账号的反代功能\"\n        },\n        \"add\": {\n            \"title\": \"添加新账号\",\n            \"tabs\": {\n                \"oauth\": \"OAuth 授权\",\n                \"token\": \"Refresh Token\",\n                \"import\": \"从数据库导入\"\n            },\n            \"oauth\": {\n                \"recommend\": \"推荐方式\",\n                \"desc\": \"将打开默认浏览器进行 Google 登录授权，自动获取并保存 Token。\",\n                \"btn_start\": \"开始 OAuth 授权\",\n                \"btn_waiting\": \"正在等待授权...\",\n                \"btn_finish\": \"我已授权，继续\",\n                \"copy_link\": \"复制授权链接\",\n                \"copied\": \"已复制\",\n                \"link_label\": \"授权链接\",\n                \"link_click_to_copy\": \"点击复制\",\n                \"manual_hint\": \"浏览器没有自动跳转？请在此处粘贴回调链接或 Authorization Code：\",\n                \"manual_placeholder\": \"粘贴回调链接或 Code...\",\n                \"error_no_flow\": \"请先点击“开始 OAuth 授权”\",\n                \"web_hint\": \"将在新窗口中打开 Google 登录页\",\n                \"error_no_url\": \"无法获取 OAuth URL\",\n                \"popup_blocked\": \"弹窗被拦截，请允许弹出窗口\",\n                \"manual_submitting\": \"正在提交认证码...\",\n                \"manual_submitted\": \"认证码已提交，后台处理中...\"\n            },\n            \"token\": {\n                \"label\": \"Refresh Token\",\n                \"placeholder\": \"在此处粘贴您的 Refresh Token (支持批量)\\n\\n支持格式:\\n1. 单个 Token (1//...)\\n2. JSON 数组 (含 refresh_token 字段)\\n3. 任意包含 Token 的文本 (自动提取)\",\n                \"hint\": \"提示: 支持一次性粘贴多个 Token 或 JSON 数组，系统将自动识别并批量导入。\",\n                \"error_token\": \"请填写 Refresh Token\",\n                \"batch_progress\": \"正在导入第 {{current}}/{{total}} 个账户...\",\n                \"batch_success\": \"成功导入 {{count}} 个账户\",\n                \"batch_partial\": \"导入完成: {{success}} 个成功, {{fail}} 个失败\",\n                \"batch_fail\": \"导入失败\"\n            },\n            \"import\": {\n                \"scheme_a\": \"方案 A: 从当前 IDE 数据库\",\n                \"scheme_a_desc\": \"自动从本地 Antigravity 数据库读取当前登录的账号信息。\",\n                \"btn_db\": \"导入当前登录账号\",\n                \"or\": \"或者\",\n                \"scheme_b\": \"方案 B: 从 V1 版本备份\",\n                \"scheme_b_desc\": \"扫描 ~/.antigravity-agent 目录，批量导入旧版本的账号数据。\",\n                \"btn_v1\": \"从 V1 备份批量导入\",\n                \"btn_custom_db\": \"从自定义 DB 导入\"\n            },\n            \"btn_cancel\": \"取消\",\n            \"btn_confirm\": \"确认添加\",\n            \"oauth_error\": \"OAuth 授权失败: {{error}}\",\n            \"status\": {\n                \"error_token\": \"请填写 Refresh Token\"\n            }\n        },\n        \"table\": {\n            \"email\": \"邮箱\",\n            \"quota\": \"模型配额\",\n            \"last_used\": \"最后使用\",\n            \"actions\": \"操作\"\n        },\n        \"drag_to_reorder\": \"拖拽排序\",\n        \"empty\": {\n            \"title\": \"暂无账号\",\n            \"desc\": \"点击上方\\\"添加账号\\\"按钮添加第一个账号\"\n        },\n        \"views\": {\n            \"list\": \"列表视图\",\n            \"grid\": \"网格视图\"\n        },\n        \"dialog\": {\n            \"add_title\": \"添加新账号\",\n            \"batch_delete_title\": \"批量删除确认\",\n            \"delete_title\": \"删除确认\",\n            \"batch_delete_msg\": \"确定要删除选中的 {{count}} 个账号吗？此操作无法撤销。\",\n            \"delete_msg\": \"确定要删除这个账号吗？此操作无法撤销。\",\n            \"refresh_title\": \"刷新配额\",\n            \"batch_refresh_title\": \"批量刷新\",\n            \"refresh_msg\": \"确定要刷新当前账号的配额吗？\",\n            \"batch_refresh_msg\": \"确定要刷新选中的 {{count}} 个账号的配额吗？这可能需要一些时间。\",\n            \"disable_proxy_title\": \"禁用反代\",\n            \"disable_proxy_msg\": \"确定要禁用此账号的反代功能吗？账号仍可在应用中使用。\",\n            \"enable_proxy_title\": \"启用反代\",\n            \"enable_proxy_msg\": \"确定要重新启用此账号的反代功能吗？\",\n            \"warmup_all_title\": \"全量手动预热\",\n            \"warmup_all_msg\": \"确定要立即为所有符合条件的账号触发预热任务吗？这将向 Google 服务发送极小流量以重置配额配额周期。\",\n            \"batch_warmup_title\": \"批量手动预热\",\n            \"batch_warmup_msg\": \"确定要为选中的 {{count}} 个账号立即触发预热吗？\"\n        }\n    },\n    \"settings\": {\n        \"save\": \"保存设置\",\n        \"tabs\": {\n            \"general\": \"通用\",\n            \"account\": \"账号\",\n            \"proxy\": \"代理设置\",\n            \"advanced\": \"高级\",\n            \"debug\": \"调试\",\n            \"about\": \"关于\"\n        },\n        \"general\": {\n            \"title\": \"通用设置\",\n            \"language\": \"语言\",\n            \"theme\": \"主题\",\n            \"theme_light\": \"浅色\",\n            \"theme_dark\": \"深色\",\n            \"theme_system\": \"跟随系统\",\n            \"auto_launch\": \"开机自动启动\",\n            \"auto_launch_enabled\": \"启用\",\n            \"auto_launch_disabled\": \"禁用\",\n            \"auto_launch_desc\": \"系统启动时自动运行 Antigravity Tools\",\n            \"auto_check_update\": \"自动检查更新\",\n            \"auto_check_update_desc\": \"启动时自动检查新版本\",\n            \"auto_check_update_enabled\": \"已启用自动检查更新\",\n            \"auto_check_update_disabled\": \"已禁用自动检查更新\",\n            \"update_check_interval\": \"检查间隔(小时)\",\n            \"update_check_interval_desc\": \"设置自动检查更新的时间间隔(1-168 小时)\",\n            \"update_check_interval_saved\": \"已保存检查间隔设置\"\n        },\n        \"account\": {\n            \"title\": \"账号设置\",\n            \"auto_refresh\": \"后台自动刷新\",\n            \"auto_refresh_desc\": \"后台自动刷新所有账号的配额信息，这是额度保护和智能预热的基础。\",\n            \"always_on\": \"始终开启\",\n            \"refresh_interval\": \"刷新间隔（分钟）\",\n            \"auto_sync\": \"自动获取当前账号\",\n            \"auto_sync_desc\": \"定期自动刷新当前活动账号的信息\",\n            \"sync_interval\": \"同步间隔（分钟）\"\n        },\n        \"warmup\": {\n            \"title\": \"智能预热\",\n            \"desc\": \"自动监控所有模型，当额度恢复到 100% 时立即触发预热，保持模型热状态\"\n        },\n        \"quota_protection\": {\n            \"title\": \"配额保护\",\n            \"enable\": \"启用配额保护\",\n            \"enable_desc\": \"当账号剩余配额低于阈值时自动禁用反代功能，配额重置后将自动恢复账号\",\n            \"threshold_label\": \"保留配额百分比\",\n            \"monitored_models_label\": \"监控模型 (触发条件)\",\n            \"monitored_models_desc\": \"至少选择一个核心模型。任一勾选模型额度低于阈值将触发保护\",\n            \"range\": \"范围\",\n            \"example\": \"示例：设置 {{percentage}}% 时，{{total}} 次配额的账号剩余 ≤ {{threshold}} 次时将被自动禁用\",\n            \"auto_restore_info\": \"配额重置后将自动重新启用账号\"\n        },\n        \"pinned_quota_models\": {\n            \"title\": \"配额关注列表\",\n            \"desc\": \"选择要在账号列表外层显示的模型配额，未选中的模型只在详情弹窗中显示\"\n        },\n        \"proxy\": {\n            \"title\": \"代理设置\"\n        },\n        \"proxy_pool\": {\n            \"title\": \"代理池\",\n            \"strategy_priority\": \"按权重随机\",\n            \"strategy_round_robin\": \"顺序循环\",\n            \"strategy_random\": \"随机\",\n            \"strategy_least_connections\": \"最少连接\",\n            \"test_all\": \"全量检测\",\n            \"batch_import\": \"导入\",\n            \"binding_manager\": \"绑定\",\n            \"add_proxy\": \"添加代理\",\n            \"edit_proxy\": \"编辑代理\",\n            \"name\": \"名称\",\n            \"url\": \"代理地址\",\n            \"username\": \"用户名\",\n            \"password\": \"密码\",\n            \"max_accounts\": \"最大账号数\",\n            \"max_accounts_hint\": \"0 = 不限制\",\n            \"priority\": \"优先级\",\n            \"priority_hint\": \"越小越优先\",\n            \"health_check_url\": \"健康检查地址\",\n            \"tags\": \"标签\",\n            \"add_tag_placeholder\": \"添加标签...\",\n            \"seconds\": \"秒\",\n            \"interval_tooltip\": \"健康检查间隔 (秒)\",\n            \"test_completed\": \"健康检查已完成\",\n            \"test_failed\": \"健康检查失败\",\n            \"confirm_delete\": \"确定要删除此代理吗？\",\n            \"empty\": \"暂无代理\",\n            \"column_priority\": \"权重\",\n            \"column_status\": \"状态\",\n            \"column_details\": \"代理详情\",\n            \"column_bindings\": \"绑定\",\n            \"import_title\": \"批量导入代理\",\n            \"import_label\": \"粘贴代理列表 (每行一个)\",\n            \"import_hint\": \"支持格式: protocol://user:pass@host:port, host:port:user:pass\",\n            \"import_preview\": \"预览\",\n            \"import_confirm\": \"导入 {{count}} 个代理\",\n            \"no_valid_proxies\": \"未找到有效代理\",\n            \"binding\": {\n                \"title\": \"账号代理绑定\",\n                \"load_failed\": \"加载绑定失败\",\n                \"unbind_success\": \"解绑成功\",\n                \"bind_success\": \"绑定成功\",\n                \"update_failed\": \"更新绑定失败\",\n                \"assigned_proxy\": \"已分配代理\",\n                \"default_strategy\": \"默认 (跟随策略)\"\n            },\n            \"status\": {\n                \"inactive\": \"未启用\",\n                \"checking\": \"正在检测\",\n                \"healthy\": \"正常\",\n                \"timeout\": \"已超时\"\n            }\n        },\n        \"advanced\": {\n            \"title\": \"高级设置\",\n            \"export_path\": \"默认导出路径\",\n            \"export_path_placeholder\": \"未设置 (每次询问)\",\n            \"default_export_path_desc\": \"设置后，导出文件将直接保存到该目录，不再弹出选择框\",\n            \"select_btn\": \"选择\",\n            \"open_btn\": \"打开\",\n            \"data_dir\": \"数据目录\",\n            \"data_dir_desc\": \"账号数据和配置文件的存储位置\",\n            \"antigravity_path\": \"反重力程序路径\",\n            \"antigravity_path_placeholder\": \"未设置 (将使用自动探测)\",\n            \"antigravity_path_desc\": \"如果您将 Antigravity 应用安装在非标准位置，可在此手动指定可执行文件路径（MacOS 指向 .app 目录）。\",\n            \"antigravity_path_select\": \"选择反重力程序可执行文件\",\n            \"antigravity_path_detected\": \"已更新探测到的路径\",\n            \"detect_btn\": \"探测\",\n            \"antigravity_args\": \"反重力程序启动参数\",\n            \"antigravity_args_placeholder\": \"--user-data-dir=/path/to/data --some-other-flag\",\n            \"antigravity_args_desc\": \"为 Antigravity 程序指定启动参数，例如 --user-data-dir 用于指定用户数据目录\",\n            \"detect_args_btn\": \"检测\",\n            \"antigravity_args_detected\": \"启动参数已更新\",\n            \"antigravity_args_detect_error\": \"检测启动参数失败\",\n            \"accounts_page_size\": \"账号列表分页大小\",\n            \"page_size_auto\": \"自动计算 (推荐)\",\n            \"page_size_desc\": \"设置每页显示的账号数量。选择自动计算将根据窗口大小动态调整。\",\n            \"logs_title\": \"日志维护\",\n            \"logs_desc\": \"清理应用产生的日志缓存文件，不会影响账号数据。\",\n            \"clear_logs\": \"清理日志缓存\",\n            \"clear_logs_title\": \"清理日志确认\",\n            \"clear_logs_msg\": \"确定要清理所有日志缓存文件吗？\",\n            \"logs_cleared\": \"日志缓存已清理\",\n            \"antigravity_cache_title\": \"Antigravity 缓存清理\",\n            \"antigravity_cache_desc\": \"清理 Antigravity 应用的缓存可以解决登录失败、版本验证错误、OAuth 授权失败等问题。\",\n            \"antigravity_cache_warning\": \"请确保 Antigravity 应用已完全退出后再执行清理操作。\",\n            \"clear_antigravity_cache\": \"清理 Antigravity 缓存\",\n            \"clear_cache_confirm_title\": \"确认清理 Antigravity 缓存\",\n            \"clear_cache_confirm_msg\": \"将清理以下缓存目录：\",\n            \"cache_cleared_success\": \"缓存清理完成，释放 {{size}} MB 空间\",\n            \"cache_not_found\": \"未找到 Antigravity 缓存目录\",\n            \"debug_logs_title\": \"调试日志\",\n            \"debug_logs_enable_desc\": \"启用后会记录完整请求与响应链路，建议仅在排查问题时开启。\",\n            \"debug_logs_desc\": \"记录完整链路：原始输入、转换后的 v1internal 请求、以及上游响应。仅用于问题排查，可能包含敏感数据。\",\n            \"debug_log_dir\": \"调试日志输出目录\",\n            \"debug_log_dir_hint\": \"不填写则使用默认目录：{{path}}/debug_logs\",\n            \"debug_log_dir_select\": \"选择调试日志输出目录\",\n            \"http_api_title\": \"HTTP API 服务\",\n            \"http_api_desc\": \"为外部程序（如 VS Code 插件）提供本地 HTTP 接口。\",\n            \"http_api_enabled\": \"启用 HTTP API\",\n            \"http_api_enabled_desc\": \"启用后，外部程序可通过 HTTP 接口管理账号\",\n            \"http_api_port\": \"监听端口\",\n            \"http_api_port_desc\": \"修改端口后需要重启应用才能生效。如遇端口冲突，请更换为其他未被占用的端口。\",\n            \"http_api_port_placeholder\": \"默认端口 19527\",\n            \"http_api_port_invalid\": \"端口号无效（范围：1024-65535）\",\n            \"http_api_settings_saved\": \"HTTP API 设置已保存，重启应用后生效\",\n            \"http_api_restart_required\": \"⚠️ 需要重启应用后生效\"\n        },\n        \"debug\": {\n            \"title\": \"调试控制台\",\n            \"desc\": \"实时查看应用日志，用于调试和问题排查\",\n            \"enabled\": \"已启用\",\n            \"disabled\": \"已禁用\",\n            \"disabled_hint\": \"调试控制台已关闭\",\n            \"disabled_desc\": \"开启后将实时记录应用日志\",\n            \"console_title\": \"调试控制台\",\n            \"console_desc\": \"查看实时应用日志，用于排查问题。\",\n            \"enable_desc\": \"开启后将捕获并显示后端日志。\",\n            \"open_btn\": \"打开控制台\",\n            \"debug_logging\": \"调试日志\",\n            \"debug_logging_desc\": \"启用后会记录完整请求与响应链路，建议仅在排查问题时开启。\"\n        },\n        \"menu\": {\n            \"title\": \"菜单显示设置\",\n            \"desc\": \"选择要在菜单栏中显示的功能项。隐藏不常用的菜单可以节省空间。\",\n            \"selected_items_note\": \"被选中的项目将显示在顶部菜单栏中。\",\n            \"required\": \"必选\"\n        },\n        \"about\": {\n            \"title\": \"关于\",\n            \"version\": \"应用版本\",\n            \"tech_stack\": \"技术栈\",\n            \"author\": \"作者\",\n            \"wechat\": \"微信公众号\",\n            \"telegram\": \"Telegram 频道\",\n            \"github\": \"开源地址\",\n            \"view_code\": \"查看代码\",\n            \"copyright\": \"Copyright © 2025-2026 Antigravity. All rights reserved.\",\n            \"check_update\": \"检测更新\",\n            \"checking_update\": \"检测中...\",\n            \"latest_version\": \"已是最新版本\",\n            \"new_version_available\": \"发现新版本 {{version}}\",\n            \"download_update\": \"前往下载\",\n            \"brew_upgrade\": \"通过 Homebrew 更新\",\n            \"brew_upgrading\": \"更新中...\",\n            \"brew_confirm_title\": \"通过 Homebrew 更新\",\n            \"brew_confirm_desc\": \"即将执行以下命令更新应用，更新完成后需要重启应用。\",\n            \"brew_quarantine_hint\": \"如更新后遇到「应用已损坏」提示，请在终端运行：\",\n            \"brew_confirm_btn\": \"开始更新\",\n            \"brew_success_title\": \"升级完成\",\n            \"brew_upgrade_success\": \"Homebrew 升级成功，请重启应用以加载新版本。\",\n            \"brew_restart_btn\": \"立即重启\",\n            \"brew_restart_failed\": \"自动重启失败，请手动关闭并重新打开应用\",\n            \"brew_upgrade_failed\": \"Homebrew 升级失败，请尝试在终端手动执行：brew upgrade --cask antigravity-tools\",\n            \"brew_error_brew_not_found\": \"未找到 Homebrew，请确认已安装 brew\",\n            \"brew_error_brew_exec_failed\": \"执行 brew 命令失败，请尝试在终端手动执行\",\n            \"brew_error_brew_timeout\": \"Homebrew 升级超时（3分钟），请在终端手动执行：brew upgrade --cask antigravity-tools\",\n            \"brew_error_brew_already_latest\": \"已是最新版本，无需升级\",\n            \"brew_error_brew_not_supported\": \"当前系统不支持 Homebrew 更新\",\n            \"update_check_failed\": \"检测更新失败\",\n            \"support_btn\": \"支持作者\",\n            \"support_title\": \"赞助支持\",\n            \"support_desc\": \"如果您觉得本工具对您有帮助，欢迎扫码请作者喝杯咖啡！您的支持是我持续维护项目的最大动力。\",\n            \"support_alipay\": \"支付宝\",\n            \"support_wechat\": \"微信支付\",\n            \"support_buymeacoffee\": \"Buy Me a Coffee\"\n        },\n        \"advanced_thinking\": {\n            \"title\": \"高级思维与全局配置\",\n            \"description\": \"集中管理思考能力、图像模式及全局指令。\"\n        },\n        \"thinking_budget\": {\n            \"title\": \"思考预算 (Thinking Budget)\",\n            \"description\": \"控制 AI 深度思考时的 Token 预算。某些模型（如 Flash、带 -thinking 后缀的模型）受上游 24576 上限限制。\",\n            \"mode_label\": \"处理模式\",\n            \"mode\": {\n                \"auto\": \"自动限制\",\n                \"passthrough\": \"透传\",\n                \"custom\": \"自定义\",\n                \"adaptive\": \"自适应\"\n            },\n            \"effort_label\": \"思考强度\",\n            \"effort\": {\n                \"low\": \"低 (Low)\",\n                \"medium\": \"中 (Medium)\",\n                \"high\": \"高 (High)\"\n            },\n            \"auto_hint\": \"自动模式：对 Flash 模型、-thinking 后缀模型、以及启用 Web Search 的请求自动限制在 24576 以避免 API 错误。\",\n            \"passthrough_warning\": \"透传：直接使用调用方原始值，不支持高值可能导致失败。\",\n            \"custom_value_hint\": \"推荐：24576 (Flash) 或 51200 (扩展)\",\n            \"adaptive_hint\": \"自适应模式：由模型根据任务复杂度自动调整思考量。Claude 4.6+ 推荐使用此模式。\",\n            \"tokens\": \"tokens\"\n        },\n        \"image_thinking_mode\": {\n            \"title\": \"图像思维模式 (Image Thinking Mode)\",\n            \"hint\": \"影响画质与生成流程\",\n            \"options\": {\n                \"enabled\": \"开启\",\n                \"disabled\": \"关闭\",\n                \"enabled_desc\": \"开启：保留思维链，返回草图 + 成品双图。\",\n                \"disabled_desc\": \"关闭：禁用思维链，直接生成单张超清图片（画质优先）。\"\n            }\n        },\n        \"global_system_prompt\": {\n            \"title\": \"全局系统提示词 (Global System Prompt)\",\n            \"hint\": \"自动注入所有请求的 systemInstruction\",\n            \"placeholder\": \"输入全局系统提示词...\\n例如：你是一位资深的全栈开发工程师，擅长 React 和 Rust。请使用简体中文回复。\",\n            \"char_count\": \"{{count}} 字符\",\n            \"long_prompt_warning\": \"提示词较长（超过 2000 字符），可能会占用较多的上下文窗口空间。\"\n        }\n    },\n    \"tray\": {\n        \"current\": \"当前\",\n        \"quota\": \"额度\",\n        \"switch_next\": \"切换下一个账号\",\n        \"refresh_current\": \"刷新当前账号额度\",\n        \"show_window\": \"显示主窗口\",\n        \"quit\": \"退出应用 (Exit)\",\n        \"no_account\": \"无账号\",\n        \"unknown_quota\": \"未知 (点击刷新)\",\n        \"forbidden\": \"账号被封禁\"\n    },\n    \"proxy\": {\n        \"title\": \"API 反代服务\",\n        \"status\": {\n            \"running\": \"服务运行中\",\n            \"stopped\": \"服务已停止\",\n            \"accounts_available\": \"{{count}} 个账号可用\",\n            \"processing\": \"处理中...\"\n        },\n        \"action\": {\n            \"start\": \"启动服务\",\n            \"stop\": \"停止服务\"\n        },\n        \"config\": {\n            \"title\": \"服务配置\",\n            \"request\": {\n                \"user_agent\": \"User-Agent 覆盖\",\n                \"user_agent_tooltip\": \"自定义发送给上游 API 的 User-Agent 请求头。留空则使用默认值。\",\n                \"user_agent_hint\": \"当前默认值: antigravity/<version> <os>/<arch>\",\n                \"user_agent_placeholder\": \"输入自定义 User-Agent 字符串...\"\n            },\n            \"port\": \"监听端口\",\n            \"port_tooltip\": \"本地 API 代理监听的端口。需要先停止服务再修改，修改后需重启生效。\",\n            \"port_hint\": \"默认 8045，修改端口需重启服务\",\n            \"auto_start\": \"跟随应用自动启动\",\n            \"auto_start_tooltip\": \"应用启动时自动启动本地 API 代理服务。\",\n            \"allow_lan_access\": \"允许局域网访问\",\n            \"allow_lan_access_tooltip\": \"开启后绑定到 0.0.0.0，局域网其他设备也能访问。建议同时开启鉴权并妥善保管 API 密钥；修改后需重启生效。\",\n            \"allow_lan_access_hint_enabled\": \"🌐 监听 0.0.0.0，局域网设备可访问\",\n            \"allow_lan_access_hint_disabled\": \"🔒 仅监听 127.0.0.1，仅本机可访问（隐私优先）\",\n            \"allow_lan_access_warning\": \"⚠️ 开启后局域网内其他设备可访问，请确保 API 密钥安全\",\n            \"allow_lan_access_restart_hint\": \"ℹ️ 需要重启服务后生效\",\n            \"api_key\": \"API 密钥\",\n            \"api_key_tooltip\": \"启用鉴权后，客户端访问代理所需的共享密钥。重新生成会立即使旧密钥失效。\",\n            \"btn_regenerate\": \"重新生成密钥\",\n            \"btn_edit\": \"编辑\",\n            \"btn_save\": \"保存\",\n            \"btn_copy\": \"复制\",\n            \"btn_copied\": \"已复制\",\n            \"warning_key\": \"注意：请妥善保管您的 API 密钥，不要泄露给他人。\",\n            \"api_key_invalid\": \"API 密钥格式无效,必须以 sk- 开头且长度至少 10 个字符\",\n            \"api_key_updated\": \"API 密钥已更新\",\n            \"admin_password\": \"Web UI 管理后台密码\",\n            \"admin_password_tooltip\": \"用于登录 Web 管理后台的密码。如果为空，则默认使用 API 密钥（API Key）。\",\n            \"admin_password_default\": \"（同 API 密钥）\",\n            \"admin_password_placeholder\": \"输入新密码，留空则使用 API 密钥\",\n            \"admin_password_hint\": \"提示：在 Docker/Web 部署场景中，您可以设置一个独立的登录密码，提高 API 密钥的安全性。\",\n            \"admin_password_short\": \"密码太短（最少 4 个字符）\",\n            \"admin_password_updated\": \"Web UI 登录密码已更新\",\n            \"auth\": {\n                \"title\": \"访问授权\",\n                \"title_tooltip\": \"控制代理是否需要鉴权，以及哪些路由需要提供 API 密钥。\",\n                \"enabled\": \"已启用\",\n                \"enabled_tooltip\": \"快速开关鉴权（通过切换鉴权模式实现）。开启后客户端需在请求头提供 Authorization: Bearer <API_KEY> 或 x-api-key。\",\n                \"mode\": \"模式\",\n                \"mode_tooltip\": \"选择鉴权覆盖范围：关闭=不鉴权；全局=所有接口都需密钥；除健康检查外=/healthz 不鉴权；自动=本机模式默认关闭，局域网模式默认“除健康检查外”。\",\n                \"hint\": \"开启后客户端需通过 Authorization: Bearer ... 传入 API 密钥（如选择“除健康检查外”则 /healthz 免鉴权）。\",\n                \"modes\": {\n                    \"off\": \"关闭（开放）\",\n                    \"strict\": \"全局（严格）\",\n                    \"all_except_health\": \"全局（除健康检查外）\",\n                    \"auto\": \"自动（推荐）\"\n                }\n            },\n            \"zai\": {\n                \"title\": \"z.ai（GLM）提供商\",\n                \"title_tooltip\": \"为 Claude 协议提供可选的 Anthropic 兼容上游（z.ai）。只影响 Claude 协议请求，Google 账号池仍按原逻辑工作。\",\n                \"subtitle\": \"可选的 Anthropic 兼容上游，仅用于 Claude 协议请求。\",\n                \"enabled\": \"已启用\",\n                \"enabled_tooltip\": \"启用后，Claude 协议请求将按“分发模式”路由到 z.ai。\",\n                \"base_url\": \"Base URL\",\n                \"base_url_tooltip\": \"z.ai Anthropic 兼容接口的基础地址。默认 https://api.z.ai/api/anthropic，代理会在其后拼接 /v1/messages 等路径。\",\n                \"dispatch_mode\": \"分发模式\",\n                \"dispatch_mode_tooltip\": \"控制何时使用 z.ai：关闭=不使用；全部 Claude 请求=所有 /v1/messages 等都转发到 z.ai；加入队列=把 z.ai 当作队列中的 1 个槽位按轮询分配；仅兜底=仅当没有可用 Google 账号时才使用。\",\n                \"api_key\": \"API Key\",\n                \"api_key_tooltip\": \"用于调用 z.ai 上游的 API Key（本地存储）。启用 z.ai 或 MCP 功能前必须配置。\",\n                \"api_key_placeholder\": \"在此粘贴 z.ai API Key\",\n                \"warning\": \"提示：该 Key 将保存在本机应用数据目录中。\",\n                \"models\": {\n                    \"title\": \"模型映射\",\n                    \"title_tooltip\": \"从 z.ai 拉取可用模型 ID，并配置如何把 Claude/Anthropic 的 model 名称转换为 z.ai 的模型 ID。\",\n                    \"refresh\": \"拉取模型\",\n                    \"refreshing\": \"拉取中...\",\n                    \"hint\": \"可用模型：{{count}}。可从建议中选择，或手动输入自定义模型 ID。\",\n                    \"error\": \"拉取模型失败：{{error}}\",\n                    \"select_placeholder\": \"选择模型...\",\n                    \"opus\": \"Opus 家族 → z.ai 模型\",\n                    \"opus_tooltip\": \"当请求的 model 包含“opus”（如 claude-opus-*）时默认使用的 z.ai 模型 ID。\",\n                    \"sonnet\": \"Sonnet 家族 → z.ai 模型\",\n                    \"sonnet_tooltip\": \"其他 Claude 模型（如 claude-sonnet-* 以及大多数 claude-*）默认使用的 z.ai 模型 ID。\",\n                    \"haiku\": \"Haiku 家族 → z.ai 模型\",\n                    \"haiku_tooltip\": \"当请求的 model 包含“haiku”（如 claude-haiku-*）时默认使用的 z.ai 模型 ID。\",\n                    \"advanced_title\": \"高级覆盖规则\",\n                    \"advanced_tooltip\": \"可选的精确匹配覆盖规则：如果 incoming model 字符串与规则键完全一致，则替换为对应的 z.ai 模型 ID。\",\n                    \"from_label\": \"incoming model\",\n                    \"to_label\": \"z.ai 模型\",\n                    \"add_rule\": \"添加\",\n                    \"empty\": \"尚未配置自定义替换规则。\",\n                    \"from_placeholder\": \"源模型 (如 claude-3-opus)\",\n                    \"to_placeholder\": \"映射至 (如 glm-4)\"\n                },\n                \"modes\": {\n                    \"off\": \"关闭\",\n                    \"exclusive\": \"全部 Claude 请求走 z.ai\",\n                    \"pooled\": \"加入队列（占 1 个槽位）\",\n                    \"fallback\": \"仅兜底\"\n                },\n                \"mcp\": {\n                    \"title\": \"MCP 服务（通过本地代理）\",\n                    \"title_tooltip\": \"在本地代理上暴露可选的 /mcp/* 端点，供 MCP 客户端直连。仅在服务运行、z.ai 配置完成且对应开关开启时可用。\",\n                    \"enabled\": \"启用 MCP 反代\",\n                    \"enabled_tooltip\": \"MCP 端点总开关；关闭时所有 /mcp/* 路由将返回 404。\",\n                    \"web_search\": \"网页搜索\",\n                    \"web_search_tooltip\": \"启用后开放 /mcp/web_search_prime/mcp，并转发到 z.ai 的 Web Search MCP 上游。\",\n                    \"web_reader\": \"网页阅读\",\n                    \"web_reader_tooltip\": \"启用后开放 /mcp/web_reader/mcp，并转发到 z.ai 的 Web Reader MCP 上游。\",\n                    \"vision\": \"视觉\",\n                    \"vision_tooltip\": \"启用后开放 /mcp/zai-mcp-server/mcp（本地 MCP 服务），提供视觉相关工具能力，并通过 z.ai 执行。\",\n                    \"local_endpoints\": \"本地地址（在 MCP 客户端中配置以下 URL）：\",\n                    \"local_endpoints_tooltip\": \"把这些 URL 填到你的 MCP 客户端里即可。它们使用同一个代理端口，并遵循当前的鉴权策略。\"\n                }\n            },\n            \"request_timeout\": \"请求超时\",\n            \"request_timeout_tooltip\": \"代理等待上游响应的最大时间（秒），包含流式输出。长文本/长推理可适当调大；修改后需重启生效。\",\n            \"request_timeout_hint\": \"默认 120 秒，范围 30-7200 秒。修改后需重启服务生效。\",\n            \"enable_logging\": \"启用请求日志\",\n            \"enable_logging_hint\": \"记录历史记录以便调试 (微小性能损耗)\",\n            \"upstream_proxy\": {\n                \"title\": \"全局上游代理 (Global Proxy)\",\n                \"desc\": \"开启后，应用内所有外部请求（API 反代、Token 刷新、配额查询、更新检测）都将通过此代理。\",\n                \"desc_short\": \"用于无法匹配代理池账号时的通用出口或降级方案。\",\n                \"enable\": \"启用上游代理\",\n                \"url\": \"代理地址\",\n                \"url_placeholder\": \"例如: http://127.0.0.1:7890 或 socks5://127.0.0.1:7890\",\n                \"tip\": \"支持 HTTP、HTTPS 和 SOCKS5 协议。\",\n                \"socks5h_hint\": \"若需避开上游风控并保留原始域名解析 (Remote DNS)，请手动将协议改为 socks5h://\",\n                \"validation_error\": \"启用上游代理时必须填写代理地址\",\n                \"restart_hint\": \"代理配置已保存，重启应用后生效\"\n            },\n            \"scheduling\": {\n                \"title\": \"账号轮换与会话调度\",\n                \"title_tooltip\": \"控制会话如何绑定到账号，以及触发限流时的行为。\",\n                \"subtitle\": \"针对全协议客户端优化 Prompt Caching 和限流处理 (OpenAI/Gemini/Claude)。\",\n                \"mode\": \"调度模式\",\n                \"mode_tooltip\": \"缓存优先：绑定账号，限流时强制等待（最大化缓存）；平衡：绑定账号，限流时自动热切；性能优先：不绑定，全自动轮换。\",\n                \"modes\": {\n                    \"CacheFirst\": \"缓存优先 (Cache First)\",\n                    \"Balance\": \"平衡轮换 (Balance)\",\n                    \"PerformanceFirst\": \"性能优先 (Performance)\"\n                },\n                \"modes_desc\": {\n                    \"CacheFirst\": \"绑定会话与账号，限流时精准等待（最大化 Prompt Cache 命中率）。\",\n                    \"Balance\": \"绑定会话，限流时自动热切换至可用账号（兼顾缓存与可用性）。\",\n                    \"PerformanceFirst\": \"无会话绑定，纯随机轮换（适合高并发，不考虑缓存）。\"\n                },\n                \"max_wait\": \"最大等待时长 (秒)\",\n                \"max_wait_tooltip\": \"仅在“缓存优先”模式下生效：如果账号限流重置时间小于此值，则原地等待而非切换账号。\",\n                \"clear_bindings\": \"清除会话绑定\",\n                \"clear_bindings_tooltip\": \"立即断开所有会话与账号的绑定关系，强制下一次请求重新分配账号。\",\n                \"clear_rate_limits\": \"清除限流记录\",\n                \"clear_rate_limits_tooltip\": \"立即清除所有账号的本地限流记录，强制下一次请求尝试直接调用上游。\"\n            },\n            \"circuit_breaker\": {\n                \"title\": \"自适应熔断器\",\n                \"tooltip\": \"当账号因配额耗尽反复失败时，自动增加锁定时间。这可以防止在死账号上浪费 API 调用，同时允许瞬态错误快速恢复。\",\n                \"backoff_levels\": \"退避等级 (秒)\",\n                \"input_placeholder\": \"输入退避时长 (秒)，以逗号分隔\",\n                \"level\": \"等级 {{level}}\",\n                \"invalid_format\": \"格式无效。请使用逗号分隔的数字 (例如 60, 300)\",\n                \"clear_records\": \"清除所有限流记录\"\n            },\n            \"experimental\": {\n                \"title\": \"实验性设置 (Experimental)\",\n                \"title_tooltip\": \"探索性功能，可能在未来版本中调整或移除。\",\n                \"enable_usage_scaling\": \"启用用量缩放\",\n                \"enable_usage_scaling_tooltip\": \"针对 Claude 兼容协议。当总输入超过 30k Token 时开启激进缩放，防止在大上下文下频繁触发客户端压缩。注意：开启后客户端显示的用量不再代表实际计费点数。\",\n                \"context_compression_threshold_l1\": \"L1 压缩阈值 (工具记录清理)\",\n                \"context_compression_threshold_l1_tooltip\": \"清理旧的工具调用记录以节省空间。建议值: 0.4 (40%)\",\n                \"context_compression_threshold_l2\": \"L2 压缩阈值 (思维链压缩)\",\n                \"context_compression_threshold_l2_tooltip\": \"压缩早期的思维链内容，保留签名。建议值: 0.55 (55%)\",\n                \"context_compression_threshold_l3\": \"L3 压缩阈值 (摘要重置)\",\n                \"context_compression_threshold_l3_tooltip\": \"强制生成 XML 状态摘要并重置会话。这是最省 token 的手段。建议值: 0.7 (70%)\"\n            },\n            \"opencode_sync\": {\n                \"card_title\": \"OpenCode\",\n                \"status\": {\n                    \"detecting\": \"检测中...\",\n                    \"installed\": \"已安装 ({{version}})\",\n                    \"not_installed\": \"未安装\",\n                    \"synced\": \"已同步\",\n                    \"not_synced\": \"未同步\",\n                    \"current_base_url\": \"当前接口地址 (BASE URL)\"\n                },\n                \"sync_accounts\": \"同步账号到 antigravity-accounts.json\",\n                \"btn_sync\": \"立即同步配置\",\n                \"btn_view\": \"查看配置\",\n                \"btn_restore\": \"还原配置\",\n                \"btn_restore_backup\": \"从备份还原\",\n                \"btn_clear\": \"清除配置\",\n                \"clear_confirm_title\": \"确认清除配置\",\n                \"clear_confirm_message\": \"确定要清除 OpenCode 配置吗？这将删除配置文件。\",\n                \"toast\": {\n                    \"config_missing\": \"请先生成 API Key 并启动服务\",\n                    \"sync_success\": \"OpenCode 配置同步成功\",\n                    \"sync_error\": \"OpenCode 同步失败: {{error}}\",\n                    \"clear_success\": \"OpenCode 配置已清除\",\n                    \"clear_error\": \"清除 OpenCode 配置失败: {{error}}\"\n                },\n                \"modal\": {\n                    \"view_title\": \"OpenCode 配置文件预览\",\n                    \"copy_success\": \"配置已复制\"\n                },\n                \"sync_confirm_title\": \"确认同步\",\n                \"sync_confirm_message\": \"将根据当前反代设置覆盖 OpenCode 配置。确定继续？\",\n                \"restore_confirm\": \"确定要将 OpenCode 还原为默认配置吗？\",\n                \"restore_backup_confirm\": \"确定要从备份还原 OpenCode 配置吗？\",\n                \"modal_title\": \"选择 OpenCode 模型\",\n                \"select_models\": \"选择要同步的模型\",\n                \"auth_plugin_warning\": \"检测到 opencode-antigravity-auth 插件。同步仅会创建 antigravity-manager provider，不会覆盖 google provider/plugin。\",\n                \"btn_confirm_sync\": \"确认同步\",\n                \"custom_base_url_label\": \"自定义 Manager BaseURL\",\n                \"custom_base_url_desc\": \"适用于 Docker Compose 网络环境\",\n                \"custom_base_url_reset\": \"重置\"\n            },\n            \"droid_sync\": {\n                \"modal_title\": \"添加模型到 Droid\",\n                \"modal_desc\": \"选中的模型将作为 customModels 写入 settings.json\",\n                \"selected\": \"已选\",\n                \"btn_confirm_sync\": \"添加所选模型\",\n                \"toast\": {\n                    \"no_models_selected\": \"请至少选择一个模型\",\n                    \"sync_success_count\": \"已添加 {{count}} 个模型到 Droid\",\n                    \"sync_error\": \"同步失败: {{error}}\"\n                }\n            }\n        },\n        \"cloudflared\": {\n            \"title\": \"公网访问 (Cloudflared)\",\n            \"subtitle\": \"通过 Cloudflare 隧道将本地服务暴露到公网\",\n            \"not_installed\": \"Cloudflared 未安装\",\n            \"install_hint\": \"Cloudflared 是 Cloudflare 提供的免费隧道工具,可将本地反代服务暴露到公网,无需公网IP或端口映射。点击下方按钮一键下载安装。\",\n            \"install\": \"一键安装\",\n            \"installing\": \"安装中...\",\n            \"install_success\": \"Cloudflared 安装成功\",\n            \"install_failed\": \"安装失败: {{error}}\",\n            \"installed\": \"已安装\",\n            \"version\": \"版本\",\n            \"mode_label\": \"隧道模式\",\n            \"mode_quick\": \"快速隧道\",\n            \"mode_quick_desc\": \"自动生成临时 URL (*.trycloudflare.com),无需账号,重启后URL会变化\",\n            \"mode_auth\": \"命名隧道\",\n            \"mode_auth_desc\": \"使用 Cloudflare 账号 Token,支持自定义域名,URL固定不变\",\n            \"token\": \"隧道 Token\",\n            \"token_placeholder\": \"粘贴您的 Cloudflare Tunnel Token\",\n            \"token_hint\": \"从 Cloudflare Zero Trust 控制台获取\",\n            \"token_required\": \"命名隧道模式需要提供 Token\",\n            \"use_http2\": \"使用 HTTP/2\",\n            \"use_http2_desc\": \"兼容性更好,推荐中国大陆用户开启\",\n            \"status_label\": \"隧道状态\",\n            \"status_stopped\": \"未运行\",\n            \"status_starting\": \"启动中...\",\n            \"status_running\": \"运行中\",\n            \"status_stopping\": \"停止中...\",\n            \"status_error\": \"错误\",\n            \"public_url\": \"公网地址\",\n            \"public_url_placeholder\": \"隧道启动后将显示公网访问地址\",\n            \"copy_url\": \"复制地址\",\n            \"url_copied\": \"地址已复制\",\n            \"start_tunnel\": \"启动隧道\",\n            \"stop_tunnel\": \"停止隧道\",\n            \"running\": \"隧道运行中\",\n            \"started\": \"隧道已启动\",\n            \"stopped\": \"隧道已停止\",\n            \"start_failed\": \"启动失败: {{error}}\",\n            \"stop_failed\": \"停止失败: {{error}}\",\n            \"require_proxy_running\": \"请先启动本地反代服务,再开启隧道\",\n            \"connection_info\": \"连接信息\",\n            \"local_port\": \"本地端口\",\n            \"tunnel_protocol\": \"隧道协议\"\n        },\n        \"example\": {\n            \"title\": \"使用示例\",\n            \"curl\": \"cURL\",\n            \"python\": \"Python\",\n            \"python_anthropic\": \"from anthropic import Anthropic\\n\\nclient = Anthropic(\\n    # 推荐使用 127.0.0.1\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\n# 注意: Antigravity 支持使用 Anthropic SDK 调用任意模型\\nresponse = client.messages.create(\\n    model=\\\"{{modelId}}\\\",\\n    max_tokens=1024,\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hello\\\"}]\\n)\\n\\nprint(response.content[0].text)\",\n            \"python_gemini\": \"# 需要安装: pip install google-generativeai\\nimport google.generativeai as genai\\n\\n# 使用 Antigravity 代理地址 (推荐 127.0.0.1)\\ngenai.configure(\\n    api_key=\\\"{{apiKey}}\\\",\\n    transport='rest',\\n    client_options={'api_endpoint': '{{rawBaseUrl}}'}\\n)\\n\\nmodel = genai.GenerativeModel('{{modelId}}')\\nresponse = model.generate_content(\\\"Hello\\\")\\nprint(response.text)\",\n            \"python_openai_image\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    # 方式 1: 使用 size 参数 (推荐)\\n    # 支持: \\\"1024x1024\\\" (1:1), \\\"1280x720\\\" (16:9), \\\"720x1280\\\" (9:16), \\\"1216x896\\\" (4:3)\\n    extra_body={ \\\"size\\\": \\\"1024x1024\\\" },\\n\\n    # 方式 2: 使用模型后缀\\n    # 例如: gemini-3-pro-image-16-9, gemini-3-pro-image-4-3\\n    # model=\\\"gemini-3-pro-image-16-9\\\",\\n    messages=[{\\n        \\\"role\\\": \\\"user\\\",\\n        \\\"content\\\": \\\"画一个未来城市\\\"\\n    }]\\n)\\n\\nprint(response.choices[0].message.content)\",\n            \"python_openai\": \"from openai import OpenAI\\n\\nclient = OpenAI(\\n    base_url=\\\"{{baseUrl}}\\\",\\n    api_key=\\\"{{apiKey}}\\\"\\n)\\n\\nresponse = client.chat.completions.create(\\n    model=\\\"{{modelId}}\\\",\\n    messages=[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hello\\\"}]\\n)\\n\\nprint(response.choices[0].message.content)\"\n        },\n        \"dialog\": {\n            \"confirm_regenerate\": \"确定要生成新的 API Key 吗？旧的 Key 将立即失效。\",\n            \"operate_failed\": \"操作失败: {{error}}\",\n            \"reset_mapping_title\": \"重置模型映射\",\n            \"reset_mapping_msg\": \"确定要重置所有模型映射为系统默认吗？此操作无法撤销。\",\n            \"regenerate_key_title\": \"重新生成 API Key\",\n            \"regenerate_key_msg\": \"确定要重新生成 API Key 吗？旧的 Key 将立即失效。\",\n            \"clear_bindings_title\": \"清除会话绑定\",\n            \"clear_bindings_msg\": \"确定要清除所有会话与账号的绑定映射吗？\",\n            \"clear_rate_limits_title\": \"清除限流记录\",\n            \"clear_rate_limits_confirm\": \"确定要清除所有本地限流记录吗？\"\n        },\n        \"model\": {\n            \"flash\": \"极速响应\",\n            \"flash_preview\": \"极速预览 (Flash 3.1)\",\n            \"flash_lite\": \"轻量极速 (Lite)\",\n            \"flash_thinking\": \"思考能力 (Thinking)\",\n            \"pro_legacy\": \"经典版 Pro\",\n            \"pro_low\": \"3.1 Pro Low\",\n            \"pro_high\": \"3.1 Pro High\",\n            \"pro_image\": \"图片生成 (1:1)\",\n            \"pro_image_16_9\": \"图片生成 (16:9)\",\n            \"pro_image_9_16\": \"图片生成 (9:16)\",\n            \"pro_image_4_3\": \"图片生成 (4:3)\",\n            \"pro_image_3_4\": \"图片生成 (3:4)\",\n            \"pro_image_1_1\": \"图片生成 (1:1)\",\n            \"claude_sonnet\": \"代码推理 (Claude 4.6)\",\n            \"claude_sonnet_thinking\": \"思维链 (4.6 Think)\",\n            \"claude_opus_thinking\": \"最强思维 (Opus Think)\",\n            \"gemini_2_5_flash\": \"极速模型 (2.5 Flash)\",\n            \"gemini_2_5_pro\": \"高性能推理 (2.5 Pro)\",\n            \"claude_4_6\": \"最新代理版 (4.6)\"\n        },\n        \"mapping\": {\n            \"title\": \"Claude Code 模型映射\",\n            \"description\": \"将 Claude Code 模型映射到 Antigravity 模型,智能路由请求以优化成本和速度。\",\n            \"default\": \"默认\",\n            \"sonnet_desc\": \"最强复杂任务\",\n            \"opus_desc\": \"高级层级\",\n            \"haiku_desc\": \"极速响应\",\n            \"maps_to\": \"映射到 Antigravity\",\n            \"apply_recommended\": \"应用推荐配置\",\n            \"restore_defaults\": \"恢复默认配置\",\n            \"reset_all\": \"重置全部\"\n        },\n        \"models\": {\n            \"flash\": \"极速响应\",\n            \"flash_thinking\": \"思考能力\",\n            \"pro_high\": \"最强推理\",\n            \"pro_low\": \"低配额\",\n            \"sonnet\": \"代码推理\",\n            \"sonnet_thinking\": \"思维链\",\n            \"opus_thinking\": \"最强思维\"\n        },\n        \"router\": {\n            \"title\": \"模型路由中心 (Model Router)\",\n            \"subtitle\": \"按“规格家族”统一路由 OpenAI/Claude 模型，或添加最高优先级的“精确映射”。\\n注意：Claude 原生直通模型（claude-sonnet-4-6-thinking, claude-opus-4-6-thinking 等）默认透传，需在“专家精确映射”中添加规则才能修改。\",\n            \"subtitle_simple\": \"通过通配符或精确映射自定义模型路由规则\",\n            \"background_task_title\": \"后台任务模型\",\n            \"background_task_desc\": \"用于 Claude CLI 的标题生成、摘要提取等后台自动化任务 (默认: gemini-2.5-flash)\",\n            \"use_default\": \"使用系统默认\",\n            \"current_model\": \"当前模型\",\n            \"apply_presets\": \"应用预设映射\",\n            \"presets_applied\": \"预设映射已应用\",\n            \"preset_default\": \"默认预设\",\n            \"preset_default_desc\": \"GPT-4 → Gemini Pro, Claude → Opus\",\n            \"preset_performance\": \"性能优先\",\n            \"preset_performance_desc\": \"全部使用高性能模型\",\n            \"preset_cost\": \"成本优化\",\n            \"preset_cost_desc\": \"优先使用经济型模型\",\n            \"preset_balanced\": \"均衡模式\",\n            \"preset_balanced_desc\": \"平衡性能和成本\",\n            \"built_in_presets\": \"内置预设\",\n            \"custom_presets\": \"自定义预设\",\n            \"apply_selected\": \"应用所选\",\n            \"add_preset\": \"保存当前映射\",\n            \"delete_preset\": \"删除当前预设\",\n            \"cannot_delete_builtin\": \"无法删除内置预设\",\n            \"no_mapping_to_save\": \"没有可保存的映射配置\",\n            \"preset_name_required\": \"请先输入预设名称\",\n            \"preset_saved\": \"预设保存成功\",\n            \"manage_presets_title\": \"管理自定义预设\",\n            \"save_current_as_preset\": \"保存当前配置\",\n            \"preset_name_placeholder\": \"输入预设名称...\",\n            \"save_hint\": \"将当前激活的模型映射保存为可重复使用的预设。\",\n            \"your_presets\": \"您的预设\",\n            \"no_custom_presets\": \"暂无自定义预设\",\n            \"mappings_count\": \"个映射\",\n            \"custom_preset_desc\": \"用户自定义预设\",\n            \"custom_mappings\": \"自定义映射 (Custom Mappings)\",\n            \"original_id\": \"原始模型 ID\",\n            \"route_to\": \"路由目标\",\n            \"group_title\": \"模型家族分组 (Series Groups)\",\n            \"gemini3_group_label\": \"Gemini 3 (推荐)\",\n            \"gemini3_option_high\": \"gemini-3.1-pro-high (高质量)\",\n            \"gemini3_option_low\": \"gemini-3.1-pro-low (均衡)\",\n            \"gemini3_option_flash\": \"gemini-3-flash (快速)\",\n            \"groups\": {\n                \"claude_45\": {\n                    \"name\": \"Opus 4.6 TK 系列\",\n                    \"desc\": \"Opus 4.5 TK (推理)\"\n                },\n                \"claude_35\": {\n                    \"name\": \"Claude 3.5 系列\",\n                    \"desc\": \"Sonnet 3.5, Haiku 3.5\"\n                },\n                \"gpt_4\": {\n                    \"name\": \"GPT-4 / o1 系列\",\n                    \"desc\": \"GPT-4, Turbo, o1-preview\"\n                },\n                \"gpt_4o\": {\n                    \"name\": \"GPT-4o / 3.5 系列\",\n                    \"desc\": \"GPT-4o, Mini, 3.5 Turbo\"\n                },\n                \"gpt_5\": {\n                    \"name\": \"GPT-5 系列\",\n                    \"desc\": \"GPT-5.1, GPT-5.2 xhigh\"\n                }\n            },\n            \"expert_title\": \"专家精确映射\",\n            \"expert_subtitle\": \"精确匹配支持从任何协议传来的原始模型 ID。\",\n            \"custom_mapping_tip\": \"💡 支持手动输入任意模型 ID,可体验未发布模型(如 claude-opus-4-6)。\",\n            \"custom_mapping_warning\": \"注意:并非所有账号都支持未发布模型。\",\n            \"money_saving_tip\": \"💰 省钱提示:\",\n            \"haiku_optimization_tip\": \"Claude CLI 默认使用 {{model}} 处理后台任务,建议映射到廉价 Flash 模型可节省约 95% 成本\",\n            \"haiku_optimization_btn\": \"一键优化\",\n            \"haiku_tip_title\": \"💰 省钱提示:\",\n            \"haiku_tip_body_before\": \"Claude CLI 默认使用\",\n            \"haiku_tip_body_after\": \"处理后台任务,建议映射到廉价 Flash 模型可节省约 95% 成本\",\n            \"haiku_tip_action\": \"一键优化\",\n            \"reset_confirm\": \"确定要重置所有模型映射为系统默认吗？\",\n            \"reset_mapping\": \"重置映射\",\n            \"add_mapping\": \"添加映射 (Add Mapping)\",\n            \"current_list\": \"当前映射列表 (Custom List)\",\n            \"no_custom_mapping\": \"暂无自定义精确映射\",\n            \"gemini3_only_warning\": \"⚠️ 仅支持 Gemini 3 系列\",\n            \"default_suffix\": \"（默认）\",\n            \"select_target_model\": \"选择目标模型\",\n            \"original_placeholder\": \"原始名 (如 gpt-4 或 gpt-4*)\"\n        },\n        \"examples\": {\n            \"title\": \"使用示例\"\n        },\n        \"multi_protocol\": {\n            \"title\": \"多协议支持 (Multi-Protocol Support)\",\n            \"subtitle\": \"快速同步 API 地址与密钥到本地 AI 工具\",\n            \"description\": \"反代服务支持 OpenAI、Anthropic 和 Gemini 协议，满足不同工具的集成需求\",\n            \"openai_label\": \"OpenAI 协议\",\n            \"anthropic_label\": \"Anthropic 协议\",\n            \"openai_tools\": \"Cherry Studio, NextChat\",\n            \"anthropic_tools\": \"Claude Code CLI\",\n            \"gemini_label\": \"Gemini 协议\",\n            \"gemini_tools\": \"Google AI SDK, LangChain\",\n            \"quick_integration\": \"快速集成 (Quick Integration)\",\n            \"click_tip\": \"👆 点击模型以更新代码示例\",\n            \"copy_base\": \"复制 Base\"\n        },\n        \"supported_models\": {\n            \"title\": \"支持模型与集成 (Supported Models & Integration)\",\n            \"model_name\": \"模型名称\",\n            \"model_id\": \"模型 ID\",\n            \"description\": \"描述\",\n            \"action\": \"操作\"\n        },\n        \"cli_sync\": {\n            \"title\": \"CLI 配置一键同步\",\n            \"subtitle\": \"快速将当前 API 服务地址与 API 密钥同步到本地 AI CLI 工具中\",\n            \"card_title\": \"{{name}} 配置\",\n            \"status\": {\n                \"not_installed\": \"未检测到安装\",\n                \"installed\": \"v{{version}}\",\n                \"synced\": \"已指向本项目\",\n                \"not_synced\": \"未同步\",\n                \"detecting\": \"检测中...\",\n                \"current_base_url\": \"当前接口地址 (Base URL)\"\n            },\n            \"model_select\": \"选择同步模型\",\n            \"btn_sync\": \"立即同步配置\",\n            \"btn_view\": \"查看当前配置\",\n            \"btn_restore\": \"恢复默认配置\",\n            \"btn_restore_backup\": \"恢复原有配置\",\n            \"restore_confirm\": \"确定要将 {{name}} 的配置恢复为官方默认地址吗？\",\n            \"restore_backup_confirm\": \"检测到旧的配置文件备份，确定要还原吗？\",\n            \"modal\": {\n                \"view_title\": \"{{name}} 配置文件内容\",\n                \"copy_success\": \"配置内容已复制\"\n            },\n            \"toast\": {\n                \"sync_success\": \"同步成功！{{name}} 已准备就绪。\",\n                \"sync_error\": \"同步失败：{{error}}\"\n            },\n            \"sync_confirm_title\": \"同步确认\",\n            \"sync_confirm_message\": \"即将为您同步 {{name}} 的配置。⚠️ 注意：此操作将覆盖您本地已有的配置文件（如登录状态、API Key 等）。确定要继续吗？\"\n        }\n    },\n    \"monitor\": {\n        \"page_title\": \"API 监控看板\",\n        \"page_subtitle\": \"实时请求日志与分析\",\n        \"open_monitor\": \"打开监控\",\n        \"logging_status\": {\n            \"active\": \"正在录制\",\n            \"paused\": \"已暂停\"\n        },\n        \"stats\": {\n            \"total\": \"总计\",\n            \"ok\": \"正常\",\n            \"err\": \"错误\"\n        },\n        \"filters\": {\n            \"placeholder\": \"搜索模型 (gemini, claude)、路径 (chat, images) 或状态码...\",\n            \"quick_filters\": \"快速过滤:\",\n            \"all\": \"全部\",\n            \"error\": \"仅错误\",\n            \"chat\": \"聊天\",\n            \"gemini\": \"Gemini\",\n            \"claude\": \"Claude\",\n            \"images\": \"绘图\",\n            \"reset\": \"重置\",\n            \"by_account\": \"按账号筛选\",\n            \"all_accounts\": \"全部账号\"\n        },\n        \"table\": {\n            \"status\": \"状态\",\n            \"method\": \"方法\",\n            \"model\": \"模型\",\n            \"protocol\": \"协议\",\n            \"account\": \"账号\",\n            \"path\": \"路径\",\n            \"usage\": \"Token 消耗\",\n            \"duration\": \"耗时\",\n            \"time\": \"时间\",\n            \"empty\": \"暂无请求记录\"\n        },\n        \"details\": {\n            \"title\": \"请求详情\",\n            \"request_payload\": \"请求报文 (Request)\",\n            \"response_payload\": \"响应报文 (Response)\",\n            \"duration\": \"耗时\",\n            \"tokens\": \"Token 消耗 (输入/输出)\",\n            \"time\": \"请求时间\",\n            \"model\": \"使用模型\",\n            \"mapped_model\": \"映射模型\",\n            \"protocol\": \"请求协议\",\n            \"account_used\": \"使用账号\",\n            \"id\": \"请求 ID\",\n            \"payload_empty\": \"无数据\"\n        },\n        \"dialog\": {\n            \"clear_title\": \"清除监控日志\",\n            \"clear_msg\": \"确定要清除所有监控记录吗？此操作无法撤销。\"\n        }\n    },\n    \"update_notification\": {\n        \"title\": \"发现新版本\",\n        \"message\": \"新版本已准备就绪，包含诸多优化与改进。当前版本: v{{current}}\",\n        \"ready\": \"更新准备就绪\",\n        \"downloading\": \"正在下载更新...\",\n        \"restarting\": \"正在重启应用...\",\n        \"auto_update\": \"自动更新\",\n        \"toast\": {\n            \"not_ready\": \"自动更新包尚未就绪，为您跳转到下载页面...\",\n            \"failed\": \"自动更新失败，为您跳转到下载页面...\"\n        }\n    },\n    \"login\": {\n        \"title\": \"安全访问控制\",\n        \"desc\": \"当前运行在 Web 模式下，请输入管理密码或 API Key 以进入后台。\",\n        \"placeholder\": \"请输入管理密码或 API Key\",\n        \"btn_login\": \"验证并进入\",\n        \"btn_verifying\": \"验证中...\",\n        \"error_invalid_key\": \"密码或 API Key 错误，请重试\",\n        \"error_network\": \"网络连接失败，请检查服务是否正常运行\",\n        \"note\": \"注意：如果设置了独立的管理密码，请输入管理密码；否则请输入 API_KEY。\",\n        \"lookup_hint\": \"如果您忘记了，请运行 docker logs antigravity-manager 寻找 Current API Key 或 Web UI Password\",\n        \"config_hint\": \"或执行 grep -E '\\\"api_key\\\"|\\\"admin_password\\\"' ~/.antigravity_tools/gui_config.json 查看。\"\n    },\n    \"token_stats\": {\n        \"title\": \"Token 消费统计\",\n        \"hourly\": \"小时\",\n        \"daily\": \"日\",\n        \"weekly\": \"周\",\n        \"total_tokens\": \"总 Token\",\n        \"input_tokens\": \"输入 Token\",\n        \"output_tokens\": \"输出 Token\",\n        \"accounts_used\": \"活跃账号\",\n        \"models_used\": \"使用模型\",\n        \"model_trend\": \"分模型使用趋势\",\n        \"account_trend\": \"分账号使用趋势\",\n        \"usage_trend\": \"Token 使用趋势\",\n        \"by_account\": \"分账号统计\",\n        \"by_model\": \"按模型\",\n        \"by_account_view\": \"按账号\",\n        \"model_details\": \"分模型详细统计\",\n        \"account_details\": \"账号详细统计\",\n        \"model\": \"模型\",\n        \"account\": \"账号\",\n        \"requests\": \"请求数\",\n        \"input\": \"输入\",\n        \"output\": \"输出\",\n        \"total\": \"合计\",\n        \"percentage\": \"占比\",\n        \"no_data\": \"暂无数据\"\n    },\n    \"errors\": {\n        \"stream\": {\n            \"timeout_error\": \"请求超时,请检查网络连接\",\n            \"connection_error\": \"无法连接到服务器,请检查网络或代理设置\",\n            \"decode_error\": \"网络连接不稳定,数据传输中断。建议: 1) 检查网络连接 2) 更换代理节点 3) 稍后重试\",\n            \"stream_error\": \"数据流传输错误,请稍后重试\",\n            \"unknown_error\": \"发生未知错误,请稍后重试\"\n        }\n    },\n    \"security\": {\n        \"title\": \"安全监控\",\n        \"refresh_data\": \"刷新数据\",\n        \"refresh\": \"刷新\",\n        \"tab_logs\": \"访问日志\",\n        \"tab_stats\": \"统计分析\",\n        \"tab_blacklist\": \"黑名单管理\",\n        \"tab_whitelist\": \"白名单管理\",\n        \"tab_config\": \"安全配置\",\n        \"stats\": {\n            \"total_requests\": \"总请求数\",\n            \"total_requests_desc\": \"所有记录的请求\",\n            \"unique_ips\": \"独立 IP 数\",\n            \"unique_ips_desc\": \"不同的客户端 IP 地址\",\n            \"blocked_requests\": \"拦截请求数\",\n            \"blocked_requests_desc\": \"被规则拒绝的请求\",\n            \"ip_activity_token_usage\": \"IP 活跃度 & Token 消耗\",\n            \"hour\": \"时\",\n            \"day\": \"日\",\n            \"week\": \"周\",\n            \"month\": \"月\",\n            \"rank\": \"排名\",\n            \"ip_address\": \"IP 地址\",\n            \"activity_reqs\": \"活跃度 (请求数)\",\n            \"total_token\": \"总 Token\",\n            \"prompt\": \"提问\",\n            \"completion\": \"回答\",\n            \"no_data\": \"暂无数据\"\n        },\n        \"logs\": {\n            \"search_placeholder\": \"搜索 IP, 路径, User Agent...\",\n            \"username\": \"用户\",\n            \"show_blocked_only\": \"仅显示被拦截\",\n            \"status\": \"状态\",\n            \"ip_address\": \"IP 地址\",\n            \"method\": \"方法\",\n            \"path\": \"路径\",\n            \"duration\": \"耗时\",\n            \"time\": \"时间\",\n            \"reason\": \"原因\",\n            \"blocked\": \"拦截\",\n            \"no_logs\": \"暂无日志\",\n            \"total_records\": \"共 {{total}} 条记录\",\n            \"prev_page\": \"上一页\",\n            \"next_page\": \"下一页\",\n            \"page_num\": \"第 {{page}} 页\",\n            \"per_page_suffix\": \"/页\"\n        },\n        \"blacklist\": {\n            \"add_ip\": \"添加 IP\",\n            \"search_placeholder\": \"搜索...\",\n            \"added_at\": \"添加时间\",\n            \"expires_at\": \"过期时间\",\n            \"no_data\": \"暂无黑名单数据\",\n            \"add_title\": \"添加到黑名单\",\n            \"ip_cidr_label\": \"IP 地址或 CIDR\",\n            \"ip_cidr_placeholder\": \"例如 192.168.1.1 或 10.0.0.0/24\",\n            \"reason_label\": \"原因 (可选)\",\n            \"reason_placeholder\": \"例如：恶意扫描\",\n            \"expires_label\": \"过期时间（小时，可选）\",\n            \"expires_placeholder\": \"留空则永久生效\",\n            \"cancel\": \"取消\",\n            \"confirm\": \"添加\",\n            \"add_btn\": \"添加\",\n            \"error_duplicate\": \"该 IP 已存在于黑名单中\",\n            \"error_invalid_ip\": \"无效的 IP 格式。请使用 IP 地址或 CIDR 表示法（例如 192.168.1.0/24）\",\n            \"error_add_failed\": \"添加失败\"\n        },\n        \"whitelist\": {\n            \"add_ip\": \"添加信任 IP\",\n            \"no_data\": \"暂无白名单数据\",\n            \"add_title\": \"添加到白名单\",\n            \"description_label\": \"备注 (可选)\",\n            \"description_placeholder\": \"例如：内部服务器\",\n            \"cancel\": \"取消\",\n            \"confirm\": \"添加\",\n            \"add_btn\": \"添加\"\n        },\n        \"config\": {\n            \"title\": \"安全设置\",\n            \"save\": \"保存更改\",\n            \"saving\": \"保存中...\",\n            \"blacklist_title\": \"IP 黑名单\",\n            \"blacklist_desc\": \"管理被拦截的 IP 地址和规则。\",\n            \"enable_blacklist\": \"启用黑名单保护\",\n            \"block_msg_label\": \"自定义拦截消息\",\n            \"block_msg_desc\": \"返回给被拦截客户端的响应内容。\",\n            \"whitelist_title\": \"IP 白名单\",\n            \"whitelist_desc\": \"管理信任的 IP 地址。\",\n            \"enable_whitelist\": \"启用白名单模式\",\n            \"whitelist_warning\": \"警告: 启用白名单模式将拦截所有不在白名单中的 IP 请求。如果您通过代理访问，请务必小心不要将自己锁在外面。\",\n            \"whitelist_priority\": \"白名单优先 (覆盖黑名单)\",\n            \"whitelist_priority_desc\": \"启用后，白名单 IP 将被允许访问，即使它们匹配黑名单规则。\",\n            \"load_error\": \"加载配置失败\",\n            \"save_success\": \"配置已保存\",\n            \"save_error\": \"保存配置失败\"\n        }\n    },\n    \"user_token\": {\n        \"title\": \"用户 Token 管理\",\n        \"total_users\": \"用户总数\",\n        \"active_tokens\": \"活跃 Token\",\n        \"total_created\": \"累计创建\",\n        \"create\": \"创建 Token\",\n        \"username\": \"用户名\",\n        \"token\": \"Token\",\n        \"expires\": \"过期时间\",\n        \"usage\": \"使用量\",\n        \"ip_limit\": \"IP 限制\",\n        \"created\": \"创建时间\",\n        \"today_requests\": \"今日请求数\",\n        \"never\": \"永不过期\",\n        \"renew\": \"续期\",\n        \"renew_button\": \"续费\",\n        \"unlimited\": \"不限\",\n        \"create_title\": \"创建新 Token\",\n        \"description\": \"描述\",\n        \"curfew\": \"宵禁时间 (服务不可用时段)\",\n        \"edit_title\": \"编辑 Token\",\n        \"username_required\": \"用户名不能为空\",\n        \"renew_success\": \"续期成功\",\n        \"expires_day\": \"1 天\",\n        \"expires_week\": \"1 周\",\n        \"expires_month\": \"1 个月\",\n        \"expires_never\": \"永不过期\",\n        \"no_data\": \"暂无数据\",\n        \"placeholder_username\": \"例如: user1\",\n        \"placeholder_desc\": \"选填备注\",\n        \"placeholder_max_ips\": \"0 = 不限制\",\n        \"hint_max_ips\": \"0 表示不限制\",\n        \"hint_curfew\": \"留空则禁用。基于服务器时间。\"\n    }\n}"
  },
  {
    "path": "src/main.tsx",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport App from './App';\nimport './i18n'; // Import i18n config\nimport \"./App.css\";\n\nimport { isTauri } from \"./utils/env\";\n// 启动时显式调用 Rust 命令显示窗口\n// 配合 visible:false 使用，解决启动黑屏问题\nif (isTauri()) {\n  import(\"@tauri-apps/api/core\").then(({ invoke }) => {\n    invoke(\"show_main_window\").catch(console.error);\n  });\n}\n\nReactDOM.createRoot(document.getElementById(\"root\") as HTMLElement).render(\n  <React.StrictMode>\n    <App />\n\n  </React.StrictMode>,\n);\n"
  },
  {
    "path": "src/pages/Accounts.tsx",
    "content": "\n\nimport {\n  Download,\n  LayoutGrid,\n  List,\n  RefreshCw,\n  Search,\n  Sparkles,\n  ToggleLeft,\n  ToggleRight,\n  Trash2,\n  Upload,\n} from \"lucide-react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport AccountDetailsDialog from \"../components/accounts/AccountDetailsDialog\";\nimport AccountGrid from \"../components/accounts/AccountGrid\";\nimport AccountTable from \"../components/accounts/AccountTable\";\nimport AddAccountDialog from \"../components/accounts/AddAccountDialog\";\nimport DeviceFingerprintDialog from \"../components/accounts/DeviceFingerprintDialog\";\nimport ModalDialog from \"../components/common/ModalDialog\";\nimport Pagination from \"../components/common/Pagination\";\nimport AccountErrorDialog from \"../components/accounts/AccountErrorDialog\";\nimport { showToast } from \"../components/common/ToastContainer\";\nimport { exportAccounts } from \"../services/accountService\";\nimport { useAccountStore } from \"../stores/useAccountStore\";\nimport { useConfigStore } from \"../stores/useConfigStore\";\nimport { Account } from \"../types/account\";\nimport { cn } from \"../utils/cn\";\nimport { isTauri } from \"../utils/env\";\nimport { request as invoke } from \"../utils/request\";\nimport { useTranslation } from \"react-i18next\";\n\ntype FilterType = \"all\" | \"pro\" | \"ultra\" | \"free\";\ntype ViewMode = \"list\" | \"grid\";\n\n\nfunction Accounts() {\n  const { t } = useTranslation();\n  const {\n    accounts,\n    currentAccount,\n    fetchAccounts,\n    addAccount,\n    deleteAccount,\n    deleteAccounts,\n    switchAccount,\n    loading,\n    refreshQuota,\n    toggleProxyStatus,\n    reorderAccounts,\n    warmUpAccounts,\n    warmUpAccount,\n    updateAccountLabel,\n  } = useAccountStore();\n  const { config, showAllQuotas, toggleShowAllQuotas } = useConfigStore();\n\n  const [searchQuery, setSearchQuery] = useState('');\n  const [filter, setFilter] = useState<FilterType>('all');\n  const [isSearchExpanded, setIsSearchExpanded] = useState(false);\n  const searchInputRef = useRef<HTMLInputElement>(null);\n  const [viewMode, setViewMode] = useState<ViewMode>(() => {\n    const saved = localStorage.getItem('accounts_view_mode');\n    return (saved === 'list' || saved === 'grid') ? saved : 'list';\n  });\n\n  // Save view mode preference\n  useEffect(() => {\n    localStorage.setItem('accounts_view_mode', viewMode);\n  }, [viewMode]);\n  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());\n  const [deviceAccount, setDeviceAccount] = useState<Account | null>(null);\n  const [detailsAccount, setDetailsAccount] = useState<Account | null>(null);\n  const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);\n  const [isBatchDelete, setIsBatchDelete] = useState(false);\n  const [toggleProxyConfirm, setToggleProxyConfirm] = useState<{\n    accountId: string;\n    enable: boolean;\n  } | null>(null);\n  const [isWarmupConfirmOpen, setIsWarmupConfirmOpen] = useState(false);\n  const [isWarmuping, setIsWarmuping] = useState(false);\n  const [refreshingIds, setRefreshingIds] = useState<Set<string>>(new Set());\n  const [errorAccountId, setErrorAccountId] = useState<string | null>(null);\n\n  const handleWarmup = async (accountId: string) => {\n    setRefreshingIds((prev) => {\n      const next = new Set(prev);\n      next.add(accountId);\n      return next;\n    });\n    try {\n      const msg = await warmUpAccount(accountId);\n      showToast(msg, \"success\");\n    } catch (error) {\n      showToast(`${t(\"common.error\")}: ${error}`, \"error\");\n    } finally {\n      setRefreshingIds((prev) => {\n        const next = new Set(prev);\n        next.delete(accountId);\n        return next;\n      });\n    }\n  };\n\n  const handleUpdateLabel = async (accountId: string, label: string) => {\n    try {\n      await updateAccountLabel(accountId, label);\n      showToast(t('accounts.label_updated', 'Label updated'), 'success');\n    } catch (error) {\n      showToast(`${t('common.error')}: ${error}`, 'error');\n    }\n  };\n\n  const handleWarmupAll = async () => {\n    setIsWarmupConfirmOpen(false);\n    setIsWarmuping(true);\n    try {\n      const isBatch = selectedIds.size > 0;\n      if (isBatch) {\n        const ids = Array.from(selectedIds);\n        setRefreshingIds(new Set(ids));\n        const results = await Promise.allSettled(\n          ids.map((id) => warmUpAccount(id)),\n        );\n        let successCount = 0;\n        results.forEach((r) => {\n          if (r.status === \"fulfilled\") successCount++;\n        });\n        showToast(\n          t(\"accounts.warmup_batch_triggered\", { count: successCount }),\n          \"success\",\n        );\n      } else {\n        const msg = await warmUpAccounts();\n        if (msg) {\n          showToast(msg, \"success\");\n        } else {\n          showToast(\n            t(\"accounts.warmup_all_triggered\", \"全量预热任务已触发\"),\n            \"success\",\n          );\n        }\n      }\n    } catch (error) {\n      showToast(`${t(\"common.error\")}: ${error}`, \"error\");\n    } finally {\n      setIsWarmuping(false);\n      setRefreshingIds(new Set());\n    }\n  };\n\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });\n\n  useEffect(() => {\n    if (!containerRef.current) return;\n    const resizeObserver = new ResizeObserver((entries) => {\n      for (let entry of entries) {\n        setContainerSize({\n          width: entry.contentRect.width,\n          height: entry.contentRect.height,\n        });\n      }\n    });\n    resizeObserver.observe(containerRef.current);\n    return () => resizeObserver.disconnect();\n  }, []);\n\n  // Pagination State\n  const [currentPage, setCurrentPage] = useState(1);\n  const [localPageSize, setLocalPageSize] = useState<number | null>(() => {\n    const saved = localStorage.getItem(\"accounts_page_size\");\n    return saved ? parseInt(saved) : null;\n  }); // 本地分页大小状态\n\n  // Save page size preference\n  useEffect(() => {\n    if (localPageSize !== null) {\n      localStorage.setItem(\"accounts_page_size\", localPageSize.toString());\n    }\n  }, [localPageSize]);\n\n  // 动态计算分页条数\n  const ITEMS_PER_PAGE = useMemo(() => {\n    // 优先使用本地设置的分页大小\n    if (localPageSize && localPageSize > 0) {\n      return localPageSize;\n    }\n\n    // 其次使用用户配置的固定值\n    if (config?.accounts_page_size && config.accounts_page_size > 0) {\n      return config.accounts_page_size;\n    }\n\n    // 回退到原有的动态计算逻辑\n    if (!containerSize.height) return viewMode === \"grid\" ? 6 : 8;\n\n    if (viewMode === \"list\") {\n      const headerHeight = 36; // 缩深后的表头高度\n      const rowHeight = 72; // 包含多行模型信息后的实际行高\n      // 计算能容纳多少行, 默认最低 10 行\n      const autoFitCount = Math.floor(\n        (containerSize.height - headerHeight) / rowHeight,\n      );\n      return Math.max(10, autoFitCount);\n    } else {\n      const cardHeight = 180; // AccountCard 实际高度 (含间距)\n      const gap = 16; // gap-4\n\n      // 匹配 Tailwind 断点逻辑\n      let cols = 1;\n      if (containerSize.width >= 1200)\n        cols = 4; // xl (约为 1280 左右)\n      else if (containerSize.width >= 900)\n        cols = 3; // lg (约为 1024 左右)\n      else if (containerSize.width >= 600) cols = 2; // md (约为 768 左右)\n\n      const rows = Math.max(\n        1,\n        Math.floor((containerSize.height + gap) / (cardHeight + gap)),\n      );\n      return cols * rows;\n    }\n  }, [localPageSize, config?.accounts_page_size, containerSize, viewMode]);\n\n  useEffect(() => {\n    fetchAccounts();\n  }, []);\n\n  // Reset pagination when view mode changes to avoid empty pages or confusion\n  useEffect(() => {\n    setCurrentPage(1);\n  }, [viewMode]);\n\n  // 搜索过滤逻辑\n  const searchedAccounts = useMemo(() => {\n    if (!searchQuery) return accounts;\n    const lowQuery = searchQuery.toLowerCase();\n    return accounts.filter((a) => a.email.toLowerCase().includes(lowQuery));\n  }, [accounts, searchQuery]);\n\n  // 计算各筛选状态下的数量 (基于搜索结果)\n  const filterCounts = useMemo(() => {\n    return {\n      all: searchedAccounts.length,\n      pro: searchedAccounts.filter((a) =>\n        a.quota?.subscription_tier?.toLowerCase().includes(\"pro\"),\n      ).length,\n      ultra: searchedAccounts.filter((a) =>\n        a.quota?.subscription_tier?.toLowerCase().includes(\"ultra\"),\n      ).length,\n      free: searchedAccounts.filter((a) => {\n        const tier = a.quota?.subscription_tier?.toLowerCase();\n        return tier && !tier.includes(\"pro\") && !tier.includes(\"ultra\");\n      }).length,\n    };\n  }, [searchedAccounts]);\n\n  // 过滤和搜索最终结果\n  const filteredAccounts = useMemo(() => {\n    let result = searchedAccounts;\n\n    if (filter === \"pro\") {\n      result = result.filter((a) =>\n        a.quota?.subscription_tier?.toLowerCase().includes(\"pro\"),\n      );\n    } else if (filter === \"ultra\") {\n      result = result.filter((a) =>\n        a.quota?.subscription_tier?.toLowerCase().includes(\"ultra\"),\n      );\n    } else if (filter === \"free\") {\n      result = result.filter((a) => {\n        const tier = a.quota?.subscription_tier?.toLowerCase();\n        return tier && !tier.includes(\"pro\") && !tier.includes(\"ultra\");\n      });\n    }\n\n    return result;\n  }, [searchedAccounts, filter]);\n\n  // Pagination Logic\n  const paginatedAccounts = useMemo(() => {\n    const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;\n    return filteredAccounts.slice(startIndex, startIndex + ITEMS_PER_PAGE);\n  }, [filteredAccounts, currentPage, ITEMS_PER_PAGE]);\n\n  const handlePageChange = (page: number) => {\n    setCurrentPage(page);\n  };\n\n  // 清空选择当过滤改变 并重置分页\n  useEffect(() => {\n    setSelectedIds(new Set());\n    setCurrentPage(1);\n  }, [filter, searchQuery]);\n\n  const handleToggleSelect = (id: string) => {\n    const newSet = new Set(selectedIds);\n    if (newSet.has(id)) {\n      newSet.delete(id);\n    } else {\n      newSet.add(id);\n    }\n    setSelectedIds(newSet);\n  };\n\n  const handleToggleAll = () => {\n    // 全选当前页的所有项\n    const currentIds = paginatedAccounts.map((a) => a.id);\n    const allSelected = currentIds.every((id) => selectedIds.has(id));\n\n    const newSet = new Set(selectedIds);\n    if (allSelected) {\n      currentIds.forEach((id) => newSet.delete(id));\n    } else {\n      currentIds.forEach((id) => newSet.add(id));\n    }\n    setSelectedIds(newSet);\n  };\n\n  const handleAddAccount = async (email: string, refreshToken: string) => {\n    await addAccount(email, refreshToken);\n  };\n\n  const [switchingAccountId, setSwitchingAccountId] = useState<string | null>(\n    null,\n  );\n\n  const handleSwitch = async (accountId: string) => {\n    if (loading || switchingAccountId) return;\n\n    setSwitchingAccountId(accountId);\n    console.log(\"[Accounts] handleSwitch called for:\", accountId);\n    try {\n      await switchAccount(accountId);\n      showToast(t(\"common.success\"), \"success\");\n    } catch (error) {\n      console.error(\"[Accounts] Switch failed:\", error);\n      showToast(`${t(\"common.error\")}: ${error}`, \"error\");\n    } finally {\n      // Add a small delay for smoother UX\n      setTimeout(() => {\n        setSwitchingAccountId(null);\n      }, 500);\n    }\n  };\n\n  const handleRefresh = async (accountId: string) => {\n    setRefreshingIds((prev) => {\n      const next = new Set(prev);\n      next.add(accountId);\n      return next;\n    });\n    try {\n      await refreshQuota(accountId);\n      await refreshQuota(accountId);\n      await refreshQuota(accountId);\n      showToast(t(\"common.success\"), \"success\");\n    } catch (error) {\n      showToast(`${t(\"common.error\")}: ${error}`, \"error\");\n    } finally {\n      setRefreshingIds((prev) => {\n        const next = new Set(prev);\n        next.delete(accountId);\n        return next;\n      });\n    }\n  };\n\n  const handleBatchDelete = () => {\n    if (selectedIds.size === 0) return;\n    setIsBatchDelete(true);\n  };\n\n  const executeBatchDelete = async () => {\n    setIsBatchDelete(false);\n    try {\n      const ids = Array.from(selectedIds);\n      console.log(\"[Accounts] Batch deleting:\", ids);\n      await deleteAccounts(ids);\n      setSelectedIds(new Set());\n      console.log(\"[Accounts] Batch delete success\");\n      showToast(t(\"common.success\"), \"success\");\n    } catch (error) {\n      console.error(\"[Accounts] Batch delete failed:\", error);\n      showToast(`${t(\"common.error\")}: ${error}`, \"error\");\n    }\n  };\n\n  const handleDelete = (accountId: string) => {\n    console.log(\"[Accounts] Request to delete:\", accountId);\n    setDeleteConfirmId(accountId);\n  };\n\n  const executeDelete = async () => {\n    if (!deleteConfirmId) return;\n\n    try {\n      console.log(\"[Accounts] Executing delete for:\", deleteConfirmId);\n      await deleteAccount(deleteConfirmId);\n      console.log(\"[Accounts] Delete success\");\n      showToast(t(\"common.success\"), \"success\");\n    } catch (error) {\n      console.error(\"[Accounts] Delete failed:\", error);\n      showToast(`${t(\"common.error\")}: ${error}`, \"error\");\n    } finally {\n      setDeleteConfirmId(null);\n    }\n  };\n\n  const handleToggleProxy = (accountId: string, currentlyDisabled: boolean) => {\n    setToggleProxyConfirm({ accountId, enable: currentlyDisabled });\n  };\n\n  const executeToggleProxy = async () => {\n    if (!toggleProxyConfirm) return;\n\n    try {\n      await toggleProxyStatus(\n        toggleProxyConfirm.accountId,\n        toggleProxyConfirm.enable,\n        toggleProxyConfirm.enable\n          ? undefined\n          : t(\"accounts.proxy_disabled_reason_manual\"),\n      );\n      showToast(t(\"common.success\"), \"success\");\n    } catch (error) {\n      console.error(\"[Accounts] Toggle proxy status failed:\", error);\n      showToast(`${t(\"common.error\")}: ${error}`, \"error\");\n    } finally {\n      setToggleProxyConfirm(null);\n    }\n  };\n\n  const handleBatchToggleProxy = async (enable: boolean) => {\n    if (selectedIds.size === 0) return;\n\n    try {\n      const promises = Array.from(selectedIds).map((id) =>\n        toggleProxyStatus(\n          id,\n          enable,\n          enable ? undefined : t(\"accounts.proxy_disabled_reason_batch\"),\n        ),\n      );\n      await Promise.all(promises);\n      showToast(\n        enable\n          ? t(\"accounts.toast.proxy_enabled\", { count: selectedIds.size })\n          : t(\"accounts.toast.proxy_disabled\", { count: selectedIds.size }),\n        \"success\",\n      );\n      setSelectedIds(new Set());\n    } catch (error) {\n      console.error(\"[Accounts] Batch toggle proxy status failed:\", error);\n      showToast(`${t(\"common.error\")}: ${error}`, \"error\");\n    }\n  };\n\n  const [isRefreshing, setIsRefreshing] = useState(false);\n  const [isRefreshConfirmOpen, setIsRefreshConfirmOpen] = useState(false);\n\n  const handleRefreshClick = () => {\n    setIsRefreshConfirmOpen(true);\n  };\n\n  const executeRefresh = async () => {\n    setIsRefreshConfirmOpen(false);\n    setIsRefreshing(true);\n    try {\n      const isBatch = selectedIds.size > 0;\n      let successCount = 0;\n      let failedCount = 0;\n      const details: string[] = [];\n\n      if (isBatch) {\n        // 批量刷新选中\n        const ids = Array.from(selectedIds);\n        setRefreshingIds(new Set(ids));\n\n        const results = await Promise.allSettled(\n          ids.map((id) => refreshQuota(id)),\n        );\n\n        results.forEach((result, index) => {\n          const id = ids[index];\n          const email = accounts.find((a) => a.id === id)?.email || id;\n          if (result.status === \"fulfilled\") {\n            successCount++;\n          } else {\n            failedCount++;\n            details.push(`${email}: ${result.reason}`);\n          }\n        });\n      } else {\n        // 刷新所有\n        setRefreshingIds(new Set(accounts.map((a) => a.id)));\n        const stats = await useAccountStore.getState().refreshAllQuotas();\n        if (stats) {\n          successCount = stats.success;\n          failedCount = stats.failed;\n          details.push(...stats.details);\n        }\n      }\n\n      if (failedCount === 0) {\n        showToast(\n          t(\"accounts.refresh_selected\", { count: successCount }),\n          \"success\",\n        );\n      } else {\n        showToast(\n          `${t(\"common.success\")}: ${successCount}, ${t(\"common.error\")}: ${failedCount}`,\n          \"warning\",\n        );\n        // You might want to show details in a different way, but for toast, keep it simple or use a \"view details\" action if supported.\n        // For now, simpler toast is better than a huge alert.\n        if (details.length > 0) {\n          console.warn(\"Refresh failures:\", details);\n        }\n      }\n    } catch (error) {\n      showToast(`${t(\"common.error\")}: ${error}`, \"error\");\n    } finally {\n      setIsRefreshing(false);\n      setRefreshingIds(new Set());\n    }\n  };\n\n  const exportAccountsToJson = async (accountsToExport: Account[]) => {\n    try {\n      if (accountsToExport.length === 0) {\n        showToast(t(\"dashboard.toast.export_no_accounts\"), \"warning\");\n        return;\n      }\n\n      // 1. Get export data from API (contains refresh_token)\n      const accountIds = accountsToExport.map((acc) => acc.id);\n      const response = await exportAccounts(accountIds);\n\n      if (!response.accounts || response.accounts.length === 0) {\n        showToast(t(\"dashboard.toast.export_no_accounts\"), \"warning\");\n        return;\n      }\n\n      const exportData = response.accounts;\n      const content = JSON.stringify(exportData, null, 2);\n      const fileName = `antigravity_accounts_${new Date().toISOString().split(\"T\")[0]}.json`;\n\n      // 2. Determine Path & Export\n      if (isTauri()) {\n        let path: string | null = null;\n        const { join } = await import(\"@tauri-apps/api/path\");\n\n        if (config?.default_export_path) {\n          // Use default path\n          path = await join(config.default_export_path, fileName);\n        } else {\n          // Use Native Dialog\n          const { save } = await import(\"@tauri-apps/plugin-dialog\");\n          path = await save({\n            filters: [\n              {\n                name: \"JSON\",\n                extensions: [\"json\"],\n              },\n            ],\n            defaultPath: fileName,\n          });\n        }\n\n        if (!path) return; // Cancelled\n\n        // 3. Write File\n        await invoke(\"save_text_file\", { path, content });\n        showToast(`${t(\"common.success\")} ${path}`, \"success\");\n      } else {\n        // Web 模式：使用浏览器下载\n        const blob = new Blob([content], { type: \"application/json\" });\n        const url = URL.createObjectURL(blob);\n        const a = document.createElement(\"a\");\n        a.href = url;\n        a.download = fileName;\n        document.body.appendChild(a);\n        a.click();\n        document.body.removeChild(a);\n        URL.revokeObjectURL(url);\n        showToast(\n          t(\"dashboard.toast.export_success\", { path: fileName }),\n          \"success\",\n        );\n      }\n    } catch (error: any) {\n      console.error(\"Export failed:\", error);\n      showToast(`${t(\"common.error\")}: ${error}`, \"error\");\n    }\n  };\n\n  const handleExport = () => {\n    const idsToExport =\n      selectedIds.size > 0\n        ? Array.from(selectedIds)\n        : accounts.map((a) => a.id);\n\n    const accountsToExport = accounts.filter((a) => idsToExport.includes(a.id));\n    exportAccountsToJson(accountsToExport);\n  };\n\n  const handleExportOne = (accountId: string) => {\n    const account = accounts.find((a) => a.id === accountId);\n    if (account) {\n      exportAccountsToJson([account]);\n    }\n  };\n\n  const processImportData = async (content: string) => {\n    let importData: Array<{ email?: string; refresh_token?: string }>;\n    try {\n      importData = JSON.parse(content);\n    } catch {\n      showToast(t(\"accounts.import_invalid_format\"), \"error\");\n      return;\n    }\n\n    if (!Array.isArray(importData) || importData.length === 0) {\n      showToast(t(\"accounts.import_invalid_format\"), \"error\");\n      return;\n    }\n\n    const validEntries = importData.filter(\n      (item) =>\n        item.refresh_token &&\n        typeof item.refresh_token === \"string\" &&\n        item.refresh_token.startsWith(\"1//\"),\n    );\n\n    if (validEntries.length === 0) {\n      showToast(t(\"accounts.import_invalid_format\"), \"error\");\n      return;\n    }\n\n    let successCount = 0;\n    let failCount = 0;\n\n    for (const entry of validEntries) {\n      try {\n        await addAccount(entry.email || \"\", entry.refresh_token!);\n        successCount++;\n      } catch (error) {\n        console.error(\"Import account failed:\", error);\n        failCount++;\n      }\n      await new Promise((r) => setTimeout(r, 100));\n    }\n\n    if (failCount === 0) {\n      showToast(\n        t(\"accounts.import_success\", { count: successCount }),\n        \"success\",\n      );\n    } else if (successCount > 0) {\n      showToast(\n        t(\"accounts.import_partial\", {\n          success: successCount,\n          fail: failCount,\n        }),\n        \"warning\",\n      );\n    } else {\n      showToast(\n        t(\"accounts.import_fail\", { error: \"All accounts failed to import\" }),\n        \"error\",\n      );\n    }\n  };\n\n  const handleImportJson = async () => {\n    if (isTauri()) {\n      try {\n        const { open } = await import(\"@tauri-apps/plugin-dialog\");\n        const selected = await open({\n          multiple: false,\n          filters: [\n            {\n              name: \"JSON\",\n              extensions: [\"json\"],\n            },\n          ],\n        });\n        if (!selected || typeof selected !== \"string\") return;\n\n        const content: string = await invoke(\"read_text_file\", {\n          path: selected,\n        });\n        await processImportData(content);\n      } catch (error) {\n        console.error(\"Import failed:\", error);\n        showToast(t(\"accounts.import_fail\", { error: String(error) }), \"error\");\n      }\n    } else {\n      // Web 模式: 触发隐藏的 file input\n      fileInputRef.current?.click();\n    }\n  };\n\n  const handleFileChange = async (\n    event: React.ChangeEvent<HTMLInputElement>,\n  ) => {\n    const file = event.target.files?.[0];\n    if (!file) return;\n\n    try {\n      const content = await file.text();\n      await processImportData(content);\n    } catch (error) {\n      console.error(\"Import failed:\", error);\n      showToast(t(\"accounts.import_fail\", { error: String(error) }), \"error\");\n    } finally {\n      // 重置 input,允许重复选择同一文件\n      event.target.value = \"\";\n    }\n  };\n\n  const handleViewDetails = (accountId: string) => {\n    const account = accounts.find((a) => a.id === accountId);\n    if (account) {\n      setDetailsAccount(account);\n    }\n  };\n  const handleViewDevice = (accountId: string) => {\n    const account = accounts.find((a) => a.id === accountId);\n    if (account) {\n      setDeviceAccount(account);\n    }\n  };\n\n  return (\n    <div className=\"h-full flex flex-col p-5 gap-4 max-w-7xl mx-auto w-full\">\n      {/* 测试按钮 - 在最顶部 */}\n      <input\n        ref={fileInputRef}\n        type=\"file\"\n        accept=\".json,application/json\"\n        style={{ display: \"none\" }}\n        onChange={handleFileChange}\n      />\n\n      {/* 顶部工具栏:搜索、过滤和操作按钮 */}\n      <div className=\"flex-none flex items-center gap-2\">\n        {/* 搜索框 - 响应式:大屏显示输入框,小屏显示图标 */}\n        <div className=\"hidden lg:block flex-none w-40 relative transition-all focus-within:w-48\">\n          <Search className=\"absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400\" />\n          <input\n            type=\"text\"\n            placeholder={t('accounts.search_placeholder')}\n            className=\"w-full pl-9 pr-4 py-2 bg-white dark:bg-base-100 text-sm text-gray-900 dark:text-base-content border border-gray-200 dark:border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder:text-gray-400 dark:placeholder:text-gray-500\"\n            value={searchQuery}\n            onChange={(e) => setSearchQuery(e.target.value)}\n          />\n        </div>\n\n        {/* 搜索按钮 - 小屏显示 */}\n        <div className=\"lg:hidden relative\">\n          {!isSearchExpanded ? (\n            <button\n              onClick={() => {\n                setIsSearchExpanded(true);\n                setTimeout(() => searchInputRef.current?.focus(), 100);\n              }}\n              className=\"p-2 bg-gray-100 dark:bg-base-200 hover:bg-gray-200 dark:hover:bg-base-100 rounded-lg transition-colors\"\n              title={t('accounts.search_placeholder')}\n            >\n              <Search className=\"w-4 h-4 text-gray-600 dark:text-gray-300\" />\n            </button>\n          ) : (\n            <div className=\"absolute left-0 top-0 z-10 w-64 flex items-center gap-1\">\n              <div className=\"flex-1 relative\">\n                <Search className=\"absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400\" />\n                <input\n                  ref={searchInputRef}\n                  type=\"text\"\n                  placeholder={t('accounts.search_placeholder')}\n                  className=\"w-full pl-9 pr-4 py-2 bg-white dark:bg-base-100 text-sm text-gray-900 dark:text-base-content border border-gray-200 dark:border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder:text-gray-400 dark:placeholder:text-gray-500 shadow-lg\"\n                  value={searchQuery}\n                  onChange={(e) => setSearchQuery(e.target.value)}\n                  onBlur={() => setIsSearchExpanded(false)}\n                />\n              </div>\n            </div>\n          )}\n        </div>\n\n        {/* 视图切换按钮组 */}\n        <div className=\"flex gap-1 bg-gray-100 dark:bg-base-200 p-1 rounded-lg shrink-0\">\n          <button\n            className={cn(\n              \"p-1.5 rounded-md transition-all\",\n              viewMode === \"list\"\n                ? \"bg-white dark:bg-base-100 text-blue-600 dark:text-blue-400 shadow-sm\"\n                : \"text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-base-content\",\n            )}\n            onClick={() => setViewMode(\"list\")}\n            title={t(\"accounts.views.list\")}\n          >\n            <List className=\"w-4 h-4\" />\n          </button>\n          <button\n            className={cn(\n              \"p-1.5 rounded-md transition-all\",\n              viewMode === \"grid\"\n                ? \"bg-white dark:bg-base-100 text-blue-600 dark:text-blue-400 shadow-sm\"\n                : \"text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-base-content\",\n            )}\n            onClick={() => setViewMode(\"grid\")}\n            title={t(\"accounts.views.grid\")}\n          >\n            <LayoutGrid className=\"w-4 h-4\" />\n          </button>\n        </div>\n\n        {/* 过滤按钮组 - 图标化响应式 */}\n        <div className=\"flex gap-0.5 bg-gray-100/80 dark:bg-base-200 p-1 rounded-xl border border-gray-200/50 dark:border-white/5 shrink-0\">\n          {/* 全部 */}\n          <button\n            className={cn(\n              \"px-2 md:px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-all flex items-center gap-1 md:gap-1.5 whitespace-nowrap shrink-0\",\n              filter === 'all'\n                ? \"bg-white dark:bg-base-100 text-blue-600 dark:text-blue-400 shadow-sm ring-1 ring-black/5\"\n                : \"text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-base-content hover:bg-white/40\"\n            )}\n            onClick={() => setFilter('all')}\n            title={`${t('accounts.all')} (${filterCounts.all})`}\n          >\n            <span className=\"hidden md:inline\">{t('accounts.all')}</span>\n            <span className={cn(\n              \"px-1.5 py-0.5 rounded-md text-[10px] font-bold transition-colors\",\n              filter === 'all'\n                ? \"bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400\"\n                : \"bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400\"\n            )}>\n              {filterCounts.all}\n            </span>\n          </button>\n\n          {/* PRO */}\n          <button\n            className={cn(\n              \"px-2 md:px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-all flex items-center gap-1 md:gap-1.5 whitespace-nowrap shrink-0\",\n              filter === 'pro'\n                ? \"bg-white dark:bg-base-100 text-blue-600 dark:text-blue-400 shadow-sm ring-1 ring-black/5\"\n                : \"text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-base-content hover:bg-white/40\"\n            )}\n            onClick={() => setFilter('pro')}\n            title={`${t('accounts.pro')} (${filterCounts.pro})`}\n          >\n            <span className=\"hidden md:inline\">{t('accounts.pro')}</span>\n            <span className={cn(\n              \"px-1.5 py-0.5 rounded-md text-[10px] font-bold transition-colors\",\n              filter === 'pro'\n                ? \"bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400\"\n                : \"bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400\"\n            )}>\n              {filterCounts.pro}\n            </span>\n          </button>\n\n          {/* ULTRA */}\n          <button\n            className={cn(\n              \"flex px-2 lg:px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-all items-center gap-1 lg:gap-1.5 whitespace-nowrap shrink-0\",\n              filter === 'ultra'\n                ? \"bg-white dark:bg-base-100 text-blue-600 dark:text-blue-400 shadow-sm ring-1 ring-black/5\"\n                : \"text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-base-content hover:bg-white/40\"\n            )}\n            onClick={() => setFilter('ultra')}\n            title={`${t('accounts.ultra')} (${filterCounts.ultra})`}\n          >\n            <span className=\"hidden md:inline\">{t('accounts.ultra')}</span>\n            <span className={cn(\n              \"px-1.5 py-0.5 rounded-md text-[10px] font-bold transition-colors\",\n              filter === 'ultra'\n                ? \"bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400\"\n                : \"bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400\"\n            )}>\n              {filterCounts.ultra}\n            </span>\n          </button>\n\n          {/* FREE */}\n          <button\n            className={cn(\n              \"flex px-2 lg:px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-all items-center gap-1 lg:gap-1.5 whitespace-nowrap shrink-0\",\n              filter === 'free'\n                ? \"bg-white dark:bg-base-100 text-blue-600 dark:text-blue-400 shadow-sm ring-1 ring-black/5\"\n                : \"text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-base-content hover:bg-white/40\"\n            )}\n            onClick={() => setFilter('free')}\n            title={`${t('accounts.free')} (${filterCounts.free})`}\n          >\n            <span className=\"hidden md:inline\">{t('accounts.free')}</span>\n            <span className={cn(\n              \"px-1.5 py-0.5 rounded-md text-[10px] font-bold transition-colors\",\n              filter === 'free'\n                ? \"bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400\"\n                : \"bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400\"\n            )}>\n              {filterCounts.free}\n            </span>\n          </button>\n        </div>\n\n        <div className=\"flex-1 min-w-[8px]\"></div>\n\n        {/* 操作按钮组 */}\n        <div className=\"flex items-center gap-1.5 shrink-0\">\n          <AddAccountDialog onAdd={handleAddAccount} showText={false} />\n\n          {selectedIds.size > 0 && (\n            <>\n              <button\n                className=\"px-2.5 py-2 bg-red-500 text-white text-xs font-medium rounded-lg hover:bg-red-600 transition-colors flex items-center gap-1.5 shadow-sm\"\n                onClick={handleBatchDelete}\n                title={t(\"accounts.delete_selected\", {\n                  count: selectedIds.size,\n                })}\n              >\n                <Trash2 className=\"w-3.5 h-3.5\" />\n                <span className=\"hidden xl:inline\">\n                  {t(\"accounts.delete_selected\", { count: selectedIds.size })}\n                </span>\n              </button>\n              <button\n                className=\"px-2.5 py-2 bg-orange-500 text-white text-xs font-medium rounded-lg hover:bg-orange-600 transition-colors flex items-center gap-1.5 shadow-sm\"\n                onClick={() => handleBatchToggleProxy(false)}\n                title={t(\"accounts.disable_proxy_selected\", {\n                  count: selectedIds.size,\n                })}\n              >\n                <ToggleLeft className=\"w-3.5 h-3.5\" />\n                <span className=\"hidden xl:inline\">\n                  {t(\"accounts.disable_proxy_selected\", {\n                    count: selectedIds.size,\n                  })}\n                </span>\n              </button>\n              <button\n                className=\"px-2.5 py-2 bg-green-500 text-white text-xs font-medium rounded-lg hover:bg-green-600 transition-colors flex items-center gap-1.5 shadow-sm\"\n                onClick={() => handleBatchToggleProxy(true)}\n                title={t(\"accounts.enable_proxy_selected\", {\n                  count: selectedIds.size,\n                })}\n              >\n                <ToggleRight className=\"w-3.5 h-3.5\" />\n                <span className=\"hidden xl:inline\">\n                  {t(\"accounts.enable_proxy_selected\", {\n                    count: selectedIds.size,\n                  })}\n                </span>\n              </button>\n            </>\n          )}\n\n          <button\n            className={`px-2.5 py-2 bg-blue-500 text-white text-xs font-medium rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-1.5 shadow-sm ${isRefreshing ? \"opacity-70 cursor-not-allowed\" : \"\"}`}\n            onClick={handleRefreshClick}\n            disabled={isRefreshing}\n            title={\n              selectedIds.size > 0\n                ? t(\"accounts.refresh_selected\", { count: selectedIds.size })\n                : t(\"accounts.refresh_all\")\n            }\n          >\n            <RefreshCw\n              className={`w-3.5 h-3.5 ${isRefreshing ? \"animate-spin\" : \"\"}`}\n            />\n            <span className=\"hidden xl:inline\">\n              {isRefreshing\n                ? t(\"common.loading\")\n                : selectedIds.size > 0\n                  ? t(\"accounts.refresh_selected\", { count: selectedIds.size })\n                  : t(\"accounts.refresh_all\")}\n            </span>\n          </button>\n\n          <button\n            className={`px-2.5 py-2 bg-orange-500 text-white text-xs font-medium rounded-lg hover:bg-orange-600 transition-colors flex items-center gap-1.5 shadow-sm ${isWarmuping ? \"opacity-70 cursor-not-allowed\" : \"\"}`}\n            onClick={() => setIsWarmupConfirmOpen(true)}\n            disabled={isWarmuping}\n            title={\n              selectedIds.size > 0\n                ? t(\"accounts.warmup_selected\", { count: selectedIds.size })\n                : t(\"accounts.warmup_all\", \"一键预热所有账号\")\n            }\n          >\n            <Sparkles\n              className={`w-3.5 h-3.5 ${isWarmuping ? \"animate-pulse\" : \"\"}`}\n            />\n            <span className=\"hidden xl:inline\">\n              {isWarmuping\n                ? t(\"common.loading\")\n                : selectedIds.size > 0\n                  ? t(\"accounts.warmup_selected\", { count: selectedIds.size })\n                  : t(\"accounts.warmup_all\", \"一键预热\")}\n            </span>\n          </button>\n\n          <label className=\"flex items-center gap-2 cursor-pointer select-none px-2 py-2 border border-transparent hover:bg-gray-100 dark:hover:bg-base-200 rounded-lg transition-colors\" title={t('accounts.show_all_quotas')}>\n            <span className=\"text-xs font-medium text-gray-600 dark:text-gray-300 hidden xl:inline\">\n              {t('accounts.show_all_quotas')}\n            </span>\n            <input\n              type=\"checkbox\"\n              className=\"toggle toggle-xs toggle-primary\"\n              checked={showAllQuotas}\n              onChange={toggleShowAllQuotas}\n            />\n          </label>\n          <div className=\"w-px h-4 bg-gray-200 dark:bg-gray-700 self-center mx-1 shrink-0\"></div>\n\n          <button\n            className=\"px-2.5 py-2 border border-gray-200 dark:border-base-300 text-gray-700 dark:text-gray-300 text-xs font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-base-200 transition-colors flex items-center gap-1.5\"\n            onClick={handleImportJson}\n            title={t(\"accounts.import_json\")}\n          >\n            <Upload className=\"w-3.5 h-3.5\" />\n            <span className=\"hidden lg:inline\">\n              {t(\"accounts.import_json\")}\n            </span>\n          </button>\n\n          <button\n            className=\"px-2.5 py-2 border border-gray-200 dark:border-base-300 text-gray-700 dark:text-gray-300 text-xs font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-base-200 transition-colors flex items-center gap-1.5\"\n            onClick={handleExport}\n            title={\n              selectedIds.size > 0\n                ? t(\"accounts.export_selected\", { count: selectedIds.size })\n                : t(\"common.export\")\n            }\n          >\n            <Download className=\"w-3.5 h-3.5\" />\n            <span className=\"hidden lg:inline\">\n              {selectedIds.size > 0\n                ? t(\"accounts.export_selected\", { count: selectedIds.size })\n                : t(\"common.export\")}\n            </span>\n          </button>\n        </div>\n      </div>\n\n      {/* 账号列表内容区域 */}\n      <div className=\"flex-1 min-h-0 relative\" ref={containerRef}>\n        {viewMode === \"list\" ? (\n          <div className=\"h-full bg-white dark:bg-base-100 rounded-2xl shadow-sm border border-gray-100 dark:border-base-200 flex flex-col overflow-hidden\">\n            <div className=\"flex-1 overflow-y-auto\">\n              <AccountTable\n                accounts={paginatedAccounts}\n                selectedIds={selectedIds}\n                refreshingIds={refreshingIds}\n                onToggleSelect={handleToggleSelect}\n                onToggleAll={handleToggleAll}\n                currentAccountId={currentAccount?.id || null}\n                switchingAccountId={switchingAccountId}\n                onSwitch={handleSwitch}\n                onRefresh={handleRefresh}\n                onViewDevice={handleViewDevice}\n                onViewDetails={handleViewDetails}\n                onExport={handleExportOne}\n                onDelete={handleDelete}\n                onToggleProxy={(id) =>\n                  handleToggleProxy(\n                    id,\n                    !!accounts.find((a) => a.id === id)?.proxy_disabled,\n                  )\n                }\n                onReorder={reorderAccounts}\n                onWarmup={handleWarmup}\n                onUpdateLabel={handleUpdateLabel}\n                onViewError={(id: string) => setErrorAccountId(id)}\n              />\n            </div>\n          </div>\n        ) : (\n          <div className=\"h-full overflow-y-auto\">\n            <AccountGrid\n              accounts={paginatedAccounts}\n              selectedIds={selectedIds}\n              refreshingIds={refreshingIds}\n              onToggleSelect={handleToggleSelect}\n              currentAccountId={currentAccount?.id || null}\n              switchingAccountId={switchingAccountId}\n              onSwitch={handleSwitch}\n              onRefresh={handleRefresh}\n              onViewDevice={handleViewDevice}\n              onViewDetails={handleViewDetails}\n              onExport={handleExportOne}\n              onDelete={handleDelete}\n              onToggleProxy={(id) =>\n                handleToggleProxy(\n                  id,\n                  !!accounts.find((a) => a.id === id)?.proxy_disabled,\n                )\n              }\n              onWarmup={handleWarmup}\n              onUpdateLabel={handleUpdateLabel}\n              onViewError={(id: string) => setErrorAccountId(id)}\n            />\n          </div>\n        )}\n      </div>\n\n      {/* 极简分页 - 无边框浮动样式 */}\n      {filteredAccounts.length > 0 && (\n        <div className=\"flex-none\">\n          <Pagination\n            currentPage={currentPage}\n            totalPages={Math.ceil(filteredAccounts.length / ITEMS_PER_PAGE)}\n            onPageChange={handlePageChange}\n            totalItems={filteredAccounts.length}\n            itemsPerPage={ITEMS_PER_PAGE}\n            onPageSizeChange={(newSize) => {\n              setLocalPageSize(newSize);\n              setCurrentPage(1); // 重置到第一页\n            }}\n            pageSizeOptions={[10, 20, 50, 100]}\n          />\n        </div>\n      )}\n\n      <AccountDetailsDialog\n        account={detailsAccount}\n        onClose={() => setDetailsAccount(null)}\n      />\n      <DeviceFingerprintDialog\n        account={deviceAccount}\n        onClose={() => setDeviceAccount(null)}\n      />\n\n      <ModalDialog\n        isOpen={!!deleteConfirmId || isBatchDelete}\n        title={\n          isBatchDelete\n            ? t(\"accounts.dialog.batch_delete_title\")\n            : t(\"accounts.dialog.delete_title\")\n        }\n        message={\n          isBatchDelete\n            ? t(\"accounts.dialog.batch_delete_msg\", { count: selectedIds.size })\n            : t(\"accounts.dialog.delete_msg\")\n        }\n        type=\"confirm\"\n        confirmText={t(\"common.delete\")}\n        isDestructive={true}\n        onConfirm={isBatchDelete ? executeBatchDelete : executeDelete}\n        onCancel={() => {\n          setDeleteConfirmId(null);\n          setIsBatchDelete(false);\n        }}\n      />\n\n      <ModalDialog\n        isOpen={isRefreshConfirmOpen}\n        title={\n          selectedIds.size > 0\n            ? t(\"accounts.dialog.batch_refresh_title\")\n            : t(\"accounts.dialog.refresh_title\")\n        }\n        message={\n          selectedIds.size > 0\n            ? t(\"accounts.dialog.batch_refresh_msg\", {\n              count: selectedIds.size,\n            })\n            : t(\"accounts.dialog.refresh_msg\")\n        }\n        type=\"confirm\"\n        confirmText={t(\"common.refresh\")}\n        isDestructive={false}\n        onConfirm={executeRefresh}\n        onCancel={() => setIsRefreshConfirmOpen(false)}\n      />\n\n      {toggleProxyConfirm && (\n        <ModalDialog\n          isOpen={!!toggleProxyConfirm}\n          onCancel={() => setToggleProxyConfirm(null)}\n          onConfirm={executeToggleProxy}\n          title={\n            toggleProxyConfirm.enable\n              ? t(\"accounts.dialog.enable_proxy_title\")\n              : t(\"accounts.dialog.disable_proxy_title\")\n          }\n          message={\n            toggleProxyConfirm.enable\n              ? t(\"accounts.dialog.enable_proxy_msg\")\n              : t(\"accounts.dialog.disable_proxy_msg\")\n          }\n        />\n      )}\n\n      <ModalDialog\n        isOpen={isWarmupConfirmOpen}\n        title={\n          selectedIds.size > 0\n            ? t(\"accounts.dialog.batch_warmup_title\", \"批量手动预热\")\n            : t(\"accounts.dialog.warmup_all_title\", \"全量手动预热\")\n        }\n        message={\n          selectedIds.size > 0\n            ? t(\n              \"accounts.dialog.batch_warmup_msg\",\n              \"确定要为选中的 {{count}} 个账号立即触发预热吗？\",\n              { count: selectedIds.size },\n            )\n            : t(\n              \"accounts.dialog.warmup_all_msg\",\n              \"确定要立即为所有符合条件的账号触发预热任务吗？这将向 Google 服务发送极小流量。\",\n            )\n        }\n        type=\"confirm\"\n        confirmText={t(\"accounts.warmup_now\", \"立即预热\")}\n        isDestructive={false}\n        onConfirm={handleWarmupAll}\n        onCancel={() => setIsWarmupConfirmOpen(false)}\n      />\n\n      {/* 账号详情弹窗 */}\n      <AccountDetailsDialog\n        account={detailsAccount}\n        onClose={() => setDetailsAccount(null)}\n      />\n\n      {/* 账号错误详情弹窗 */}\n      <AccountErrorDialog\n        account={accounts.find(a => a.id === errorAccountId) || null}\n        onClose={() => setErrorAccountId(null)}\n      />\n    </div>\n  );\n}\n\nexport default Accounts;\n"
  },
  {
    "path": "src/pages/ApiProxy.tsx",
    "content": "import { useState, useEffect, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { request as invoke } from '../utils/request';\nimport { isTauri } from '../utils/env';\nimport { copyToClipboard } from '../utils/clipboard';\nimport {\n    Power,\n    Copy,\n    RefreshCw,\n    CheckCircle,\n    Settings,\n    Target,\n    Plus,\n    Terminal,\n    Trash2,\n    BrainCircuit,\n    Puzzle,\n    Zap,\n    ArrowRight,\n    Sparkles,\n    Code,\n    Check,\n    X,\n    Edit2,\n    Save\n} from 'lucide-react';\nimport { AppConfig, ProxyConfig, StickySessionConfig, ExperimentalConfig } from '../types/config';\nimport HelpTooltip from '../components/common/HelpTooltip';\nimport ModalDialog from '../components/common/ModalDialog';\nimport { showToast } from '../components/common/ToastContainer';\nimport { cn } from '../utils/cn';\nimport { useProxyModels } from '../hooks/useProxyModels';\nimport GroupedSelect, { SelectOption } from '../components/common/GroupedSelect';\nimport { CliSyncCard } from '../components/proxy/CliSyncCard';\nimport DebouncedSlider from '../components/common/DebouncedSlider';\nimport { listAccounts } from '../services/accountService';\nimport CircuitBreaker from '../components/settings/CircuitBreaker';\nimport AdvancedThinking from '../components/settings/AdvancedThinking';\nimport { CircuitBreakerConfig } from '../types/config';\n\ninterface ProxyStatus {\n    running: boolean;\n    port: number;\n    base_url: string;\n    active_accounts: number;\n}\n\ninterface CustomPreset {\n    id: string;\n    name: string;\n    description: string;\n    mappings: Record<string, string>;\n}\n\n\ninterface CollapsibleCardProps {\n    title: string;\n    icon: React.ReactNode;\n    enabled?: boolean;\n    onToggle?: (enabled: boolean) => void;\n    children: React.ReactNode;\n    defaultExpanded?: boolean;\n    rightElement?: React.ReactNode;\n    allowInteractionWhenDisabled?: boolean;\n}\n\nfunction CollapsibleCard({\n    title,\n    icon,\n    enabled,\n    onToggle,\n    children,\n    defaultExpanded = false,\n    rightElement,\n    allowInteractionWhenDisabled = false,\n}: CollapsibleCardProps) {\n    const [isExpanded, setIsExpanded] = useState(defaultExpanded);\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"bg-white dark:bg-base-100 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700/50 overflow-hidden transition-all duration-200 hover:shadow-md\">\n            <div\n                className=\"px-5 py-4 flex items-center justify-between cursor-pointer bg-gray-50/50 dark:bg-gray-800/50 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors\"\n                onClick={(e) => {\n                    // Prevent toggle when clicking the switch or right element\n                    if ((e.target as HTMLElement).closest('.no-expand')) return;\n                    setIsExpanded(!isExpanded);\n                }}\n            >\n                <div className=\"flex items-center gap-3\">\n                    <div className=\"text-gray-500 dark:text-gray-400\">\n                        {icon}\n                    </div>\n                    <span className=\"font-medium text-sm text-gray-900 dark:text-gray-100\">\n                        {title}\n                    </span>\n                    {enabled !== undefined && (\n                        <div className={cn('text-xs px-2 py-0.5 rounded-full', enabled ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-600/50 dark:text-gray-300')}>\n                            {enabled ? t('common.enabled') : t('common.disabled')}\n                        </div>\n                    )}\n                </div>\n\n                <div className=\"flex items-center gap-4 no-expand\">\n                    {rightElement}\n\n                    {enabled !== undefined && onToggle && (\n                        <div className=\"flex items-center\" onClick={(e) => e.stopPropagation()}>\n                            <input\n                                type=\"checkbox\"\n                                className=\"toggle toggle-sm bg-gray-200 dark:bg-gray-700 border-gray-300 dark:border-gray-600 checked:bg-blue-500 checked:border-blue-500\"\n                                checked={enabled}\n                                onChange={(e) => onToggle(e.target.checked)}\n                            />\n                        </div>\n                    )}\n\n                    <button\n                        className={cn('p-1 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-all duration-200', isExpanded ? 'rotate-180' : '')}\n                    >\n                        <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                            <path d=\"m6 9 6 6 6-6\" />\n                        </svg>\n                    </button>\n                </div>\n            </div>\n\n            <div\n                className={`transition-all duration-300 ease-in-out border-t border-gray-100 dark:border-base-200 ${isExpanded ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0 overflow-hidden'\n                    }`}\n            >\n                <div className=\"p-5 relative\">\n                    {/* Overlay when disabled */}\n                    {enabled === false && !allowInteractionWhenDisabled && (\n                        <div className=\"absolute inset-0 bg-gray-100/40 dark:bg-black/30 z-10 cursor-not-allowed\" />\n                    )}\n                    <div className={enabled === false && !allowInteractionWhenDisabled ? 'opacity-60 pointer-events-none select-none' : ''}>\n                        {children}\n                    </div>\n                </div>\n\n            </div>\n        </div>\n    );\n}\n\nexport default function ApiProxy() {\n    const { t } = useTranslation();\n\n    const { models } = useProxyModels();\n\n    const [status, setStatus] = useState<ProxyStatus>({\n        running: false,\n        port: 0,\n        base_url: '',\n        active_accounts: 0,\n    });\n\n    const [appConfig, setAppConfig] = useState<AppConfig | null>(null);\n    const [configLoading, setConfigLoading] = useState(true);\n    const [configError, setConfigError] = useState<string | null>(null);\n    const [loading, setLoading] = useState(false);\n    const [copied, setCopied] = useState<string | null>(null);\n    const [selectedProtocol, setSelectedProtocol] = useState<'openai' | 'anthropic' | 'gemini'>('openai');\n    const [selectedModelId, setSelectedModelId] = useState('gemini-3-flash');\n    const [zaiAvailableModels, setZaiAvailableModels] = useState<string[]>([]);\n    const [zaiModelsLoading, setZaiModelsLoading] = useState(false);\n    const [, setZaiModelsError] = useState<string | null>(null);\n    const [zaiNewMappingFrom, setZaiNewMappingFrom] = useState('');\n    const [zaiNewMappingTo, setZaiNewMappingTo] = useState('');\n    const [customMappingValue, setCustomMappingValue] = useState(''); // 自定义映射表单的选中值\n    const [editingKey, setEditingKey] = useState<string | null>(null);\n    const [editingValue, setEditingValue] = useState<string>('');\n\n    // API Key editing states\n    const [isEditingApiKey, setIsEditingApiKey] = useState(false);\n    const [tempApiKey, setTempApiKey] = useState('');\n\n    const [isEditingAdminPassword, setIsEditingAdminPassword] = useState(false);\n    const [tempAdminPassword, setTempAdminPassword] = useState('');\n\n    // Preset selection state\n    const [selectedPreset, setSelectedPreset] = useState<string>('default');\n    const [customPresets, setCustomPresets] = useState<CustomPreset[]>([]);\n    const [isPresetManagerOpen, setIsPresetManagerOpen] = useState(false);\n    const [newPresetName, setNewPresetName] = useState('');\n\n    // Modal states\n\n    // Modal states\n    const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);\n    const [isRegenerateKeyConfirmOpen, setIsRegenerateKeyConfirmOpen] = useState(false);\n    const [isClearBindingsConfirmOpen, setIsClearBindingsConfirmOpen] = useState(false);\n    const [isClearRateLimitsConfirmOpen, setIsClearRateLimitsConfirmOpen] = useState(false);\n\n    // [FIX #820] Fixed account mode states\n    const [preferredAccountId, setPreferredAccountId] = useState<string | null>(null);\n    const [availableAccounts, setAvailableAccounts] = useState<Array<{ id: string; email: string }>>([]);\n\n    // Cloudflared (CF隧道) states\n    const [cfStatus, setCfStatus] = useState<{ installed: boolean; version?: string; running: boolean; url?: string; error?: string }>({\n        installed: false,\n        running: false,\n    });\n    const [cfLoading, setCfLoading] = useState(false);\n    const [cfMode, setCfMode] = useState<'quick' | 'auth'>('quick');\n    const [cfToken, setCfToken] = useState('');\n    const [cfUseHttp2, setCfUseHttp2] = useState(true); // 默认启用HTTP/2，更稳定\n\n    const zaiModelOptions = useMemo(() => {\n        const unique = new Set(zaiAvailableModels);\n        return Array.from(unique).sort();\n    }, [zaiAvailableModels]);\n\n    const zaiModelMapping = useMemo(() => {\n        return appConfig?.proxy.zai?.model_mapping || {};\n    }, [appConfig?.proxy.zai?.model_mapping]);\n\n\n    // 生成自定义映射表单的选项 (从 models 动态生成)\n    const customMappingOptions: SelectOption[] = useMemo(() => {\n        return models.map(model => ({\n            value: model.id,\n            label: `${model.id} (${model.name})`,\n            group: model.group || 'Other'\n        }));\n    }, [models]);\n\n    // 初始化加载\n    useEffect(() => {\n        loadConfig();\n        loadStatus();\n        loadAccounts();\n        loadPreferredAccount();\n        loadCfStatus();\n        loadCustomPresets();\n        const interval = setInterval(loadStatus, 3000);\n        const cfInterval = setInterval(loadCfStatus, 5000);\n        return () => {\n            clearInterval(interval);\n            clearInterval(cfInterval);\n        };\n    }, []);\n\n\n\n    // [FIX #820] Load available accounts for fixed account mode\n    const loadAccounts = async () => {\n        try {\n            const accounts = await listAccounts();\n            setAvailableAccounts(accounts.map(a => ({ id: a.id, email: a.email })));\n        } catch (error) {\n            console.error('Failed to load accounts:', error);\n        }\n    };\n\n    // Cloudflared: 检查状态\n    const loadCfStatus = async () => {\n        try {\n            const status = await invoke<typeof cfStatus>('cloudflared_get_status');\n            setCfStatus(status);\n        } catch (error) {\n            // 忽略错误，可能是manager未初始化\n        }\n    };\n\n    // Cloudflared: 安装\n    const handleCfInstall = async () => {\n        console.log('[Cloudflared] Install button clicked');\n        setCfLoading(true);\n        try {\n            console.log('[Cloudflared] Calling cloudflared_install...');\n            const status = await invoke<typeof cfStatus>('cloudflared_install');\n            console.log('[Cloudflared] Install result:', status);\n            setCfStatus(status);\n            showToast(t('proxy.cloudflared.install_success', { defaultValue: 'Cloudflared installed successfully' }), 'success');\n        } catch (error) {\n            console.error('[Cloudflared] Install error:', error);\n            showToast(String(error), 'error');\n        } finally {\n            setCfLoading(false);\n        }\n    };\n\n    // Cloudflared: 启动/停止\n    const handleCfToggle = async (enable: boolean) => {\n        if (enable && !status.running) {\n            showToast(\n                t('proxy.cloudflared.require_proxy_running', { defaultValue: 'Please start the local proxy service first' }),\n                'warning'\n            );\n            return;\n        }\n        setCfLoading(true);\n        try {\n            if (enable) {\n                if (!cfStatus.installed) {\n                    const installStatus = await invoke<typeof cfStatus>('cloudflared_install');\n                    setCfStatus(installStatus);\n                    if (!installStatus.installed) {\n                        throw new Error('Cloudflared install failed');\n                    }\n                    showToast(t('proxy.cloudflared.install_success', { defaultValue: 'Cloudflared installed successfully' }), 'success');\n                }\n\n                const config = {\n                    enabled: true,\n                    mode: cfMode,\n                    port: appConfig?.proxy.port || 8045,\n                    token: cfMode === 'auth' ? cfToken : null,\n                    use_http2: cfUseHttp2,\n                };\n                const status = await invoke<typeof cfStatus>('cloudflared_start', { config });\n                setCfStatus(status);\n                showToast(t('proxy.cloudflared.started', { defaultValue: 'Tunnel started' }), 'success');\n\n                // 持久化“启用”状态\n                if (appConfig) {\n                    const newConfig = {\n                        ...appConfig,\n                        cloudflared: {\n                            ...appConfig.cloudflared,\n                            enabled: true,\n                            mode: cfMode,\n                            token: cfToken,\n                            use_http2: cfUseHttp2,\n                            port: appConfig.proxy.port || 8045\n                        }\n                    };\n                    saveConfig(newConfig);\n                }\n            } else {\n                const status = await invoke<typeof cfStatus>('cloudflared_stop');\n                setCfStatus(status);\n                showToast(t('proxy.cloudflared.stopped', { defaultValue: 'Tunnel stopped' }), 'success');\n\n                // 持久化“禁用”状态\n                if (appConfig) {\n                    const newConfig = {\n                        ...appConfig,\n                        cloudflared: {\n                            ...appConfig.cloudflared,\n                            enabled: false\n                        }\n                    };\n                    saveConfig(newConfig);\n                }\n            }\n        } catch (error) {\n            showToast(String(error), 'error');\n        } finally {\n            setCfLoading(false);\n        }\n    };\n\n    // Cloudflared: 复制URL\n    const handleCfCopyUrl = async () => {\n        if (cfStatus.url) {\n            const success = await copyToClipboard(cfStatus.url);\n            if (success) {\n                setCopied('cf-url');\n                setTimeout(() => setCopied(null), 2000);\n            }\n        }\n    };\n\n    // [FIX #820] Load current preferred account\n    const loadPreferredAccount = async () => {\n        try {\n            const prefId = await invoke<string | null>('get_preferred_account');\n            setPreferredAccountId(prefId);\n        } catch (error) {\n            // Service not running, ignore\n        }\n    };\n\n    // [FIX #820] Set preferred account\n    const handleSetPreferredAccount = async (accountId: string | null) => {\n        try {\n            const wasEnabled = preferredAccountId !== null;\n            await invoke('set_preferred_account', { accountId });\n            setPreferredAccountId(accountId);\n\n            // Determine appropriate message\n            let message: string;\n            if (accountId === null) {\n                message = t('proxy.config.scheduling.round_robin_set', { defaultValue: 'Round-robin mode enabled' });\n            } else if (wasEnabled) {\n                // Changed account while already in fixed mode\n                const account = availableAccounts.find(a => a.id === accountId);\n                message = t('proxy.config.scheduling.account_changed', {\n                    defaultValue: `Switched to ${account?.email || accountId}`,\n                    email: account?.email || accountId\n                });\n            } else {\n                // Just enabled fixed mode\n                message = t('proxy.config.scheduling.fixed_account_set', { defaultValue: 'Fixed account mode enabled' });\n            }\n\n            showToast(message, 'success');\n        } catch (error) {\n            showToast(String(error), 'error');\n        }\n    };\n\n    const loadConfig = async () => {\n        setConfigLoading(true);\n        setConfigError(null);\n        try {\n            const config = await invoke<AppConfig>('load_config');\n            setAppConfig(config);\n\n            // 恢复 Cloudflared 持久化状态\n            if (config.cloudflared) {\n                setCfMode(config.cloudflared.mode || 'quick');\n                setCfToken(config.cloudflared.token || '');\n                setCfUseHttp2(config.cloudflared.use_http2 !== false); // 默认开启 HTTP/2\n            }\n\n            // 恢复 Cloudflared 状态并实现持久化同步\n            if (config.cloudflared) {\n                setCfMode(config.cloudflared.mode || 'quick');\n                setCfToken(config.cloudflared.token || '');\n                setCfUseHttp2(config.cloudflared.use_http2 !== false); // 默认 true\n            }\n        } catch (error) {\n            console.error('加载配置失败:', error);\n            setConfigError(String(error));\n        } finally {\n            setConfigLoading(false);\n        }\n    };\n\n    const loadStatus = async () => {\n        try {\n            const s = await invoke<ProxyStatus>('get_proxy_status');\n            // 如果后端返回 starting 或 busy，则在 UI 上表现为加载中\n            if (s.base_url === 'starting' || s.base_url === 'busy') {\n                // 如果当前已经是运行状态，不要被覆盖为 false\n                setStatus(prev => ({ ...s, running: prev.running }));\n            } else {\n                setStatus(s);\n            }\n        } catch (error) {\n            console.error('获取状态失败:', error);\n        }\n    };\n\n\n    const saveConfig = async (newConfig: AppConfig) => {\n        // 1. 立即更新 UI 状态，确保流畅\n        setAppConfig(newConfig);\n        try {\n            await invoke('save_config', { config: newConfig });\n        } catch (error) {\n            console.error('保存配置失败:', error);\n            showToast(`${t('common.error')}: ${error}`, 'error');\n        }\n    };\n\n    // 专门处理模型映射的热更新 (全量)\n    const handleMappingUpdate = async (type: 'custom', key: string, value: string) => {\n        if (!appConfig) return;\n\n        console.log('[DEBUG] handleMappingUpdate called:', { type, key, value });\n\n        const newConfig = { ...appConfig.proxy };\n        newConfig.custom_mapping = { ...(newConfig.custom_mapping || {}), [key]: value };\n\n        try {\n            await invoke('update_model_mapping', { config: newConfig });\n            setAppConfig({ ...appConfig, proxy: newConfig });\n            console.log('[DEBUG] Mapping updated successfully');\n            showToast(t('common.saved'), 'success');\n        } catch (error) {\n            console.error('Failed to update mapping:', error);\n            showToast(`${t('common.error')}: ${error}`, 'error');\n        }\n    };\n\n    const handleResetMapping = () => {\n        if (!appConfig) return;\n        setIsResetConfirmOpen(true);\n    };\n\n    const executeResetMapping = async () => {\n        if (!appConfig) return;\n        setIsResetConfirmOpen(false);\n\n        // 恢复到默认映射值 (空映射)\n        const newConfig = {\n            ...appConfig.proxy,\n            custom_mapping: {}\n        };\n\n        try {\n            await invoke('update_model_mapping', { config: newConfig });\n            setAppConfig({ ...appConfig, proxy: newConfig });\n            showToast(t('common.success'), 'success');\n        } catch (error) {\n            console.error('Failed to reset mapping:', error);\n            showToast(`${t('common.error')}: ${error}`, 'error');\n        }\n    };\n\n\n    // 定义多个预设方案\n    const defaultPresets = useMemo(() => [\n        {\n            id: 'default',\n            name: t('proxy.router.preset_default'),\n            description: t('proxy.router.preset_default_desc'),\n            mappings: {\n                \"gpt-4*\": \"gemini-3.1-pro-high\",\n                \"gpt-4o*\": \"gemini-3-flash\",\n                \"gpt-3.5*\": \"gemini-2.5-flash\",\n                \"o1-*\": \"gemini-3.1-pro-high\",\n                \"o3-*\": \"gemini-3.1-pro-high\",\n                \"claude-3-5-sonnet-*\": \"claude-sonnet-4-6\",\n                \"claude-3-opus-*\": \"claude-opus-4-6-thinking\",\n                \"claude-opus-4-6*\": \"claude-opus-4-6-thinking\",\n                \"claude-haiku-*\": \"gemini-2.5-flash\",\n                \"claude-3-haiku-*\": \"gemini-2.5-flash\",\n            }\n        },\n        {\n            id: 'performance',\n            name: t('proxy.router.preset_performance'),\n            description: t('proxy.router.preset_performance_desc'),\n            mappings: {\n                \"gpt-4*\": \"claude-opus-4-6-thinking\",\n                \"gpt-4o*\": \"claude-sonnet-4-6\",\n                \"gpt-3.5*\": \"gemini-3-flash\",\n                \"o1-*\": \"claude-opus-4-6-thinking\",\n                \"o3-*\": \"claude-opus-4-6-thinking\",\n                \"claude-3-5-sonnet-*\": \"claude-sonnet-4-6\",\n                \"claude-3-opus-*\": \"claude-opus-4-6-thinking\",\n                \"claude-opus-4-6*\": \"claude-opus-4-6-thinking\",\n                \"claude-haiku-*\": \"claude-sonnet-4-6\",\n                \"claude-3-haiku-*\": \"claude-sonnet-4-6\",\n            }\n        },\n        {\n            id: 'cost-effective',\n            name: t('proxy.router.preset_cost'),\n            description: t('proxy.router.preset_cost_desc'),\n            mappings: {\n                \"gpt-4*\": \"gemini-3-flash\",\n                \"gpt-4o*\": \"gemini-2.5-flash\",\n                \"gpt-3.5*\": \"gemini-2.5-flash\",\n                \"o1-*\": \"gemini-3-flash\",\n                \"o3-*\": \"gemini-3-flash\",\n                \"claude-3-5-sonnet-*\": \"gemini-3-flash\",\n                \"claude-3-opus-*\": \"gemini-3-flash\",\n                \"claude-opus-4-*\": \"gemini-3-flash\", // Cost-effective: map all opus 4 to flash\n                \"claude-haiku-*\": \"gemini-2.5-flash\",\n                \"claude-3-haiku-*\": \"gemini-2.5-flash\",\n            }\n        },\n        {\n            id: 'balanced',\n            name: t('proxy.router.preset_balanced'),\n            description: t('proxy.router.preset_balanced_desc'),\n            mappings: {\n                \"gpt-4*\": \"gemini-3.1-pro-high\",\n                \"gpt-4o*\": \"gemini-3-flash\",\n                \"gpt-3.5*\": \"gemini-2.5-flash\",\n                \"o1-*\": \"claude-sonnet-4-6\",\n                \"o3-*\": \"claude-sonnet-4-6\",\n                \"claude-3-5-sonnet-*\": \"claude-sonnet-4-6\",\n                \"claude-3-opus-*\": \"gemini-3.1-pro-high\",\n                \"claude-opus-4-5*\": \"gemini-3.1-pro-high\",\n                \"claude-opus-4-6*\": \"claude-opus-4-6-thinking\", // Balanced: Keep 4.6 as itself (or map to high?) Let's map to itself for now to utilize header\n                \"claude-haiku-*\": \"gemini-2.5-flash\",\n                \"claude-3-haiku-*\": \"gemini-2.5-flash\",\n            }\n        },\n    ], [t]);\n\n    const presetOptions = useMemo(() => {\n        return [...defaultPresets, ...customPresets];\n    }, [defaultPresets, customPresets]);\n\n    // Custom Presets Logic\n    const loadCustomPresets = () => {\n        try {\n            const saved = localStorage.getItem('antigravity_custom_presets');\n            if (saved) {\n                setCustomPresets(JSON.parse(saved));\n            }\n        } catch (error) {\n            console.error('Failed to load custom presets:', error);\n        }\n    };\n\n    const saveCustomPresetsToStorage = (presets: CustomPreset[]) => {\n        try {\n            localStorage.setItem('antigravity_custom_presets', JSON.stringify(presets));\n            setCustomPresets(presets);\n        } catch (error) {\n            console.error('Failed to save custom presets:', error);\n            showToast('Failed to save preset', 'error');\n        }\n    };\n\n    const handleSaveCurrentAsPreset = () => {\n        if (!appConfig?.proxy.custom_mapping || Object.keys(appConfig.proxy.custom_mapping).length === 0) {\n            showToast(t('proxy.router.no_mapping_to_save'), 'warning');\n            return;\n        }\n        if (!newPresetName.trim()) {\n            showToast(t('proxy.router.preset_name_required'), 'warning');\n            return;\n        }\n\n        const newPreset: CustomPreset = {\n            id: `custom_${Date.now()}`,\n            name: newPresetName,\n            description: t('proxy.router.custom_preset_desc'),\n            mappings: { ...appConfig.proxy.custom_mapping }\n        };\n\n        const updatedPresets = [...customPresets, newPreset];\n        saveCustomPresetsToStorage(updatedPresets);\n        setNewPresetName('');\n        setIsPresetManagerOpen(false);\n        showToast(t('proxy.router.preset_saved', { defaultValue: 'Preset saved successfully' }), 'success');\n        // Auto select the new preset\n        setSelectedPreset(newPreset.id);\n    };\n\n    const handleDeletePreset = (id: string) => {\n        const updatedPresets = customPresets.filter(p => p.id !== id);\n        saveCustomPresetsToStorage(updatedPresets);\n        if (selectedPreset === id) {\n            setSelectedPreset('default');\n        }\n    };\n\n    // 应用预设映射 (通配符)\n    const handleApplyPresets = async () => {\n        if (!appConfig) return;\n\n        const selectedPresetData = presetOptions.find(p => p.id === selectedPreset);\n        if (!selectedPresetData) return;\n\n        // 构造新配置\n        const newConfig = {\n            ...appConfig.proxy,\n            // 策略:覆盖同名 key,保留其他自定义 key\n            // [FIX #1738] Type assertion to ensure Record<string, string> compatibility\n            custom_mapping: { ...appConfig.proxy.custom_mapping, ...selectedPresetData.mappings } as Record<string, string>\n        };\n\n        // 备份旧配置用于回滚\n        const oldConfig = { ...appConfig };\n\n        try {\n            // 1. 乐观更新：立即更新 UI\n            setAppConfig({ ...appConfig, proxy: newConfig });\n            showToast(t('proxy.router.presets_applied') + ` (${selectedPresetData.name})`, 'success');\n\n            // 2. 后台异步保存\n            await invoke('update_model_mapping', { config: newConfig });\n\n            // 3. 重新加载配置以确保一致性\n            await loadConfig();\n        } catch (error) {\n            console.error('Failed to apply presets:', error);\n            // 3. 失败回滚\n            setAppConfig(oldConfig);\n            showToast(`${t('common.error')}: ${error}`, 'error');\n        }\n    };\n\n    const handleRemoveCustomMapping = async (key: string) => {\n        if (!appConfig || !appConfig.proxy.custom_mapping) return;\n        const newCustom = { ...appConfig.proxy.custom_mapping };\n        delete newCustom[key];\n        const newConfig = { ...appConfig.proxy, custom_mapping: newCustom };\n        try {\n            await invoke('update_model_mapping', { config: newConfig });\n            setAppConfig({ ...appConfig, proxy: newConfig });\n        } catch (error) {\n            console.error('Failed to remove custom mapping:', error);\n        }\n    };\n\n    const updateProxyConfig = (updates: Partial<ProxyConfig>) => {\n        if (!appConfig) return;\n        const newConfig = {\n            ...appConfig,\n            proxy: {\n                ...appConfig.proxy,\n                ...updates\n            }\n        };\n        saveConfig(newConfig);\n    };\n\n    const updateSchedulingConfig = (updates: Partial<StickySessionConfig>) => {\n        if (!appConfig) return;\n        const currentScheduling = appConfig.proxy.scheduling || { mode: 'Balance', max_wait_seconds: 60 };\n        const newScheduling = { ...currentScheduling, ...updates };\n\n        const newAppConfig = {\n            ...appConfig,\n            proxy: {\n                ...appConfig.proxy,\n                scheduling: newScheduling\n            }\n        };\n        saveConfig(newAppConfig);\n    };\n\n    const updateExperimentalConfig = (updates: Partial<ExperimentalConfig>) => {\n        if (!appConfig) return;\n        const newConfig = {\n            ...appConfig,\n            proxy: {\n                ...appConfig.proxy,\n                experimental: {\n                    ...(appConfig.proxy.experimental || {\n                        enable_usage_scaling: true,\n                        context_compression_threshold_l1: 0.4,\n                        context_compression_threshold_l2: 0.55,\n                        context_compression_threshold_l3: 0.7\n                    }),\n                    ...updates\n                }\n            }\n        };\n        saveConfig(newConfig);\n    };\n\n    const updateCircuitBreakerConfig = (newBreakerConfig: CircuitBreakerConfig) => {\n        if (!appConfig) return;\n        const newConfig = {\n            ...appConfig,\n            circuit_breaker: newBreakerConfig\n        };\n        saveConfig(newConfig);\n    };\n\n    const handleClearSessionBindings = () => {\n        setIsClearBindingsConfirmOpen(true);\n    };\n\n    const executeClearSessionBindings = async () => {\n        setIsClearBindingsConfirmOpen(false);\n        try {\n            await invoke('clear_proxy_session_bindings');\n            showToast(t('common.success'), 'success');\n        } catch (error) {\n            console.error('Failed to clear session bindings:', error);\n            showToast(`${t('common.error')}: ${error}`, 'error');\n        }\n    };\n\n    const handleClearRateLimits = () => {\n        setIsClearRateLimitsConfirmOpen(true);\n    };\n\n    const executeClearRateLimits = async () => {\n        setIsClearRateLimitsConfirmOpen(false);\n        try {\n            await invoke('clear_all_proxy_rate_limits');\n            showToast(t('common.success'), 'success');\n        } catch (error) {\n            console.error('Failed to clear rate limits:', error);\n            showToast(`${t('common.error')}: ${error}`, 'error');\n        }\n    };\n\n    const refreshZaiModels = async () => {\n        if (!appConfig?.proxy.zai) return;\n        setZaiModelsLoading(true);\n        setZaiModelsError(null);\n        try {\n            const models = await invoke<string[]>('fetch_zai_models', {\n                zai: appConfig.proxy.zai,\n                upstreamProxy: appConfig.proxy.upstream_proxy,\n                requestTimeout: appConfig.proxy.request_timeout,\n            });\n            setZaiAvailableModels(models);\n        } catch (error: any) {\n            console.error('Failed to fetch z.ai models:', error);\n            setZaiModelsError(error.toString());\n        } finally {\n            setZaiModelsLoading(false);\n        }\n    };\n\n    const updateZaiDefaultModels = (updates: Partial<NonNullable<ProxyConfig['zai']>['models']>) => {\n        if (!appConfig?.proxy.zai) return;\n        const newConfig = {\n            ...appConfig,\n            proxy: {\n                ...appConfig.proxy,\n                zai: {\n                    ...appConfig.proxy.zai,\n                    models: { ...appConfig.proxy.zai.models, ...updates }\n                }\n            }\n        };\n        saveConfig(newConfig);\n    };\n\n    const upsertZaiModelMapping = (from: string, to: string) => {\n        if (!appConfig?.proxy.zai) return;\n        const currentMapping = appConfig.proxy.zai.model_mapping || {};\n        const newMapping = { ...currentMapping, [from]: to };\n\n        const newConfig = {\n            ...appConfig,\n            proxy: {\n                ...appConfig.proxy,\n                zai: {\n                    ...appConfig.proxy.zai,\n                    model_mapping: newMapping\n                }\n            }\n        };\n        saveConfig(newConfig);\n    };\n\n    const removeZaiModelMapping = (from: string) => {\n        if (!appConfig?.proxy.zai) return;\n        const currentMapping = appConfig.proxy.zai.model_mapping || {};\n        const newMapping = { ...currentMapping };\n        delete newMapping[from];\n\n        const newConfig = {\n            ...appConfig,\n            proxy: {\n                ...appConfig.proxy,\n                zai: {\n                    ...appConfig.proxy.zai,\n                    model_mapping: newMapping\n                }\n            }\n        };\n        saveConfig(newConfig);\n    };\n\n    const updateZaiGeneralConfig = (updates: Partial<NonNullable<ProxyConfig['zai']>>) => {\n        if (!appConfig?.proxy.zai) return;\n        const newConfig = {\n            ...appConfig,\n            proxy: {\n                ...appConfig.proxy,\n                zai: {\n                    ...appConfig.proxy.zai,\n                    ...updates\n                }\n            }\n        };\n        saveConfig(newConfig);\n    };\n\n    const handleToggle = async () => {\n        if (!appConfig) return;\n        setLoading(true);\n        try {\n            if (status.running) {\n                await invoke('stop_proxy_service');\n            } else {\n                // 使用当前的 appConfig.proxy 启动\n                await invoke('start_proxy_service', { config: appConfig.proxy });\n            }\n            await loadStatus();\n        } catch (error: any) {\n            showToast(t('proxy.dialog.operate_failed', { error: error.toString() }), 'error');\n        } finally {\n            setLoading(false);\n        }\n    };\n\n    const handleGenerateApiKey = () => {\n        setIsRegenerateKeyConfirmOpen(true);\n    };\n\n    const executeGenerateApiKey = async () => {\n        setIsRegenerateKeyConfirmOpen(false);\n        try {\n            const newKey = await invoke<string>('generate_api_key');\n            updateProxyConfig({ api_key: newKey });\n            showToast(t('common.success'), 'success');\n        } catch (error: any) {\n            console.error('生成 API Key 失败:', error);\n            showToast(t('proxy.dialog.operate_failed', { error: error.toString() }), 'error');\n        }\n    };\n\n    const copyToClipboardHandler = (text: string, label: string) => {\n        copyToClipboard(text).then((success) => {\n            if (success) {\n                setCopied(label);\n                setTimeout(() => setCopied(null), 2000);\n            }\n        });\n    };\n\n    // API Key editing functions\n    const validateApiKey = (key: string): boolean => {\n        // Must start with 'sk-' and be at least 10 characters long\n        return key.startsWith('sk-') && key.length >= 10;\n    };\n\n    const handleEditApiKey = () => {\n        setTempApiKey(appConfig?.proxy.api_key || '');\n        setIsEditingApiKey(true);\n    };\n\n    const handleSaveApiKey = () => {\n        if (!validateApiKey(tempApiKey)) {\n            showToast(t('proxy.config.api_key_invalid'), 'error');\n            return;\n        }\n        updateProxyConfig({ api_key: tempApiKey });\n        setIsEditingApiKey(false);\n        showToast(t('proxy.config.api_key_updated'), 'success');\n    };\n\n    const handleCancelEditApiKey = () => {\n        setTempApiKey('');\n        setIsEditingApiKey(false);\n    };\n\n    // Admin Password editing functions\n    const handleEditAdminPassword = () => {\n        setTempAdminPassword(appConfig?.proxy.admin_password || '');\n        setIsEditingAdminPassword(true);\n    };\n\n    const handleSaveAdminPassword = () => {\n        // Validation: can be empty (meaning fallback to api_key) or at least 4 chars\n        if (tempAdminPassword && tempAdminPassword.length < 4) {\n            showToast(t('proxy.config.admin_password_short', { defaultValue: 'Password is too short (min 4 chars)' }), 'error');\n            return;\n        }\n        updateProxyConfig({ admin_password: tempAdminPassword || undefined });\n        setIsEditingAdminPassword(false);\n        showToast(t('proxy.config.admin_password_updated', { defaultValue: 'Web UI password updated' }), 'success');\n    };\n\n    const handleCancelEditAdminPassword = () => {\n        setTempAdminPassword('');\n        setIsEditingAdminPassword(false);\n    };\n\n\n    const getPythonExample = (modelId: string) => {\n        const port = status.running ? status.port : (appConfig?.proxy.port || 8045);\n        // 推荐使用 127.0.0.1 以避免部分环境 IPv6 解析延迟问题\n        const baseUrl = `http://127.0.0.1:${port}/v1`;\n        const apiKey = appConfig?.proxy.api_key || 'YOUR_API_KEY';\n\n        // 1. Anthropic Protocol\n        if (selectedProtocol === 'anthropic') {\n            return `from anthropic import Anthropic\n\nclient = Anthropic(\n    # 推荐使用 127.0.0.1\n    base_url=\"${`http://127.0.0.1:${port}`}\",\n    api_key=\"${apiKey}\"\n)\n\n# 注意: Antigravity 支持使用 Anthropic SDK 调用任意模型\nresponse = client.messages.create(\n    model=\"${modelId}\",\n    max_tokens=1024,\n    messages=[{\"role\": \"user\", \"content\": \"Hello\"}]\n)\n\nprint(response.content[0].text)`;\n        }\n\n        // 2. Gemini Protocol (Native)\n        if (selectedProtocol === 'gemini') {\n            const rawBaseUrl = `http://127.0.0.1:${port}`;\n            return `# 需要安装: pip install google-generativeai\nimport google.generativeai as genai\n\n# 使用 Antigravity 代理地址 (推荐 127.0.0.1)\ngenai.configure(\n    api_key=\"${apiKey}\",\n    transport='rest',\n    client_options={'api_endpoint': '${rawBaseUrl}'}\n)\n\nmodel = genai.GenerativeModel('${modelId}')\nresponse = model.generate_content(\"Hello\")\nprint(response.text)`;\n        }\n\n        // 3. OpenAI Protocol\n        if (modelId.startsWith('gemini-3-pro-image')) {\n            return `from openai import OpenAI\n\nclient = OpenAI(\n    base_url=\"${baseUrl}\",\n    api_key=\"${apiKey}\"\n)\n\nresponse = client.chat.completions.create(\n    model=\"${modelId}\",\n    # 方式 1: 使用 size 参数 (推荐)\n    # 支持: \"1024x1024\" (1:1), \"1280x720\" (16:9), \"720x1280\" (9:16), \"1216x896\" (4:3)\n    extra_body={ \"size\": \"1024x1024\" },\n    \n    # 方式 2: 使用模型后缀\n    # 例如: gemini-3-pro-image-16-9, gemini-3-pro-image-4-3\n    # model=\"gemini-3-pro-image-16-9\",\n    messages=[{\n        \"role\": \"user\",\n        \"content\": \"Draw a futuristic city\"\n    }]\n)\n\nprint(response.choices[0].message.content)`;\n        }\n\n        return `from openai import OpenAI\n\nclient = OpenAI(\n    base_url=\"${baseUrl}\",\n    api_key=\"${apiKey}\"\n)\n\nresponse = client.chat.completions.create(\n    model=\"${modelId}\",\n    messages=[{\"role\": \"user\", \"content\": \"Hello\"}]\n)\n\nprint(response.choices[0].message.content)`;\n    };\n\n    // 在 filter 逻辑中，当选择 openai 协议时，允许显示所有模型\n    const filteredModels = models.filter(model => {\n        if (selectedProtocol === 'openai') {\n            return true;\n        }\n        // Anthropic 协议下隐藏不支持的图片模型\n        if (selectedProtocol === 'anthropic') {\n            return !model.id.includes('image');\n        }\n        return true;\n    });\n\n    return (\n        <div className=\"h-full w-full overflow-y-auto overflow-x-hidden\">\n            <div className=\"p-5 space-y-4 max-w-7xl mx-auto\">\n\n                {/* Loading State */}\n                {configLoading && (\n                    <div className=\"flex items-center justify-center py-20\">\n                        <div className=\"flex flex-col items-center gap-4\">\n                            <RefreshCw size={32} className=\"animate-spin text-blue-500\" />\n                            <span className=\"text-sm text-gray-500 dark:text-gray-400\">\n                                {t('common.loading')}\n                            </span>\n                        </div>\n                    </div>\n                )}\n\n                {/* Error State */}\n                {!configLoading && configError && (\n                    <div className=\"flex items-center justify-center py-20\">\n                        <div className=\"flex flex-col items-center gap-4 text-center\">\n                            <div className=\"w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center\">\n                                <Settings size={32} className=\"text-red-500\" />\n                            </div>\n                            <div className=\"space-y-2\">\n                                <h3 className=\"text-lg font-semibold text-gray-900 dark:text-gray-100\">\n                                    {t('proxy.error.load_failed')}\n                                </h3>\n                                <p className=\"text-sm text-gray-500 dark:text-gray-400 max-w-md\">\n                                    {configError}\n                                </p>\n                            </div>\n                            <button\n                                onClick={loadConfig}\n                                className=\"px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium flex items-center gap-2 transition-colors\"\n                            >\n                                <RefreshCw size={16} />\n                                {t('common.retry')}\n                            </button>\n                        </div>\n                    </div>\n                )}\n\n                {/* 配置区 */}\n                {!configLoading && !configError && appConfig && (\n                    <div className=\"bg-white dark:bg-base-100 rounded-xl shadow-sm border border-gray-100 dark:border-base-200\">\n                        <div className=\"px-4 py-2.5 border-b border-gray-100 dark:border-base-200 flex items-center justify-between\">\n                            <div className=\"flex items-center gap-4\">\n                                <h2 className=\"text-base font-semibold flex items-center gap-2 text-gray-900 dark:text-base-content\">\n                                    <Settings size={18} />\n                                    {t('proxy.config.title')}\n                                </h2>\n                                {/* 状态指示器 */}\n                                <div className=\"flex items-center gap-2 pl-4 border-l border-gray-200 dark:border-base-300\">\n                                    <div className={`w-2 h-2 rounded-full ${status.running ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}`} />\n                                    <span className={`text-xs font-medium ${status.running ? 'text-green-600' : 'text-gray-500'}`}>\n                                        {status.running\n                                            ? `${t('proxy.status.running')} (${status.active_accounts} ${t('common.accounts')})`\n                                            : t('proxy.status.stopped')}\n                                    </span>\n                                </div>\n                            </div>\n\n                            {/* 控制按钮 */}\n                            <div className=\"flex items-center gap-2\">\n                                <button\n                                    onClick={handleToggle}\n                                    disabled={loading || !appConfig}\n                                    className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-2 ${status.running\n                                        ? 'bg-red-50 to-red-600 text-red-600 hover:bg-red-100 border border-red-200'\n                                        : 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm shadow-blue-500/30'\n                                        } ${(loading || !appConfig) ? 'opacity-50 cursor-not-allowed' : ''}`}\n                                >\n                                    <Power size={14} />\n                                    {loading ? t('proxy.status.processing') : (status.running ? t('proxy.action.stop') : t('proxy.action.start'))}\n                                </button>\n                            </div>\n                        </div>\n                        <div className=\"p-3 space-y-3\">\n                            {/* 监听端口、超时和自启动 */}\n                            <div className=\"grid grid-cols-1 md:grid-cols-3 gap-3\">\n                                <div>\n                                    <label className=\"block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1\">\n                                        <span className=\"inline-flex items-center gap-1\">\n                                            {t('proxy.config.port')}\n                                            <HelpTooltip\n                                                text={t('proxy.config.port_tooltip')}\n                                                ariaLabel={t('proxy.config.port')}\n                                                placement=\"right\"\n                                            />\n                                        </span>\n                                    </label>\n                                    <input\n                                        type=\"number\"\n                                        value={appConfig.proxy.port}\n                                        onChange={(e) => updateProxyConfig({ port: parseInt(e.target.value) })}\n                                        min={8000}\n                                        max={65535}\n                                        disabled={status.running}\n                                        className=\"w-full px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 text-xs text-gray-900 dark:text-base-content focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed\"\n                                    />\n                                    <p className=\"mt-0.5 text-[10px] text-gray-500 dark:text-gray-400\">\n                                        {t('proxy.config.port_hint')}\n                                    </p>\n                                </div>\n                                <div>\n                                    <label className=\"block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1\">\n                                        <span className=\"inline-flex items-center gap-1\">\n                                            {t('proxy.config.request_timeout')}\n                                            <HelpTooltip\n                                                text={t('proxy.config.request_timeout_tooltip')}\n                                                ariaLabel={t('proxy.config.request_timeout')}\n                                                placement=\"top\"\n                                            />\n                                        </span>\n                                    </label>\n                                    <input\n                                        type=\"number\"\n                                        value={appConfig.proxy.request_timeout || 120}\n                                        onChange={(e) => {\n                                            const value = parseInt(e.target.value);\n                                            const timeout = Math.max(30, Math.min(7200, value));\n                                            updateProxyConfig({ request_timeout: timeout });\n                                        }}\n                                        min={30}\n                                        max={7200}\n                                        disabled={status.running}\n                                        className=\"w-full px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 text-xs text-gray-900 dark:text-base-content focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed\"\n                                    />\n                                    <p className=\"mt-0.5 text-[10px] text-gray-500 dark:text-gray-400\">\n                                        {t('proxy.config.request_timeout_hint')}\n                                    </p>\n                                </div>\n                                <div className=\"flex items-center\">\n                                    <label className=\"flex items-center cursor-pointer gap-3\">\n                                        <input\n                                            type=\"checkbox\"\n                                            className=\"toggle toggle-sm bg-gray-200 dark:bg-gray-700 border-gray-300 dark:border-gray-600 checked:bg-blue-500 checked:border-blue-500 disabled:opacity-50 disabled:bg-gray-100 dark:disabled:bg-gray-800\"\n                                            checked={appConfig.proxy.auto_start}\n                                            onChange={(e) => updateProxyConfig({ auto_start: e.target.checked })}\n                                        />\n                                        <span className=\"text-xs font-medium text-gray-900 dark:text-base-content inline-flex items-center gap-1\">\n                                            {t('proxy.config.auto_start')}\n                                            <HelpTooltip\n                                                text={t('proxy.config.auto_start_tooltip')}\n                                                ariaLabel={t('proxy.config.auto_start')}\n                                                placement=\"right\"\n                                            />\n                                        </span>\n                                    </label>\n                                </div>\n                            </div>\n\n\n                            {/* 局域网访问 & 访问授权 - 合并到同一行 */}\n                            <div className=\"border-t border-gray-200 dark:border-base-300 pt-3 mt-3\">\n                                <div className=\"grid grid-cols-1 lg:grid-cols-2 gap-4\">\n                                    {/* 允许局域网访问 */}\n                                    <div className=\"space-y-2\">\n                                        <div className=\"flex items-center justify-between\">\n                                            <span className=\"text-xs font-medium text-gray-700 dark:text-gray-300 inline-flex items-center gap-1\">\n                                                {t('proxy.config.allow_lan_access')}\n                                                <HelpTooltip\n                                                    text={t('proxy.config.allow_lan_access_tooltip')}\n                                                    ariaLabel={t('proxy.config.allow_lan_access')}\n                                                    placement=\"right\"\n                                                />\n                                            </span>\n                                            <input\n                                                type=\"checkbox\"\n                                                className=\"toggle toggle-sm bg-gray-200 dark:bg-gray-700 border-gray-300 dark:border-gray-600 checked:bg-blue-500 checked:border-blue-500\"\n                                                checked={appConfig.proxy.allow_lan_access || false}\n                                                onChange={(e) => updateProxyConfig({ allow_lan_access: e.target.checked })}\n                                            />\n                                        </div>\n                                        <p className=\"text-[10px] text-gray-500 dark:text-gray-400\">\n                                            {(appConfig.proxy.allow_lan_access || false)\n                                                ? t('proxy.config.allow_lan_access_hint_enabled')\n                                                : t('proxy.config.allow_lan_access_hint_disabled')}\n                                        </p>\n                                        {(appConfig.proxy.allow_lan_access || false) && (\n                                            <p className=\"text-[10px] text-amber-600 dark:text-amber-500\">\n                                                {t('proxy.config.allow_lan_access_warning')}\n                                            </p>\n                                        )}\n                                        {status.running && (\n                                            <p className=\"text-[10px] text-blue-600 dark:text-blue-400\">\n                                                {t('proxy.config.allow_lan_access_restart_hint')}\n                                            </p>\n                                        )}\n                                    </div>\n\n                                    {/* 访问授权 */}\n                                    <div className=\"space-y-2\">\n                                        <div className=\"flex items-center justify-between\">\n                                            <label className=\"text-xs font-medium text-gray-700 dark:text-gray-300\">\n                                                <span className=\"inline-flex items-center gap-1\">\n                                                    {t('proxy.config.auth.title')}\n                                                    <HelpTooltip\n                                                        text={t('proxy.config.auth.title_tooltip')}\n                                                        ariaLabel={t('proxy.config.auth.title')}\n                                                        placement=\"top\"\n                                                    />\n                                                </span>\n                                            </label>\n                                            <label className=\"flex items-center cursor-pointer gap-2\">\n                                                <span className=\"text-[11px] text-gray-600 dark:text-gray-400 inline-flex items-center gap-1\">\n                                                    {(appConfig.proxy.auth_mode || 'off') !== 'off' ? t('proxy.config.auth.enabled') : t('common.disabled')}\n                                                    <HelpTooltip\n                                                        text={t('proxy.config.auth.enabled_tooltip')}\n                                                        ariaLabel={t('proxy.config.auth.enabled')}\n                                                        placement=\"left\"\n                                                    />\n                                                </span>\n                                                <input\n                                                    type=\"checkbox\"\n                                                    className=\"toggle toggle-sm bg-gray-200 dark:bg-gray-700 border-gray-300 dark:border-gray-600 checked:bg-blue-500 checked:border-blue-500 disabled:opacity-50 disabled:bg-gray-100 dark:disabled:bg-gray-800\"\n                                                    checked={(appConfig.proxy.auth_mode || 'off') !== 'off'}\n                                                    onChange={(e) => {\n                                                        const nextMode = e.target.checked ? 'all_except_health' : 'off';\n                                                        updateProxyConfig({ auth_mode: nextMode });\n                                                    }}\n                                                />\n                                            </label>\n                                        </div>\n\n                                        <div>\n                                            <label className=\"block text-[11px] text-gray-600 dark:text-gray-400 mb-1\">\n                                                <span className=\"inline-flex items-center gap-1\">\n                                                    {t('proxy.config.auth.mode')}\n                                                    <HelpTooltip\n                                                        text={t('proxy.config.auth.mode_tooltip')}\n                                                        ariaLabel={t('proxy.config.auth.mode')}\n                                                        placement=\"top\"\n                                                    />\n                                                </span>\n                                            </label>\n                                            <select\n                                                value={appConfig.proxy.auth_mode || 'off'}\n                                                onChange={(e) =>\n                                                    updateProxyConfig({\n                                                        auth_mode: e.target.value as ProxyConfig['auth_mode'],\n                                                    })\n                                                }\n                                                className=\"w-full px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 text-xs text-gray-900 dark:text-base-content focus:ring-2 focus:ring-blue-500 focus:border-transparent\"\n                                            >\n                                                <option value=\"off\">{t('proxy.config.auth.modes.off')}</option>\n                                                <option value=\"strict\">{t('proxy.config.auth.modes.strict')}</option>\n                                                <option value=\"all_except_health\">{t('proxy.config.auth.modes.all_except_health')}</option>\n                                                <option value=\"auto\">{t('proxy.config.auth.modes.auto')}</option>\n                                            </select>\n                                            <p className=\"mt-0.5 text-[10px] text-gray-500 dark:text-gray-400\">\n                                                {t('proxy.config.auth.hint')}\n                                            </p>\n                                        </div>\n                                    </div>\n                                </div>\n                            </div>\n\n                            {/* API 密钥 */}\n                            <div>\n                                <label className=\"block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1\">\n                                    <span className=\"inline-flex items-center gap-1\">\n                                        {t('proxy.config.api_key')}\n                                        <HelpTooltip\n                                            text={t('proxy.config.api_key_tooltip')}\n                                            ariaLabel={t('proxy.config.api_key')}\n                                            placement=\"right\"\n                                        />\n                                    </span>\n                                </label>\n                                <div className=\"flex gap-2\">\n                                    <input\n                                        type=\"text\"\n                                        value={isEditingApiKey ? tempApiKey : (appConfig.proxy.api_key)}\n                                        onChange={(e) => isEditingApiKey && setTempApiKey(e.target.value)}\n                                        readOnly={!isEditingApiKey}\n                                        className={`flex-1 px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg text-xs font-mono ${isEditingApiKey\n                                            ? 'bg-white dark:bg-base-200 text-gray-900 dark:text-base-content'\n                                            : 'bg-gray-50 dark:bg-base-300 text-gray-600 dark:text-gray-400'\n                                            }`}\n                                    />\n                                    {isEditingApiKey ? (\n                                        <>\n                                            <button\n                                                onClick={handleSaveApiKey}\n                                                className=\"px-2.5 py-1.5 border border-green-300 dark:border-green-700 rounded-lg bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors text-green-600 dark:text-green-400\"\n                                                title={t('proxy.config.btn_save')}\n                                            >\n                                                <CheckCircle size={14} />\n                                            </button>\n                                            <button\n                                                onClick={handleCancelEditApiKey}\n                                                className=\"px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 hover:bg-gray-50 dark:hover:bg-base-300 transition-colors\"\n                                                title={t('common.cancel')}\n                                            >\n                                                <X size={14} />\n                                            </button>\n                                        </>\n                                    ) : (\n                                        <>\n                                            <button\n                                                onClick={handleEditApiKey}\n                                                className=\"px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 hover:bg-gray-50 dark:hover:bg-base-300 transition-colors\"\n                                                title={t('proxy.config.btn_edit')}\n                                            >\n                                                <Edit2 size={14} />\n                                            </button>\n                                            <button\n                                                onClick={handleGenerateApiKey}\n                                                className=\"px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 hover:bg-gray-50 dark:hover:bg-base-300 transition-colors\"\n                                                title={t('proxy.config.btn_regenerate')}\n                                            >\n                                                <RefreshCw size={14} />\n                                            </button>\n                                            <button\n                                                onClick={() => copyToClipboardHandler(appConfig.proxy.api_key, 'api_key')}\n                                                className=\"px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 hover:bg-gray-50 dark:hover:bg-base-300 transition-colors\"\n                                                title={t('proxy.config.btn_copy')}\n                                            >\n                                                {copied === 'api_key' ? (\n                                                    <CheckCircle size={14} className=\"text-green-500\" />\n                                                ) : (\n                                                    <Copy size={14} />\n                                                )}\n                                            </button>\n                                        </>\n                                    )}\n                                </div>\n                                <p className=\"mt-0.5 text-[10px] text-amber-600 dark:text-amber-500\">\n                                    {t('proxy.config.warning_key')}\n                                </p>\n                            </div>\n\n                            {/* Web UI 管理密码 */}\n                            <div className=\"border-t border-gray-200 dark:border-base-300 pt-3 mt-3\">\n                                <label className=\"block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1\">\n                                    <span className=\"inline-flex items-center gap-1\">\n                                        {t('proxy.config.admin_password', { defaultValue: 'Web UI Login Password' })}\n                                        <HelpTooltip\n                                            text={t('proxy.config.admin_password_tooltip', { defaultValue: 'Used for logging into the Web Management Console. If empty, it defaults to the API Key.' })}\n                                            ariaLabel={t('proxy.config.admin_password')}\n                                            placement=\"right\"\n                                        />\n                                    </span>\n                                </label>\n                                <div className=\"flex gap-2\">\n                                    <input\n                                        type=\"text\"\n                                        value={isEditingAdminPassword ? tempAdminPassword : (appConfig.proxy.admin_password || t('proxy.config.admin_password_default', { defaultValue: '(Same as API Key)' }))}\n                                        onChange={(e) => isEditingAdminPassword && setTempAdminPassword(e.target.value)}\n                                        readOnly={!isEditingAdminPassword}\n                                        placeholder={t('proxy.config.admin_password_placeholder', { defaultValue: 'Enter new password or leave empty to use API Key' })}\n                                        className={`flex-1 px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg text-xs font-mono ${isEditingAdminPassword\n                                            ? 'bg-white dark:bg-base-200 text-gray-900 dark:text-base-content'\n                                            : 'bg-gray-50 dark:bg-base-300 text-gray-600 dark:text-gray-400'\n                                            }`}\n                                    />\n                                    {isEditingAdminPassword ? (\n                                        <>\n                                            <button\n                                                onClick={handleSaveAdminPassword}\n                                                className=\"px-2.5 py-1.5 border border-green-300 dark:border-green-700 rounded-lg bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors text-green-600 dark:text-green-400\"\n                                                title={t('proxy.config.btn_save')}\n                                            >\n                                                <CheckCircle size={14} />\n                                            </button>\n                                            <button\n                                                onClick={handleCancelEditAdminPassword}\n                                                className=\"px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 hover:bg-gray-50 dark:hover:bg-base-300 transition-colors\"\n                                                title={t('common.cancel')}\n                                            >\n                                                <X size={14} />\n                                            </button>\n                                        </>\n                                    ) : (\n                                        <>\n                                            <button\n                                                onClick={handleEditAdminPassword}\n                                                className=\"px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 hover:bg-gray-50 dark:hover:bg-base-300 transition-colors\"\n                                                title={t('proxy.config.btn_edit')}\n                                            >\n                                                <Edit2 size={14} />\n                                            </button>\n                                            <button\n                                                onClick={() => copyToClipboardHandler(appConfig.proxy.admin_password || appConfig.proxy.api_key, 'admin_password')}\n                                                className=\"px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 hover:bg-gray-50 dark:hover:bg-base-300 transition-colors\"\n                                                title={t('proxy.config.btn_copy')}\n                                            >\n                                                {copied === 'admin_password' ? (\n                                                    <CheckCircle size={14} className=\"text-green-500\" />\n                                                ) : (\n                                                    <Copy size={14} />\n                                                )}\n                                            </button>\n                                        </>\n                                    )}\n                                </div>\n                                <p className=\"mt-0.5 text-[10px] text-gray-500 dark:text-gray-400\">\n                                    {t('proxy.config.admin_password_hint', { defaultValue: 'For safety in Docker/Browser environments, you can set a separate login password from your API Key.' })}\n                                </p>\n                            </div>\n\n                            {/* User-Agent Overrides */}\n                            <div className=\"border-t border-gray-200 dark:border-base-300 pt-3 mt-3\">\n                                <div className=\"flex items-center justify-between mb-2\">\n                                    <label className=\"text-xs font-medium text-gray-700 dark:text-gray-300 inline-flex items-center gap-1\">\n                                        {t('proxy.config.request.user_agent', { defaultValue: 'User-Agent Override' })}\n                                        <HelpTooltip text={t('proxy.config.request.user_agent_tooltip', { defaultValue: 'Override the User-Agent header sent to upstream APIs.' })} />\n                                    </label>\n                                    <input\n                                        type=\"checkbox\"\n                                        className=\"toggle toggle-sm bg-gray-200 dark:bg-gray-700 border-gray-300 dark:border-gray-600 checked:bg-blue-500 checked:border-blue-500\"\n                                        checked={!!appConfig.proxy.user_agent_override}\n                                        onChange={(e) => {\n                                            const enabled = e.target.checked;\n                                            if (enabled) {\n                                                // Restore saved override from config or use default\n                                                const restoredValue = appConfig.proxy.saved_user_agent || 'antigravity/1.15.8 darwin/arm64';\n                                                updateProxyConfig({\n                                                    user_agent_override: restoredValue,\n                                                    saved_user_agent: restoredValue\n                                                });\n                                            } else {\n                                                // Disable active override but keep saved value\n                                                updateProxyConfig({ user_agent_override: undefined });\n                                            }\n                                        }}\n                                    />\n                                </div>\n\n                                {!!appConfig.proxy.user_agent_override && (\n                                    <div className=\"space-y-2 animate-in fade-in slide-in-from-top-1 duration-200\">\n                                        <input\n                                            type=\"text\"\n                                            value={appConfig.proxy.user_agent_override}\n                                            onChange={(e) => {\n                                                const newValue = e.target.value;\n                                                updateProxyConfig({\n                                                    user_agent_override: newValue,\n                                                    saved_user_agent: newValue\n                                                });\n                                            }}\n                                            className=\"w-full px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 text-xs font-mono text-gray-900 dark:text-base-content focus:ring-2 focus:ring-blue-500 focus:border-transparent\"\n                                            placeholder={t('proxy.config.request.user_agent_placeholder', { defaultValue: 'Enter custom User-Agent string...' })}\n                                        />\n                                        <div className=\"bg-gray-50 dark:bg-base-300 rounded p-2 text-[10px] text-gray-500 font-mono break-all\">\n                                            <span className=\"font-bold select-none mr-2\">{t('common.example', { defaultValue: 'Example' })}:</span>\n                                            antigravity/1.15.8 darwin/arm64\n                                        </div>\n                                    </div>\n                                )}\n                            </div>\n\n\n                        </div>\n                    </div>\n                )}\n\n                {/* External Providers Integration */}\n                {\n                    !configLoading && !configError && appConfig && (\n                        <div className=\"space-y-4\">\n                            <CollapsibleCard\n                                title={t('proxy.cli_sync.title', { defaultValue: 'CLI Sync' })}\n                                icon={<Terminal size={18} className=\"text-gray-500\" />}\n                                defaultExpanded={false}\n                            >\n                                <CliSyncCard\n                                    proxyUrl={status.running ? status.base_url : `http://127.0.0.1:${appConfig.proxy.port || 8045}`}\n                                    apiKey={appConfig.proxy.api_key}\n                                />\n                            </CollapsibleCard>\n\n                            {/* z.ai (GLM) Dispatcher */}\n                            <CollapsibleCard\n                                title={t('proxy.config.zai.title')}\n                                icon={<Zap size={18} className=\"text-amber-500\" />}\n                                enabled={!!appConfig.proxy.zai?.enabled}\n                                onToggle={(checked) => updateZaiGeneralConfig({ enabled: checked })}\n                            >\n                                <div className=\"space-y-4\">\n                                    <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n                                        <div className=\"space-y-1\">\n                                            <label className=\"text-[11px] font-medium text-gray-500 dark:text-gray-400\">\n                                                {t('proxy.config.zai.base_url')}\n                                            </label>\n                                            <input\n                                                type=\"text\"\n                                                value={appConfig.proxy.zai?.base_url || 'https://api.z.ai/api/anthropic'}\n                                                onChange={(e) => updateZaiGeneralConfig({ base_url: e.target.value })}\n                                                className=\"input input-sm input-bordered w-full font-mono text-xs\"\n                                            />\n                                        </div>\n                                        <div className=\"space-y-1\">\n                                            <label className=\"text-[11px] font-medium text-gray-500 dark:text-gray-400\">\n                                                {t('proxy.config.zai.dispatch_mode')}\n                                            </label>\n                                            <select\n                                                className=\"select select-sm select-bordered w-full text-xs\"\n                                                value={appConfig.proxy.zai?.dispatch_mode || 'off'}\n                                                onChange={(e) => updateZaiGeneralConfig({ dispatch_mode: e.target.value as any })}\n                                            >\n                                                <option value=\"off\">{t('proxy.config.zai.modes.off')}</option>\n                                                <option value=\"exclusive\">{t('proxy.config.zai.modes.exclusive')}</option>\n                                                <option value=\"pooled\">{t('proxy.config.zai.modes.pooled')}</option>\n                                                <option value=\"fallback\">{t('proxy.config.zai.modes.fallback')}</option>\n                                            </select>\n                                        </div>\n                                    </div>\n\n                                    <div className=\"space-y-1\">\n                                        <label className=\"text-[11px] font-medium text-gray-500 dark:text-gray-400 flex items-center justify-between\">\n                                            <span>{t('proxy.config.zai.api_key')}</span>\n                                            {!(appConfig.proxy.zai?.api_key) && (\n                                                <span className=\"text-amber-500 text-[10px] flex items-center gap-1\">\n                                                    <HelpTooltip text={t('proxy.config.zai.warning')} />\n                                                    {t('common.required')}\n                                                </span>\n                                            )}\n                                        </label>\n                                        <input\n                                            type=\"password\"\n                                            value={appConfig.proxy.zai?.api_key || ''}\n                                            onChange={(e) => updateZaiGeneralConfig({ api_key: e.target.value })}\n                                            placeholder=\"sk-...\"\n                                            className=\"input input-sm input-bordered w-full font-mono text-xs\"\n                                        />\n                                    </div>\n\n                                    {/* Model Mapping Section */}\n                                    <div className=\"pt-4 border-t border-gray-100 dark:border-base-200\">\n                                        <div className=\"flex items-center justify-between mb-3\">\n                                            <h4 className=\"text-[11px] font-bold text-gray-400 uppercase tracking-widest\">\n                                                {t('proxy.config.zai.models.title')}\n                                            </h4>\n                                            <button\n                                                onClick={refreshZaiModels}\n                                                disabled={zaiModelsLoading || !appConfig.proxy.zai?.api_key}\n                                                className=\"btn btn-ghost btn-xs gap-1\"\n                                            >\n                                                <RefreshCw size={12} className={zaiModelsLoading ? 'animate-spin' : ''} />\n                                                {t('proxy.config.zai.models.refresh')}\n                                            </button>\n                                        </div>\n\n                                        <div className=\"grid grid-cols-1 md:grid-cols-3 gap-3\">\n                                            {['opus', 'sonnet', 'haiku'].map((family) => (\n                                                <div key={family} className=\"space-y-1\">\n                                                    <label className=\"text-[10px] text-gray-500 capitalize\">{family}</label>\n                                                    <div className=\"flex gap-1\">\n                                                        {zaiModelOptions.length > 0 && (\n                                                            <select\n                                                                className=\"select select-xs select-bordered max-w-[80px]\"\n                                                                value=\"\"\n                                                                onChange={(e) => e.target.value && updateZaiDefaultModels({ [family]: e.target.value })}\n                                                            >\n                                                                <option value=\"\">{t('proxy.config.zai.models.select_placeholder')}</option>\n                                                                {zaiModelOptions.map(m => <option key={m} value={m}>{m}</option>)}\n                                                            </select>\n                                                        )}\n                                                        <input\n                                                            type=\"text\"\n                                                            className=\"input input-xs input-bordered w-full font-mono\"\n                                                            value={appConfig.proxy.zai?.models?.[family as keyof typeof appConfig.proxy.zai.models] || ''}\n                                                            onChange={(e) => updateZaiDefaultModels({ [family]: e.target.value })}\n                                                        />\n                                                    </div>\n                                                </div>\n                                            ))}\n                                        </div>\n\n                                        <details className=\"mt-3 group\">\n                                            <summary className=\"cursor-pointer text-[10px] text-gray-500 hover:text-blue-500 transition-colors inline-flex items-center gap-1 select-none\">\n                                                <Settings size={12} />\n                                                {t('proxy.config.zai.models.advanced_title')}\n                                            </summary>\n                                            <div className=\"mt-2 space-y-2 p-2 bg-gray-50 dark:bg-base-200/50 rounded-lg\">\n                                                {/* Advanced Mapping Table */}\n                                                {Object.entries(zaiModelMapping).map(([from, to]) => (\n                                                    <div key={from} className=\"flex items-center gap-2\">\n                                                        <div className=\"flex-1 bg-white dark:bg-base-100 px-2 py-1 rounded border border-gray-200 dark:border-base-300 text-[10px] font-mono truncate\" title={from}>{from}</div>\n                                                        <ArrowRight size={10} className=\"text-gray-400\" />\n                                                        <div className=\"flex-[1.5] flex gap-1\">\n                                                            {zaiModelOptions.length > 0 && (\n                                                                <select\n                                                                    className=\"select select-xs select-ghost h-6 min-h-0 px-1\"\n                                                                    value=\"\"\n                                                                    onChange={(e) => e.target.value && upsertZaiModelMapping(from, e.target.value)}\n                                                                >\n                                                                    <option value=\"\">▼</option>\n                                                                    {zaiModelOptions.map(m => <option key={m} value={m}>{m}</option>)}\n                                                                </select>\n                                                            )}\n                                                            <input\n                                                                type=\"text\"\n                                                                className=\"input input-xs input-bordered w-full font-mono h-6\"\n                                                                value={to}\n                                                                onChange={(e) => upsertZaiModelMapping(from, e.target.value)}\n                                                            />\n                                                        </div>\n                                                        <button onClick={() => removeZaiModelMapping(from)} className=\"text-gray-400 hover:text-red-500\"><Trash2 size={12} /></button>\n                                                    </div>\n                                                ))}\n\n                                                <div className=\"flex items-center gap-2 pt-2 border-t border-gray-200/50\">\n                                                    <input\n                                                        className=\"input input-xs input-bordered flex-1 font-mono\"\n                                                        placeholder={t('proxy.config.zai.models.from_placeholder') || \"From (e.g. claude-3-opus)\"}\n                                                        value={zaiNewMappingFrom}\n                                                        onChange={e => setZaiNewMappingFrom(e.target.value)}\n                                                    />\n                                                    <input\n                                                        className=\"input input-xs input-bordered flex-1 font-mono\"\n                                                        placeholder={t('proxy.config.zai.models.to_placeholder') || \"To (e.g. glm-4)\"}\n                                                        value={zaiNewMappingTo}\n                                                        onChange={e => setZaiNewMappingTo(e.target.value)}\n                                                    />\n                                                    <button\n                                                        className=\"btn btn-xs btn-primary\"\n                                                        onClick={() => {\n                                                            if (zaiNewMappingFrom && zaiNewMappingTo) {\n                                                                upsertZaiModelMapping(zaiNewMappingFrom, zaiNewMappingTo);\n                                                                setZaiNewMappingFrom('');\n                                                                setZaiNewMappingTo('');\n                                                            }\n                                                        }}\n                                                    >\n                                                        <Plus size={12} />\n                                                    </button>\n                                                </div>\n                                            </div>\n                                        </details>\n                                    </div>\n                                </div>\n                            </CollapsibleCard>\n\n                            {/* MCP System */}\n                            <CollapsibleCard\n                                title={t('proxy.config.zai.mcp.title')}\n                                icon={<Puzzle size={18} className=\"text-blue-500\" />}\n                                enabled={!!appConfig.proxy.zai?.mcp?.enabled}\n                                onToggle={(checked) => updateZaiGeneralConfig({ mcp: { ...(appConfig.proxy.zai?.mcp || {}), enabled: checked } as any })}\n                                rightElement={\n                                    <div className=\"flex gap-2 text-[10px]\">\n                                        {['web_search', 'web_reader', 'vision'].map(f =>\n                                            appConfig.proxy.zai?.mcp?.[(f + '_enabled') as keyof typeof appConfig.proxy.zai.mcp] && (\n                                                <span key={f} className=\"bg-blue-500 dark:bg-blue-600 px-1.5 py-0.5 rounded text-white font-semibold shadow-sm\">\n                                                    {t(`proxy.config.zai.mcp.${f}`).split(' ')[0]}\n                                                </span>\n                                            )\n                                        )}\n                                    </div>\n                                }\n                            >\n                                <div className=\"space-y-3\">\n                                    <div className=\"grid grid-cols-2 md:grid-cols-4 gap-3\">\n                                        <label className=\"flex items-center gap-2 border border-gray-100 dark:border-base-200 p-2 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-base-200/50 transition-colors\">\n                                            <input\n                                                type=\"checkbox\"\n                                                className=\"checkbox checkbox-xs rounded border-2 border-gray-400 dark:border-gray-500 checked:border-blue-600 checked:bg-blue-600 [--chkbg:theme(colors.blue.600)] [--chkfg:white]\"\n                                                checked={!!appConfig.proxy.zai?.mcp?.web_search_enabled}\n                                                onChange={(e) => updateZaiGeneralConfig({ mcp: { ...(appConfig.proxy.zai?.mcp || {}), web_search_enabled: e.target.checked } as any })}\n                                            />\n                                            <span className=\"text-xs\">{t('proxy.config.zai.mcp.web_search')}</span>\n                                        </label>\n                                        <label className=\"flex items-center gap-2 border border-gray-100 dark:border-base-200 p-2 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-base-200/50 transition-colors\">\n                                            <input\n                                                type=\"checkbox\"\n                                                className=\"checkbox checkbox-xs rounded border-2 border-gray-400 dark:border-gray-500 checked:border-blue-600 checked:bg-blue-600 [--chkbg:theme(colors.blue.600)] [--chkfg:white]\"\n                                                checked={!!appConfig.proxy.zai?.mcp?.web_reader_enabled}\n                                                onChange={(e) => updateZaiGeneralConfig({ mcp: { ...(appConfig.proxy.zai?.mcp || {}), web_reader_enabled: e.target.checked } as any })}\n                                            />\n                                            <span className=\"text-xs\">{t('proxy.config.zai.mcp.web_reader')}</span>\n                                        </label>\n                                        <label className=\"flex items-center gap-2 border border-gray-100 dark:border-base-200 p-2 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-base-200/50 transition-colors\">\n                                            <input\n                                                type=\"checkbox\"\n                                                className=\"checkbox checkbox-xs rounded border-2 border-gray-400 dark:border-gray-500 checked:border-blue-600 checked:bg-blue-600 [--chkbg:theme(colors.blue.600)] [--chkfg:white]\"\n                                                checked={!!appConfig.proxy.zai?.mcp?.vision_enabled}\n                                                onChange={(e) => updateZaiGeneralConfig({ mcp: { ...(appConfig.proxy.zai?.mcp || {}), vision_enabled: e.target.checked } as any })}\n                                            />\n                                            <span className=\"text-xs\">{t('proxy.config.zai.mcp.vision')}</span>\n                                        </label>\n                                    </div>\n\n                                    {appConfig.proxy.zai?.mcp?.enabled && (\n                                        <div className=\"bg-slate-100 dark:bg-slate-800/80 rounded-lg p-3 text-[10px] font-mono text-slate-600 dark:text-slate-400\">\n                                            <div className=\"mb-1 font-bold text-gray-400 uppercase tracking-wider\">{t('proxy.config.zai.mcp.local_endpoints')}</div>\n                                            <div className=\"space-y-0.5 select-all\">\n                                                {appConfig.proxy.zai?.mcp?.web_search_enabled && <div>http://127.0.0.1:{status.running ? status.port : (appConfig.proxy.port || 8045)}/mcp/web_search_prime/mcp</div>}\n                                                {appConfig.proxy.zai?.mcp?.web_reader_enabled && <div>http://127.0.0.1:{status.running ? status.port : (appConfig.proxy.port || 8045)}/mcp/web_reader/mcp</div>}\n                                                {appConfig.proxy.zai?.mcp?.vision_enabled && <div>http://127.0.0.1:{status.running ? status.port : (appConfig.proxy.port || 8045)}/mcp/zai-mcp-server/mcp</div>}\n                                            </div>\n                                        </div>\n                                    )}\n                                </div>\n                            </CollapsibleCard>\n\n                            {/* Account Scheduling & Rotation */}\n                            <CollapsibleCard\n                                title={t('proxy.config.scheduling.title')}\n                                icon={<RefreshCw size={18} className=\"text-indigo-500\" />}\n                            >\n                                <div className=\"space-y-4\">\n                                    <div className=\"grid grid-cols-1 md:grid-cols-2 gap-6\">\n                                        <div className=\"space-y-3\">\n                                            <div className=\"flex items-center justify-between\">\n                                                <label className=\"text-xs font-medium text-gray-700 dark:text-gray-300 inline-flex items-center gap-1\">\n                                                    {t('proxy.config.scheduling.mode')}\n                                                    <HelpTooltip\n                                                        text={t('proxy.config.scheduling.mode_tooltip')}\n                                                        placement=\"right\"\n                                                    />\n                                                </label>\n                                                <div className=\"flex items-center gap-3\">\n                                                    {/* [MOVED] Clear Rate Limit button moved to CircuitBreaker component */}\n                                                    <button\n                                                        onClick={handleClearSessionBindings}\n                                                        className=\"text-[10px] text-indigo-500 hover:text-indigo-600 transition-colors flex items-center gap-1\"\n                                                        title={t('proxy.config.scheduling.clear_bindings_tooltip')}\n                                                    >\n                                                        <Trash2 size={12} />\n                                                        {t('proxy.config.scheduling.clear_bindings')}\n                                                    </button>\n                                                </div>\n                                            </div>\n                                            <div className=\"grid grid-cols-1 gap-2\">\n                                                {(['CacheFirst', 'Balance', 'PerformanceFirst'] as const).map(mode => (\n                                                    <label\n                                                        key={mode}\n                                                        className={`flex items-start gap-3 p-3 rounded-xl border cursor-pointer transition-all duration-200 ${(appConfig.proxy.scheduling?.mode || 'Balance') === mode\n                                                            ? 'border-indigo-500 bg-indigo-50/30 dark:bg-indigo-900/10'\n                                                            : 'border-gray-100 dark:border-base-200 hover:border-indigo-200'\n                                                            }`}\n                                                    >\n                                                        <input\n                                                            type=\"radio\"\n                                                            className=\"radio radio-xs radio-primary mt-1\"\n                                                            checked={(appConfig.proxy.scheduling?.mode || 'Balance') === mode}\n                                                            onChange={() => updateSchedulingConfig({ mode })}\n                                                        />\n                                                        <div className=\"space-y-1\">\n                                                            <div className=\"text-xs font-bold text-gray-900 dark:text-base-content\">\n                                                                {t(`proxy.config.scheduling.modes.${mode}`)}\n                                                            </div>\n                                                            <div className=\"text-[10px] text-gray-500 line-clamp-2\">\n                                                                {t(`proxy.config.scheduling.modes_desc.${mode}`, {\n                                                                    defaultValue: mode === 'CacheFirst' ? 'Binds session to account, waits precisely if limited (Maximizes Prompt Cache hits).' :\n                                                                        mode === 'Balance' ? 'Binds session, auto-switches to available account if limited (Balanced cache & availability).' :\n                                                                            'No session binding, pure round-robin rotation (Best for high concurrency).'\n                                                                })}\n                                                            </div>\n                                                        </div>\n                                                    </label>\n                                                ))}\n                                            </div>\n                                        </div>\n\n                                        <div className=\"space-y-4 pt-1\">\n                                            <div className=\"bg-slate-100 dark:bg-slate-800/80 rounded-xl p-4 border border-slate-200 dark:border-slate-700\">\n                                                <div className=\"flex items-center justify-between mb-2\">\n                                                    <label className=\"text-xs font-medium text-gray-700 dark:text-gray-300 inline-flex items-center gap-1\">\n                                                        {t('proxy.config.scheduling.max_wait')}\n                                                        <HelpTooltip text={t('proxy.config.scheduling.max_wait_tooltip')} />\n                                                    </label>\n                                                    <span className=\"text-xs font-mono text-indigo-600 font-bold\">\n                                                        {appConfig.proxy.scheduling?.max_wait_seconds || 60}s\n                                                    </span>\n                                                </div>\n                                                <input\n                                                    type=\"range\"\n                                                    min=\"0\"\n                                                    max=\"300\"\n                                                    step=\"10\"\n                                                    disabled={(appConfig.proxy.scheduling?.mode || 'Balance') !== 'CacheFirst'}\n                                                    className=\"range range-indigo range-xs\"\n                                                    value={appConfig.proxy.scheduling?.max_wait_seconds || 60}\n                                                    onChange={(e) => updateSchedulingConfig({ max_wait_seconds: parseInt(e.target.value) })}\n                                                />\n                                                <div className=\"flex justify-between px-1 mt-1 text-[10px] text-gray-400 font-mono\">\n                                                    <span>0s</span>\n                                                    <span>300s</span>\n                                                </div>\n                                            </div>\n\n                                            <div className=\"p-3 bg-amber-50 dark:bg-amber-900/10 border border-amber-100 dark:border-amber-900/20 rounded-xl\">\n                                                <p className=\"text-[10px] text-amber-700 dark:text-amber-500 leading-relaxed\">\n                                                    <strong>{t('common.info')}:</strong> {t('proxy.config.scheduling.subtitle')}\n                                                </p>\n                                            </div>\n\n                                            {/* [FIX #820] Fixed Account Mode */}\n                                            <div className=\"bg-indigo-50 dark:bg-indigo-900/20 rounded-xl p-4 border border-indigo-200 dark:border-indigo-800\">\n                                                <div className=\"flex items-center justify-between mb-3\">\n                                                    <label className=\"text-xs font-medium text-gray-700 dark:text-gray-300 inline-flex items-center gap-1\">\n                                                        🔒 {t('proxy.config.scheduling.fixed_account', { defaultValue: 'Fixed Account Mode' })}\n                                                        <HelpTooltip text={t('proxy.config.scheduling.fixed_account_tooltip', { defaultValue: 'When enabled, all API requests will use only the selected account instead of rotating between accounts.' })} />\n                                                    </label>\n                                                    <input\n                                                        type=\"checkbox\"\n                                                        className=\"toggle toggle-sm toggle-primary\"\n                                                        checked={preferredAccountId !== null}\n                                                        onChange={(e) => {\n                                                            if (e.target.checked) {\n                                                                // Enable fixed mode with first available account\n                                                                if (availableAccounts.length > 0) {\n                                                                    handleSetPreferredAccount(availableAccounts[0].id);\n                                                                }\n                                                            } else {\n                                                                // Disable fixed mode\n                                                                handleSetPreferredAccount(null);\n                                                            }\n                                                        }}\n                                                        disabled={!status.running}\n                                                    />\n                                                </div>\n                                                {preferredAccountId !== null && (\n                                                    <select\n                                                        className=\"select select-bordered select-sm w-full text-xs\"\n                                                        value={preferredAccountId || ''}\n                                                        onChange={(e) => handleSetPreferredAccount(e.target.value || null)}\n                                                        disabled={!status.running}\n                                                    >\n                                                        {availableAccounts.map(account => (\n                                                            <option key={account.id} value={account.id}>\n                                                                {account.email}\n                                                            </option>\n                                                        ))}\n                                                    </select>\n                                                )}\n                                                {!status.running && (\n                                                    <p className=\"text-[10px] text-gray-500 mt-2\">\n                                                        {t('proxy.config.scheduling.start_proxy_first', { defaultValue: 'Start the proxy service to configure fixed account mode.' })}\n                                                    </p>\n                                                )}\n                                            </div>\n                                        </div>\n                                    </div>\n\n                                    {/* Circuit Breaker Section */}\n                                    {appConfig.circuit_breaker && (\n                                        <div className=\"pt-4 border-t border-gray-100 dark:border-gray-700/50\">\n                                            <div className=\"flex items-center justify-between mb-4\">\n                                                <label className=\"text-xs font-medium text-gray-700 dark:text-gray-300 inline-flex items-center gap-1\">\n                                                    {t('proxy.config.circuit_breaker.title', { defaultValue: 'Adaptive Circuit Breaker' })}\n                                                    <HelpTooltip text={t('proxy.config.circuit_breaker.tooltip', { defaultValue: 'Prevent continuous failures by exponentially backing off when quota is exhausted.' })} />\n                                                </label>\n                                                <input\n                                                    type=\"checkbox\"\n                                                    className=\"toggle toggle-sm toggle-warning\"\n                                                    checked={appConfig.circuit_breaker.enabled}\n                                                    onChange={(e) => updateCircuitBreakerConfig({ ...appConfig.circuit_breaker, enabled: e.target.checked })}\n                                                />\n                                            </div>\n\n                                            {appConfig.circuit_breaker.enabled && (\n                                                <CircuitBreaker\n                                                    config={appConfig.circuit_breaker}\n                                                    onChange={updateCircuitBreakerConfig}\n                                                    onClearRateLimits={handleClearRateLimits}\n                                                />\n                                            )}\n                                        </div>\n                                    )}\n                                </div>\n                            </CollapsibleCard>\n\n                            {/* Advanced Thinking & Global Config */}\n                            <CollapsibleCard\n                                title={t('settings.advanced_thinking.title', { defaultValue: 'Advanced Thinking & Global Config' })}\n                                icon={<BrainCircuit size={18} className=\"text-pink-500\" />}\n                            >\n                                <AdvancedThinking\n                                    config={appConfig.proxy}\n                                    onChange={(newProxyConfig) => updateProxyConfig(newProxyConfig)}\n                                />\n                            </CollapsibleCard>\n\n                            {/* 实验性设置 */}\n                            <CollapsibleCard\n                                title={t('proxy.config.experimental.title')}\n                                icon={<Sparkles size={18} className=\"text-purple-500\" />}\n                            >\n                                <div className=\"space-y-4\">\n                                    <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-base-200 rounded-xl border border-gray-100 dark:border-base-300\">\n                                        <div className=\"space-y-1\">\n                                            <div className=\"flex items-center gap-2\">\n                                                <span className=\"text-sm font-bold text-gray-900 dark:text-base-content\">\n                                                    {t('proxy.config.experimental.enable_usage_scaling')}\n                                                </span>\n                                                <HelpTooltip text={t('proxy.config.experimental.enable_usage_scaling_tooltip')} />\n                                                <span className=\"px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-900/30 text-[10px] text-purple-600 dark:text-purple-400 font-bold border border-purple-200 dark:border-purple-800\">\n                                                    Claude\n                                                </span>\n                                            </div>\n                                            <p className=\"text-[10px] text-gray-500 dark:text-gray-400 max-w-lg\">\n                                                {t('proxy.config.experimental.enable_usage_scaling_tooltip')}\n                                            </p>\n                                        </div>\n                                        <label className=\"relative inline-flex items-center cursor-pointer\">\n                                            <input\n                                                type=\"checkbox\"\n                                                className=\"sr-only peer\"\n                                                checked={!!appConfig.proxy.experimental?.enable_usage_scaling}\n                                                onChange={(e) => updateExperimentalConfig({ enable_usage_scaling: e.target.checked })}\n                                            />\n                                            <div className=\"w-11 h-6 bg-gray-200 dark:bg-base-300 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-500 shadow-inner\"></div>\n                                        </label>\n                                    </div>\n\n                                    {/* L1 Threshold */}\n                                    <div className=\"flex flex-col gap-2 p-4 bg-gray-50 dark:bg-base-200 rounded-xl border border-gray-100 dark:border-base-300\">\n                                        <div className=\"flex items-center justify-between w-full\">\n                                            <div className=\"flex items-center gap-2\">\n                                                <span className=\"text-sm font-bold text-gray-900 dark:text-base-content\">\n                                                    {t('proxy.config.experimental.context_compression_threshold_l1')}\n                                                </span>\n                                                <HelpTooltip text={t('proxy.config.experimental.context_compression_threshold_l1_tooltip')} />\n                                            </div>\n                                        </div>\n                                        <DebouncedSlider\n                                            min={0.1}\n                                            max={1}\n                                            step={0.05}\n                                            className=\"range range-purple range-xs\"\n                                            value={appConfig.proxy.experimental?.context_compression_threshold_l1 || 0.4}\n                                            onChange={(val) => updateExperimentalConfig({ context_compression_threshold_l1: val })}\n                                        />\n                                    </div>\n\n                                    {/* L2 Threshold */}\n                                    <div className=\"flex flex-col gap-2 p-4 bg-gray-50 dark:bg-base-200 rounded-xl border border-gray-100 dark:border-base-300\">\n                                        <div className=\"flex items-center justify-between w-full\">\n                                            <div className=\"flex items-center gap-2\">\n                                                <span className=\"text-sm font-bold text-gray-900 dark:text-base-content\">\n                                                    {t('proxy.config.experimental.context_compression_threshold_l2')}\n                                                </span>\n                                                <HelpTooltip text={t('proxy.config.experimental.context_compression_threshold_l2_tooltip')} />\n                                            </div>\n                                        </div>\n                                        <DebouncedSlider\n                                            min={0.1}\n                                            max={1}\n                                            step={0.05}\n                                            className=\"range range-purple range-xs\"\n                                            value={appConfig.proxy.experimental?.context_compression_threshold_l2 || 0.55}\n                                            onChange={(val) => updateExperimentalConfig({ context_compression_threshold_l2: val })}\n                                        />\n                                    </div>\n\n                                    {/* L3 Threshold */}\n                                    <div className=\"flex flex-col gap-2 p-4 bg-gray-50 dark:bg-base-200 rounded-xl border border-gray-100 dark:border-base-300\">\n                                        <div className=\"flex items-center justify-between w-full\">\n                                            <div className=\"flex items-center gap-2\">\n                                                <span className=\"text-sm font-bold text-gray-900 dark:text-base-content\">\n                                                    {t('proxy.config.experimental.context_compression_threshold_l3')}\n                                                </span>\n                                                <HelpTooltip text={t('proxy.config.experimental.context_compression_threshold_l3_tooltip')} />\n                                            </div>\n                                        </div>\n                                        <DebouncedSlider\n                                            min={0.1}\n                                            max={1}\n                                            step={0.05}\n                                            className=\"range range-purple range-xs\"\n                                            value={appConfig.proxy.experimental?.context_compression_threshold_l3 || 0.7}\n                                            onChange={(val) => updateExperimentalConfig({ context_compression_threshold_l3: val })}\n                                        />\n                                    </div>\n                                </div>\n                            </CollapsibleCard>\n\n                            {/* 公网访问 (Cloudflared) - 仅在桌面端显示 */}\n                            {isTauri() && (\n                                <CollapsibleCard\n                                    title={t('proxy.cloudflared.title', { defaultValue: 'Public Access (Cloudflared)' })}\n                                    icon={<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"text-orange-500\"><path d=\"M12 2L2 7l10 5 10-5-10-5z\" /><path d=\"M2 17l10 5 10-5\" /><path d=\"M2 12l10 5 10-5\" /></svg>}\n                                    enabled={cfStatus.running}\n                                    onToggle={handleCfToggle}\n                                    allowInteractionWhenDisabled={true}\n                                    rightElement={\n                                        cfLoading ? (\n                                            <span className=\"loading loading-spinner loading-xs\"></span>\n                                        ) : cfStatus.running && cfStatus.url ? (\n                                            <button\n                                                onClick={(e) => { e.stopPropagation(); handleCfCopyUrl(); }}\n                                                className=\"text-xs px-2 py-1 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-900/50 transition-colors flex items-center gap-1\"\n                                            >\n                                                {copied === 'cf-url' ? <CheckCircle size={12} /> : <Copy size={12} />}\n                                                {cfStatus.url.replace('https://', '').slice(0, 20)}...\n                                            </button>\n                                        ) : null\n                                    }\n                                >\n                                    <div className=\"space-y-4\">\n                                        {/* 安装状态 */}\n                                        {!cfStatus.installed ? (\n                                            <div className=\"flex items-center justify-between p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-xl border border-yellow-200 dark:border-yellow-800\">\n                                                <div className=\"space-y-1\">\n                                                    <span className=\"text-sm font-bold text-yellow-800 dark:text-yellow-200\">\n                                                        {t('proxy.cloudflared.not_installed', { defaultValue: 'Cloudflared not installed' })}\n                                                    </span>\n                                                    <p className=\"text-xs text-yellow-600 dark:text-yellow-400\">\n                                                        {t('proxy.cloudflared.install_hint', { defaultValue: 'Click to download and install cloudflared binary' })}\n                                                    </p>\n                                                </div>\n                                                <button\n                                                    onClick={handleCfInstall}\n                                                    disabled={cfLoading}\n                                                    className=\"px-4 py-2 rounded-lg text-sm font-medium bg-yellow-500 text-white hover:bg-yellow-600 disabled:opacity-50 flex items-center gap-2\"\n                                                >\n                                                    {cfLoading ? <span className=\"loading loading-spinner loading-xs\"></span> : null}\n                                                    {t('proxy.cloudflared.install', { defaultValue: 'Install' })}\n                                                </button>\n                                            </div>\n                                        ) : (\n                                            <>\n                                                {/* 版本信息 */}\n                                                <div className=\"flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400\">\n                                                    <CheckCircle size={14} className=\"text-green-500\" />\n                                                    {t('proxy.cloudflared.installed', { defaultValue: 'Installed' })}: {cfStatus.version || 'Unknown'}\n                                                </div>\n\n                                                {/* 隧道模式选择 */}\n                                                <div className=\"grid grid-cols-2 gap-3\">\n                                                    <button\n                                                        onClick={() => {\n                                                            setCfMode('quick');\n                                                            if (appConfig) {\n                                                                saveConfig({\n                                                                    ...appConfig,\n                                                                    cloudflared: { ...appConfig.cloudflared, mode: 'quick' }\n                                                                });\n                                                            }\n                                                        }}\n                                                        disabled={cfStatus.running}\n                                                        className={cn(\n                                                            \"p-3 rounded-lg border-2 text-left transition-all\",\n                                                            cfMode === 'quick'\n                                                                ? \"border-orange-500 bg-orange-50 dark:bg-orange-900/20\"\n                                                                : \"border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600\",\n                                                            cfStatus.running && \"opacity-60 cursor-not-allowed\"\n                                                        )}\n                                                    >\n                                                        <div className=\"text-sm font-bold text-gray-900 dark:text-base-content\">\n                                                            {t('proxy.cloudflared.mode_quick', { defaultValue: 'Quick Tunnel' })}\n                                                        </div>\n                                                        <p className=\"text-[10px] text-gray-500 dark:text-gray-400 mt-1\">\n                                                            {t('proxy.cloudflared.mode_quick_desc', { defaultValue: 'Auto-generated temporary URL (*.trycloudflare.com)' })}\n                                                        </p>\n                                                    </button>\n                                                    <button\n                                                        onClick={() => {\n                                                            setCfMode('auth');\n                                                            if (appConfig) {\n                                                                saveConfig({\n                                                                    ...appConfig,\n                                                                    cloudflared: { ...appConfig.cloudflared, mode: 'auth' }\n                                                                });\n                                                            }\n                                                        }}\n                                                        disabled={cfStatus.running}\n                                                        className={cn(\n                                                            \"p-3 rounded-lg border-2 text-left transition-all\",\n                                                            cfMode === 'auth'\n                                                                ? \"border-orange-500 bg-orange-50 dark:bg-orange-900/20\"\n                                                                : \"border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600\",\n                                                            cfStatus.running && \"opacity-60 cursor-not-allowed\"\n                                                        )}\n                                                    >\n                                                        <div className=\"text-sm font-bold text-gray-900 dark:text-base-content\">\n                                                            {t('proxy.cloudflared.mode_auth', { defaultValue: 'Named Tunnel' })}\n                                                        </div>\n                                                        <p className=\"text-[10px] text-gray-500 dark:text-gray-400 mt-1\">\n                                                            {t('proxy.cloudflared.mode_auth_desc', { defaultValue: 'Use your Cloudflare account with custom domain' })}\n                                                        </p>\n                                                    </button>\n                                                </div>\n\n                                                {/* Token输入 (仅auth模式) */}\n                                                {cfMode === 'auth' && (\n                                                    <div className=\"space-y-2\">\n                                                        <label className=\"text-sm font-medium text-gray-700 dark:text-gray-300\">\n                                                            {t('proxy.cloudflared.token', { defaultValue: 'Tunnel Token' })}\n                                                        </label>\n                                                        <input\n                                                            type=\"password\"\n                                                            value={cfToken}\n                                                            onChange={(e) => setCfToken(e.target.value)}\n                                                            onBlur={() => {\n                                                                if (appConfig) {\n                                                                    saveConfig({\n                                                                        ...appConfig,\n                                                                        cloudflared: { ...appConfig.cloudflared, token: cfToken }\n                                                                    });\n                                                                }\n                                                            }}\n                                                            disabled={cfStatus.running}\n                                                            placeholder=\"eyJhIjoiNj...\"\n                                                            className=\"w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-base-200 text-sm font-mono disabled:opacity-60\"\n                                                        />\n                                                    </div>\n                                                )}\n\n                                                {/* HTTP2选项 */}\n                                                <div className=\"flex items-center justify-between p-3 bg-gray-50 dark:bg-base-200 rounded-lg\">\n                                                    <div className=\"space-y-0.5\">\n                                                        <span className=\"text-sm font-medium text-gray-900 dark:text-base-content\">\n                                                            {t('proxy.cloudflared.use_http2', { defaultValue: 'Use HTTP/2' })}\n                                                        </span>\n                                                        <p className=\"text-[10px] text-gray-500 dark:text-gray-400\">\n                                                            {t('proxy.cloudflared.use_http2_desc', { defaultValue: 'More compatible, recommended for China mainland' })}\n                                                        </p>\n                                                    </div>\n                                                    <input\n                                                        type=\"checkbox\"\n                                                        className=\"toggle toggle-sm\"\n                                                        checked={cfUseHttp2}\n                                                        onChange={(e) => {\n                                                            const val = e.target.checked;\n                                                            setCfUseHttp2(val);\n                                                            if (appConfig) {\n                                                                const newConfig = {\n                                                                    ...appConfig,\n                                                                    cloudflared: {\n                                                                        ...appConfig.cloudflared,\n                                                                        use_http2: val\n                                                                    }\n                                                                };\n                                                                saveConfig(newConfig);\n                                                            }\n                                                        }}\n                                                        disabled={cfStatus.running}\n                                                    />\n                                                </div>\n\n                                                {/* 运行状态和URL */}\n                                                {cfStatus.running && (\n                                                    <div className=\"p-4 bg-green-50 dark:bg-green-900/20 rounded-xl border border-green-200 dark:border-green-800\">\n                                                        <div className=\"flex items-center gap-2 mb-2\">\n                                                            <div className=\"w-2 h-2 rounded-full bg-green-500 animate-pulse\"></div>\n                                                            <span className=\"text-sm font-bold text-green-800 dark:text-green-200\">\n                                                                {t('proxy.cloudflared.running', { defaultValue: 'Tunnel Running' })}\n                                                            </span>\n                                                        </div>\n                                                        {cfStatus.url && (\n                                                            <div className=\"flex items-center gap-2\">\n                                                                <code className=\"flex-1 px-3 py-2 bg-white dark:bg-base-100 rounded text-xs font-mono text-gray-800 dark:text-gray-200 border border-green-200 dark:border-green-800\">\n                                                                    {cfStatus.url}\n                                                                </code>\n                                                                <button\n                                                                    onClick={handleCfCopyUrl}\n                                                                    className=\"p-2 rounded-lg bg-green-500 text-white hover:bg-green-600 transition-colors\"\n                                                                >\n                                                                    {copied === 'cf-url' ? <CheckCircle size={16} /> : <Copy size={16} />}\n                                                                </button>\n                                                            </div>\n                                                        )}\n                                                    </div>\n                                                )}\n\n                                                {/* 错误信息 */}\n                                                {cfStatus.error && (\n                                                    <div className=\"p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800 text-sm text-red-700 dark:text-red-300\">\n                                                        {cfStatus.error}\n                                                    </div>\n                                                )}\n                                            </>\n                                        )}\n                                    </div>\n                                </CollapsibleCard>\n                            )}\n                        </div>\n                    )\n                }\n\n                {/* 模型路由中心 */}\n                {\n                    !configLoading && !configError && appConfig && (\n                        <div className=\"bg-white dark:bg-base-100 rounded-xl shadow-sm border border-gray-100 dark:border-base-200 overflow-hidden\">\n                            <div className=\"px-4 py-3 border-b border-gray-100 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/50\">\n                                <div className=\"flex flex-col md:flex-row md:items-center justify-between gap-4\">\n                                    <div className=\"flex-1\">\n                                        <h2 className=\"text-base font-bold flex items-center gap-2 text-gray-900 dark:text-base-content\">\n                                            <BrainCircuit size={18} className=\"text-blue-500\" />\n                                            {t('proxy.router.title')}\n                                        </h2>\n                                        <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-1 max-w-xl leading-relaxed\">\n                                            {t('proxy.router.subtitle_simple')}\n                                        </p>\n                                    </div>\n                                    <div className=\"flex flex-wrap items-center gap-2 bg-white dark:bg-base-100 p-1.5 rounded-xl border border-gray-100 dark:border-gray-700/50 shadow-sm\">\n                                        {/* 预设选择下拉框 */}\n                                        <div className=\"relative min-w-[140px]\">\n                                            <select\n                                                value={selectedPreset}\n                                                onChange={(e) => setSelectedPreset(e.target.value)}\n                                                className=\"select select-sm w-full bg-gray-50 dark:bg-base-200 border-gray-200 dark:border-gray-700 text-xs font-medium focus:ring-1 focus:ring-blue-500 h-9 min-h-0 rounded-lg\"\n                                            >\n                                                <optgroup label={t('proxy.router.built_in_presets')}>\n                                                    {defaultPresets.map(preset => (\n                                                        <option key={preset.id} value={preset.id}>\n                                                            {preset.name}\n                                                        </option>\n                                                    ))}\n                                                </optgroup>\n                                                {customPresets.length > 0 && (\n                                                    <optgroup label={t('proxy.router.custom_presets')}>\n                                                        {customPresets.map(preset => (\n                                                            <option key={preset.id} value={preset.id}>\n                                                                {preset.name}\n                                                            </option>\n                                                        ))}\n                                                    </optgroup>\n                                                )}\n                                            </select>\n                                        </div>\n\n                                        <button\n                                            onClick={handleApplyPresets}\n                                            className=\"px-3 md:px-4 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 bg-blue-600 hover:bg-blue-700 text-white shadow-sm hover:shadow active:scale-95 h-9\"\n                                            title={presetOptions.find(p => p.id === selectedPreset)?.description}\n                                        >\n                                            <Sparkles size={14} className=\"fill-white/20\" />\n                                            {t('proxy.router.apply_selected')}\n                                        </button>\n\n                                        <div className=\"w-[1px] h-5 bg-gray-200 dark:bg-gray-700 mx-1\"></div>\n\n                                        {/* 添加映射预设 */}\n                                        <button\n                                            onClick={() => setIsPresetManagerOpen(true)}\n                                            className=\"p-2 rounded-lg text-gray-500 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all h-9 w-9 flex items-center justify-center border border-transparent hover:border-green-100 dark:hover:border-green-900/30\"\n                                            title={t('proxy.router.add_preset')}\n                                        >\n                                            <Plus size={16} />\n                                        </button>\n\n                                        {/* 删除当前预设（仅自定义预设） */}\n                                        <button\n                                            onClick={() => {\n                                                if (selectedPreset.startsWith('custom_')) {\n                                                    handleDeletePreset(selectedPreset);\n                                                } else {\n                                                    showToast(t('proxy.router.cannot_delete_builtin'), 'warning');\n                                                }\n                                            }}\n                                            className={`p-2 rounded-lg transition-all h-9 w-9 flex items-center justify-center border border-transparent ${selectedPreset.startsWith('custom_')\n                                                ? 'text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 hover:border-red-100 dark:hover:border-red-900/30'\n                                                : 'text-gray-300 dark:text-gray-600 cursor-not-allowed'\n                                                }`}\n                                            title={selectedPreset.startsWith('custom_')\n                                                ? t('proxy.router.delete_preset')\n                                                : t('proxy.router.cannot_delete_builtin')}\n                                            disabled={!selectedPreset.startsWith('custom_')}\n                                        >\n                                            <Trash2 size={16} />\n                                        </button>\n\n                                        <div className=\"w-[1px] h-5 bg-gray-200 dark:bg-gray-700 mx-1\"></div>\n\n                                        <button\n                                            onClick={handleResetMapping}\n                                            className=\"p-2 rounded-lg text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all h-9 w-9 flex items-center justify-center border border-transparent hover:border-red-100 dark:hover:border-red-900/30\"\n                                            title={t('proxy.router.reset_mapping')}\n                                        >\n                                            <RefreshCw size={16} />\n                                        </button>\n                                    </div>\n                                </div>\n                            </div>\n\n                            <div className=\"p-3 space-y-3\">\n                                {/* 精确映射管理 */}\n                                <div>\n                                    {/* 后台任务模型配置 (Compact Mode) */}\n                                    <div className=\"mb-4 pb-4 border-b border-gray-100 dark:border-base-200\">\n                                        <div className=\"flex flex-col sm:flex-row sm:items-center justify-between gap-3\">\n                                            <div className=\"flex-1\">\n                                                <h3 className=\"text-xs font-bold text-gray-700 dark:text-gray-300 flex items-center gap-2\">\n                                                    <Sparkles size={14} className=\"text-blue-500\" />\n                                                    {t('proxy.router.background_task_title')}\n                                                </h3>\n                                                <p className=\"text-[10px] text-gray-500 dark:text-gray-400 mt-0.5\">\n                                                    {t('proxy.router.background_task_desc')}\n                                                </p>\n                                            </div>\n\n                                            <div className=\"flex items-center gap-2 w-full sm:w-auto min-w-[200px] max-w-sm\">\n                                                <div className=\"relative flex-1\">\n                                                    <GroupedSelect\n                                                        value={appConfig.proxy.custom_mapping?.['internal-background-task'] || ''}\n                                                        onChange={(val) => handleMappingUpdate('custom', 'internal-background-task', val)}\n                                                        options={[\n                                                            { value: '', label: 'Default (gemini-2.5-flash)', group: 'System' },\n                                                            ...customMappingOptions\n                                                        ]}\n                                                        placeholder=\"Default (gemini-2.5-flash)\"\n                                                        className=\"font-mono text-[11px] h-8 dark:bg-base-200 w-full\"\n                                                    />\n                                                </div>\n\n                                                {appConfig.proxy.custom_mapping && appConfig.proxy.custom_mapping['internal-background-task'] && (\n                                                    <button\n                                                        onClick={() => handleRemoveCustomMapping('internal-background-task')}\n                                                        className=\"p-1.5 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors\"\n                                                        title={t('proxy.router.use_default')}\n                                                    >\n                                                        <RefreshCw size={12} />\n                                                    </button>\n                                                )}\n                                            </div>\n                                        </div>\n                                    </div>\n\n                                    <div className=\"flex items-center justify-between mb-3\">\n                                        <div className=\"flex flex-col gap-1\">\n                                            <h3 className=\"text-[10px] font-bold text-gray-400 uppercase tracking-widest flex items-center gap-2\">\n                                                <ArrowRight size={14} /> {t('proxy.router.custom_mappings')}\n                                            </h3>\n                                            <p className=\"text-[9px] text-gray-500 dark:text-gray-400 leading-relaxed\">\n                                                {t('proxy.router.custom_mapping_tip')}\n                                                <span className=\"text-amber-600 dark:text-amber-400\">{t('proxy.router.custom_mapping_warning')}</span>\n                                            </p>\n                                        </div>\n                                    </div>\n                                    <div className=\"flex flex-col gap-4\">\n                                        {/* 当前映射列表 (置顶 2 列) */}\n                                        <div className=\"w-full flex flex-col\">\n                                            <div className=\"flex items-center justify-between mb-2\">\n                                                <span className=\"text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider\">\n                                                    {t('proxy.router.current_list')}\n                                                </span>\n                                            </div>\n                                            <div className=\"overflow-y-auto max-h-[180px] border border-gray-100 dark:border-white/5 rounded-lg bg-gray-50/10 dark:bg-white/5 p-3\" data-custom-mapping-list>\n                                                <div className=\"grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2\">\n                                                    {appConfig.proxy.custom_mapping && Object.entries(appConfig.proxy.custom_mapping).length > 0 ? (\n                                                        Object.entries(appConfig.proxy.custom_mapping).map(([key, val]) => (\n                                                            <div key={key} className={`flex items-center justify-between p-1.5 rounded-md transition-all border group ${editingKey === key ? 'bg-blue-50/80 dark:bg-blue-900/15 border-blue-300/50 dark:border-blue-500/30 shadow-sm' : 'border-transparent hover:bg-gray-100 dark:hover:bg-white/5 hover:border-gray-200 dark:hover:border-white/10'}`}>\n                                                                <div className=\"flex items-center gap-2.5 overflow-hidden flex-1\">\n                                                                    <span className=\"font-mono text-[10px] font-bold text-blue-600 dark:text-blue-400 truncate max-w-[140px]\" title={key}>{key}</span>\n                                                                    <ArrowRight size={10} className=\"text-gray-300 dark:text-gray-600 shrink-0\" />\n\n                                                                    {editingKey === key ? (\n                                                                        <div className=\"flex-1 mr-2\">\n                                                                            <GroupedSelect\n                                                                                value={editingValue}\n                                                                                onChange={setEditingValue}\n                                                                                options={customMappingOptions}\n                                                                                placeholder=\"Select...\"\n                                                                                className=\"font-mono text-[10px] h-7 dark:bg-gray-800 border-blue-200 dark:border-blue-800\"\n                                                                                allowCustomInput={true}\n                                                                            />\n                                                                        </div>\n                                                                    ) : (\n                                                                        <span className=\"font-mono text-[10px] text-gray-500 dark:text-gray-400 truncate cursor-pointer hover:text-blue-500\"\n                                                                            onClick={() => { setEditingKey(key); setEditingValue(val); }}\n                                                                            title={val}>{val}</span>\n                                                                    )}\n                                                                </div>\n\n                                                                <div className=\"flex items-center gap-1.5 shrink-0\">\n                                                                    {editingKey === key ? (\n                                                                        <div className=\"flex items-center gap-1 bg-white dark:bg-gray-800 rounded-md border border-blue-200 dark:border-blue-800 p-0.5 shadow-sm\">\n                                                                            <button\n                                                                                className=\"btn btn-ghost btn-xs text-primary hover:bg-blue-50 dark:hover:bg-blue-900/30 p-0 h-6 w-6 min-h-0\"\n                                                                                onClick={() => {\n                                                                                    handleMappingUpdate('custom', key, editingValue);\n                                                                                    setEditingKey(null);\n                                                                                }}\n                                                                                title={t('common.save') || 'Save'}\n                                                                            >\n                                                                                <Check size={14} strokeWidth={3} />\n                                                                            </button>\n                                                                            <div className=\"w-[1px] h-3 bg-gray-200 dark:bg-gray-700\" />\n                                                                            <button\n                                                                                className=\"btn btn-ghost btn-xs text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 p-0 h-6 w-6 min-h-0\"\n                                                                                onClick={() => setEditingKey(null)}\n                                                                                title={t('common.cancel') || 'Cancel'}\n                                                                            >\n                                                                                <X size={14} strokeWidth={3} />\n                                                                            </button>\n                                                                        </div>\n                                                                    ) : (\n                                                                        <div className=\"flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity\">\n                                                                            <button\n                                                                                className=\"btn btn-ghost btn-xs text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-white/10 p-0 h-6 w-6 min-h-0\"\n                                                                                onClick={() => { setEditingKey(key); setEditingValue(val); }}\n                                                                                title={t('common.edit') || 'Edit'}\n                                                                            >\n                                                                                <Edit2 size={12} />\n                                                                            </button>\n                                                                            <button\n                                                                                className=\"btn btn-ghost btn-xs text-error hover:bg-red-50 dark:hover:bg-red-900/20 p-0 h-6 w-6 min-h-0\"\n                                                                                onClick={() => handleRemoveCustomMapping(key)}\n                                                                                title={t('common.delete') || 'Delete'}\n                                                                            >\n                                                                                <Trash2 size={12} />\n                                                                            </button>\n                                                                        </div>\n                                                                    )}\n                                                                </div>\n                                                            </div>\n                                                        ))\n                                                    ) : (\n                                                        <div className=\"col-span-full text-center py-4 text-gray-400 dark:text-gray-600 italic text-[11px]\">{t('proxy.router.no_custom_mapping')}</div>\n                                                    )}\n                                                </div>\n                                            </div>\n                                        </div>\n\n                                        {/* 添加映射表单 (置底单行) */}\n                                        <div className=\"w-full bg-gray-50/50 dark:bg-white/5 p-2.5 rounded-xl border border-gray-100 dark:border-white/5 shadow-inner\">\n                                            <div className=\"flex flex-col sm:flex-row items-center gap-3\">\n                                                <div className=\"flex items-center gap-1.5 shrink-0\">\n                                                    <Target size={14} className=\"text-gray-400 dark:text-gray-500\" />\n                                                    <span className=\"text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider\">{t('proxy.router.add_mapping')}</span>\n                                                </div>\n                                                <div className=\"flex-1 flex flex-col sm:flex-row gap-2 w-full\">\n                                                    <input\n                                                        id=\"custom-key\"\n                                                        type=\"text\"\n                                                        placeholder={t('proxy.router.original_placeholder') || \"Original (e.g. gpt-4 or gpt-4*)\"}\n                                                        className=\"input input-xs input-bordered flex-1 font-mono text-[11px] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all placeholder:text-gray-400 dark:placeholder:text-gray-600 h-8\"\n                                                    />\n                                                    <div className=\"w-full sm:w-48\">\n                                                        <GroupedSelect\n                                                            value={customMappingValue}\n                                                            onChange={setCustomMappingValue}\n                                                            options={customMappingOptions}\n                                                            placeholder={t('proxy.router.select_target_model') || 'Select Target Model'}\n                                                            className=\"font-mono text-[11px] h-8 dark:bg-gray-800\"\n                                                            allowCustomInput={true}\n                                                        />\n                                                    </div>\n                                                </div>\n                                                <button\n                                                    className=\"btn btn-xs sm:w-20 gap-1.5 shadow-md hover:shadow-lg transition-all bg-blue-600 hover:bg-blue-700 text-white border-none h-8\"\n                                                    onClick={() => {\n                                                        const k = (document.getElementById('custom-key') as HTMLInputElement).value;\n                                                        const v = customMappingValue;\n                                                        if (k && v) {\n                                                            handleMappingUpdate('custom', k, v);\n                                                            (document.getElementById('custom-key') as HTMLInputElement).value = '';\n                                                            setCustomMappingValue(''); // 清空选择\n                                                        }\n                                                    }}\n                                                >\n                                                    <Plus size={14} />\n                                                    {t('common.add')}\n                                                </button>\n                                            </div>\n                                        </div>\n                                    </div>\n                                </div>\n                            </div>\n                        </div>\n                    )\n                }\n\n                {/* 多协议支持信息 */}\n                {\n                    !configLoading && !configError && appConfig && (\n                        <div className=\"bg-white dark:bg-base-100 rounded-xl shadow-sm border border-gray-100 dark:border-base-200 overflow-hidden\">\n                            <div className=\"p-3\">\n                                <div className=\"flex items-center gap-3 mb-3\">\n                                    <div className=\"w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-md\">\n                                        <Code size={16} className=\"text-white\" />\n                                    </div>\n                                    <div>\n                                        <h3 className=\"text-base font-bold text-gray-900 dark:text-base-content\">\n                                            🔗 {t('proxy.multi_protocol.title')}\n                                        </h3>\n                                        <p className=\"text-[10px] text-gray-500 dark:text-gray-400\">\n                                            {t('proxy.multi_protocol.subtitle')}\n                                        </p>\n                                    </div>\n                                </div>\n\n                                <p className=\"text-xs text-gray-700 dark:text-gray-300 mb-4 leading-relaxed\">\n                                    {t('proxy.multi_protocol.description')}\n                                </p>\n\n                                <div className=\"grid grid-cols-1 md:grid-cols-3 gap-3\">\n                                    {/* OpenAI Card */}\n                                    <div\n                                        className={`p-3 rounded-xl border-2 transition-all cursor-pointer ${selectedProtocol === 'openai' ? 'border-blue-500 bg-blue-50/30 dark:bg-blue-900/10' : 'border-gray-100 dark:border-base-200 hover:border-blue-200'}`}\n                                        onClick={() => setSelectedProtocol('openai')}\n                                    >\n                                        <div className=\"flex items-center justify-between mb-2\">\n                                            <span className=\"text-xs font-bold text-blue-600\">{t('proxy.multi_protocol.openai_label')}</span>\n                                            <button onClick={(e) => {\n                                                e.stopPropagation();\n                                                const baseUrl = status.running ? status.base_url : `http://127.0.0.1:${appConfig.proxy.port || 8045}`;\n                                                copyToClipboardHandler(`${baseUrl}/v1`, 'openai');\n                                            }} className=\"btn btn-ghost btn-xs\">\n                                                {copied === 'openai' ? <CheckCircle size={14} /> : <div className=\"flex items-center gap-1 text-[10px] uppercase font-bold tracking-tighter\"><Copy size={12} /> {t('proxy.multi_protocol.copy_base', { defaultValue: 'Base' })}</div>}\n                                            </button>\n                                        </div>\n                                        <div className=\"space-y-1\">\n                                            <div className=\"flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5 rounded p-0.5 group\">\n                                                <code className=\"text-[10px] opacity-70\">/v1/chat/completions</code>\n                                                <button onClick={(e) => {\n                                                    e.stopPropagation();\n                                                    const baseUrl = status.running ? status.base_url : `http://127.0.0.1:${appConfig.proxy.port || 8045}`;\n                                                    copyToClipboardHandler(`${baseUrl}/v1/chat/completions`, 'openai-chat');\n                                                }} className=\"opacity-0 group-hover:opacity-100 transition-opacity\">\n                                                    {copied === 'openai-chat' ? <CheckCircle size={10} className=\"text-green-500\" /> : <Copy size={10} />}\n                                                </button>\n                                            </div>\n                                            <div className=\"flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5 rounded p-0.5 group\">\n                                                <code className=\"text-[10px] opacity-70\">/v1/completions</code>\n                                                <button onClick={(e) => {\n                                                    e.stopPropagation();\n                                                    const baseUrl = status.running ? status.base_url : `http://127.0.0.1:${appConfig.proxy.port || 8045}`;\n                                                    copyToClipboardHandler(`${baseUrl}/v1/completions`, 'openai-compl');\n                                                }} className=\"opacity-0 group-hover:opacity-100 transition-opacity\">\n                                                    {copied === 'openai-compl' ? <CheckCircle size={10} className=\"text-green-500\" /> : <Copy size={10} />}\n                                                </button>\n                                            </div>\n                                            <div className=\"flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5 rounded p-0.5 group\">\n                                                <code className=\"text-[10px] opacity-70 font-bold text-blue-500\">/v1/responses (Codex)</code>\n                                                <button onClick={(e) => {\n                                                    e.stopPropagation();\n                                                    const baseUrl = status.running ? status.base_url : `http://127.0.0.1:${appConfig.proxy.port || 8045}`;\n                                                    copyToClipboardHandler(`${baseUrl}/v1/responses`, 'openai-resp');\n                                                }} className=\"opacity-0 group-hover:opacity-100 transition-opacity\">\n                                                    {copied === 'openai-resp' ? <CheckCircle size={10} className=\"text-green-500\" /> : <Copy size={10} />}\n                                                </button>\n                                            </div>\n                                        </div>\n                                    </div>\n\n                                    {/* Anthropic Card */}\n                                    <div\n                                        className={`p-3 rounded-xl border-2 transition-all cursor-pointer ${selectedProtocol === 'anthropic' ? 'border-purple-500 bg-purple-50/30 dark:bg-purple-900/10' : 'border-gray-100 dark:border-base-200 hover:border-purple-200'}`}\n                                        onClick={() => setSelectedProtocol('anthropic')}\n                                    >\n                                        <div className=\"flex items-center justify-between mb-2\">\n                                            <span className=\"text-xs font-bold text-purple-600\">{t('proxy.multi_protocol.anthropic_label')}</span>\n                                            <button onClick={(e) => {\n                                                e.stopPropagation();\n                                                const baseUrl = status.running ? status.base_url : `http://127.0.0.1:${appConfig.proxy.port || 8045}`;\n                                                copyToClipboardHandler(`${baseUrl}/v1/messages`, 'anthropic');\n                                            }} className=\"btn btn-ghost btn-xs\">\n                                                {copied === 'anthropic' ? <CheckCircle size={14} /> : <Copy size={14} />}\n                                            </button>\n                                        </div>\n                                        <code className=\"text-[10px] block truncate bg-black/5 dark:bg-white/5 p-1 rounded\">/v1/messages</code>\n                                    </div>\n\n                                    {/* Gemini Card */}\n                                    <div\n                                        className={`p-3 rounded-xl border-2 transition-all cursor-pointer ${selectedProtocol === 'gemini' ? 'border-green-500 bg-green-50/30 dark:bg-green-900/10' : 'border-gray-100 dark:border-base-200 hover:border-green-200'}`}\n                                        onClick={() => setSelectedProtocol('gemini')}\n                                    >\n                                        <div className=\"flex items-center justify-between mb-2\">\n                                            <span className=\"text-xs font-bold text-green-600\">{t('proxy.multi_protocol.gemini_label')}</span>\n                                            <button onClick={(e) => {\n                                                e.stopPropagation();\n                                                const baseUrl = status.running ? status.base_url : `http://127.0.0.1:${appConfig.proxy.port || 8045}`;\n                                                copyToClipboardHandler(`${baseUrl}/v1beta/models`, 'gemini');\n                                            }} className=\"btn btn-ghost btn-xs\">\n                                                {copied === 'gemini' ? <CheckCircle size={14} /> : <Copy size={14} />}\n                                            </button>\n                                        </div>\n                                        <code className=\"text-[10px] block truncate bg-black/5 dark:bg-white/5 p-1 rounded\">/v1beta/models/...</code>\n                                    </div>\n                                </div>\n                            </div>\n                        </div>\n                    )\n                }\n\n\n                {/* 支持模型与集成 */}\n                {\n                    !configLoading && !configError && appConfig && (\n                        <div className=\"bg-white dark:bg-base-100 rounded-xl shadow-sm border border-gray-100 dark:border-base-200 overflow-hidden mt-4\">\n                            <div className=\"px-4 py-2.5 border-b border-gray-100 dark:border-base-200\">\n                                <h2 className=\"text-base font-bold text-gray-900 dark:text-base-content flex items-center gap-2\">\n                                    <Terminal size={18} />\n                                    {t('proxy.supported_models.title')}\n                                </h2>\n                            </div>\n\n                            <div className=\"grid grid-cols-1 lg:grid-cols-3 gap-0 lg:divide-x dark:divide-gray-700\">\n                                {/* 左侧：模型列表 */}\n                                <div className=\"col-span-2 p-0\">\n                                    <div className=\"overflow-x-auto\">\n                                        <table className=\"table w-full\">\n                                            <thead className=\"bg-gray-50/50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400\">\n                                                <tr>\n                                                    <th className=\"w-10 pl-3\"></th>\n                                                    <th className=\"text-[11px] font-medium\">{t('proxy.supported_models.model_name')}</th>\n                                                    <th className=\"text-[11px] font-medium\">{t('proxy.supported_models.model_id')}</th>\n                                                    <th className=\"text-[11px] hidden sm:table-cell font-medium\">{t('proxy.supported_models.description')}</th>\n                                                    <th className=\"text-[11px] w-20 text-center font-medium\">{t('proxy.supported_models.action')}</th>\n                                                </tr>\n                                            </thead>\n                                            <tbody>\n                                                {filteredModels.map((m) => (\n                                                    <tr\n                                                        key={m.id}\n                                                        className={`hover:bg-blue-50/50 dark:hover:bg-blue-900/10 cursor-pointer transition-colors ${selectedModelId === m.id ? 'bg-blue-50/80 dark:bg-blue-900/20' : ''}`}\n                                                        onClick={() => setSelectedModelId(m.id)}\n                                                    >\n                                                        <td className=\"pl-4 text-blue-500\">{m.icon}</td>\n                                                        <td className=\"font-bold text-xs\">{m.name}</td>\n                                                        <td className=\"font-mono text-[10px] text-gray-500\">{m.id}</td>\n                                                        <td className=\"text-[10px] text-gray-400 hidden sm:table-cell\">{m.desc}</td>\n                                                        <td className=\"text-center\">\n                                                            <button\n                                                                className=\"btn btn-ghost btn-xs text-blue-500\"\n                                                                onClick={(e) => {\n                                                                    e.stopPropagation();\n                                                                    copyToClipboardHandler(m.id, `model-${m.id}`);\n                                                                }}\n                                                            >\n                                                                {copied === `model-${m.id}` ? <CheckCircle size={14} /> : <div className=\"flex items-center gap-1 text-[10px] font-bold tracking-tight\"><Copy size={12} /> {t('common.copy')}</div>}\n                                                            </button>\n                                                        </td>\n                                                    </tr>\n                                                ))}\n                                            </tbody>\n                                        </table>\n                                    </div>\n                                </div>\n\n                                {/* 右侧：代码预览 */}\n                                <div className=\"col-span-1 bg-gray-900 text-blue-100 flex flex-col h-[400px] lg:h-auto\">\n                                    <div className=\"p-3 border-b border-gray-800 flex items-center justify-between\">\n                                        <span className=\"text-xs font-bold text-gray-400 uppercase tracking-wider\">{t('proxy.multi_protocol.quick_integration')}</span>\n                                        <div className=\"flex gap-2\">\n                                            {/* 这里可以放 cURL/Python 切换，或者直接默认显示 Python，根据 selectedProtocol 决定 */}\n                                            <span className=\"text-[10px] px-2 py-0.5 rounded bg-blue-500/20 text-blue-400 border border-blue-500/30\">\n                                                {selectedProtocol === 'anthropic' ? 'Python (Anthropic SDK)' : (selectedProtocol === 'gemini' ? 'Python (Google GenAI)' : 'Python (OpenAI SDK)')}\n                                            </span>\n                                        </div>\n                                    </div>\n                                    <div className=\"flex-1 relative overflow-hidden group\">\n                                        <div className=\"absolute inset-0 overflow-auto scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent\">\n                                            <pre className=\"p-4 text-[10px] font-mono leading-relaxed\">\n                                                {getPythonExample(selectedModelId)}\n                                            </pre>\n                                        </div>\n                                        <button\n                                            onClick={() => copyToClipboardHandler(getPythonExample(selectedModelId), 'example-code')}\n                                            className=\"absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors text-white opacity-0 group-hover:opacity-100\"\n                                        >\n                                            {copied === 'example-code' ? <CheckCircle size={16} /> : <Copy size={16} />}\n                                        </button>\n                                    </div>\n                                    <div className=\"p-3 bg-gray-800/50 border-t border-gray-800 text-[10px] text-gray-400\">\n                                        {t('proxy.multi_protocol.click_tip')}\n                                    </div>\n                                </div>\n                            </div>\n                        </div>\n                    )\n                }\n                {/* 各种对话框 */}\n                <ModalDialog\n                    isOpen={isResetConfirmOpen}\n                    title={t('proxy.dialog.reset_mapping_title') || '重置映射'}\n                    message={t('proxy.dialog.reset_mapping_msg') || '确定要重置所有模型映射为系统默认吗？'}\n                    type=\"confirm\"\n                    isDestructive={true}\n                    onConfirm={executeResetMapping}\n                    onCancel={() => setIsResetConfirmOpen(false)}\n                />\n\n                <ModalDialog\n                    isOpen={isRegenerateKeyConfirmOpen}\n                    title={t('proxy.dialog.regenerate_key_title') || t('proxy.dialog.confirm_regenerate')}\n                    message={t('proxy.dialog.regenerate_key_msg') || t('proxy.dialog.confirm_regenerate')}\n                    type=\"confirm\"\n                    isDestructive={true}\n                    onConfirm={executeGenerateApiKey}\n                    onCancel={() => setIsRegenerateKeyConfirmOpen(false)}\n                />\n\n                <ModalDialog\n                    isOpen={isClearBindingsConfirmOpen}\n                    title={t('proxy.dialog.clear_bindings_title') || '清除会话绑定'}\n                    message={t('proxy.dialog.clear_bindings_msg') || '确定要清除所有会话与账号的绑定映射吗？'}\n                    type=\"confirm\"\n                    isDestructive={true}\n                    onConfirm={executeClearSessionBindings}\n                    onCancel={() => setIsClearBindingsConfirmOpen(false)}\n                />\n\n                <ModalDialog\n                    isOpen={isClearRateLimitsConfirmOpen}\n                    title={t('proxy.dialog.clear_rate_limits_title') || '清除限流记录'}\n                    message={t('proxy.dialog.clear_rate_limits_confirm') || '确定要清除所有本地限流记录吗？'}\n                    type=\"confirm\"\n                    isDestructive={true}\n                    onConfirm={executeClearRateLimits}\n                    onCancel={() => setIsClearRateLimitsConfirmOpen(false)}\n                />\n\n                <ModalDialog\n                    isOpen={isPresetManagerOpen}\n                    title={t('proxy.router.manage_presets_title')}\n                    onConfirm={() => setIsPresetManagerOpen(false)}\n                    confirmText={t('common.close')}\n                    type=\"info\"\n                >\n                    <div className=\"space-y-6\">\n                        {/* Save Current Section */}\n                        <div className=\"space-y-3 p-4 bg-blue-50/50 dark:bg-blue-900/10 rounded-xl border border-blue-100 dark:border-blue-900/20\">\n                            <h3 className=\"text-sm font-bold text-gray-800 dark:text-gray-200 flex items-center gap-2\">\n                                <Save size={16} className=\"text-blue-500\" />\n                                {t('proxy.router.save_current_as_preset')}\n                            </h3>\n                            <div className=\"flex gap-2\">\n                                <input\n                                    type=\"text\"\n                                    value={newPresetName}\n                                    onChange={(e) => setNewPresetName(e.target.value)}\n                                    placeholder={t('proxy.router.preset_name_placeholder')}\n                                    className=\"input input-sm flex-1 border-gray-300 focus:border-blue-500\"\n                                />\n                                <button\n                                    onClick={handleSaveCurrentAsPreset}\n                                    disabled={!newPresetName.trim()}\n                                    className=\"btn btn-sm btn-primary text-white\"\n                                >\n                                    {t('common.save')}\n                                </button>\n                            </div>\n                            <p className=\"text-[10px] text-gray-500 dark:text-gray-400\">\n                                {t('proxy.router.save_hint')}\n                            </p>\n                        </div>\n\n                        {/* Existing Presets List */}\n                        <div className=\"space-y-3\">\n                            <h3 className=\"text-sm font-bold text-gray-800 dark:text-gray-200 px-1\">\n                                {t('proxy.router.your_presets')}\n                            </h3>\n                            <div className=\"max-h-[300px] overflow-y-auto space-y-2 pr-1\">\n                                {customPresets.length === 0 ? (\n                                    <div className=\"text-center py-8 text-gray-400 dark:text-gray-600 bg-gray-50 dark:bg-base-200 rounded-xl border border-dashed border-gray-200 dark:border-gray-700\">\n                                        <p>{t('proxy.router.no_custom_presets')}</p>\n                                    </div>\n                                ) : (\n                                    customPresets.map(preset => (\n                                        <div key={preset.id} className=\"flex items-center justify-between p-3 bg-white dark:bg-base-200 border border-gray-100 dark:border-gray-700 rounded-xl hover:shadow-sm transition-all group\">\n                                            <div className=\"flex-1 min-w-0\">\n                                                <div className=\"font-bold text-sm text-gray-800 dark:text-gray-200 truncate\">{preset.name}</div>\n                                                <div className=\"text-[10px] text-gray-400 dark:text-gray-500 truncate\">\n                                                    {Object.keys(preset.mappings).length} {t('proxy.router.mappings_count')}\n                                                </div>\n                                            </div>\n                                            <div className=\"flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity\">\n                                                <button\n                                                    onClick={() => handleDeletePreset(preset.id)}\n                                                    className=\"p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors\"\n                                                    title={t('common.delete')}\n                                                >\n                                                    <Trash2 size={16} />\n                                                </button>\n                                            </div>\n                                        </div>\n                                    ))\n                                )}\n                            </div>\n                        </div>\n                    </div>\n                </ModalDialog>\n            </div >\n        </div >\n    );\n}\n"
  },
  {
    "path": "src/pages/Dashboard.tsx",
    "content": "import { save } from '@tauri-apps/plugin-dialog';\nimport { AlertTriangle, ArrowRight, Bot, Download, RefreshCw, Sparkles, Users } from 'lucide-react';\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useNavigate } from 'react-router-dom';\nimport AddAccountDialog from '../components/accounts/AddAccountDialog';\nimport { showToast } from '../components/common/ToastContainer';\nimport BestAccounts from '../components/dashboard/BestAccounts';\nimport CurrentAccount from '../components/dashboard/CurrentAccount';\nimport { exportAccounts } from '../services/accountService';\nimport { useAccountStore } from '../stores/useAccountStore';\nimport { Account } from '../types/account';\nimport { isTauri } from '../utils/env';\nimport { request as invoke } from '../utils/request';\n\nfunction Dashboard() {\n    const { t } = useTranslation();\n    const navigate = useNavigate();\n    const {\n        accounts,\n        currentAccount,\n        fetchAccounts,\n        fetchCurrentAccount,\n        switchAccount,\n        addAccount,\n        refreshQuota,\n        loading\n    } = useAccountStore();\n\n    useEffect(() => {\n        fetchAccounts();\n        fetchCurrentAccount();\n    }, []);\n\n    // 计算统计数据\n    const stats = useMemo(() => {\n        const getGeminiProQuota = (a: Account) =>\n            (a.quota?.models || [])\n                .filter(m =>\n                    m.name.toLowerCase() === 'gemini-3-pro-high'\n                    || m.name.toLowerCase() === 'gemini-3-pro-low'\n                    || m.name.toLowerCase() === 'gemini-3.1-pro-high'\n                    || m.name.toLowerCase() === 'gemini-3.1-pro-low'\n                )\n                .reduce((best, model) => Math.max(best, model.percentage || 0), 0);\n\n        const geminiQuotas = accounts\n            .map(a => getGeminiProQuota(a))\n            .filter(q => q > 0);\n\n        const geminiImageQuotas = accounts\n            .map(a => a.quota?.models.find(m =>\n                m.name.toLowerCase() === 'gemini-3.1-flash-image' ||\n                m.name.toLowerCase() === 'gemini-3-pro-image'\n            )?.percentage || 0)\n            .filter(q => q > 0);\n\n        const claudeQuotas = accounts\n            .map(a => a.quota?.models.find(m => m.name.toLowerCase() === 'claude-sonnet-4-6' || m.name.toLowerCase() === 'claude-sonnet-4-5')?.percentage || 0)\n            .filter(q => q > 0);\n\n        const lowQuotaCount = accounts.filter(a => {\n            if (a.quota?.is_forbidden) return false;\n            const gemini = getGeminiProQuota(a);\n            const claude = a.quota?.models.find(m => m.name.toLowerCase() === 'claude-sonnet-4-6' || m.name.toLowerCase() === 'claude-sonnet-4-5')?.percentage || 0;\n            return gemini < 20 || claude < 20;\n        }).length;\n\n        return {\n            total: accounts.length,\n            avgGemini: geminiQuotas.length > 0\n                ? Math.round(geminiQuotas.reduce((a, b) => a + b, 0) / geminiQuotas.length)\n                : 0,\n            avgGeminiImage: geminiImageQuotas.length > 0\n                ? Math.round(geminiImageQuotas.reduce((a, b) => a + b, 0) / geminiImageQuotas.length)\n                : 0,\n            avgClaude: claudeQuotas.length > 0\n                ? Math.round(claudeQuotas.reduce((a, b) => a + b, 0) / claudeQuotas.length)\n                : 0,\n            lowQuota: lowQuotaCount,\n        };\n    }, [accounts]);\n\n    const isSwitchingRef = useRef(false);\n\n    const handleSwitch = async (accountId: string) => {\n        if (loading || isSwitchingRef.current) return;\n\n        isSwitchingRef.current = true;\n        console.log('[Dashboard] handleSwitch called for', accountId);\n        try {\n            await switchAccount(accountId);\n            showToast(t('dashboard.toast.switch_success'), 'success');\n        } catch (error) {\n            console.error('切换账号失败:', error);\n            showToast(`${t('dashboard.toast.switch_error')}: ${error}`, 'error');\n        } finally {\n            setTimeout(() => {\n                isSwitchingRef.current = false;\n            }, 1000);\n        }\n    };\n\n    const handleAddAccount = async (email: string, refreshToken: string) => {\n        await addAccount(email, refreshToken);\n        await fetchAccounts(); // 刷新列表\n    };\n\n    const [isRefreshing, setIsRefreshing] = useState(false);\n\n    const handleRefreshCurrent = async () => {\n        if (!currentAccount) return;\n\n        setIsRefreshing(true);\n        try {\n            await refreshQuota(currentAccount.id);\n            // 刷新成功后重新获取最新数据\n            await fetchCurrentAccount();\n            showToast(t('dashboard.toast.refresh_success'), 'success');\n        } catch (error) {\n            console.error('[Dashboard] Refresh failed:', error);\n            showToast(`${t('dashboard.toast.refresh_error')}: ${error}`, 'error');\n        } finally {\n            setIsRefreshing(false);\n        }\n    };\n\n    const exportAccountsToJson = async (accountsToExport: Account[]) => {\n        try {\n            if (accountsToExport.length === 0) {\n                showToast(t('dashboard.toast.export_no_accounts'), 'warning');\n                return;\n            }\n\n            // Get export data from API (contains refresh_token)\n            const accountIds = accountsToExport.map(acc => acc.id);\n            const response = await exportAccounts(accountIds);\n\n            if (!response.accounts || response.accounts.length === 0) {\n                showToast(t('dashboard.toast.export_no_accounts'), 'warning');\n                return;\n            }\n\n            const exportData = response.accounts;\n            const content = JSON.stringify(exportData, null, 2);\n            const fileName = `antigravity_accounts_${new Date().toISOString().split('T')[0]}.json`;\n\n            if (isTauri()) {\n                const path = await save({\n                    filters: [{\n                        name: 'JSON',\n                        extensions: ['json']\n                    }],\n                    defaultPath: fileName\n                });\n\n                if (!path) return;\n\n                await invoke('save_text_file', { path, content });\n                showToast(t('dashboard.toast.export_success', { path }), 'success');\n            } else {\n                // Web 模式：使用浏览器下载\n                const blob = new Blob([content], { type: 'application/json' });\n                const url = URL.createObjectURL(blob);\n                const a = document.createElement('a');\n                a.href = url;\n                a.download = fileName;\n                document.body.appendChild(a);\n                a.click();\n                document.body.removeChild(a);\n                URL.revokeObjectURL(url);\n                showToast(t('dashboard.toast.export_success', { path: fileName }), 'success');\n            }\n        } catch (error: any) {\n            console.error('Export failed:', error);\n            showToast(`${t('dashboard.toast.export_error')}: ${error.toString()}`, 'error');\n        }\n    };\n\n    const handleExport = () => {\n        exportAccountsToJson(accounts);\n    };\n\n    return (\n        <div className=\"h-full w-full overflow-y-auto\">\n            <div\n                className=\"p-5 space-y-4 max-w-7xl mx-auto\"\n                onMouseMove={() => console.log('Mouse moving over Dashboard')}\n                style={{ position: 'relative', zIndex: 1 }}\n            >\n                {/* 问候语和操作按钮 */}\n                <div\n                    className=\"flex justify-between items-center\"\n                >\n                    <div>\n                        <h1 className=\"text-2xl font-bold text-gray-900 dark:text-base-content\">\n                            {currentAccount\n                                ? t('dashboard.hello').replace('用户', currentAccount.name || currentAccount.email.split('@')[0])\n                                : t('dashboard.hello')\n                            }\n                        </h1>\n                    </div>\n                    <div className=\"flex gap-2\">\n                        <AddAccountDialog onAdd={handleAddAccount} />\n                        <button\n                            className={`px-3 py-1.5 bg-blue-500 text-white text-xs font-medium rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-1.5 shadow-sm ${isRefreshing || !currentAccount ? 'opacity-70 cursor-not-allowed' : ''}`}\n                            onClick={handleRefreshCurrent}\n                            disabled={isRefreshing || !currentAccount}\n                            title={isRefreshing ? t('dashboard.refreshing') : t('dashboard.refresh_quota')}\n                        >\n                            <RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />\n                            <span className=\"hidden sm:inline\">{isRefreshing ? t('dashboard.refreshing') : t('dashboard.refresh_quota')}</span>\n                        </button>\n                    </div>\n                </div>\n\n                {/* 统计卡片 - 5 columns on medium screens and up */}\n                <div className=\"grid grid-cols-2 md:grid-cols-5 gap-3\">\n                    <div className=\"bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200\">\n                        <div className=\"flex items-center justify-between mb-2\">\n                            <div className=\"p-1.5 bg-blue-50 dark:bg-blue-900/20 rounded-md\">\n                                <Users className=\"w-4 h-4 text-blue-500 dark:text-blue-400\" />\n                            </div>\n                        </div>\n                        <div className=\"text-2xl font-bold text-gray-900 dark:text-base-content mb-0.5\">{stats.total}</div>\n                        <div className=\"text-xs text-gray-500 dark:text-gray-400\">{t('dashboard.total_accounts')}</div>\n                    </div>\n\n                    <div className=\"bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200\">\n                        <div className=\"flex items-center justify-between mb-2\">\n                            <div className=\"p-1.5 bg-green-50 dark:bg-green-900/20 rounded-md\">\n                                <Sparkles className=\"w-4 h-4 text-green-500 dark:text-green-400\" />\n                            </div>\n                        </div>\n                        <div className=\"text-2xl font-bold text-gray-900 dark:text-base-content mb-0.5\">{stats.avgGemini}%</div>\n                        <div className=\"text-xs text-gray-500 dark:text-gray-400\">{t('dashboard.avg_gemini')}</div>\n                        {stats.avgGemini > 0 && (\n                            <div className={`text-[10px] mt-1 ${stats.avgGemini >= 50 ? 'text-green-600 dark:text-green-400' : 'text-orange-600 dark:text-orange-400'}`}>\n                                {stats.avgGemini >= 50 ? t('dashboard.quota_sufficient') : t('dashboard.quota_low')}\n                            </div>\n                        )}\n                    </div>\n\n                    <div className=\"bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200\">\n                        <div className=\"flex items-center justify-between mb-2\">\n                            <div className=\"p-1.5 bg-purple-50 dark:bg-purple-900/20 rounded-md\">\n                                <Sparkles className=\"w-4 h-4 text-purple-500 dark:text-purple-400\" />\n                            </div>\n                        </div>\n                        <div className=\"text-2xl font-bold text-gray-900 dark:text-base-content mb-0.5\">{stats.avgGeminiImage}%</div>\n                        <div className=\"text-xs text-gray-500 dark:text-gray-400\">{t('dashboard.avg_gemini_image')}</div>\n                        {stats.avgGeminiImage > 0 && (\n                            <div className={`text-[10px] mt-1 ${stats.avgGeminiImage >= 50 ? 'text-green-600 dark:text-green-400' : 'text-orange-600 dark:text-orange-400'}`}>\n                                {stats.avgGeminiImage >= 50 ? t('dashboard.quota_sufficient') : t('dashboard.quota_low')}\n                            </div>\n                        )}\n                    </div>\n\n                    <div className=\"bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200\">\n                        <div className=\"flex items-center justify-between mb-2\">\n                            <div className=\"p-1.5 bg-cyan-50 dark:bg-cyan-900/20 rounded-md\">\n                                <Bot className=\"w-4 h-4 text-cyan-500 dark:text-cyan-400\" />\n                            </div>\n                        </div>\n                        <div className=\"text-2xl font-bold text-gray-900 dark:text-base-content mb-0.5\">{stats.avgClaude}%</div>\n                        <div className=\"text-xs text-gray-500 dark:text-gray-400\">{t('dashboard.avg_claude')}</div>\n                        {stats.avgClaude > 0 && (\n                            <div className={`text-[10px] mt-1 ${stats.avgClaude >= 50 ? 'text-green-600 dark:text-green-400' : 'text-orange-600 dark:text-orange-400'}`}>\n                                {stats.avgClaude >= 50 ? t('dashboard.quota_sufficient') : t('dashboard.quota_low')}\n                            </div>\n                        )}\n                    </div>\n\n                    <div className=\"bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200\">\n                        <div className=\"flex items-center justify-between mb-2\">\n                            <div className=\"p-1.5 bg-orange-50 dark:bg-orange-900/20 rounded-md\">\n                                <AlertTriangle className=\"w-4 h-4 text-orange-500 dark:text-orange-400\" />\n                            </div>\n                        </div>\n                        <div className=\"text-2xl font-bold text-gray-900 dark:text-base-content mb-0.5\">{stats.lowQuota}</div>\n                        <div className=\"text-xs text-gray-500 dark:text-gray-400\">{t('dashboard.low_quota_accounts')}</div>\n                        <div className=\"text-[10px] text-gray-400 dark:text-gray-500 mt-1\">{t('dashboard.quota_desc')}</div>\n                    </div>\n                </div>\n\n                {/* 双栏布局 */}\n                <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n                    <CurrentAccount\n                        account={currentAccount}\n                        onSwitch={() => navigate('/accounts')}\n                    />\n                    <BestAccounts\n                        accounts={accounts}\n                        currentAccountId={currentAccount?.id}\n                        onSwitch={handleSwitch}\n                    />\n                </div>\n\n                {/* 快速链接 */}\n                <div className=\"grid grid-cols-2 gap-3\">\n                    <button\n                        className=\"bg-indigo-50 dark:bg-indigo-900/20 rounded-lg p-3 shadow-sm border border-indigo-100 dark:border-indigo-900/30 hover:border-indigo-300 dark:hover:border-indigo-700 hover:shadow-md transition-all flex items-center justify-between group\"\n                        onClick={() => navigate('/accounts')}\n                    >\n                        <span className=\"text-indigo-700 dark:text-indigo-300 font-medium text-sm\">{t('dashboard.view_all_accounts')}</span>\n                        <ArrowRight className=\"w-4 h-4 text-indigo-400 dark:text-indigo-500 group-hover:text-indigo-600 dark:group-hover:text-indigo-300 group-hover:translate-x-1 transition-all\" />\n                    </button>\n                    <button\n                        className=\"bg-purple-50 dark:bg-purple-900/20 rounded-lg p-3 shadow-sm border border-purple-100 dark:border-purple-900/30 hover:border-purple-300 dark:hover:border-purple-700 hover:shadow-md transition-all flex items-center justify-between group\"\n                        onClick={handleExport}\n                    >\n                        <span className=\"text-purple-700 dark:text-purple-300 font-medium text-sm\">{t('dashboard.export_data')}</span>\n                        <Download className=\"w-4 h-4 text-purple-400 dark:text-purple-500 group-hover:text-purple-600 dark:group-hover:text-purple-300 transition-all\" />\n                    </button>\n                </div>\n            </div>\n        </div>\n    );\n}\n\nexport default Dashboard;\n"
  },
  {
    "path": "src/pages/Monitor.tsx",
    "content": "import React from 'react';\nimport { ProxyMonitor } from '../components/proxy/ProxyMonitor';\n\nconst Monitor: React.FC = () => {\n    return (\n        <div className=\"h-full flex flex-col p-5 gap-4 max-w-7xl mx-auto w-full\">\n            <ProxyMonitor className=\"flex-1\" />\n        </div>\n    );\n};\n\nexport default Monitor;"
  },
  {
    "path": "src/pages/Security.tsx",
    "content": "import React, { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Shield, Lock, FileText, Settings, Activity, RefreshCw } from 'lucide-react';\nimport { IpAccessLogs } from '../components/security/IpAccessLogs';\nimport { BlacklistManager } from '../components/security/BlacklistManager';\nimport { WhitelistManager } from '../components/security/WhitelistManager';\nimport { SecurityConfig } from '../components/security/SecurityConfig';\nimport { IpStatistics } from '../components/security/IpStatistics';\n\nconst Security: React.FC = () => {\n    const { t } = useTranslation();\n    const [activeTab, setActiveTab] = useState<'logs' | 'stats' | 'blacklist' | 'whitelist' | 'config'>('logs');\n    const [refreshKey, setRefreshKey] = useState(0);\n\n    const handleRefresh = () => {\n        setRefreshKey(prev => prev + 1);\n    };\n\n    const renderContent = () => {\n        switch (activeTab) {\n            case 'logs':\n                return <IpAccessLogs refreshKey={refreshKey} />;\n            case 'stats':\n                return <IpStatistics refreshKey={refreshKey} />;\n            case 'blacklist':\n                return <BlacklistManager refreshKey={refreshKey} />;\n            case 'whitelist':\n                return <WhitelistManager refreshKey={refreshKey} />;\n            case 'config':\n                return <SecurityConfig />;\n            default:\n                return <IpAccessLogs refreshKey={refreshKey} />;\n        }\n    };\n\n    const tabs = [\n        { id: 'logs', label: t('security.tab_logs'), icon: FileText },\n        { id: 'stats', label: t('security.tab_stats'), icon: Activity },\n        { id: 'blacklist', label: t('security.tab_blacklist'), icon: Shield },\n        { id: 'whitelist', label: t('security.tab_whitelist'), icon: Lock },\n        { id: 'config', label: t('security.tab_config'), icon: Settings },\n    ];\n\n    return (\n        <div className=\"h-full flex flex-col p-5 gap-4 max-w-7xl mx-auto w-full\">\n            <div className=\"flex items-center justify-between\">\n                <h1 className=\"text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2\">\n                    <Shield className=\"text-blue-500\" />\n                    {t('security.title')}\n                </h1>\n                {activeTab !== 'config' && (\n                    <button\n                        onClick={handleRefresh}\n                        className=\"btn btn-sm btn-ghost gap-2 text-gray-600 dark:text-gray-400\"\n                        title={t('security.refresh_data')}\n                    >\n                        <RefreshCw size={16} />\n                        {t('security.refresh')}\n                    </button>\n                )}\n            </div>\n\n            <div className=\"bg-white dark:bg-base-100 rounded-xl shadow-sm border border-gray-100 dark:border-base-200 mt-2\">\n                <div className=\"flex border-b border-gray-100 dark:border-base-200\">\n                    {tabs.map((tab) => (\n                        <button\n                            key={tab.id}\n                            onClick={() => setActiveTab(tab.id as any)}\n                            className={`flex items-center gap-2 px-6 py-4 text-sm font-medium transition-colors relative ${activeTab === tab.id\n                                ? 'text-blue-600 dark:text-blue-400'\n                                : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'\n                                }`}\n                        >\n                            <tab.icon size={18} />\n                            {tab.label}\n                            {activeTab === tab.id && (\n                                <div className=\"absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400\" />\n                            )}\n                        </button>\n                    ))}\n                </div>\n                <div className=\"p-0\">\n                    {/* Content is rendered here, often components handle their own padding/layout */}\n                </div>\n            </div>\n\n            <div className=\"flex-1 overflow-hidden flex flex-col bg-white dark:bg-base-100 rounded-xl shadow-sm border border-gray-100 dark:border-base-200\">\n                {renderContent()}\n            </div>\n        </div>\n    );\n};\n\nexport default Security;\n"
  },
  {
    "path": "src/pages/Settings.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { Save, Github, User, MessageCircle, ExternalLink, RefreshCw, Heart, Coffee, LayoutDashboard, Users, Network, Activity, BarChart3, Settings as SettingsIcon, Lock, CheckCircle2, Globe, Send } from 'lucide-react';\nimport { request as invoke } from '../utils/request';\nimport { open } from '@tauri-apps/plugin-dialog';\nimport { useConfigStore } from '../stores/useConfigStore';\nimport { AppConfig } from '../types/config';\nimport ModalDialog from '../components/common/ModalDialog';\nimport { showToast } from '../components/common/ToastContainer';\nimport QuotaProtection from '../components/settings/QuotaProtection';\n// import SmartWarmup from '../components/settings/SmartWarmup';\nimport PinnedQuotaModels from '../components/settings/PinnedQuotaModels';\nimport { useDebugConsole } from '../stores/useDebugConsole';\n\nimport { useTranslation } from 'react-i18next';\nimport { isTauri } from '../utils/env';\nimport { relaunch } from '@tauri-apps/plugin-process';\n\nimport DebugConsole from '../components/debug/DebugConsole';\nimport ProxyPoolSettings from '../components/settings/ProxyPoolSettings';\n\n\nfunction Settings() {\n    const { t, i18n } = useTranslation();\n    const { config, loadConfig, saveConfig, updateLanguage, updateTheme } = useConfigStore();\n    const { enable, disable, isEnabled } = useDebugConsole();\n    const [activeTab, setActiveTab] = useState<'general' | 'account' | 'proxy' | 'advanced' | 'debug' | 'about'>('general');\n    const [formData, setFormData] = useState<AppConfig>({\n        language: 'zh',\n        theme: 'system',\n        auto_refresh: false,\n        refresh_interval: 15,\n        auto_sync: false,\n        sync_interval: 5,\n        proxy: {\n            enabled: false,\n            port: 8080,\n            api_key: '',\n            auto_start: false,\n            request_timeout: 120,\n            enable_logging: false,\n            upstream_proxy: {\n                enabled: false,\n                url: ''\n            },\n            debug_logging: {\n                enabled: false,\n                output_dir: undefined\n            } as { enabled: boolean; output_dir?: string },\n            proxy_pool: {\n                enabled: false,\n                proxies: [],\n                health_check_interval: 300,\n                auto_failover: true,\n                strategy: 'priority',\n                account_bindings: {}\n            }\n        },\n        scheduled_warmup: {\n            enabled: false,\n            monitored_models: []\n        },\n        quota_protection: {\n            enabled: false,\n            threshold_percentage: 10,\n            monitored_models: []\n        },\n        pinned_quota_models: {\n            models: ['gemini-3-pro-high', 'gemini-3-flash', 'gemini-3-pro-image', 'claude-opus-4-6-thinking']\n        },\n        cloudflared: {\n            enabled: false,\n            mode: 'quick',\n            port: 7860,\n            use_http2: true\n        },\n        circuit_breaker: {\n            enabled: false,\n            backoff_steps: [30, 60, 120, 300, 600]\n        },\n        hidden_menu_items: [],  // 菜单显示设置：默认不隐藏任何菜单项\n\n    });\n\n    // Dialog state\n    // Dialog state\n    const [isClearLogsOpen, setIsClearLogsOpen] = useState(false);\n    const [isSupportModalOpen, setIsSupportModalOpen] = useState(false);\n    const [dataDirPath, setDataDirPath] = useState<string>('~/.antigravity_tools/');\n\n    // Antigravity cache clearing state\n    const [isClearCacheOpen, setIsClearCacheOpen] = useState(false);\n    const [cachePaths, setCachePaths] = useState<string[]>([]);\n    const [isClearingCache, setIsClearingCache] = useState(false);\n\n    // Update check state\n    const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);\n    const [updateInfo, setUpdateInfo] = useState<{\n        hasUpdate: boolean;\n        latestVersion: string;\n        currentVersion: string;\n        downloadUrl: string;\n        source?: string;\n    } | null>(null);\n\n    // Homebrew Cask state\n    const [isBrewInstalled, setIsBrewInstalled] = useState(false);\n    const [isBrewUpgrading, setIsBrewUpgrading] = useState(false);\n    const [isBrewConfirmOpen, setIsBrewConfirmOpen] = useState(false);\n    const [isBrewSuccessOpen, setIsBrewSuccessOpen] = useState(false);\n\n\n    useEffect(() => {\n        loadConfig();\n\n        // 获取真实数据目录路径\n        invoke<string>('get_data_dir_path')\n            .then(path => setDataDirPath(path))\n            .catch(err => console.error('Failed to get data dir:', err));\n\n        // 加载更新设置\n        invoke<{ auto_check: boolean; last_check_time: number; check_interval_hours: number }>('get_update_settings')\n            .then(settings => {\n                setFormData(prev => ({\n                    ...prev,\n                    auto_check_update: settings.auto_check,\n                    update_check_interval: settings.check_interval_hours\n                }));\n            })\n            .catch(err => console.error('Failed to load update settings:', err));\n\n        // 获取真实的开机自启状态\n        invoke<boolean>('is_auto_launch_enabled')\n            .then(enabled => {\n                setFormData(prev => ({ ...prev, auto_launch: enabled }));\n            })\n            .catch(err => console.error('Failed to get auto launch status:', err));\n\n        // 检测是否通过 Homebrew Cask 安装 (仅 Tauri 环境)\n        if (isTauri()) {\n            invoke<boolean>('check_homebrew_installation')\n                .then(installed => setIsBrewInstalled(installed))\n                .catch(err => console.error('Failed to check Homebrew installation:', err));\n        }\n\n    }, [loadConfig]);\n\n    useEffect(() => {\n        if (config) {\n            setFormData(config);\n        }\n    }, [config]);\n\n    // 删除自动启用调试控制台的逻辑 - 改为用户手动控制\n\n    const handleSave = async () => {\n        try {\n            // 校验：如果启用了上游代理但没有填写地址，给出提示\n            const proxyEnabled = formData.proxy?.upstream_proxy?.enabled;\n            const proxyUrl = formData.proxy?.upstream_proxy?.url?.trim();\n            if (proxyEnabled && !proxyUrl) {\n                showToast(t('proxy.config.upstream_proxy.validation_error'), 'error');\n                return;\n            }\n\n            await saveConfig(formData);\n            showToast(t('common.saved'), 'success');\n\n            // 如果修改了代理配置，提示用户需要重启\n            if (proxyEnabled && proxyUrl) {\n                showToast(t('proxy.config.upstream_proxy.restart_hint'), 'info');\n            }\n        } catch (error) {\n            showToast(`${t('common.error')}: ${error}`, 'error');\n        }\n    };\n\n    const confirmClearLogs = async () => {\n        try {\n            await invoke('clear_log_cache');\n            showToast(t('settings.advanced.logs_cleared'), 'success');\n        } catch (error) {\n            showToast(`${t('common.error')}: ${error}`, 'error');\n        }\n        setIsClearLogsOpen(false);\n    };\n\n    const handleOpenDataDir = async () => {\n        try {\n            await invoke('open_data_folder');\n        } catch (error) {\n            showToast(`${t('common.error')}: ${error}`, 'error');\n        }\n    };\n\n    const handleSelectExportPath = async () => {\n        try {\n            // @ts-ignore\n            const selected = await open({\n                directory: true,\n                multiple: false,\n                title: t('settings.advanced.export_path'),\n            });\n            if (selected && typeof selected === 'string') {\n                setFormData({ ...formData, default_export_path: selected });\n            }\n        } catch (error) {\n            showToast(`${t('common.error')}: ${error}`, 'error');\n        }\n    };\n\n    const handleSelectAntigravityPath = async () => {\n        try {\n            const selected = await open({\n                directory: false,\n                multiple: false,\n                title: t('settings.advanced.antigravity_path_select'),\n            });\n            if (selected && typeof selected === 'string') {\n                setFormData({ ...formData, antigravity_executable: selected });\n            }\n        } catch (error) {\n            showToast(`${t('common.error')}: ${error}`, 'error');\n        }\n    };\n\n    const handleSelectDebugLogDir = async () => {\n        try {\n            const selected = await open({\n                directory: true,\n                multiple: false,\n                title: t('settings.advanced.debug_log_dir_select'),\n            });\n            if (selected && typeof selected === 'string') {\n                setFormData({\n                    ...formData,\n                    proxy: {\n                        ...formData.proxy,\n                        debug_logging: {\n                            enabled: formData.proxy?.debug_logging?.enabled ?? false,\n                            output_dir: selected,\n                        },\n                    },\n                });\n            }\n        } catch (error) {\n            showToast(`${t('common.error')}: ${error}`, 'error');\n        }\n    };\n\n    const handleDetectAntigravityPath = async () => {\n        try {\n            const command = isTauri() ? 'get_antigravity_path' : 'get_antigravity_path'; // 后端已统一\n            const path = await invoke<string>(command, { bypassConfig: true });\n            setFormData({ ...formData, antigravity_executable: path });\n            showToast(t('settings.advanced.antigravity_path_detected'), 'success');\n        } catch (error) {\n            showToast(`${t('common.error')}: ${error}`, 'error');\n        }\n    };\n\n    const handleCheckUpdate = async () => {\n        setIsCheckingUpdate(true);\n        setUpdateInfo(null);\n        try {\n            const result = await invoke<{\n                has_update: boolean;\n                latest_version: string;\n                current_version: string;\n                download_url: string;\n                source?: string;\n            }>('check_for_updates');\n\n            setUpdateInfo({\n                hasUpdate: result.has_update,\n                latestVersion: result.latest_version,\n                currentVersion: result.current_version,\n                downloadUrl: result.download_url,\n                source: result.source,\n            });\n\n            if (result.has_update) {\n                const sourceMsg = result.source && result.source !== 'GitHub API' ? ` (via ${result.source})` : '';\n                showToast(t('settings.about.new_version_available', { version: result.latest_version }) + sourceMsg, 'info');\n            } else {\n                showToast(t('settings.about.latest_version'), 'success');\n            }\n        } catch (error) {\n            showToast(`${t('settings.about.update_check_failed')}: ${error}`, 'error');\n        } finally {\n            setIsCheckingUpdate(false);\n        }\n    };\n\n    const handleBrewUpgrade = async () => {\n        setIsBrewConfirmOpen(false);\n        setIsBrewUpgrading(true);\n        try {\n            await invoke<string>('brew_upgrade_cask');\n            setUpdateInfo(null);\n            setIsBrewSuccessOpen(true);\n        } catch (error) {\n            const errKey = String(error);\n            const errMsg = t(`settings.about.brew_error_${errKey}`, t('settings.about.brew_upgrade_failed'));\n            showToast(errMsg, 'error');\n        } finally {\n            setIsBrewUpgrading(false);\n        }\n    };\n\n    // Handle opening cache clear dialog\n    const handleOpenClearCacheDialog = async () => {\n        try {\n            const paths = await invoke<string[]>('get_antigravity_cache_paths');\n            setCachePaths(paths);\n            setIsClearCacheOpen(true);\n        } catch (error) {\n            // If no cache paths found, still allow opening the dialog\n            setCachePaths([]);\n            setIsClearCacheOpen(true);\n        }\n    };\n\n    // Handle clearing Antigravity cache\n    const confirmClearAntigravityCache = async () => {\n        setIsClearingCache(true);\n        try {\n            const result = await invoke<{\n                cleared_paths: string[];\n                total_size_freed: number;\n                errors: string[];\n            }>('clear_antigravity_cache');\n\n            const sizeMB = (result.total_size_freed / 1024 / 1024).toFixed(2);\n\n            if (result.cleared_paths.length > 0) {\n                showToast(t('settings.advanced.cache_cleared_success', { size: sizeMB }), 'success');\n            } else if (result.errors.length > 0) {\n                showToast(`${t('common.error')}: ${result.errors[0]}`, 'error');\n            } else {\n                showToast(t('settings.advanced.cache_not_found'), 'info');\n            }\n        } catch (error) {\n            showToast(`${t('common.error')}: ${error}`, 'error');\n        } finally {\n            setIsClearingCache(false);\n            setIsClearCacheOpen(false);\n        }\n    };\n\n    return (\n        <div className=\"h-full w-full overflow-y-auto\">\n            <div className=\"p-5 space-y-4 max-w-7xl mx-auto\">\n                {/* 顶部工具栏：Tab 导航和保存按钮 */}\n                <div className=\"flex justify-between items-center\">\n                    {/* Tab 导航 - 采用顶部导航栏样式：外层灰色容器 */}\n                    <div className=\"flex items-center gap-1 bg-gray-100 dark:bg-base-200 rounded-full p-1 w-fit\">\n                        <button\n                            className={`px-6 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'general'\n                                ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'\n                                : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'\n                                }`}\n                            onClick={() => setActiveTab('general')}\n                        >\n                            {t('settings.tabs.general')}\n                        </button>\n                        <button\n                            className={`px-6 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'account'\n                                ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'\n                                : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'\n                                }`}\n                            onClick={() => setActiveTab('account')}\n                        >\n                            {t('settings.tabs.account')}\n                        </button>\n                        <button\n                            className={`px-6 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'proxy'\n                                ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'\n                                : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'\n                                }`}\n                            onClick={() => setActiveTab('proxy')}\n                        >\n                            {t('settings.tabs.proxy')}\n                        </button>\n                        <button\n                            className={`px-6 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'advanced'\n                                ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'\n                                : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'\n                                }`}\n                            onClick={() => setActiveTab('advanced')}\n                        >\n                            {t('settings.tabs.advanced')}\n                        </button>\n                        <button\n                            className={`px-6 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'debug'\n                                ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'\n                                : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'\n                                }`}\n                            onClick={() => setActiveTab('debug')}\n                        >\n                            {t('settings.tabs.debug')}\n                        </button>\n                        <button\n                            className={`px-6 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'about'\n                                ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'\n                                : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'\n                                }`}\n                            onClick={() => setActiveTab('about')}\n                        >\n                            {t('settings.tabs.about')}\n                        </button>\n                    </div>\n\n                    <button\n                        className=\"px-4 py-2 bg-blue-500 text-white text-sm rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2 shadow-sm\"\n                        onClick={handleSave}\n                    >\n                        <Save className=\"w-4 h-4\" />\n                        {t('settings.save')}\n                    </button>\n                </div>\n\n                {/* 设置表单 */}\n                <div className=\"bg-white dark:bg-base-100 rounded-2xl p-6 shadow-sm border border-gray-100 dark:border-base-200\">\n                    {/* 通用设置 */}\n                    {activeTab === 'general' && (\n                        <div className=\"space-y-6\">\n                            <h2 className=\"text-lg font-semibold text-gray-900 dark:text-base-content\">{t('settings.general.title')}</h2>\n\n                            {/* 语言选择 */}\n                            <div>\n                                <label className=\"block text-sm font-medium text-gray-900 dark:text-base-content mb-2\">{t('settings.general.language')}</label>\n                                <select\n                                    className=\"w-full px-4 py-4 border border-gray-200 dark:border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 dark:text-base-content bg-gray-50 dark:bg-base-200\"\n                                    value={formData.language}\n                                    onChange={(e) => {\n                                        const newLang = e.target.value;\n                                        setFormData({ ...formData, language: newLang });\n                                        i18n.changeLanguage(newLang);\n                                        updateLanguage(newLang);\n                                    }}\n                                >\n                                    <option value=\"zh\">简体中文</option>\n                                    <option value=\"zh-TW\">繁體中文</option>\n                                    <option value=\"en\">English</option>\n                                    <option value=\"ja\">日本語</option>\n                                    <option value=\"tr\">Türkçe</option>\n                                    <option value=\"vi\">Tiếng Việt</option>\n                                    <option value=\"pt\">Português</option>\n                                    <option value=\"ko\">한국어</option>\n                                    <option value=\"ru\">Русский</option>\n                                    <option value=\"ar\">العربية</option>\n                                </select>\n                            </div>\n\n                            {/* 主题选择 */}\n                            <div>\n                                <label className=\"block text-sm font-medium text-gray-900 dark:text-base-content mb-2\">{t('settings.general.theme')}</label>\n                                <select\n                                    className=\"w-full px-4 py-4 border border-gray-200 dark:border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 dark:text-base-content bg-gray-50 dark:bg-base-200\"\n                                    value={formData.theme}\n                                    onChange={(e) => {\n                                        const newTheme = e.target.value;\n                                        setFormData({ ...formData, theme: newTheme });\n                                        updateTheme(newTheme);\n                                    }}\n                                >\n                                    <option value=\"light\">{t('settings.general.theme_light')}</option>\n                                    <option value=\"dark\">{t('settings.general.theme_dark')}</option>\n                                    <option value=\"system\">{t('settings.general.theme_system')}</option>\n                                </select>\n                            </div>\n\n                            {/* 开机自动启动 */}\n                            <div>\n                                <div className=\"flex justify-between items-center mb-2\">\n                                    <label className=\"block text-sm font-medium text-gray-900 dark:text-base-content\">{t('settings.general.auto_launch')}</label>\n                                    {!isTauri() && (\n                                        <span className=\"text-xs text-orange-500 dark:text-orange-400\">\n                                            {t('settings.web_mode_limitation', '(Web 模式不支持)')}\n                                        </span>\n                                    )}\n                                </div>\n                                <select\n                                    className=\"w-full px-4 py-4 border border-gray-200 dark:border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 dark:text-base-content bg-gray-50 dark:bg-base-200\"\n                                    value={formData.auto_launch ? 'enabled' : 'disabled'}\n                                    onChange={async (e) => {\n                                        const enabled = e.target.value === 'enabled';\n                                        try {\n                                            await invoke('toggle_auto_launch', { enable: enabled });\n                                            setFormData({ ...formData, auto_launch: enabled });\n                                            showToast(enabled ? t('settings.general.auto_launch_enabled') : t('settings.general.auto_launch_disabled'), 'success');\n                                        } catch (error) {\n                                            showToast(`${t('common.error')}: ${error}`, 'error');\n                                        }\n                                    }}\n                                >\n                                    <option value=\"disabled\">{t('settings.general.auto_launch_disabled')}</option>\n                                    <option value=\"enabled\" disabled={!isTauri()}>{t('settings.general.auto_launch_enabled')}</option>\n\n                                </select>\n                                <p className=\"text-sm text-gray-500 dark:text-gray-400 mt-2\">{t('settings.general.auto_launch_desc')}</p>\n                            </div>\n\n                            {/* 自动检查更新 */}\n                            <>\n                                <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-base-200 rounded-lg border border-gray-100 dark:border-base-300\">\n                                    <div>\n                                        <div className=\"font-medium text-gray-900 dark:text-base-content\">{t('settings.general.auto_check_update')}</div>\n                                        <p className=\"text-sm text-gray-600 dark:text-gray-400 mt-1\">{t('settings.general.auto_check_update_desc')}</p>\n                                    </div>\n                                    <label className=\"relative inline-flex items-center cursor-pointer\">\n                                        <input\n                                            type=\"checkbox\"\n                                            className=\"sr-only peer\"\n                                            checked={formData.auto_check_update ?? true}\n                                            onChange={async (e) => {\n                                                const enabled = e.target.checked;\n                                                try {\n                                                    await invoke('save_update_settings', {\n                                                        settings: {\n                                                            auto_check: enabled,\n                                                            last_check_time: 0,\n                                                            check_interval_hours: formData.update_check_interval ?? 24\n                                                        }\n                                                    });\n                                                    setFormData({ ...formData, auto_check_update: enabled });\n                                                    showToast(enabled ? t('settings.general.auto_check_update_enabled') : t('settings.general.auto_check_update_disabled'), 'success');\n                                                } catch (error) {\n                                                    showToast(`${t('common.error')}: ${error}`, 'error');\n                                                }\n                                            }}\n                                        />\n                                        <div className=\"w-11 h-6 bg-gray-200 dark:bg-base-300 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500\"></div>\n                                    </label>\n                                </div>\n\n                                {/* 检查间隔 */}\n                                {formData.auto_check_update && (\n                                    <div className=\"ml-4\">\n                                        <label className=\"block text-sm font-medium text-gray-900 dark:text-base-content mb-2\">{t('settings.general.update_check_interval')}</label>\n                                        <input\n                                            type=\"number\"\n                                            className=\"w-32 px-4 py-4 border border-gray-200 dark:border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 dark:text-base-content bg-gray-50 dark:bg-base-200\"\n                                            min=\"1\"\n                                            max=\"168\"\n                                            value={formData.update_check_interval ?? 24}\n                                            onChange={(e) => setFormData({ ...formData, update_check_interval: parseInt(e.target.value) })}\n                                            onBlur={async () => {\n                                                try {\n                                                    await invoke('save_update_settings', {\n                                                        settings: {\n                                                            auto_check: formData.auto_check_update ?? true,\n                                                            last_check_time: 0,\n                                                            check_interval_hours: formData.update_check_interval ?? 24\n                                                        }\n                                                    });\n                                                    showToast(t('settings.general.update_check_interval_saved'), 'success');\n                                                } catch (error) {\n                                                    showToast(`${t('common.error')}: ${error}`, 'error');\n                                                }\n                                            }}\n                                        />\n                                        <p className=\"text-sm text-gray-500 dark:text-gray-400 mt-2\">{t('settings.general.update_check_interval_desc')}</p>\n                                    </div>\n                                )}\n\n                                {/* 菜单显示设置 */}\n                                <div className=\"border-t border-gray-200 dark:border-base-200 pt-6 mt-6\">\n                                    <h3 className=\"font-medium text-gray-900 dark:text-base-content mb-3\">{t('settings.menu.title')}</h3>\n                                    <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-4\">\n                                        {t('settings.menu.desc')}\n                                    </p>\n                                    <div className=\"grid grid-cols-2 lg:grid-cols-4 gap-3\">\n                                        {[\n                                            { path: '/', label: t('nav.dashboard'), icon: LayoutDashboard },\n                                            { path: '/accounts', label: t('nav.accounts'), icon: Users },\n                                            { path: '/api-proxy', label: t('nav.proxy'), icon: Network },\n                                            { path: '/monitor', label: t('nav.call_records'), icon: Activity },\n                                            { path: '/token-stats', label: t('nav.token_stats'), icon: BarChart3 },\n                                            { path: '/user-token', label: t('nav.user_token', 'User Tokens'), icon: Users },\n                                            { path: '/security', label: t('nav.security'), icon: Lock },\n                                            { path: '/settings', label: t('nav.settings'), icon: SettingsIcon },\n                                        ].map((item) => {\n                                            const hiddenItems = formData.hidden_menu_items || [];\n                                            const isVisible = !hiddenItems.includes(item.path);\n                                            const isSettings = item.path === '/settings';\n\n                                            return (\n                                                <div\n                                                    key={item.path}\n                                                    onClick={async () => {\n                                                        if (!isSettings) {\n                                                            const originalConfig = { ...formData };\n                                                            const hiddenItems = formData.hidden_menu_items || [];\n                                                            const newHiddenItems = isVisible\n                                                                ? [...hiddenItems, item.path]\n                                                                : hiddenItems.filter(p => p !== item.path);\n\n                                                            // 乐观更新 UI\n                                                            const newConfig = {\n                                                                ...formData,\n                                                                hidden_menu_items: newHiddenItems\n                                                            };\n                                                            setFormData(newConfig);\n\n                                                            // 尝试保存\n                                                            try {\n                                                                await saveConfig(newConfig);\n                                                            } catch (error) {\n                                                                // 保存失败，回滚到原始快照\n                                                                setFormData(originalConfig);\n                                                                showToast(`保存失败，已恢复设置: ${error}`, 'error');\n                                                            }\n                                                        }\n                                                    }}\n                                                    className={`\n                                                        relative flex flex-col items-center justify-center gap-3 p-4 rounded-xl border-2 transition-all cursor-pointer select-none\n                                                        ${isSettings\n                                                            ? 'bg-gray-50 dark:bg-base-200 border-gray-100 dark:border-base-300 opacity-60 cursor-not-allowed'\n                                                            : isVisible\n                                                                ? 'bg-blue-50/50 dark:bg-blue-900/10 border-blue-500 dark:border-blue-500 shadow-sm'\n                                                                : 'bg-white dark:bg-base-100 border-gray-200 dark:border-base-300 hover:border-gray-300 dark:hover:border-base-content/20 text-gray-500'\n                                                        }\n                                                    `}\n                                                >\n                                                    {/* 选中标记 */}\n                                                    {isVisible && (\n                                                        <div className=\"absolute top-2 right-2 text-blue-500\">\n                                                            <CheckCircle2 size={16} fill=\"currentColor\" className=\"text-white dark:text-base-100\" />\n                                                        </div>\n                                                    )}\n\n                                                    {isSettings && (\n                                                        <div className=\"absolute top-2 right-2 text-xs font-bold text-gray-400 bg-gray-200 dark:bg-base-300 px-1.5 py-0.5 rounded\">\n                                                            {t('settings.menu.required')}\n                                                        </div>\n                                                    )}\n\n                                                    <div className={`\n                                                        p-3 rounded-xl transition-colors\n                                                        ${isVisible\n                                                            ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'\n                                                            : 'bg-gray-100 dark:bg-base-200 text-gray-400 dark:text-base-content/50'\n                                                        }\n                                                    `}>\n                                                        <item.icon size={24} />\n                                                    </div>\n\n                                                    <span className={`font-medium text-sm ${isVisible ? 'text-blue-900 dark:text-blue-100' : 'text-gray-500'}`}>\n                                                        {item.label}\n                                                    </span>\n                                                </div>\n                                            );\n                                        })}\n                                    </div>\n                                    <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-4 flex items-center gap-1.5\">\n                                        <div className=\"w-1.5 h-1.5 rounded-full bg-gray-400\"></div>\n                                        {t('settings.menu.selected_items_note')}\n                                    </p>\n                                </div>\n                            </>\n                        </div>\n                    )}\n\n                    {/* 账号设置 */}\n                    {activeTab === 'account' && (\n                        <div className=\"space-y-4 animate-in fade-in duration-500\">\n                            {/* 自动刷新配额 */}\n                            <div className=\"group bg-white dark:bg-base-100 rounded-xl p-5 border border-gray-100 dark:border-base-200 hover:border-blue-200 transition-all duration-300 shadow-sm\">\n                                <div className=\"flex items-center justify-between\">\n                                    <div className=\"flex items-center gap-4\">\n                                        <div className=\"w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center text-blue-500 group-hover:bg-blue-500 group-hover:text-white transition-all duration-300\">\n                                            <RefreshCw size={20} />\n                                        </div>\n                                        <div>\n                                            <div className=\"font-bold text-gray-900 dark:text-gray-100\">{t('settings.account.auto_refresh')}</div>\n                                            <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-0.5\">{t('settings.account.auto_refresh_desc')}</p>\n                                        </div>\n                                    </div>\n                                    <label className={`relative inline-flex items-center ${formData.quota_protection.enabled ? 'cursor-not-allowed opacity-80' : 'cursor-pointer'}`}>\n                                        <input\n                                            type=\"checkbox\"\n                                            className=\"sr-only peer\"\n                                            checked={formData.auto_refresh}\n                                            disabled={formData.quota_protection.enabled}\n                                            onChange={async (e) => {\n                                                const enabled = e.target.checked;\n                                                const newConfig = { ...formData, auto_refresh: enabled };\n                                                setFormData(newConfig);\n                                                // Hot Save\n                                                try {\n                                                    await saveConfig(newConfig);\n                                                } catch (error) {\n                                                    showToast(`${t('common.error')}: ${error}`, 'error');\n                                                }\n                                            }}\n                                        />\n                                        <div className={`w-11 h-6 bg-gray-200 dark:bg-base-300 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500 shadow-inner ${formData.quota_protection.enabled ? 'peer-checked:bg-blue-500' : ''}`}></div>\n                                    </label>\n                                </div>\n\n                                <div className=\"mt-5 pt-5 border-t border-gray-50 dark:border-base-300 flex items-center gap-4 animate-in slide-in-from-top-1 duration-200\">\n                                    <label className=\"text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider\">{t('settings.account.refresh_interval')}</label>\n                                    <div className=\"relative\">\n                                        <input\n                                            type=\"number\"\n                                            className=\"w-24 px-3 py-2 bg-gray-50 dark:bg-base-200 border border-gray-100 dark:border-base-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm font-bold text-blue-600 dark:text-blue-400\"\n                                            min=\"1\"\n                                            max=\"35791\"\n                                            value={formData.refresh_interval}\n                                            onChange={(e) => setFormData({ ...formData, refresh_interval: isNaN(parseInt(e.target.value)) ? 1 : Math.min(Math.max(parseInt(e.target.value), 1), 35791) })}\n                                        />\n                                    </div>\n                                </div>\n                            </div>\n\n                            {/* 自动获取当前账号 */}\n                            <div className=\"group bg-white dark:bg-base-100 rounded-xl p-5 border border-gray-100 dark:border-base-200 hover:border-emerald-200 transition-all duration-300 shadow-sm\">\n                                <div className=\"flex items-center justify-between\">\n                                    <div className=\"flex items-center gap-4\">\n                                        <div className=\"w-10 h-10 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center text-emerald-500 group-hover:bg-emerald-500 group-hover:text-white transition-all duration-300\">\n                                            <User size={20} />\n                                        </div>\n                                        <div>\n                                            <div className=\"font-bold text-gray-900 dark:text-gray-100\">{t('settings.account.auto_sync')}</div>\n                                            <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-0.5\">{t('settings.account.auto_sync_desc')}</p>\n                                        </div>\n                                    </div>\n                                    <label className=\"relative inline-flex items-center cursor-pointer\">\n                                        <input\n                                            type=\"checkbox\"\n                                            className=\"sr-only peer\"\n                                            checked={formData.auto_sync}\n                                            onChange={(e) => setFormData({ ...formData, auto_sync: e.target.checked })}\n                                        />\n                                        <div className=\"w-11 h-6 bg-gray-200 dark:bg-base-300 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-emerald-500 shadow-inner\"></div>\n                                    </label>\n                                </div>\n\n                                {formData.auto_sync && (\n                                    <div className=\"mt-5 pt-5 border-t border-gray-50 dark:border-base-300 flex items-center gap-4 animate-in slide-in-from-top-1 duration-200\">\n                                        <label className=\"text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider\">{t('settings.account.sync_interval')}</label>\n                                        <input\n                                            type=\"number\"\n                                            className=\"w-24 px-3 py-2 bg-gray-50 dark:bg-base-200 border border-gray-100 dark:border-base-300 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none text-sm font-bold text-emerald-600 dark:text-emerald-400\"\n                                            min=\"1\"\n                                            max=\"35791\"\n                                            value={formData.sync_interval}\n                                            onChange={(e) => setFormData({ ...formData, sync_interval: isNaN(parseInt(e.target.value)) ? 1 : Math.min(Math.max(parseInt(e.target.value), 1), 35791) })}\n                                        />\n                                    </div>\n                                )}\n                            </div>\n\n                            {/* 智能预热 (Smart Warmup) - [DISABLED] Backend scheduler commented out as per user request */}\n                            {/* <div className=\"group bg-white dark:bg-base-100 rounded-xl p-5 border border-gray-100 dark:border-base-200 hover:border-orange-200 transition-all duration-300 shadow-sm\">\n                                <SmartWarmup\n                                    config={formData.scheduled_warmup}\n                                    onChange={async (newConfig) => {\n                                        const newFormData = {\n                                            ...formData,\n                                            scheduled_warmup: newConfig\n                                        };\n                                        setFormData(newFormData);\n                                        // Hot Save\n                                        try {\n                                            await saveConfig(newFormData);\n                                        } catch (error) {\n                                            showToast(`${t('common.error')}: ${error}`, 'error');\n                                        }\n                                    }}\n                                />\n                            </div> */}\n\n                            {/* 配额保护 (Quota Protection) */}\n                            <div className=\"group bg-white dark:bg-base-100 rounded-xl p-5 border border-gray-100 dark:border-base-200 hover:border-rose-200 transition-all duration-300 shadow-sm\">\n                                <QuotaProtection\n                                    config={formData.quota_protection}\n                                    onChange={async (newConfig) => {\n                                        const updates: any = {\n                                            quota_protection: newConfig\n                                        };\n                                        // 联动逻辑：开启配额保护时，强制开启后台自动刷新 (不仅仅是预热)\n                                        if (newConfig.enabled) {\n                                            updates.auto_refresh = true;\n                                        }\n\n                                        const newFormData = {\n                                            ...formData,\n                                            ...updates\n                                        };\n                                        setFormData(newFormData);\n\n                                        // Hot Save\n                                        try {\n                                            await saveConfig(newFormData);\n                                        } catch (error) {\n                                            showToast(`${t('common.error')}: ${error}`, 'error');\n                                        }\n                                    }}\n                                />\n                            </div>\n\n                            {/* 配额关注列表 (Pinned Quota Models) */}\n                            <div className=\"group bg-white dark:bg-base-100 rounded-xl p-5 border border-gray-100 dark:border-base-200 hover:border-indigo-200 transition-all duration-300 shadow-sm\">\n                                <PinnedQuotaModels\n                                    config={formData.pinned_quota_models}\n                                    onChange={(newConfig) => setFormData({\n                                        ...formData,\n                                        pinned_quota_models: newConfig\n                                    })}\n                                />\n                            </div>\n                        </div>\n                    )}\n\n                    {/* 高级设置 */}\n                    {activeTab === 'advanced' && (\n                        <>\n                            <div className=\"space-y-4\">\n                                {/* 默认导出路径 */}\n                                <div>\n                                    <label className=\"block text-sm font-medium text-gray-900 dark:text-base-content mb-1\">{t('settings.advanced.export_path')}</label>\n                                    <div className=\"flex gap-2\">\n                                        <input\n                                            type=\"text\"\n                                            className=\"flex-1 px-4 py-4 border border-gray-200 dark:border-base-300 rounded-lg bg-gray-50 dark:bg-base-200 text-gray-900 dark:text-base-content font-medium\"\n                                            value={formData.default_export_path || t('settings.advanced.export_path_placeholder')}\n                                            readOnly\n                                        />\n                                        {formData.default_export_path && (\n                                            <button\n                                                className=\"px-4 py-2 border border-gray-200 dark:border-base-300 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors\"\n                                                onClick={() => setFormData({ ...formData, default_export_path: undefined })}\n                                            >\n                                                {t('common.clear')}\n                                            </button>\n                                        )}\n                                        {isTauri() ? (\n                                            <button\n                                                className=\"px-4 py-2 border border-gray-200 dark:border-base-300 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-base-200 hover:text-gray-900 dark:hover:text-base-content transition-colors\"\n                                                onClick={handleSelectExportPath}\n                                            >\n                                                {t('settings.advanced.select_btn')}\n                                            </button>\n                                        ) : (\n                                            <span className=\"self-center text-xs text-gray-400 dark:text-gray-500 italic px-2\">\n                                                {t('settings.web_mode_limitation', '(Web 模式不支持)')}\n                                            </span>\n                                        )}\n                                    </div>\n                                    <p className=\"text-sm text-gray-500 dark:text-gray-400 mt-2\">{t('settings.advanced.default_export_path_desc')}</p>\n                                </div>\n\n                                {/* 数据目录 */}\n                                <div>\n                                    <label className=\"block text-sm font-medium text-gray-900 dark:text-base-content mb-1\">{t('settings.advanced.data_dir')}</label>\n                                    <div className=\"flex gap-2\">\n                                        <input\n                                            type=\"text\"\n                                            className=\"flex-1 px-4 py-4 border border-gray-200 dark:border-base-300 rounded-lg bg-gray-50 dark:bg-base-200 text-gray-900 dark:text-base-content font-medium\"\n                                            value={dataDirPath}\n                                            readOnly\n                                        />\n                                        {isTauri() ? (\n                                            <button\n                                                className=\"px-4 py-2 border border-gray-200 dark:border-base-300 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-base-200 hover:text-gray-900 dark:hover:text-base-content transition-colors\"\n                                                onClick={handleOpenDataDir}\n                                            >\n                                                {t('settings.advanced.open_btn')}\n                                            </button>\n                                        ) : (\n                                            <span className=\"self-center text-xs text-gray-400 dark:text-gray-500 italic px-2\">\n                                                {t('settings.web_mode_limitation', '(Web 模式不支持)')}\n                                            </span>\n                                        )}\n                                    </div>\n                                    <p className=\"text-sm text-gray-500 dark:text-gray-400 mt-2\">{t('settings.advanced.data_dir_desc')}</p>\n                                </div>\n\n                                {/* 反重力程序路径 */}\n                                <div>\n                                    <label className=\"block text-sm font-medium text-gray-900 dark:text-base-content mb-1\">\n                                        {t('settings.advanced.antigravity_path')}\n                                    </label>\n                                    <div className=\"flex gap-2\">\n                                        <input\n                                            type=\"text\"\n                                            className=\"flex-1 px-4 py-4 border border-gray-200 dark:border-base-300 rounded-lg bg-gray-50 dark:bg-base-200 text-gray-900 dark:text-base-content font-medium\"\n                                            value={formData.antigravity_executable || ''}\n                                            placeholder={t('settings.advanced.antigravity_path_placeholder')}\n                                            onChange={(e) => setFormData({ ...formData, antigravity_executable: e.target.value })}\n                                        />\n                                        {formData.antigravity_executable && (\n                                            <button\n                                                className=\"px-4 py-2 border border-gray-200 dark:border-base-300 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors\"\n                                                onClick={() => setFormData({ ...formData, antigravity_executable: undefined })}\n                                            >\n                                                {t('common.clear')}\n                                            </button>\n                                        )}\n                                        <button\n                                            className=\"px-4 py-2 border border-gray-200 dark:border-base-300 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-base-200 transition-colors\"\n                                            onClick={handleDetectAntigravityPath}\n                                        >\n                                            {t('settings.advanced.detect_btn')}\n                                        </button>\n                                        {isTauri() ? (\n                                            <button\n                                                className=\"px-4 py-2 border border-gray-200 dark:border-base-300 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-base-200 transition-colors\"\n                                                onClick={handleSelectAntigravityPath}\n                                            >\n                                                {t('settings.advanced.select_btn')}\n                                            </button>\n                                        ) : (\n                                            <span className=\"self-center text-xs text-gray-400 dark:text-gray-500 italic px-2\">\n                                                {t('settings.web_mode_limitation', '(Web 模式不支持)')}\n                                            </span>\n                                        )}\n                                    </div>\n                                    <p className=\"text-sm text-gray-500 dark:text-gray-400 mt-2\">\n                                        {t('settings.advanced.antigravity_path_desc')}\n                                    </p>\n                                </div>\n\n                                {/* 反重力程序启动参数 */}\n                                <div>\n                                    <label className=\"block text-sm font-medium text-gray-900 dark:text-base-content mb-1\">\n                                        {t('settings.advanced.antigravity_args')}\n                                    </label>\n                                    <div className=\"flex gap-2\">\n                                        <input\n                                            type=\"text\"\n                                            className=\"flex-1 px-4 py-4 border border-gray-200 dark:border-base-300 rounded-lg bg-gray-50 dark:bg-base-200 text-gray-900 dark:text-base-content font-medium\"\n                                            value={formData.antigravity_args ? formData.antigravity_args.join(' ') : ''}\n                                            placeholder={t('settings.advanced.antigravity_args_placeholder')}\n                                            onChange={(e) => {\n                                                const args = e.target.value.trim() === '' ? [] : e.target.value.split(' ').map(arg => arg.trim()).filter(arg => arg !== '');\n                                                setFormData({ ...formData, antigravity_args: args });\n                                            }}\n                                        />\n                                        <button\n                                            className=\"px-4 py-2 border border-gray-200 dark:border-base-300 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-base-200 transition-colors\"\n                                            onClick={async () => {\n                                                try {\n                                                    const args = await invoke<string[]>('get_antigravity_args');\n                                                    setFormData({ ...formData, antigravity_args: args });\n                                                    showToast(t('settings.advanced.antigravity_args_detected'), 'success');\n                                                } catch (error) {\n                                                    showToast(`${t('settings.advanced.antigravity_args_detect_error')}: ${error}`, 'error');\n                                                }\n                                            }}\n                                        >\n                                            {t('settings.advanced.detect_args_btn')}\n                                        </button>\n                                    </div>\n                                    <p className=\"text-sm text-gray-500 dark:text-gray-400 mt-2\">\n                                        {t('settings.advanced.antigravity_args_desc')}\n                                    </p>\n                                </div>\n\n                                {/* 日志缓存清理 */}\n                                <div className=\"border-t border-gray-200 dark:border-base-200 pt-4\">\n                                    <h3 className=\"font-medium text-gray-900 dark:text-base-content mb-3\">{t('settings.advanced.logs_title')}</h3>\n                                    <div className=\"bg-gray-50 dark:bg-base-200 border border-gray-200 dark:border-base-300 rounded-lg p-3 mb-3\">\n                                        <p className=\"text-sm text-gray-600 dark:text-gray-400\">{t('settings.advanced.logs_desc')}</p>\n                                    </div>\n                                    <div className=\"flex items-center gap-4\">\n                                        <button\n                                            className=\"px-4 py-2 border border-gray-300 dark:border-base-300 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-base-200 transition-colors\"\n                                            onClick={() => setIsClearLogsOpen(true)}\n                                        >\n                                            {t('settings.advanced.clear_logs')}\n                                        </button>\n                                    </div>\n                                </div>\n\n                                {/* Antigravity 缓存清理 */}\n                                <div className=\"border-t border-gray-200 dark:border-base-200 pt-4\">\n                                    <h3 className=\"font-medium text-gray-900 dark:text-base-content mb-3\">{t('settings.advanced.antigravity_cache_title')}</h3>\n                                    <div className=\"bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700/30 rounded-lg p-3 mb-3\">\n                                        <p className=\"text-sm text-amber-700 dark:text-amber-400\">{t('settings.advanced.antigravity_cache_warning')}</p>\n                                    </div>\n                                    <div className=\"bg-gray-50 dark:bg-base-200 border border-gray-200 dark:border-base-300 rounded-lg p-3 mb-3\">\n                                        <p className=\"text-sm text-gray-600 dark:text-gray-400\">{t('settings.advanced.antigravity_cache_desc')}</p>\n                                    </div>\n                                    <div className=\"flex items-center gap-4\">\n                                        <button\n                                            className=\"px-4 py-2 border border-orange-300 dark:border-orange-700 text-orange-700 dark:text-orange-400 rounded-lg hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-colors\"\n                                            onClick={handleOpenClearCacheDialog}\n                                        >\n                                            {t('settings.advanced.clear_antigravity_cache')}\n                                        </button>\n                                    </div>\n                                </div>\n\n\n\n                                <div className=\"border-t border-gray-200 dark:border-base-200 pt-4\">\n                                    <div className=\"space-y-3\">\n                                        <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-base-200 rounded-lg border border-gray-100 dark:border-base-300\">\n                                            <div>\n                                                <div className=\"font-medium text-gray-900 dark:text-base-content\">\n                                                    {t('settings.advanced.debug_logs_title')}\n                                                </div>\n                                                <p className=\"text-sm text-gray-600 dark:text-gray-400 mt-1\">\n                                                    {t('settings.advanced.debug_logs_enable_desc')}\n                                                </p>\n                                            </div>\n                                            <label className=\"relative inline-flex items-center cursor-pointer\">\n                                                <input\n                                                    type=\"checkbox\"\n                                                    className=\"sr-only peer\"\n                                                    checked={formData.proxy?.debug_logging?.enabled ?? false}\n                                                    onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFormData({\n                                                        ...formData,\n                                                        proxy: {\n                                                            ...formData.proxy,\n                                                            debug_logging: {\n                                                                enabled: e.target.checked,\n                                                                output_dir: formData.proxy?.debug_logging?.output_dir,\n                                                            },\n                                                        },\n                                                    })}\n                                                />\n                                                <div className=\"w-11 h-6 bg-gray-200 dark:bg-base-300 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500\"></div>\n                                            </label>\n                                        </div>\n                                        {(formData.proxy?.debug_logging?.enabled ?? false) && (\n                                            <>\n                                                <div className=\"bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700/30 rounded-lg p-3\">\n                                                    <p className=\"text-sm text-amber-700 dark:text-amber-400\">\n                                                        {t('settings.advanced.debug_logs_desc')}\n                                                    </p>\n                                                </div>\n                                                <div>\n                                                    <label className=\"block text-sm font-medium text-gray-900 dark:text-base-content mb-1\">\n                                                        {t('settings.advanced.debug_log_dir')}\n                                                    </label>\n                                                    <div className=\"flex gap-2\">\n                                                        <input\n                                                            type=\"text\"\n                                                            className=\"flex-1 px-4 py-3 border border-gray-200 dark:border-base-300 rounded-lg bg-gray-50 dark:bg-base-200 text-gray-900 dark:text-base-content font-medium\"\n                                                            value={formData.proxy?.debug_logging?.output_dir || ''}\n                                                            placeholder={`${dataDirPath.replace(/\\/$/, '')}/debug_logs`}\n                                                            onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFormData({\n                                                                ...formData,\n                                                                proxy: {\n                                                                    ...formData.proxy,\n                                                                    debug_logging: {\n                                                                        enabled: formData.proxy?.debug_logging?.enabled ?? false,\n                                                                        output_dir: e.target.value || undefined,\n                                                                    },\n                                                                },\n                                                            })}\n                                                        />\n                                                        {isTauri() && (\n                                                            <button\n                                                                className=\"px-4 py-2 border border-gray-200 dark:border-base-300 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-base-200 transition-colors\"\n                                                                onClick={handleSelectDebugLogDir}\n                                                            >\n                                                                {t('settings.advanced.select_btn')}\n                                                            </button>\n                                                        )}\n                                                    </div>\n                                                    <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-2\">\n                                                        {t('settings.advanced.debug_log_dir_hint', { path: dataDirPath.replace(/\\/$/, '') })}\n                                                    </p>\n                                                </div>\n                                            </>\n                                        )}\n                                    </div>\n                                </div>\n\n                            </div>\n                        </>\n                    )}\n\n\n                    {/* 调试设置 */}\n                    {activeTab === 'debug' && (\n                        <div className=\"space-y-4 animate-in fade-in duration-500\">\n                            {/* 标题和开关 */}\n                            <div className=\"flex items-center justify-between\">\n                                <div>\n                                    <h2 className=\"text-lg font-semibold text-gray-900 dark:text-base-content\">\n                                        {t('settings.debug.title')}\n                                    </h2>\n                                    <p className=\"text-sm text-gray-500 dark:text-gray-400 mt-1\">\n                                        {t('settings.debug.desc')}\n                                    </p>\n                                </div>\n                                <label className=\"relative inline-flex items-center cursor-pointer\">\n                                    <input\n                                        type=\"checkbox\"\n                                        className=\"sr-only peer\"\n                                        checked={isEnabled}\n                                        onChange={(e) => e.target.checked ? enable() : disable()}\n                                    />\n                                    <div className=\"w-11 h-6 bg-gray-200 dark:bg-base-300 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500\"></div>\n                                    <span className=\"ml-3 text-sm font-medium text-gray-700 dark:text-gray-300\">\n                                        {isEnabled ? t('settings.debug.enabled') : t('settings.debug.disabled')}\n                                    </span>\n                                </label>\n                            </div>\n\n                            {/* 控制台或提示 */}\n                            {isEnabled ? (\n                                <div className=\"h-[calc(100vh-320px)] min-h-[400px]\">\n                                    <DebugConsole embedded />\n                                </div>\n                            ) : (\n                                <div className=\"h-[calc(100vh-320px)] min-h-[400px] flex items-center justify-center bg-gray-50 dark:bg-base-200 rounded-xl border border-gray-200 dark:border-base-300\">\n                                    <div className=\"text-center\">\n                                        <p className=\"text-gray-500 dark:text-gray-400 text-lg font-medium\">\n                                            {t('settings.debug.disabled_hint')}\n                                        </p>\n                                        <p className=\"text-gray-400 dark:text-gray-500 text-sm mt-2\">\n                                            {t('settings.debug.disabled_desc')}\n                                        </p>\n                                    </div>\n                                </div>\n                            )}\n                        </div>\n                    )}\n\n                    {/* 代理设置 */}\n                    {activeTab === 'proxy' && (\n                        <div className=\"space-y-4 animate-in fade-in duration-300\">\n                            <ProxyPoolSettings\n                                config={formData.proxy?.proxy_pool || {\n                                    enabled: false,\n                                    proxies: [],\n                                    health_check_interval: 300,\n                                    auto_failover: true,\n                                    strategy: 'priority'\n                                }}\n                                onChange={(newConfig, silent = false) => {\n                                    const updatedFormData = {\n                                        ...formData,\n                                        proxy: {\n                                            ...formData.proxy,\n                                            proxy_pool: newConfig\n                                        }\n                                    };\n                                    setFormData(updatedFormData);\n\n                                    // [FIX] Silent updates (like health polling) should NOT trigger saveConfig\n                                    // to prevent race conditions where old memory state rolls back new manual changes\n                                    if (silent) {\n                                        console.log('Proxy status sync (silent)');\n                                        return;\n                                    }\n\n                                    // Hot reload: save immediately for manual changes\n                                    saveConfig({ ...updatedFormData, auto_refresh: true })\n                                        .then(() => {\n                                            console.log('Proxy config saved');\n                                        })\n                                        .catch(err => console.error('Save failed:', err));\n                                }}\n                            />\n\n                            {/* [FIX #1701] 恢复全局上游代理设置 */}\n                            <div className=\"group bg-white dark:bg-base-100 rounded-xl p-5 border border-gray-100 dark:border-base-200 hover:border-blue-200 transition-all duration-300 shadow-sm relative overflow-hidden\">\n                                <div className=\"absolute top-0 right-0 w-24 h-24 bg-blue-500/5 -mr-12 -mt-12 rounded-full blur-2xl group-hover:bg-blue-500/10 transition-colors\"></div>\n                                <div className=\"flex items-center justify-between mb-5 relative z-10\">\n                                    <div className=\"flex items-center gap-4\">\n                                        <div className=\"w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center text-blue-500 group-hover:bg-blue-500 group-hover:text-white transition-all duration-300 shadow-sm\">\n                                            <Globe size={18} />\n                                        </div>\n                                        <div>\n                                            <div className=\"font-bold text-gray-900 dark:text-gray-100 text-sm\">{t('proxy.config.upstream_proxy.title')}</div>\n                                            <p className=\"text-[11px] text-gray-500 dark:text-gray-400 mt-0.5 leading-tight max-w-[280px]\">\n                                                {t('proxy.config.upstream_proxy.desc_short')}\n                                            </p>\n                                        </div>\n                                    </div>\n                                    <label className=\"relative inline-flex items-center cursor-pointer scale-90\">\n                                        <input\n                                            type=\"checkbox\"\n                                            className=\"sr-only peer\"\n                                            checked={formData.proxy?.upstream_proxy?.enabled ?? false}\n                                            onChange={(e) => setFormData({\n                                                ...formData,\n                                                proxy: {\n                                                    ...formData.proxy,\n                                                    upstream_proxy: {\n                                                        ...formData.proxy?.upstream_proxy,\n                                                        enabled: e.target.checked\n                                                    }\n                                                }\n                                            })}\n                                        />\n                                        <div className=\"w-11 h-6 bg-gray-200 dark:bg-base-300 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500 shadow-inner\"></div>\n                                    </label>\n                                </div>\n\n                                {formData.proxy?.upstream_proxy?.enabled && (\n                                    <div className=\"space-y-4 animate-in slide-in-from-top-2 duration-300 relative z-10\">\n                                        <div className=\"pt-4 border-t border-gray-50 dark:border-base-300\">\n                                            <label className=\"block text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-2.5\">\n                                                {t('proxy.config.upstream_proxy.url')}\n                                            </label>\n                                            <div className=\"relative group/input\">\n                                                <input\n                                                    type=\"text\"\n                                                    className=\"w-full px-4 py-2.5 bg-gray-50 dark:bg-base-200 border border-gray-100 dark:border-base-300 rounded-xl focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none text-sm font-medium transition-all shadow-inner\"\n                                                    placeholder={t('proxy.config.upstream_proxy.url_placeholder')}\n                                                    value={formData.proxy?.upstream_proxy?.url || ''}\n                                                    onChange={(e) => setFormData({\n                                                        ...formData,\n                                                        proxy: {\n                                                            ...formData.proxy,\n                                                            upstream_proxy: {\n                                                                ...formData.proxy?.upstream_proxy,\n                                                                url: e.target.value\n                                                            }\n                                                        }\n                                                    })}\n                                                />\n                                            </div>\n                                            <div className=\"mt-4 bg-amber-50/40 dark:bg-amber-900/10 rounded-xl p-3.5 border border-amber-100/50 dark:border-amber-800/20 text-[11px] text-amber-700 dark:text-amber-400 flex items-start gap-3 transition-colors hover:bg-amber-50/60\">\n                                                <div className=\"mt-0.5 p-1 bg-amber-100/80 dark:bg-amber-800/40 rounded-lg shadow-sm\">\n                                                    <Network size={12} className=\"text-amber-600 dark:text-amber-400\" />\n                                                </div>\n                                                <div className=\"leading-relaxed\">\n                                                    <span className=\"font-bold mr-1.5 opacity-80 uppercase tracking-tighter\">Tip:</span>\n                                                    {t('proxy.config.upstream_proxy.socks5h_hint')}\n                                                </div>\n                                            </div>\n                                        </div>\n                                    </div>\n                                )}\n                            </div>\n                        </div>\n                    )}\n\n                    {activeTab === 'about' && (\n                        <div className=\"flex flex-col h-full animate-in fade-in duration-500\">\n                            <div className=\"flex-1 flex flex-col justify-center items-center space-y-8\">\n                                {/* Branding Section */}\n                                <div className=\"text-center space-y-4\">\n                                    <div className=\"relative inline-block group\">\n                                        <div className=\"absolute inset-0 bg-blue-500/20 rounded-3xl blur-xl group-hover:blur-2xl transition-all duration-500\"></div>\n                                        <img\n                                            src=\"/icon.png\"\n                                            alt=\"Antigravity Logo\"\n                                            className=\"relative w-24 h-24 rounded-3xl shadow-2xl transform group-hover:scale-105 transition-all duration-500 rotate-3 group-hover:rotate-6 object-cover bg-white dark:bg-black\"\n                                        />\n                                    </div>\n\n                                    <div>\n                                        <h3 className=\"text-3xl font-black text-gray-900 dark:text-base-content tracking-tight mb-2\">{t('common.app_name', 'Antigravity Tools')}</h3>\n                                        <div className=\"flex items-center justify-center gap-2 text-sm\">\n                                            v4.1.30\n                                            <span className=\"text-gray-400 dark:text-gray-600\">•</span>\n                                            <span className=\"text-gray-500 dark:text-gray-400\">{t('settings.branding.subtitle')}</span>\n                                        </div>\n                                    </div>\n                                </div>\n\n                                {/* Cards Grid - Now 5 columns */}\n                                <div className=\"grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 w-full max-w-6xl px-4\">\n                                    {/* Author Card */}\n                                    <div className=\"bg-white dark:bg-base-100 p-4 rounded-2xl border border-gray-100 dark:border-base-300 shadow-sm hover:shadow-md hover:border-blue-200 dark:hover:border-blue-800 transition-all group flex flex-col items-center text-center gap-3\">\n                                        <div className=\"p-3 bg-blue-50 dark:bg-blue-900/20 rounded-xl group-hover:scale-110 transition-transform duration-300\">\n                                            <User className=\"w-6 h-6 text-blue-500\" />\n                                        </div>\n                                        <div>\n                                            <div className=\"text-xs text-gray-400 uppercase tracking-wider font-semibold mb-1\">{t('settings.about.author')}</div>\n                                            <div className=\"font-bold text-gray-900 dark:text-base-content\">Ctrler</div>\n                                        </div>\n                                    </div>\n\n                                    {/* WeChat Card */}\n                                    <div className=\"bg-white dark:bg-base-100 p-4 rounded-2xl border border-gray-100 dark:border-base-300 shadow-sm hover:shadow-md hover:border-green-200 dark:hover:border-green-800 transition-all group flex flex-col items-center text-center gap-3\">\n                                        <div className=\"p-3 bg-green-50 dark:bg-green-900/20 rounded-xl group-hover:scale-110 transition-transform duration-300\">\n                                            <MessageCircle className=\"w-6 h-6 text-green-500\" />\n                                        </div>\n                                        <div>\n                                            <div className=\"text-xs text-gray-400 uppercase tracking-wider font-semibold mb-1\">{t('settings.about.wechat')}</div>\n                                            <div className=\"font-bold text-gray-900 dark:text-base-content\">Ctrler</div>\n                                        </div>\n                                    </div>\n\n                                    {/* Telegram Card */}\n                                    <a\n                                        href=\"https://t.me/AntigravityManager\"\n                                        target=\"_blank\"\n                                        rel=\"noreferrer\"\n                                        className=\"bg-white dark:bg-base-100 p-4 rounded-2xl border border-gray-100 dark:border-base-300 shadow-sm hover:shadow-md hover:border-sky-200 dark:hover:border-sky-800 transition-all group flex flex-col items-center text-center gap-3 cursor-pointer\"\n                                    >\n                                        <div className=\"p-3 bg-sky-50 dark:bg-sky-900/20 rounded-xl group-hover:scale-110 transition-transform duration-300\">\n                                            <Send className=\"w-6 h-6 text-sky-500\" />\n                                        </div>\n                                        <div>\n                                            <div className=\"text-xs text-gray-400 uppercase tracking-wider font-semibold mb-1\">{t('settings.about.telegram')}</div>\n                                            <div className=\"font-bold text-gray-900 dark:text-base-content whitespace-nowrap overflow-hidden text-ellipsis w-full\">Channel</div>\n                                        </div>\n                                    </a>\n\n                                    {/* GitHub Card */}\n                                    <a\n                                        href=\"https://github.com/lbjlaq/Antigravity-Manager\"\n                                        target=\"_blank\"\n                                        rel=\"noreferrer\"\n                                        className=\"bg-white dark:bg-base-100 p-4 rounded-2xl border border-gray-100 dark:border-base-300 shadow-sm hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600 transition-all group flex flex-col items-center text-center gap-3 cursor-pointer\"\n                                    >\n                                        <div className=\"p-3 bg-gray-50 dark:bg-gray-800 rounded-xl group-hover:scale-110 transition-transform duration-300\">\n                                            <Github className=\"w-6 h-6 text-gray-900 dark:text-white\" />\n                                        </div>\n                                        <div>\n                                            <div className=\"text-xs text-gray-400 uppercase tracking-wider font-semibold mb-1\">{t('settings.about.github')}</div>\n                                            <div className=\"flex items-center gap-1 font-bold text-gray-900 dark:text-base-content\">\n                                                <span>{t('settings.about.view_code')}</span>\n                                                <ExternalLink className=\"w-3 h-3 text-gray-400\" />\n                                            </div>\n                                        </div>\n                                    </a>\n\n                                    {/* Support Card */}\n                                    <div\n                                        onClick={() => setIsSupportModalOpen(true)}\n                                        className=\"bg-white dark:bg-base-100 p-4 rounded-2xl border border-gray-100 dark:border-base-300 shadow-sm hover:shadow-md hover:border-pink-200 dark:hover:border-pink-800 transition-all group flex flex-col items-center text-center gap-3 cursor-pointer\"\n                                    >\n                                        <div className=\"p-3 bg-pink-50 dark:bg-pink-900/20 rounded-xl group-hover:scale-110 transition-transform duration-300\">\n                                            <Heart className=\"w-6 h-6 text-pink-500 fill-pink-500\" />\n                                        </div>\n                                        <div>\n                                            <div className=\"text-xs text-gray-400 uppercase tracking-wider font-semibold mb-1\">{t('settings.about.support_title')}</div>\n                                            <div className=\"font-bold text-gray-900 dark:text-base-content\">{t('settings.about.support_btn')}</div>\n                                        </div>\n                                    </div>\n                                </div>\n\n                                {/* Tech Stack Badges */}\n                                <div className=\"flex gap-2 justify-center\">\n                                    <div className=\"px-3 py-1 bg-gray-50 dark:bg-base-200 rounded-lg text-xs font-medium text-gray-500 dark:text-gray-400 border border-gray-100 dark:border-base-300\">\n                                        Tauri v2\n                                    </div>\n                                    <div className=\"px-3 py-1 bg-gray-50 dark:bg-base-200 rounded-lg text-xs font-medium text-gray-500 dark:text-gray-400 border border-gray-100 dark:border-base-300\">\n                                        React 19\n                                    </div>\n                                    <div className=\"px-3 py-1 bg-gray-50 dark:bg-base-200 rounded-lg text-xs font-medium text-gray-500 dark:text-gray-400 border border-gray-100 dark:border-base-300\">\n                                        TypeScript\n                                    </div>\n                                </div>\n\n                                {/* Check for Updates */}\n                                <div className=\"flex flex-col items-center gap-3\">\n                                    <button\n                                        onClick={handleCheckUpdate}\n                                        disabled={isCheckingUpdate}\n                                        className=\"px-6 py-2.5 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 dark:disabled:bg-gray-700 text-white rounded-lg transition-all flex items-center gap-2 shadow-sm hover:shadow-md disabled:cursor-not-allowed\"\n                                    >\n                                        <RefreshCw className={`w-4 h-4 ${isCheckingUpdate ? 'animate-spin' : ''}`} />\n                                        {isCheckingUpdate ? t('settings.about.checking_update') : t('settings.about.check_update')}\n                                    </button>\n\n                                    {/* Update Status */}\n                                    {updateInfo && !isCheckingUpdate && (\n                                        <div className=\"text-center\">\n                                            {updateInfo.hasUpdate ? (\n                                                <div className=\"flex flex-col items-center gap-2\">\n                                                    <div className=\"text-sm text-orange-600 dark:text-orange-400 font-medium\">\n                                                        {t('settings.about.new_version_available', { version: updateInfo.latestVersion })}\n                                                    </div>\n                                                    <div className=\"flex items-center gap-2\">\n                                                        {isBrewInstalled && (\n                                                            <button\n                                                                onClick={() => setIsBrewConfirmOpen(true)}\n                                                                disabled={isBrewUpgrading}\n                                                                className=\"px-4 py-1.5 bg-green-500 hover:bg-green-600 disabled:bg-gray-300 dark:disabled:bg-gray-700 text-white text-sm rounded-lg transition-colors flex items-center gap-1.5 disabled:cursor-not-allowed\"\n                                                            >\n                                                                {isBrewUpgrading ? (\n                                                                    <>\n                                                                        <RefreshCw className=\"w-3.5 h-3.5 animate-spin\" />\n                                                                        {t('settings.about.brew_upgrading')}\n                                                                    </>\n                                                                ) : (\n                                                                    t('settings.about.brew_upgrade')\n                                                                )}\n                                                            </button>\n                                                        )}\n                                                        <a\n                                                            href={updateInfo.downloadUrl}\n                                                            target=\"_blank\"\n                                                            rel=\"noreferrer\"\n                                                            className=\"px-4 py-1.5 bg-orange-500 hover:bg-orange-600 text-white text-sm rounded-lg transition-colors flex items-center gap-1.5\"\n                                                        >\n                                                            {t('settings.about.download_update')}\n                                                            <ExternalLink className=\"w-3.5 h-3.5\" />\n                                                        </a>\n                                                    </div>\n                                                </div>\n                                            ) : (\n                                                <div className=\"text-sm text-green-600 dark:text-green-400 font-medium\">\n                                                    ✓ {t('settings.about.latest_version')}\n                                                </div>\n                                            )}\n                                        </div>\n                                    )}\n                                </div>\n                            </div>\n\n                            <div className=\"text-center text-[10px] text-gray-300 dark:text-gray-600 mt-auto pb-2\">\n                                {t('settings.about.copyright')}\n                            </div>\n                        </div>\n                    )\n                    }\n                </div >\n\n                <ModalDialog\n                    isOpen={isClearLogsOpen}\n                    title={t('settings.advanced.clear_logs_title')}\n                    message={t('settings.advanced.clear_logs_msg')}\n                    type=\"confirm\"\n                    confirmText={t('common.clear')}\n                    cancelText={t('common.cancel')}\n                    isDestructive={true}\n                    onConfirm={confirmClearLogs}\n                    onCancel={() => setIsClearLogsOpen(false)}\n                />\n\n                {/* Antigravity Cache Clear Modal */}\n                <ModalDialog\n                    isOpen={isClearCacheOpen}\n                    title={t('settings.advanced.clear_cache_confirm_title')}\n                    type=\"confirm\"\n                    confirmText={isClearingCache ? t('common.clearing') : t('common.clear')}\n                    cancelText={t('common.cancel')}\n                    isDestructive={true}\n                    onConfirm={confirmClearAntigravityCache}\n                    onCancel={() => setIsClearCacheOpen(false)}\n                >\n                    <div className=\"space-y-3\">\n                        <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n                            {t('settings.advanced.clear_cache_confirm_msg')}\n                        </p>\n                        {cachePaths.length > 0 ? (\n                            <div className=\"bg-gray-50 dark:bg-base-200 rounded-lg p-3 max-h-40 overflow-y-auto\">\n                                <ul className=\"text-xs font-mono text-gray-600 dark:text-gray-400 space-y-1\">\n                                    {cachePaths.map((path, index) => (\n                                        <li key={index} className=\"truncate\">• {path}</li>\n                                    ))}\n                                </ul>\n                            </div>\n                        ) : (\n                            <div className=\"bg-gray-50 dark:bg-base-200 rounded-lg p-3\">\n                                <p className=\"text-xs text-gray-500 dark:text-gray-400\">\n                                    {t('settings.advanced.cache_not_found')}\n                                </p>\n                            </div>\n                        )}\n                        <div className=\"bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700/30 rounded-lg p-2\">\n                            <p className=\"text-xs text-amber-700 dark:text-amber-400\">\n                                {t('settings.advanced.antigravity_cache_warning')}\n                            </p>\n                        </div>\n                    </div>\n                </ModalDialog>\n\n                {/* Homebrew Upgrade Confirm Modal */}\n                <ModalDialog\n                    isOpen={isBrewConfirmOpen}\n                    title={t('settings.about.brew_confirm_title')}\n                    type=\"confirm\"\n                    confirmText={t('settings.about.brew_confirm_btn')}\n                    cancelText={t('common.cancel')}\n                    onConfirm={handleBrewUpgrade}\n                    onCancel={() => setIsBrewConfirmOpen(false)}\n                >\n                    <div className=\"space-y-3\">\n                        <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n                            {t('settings.about.brew_confirm_desc')}\n                        </p>\n                        <div className=\"bg-gray-50 dark:bg-base-200 rounded-lg p-3\">\n                            <div className=\"flex items-center justify-between gap-2\">\n                                <code className=\"text-xs text-gray-700 dark:text-gray-300 break-all\">brew upgrade --cask antigravity-tools</code>\n                                <button\n                                    className=\"shrink-0 px-2 py-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border border-gray-200 dark:border-base-300 rounded hover:bg-gray-100 dark:hover:bg-base-300 transition-colors\"\n                                    onClick={() => {\n                                        navigator.clipboard.writeText('brew upgrade --cask antigravity-tools');\n                                        showToast(t('common.copied', 'Copied'), 'success');\n                                    }}\n                                >\n                                    {t('common.copy', 'Copy')}\n                                </button>\n                            </div>\n                        </div>\n                        <div className=\"bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700/30 rounded-lg p-3\">\n                            <p className=\"text-xs text-amber-700 dark:text-amber-400 mb-2\">{t('settings.about.brew_quarantine_hint')}</p>\n                            <div className=\"flex items-center justify-between gap-2\">\n                                <code className=\"text-xs text-amber-800 dark:text-amber-300 break-all\">sudo xattr -rd com.apple.quarantine \"/Applications/Antigravity Tools.app\"</code>\n                                <button\n                                    className=\"shrink-0 px-2 py-1 text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-200 border border-amber-200 dark:border-amber-700 rounded hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors\"\n                                    onClick={() => {\n                                        navigator.clipboard.writeText('sudo xattr -rd com.apple.quarantine \"/Applications/Antigravity Tools.app\"');\n                                        showToast(t('common.copied', 'Copied'), 'success');\n                                    }}\n                                >\n                                    {t('common.copy', 'Copy')}\n                                </button>\n                            </div>\n                        </div>\n                    </div>\n                </ModalDialog>\n\n                {/* Homebrew Upgrade Success Modal */}\n                <ModalDialog\n                    isOpen={isBrewSuccessOpen}\n                    title={t('settings.about.brew_success_title')}\n                    type=\"success\"\n                    confirmText={t('settings.about.brew_restart_btn')}\n                    onConfirm={async () => {\n                        try {\n                            await relaunch();\n                        } catch {\n                            setIsBrewSuccessOpen(false);\n                            showToast(t('settings.about.brew_restart_failed'), 'error');\n                        }\n                    }}\n                >\n                    <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n                        {t('settings.about.brew_upgrade_success')}\n                    </p>\n                </ModalDialog>\n\n                {/* Support Modal */}\n                <div className={`modal ${isSupportModalOpen ? 'modal-open' : ''} z-[100]`}>\n                    <div data-tauri-drag-region className=\"fixed top-0 left-0 right-0 h-8 z-[110]\" />\n                    <div className=\"modal-box relative max-w-2xl bg-white dark:bg-base-100 shadow-2xl rounded-3xl p-0 overflow-hidden transform transition-all animate-in fade-in zoom-in-95 duration-300\">\n                        <div className=\"flex flex-col items-center p-8\">\n                            <div className=\"w-16 h-16 bg-pink-50 dark:bg-pink-900/20 rounded-2xl flex items-center justify-center mb-6 shadow-sm\">\n                                <Coffee className=\"w-8 h-8 text-pink-500\" />\n                            </div>\n\n                            <h3 className=\"text-2xl font-black text-gray-900 dark:text-base-content mb-3\">{t('settings.about.support_title')}</h3>\n                            <p className=\"text-gray-500 dark:text-gray-400 text-sm text-center mb-8 max-w-md leading-relaxed\">\n                                {t('settings.about.support_desc')}\n                            </p>\n\n                            {/* QR Codes Grid */}\n                            <div className=\"grid grid-cols-1 md:grid-cols-3 gap-6 w-full mb-8\">\n                                {/* Alipay */}\n                                <div className=\"flex flex-col items-center gap-3 p-4 rounded-2xl bg-gray-50 dark:bg-base-200 border border-gray-100 dark:border-base-300\">\n                                    <div className=\"w-full aspect-square relative bg-white rounded-xl overflow-hidden shadow-sm border border-gray-100\">\n                                        <img src=\"/images/donate/alipay.png\" alt=\"Alipay\" className=\"w-full h-full object-contain\" />\n                                    </div>\n                                    <span className=\"text-xs font-bold text-gray-700 dark:text-gray-300\">{t('settings.about.support_alipay')}</span>\n                                </div>\n\n                                {/* WeChat */}\n                                <div className=\"flex flex-col items-center gap-3 p-4 rounded-2xl bg-gray-50 dark:bg-base-200 border border-gray-100 dark:border-base-300\">\n                                    <div className=\"w-full aspect-square relative bg-white rounded-xl overflow-hidden shadow-sm border border-gray-100\">\n                                        <img src=\"/images/donate/wechat.png\" alt=\"WeChat\" className=\"w-full h-full object-contain\" />\n                                    </div>\n                                    <span className=\"text-xs font-bold text-gray-700 dark:text-gray-300\">{t('settings.about.support_wechat')}</span>\n                                </div>\n\n                                {/* Buy Me a Coffee */}\n                                <div className=\"flex flex-col items-center gap-3 p-4 rounded-2xl bg-gray-50 dark:bg-base-200 border border-gray-100 dark:border-base-300\">\n                                    <div className=\"w-full aspect-square relative bg-white rounded-xl overflow-hidden shadow-sm border border-gray-100\">\n                                        <img src=\"/images/donate/coffee.png\" alt=\"Buy Me A Coffee\" className=\"w-full h-full object-contain\" />\n                                    </div>\n                                    <span className=\"text-xs font-bold text-gray-700 dark:text-gray-300\">{t('settings.about.support_buymeacoffee')}</span>\n                                </div>\n                            </div>\n\n                            <button\n                                onClick={() => setIsSupportModalOpen(false)}\n                                className=\"w-full md:w-auto px-12 py-3 bg-gray-100 dark:bg-base-300 text-gray-700 dark:text-gray-200 font-bold rounded-xl hover:bg-gray-200 dark:hover:bg-base-200 transition-all\"\n                            >\n                                {t('common.close') || 'Close'}\n                            </button>\n                        </div>\n                    </div>\n                    <div className=\"modal-backdrop bg-black/60 backdrop-blur-md fixed inset-0 z-[-1]\" onClick={() => setIsSupportModalOpen(false)}></div>\n                </div>\n            </div >\n        </div >\n    );\n}\n\nexport default Settings;\n"
  },
  {
    "path": "src/pages/TokenStats.tsx",
    "content": "import React, { useEffect, useState, useRef, useCallback } from 'react';\nimport { request as invoke } from '../utils/request';\nimport { useTranslation } from 'react-i18next';\nimport { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts';\nimport { Clock, Calendar, CalendarDays, Users, Zap, TrendingUp, RefreshCw, Cpu } from 'lucide-react';\n\ninterface TokenStatsAggregated {\n    period: string;\n    total_input_tokens: number;\n    total_output_tokens: number;\n    total_tokens: number;\n    request_count: number;\n}\n\ninterface AccountTokenStats {\n    account_email: string;\n    total_input_tokens: number;\n    total_output_tokens: number;\n    total_tokens: number;\n    request_count: number;\n}\n\ninterface ModelTokenStats {\n    model: string;\n    total_input_tokens: number;\n    total_output_tokens: number;\n    total_tokens: number;\n    request_count: number;\n}\n\ninterface ModelTrendPoint {\n    period: string;\n    model_data: Record<string, number>;\n}\n\ninterface AccountTrendPoint {\n    period: string;\n    account_data: Record<string, number>;\n}\n\ninterface TokenStatsSummary {\n    total_input_tokens: number;\n    total_output_tokens: number;\n    total_tokens: number;\n    total_requests: number;\n    unique_accounts: number;\n}\n\ntype TimeRange = 'hourly' | 'daily' | 'weekly';\ntype ViewMode = 'model' | 'account';\n\nconst MODEL_COLORS = [\n    '#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981',\n    '#06b6d4', '#6366f1', '#f43f5e', '#84cc16', '#a855f7',\n    '#14b8a6', '#f97316', '#64748b', '#0ea5e9', '#d946ef'\n];\n\nconst COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4', '#6366f1', '#f43f5e'];\n\nconst formatNumber = (num: number): string => {\n    if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;\n    if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;\n    return num.toString();\n};\n\nconst shortenModelName = (model: string): string => {\n    return model\n        .replace('gemini-', 'g-')\n        .replace('claude-', 'c-')\n        .replace('-preview', '')\n        .replace('-latest', '');\n};\n\nconst TokenStats: React.FC = () => {\n    const { t } = useTranslation();\n    const [timeRange, setTimeRange] = useState<TimeRange>('daily');\n    const [viewMode, setViewMode] = useState<ViewMode>('model');\n    const [chartData, setChartData] = useState<TokenStatsAggregated[]>([]);\n    const [accountData, setAccountData] = useState<AccountTokenStats[]>([]);\n    const [modelData, setModelData] = useState<ModelTokenStats[]>([]);\n    const [modelTrendData, setModelTrendData] = useState<any[]>([]);\n    const [accountTrendData, setAccountTrendData] = useState<any[]>([]);\n    const [allModels, setAllModels] = useState<string[]>([]);\n    const [allAccounts, setAllAccounts] = useState<string[]>([]);\n    const [summary, setSummary] = useState<TokenStatsSummary | null>(null);\n    const [loading, setLoading] = useState(true);\n\n    const fetchData = async () => {\n        setLoading(true);\n        try {\n            let hours = 24;\n            let data: TokenStatsAggregated[] = [];\n            let modelTrend: ModelTrendPoint[] = [];\n            let accountTrend: AccountTrendPoint[] = [];\n\n            switch (timeRange) {\n                case 'hourly':\n                    hours = 24;\n                    data = await invoke<TokenStatsAggregated[]>('get_token_stats_hourly', { hours: 24 });\n                    modelTrend = await invoke<ModelTrendPoint[]>('get_token_stats_model_trend_hourly', { hours: 24 });\n                    accountTrend = await invoke<AccountTrendPoint[]>('get_token_stats_account_trend_hourly', { hours: 24 });\n                    break;\n                case 'daily':\n                    hours = 168;\n                    data = await invoke<TokenStatsAggregated[]>('get_token_stats_daily', { days: 7 });\n                    modelTrend = await invoke<ModelTrendPoint[]>('get_token_stats_model_trend_daily', { days: 7 });\n                    accountTrend = await invoke<AccountTrendPoint[]>('get_token_stats_account_trend_daily', { days: 7 });\n                    break;\n                case 'weekly':\n                    hours = 720;\n                    data = await invoke<TokenStatsAggregated[]>('get_token_stats_weekly', { weeks: 4 });\n                    modelTrend = await invoke<ModelTrendPoint[]>('get_token_stats_model_trend_daily', { days: 30 });\n                    accountTrend = await invoke<AccountTrendPoint[]>('get_token_stats_account_trend_daily', { days: 30 });\n                    break;\n            }\n\n            setChartData(data);\n\n            const models = new Set<string>();\n            modelTrend.forEach(point => {\n                Object.keys(point.model_data).forEach(m => models.add(m));\n            });\n            const modelList = Array.from(models);\n            setAllModels(modelList);\n\n            const transformedTrend = modelTrend.map(point => {\n                const row: Record<string, any> = { period: point.period };\n                modelList.forEach(model => {\n                    row[model] = point.model_data[model] || 0;\n                });\n                return row;\n            });\n            setModelTrendData(transformedTrend);\n\n            // Process Account Trend Data\n            const accountsSet = new Set<string>();\n            accountTrend.forEach(point => {\n                Object.keys(point.account_data).forEach(acc => accountsSet.add(acc));\n            });\n            const accountList = Array.from(accountsSet);\n            setAllAccounts(accountList);\n\n            const transformedAccountTrend = accountTrend.map(point => {\n                const row: Record<string, any> = { period: point.period };\n                accountList.forEach(acc => {\n                    row[acc] = point.account_data[acc] || 0;\n                });\n                return row;\n            });\n            setAccountTrendData(transformedAccountTrend);\n\n            const [accounts, models_stats, summaryData] = await Promise.all([\n                invoke<AccountTokenStats[]>('get_token_stats_by_account', { hours }),\n                invoke<ModelTokenStats[]>('get_token_stats_by_model', { hours }),\n                invoke<TokenStatsSummary>('get_token_stats_summary', { hours })\n            ]);\n\n            setAccountData(accounts);\n            setModelData(models_stats);\n            setSummary(summaryData);\n        } catch (error) {\n            console.error('Failed to fetch token stats:', error);\n        } finally {\n            setLoading(false);\n        }\n    };\n\n    useEffect(() => {\n        fetchData();\n    }, [timeRange]);\n\n    const pieData = accountData.slice(0, 8).map((account, index) => ({\n        name: account.account_email.split('@')[0] + '...',\n        value: account.total_tokens,\n        fullEmail: account.account_email,\n        color: COLORS[index % COLORS.length]\n    }));\n\n    const modelColorMap = new Map<string, string>();\n    allModels.forEach((model, index) => {\n        modelColorMap.set(model, MODEL_COLORS[index % MODEL_COLORS.length]);\n    });\n\n    const trendChartContainerRef = useRef<HTMLDivElement>(null);\n    const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | undefined>(undefined);\n\n    // Ref and state for pie chart tooltip position\n    const pieChartContainerRef = useRef<HTMLDivElement>(null);\n    const [pieTooltipPosition, setPieTooltipPosition] = useState<{ x: number; y: number } | undefined>(undefined);\n\n    // Handle mouse move to calculate tooltip position\n    const handleTrendChartMouseMove = useCallback((e: any) => {\n        if (!trendChartContainerRef.current || !e?.activeCoordinate) return;\n\n        const containerRect = trendChartContainerRef.current.getBoundingClientRect();\n        const tooltipWidth = 200; // Approximate tooltip width\n        const rightEdgeThreshold = containerRect.width - tooltipWidth - 20; // 20px buffer\n\n        const mouseXInContainer = e.activeCoordinate.x;\n\n        if (mouseXInContainer > rightEdgeThreshold) {\n            setTooltipPosition({\n                x: e.activeCoordinate.x - tooltipWidth - 15,\n                y: e.activeCoordinate.y\n            });\n        } else {\n            setTooltipPosition(undefined); // Use default positioning\n        }\n    }, []);\n\n    // Handle mouse move for pie chart to calculate tooltip position\n    const handlePieChartMouseMove = useCallback((e: any) => {\n        if (!pieChartContainerRef.current) return;\n\n        const containerRect = pieChartContainerRef.current.getBoundingClientRect();\n        const tooltipWidth = 180; // Approximate tooltip width for pie chart\n\n        // Get mouse position relative to container\n        if (e?.activeCoordinate) {\n            const mouseXInContainer = e.activeCoordinate.x;\n            const rightEdgeThreshold = containerRect.width - tooltipWidth - 20;\n\n            if (mouseXInContainer > rightEdgeThreshold) {\n                setPieTooltipPosition({\n                    x: e.activeCoordinate.x - tooltipWidth - 15,\n                    y: e.activeCoordinate.y\n                });\n            } else {\n                setPieTooltipPosition(undefined);\n            }\n        }\n    }, []);\n\n    // Custom Tooltip for Trend Chart\n    const CustomTrendTooltip = ({ active, payload, label }: any) => {\n        if (!active || !payload || !payload.length) return null;\n\n        // Sort payload by value descending\n        const sortedPayload = [...payload].sort((a: any, b: any) => b.value - a.value);\n\n        return (\n            <div className=\"bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2.5 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 text-xs z-[100] min-w-[180px] pointer-events-none\">\n                <p className=\"font-semibold text-gray-700 dark:text-gray-200 mb-1.5 border-b border-gray-100 dark:border-gray-700 pb-1.5\">\n                    {label}\n                </p>\n                <div className=\"max-h-[180px] overflow-y-auto space-y-1 pr-1.5 scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700\">\n                    {sortedPayload.map((entry: any, index: number) => {\n                        const name = entry.name;\n                        const displayName = viewMode === 'model' ? shortenModelName(name) : name.split('@')[0];\n                        return (\n                            <div key={index} className=\"flex items-center justify-between gap-4\">\n                                <div className=\"flex items-center gap-2 overflow-hidden\">\n                                    <div className=\"w-2 h-2 rounded-full flex-shrink-0\" style={{ backgroundColor: entry.color }} />\n                                    <span className=\"text-gray-500 dark:text-gray-400 truncate max-w-[120px]\" title={name}>\n                                        {displayName}\n                                    </span>\n                                </div>\n                                <span className=\"font-mono font-medium text-gray-700 dark:text-gray-200\">\n                                    {formatNumber(entry.value)}\n                                </span>\n                            </div>\n                        );\n                    })}\n                </div>\n            </div>\n        );\n    };\n\n    // Custom Tooltip for Bar/Pie Charts\n    const SimpleCustomTooltip = ({ active, payload, label }: any) => {\n        if (!active || !payload || !payload.length) return null;\n        return (\n            <div className=\"bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2.5 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 text-xs z-[100] pointer-events-none\">\n                {label && <p className=\"font-semibold text-gray-700 dark:text-gray-200 mb-2\">{label}</p>}\n                <div className=\"space-y-1\">\n                    {payload.map((entry: any, index: number) => (\n                        <div key={index} className=\"flex items-center gap-2\">\n                            <div className=\"w-2 h-2 rounded-full\" style={{ backgroundColor: entry.color || entry.fill }} />\n                            <span className=\"text-gray-500 dark:text-gray-400\">\n                                {entry.name}:\n                            </span>\n                            <span className=\"font-mono font-medium text-gray-700 dark:text-gray-200\">\n                                {formatNumber(entry.value)}\n                            </span>\n                        </div>\n                    ))}\n                </div>\n            </div>\n        );\n    };\n\n    // Custom Tooltip for Pie Chart\n    const CustomPieTooltip = ({ active, payload }: any) => {\n        if (!active || !payload || !payload.length) return null;\n        const entry = payload[0];\n        return (\n            <div className=\"bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2.5 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 text-xs z-[100] pointer-events-none\">\n                <div className=\"flex items-center gap-2\">\n                    <div className=\"w-2 h-2 rounded-full\" style={{ backgroundColor: entry.payload.color || entry.color }} />\n                    <span className=\"text-gray-500 dark:text-gray-400\">\n                        {entry.payload.fullEmail || entry.name}:\n                    </span>\n                    <span className=\"font-mono font-medium text-gray-700 dark:text-gray-200\">\n                        {formatNumber(entry.value)}\n                    </span>\n                </div>\n            </div>\n        );\n    };\n\n    return (\n        <div className=\"h-full w-full overflow-y-auto\">\n            <div className=\"p-5 space-y-4 max-w-7xl mx-auto\">\n                <div className=\"flex items-center justify-between\">\n                    <h1 className=\"text-2xl font-bold text-gray-800 dark:text-white flex items-center gap-2\">\n                        <Zap className=\"w-6 h-6 text-blue-500\" />\n                        {t('token_stats.title', 'Token 消费统计')}\n                    </h1>\n                    <div className=\"flex items-center gap-2\">\n                        <div className=\"flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1\">\n                            <button\n                                onClick={() => setTimeRange('hourly')}\n                                className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-1.5 ${timeRange === 'hourly'\n                                    ? 'bg-white dark:bg-gray-700 text-blue-600 shadow-sm'\n                                    : 'text-gray-600 dark:text-gray-400 hover:text-gray-800'\n                                    }`}\n                            >\n                                <Clock className=\"w-4 h-4\" />\n                                {t('token_stats.hourly', '小时')}\n                            </button>\n                            <button\n                                onClick={() => setTimeRange('daily')}\n                                className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-1.5 ${timeRange === 'daily'\n                                    ? 'bg-white dark:bg-gray-700 text-blue-600 shadow-sm'\n                                    : 'text-gray-600 dark:text-gray-400 hover:text-gray-800'\n                                    }`}\n                            >\n                                <Calendar className=\"w-4 h-4\" />\n                                {t('token_stats.daily', '日')}\n                            </button>\n                            <button\n                                onClick={() => setTimeRange('weekly')}\n                                className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-1.5 ${timeRange === 'weekly'\n                                    ? 'bg-white dark:bg-gray-700 text-blue-600 shadow-sm'\n                                    : 'text-gray-600 dark:text-gray-400 hover:text-gray-800'\n                                    }`}\n                            >\n                                <CalendarDays className=\"w-4 h-4\" />\n                                {t('token_stats.weekly', '周')}\n                            </button>\n                        </div>\n                        <button\n                            onClick={fetchData}\n                            disabled={loading}\n                            className=\"p-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors disabled:opacity-50\"\n                        >\n                            <RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />\n                        </button>\n                    </div>\n                </div>\n\n                {summary && (\n                    <div className=\"grid grid-cols-2 md:grid-cols-5 gap-4\">\n                        <div className=\"bg-gradient-to-br from-white to-gray-50 dark:from-gray-800 dark:to-gray-800/50 rounded-xl p-4 shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow\">\n                            <div className=\"flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm mb-2\">\n                                <div className=\"p-1.5 rounded-lg bg-gray-100 dark:bg-gray-700\">\n                                    <Zap className=\"w-4 h-4 text-gray-600 dark:text-gray-300\" />\n                                </div>\n                                {t('token_stats.total_tokens', '总 Token')}\n                            </div>\n                            <div className=\"text-2xl font-bold text-gray-800 dark:text-white\">\n                                {formatNumber(summary.total_tokens)}\n                            </div>\n                        </div>\n                        <div className=\"bg-gradient-to-br from-blue-50/50 to-white dark:from-blue-900/10 dark:to-gray-800 rounded-xl p-4 shadow-sm border border-blue-100 dark:border-blue-900/30 hover:shadow-md transition-shadow\">\n                            <div className=\"flex items-center gap-2 text-blue-600/80 dark:text-blue-400/80 text-sm mb-2\">\n                                <div className=\"p-1.5 rounded-lg bg-blue-100/50 dark:bg-blue-900/30\">\n                                    <TrendingUp className=\"w-4 h-4 text-blue-600 dark:text-blue-400\" />\n                                </div>\n                                {t('token_stats.input_tokens', '输入 Token')}\n                            </div>\n                            <div className=\"text-2xl font-bold text-blue-600 dark:text-blue-400\">\n                                {formatNumber(summary.total_input_tokens)}\n                            </div>\n                        </div>\n                        <div className=\"bg-gradient-to-br from-purple-50/50 to-white dark:from-purple-900/10 dark:to-gray-800 rounded-xl p-4 shadow-sm border border-purple-100 dark:border-purple-900/30 hover:shadow-md transition-shadow\">\n                            <div className=\"flex items-center gap-2 text-purple-600/80 dark:text-purple-400/80 text-sm mb-2\">\n                                <div className=\"p-1.5 rounded-lg bg-purple-100/50 dark:bg-purple-900/30\">\n                                    <TrendingUp className=\"w-4 h-4 rotate-180 text-purple-600 dark:text-purple-400\" />\n                                </div>\n                                {t('token_stats.output_tokens', '输出 Token')}\n                            </div>\n                            <div className=\"text-2xl font-bold text-purple-600 dark:text-purple-400\">\n                                {formatNumber(summary.total_output_tokens)}\n                            </div>\n                        </div>\n                        <div className=\"bg-gradient-to-br from-green-50/50 to-white dark:from-green-900/10 dark:to-gray-800 rounded-xl p-4 shadow-sm border border-green-100 dark:border-green-900/30 hover:shadow-md transition-shadow\">\n                            <div className=\"flex items-center gap-2 text-green-600/80 dark:text-green-400/80 text-sm mb-2\">\n                                <div className=\"p-1.5 rounded-lg bg-green-100/50 dark:bg-green-900/30\">\n                                    <Users className=\"w-4 h-4 text-green-600 dark:text-green-400\" />\n                                </div>\n                                {t('token_stats.accounts_used', '活跃账号')}\n                            </div>\n                            <div className=\"text-2xl font-bold text-green-600 dark:text-green-400\">\n                                {summary.unique_accounts}\n                            </div>\n                        </div>\n                        <div className=\"bg-gradient-to-br from-orange-50/50 to-white dark:from-orange-900/10 dark:to-gray-800 rounded-xl p-4 shadow-sm border border-orange-100 dark:border-orange-900/30 hover:shadow-md transition-shadow\">\n                            <div className=\"flex items-center gap-2 text-orange-600/80 dark:text-orange-400/80 text-sm mb-2\">\n                                <div className=\"p-1.5 rounded-lg bg-orange-100/50 dark:bg-orange-900/30\">\n                                    <Cpu className=\"w-4 h-4 text-orange-600 dark:text-orange-400\" />\n                                </div>\n                                {t('token_stats.models_used', '使用模型')}\n                            </div>\n                            <div className=\"text-2xl font-bold text-orange-600 dark:text-orange-400\">\n                                {modelData.length}\n                            </div>\n                        </div>\n                    </div>\n                )}\n\n                <div className=\"bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-gray-700\">\n                    <div className=\"flex items-center justify-between mb-4\">\n                        <h2 className=\"text-lg font-semibold text-gray-800 dark:text-white flex items-center gap-2\">\n                            {viewMode === 'model' ? (\n                                <Cpu className=\"w-5 h-5 text-purple-500\" />\n                            ) : (\n                                <Users className=\"w-5 h-5 text-green-500\" />\n                            )}\n                            {viewMode === 'model'\n                                ? t('token_stats.model_trend', '分模型使用趋势')\n                                : t('token_stats.account_trend', '分账号使用趋势')\n                            }\n                        </h2>\n                        <div className=\"flex bg-gray-100/80 dark:bg-gray-700/50 rounded-lg p-1\">\n                            <button\n                                onClick={() => setViewMode('model')}\n                                className={`px-3 py-1 text-xs font-medium rounded-md transition-all ${viewMode === 'model'\n                                    ? 'bg-white dark:bg-gray-600 text-blue-600 dark:text-blue-400 shadow-sm'\n                                    : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'\n                                    }`}\n                            >\n                                {t('token_stats.by_model', '按模型')}\n                            </button>\n                            <button\n                                onClick={() => setViewMode('account')}\n                                className={`px-3 py-1 text-xs font-medium rounded-md transition-all ${viewMode === 'account'\n                                    ? 'bg-white dark:bg-gray-600 text-blue-600 dark:text-blue-400 shadow-sm'\n                                    : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'\n                                    }`}\n                            >\n                                {t('token_stats.by_account_view', '按账号')}\n                            </button>\n                        </div>\n                    </div>\n                    <div className=\"h-72\" ref={trendChartContainerRef}>\n                        {modelTrendData.length > 0 && allModels.length > 0 ? (\n                            <ResponsiveContainer width=\"100%\" height=\"100%\">\n                                <AreaChart\n                                    data={viewMode === 'model' ? modelTrendData : accountTrendData}\n                                    onMouseMove={handleTrendChartMouseMove}\n                                    onMouseLeave={() => setTooltipPosition(undefined)}\n                                >\n                                    <CartesianGrid strokeDasharray=\"3 3\" vertical={false} stroke=\"#374151\" strokeOpacity={0.15} />\n                                    <XAxis\n                                        dataKey=\"period\"\n                                        tick={{ fontSize: 11, fill: '#6b7280' }}\n                                        tickFormatter={(val) => {\n                                            if (timeRange === 'hourly') return val.split(' ')[1] || val;\n                                            if (timeRange === 'daily') return val.split('-').slice(1).join('/');\n                                            return val;\n                                        }}\n                                        axisLine={false}\n                                        tickLine={false}\n                                        dy={10}\n                                    />\n                                    <YAxis\n                                        tick={{ fontSize: 11, fill: '#6b7280' }}\n                                        tickFormatter={(val) => formatNumber(val)}\n                                        axisLine={false}\n                                        tickLine={false}\n                                    />\n                                    <Tooltip\n                                        content={<CustomTrendTooltip />}\n                                        cursor={{ stroke: '#6b7280', strokeWidth: 1, strokeDasharray: '4 4', fill: 'transparent' }}\n                                        allowEscapeViewBox={{ x: true, y: true }}\n                                        position={tooltipPosition}\n                                        wrapperStyle={{ zIndex: 100 }}\n                                    />\n                                    <Legend\n                                        formatter={(value) => viewMode === 'model' ? shortenModelName(value) : value.split('@')[0]}\n                                        wrapperStyle={{\n                                            fontSize: '11px',\n                                            paddingTop: '10px',\n                                            maxHeight: '60px',\n                                            overflowY: 'auto',\n                                            zIndex: 0\n                                        }}\n                                    />\n                                    {(viewMode === 'model' ? allModels : allAccounts).map((item, index) => (\n                                        <Area\n                                            key={item}\n                                            type=\"monotone\"\n                                            dataKey={item}\n                                            stackId=\"1\"\n                                            stroke={viewMode === 'model' ? MODEL_COLORS[index % MODEL_COLORS.length] : COLORS[index % COLORS.length]}\n                                            fill={viewMode === 'model' ? MODEL_COLORS[index % MODEL_COLORS.length] : COLORS[index % COLORS.length]}\n                                            fillOpacity={0.6}\n                                        />\n                                    ))}\n                                </AreaChart>\n                            </ResponsiveContainer>\n                        ) : (\n                            <div className=\"h-full flex items-center justify-center text-gray-400\">\n                                {loading ? t('common.loading', '加载中...') : t('token_stats.no_data', '暂无数据')}\n                            </div>\n                        )}\n                    </div>\n                </div>\n\n                <div className=\"grid grid-cols-1 lg:grid-cols-3 gap-6\">\n                    <div className=\"lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-gray-700 flex flex-col\">\n                        <h2 className=\"text-lg font-semibold text-gray-800 dark:text-white mb-4\">\n                            {t('token_stats.usage_trend', 'Token 使用趋势')}\n                        </h2>\n                        <div className=\"flex-1 min-h-[16rem]\">\n                            {chartData.length > 0 ? (\n                                <ResponsiveContainer width=\"100%\" height=\"100%\">\n                                    <BarChart data={chartData}>\n                                        <CartesianGrid strokeDasharray=\"3 3\" vertical={false} stroke=\"#374151\" strokeOpacity={0.15} />\n                                        <XAxis\n                                            dataKey=\"period\"\n                                            tick={{ fontSize: 11, fill: '#6b7280' }}\n                                            tickFormatter={(val) => {\n                                                if (timeRange === 'hourly') return val.split(' ')[1] || val;\n                                                if (timeRange === 'daily') return val.split('-').slice(1).join('/');\n                                                return val;\n                                            }}\n                                            axisLine={false}\n                                            tickLine={false}\n                                            dy={10}\n                                        />\n                                        <YAxis\n                                            tick={{ fontSize: 11, fill: '#6b7280' }}\n                                            tickFormatter={(val) => formatNumber(val)}\n                                            axisLine={false}\n                                            tickLine={false}\n                                        />\n                                        <Tooltip\n                                            content={<SimpleCustomTooltip />}\n                                            cursor={{ fill: 'transparent' }}\n                                            allowEscapeViewBox={{ x: true, y: true }}\n                                            wrapperStyle={{ zIndex: 100 }}\n                                        />\n                                        <Bar dataKey=\"total_input_tokens\" name=\"Input\" fill=\"#3b82f6\" radius={[4, 4, 0, 0]} maxBarSize={50} />\n                                        <Bar dataKey=\"total_output_tokens\" name=\"Output\" fill=\"#8b5cf6\" radius={[4, 4, 0, 0]} maxBarSize={50} />\n                                    </BarChart>\n                                </ResponsiveContainer>\n                            ) : (\n                                <div className=\"h-full flex items-center justify-center text-gray-400\">\n                                    {loading ? t('common.loading', '加载中...') : t('token_stats.no_data', '暂无数据')}\n                                </div>\n                            )}\n                        </div>\n                    </div>\n\n                    <div className=\"bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-gray-700\">\n                        <h2 className=\"text-lg font-semibold text-gray-800 dark:text-white mb-4\">\n                            {t('token_stats.by_account', '分账号统计')}\n                        </h2>\n                        <div className=\"h-48\" ref={pieChartContainerRef}>\n                            {pieData.length > 0 ? (\n                                <ResponsiveContainer width=\"100%\" height=\"100%\">\n                                    <PieChart\n                                        onMouseMove={handlePieChartMouseMove}\n                                        onMouseLeave={() => setPieTooltipPosition(undefined)}\n                                    >\n                                        <Pie\n                                            data={pieData}\n                                            cx=\"50%\"\n                                            cy=\"50%\"\n                                            innerRadius={40}\n                                            outerRadius={70}\n                                            paddingAngle={2}\n                                            dataKey=\"value\"\n                                        >\n                                            {pieData.map((entry, index) => (\n                                                <Cell key={`cell-${index}`} fill={entry.color} />\n                                            ))}\n                                        </Pie>\n                                        <Tooltip\n                                            content={<CustomPieTooltip />}\n                                            allowEscapeViewBox={{ x: true, y: true }}\n                                            position={pieTooltipPosition}\n                                            wrapperStyle={{ zIndex: 100 }}\n                                        />\n                                    </PieChart>\n                                </ResponsiveContainer>\n                            ) : (\n                                <div className=\"h-full flex items-center justify-center text-gray-400\">\n                                    {loading ? t('common.loading', '加载中...') : t('token_stats.no_data', '暂无数据')}\n                                </div>\n                            )}\n                        </div>\n                        <div className=\"mt-4 space-y-2 max-h-32 overflow-y-auto\">\n                            {accountData.slice(0, 5).map((account, index) => (\n                                <div key={account.account_email} className=\"flex items-center justify-between text-sm\">\n                                    <div className=\"flex items-center gap-2\">\n                                        <div\n                                            className=\"w-3 h-3 rounded-full\"\n                                            style={{ backgroundColor: COLORS[index % COLORS.length] }}\n                                        />\n                                        <span className=\"text-gray-600 dark:text-gray-300 truncate max-w-[120px]\">\n                                            {account.account_email.split('@')[0]}\n                                        </span>\n                                    </div>\n                                    <span className=\"font-medium text-gray-800 dark:text-white\">\n                                        {formatNumber(account.total_tokens)}\n                                    </span>\n                                </div>\n                            ))}\n                        </div>\n                    </div>\n                </div>\n\n\n                {\n                    modelData.length > 0 && viewMode === 'model' && (\n                        <div className=\"bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-gray-700\">\n                            <h2 className=\"text-lg font-semibold text-gray-800 dark:text-white mb-4 flex items-center gap-2\">\n                                <Cpu className=\"w-5 h-5 text-blue-500\" />\n                                {t('token_stats.model_details', '分模型详细统计')}\n                            </h2>\n                            <div className=\"overflow-x-auto\">\n                                <table className=\"w-full text-sm\">\n                                    <thead>\n                                        <tr className=\"border-b border-gray-200 dark:border-gray-700\">\n                                            <th className=\"text-left py-3 px-4 font-medium text-gray-500 dark:text-gray-400\">\n                                                {t('token_stats.model', '模型')}\n                                            </th>\n                                            <th className=\"text-right py-3 px-4 font-medium text-gray-500 dark:text-gray-400\">\n                                                {t('token_stats.requests', '请求数')}\n                                            </th>\n                                            <th className=\"text-right py-3 px-4 font-medium text-gray-500 dark:text-gray-400\">\n                                                {t('token_stats.input', '输入')}\n                                            </th>\n                                            <th className=\"text-right py-3 px-4 font-medium text-gray-500 dark:text-gray-400\">\n                                                {t('token_stats.output', '输出')}\n                                            </th>\n                                            <th className=\"text-right py-3 px-4 font-medium text-gray-500 dark:text-gray-400\">\n                                                {t('token_stats.total', '合计')}\n                                            </th>\n                                            <th className=\"text-right py-3 px-4 font-medium text-gray-500 dark:text-gray-400\">\n                                                {t('token_stats.percentage', '占比')}\n                                            </th>\n                                        </tr>\n                                    </thead>\n                                    <tbody>\n                                        {modelData.map((model, index) => {\n                                            const percentage = summary ? ((model.total_tokens / summary.total_tokens) * 100).toFixed(1) : '0';\n                                            return (\n                                                <tr\n                                                    key={model.model}\n                                                    className=\"border-b border-gray-100 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-700/30\"\n                                                >\n                                                    <td className=\"py-3 px-4\">\n                                                        <div className=\"flex items-center gap-2\">\n                                                            <div\n                                                                className=\"w-3 h-3 rounded-full\"\n                                                                style={{ backgroundColor: MODEL_COLORS[index % MODEL_COLORS.length] }}\n                                                            />\n                                                            <span className=\"text-gray-800 dark:text-white font-medium\">\n                                                                {model.model}\n                                                            </span>\n                                                        </div>\n                                                    </td>\n                                                    <td className=\"py-3 px-4 text-right text-gray-600 dark:text-gray-300\">\n                                                        {model.request_count.toLocaleString()}\n                                                    </td>\n                                                    <td className=\"py-3 px-4 text-right text-blue-600\">\n                                                        {formatNumber(model.total_input_tokens)}\n                                                    </td>\n                                                    <td className=\"py-3 px-4 text-right text-purple-600\">\n                                                        {formatNumber(model.total_output_tokens)}\n                                                    </td>\n                                                    <td className=\"py-3 px-4 text-right font-semibold text-gray-800 dark:text-white\">\n                                                        {formatNumber(model.total_tokens)}\n                                                    </td>\n                                                    <td className=\"py-3 px-4 text-right\">\n                                                        <div className=\"flex items-center justify-end gap-2\">\n                                                            <div className=\"w-16 bg-gray-200 dark:bg-gray-700 rounded-full h-2\">\n                                                                <div\n                                                                    className=\"h-2 rounded-full\"\n                                                                    style={{\n                                                                        width: `${percentage}%`,\n                                                                        backgroundColor: MODEL_COLORS[index % MODEL_COLORS.length]\n                                                                    }}\n                                                                />\n                                                            </div>\n                                                            <span className=\"text-gray-600 dark:text-gray-300 w-12 text-right\">\n                                                                {percentage}%\n                                                            </span>\n                                                        </div>\n                                                    </td>\n                                                </tr>\n                                            );\n                                        })}\n                                    </tbody>\n                                </table>\n                            </div>\n                        </div>\n                    )\n                }\n\n\n\n                {\n                    accountData.length > 0 && viewMode === 'account' && (\n                        <div className=\"bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-gray-700\">\n                            <h2 className=\"text-lg font-semibold text-gray-800 dark:text-white mb-4\">\n                                {t('token_stats.account_details', '账号详细统计')}\n                            </h2>\n                            <div className=\"overflow-x-auto\">\n                                <table className=\"w-full text-sm\">\n                                    <thead>\n                                        <tr className=\"border-b border-gray-200 dark:border-gray-700\">\n                                            <th className=\"text-left py-3 px-4 font-medium text-gray-500 dark:text-gray-400\">\n                                                {t('token_stats.account', '账号')}\n                                            </th>\n                                            <th className=\"text-right py-3 px-4 font-medium text-gray-500 dark:text-gray-400\">\n                                                {t('token_stats.requests', '请求数')}\n                                            </th>\n                                            <th className=\"text-right py-3 px-4 font-medium text-gray-500 dark:text-gray-400\">\n                                                {t('token_stats.input', '输入')}\n                                            </th>\n                                            <th className=\"text-right py-3 px-4 font-medium text-gray-500 dark:text-gray-400\">\n                                                {t('token_stats.output', '输出')}\n                                            </th>\n                                            <th className=\"text-right py-3 px-4 font-medium text-gray-500 dark:text-gray-400\">\n                                                {t('token_stats.total', '合计')}\n                                            </th>\n                                        </tr>\n                                    </thead>\n                                    <tbody>\n                                        {accountData.map((account) => (\n                                            <tr\n                                                key={account.account_email}\n                                                className=\"border-b border-gray-100 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-700/30\"\n                                            >\n                                                <td className=\"py-3 px-4 text-gray-800 dark:text-white\">\n                                                    {account.account_email}\n                                                </td>\n                                                <td className=\"py-3 px-4 text-right text-gray-600 dark:text-gray-300\">\n                                                    {account.request_count.toLocaleString()}\n                                                </td>\n                                                <td className=\"py-3 px-4 text-right text-blue-600\">\n                                                    {formatNumber(account.total_input_tokens)}\n                                                </td>\n                                                <td className=\"py-3 px-4 text-right text-purple-600\">\n                                                    {formatNumber(account.total_output_tokens)}\n                                                </td>\n                                                <td className=\"py-3 px-4 text-right font-semibold text-gray-800 dark:text-white\">\n                                                    {formatNumber(account.total_tokens)}\n                                                </td>\n                                            </tr>\n                                        ))}\n                                    </tbody>\n                                </table>\n                            </div>\n                        </div>\n                    )\n                }\n            </div>\n        </div>\n    );\n};\n\nexport default TokenStats;\n"
  },
  {
    "path": "src/pages/UserToken.tsx",
    "content": "import React, { useState, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Plus, Trash2, RefreshCw, Copy, Activity, User, Settings, Shield, Clock, Users } from 'lucide-react';\nimport { motion, AnimatePresence } from 'framer-motion';\nimport { request as invoke } from '../utils/request';\nimport { showToast } from '../components/common/ToastContainer';\nimport { copyToClipboard } from '../utils/clipboard';\n\ninterface UserToken {\n    id: string;\n    token: string;\n    username: string;\n    description?: string;\n    enabled: boolean;\n    expires_type: string;\n    expires_at?: number;\n    max_ips: number;\n    curfew_start?: string;\n    curfew_end?: string;\n    created_at: number;\n    updated_at: number;\n    last_used_at?: number;\n    total_requests: number;\n    total_tokens_used: number;\n}\n\ninterface UserTokenStats {\n    total_tokens: number;\n    active_tokens: number;\n    total_users: number;\n    today_requests: number;\n}\n\n// interface CreateTokenRequest omitted as it's not explicitly used for typing variables\n\nconst UserToken: React.FC = () => {\n    const { t } = useTranslation();\n    const [tokens, setTokens] = useState<UserToken[]>([]);\n    const [stats, setStats] = useState<UserTokenStats | null>(null);\n    const [loading, setLoading] = useState(false);\n    const [showCreateModal, setShowCreateModal] = useState(false);\n    const [creating, setCreating] = useState(false);\n\n    // Edit State\n    const [showEditModal, setShowEditModal] = useState(false);\n    const [editingToken, setEditingToken] = useState<UserToken | null>(null);\n    const [editUsername, setEditUsername] = useState('');\n    const [editDesc, setEditDesc] = useState('');\n    const [editMaxIps, setEditMaxIps] = useState(0);\n    const [editCurfewStart, setEditCurfewStart] = useState('');\n    const [editCurfewEnd, setEditCurfewEnd] = useState('');\n    const [updating, setUpdating] = useState(false);\n\n    // Create Form State\n    const [newUsername, setNewUsername] = useState('');\n    const [newDesc, setNewDesc] = useState('');\n    const [newExpiresType, setNewExpiresType] = useState('month'); // day, week, month, never, custom\n    const [newMaxIps, setNewMaxIps] = useState(0);\n    const [newCurfewStart, setNewCurfewStart] = useState('');\n    const [newCurfewEnd, setNewCurfewEnd] = useState('');\n    const [newCustomExpires, setNewCustomExpires] = useState(''); // datetime-local value\n\n    const loadData = async () => {\n        setLoading(true);\n        try {\n            const [tokensData, statsData] = await Promise.all([\n                invoke<UserToken[]>('list_user_tokens'),\n                invoke<UserTokenStats>('get_user_token_summary')\n            ]);\n            setTokens(tokensData);\n            setStats(statsData);\n        } catch (e) {\n            console.error('Failed to load user tokens', e);\n            showToast(t('common.load_failed') || 'Failed to load data', 'error');\n        } finally {\n            setLoading(false);\n        }\n    };\n\n    useEffect(() => {\n        loadData();\n    }, []);\n\n    const handleCreate = async () => {\n        if (!newUsername) {\n            showToast(t('user_token.username_required') || 'Username is required', 'error');\n            return;\n        }\n\n        // 验证自定义时间\n        if (newExpiresType === 'custom' && !newCustomExpires) {\n            showToast(t('user_token.custom_expires_required') || 'Please select a custom expiration time', 'error');\n            return;\n        }\n\n        setCreating(true);\n        try {\n            // 计算自定义过期时间戳\n            const customExpiresAt = newExpiresType === 'custom' && newCustomExpires\n                ? Math.floor(new Date(newCustomExpires).getTime() / 1000)\n                : undefined;\n\n            await invoke('create_user_token', {\n                request: {\n                    username: newUsername,\n                    expires_type: newExpiresType,\n                    description: newDesc || null,\n                    max_ips: newMaxIps,\n                    curfew_start: newCurfewStart || null,\n                    curfew_end: newCurfewEnd || null,\n                    custom_expires_at: customExpiresAt || null\n                }\n            });\n            showToast(t('common.create_success') || 'Created successfully', 'success');\n            setShowCreateModal(false);\n            setNewUsername('');\n            setNewDesc('');\n            setNewExpiresType('month');\n            setNewMaxIps(0);\n            setNewCurfewStart('');\n            setNewCurfewEnd('');\n            setNewCustomExpires('');\n            loadData();\n        } catch (e) {\n            console.error('Failed to create token', e);\n            showToast(String(e), 'error');\n        } finally {\n            setCreating(false);\n        }\n    };\n\n    const handleDelete = async (id: string) => {\n        try {\n            await invoke('delete_user_token', { id });\n            showToast(t('common.delete_success') || 'Deleted successfully', 'success');\n            loadData();\n        } catch (e) {\n            showToast(String(e), 'error');\n        }\n    };\n\n    const handleEdit = (token: UserToken) => {\n        console.log('Editing token:', token); // 调试日志\n        setEditingToken(token);\n        setEditUsername(token.username);\n        setEditDesc(token.description || '');\n        setEditMaxIps(token.max_ips ?? 0);  // 使用 ?? 确保 null/undefined 变为 0\n        setEditCurfewStart(token.curfew_start ?? '');\n        setEditCurfewEnd(token.curfew_end ?? '');\n        setShowEditModal(true);\n    };\n\n    const handleUpdate = async () => {\n        if (!editingToken) return;\n        if (!editUsername) {\n            showToast(t('user_token.username_required') || 'Username is required', 'error');\n            return;\n        }\n\n        setUpdating(true);\n        try {\n            await invoke('update_user_token', {\n                id: editingToken.id,\n                request: {\n                    username: editUsername,\n                    description: editDesc || undefined,\n                    max_ips: editMaxIps,\n                    // 使用双层包装: undefined = 不更新, null = 清空, string = 设置值\n                    curfew_start: editCurfewStart === '' ? null : editCurfewStart,\n                    curfew_end: editCurfewEnd === '' ? null : editCurfewEnd\n                }\n            });\n            showToast(t('common.update_success') || 'Updated successfully', 'success');\n            setShowEditModal(false);\n            setEditingToken(null);\n            loadData();\n        } catch (e) {\n            console.error('Failed to update token', e);\n            showToast(String(e), 'error');\n        } finally {\n            setUpdating(false);\n        }\n    };\n\n    const handleRenew = async (id: string, type: string) => {\n        try {\n            await invoke('renew_user_token', { id, expiresType: type });\n            showToast(t('user_token.renew_success') || 'Renewed successfully', 'success');\n            loadData();\n        } catch (e) {\n            showToast(String(e), 'error');\n        }\n    };\n\n    const handleCopyToken = async (text: string) => {\n        const success = await copyToClipboard(text);\n        if (success) {\n            showToast(t('common.copied') || 'Copied to clipboard', 'success');\n        } else {\n            showToast(t('common.copy_failed') || 'Failed to copy to clipboard', 'error');\n        }\n    };\n\n    const formatTime = (ts?: number) => {\n        if (!ts) return '-';\n        return new Date(ts * 1000).toLocaleString();\n    };\n\n    const getExpiresLabel = (type: string) => {\n        switch (type) {\n            case 'day': return t('user_token.expires_day', { defaultValue: '1 Day' });\n            case 'week': return t('user_token.expires_week', { defaultValue: '1 Week' });\n            case 'month': return t('user_token.expires_month', { defaultValue: '1 Month' });\n            case 'never': return t('user_token.expires_never', { defaultValue: 'Never' });\n            case 'custom': return t('user_token.expires_custom', { defaultValue: 'Custom' });\n            default: return type;\n        }\n    };\n\n    // Calculate expiration status style\n    const getExpiresStatus = (expiresAt?: number) => {\n        if (!expiresAt) return 'text-green-500';\n        const now = Date.now() / 1000;\n        if (expiresAt < now) return 'text-red-500 font-bold';\n        if (expiresAt - now < 86400 * 3) return 'text-orange-500'; // Less than 3 days\n        return 'text-green-500';\n    };\n\n    return (\n        <motion.div\n            initial={{ opacity: 0, y: 10 }}\n            animate={{ opacity: 1, y: 0 }}\n            className=\"h-full flex flex-col p-5 gap-5 max-w-7xl mx-auto w-full\"\n        >\n            {/* Header */}\n            <div className=\"flex justify-between items-center\">\n                <h1 className=\"text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2\">\n                    <div className=\"p-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg\">\n                        <User className=\"text-purple-500 w-5 h-5\" />\n                    </div>\n                    {t('user_token.title', { defaultValue: 'User Tokens' })}\n                </h1>\n\n                <div className=\"flex items-center gap-2\">\n                    <button\n                        onClick={() => loadData()}\n                        className={`p-2 hover:bg-gray-100 dark:hover:bg-base-200 rounded-lg transition-colors ${loading ? 'text-blue-500' : 'text-gray-500'}`}\n                        title={t('common.refresh') || 'Refresh'}\n                    >\n                        <RefreshCw size={18} className={loading ? 'animate-spin' : ''} />\n                    </button>\n                    <button\n                        onClick={() => setShowCreateModal(true)}\n                        className=\"px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-all flex items-center gap-2 shadow-sm shadow-blue-500/20\"\n                    >\n                        <Plus size={16} />\n                        <span>{t('user_token.create', { defaultValue: 'Create Token' })}</span>\n                    </button>\n                </div>\n            </div>\n\n            {/* Stats Cards Row */}\n            <div className=\"grid grid-cols-2 md:grid-cols-4 gap-4\">\n                <motion.div\n                    whileHover={{ y: -2 }}\n                    className=\"bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200\"\n                >\n                    <div className=\"flex items-center justify-between mb-2\">\n                        <div className=\"p-1.5 bg-blue-50 dark:bg-blue-900/20 rounded-md\">\n                            <Users className=\"w-4 h-4 text-blue-500\" />\n                        </div>\n                    </div>\n                    <div className=\"text-2xl font-bold text-gray-900 dark:text-base-content mb-0.5\">{stats?.total_users || 0}</div>\n                    <div className=\"text-xs text-gray-500 dark:text-gray-400\">{t('user_token.total_users', { defaultValue: 'Total Users' })}</div>\n                </motion.div>\n\n                <motion.div\n                    whileHover={{ y: -2 }}\n                    className=\"bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200\"\n                >\n                    <div className=\"flex items-center justify-between mb-2\">\n                        <div className=\"p-1.5 bg-green-50 dark:bg-green-900/20 rounded-md\">\n                            <Activity className=\"w-4 h-4 text-green-500\" />\n                        </div>\n                    </div>\n                    <div className=\"text-2xl font-bold text-gray-900 dark:text-base-content mb-0.5\">{stats?.active_tokens || 0}</div>\n                    <div className=\"text-xs text-gray-500 dark:text-gray-400\">{t('user_token.active_tokens', { defaultValue: 'Active Tokens' })}</div>\n                </motion.div>\n\n                <motion.div\n                    whileHover={{ y: -2 }}\n                    className=\"bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200\"\n                >\n                    <div className=\"flex items-center justify-between mb-2\">\n                        <div className=\"p-1.5 bg-purple-50 dark:bg-purple-900/20 rounded-md\">\n                            <Clock className=\"w-4 h-4 text-purple-500\" />\n                        </div>\n                    </div>\n                    <div className=\"text-2xl font-bold text-gray-900 dark:text-base-content mb-0.5\">{stats?.total_tokens || 0}</div>\n                    <div className=\"text-xs text-gray-500 dark:text-gray-400\">{t('user_token.total_created', { defaultValue: 'Total Tokens' })}</div>\n                </motion.div>\n\n                <motion.div\n                    whileHover={{ y: -2 }}\n                    className=\"bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200\"\n                >\n                    <div className=\"flex items-center justify-between mb-2\">\n                        <div className=\"p-1.5 bg-orange-50 dark:bg-orange-900/20 rounded-md\">\n                            <Shield className=\"w-4 h-4 text-orange-500\" />\n                        </div>\n                    </div>\n                    <div className=\"text-2xl font-bold text-gray-900 dark:text-base-content mb-0.5\">{stats?.today_requests || 0}</div>\n                    <div className=\"text-xs text-gray-500 dark:text-gray-400\">{t('user_token.today_requests', { defaultValue: 'Today Requests' })}</div>\n                </motion.div>\n            </div>\n\n            {/* Token List */}\n            <div className=\"flex-1 overflow-auto bg-white dark:bg-base-100 rounded-2xl shadow-sm border border-gray-100 dark:border-base-200\">\n                <table className=\"table table-pin-rows\">\n                    <thead>\n                        <tr className=\"bg-gray-50/50 dark:bg-base-200/50\">\n                            <th className=\"bg-transparent text-gray-500 font-medium py-4\">{t('user_token.username', { defaultValue: 'Username' })}</th>\n                            <th className=\"bg-transparent text-gray-500 font-medium py-4\">{t('user_token.token', { defaultValue: 'Token' })}</th>\n                            <th className=\"bg-transparent text-gray-500 font-medium py-4\">{t('user_token.expires', { defaultValue: 'Expires' })}</th>\n                            <th className=\"bg-transparent text-gray-500 font-medium py-4\">{t('user_token.usage', { defaultValue: 'Usage' })}</th>\n                            <th className=\"bg-transparent text-gray-500 font-medium py-4\">{t('user_token.ip_limit', { defaultValue: 'IP Limit' })}</th>\n                            <th className=\"bg-transparent text-gray-500 font-medium py-4\">{t('user_token.created', { defaultValue: 'Created' })}</th>\n                            <th className=\"bg-transparent text-gray-500 font-medium py-4 text-right\">{t('common.actions', { defaultValue: 'Actions' })}</th>\n                        </tr>\n                    </thead>\n                    <tbody className=\"divide-y divide-gray-50 dark:divide-base-200\">\n                        <AnimatePresence mode=\"popLayout\">\n                            {tokens.map((token, index) => (\n                                <motion.tr\n                                    key={token.id}\n                                    initial={{ opacity: 0, x: -10 }}\n                                    animate={{ opacity: 1, x: 0 }}\n                                    exit={{ opacity: 0, scale: 0.95 }}\n                                    transition={{ delay: index * 0.03 }}\n                                    className=\"hover:bg-gray-50/80 dark:hover:bg-base-200/50 transition-colors group\"\n                                >\n                                    <td className=\"py-4\">\n                                        <div className=\"flex items-center gap-3\">\n                                            <div className=\"w-8 h-8 rounded-full bg-purple-50 dark:bg-purple-900/20 flex items-center justify-center text-purple-600 font-bold text-xs\">\n                                                {token.username.substring(0, 2).toUpperCase()}\n                                            </div>\n                                            <div>\n                                                <div className=\"font-semibold text-gray-900 dark:text-white uppercase tracking-wider text-xs\">{token.username}</div>\n                                                <div className=\"text-[10px] text-gray-500\">{token.description || '-'}</div>\n                                            </div>\n                                        </div>\n                                    </td>\n                                    <td>\n                                        <div className=\"flex items-center gap-2 group/token\">\n                                            <code className=\"bg-gray-50 dark:bg-base-200 px-2 py-1 rounded border border-gray-100 dark:border-base-300 text-[11px] font-mono text-gray-600 dark:text-gray-400\">\n                                                {token.token.substring(0, 8)}••••••••\n                                            </code>\n                                            <button\n                                                onClick={() => handleCopyToken(token.token)}\n                                                className=\"p-1.5 hover:bg-gray-200 dark:hover:bg-base-300 rounded-md transition-all text-gray-400 hover:text-gray-600 dark:hover:text-white\"\n                                            >\n                                                <Copy size={13} />\n                                            </button>\n                                        </div>\n                                    </td>\n                                    <td>\n                                        <div className={`text-xs font-medium mb-1 ${getExpiresStatus(token.expires_at)}`}>\n                                            {token.expires_at ? formatTime(token.expires_at) : t('user_token.never', { defaultValue: 'Never' })}\n                                        </div>\n                                        <div className=\"flex items-center gap-2\">\n                                            <span className=\"text-[10px] px-1.5 py-0.5 bg-gray-100 dark:bg-base-200 text-gray-500 rounded lowercase\">\n                                                {getExpiresLabel(token.expires_type)}\n                                            </span>\n                                            {token.expires_at && token.expires_at < Date.now() / 1000 && (\n                                                <button\n                                                    onClick={() => handleRenew(token.id, token.expires_type)}\n                                                    className=\"text-[10px] text-blue-500 hover:underline font-medium\"\n                                                >\n                                                    {t('user_token.renew_button', { defaultValue: 'Renew' })}\n                                                </button>\n                                            )}\n                                        </div>\n                                    </td>\n                                    <td>\n                                        <div className=\"text-xs font-semibold text-gray-700 dark:text-gray-300\">{token.total_requests} <span className=\"text-[10px] font-normal text-gray-400\">reqs</span></div>\n                                        <div className=\"text-[10px] text-gray-400 mt-0.5\">\n                                            {(token.total_tokens_used / 1000).toFixed(1)}k tokens\n                                        </div>\n                                    </td>\n                                    <td>\n                                        {token.max_ips === 0\n                                            ? <span className=\"px-2 py-0.5 bg-gray-100 dark:bg-base-200 text-gray-500 text-[10px] rounded-full\">{t('user_token.unlimited', { defaultValue: 'Unlimited' })}</span>\n                                            : <span className=\"px-2 py-0.5 bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 text-[10px] font-medium rounded-full border border-orange-100 dark:border-orange-900/30\">{token.max_ips} IPs</span>\n                                        }\n                                        {token.curfew_start && token.curfew_end && (\n                                            <div className=\"text-[10px] text-gray-400 mt-1.5 flex items-center gap-1 bg-gray-50 dark:bg-base-200 w-fit px-1.5 py-0.5 rounded\">\n                                                <Clock size={10} className=\"text-orange-500\" />\n                                                <span>{token.curfew_start} - {token.curfew_end}</span>\n                                            </div>\n                                        )}\n                                    </td>\n                                    <td className=\"text-[10px] text-gray-400 italic\">\n                                        {formatTime(token.created_at)}\n                                    </td>\n                                    <td className=\"text-right\">\n                                        <div className=\"flex justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity\">\n                                            <button\n                                                onClick={() => handleEdit(token)}\n                                                className=\"p-1.5 hover:bg-gray-100 dark:hover:bg-base-200 rounded-lg text-gray-500 hover:text-blue-500 transition-colors\"\n                                                title={t('common.edit', { defaultValue: 'Edit' })}\n                                            >\n                                                <Settings size={14} />\n                                            </button>\n                                            <div className=\"dropdown dropdown-end\">\n                                                <label tabIndex={0} className=\"p-1.5 hover:bg-gray-100 dark:hover:bg-base-200 rounded-lg text-gray-500 hover:text-green-500 transition-colors inline-block cursor-pointer\">\n                                                    <RefreshCw size={14} />\n                                                </label>\n                                                <ul tabIndex={0} className=\"dropdown-content z-[10] menu p-2 shadow-xl bg-white dark:bg-base-100 rounded-xl w-32 border border-gray-100 dark:border-base-200 mt-1\">\n                                                    <div className=\"px-3 py-1.5 text-[10px] font-bold text-gray-400 uppercase tracking-widest\">{t('user_token.renew')}</div>\n                                                    <li><a className=\"text-xs py-2\" onClick={() => handleRenew(token.id, 'day')}>{t('user_token.expires_day', { defaultValue: '1 Day' })}</a></li>\n                                                    <li><a className=\"text-xs py-2\" onClick={() => handleRenew(token.id, 'week')}>{t('user_token.expires_week', { defaultValue: '1 Week' })}</a></li>\n                                                    <li><a className=\"text-xs py-2\" onClick={() => handleRenew(token.id, 'month')}>{t('user_token.expires_month', { defaultValue: '1 Month' })}</a></li>\n                                                </ul>\n                                            </div>\n                                            <button\n                                                onClick={() => handleDelete(token.id)}\n                                                className=\"p-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg text-gray-400 hover:text-red-500 transition-colors\"\n                                            >\n                                                <Trash2 size={14} />\n                                            </button>\n                                        </div>\n                                    </td>\n                                </motion.tr>\n                            ))}\n                        </AnimatePresence>\n                        {tokens.length === 0 && !loading && (\n                            <tr>\n                                <td colSpan={7} className=\"py-20\">\n                                    <div className=\"flex flex-col items-center justify-center text-gray-400 gap-3\">\n                                        <div className=\"p-4 bg-gray-50 dark:bg-base-200 rounded-full\">\n                                            <Users size={40} className=\"opacity-20\" />\n                                        </div>\n                                        <p className=\"text-sm\">{t('user_token.no_data', { defaultValue: 'No tokens found' })}</p>\n                                        <button\n                                            onClick={() => setShowCreateModal(true)}\n                                            className=\"text-xs text-blue-500 hover:underline\"\n                                        >\n                                            {t('user_token.create', { defaultValue: 'Create your first token' })}\n                                        </button>\n                                    </div>\n                                </td>\n                            </tr>\n                        )}\n                    </tbody>\n                </table>\n            </div>\n\n            {/* Create Modal */}\n            {showCreateModal && (\n                <div className=\"modal modal-open\">\n                    <div className=\"modal-box\">\n                        <h3 className=\"font-bold text-lg mb-4\">{t('user_token.create_title', { defaultValue: 'Create New Token' })}</h3>\n\n                        <div className=\"form-control w-full mb-3\">\n                            <label className=\"label\">\n                                <span className=\"label-text\">{t('user_token.username', { defaultValue: 'Username' })} *</span>\n                            </label>\n                            <input\n                                type=\"text\"\n                                className=\"input input-bordered w-full\"\n                                value={newUsername}\n                                onChange={e => setNewUsername(e.target.value)}\n                                placeholder={t('user_token.placeholder_username', { defaultValue: 'e.g. user1' })}\n                            />\n                        </div>\n\n                        <div className=\"form-control w-full mb-3\">\n                            <label className=\"label\">\n                                <span className=\"label-text\">{t('user_token.description', { defaultValue: 'Description' })}</span>\n                            </label>\n                            <input\n                                type=\"text\"\n                                className=\"input input-bordered w-full\"\n                                value={newDesc}\n                                onChange={e => setNewDesc(e.target.value)}\n                                placeholder={t('user_token.placeholder_desc', { defaultValue: 'Optional notes' })}\n                            />\n                        </div>\n\n                        <div className=\"grid grid-cols-2 gap-4 mb-3\">\n                            <div className=\"form-control w-full\">\n                                <label className=\"label\">\n                                    <span className=\"label-text\">{t('user_token.expires', { defaultValue: 'Expires In' })}</span>\n                                </label>\n                                <select\n                                    className=\"select select-bordered w-full\"\n                                    value={newExpiresType}\n                                    onChange={e => setNewExpiresType(e.target.value)}\n                                >\n                                    <option value=\"day\">{t('user_token.expires_day', { defaultValue: '1 Day' })}</option>\n                                    <option value=\"week\">{t('user_token.expires_week', { defaultValue: '1 Week' })}</option>\n                                    <option value=\"month\">{t('user_token.expires_month', { defaultValue: '1 Month' })}</option>\n                                    <option value=\"custom\">{t('user_token.expires_custom', { defaultValue: 'Custom' })}</option>\n                                    <option value=\"never\">{t('user_token.expires_never', { defaultValue: 'Never' })}</option>\n                                </select>\n                            </div>\n\n                            <div className=\"form-control w-full\">\n                                <label className=\"label\">\n                                    <span className=\"label-text\">{t('user_token.ip_limit', { defaultValue: 'Max IPs' })}</span>\n                                </label>\n                                <input\n                                    type=\"number\"\n                                    className=\"input input-bordered w-full\"\n                                    value={newMaxIps}\n                                    onChange={e => setNewMaxIps(parseInt(e.target.value) || 0)}\n                                    min=\"0\"\n                                    placeholder={t('user_token.placeholder_max_ips', { defaultValue: '0 = Unlimited' })}\n                                />\n                                <label className=\"label\">\n                                    <span className=\"label-text-alt text-gray-500\">{t('user_token.hint_max_ips', { defaultValue: '0 = Unlimited' })}</span>\n                                </label>\n                            </div>\n                        </div>\n\n                        {/* Custom Expiration Time Picker */}\n                        {newExpiresType === 'custom' && (\n                            <div className=\"form-control w-full mb-3\">\n                                <label className=\"label\">\n                                    <span className=\"label-text\">{t('user_token.custom_expires_at', { defaultValue: 'Expiration Date & Time' })} *</span>\n                                </label>\n                                <input\n                                    type=\"datetime-local\"\n                                    className=\"input input-bordered w-full\"\n                                    value={newCustomExpires}\n                                    onChange={e => setNewCustomExpires(e.target.value)}\n                                    min={new Date().toISOString().slice(0, 16)}\n                                />\n                                <label className=\"label\">\n                                    <span className=\"label-text-alt text-gray-500\">{t('user_token.hint_custom_expires', { defaultValue: 'Select the exact date and hour when this token expires' })}</span>\n                                </label>\n                            </div>\n                        )}\n\n                        <div className=\"form-control w-full mb-3\">\n                            <label className=\"label\">\n                                <span className=\"label-text\">{t('user_token.curfew', { defaultValue: 'Curfew (Service Unavailable Time)' })}</span>\n                            </label>\n                            <div className=\"flex gap-2 items-center\">\n                                <input\n                                    type=\"time\"\n                                    className=\"input input-bordered w-full\"\n                                    value={newCurfewStart}\n                                    onChange={e => setNewCurfewStart(e.target.value)}\n                                />\n                                <span className=\"text-gray-400\">to</span>\n                                <input\n                                    type=\"time\"\n                                    className=\"input input-bordered w-full\"\n                                    value={newCurfewEnd}\n                                    onChange={e => setNewCurfewEnd(e.target.value)}\n                                />\n                            </div>\n                            <label className=\"label\">\n                                <span className=\"label-text-alt text-gray-500\">{t('user_token.hint_curfew', { defaultValue: 'Leave empty to disable. Based on Beijing time (UTC+8).' })}</span>\n                            </label>\n                        </div>\n\n                        <div className=\"modal-action\">\n                            <button className=\"px-4 py-2 hover:bg-gray-100 dark:hover:bg-base-200 rounded-lg text-sm transition-colors\" onClick={() => setShowCreateModal(false)}>\n                                {t('common.cancel', { defaultValue: 'Cancel' })}\n                            </button>\n                            <button\n                                className={`px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-all shadow-sm shadow-blue-500/20 flex items-center gap-2 ${creating ? 'opacity-50 cursor-not-allowed' : ''}`}\n                                onClick={handleCreate}\n                                disabled={creating}\n                            >\n                                {creating && <RefreshCw size={14} className=\"animate-spin\" />}\n                                {t('common.create', { defaultValue: 'Create' })}\n                            </button>\n                        </div>\n                    </div>\n                </div>\n            )}\n\n            {/* Edit Modal */}\n            {showEditModal && editingToken && (\n                <div className=\"modal modal-open\">\n                    <div className=\"modal-box\">\n                        <h3 className=\"font-bold text-lg mb-4\">{t('user_token.edit_title', { defaultValue: 'Edit Token' })}</h3>\n\n                        <div className=\"form-control w-full mb-3\">\n                            <label className=\"label\">\n                                <span className=\"label-text\">{t('user_token.username', { defaultValue: 'Username' })} *</span>\n                            </label>\n                            <input\n                                type=\"text\"\n                                className=\"input input-bordered w-full\"\n                                value={editUsername}\n                                onChange={e => setEditUsername(e.target.value)}\n                                placeholder={t('user_token.placeholder_username', { defaultValue: 'e.g. user1' })}\n                            />\n                        </div>\n\n                        <div className=\"form-control w-full mb-3\">\n                            <label className=\"label\">\n                                <span className=\"label-text\">{t('user_token.description', { defaultValue: 'Description' })}</span>\n                            </label>\n                            <input\n                                type=\"text\"\n                                className=\"input input-bordered w-full\"\n                                value={editDesc}\n                                onChange={e => setEditDesc(e.target.value)}\n                                placeholder={t('user_token.placeholder_desc', { defaultValue: 'Optional notes' })}\n                            />\n                        </div>\n\n                        <div className=\"form-control w-full mb-3\">\n                            <label className=\"label\">\n                                <span className=\"label-text\">{t('user_token.ip_limit', { defaultValue: 'Max IPs' })}</span>\n                            </label>\n                            <input\n                                type=\"number\"\n                                className=\"input input-bordered w-full\"\n                                value={editMaxIps}\n                                onChange={e => setEditMaxIps(parseInt(e.target.value) || 0)}\n                                min=\"0\"\n                                placeholder={t('user_token.placeholder_max_ips', { defaultValue: '0 = Unlimited' })}\n                            />\n                            <label className=\"label\">\n                                <span className=\"label-text-alt text-gray-500\">{t('user_token.hint_max_ips', { defaultValue: '0 = Unlimited' })}</span>\n                            </label>\n                        </div>\n\n                        <div className=\"form-control w-full mb-3\">\n                            <label className=\"label\">\n                                <span className=\"label-text\">{t('user_token.curfew', { defaultValue: 'Curfew (Service Unavailable Time)' })}</span>\n                            </label>\n                            <div className=\"flex gap-2 items-center\">\n                                <input\n                                    type=\"time\"\n                                    className=\"input input-bordered w-full\"\n                                    value={editCurfewStart}\n                                    onChange={e => setEditCurfewStart(e.target.value)}\n                                />\n                                <span className=\"text-gray-400\">to</span>\n                                <input\n                                    type=\"time\"\n                                    className=\"input input-bordered w-full\"\n                                    value={editCurfewEnd}\n                                    onChange={e => setEditCurfewEnd(e.target.value)}\n                                />\n                            </div>\n                            <label className=\"label\">\n                                <span className=\"label-text-alt text-gray-500\">{t('user_token.hint_curfew', { defaultValue: 'Leave empty to disable. Based on Beijing time (UTC+8).' })}</span>\n                            </label>\n                        </div>\n\n                        <div className=\"modal-action\">\n                            <button className=\"px-4 py-2 hover:bg-gray-100 dark:hover:bg-base-200 rounded-lg text-sm transition-colors\" onClick={() => setShowEditModal(false)}>\n                                {t('common.cancel', { defaultValue: 'Cancel' })}\n                            </button>\n                            <button\n                                className={`px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-all shadow-sm shadow-blue-500/20 flex items-center gap-2 ${updating ? 'opacity-50 cursor-not-allowed' : ''}`}\n                                onClick={handleUpdate}\n                                disabled={updating}\n                            >\n                                {updating && <RefreshCw size={14} className=\"animate-spin\" />}\n                                {t('common.update', { defaultValue: 'Update' })}\n                            </button>\n                        </div>\n                    </div>\n                </div>\n            )}\n        </motion.div>\n    );\n};\nexport default UserToken;\n"
  },
  {
    "path": "src/services/accountService.ts",
    "content": "import i18n from '../i18n';\nimport { Account, DeviceProfile, DeviceProfileVersion, QuotaData } from '../types/account';\nimport { request as invoke } from '../utils/request';\n\n// 检查环境 (可选)\nfunction ensureTauriEnvironment() {\n    // Web 模式下 request 也是一个 function，所以这里不应抛错\n    if (typeof invoke !== 'function') {\n        throw new Error(i18n.t('common.tauri_api_not_loaded'));\n    }\n}\n\nexport async function listAccounts(): Promise<Account[]> {\n    const response = await invoke<any>('list_accounts');\n    // 如果返回的是对象格式 { accounts: [...] }, 则取其 accounts 属性\n    if (response && typeof response === 'object' && Array.isArray(response.accounts)) {\n        return response.accounts;\n    }\n    // 否则直接返回响应内容（假设为数组）\n    return response || [];\n}\n\nexport async function getCurrentAccount(): Promise<Account | null> {\n    return await invoke('get_current_account');\n}\n\nexport async function addAccount(email: string, refreshToken: string): Promise<Account> {\n    return await invoke('add_account', { email, refreshToken });\n}\n\nexport async function deleteAccount(accountId: string): Promise<void> {\n    return await invoke('delete_account', { accountId });\n}\n\nexport async function deleteAccounts(accountIds: string[]): Promise<void> {\n    return await invoke('delete_accounts', { accountIds });\n}\n\nexport async function switchAccount(accountId: string): Promise<void> {\n    return await invoke('switch_account', { accountId });\n}\n\nexport async function fetchAccountQuota(accountId: string): Promise<QuotaData> {\n    return await invoke('fetch_account_quota', { accountId });\n}\n\nexport interface RefreshStats {\n    total: number;\n    success: number;\n    failed: number;\n    details: string[];\n}\n\nexport async function refreshAllQuotas(): Promise<RefreshStats> {\n    return await invoke('refresh_all_quotas');\n}\n\n// OAuth\nexport async function startOAuthLogin(): Promise<Account> {\n    ensureTauriEnvironment();\n\n    try {\n        return await invoke('start_oauth_login');\n    } catch (error) {\n        // 增强错误信息\n        if (typeof error === 'string') {\n            // 如果是 refresh_token 缺失错误,保持原样(已包含详细说明)\n            if (error.includes('Refresh Token') || error.includes('refresh_token')) {\n                throw error;\n            }\n            // 其他错误添加上下文\n            throw i18n.t('accounts.add.oauth_error', { error });\n        }\n        throw error;\n    }\n}\n\nexport async function completeOAuthLogin(): Promise<Account> {\n    ensureTauriEnvironment();\n    try {\n        return await invoke('complete_oauth_login');\n    } catch (error) {\n        if (typeof error === 'string') {\n            if (error.includes('Refresh Token') || error.includes('refresh_token')) {\n                throw error;\n            }\n            throw i18n.t('accounts.add.oauth_error', { error });\n        }\n        throw error;\n    }\n}\n\nexport async function cancelOAuthLogin(): Promise<void> {\n    ensureTauriEnvironment();\n    return await invoke('cancel_oauth_login');\n}\n\n// 导入\nexport async function importV1Accounts(): Promise<Account[]> {\n    return await invoke('import_v1_accounts');\n}\n\nexport async function importFromDb(): Promise<Account> {\n    return await invoke('import_from_db');\n}\n\nexport async function importFromCustomDb(path: string): Promise<Account> {\n    return await invoke('import_custom_db', { path });\n}\n\nexport async function syncAccountFromDb(): Promise<Account | null> {\n    return await invoke('sync_account_from_db');\n}\n\nexport async function toggleProxyStatus(accountId: string, enable: boolean, reason?: string): Promise<void> {\n    return await invoke('toggle_proxy_status', { accountId, enable, reason });\n}\n\n/**\n * 重新排序账号列表\n * @param accountIds 按新顺序排列的账号ID数组\n */\nexport async function reorderAccounts(accountIds: string[]): Promise<void> {\n    return await invoke('reorder_accounts', { accountIds });\n}\n\n// 设备指纹相关\nexport interface DeviceProfilesResponse {\n    current_storage?: DeviceProfile;\n    history?: DeviceProfileVersion[];\n    baseline?: DeviceProfile;\n}\n\nexport async function getDeviceProfiles(accountId: string): Promise<DeviceProfilesResponse> {\n    return await invoke('get_device_profiles', { accountId });\n}\n\nexport async function bindDeviceProfile(accountId: string, mode: 'capture' | 'generate'): Promise<DeviceProfile> {\n    return await invoke('bind_device_profile', { accountId, mode });\n}\n\nexport async function restoreOriginalDevice(): Promise<string> {\n    return await invoke('restore_original_device');\n}\n\nexport async function listDeviceVersions(accountId: string): Promise<DeviceProfilesResponse> {\n    return await invoke('list_device_versions', { accountId });\n}\n\nexport async function restoreDeviceVersion(accountId: string, versionId: string): Promise<DeviceProfile> {\n    return await invoke('restore_device_version', { accountId, versionId });\n}\n\nexport async function deleteDeviceVersion(accountId: string, versionId: string): Promise<void> {\n    return await invoke('delete_device_version', { accountId, versionId });\n}\n\nexport async function openDeviceFolder(): Promise<void> {\n    return await invoke('open_device_folder');\n}\n\nexport async function previewGenerateProfile(): Promise<DeviceProfile> {\n    return await invoke('preview_generate_profile');\n}\n\nexport async function bindDeviceProfileWithProfile(accountId: string, profile: DeviceProfile): Promise<DeviceProfile> {\n    return await invoke('bind_device_profile_with_profile', { accountId, profile });\n}\n\n// 预热相关\nexport async function warmUpAllAccounts(): Promise<string> {\n    return await invoke('warm_up_all_accounts');\n}\n\nexport async function warmUpAccount(accountId: string): Promise<string> {\n    return await invoke('warm_up_account', { accountId });\n}\n\n// 导出账号相关\nexport interface ExportAccountItem {\n    email: string;\n    refresh_token: string;\n}\n\nexport interface ExportAccountsResponse {\n    accounts: ExportAccountItem[];\n}\n\nexport async function exportAccounts(accountIds: string[]): Promise<ExportAccountsResponse> {\n    return await invoke('export_accounts', { accountIds });\n}\n\n// 自定义标签相关\nexport async function updateAccountLabel(accountId: string, label: string): Promise<void> {\n    return await invoke('update_account_label', { accountId, label });\n}\n\n"
  },
  {
    "path": "src/services/configService.ts",
    "content": "import { request as invoke } from '../utils/request';\nimport { AppConfig } from '../types/config';\n\nexport async function loadConfig(): Promise<AppConfig> {\n    return await invoke('load_config');\n}\n\nexport async function saveConfig(config: AppConfig): Promise<void> {\n    return await invoke('save_config', { config });\n}\n"
  },
  {
    "path": "src/stores/networkMonitorStore.ts",
    "content": "import { create } from 'zustand';\n\nexport interface NetworkRequest {\n  id: string;\n  cmd: string;\n  args?: any;\n  startTime: number;\n  endTime?: number;\n  duration?: number;\n  status: 'pending' | 'success' | 'error';\n  response?: any;\n  error?: any;\n}\n\ninterface NetworkMonitorState {\n  requests: NetworkRequest[];\n  isOpen: boolean;\n  isRecording: boolean;\n  addRequest: (request: NetworkRequest) => void;\n  updateRequest: (id: string, updates: Partial<NetworkRequest>) => void;\n  clearRequests: () => void;\n  setIsOpen: (isOpen: boolean) => void;\n  toggleRecording: () => void;\n}\n\nexport const useNetworkMonitorStore = create<NetworkMonitorState>((set) => ({\n  requests: [],\n  isOpen: false,\n  isRecording: true,\n\n  addRequest: (request) => set((state) => {\n    if (!state.isRecording) return state;\n    return { requests: [request, ...state.requests].slice(0, 100) }; // Keep last 100 requests (reduced from 1000)\n  }),\n\n  updateRequest: (id, updates) => set((state) => ({\n    requests: state.requests.map((req) =>\n      req.id === id ? { ...req, ...updates } : req\n    ),\n  })),\n\n  clearRequests: () => set({ requests: [] }),\n  setIsOpen: (isOpen) => set({ isOpen }),\n  toggleRecording: () => set((state) => ({ isRecording: !state.isRecording })),\n}));\n"
  },
  {
    "path": "src/stores/useAccountStore.ts",
    "content": "import { create } from 'zustand';\nimport { Account } from '../types/account';\nimport * as accountService from '../services/accountService';\n\ninterface AccountState {\n    accounts: Account[];\n    currentAccount: Account | null;\n    loading: boolean;\n    error: string | null;\n\n    // Actions\n    fetchAccounts: () => Promise<void>;\n    fetchCurrentAccount: () => Promise<void>;\n    addAccount: (email: string, refreshToken: string) => Promise<void>;\n    deleteAccount: (accountId: string) => Promise<void>;\n    deleteAccounts: (accountIds: string[]) => Promise<void>;\n    switchAccount: (accountId: string) => Promise<void>;\n    refreshQuota: (accountId: string) => Promise<void>;\n    refreshAllQuotas: () => Promise<accountService.RefreshStats>;\n    reorderAccounts: (accountIds: string[]) => Promise<void>;\n\n    // 新增 actions\n    startOAuthLogin: () => Promise<void>;\n    completeOAuthLogin: () => Promise<void>;\n    cancelOAuthLogin: () => Promise<void>;\n    importV1Accounts: () => Promise<void>;\n    importFromDb: () => Promise<void>;\n    importFromCustomDb: (path: string) => Promise<void>;\n    syncAccountFromDb: () => Promise<void>;\n    toggleProxyStatus: (accountId: string, enable: boolean, reason?: string) => Promise<void>;\n    warmUpAccounts: () => Promise<string>;\n    warmUpAccount: (accountId: string) => Promise<string>;\n    updateAccountLabel: (accountId: string, label: string) => Promise<void>;\n}\n\nexport const useAccountStore = create<AccountState>((set, get) => ({\n    accounts: [],\n    currentAccount: null,\n    loading: false,\n    error: null,\n\n    fetchAccounts: async () => {\n        set({ loading: true, error: null });\n        try {\n            console.log('[Store] Fetching accounts...');\n            const accounts = await accountService.listAccounts();\n            set({ accounts, loading: false });\n        } catch (error) {\n            console.error('[Store] Fetch accounts failed:', error);\n            set({ error: String(error), loading: false });\n        }\n    },\n\n    fetchCurrentAccount: async () => {\n        set({ loading: true, error: null });\n        try {\n            const account = await accountService.getCurrentAccount();\n            set({ currentAccount: account, loading: false });\n        } catch (error) {\n            set({ error: String(error), loading: false });\n        }\n    },\n\n    addAccount: async (email: string, refreshToken: string) => {\n        set({ loading: true, error: null });\n        try {\n            await accountService.addAccount(email, refreshToken);\n            await get().fetchAccounts();\n            set({ loading: false });\n        } catch (error) {\n            set({ error: String(error), loading: false });\n            throw error;\n        }\n    },\n\n    deleteAccount: async (accountId: string) => {\n        set({ loading: true, error: null });\n        try {\n            await accountService.deleteAccount(accountId);\n            await Promise.all([\n                get().fetchAccounts(),\n                get().fetchCurrentAccount()\n            ]);\n            set({ loading: false });\n        } catch (error) {\n            set({ error: String(error), loading: false });\n            throw error;\n        }\n    },\n\n    deleteAccounts: async (accountIds: string[]) => {\n        set({ loading: true, error: null });\n        try {\n            await accountService.deleteAccounts(accountIds);\n            await Promise.all([\n                get().fetchAccounts(),\n                get().fetchCurrentAccount()\n            ]);\n            set({ loading: false });\n        } catch (error) {\n            set({ error: String(error), loading: false });\n            throw error;\n        }\n    },\n\n    switchAccount: async (accountId: string) => {\n        set({ loading: true, error: null });\n        try {\n            await accountService.switchAccount(accountId);\n            await get().fetchCurrentAccount();\n            set({ loading: false });\n        } catch (error) {\n            set({ error: String(error), loading: false });\n            throw error;\n        }\n    },\n\n    refreshQuota: async (accountId: string) => {\n        set({ loading: true, error: null });\n        try {\n            await accountService.fetchAccountQuota(accountId);\n            await get().fetchAccounts();\n            set({ loading: false });\n        } catch (error) {\n            set({ error: String(error), loading: false });\n            throw error;\n        }\n    },\n\n    refreshAllQuotas: async () => {\n        set({ loading: true, error: null });\n        try {\n            const stats = await accountService.refreshAllQuotas();\n            await get().fetchAccounts();\n            set({ loading: false });\n            return stats;\n        } catch (error) {\n            set({ error: String(error), loading: false });\n            throw error;\n        }\n    },\n\n    /**\n     * 重新排序账号列表\n     * 采用乐观更新策略：先更新本地状态再调用后端持久化，以提供流畅的拖拽体验\n     */\n    reorderAccounts: async (accountIds: string[]) => {\n        const { accounts } = get();\n\n        // 创建 ID 到账号的映射\n        const accountMap = new Map(accounts.map(acc => [acc.id, acc]));\n\n        // 按新顺序重建账号数组\n        const reorderedAccounts = accountIds\n            .map(id => accountMap.get(id))\n            .filter((acc): acc is Account => acc !== undefined);\n\n        // 添加未在新顺序中的账号（保持原有顺序）\n        const remainingAccounts = accounts.filter(acc => !accountIds.includes(acc.id));\n        const finalAccounts = [...reorderedAccounts, ...remainingAccounts];\n\n        // 乐观更新本地状态\n        set({ accounts: finalAccounts });\n\n        try {\n            await accountService.reorderAccounts(accountIds);\n        } catch (error) {\n            // 后端失败时回滚到原始顺序\n            console.error('[AccountStore] Reorder accounts failed:', error);\n            set({ accounts });\n            throw error;\n        }\n    },\n\n    startOAuthLogin: async () => {\n        set({ loading: true, error: null });\n        try {\n            await accountService.startOAuthLogin();\n            await get().fetchAccounts();\n            set({ loading: false });\n        } catch (error) {\n            set({ error: String(error), loading: false });\n            throw error;\n        }\n    },\n\n    completeOAuthLogin: async () => {\n        set({ loading: true, error: null });\n        try {\n            await accountService.completeOAuthLogin();\n            await get().fetchAccounts();\n            set({ loading: false });\n        } catch (error) {\n            set({ error: String(error), loading: false });\n            throw error;\n        }\n    },\n\n    cancelOAuthLogin: async () => {\n        try {\n            await accountService.cancelOAuthLogin();\n            set({ loading: false, error: null });\n        } catch (error) {\n            console.error('[Store] Cancel OAuth failed:', error);\n        }\n    },\n\n    importV1Accounts: async () => {\n        set({ loading: true, error: null });\n        try {\n            await accountService.importV1Accounts();\n            await get().fetchAccounts();\n            set({ loading: false });\n        } catch (error) {\n            set({ error: String(error), loading: false });\n            throw error;\n        }\n    },\n\n    importFromDb: async () => {\n        set({ loading: true, error: null });\n        try {\n            await accountService.importFromDb();\n            await Promise.all([\n                get().fetchAccounts(),\n                get().fetchCurrentAccount()\n            ]);\n            set({ loading: false });\n        } catch (error) {\n            set({ error: String(error), loading: false });\n            throw error;\n        }\n    },\n\n    importFromCustomDb: async (path: string) => {\n        set({ loading: true, error: null });\n        try {\n            await accountService.importFromCustomDb(path);\n            await Promise.all([\n                get().fetchAccounts(),\n                get().fetchCurrentAccount()\n            ]);\n            set({ loading: false });\n        } catch (error) {\n            set({ error: String(error), loading: false });\n            throw error;\n        }\n    },\n\n    syncAccountFromDb: async () => {\n        try {\n            const syncedAccount = await accountService.syncAccountFromDb();\n            if (syncedAccount) {\n                console.log('[AccountStore] Account synced from DB:', syncedAccount.email);\n                await get().fetchAccounts();\n                set({ currentAccount: syncedAccount });\n            }\n        } catch (error) {\n            console.error('[AccountStore] Sync from DB failed:', error);\n        }\n    },\n\n    toggleProxyStatus: async (accountId: string, enable: boolean, reason?: string) => {\n        try {\n            await accountService.toggleProxyStatus(accountId, enable, reason);\n            await get().fetchAccounts();\n        } catch (error) {\n            console.error('[AccountStore] Toggle proxy status failed:', error);\n            throw error;\n        }\n    },\n\n    warmUpAccounts: async () => {\n        set({ loading: true, error: null });\n        try {\n            const result = await accountService.warmUpAllAccounts();\n            set({ loading: false });\n            return result;\n        } catch (error) {\n            set({ error: String(error), loading: false });\n            throw error;\n        } finally {\n            await get().fetchAccounts();\n        }\n    },\n\n    warmUpAccount: async (accountId: string) => {\n        set({ loading: true, error: null });\n        try {\n            const result = await accountService.warmUpAccount(accountId);\n            set({ loading: false });\n            return result;\n        } catch (error) {\n            set({ error: String(error), loading: false });\n            throw error;\n        } finally {\n            await get().fetchAccounts();\n        }\n    },\n\n    updateAccountLabel: async (accountId: string, label: string) => {\n        try {\n            await accountService.updateAccountLabel(accountId, label);\n            // 乐观更新本地状态\n            const { accounts } = get();\n            const updatedAccounts = accounts.map(acc =>\n                acc.id === accountId ? { ...acc, custom_label: label || undefined } : acc\n            );\n            set({ accounts: updatedAccounts });\n        } catch (error) {\n            console.error('[AccountStore] Update label failed:', error);\n            throw error;\n        }\n    },\n}));\n"
  },
  {
    "path": "src/stores/useConfigStore.ts",
    "content": "import { create } from 'zustand';\nimport { AppConfig } from '../types/config';\nimport * as configService from '../services/configService';\n\ninterface ConfigState {\n    config: AppConfig | null;\n    loading: boolean;\n    error: string | null;\n\n    // Actions\n    loadConfig: () => Promise<void>;\n    saveConfig: (config: AppConfig, silent?: boolean) => Promise<void>;\n    updateTheme: (theme: string) => Promise<void>;\n    updateLanguage: (language: string) => Promise<void>;\n    toggleShowAllQuotas: () => void;\n    showAllQuotas: boolean;\n    toggleMenuItem: (path: string) => Promise<void>;\n    isMenuItemHidden: (path: string) => boolean;\n}\n\nexport const useConfigStore = create<ConfigState>((set, get) => ({\n    config: null,\n    loading: false,\n    error: null,\n    showAllQuotas: localStorage.getItem('antigravity_show_all_quotas') === 'true',\n\n    loadConfig: async () => {\n        set({ loading: true, error: null });\n        try {\n            const config = await configService.loadConfig();\n            set({ config, loading: false });\n        } catch (error) {\n            set({ error: String(error), loading: false });\n        }\n    },\n\n    saveConfig: async (config: AppConfig, silent: boolean = false) => {\n        if (!silent) set({ loading: true, error: null });\n        try {\n            await configService.saveConfig(config);\n            set({ config, loading: false });\n            const { isTauri } = await import('../utils/env');\n            if (isTauri()) {\n                const { invoke } = await import('@tauri-apps/api/core');\n                await invoke('set_window_theme', { theme: config.theme }).catch(() => {\n                });\n            }\n        } catch (error) {\n            set({ error: String(error), loading: false });\n            throw error;\n        }\n    },\n\n    updateTheme: async (theme: string) => {\n        const { config } = get();\n        if (!config || config.theme === theme) return;\n\n        const newConfig = { ...config, theme };\n        await get().saveConfig(newConfig, true);\n    },\n\n    updateLanguage: async (language: string) => {\n        const { config } = get();\n        if (!config || config.language === language) return;\n\n        const newConfig = { ...config, language };\n        await get().saveConfig(newConfig, true);\n    },\n\n    toggleShowAllQuotas: () => {\n        const current = get().showAllQuotas;\n        const next = !current;\n        localStorage.setItem('antigravity_show_all_quotas', String(next));\n        set({ showAllQuotas: next });\n    },\n\n    toggleMenuItem: async (path: string) => {\n        const { config } = get();\n        if (!config) return;\n\n        const hiddenItems = config.hidden_menu_items || [];\n        const isHidden = hiddenItems.includes(path);\n\n        const newHiddenItems = isHidden\n            ? hiddenItems.filter(item => item !== path)\n            : [...hiddenItems, path];\n\n        const newConfig = { ...config, hidden_menu_items: newHiddenItems };\n        await get().saveConfig(newConfig, true);\n    },\n\n    isMenuItemHidden: (path: string) => {\n        const { config } = get();\n        if (!config) return false;\n        return (config.hidden_menu_items || []).includes(path);\n    },\n}));\n"
  },
  {
    "path": "src/stores/useDebugConsole.ts",
    "content": "import { create } from 'zustand';\nimport { request as invoke } from '../utils/request';\nimport { listen, UnlistenFn } from '@tauri-apps/api/event';\nimport { isTauri } from '../utils/env';\nimport { request } from '../utils/request';\n\nexport interface LogEntry {\n    id: number;\n    timestamp: number;\n    level: 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';\n    target: string;\n    message: string;\n    fields: Record<string, string>;\n}\n\nexport type LogLevel = 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';\n\ninterface DebugConsoleState {\n    isOpen: boolean;\n    isEnabled: boolean;\n    logs: LogEntry[];\n    filter: LogLevel[];\n    searchTerm: string;\n    autoScroll: boolean;\n    unlistenFn: UnlistenFn | null;\n    pollInterval: number | null;\n\n    // Actions\n    open: () => void;\n    close: () => void;\n    toggle: () => void;\n    enable: () => Promise<void>;\n    disable: () => Promise<void>;\n    loadLogs: () => Promise<void>;\n    clearLogs: () => Promise<void>;\n    addLog: (log: LogEntry) => void;\n    setFilter: (levels: LogLevel[]) => void;\n    setSearchTerm: (term: string) => void;\n    setAutoScroll: (enabled: boolean) => void;\n    startListening: () => Promise<void>;\n    stopListening: () => void;\n    startPolling: () => void;\n    stopPolling: () => void;\n    checkEnabled: () => Promise<void>;\n}\n\nconst MAX_LOGS = 5000;\n\nexport const useDebugConsole = create<DebugConsoleState>((set, get) => ({\n    isOpen: false,\n    isEnabled: false,\n    logs: [],\n    filter: ['ERROR', 'WARN', 'INFO'],\n    searchTerm: '',\n    autoScroll: true,\n    unlistenFn: null,\n    pollInterval: null,\n\n    open: () => set({ isOpen: true }),\n    close: () => set({ isOpen: false }),\n    toggle: () => set((state) => ({ isOpen: !state.isOpen })),\n\n    enable: async () => {\n        try {\n            if (isTauri()) {\n                await invoke('enable_debug_console');\n            } else {\n                await request('enable_debug_console');\n            }\n            set({ isEnabled: true });\n            await get().loadLogs();\n            if (isTauri()) {\n                await get().startListening();\n            } else {\n                get().startPolling();\n            }\n        } catch (error) {\n            console.error('Failed to enable debug console:', error);\n        }\n    },\n\n    startPolling: () => {\n        if (get().pollInterval) return;\n        const interval = window.setInterval(async () => {\n            if (get().isEnabled && get().isOpen) {\n                await get().loadLogs();\n            }\n        }, 2000);\n        set({ pollInterval: interval });\n    },\n\n    stopPolling: () => {\n        const { pollInterval } = get();\n        if (pollInterval) {\n            clearInterval(pollInterval);\n            set({ pollInterval: null });\n        }\n    },\n\n    disable: async () => {\n        try {\n            if (isTauri()) {\n                await invoke('disable_debug_console');\n            } else {\n                await request('disable_debug_console');\n            }\n            if (isTauri()) {\n                get().stopListening();\n            } else {\n                get().stopPolling();\n            }\n            set({ isEnabled: false });\n        } catch (error) {\n            console.error('Failed to disable debug console:', error);\n        }\n    },\n\n    loadLogs: async () => {\n        try {\n            let logs: LogEntry[];\n            if (isTauri()) {\n                logs = await invoke<LogEntry[]>('get_debug_console_logs');\n            } else {\n                logs = await request<LogEntry[]>('get_debug_console_logs');\n            }\n            set({ logs });\n        } catch (error) {\n            console.error('Failed to load logs:', error);\n        }\n    },\n\n    clearLogs: async () => {\n        console.log('[DebugConsole] Clearing logs...');\n        set({ logs: [] }); // Clear immediately in frontend\n        try {\n            if (isTauri()) {\n                await invoke('clear_debug_console_logs');\n            } else {\n                await request('clear_debug_console_logs');\n            }\n            console.log('[DebugConsole] Backend log buffer cleared');\n        } catch (error) {\n            console.error('[DebugConsole] Failed to clear background logs:', error);\n        }\n    },\n\n    addLog: (log: LogEntry) => {\n        set((state) => {\n            const newLogs = [...state.logs, log];\n            // Keep only last MAX_LOGS entries\n            if (newLogs.length > MAX_LOGS) {\n                return { logs: newLogs.slice(-MAX_LOGS) };\n            }\n            return { logs: newLogs };\n        });\n    },\n\n    setFilter: (levels: LogLevel[]) => set({ filter: levels }),\n    setSearchTerm: (term: string) => set({ searchTerm: term }),\n    setAutoScroll: (enabled: boolean) => set({ autoScroll: enabled }),\n\n    startListening: async () => {\n        // Web 模式下不支持 Tauri 事件监听，跳过\n        if (!isTauri()) return;\n\n        const { unlistenFn } = get();\n        if (unlistenFn) return; // Already listening\n\n        try {\n            const unlisten = await listen<LogEntry>('log-event', (event) => {\n                get().addLog(event.payload);\n            });\n            set({ unlistenFn: unlisten });\n        } catch (error) {\n            console.error('Failed to start listening for logs:', error);\n        }\n    },\n\n    stopListening: () => {\n        const { unlistenFn } = get();\n        if (unlistenFn) {\n            unlistenFn();\n            set({ unlistenFn: null });\n        }\n    },\n\n    checkEnabled: async () => {\n        try {\n            let isEnabled: boolean;\n            if (isTauri()) {\n                isEnabled = await invoke<boolean>('is_debug_console_enabled');\n            } else {\n                isEnabled = await request<boolean>('is_debug_console_enabled');\n            }\n            set({ isEnabled });\n            if (isEnabled) {\n                await get().loadLogs();\n                if (isTauri()) {\n                    await get().startListening();\n                } else {\n                    get().startPolling();\n                }\n            }\n        } catch (error) {\n            console.error('Failed to check debug console status:', error);\n        }\n    },\n}));\n"
  },
  {
    "path": "src/stores/useViewStore.ts",
    "content": "import { create } from 'zustand';\n\ninterface ViewState {\n    isMiniView: boolean;\n    setMiniView: (isMini: boolean) => void;\n    toggleMiniView: () => void;\n}\n\nexport const useViewStore = create<ViewState>((set) => ({\n    isMiniView: false,\n    setMiniView: (isMini) => set({ isMiniView: isMini }),\n    toggleMiniView: () => set((state) => ({ isMiniView: !state.isMiniView })),\n}));\n"
  },
  {
    "path": "src/types/account.ts",
    "content": "export interface Account {\n    id: string;\n    email: string;\n    name?: string;\n    token: TokenData;\n    device_profile?: DeviceProfile;\n    device_history?: DeviceProfileVersion[];\n    quota?: QuotaData;\n    disabled?: boolean;\n    disabled_reason?: string;\n    disabled_at?: number;\n    proxy_disabled?: boolean;\n    proxy_disabled_reason?: string;\n    proxy_disabled_at?: number;\n    protected_models?: string[];\n    custom_label?: string;  // 用户自定义标签\n    validation_blocked?: boolean;\n    validation_blocked_until?: number;\n    validation_blocked_reason?: string;\n    validation_url?: string;\n    created_at: number;\n    last_used: number;\n}\n\nexport interface TokenData {\n    access_token: string;\n    refresh_token: string;\n    expires_in: number;\n    expiry_timestamp: number;\n    token_type: string;\n    email?: string;\n}\n\nexport interface QuotaData {\n    models: ModelQuota[];\n    last_updated: number;\n    is_forbidden?: boolean;\n    forbidden_reason?: string;\n    subscription_tier?: string;  // 订阅类型: FREE/PRO/ULTRA\n    model_forwarding_rules?: Record<string, string>; // 废弃模型转发表\n}\n\nexport interface ModelQuota {\n    name: string;\n    percentage: number;\n    reset_time: string;\n    display_name?: string;\n    supports_images?: boolean;\n    supports_thinking?: boolean;\n    thinking_budget?: number;\n    recommended?: boolean;\n    max_tokens?: number;\n    max_output_tokens?: number;\n    supported_mime_types?: Record<string, boolean>;\n}\n\nexport interface DeviceProfile {\n    machine_id: string;\n    mac_machine_id: string;\n    dev_device_id: string;\n    sqm_id: string;\n}\n\nexport interface DeviceProfileVersion {\n    id: string;\n    created_at: number;\n    label: string;\n    profile: DeviceProfile;\n    is_current?: boolean;\n}\n\n"
  },
  {
    "path": "src/types/config.ts",
    "content": "export interface UpstreamProxyConfig {\n    enabled: boolean;\n    url: string;\n}\n\nexport interface ProxyConfig {\n    enabled: boolean;\n    allow_lan_access?: boolean;\n    auth_mode?: 'off' | 'strict' | 'all_except_health' | 'auto';\n    port: number;\n    api_key: string;\n    admin_password?: string;\n    auto_start: boolean;\n    custom_mapping?: Record<string, string>;\n    request_timeout: number;\n    enable_logging: boolean;\n    debug_logging?: DebugLoggingConfig;\n    upstream_proxy: UpstreamProxyConfig;\n    zai?: ZaiConfig;\n    scheduling?: StickySessionConfig;\n    experimental?: ExperimentalConfig;\n    user_agent_override?: string;\n    saved_user_agent?: string;\n    thinking_budget?: ThinkingBudgetConfig;\n    global_system_prompt?: GlobalSystemPromptConfig;\n    image_thinking_mode?: 'enabled' | 'disabled'; // [NEW] 图像思维模式开关\n    proxy_pool?: ProxyPoolConfig;\n}\n\n// ============================================================================\n// Thinking Budget 配置 (控制 AI 深度思考时的 Token 预算)\n// ============================================================================\n\n/** Thinking Budget 处理模式 */\nexport type ThinkingBudgetMode = 'auto' | 'passthrough' | 'custom' | 'adaptive'; // [NEW] 支持自适应模式\n\n/** Thinking Effort 等级 (仅 adaptive 模式) */\nexport type ThinkingEffort = 'low' | 'medium' | 'high';\n\n/** Thinking Budget 配置 */\nexport interface ThinkingBudgetConfig {\n    /** 模式选择 */\n    mode: ThinkingBudgetMode;\n    /** 自定义固定值（仅在 mode=custom 时生效），范围 1024-65536 */\n    custom_value: number;\n    /** 思考强度 (仅在 mode=adaptive 时生效) */\n    effort?: ThinkingEffort;\n}\n\n// ============================================================================\n// 全局系统提示词配置\n// ============================================================================\n\n/** 全局系统提示词配置 */\nexport interface GlobalSystemPromptConfig {\n    /** 是否启用 */\n    enabled: boolean;\n    /** 提示词内容 */\n    content: string;\n}\n\nexport interface DebugLoggingConfig {\n    enabled: boolean;\n    output_dir?: string;\n}\n\nexport type SchedulingMode = 'CacheFirst' | 'Balance' | 'PerformanceFirst';\n\nexport interface StickySessionConfig {\n    mode: SchedulingMode;\n    max_wait_seconds: number;\n}\n\nexport type ZaiDispatchMode = 'off' | 'exclusive' | 'pooled' | 'fallback';\n\nexport interface ZaiMcpConfig {\n    enabled: boolean;\n    web_search_enabled: boolean;\n    web_reader_enabled: boolean;\n    vision_enabled: boolean;\n}\n\nexport interface ZaiModelDefaults {\n    opus: string;\n    sonnet: string;\n    haiku: string;\n}\n\nexport interface ZaiConfig {\n    enabled: boolean;\n    base_url: string;\n    api_key: string;\n    dispatch_mode: ZaiDispatchMode;\n    model_mapping?: Record<string, string>;\n    models: ZaiModelDefaults;\n    mcp: ZaiMcpConfig;\n}\n\nexport interface ScheduledWarmupConfig {\n    enabled: boolean;\n    monitored_models: string[];\n}\n\nexport interface QuotaProtectionConfig {\n    enabled: boolean;\n    threshold_percentage: number; // 1-99\n    monitored_models: string[];\n}\n\nexport interface PinnedQuotaModelsConfig {\n    models: string[];\n}\n\nexport interface ExperimentalConfig {\n    enable_usage_scaling: boolean;\n    context_compression_threshold_l1?: number;\n    context_compression_threshold_l2?: number;\n    context_compression_threshold_l3?: number;\n}\n\nexport interface CircuitBreakerConfig {\n    enabled: boolean;\n    backoff_steps: number[];\n}\n\nexport interface AppConfig {\n    language: string;\n    theme: string;\n    auto_refresh: boolean;\n    refresh_interval: number;\n    auto_sync: boolean;\n    sync_interval: number;\n    default_export_path?: string;\n    antigravity_executable?: string; // [NEW] 手动指定的反重力程序路径\n    antigravity_args?: string[]; // [NEW] Antigravity 启动参数\n    auto_launch?: boolean; // 开机自动启动\n    auto_check_update?: boolean; // 自动检查更新\n    update_check_interval?: number; // 更新检查间隔（小时）\n    accounts_page_size?: number; // 账号列表每页显示数量,默认 0 表示自动计算\n    hidden_menu_items?: string[]; // 隐藏的菜单项路径列表\n    scheduled_warmup: ScheduledWarmupConfig;\n    quota_protection: QuotaProtectionConfig; // [NEW] 配额保护配置\n    pinned_quota_models: PinnedQuotaModelsConfig; // [NEW] 配额关注列表\n    circuit_breaker: CircuitBreakerConfig; // [NEW] 熔断器配置\n    proxy: ProxyConfig;\n    cloudflared: CloudflaredConfig; // [NEW] Cloudflared 配置\n}\n\n// ============================================================================\n// Cloudflared (CF隧道) 类型定义\n// ============================================================================\n\nexport type TunnelMode = 'quick' | 'auth';\n\nexport interface CloudflaredConfig {\n    enabled: boolean;\n    mode: TunnelMode;\n    port: number;\n    token?: string;\n    use_http2: boolean;\n}\n\nexport interface CloudflaredStatus {\n    installed: boolean;\n    version?: string;\n    running: boolean;\n    url?: string;\n    error?: string;\n}\n\n// ============================================================================\n// 代理池类型定义\n// ============================================================================\n\nexport interface ProxyAuth {\n    username: string;\n    password?: string;\n}\n\nexport interface ProxyEntry {\n    id: string;\n    name: string;\n    url: string;\n    auth?: ProxyAuth;\n    enabled: boolean;\n    priority: number;\n    tags: string[];\n    max_accounts?: number;\n    health_check_url?: string;\n    last_check_time?: number;\n    is_healthy: boolean;\n    latency?: number; // [NEW] 延迟 (毫秒)\n}\n\n// export type ProxyPoolMode = 'global' | 'per_account' | 'hybrid'; // [REMOVED]\n\nexport type ProxySelectionStrategy = 'round_robin' | 'random' | 'priority' | 'least_connections' | 'weighted_round_robin';\n\nexport interface ProxyPoolConfig {\n    enabled: boolean;\n    // mode: ProxyPoolMode; // [REMOVED]\n    proxies: ProxyEntry[];\n    health_check_interval: number;\n    auto_failover: boolean;\n    strategy: ProxySelectionStrategy;\n    account_bindings?: Record<string, string>;\n}\n"
  },
  {
    "path": "src/utils/clipboard.ts",
    "content": "/**\n * 健壮的剪贴板复制工具函数\n * \n * 浏览器限制：在非安全上下文（非 HTTPS 或 localhost）下，navigator.clipboard 是 undefined。\n * 本函数通过 execCommand('copy') 提供回退方案，确保在 HTTP 环境（如 Docker IP 访问）下也能正常工作。\n */\nexport async function copyToClipboard(text: string): Promise<boolean> {\n    // 1. 尝试现代 Clipboard API\n    if (navigator.clipboard && window.isSecureContext) {\n        try {\n            await navigator.clipboard.writeText(text);\n            return true;\n        } catch (err) {\n            console.error('Clipboard API 复制失败:', err);\n        }\n    }\n\n    // 2. 回退到传统的 execCommand('copy') 方案\n    try {\n        const textArea = document.createElement('textarea');\n        textArea.value = text;\n\n        // 确保 textarea 在页面上不可见，但必须在 DOM 中才能执行 copy\n        textArea.style.position = 'fixed';\n        textArea.style.left = '-9999px';\n        textArea.style.top = '0';\n        document.body.appendChild(textArea);\n\n        textArea.focus();\n        textArea.select();\n\n        const successful = document.execCommand('copy');\n        document.body.removeChild(textArea);\n\n        return successful;\n    } catch (err) {\n        console.error('execCommand 复制失败:', err);\n        return false;\n    }\n}\n"
  },
  {
    "path": "src/utils/cn.ts",
    "content": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n    return twMerge(clsx(inputs));\n}\n"
  },
  {
    "path": "src/utils/env.ts",
    "content": "/**\n * Detect if the app is running in a Tauri environment\n */\nexport const isTauri = () => {\n    return typeof window !== 'undefined' &&\n        (!!(window as any).__TAURI_INTERNALS__ || !!(window as any).__TAURI__);\n};\n\n/**\n * Detect if running on Linux\n */\nexport const isLinux = () => {\n    return navigator.userAgent.toLowerCase().includes('linux');\n};\n"
  },
  {
    "path": "src/utils/format.ts",
    "content": "import { formatDistanceToNow } from 'date-fns';\nimport { zhCN, zhTW, enUS, ja, tr, vi, ptBR } from 'date-fns/locale';\n\nexport function formatRelativeTime(timestamp: number, language: string = 'zh-CN'): string {\n    let locale = enUS;\n    if (language === 'zh-CN' || language === 'zh') locale = zhCN;\n    else if (language === 'zh-TW') locale = zhTW;\n    else if (language === 'ja') locale = ja;\n    else if (language === 'tr') locale = tr;\n    else if (language === 'vi') locale = vi;\n    else if (language === 'pt' || language === 'pt-BR') locale = ptBR;\n\n    return formatDistanceToNow(new Date(timestamp * 1000), {\n        addSuffix: true,\n        locale,\n    });\n}\n\nexport function formatBytes(bytes: number): string {\n    if (bytes === 0) return '0 Bytes';\n\n    const k = 1024;\n    const sizes = ['Bytes', 'KB', 'MB', 'GB'];\n    const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n    return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];\n}\n\nexport function getQuotaColor(percentage: number): string {\n    if (percentage >= 50) return 'success';\n    if (percentage >= 20) return 'warning';\n    return 'error';\n}\n\nexport function formatTimeRemaining(dateStr: string): string {\n    const targetDate = new Date(dateStr);\n    const now = new Date();\n    const diffMs = targetDate.getTime() - now.getTime();\n\n    if (diffMs <= 0) return '0h 0m';\n\n    const diffHrs = Math.floor(diffMs / (1000 * 60 * 60));\n    const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));\n\n    if (diffHrs >= 24) {\n        const diffDays = Math.floor(diffHrs / 24);\n        const remainingHrs = diffHrs % 24;\n        return `${diffDays}d ${remainingHrs}h`;\n    }\n\n    return `${diffHrs}h ${diffMins}m`;\n}\n\nexport function getTimeRemainingColor(dateStr: string | undefined): string {\n    if (!dateStr) return 'gray';\n    const targetDate = new Date(dateStr);\n    const now = new Date();\n    const diffMs = targetDate.getTime() - now.getTime();\n\n    if (diffMs <= 0) return 'success'; // 已经过期的也算成功（即将重置或已重置）\n\n    const diffHrs = diffMs / (1000 * 60 * 60);\n\n    if (diffHrs < 1) return 'success';   // < 1h: 绿色 (快重置了)\n    if (diffHrs < 6) return 'warning';   // 1-6h: 琥珀色 (等待中)\n    return 'neutral';                   // > 6h: 灰色 (长等待)\n}\n\nexport function formatDate(timestamp: string | number | undefined | null): string | null {\n    if (!timestamp) return null;\n    const date = typeof timestamp === 'number'\n        ? new Date(timestamp * 1000)\n        : new Date(timestamp);\n\n    if (isNaN(date.getTime())) return null;\n\n    return date.toLocaleString(undefined, {\n        year: 'numeric',\n        month: '2-digit',\n        day: '2-digit',\n        hour: '2-digit',\n        minute: '2-digit',\n        second: '2-digit',\n        hour12: false\n    });\n}\n\nexport function formatCompactNumber(num: number): string {\n    if (num === 0) return '0';\n    if (num < 1000 && num > -1000) return num.toString();\n\n    const units = ['', 'k', 'M', 'G', 'T', 'P'];\n    const absNum = Math.abs(num);\n    const i = Math.floor(Math.log10(absNum) / 3);\n    const value = num / Math.pow(1000, i);\n\n    // Round to 1 decimal place if needed\n    const formatted = value.toFixed(Math.abs(value) < 10 && i > 0 ? 1 : 0);\n    return `${formatted.replace(/\\.0$/, '')}${units[i]}`;\n}\n"
  },
  {
    "path": "src/utils/request.ts",
    "content": "// 探测环境\nconst isTauri = typeof window !== 'undefined' && (!!(window as any).__TAURI_INTERNALS__ || !!(window as any).__TAURI__);\n\n// 命令到 API 的映射\nconst COMMAND_MAPPING: Record<string, { url: string; method: 'GET' | 'POST' | 'DELETE' | 'PATCH' }> = {\n  // Accounts\n  'list_accounts': { url: '/api/accounts', method: 'GET' },\n  'get_current_account': { url: '/api/accounts/current', method: 'GET' },\n  'switch_account': { url: '/api/accounts/switch', method: 'POST' },\n  'add_account': { url: '/api/accounts', method: 'POST' },\n  'delete_account': { url: '/api/accounts/:accountId', method: 'DELETE' },\n  'delete_accounts': { url: '/api/accounts/bulk-delete', method: 'POST' },\n  'fetch_account_quota': { url: '/api/accounts/:accountId/quota', method: 'GET' },\n  'refresh_account_quota': { url: '/api/accounts/:accountId/quota', method: 'GET' },\n  'refresh_all_quotas': { url: '/api/accounts/refresh', method: 'POST' },\n  'reorder_accounts': { url: '/api/accounts/reorder', method: 'POST' },\n  'toggle_proxy_status': { url: '/api/accounts/:accountId/toggle-proxy', method: 'POST' },\n  'warm_up_accounts': { url: '/api/accounts/warmup', method: 'POST' },\n  'warm_up_all_accounts': { url: '/api/accounts/warmup', method: 'POST' },\n  'warm_up_account': { url: '/api/accounts/:accountId/warmup', method: 'POST' },\n  'update_account_label': { url: '/api/accounts/:accountId/label', method: 'POST' },\n  'export_accounts': { url: '/api/accounts/export', method: 'POST' },\n  'bind_device_profile': { url: '/api/accounts/:accountId/bind-device', method: 'POST' },\n  'get_device_profiles': { url: '/api/accounts/:accountId/device-profiles', method: 'GET' },\n  'list_device_versions': { url: '/api/accounts/:accountId/device-versions', method: 'GET' },\n  'preview_generate_profile': { url: '/api/accounts/device-preview', method: 'POST' },\n  'bind_device_profile_with_profile': { url: '/api/accounts/:accountId/bind-device-profile', method: 'POST' },\n  'restore_original_device': { url: '/api/accounts/restore-original', method: 'POST' },\n  'restore_device_version': { url: '/api/accounts/:accountId/device-versions/:versionId/restore', method: 'POST' },\n  'delete_device_version': { url: '/api/accounts/:accountId/device-versions/:versionId', method: 'DELETE' },\n  'open_device_folder': { url: '/api/system/open-folder', method: 'POST' },\n\n  // Proxy Control & Status\n  'get_proxy_status': { url: '/api/proxy/status', method: 'GET' },\n  'start_proxy_service': { url: '/api/proxy/start', method: 'POST' },\n  'stop_proxy_service': { url: '/api/proxy/stop', method: 'POST' },\n  'update_model_mapping': { url: '/api/proxy/mapping', method: 'POST' },\n  'generate_api_key': { url: '/api/proxy/api-key/generate', method: 'POST' },\n  'clear_proxy_session_bindings': { url: '/api/proxy/session-bindings/clear', method: 'POST' },\n  'clear_proxy_rate_limit': { url: '/api/proxy/rate-limits/:accountId', method: 'DELETE' },\n  'clear_all_proxy_rate_limits': { url: '/api/proxy/rate-limits', method: 'DELETE' },\n  'check_proxy_health': { url: '/api/proxy/health-check/trigger', method: 'POST' },\n  'get_preferred_account': { url: '/api/proxy/preferred-account', method: 'GET' },\n  'set_preferred_account': { url: '/api/proxy/preferred-account', method: 'POST' },\n  'fetch_zai_models': { url: '/api/zai/models/fetch', method: 'POST' },\n  'load_config': { url: '/api/config', method: 'GET' },\n  'save_config': { url: '/api/config', method: 'POST' },\n  'get_proxy_stats': { url: '/api/proxy/stats', method: 'GET' },\n  'set_proxy_monitor_enabled': { url: '/api/proxy/monitor/toggle', method: 'POST' },\n\n  // Logs & Monitoring\n  'get_proxy_logs_filtered': { url: '/api/logs', method: 'GET' },\n  'get_proxy_logs_count_filtered': { url: '/api/logs/count', method: 'GET' },\n  'clear_proxy_logs': { url: '/api/logs/clear', method: 'POST' },\n  'get_proxy_log_detail': { url: '/api/logs/:logId', method: 'GET' },\n\n  // Debug Console\n  'enable_debug_console': { url: '/api/debug/enable', method: 'POST' },\n  'disable_debug_console': { url: '/api/debug/disable', method: 'POST' },\n  'is_debug_console_enabled': { url: '/api/debug/enabled', method: 'GET' },\n  'get_debug_console_logs': { url: '/api/debug/logs', method: 'GET' },\n  'clear_debug_console_logs': { url: '/api/debug/logs/clear', method: 'POST' },\n\n  // CLI Sync\n  'get_cli_sync_status': { url: '/api/proxy/cli/status', method: 'POST' },\n  'execute_cli_sync': { url: '/api/proxy/cli/sync', method: 'POST' },\n  'execute_cli_restore': { url: '/api/proxy/cli/restore', method: 'POST' },\n  'get_cli_config_content': { url: '/api/proxy/cli/config', method: 'POST' },\n\n  // OpenCode Sync\n  'get_opencode_sync_status': { url: '/api/proxy/opencode/status', method: 'POST' },\n  'execute_opencode_sync': { url: '/api/proxy/opencode/sync', method: 'POST' },\n  'execute_opencode_restore': { url: '/api/proxy/opencode/restore', method: 'POST' },\n  'execute_opencode_clear': { url: '/api/proxy/opencode/clear', method: 'POST' },\n  'get_opencode_config_content': { url: '/api/proxy/opencode/config', method: 'POST' },\n\n  // Stats\n  'get_token_stats_hourly': { url: '/api/stats/token/hourly', method: 'GET' },\n  'get_token_stats_daily': { url: '/api/stats/token/daily', method: 'GET' },\n  'get_token_stats_weekly': { url: '/api/stats/token/weekly', method: 'GET' },\n  'get_token_stats_by_account': { url: '/api/stats/token/by-account', method: 'GET' },\n  'get_token_stats_summary': { url: '/api/stats/token/summary', method: 'GET' },\n  'get_token_stats_by_model': { url: '/api/stats/token/by-model', method: 'GET' },\n  'get_token_stats_model_trend_hourly': { url: '/api/stats/token/model-trend/hourly', method: 'GET' },\n  'get_token_stats_model_trend_daily': { url: '/api/stats/token/model-trend/daily', method: 'GET' },\n  'get_token_stats_account_trend_hourly': { url: '/api/stats/token/account-trend/hourly', method: 'GET' },\n  'get_token_stats_account_trend_daily': { url: '/api/stats/token/account-trend/daily', method: 'GET' },\n  'clear_token_stats': { url: '/api/stats/token/clear', method: 'POST' },\n\n  // System\n  'get_data_dir_path': { url: '/api/system/data-dir', method: 'GET' },\n  'get_update_settings': { url: '/api/system/updates/settings', method: 'GET' },\n  'save_update_settings': { url: '/api/system/updates/save', method: 'POST' },\n  'is_auto_launch_enabled': { url: '/api/system/autostart/status', method: 'GET' },\n  'toggle_auto_launch': { url: '/api/system/autostart/toggle', method: 'POST' },\n  'get_http_api_settings': { url: '/api/system/http-api/settings', method: 'GET' },\n  'save_http_api_settings': { url: '/api/system/http-api/settings', method: 'POST' },\n  'get_antigravity_path': { url: '/api/system/antigravity/path', method: 'GET' },\n  'get_antigravity_args': { url: '/api/system/antigravity/args', method: 'GET' },\n\n  // Cloudflared\n  'cloudflared_install': { url: '/api/proxy/cloudflared/install', method: 'POST' },\n  'cloudflared_start': { url: '/api/proxy/cloudflared/start', method: 'POST' },\n  'cloudflared_stop': { url: '/api/proxy/cloudflared/stop', method: 'POST' },\n  'cloudflared_get_status': { url: '/api/proxy/cloudflared/status', method: 'GET' },\n\n  // Updates\n  'should_check_updates': { url: '/api/system/updates/check-status', method: 'GET' },\n  'check_for_updates': { url: '/api/system/updates/check', method: 'POST' },\n  'update_last_check_time': { url: '/api/system/updates/touch', method: 'POST' },\n\n  // OAuth\n  'prepare_oauth_url': { url: '/api/auth/url', method: 'GET' },\n  'start_oauth_login': { url: '/api/accounts/oauth/start', method: 'POST' },\n  'complete_oauth_login': { url: '/api/accounts/oauth/complete', method: 'POST' },\n  'cancel_oauth_login': { url: '/api/accounts/oauth/cancel', method: 'POST' },\n  'submit_oauth_code': { url: '/api/accounts/oauth/submit-code', method: 'POST' },\n\n  // Import\n  'import_v1_accounts': { url: '/api/accounts/import/v1', method: 'POST' },\n  'import_from_db': { url: '/api/accounts/import/db', method: 'POST' },\n  'import_custom_db': { url: '/api/accounts/import/db-custom', method: 'POST' },\n  'sync_account_from_db': { url: '/api/accounts/sync/db', method: 'POST' },\n\n  // System Extra & Cache\n  'open_data_folder': { url: '/api/system/open-folder', method: 'POST' },\n  'clear_antigravity_cache': { url: '/api/system/cache/clear', method: 'POST' },\n  'get_antigravity_cache_paths': { url: '/api/system/cache/paths', method: 'GET' },\n  'clear_log_cache': { url: '/api/system/logs/clear-cache', method: 'POST' },\n\n  // Security / IP Management\n  'get_ip_access_logs': { url: '/api/security/logs', method: 'GET' },\n  'clear_ip_access_logs': { url: '/api/security/logs/clear', method: 'POST' },\n  'get_ip_stats': { url: '/api/security/stats', method: 'GET' },\n  'get_ip_token_stats': { url: '/api/security/token-stats', method: 'GET' },\n  'get_ip_blacklist': { url: '/api/security/blacklist', method: 'GET' },\n  'add_ip_to_blacklist': { url: '/api/security/blacklist', method: 'POST' },\n  'remove_ip_from_blacklist': { url: '/api/security/blacklist', method: 'DELETE' },\n  'clear_ip_blacklist': { url: '/api/security/blacklist/clear', method: 'POST' },\n  'check_ip_in_blacklist': { url: '/api/security/blacklist/check', method: 'GET' },\n  'get_ip_whitelist': { url: '/api/security/whitelist', method: 'GET' },\n  'add_ip_to_whitelist': { url: '/api/security/whitelist', method: 'POST' },\n  'remove_ip_from_whitelist': { url: '/api/security/whitelist', method: 'DELETE' },\n  'clear_ip_whitelist': { url: '/api/security/whitelist/clear', method: 'POST' },\n  'check_ip_in_whitelist': { url: '/api/security/whitelist/check', method: 'GET' },\n  'get_security_config': { url: '/api/security/config', method: 'GET' },\n  'update_security_config': { url: '/api/security/config', method: 'POST' },\n  // User Tokens\n  'list_user_tokens': { url: '/api/user-tokens', method: 'GET' },\n  'get_user_token_summary': { url: '/api/user-tokens/summary', method: 'GET' },\n  'create_user_token': { url: '/api/user-tokens', method: 'POST' },\n  'renew_user_token': { url: '/api/user-tokens/:id/renew', method: 'POST' },\n  'delete_user_token': { url: '/api/user-tokens/:id', method: 'DELETE' },\n  'update_user_token': { url: '/api/user-tokens/:id', method: 'PATCH' },\n\n  // Proxy Pool (Web Mode Fix)\n  'get_proxy_pool_config': { url: '/api/proxy/pool/config', method: 'GET' },\n  'get_all_account_bindings': { url: '/api/proxy/pool/bindings', method: 'GET' },\n  'bind_account_proxy': { url: '/api/proxy/pool/bind', method: 'POST' },\n  'unbind_account_proxy': { url: '/api/proxy/pool/unbind', method: 'POST' },\n  'get_account_proxy_binding': { url: '/api/proxy/pool/binding/:accountId', method: 'GET' },\n};\n\nexport async function request<T>(cmd: string, args?: any): Promise<T> {\n  // 1. Tauri 环境：直接使用 invoke ...\n  if (isTauri) {\n    try {\n      const { invoke } = await import('@tauri-apps/api/core');\n      return await invoke<T>(cmd, args);\n    } catch (error) {\n      console.error(`Tauri Invoke Error [${cmd}]:`, error);\n      throw error;\n    }\n  }\n\n  // 2. Web 环境：映射到 HTTP API\n  const mapping = COMMAND_MAPPING[cmd];\n  if (!mapping) {\n    console.error(`Command [${cmd}] is not yet mapped for Web mode. Failing.`);\n    throw new Error(`Command [${cmd}] not supported in Web mode.`);\n  }\n\n  let url = mapping.url;\n  // [FIX] 创建 args 副本，用于移除已使用的路径参数\n  let bodyArgs = args ? { ...args } : undefined;\n\n  // 通用路径参数处理：替换 :key 为 args[key]\n  if (args) {\n    Object.keys(args).forEach(key => {\n      const placeholder = `:${key}`;\n      if (url.includes(placeholder)) {\n        url = url.replace(placeholder, encodeURIComponent(String(args[key])));\n        // [FIX] 从 body 参数中移除已用于路径的参数\n        if (bodyArgs) {\n          delete bodyArgs[key];\n        }\n      }\n    });\n  }\n\n  const apiKey = typeof window !== 'undefined' ? sessionStorage.getItem('abv_admin_api_key') : null;\n\n  const options: RequestInit = {\n    method: mapping.method,\n    headers: {\n      'Content-Type': 'application/json',\n      ...(apiKey ? {\n        'Authorization': `Bearer ${apiKey}`,\n        'x-api-key': apiKey\n      } : {}),\n    },\n  };\n\n  if ((mapping.method === 'GET' || mapping.method === 'DELETE') && args) {\n    const params = new URLSearchParams();\n    Object.entries(args).forEach(([key, value]) => {\n      // [FIX] 跳过已用于路径替换的参数\n      if (url.includes(encodeURIComponent(String(value)))) return;\n      if (value !== undefined && value !== null) {\n        params.append(key, String(value));\n      }\n    });\n    const qs = params.toString();\n    if (qs) url += `?${qs}`;\n  } else if ((mapping.method === 'POST' || mapping.method === 'PATCH') && bodyArgs) {\n    // [FIX] 如果有 request 包装，提取其内容作为 body\n    const body = bodyArgs.request !== undefined ? bodyArgs.request : bodyArgs;\n    options.body = JSON.stringify(body);\n  }\n\n  try {\n    const response = await fetch(url, options);\n    if (!response.ok) {\n      if (!isTauri && response.status === 401) {\n        // [FIX #1163] 增加防抖锁，避免重复事件导致 UI 抖动\n        const now = Date.now();\n        const lastAuthError = (window as any)._lastAuthErrorTime || 0;\n        if (now - lastAuthError > 2000) {\n          (window as any)._lastAuthErrorTime = now;\n          window.dispatchEvent(new CustomEvent('abv-unauthorized'));\n        }\n      }\n      const errorData = await response.json().catch(() => ({}));\n      throw errorData.error || `HTTP Error ${response.status}`;\n    }\n\n    // 如果是 204 No Content，直接返回 null\n    if (response.status === 204) {\n      return null as unknown as T;\n    }\n\n    const text = await response.text();\n    if (!text) {\n      return null as unknown as T;\n    }\n\n    try {\n      return JSON.parse(text) as T;\n    } catch (e) {\n      console.warn(`Failed to parse JSON response for [${cmd}]:`, text);\n      return text as unknown as T; // Fallback for plain text responses\n    }\n  } catch (error) {\n    console.error(`Web Fetch Error [${cmd}]:`, error);\n    throw error;\n  }\n}\n"
  },
  {
    "path": "src/utils/uuid.ts",
    "content": "/**\n * Generates a UUID (Universally Unique Identifier) v4.\n * \n * This function attempts to use the native `crypto.randomUUID()` API first.\n * If that API is unavailable (e.g., in non-secure contexts like HTTP),\n * it falls back to a custom implementation using checksum-based random generation.\n * \n * @returns {string} A valid UUID v4 string.\n */\nexport const generateUUID = (): string => {\n    // Try to use the native API first if available\n    if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n        try {\n            return crypto.randomUUID();\n        } catch (e) {\n            // Fallback if native call fails for some reason\n            console.warn('crypto.randomUUID() failed, falling back to custom implementation', e);\n        }\n    }\n\n    // Fallback implementation for non-secure contexts\n    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {\n        const r = Math.random() * 16 | 0;\n        const v = c === 'x' ? r : (r & 0x3 | 0x8);\n        return v.toString(16);\n    });\n};\n"
  },
  {
    "path": "src/utils/windowManager.ts",
    "content": "import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window';\nimport { isTauri } from './env';\n\n/**\n * Enter mini view mode\n * @param contentHeight The height of the content to fit\n * @param shouldCenter Whether to center the window (default: false)\n */\nexport const enterMiniMode = async (contentHeight: number, shouldCenter: boolean = false) => {\n    if (!isTauri()) return;\n    try {\n        const win = getCurrentWindow();\n\n        // Hide window decorations (title bar) first to ensure accurate sizing\n        await win.setDecorations(false);\n\n        // Set window size: width 300, height = content height \n        await win.setSize(new LogicalSize(300, contentHeight+2));\n\n        await win.setAlwaysOnTop(true);\n        // Enable window shadow\n        await win.setShadow(true);\n        // Disable resizing in mini mode\n        await win.setResizable(false);\n\n        // Center window only if requested (usually on first load)\n        if (shouldCenter) {\n            await win.center();\n        }\n    } catch (error) {\n        console.error('Failed to enter mini mode:', error);\n    }\n};\n\n/**\n * Exit mini view mode and restore default window state\n */\nexport const exitMiniMode = async () => {\n    if (!isTauri()) return;\n    try {\n        const win = getCurrentWindow();\n        // Restore to a reasonable default size\n        await win.setSize(new LogicalSize(1200, 800));\n        await win.setAlwaysOnTop(false);\n        await win.center();\n        // Restore window decorations (title bar)\n        await win.setDecorations(true);\n        // Re-enable resizing\n        await win.setResizable(true);\n    } catch (error) {\n        console.error('Failed to exit mini mode:', error);\n    }\n};\n\n/**\n * Ensure window is in valid full view state (Self-healing)\n * Used on app startup to recover from improper shutdown in mini mode\n */\nexport const ensureFullViewState = async () => {\n    if (!isTauri()) return;\n    try {\n        const win = getCurrentWindow();\n        const size = await win.outerSize();\n        // If window is suspiciously narrow (likely leftover from Mini View), restore default size\n        if (size.width < 500) {\n            await win.setSize(new LogicalSize(1200, 800));\n            await win.center();\n        }\n        // Always enforce standard window properties for Full View\n        await win.setDecorations(true);\n        await win.setResizable(true);\n        await win.setAlwaysOnTop(false);\n    } catch (error) {\n        console.error('Failed to ensure full view state:', error);\n    }\n};\n"
  },
  {
    "path": "src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "src-tauri/.gitignore",
    "content": "# Generated by Cargo\n# will have compiled files and executables\n/target/\n\n# Generated by Tauri\n# will have schema files for capabilities auto-completion\n/gen/schemas\n"
  },
  {
    "path": "src-tauri/Cargo.toml",
    "content": "[package]\nname = \"antigravity_tools\"\nversion = \"4.1.30\"\ndescription = \"A Tauri App\"\nauthors = [\"you\"]\nlicense = \"CC-BY-NC-SA-4.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[lib]\n# The `_lib` suffix may seem redundant but it is necessary\n# to make the lib name unique and wouldn't conflict with the bin name.\n# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519\nname = \"antigravity_tools_lib\"\ncrate-type = [\"staticlib\", \"cdylib\", \"rlib\"]\n\n[build-dependencies]\ntauri-build = { version = \"^2.2.5\", features = [] }\n\n[dependencies]\ntauri = { version = \"^2.2.5\", features = [\"tray-icon\", \"image-png\"] }\ntauri-plugin-opener = \"2\"\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = { version = \"1\", features = [\"preserve_order\"] }\nuuid = { version = \"1.10\", features = [\"v4\", \"serde\"] }\nchrono = \"0.4\"\ndirs = \"5.0\"\nreqwest = { version = \"0.12\", default-features = false, features = [\"json\", \"stream\", \"socks\", \"blocking\", \"rustls-tls\"] }\ntracing = \"0.1\"\ntracing-subscriber = { version = \"0.3\", features = [\"env-filter\", \"time\"] }\nrusqlite = { version = \"0.32\", features = [\"bundled\"] }\nbase64 = \"0.22\"\nsysinfo = \"0.31\"\ntokio = { version = \"1\", features = [\"full\"] }\nurl = \"2.5.7\"\ntauri-plugin-dialog = \"2.6.0\"\ntauri-plugin-fs = \"2.4.5\"\nimage = { version = \"0.25.9\", default-features = false, features = [\"png\", \"webp\"] }\nthiserror = \"2.0.17\"\n\n# 反代服务依赖\naxum = { version = \"0.7\", features = [\"multipart\"] }\ntokio-stream = { version = \"0.1.17\", features = [\"sync\"] }\n\nhyper = { version = \"1\", features = [\"full\"] }\nhyper-util = { version = \"0.1\", features = [\"full\"] }\ntower = { version = \"0.4\", features = [\"util\"] }\ntower-http = { version = \"0.5\", features = [\"cors\", \"trace\", \"fs\"] }\neventsource-stream = \"0.2\"\ndashmap = \"6.1\"\nanyhow = \"1.0\"\nfutures = \"0.3\"\nrand = \"0.8\"                        # 生成 sessionId 和 mock project_id\nasync-stream = \"0.3.6\"              # 简化异步流生成\nregex = \"1.12.2\"                    # Duration 解析\nonce_cell = \"1.19\"                  # 静态初始化 (模型映射表)\npin-project = \"1.1\"                 # Pin 投影辅助\nbytes = \"1.5\"                       # SSE 字节操作\ntauri-plugin-single-instance = { version = \"2.3.6\", features = [\"deep-link\"] }\nlibc = \"0.2\"\ntracing-appender = \"0.2.4\"\ntracing-log = \"0.2.0\"\ntauri-plugin-autostart = \"2.5.1\"\ntauri-plugin-updater = \"2\"\ntauri-plugin-process = \"2\"\nsha2 = \"0.10\"\ntoml = \"0.8\"\ntoml_edit = \"0.22\"\ntauri-plugin-window-state = \"2\"\nparking_lot = \"0.12.5\"\ntokio-util = \"0.7.18\"\naes-gcm = \"0.10.3\"\nmachine-uid = \"0.5.4\"\nplist = \"1.7\"\nrquest = { version = \"5.1.0\", features = [\"json\", \"stream\", \"socks\", \"cookies\"] }\nrquest-util = \"2.2.1\"\n\n[target.'cfg(target_os = \"linux\")'.dependencies]\ngtk = \"0.18\"\n\n[features]\ndefault = [\"custom-protocol\"]\ncustom-protocol = [\"tauri/custom-protocol\"]\n"
  },
  {
    "path": "src-tauri/Entitlements.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>com.apple.security.app-sandbox</key>\n    <true/>\n    <key>com.apple.security.files.user-selected.read-only</key>\n    <true/>\n    <key>com.apple.security.network.client</key>\n    <true/>\n    <key>com.apple.security.network.server</key>\n    <true/>\n    <key>com.apple.security.temporary-exception.shared-preference.read-only</key>\n    <array>\n        <string>kCFPreferencesAnyApplication</string>\n    </array>\n</dict>\n</plist>\n"
  },
  {
    "path": "src-tauri/build.rs",
    "content": "fn main() {\n    tauri_build::build()\n}\n"
  },
  {
    "path": "src-tauri/capabilities/default.json",
    "content": "{\n  \"$schema\": \"../gen/schemas/desktop-schema.json\",\n  \"identifier\": \"default\",\n  \"description\": \"Capability for the main window\",\n  \"windows\": [\n    \"main\"\n  ],\n  \"permissions\": [\n    \"core:default\",\n    \"core:window:default\",\n    \"core:window:allow-start-dragging\",\n    \"core:window:allow-set-background-color\",\n    \"core:window:allow-set-size\",\n    \"core:window:allow-set-always-on-top\",\n    \"core:window:allow-center\",\n    \"core:window:allow-set-decorations\",\n    \"core:window:allow-set-resizable\",\n    \"core:window:allow-set-shadow\",\n    \"core:window:allow-show\",\n    \"opener:default\",\n    \"dialog:default\",\n    \"fs:default\",\n    \"core:tray:default\",\n    \"core:event:default\",\n    \"autostart:allow-enable\",\n    \"autostart:allow-disable\",\n    \"autostart:allow-is-enabled\",\n    \"window-state:default\",\n    \"updater:default\",\n    \"process:allow-restart\",\n    \"process:allow-exit\"\n  ]\n}"
  },
  {
    "path": "src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n  <foreground android:drawable=\"@mipmap/ic_launcher_foreground\"/>\n  <background android:drawable=\"@color/ic_launcher_background\"/>\n</adaptive-icon>"
  },
  {
    "path": "src-tauri/icons/android/values/ic_launcher_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n  <color name=\"ic_launcher_background\">#fff</color>\n</resources>"
  },
  {
    "path": "src-tauri/resources/model_specs.json",
    "content": "{\n    \"models\": {\n        \"gemini-2.0-flash\": {\n            \"max_output_tokens\": 65535,\n            \"thinking_budget\": 24576,\n            \"is_thinking\": false\n        },\n        \"gemini-2.5-flash\": {\n            \"max_output_tokens\": 65535,\n            \"thinking_budget\": 32768,\n            \"is_thinking\": true\n        },\n        \"gemini-3-flash\": {\n            \"max_output_tokens\": 65536,\n            \"thinking_budget\": 32768,\n            \"is_thinking\": true\n        },\n        \"gemini-3-pro-high\": {\n            \"max_output_tokens\": 65536,\n            \"thinking_budget\": 49152,\n            \"is_thinking\": true\n        },\n        \"gemini-3.1-pro-preview\": {\n            \"max_output_tokens\": 65536,\n            \"thinking_budget\": 49152,\n            \"is_thinking\": true\n        },\n        \"claude-sonnet-4-6\": {\n            \"max_output_tokens\": 64000,\n            \"thinking_budget\": 32768,\n            \"is_thinking\": true\n        },\n        \"claude-opus-4-6-thinking\": {\n            \"max_output_tokens\": 64000,\n            \"thinking_budget\": 32768,\n            \"is_thinking\": true\n        },\n        \"gpt-oss-120b-medium\": {\n            \"max_output_tokens\": 32768,\n            \"thinking_budget\": 0,\n            \"is_thinking\": false\n        }\n    },\n    \"aliases\": {\n        \"gpt-4o\": \"gemini-2.5-flash\",\n        \"gpt-3.5-turbo\": \"gemini-2.0-flash-lite\",\n        \"claude-3-5-sonnet\": \"claude-sonnet-4-6\",\n        \"claude-3-7-sonnet\": \"claude-sonnet-4-6-thinking\"\n    }\n}"
  },
  {
    "path": "src-tauri/src/commands/autostart.rs",
    "content": "// Autostart 命令\nuse tauri_plugin_autostart::ManagerExt;\n\n#[tauri::command]\npub async fn toggle_auto_launch(\n    app: tauri::AppHandle,\n    enable: bool,\n) -> Result<(), String> {\n    let manager = app.autolaunch();\n    \n    if enable {\n        manager.enable().map_err(|e| format!(\"启用自动启动失败: {}\", e))?;\n        crate::modules::logger::log_info(\"已启用开机自动启动\");\n    } else {\n        match manager.disable() {\n            Ok(_) => {\n                crate::modules::logger::log_info(\"已禁用开机自动启动\");\n            },\n            Err(e) => {\n                let err_msg = e.to_string();\n                // 在 Windows 上，如果注册表项不存在，disable() 会返回 \"系统找不到指定的文件\" (os error 2)\n                // 这种情况应该视为成功，因为目标（禁用）已经达成\n                if err_msg.contains(\"os error 2\") || err_msg.contains(\"找不到指定的文件\") {\n                    crate::modules::logger::log_info(\"开机自启项已不存在，视为禁用成功\");\n                } else {\n                    return Err(format!(\"禁用自动启动失败: {}\", e));\n                }\n            }\n        }\n    }\n    \n    Ok(())\n}\n\n#[tauri::command]\npub async fn is_auto_launch_enabled(app: tauri::AppHandle) -> Result<bool, String> {\n    let manager = app.autolaunch();\n    manager.is_enabled().map_err(|e| e.to_string())\n}\n"
  },
  {
    "path": "src-tauri/src/commands/cloudflared.rs",
    "content": "use tauri::State;\nuse crate::modules::cloudflared::{CloudflaredConfig, CloudflaredManager, CloudflaredStatus};\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\n\n/// Cloudflared服务状态管理\n#[derive(Clone)]\npub struct CloudflaredState {\n    pub manager: Arc<RwLock<Option<CloudflaredManager>>>,\n}\n\nimpl CloudflaredState {\n    pub fn new() -> Self {\n        Self {\n            manager: Arc::new(RwLock::new(None)),\n        }\n    }\n\n    /// 确保管理器已初始化\n    pub async fn ensure_manager(&self) -> Result<(), String> {\n        let mut lock = self.manager.write().await;\n        if lock.is_none() {\n            let data_dir = crate::modules::account::get_data_dir()?;\n            *lock = Some(CloudflaredManager::new(&data_dir));\n        }\n        Ok(())\n    }\n}\n\n/// 检查cloudflared是否已安装\n#[tauri::command]\npub async fn cloudflared_check(\n    state: State<'_, CloudflaredState>,\n) -> Result<CloudflaredStatus, String> {\n    state.ensure_manager().await?;\n    \n    let lock = state.manager.read().await;\n    if let Some(manager) = lock.as_ref() {\n        let (installed, version) = manager.check_installed().await;\n        Ok(CloudflaredStatus {\n            installed,\n            version,\n            running: false,\n            url: None,\n            error: None,\n        })\n    } else {\n        Err(\"Manager not initialized\".to_string())\n    }\n}\n\n/// 安装cloudflared\n#[tauri::command]\npub async fn cloudflared_install(\n    state: State<'_, CloudflaredState>,\n) -> Result<CloudflaredStatus, String> {\n    state.ensure_manager().await?;\n    \n    let lock = state.manager.read().await;\n    if let Some(manager) = lock.as_ref() {\n        manager.install().await\n    } else {\n        Err(\"Manager not initialized\".to_string())\n    }\n}\n\n/// 启动cloudflared隧道\n#[tauri::command]\npub async fn cloudflared_start(\n    state: State<'_, CloudflaredState>,\n    config: CloudflaredConfig,\n) -> Result<CloudflaredStatus, String> {\n    state.ensure_manager().await?;\n    \n    let lock = state.manager.read().await;\n    if let Some(manager) = lock.as_ref() {\n        manager.start(config).await\n    } else {\n        Err(\"Manager not initialized\".to_string())\n    }\n}\n\n/// 停止cloudflared隧道\n#[tauri::command]\npub async fn cloudflared_stop(\n    state: State<'_, CloudflaredState>,\n) -> Result<CloudflaredStatus, String> {\n    state.ensure_manager().await?;\n    \n    let lock = state.manager.read().await;\n    if let Some(manager) = lock.as_ref() {\n        manager.stop().await\n    } else {\n        Err(\"Manager not initialized\".to_string())\n    }\n}\n\n/// 获取cloudflared状态\n#[tauri::command]\npub async fn cloudflared_get_status(\n    state: State<'_, CloudflaredState>,\n) -> Result<CloudflaredStatus, String> {\n    state.ensure_manager().await?;\n\n    let lock = state.manager.read().await;\n    if let Some(manager) = lock.as_ref() {\n        let (installed, version) = manager.check_installed().await;\n        let mut status = manager.get_status().await;\n        status.installed = installed;\n        status.version = version;\n        if !installed {\n            status.running = false;\n            status.url = None;\n        }\n        Ok(status)\n    } else {\n        Ok(CloudflaredStatus::default())\n    }\n}\n\n"
  },
  {
    "path": "src-tauri/src/commands/mod.rs",
    "content": "use crate::models::{Account, AppConfig, QuotaData};\nuse crate::modules;\nuse tauri::{Emitter, Manager};\nuse tauri_plugin_opener::OpenerExt;\n\n// 导出 proxy 命令\npub mod proxy;\n// 导出 autostart 命令\npub mod autostart;\n// 导出 cloudflared 命令\npub mod cloudflared;\n// 导出 security 命令 (IP 监控)\npub mod security;\n// 导出 proxy_pool 命令\npub mod proxy_pool;\n// 导出 user_token 命令\npub mod user_token;\n\n/// 列出所有账号\n#[tauri::command]\npub async fn list_accounts() -> Result<Vec<Account>, String> {\n    modules::list_accounts()\n}\n\n/// 添加账号\n#[tauri::command]\npub async fn add_account(\n    app: tauri::AppHandle,\n    _email: String,\n    refresh_token: String,\n) -> Result<Account, String> {\n    let service = modules::account_service::AccountService::new(\n        crate::modules::integration::SystemManager::Desktop(app.clone()),\n    );\n\n    let mut account = service.add_account(&refresh_token).await?;\n\n    // 自动刷新配额\n    let _ = internal_refresh_account_quota(&app, &mut account).await;\n\n    // 重载账号池\n    let _ = crate::commands::proxy::reload_proxy_accounts(\n        app.state::<crate::commands::proxy::ProxyServiceState>(),\n    )\n    .await;\n\n    Ok(account)\n}\n\n/// 删除账号\n/// 删除账号\n#[tauri::command]\npub async fn delete_account(\n    app: tauri::AppHandle,\n    proxy_state: tauri::State<'_, crate::commands::proxy::ProxyServiceState>,\n    account_id: String,\n) -> Result<(), String> {\n    let service = modules::account_service::AccountService::new(\n        crate::modules::integration::SystemManager::Desktop(app.clone()),\n    );\n    service.delete_account(&account_id)?;\n\n    // Reload token pool\n    let _ = crate::commands::proxy::reload_proxy_accounts(proxy_state).await;\n\n    Ok(())\n}\n\n/// 批量删除账号\n#[tauri::command]\npub async fn delete_accounts(\n    app: tauri::AppHandle,\n    proxy_state: tauri::State<'_, crate::commands::proxy::ProxyServiceState>,\n    account_ids: Vec<String>,\n) -> Result<(), String> {\n    modules::logger::log_info(&format!(\n        \"收到批量删除请求，共 {} 个账号\",\n        account_ids.len()\n    ));\n    modules::account::delete_accounts(&account_ids).map_err(|e| {\n        modules::logger::log_error(&format!(\"批量删除失败: {}\", e));\n        e\n    })?;\n\n    // 强制同步托盘\n    crate::modules::tray::update_tray_menus(&app);\n\n    // Reload token pool\n    let _ = crate::commands::proxy::reload_proxy_accounts(proxy_state).await;\n\n    Ok(())\n}\n\n/// 重新排序账号列表\n/// 根据传入的账号ID数组顺序更新账号排列\n#[tauri::command]\npub async fn reorder_accounts(\n    proxy_state: tauri::State<'_, crate::commands::proxy::ProxyServiceState>,\n    account_ids: Vec<String>,\n) -> Result<(), String> {\n    modules::logger::log_info(&format!(\n        \"收到账号重排序请求，共 {} 个账号\",\n        account_ids.len()\n    ));\n    modules::account::reorder_accounts(&account_ids).map_err(|e| {\n        modules::logger::log_error(&format!(\"账号重排序失败: {}\", e));\n        e\n    })?;\n\n    // Reload pool to reflect new order if running\n    let _ = crate::commands::proxy::reload_proxy_accounts(proxy_state).await;\n    Ok(())\n}\n\n/// 切换账号\n#[tauri::command]\npub async fn switch_account(\n    app: tauri::AppHandle,\n    proxy_state: tauri::State<'_, crate::commands::proxy::ProxyServiceState>,\n    account_id: String,\n) -> Result<(), String> {\n    let service = modules::account_service::AccountService::new(\n        crate::modules::integration::SystemManager::Desktop(app.clone()),\n    );\n\n    service.switch_account(&account_id).await?;\n\n    // 同步托盘\n    crate::modules::tray::update_tray_menus(&app);\n\n    // [FIX #820] Notify proxy to clear stale session bindings and reload accounts\n    let _ = crate::commands::proxy::reload_proxy_accounts(proxy_state).await;\n\n    Ok(())\n}\n\n/// 获取当前账号\n#[tauri::command]\npub async fn get_current_account() -> Result<Option<Account>, String> {\n    // println!(\"🚀 Backend Command: get_current_account called\"); // Commented out to reduce noise for frequent calls, relies on frontend log for frequency\n    // Actually user WANTS to see it.\n    modules::logger::log_info(\"Backend Command: get_current_account called\");\n\n    let account_id = modules::get_current_account_id()?;\n\n    if let Some(id) = account_id {\n        // modules::logger::log_info(&format!(\"   Found current account ID: {}\", id));\n        modules::load_account(&id).map(Some)\n    } else {\n        modules::logger::log_info(\"   No current account set\");\n        Ok(None)\n    }\n}\n\n/// 导出账号（包含 refresh_token）\nuse crate::models::AccountExportResponse;\n\n#[tauri::command]\npub async fn export_accounts(account_ids: Vec<String>) -> Result<AccountExportResponse, String> {\n    modules::account::export_accounts_by_ids(&account_ids)\n}\n\n/// 内部辅助功能：在添加或导入账号后自动刷新一次额度\nasync fn internal_refresh_account_quota(\n    app: &tauri::AppHandle,\n    account: &mut Account,\n) -> Result<QuotaData, String> {\n    modules::logger::log_info(&format!(\"自动触发刷新配额: {}\", account.email));\n\n    // 使用带重试的查询 (Shared logic)\n    match modules::account::fetch_quota_with_retry(account).await {\n        Ok(quota) => {\n            // 更新账号配额\n            let _ = modules::update_account_quota(&account.id, quota.clone());\n            // 更新托盘菜单\n            crate::modules::tray::update_tray_menus(app);\n            Ok(quota)\n        }\n        Err(e) => {\n            modules::logger::log_warn(&format!(\"自动刷新配额失败 ({}): {}\", account.email, e));\n            Err(e.to_string())\n        }\n    }\n}\n\n/// 查询账号配额\n#[tauri::command]\npub async fn fetch_account_quota(\n    app: tauri::AppHandle,\n    proxy_state: tauri::State<'_, crate::commands::proxy::ProxyServiceState>,\n    account_id: String,\n) -> crate::error::AppResult<QuotaData> {\n    modules::logger::log_info(&format!(\"手动刷新配额请求: {}\", account_id));\n    let mut account =\n        modules::load_account(&account_id).map_err(crate::error::AppError::Account)?;\n\n    // 使用带重试的查询 (Shared logic)\n    let quota = modules::account::fetch_quota_with_retry(&mut account).await?;\n\n    // 4. 更新账号配额\n    modules::update_account_quota(&account_id, quota.clone())\n        .map_err(crate::error::AppError::Account)?;\n\n    crate::modules::tray::update_tray_menus(&app);\n\n    // 5. 同步到运行中的反代服务（如果已启动）\n    let instance_lock = proxy_state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        let _ = instance.token_manager.reload_account(&account_id).await;\n    }\n\n    Ok(quota)\n}\n\npub use modules::account::RefreshStats;\n\n/// 刷新所有账号配额 (内部实现)\npub async fn refresh_all_quotas_internal(\n    proxy_state: &crate::commands::proxy::ProxyServiceState,\n    app_handle: Option<tauri::AppHandle>,\n) -> Result<RefreshStats, String> {\n    let stats = modules::account::refresh_all_quotas_logic().await?;\n\n    // 同步到运行中的反代服务（如果已启动）\n    let instance_lock = proxy_state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        let _ = instance.token_manager.reload_all_accounts().await;\n    }\n\n    // 发送全局刷新事件给 UI (如果需要)\n    if let Some(handle) = app_handle {\n        use tauri::Emitter;\n        let _ = handle.emit(\"accounts://refreshed\", ());\n    }\n\n    Ok(stats)\n}\n\n/// 刷新所有账号配额 (Tauri Command)\n#[tauri::command]\npub async fn refresh_all_quotas(\n    proxy_state: tauri::State<'_, crate::commands::proxy::ProxyServiceState>,\n    app_handle: tauri::AppHandle,\n) -> Result<RefreshStats, String> {\n    refresh_all_quotas_internal(&proxy_state, Some(app_handle)).await\n}\n/// 获取设备指纹（当前 storage.json + 账号绑定）\n#[tauri::command]\npub async fn get_device_profiles(\n    account_id: String,\n) -> Result<modules::account::DeviceProfiles, String> {\n    modules::get_device_profiles(&account_id)\n}\n\n/// 绑定设备指纹（capture: 采集当前；generate: 生成新指纹），并写入 storage.json\n#[tauri::command]\npub async fn bind_device_profile(\n    account_id: String,\n    mode: String,\n) -> Result<crate::models::DeviceProfile, String> {\n    modules::bind_device_profile(&account_id, &mode)\n}\n\n/// 预览生成一个指纹（不落盘）\n#[tauri::command]\npub async fn preview_generate_profile() -> Result<crate::models::DeviceProfile, String> {\n    Ok(crate::modules::device::generate_profile())\n}\n\n/// 使用给定指纹直接绑定\n#[tauri::command]\npub async fn bind_device_profile_with_profile(\n    account_id: String,\n    profile: crate::models::DeviceProfile,\n) -> Result<crate::models::DeviceProfile, String> {\n    modules::bind_device_profile_with_profile(&account_id, profile, Some(\"generated\".to_string()))\n}\n\n/// 将账号已绑定的指纹应用到 storage.json\n#[tauri::command]\npub async fn apply_device_profile(\n    account_id: String,\n) -> Result<crate::models::DeviceProfile, String> {\n    modules::apply_device_profile(&account_id)\n}\n\n/// 恢复最早的 storage.json 备份（近似“原始”状态）\n#[tauri::command]\npub async fn restore_original_device() -> Result<String, String> {\n    modules::restore_original_device()\n}\n\n/// 列出指纹版本\n#[tauri::command]\npub async fn list_device_versions(\n    account_id: String,\n) -> Result<modules::account::DeviceProfiles, String> {\n    modules::list_device_versions(&account_id)\n}\n\n/// 按版本恢复指纹\n#[tauri::command]\npub async fn restore_device_version(\n    account_id: String,\n    version_id: String,\n) -> Result<crate::models::DeviceProfile, String> {\n    modules::restore_device_version(&account_id, &version_id)\n}\n\n/// 删除历史指纹（baseline 不可删）\n#[tauri::command]\npub async fn delete_device_version(account_id: String, version_id: String) -> Result<(), String> {\n    modules::delete_device_version(&account_id, &version_id)\n}\n\n/// 打开设备存储目录\n#[tauri::command]\npub async fn open_device_folder(app: tauri::AppHandle) -> Result<(), String> {\n    let dir = modules::device::get_storage_dir()?;\n    let dir_str = dir\n        .to_str()\n        .ok_or(\"无法解析存储目录路径为字符串\")?\n        .to_string();\n    app.opener()\n        .open_path(dir_str, None::<&str>)\n        .map_err(|e| format!(\"打开目录失败: {}\", e))\n}\n\n/// 加载配置\n#[tauri::command]\npub async fn load_config() -> Result<AppConfig, String> {\n    modules::load_app_config()\n}\n\n/// 保存配置\n#[tauri::command]\npub async fn save_config(\n    app: tauri::AppHandle,\n    proxy_state: tauri::State<'_, crate::commands::proxy::ProxyServiceState>,\n    config: AppConfig,\n) -> Result<(), String> {\n    modules::save_app_config(&config)?;\n\n    // 通知托盘配置已更新\n    let _ = app.emit(\"config://updated\", ());\n\n    // 热更新正在运行的服务\n    let instance_lock = proxy_state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        // 更新模型映射\n        instance.axum_server.update_mapping(&config.proxy).await;\n        // 更新上游代理\n        instance\n            .axum_server\n            .update_proxy(config.proxy.upstream_proxy.clone())\n            .await;\n        // 更新安全策略 (auth)\n        instance.axum_server.update_security(&config.proxy).await;\n        // 更新 z.ai 配置\n        instance.axum_server.update_zai(&config.proxy).await;\n        // 更新实验性配置\n        instance\n            .axum_server\n            .update_experimental(&config.proxy)\n            .await;\n        // 更新调试日志配置\n        instance\n            .axum_server\n            .update_debug_logging(&config.proxy)\n            .await;\n        // [NEW] 更新 User-Agent 配置\n        instance.axum_server.update_user_agent(&config.proxy).await;\n        // 更新 Thinking Budget 配置\n        crate::proxy::update_thinking_budget_config(config.proxy.thinking_budget.clone());\n        // [NEW] 更新全局系统提示词配置\n        crate::proxy::update_global_system_prompt_config(config.proxy.global_system_prompt.clone());\n        // [NEW] 更新全局图像思维模式配置\n        crate::proxy::update_image_thinking_mode(config.proxy.image_thinking_mode.clone());\n        // 更新代理池配置\n        instance\n            .axum_server\n            .update_proxy_pool(config.proxy.proxy_pool.clone())\n            .await;\n        // 更新熔断配置\n        instance\n            .token_manager\n            .update_circuit_breaker_config(config.circuit_breaker.clone())\n            .await;\n        tracing::debug!(\"已同步热更新反代服务配置\");\n    }\n\n    Ok(())\n}\n\n// --- OAuth 命令 ---\n\n#[tauri::command]\npub async fn start_oauth_login(app_handle: tauri::AppHandle) -> Result<Account, String> {\n    modules::logger::log_info(\"开始 OAuth 授权流程...\");\n    let service = modules::account_service::AccountService::new(\n        crate::modules::integration::SystemManager::Desktop(app_handle.clone()),\n    );\n\n    let mut account = service.start_oauth_login().await?;\n\n    // 自动触发刷新额度\n    let _ = internal_refresh_account_quota(&app_handle, &mut account).await;\n\n    // Reload token pool\n    let _ = crate::commands::proxy::reload_proxy_accounts(\n        app_handle.state::<crate::commands::proxy::ProxyServiceState>(),\n    )\n    .await;\n\n    Ok(account)\n}\n\n/// 完成 OAuth 授权（不自动打开浏览器）\n#[tauri::command]\npub async fn complete_oauth_login(app_handle: tauri::AppHandle) -> Result<Account, String> {\n    modules::logger::log_info(\"完成 OAuth 授权流程 (manual)...\");\n    let service = modules::account_service::AccountService::new(\n        crate::modules::integration::SystemManager::Desktop(app_handle.clone()),\n    );\n\n    let mut account = service.complete_oauth_login().await?;\n\n    // 自动触发刷新额度\n    let _ = internal_refresh_account_quota(&app_handle, &mut account).await;\n\n    // Reload token pool\n    let _ = crate::commands::proxy::reload_proxy_accounts(\n        app_handle.state::<crate::commands::proxy::ProxyServiceState>(),\n    )\n    .await;\n\n    Ok(account)\n}\n\n/// 预生成 OAuth 授权链接 (不打开浏览器)\n#[tauri::command]\npub async fn prepare_oauth_url(app_handle: tauri::AppHandle) -> Result<String, String> {\n    let service = modules::account_service::AccountService::new(\n        crate::modules::integration::SystemManager::Desktop(app_handle.clone()),\n    );\n    service.prepare_oauth_url().await\n}\n\n#[tauri::command]\npub async fn cancel_oauth_login() -> Result<(), String> {\n    modules::oauth_server::cancel_oauth_flow();\n    Ok(())\n}\n\n/// 手动提交 OAuth Code (用于 Docker/远程环境无法自动回调时)\n#[tauri::command]\npub async fn submit_oauth_code(code: String, state: Option<String>) -> Result<(), String> {\n    modules::logger::log_info(\"收到手动提交 OAuth Code 请求\");\n    modules::oauth_server::submit_oauth_code(code, state).await\n}\n\n// --- 导入命令 ---\n\n#[tauri::command]\npub async fn import_v1_accounts(\n    app: tauri::AppHandle,\n    proxy_state: tauri::State<'_, crate::commands::proxy::ProxyServiceState>,\n) -> Result<Vec<Account>, String> {\n    let accounts = modules::migration::import_from_v1().await?;\n\n    // 对导入的账号尝试刷新一波\n    for mut account in accounts.clone() {\n        let _ = internal_refresh_account_quota(&app, &mut account).await;\n    }\n\n    // Reload token pool\n    let _ = crate::commands::proxy::reload_proxy_accounts(proxy_state).await;\n\n    Ok(accounts)\n}\n\n#[tauri::command]\npub async fn import_from_db(\n    app: tauri::AppHandle,\n    proxy_state: tauri::State<'_, crate::commands::proxy::ProxyServiceState>,\n) -> Result<Account, String> {\n    // 同步函数包装为 async\n    let mut account = modules::migration::import_from_db().await?;\n\n    // 既然是从数据库导入（即 IDE 当前账号），自动将其设为 Manager 的当前账号\n    let account_id = account.id.clone();\n    modules::account::set_current_account_id(&account_id)?;\n\n    // 自动触发刷新额度\n    let _ = internal_refresh_account_quota(&app, &mut account).await;\n\n    // 刷新托盘图标展示\n    crate::modules::tray::update_tray_menus(&app);\n\n    // Reload token pool\n    let _ = crate::commands::proxy::reload_proxy_accounts(proxy_state).await;\n\n    Ok(account)\n}\n\n#[tauri::command]\n#[allow(dead_code)]\npub async fn import_custom_db(\n    app: tauri::AppHandle,\n    proxy_state: tauri::State<'_, crate::commands::proxy::ProxyServiceState>,\n    path: String,\n) -> Result<Account, String> {\n    // 调用重构后的自定义导入函数\n    let mut account = modules::migration::import_from_custom_db_path(path).await?;\n\n    // 自动设为当前账号\n    let account_id = account.id.clone();\n    modules::account::set_current_account_id(&account_id)?;\n\n    // 自动触发刷新额度\n    let _ = internal_refresh_account_quota(&app, &mut account).await;\n\n    // 刷新托盘图标展示\n    crate::modules::tray::update_tray_menus(&app);\n\n    // Reload token pool\n    let _ = crate::commands::proxy::reload_proxy_accounts(proxy_state).await;\n\n    Ok(account)\n}\n\n#[tauri::command]\npub async fn sync_account_from_db(\n    app: tauri::AppHandle,\n    proxy_state: tauri::State<'_, crate::commands::proxy::ProxyServiceState>,\n) -> Result<Option<Account>, String> {\n    // 1. 获取 DB 中的 Refresh Token\n    let db_refresh_token = match modules::migration::get_refresh_token_from_db() {\n        Ok(token) => token,\n        Err(e) => {\n            modules::logger::log_info(&format!(\"自动同步跳过: {}\", e));\n            return Ok(None);\n        }\n    };\n\n    // 2. 获取 Manager 当前账号\n    let curr_account = modules::account::get_current_account()?;\n\n    // 3. 对比：如果 Refresh Token 相同，说明账号没变，无需导入\n    if let Some(acc) = curr_account {\n        if acc.token.refresh_token == db_refresh_token {\n            // 账号未变，由于已经是周期性任务，我们可以选择性刷新一下配额，或者直接返回\n            // 这里为了节省 API 流量，直接返回\n            return Ok(None);\n        }\n        modules::logger::log_info(&format!(\n            \"检测到账号切换 ({} -> DB新账号)，正在同步...\",\n            acc.email\n        ));\n    } else {\n        modules::logger::log_info(\"检测到新登录账号，正在自动同步...\");\n    }\n\n    // 4. 执行完整导入\n    let account = import_from_db(app, proxy_state).await?;\n    Ok(Some(account))\n}\n\nfn validate_path(path: &str) -> Result<(), String> {\n    if path.contains(\"..\") {\n        return Err(\"非法路径: 不允许目录遍历\".to_string());\n    }\n\n    // 检查是否指向系统敏感路径 (基础黑名单)\n    let lower_path = path.to_lowercase();\n    let sensitive_prefixes = [\n        \"/etc/\",\n        \"/var/spool/cron\",\n        \"/root/\",\n        \"/proc/\",\n        \"/sys/\",\n        \"/dev/\",\n        \"c:\\\\windows\",\n        \"c:\\\\users\\\\administrator\",\n        \"c:\\\\pagefile.sys\",\n    ];\n\n    for prefix in sensitive_prefixes {\n        if lower_path.starts_with(prefix) {\n            return Err(format!(\"安全拒绝: 禁止访问系统敏感路径 ({})\", prefix));\n        }\n    }\n\n    Ok(())\n}\n\n/// 保存文本文件 (绕过前端 Scope 限制)\n#[tauri::command]\npub async fn save_text_file(path: String, content: String) -> Result<(), String> {\n    validate_path(&path)?;\n    std::fs::write(&path, content).map_err(|e| format!(\"写入文件失败: {}\", e))\n}\n\n/// 读取文本文件 (绕过前端 Scope 限制)\n#[tauri::command]\npub async fn read_text_file(path: String) -> Result<String, String> {\n    validate_path(&path)?;\n    std::fs::read_to_string(&path).map_err(|e| format!(\"读取文件失败: {}\", e))\n}\n\n/// 清理日志缓存\n#[tauri::command]\npub async fn clear_log_cache() -> Result<(), String> {\n    modules::logger::clear_logs()\n}\n\n/// 清理 Antigravity 应用缓存\n/// 用于解决登录失败、版本验证错误等问题\n#[tauri::command]\npub async fn clear_antigravity_cache() -> Result<modules::cache::ClearResult, String> {\n    modules::cache::clear_antigravity_cache(None)\n}\n\n/// 获取 Antigravity 缓存路径列表（用于预览）\n#[tauri::command]\npub async fn get_antigravity_cache_paths() -> Result<Vec<String>, String> {\n    Ok(modules::cache::get_existing_cache_paths()\n        .into_iter()\n        .map(|p| p.to_string_lossy().to_string())\n        .collect())\n}\n\n/// 打开数据目录\n#[tauri::command]\npub async fn open_data_folder() -> Result<(), String> {\n    let path = modules::account::get_data_dir()?;\n\n    #[cfg(target_os = \"macos\")]\n    {\n        std::process::Command::new(\"open\")\n            .arg(path)\n            .spawn()\n            .map_err(|e| format!(\"打开文件夹失败: {}\", e))?;\n    }\n\n    #[cfg(target_os = \"windows\")]\n    {\n        use crate::utils::command::CommandExtWrapper;\n        std::process::Command::new(\"explorer\")\n            .creation_flags_windows()\n            .arg(path)\n            .spawn()\n            .map_err(|e| format!(\"打开文件夹失败: {}\", e))?;\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        std::process::Command::new(\"xdg-open\")\n            .arg(path)\n            .spawn()\n            .map_err(|e| format!(\"打开文件夹失败: {}\", e))?;\n    }\n\n    Ok(())\n}\n\n/// 获取数据目录绝对路径\n#[tauri::command]\npub async fn get_data_dir_path() -> Result<String, String> {\n    let path = modules::account::get_data_dir()?;\n    Ok(path.to_string_lossy().to_string())\n}\n\n/// 显示主窗口\n#[tauri::command]\npub async fn show_main_window(window: tauri::Window) -> Result<(), String> {\n    window.show().map_err(|e| e.to_string())\n}\n\n/// 设置窗口主题（用于同步 Windows 标题栏按钮颜色）\n#[tauri::command]\npub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<(), String> {\n    use tauri::Theme;\n\n    let tauri_theme = match theme.as_str() {\n        \"dark\" => Some(Theme::Dark),\n        \"light\" => Some(Theme::Light),\n        _ => None, // system default\n    };\n\n    window.set_theme(tauri_theme).map_err(|e| e.to_string())\n}\n\n/// 获取 Antigravity 可执行文件路径\n#[tauri::command]\npub async fn get_antigravity_path(bypass_config: Option<bool>) -> Result<String, String> {\n    // 1. 优先从配置查询 (除非明确要求绕过)\n    if bypass_config != Some(true) {\n        if let Ok(config) = crate::modules::config::load_app_config() {\n            if let Some(path) = config.antigravity_executable {\n                if std::path::Path::new(&path).exists() {\n                    return Ok(path);\n                }\n            }\n        }\n    }\n\n    // 2. 执行实时探测\n    match crate::modules::process::get_antigravity_executable_path() {\n        Some(path) => Ok(path.to_string_lossy().to_string()),\n        None => Err(\"未找到 Antigravity 安装路径\".to_string()),\n    }\n}\n\n/// 获取 Antigravity 启动参数\n#[tauri::command]\npub async fn get_antigravity_args() -> Result<Vec<String>, String> {\n    match crate::modules::process::get_args_from_running_process() {\n        Some(args) => Ok(args),\n        None => Err(\"未找到正在运行的 Antigravity 进程\".to_string()),\n    }\n}\n\n/// 检测更新响应结构\npub use crate::modules::update_checker::UpdateInfo;\n\n/// 检测 GitHub releases 更新\n#[tauri::command]\npub async fn check_for_updates() -> Result<UpdateInfo, String> {\n    modules::logger::log_info(\"收到前端触发的更新检查请求\");\n    crate::modules::update_checker::check_for_updates().await\n}\n\n#[tauri::command]\npub async fn should_check_updates() -> Result<bool, String> {\n    let settings = crate::modules::update_checker::load_update_settings()?;\n    Ok(crate::modules::update_checker::should_check_for_updates(\n        &settings,\n    ))\n}\n\n#[tauri::command]\npub async fn update_last_check_time() -> Result<(), String> {\n    crate::modules::update_checker::update_last_check_time()\n}\n\n\n/// 检测是否通过 Homebrew Cask 安装\n#[tauri::command]\npub async fn check_homebrew_installation() -> Result<bool, String> {\n    Ok(crate::modules::update_checker::is_homebrew_installed())\n}\n\n/// 通过 Homebrew Cask 升级应用\n#[tauri::command]\npub async fn brew_upgrade_cask() -> Result<String, String> {\n    modules::logger::log_info(\"收到前端触发的 Homebrew 升级请求\");\n    crate::modules::update_checker::brew_upgrade_cask().await\n}\n\n\n/// 获取更新设置\n#[tauri::command]\npub async fn get_update_settings() -> Result<crate::modules::update_checker::UpdateSettings, String>\n{\n    crate::modules::update_checker::load_update_settings()\n}\n\n/// 保存更新设置\n#[tauri::command]\npub async fn save_update_settings(\n    settings: crate::modules::update_checker::UpdateSettings,\n) -> Result<(), String> {\n    crate::modules::update_checker::save_update_settings(&settings)\n}\n\n/// 切换账号的反代禁用状态\n#[tauri::command]\npub async fn toggle_proxy_status(\n    app: tauri::AppHandle,\n    proxy_state: tauri::State<'_, crate::commands::proxy::ProxyServiceState>,\n    account_id: String,\n    enable: bool,\n    reason: Option<String>,\n) -> Result<(), String> {\n    modules::logger::log_info(&format!(\n        \"切换账号反代状态: {} -> {}\",\n        account_id,\n        if enable { \"启用\" } else { \"禁用\" }\n    ));\n\n    // 1. 读取账号文件\n    let data_dir = modules::account::get_data_dir()?;\n    let account_path = data_dir\n        .join(\"accounts\")\n        .join(format!(\"{}.json\", account_id));\n\n    if !account_path.exists() {\n        return Err(format!(\"账号文件不存在: {}\", account_id));\n    }\n\n    let content =\n        std::fs::read_to_string(&account_path).map_err(|e| format!(\"读取账号文件失败: {}\", e))?;\n\n    let mut account_json: serde_json::Value =\n        serde_json::from_str(&content).map_err(|e| format!(\"解析账号文件失败: {}\", e))?;\n\n    // 2. 更新 proxy_disabled 字段\n    if enable {\n        // 启用反代\n        account_json[\"proxy_disabled\"] = serde_json::Value::Bool(false);\n        account_json[\"proxy_disabled_reason\"] = serde_json::Value::Null;\n        account_json[\"proxy_disabled_at\"] = serde_json::Value::Null;\n    } else {\n        // 禁用反代\n        let now = chrono::Utc::now().timestamp();\n        account_json[\"proxy_disabled\"] = serde_json::Value::Bool(true);\n        account_json[\"proxy_disabled_at\"] = serde_json::Value::Number(now.into());\n        account_json[\"proxy_disabled_reason\"] =\n            serde_json::Value::String(reason.unwrap_or_else(|| \"用户手动禁用\".to_string()));\n    }\n\n    // 3. 保存到磁盘\n    let json_str = serde_json::to_string_pretty(&account_json)\n        .map_err(|e| format!(\"序列化账号数据失败: {}\", e))?;\n    std::fs::write(&account_path, json_str).map_err(|e| format!(\"写入账号文件失败: {}\", e))?;\n\n    modules::logger::log_info(&format!(\n        \"账号反代状态已更新: {} ({})\",\n        account_id,\n        if enable { \"已启用\" } else { \"已禁用\" }\n    ));\n\n    // 4. 如果反代服务正在运行,立刻同步到内存池（避免禁用后仍被选中）\n    {\n        let instance_lock = proxy_state.instance.read().await;\n        if let Some(instance) = instance_lock.as_ref() {\n            // 如果禁用的是当前固定账号，则自动关闭固定模式（内存 + 配置持久化）\n            if !enable {\n                let pref_id = instance.token_manager.get_preferred_account().await;\n                if pref_id.as_deref() == Some(&account_id) {\n                    instance.token_manager.set_preferred_account(None).await;\n\n                    if let Ok(mut cfg) = crate::modules::config::load_app_config() {\n                        if cfg.proxy.preferred_account_id.as_deref() == Some(&account_id) {\n                            cfg.proxy.preferred_account_id = None;\n                            let _ = crate::modules::config::save_app_config(&cfg);\n                        }\n                    }\n                }\n            }\n\n            instance\n                .token_manager\n                .reload_account(&account_id)\n                .await\n                .map_err(|e| format!(\"同步账号失败: {}\", e))?;\n        }\n    }\n\n    // 5. 更新托盘菜单\n    crate::modules::tray::update_tray_menus(&app);\n\n    Ok(())\n}\n\n/// 预热所有可用账号\n#[tauri::command]\npub async fn warm_up_all_accounts() -> Result<String, String> {\n    modules::quota::warm_up_all_accounts().await\n}\n\n/// 预热指定账号\n#[tauri::command]\npub async fn warm_up_account(account_id: String) -> Result<String, String> {\n    modules::quota::warm_up_account(&account_id).await\n}\n\n/// 更新账号自定义标签\n#[tauri::command]\npub async fn update_account_label(account_id: String, label: String) -> Result<(), String> {\n    // 验证标签长度（按字符数计算，支持中文）\n    if label.chars().count() > 15 {\n        return Err(\"标签长度不能超过15个字符\".to_string());\n    }\n\n    modules::logger::log_info(&format!(\n        \"更新账号标签: {} -> {:?}\",\n        account_id,\n        if label.is_empty() { \"无\" } else { &label }\n    ));\n\n    // 1. 读取账号文件\n    let data_dir = modules::account::get_data_dir()?;\n    let account_path = data_dir\n        .join(\"accounts\")\n        .join(format!(\"{}.json\", account_id));\n\n    if !account_path.exists() {\n        return Err(format!(\"账号文件不存在: {}\", account_id));\n    }\n\n    let content =\n        std::fs::read_to_string(&account_path).map_err(|e| format!(\"读取账号文件失败: {}\", e))?;\n\n    let mut account_json: serde_json::Value =\n        serde_json::from_str(&content).map_err(|e| format!(\"解析账号文件失败: {}\", e))?;\n\n    // 2. 更新 custom_label 字段\n    if label.is_empty() {\n        account_json[\"custom_label\"] = serde_json::Value::Null;\n    } else {\n        account_json[\"custom_label\"] = serde_json::Value::String(label.clone());\n    }\n\n    // 3. 保存到磁盘\n    let json_str = serde_json::to_string_pretty(&account_json)\n        .map_err(|e| format!(\"序列化账号数据失败: {}\", e))?;\n    std::fs::write(&account_path, json_str).map_err(|e| format!(\"写入账号文件失败: {}\", e))?;\n\n    modules::logger::log_info(&format!(\n        \"账号标签已更新: {} ({})\",\n        account_id,\n        if label.is_empty() {\n            \"已清除\".to_string()\n        } else {\n            label\n        }\n    ));\n\n    Ok(())\n}\n\n// ============================================================================\n// HTTP API 设置命令\n// ============================================================================\n\n/// 获取 HTTP API 设置\n#[tauri::command]\npub async fn get_http_api_settings() -> Result<crate::modules::http_api::HttpApiSettings, String> {\n    crate::modules::http_api::load_settings()\n}\n\n/// 保存 HTTP API 设置\n#[tauri::command]\npub async fn save_http_api_settings(\n    settings: crate::modules::http_api::HttpApiSettings,\n) -> Result<(), String> {\n    crate::modules::http_api::save_settings(&settings)\n}\n\n// ============================================================================\n// Token Statistics Commands\n// ============================================================================\n\npub use crate::modules::token_stats::{AccountTokenStats, TokenStatsAggregated, TokenStatsSummary};\n\n#[tauri::command]\npub async fn get_token_stats_hourly(hours: i64) -> Result<Vec<TokenStatsAggregated>, String> {\n    crate::modules::token_stats::get_hourly_stats(hours)\n}\n\n#[tauri::command]\npub async fn get_token_stats_daily(days: i64) -> Result<Vec<TokenStatsAggregated>, String> {\n    crate::modules::token_stats::get_daily_stats(days)\n}\n\n#[tauri::command]\npub async fn get_token_stats_weekly(weeks: i64) -> Result<Vec<TokenStatsAggregated>, String> {\n    crate::modules::token_stats::get_weekly_stats(weeks)\n}\n\n#[tauri::command]\npub async fn get_token_stats_by_account(hours: i64) -> Result<Vec<AccountTokenStats>, String> {\n    crate::modules::token_stats::get_account_stats(hours)\n}\n\n#[tauri::command]\npub async fn get_token_stats_summary(hours: i64) -> Result<TokenStatsSummary, String> {\n    crate::modules::token_stats::get_summary_stats(hours)\n}\n\n#[tauri::command]\npub async fn get_token_stats_by_model(\n    hours: i64,\n) -> Result<Vec<crate::modules::token_stats::ModelTokenStats>, String> {\n    crate::modules::token_stats::get_model_stats(hours)\n}\n\n#[tauri::command]\npub async fn get_token_stats_model_trend_hourly(\n    hours: i64,\n) -> Result<Vec<crate::modules::token_stats::ModelTrendPoint>, String> {\n    crate::modules::token_stats::get_model_trend_hourly(hours)\n}\n\n#[tauri::command]\npub async fn get_token_stats_model_trend_daily(\n    days: i64,\n) -> Result<Vec<crate::modules::token_stats::ModelTrendPoint>, String> {\n    crate::modules::token_stats::get_model_trend_daily(days)\n}\n\n#[tauri::command]\npub async fn get_token_stats_account_trend_hourly(\n    hours: i64,\n) -> Result<Vec<crate::modules::token_stats::AccountTrendPoint>, String> {\n    crate::modules::token_stats::get_account_trend_hourly(hours)\n}\n\n#[tauri::command]\npub async fn get_token_stats_account_trend_daily(\n    days: i64,\n) -> Result<Vec<crate::modules::token_stats::AccountTrendPoint>, String> {\n    crate::modules::token_stats::get_account_trend_daily(days)\n}\n"
  },
  {
    "path": "src-tauri/src/commands/proxy.rs",
    "content": "use crate::proxy::monitor::{ProxyMonitor, ProxyRequestLog, ProxyStats};\nuse crate::proxy::{ProxyConfig, ProxyPoolConfig, TokenManager};\nuse serde::{Deserialize, Serialize};\nuse std::sync::atomic::{AtomicBool, Ordering};\nuse std::sync::Arc;\nuse tauri::State;\nuse tokio::sync::RwLock;\nuse tokio::time::Duration;\n\n/// 反代服务状态\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProxyStatus {\n    pub running: bool,\n    pub port: u16,\n    pub base_url: String,\n    pub active_accounts: usize,\n}\n\n/// 反代服务全局状态\n#[derive(Clone)]\npub struct ProxyServiceState {\n    pub instance: Arc<RwLock<Option<ProxyServiceInstance>>>,\n    pub monitor: Arc<RwLock<Option<Arc<ProxyMonitor>>>>,\n    pub admin_server: Arc<RwLock<Option<AdminServerInstance>>>, // [NEW] 常驻管理服务器\n    pub starting: Arc<AtomicBool>, // [NEW] 标识是否正在启动中，防止死锁\n}\n\npub struct AdminServerInstance {\n    pub axum_server: crate::proxy::AxumServer,\n    #[allow(dead_code)] // 保留句柄以便未来支持显式停服/诊断\n    pub server_handle: tokio::task::JoinHandle<()>,\n}\n\n/// 反代服务实例\npub struct ProxyServiceInstance {\n    pub config: ProxyConfig,\n    pub token_manager: Arc<TokenManager>,\n    pub axum_server: crate::proxy::AxumServer,\n    #[allow(dead_code)] // 保留句柄以便未来支持显式停服/诊断\n    pub server_handle: tokio::task::JoinHandle<()>,\n}\n\nimpl ProxyServiceState {\n    pub fn new() -> Self {\n        Self {\n            instance: Arc::new(RwLock::new(None)),\n            monitor: Arc::new(RwLock::new(None)),\n            admin_server: Arc::new(RwLock::new(None)),\n            starting: Arc::new(AtomicBool::new(false)),\n        }\n    }\n}\n\n/// 启动反代服务 (Tauri 命令)\n#[tauri::command]\npub async fn start_proxy_service(\n    config: ProxyConfig,\n    state: State<'_, ProxyServiceState>,\n    cf_state: State<'_, crate::commands::cloudflared::CloudflaredState>,\n    app_handle: tauri::AppHandle,\n) -> Result<ProxyStatus, String> {\n    internal_start_proxy_service(\n        config,\n        &state,\n        crate::modules::integration::SystemManager::Desktop(app_handle),\n        Arc::new(cf_state.inner().clone()),\n    )\n    .await\n}\n\nstruct StartingGuard(Arc<AtomicBool>);\nimpl Drop for StartingGuard {\n    fn drop(&mut self) {\n        self.0.store(false, Ordering::SeqCst);\n    }\n}\n\n/// 内部启动反代服务逻辑 (解耦版本)\npub async fn internal_start_proxy_service(\n    config: ProxyConfig,\n    state: &ProxyServiceState,\n    integration: crate::modules::integration::SystemManager,\n    cloudflared_state: Arc<crate::commands::cloudflared::CloudflaredState>,\n) -> Result<ProxyStatus, String> {\n    // 1. 检查状态并加锁\n    {\n        let instance_lock = state.instance.read().await;\n        if instance_lock.is_some() {\n            return Err(\"服务已在运行中\".to_string());\n        }\n    }\n\n    // 2. 检查是否正在启动中 (防止死锁 & 并发启动)\n    if state\n        .starting\n        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)\n        .is_err()\n    {\n        return Err(\"服务正在启动中，请稍候...\".to_string());\n    }\n\n    // 使用自定义 Drop guard 确保无论成功失败都会重置 starting 状态\n    let _starting_guard = StartingGuard(state.starting.clone());\n\n    // Ensure monitor exists\n    {\n        let mut monitor_lock = state.monitor.write().await;\n        if monitor_lock.is_none() {\n            let app_handle =\n                if let crate::modules::integration::SystemManager::Desktop(ref h) = integration {\n                    Some(h.clone())\n                } else {\n                    None\n                };\n            *monitor_lock = Some(Arc::new(ProxyMonitor::new(1000, app_handle)));\n        }\n        // Sync enabled state from config\n        if let Some(monitor) = monitor_lock.as_ref() {\n            monitor.set_enabled(config.enable_logging);\n        }\n    }\n\n    let _monitor = state.monitor.read().await.as_ref().unwrap().clone();\n\n    // 檢查並啟動管理服務器（如果尚未運行）\n    ensure_admin_server(\n        config.clone(),\n        state,\n        integration.clone(),\n        cloudflared_state.clone(),\n    )\n    .await?;\n\n    // 2. [FIX] 复用管理服务器的 Token 管理器 (单实例，解决热更新同步问题)\n    let token_manager = {\n        let admin_lock = state.admin_server.read().await;\n        admin_lock\n            .as_ref()\n            .unwrap()\n            .axum_server\n            .token_manager\n            .clone()\n    };\n\n    // 同步配置到运行中的 TokenManager\n    token_manager.start_auto_cleanup().await;\n    token_manager\n        .update_sticky_config(config.scheduling.clone())\n        .await;\n\n    // [NEW] 加载熔断配置 (从主配置加载)\n    let app_config = crate::modules::config::load_app_config()\n        .unwrap_or_else(|_| crate::models::AppConfig::new());\n    token_manager\n        .update_circuit_breaker_config(app_config.circuit_breaker)\n        .await;\n\n    // 🆕 [FIX #820] 恢复固定账号模式设置\n    if let Some(ref account_id) = config.preferred_account_id {\n        token_manager\n            .set_preferred_account(Some(account_id.clone()))\n            .await;\n        tracing::info!(\"🔒 [FIX #820] Fixed account mode restored: {}\", account_id);\n    }\n\n    // 3. 加載賬號\n    let active_accounts = token_manager.load_accounts().await.unwrap_or(0);\n\n    if active_accounts == 0 {\n        let zai_enabled = config.zai.enabled\n            && !matches!(config.zai.dispatch_mode, crate::proxy::ZaiDispatchMode::Off);\n        if !zai_enabled {\n            tracing::warn!(\"沒有可用賬號，反代邏輯將暫停，請通過管理界面添加。\");\n            return Ok(ProxyStatus {\n                running: false,\n                port: config.port,\n                base_url: format!(\"http://127.0.0.1:{}\", config.port),\n                active_accounts: 0,\n            });\n        }\n    }\n\n    let mut instance_lock = state.instance.write().await;\n    let admin_lock = state.admin_server.read().await;\n    let axum_server = admin_lock.as_ref().unwrap().axum_server.clone();\n\n    // 创建服务实例（逻辑启动）\n    let instance = ProxyServiceInstance {\n        config: config.clone(),\n        token_manager: token_manager.clone(),\n        axum_server: axum_server.clone(),\n        server_handle: tokio::spawn(async {}), // 逻辑上的 handle\n    };\n\n    // [FIX] Ensure the server is logically running\n    axum_server.set_running(true).await;\n\n    *instance_lock = Some(instance);\n\n    // 成功启动后，guard 在这里结束并重置 starting 是 OK 的\n    // 但其实我们可以直接手动掉，或者相信 guard\n    Ok(ProxyStatus {\n        running: true,\n        port: config.port,\n        base_url: format!(\"http://127.0.0.1:{}\", config.port),\n        active_accounts,\n    })\n}\n\n/// 确保管理服务器正在运行\npub async fn ensure_admin_server(\n    config: ProxyConfig,\n    state: &ProxyServiceState,\n    integration: crate::modules::integration::SystemManager,\n    cloudflared_state: Arc<crate::commands::cloudflared::CloudflaredState>,\n) -> Result<(), String> {\n    let mut admin_lock = state.admin_server.write().await;\n    if admin_lock.is_some() {\n        return Ok(());\n    }\n\n    // Ensure monitor exists\n    let monitor = {\n        let mut monitor_lock = state.monitor.write().await;\n        if monitor_lock.is_none() {\n            let app_handle =\n                if let crate::modules::integration::SystemManager::Desktop(ref h) = integration {\n                    Some(h.clone())\n                } else {\n                    None\n                };\n            *monitor_lock = Some(Arc::new(ProxyMonitor::new(1000, app_handle)));\n        }\n        monitor_lock.as_ref().unwrap().clone()\n    };\n\n    // 默认空 TokenManager 用于管理界面\n    let app_data_dir = crate::modules::account::get_data_dir()?;\n    let token_manager = Arc::new(TokenManager::new(app_data_dir));\n    // [NEW] 加载账号数据，否则管理界面统计为 0\n    let _ = token_manager.load_accounts().await;\n\n    let (axum_server, server_handle) = match crate::proxy::AxumServer::start(\n        config.get_bind_address().to_string(),\n        config.port,\n        token_manager,\n        config.custom_mapping.clone(),\n        config.request_timeout,\n        config.upstream_proxy.clone(),\n        config.user_agent_override.clone(),\n        crate::proxy::ProxySecurityConfig::from_proxy_config(&config),\n        config.zai.clone(),\n        monitor,\n        config.experimental.clone(),\n        config.debug_logging.clone(),\n        integration.clone(),\n        cloudflared_state,\n        config.proxy_pool.clone(),\n    )\n    .await\n    {\n        Ok((server, handle)) => (server, handle),\n        Err(e) => return Err(format!(\"启动管理服务器失败: {}\", e)),\n    };\n\n    *admin_lock = Some(AdminServerInstance {\n        axum_server,\n        server_handle,\n    });\n\n    // [NEW] 初始化全局 Thinking Budget 配置\n    crate::proxy::update_thinking_budget_config(config.thinking_budget.clone());\n    // [NEW] 初始化全局系统提示词配置\n    crate::proxy::update_global_system_prompt_config(config.global_system_prompt.clone());\n    // [NEW] 初始化全局图像思维模式配置\n    crate::proxy::update_image_thinking_mode(config.image_thinking_mode.clone());\n\n    Ok(())\n}\n\n/// 停止反代服务\n#[tauri::command]\npub async fn stop_proxy_service(state: State<'_, ProxyServiceState>) -> Result<(), String> {\n    let mut instance_lock = state.instance.write().await;\n\n    if instance_lock.is_none() {\n        return Err(\"服务未运行\".to_string());\n    }\n\n    // 停止 Axum 服务器 (仅逻辑停止，不杀死进程)\n    if let Some(instance) = instance_lock.take() {\n        instance.token_manager.abort_background_tasks().await;\n        instance.axum_server.set_running(false).await;\n        // 已移除 instance.axum_server.stop() 调用，防止杀死 Admin Server\n    }\n\n    Ok(())\n}\n\n/// 获取反代服务状态\n#[tauri::command]\npub async fn get_proxy_status(state: State<'_, ProxyServiceState>) -> Result<ProxyStatus, String> {\n    // 优先检查启动标志，避免被写锁阻塞\n    if state.starting.load(Ordering::SeqCst) {\n        return Ok(ProxyStatus {\n            running: false, // 逻辑上还没运行\n            port: 0,\n            base_url: \"starting\".to_string(), // 给前端标识\n            active_accounts: 0,\n        });\n    }\n\n    // 使用 try_read 避免在该命令中产生产生排队延迟\n    let lock_res = state.instance.try_read();\n\n    match lock_res {\n        Ok(instance_lock) => match instance_lock.as_ref() {\n            Some(instance) => Ok(ProxyStatus {\n                running: true,\n                port: instance.config.port,\n                base_url: format!(\"http://127.0.0.1:{}\", instance.config.port),\n                active_accounts: instance.token_manager.len(),\n            }),\n            None => Ok(ProxyStatus {\n                running: false,\n                port: 0,\n                base_url: String::new(),\n                active_accounts: 0,\n            }),\n        },\n        Err(_) => {\n            // 如果拿不到锁，说明正在进行写操作（可能是正在启动或停止中）\n            Ok(ProxyStatus {\n                running: false,\n                port: 0,\n                base_url: \"busy\".to_string(),\n                active_accounts: 0,\n            })\n        }\n    }\n}\n\n/// 获取反代服务统计\n#[tauri::command]\npub async fn get_proxy_stats(state: State<'_, ProxyServiceState>) -> Result<ProxyStats, String> {\n    let monitor_lock = state.monitor.read().await;\n    if let Some(monitor) = monitor_lock.as_ref() {\n        Ok(monitor.get_stats().await)\n    } else {\n        Ok(ProxyStats::default())\n    }\n}\n\n/// 获取反代请求日志\n#[tauri::command]\npub async fn get_proxy_logs(\n    state: State<'_, ProxyServiceState>,\n    limit: Option<usize>,\n) -> Result<Vec<ProxyRequestLog>, String> {\n    let monitor_lock = state.monitor.read().await;\n    if let Some(monitor) = monitor_lock.as_ref() {\n        Ok(monitor.get_logs(limit.unwrap_or(100)).await)\n    } else {\n        Ok(Vec::new())\n    }\n}\n\n/// 设置监控开启状态\n#[tauri::command]\npub async fn set_proxy_monitor_enabled(\n    state: State<'_, ProxyServiceState>,\n    enabled: bool,\n) -> Result<(), String> {\n    let monitor_lock = state.monitor.read().await;\n    if let Some(monitor) = monitor_lock.as_ref() {\n        monitor.set_enabled(enabled);\n    }\n    Ok(())\n}\n\n/// 清除反代请求日志\n#[tauri::command]\npub async fn clear_proxy_logs(state: State<'_, ProxyServiceState>) -> Result<(), String> {\n    let monitor_lock = state.monitor.read().await;\n    if let Some(monitor) = monitor_lock.as_ref() {\n        monitor.clear().await;\n    }\n    Ok(())\n}\n\n/// 获取反代请求日志 (分页)\n#[tauri::command]\npub async fn get_proxy_logs_paginated(\n    limit: Option<usize>,\n    offset: Option<usize>,\n) -> Result<Vec<ProxyRequestLog>, String> {\n    crate::modules::proxy_db::get_logs_summary(limit.unwrap_or(20), offset.unwrap_or(0))\n}\n\n/// 获取单条日志的完整详情\n#[tauri::command]\npub async fn get_proxy_log_detail(log_id: String) -> Result<ProxyRequestLog, String> {\n    crate::modules::proxy_db::get_log_detail(&log_id)\n}\n\n/// 获取日志总数\n#[tauri::command]\npub async fn get_proxy_logs_count() -> Result<u64, String> {\n    crate::modules::proxy_db::get_logs_count()\n}\n\n/// 导出所有日志到指定文件\n#[tauri::command]\npub async fn export_proxy_logs(file_path: String) -> Result<usize, String> {\n    let logs = crate::modules::proxy_db::get_all_logs_for_export()?;\n    let count = logs.len();\n\n    let json = serde_json::to_string_pretty(&logs)\n        .map_err(|e| format!(\"Failed to serialize logs: {}\", e))?;\n\n    std::fs::write(&file_path, json).map_err(|e| format!(\"Failed to write file: {}\", e))?;\n\n    Ok(count)\n}\n\n/// 导出指定的日志JSON到文件\n#[tauri::command]\npub async fn export_proxy_logs_json(file_path: String, json_data: String) -> Result<usize, String> {\n    // Parse to count items\n    let logs: Vec<serde_json::Value> =\n        serde_json::from_str(&json_data).map_err(|e| format!(\"Failed to parse JSON: {}\", e))?;\n    let count = logs.len();\n\n    // Pretty print\n    let pretty_json =\n        serde_json::to_string_pretty(&logs).map_err(|e| format!(\"Failed to serialize: {}\", e))?;\n\n    std::fs::write(&file_path, pretty_json).map_err(|e| format!(\"Failed to write file: {}\", e))?;\n\n    Ok(count)\n}\n\n/// 获取带搜索条件的日志数量\n#[tauri::command]\npub async fn get_proxy_logs_count_filtered(\n    filter: String,\n    errors_only: bool,\n) -> Result<u64, String> {\n    crate::modules::proxy_db::get_logs_count_filtered(&filter, errors_only)\n}\n\n/// 获取带搜索条件的分页日志\n#[tauri::command]\npub async fn get_proxy_logs_filtered(\n    filter: String,\n    errors_only: bool,\n    limit: usize,\n    offset: usize,\n) -> Result<Vec<crate::proxy::monitor::ProxyRequestLog>, String> {\n    crate::modules::proxy_db::get_logs_filtered(&filter, errors_only, limit, offset)\n}\n\n/// 生成 API Key\n#[tauri::command]\npub fn generate_api_key() -> String {\n    format!(\"sk-{}\", uuid::Uuid::new_v4().simple())\n}\n\n/// 重新加载账号（当主应用添加/删除账号时调用）\n#[tauri::command]\npub async fn reload_proxy_accounts(state: State<'_, ProxyServiceState>) -> Result<usize, String> {\n    let instance_lock = state.instance.read().await;\n\n    if let Some(instance) = instance_lock.as_ref() {\n        // [FIX #820] Clear stale session bindings before reloading accounts\n        // This ensures that after switching accounts in the UI, API requests\n        // won't be routed to the previously bound (wrong) account\n        instance.token_manager.clear_all_sessions();\n\n        // 重新加载账号\n        let count = instance\n            .token_manager\n            .load_accounts()\n            .await\n            .map_err(|e| format!(\"重新加载账号失败: {}\", e))?;\n        Ok(count)\n    } else {\n        Err(\"服务未运行\".to_string())\n    }\n}\n\n/// 更新模型映射表 (热更新)\n#[tauri::command]\npub async fn update_model_mapping(\n    config: ProxyConfig,\n    state: State<'_, ProxyServiceState>,\n) -> Result<(), String> {\n    let instance_lock = state.instance.read().await;\n\n    // 1. 如果服务正在运行，立即更新内存中的映射 (这里目前只更新了 anthropic_mapping 的 RwLock,\n    // 后续可以根据需要让 resolve_model_route 直接读取全量 config)\n    if let Some(instance) = instance_lock.as_ref() {\n        instance.axum_server.update_mapping(&config).await;\n        tracing::debug!(\"后端服务已接收全量模型映射配置\");\n    }\n\n    // 2. 无论是否运行，都保存到全局配置持久化\n    let mut app_config = crate::modules::config::load_app_config().map_err(|e| e)?;\n    app_config.proxy.custom_mapping = config.custom_mapping;\n    crate::modules::config::save_app_config(&app_config).map_err(|e| e)?;\n\n    Ok(())\n}\n\nfn join_base_url(base: &str, path: &str) -> String {\n    let base = base.trim_end_matches('/');\n    let path = if path.starts_with('/') {\n        path.to_string()\n    } else {\n        format!(\"/{}\", path)\n    };\n    format!(\"{}{}\", base, path)\n}\n\nfn extract_model_ids(value: &serde_json::Value) -> Vec<String> {\n    let mut out = Vec::new();\n\n    fn push_from_item(out: &mut Vec<String>, item: &serde_json::Value) {\n        match item {\n            serde_json::Value::String(s) => out.push(s.to_string()),\n            serde_json::Value::Object(map) => {\n                if let Some(id) = map.get(\"id\").and_then(|v| v.as_str()) {\n                    out.push(id.to_string());\n                } else if let Some(name) = map.get(\"name\").and_then(|v| v.as_str()) {\n                    out.push(name.to_string());\n                }\n            }\n            _ => {}\n        }\n    }\n\n    match value {\n        serde_json::Value::Array(arr) => {\n            for item in arr {\n                push_from_item(&mut out, item);\n            }\n        }\n        serde_json::Value::Object(map) => {\n            if let Some(data) = map.get(\"data\") {\n                if let serde_json::Value::Array(arr) = data {\n                    for item in arr {\n                        push_from_item(&mut out, item);\n                    }\n                }\n            }\n            if let Some(models) = map.get(\"models\") {\n                match models {\n                    serde_json::Value::Array(arr) => {\n                        for item in arr {\n                            push_from_item(&mut out, item);\n                        }\n                    }\n                    other => push_from_item(&mut out, other),\n                }\n            }\n        }\n        _ => {}\n    }\n\n    out\n}\n\n/// Fetch available models from the configured z.ai Anthropic-compatible API (`/v1/models`).\n#[tauri::command]\npub async fn fetch_zai_models(\n    zai: crate::proxy::ZaiConfig,\n    upstream_proxy: crate::proxy::config::UpstreamProxyConfig,\n    request_timeout: u64,\n) -> Result<Vec<String>, String> {\n    if zai.base_url.trim().is_empty() {\n        return Err(\"z.ai base_url is empty\".to_string());\n    }\n    if zai.api_key.trim().is_empty() {\n        return Err(\"z.ai api_key is not set\".to_string());\n    }\n\n    let url = join_base_url(&zai.base_url, \"/v1/models\");\n\n    let mut builder =\n        reqwest::Client::builder().timeout(Duration::from_secs(request_timeout.max(5)));\n    if upstream_proxy.enabled && !upstream_proxy.url.is_empty() {\n        let proxy = reqwest::Proxy::all(&upstream_proxy.url)\n            .map_err(|e| format!(\"Invalid upstream proxy url: {}\", e))?;\n        builder = builder.proxy(proxy);\n    }\n    let client = builder\n        .build()\n        .map_err(|e| format!(\"Failed to build HTTP client: {}\", e))?;\n\n    let resp = client\n        .get(&url)\n        .header(\"Authorization\", format!(\"Bearer {}\", zai.api_key))\n        .header(\"x-api-key\", zai.api_key)\n        .header(\"anthropic-version\", \"2023-06-01\")\n        .header(\"accept\", \"application/json\")\n        .send()\n        .await\n        .map_err(|e| format!(\"Upstream request failed: {}\", e))?;\n\n    let status = resp.status();\n    let text = resp\n        .text()\n        .await\n        .map_err(|e| format!(\"Failed to read response: {}\", e))?;\n\n    if !status.is_success() {\n        let preview = if text.len() > 4000 {\n            &text[..4000]\n        } else {\n            &text\n        };\n        return Err(format!(\"Upstream returned {}: {}\", status, preview));\n    }\n\n    let json: serde_json::Value =\n        serde_json::from_str(&text).map_err(|e| format!(\"Invalid JSON response: {}\", e))?;\n    let mut models = extract_model_ids(&json);\n    models.retain(|s| !s.trim().is_empty());\n    models.sort();\n    models.dedup();\n    Ok(models)\n}\n\n/// 获取当前调度配置\n#[tauri::command]\npub async fn get_proxy_scheduling_config(\n    state: State<'_, ProxyServiceState>,\n) -> Result<crate::proxy::sticky_config::StickySessionConfig, String> {\n    let instance_lock = state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        Ok(instance.token_manager.get_sticky_config().await)\n    } else {\n        Ok(crate::proxy::sticky_config::StickySessionConfig::default())\n    }\n}\n\n/// 更新调度配置\n#[tauri::command]\npub async fn update_proxy_scheduling_config(\n    state: State<'_, ProxyServiceState>,\n    config: crate::proxy::sticky_config::StickySessionConfig,\n) -> Result<(), String> {\n    let instance_lock = state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        instance.token_manager.update_sticky_config(config).await;\n        Ok(())\n    } else {\n        Err(\"服务未运行，无法更新实时配置\".to_string())\n    }\n}\n\n/// 清除所有会话粘性绑定\n#[tauri::command]\npub async fn clear_proxy_session_bindings(\n    state: State<'_, ProxyServiceState>,\n) -> Result<(), String> {\n    let instance_lock = state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        instance.token_manager.clear_all_sessions();\n        Ok(())\n    } else {\n        Err(\"服务未运行\".to_string())\n    }\n}\n\n// ===== [FIX #820] 固定账号模式命令 =====\n\n/// 设置优先使用的账号（固定账号模式）\n/// 传入 account_id 启用固定模式，传入 null/空字符串恢复轮询模式\n#[tauri::command]\npub async fn set_preferred_account(\n    state: State<'_, ProxyServiceState>,\n    account_id: Option<String>,\n) -> Result<(), String> {\n    let instance_lock = state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        // 过滤空字符串为 None\n        let cleaned_id = account_id.filter(|s| !s.trim().is_empty());\n\n        // 1. 更新内存状态\n        instance\n            .token_manager\n            .set_preferred_account(cleaned_id.clone())\n            .await;\n\n        // 2. 持久化到配置文件 (修复 Issue #820 自动关闭问题)\n        let mut app_config = crate::modules::config::load_app_config()\n            .map_err(|e| format!(\"加载配置失败: {}\", e))?;\n        app_config.proxy.preferred_account_id = cleaned_id.clone();\n        crate::modules::config::save_app_config(&app_config)\n            .map_err(|e| format!(\"保存配置失败: {}\", e))?;\n\n        if let Some(ref id) = cleaned_id {\n            tracing::info!(\n                \"🔒 [FIX #820] Fixed account mode enabled and persisted: {}\",\n                id\n            );\n        } else {\n            tracing::info!(\"🔄 [FIX #820] Round-robin mode enabled and persisted\");\n        }\n\n        Ok(())\n    } else {\n        Err(\"服务未运行\".to_string())\n    }\n}\n\n/// 获取当前优先使用的账号ID\n#[tauri::command]\npub async fn get_preferred_account(\n    state: State<'_, ProxyServiceState>,\n) -> Result<Option<String>, String> {\n    let instance_lock = state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        Ok(instance.token_manager.get_preferred_account().await)\n    } else {\n        Ok(None)\n    }\n}\n\n/// 清除指定账号的限流记录\n#[tauri::command]\npub async fn clear_proxy_rate_limit(\n    state: State<'_, ProxyServiceState>,\n    account_id: String,\n) -> Result<bool, String> {\n    let instance_lock = state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        Ok(instance.token_manager.clear_rate_limit(&account_id))\n    } else {\n        Err(\"服务未运行\".to_string())\n    }\n}\n\n/// 清除所有限流记录\n#[tauri::command]\npub async fn clear_all_proxy_rate_limits(\n    state: State<'_, ProxyServiceState>,\n) -> Result<(), String> {\n    let instance_lock = state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        instance.token_manager.clear_all_rate_limits();\n        Ok(())\n    } else {\n        Err(\"服务未运行\".to_string())\n    }\n}\n\n/// 触发所有代理的健康检查，并返回更新后的配置\n#[tauri::command]\npub async fn check_proxy_health(\n    state: State<'_, ProxyServiceState>,\n) -> Result<ProxyPoolConfig, String> {\n    let instance_lock = state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        let pool_state = instance.axum_server.proxy_pool_state.clone();\n        let manager = crate::proxy::proxy_pool::ProxyPoolManager::new(pool_state.clone());\n\n        manager.health_check().await?;\n\n        // Return the updated config from memory\n        let config = pool_state.read().await;\n        Ok(config.clone())\n    } else {\n        Err(\"服务未运行\".to_string())\n    }\n}\n\n/// 获取当前内存中的代理池状态\n#[tauri::command]\npub async fn get_proxy_pool_config(\n    state: State<'_, ProxyServiceState>,\n) -> Result<ProxyPoolConfig, String> {\n    let instance_lock = state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        let config = instance.axum_server.proxy_pool_state.read().await;\n        Ok(config.clone())\n    } else {\n        Err(\"服务未运行\".to_string())\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/commands/proxy_pool.rs",
    "content": "use tauri::State;\nuse crate::commands::proxy::ProxyServiceState;\nuse std::collections::HashMap;\n\n/// Bind an account to a specific proxy\n#[tauri::command]\npub async fn bind_account_proxy(\n    state: State<'_, ProxyServiceState>,\n    account_id: String,\n    proxy_id: String,\n) -> Result<(), String> {\n    let instance_lock = state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        instance.axum_server.proxy_pool_manager.bind_account_to_proxy(account_id, proxy_id).await\n    } else {\n        Err(\"Service not running\".to_string())\n    }\n}\n\n/// Unbind an account from its proxy\n#[tauri::command]\npub async fn unbind_account_proxy(\n    state: State<'_, ProxyServiceState>,\n    account_id: String,\n) -> Result<(), String> {\n    let instance_lock = state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        instance.axum_server.proxy_pool_manager.unbind_account_proxy(account_id).await;\n        Ok(())\n    } else {\n        Err(\"Service not running\".to_string())\n    }\n}\n\n/// Get the proxy binding for a specific account\n#[tauri::command]\npub async fn get_account_proxy_binding(\n    state: State<'_, ProxyServiceState>,\n    account_id: String,\n) -> Result<Option<String>, String> {\n    let instance_lock = state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        Ok(instance.axum_server.proxy_pool_manager.get_account_binding(&account_id))\n    } else {\n        Err(\"Service not running\".to_string())\n    }\n}\n\n/// Get all account proxy bindings\n#[tauri::command]\npub async fn get_all_account_bindings(\n    state: State<'_, ProxyServiceState>,\n) -> Result<HashMap<String, String>, String> {\n    let instance_lock = state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        // Since get_all_bindings returns a DashMap ref or clone, we need to convert it to HashMap for serialization\n        // Assuming we add a method to ProxyPoolManager to get a snapshot\n        Ok(instance.axum_server.proxy_pool_manager.get_all_bindings_snapshot())\n    } else {\n        Err(\"Service not running\".to_string())\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/commands/security.rs",
    "content": "use tauri::State;\nuse serde::{Deserialize, Serialize};\nuse crate::modules::security_db;\n\n// ==================== 请求/响应结构 ====================\n\n#[derive(Debug, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct IpAccessLogQuery {\n    pub page: usize,\n    pub page_size: usize,\n    pub search: Option<String>,\n    pub blocked_only: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct IpAccessLogResponse {\n    pub logs: Vec<security_db::IpAccessLog>,\n    pub total: usize,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AddBlacklistRequest {\n    pub ip_pattern: String,\n    pub reason: Option<String>,\n    pub expires_at: Option<i64>, // Unix timestamp\n}\n\n#[derive(Debug, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AddWhitelistRequest {\n    pub ip_pattern: String,\n    pub description: Option<String>,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct IpStatsResponse {\n    pub total_requests: usize,\n    pub unique_ips: usize,\n    pub blocked_requests: usize,\n    pub top_ips: Vec<security_db::IpRanking>,\n}\n\n// ==================== IP 访问日志命令 ====================\n\n/// 获取 IP 访问日志列表\n#[tauri::command]\npub async fn get_ip_access_logs(\n    query: IpAccessLogQuery,\n) -> Result<IpAccessLogResponse, String> {\n    let offset = (query.page.max(1) - 1) * query.page_size;\n    \n    let logs = security_db::get_ip_access_logs(\n        query.page_size,\n        offset,\n        query.search.as_deref(),\n        query.blocked_only,\n    )?;\n    \n    // 简单计算总数 (如果需要精确分页,可以添加 count 函数)\n    let total = logs.len();\n    \n    Ok(IpAccessLogResponse { logs, total })\n}\n\n/// 获取 IP 统计信息\n#[tauri::command]\npub async fn get_ip_stats() -> Result<IpStatsResponse, String> {\n    let stats = security_db::get_ip_stats()?;\n    let top_ips = security_db::get_top_ips(10, 24)?; // Top 10 IPs in last 24 hours\n    \n    Ok(IpStatsResponse {\n        total_requests: stats.total_requests as usize,\n        unique_ips: stats.unique_ips as usize,\n        blocked_requests: stats.blocked_count as usize,\n        top_ips,\n    })\n}\n\n/// 清空 IP 访问日志\n#[tauri::command]\npub async fn clear_ip_access_logs() -> Result<(), String> {\n    security_db::clear_ip_access_logs()\n}\n\n// ==================== IP 黑名单命令 ====================\n\n/// 获取 IP 黑名单列表\n#[tauri::command]\npub async fn get_ip_blacklist() -> Result<Vec<security_db::IpBlacklistEntry>, String> {\n    security_db::get_blacklist()\n}\n\n/// 添加 IP 到黑名单\n#[tauri::command]\npub async fn add_ip_to_blacklist(\n    request: AddBlacklistRequest,\n) -> Result<(), String> {\n    // 验证 IP 格式\n    if !is_valid_ip_pattern(&request.ip_pattern) {\n        return Err(\"Invalid IP pattern. Use IP address or CIDR notation (e.g., 192.168.1.0/24)\".to_string());\n    }\n    \n    security_db::add_to_blacklist(\n        &request.ip_pattern,\n        request.reason.as_deref(),\n        request.expires_at,\n        \"manual\",\n    )?;\n    Ok(())\n}\n\n/// 从黑名单移除 IP\n#[tauri::command]\npub async fn remove_ip_from_blacklist(ip_pattern: String) -> Result<(), String> {\n    // 先获取黑名单列表，找到对应的id\n    let entries = security_db::get_blacklist()?;\n    let entry = entries.iter().find(|e| e.ip_pattern == ip_pattern);\n    \n    if let Some(entry) = entry {\n        security_db::remove_from_blacklist(&entry.id)\n    } else {\n        Err(format!(\"IP pattern {} not found in blacklist\", ip_pattern))\n    }\n}\n\n/// 清空黑名单\n#[tauri::command]\npub async fn clear_ip_blacklist() -> Result<(), String> {\n    // 获取所有黑名单条目并逐个删除\n    let entries = security_db::get_blacklist()?;\n    for entry in entries {\n        security_db::remove_from_blacklist(&entry.ip_pattern)?;\n    }\n    Ok(())\n}\n\n/// 检查 IP 是否在黑名单中\n#[tauri::command]\npub async fn check_ip_in_blacklist(ip: String) -> Result<bool, String> {\n    security_db::is_ip_in_blacklist(&ip)\n}\n\n// ==================== IP 白名单命令 ====================\n\n/// 获取 IP 白名单列表\n#[tauri::command]\npub async fn get_ip_whitelist() -> Result<Vec<security_db::IpWhitelistEntry>, String> {\n    security_db::get_whitelist()\n}\n\n/// 添加 IP 到白名单\n#[tauri::command]\npub async fn add_ip_to_whitelist(\n    request: AddWhitelistRequest,\n) -> Result<(), String> {\n    // 验证 IP 格式\n    if !is_valid_ip_pattern(&request.ip_pattern) {\n        return Err(\"Invalid IP pattern. Use IP address or CIDR notation (e.g., 192.168.1.0/24)\".to_string());\n    }\n    \n    security_db::add_to_whitelist(\n        &request.ip_pattern,\n        request.description.as_deref(),\n    )?;\n    Ok(())\n}\n\n/// 从白名单移除 IP\n#[tauri::command]\npub async fn remove_ip_from_whitelist(ip_pattern: String) -> Result<(), String> {\n    // 先获取白名单列表，找到对应的id\n    let entries = security_db::get_whitelist()?;\n    let entry = entries.iter().find(|e| e.ip_pattern == ip_pattern);\n    \n    if let Some(entry) = entry {\n        security_db::remove_from_whitelist(&entry.id)\n    } else {\n        Err(format!(\"IP pattern {} not found in whitelist\", ip_pattern))\n    }\n}\n\n/// 清空白名单\n#[tauri::command]\npub async fn clear_ip_whitelist() -> Result<(), String> {\n    // 获取所有白名单条目并逐个删除\n    let entries = security_db::get_whitelist()?;\n    for entry in entries {\n        security_db::remove_from_whitelist(&entry.ip_pattern)?;\n    }\n    Ok(())\n}\n\n/// 检查 IP 是否在白名单中\n#[tauri::command]\npub async fn check_ip_in_whitelist(ip: String) -> Result<bool, String> {\n    security_db::is_ip_in_whitelist(&ip)\n}\n\n// ==================== 安全配置命令 ====================\n\n/// 获取安全监控配置\n#[tauri::command]\npub async fn get_security_config(\n    app_state: State<'_, crate::commands::proxy::ProxyServiceState>,\n) -> Result<crate::proxy::config::SecurityMonitorConfig, String> {\n    // 1. 尝试从运行中的实例获取 (内存中可能由最新的配置)\n    let instance_lock = app_state.instance.read().await;\n    if let Some(instance) = instance_lock.as_ref() {\n        return Ok(instance.config.security_monitor.clone());\n    }\n\n    // 2. 如果服务未运行，从磁盘加载\n    let app_config = crate::modules::config::load_app_config()\n        .map_err(|e| format!(\"Failed to load config: {}\", e))?;\n    Ok(app_config.proxy.security_monitor)\n}\n\n/// 更新安全监控配置\n#[tauri::command]\npub async fn update_security_config(\n    config: crate::proxy::config::SecurityMonitorConfig,\n    app_state: State<'_, crate::commands::proxy::ProxyServiceState>,\n) -> Result<(), String> {\n    // 1. 同步保存到配置文件\n    let mut app_config = crate::modules::config::load_app_config()\n        .map_err(|e| format!(\"Failed to load config: {}\", e))?;\n    app_config.proxy.security_monitor = config.clone();\n    crate::modules::config::save_app_config(&app_config)\n        .map_err(|e| format!(\"Failed to save config: {}\", e))?;\n\n    // 2. 更新内存中的配置 (如果服务正在运行)\n    {\n        let mut instance_lock = app_state.instance.write().await;\n        if let Some(instance) = instance_lock.as_mut() {\n            instance.config.security_monitor = config.clone();\n            // [FIX] 调用 update_security 热更新运行中的中间件配置\n            // 这是关键步骤！中间件读取的是 AppState.security (Arc<RwLock<ProxySecurityConfig>>)\n            // 必须调用 update_security() 才能使黑白名单配置实时生效\n            instance.axum_server.update_security(&instance.config).await;\n            tracing::info!(\"[Security] Runtime security config hot-reloaded\");\n        }\n    }\n\n    tracing::info!(\"[Security] Security monitor config updated and saved\");\n    Ok(())\n}\n\n// ==================== 统计分析命令 ====================\n\n/// 获取 IP Token 消耗统计\n#[tauri::command]\npub async fn get_ip_token_stats(\n    limit: Option<usize>,\n    hours: Option<i64>\n) -> Result<Vec<crate::modules::proxy_db::IpTokenStats>, String> {\n    crate::modules::proxy_db::get_token_usage_by_ip(\n        limit.unwrap_or(100),\n        hours.unwrap_or(720)\n    )\n}\n\n// ==================== 辅助函数 ====================\n\n/// 验证 IP 模式格式 (支持单个 IP 和 CIDR)\nfn is_valid_ip_pattern(pattern: &str) -> bool {\n    // 检查是否为 CIDR 格式\n    if pattern.contains('/') {\n        let parts: Vec<&str> = pattern.split('/').collect();\n        if parts.len() != 2 {\n            return false;\n        }\n        \n        // 验证 IP 部分\n        if !is_valid_ip(parts[0]) {\n            return false;\n        }\n        \n        // 验证掩码部分\n        if let Ok(mask) = parts[1].parse::<u8>() {\n            return mask <= 32;\n        }\n        return false;\n    }\n    \n    // 单个 IP 地址\n    is_valid_ip(pattern)\n}\n\n/// 验证 IP 地址格式\nfn is_valid_ip(ip: &str) -> bool {\n    let parts: Vec<&str> = ip.split('.').collect();\n    if parts.len() != 4 {\n        return false;\n    }\n    \n    for part in parts {\n        if part.parse::<u8>().is_err() {\n            return false;\n        }\n    }\n    \n    true\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_valid_ip_patterns() {\n        assert!(is_valid_ip_pattern(\"192.168.1.1\"));\n        assert!(is_valid_ip_pattern(\"10.0.0.0/8\"));\n        assert!(is_valid_ip_pattern(\"172.16.0.0/16\"));\n        assert!(is_valid_ip_pattern(\"192.168.1.0/24\"));\n        assert!(is_valid_ip_pattern(\"8.8.8.8/32\"));\n    }\n\n    #[test]\n    fn test_invalid_ip_patterns() {\n        assert!(!is_valid_ip_pattern(\"256.1.1.1\"));\n        assert!(!is_valid_ip_pattern(\"192.168.1\"));\n        assert!(!is_valid_ip_pattern(\"192.168.1.1/33\"));\n        assert!(!is_valid_ip_pattern(\"192.168.1.1/\"));\n        assert!(!is_valid_ip_pattern(\"invalid\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/commands/user_token.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse crate::modules::user_token_db::{self, UserToken, TokenIpBinding};\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct CreateTokenRequest {\n    pub username: String,\n    pub expires_type: String,\n    pub description: Option<String>,\n    pub max_ips: i32,\n    pub curfew_start: Option<String>,\n    pub curfew_end: Option<String>,\n    pub custom_expires_at: Option<i64>,  // 自定义过期时间戳 (秒)\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct UpdateTokenRequest {\n    pub username: Option<String>,\n    pub description: Option<String>,\n    pub enabled: Option<bool>,\n    pub max_ips: Option<i32>,\n    pub curfew_start: Option<Option<String>>,\n    pub curfew_end: Option<Option<String>>,\n}\n\n// 命令实现\n\n/// 列出所有令牌\n#[tauri::command]\npub async fn list_user_tokens() -> Result<Vec<UserToken>, String> {\n    user_token_db::list_tokens()\n}\n\n/// 创建新令牌\n#[tauri::command]\npub async fn create_user_token(request: CreateTokenRequest) -> Result<UserToken, String> {\n    user_token_db::create_token(\n        request.username,\n        request.expires_type,\n        request.description,\n        request.max_ips,\n        request.curfew_start,\n        request.curfew_end,\n        request.custom_expires_at,\n    )\n}\n\n/// 更新令牌\n#[tauri::command]\npub async fn update_user_token(id: String, request: UpdateTokenRequest) -> Result<(), String> {\n    user_token_db::update_token(\n        &id,\n        request.username,\n        request.description,\n        request.enabled,\n        request.max_ips,\n        request.curfew_start,\n        request.curfew_end,\n    )\n}\n\n/// 删除令牌\n#[tauri::command]\npub async fn delete_user_token(id: String) -> Result<(), String> {\n    user_token_db::delete_token(&id)\n}\n\n/// 续期令牌\n#[tauri::command]\npub async fn renew_user_token(id: String, expires_type: String) -> Result<(), String> {\n    user_token_db::renew_token(&id, &expires_type)\n}\n\n/// 获取令牌 IP 绑定\n#[tauri::command]\npub async fn get_token_ip_bindings(token_id: String) -> Result<Vec<TokenIpBinding>, String> {\n    user_token_db::get_token_ips(&token_id)\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct UserTokenStats {\n    pub total_tokens: usize,\n    pub active_tokens: usize,\n    pub total_users: usize,\n    pub today_requests: i64,\n}\n\n/// 获取简单的统计信息\n#[tauri::command]\npub async fn get_user_token_summary() -> Result<UserTokenStats, String> {\n    let tokens = user_token_db::list_tokens()?;\n    let active_tokens = tokens.iter().filter(|t| t.enabled).count();\n    \n    // 统计唯一用户\n    let mut users = std::collections::HashSet::new();\n    for t in &tokens {\n        users.insert(t.username.clone());\n    }\n    \n    // 这里简单返回一些数据，请求数最好从数据库聚合查询\n    // 目前仅作为演示，请求数暂不精确统计今日的\n    \n    Ok(UserTokenStats {\n        total_tokens: tokens.len(),\n        active_tokens,\n        total_users: users.len(),\n        today_requests: 0, // TODO: Implement daily stats query\n    })\n}\n"
  },
  {
    "path": "src-tauri/src/constants.rs",
    "content": "use std::sync::LazyLock;\nuse regex::Regex;\n\n/// URL to fetch the latest Antigravity version\nconst VERSION_URL: &str = \"https://antigravity-auto-updater-974169037036.us-central1.run.app\";\n\n/// Second fallback: Official Changelog page\nconst CHANGELOG_URL: &str = \"https://antigravity.google/changelog\";\n\n\n\n/// Known stable configuration (for Docker/Headless fallback)\n/// Antigravity 4.1.30 uses Electron 39.2.3 which corresponds to Chrome 132.0.6834.160\nconst KNOWN_STABLE_VERSION: &str = \"4.1.30\";\nconst KNOWN_STABLE_ELECTRON: &str = \"39.2.3\";\nconst KNOWN_STABLE_CHROME: &str = \"132.0.6834.160\";\n\n/// Pre-compiled regex for version parsing (X.Y.Z pattern)\nstatic VERSION_REGEX: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(r\"\\d+\\.\\d+\\.\\d+\").expect(\"Invalid version regex\")\n});\n\n/// Parse version from response text using pre-compiled regex\n/// Matches semver pattern: X.Y.Z (e.g., \"1.15.8\")\nfn parse_version(text: &str) -> Option<String> {\n    VERSION_REGEX.find(text).map(|m| m.as_str().to_string())\n}\n\n/// Compare two X.Y.Z semantic version strings.\n/// Returns Ordering::Greater if v1 > v2.\nfn compare_semver(v1: &str, v2: &str) -> std::cmp::Ordering {\n    let parse = |v: &str| -> Vec<u32> {\n        v.split('.').filter_map(|s| s.parse().ok()).collect()\n    };\n    let p1 = parse(v1);\n    let p2 = parse(v2);\n    for i in 0..p1.len().max(p2.len()) {\n        let a = p1.get(i).copied().unwrap_or(0);\n        let b = p2.get(i).copied().unwrap_or(0);\n        match a.cmp(&b) {\n            std::cmp::Ordering::Equal => continue,\n            other => return other,\n        }\n    }\n    std::cmp::Ordering::Equal\n}\n\n/// Version source for logging\n#[derive(Debug, PartialEq)]\nenum VersionSource {\n    LocalInstallation,\n    KnownStableFallback,\n    RemoteAPI,\n    #[allow(dead_code)]\n    ChangelogWeb,\n    #[allow(dead_code)]\n    CargoToml,\n}\n\n/// Helper struct for version info\nstruct VersionConfig {\n    version: String,\n    electron: String,\n    chrome: String,\n}\n\n/// Try to fetch the latest Antigravity version from the remote update server.\n/// Runs in a dedicated OS thread to avoid blocking Tokio's async runtime.\n/// Returns None on any network/parse failure — always non-fatal, 5s timeout.\nfn try_fetch_remote_version() -> Option<String> {\n    // Spawn a dedicated OS thread so that `reqwest::blocking` never touches\n    // the Tokio thread-pool and cannot trigger the \"Cannot block the current\n    // thread from within an asynchronous execution context\" panic.\n    let (tx, rx) = std::sync::mpsc::channel::<Option<String>>();\n\n    std::thread::spawn(move || {\n        let result = (|| -> Option<String> {\n            let client = reqwest::blocking::Client::builder()\n                .timeout(std::time::Duration::from_secs(5))\n                .build()\n                .ok()?;\n\n            // 1. Try primary update URL\n            if let Ok(resp) = client.get(VERSION_URL).send() {\n                if let Ok(text) = resp.text() {\n                    if let Some(ver) = parse_version(&text) {\n                        tracing::debug!(remote_version = %ver, \"Fetched remote version from VERSION_URL\");\n                        return Some(ver);\n                    }\n                }\n            }\n\n            // 2. Try changelog page as secondary fallback\n            if let Ok(resp) = client.get(CHANGELOG_URL).send() {\n                if let Ok(text) = resp.text() {\n                    if let Some(ver) = parse_version(&text) {\n                        tracing::debug!(remote_version = %ver, \"Fetched remote version from CHANGELOG_URL\");\n                        return Some(ver);\n                    }\n                }\n            }\n\n            tracing::debug!(\"Unable to fetch remote version; will rely on local/stable floor\");\n            None\n        })();\n\n        let _ = tx.send(result);\n    });\n\n    // Wait up to 6 seconds (slightly over the client timeout) for the thread\n    rx.recv_timeout(std::time::Duration::from_secs(6))\n        .unwrap_or(None)\n}\n\n/// Smart version resolution strategy:\n///   best = max(Local Installation, Remote Latest, Known Stable Fallback)\n///\n/// This guarantees that even when:\n///   - The local Antigravity install is outdated, OR\n///   - Local detection fails (Docker / headless / non-standard path),\n/// ...we always report a version >= the current minimum required by Google's API.\nfn resolve_version_config() -> (VersionConfig, VersionSource) {\n    // Floor: static known-stable value (updated with each release of this project)\n    let mut best_version = KNOWN_STABLE_VERSION.to_string();\n    let mut source = VersionSource::KnownStableFallback;\n\n    // 1. Try Local Installation\n    if let Ok(local_ver) = crate::modules::version::get_antigravity_version() {\n        let local_parsed = parse_version(&local_ver.short_version)\n            .or_else(|| parse_version(&local_ver.bundle_version));\n\n        if let Some(local_v) = local_parsed {\n            if compare_semver(&local_v, &best_version) > std::cmp::Ordering::Equal {\n                // Local is newer than the floor — use it\n                tracing::debug!(\n                    local_version = %local_v,\n                    \"Local installation version is newer than known-stable floor; using local\"\n                );\n                best_version = local_v;\n                source = VersionSource::LocalInstallation;\n            } else {\n                // Local is older than or equal to the floor (e.g. user hasn't updated yet)\n                tracing::info!(\n                    local_version = %local_v,\n                    floor_version = %best_version,\n                    \"Local Antigravity version is older than known-stable floor; \\\n                     using floor to avoid upstream model rejection\"\n                );\n                // source stays KnownStableFallback — the local version is intentionally ignored\n            }\n        }\n    }\n\n    // 2. Try Remote Version (best-effort; failure is silently ignored)\n    if let Some(remote_v) = try_fetch_remote_version() {\n        if compare_semver(&remote_v, &best_version) > std::cmp::Ordering::Equal {\n            tracing::info!(\n                remote_version = %remote_v,\n                previous_best = %best_version,\n                \"Remote version is newer than current best; upgrading fingerprint version\"\n            );\n            best_version = remote_v;\n            source = VersionSource::RemoteAPI;\n        }\n    }\n\n    (\n        VersionConfig {\n            version: best_version,\n            electron: KNOWN_STABLE_ELECTRON.to_string(),\n            chrome: KNOWN_STABLE_CHROME.to_string(),\n        },\n        source,\n    )\n}\n\n/// Current resolved Antigravity version (e.g., \"4.1.30\")\n/// Always >= KNOWN_STABLE_VERSION, and >= remote latest when reachable.\npub static CURRENT_VERSION: LazyLock<String> = LazyLock::new(|| {\n    let (config, _) = resolve_version_config();\n    config.version\n});\n\n/// Native OAuth Authorization User-Agent\npub static NATIVE_OAUTH_USER_AGENT: LazyLock<String> = LazyLock::new(|| {\n    format!(\"vscode/1.X.X (Antigravity/{})\", CURRENT_VERSION.as_str())\n});\n\n/// Current resolved Antigravity version (e.g., \"4.1.30\")\npub fn get_current_version() -> String {\n    env!(\"CARGO_PKG_VERSION\").to_string()\n}\n\n/// Returns a full User-Agent string for the current version\n/// \"Antigravity/4.1.30 (Macintosh; Intel Mac OS X 10_15_7) Chrome/132.0.6834.160 Electron/39.2.3\"\npub fn get_default_user_agent() -> String {\n    format!(\"Antigravity/{} (Macintosh; Intel Mac OS X 10_15_7) Chrome/132.0.6834.160 Electron/39.2.3\", env!(\"CARGO_PKG_VERSION\"))\n}\n\n/// Global Session ID (generated once per app launch)\npub static SESSION_ID: LazyLock<String> = LazyLock::new(|| {\n    uuid::Uuid::new_v4().to_string()\n});\n\n/// Returns the best version choice between local and remote\n/// Version selection: max(local installation, remote latest, known stable 4.1.30)\n/// This prevents model rejection due to outdated client version headers.\npub static USER_AGENT: LazyLock<String> = LazyLock::new(|| {\n    let (config, source) = resolve_version_config();\n\n    tracing::info!(\n        version = %config.version,\n        source = ?source,\n        \"User-Agent initialized\"\n    );\n\n    let platform_info = match std::env::consts::OS {\n        \"macos\" => \"Macintosh; Intel Mac OS X 10_15_7\",\n        \"windows\" => \"Windows NT 10.0; Win64; x64\",\n        \"linux\" => \"X11; Linux x86_64\",\n        _ => \"X11; Linux x86_64\",\n    };\n\n    format!(\n        \"Antigravity/{} ({}) Chrome/{} Electron/{}\",\n        config.version,\n        platform_info,\n        config.chrome,\n        config.electron\n    )\n});\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_parse_version_from_updater_response() {\n        let text = \"Auto updater is running. Stable Version: 1.15.8-5724687216017408\";\n        assert_eq!(parse_version(text), Some(\"1.15.8\".to_string()));\n    }\n\n    #[test]\n    fn test_parse_version_simple() {\n        assert_eq!(parse_version(\"1.15.8\"), Some(\"1.15.8\".to_string()));\n        assert_eq!(parse_version(\"Version: 2.0.0\"), Some(\"2.0.0\".to_string()));\n        assert_eq!(parse_version(\"v1.2.3\"), Some(\"1.2.3\".to_string()));\n    }\n\n    #[test]\n    fn test_parse_version_invalid() {\n        assert_eq!(parse_version(\"no version here\"), None);\n        assert_eq!(parse_version(\"\"), None);\n        assert_eq!(parse_version(\"1.2\"), None); // Only X.Y, not X.Y.Z\n    }\n\n    #[test]\n    fn test_parse_version_with_suffix() {\n        // Regex only matches X.Y.Z, suffix is naturally excluded\n        let text = \"antigravity/1.15.8 windows/amd64\";\n        assert_eq!(parse_version(text), Some(\"1.15.8\".to_string()));\n    }\n\n    #[test]\n    fn test_compare_semver() {\n        assert_eq!(compare_semver(\"4.1.30\", \"4.1.22\"), std::cmp::Ordering::Greater);\n        assert_eq!(compare_semver(\"4.1.22\", \"4.1.30\"), std::cmp::Ordering::Less);\n        assert_eq!(compare_semver(\"4.1.30\", \"4.1.30\"), std::cmp::Ordering::Equal);\n        assert_eq!(compare_semver(\"5.0.0\", \"4.9.9\"), std::cmp::Ordering::Greater);\n        assert_eq!(compare_semver(\"1.16.5\", \"1.16.4\"), std::cmp::Ordering::Greater);\n    }\n\n    #[test]\n    fn test_known_stable_floor_is_up_to_date() {\n        // KNOWN_STABLE_VERSION must always be kept in sync with Cargo.toml.\n        // This test will fail and remind the developer to update it.\n        assert!(\n            compare_semver(KNOWN_STABLE_VERSION, \"4.1.22\") > std::cmp::Ordering::Equal,\n            \"KNOWN_STABLE_VERSION ({}) must be > 4.1.22; please sync with Cargo.toml\",\n            KNOWN_STABLE_VERSION\n        );\n    }\n\n    #[test]\n    fn test_old_local_version_uses_floor() {\n        // Simulate: local = 4.1.20 (old), floor = 4.1.30\n        // Expected: use floor\n        let local = \"4.1.20\";\n        let floor = KNOWN_STABLE_VERSION;\n        let best = if compare_semver(local, floor) > std::cmp::Ordering::Equal {\n            local\n        } else {\n            floor\n        };\n        assert_eq!(best, KNOWN_STABLE_VERSION);\n    }\n\n    #[test]\n    fn test_newer_local_version_takes_priority() {\n        // Simulate: local = 4.1.30 (newer than floor), floor = 4.1.30\n        // Expected: use local\n        let local = \"4.1.30\";\n        let floor = KNOWN_STABLE_VERSION;\n        let best = if compare_semver(local, floor) >= std::cmp::Ordering::Equal {\n            local\n        } else {\n            floor\n        };\n        assert_eq!(best, \"4.1.30\");\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/error.rs",
    "content": "use serde::Serialize;\nuse thiserror::Error;\n\n#[derive(Error, Debug)]\npub enum AppError {\n    #[error(\"Database error: {0}\")]\n    Database(#[from] rusqlite::Error),\n\n    #[error(\"Network error: {0}\")]\n    Network(String, Option<u16>),\n\n    #[error(\"IO error: {0}\")]\n    Io(#[from] std::io::Error),\n\n    #[error(\"Tauri error: {0}\")]\n    Tauri(#[from] tauri::Error),\n\n    #[error(\"OAuth error: {0}\")]\n    OAuth(String),\n\n    #[error(\"Configuration error: {0}\")]\n    Config(String),\n\n    #[error(\"Account error: {0}\")]\n    Account(String),\n\n    #[error(\"Unknown error: {0}\")]\n    Unknown(String),\n}\n\nimpl From<reqwest::Error> for AppError {\n    fn from(err: reqwest::Error) -> Self {\n        let status = err.status().map(|s| s.as_u16());\n        AppError::Network(err.to_string(), status)\n    }\n}\n\nimpl From<rquest::Error> for AppError {\n    fn from(err: rquest::Error) -> Self {\n        let status = err.status().map(|s| s.as_u16());\n        AppError::Network(err.to_string(), status)\n    }\n}\n\n// Implement Serialize so it can be used as a return value for Tauri commands\nimpl Serialize for AppError {\n    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>\n    where\n        S: serde::Serializer,\n    {\n        serializer.serialize_str(self.to_string().as_str())\n    }\n}\n\n// Implement alias for Result to simplify usage\npub type AppResult<T> = Result<T, AppError>;\n"
  },
  {
    "path": "src-tauri/src/lib.rs",
    "content": "mod models;\nmod modules;\nmod commands;\nmod utils;\nmod proxy;  // Proxy service module\npub mod error;\npub mod constants;\n\nuse tauri::Manager;\nuse modules::logger;\nuse tracing::{info, warn, error};\nuse std::sync::Arc;\n\n#[derive(Clone, Copy)]\nstruct AppRuntimeFlags {\n    tray_enabled: bool,\n}\n\nfn env_flag_enabled(name: &str) -> bool {\n    std::env::var(name)\n        .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), \"1\" | \"true\" | \"yes\" | \"on\"))\n        .unwrap_or(false)\n}\n\n#[cfg(target_os = \"linux\")]\nfn is_wayland_session() -> bool {\n    std::env::var(\"WAYLAND_DISPLAY\")\n        .map(|v| !v.trim().is_empty())\n        .unwrap_or(false)\n        || std::env::var(\"XDG_SESSION_TYPE\")\n            .map(|v| v.eq_ignore_ascii_case(\"wayland\"))\n            .unwrap_or(false)\n}\n\nfn should_enable_tray() -> bool {\n    if env_flag_enabled(\"ANTIGRAVITY_DISABLE_TRAY\") {\n        info!(\"Tray disabled by ANTIGRAVITY_DISABLE_TRAY\");\n        return false;\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        if is_wayland_session() && !env_flag_enabled(\"ANTIGRAVITY_FORCE_TRAY\") {\n            warn!(\n                \"Linux Wayland session detected; disabling tray by default to avoid GTK/AppIndicator crashes. Set ANTIGRAVITY_FORCE_TRAY=1 to force-enable.\"\n            );\n            return false;\n        }\n    }\n\n    true\n}\n\n#[cfg(target_os = \"linux\")]\nfn configure_linux_gdk_backend() {\n    if std::env::var(\"GDK_BACKEND\").is_ok() {\n        return;\n    }\n\n    let is_wayland = is_wayland_session();\n    let has_x11_display = std::env::var(\"DISPLAY\")\n        .map(|v| !v.trim().is_empty())\n        .unwrap_or(false);\n    let force_wayland = env_flag_enabled(\"ANTIGRAVITY_FORCE_WAYLAND\");\n    let force_x11 = env_flag_enabled(\"ANTIGRAVITY_FORCE_X11\");\n\n    if force_x11 || (is_wayland && has_x11_display && !force_wayland) {\n        // Force X11 backend under Wayland sessions to avoid a GTK Wayland shm crash.\n        std::env::set_var(\"GDK_BACKEND\", \"x11\");\n        warn!(\n            \"Forcing GDK_BACKEND=x11 for stability on Wayland. Set ANTIGRAVITY_FORCE_WAYLAND=1 to keep Wayland backend.\"\n        );\n    }\n}\n\n/// Increase file descriptor limit for macOS to prevent \"Too many open files\" errors\n#[cfg(target_os = \"macos\")]\nfn increase_nofile_limit() {\n    unsafe {\n        let mut rl = libc::rlimit {\n            rlim_cur: 0,\n            rlim_max: 0,\n        };\n\n        if libc::getrlimit(libc::RLIMIT_NOFILE, &mut rl) == 0 {\n            info!(\"Current open file limit: soft={}, hard={}\", rl.rlim_cur, rl.rlim_max);\n\n            // Attempt to increase to 4096 or maximum hard limit\n            let target = 4096.min(rl.rlim_max);\n            if rl.rlim_cur < target {\n                rl.rlim_cur = target;\n                if libc::setrlimit(libc::RLIMIT_NOFILE, &rl) == 0 {\n                    info!(\"Successfully increased hard file limit to {}\", target);\n                } else {\n                    warn!(\"Failed to increase file descriptor limit\");\n                }\n            }\n        }\n    }\n}\n\n// Test command\n#[tauri::command]\nfn greet(name: &str) -> String {\n    format!(\"Hello, {}! You've been greeted from Rust!\", name)\n}\n\n#[cfg_attr(mobile, tauri::mobile_entry_point)]\npub fn run() {\n    // Check for headless mode\n    let args: Vec<String> = std::env::args().collect();\n    let is_headless = args.iter().any(|arg| arg == \"--headless\");\n\n    // Increase file descriptor limit (macOS only)\n    #[cfg(target_os = \"macos\")]\n    increase_nofile_limit();\n\n    // Initialize logger\n    logger::init_logger();\n\n    #[cfg(target_os = \"linux\")]\n    configure_linux_gdk_backend();\n\n    // Initialize token stats database\n    if let Err(e) = modules::token_stats::init_db() {\n        error!(\"Failed to initialize token stats database: {}\", e);\n    }\n\n    // Initialize security database\n    if let Err(e) = modules::security_db::init_db() {\n        error!(\"Failed to initialize security database: {}\", e);\n    }\n    \n    // Initialize user token database\n    if let Err(e) = modules::user_token_db::init_db() {\n        error!(\"Failed to initialize user token database: {}\", e);\n    }\n\n    if is_headless {\n        info!(\"Starting in HEADLESS mode...\");\n\n        let rt = tokio::runtime::Runtime::new().expect(\"Failed to create Tokio runtime\");\n        rt.block_on(async {\n            // Initialize states manually\n            // [FIX] Initialize log bridge for headless mode\n            // Pass a dummy app handle or None since we don't have a Tauri app handle in headless mode\n            // Actually log_bridge relies on AppHandle to emit events.\n            // In headless mode, we don't emit events, but we still need the buffer.\n            // We need to modify log_bridge to handle missing AppHandle gracefully, which it already does (Option).\n            // But init_log_bridge requires AppHandle.\n            // We'll skip passing AppHandle for now and just leverage the global buffer capability.\n            // Since init_log_bridge takes AppHandle, we might need a separate init for headless or just not call init and rely on lazy init of buffer?\n            // Checking log_bridge code again...\n            // \"static LOG_BUFFER: OnceLock<...> = OnceLock::new();\" -> lazy init.\n            // So we just need to ensure the tracing layer is added.\n            // And `logger::init_logger()` adds the layer?\n            // Let's check `modules::logger`.\n\n            let proxy_state = commands::proxy::ProxyServiceState::new();\n            let cf_state = Arc::new(commands::cloudflared::CloudflaredState::new());\n\n            // Load config\n            match modules::config::load_app_config() {\n                Ok(mut config) => {\n                    let mut modified = false;\n                    // Headless/docker 默认允许 LAN 访问（绑定 0.0.0.0）\n                    // 若设置 ABV_BIND_LOCAL_ONLY，则仅绑定 127.0.0.1\n                    let bind_local_only = std::env::var(\"ABV_BIND_LOCAL_ONLY\")\n                        .map(|v| matches!(v.to_lowercase().as_str(), \"1\" | \"true\" | \"yes\" | \"on\"))\n                        .unwrap_or(false);\n                    if bind_local_only {\n                        config.proxy.allow_lan_access = false;\n                        modified = true;\n                    } else {\n                        config.proxy.allow_lan_access = true;\n                    }\n\n                    // [FIX] Force auth mode to AllExceptHealth in headless mode if it's Off or Auto\n                    // This ensures Web UI login validation works properly\n                    if matches!(config.proxy.auth_mode, crate::proxy::ProxyAuthMode::Off | crate::proxy::ProxyAuthMode::Auto) {\n                        info!(\"Headless mode: Forcing auth_mode to AllExceptHealth for Web UI security\");\n                        config.proxy.auth_mode = crate::proxy::ProxyAuthMode::AllExceptHealth;\n                        modified = true;\n                    }\n\n                    // [NEW] 支持通过环境变量注入 API Key\n                    // 优先级：ABV_API_KEY > API_KEY > 配置文件\n                    let env_key = std::env::var(\"ABV_API_KEY\")\n                        .or_else(|_| std::env::var(\"API_KEY\"))\n                        .ok();\n\n                    if let Some(key) = env_key {\n                        if !key.trim().is_empty() {\n                            info!(\"Using API Key from environment variable\");\n                            config.proxy.api_key = key;\n                            modified = true;\n                        }\n                    }\n\n                    // [NEW] 支持通过环境变量注入 Web UI 密码\n                    // 优先级：ABV_WEB_PASSWORD > WEB_PASSWORD > 配置文件\n                    let env_web_password = std::env::var(\"ABV_WEB_PASSWORD\")\n                        .or_else(|_| std::env::var(\"WEB_PASSWORD\"))\n                        .ok();\n\n                    if let Some(pwd) = env_web_password {\n                        if !pwd.trim().is_empty() {\n                            info!(\"Using Web UI Password from environment variable\");\n                            config.proxy.admin_password = Some(pwd);\n                            modified = true;\n                        }\n                    }\n\n                    // [NEW] 支持通过环境变量注入鉴权模式\n                    // 优先级：ABV_AUTH_MODE > AUTH_MODE > 配置文件\n                    let env_auth_mode = std::env::var(\"ABV_AUTH_MODE\")\n                        .or_else(|_| std::env::var(\"AUTH_MODE\"))\n                        .ok();\n\n                    if let Some(mode_str) = env_auth_mode {\n                        let mode = match mode_str.to_lowercase().as_str() {\n                            \"off\" => Some(crate::proxy::ProxyAuthMode::Off),\n                            \"strict\" => Some(crate::proxy::ProxyAuthMode::Strict),\n                            \"all_except_health\" => Some(crate::proxy::ProxyAuthMode::AllExceptHealth),\n                            \"auto\" => Some(crate::proxy::ProxyAuthMode::Auto),\n                            _ => {\n                                warn!(\"Invalid AUTH_MODE: {}, ignoring\", mode_str);\n                                None\n                            }\n                        };\n                        if let Some(m) = mode {\n                            info!(\"Using Auth Mode from environment variable: {:?}\", m);\n                            config.proxy.auth_mode = m;\n                            modified = true;\n                        }\n                    }\n\n                    info!(\"--------------------------------------------------\");\n                    info!(\"🚀 Headless mode proxy service starting...\");\n                    info!(\"📍 Port: {}\", config.proxy.port);\n                    info!(\"🔑 Current API Key: {}\", config.proxy.api_key);\n                    if let Some(ref pwd) = config.proxy.admin_password {\n                        info!(\"🔐 Web UI Password: {}\", pwd);\n                    } else {\n                        info!(\"🔐 Web UI Password: (Same as API Key)\");\n                    }\n                    info!(\"💡 Tips: You can use these keys to login to Web UI and access AI APIs.\");\n                    info!(\"💡 Search docker logs or grep gui_config.json to find them.\");\n                    info!(\"--------------------------------------------------\");\n\n                    // [FIX #1460] Persist environment overrides to ensure they are visible in Web UI/load_config\n                    if modified {\n                        if let Err(e) = modules::config::save_app_config(&config) {\n                            error!(\"Failed to persist environment overrides: {}\", e);\n                        } else {\n                            info!(\"Environment overrides persisted to gui_config.json\");\n                        }\n                    }\n\n                    // Start proxy service\n                    if let Err(e) = commands::proxy::internal_start_proxy_service(\n                        config.proxy,\n                        &proxy_state,\n                        crate::modules::integration::SystemManager::Headless,\n                        cf_state.clone(),\n                    ).await {\n                        error!(\"Failed to start proxy service in headless mode: {}\", e);\n                        std::process::exit(1);\n                    }\n\n                    info!(\"Headless proxy service is running.\");\n\n                    // [DISABLED] Start smart scheduler (Automatic warmup disabled as per user request)\n                    // modules::scheduler::start_scheduler(None, proxy_state.clone());\n                    info!(\"Smart scheduler (Automatic Warmup) is DISABLED.\");\n                    info!(\"Smart scheduler started in headless mode.\");\n                }\n                Err(e) => {\n                    error!(\"Failed to load config for headless mode: {}\", e);\n                    std::process::exit(1);\n                }\n            }\n\n            // Wait for Ctrl-C\n            tokio::signal::ctrl_c().await.ok();\n            info!(\"Headless mode shutting down\");\n        });\n        return;\n    }\n\n    let tray_enabled = should_enable_tray();\n\n    tauri::Builder::default()\n        .plugin(tauri_plugin_dialog::init())\n        .plugin(tauri_plugin_fs::init())\n        .plugin(tauri_plugin_opener::init())\n        .plugin(tauri_plugin_autostart::init(\n            tauri_plugin_autostart::MacosLauncher::LaunchAgent,\n            Some(vec![\"--minimized\"]),\n        ))\n        .plugin(tauri_plugin_updater::Builder::new().build())\n        .plugin(tauri_plugin_process::init())\n        .plugin(tauri_plugin_window_state::Builder::default().build())\n        .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {\n            let _ = app.get_webview_window(\"main\")\n                .map(|window| {\n                    let _ = window.show();\n                    let _ = window.set_focus();\n                    #[cfg(target_os = \"macos\")]\n                    app.set_activation_policy(tauri::ActivationPolicy::Regular).unwrap_or(());\n                });\n        }))\n        .manage(commands::proxy::ProxyServiceState::new())\n        .manage(commands::cloudflared::CloudflaredState::new())\n        .manage(AppRuntimeFlags { tray_enabled })\n        .setup(|app| {\n            info!(\"Setup starting...\");\n\n            // Initialize log bridge with app handle for debug console\n            modules::log_bridge::init_log_bridge(app.handle().clone());\n\n            // Linux: Workaround for transparent window crash/freeze\n            // The transparent window feature is unstable on Linux with WebKitGTK\n            // We disable the visual alpha channel to prevent softbuffer-related crashes\n            #[cfg(target_os = \"linux\")]\n            {\n                use tauri::Manager;\n                if is_wayland_session() {\n                    info!(\"Linux Wayland session detected; skipping transparent window workaround\");\n                } else if let Some(window) = app.get_webview_window(\"main\") {\n                    // Access GTK window and disable transparency at the GTK level\n                    if let Ok(gtk_window) = window.gtk_window() {\n                        use gtk::prelude::WidgetExt;\n                        // Remove the visual's alpha channel to disable transparency\n                        if let Some(screen) = gtk_window.screen() {\n                            // Use non-composited visual if available\n                            if let Some(visual) = screen.system_visual() {\n                                gtk_window.set_visual(Some(&visual));\n                            }\n                            info!(\"Linux: Applied transparent window workaround\");\n                        }\n                    }\n                }\n            }\n\n            let runtime_flags = app.state::<AppRuntimeFlags>();\n            if runtime_flags.tray_enabled {\n                modules::tray::create_tray(app.handle())?;\n                info!(\"Tray created\");\n            } else {\n                info!(\"Tray disabled for this session\");\n            }\n\n            // 立即启动管理服务器 (8045)，以便 Web 端能访问\n            let handle = app.handle().clone();\n            tauri::async_runtime::spawn(async move {\n                // Load config\n                if let Ok(config) = modules::config::load_app_config() {\n                    let state = handle.state::<commands::proxy::ProxyServiceState>();\n                    let cf_state = handle.state::<commands::cloudflared::CloudflaredState>();\n                    let integration = crate::modules::integration::SystemManager::Desktop(handle.clone());\n\n                    // 1. 确保管理后台开启\n                    if let Err(e) = commands::proxy::ensure_admin_server(\n                        config.proxy.clone(),\n                        &state,\n                        integration.clone(),\n                        Arc::new(cf_state.inner().clone()),\n                    ).await {\n                        error!(\"Failed to start admin server: {}\", e);\n                    } else {\n                        info!(\"Admin server (port {}) started successfully\", config.proxy.port);\n                    }\n\n                    // 2. 自动启动转发逻辑\n                    if config.proxy.auto_start {\n                        if let Err(e) = commands::proxy::internal_start_proxy_service(\n                            config.proxy,\n                            &state,\n                            integration,\n                            Arc::new(cf_state.inner().clone()),\n                        ).await {\n                            error!(\"Failed to auto-start proxy service: {}\", e);\n                        } else {\n                            info!(\"Proxy service auto-started successfully\");\n                        }\n                    }\n                }\n            });\n\n            // [DISABLED] Start smart scheduler (Automatic warmup disabled as per user request)\n            // let scheduler_state = app.handle().state::<commands::proxy::ProxyServiceState>();\n            // modules::scheduler::start_scheduler(Some(app.handle().clone()), scheduler_state.inner().clone());\n            info!(\"Smart scheduler (Automatic Warmup) is DISABLED.\");\n\n            // [PHASE 1] 已整合至 Axum 端口 (8045)，不再单独启动 19527 端口\n            info!(\"Management API integrated into main proxy server (port 8045)\");\n\n            Ok(())\n        })\n        .on_window_event(|window, event| {\n            if let tauri::WindowEvent::CloseRequested { api, .. } = event {\n                let tray_enabled = window\n                    .app_handle()\n                    .try_state::<AppRuntimeFlags>()\n                    .map(|flags| flags.tray_enabled)\n                    .unwrap_or(true);\n\n                if tray_enabled {\n                    let _ = window.hide();\n                    #[cfg(target_os = \"macos\")]\n                    {\n                        use tauri::Manager;\n                        window\n                            .app_handle()\n                            .set_activation_policy(tauri::ActivationPolicy::Accessory)\n                            .unwrap_or(());\n                    }\n                    api.prevent_close();\n                }\n            }\n        })\n        .invoke_handler(tauri::generate_handler![\n            greet,\n            // Account management commands\n            commands::list_accounts,\n            commands::add_account,\n            commands::delete_account,\n            commands::delete_accounts,\n            commands::reorder_accounts,\n            commands::switch_account,\n            commands::export_accounts,\n            // Device fingerprint\n            commands::get_device_profiles,\n            commands::bind_device_profile,\n            commands::bind_device_profile_with_profile,\n            commands::preview_generate_profile,\n            commands::apply_device_profile,\n            commands::restore_original_device,\n            commands::list_device_versions,\n            commands::restore_device_version,\n            commands::delete_device_version,\n            commands::open_device_folder,\n            commands::get_current_account,\n            // Quota commands\n            commands::fetch_account_quota,\n            commands::refresh_all_quotas,\n            // Config commands\n            commands::load_config,\n            commands::save_config,\n            // Additional commands\n            commands::prepare_oauth_url,\n            commands::start_oauth_login,\n            commands::complete_oauth_login,\n            commands::cancel_oauth_login,\n            commands::submit_oauth_code,\n            commands::import_v1_accounts,\n            commands::import_from_db,\n            commands::import_custom_db,\n            commands::sync_account_from_db,\n            commands::save_text_file,\n            commands::read_text_file,\n            commands::clear_log_cache,\n            commands::clear_antigravity_cache,\n            commands::get_antigravity_cache_paths,\n            commands::open_data_folder,\n            commands::get_data_dir_path,\n            commands::show_main_window,\n            commands::set_window_theme,\n            commands::get_antigravity_path,\n            commands::get_antigravity_args,\n            commands::check_for_updates,\n            commands::check_homebrew_installation,\n            commands::brew_upgrade_cask,\n            commands::get_update_settings,\n            commands::save_update_settings,\n            commands::should_check_updates,\n            commands::update_last_check_time,\n            commands::toggle_proxy_status,\n            // Proxy service commands\n            commands::proxy::start_proxy_service,\n            commands::proxy::stop_proxy_service,\n            commands::proxy::get_proxy_status,\n            commands::proxy::get_proxy_stats,\n            commands::proxy::get_proxy_logs,\n            commands::proxy::get_proxy_logs_paginated,\n            commands::proxy::get_proxy_log_detail,\n            commands::proxy::get_proxy_logs_count,\n            commands::proxy::export_proxy_logs,\n            commands::proxy::export_proxy_logs_json,\n            commands::proxy::get_proxy_logs_count_filtered,\n            commands::proxy::get_proxy_logs_filtered,\n            commands::proxy::set_proxy_monitor_enabled,\n            commands::proxy::clear_proxy_logs,\n            commands::proxy::generate_api_key,\n            commands::proxy::reload_proxy_accounts,\n            commands::proxy::update_model_mapping,\n            commands::proxy::check_proxy_health,\n            commands::proxy::get_proxy_pool_config,\n            commands::proxy::fetch_zai_models,\n            commands::proxy::get_proxy_scheduling_config,\n            commands::proxy::update_proxy_scheduling_config,\n            commands::proxy::clear_proxy_session_bindings,\n            commands::proxy::set_preferred_account,\n            commands::proxy::get_preferred_account,\n            commands::proxy::clear_proxy_rate_limit,\n            commands::proxy::clear_all_proxy_rate_limits,\n            commands::proxy::check_proxy_health,\n            // Proxy Pool Binding commands\n            commands::proxy_pool::bind_account_proxy,\n            commands::proxy_pool::unbind_account_proxy,\n            commands::proxy_pool::get_account_proxy_binding,\n            commands::proxy_pool::get_all_account_bindings,\n            // Autostart commands\n            commands::autostart::toggle_auto_launch,\n            commands::autostart::is_auto_launch_enabled,\n            // Warmup commands\n            commands::warm_up_all_accounts,\n            commands::warm_up_account,\n            commands::update_account_label,\n            // HTTP API settings commands\n            commands::get_http_api_settings,\n            commands::save_http_api_settings,\n            // Token 统计命令\n            commands::get_token_stats_hourly,\n            commands::get_token_stats_daily,\n            commands::get_token_stats_weekly,\n            commands::get_token_stats_by_account,\n            commands::get_token_stats_summary,\n            commands::get_token_stats_by_model,\n            commands::get_token_stats_model_trend_hourly,\n            commands::get_token_stats_model_trend_daily,\n            commands::get_token_stats_account_trend_hourly,\n            commands::get_token_stats_account_trend_daily,\n            proxy::cli_sync::get_cli_sync_status,\n            proxy::cli_sync::execute_cli_sync,\n            proxy::cli_sync::execute_cli_restore,\n            proxy::cli_sync::get_cli_config_content,\n            proxy::opencode_sync::get_opencode_sync_status,\n            proxy::opencode_sync::execute_opencode_sync,\n            proxy::opencode_sync::execute_opencode_restore,\n            proxy::opencode_sync::get_opencode_config_content,\n            proxy::opencode_sync::execute_opencode_clear,\n            proxy::droid_sync::get_droid_sync_status,\n            proxy::droid_sync::execute_droid_sync,\n            proxy::droid_sync::execute_droid_restore,\n            proxy::droid_sync::get_droid_config_content,\n            // Security/IP monitoring commands\n            commands::security::get_ip_access_logs,\n            commands::security::get_ip_stats,\n            commands::security::get_ip_token_stats,\n            commands::security::clear_ip_access_logs,\n            commands::security::get_ip_blacklist,\n            commands::security::add_ip_to_blacklist,\n            commands::security::remove_ip_from_blacklist,\n            commands::security::clear_ip_blacklist,\n            commands::security::check_ip_in_blacklist,\n            commands::security::get_ip_whitelist,\n            commands::security::add_ip_to_whitelist,\n            commands::security::remove_ip_from_whitelist,\n            commands::security::clear_ip_whitelist,\n            commands::security::check_ip_in_whitelist,\n            commands::security::get_security_config,\n            commands::security::update_security_config,\n            // Cloudflared commands\n            commands::cloudflared::cloudflared_check,\n            commands::cloudflared::cloudflared_install,\n            commands::cloudflared::cloudflared_start,\n            commands::cloudflared::cloudflared_stop,\n            commands::cloudflared::cloudflared_get_status,\n            // Debug console commands\n            modules::log_bridge::enable_debug_console,\n            modules::log_bridge::disable_debug_console,\n            modules::log_bridge::is_debug_console_enabled,\n            modules::log_bridge::get_debug_console_logs,\n            modules::log_bridge::clear_debug_console_logs,\n            // User Token commands\n            commands::user_token::list_user_tokens,\n            commands::user_token::create_user_token,\n            commands::user_token::update_user_token,\n            commands::user_token::delete_user_token,\n            commands::user_token::renew_user_token,\n            commands::user_token::get_token_ip_bindings,\n            commands::user_token::get_user_token_summary,\n        ])\n        .build(tauri::generate_context!())\n        .expect(\"error while building tauri application\")\n        .run(|app_handle, event| {\n            match event {\n                // Handle app exit - cleanup background tasks\n                tauri::RunEvent::Exit => {\n                    tracing::info!(\"Application exiting, cleaning up background tasks...\");\n                    if let Some(state) = app_handle.try_state::<crate::commands::proxy::ProxyServiceState>() {\n                        tauri::async_runtime::block_on(async {\n                            // Use timeout-based read() instead of try_read() to handle lock contention\n                            match tokio::time::timeout(\n                                std::time::Duration::from_secs(3),\n                                state.instance.read()\n                            ).await {\n                                Ok(guard) => {\n                                    if let Some(instance) = guard.as_ref() {\n                                        // Use graceful_shutdown with 2s timeout for task cleanup\n                                        instance.token_manager\n                                            .graceful_shutdown(std::time::Duration::from_secs(2))\n                                            .await;\n                                    }\n                                }\n                                Err(_) => {\n                                    tracing::warn!(\"Lock acquisition timed out after 3s, forcing exit\");\n                                }\n                            }\n                        });\n                    }\n                }\n                // Handle macOS dock icon click to reopen window\n                #[cfg(target_os = \"macos\")]\n                tauri::RunEvent::Reopen { .. } => {\n                    if let Some(window) = app_handle.get_webview_window(\"main\") {\n                        let _ = window.show();\n                        let _ = window.unminimize();\n                        let _ = window.set_focus();\n                        app_handle.set_activation_policy(tauri::ActivationPolicy::Regular).unwrap_or(());\n                    }\n                }\n                _ => {}\n            }\n        });\n}\n"
  },
  {
    "path": "src-tauri/src/main.rs",
    "content": "// Prevents additional console window on Windows in release, DO NOT REMOVE!!\n#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nfn main() {\n    #[cfg(target_os = \"linux\")]\n    {\n        // Fix for transparent window on some Linux systems\n        // See: https://github.com/spacedriveapp/spacedrive/issues/1512#issuecomment-1758550164\n        std::env::set_var(\"WEBKIT_DISABLE_DMABUF_RENDERER\", \"1\");\n    }\n\n    antigravity_tools_lib::run()\n}\n"
  },
  {
    "path": "src-tauri/src/models/account.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse std::collections::HashSet;\nuse super::{token::TokenData, quota::QuotaData};\n\n/// 账号数据结构\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Account {\n    pub id: String,\n    pub email: String,\n    pub name: Option<String>,\n    pub token: TokenData,\n    /// 可选的设备指纹，用于切换账号时固定机器信息\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub device_profile: Option<DeviceProfile>,\n    /// 设备指纹历史（生成/采集时记录），不含基线\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n    pub device_history: Vec<DeviceProfileVersion>,\n    pub quota: Option<QuotaData>,\n    /// Disabled accounts are ignored by the proxy token pool (e.g. revoked refresh_token -> invalid_grant).\n    #[serde(default)]\n    pub disabled: bool,\n    /// Optional human-readable reason for disabling.\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub disabled_reason: Option<String>,\n    /// Unix timestamp when the account was disabled.\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub disabled_at: Option<i64>,\n    /// User manually disabled proxy feature (does not affect app usage).\n    #[serde(default)]\n    pub proxy_disabled: bool,\n    /// Optional human-readable reason for proxy disabling.\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub proxy_disabled_reason: Option<String>,\n    /// Unix timestamp when the proxy was disabled.\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub proxy_disabled_at: Option<i64>,\n    /// 受配额保护禁用的模型列表 [NEW #621]\n    #[serde(default, skip_serializing_if = \"HashSet::is_empty\")]\n    pub protected_models: HashSet<String>,\n    /// [NEW] 403 验证阻止状态 (VALIDATION_REQUIRED)\n    #[serde(default)]\n    pub validation_blocked: bool,\n    /// [NEW] 验证阻止截止时间戳\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub validation_blocked_until: Option<i64>,\n    /// [NEW] 验证阻止原因\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub validation_blocked_reason: Option<String>,\n    /// [NEW] 验证链接 URL (#1522)\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub validation_url: Option<String>,\n    pub created_at: i64,\n    pub last_used: i64,\n    /// 绑定的代理 ID (None = 使用全局代理池)\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub proxy_id: Option<String>,\n    /// 代理绑定时间\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub proxy_bound_at: Option<i64>,\n    /// 用户自定义标签\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub custom_label: Option<String>,\n}\n\nimpl Account {\n    pub fn new(id: String, email: String, token: TokenData) -> Self {\n        let now = chrono::Utc::now().timestamp();\n        Self {\n            id,\n            email,\n            name: None,\n            token,\n            device_profile: None,\n            device_history: Vec::new(),\n            quota: None,\n            disabled: false,\n            disabled_reason: None,\n            disabled_at: None,\n            proxy_disabled: false,\n            proxy_disabled_reason: None,\n            proxy_disabled_at: None,\n            protected_models: HashSet::new(),\n            validation_blocked: false,\n            validation_blocked_until: None,\n            validation_blocked_reason: None,\n            validation_url: None,\n            created_at: now,\n            last_used: now,\n            proxy_id: None,\n            proxy_bound_at: None,\n            custom_label: None,\n        }\n    }\n\n    pub fn update_last_used(&mut self) {\n        self.last_used = chrono::Utc::now().timestamp();\n    }\n\n    pub fn update_quota(&mut self, quota: QuotaData) {\n        self.quota = Some(quota);\n    }\n}\n\n/// 账号索引数据（accounts.json）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AccountIndex {\n    pub version: String,\n    pub accounts: Vec<AccountSummary>,\n    pub current_account_id: Option<String>,\n}\n\n/// 账号摘要信息\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AccountSummary {\n    pub id: String,\n    pub email: String,\n    pub name: Option<String>,\n    #[serde(default)]\n    pub disabled: bool,\n    #[serde(default)]\n    pub proxy_disabled: bool,\n    /// 受保护的模型列表 [NEW] 供 UI 显示锁定图标\n    #[serde(default, skip_serializing_if = \"HashSet::is_empty\")]\n    pub protected_models: HashSet<String>,\n    pub created_at: i64,\n    pub last_used: i64,\n}\n\nimpl AccountIndex {\n    pub fn new() -> Self {\n        Self {\n            version: \"2.0\".to_string(),\n            accounts: Vec::new(),\n            current_account_id: None,\n        }\n    }\n}\n\nimpl Default for AccountIndex {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n/// 设备指纹（storage.json 中 telemetry 相关字段）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct DeviceProfile {\n    pub machine_id: String,\n    pub mac_machine_id: String,\n    pub dev_device_id: String,\n    pub sqm_id: String,\n}\n\n/// 指纹历史版本\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct DeviceProfileVersion {\n    pub id: String,\n    pub created_at: i64,\n    pub label: String,\n    pub profile: DeviceProfile,\n    #[serde(default)]\n    pub is_current: bool,\n}\n\n/// 导出账号项（用于备份/迁移）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AccountExportItem {\n    pub email: String,\n    pub refresh_token: String,\n}\n\n/// 导出账号响应\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AccountExportResponse {\n    pub accounts: Vec<AccountExportItem>,\n}\n"
  },
  {
    "path": "src-tauri/src/models/config.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse crate::proxy::ProxyConfig;\nuse crate::modules::cloudflared::CloudflaredConfig;\n\n/// Application configuration\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AppConfig {\n    pub language: String,\n    pub theme: String,\n    pub auto_refresh: bool,\n    pub refresh_interval: i32,  // minutes\n    pub auto_sync: bool,\n    pub sync_interval: i32,  // minutes\n    pub default_export_path: Option<String>,\n    #[serde(default)]\n    pub proxy: ProxyConfig,\n    pub antigravity_executable: Option<String>, // [NEW] Manually specified Antigravity executable path\n    pub antigravity_args: Option<Vec<String>>, // [NEW] Antigravity startup arguments\n    #[serde(default)]\n    pub auto_launch: bool,  // Launch on startup\n    #[serde(default)]\n    pub scheduled_warmup: ScheduledWarmupConfig, // [NEW] Scheduled warmup configuration\n    #[serde(default)]\n    pub quota_protection: QuotaProtectionConfig, // [NEW] Quota protection configuration\n    #[serde(default)]\n    pub pinned_quota_models: PinnedQuotaModelsConfig, // [NEW] Pinned quota models list\n    #[serde(default)]\n    pub circuit_breaker: CircuitBreakerConfig, // [NEW] Circuit breaker configuration\n    #[serde(default)]\n    pub hidden_menu_items: Vec<String>, // Hidden menu item path list\n    #[serde(default)]\n    pub cloudflared: CloudflaredConfig, // [NEW] Cloudflared configuration\n}\n\n/// Scheduled warmup configuration\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ScheduledWarmupConfig {\n    /// Whether smart warmup is enabled\n    pub enabled: bool,\n\n    /// List of models to warmup\n    #[serde(default = \"default_warmup_models\")]\n    pub monitored_models: Vec<String>,\n}\n\nfn default_warmup_models() -> Vec<String> {\n    vec![\n        \"gemini-3-flash\".to_string(),\n        \"claude\".to_string(),\n        \"gemini-3-pro-high\".to_string(),\n        \"gemini-3-pro-image\".to_string(),\n    ]\n}\n\nimpl ScheduledWarmupConfig {\n    pub fn new() -> Self {\n        Self {\n            enabled: false,\n            monitored_models: default_warmup_models(),\n        }\n    }\n}\n\nimpl Default for ScheduledWarmupConfig {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n/// Quota protection configuration\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct QuotaProtectionConfig {\n    /// Whether quota protection is enabled\n    pub enabled: bool,\n    \n    /// Reserved quota percentage (1-99)\n    pub threshold_percentage: u32,\n\n    /// List of monitored models (e.g. gemini-3-flash, gemini-3-pro-high, gemini-3.1-pro-high, claude-sonnet-4-6)\n    #[serde(default = \"default_monitored_models\")]\n    pub monitored_models: Vec<String>,\n}\n\nfn default_monitored_models() -> Vec<String> {\n    vec![\n        \"claude\".to_string(),\n        \"gemini-3-pro-high\".to_string(),\n        \"gemini-3-flash\".to_string(),\n        \"gemini-3-pro-image\".to_string(),\n    ]\n}\n\nimpl QuotaProtectionConfig {\n    pub fn new() -> Self {\n        Self {\n            enabled: false,\n            threshold_percentage: 10, // Default 10% reserve\n            monitored_models: default_monitored_models(),\n        }\n    }\n}\n\nimpl Default for QuotaProtectionConfig {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n/// Pinned quota models configuration\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct PinnedQuotaModelsConfig {\n    /// List of pinned models (displayed outside the account list)\n    #[serde(default = \"default_pinned_models\")]\n    pub models: Vec<String>,\n}\n\nfn default_pinned_models() -> Vec<String> {\n    vec![\n        \"gemini-3-pro-high\".to_string(),\n        \"gemini-3-flash\".to_string(),\n        \"gemini-3-pro-image\".to_string(),\n        \"claude-sonnet-4-6-thinking\".to_string(),\n    ]\n}\n\nimpl PinnedQuotaModelsConfig {\n    pub fn new() -> Self {\n        Self {\n            models: default_pinned_models(),\n        }\n    }\n}\n\nimpl Default for PinnedQuotaModelsConfig {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n/// Circuit breaker configuration\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CircuitBreakerConfig {\n    /// Whether circuit breaker is enabled\n    pub enabled: bool,\n\n    /// Unified backoff steps (seconds)\n    /// Default: [60, 300, 1800, 7200]\n    #[serde(default = \"default_backoff_steps\")]\n    pub backoff_steps: Vec<u64>,\n}\n\nfn default_backoff_steps() -> Vec<u64> {\n    vec![60, 300, 1800, 7200]\n}\n\nimpl CircuitBreakerConfig {\n    pub fn new() -> Self {\n        Self {\n            enabled: true,\n            backoff_steps: default_backoff_steps(),\n        }\n    }\n}\n\nimpl Default for CircuitBreakerConfig {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl AppConfig {\n    pub fn new() -> Self {\n        Self {\n            language: \"zh\".to_string(),\n            theme: \"system\".to_string(),\n            auto_refresh: true,\n            refresh_interval: 15,\n            auto_sync: false,\n            sync_interval: 5,\n            default_export_path: None,\n            proxy: ProxyConfig::default(),\n            antigravity_executable: None,\n            antigravity_args: None,\n            auto_launch: false,\n            scheduled_warmup: ScheduledWarmupConfig::default(),\n            quota_protection: QuotaProtectionConfig::default(),\n            pinned_quota_models: PinnedQuotaModelsConfig::default(),\n            circuit_breaker: CircuitBreakerConfig::default(),\n            hidden_menu_items: Vec::new(),\n            cloudflared: CloudflaredConfig::default(),\n        }\n    }\n}\n\nimpl Default for AppConfig {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/models/mod.rs",
    "content": "pub mod account;\npub mod token;\npub mod quota;\npub mod config;\n\npub use account::{Account, AccountIndex, AccountSummary, DeviceProfile, DeviceProfileVersion, AccountExportItem, AccountExportResponse};\npub use token::TokenData;\npub use quota::QuotaData;\npub use config::{AppConfig, QuotaProtectionConfig, CircuitBreakerConfig};\n\n"
  },
  {
    "path": "src-tauri/src/models/quota.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n/// 模型配额信息\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ModelQuota {\n    pub name: String,\n    pub percentage: i32,  // 剩余百分比 0-100\n    pub reset_time: String,\n    \n    // -- 动态参数解析与持久化 --\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub display_name: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub supports_images: Option<bool>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub supports_thinking: Option<bool>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub thinking_budget: Option<i32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub recommended: Option<bool>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub max_tokens: Option<i32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub max_output_tokens: Option<i32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub supported_mime_types: Option<std::collections::HashMap<String, bool>>,\n}\n\n/// 配额数据结构\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct QuotaData {\n    pub models: Vec<ModelQuota>,\n    pub last_updated: i64,\n    #[serde(default)]\n    pub is_forbidden: bool,\n    /// 禁止访问的原因 (403 详细信息)\n    #[serde(default)]\n    pub forbidden_reason: Option<String>,\n    /// 订阅等级 (FREE/PRO/ULTRA)\n    #[serde(default)]\n    pub subscription_tier: Option<String>,\n    /// 模型淘汰重定向规则表 (old_model_id -> new_model_id)\n    #[serde(default)]\n    pub model_forwarding_rules: std::collections::HashMap<String, String>,\n}\n\nimpl QuotaData {\n    pub fn new() -> Self {\n        Self {\n            models: Vec::new(),\n            last_updated: chrono::Utc::now().timestamp(),\n            is_forbidden: false,\n            forbidden_reason: None,\n            subscription_tier: None,\n            model_forwarding_rules: std::collections::HashMap::new(),\n        }\n    }\n\n    pub fn add_model(&mut self, model: ModelQuota) {\n        self.models.push(model);\n    }\n}\n\nimpl Default for QuotaData {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/models/token.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TokenData {\n    pub access_token: String,\n    pub refresh_token: String,\n    pub expires_in: i64,\n    pub expiry_timestamp: i64,\n    pub token_type: String,\n    pub email: Option<String>,\n    /// Google Cloud 项目ID，用于 API 请求标识\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub project_id: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub session_id: Option<String>,  // 新增：Antigravity sessionId\n}\n\nimpl TokenData {\n    pub fn new(\n        access_token: String,\n        refresh_token: String,\n        expires_in: i64,\n        email: Option<String>,\n        project_id: Option<String>,\n        session_id: Option<String>,\n    ) -> Self {\n        let expiry_timestamp = chrono::Utc::now().timestamp() + expires_in;\n        Self {\n            access_token,\n            refresh_token,\n            expires_in,\n            expiry_timestamp,\n            token_type: \"Bearer\".to_string(),\n            email,\n            project_id,\n            session_id,\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/modules/account.rs",
    "content": "use serde::Serialize;\nuse serde_json;\nuse std::collections::HashMap;\nuse std::fs;\nuse std::path::PathBuf;\nuse uuid::Uuid;\nuse std::collections::HashSet;\n\nuse crate::models::{\n    Account, AccountIndex, AccountSummary, DeviceProfile, DeviceProfileVersion, QuotaData,\n    TokenData,\n};\nuse crate::modules;\nuse once_cell::sync::Lazy;\nuse std::sync::Mutex;\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::sync::Mutex as StdMutex;\n\n    // Global mutex to prevent concurrent test execution\n    static TEST_MUTEX: Lazy<StdMutex<()>> = Lazy::new(|| StdMutex::new(()));\n\n    struct TestDataDir {\n        path: PathBuf,\n    }\n\n    impl TestDataDir {\n        fn new() -> Self {\n            let temp_path = std::env::temp_dir().join(format!(\n                \"antigravity_test_{}_{}\",\n                std::process::id(),\n                std::time::SystemTime::now()\n                    .duration_since(std::time::UNIX_EPOCH)\n                    .unwrap()\n                    .as_millis()\n            ));\n            fs::create_dir_all(&temp_path).expect(\"Failed to create temp dir\");\n            \n            Self {\n                path: temp_path,\n            }\n        }\n\n        fn path(&self) -> &PathBuf {\n            &self.path\n        }\n    }\n\n    impl Drop for TestDataDir {\n        fn drop(&mut self) {\n            let _ = fs::remove_dir_all(&self.path);\n        }\n    }\n\n    /// Helper to write corrupted content to accounts.json\n    fn write_corrupted_index(path: &PathBuf, content: &[u8]) {\n        let index_path = path.join(\"accounts.json\");\n        fs::write(&index_path, content).expect(\"Failed to write corrupted index\");\n    }\n\n    /// Helper to create a valid account file in accounts/ directory\n    fn create_account_file(path: &PathBuf, account_id: &str, email: &str) {\n        let accounts_dir = path.join(\"accounts\");\n        fs::create_dir_all(&accounts_dir).expect(\"Failed to create accounts dir\");\n        \n        let account = Account::new(\n            account_id.to_string(),\n            email.to_string(),\n            TokenData::new(\n                \"test_access_token\".to_string(),\n                \"test_refresh_token\".to_string(),\n                3600,\n                Some(email.to_string()),\n                None,\n                None,\n            ),\n        );\n        \n        let content = serde_json::to_string_pretty(&account).expect(\"Failed to serialize account\");\n        let account_path = accounts_dir.join(format!(\"{}.json\", account_id));\n        fs::write(&account_path, content).expect(\"Failed to write account file\");\n    }\n\n    #[test]\n    fn test_load_account_index_with_bom_prefix() {\n        let _guard = TEST_MUTEX.lock().unwrap();\n        let dir = TestDataDir::new();\n\n        // UTF-8 BOM followed by valid JSON\n        let bom = [0xEF, 0xBB, 0xBF];\n        let json = r#\"{\"version\":\"2.0\",\"accounts\":[],\"current_account_id\":null}\"#;\n        let mut content = Vec::new();\n        content.extend_from_slice(&bom);\n        content.extend_from_slice(json.as_bytes());\n        \n        write_corrupted_index(dir.path(), &content);\n\n        let result = load_account_index_in_dir(dir.path());\n        \n        // New behavior: BOM is stripped and JSON parses successfully\n        assert!(result.is_ok(), \"BOM should be stripped and JSON should parse: {:?}\", result);\n        let index = result.unwrap();\n        assert!(index.accounts.is_empty());\n        println!(\"BOM case: successfully loaded index after sanitization\");\n    }\n\n    #[test]\n    fn test_load_account_index_with_nul_prefix() {\n        let _guard = TEST_MUTEX.lock().unwrap();\n        let dir = TestDataDir::new();\n\n        // NUL byte prefix followed by valid JSON\n        let nul = [0x00];\n        let json = r#\"{\"version\":\"2.0\",\"accounts\":[],\"current_account_id\":null}\"#;\n        let mut content = Vec::new();\n        content.extend_from_slice(&nul);\n        content.extend_from_slice(json.as_bytes());\n        \n        write_corrupted_index(dir.path(), &content);\n\n        let result = load_account_index_in_dir(dir.path());\n        \n        // New behavior: NUL bytes are stripped and JSON parses successfully\n        assert!(result.is_ok(), \"NUL prefix should be stripped and JSON should parse: {:?}\", result);\n        let index = result.unwrap();\n        assert!(index.accounts.is_empty());\n        println!(\"NUL prefix case: successfully loaded index after sanitization\");\n    }\n\n    #[test]\n    fn test_load_account_index_with_garbage_content() {\n        let _guard = TEST_MUTEX.lock().unwrap();\n        let dir = TestDataDir::new();\n\n        // Non-JSON garbage content - should trigger recovery\n        write_corrupted_index(dir.path(), b\"\\0\\0not json\");\n\n        let result = load_account_index_in_dir(dir.path());\n        \n        // New behavior: garbage content triggers recovery, returns empty index\n        assert!(result.is_ok(), \"Garbage content should trigger recovery and return Ok: {:?}\", result);\n        let index = result.unwrap();\n        assert!(index.accounts.is_empty(), \"Recovered index should be empty when no account files exist\");\n        println!(\"Garbage content case: successfully recovered to empty index\");\n    }\n\n    #[test]\n    fn test_load_account_index_with_empty_file() {\n        let _guard = TEST_MUTEX.lock().unwrap();\n        let dir = TestDataDir::new();\n\n        // Empty file\n        write_corrupted_index(dir.path(), b\"\");\n\n        let result = load_account_index_in_dir(dir.path());\n        \n        // Current behavior: empty file returns new empty index\n        assert!(result.is_ok());\n        let index = result.unwrap();\n        assert!(index.accounts.is_empty());\n    }\n\n    #[test]\n    fn test_load_account_index_with_whitespace_only() {\n        let _guard = TEST_MUTEX.lock().unwrap();\n        let dir = TestDataDir::new();\n\n        // Whitespace-only file\n        write_corrupted_index(dir.path(), b\"   \\n\\t  \");\n\n        let result = load_account_index_in_dir(dir.path());\n        \n        // Current behavior: whitespace-only file returns new empty index\n        assert!(result.is_ok());\n        let index = result.unwrap();\n        assert!(index.accounts.is_empty());\n    }\n\n    #[test]\n    fn test_missing_index_with_existing_accounts() {\n        let _guard = TEST_MUTEX.lock().unwrap();\n        let dir = TestDataDir::new();\n\n        // Create accounts directory with account files but NO accounts.json index\n        create_account_file(dir.path(), \"test-id-1\", \"user1@example.com\");\n        create_account_file(dir.path(), \"test-id-2\", \"user2@example.com\");\n\n        // accounts.json does not exist\n        let index_path = dir.path().join(\"accounts.json\");\n        assert!(!index_path.exists());\n\n        // Load account index - should recover from accounts directory\n        let result = load_account_index_in_dir(dir.path());\n        assert!(result.is_ok(), \"Should recover from accounts directory\");\n        let index = result.unwrap();\n        assert_eq!(index.accounts.len(), 2, \"Index should have 2 accounts recovered from accounts directory\");\n        \n        // Verify recovered accounts have correct data\n        let emails: Vec<_> = index.accounts.iter().map(|s| s.email.clone()).collect();\n        assert!(emails.contains(&\"user1@example.com\".to_string()));\n        assert!(emails.contains(&\"user2@example.com\".to_string()));\n\n        // Verify account files still exist\n        let accounts_dir = dir.path().join(\"accounts\");\n        let account_files: Vec<_> = fs::read_dir(&accounts_dir)\n            .unwrap()\n            .filter_map(|e| e.ok())\n            .filter(|e| e.path().extension().map_or(false, |ext| ext == \"json\"))\n            .collect();\n        assert_eq!(account_files.len(), 2, \"Account files should still exist on disk\");\n        \n        println!(\"Missing index with existing accounts: successfully recovered {} accounts\", index.accounts.len());\n    }\n\n    #[test]\n    fn test_save_account_index_roundtrip() {\n        let _guard = TEST_MUTEX.lock().unwrap();\n        let dir = TestDataDir::new();\n\n        // Build an AccountIndex with 2 accounts\n        let now = chrono::Utc::now().timestamp();\n        let index = AccountIndex {\n            version: \"2.0\".to_string(),\n            accounts: vec![\n                AccountSummary {\n                    id: \"acc-1\".to_string(),\n                    email: \"user1@example.com\".to_string(),\n                    name: Some(\"User One\".to_string()),\n                    disabled: false,\n                    proxy_disabled: false,\n                    protected_models: HashSet::new(),\n                    created_at: now,\n                    last_used: now,\n                },\n                AccountSummary {\n                    id: \"acc-2\".to_string(),\n                    email: \"user2@example.com\".to_string(),\n                    name: None,\n                    disabled: true,\n                    proxy_disabled: true,\n                    protected_models: HashSet::new(),\n                    created_at: now - 100,\n                    last_used: now - 50,\n                },\n            ],\n            current_account_id: Some(\"acc-1\".to_string()),\n        };\n\n        // Save the index\n        save_account_index_in_dir(dir.path(), &index).expect(\"Failed to save account index\");\n\n        // Load it back\n        let loaded = load_account_index_in_dir(dir.path()).expect(\"Failed to load account index\");\n\n        // Assert it matches\n        assert_eq!(loaded.accounts.len(), 2, \"Should have 2 accounts\");\n        assert_eq!(loaded.current_account_id, Some(\"acc-1\".to_string()), \"current_account_id should match\");\n        \n        // Check first account\n        let acc1 = loaded.accounts.iter().find(|a| a.id == \"acc-1\").expect(\"acc-1 should exist\");\n        assert_eq!(acc1.email, \"user1@example.com\");\n        assert_eq!(acc1.name, Some(\"User One\".to_string()));\n        assert!(!acc1.disabled);\n        assert!(!acc1.proxy_disabled);\n        \n        // Check second account\n        let acc2 = loaded.accounts.iter().find(|a| a.id == \"acc-2\").expect(\"acc-2 should exist\");\n        assert_eq!(acc2.email, \"user2@example.com\");\n        assert_eq!(acc2.name, None);\n        assert!(acc2.disabled);\n        assert!(acc2.proxy_disabled);\n\n        println!(\"save_account_index roundtrip: successfully saved and loaded index with {} accounts\", loaded.accounts.len());\n    }\n\n    #[test]\n    fn test_backup_created_on_parse_failure() {\n        let _guard = TEST_MUTEX.lock().unwrap();\n        let dir = TestDataDir::new();\n\n        // Create a valid account file\n        create_account_file(dir.path(), \"recovered-acc\", \"recovered@example.com\");\n\n        // Create corrupt accounts.json with garbage (non-empty)\n        let garbage_content = b\"this is not valid json { broken\";\n        write_corrupted_index(dir.path(), garbage_content);\n\n        // Verify accounts.json exists and is corrupt\n        let index_path = dir.path().join(\"accounts.json\");\n        assert!(index_path.exists(), \"accounts.json should exist\");\n\n        // Call load_account_index to trigger recovery and backup creation\n        let recovered = load_account_index_in_dir(dir.path()).expect(\"Should recover from accounts\");\n        assert_eq!(recovered.accounts.len(), 1, \"Should recover 1 account\");\n        assert_eq!(recovered.accounts[0].email, \"recovered@example.com\");\n        assert_eq!(recovered.current_account_id, Some(\"recovered-acc\".to_string()));\n\n        // Assert a backup file exists with prefix \"accounts.json.corrupt-\"\n        let data_dir = dir.path();\n        let backup_files: Vec<_> = fs::read_dir(data_dir)\n            .unwrap()\n            .filter_map(|e| e.ok())\n            .filter(|e| {\n                e.file_name()\n                    .to_str()\n                    .map_or(false, |name| name.starts_with(\"accounts.json.corrupt-\"))\n            })\n            .collect();\n        \n        assert_eq!(backup_files.len(), 1, \"Should have exactly one backup file\");\n        \n        // Verify backup contains the original garbage content\n        let backup_content = fs::read(&backup_files[0].path()).expect(\"Should be able to read backup file\");\n        assert_eq!(backup_content, garbage_content, \"Backup should contain original corrupt content\");\n\n        println!(\"Backup creation on parse failure: successfully created backup\");\n    }\n}\n\n/// Global account write lock to prevent corruption during concurrent operations\nstatic ACCOUNT_INDEX_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));\n\n// ... existing constants ...\nconst DATA_DIR: &str = \".antigravity_tools\";\nconst ACCOUNTS_INDEX: &str = \"accounts.json\";\nconst ACCOUNTS_DIR: &str = \"accounts\";\n\n/// Get data directory path\npub fn get_data_dir() -> Result<PathBuf, String> {\n    // [NEW] Support custom data directory via environment variable\n    if let Ok(env_path) = std::env::var(\"ABV_DATA_DIR\") {\n        if !env_path.trim().is_empty() {\n            let data_dir = PathBuf::from(env_path);\n            if !data_dir.exists() {\n                fs::create_dir_all(&data_dir).map_err(|e| format!(\"failed_to_create_custom_data_dir: {}\", e))?;\n            }\n            return Ok(data_dir);\n        }\n    }\n\n    let home = dirs::home_dir().ok_or(\"failed_to_get_home_dir\")?;\n    let data_dir = home.join(DATA_DIR);\n\n    // Ensure directory exists\n    if !data_dir.exists() {\n        fs::create_dir_all(&data_dir).map_err(|e| format!(\"failed_to_create_data_dir: {}\", e))?;\n    }\n\n    Ok(data_dir)\n}\n\n/// Get accounts directory path\npub fn get_accounts_dir() -> Result<PathBuf, String> {\n    let data_dir = get_data_dir()?;\n    let accounts_dir = data_dir.join(ACCOUNTS_DIR);\n\n    if !accounts_dir.exists() {\n        fs::create_dir_all(&accounts_dir)\n            .map_err(|e| format!(\"failed_to_create_accounts_dir: {}\", e))?;\n    }\n\n    Ok(accounts_dir)\n}\n\n/// Load account index from a specific directory (internal helper)\nfn load_account_index_in_dir(data_dir: &PathBuf) -> Result<AccountIndex, String> {\n    let index_path = data_dir.join(ACCOUNTS_INDEX);\n\n    if !index_path.exists() {\n        crate::modules::logger::log_warn(\n            \"Account index file not found, attempting recovery from accounts directory\",\n        );\n        let recovered = rebuild_index_from_accounts_in_dir(data_dir)?;\n        try_save_recovered_index(data_dir, &index_path, &recovered, None)?;\n        return Ok(recovered);\n    }\n\n    let raw_content = fs::read(&index_path)\n        .map_err(|e| format!(\"failed_to_read_account_index: {}\", e))?;\n\n    // If file is empty, attempt recovery\n    if raw_content.is_empty() {\n        crate::modules::logger::log_warn(\n            \"Account index is empty, attempting recovery from accounts directory\",\n        );\n        let recovered = rebuild_index_from_accounts_in_dir(data_dir)?;\n        try_save_recovered_index(data_dir, &index_path, &recovered, None)?;\n        return Ok(recovered);\n    }\n\n    // Sanitize content: strip BOM and leading NUL bytes\n    let sanitized = sanitize_index_content(&raw_content);\n\n    // If sanitized content is empty/whitespace, attempt recovery\n    if sanitized.trim().is_empty() {\n        crate::modules::logger::log_warn(\n            \"Account index is empty after sanitization, attempting recovery from accounts directory\",\n        );\n        let recovered = rebuild_index_from_accounts_in_dir(data_dir)?;\n        try_save_recovered_index(data_dir, &index_path, &recovered, None)?;\n        return Ok(recovered);\n    }\n\n    // Try to parse sanitized content\n    match serde_json::from_str::<AccountIndex>(&sanitized) {\n        Ok(index) => {\n            crate::modules::logger::log_info(&format!(\n                \"Successfully loaded index with {} accounts\",\n                index.accounts.len()\n            ));\n            Ok(index)\n        }\n        Err(parse_err) => {\n            crate::modules::logger::log_error(&format!(\n                \"Failed to parse account index: {}. Attempting recovery from accounts directory\",\n                parse_err\n            ));\n            let recovered = rebuild_index_from_accounts_in_dir(data_dir)?;\n            try_save_recovered_index(data_dir, &index_path, &recovered, Some(&raw_content))?;\n            Ok(recovered)\n        }\n    }\n}\n\n/// Save account index to a specific directory (internal helper)\nfn save_account_index_in_dir(data_dir: &PathBuf, index: &AccountIndex) -> Result<(), String> {\n    let index_path = data_dir.join(ACCOUNTS_INDEX);\n    // Use unique temp file name per write to avoid collision\n    let temp_filename = format!(\"{}.tmp.{}\", ACCOUNTS_INDEX, Uuid::new_v4());\n    let temp_path = data_dir.join(&temp_filename);\n\n    let content = serde_json::to_string_pretty(index)\n        .map_err(|e| format!(\"failed_to_serialize_account_index: {}\", e))?;\n\n    // Write to temporary file\n    if let Err(e) = fs::write(&temp_path, content) {\n        // Clean up temp file on failure\n        let _ = fs::remove_file(&temp_path);\n        return Err(format!(\"failed_to_write_temp_index_file: {}\", e));\n    }\n\n    // Atomic rename with platform-specific handling\n    if let Err(e) = atomic_replace_file(&temp_path, &index_path) {\n        // Clean up temp file on failure\n        let _ = fs::remove_file(&temp_path);\n        return Err(format!(\"failed_to_replace_index_file: {}\", e));\n    }\n\n    Ok(())\n}\n\n/// Rebuild AccountIndex by scanning accounts/*.json files in specific directory\nfn rebuild_index_from_accounts_in_dir(data_dir: &PathBuf) -> Result<AccountIndex, String> {\n    let accounts_dir = data_dir.join(ACCOUNTS_DIR);\n    let mut summaries = Vec::new();\n\n    if accounts_dir.exists() {\n        if let Ok(entries) = fs::read_dir(&accounts_dir) {\n            for entry in entries.filter_map(|e| e.ok()) {\n                let path = entry.path();\n                if path.extension().map_or(false, |ext| ext == \"json\") {\n                    if let Some(account_id) = path.file_stem().and_then(|s| s.to_str()) {\n                        match load_account_at_path(&path) {\n                            Ok(account) => {\n                                    summaries.push(AccountSummary {\n                                        id: account.id,\n                                        email: account.email,\n                                        name: account.name,\n                                        disabled: account.disabled,\n                                        proxy_disabled: account.proxy_disabled,\n                                        protected_models: account.protected_models,\n                                        created_at: account.created_at,\n                                        last_used: account.last_used,\n                                    });\n                            }\n                            Err(e) => {\n                                crate::modules::logger::log_warn(&format!(\n                                    \"Failed to load account {} during recovery: {}\",\n                                    account_id, e\n                                ));\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    // Sort by last_used desc, then by email for deterministic order\n    summaries.sort_by(|a, b| {\n        b.last_used\n            .cmp(&a.last_used)\n            .then_with(|| a.email.cmp(&b.email))\n    });\n\n    let current_account_id = summaries.first().map(|s| s.id.clone());\n\n    crate::modules::logger::log_info(&format!(\n        \"Rebuilt index from accounts directory: {} accounts recovered\",\n        summaries.len()\n    ));\n\n    Ok(AccountIndex {\n        version: \"2.0\".to_string(),\n        accounts: summaries,\n        current_account_id,\n    })\n}\n\n/// Load account from a specific path (internal helper)\nfn load_account_at_path(account_path: &PathBuf) -> Result<Account, String> {\n    let content = fs::read_to_string(account_path)\n        .map_err(|e| format!(\"failed_to_read_account_data: {}\", e))?;\n    serde_json::from_str(&content).map_err(|e| format!(\"failed_to_parse_account_data: {}\", e))\n}\n\n/// Load account index with recovery support\npub fn load_account_index() -> Result<AccountIndex, String> {\n    let data_dir = get_data_dir()?;\n    load_account_index_in_dir(&data_dir)\n}\n\n/// Sanitize index file content by stripping BOM and leading NUL bytes\nfn sanitize_index_content(raw: &[u8]) -> String {\n    // Skip UTF-8 BOM if present\n    let without_bom = if raw.starts_with(&[0xEF, 0xBB, 0xBF]) {\n        &raw[3..]\n    } else {\n        raw\n    };\n\n    // Skip leading NUL bytes\n    let without_nul = without_bom\n        .iter()\n        .skip_while(|&&b| b == 0x00)\n        .copied()\n        .collect::<Vec<u8>>();\n\n    // Convert to string (lossy - invalid UTF-8 sequences become replacement chars)\n    String::from_utf8_lossy(&without_nul).into_owned()\n}\n\n/// Best-effort save of recovered index without deadlocking\nfn try_save_recovered_index(\n    data_dir: &PathBuf,\n    _index_path: &PathBuf,\n    index: &AccountIndex,\n    corrupt_content: Option<&[u8]>,\n) -> Result<(), String> {\n    // Backup corrupt file if content provided\n    if let Some(content) = corrupt_content {\n        let timestamp = chrono::Utc::now().timestamp();\n        let backup_name = format!(\"accounts.json.corrupt-{}-{}\", timestamp, Uuid::new_v4());\n        let backup_path = data_dir.join(&backup_name);\n        if let Err(e) = fs::write(&backup_path, content) {\n            crate::modules::logger::log_warn(&format!(\n                \"Failed to backup corrupt index to {}: {}\",\n                backup_name, e\n            ));\n        } else {\n            crate::modules::logger::log_info(&format!(\n                \"Backed up corrupt index to {}\",\n                backup_name\n            ));\n        }\n    }\n\n    // Try to acquire lock without blocking - if we can't get it, skip saving\n    match ACCOUNT_INDEX_LOCK.try_lock() {\n        Ok(_guard) => {\n            if let Err(e) = save_account_index_in_dir(data_dir, index) {\n                crate::modules::logger::log_warn(&format!(\n                    \"Failed to save recovered index: {}. Will retry on next load.\",\n                    e\n                ));\n            } else {\n                crate::modules::logger::log_info(\"Successfully saved recovered index\");\n            }\n        }\n        Err(_) => {\n            crate::modules::logger::log_warn(\n                \"Could not acquire lock to save recovered index. Will retry on next load.\"\n            );\n        }\n    }\n\n    Ok(())\n}\n\n/// Save account index (atomic write)\npub fn save_account_index(index: &AccountIndex) -> Result<(), String> {\n    let data_dir = get_data_dir()?;\n    save_account_index_in_dir(&data_dir, index)\n}\n\n/// Platform-specific atomic file replacement\n#[cfg(target_os = \"windows\")]\nfn atomic_replace_file(src: &PathBuf, dst: &PathBuf) -> Result<(), String> {\n    use std::os::windows::ffi::OsStrExt;\n\n    type Bool = i32;\n    type Dword = u32;\n\n    #[link(name = \"Kernel32\")]\n    extern \"system\" {\n        fn MoveFileExW(lp_existing_file_name: *const u16, lp_new_file_name: *const u16, dw_flags: Dword) -> Bool;\n    }\n\n    let src_wide: Vec<u16> = src\n        .as_os_str()\n        .encode_wide()\n        .chain(std::iter::once(0))\n        .collect();\n    let dst_wide: Vec<u16> = dst\n        .as_os_str()\n        .encode_wide()\n        .chain(std::iter::once(0))\n        .collect();\n\n    // MOVEFILE_REPLACE_EXISTING = 0x1\n    // MOVEFILE_WRITE_THROUGH = 0x8\n    const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;\n    const MOVEFILE_WRITE_THROUGH: u32 = 0x8;\n    let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;\n\n    let result = unsafe { MoveFileExW(src_wide.as_ptr(), dst_wide.as_ptr(), flags) };\n    if result == 0 {\n        let err = std::io::Error::last_os_error();\n        // Clean up source file on failure\n        let _ = fs::remove_file(src);\n        return Err(format!(\"MoveFileExW failed: {}\", err));\n    }\n\n    Ok(())\n}\n\n/// Non-Windows: use standard rename\n#[cfg(not(target_os = \"windows\"))]\nfn atomic_replace_file(src: &PathBuf, dst: &PathBuf) -> Result<(), String> {\n    fs::rename(src, dst).map_err(|e| format!(\"rename failed: {}\", e))\n}\n\n/// Load account data\npub fn load_account(account_id: &str) -> Result<Account, String> {\n    let accounts_dir = get_accounts_dir()?;\n    let account_path = accounts_dir.join(format!(\"{}.json\", account_id));\n    load_account_at_path(&account_path)\n}\n\n/// Save account data\npub fn save_account(account: &Account) -> Result<(), String> {\n    let accounts_dir = get_accounts_dir()?;\n    let account_path = accounts_dir.join(format!(\"{}.json\", account.id));\n\n    let temp_filename = format!(\"{}.tmp.{}\", account.id, Uuid::new_v4());\n    let temp_path = accounts_dir.join(&temp_filename);\n\n    let content = serde_json::to_string_pretty(account)\n        .map_err(|e| format!(\"failed_to_serialize_account_data: {}\", e))?;\n\n    if let Err(e) = std::fs::write(&temp_path, content) {\n        let _ = std::fs::remove_file(&temp_path);\n        return Err(format!(\"failed_to_write_temp_account_file: {}\", e));\n    }\n\n    if let Err(e) = atomic_replace_file(&temp_path, &account_path) {\n        let _ = std::fs::remove_file(&temp_path);\n        return Err(format!(\"failed_to_replace_account_file: {}\", e));\n    }\n\n    Ok(())\n}\n\n/// List all accounts\npub fn list_accounts() -> Result<Vec<Account>, String> {\n    crate::modules::logger::log_info(\"Listing accounts...\");\n    let index = load_account_index()?;\n    let mut accounts = Vec::new();\n\n    for summary in &index.accounts {\n        match load_account(&summary.id) {\n            Ok(account) => accounts.push(account),\n            Err(e) => {\n                crate::modules::logger::log_error(&format!(\n                    \"Failed to load account {}: {}\",\n                    summary.id, e\n                ));\n                // [FIX #929] Removed auto-repair logic.\n                // We no longer silently delete account IDs from the index if the file is missing.\n                // This prevents account loss during version upgrades or temporary FS issues.\n            }\n        }\n    }\n\n    Ok(accounts)\n}\n\n/// Add account\npub fn add_account(\n    email: String,\n    name: Option<String>,\n    token: TokenData,\n) -> Result<Account, String> {\n    let _lock = ACCOUNT_INDEX_LOCK\n        .lock()\n        .map_err(|e| format!(\"failed_to_acquire_lock: {}\", e))?;\n    let mut index = load_account_index()?;\n\n    // Check if account already exists\n    if index.accounts.iter().any(|s| s.email == email) {\n        return Err(format!(\"Account already exists: {}\", email));\n    }\n\n    // Create new account\n    let account_id = Uuid::new_v4().to_string();\n    let mut account = Account::new(account_id.clone(), email.clone(), token);\n    account.name = name.clone();\n\n    // Save account data\n    save_account(&account)?;\n\n    // Update index\n    index.accounts.push(AccountSummary {\n        id: account.id.clone(),\n        email: account.email.clone(),\n        name: account.name.clone(),\n        disabled: account.disabled,\n        proxy_disabled: account.proxy_disabled,\n        protected_models: account.protected_models.clone(),\n        created_at: account.created_at,\n        last_used: account.last_used,\n    });\n\n    // If first account, set as current\n    if index.current_account_id.is_none() {\n        index.current_account_id = Some(account_id);\n    }\n\n    save_account_index(&index)?;\n\n    Ok(account)\n}\n\n/// Add or update account\npub fn upsert_account(\n    email: String,\n    name: Option<String>,\n    token: TokenData,\n) -> Result<Account, String> {\n    let _lock = ACCOUNT_INDEX_LOCK\n        .lock()\n        .map_err(|e| format!(\"failed_to_acquire_lock: {}\", e))?;\n    let mut index = load_account_index()?;\n\n    // Find account ID if exists\n    let existing_account_id = index\n        .accounts\n        .iter()\n        .find(|s| s.email == email)\n        .map(|s| s.id.clone());\n\n    if let Some(account_id) = existing_account_id {\n        // Update existing account\n        match load_account(&account_id) {\n            Ok(mut account) => {\n                let old_access_token = account.token.access_token.clone();\n                let old_refresh_token = account.token.refresh_token.clone();\n                account.token = token;\n                account.name = name.clone();\n                // If an account was previously disabled (e.g. invalid_grant), any explicit token upsert\n                // should re-enable it (user manually updated credentials in the UI).\n                if account.disabled\n                    && (account.token.refresh_token != old_refresh_token\n                        || account.token.access_token != old_access_token)\n                {\n                    account.disabled = false;\n                    account.disabled_reason = None;\n                    account.disabled_at = None;\n                }\n                account.update_last_used();\n                save_account(&account)?;\n\n                // Sync name in index\n                if let Some(idx_summary) = index.accounts.iter_mut().find(|s| s.id == account_id) {\n                    idx_summary.name = name;\n                    save_account_index(&index)?;\n                }\n\n                return Ok(account);\n            }\n            Err(e) => {\n                crate::modules::logger::log_warn(&format!(\n                    \"Account {} file missing ({}), recreating...\",\n                    account_id, e\n                ));\n                // Index exists but file is missing, recreating\n                let mut account = Account::new(account_id.clone(), email.clone(), token);\n                account.name = name.clone();\n                save_account(&account)?;\n\n                // Sync name in index\n                if let Some(idx_summary) = index.accounts.iter_mut().find(|s| s.id == account_id) {\n                    idx_summary.name = name;\n                    save_account_index(&index)?;\n                }\n\n                return Ok(account);\n            }\n        }\n    }\n\n    // Add if not exists\n    // Note: add_account will attempt to acquire lock, which would deadlock here.\n    // Use an internal version or release lock.\n\n    // Release lock, let add_account handle it\n    drop(_lock);\n    add_account(email, name, token)\n}\n\n/// Delete account\npub fn delete_account(account_id: &str) -> Result<(), String> {\n    let _lock = ACCOUNT_INDEX_LOCK\n        .lock()\n        .map_err(|e| format!(\"failed_to_acquire_lock: {}\", e))?;\n    let mut index = load_account_index()?;\n\n    // Remove from index\n    let original_len = index.accounts.len();\n    index.accounts.retain(|s| s.id != account_id);\n\n    if index.accounts.len() == original_len {\n        return Err(format!(\"Account ID not found: {}\", account_id));\n    }\n\n    // Clear current account if it's being deleted\n    if index.current_account_id.as_deref() == Some(account_id) {\n        index.current_account_id = index.accounts.first().map(|s| s.id.clone());\n    }\n\n    save_account_index(&index)?;\n\n    // Delete account file\n    let accounts_dir = get_accounts_dir()?;\n    let account_path = accounts_dir.join(format!(\"{}.json\", account_id));\n\n    if account_path.exists() {\n        fs::remove_file(&account_path)\n            .map_err(|e| format!(\"failed_to_delete_account_file: {}\", e))?;\n    }\n\n    // [FIX #1477] Trigger TokenManager cache cleanup signal\n    crate::proxy::server::trigger_account_delete(account_id);\n\n    Ok(())\n}\n\n/// Batch delete accounts (atomic index operation)\npub fn delete_accounts(account_ids: &[String]) -> Result<(), String> {\n    let _lock = ACCOUNT_INDEX_LOCK\n        .lock()\n        .map_err(|e| format!(\"failed_to_acquire_lock: {}\", e))?;\n    let mut index = load_account_index()?;\n\n    let accounts_dir = get_accounts_dir()?;\n\n    for account_id in account_ids {\n        // Remove from index\n        index.accounts.retain(|s| &s.id != account_id);\n\n        // Clear current account if it's being deleted\n        if index.current_account_id.as_deref() == Some(account_id) {\n            index.current_account_id = None;\n        }\n\n        // Delete account file\n        let account_path = accounts_dir.join(format!(\"{}.json\", account_id));\n        if account_path.exists() {\n            let _ = fs::remove_file(&account_path);\n        }\n\n        // [FIX #1477] Trigger TokenManager cache cleanup signal\n        crate::proxy::server::trigger_account_delete(account_id);\n    }\n\n    // If current account is empty, use first one as default\n    if index.current_account_id.is_none() {\n        index.current_account_id = index.accounts.first().map(|s| s.id.clone());\n    }\n\n    save_account_index(&index)\n}\n\n/// Reorder account list\n/// Update account order in index file based on provided IDs\npub fn reorder_accounts(account_ids: &[String]) -> Result<(), String> {\n    let _lock = ACCOUNT_INDEX_LOCK\n        .lock()\n        .map_err(|e| format!(\"failed_to_acquire_lock: {}\", e))?;\n    let mut index = load_account_index()?;\n\n    // Create a map of account ID to summary\n    let id_to_summary: std::collections::HashMap<_, _> = index\n        .accounts\n        .iter()\n        .map(|s| (s.id.clone(), s.clone()))\n        .collect();\n\n    // Rebuild account list with new order\n    let mut new_accounts = Vec::new();\n    for id in account_ids {\n        if let Some(summary) = id_to_summary.get(id) {\n            new_accounts.push(summary.clone());\n        }\n    }\n\n    // Add accounts missing from new order to the end\n    for summary in &index.accounts {\n        if !account_ids.contains(&summary.id) {\n            new_accounts.push(summary.clone());\n        }\n    }\n\n    index.accounts = new_accounts;\n\n    crate::modules::logger::log_info(&format!(\n        \"Account order updated, {} accounts total\",\n        index.accounts.len()\n    ));\n\n    save_account_index(&index)\n}\n\n/// Switch current account (Core Logic)\npub async fn switch_account(\n    account_id: &str,\n    integration: &(impl modules::integration::SystemIntegration + ?Sized),\n) -> Result<(), String> {\n    use crate::modules::oauth;\n\n    let index = {\n        let _lock = ACCOUNT_INDEX_LOCK\n            .lock()\n            .map_err(|e| format!(\"failed_to_acquire_lock: {}\", e))?;\n        load_account_index()?\n    };\n\n    // 1. Verify account exists\n    if !index.accounts.iter().any(|s| s.id == account_id) {\n        return Err(format!(\"Account not found: {}\", account_id));\n    }\n\n    let mut account = load_account(account_id)?;\n    crate::modules::logger::log_info(&format!(\n        \"Switching to account: {} (ID: {})\",\n        account.email, account.id\n    ));\n\n    // 2. Ensure Token is valid (auto-refresh)\n    let fresh_token = oauth::ensure_fresh_token(&account.token, Some(&account.id))\n        .await\n        .map_err(|e| format!(\"Token refresh failed: {}\", e))?;\n\n    // If Token updated, save back to account file\n    if fresh_token.access_token != account.token.access_token {\n        account.token = fresh_token.clone();\n        save_account(&account)?;\n    }\n\n    // [FIX] Ensure account has a device profile for isolation\n    if account.device_profile.is_none() {\n        crate::modules::logger::log_info(&format!(\n            \"Account {} has no bound fingerprint, generating new one for isolation...\",\n            account.email\n        ));\n        let new_profile = modules::device::generate_profile();\n        apply_profile_to_account(\n            &mut account,\n            new_profile.clone(),\n            Some(\"auto_generated\".to_string()),\n            true,\n        )?;\n    }\n\n    // 3. Execute platform-specific system integration (Close proc, Inject DB, Start proc, etc.)\n    integration.on_account_switch(&account).await?;\n\n    // 4. Update tool internal state\n    {\n        let _lock = ACCOUNT_INDEX_LOCK\n            .lock()\n            .map_err(|e| format!(\"failed_to_acquire_lock: {}\", e))?;\n        let mut index = load_account_index()?;\n        index.current_account_id = Some(account_id.to_string());\n        save_account_index(&index)?;\n    }\n\n    account.update_last_used();\n    save_account(&account)?;\n\n    crate::modules::logger::log_info(&format!(\n        \"Account switch core logic completed: {}\",\n        account.email\n    ));\n\n    Ok(())\n}\n\n/// Get device profile info: current storage.json + account bound profile\n#[derive(Debug, Serialize)]\npub struct DeviceProfiles {\n    pub current_storage: Option<DeviceProfile>,\n    pub bound_profile: Option<DeviceProfile>,\n    pub history: Vec<DeviceProfileVersion>,\n    pub baseline: Option<DeviceProfile>,\n}\n\npub fn get_device_profiles(account_id: &str) -> Result<DeviceProfiles, String> {\n    // In headless/Docker mode, storage.json may not exist - handle gracefully\n    let current = crate::modules::device::get_storage_path()\n        .ok()\n        .and_then(|path| crate::modules::device::read_profile(&path).ok());\n    let account = load_account(account_id)?;\n    Ok(DeviceProfiles {\n        current_storage: current,\n        bound_profile: account.device_profile.clone(),\n        history: account.device_history.clone(),\n        baseline: crate::modules::device::load_global_original(),\n    })\n}\n\n/// Bind device profile and write to storage.json immediately\npub fn bind_device_profile(account_id: &str, mode: &str) -> Result<DeviceProfile, String> {\n    use crate::modules::device;\n\n    let profile = match mode {\n        \"capture\" => device::read_profile(&device::get_storage_path()?)?,\n        \"generate\" => device::generate_profile(),\n        _ => return Err(\"mode must be 'capture' or 'generate'\".to_string()),\n    };\n\n    let mut account = load_account(account_id)?;\n    let _ = device::save_global_original(&profile);\n    apply_profile_to_account(\n        &mut account, profile.clone(), Some(mode.to_string()), true)?;\n\n    Ok(profile)\n}\n\n/// Bind directly with provided profile\npub fn bind_device_profile_with_profile(\n    account_id: &str,\n    profile: DeviceProfile,\n    label: Option<String>,\n) -> Result<DeviceProfile, String> {\n    let mut account = load_account(account_id)?;\n    let _ = crate::modules::device::save_global_original(&profile);\n    apply_profile_to_account(&mut account, profile.clone(), label, true)?;\n\n    Ok(profile)\n}\n\nfn apply_profile_to_account(\n    account: &mut Account,\n    profile: DeviceProfile,\n    label: Option<String>,\n    add_history: bool,\n) -> Result<(), String> {\n    account.device_profile = Some(profile.clone());\n    if add_history {\n        // Clear 'current' flag\n        for h in account.device_history.iter_mut() {\n            h.is_current = false;\n        }\n        account.device_history.push(DeviceProfileVersion {\n            id: Uuid::new_v4().to_string(),\n            created_at: chrono::Utc::now().timestamp(),\n            label: label.unwrap_or_else(|| \"generated\".to_string()),\n            profile: profile.clone(),\n            is_current: true,\n        });\n    }\n    save_account(account)?;\n    Ok(())\n}\n\n/// List available device profile versions for an account (including baseline)\npub fn list_device_versions(account_id: &str) -> Result<DeviceProfiles, String> {\n    get_device_profiles(account_id)\n}\n\n/// Restore device profile by version ID (\"baseline\" for global original, \"current\" for current bound)\npub fn restore_device_version(account_id: &str, version_id: &str) -> Result<DeviceProfile, String> {\n    let mut account = load_account(account_id)?;\n\n    let target_profile = if version_id == \"baseline\" {\n        crate::modules::device::load_global_original().ok_or(\"Global original profile not found\")?\n    } else if let Some(v) = account.device_history.iter().find(|v| v.id == version_id) {\n        v.profile.clone()\n    } else if version_id == \"current\" {\n        account\n            .device_profile\n            .clone()\n            .ok_or(\"No currently bound profile\")?\n    } else {\n        return Err(\"Device profile version not found\".to_string());\n    };\n\n    account.device_profile = Some(target_profile.clone());\n    for h in account.device_history.iter_mut() {\n        h.is_current = h.id == version_id;\n    }\n    save_account(&account)?;\n    Ok(target_profile)\n}\n\n/// Delete specific historical device profile (baseline cannot be deleted)\npub fn delete_device_version(account_id: &str, version_id: &str) -> Result<(), String> {\n    if version_id == \"baseline\" {\n        return Err(\"Original profile cannot be deleted\".to_string());\n    }\n    let mut account = load_account(account_id)?;\n    if account\n        .device_history\n        .iter()\n        .any(|v| v.id == version_id && v.is_current)\n    {\n        return Err(\"Currently bound profile cannot be deleted\".to_string());\n    }\n    let before = account.device_history.len();\n    account.device_history.retain(|v| v.id != version_id);\n    if account.device_history.len() == before {\n        return Err(\"Historical device profile not found\".to_string());\n    }\n    save_account(&account)?;\n    Ok(())\n}\n/// Apply account bound device profile to storage.json\npub fn apply_device_profile(account_id: &str) -> Result<DeviceProfile, String> {\n    use crate::modules::device;\n    let mut account = load_account(account_id)?;\n    let profile = account\n        .device_profile\n        .clone()\n        .ok_or(\"Account has no bound device profile\")?;\n    let storage_path = device::get_storage_path()?;\n    device::write_profile(&storage_path, &profile)?;\n    account.update_last_used();\n    save_account(&account)?;\n    Ok(profile)\n}\n\n/// Restore earliest storage.json backup (approximate \"original\" state)\npub fn restore_original_device() -> Result<String, String> {\n    if let Some(current_id) = get_current_account_id()? {\n        if let Ok(mut account) = load_account(&current_id) {\n            if let Some(original) = crate::modules::device::load_global_original() {\n                account.device_profile = Some(original);\n                for h in account.device_history.iter_mut() {\n                    h.is_current = false;\n                }\n                save_account(&account)?;\n                return Ok(\n                    \"Reset current account bound profile to original (not applied to storage)\"\n                        .to_string(),\n                );\n            }\n        }\n    }\n    Err(\"Original profile not found, cannot restore\".to_string())\n}\n\n/// Get current account ID\npub fn get_current_account_id() -> Result<Option<String>, String> {\n    let index = load_account_index()?;\n    Ok(index.current_account_id)\n}\n\n/// Get currently active account details\npub fn get_current_account() -> Result<Option<Account>, String> {\n    if let Some(id) = get_current_account_id()? {\n        Ok(Some(load_account(&id)?))\n    } else {\n        Ok(None)\n    }\n}\n\n/// Set current active account ID\npub fn set_current_account_id(account_id: &str) -> Result<(), String> {\n    let _lock = ACCOUNT_INDEX_LOCK\n        .lock()\n        .map_err(|e| format!(\"failed_to_acquire_lock: {}\", e))?;\n    let mut index = load_account_index()?;\n    index.current_account_id = Some(account_id.to_string());\n    save_account_index(&index)\n}\n\n/// Update account quota\npub fn update_account_quota(account_id: &str, quota: QuotaData) -> Result<(), String> {\n    let mut account = load_account(account_id)?;\n    account.update_quota(quota);\n\n    // --- Quota protection logic start ---\n    if let Ok(config) = crate::modules::config::load_app_config() {\n        if config.quota_protection.enabled {\n            if let Some(ref q) = account.quota {\n                let threshold = config.quota_protection.threshold_percentage as i32;\n\n                let mut group_min_percentage: HashMap<String, i32> = HashMap::new();\n\n                for model in &q.models {\n                    if let Some(std_id) =\n                        crate::proxy::common::model_mapping::normalize_to_standard_id(&model.name)\n                    {\n                        let entry = group_min_percentage.entry(std_id).or_insert(100);\n                        if model.percentage < *entry {\n                            *entry = model.percentage;\n                        }\n                    }\n                }\n\n                for std_id in &config.quota_protection.monitored_models {\n                    let min_pct = group_min_percentage.get(std_id).cloned().unwrap_or(100);\n\n                    if min_pct <= threshold {\n                        if !account.protected_models.contains(std_id) {\n                            crate::modules::logger::log_info(&format!(\n                                \"[Quota] Triggering model protection: {} (Group: {} Min: {}% <= Thres: {}%)\",\n                                account.email, std_id, min_pct, threshold\n                            ));\n                            account.protected_models.insert(std_id.clone());\n                        }\n                    } else {\n                        if account.protected_models.contains(std_id) {\n                            crate::modules::logger::log_info(&format!(\n                                \"[Quota] Model protection recovered: {} (Group: {} Min: {}% > Thres: {}%)\",\n                                account.email, std_id, min_pct, threshold\n                            ));\n                            account.protected_models.remove(std_id);\n                        }\n                    }\n                }\n\n                // [Compatibility] Migrate from account-level to model-level protection if previously disabled for quota\n                if account.proxy_disabled\n                    && account\n                        .proxy_disabled_reason\n                        .as_ref()\n                        .map_or(false, |r| r == \"quota_protection\")\n                {\n                    crate::modules::logger::log_info(&format!(\n                        \"[Quota] Migrating account {} from account-level to model-level protection\",\n                        account.email\n                    ));\n                    account.proxy_disabled = false;\n                    account.proxy_disabled_reason = None;\n                    account.proxy_disabled_at = None;\n                }\n            }\n        }\n    }\n    // --- Quota protection logic end ---\n\n    // Save account first\n    save_account(&account)?;\n\n    // [FIX] 同时更新索引文件中的摘要信息，确保列表页图标即时刷新\n    {\n        let _lock = ACCOUNT_INDEX_LOCK\n            .lock()\n            .map_err(|e| format!(\"failed_to_acquire_lock: {}\", e))?;\n        if let Ok(mut index) = load_account_index() {\n            if let Some(summary) = index.accounts.iter_mut().find(|a| a.id == account_id) {\n                summary.protected_models = account.protected_models.clone();\n                let _ = save_account_index(&index);\n            }\n        }\n    }\n\n    // [FIX] Trigger TokenManager account reload signal\n    // This ensures in-memory protected_models are updated\n    crate::proxy::server::trigger_account_reload(account_id);\n\n    Ok(())\n}\n\n/// Toggle proxy disabled status for an account\npub fn toggle_proxy_status(\n    account_id: &str,\n    enable: bool,\n    reason: Option<&str>,\n) -> Result<(), String> {\n    let _lock = ACCOUNT_INDEX_LOCK\n        .lock()\n        .map_err(|e| format!(\"failed_to_acquire_lock: {}\", e))?;\n\n    let mut account = load_account(account_id)?;\n\n    account.proxy_disabled = !enable;\n    account.proxy_disabled_reason = if !enable {\n        reason.map(|s| s.to_string())\n    } else {\n        None\n    };\n    account.proxy_disabled_at = if !enable {\n        Some(chrono::Utc::now().timestamp())\n    } else {\n        None\n    };\n\n    save_account(&account)?;\n\n    // Also update index summary\n    let mut index = load_account_index()?;\n    if let Some(summary) = index.accounts.iter_mut().find(|a| a.id == account_id) {\n        summary.proxy_disabled = !enable;\n        save_account_index(&index)?;\n    }\n\n    Ok(())\n}\n\n/// Find account ID by email (from index)\npub fn find_account_id_by_email(email: &str) -> Option<String> {\n    load_account_index().ok()?.accounts.into_iter()\n        .find(|a| a.email == email)\n        .map(|a| a.id)\n}\n\npub fn mark_account_forbidden(account_id: &str, reason: &str) -> Result<(), String> {\n    let _lock = ACCOUNT_INDEX_LOCK\n        .lock()\n        .map_err(|e| format!(\"failed_to_acquire_lock: {}\", e))?;\n\n    let mut account = load_account(account_id)?;\n\n    // 1. Update quota status\n    if let Some(ref mut q) = account.quota {\n        q.is_forbidden = true;\n        q.forbidden_reason = Some(reason.to_string());\n    } else {\n        account.quota = Some(crate::models::QuotaData {\n            models: Vec::new(),\n            last_updated: chrono::Utc::now().timestamp(),\n            subscription_tier: None,\n            is_forbidden: true,\n            forbidden_reason: Some(reason.to_string()),\n            model_forwarding_rules: std::collections::HashMap::new(),\n        });\n    }\n\n    // 2. Disable proxy for this account\n    account.proxy_disabled = true;\n    account.proxy_disabled_reason = Some(format!(\"Forbidden (403): {}\", reason));\n    account.proxy_disabled_at = Some(chrono::Utc::now().timestamp());\n\n    save_account(&account)?;\n\n    // 3. Update index summary\n    let mut index = load_account_index()?;\n    if let Some(summary) = index.accounts.iter_mut().find(|a| a.id == account_id) {\n        summary.proxy_disabled = true;\n        save_account_index(&index)?;\n    }\n\n    // 4. Notify frontend to refresh account list\n    crate::modules::log_bridge::emit_accounts_refreshed();\n\n    Ok(())\n}\n\n/// Export accounts by IDs (for backup/migration)\npub fn export_accounts_by_ids(account_ids: &[String]) -> Result<crate::models::AccountExportResponse, String> {\n    use crate::models::{AccountExportItem, AccountExportResponse};\n    \n    let accounts = list_accounts()?;\n    \n    let export_items: Vec<AccountExportItem> = accounts\n        .into_iter()\n        .filter(|acc| account_ids.contains(&acc.id))\n        .map(|acc| AccountExportItem {\n            email: acc.email,\n            refresh_token: acc.token.refresh_token,\n        })\n        .collect();\n\n    Ok(AccountExportResponse {\n        accounts: export_items,\n    })\n}\n\n/// Export all accounts' refresh_tokens (legacy, kept for compatibility)\n#[allow(dead_code)]\npub fn export_accounts() -> Result<Vec<(String, String)>, String> {\n    let accounts = list_accounts()?;\n    let mut exports = Vec::new();\n\n    for account in accounts {\n        exports.push((account.email, account.token.refresh_token));\n    }\n\n    Ok(exports)\n}\n\n/// Quota query with retry (moved from commands to modules for reuse)\npub async fn fetch_quota_with_retry(account: &mut Account) -> crate::error::AppResult<QuotaData> {\n    use crate::error::AppError;\n    use crate::modules::oauth;\n    use reqwest::StatusCode;\n\n    // 1. Time-based check - ensure Token is valid first\n    let token = match oauth::ensure_fresh_token(&account.token, Some(&account.id)).await {\n        Ok(t) => t,\n        Err(e) => {\n            if e.contains(\"invalid_grant\") {\n                modules::logger::log_error(&format!(\n                    \"Disabling account {} due to invalid_grant during token refresh (quota check)\",\n                    account.email\n                ));\n                account.disabled = true;\n                account.disabled_at = Some(chrono::Utc::now().timestamp());\n                account.disabled_reason = Some(format!(\"invalid_grant: {}\", e));\n                let _ = save_account(account);\n                crate::proxy::server::trigger_account_reload(&account.id);\n            }\n            return Err(AppError::OAuth(e));\n        }\n    };\n\n    if token.access_token != account.token.access_token {\n        modules::logger::log_info(&format!(\"Time-based Token refresh: {}\", account.email));\n        account.token = token.clone();\n\n        // Get display name (incidental to Token refresh)\n        let name = if account.name.is_none()\n            || account.name.as_ref().map_or(false, |n| n.trim().is_empty())\n        {\n            match oauth::get_user_info(&token.access_token, Some(&account.id)).await {\n                Ok(user_info) => user_info.get_display_name(),\n                Err(_) => None,\n            }\n        } else {\n            account.name.clone()\n        };\n\n        account.name = name.clone();\n        upsert_account(account.email.clone(), name, token.clone()).map_err(AppError::Account)?;\n    }\n\n    // 0. Supplement display name (if missing or upper step failed)\n    if account.name.is_none() || account.name.as_ref().map_or(false, |n| n.trim().is_empty()) {\n        modules::logger::log_info(&format!(\n            \"Account {} missing display name, attempting to fetch...\",\n            account.email\n        ));\n        // Use updated token\n        match oauth::get_user_info(&account.token.access_token, Some(&account.id)).await {\n            Ok(user_info) => {\n                let display_name = user_info.get_display_name();\n                modules::logger::log_info(&format!(\n                    \"Successfully fetched display name: {:?}\",\n                    display_name\n                ));\n                account.name = display_name.clone();\n                // Save immediately\n                if let Err(e) =\n                    upsert_account(account.email.clone(), display_name, account.token.clone())\n                {\n                    modules::logger::log_warn(&format!(\"Failed to save display name: {}\", e));\n                }\n            }\n            Err(e) => {\n                modules::logger::log_warn(&format!(\"Failed to fetch display name: {}\", e));\n            }\n        }\n    }\n\n    // 2. Attempt query\n    let result: crate::error::AppResult<(QuotaData, Option<String>)> =\n        modules::fetch_quota(&account.token.access_token, &account.email, Some(&account.id)).await;\n\n    // Capture potentially updated project_id and save\n    if let Ok((ref _q, ref project_id)) = result {\n        if project_id.is_some() && *project_id != account.token.project_id {\n            modules::logger::log_info(&format!(\n                \"Detected project_id update ({}), saving...\",\n                account.email\n            ));\n            account.token.project_id = project_id.clone();\n            if let Err(e) = upsert_account(\n                account.email.clone(),\n                account.name.clone(),\n                account.token.clone(),\n            ) {\n                modules::logger::log_warn(&format!(\"Failed to sync project_id: {}\", e));\n            }\n        }\n    }\n\n    // 3. Handle 401 error\n    if let Err(AppError::Network(_, status)) = result {\n        if let Some(code) = status {\n            if code == 401 {\n                modules::logger::log_warn(&format!(\n                    \"401 Unauthorized for {}, forcing refresh...\",\n                    account.email\n                ));\n\n                // Force refresh\n                let token_res = match oauth::refresh_access_token(&account.token.refresh_token, Some(&account.id))\n                    .await\n                {\n                    Ok(t) => t,\n                    Err(e) => {\n                        if e.contains(\"invalid_grant\") {\n                            modules::logger::log_error(&format!(\n                                \"Disabling account {} due to invalid_grant during forced refresh (quota check)\",\n                                account.email\n                            ));\n                            account.disabled = true;\n                            account.disabled_at = Some(chrono::Utc::now().timestamp());\n                            account.disabled_reason = Some(format!(\"invalid_grant: {}\", e));\n                            let _ = save_account(account);\n                            crate::proxy::server::trigger_account_reload(&account.id);\n                        }\n                        return Err(AppError::OAuth(e));\n                    }\n                };\n\n                let new_token = TokenData::new(\n                    token_res.access_token.clone(),\n                    account.token.refresh_token.clone(),\n                    token_res.expires_in,\n                    account.token.email.clone(),\n                    account.token.project_id.clone(), // Keep original project_id\n                    None,                             // Add None as session_id\n                );\n\n                // Re-fetch display name\n                let name = if account.name.is_none()\n                    || account.name.as_ref().map_or(false, |n| n.trim().is_empty())\n                {\n                    match oauth::get_user_info(&token_res.access_token, Some(&account.id)).await {\n                        Ok(user_info) => user_info.get_display_name(),\n                        Err(_) => None,\n                    }\n                } else {\n                    account.name.clone()\n                };\n\n                account.token = new_token.clone();\n                account.name = name.clone();\n                upsert_account(account.email.clone(), name, new_token.clone())\n                    .map_err(AppError::Account)?;\n\n                // Retry query\n                let retry_result: crate::error::AppResult<(QuotaData, Option<String>)> =\n                    modules::fetch_quota(&new_token.access_token, &account.email, Some(&account.id)).await;\n\n                // Also handle project_id saving during retry\n                if let Ok((ref _q, ref project_id)) = retry_result {\n                    if project_id.is_some() && *project_id != account.token.project_id {\n                        modules::logger::log_info(&format!(\n                            \"Detected update of project_id after retry ({}), saving...\",\n                            account.email\n                        ));\n                        account.token.project_id = project_id.clone();\n                        let _ = upsert_account(\n                            account.email.clone(),\n                            account.name.clone(),\n                            account.token.clone(),\n                        );\n                    }\n                }\n\n                if let Err(AppError::Network(_, status)) = retry_result {\n                    if let Some(code) = status {\n                        if code == 403 {\n                            let mut q = QuotaData::new();\n                            q.is_forbidden = true;\n                            return Ok(q);\n                        }\n                    }\n                }\n                return retry_result.map(|(q, _)| q);\n            }\n        }\n    }\n\n    // fetch_quota already handles 403, just return mapping result\n    result.map(|(q, _)| q)\n}\n\n#[derive(Serialize)]\npub struct RefreshStats {\n    pub total: usize,\n    pub success: usize,\n    pub failed: usize,\n    pub details: Vec<String>,\n}\n\n/// Core logic to batch refresh all account quotas (decoupled from Tauri status)\npub async fn refresh_all_quotas_logic() -> Result<RefreshStats, String> {\n    use futures::future::join_all;\n    use std::sync::Arc;\n    use tokio::sync::Semaphore;\n\n    const MAX_CONCURRENT: usize = 5;\n    let start = std::time::Instant::now();\n\n    crate::modules::logger::log_info(&format!(\n        \"Starting batch refresh of all account quotas (Concurrent mode, max: {})\",\n        MAX_CONCURRENT\n    ));\n    let accounts = list_accounts()?;\n\n    let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT));\n\n    let tasks: Vec<_> = accounts\n        .into_iter()\n        .filter(|account| {\n            // [MOD] Now we allow refreshing disabled and proxy_disabled accounts\n            // to support forced re-sync from UI. \n            // Only strictly skip forbidden accounts if necessary, but even those \n            // might want a retry to see if they are unbanned.\n            if let Some(ref q) = account.quota {\n                if q.is_forbidden {\n                    crate::modules::logger::log_info(&format!(\n                        \"  - Skipping {} (Forbidden)\",\n                        account.email\n                    ));\n                    return false;\n                }\n            }\n            true\n        })\n        .map(|mut account| {\n            let email = account.email.clone();\n            let account_id = account.id.clone();\n            let permit = semaphore.clone();\n            async move {\n                let _guard = permit.acquire().await.unwrap();\n                crate::modules::logger::log_info(&format!(\"  - Processing {}\", email));\n                match fetch_quota_with_retry(&mut account).await {\n                    Ok(quota) => {\n                        if let Err(e) = update_account_quota(&account_id, quota) {\n                            let msg = format!(\"Account {}: Save quota failed - {}\", email, e);\n                            crate::modules::logger::log_error(&msg);\n                            Err(msg)\n                        } else {\n                            crate::modules::logger::log_info(&format!(\"    Success {}\", email));\n                            Ok(())\n                        }\n                    }\n                    Err(e) => {\n                        let msg = format!(\"Account {}: Fetch quota failed - {}\", email, e);\n                        crate::modules::logger::log_error(&msg);\n                        Err(msg)\n                    }\n                }\n            }\n        })\n        .collect();\n\n    let total = tasks.len();\n    let results = join_all(tasks).await;\n\n    let mut success = 0;\n    let mut failed = 0;\n    let mut details = Vec::new();\n\n    for result in results {\n        match result {\n            Ok(()) => success += 1,\n            Err(msg) => {\n                failed += 1;\n                details.push(msg);\n            }\n        }\n    }\n\n    let elapsed = start.elapsed();\n    crate::modules::logger::log_info(&format!(\n        \"Batch refresh completed: {} success, {} failed, took: {}ms\",\n        success,\n        failed,\n        elapsed.as_millis()\n    ));\n\n    // After quota refresh, immediately check and trigger warmup for recovered models\n    // [Disabled] Automatic warmup is temporarily disabled\n    // tokio::spawn(async {\n    //     check_and_trigger_warmup_for_recovered_models().await;\n    // });\n\n    Ok(RefreshStats {\n        total,\n        success,\n        failed,\n        details,\n    })\n}\n\n/// Check and trigger warmup for models that have recovered to 100%\n/// Called automatically after quota refresh to enable immediate warmup\npub async fn check_and_trigger_warmup_for_recovered_models() {\n    let accounts = match list_accounts() {\n        Ok(acc) => acc,\n        Err(_) => return,\n    };\n\n    // Load config to check if scheduled warmup is enabled\n    let app_config = match crate::modules::config::load_app_config() {\n        Ok(cfg) => cfg,\n        Err(_) => return,\n    };\n\n    if !app_config.scheduled_warmup.enabled {\n        return;\n    }\n\n    crate::modules::logger::log_info(&format!(\n        \"[Warmup] Checking {} accounts for recovered models after quota refresh...\",\n        accounts.len()\n    ));\n\n    for account in accounts {\n        // Skip disabled accounts\n        if account.disabled || account.proxy_disabled {\n            continue;\n        }\n\n        // Trigger warmup check for this account\n        crate::modules::scheduler::trigger_warmup_for_account(&account).await;\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/modules/account_service.rs",
    "content": "use crate::models::{Account, TokenData};\nuse crate::modules;\n\n/// 账号服务层 - 彻底解除对 Tauri 运行时的依赖\npub struct AccountService {\n    pub integration: crate::modules::integration::SystemManager,\n}\n\nimpl AccountService {\n    pub fn new(integration: crate::modules::integration::SystemManager) -> Self {\n        Self { integration }\n    }\n\n    /// 添加账号逻辑\n    pub async fn add_account(&self, refresh_token: &str) -> Result<Account, String> {\n        // [FIX #1583] 生成临时 UUID 作为账号上下文，避免传递 None 导致代理选择异常\n        let temp_account_id = uuid::Uuid::new_v4().to_string();\n        \n        // 1. 获取 Token (使用临时 ID 确保代理选择有明确上下文)\n        let token_res = modules::oauth::refresh_access_token(refresh_token, Some(&temp_account_id)).await?;\n\n        // 2. 获取用户信息\n        let user_info = modules::oauth::get_user_info(&token_res.access_token, Some(&temp_account_id)).await?;\n\n        // 3. 获取项目 ID (尝试)\n        let project_id = crate::proxy::project_resolver::fetch_project_id(&token_res.access_token)\n            .await\n            .ok();\n\n        // 4. 构造 TokenData\n        let token = TokenData::new(\n            token_res.access_token.clone(),\n            refresh_token.to_string(),\n            token_res.expires_in,\n            Some(user_info.email.clone()),\n            project_id,\n            None,\n        );\n\n        // 5. 持久化\n        let mut account =\n            modules::upsert_account(user_info.email.clone(), user_info.get_display_name(), token)?;\n\n        // 6. [NEW] 自动获取配额信息（用于刷新时间排序）\n        let email_for_log = account.email.clone();\n        let access_token = token_res.access_token.clone();\n        match modules::quota::fetch_quota(&access_token, &email_for_log, Some(&account.id)).await {\n            Ok((quota_data, new_project_id)) => {\n                account.quota = Some(quota_data);\n                if let Some(pid) = new_project_id {\n                    account.token.project_id = Some(pid);\n                }\n                // 保存更新后的账号信息\n                if let Err(e) = modules::account::save_account(&account) {\n                    modules::logger::log_warn(&format!(\n                        \"[Service] Failed to save quota for {}: {}\",\n                        email_for_log, e\n                    ));\n                } else {\n                    modules::logger::log_info(&format!(\n                        \"[Service] Fetched quota for new account: {}\",\n                        email_for_log\n                    ));\n                }\n            }\n            Err(e) => {\n                modules::logger::log_warn(&format!(\n                    \"[Service] Failed to fetch quota for {}: {}\",\n                    email_for_log, e\n                ));\n            }\n        }\n\n        modules::logger::log_info(&format!(\n            \"[Service] Added/Updated account: {}\",\n            account.email\n        ));\n        Ok(account)\n    }\n\n    /// 删除账号逻辑\n    pub fn delete_account(&self, account_id: &str) -> Result<(), String> {\n        modules::delete_account(account_id)?;\n        self.integration.update_tray();\n        Ok(())\n    }\n\n    /// 切换账号逻辑\n    pub async fn switch_account(&self, account_id: &str) -> Result<(), String> {\n        modules::account::switch_account(account_id, &self.integration).await\n    }\n\n    /// 列表获取\n    pub fn list_accounts(&self) -> Result<Vec<Account>, String> {\n        modules::list_accounts()\n    }\n\n    /// 获取当前 ID\n    pub fn get_current_id(&self) -> Result<Option<String>, String> {\n        modules::get_current_account_id()\n    }\n\n    // --- OAuth 逻辑 ---\n\n    pub async fn prepare_oauth_url(&self) -> Result<String, String> {\n        let handle = match &self.integration {\n            modules::integration::SystemManager::Desktop(h) => Some(h.clone()),\n            modules::integration::SystemManager::Headless => None,\n        };\n        modules::oauth_server::prepare_oauth_url(handle).await\n    }\n\n    pub async fn start_oauth_login(&self) -> Result<Account, String> {\n        let handle = match &self.integration {\n            modules::integration::SystemManager::Desktop(h) => Some(h.clone()),\n            modules::integration::SystemManager::Headless => None,\n        };\n        let token_res = modules::oauth_server::start_oauth_flow(handle).await?;\n        self.process_oauth_token(token_res).await\n    }\n\n    pub async fn complete_oauth_login(&self) -> Result<Account, String> {\n        let handle = match &self.integration {\n            modules::integration::SystemManager::Desktop(h) => Some(h.clone()),\n            modules::integration::SystemManager::Headless => None,\n        };\n        let token_res = modules::oauth_server::complete_oauth_flow(handle).await?;\n        self.process_oauth_token(token_res).await\n    }\n\n    pub fn cancel_oauth_login(&self) {\n        modules::oauth_server::cancel_oauth_flow();\n    }\n\n    pub async fn submit_oauth_code(\n        &self,\n        code: String,\n        state: Option<String>,\n    ) -> Result<(), String> {\n        modules::oauth_server::submit_oauth_code(code, state).await\n    }\n\n    async fn process_oauth_token(\n        &self,\n        token_res: modules::oauth::TokenResponse,\n    ) -> Result<Account, String> {\n        let refresh_token = token_res\n            .refresh_token\n            .ok_or_else(|| \"未获取到 Refresh Token。请撤销权限后重试。\".to_string())?;\n\n        // [FIX #1583] 生成临时 UUID 作为账号上下文\n        let temp_account_id = uuid::Uuid::new_v4().to_string();\n        \n        let user_info = modules::oauth::get_user_info(&token_res.access_token, Some(&temp_account_id)).await?;\n        let project_id = crate::proxy::project_resolver::fetch_project_id(&token_res.access_token)\n            .await\n            .ok();\n\n        let token_data = crate::models::TokenData::new(\n            token_res.access_token,\n            refresh_token,\n            token_res.expires_in,\n            Some(user_info.email.clone()),\n            project_id,\n            None,\n        );\n\n        let account = modules::upsert_account(\n            user_info.email.clone(),\n            user_info.get_display_name(),\n            token_data,\n        )?;\n\n        // 发送 UI 更新通知 (通过 integration)\n        self.integration.update_tray();\n\n        Ok(account)\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/modules/cache.rs",
    "content": "//! Antigravity cache clearing module\n//!\n//! Provides functionality to clear Antigravity application cache directories\n//! to resolve login failures, version validation errors, and OAuth issues.\n\nuse crate::modules::logger;\nuse serde::{Deserialize, Serialize};\nuse std::fs;\nuse std::path::PathBuf;\n\n/// Result of cache clearing operation\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClearResult {\n    /// Paths that were successfully cleared\n    pub cleared_paths: Vec<String>,\n    /// Total size freed in bytes\n    pub total_size_freed: u64,\n    /// Errors encountered during clearing\n    pub errors: Vec<String>,\n}\n\n/// Get all known Antigravity cache paths for the current platform\npub fn get_antigravity_cache_paths() -> Vec<PathBuf> {\n    let mut paths = Vec::new();\n\n    #[cfg(target_os = \"macos\")]\n    {\n        if let Some(home) = dirs::home_dir() {\n            // Primary cache location - HTTP storage (contains User-Agent cache)\n            // This is the main cause of \"version no longer supported\" errors\n            paths.push(home.join(\"Library/HTTPStorages/com.google.antigravity\"));\n\n            // Application caches\n            paths.push(home.join(\"Library/Caches/com.google.antigravity\"));\n\n            // Alternative cache locations that may exist\n            paths.push(home.join(\".antigravity\"));\n            paths.push(home.join(\".config/antigravity\"));\n        }\n    }\n\n    #[cfg(target_os = \"windows\")]\n    {\n        // LocalAppData cache\n        if let Ok(local_app_data) = std::env::var(\"LOCALAPPDATA\") {\n            let local_path = PathBuf::from(&local_app_data);\n            paths.push(local_path.join(\"Google\\\\Antigravity\"));\n            paths.push(local_path.join(\"Antigravity\\\\Cache\"));\n        }\n\n        // AppData cache\n        if let Ok(app_data) = std::env::var(\"APPDATA\") {\n            let app_path = PathBuf::from(&app_data);\n            paths.push(app_path.join(\"Antigravity\\\\Cache\"));\n        }\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        if let Some(home) = dirs::home_dir() {\n            // XDG cache directory\n            paths.push(home.join(\".cache/Antigravity\"));\n            paths.push(home.join(\".cache/google-antigravity\"));\n\n            // Alternative locations\n            paths.push(home.join(\".antigravity\"));\n        }\n\n        // XDG_CACHE_HOME if set\n        if let Ok(xdg_cache) = std::env::var(\"XDG_CACHE_HOME\") {\n            let cache_path = PathBuf::from(&xdg_cache);\n            paths.push(cache_path.join(\"Antigravity\"));\n            paths.push(cache_path.join(\"google-antigravity\"));\n        }\n    }\n\n    paths\n}\n\n/// Get only existing cache paths\npub fn get_existing_cache_paths() -> Vec<PathBuf> {\n    get_antigravity_cache_paths()\n        .into_iter()\n        .filter(|p| p.exists())\n        .collect()\n}\n\n/// Calculate directory size recursively\nfn get_dir_size(path: &PathBuf) -> u64 {\n    let mut size = 0u64;\n\n    if path.is_file() {\n        if let Ok(metadata) = fs::metadata(path) {\n            return metadata.len();\n        }\n        return 0;\n    }\n\n    if let Ok(entries) = fs::read_dir(path) {\n        for entry in entries.flatten() {\n            let entry_path = entry.path();\n            if entry_path.is_file() {\n                if let Ok(metadata) = fs::metadata(&entry_path) {\n                    size += metadata.len();\n                }\n            } else if entry_path.is_dir() {\n                size += get_dir_size(&entry_path);\n            }\n        }\n    }\n\n    size\n}\n\n/// Clear a single directory and return size freed\nfn clear_directory(path: &PathBuf) -> Result<u64, String> {\n    if !path.exists() {\n        return Ok(0);\n    }\n\n    let size = get_dir_size(path);\n\n    // Remove directory contents\n    fs::remove_dir_all(path).map_err(|e| format!(\"Failed to remove {}: {}\", path.display(), e))?;\n\n    Ok(size)\n}\n\n/// Clear Antigravity application cache\n///\n/// # Arguments\n/// * `custom_paths` - Optional custom paths to clear. If None, uses default platform paths.\n///\n/// # Returns\n/// * `ClearResult` containing cleared paths, total size freed, and any errors\npub fn clear_antigravity_cache(custom_paths: Option<Vec<String>>) -> Result<ClearResult, String> {\n    let paths: Vec<PathBuf> = match custom_paths {\n        Some(custom) => custom.into_iter().map(PathBuf::from).collect(),\n        None => get_antigravity_cache_paths(),\n    };\n\n    logger::log_info(&format!(\n        \"Starting Antigravity cache clearing, {} potential paths\",\n        paths.len()\n    ));\n\n    let mut result = ClearResult {\n        cleared_paths: Vec::new(),\n        total_size_freed: 0,\n        errors: Vec::new(),\n    };\n\n    for path in paths {\n        if !path.exists() {\n            logger::log_info(&format!(\"Cache path does not exist, skipping: {}\", path.display()));\n            continue;\n        }\n\n        logger::log_info(&format!(\"Clearing cache: {}\", path.display()));\n\n        match clear_directory(&path) {\n            Ok(size) => {\n                result.cleared_paths.push(path.to_string_lossy().to_string());\n                result.total_size_freed += size;\n                logger::log_info(&format!(\n                    \"Cleared {}: {:.2} MB freed\",\n                    path.display(),\n                    size as f64 / 1024.0 / 1024.0\n                ));\n            }\n            Err(e) => {\n                logger::log_warn(&format!(\"Failed to clear {}: {}\", path.display(), e));\n                result.errors.push(e);\n            }\n        }\n    }\n\n    let total_mb = result.total_size_freed as f64 / 1024.0 / 1024.0;\n    logger::log_info(&format!(\n        \"Antigravity cache clearing completed: {} paths cleared, {:.2} MB freed, {} errors\",\n        result.cleared_paths.len(),\n        total_mb,\n        result.errors.len()\n    ));\n\n    Ok(result)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_get_cache_paths_not_empty() {\n        let paths = get_antigravity_cache_paths();\n        assert!(!paths.is_empty(), \"Should return at least one cache path\");\n    }\n\n    #[test]\n    fn test_clear_result_serialization() {\n        let result = ClearResult {\n            cleared_paths: vec![\"/test/path\".to_string()],\n            total_size_freed: 1024,\n            errors: vec![],\n        };\n\n        let json = serde_json::to_string(&result).unwrap();\n        assert!(json.contains(\"cleared_paths\"));\n        assert!(json.contains(\"total_size_freed\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/modules/cloudflared.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\nuse std::process::Stdio;\nuse std::sync::Arc;\nuse tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};\nuse tokio::process::{Child, Command};\nuse tokio::sync::RwLock;\nuse tracing::{debug, info};\n\n#[cfg(target_os = \"windows\")]\nuse std::os::windows::process::CommandExt;\n\n#[cfg(target_os = \"windows\")]\nconst CREATE_NO_WINDOW: u32 = 0x08000000;\n#[cfg(target_os = \"windows\")]\nconst DETACHED_PROCESS: u32 = 0x00000008;\n#[cfg(target_os = \"windows\")]\nconst CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;\n\n/// Cloudflared隧道模式\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]\n#[serde(rename_all = \"snake_case\")]\npub enum TunnelMode {\n    /// 快速隧道(临时URL)\n    Quick,\n    /// 认证隧道(使用Token)\n    Auth,\n}\n\nimpl Default for TunnelMode {\n    fn default() -> Self {\n        Self::Quick\n    }\n}\n\n/// Cloudflared配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CloudflaredConfig {\n    #[serde(default)]\n    pub enabled: bool,\n    #[serde(default)]\n    pub mode: TunnelMode,\n    /// 代理的本地端口\n    pub port: u16,\n    /// 认证模式的Token\n    #[serde(default)]\n    pub token: Option<String>,\n    /// 使用http2协议(更兼容)\n    #[serde(default)]\n    pub use_http2: bool,\n}\n\nimpl Default for CloudflaredConfig {\n    fn default() -> Self {\n        Self {\n            enabled: false,\n            mode: TunnelMode::Quick,\n            port: 8045,\n            token: None,\n            use_http2: true, // 默认启用http2，更稳定\n        }\n    }\n}\n\n/// Cloudflared状态\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CloudflaredStatus {\n    pub installed: bool,\n    pub version: Option<String>,\n    pub running: bool,\n    pub url: Option<String>,\n    pub error: Option<String>,\n}\n\nimpl Default for CloudflaredStatus {\n    fn default() -> Self {\n        Self {\n            installed: false,\n            version: None,\n            running: false,\n            url: None,\n            error: None,\n        }\n    }\n}\n\n/// Cloudflared管理器状态\npub struct CloudflaredManager {\n    process: Arc<RwLock<Option<Child>>>,\n    status: Arc<RwLock<CloudflaredStatus>>,\n    bin_path: PathBuf,\n    /// 用于通知进程监控任务停止\n    shutdown_tx: RwLock<Option<tokio::sync::oneshot::Sender<()>>>,\n}\n\nimpl CloudflaredManager {\n    pub fn new(data_dir: &PathBuf) -> Self {\n        let bin_name = if cfg!(target_os = \"windows\") {\n            \"cloudflared.exe\"\n        } else {\n            \"cloudflared\"\n        };\n        let bin_path = data_dir.join(\"bin\").join(bin_name);\n\n        Self {\n            process: Arc::new(RwLock::new(None)),\n            status: Arc::new(RwLock::new(CloudflaredStatus::default())),\n            bin_path,\n            shutdown_tx: RwLock::new(None),\n        }\n    }\n\n    /// 检查是否已安装\n    pub async fn check_installed(&self) -> (bool, Option<String>) {\n        if !self.bin_path.exists() {\n            return (false, None);\n        }\n\n        let mut cmd = Command::new(&self.bin_path);\n        cmd.arg(\"--version\");\n        #[cfg(target_os = \"windows\")]\n        cmd.creation_flags(CREATE_NO_WINDOW);\n        \n        match cmd.output().await {\n            Ok(output) => {\n                if output.status.success() {\n                    let version = String::from_utf8_lossy(&output.stdout)\n                        .lines()\n                        .next()\n                        .map(|s| s.trim().to_string());\n                    (true, version)\n                } else {\n                    (false, None)\n                }\n            }\n            Err(_) => (false, None),\n        }\n    }\n\n    /// 获取当前状态\n    pub async fn get_status(&self) -> CloudflaredStatus {\n        self.status.read().await.clone()\n    }\n\n    /// 更新状态\n    async fn update_status(&self, f: impl FnOnce(&mut CloudflaredStatus)) {\n        let mut status = self.status.write().await;\n        f(&mut status);\n    }\n\n    /// 安装cloudflared\n    pub async fn install(&self) -> Result<CloudflaredStatus, String> {\n        let bin_dir = self.bin_path.parent().unwrap();\n        if !bin_dir.exists() {\n            std::fs::create_dir_all(bin_dir)\n                .map_err(|e| format!(\"Failed to create bin directory: {}\", e))?;\n        }\n\n        let download_url = get_download_url()?;\n        info!(\"[cloudflared] Downloading from: {}\", download_url);\n\n        let response = reqwest::get(&download_url)\n            .await\n            .map_err(|e| format!(\"Download failed: {}\", e))?;\n\n        if !response.status().is_success() {\n            return Err(format!(\"Download failed with status: {}\", response.status()));\n        }\n\n        let bytes = response\n            .bytes()\n            .await\n            .map_err(|e| format!(\"Failed to read response: {}\", e))?;\n\n        let is_archive = download_url.ends_with(\".tgz\");\n        if is_archive {\n            let archive_path = self.bin_path.with_extension(\"tgz\");\n            std::fs::write(&archive_path, &bytes)\n                .map_err(|e| format!(\"Failed to write archive: {}\", e))?;\n\n            let status = Command::new(\"tar\")\n                .arg(\"-xzf\")\n                .arg(&archive_path)\n                .arg(\"-C\")\n                .arg(bin_dir)\n                .status()\n                .await\n                .map_err(|e| format!(\"Failed to extract archive: {}\", e))?;\n\n            if !status.success() {\n                return Err(\"Failed to extract cloudflared archive\".to_string());\n            }\n\n            let _ = std::fs::remove_file(&archive_path);\n        } else {\n            std::fs::write(&self.bin_path, &bytes)\n                .map_err(|e| format!(\"Failed to write binary: {}\", e))?;\n        }\n\n        #[cfg(unix)]\n        {\n            use std::os::unix::fs::PermissionsExt;\n            std::fs::set_permissions(&self.bin_path, std::fs::Permissions::from_mode(0o755))\n                .map_err(|e| format!(\"Failed to set permissions: {}\", e))?;\n        }\n\n        let (installed, version) = self.check_installed().await;\n        self.update_status(|s| {\n            s.installed = installed;\n            s.version = version.clone();\n        }).await;\n\n        info!(\"[cloudflared] Installed successfully, version: {:?}\", version);\n        Ok(self.get_status().await)\n    }\n\n    /// 启动隧道\n    pub async fn start(&self, config: CloudflaredConfig) -> Result<CloudflaredStatus, String> {\n        // 检查是否已在运行\n        {\n            let proc = self.process.read().await;\n            if proc.is_some() {\n                return Ok(self.get_status().await);\n            }\n        }\n\n        // 停止之前的监控任务\n        if let Some(tx) = self.shutdown_tx.write().await.take() {\n            let _ = tx.send(());\n        }\n\n        let (installed, version) = self.check_installed().await;\n        if !installed {\n            return Err(\"Cloudflared not installed\".to_string());\n        }\n\n        let local_url = format!(\"http://localhost:{}\", config.port);\n        info!(\"[cloudflared] Starting tunnel to: {}\", local_url);\n\n        let mut cmd = Command::new(&self.bin_path);\n        \n        // 设置工作目录\n        // 设置工作目录\n        if let Some(bin_dir) = self.bin_path.parent() {\n            cmd.current_dir(bin_dir);\n            debug!(\"[cloudflared] Working directory: {:?}\", bin_dir);\n        }\n\n        match config.mode {\n            TunnelMode::Quick => {\n                cmd.arg(\"tunnel\")\n                    .arg(\"--url\")\n                    .arg(&local_url);\n                \n                // 注意：--no-autoupdate 参数在较新版本的 cloudflared 中已不被支持，会导致进程立即退出\n                // cmd.arg(\"--no-autoupdate\");\n\n                if config.use_http2 {\n                    cmd.arg(\"--protocol\").arg(\"http2\");\n                }\n                \n                // 注意：--loglevel 参数在此上下文中也会导致 Incorrect Usage 错误，故移除以使用默认值\n                // cmd.arg(\"--loglevel\").arg(\"info\");\n                \n                info!(\"[cloudflared] Command args: tunnel --url {} ...\", local_url);\n            }\n            TunnelMode::Auth => {\n                if let Some(token) = &config.token {\n                    cmd.arg(\"tunnel\")\n                        .arg(\"run\")\n                        .arg(\"--token\")\n                        .arg(token);\n                    \n                    // 注意：--no-autoupdate 参数不被支持\n                    // cmd.arg(\"--no-autoupdate\");\n                    \n                    if config.use_http2 {\n                        cmd.arg(\"--protocol\").arg(\"http2\");\n                    }\n                    \n                    // 注意：--loglevel 参数不被支持\n                    // cmd.arg(\"--loglevel\").arg(\"info\");\n                    \n                    info!(\"[cloudflared] Command args: tunnel run --token [HIDDEN] ...\");\n                } else {\n                    return Err(\"Token required for auth mode\".to_string());\n                }\n            }\n        }\n\n        // 恢复管道\n        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());\n        \n        // 使用 DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP 隐藏窗口\n        #[cfg(target_os = \"windows\")]\n        cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);\n\n        let mut child = cmd.spawn().map_err(|e| format!(\"Failed to spawn: {}\", e))?;\n\n        let stdout = child.stdout.take();\n        let stderr = child.stderr.take();\n\n        let status_clone = self.status.clone();\n        if let Some(stdout) = stdout {\n            spawn_log_reader(stdout, status_clone.clone());\n        }\n\n        if let Some(stderr) = stderr {\n            spawn_log_reader(stderr, status_clone.clone());\n        }\n\n        *self.process.write().await = Some(child);\n        self.update_status(|s| {\n            s.installed = installed.clone();\n            s.version = version.clone();\n            s.running = true;\n            s.error = None;\n        }).await;\n\n        // 启动进程监控任务\n        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();\n        *self.shutdown_tx.write().await = Some(shutdown_tx);\n\n        let process_ref = self.process.clone();\n        let status_ref = self.status.clone();\n\n        tokio::spawn(async move {\n            tokio::select! {\n                _ = shutdown_rx => {\n                    debug!(\"[cloudflared] Process monitor shutdown\");\n                }\n                _ = async {\n                    loop {\n                        tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;\n\n                        let mut proc_lock = process_ref.write().await;\n                        if let Some(ref mut child) = *proc_lock {\n                            match child.try_wait() {\n                                Ok(Some(exit_status)) => {\n                                    // 进程已退出\n                                    info!(\"[cloudflared] Process exited with status: {:?}\", exit_status);\n                                    *proc_lock = None;\n                                    drop(proc_lock);\n\n                                    let mut s = status_ref.write().await;\n                                    s.running = false;\n                                    s.error = Some(format!(\"Tunnel process exited (status: {:?})\", exit_status));\n                                    break;\n                                }\n                                Ok(None) => {\n                                    // 进程仍在运行\n                                }\n                                Err(e) => {\n                                    info!(\"[cloudflared] Error checking process: {}\", e);\n                                    *proc_lock = None;\n                                    drop(proc_lock);\n\n                                    let mut s = status_ref.write().await;\n                                    s.running = false;\n                                    s.error = Some(format!(\"Error checking tunnel: {}\", e));\n                                    break;\n                                }\n                            }\n                        } else {\n                            // 进程不存在\n                            drop(proc_lock);\n                            let mut s = status_ref.write().await;\n                            if s.running {\n                                s.running = false;\n                                s.error = Some(\"Tunnel process not found\".to_string());\n                            }\n                            break;\n                        }\n                    }\n                } => {}\n            }\n        });\n\n        Ok(self.get_status().await)\n    }\n\n    /// 停止隧道\n    pub async fn stop(&self) -> Result<CloudflaredStatus, String> {\n        let mut proc_lock = self.process.write().await;\n        if let Some(mut child) = proc_lock.take() {\n            let _ = child.kill().await;\n            info!(\"[cloudflared] Tunnel stopped\");\n        }\n\n        self.update_status(|s| {\n            s.running = false;\n            s.url = None;\n            s.error = None;\n        }).await;\n\n        Ok(self.get_status().await)\n    }\n}\n\n/// 获取下载URL\nfn get_download_url() -> Result<String, String> {\n    let os = std::env::consts::OS;\n    let arch = std::env::consts::ARCH;\n\n    let (os_str, arch_str, ext) = match (os, arch) {\n        (\"macos\", \"aarch64\") => (\"darwin\", \"arm64\", \".tgz\"),\n        (\"macos\", \"x86_64\") => (\"darwin\", \"amd64\", \".tgz\"),\n        (\"linux\", \"x86_64\") => (\"linux\", \"amd64\", \"\"),\n        (\"linux\", \"aarch64\") => (\"linux\", \"arm64\", \"\"),\n        (\"windows\", \"x86_64\") => (\"windows\", \"amd64\", \".exe\"),\n        _ => return Err(format!(\"Unsupported platform: {}-{}\", os, arch)),\n    };\n\n    Ok(format!(\n        \"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-{}-{}{}\",\n        os_str, arch_str, ext\n    ))\n}\n\nfn spawn_log_reader<R>(stream: R, status_ref: Arc<RwLock<CloudflaredStatus>>)\nwhere\n    R: AsyncRead + Unpin + Send + 'static,\n{\n    tokio::spawn(async move {\n        let reader = BufReader::new(stream);\n        let mut lines = reader.lines();\n        while let Ok(Some(line)) = lines.next_line().await {\n            // 恢复日志级别为 debug，避免污染生产环境日志\n            debug!(\"[cloudflared output] {}\", line);\n            if let Some(url) = extract_tunnel_url(&line) {\n                info!(\"[cloudflared] Tunnel URL: {}\", url);\n                let mut s = status_ref.write().await;\n                s.url = Some(url);\n            }\n        }\n    });\n}\n\n/// 从日志行提取隧道URL\n/// 支持两种模式：\n/// 1. 快速隧道：直接提取 .trycloudflare.com URL\n/// 2. 命名隧道：从 ingress 配置中解析 hostname\nfn extract_tunnel_url(line: &str) -> Option<String> {\n    // 快速隧道模式：直接查找 trycloudflare.com URL\n    if let Some(url) = line.split_whitespace()\n        .find(|s| s.starts_with(\"https://\") && s.contains(\".trycloudflare.com\"))\n    {\n        return Some(url.to_string());\n    }\n    \n    // 命名隧道模式：从 \"Updated to new configuration\" 日志中解析 hostname\n    // 日志格式示例：Updated to new configuration config=\"{\\\"ingress\\\":[{\\\"hostname\\\":\\\"api.example.com\\\", ...}]}\"\n    if line.contains(\"Updated to new configuration\") && line.contains(\"ingress\") {\n        // 查找 hostname 字段\n        if let Some(start) = line.find(\"\\\\\\\"hostname\\\\\\\":\\\\\\\"\") {\n            let after_key = &line[start + 15..]; // 跳过 \\\"hostname\\\":\\\" (共15字符)\n            if let Some(end) = after_key.find(\"\\\\\\\"\") {\n                let hostname = &after_key[..end];\n                if !hostname.is_empty() {\n                    return Some(format!(\"https://{}\", hostname));\n                }\n            }\n        }\n    }\n    \n    None\n}\n\n"
  },
  {
    "path": "src-tauri/src/modules/config.rs",
    "content": "use std::fs;\nuse serde_json;\n\nuse crate::models::AppConfig;\nuse super::account::get_data_dir;\nuse tracing::warn;\n\nconst CONFIG_FILE: &str = \"gui_config.json\";\n\n/// Load application configuration\npub fn load_app_config() -> Result<AppConfig, String> {\n    let data_dir = get_data_dir()?;\n    let config_path = data_dir.join(CONFIG_FILE);\n    \n    if !config_path.exists() {\n        let config = AppConfig::new();\n        // [FIX #1460] Persist initial config to prevent new API Key on every refresh\n        let _ = save_app_config(&config);\n        return Ok(config);\n    }\n    \n    let content = fs::read_to_string(&config_path)\n        .map_err(|e| format!(\"failed_to_read_config_file: {}\", e))?;\n    \n    let mut v: serde_json::Value = serde_json::from_str(&content)\n        .map_err(|e| format!(\"failed_to_parse_config_file: {}\", e))?;\n    \n    let mut modified = false;\n\n    // Migration logic\n    if let Some(proxy) = v.get_mut(\"proxy\") {\n        // [FIX #1738] Enhanced type checking for custom_mapping\n        // Ensures the field is always parsed as an object, preventing type mismatch errors\n        let mut custom_mapping = match proxy.get(\"custom_mapping\") {\n            Some(m) if m.is_object() => m.as_object().unwrap().clone(),\n            Some(m) => {\n                // If custom_mapping is not an object type (e.g., string), log warning and reset to empty\n                tracing::warn!(\"Invalid custom_mapping type (expected object, got {:?}), resetting to empty\", m);\n                serde_json::Map::new()\n            }\n            None => serde_json::Map::new(),\n        };\n\n        // Migrate Anthropic mapping\n        if let Some(anthropic) = proxy.get_mut(\"anthropic_mapping\").and_then(|m| m.as_object_mut()) {\n            for (k, v) in anthropic.iter() {\n                // Only move non-series fields, as series fields are now handled by Preset logic or builtin tables\n                if !k.ends_with(\"-series\") {\n                    if !custom_mapping.contains_key(k) {\n                        custom_mapping.insert(k.clone(), v.clone());\n                    }\n                }\n            }\n            // Remove old field\n            proxy.as_object_mut().unwrap().remove(\"anthropic_mapping\");\n            modified = true;\n        }\n\n        // Migrate OpenAI mapping\n        if let Some(openai) = proxy.get_mut(\"openai_mapping\").and_then(|m| m.as_object_mut()) {\n            for (k, v) in openai.iter() {\n                if !k.ends_with(\"-series\") {\n                    if !custom_mapping.contains_key(k) {\n                        custom_mapping.insert(k.clone(), v.clone());\n                    }\n                }\n            }\n            // Remove old field\n            proxy.as_object_mut().unwrap().remove(\"openai_mapping\");\n            modified = true;\n        }\n\n        if modified {\n            proxy.as_object_mut().unwrap().insert(\"custom_mapping\".to_string(), serde_json::Value::Object(custom_mapping));\n        }\n    }\n\n    let config: AppConfig = serde_json::from_value(v)\n        .map_err(|e| format!(\"failed_to_convert_config_after_migration: {}\", e))?;\n    \n    // If migration occurred, auto-save once to clean up the file\n    if modified {\n        let _ = save_app_config(&config);\n    }\n\n    Ok(config)\n}\n\n/// Save application configuration\npub fn save_app_config(config: &AppConfig) -> Result<(), String> {\n    let data_dir = get_data_dir()?;\n    let config_path = data_dir.join(CONFIG_FILE);\n    \n    let content = serde_json::to_string_pretty(config)\n        .map_err(|e| format!(\"failed_to_serialize_config: {}\", e))?;\n    \n    fs::write(&config_path, content)\n        .map_err(|e| format!(\"failed_to_save_config: {}\", e))\n}\n"
  },
  {
    "path": "src-tauri/src/modules/db.rs",
    "content": "use crate::utils::protobuf;\nuse rusqlite::Connection;\nuse std::path::PathBuf;\n\nfn get_antigravity_path() -> Option<PathBuf> {\n    if let Ok(config) = crate::modules::config::load_app_config() {\n        if let Some(path_str) = config.antigravity_executable {\n            let path = PathBuf::from(path_str);\n            if path.exists() {\n                return Some(path);\n            }\n        }\n    }\n    crate::modules::process::get_antigravity_executable_path()\n}\n\n/// Get Antigravity database path (cross-platform)\npub fn get_db_path() -> Result<PathBuf, String> {\n    // Prefer path specified by --user-data-dir argument\n    if let Some(user_data_dir) = crate::modules::process::get_user_data_dir_from_process() {\n        let custom_db_path = user_data_dir.join(\"User\").join(\"globalStorage\").join(\"state.vscdb\");\n        if custom_db_path.exists() {\n            return Ok(custom_db_path);\n        }\n    }\n\n    // Check if in portable mode\n    if let Some(antigravity_path) = get_antigravity_path() {\n        if let Some(parent_dir) = antigravity_path.parent() {\n            let portable_db_path = PathBuf::from(parent_dir)\n                .join(\"data\")\n                .join(\"user-data\")\n                .join(\"User\")\n                .join(\"globalStorage\")\n                .join(\"state.vscdb\");\n\n            if portable_db_path.exists() {\n                return Ok(portable_db_path);\n            }\n        }\n    }\n\n    // Standard mode: use system default path\n    #[cfg(target_os = \"macos\")]\n    {\n        let home = dirs::home_dir().ok_or(\"Failed to get home directory\")?;\n        Ok(home.join(\"Library/Application Support/Antigravity/User/globalStorage/state.vscdb\"))\n    }\n\n    #[cfg(target_os = \"windows\")]\n    {\n        let appdata =\n            std::env::var(\"APPDATA\").map_err(|_| \"Failed to get APPDATA environment variable\".to_string())?;\n        Ok(PathBuf::from(appdata).join(\"Antigravity\\\\User\\\\globalStorage\\\\state.vscdb\"))\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        let home = dirs::home_dir().ok_or(\"Failed to get home directory\")?;\n        Ok(home.join(\".config/Antigravity/User/globalStorage/state.vscdb\"))\n    }\n}\n\n/// Inject Token and Email into database\npub fn inject_token(\n    db_path: &PathBuf,\n    access_token: &str,\n    refresh_token: &str,\n    expiry: i64,\n    email: &str,\n) -> Result<String, String> {\n    crate::modules::logger::log_info(\"Starting Token injection...\");\n    \n    // 1. Detect Antigravity version\n    let version_result = crate::modules::version::get_antigravity_version();\n    \n    match version_result {\n        Ok(ver) => {\n            crate::modules::logger::log_info(&format!(\n                \"Detected Antigravity version: {}\",\n                ver.short_version\n            ));\n            \n            // 2. Choose injection strategy based on version\n            if crate::modules::version::is_new_version(&ver) {\n                // >= 1.16.5: Use new format only\n                crate::modules::logger::log_info(\n                    \"Using new format injection (antigravityUnifiedStateSync.oauthToken)\"\n                );\n                inject_new_format(db_path, access_token, refresh_token, expiry)\n            } else {\n                // < 1.16.5: Use old format only\n                crate::modules::logger::log_info(\n                    \"Using old format injection (jetskiStateSync.agentManagerInitState)\"\n                );\n                inject_old_format(db_path, access_token, refresh_token, expiry, email)\n            }\n        }\n        Err(e) => {\n            // Cannot detect version: Try both formats (fallback)\n            crate::modules::logger::log_warn(&format!(\n                \"Version detection failed, trying both formats for compatibility: {}\",\n                e\n            ));\n            \n            // Try new format first\n            let new_result = inject_new_format(db_path, access_token, refresh_token, expiry);\n            \n            // Try old format\n            let old_result = inject_old_format(db_path, access_token, refresh_token, expiry, email);\n            \n            // Return success if either format succeeded\n            if new_result.is_ok() || old_result.is_ok() {\n                Ok(\"Token injection successful (dual format fallback)\".to_string())\n            } else {\n                Err(format!(\n                    \"Both formats failed - New: {:?}, Old: {:?}\",\n                    new_result.err(),\n                    old_result.err()\n                ))\n            }\n        }\n    }\n}\n\n/// New format injection (>= 1.16.5)\nfn inject_new_format(\n    db_path: &PathBuf,\n    access_token: &str,\n    refresh_token: &str,\n    expiry: i64,\n) -> Result<String, String> {\n    use base64::{engine::general_purpose, Engine as _};\n    \n    let conn = Connection::open(db_path)\n        .map_err(|e| format!(\"Failed to open database: {}\", e))?;\n    \n    // Create OAuthTokenInfo (binary)\n    let oauth_info = protobuf::create_oauth_info(access_token, refresh_token, expiry);\n    let oauth_info_b64 = general_purpose::STANDARD.encode(&oauth_info);\n    \n    // InnerMessage2: field 1 = base64(oauth_info)\n    let inner2 = protobuf::encode_string_field(1, &oauth_info_b64);\n    \n    // InnerMessage: field 1 = sentinel key, field 2 = inner2\n    let inner1 = protobuf::encode_string_field(1, \"oauthTokenInfoSentinelKey\");\n    let inner = [inner1, protobuf::encode_len_delim_field(2, &inner2)].concat();\n    \n    // OuterMessage: field 1 = inner\n    let outer = protobuf::encode_len_delim_field(1, &inner);\n    let outer_b64 = general_purpose::STANDARD.encode(&outer);\n    \n    conn.execute(\n        \"INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)\",\n        [\"antigravityUnifiedStateSync.oauthToken\", &outer_b64],\n    )\n    .map_err(|e| format!(\"Failed to write new format: {}\", e))?;\n    \n    // Inject Onboarding flag\n    conn.execute(\n        \"INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)\",\n        [\"antigravityOnboarding\", \"true\"],\n    )\n    .map_err(|e| format!(\"Failed to write onboarding flag: {}\", e))?;\n    \n    Ok(\"Token injection successful (new format)\".to_string())\n}\n\n/// Old format injection (< 1.16.5)\nfn inject_old_format(\n    db_path: &PathBuf,\n    access_token: &str,\n    refresh_token: &str,\n    expiry: i64,\n    email: &str,\n) -> Result<String, String> {\n    use base64::{engine::general_purpose, Engine as _};\n    use rusqlite::Error as SqliteError;\n    \n    let conn = Connection::open(db_path)\n        .map_err(|e| format!(\"Failed to open database: {}\", e))?;\n    \n    // Read current data\n    let current_data: String = conn\n        .query_row(\n            \"SELECT value FROM ItemTable WHERE key = ?\",\n            [\"jetskiStateSync.agentManagerInitState\"],\n            |row| row.get(0),\n        )\n        .map_err(|e| match e {\n            SqliteError::QueryReturnedNoRows => {\n                \"Old format key does not exist, possibly new version Antigravity\".to_string()\n            }\n            _ => format!(\"Failed to read data: {}\", e),\n        })?;\n    \n    // Base64 decode\n    let blob = general_purpose::STANDARD\n        .decode(&current_data)\n        .map_err(|e| format!(\"Base64 decoding failed: {}\", e))?;\n    \n    // Remove old fields\n    let mut clean_data = protobuf::remove_field(&blob, 1)?; // UserID\n    clean_data = protobuf::remove_field(&clean_data, 2)?;   // Email\n    clean_data = protobuf::remove_field(&clean_data, 6)?;   // OAuthTokenInfo\n    \n    // Create new fields\n    let new_email_field = protobuf::create_email_field(email);\n    let new_oauth_field = protobuf::create_oauth_field(access_token, refresh_token, expiry);\n    \n    // Merge data\n    // We intentionally do NOT re-inject Field 1 (UserID) to force the client \n    // to re-authenticate the session with the new token.\n    let final_data = [clean_data, new_email_field, new_oauth_field].concat();\n    let final_b64 = general_purpose::STANDARD.encode(&final_data);\n    \n    // Write to database\n    conn.execute(\n        \"UPDATE ItemTable SET value = ? WHERE key = ?\",\n        [&final_b64, \"jetskiStateSync.agentManagerInitState\"],\n    )\n    .map_err(|e| format!(\"Failed to write data: {}\", e))?;\n    \n    // Inject Onboarding flag\n    conn.execute(\n        \"INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)\",\n        [\"antigravityOnboarding\", \"true\"],\n    )\n    .map_err(|e| format!(\"Failed to write onboarding flag: {}\", e))?;\n    \n    Ok(\"Token injection successful (old format)\".to_string())\n}\n"
  },
  {
    "path": "src-tauri/src/modules/device.rs",
    "content": "use crate::models::DeviceProfile;\nuse crate::modules::{logger, process};\nuse chrono::Local;\nuse rand::{distributions::Alphanumeric, Rng};\nuse rusqlite::Connection;\nuse serde_json::Value;\nuse std::fs;\nuse std::path::{Path, PathBuf};\nuse uuid::Uuid;\n\nconst DATA_DIR: &str = \".antigravity_tools\";\nconst GLOBAL_BASELINE: &str = \"device_original.json\";\n\nfn get_data_dir() -> Result<PathBuf, String> {\n    let home = dirs::home_dir().ok_or(\"failed_to_get_home_dir\")?;\n    let data_dir = home.join(DATA_DIR);\n    if !data_dir.exists() {\n        fs::create_dir_all(&data_dir).map_err(|e| format!(\"failed_to_create_data_dir: {}\", e))?;\n    }\n    Ok(data_dir)\n}\n\n/// Find storage.json path (prefer custom/portable paths)\npub fn get_storage_path() -> Result<PathBuf, String> {\n    // 1) --user-data-dir flag\n    if let Some(user_data_dir) = process::get_user_data_dir_from_process() {\n        let path = user_data_dir\n            .join(\"User\")\n            .join(\"globalStorage\")\n            .join(\"storage.json\");\n        if path.exists() {\n            return Ok(path);\n        }\n    }\n\n    // 2) Portable mode (based on executable data/user-data)\n    if let Some(exe_path) = process::get_antigravity_executable_path() {\n        if let Some(parent) = exe_path.parent() {\n            let portable = parent\n                .join(\"data\")\n                .join(\"user-data\")\n                .join(\"User\")\n                .join(\"globalStorage\")\n                .join(\"storage.json\");\n            if portable.exists() {\n                return Ok(portable);\n            }\n        }\n    }\n\n    // 3) Standard installation location\n    #[cfg(target_os = \"macos\")]\n    {\n        let home = dirs::home_dir().ok_or(\"failed_to_get_home_dir\")?;\n        let path =\n            home.join(\"Library/Application Support/Antigravity/User/globalStorage/storage.json\");\n        if path.exists() {\n            return Ok(path);\n        }\n    }\n\n    #[cfg(target_os = \"windows\")]\n    {\n        let appdata =\n            std::env::var(\"APPDATA\").map_err(|_| \"failed_to_get_appdata_env\".to_string())?;\n        let path = PathBuf::from(appdata).join(\"Antigravity\\\\User\\\\globalStorage\\\\storage.json\");\n        if path.exists() {\n            return Ok(path);\n        }\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        let home = dirs::home_dir().ok_or(\"failed_to_get_home_dir\")?;\n        let path = home.join(\".config/Antigravity/User/globalStorage/storage.json\");\n        if path.exists() {\n            return Ok(path);\n        }\n    }\n\n    Err(\"storage_json_not_found\".to_string())\n}\n\n/// Get directory of storage.json\npub fn get_storage_dir() -> Result<PathBuf, String> {\n    let path = get_storage_path()?;\n    path.parent()\n        .map(|p| p.to_path_buf())\n        .ok_or_else(|| \"failed_to_get_storage_parent_dir\".to_string())\n}\n\n/// Get state.vscdb path (same directory as storage.json)\npub fn get_state_db_path() -> Result<PathBuf, String> {\n    let dir = get_storage_dir()?;\n    Ok(dir.join(\"state.vscdb\"))\n}\n\n/// Backup storage.json, returns backup file path\n#[allow(dead_code)]\npub fn backup_storage(storage_path: &Path) -> Result<PathBuf, String> {\n    if !storage_path.exists() {\n        return Err(format!(\"storage_json_missing: {:?}\", storage_path));\n    }\n    let dir = storage_path\n        .parent()\n        .ok_or_else(|| \"failed_to_get_storage_parent_dir\".to_string())?;\n    let backup_path = dir.join(format!(\n        \"storage.json.backup_{}\",\n        Local::now().format(\"%Y%m%d_%H%M%S\")\n    ));\n    fs::copy(storage_path, &backup_path).map_err(|e| format!(\"backup_failed: {}\", e))?;\n    Ok(backup_path)\n}\n\n/// Read current device profile from storage.json\n#[allow(dead_code)]\npub fn read_profile(storage_path: &Path) -> Result<DeviceProfile, String> {\n    let content =\n        fs::read_to_string(storage_path).map_err(|e| format!(\"read_failed ({:?}): {}\", storage_path, e))?;\n    let json: Value =\n        serde_json::from_str(&content).map_err(|e| format!(\"parse_failed ({:?}): {}\", storage_path, e))?;\n\n    // Supports nested telemetry or flat telemetry.xxx\n    let get_field = |key: &str| -> Option<String> {\n        if let Some(obj) = json.get(\"telemetry\").and_then(|v| v.as_object()) {\n            if let Some(v) = obj.get(key).and_then(|v| v.as_str()) {\n                return Some(v.to_string());\n            }\n        }\n        if let Some(v) = json\n            .get(format!(\"telemetry.{key}\"))\n            .and_then(|v| v.as_str())\n        {\n            return Some(v.to_string());\n        }\n        None\n    };\n\n    Ok(DeviceProfile {\n        machine_id: get_field(\"machineId\").ok_or(\"missing_machine_id\")?,\n        mac_machine_id: get_field(\"macMachineId\").ok_or(\"missing_mac_machine_id\")?,\n        dev_device_id: get_field(\"devDeviceId\").ok_or(\"missing_dev_device_id\")?,\n        sqm_id: get_field(\"sqmId\").ok_or(\"missing_sqm_id\")?,\n    })\n}\n\n/// Write device profile to storage.json\npub fn write_profile(storage_path: &Path, profile: &DeviceProfile) -> Result<(), String> {\n    if !storage_path.exists() {\n        return Err(format!(\"storage_json_missing: {:?}\", storage_path));\n    }\n\n    let content =\n        fs::read_to_string(storage_path).map_err(|e| format!(\"read_failed: {}\", e))?;\n    let mut json: Value =\n        serde_json::from_str(&content).map_err(|e| format!(\"parse_failed: {}\", e))?;\n\n    // Ensure telemetry is an object\n    if !json.get(\"telemetry\").map_or(false, |v| v.is_object()) {\n        if json.as_object_mut().is_some() {\n            json[\"telemetry\"] = serde_json::json!({});\n        } else {\n            return Err(\"json_top_level_not_object\".to_string());\n        }\n    }\n\n    if let Some(telemetry) = json.get_mut(\"telemetry\").and_then(|v| v.as_object_mut()) {\n        telemetry.insert(\n            \"machineId\".to_string(),\n            Value::String(profile.machine_id.clone()),\n        );\n        telemetry.insert(\n            \"macMachineId\".to_string(),\n            Value::String(profile.mac_machine_id.clone()),\n        );\n        telemetry.insert(\n            \"devDeviceId\".to_string(),\n            Value::String(profile.dev_device_id.clone()),\n        );\n        telemetry.insert(\"sqmId\".to_string(), Value::String(profile.sqm_id.clone()));\n    } else {\n        return Err(\"telemetry_not_object\".to_string());\n    }\n\n    // Write flat keys as well, compatible with old formats\n    if let Some(map) = json.as_object_mut() {\n        map.insert(\n            \"telemetry.machineId\".to_string(),\n            Value::String(profile.machine_id.clone()),\n        );\n        map.insert(\n            \"telemetry.macMachineId\".to_string(),\n            Value::String(profile.mac_machine_id.clone()),\n        );\n        map.insert(\n            \"telemetry.devDeviceId\".to_string(),\n            Value::String(profile.dev_device_id.clone()),\n        );\n        map.insert(\n            \"telemetry.sqmId\".to_string(),\n            Value::String(profile.sqm_id.clone()),\n        );\n    }\n\n    // Sync storage.serviceMachineId (match with devDeviceId), place at root level\n    if let Some(map) = json.as_object_mut() {\n        map.insert(\n            \"storage.serviceMachineId\".to_string(),\n            Value::String(profile.dev_device_id.clone()),\n        );\n    }\n\n    let updated = serde_json::to_string_pretty(&json)\n        .map_err(|e| format!(\"serialize_failed: {}\", e))?;\n    fs::write(storage_path, updated).map_err(|e| format!(\"write_failed ({:?}): {}\", storage_path, e))?;\n    logger::log_info(&format!(\"device_profile_written to {:?}\", storage_path));\n\n    // Sync ItemTable.storage.serviceMachineId in state.vscdb\n    let _ = sync_state_service_machine_id_value(&profile.dev_device_id);\n    Ok(())\n}\n\n/// Only sync serviceMachineId, don't change other fields\n#[allow(dead_code)]\npub fn sync_service_machine_id(storage_path: &Path, service_id: &str) -> Result<(), String> {\n    let content =\n        fs::read_to_string(storage_path).map_err(|e| format!(\"read_failed: {}\", e))?;\n    let mut json: Value =\n        serde_json::from_str(&content).map_err(|e| format!(\"parse_failed: {}\", e))?;\n\n    if let Some(map) = json.as_object_mut() {\n        map.insert(\n            \"storage.serviceMachineId\".to_string(),\n            Value::String(service_id.to_string()),\n        );\n    }\n\n    let updated = serde_json::to_string_pretty(&json)\n        .map_err(|e| format!(\"serialize_failed: {}\", e))?;\n    fs::write(storage_path, updated).map_err(|e| format!(\"write_failed: {}\", e))?;\n    logger::log_info(\"service_machine_id_synced\");\n\n    let _ = sync_state_service_machine_id_value(service_id);\n    Ok(())\n}\n\n/// Read serviceMachineId from storage.json (fallback to devDeviceId), sync back if missing and sync state.vscdb\n#[allow(dead_code)]\npub fn sync_service_machine_id_from_storage(storage_path: &Path) -> Result<(), String> {\n    if !storage_path.exists() {\n        return Err(\"storage_json_missing\".to_string());\n    }\n    let content = fs::read_to_string(storage_path).map_err(|e| format!(\"read_failed: {}\", e))?;\n    let mut json: Value = serde_json::from_str(&content).map_err(|e| format!(\"parse_failed: {}\", e))?;\n\n    let service_id = json\n        .get(\"storage.serviceMachineId\")\n        .and_then(|v| v.as_str())\n        .map(|s| s.to_string())\n        .or_else(|| {\n            json.get(\"telemetry\")\n                .and_then(|t| t.get(\"devDeviceId\"))\n                .and_then(|v| v.as_str())\n                .map(|s| s.to_string())\n        })\n        .or_else(|| {\n            json.get(\"telemetry.devDeviceId\")\n                .and_then(|v| v.as_str())\n                .map(|s| s.to_string())\n        })\n        .ok_or(\"missing_ids_in_storage\")?;\n\n    let mut dirty = false;\n    if json\n        .get(\"storage.serviceMachineId\")\n        .and_then(|v| v.as_str())\n        .is_none()\n    {\n        if let Some(map) = json.as_object_mut() {\n            map.insert(\"storage.serviceMachineId\".to_string(), Value::String(service_id.clone()));\n            dirty = true;\n        }\n    }\n\n    if dirty {\n        let updated = serde_json::to_string_pretty(&json).map_err(|e| format!(\"serialize_failed: {}\", e))?;\n        fs::write(storage_path, updated).map_err(|e| format!(\"write_failed: {}\", e))?;\n        logger::log_info(\"service_machine_id_added\");\n    }\n\n    sync_state_service_machine_id_value(&service_id)\n}\n\nfn sync_state_service_machine_id_value(service_id: &str) -> Result<(), String> {\n    let db_path = get_state_db_path()?;\n    if !db_path.exists() {\n        logger::log_warn(&format!(\n            \"state_db_missing: {:?}\",\n            db_path\n        ));\n        return Ok(());\n    }\n\n    let conn = Connection::open(&db_path).map_err(|e| format!(\"db_open_failed: {}\", e))?;\n    conn.execute(\n        \"CREATE TABLE IF NOT EXISTS ItemTable (key TEXT PRIMARY KEY, value TEXT);\",\n        [],\n    )\n    .map_err(|e| format!(\"failed_to_create_item_table: {}\", e))?;\n    conn.execute(\n        \"INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('storage.serviceMachineId', ?1);\",\n        [service_id],\n    )\n    .map_err(|e| format!(\"failed_to_write_to_db: {}\", e))?;\n    logger::log_info(\"service_machine_id_synced_to_db\");\n    Ok(())\n}\n\n/// Load/Save global original profile (shared across all accounts)\npub fn load_global_original() -> Option<DeviceProfile> {\n    if let Ok(dir) = get_data_dir() {\n        let path = dir.join(GLOBAL_BASELINE);\n        if path.exists() {\n            if let Ok(content) = fs::read_to_string(&path) {\n                if let Ok(profile) = serde_json::from_str::<DeviceProfile>(&content) {\n                    return Some(profile);\n                }\n            }\n        }\n    }\n    None\n}\n\npub fn save_global_original(profile: &DeviceProfile) -> Result<(), String> {\n    let dir = get_data_dir()?;\n    let path = dir.join(GLOBAL_BASELINE);\n    if path.exists() {\n        return Ok(()); // already exists, don't overwrite\n    }\n    let content =\n        serde_json::to_string_pretty(profile).map_err(|e| format!(\"serialize_failed: {}\", e))?;\n    fs::write(&path, content).map_err(|e| format!(\"write_failed: {}\", e))\n}\n\n/// List storage.json backups in current directory (descending by time)\n#[allow(dead_code)]\npub fn list_backups(storage_path: &Path) -> Result<Vec<PathBuf>, String> {\n    let dir = storage_path\n        .parent()\n        .ok_or_else(|| \"failed_to_get_storage_parent_dir\".to_string())?;\n    let mut backups = Vec::new();\n    if let Ok(entries) = fs::read_dir(dir) {\n        for entry in entries.flatten() {\n            let path = entry.path();\n            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {\n                if name.starts_with(\"storage.json.backup_\") {\n                    backups.push(path);\n                }\n            }\n        }\n    }\n    // Sort by modification time (new to old)\n    backups.sort_by(|a, b| {\n        let ma = fs::metadata(a).and_then(|m| m.modified()).ok();\n        let mb = fs::metadata(b).and_then(|m| m.modified()).ok();\n        mb.cmp(&ma)\n    });\n    Ok(backups)\n}\n\n/// Restore backup to storage.json. If use_oldest=true, use oldest backup, else use latest.\n#[allow(dead_code)]\npub fn restore_backup(storage_path: &Path, use_oldest: bool) -> Result<PathBuf, String> {\n    let backups = list_backups(storage_path)?;\n    if backups.is_empty() {\n        return Err(\"no_backups_found\".to_string());\n    }\n    let target = if use_oldest {\n        backups.last().unwrap().clone()\n    } else {\n        backups.first().unwrap().clone()\n    };\n    // backup current first\n    let _ = backup_storage(storage_path)?;\n    fs::copy(&target, storage_path).map_err(|e| format!(\"restore_failed: {}\", e))?;\n    logger::log_info(&format!(\"storage_json_restored: {:?}\", target));\n    Ok(target)\n}\n\n/// Generate a new set of device fingerprints (Cursor/VSCode style)\npub fn generate_profile() -> DeviceProfile {\n    DeviceProfile {\n        machine_id: format!(\"auth0|user_{}\", random_hex(32)),\n        mac_machine_id: new_standard_machine_id(),\n        dev_device_id: Uuid::new_v4().to_string(),\n        sqm_id: format!(\"{{{}}}\", Uuid::new_v4().to_string().to_uppercase()),\n    }\n}\n\nfn random_hex(length: usize) -> String {\n    rand::thread_rng()\n        .sample_iter(&Alphanumeric)\n        .take(length)\n        .map(char::from)\n        .collect::<String>()\n        .to_lowercase()\n}\n\nfn new_standard_machine_id() -> String {\n    // xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx (y in 8..b)\n    let mut rng = rand::thread_rng();\n    let mut id = String::with_capacity(36);\n    for ch in \"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\".chars() {\n        if ch == '-' || ch == '4' {\n            id.push(ch);\n        } else if ch == 'x' {\n            id.push_str(&format!(\"{:x}\", rng.gen_range(0..16)));\n        } else if ch == 'y' {\n            id.push_str(&format!(\"{:x}\", rng.gen_range(8..12)));\n        }\n    }\n    id\n}\n"
  },
  {
    "path": "src-tauri/src/modules/http_api.rs",
    "content": "//! HTTP API Module\n//! Provides local HTTP interfaces for external programs (e.g., VS Code extension) to call.\n\nuse axum::{\n    extract::{Path, Query, State},\n    http::StatusCode,\n    response::IntoResponse,\n    routing::{get, post},\n    Json, Router,\n};\nuse serde::{Deserialize, Serialize};\n// 预留 HTTP API 模块，当前未在主流程中启用\n\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\nuse tower_http::cors::{Any, CorsLayer};\n\nuse crate::modules::{account, logger, proxy_db};\n\n/// Default port for HTTP API server\npub const DEFAULT_PORT: u16 = 19527;\n\n// ============================================================================\n// Settings\n// ============================================================================\n\n/// HTTP API Settings\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct HttpApiSettings {\n    /// Whether to enable HTTP API service\n    #[serde(default = \"default_enabled\")]\n    pub enabled: bool,\n    /// Listening port\n    #[serde(default = \"default_port\")]\n    pub port: u16,\n}\n\nfn default_enabled() -> bool {\n    true\n}\n\nfn default_port() -> u16 {\n    DEFAULT_PORT\n}\n\nimpl Default for HttpApiSettings {\n    fn default() -> Self {\n        Self {\n            enabled: true,\n            port: DEFAULT_PORT,\n        }\n    }\n}\n\n/// Load HTTP API settings\npub fn load_settings() -> Result<HttpApiSettings, String> {\n    let data_dir = crate::modules::account::get_data_dir()\n        .map_err(|e| format!(\"Failed to get data dir: {}\", e))?;\n    let settings_path = data_dir.join(\"http_api_settings.json\");\n\n    if !settings_path.exists() {\n        return Ok(HttpApiSettings::default());\n    }\n\n    let content = std::fs::read_to_string(&settings_path)\n        .map_err(|e| format!(\"Failed to read settings file: {}\", e))?;\n\n    serde_json::from_str(&content)\n        .map_err(|e| format!(\"Failed to parse settings: {}\", e))\n}\n\n/// Save HTTP API settings\npub fn save_settings(settings: &HttpApiSettings) -> Result<(), String> {\n    let data_dir = crate::modules::account::get_data_dir()\n        .map_err(|e| format!(\"Failed to get data dir: {}\", e))?;\n    let settings_path = data_dir.join(\"http_api_settings.json\");\n\n    let content = serde_json::to_string_pretty(settings)\n        .map_err(|e| format!(\"Failed to serialize settings: {}\", e))?;\n\n    std::fs::write(&settings_path, content)\n        .map_err(|e| format!(\"Failed to write settings file: {}\", e))\n}\n\n/// Server State\n#[derive(Clone)]\npub struct ApiState {\n    /// Whether there is a switch operation currently in progress\n    pub switching: Arc<RwLock<bool>>,\n    pub integration: crate::modules::integration::SystemManager,\n}\n\nimpl ApiState {\n    pub fn new(integration: crate::modules::integration::SystemManager) -> Self {\n        Self {\n            switching: Arc::new(RwLock::new(false)),\n            integration,\n        }\n    }\n}\n\n// ============================================================================\n// Response Types\n// ============================================================================\n\n#[derive(Serialize)]\nstruct HealthResponse {\n    status: String,\n    version: String,\n}\n\n#[derive(Serialize)]\nstruct AccountResponse {\n    id: String,\n    email: String,\n    name: Option<String>,\n    is_current: bool,\n    disabled: bool,\n    quota: Option<QuotaResponse>,\n    device_bound: bool,\n    last_used: i64,\n}\n\n#[derive(Serialize)]\nstruct QuotaResponse {\n    models: Vec<ModelQuota>,\n    updated_at: Option<i64>,\n    subscription_tier: Option<String>,\n}\n\n#[derive(Serialize)]\nstruct ModelQuota {\n    name: String,\n    percentage: i32,\n    reset_time: String,\n}\n\n#[derive(Serialize)]\nstruct AccountListResponse {\n    accounts: Vec<AccountResponse>,\n    current_account_id: Option<String>,\n}\n\n#[derive(Serialize)]\nstruct CurrentAccountResponse {\n    account: Option<AccountResponse>,\n}\n\n#[derive(Serialize)]\nstruct SwitchResponse {\n    success: bool,\n    message: String,\n}\n\n#[derive(Serialize)]\nstruct RefreshResponse {\n    success: bool,\n    message: String,\n    refreshed_count: usize,\n}\n\n#[derive(Serialize)]\nstruct BindDeviceResponse {\n    success: bool,\n    message: String,\n    device_profile: Option<DeviceProfileResponse>,\n}\n\n#[derive(Serialize)]\nstruct DeviceProfileResponse {\n    machine_id: String,\n    mac_machine_id: String,\n    dev_device_id: String,\n    sqm_id: String,\n}\n\n#[derive(Serialize)]\nstruct ErrorResponse {\n    error: String,\n}\n\n#[derive(Serialize)]\nstruct LogsResponse {\n    total: u64,\n    logs: Vec<crate::proxy::monitor::ProxyRequestLog>,\n}\n\n// ============================================================================\n// Request Types\n// ============================================================================\n\n#[derive(Deserialize)]\nstruct SwitchRequest {\n    account_id: String,\n}\n\n#[derive(Deserialize)]\nstruct BindDeviceRequest {\n    #[serde(default = \"default_bind_mode\")]\n    mode: String,\n}\n\nfn default_bind_mode() -> String {\n    \"generate\".to_string()\n}\n\n#[derive(Deserialize)]\nstruct LogsRequest {\n    #[serde(default)]\n    limit: usize,\n    #[serde(default)]\n    offset: usize,\n    #[serde(default)]\n    filter: String,\n    #[serde(default)]\n    errors_only: bool,\n}\n\n// ============================================================================\n// Handlers\n// ============================================================================\n\n/// GET /health - Health check\nasync fn health() -> impl IntoResponse {\n    Json(HealthResponse {\n        status: \"ok\".to_string(),\n        version: env!(\"CARGO_PKG_VERSION\").to_string(),\n    })\n}\n\n/// GET /accounts - Get all accounts\nasync fn list_accounts() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let accounts = account::list_accounts().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    let current_id = account::get_current_account_id()\n        .ok()\n        .flatten();\n\n    let account_responses: Vec<AccountResponse> = accounts\n        .into_iter()\n        .map(|acc| {\n            let is_current = current_id.as_ref().map(|id| id == &acc.id).unwrap_or(false);\n            let quota = acc.quota.map(|q| QuotaResponse {\n                models: q.models.into_iter().map(|m| ModelQuota {\n                    name: m.name,\n                    percentage: m.percentage,\n                    reset_time: m.reset_time,\n                }).collect(),\n                updated_at: Some(q.last_updated),\n                subscription_tier: q.subscription_tier,\n            });\n            \n            AccountResponse {\n                id: acc.id,\n                email: acc.email,\n                name: acc.name,\n                is_current,\n                disabled: acc.disabled,\n                quota,\n                device_bound: acc.device_profile.is_some(),\n                last_used: acc.last_used,\n            }\n        })\n        .collect();\n\n    Ok(Json(AccountListResponse {\n        current_account_id: current_id,\n        accounts: account_responses,\n    }))\n}\n\n/// GET /accounts/current - Get current account\nasync fn get_current_account() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let current = account::get_current_account().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    let response = current.map(|acc| {\n        let quota = acc.quota.map(|q| QuotaResponse {\n            models: q.models.into_iter().map(|m| ModelQuota {\n                name: m.name,\n                percentage: m.percentage,\n                reset_time: m.reset_time,\n            }).collect(),\n            updated_at: Some(q.last_updated),\n            subscription_tier: q.subscription_tier,\n        });\n\n        AccountResponse {\n            id: acc.id,\n            email: acc.email,\n            name: acc.name,\n            is_current: true,\n            disabled: acc.disabled,\n            quota,\n            device_bound: acc.device_profile.is_some(),\n            last_used: acc.last_used,\n        }\n    });\n\n    Ok(Json(CurrentAccountResponse { account: response }))\n}\n\n/// POST /accounts/switch - Switch account\nasync fn switch_account(\n    State(state): State<ApiState>,\n    Json(payload): Json<SwitchRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    // Check if another switch operation is already in progress\n    {\n        let switching = state.switching.read().await;\n        if *switching {\n            return Err((\n                StatusCode::CONFLICT,\n                Json(ErrorResponse {\n                    error: \"Another switch operation is already in progress\".to_string(),\n                }),\n            ));\n        }\n    }\n\n    // Mark switch started\n    {\n        let mut switching = state.switching.write().await;\n        *switching = true;\n    }\n\n    let account_id = payload.account_id.clone();\n    let state_clone = state.clone();\n\n    // Execute switch asynchronously (non-blocking response)\n    tokio::spawn(async move {\n        logger::log_info(&format!(\"[HTTP API] Starting account switch: {}\", account_id));\n        \n        match account::switch_account(&account_id, &state_clone.integration).await {\n            Ok(()) => {\n                logger::log_info(&format!(\"[HTTP API] Account switch successful: {}\", account_id));\n            }\n            Err(e) => {\n                logger::log_error(&format!(\"[HTTP API] Account switch failed: {}\", e));\n            }\n        }\n\n        // Mark switch ended\n        let mut switching = state_clone.switching.write().await;\n        *switching = false;\n    });\n\n    // Immediately return 202 Accepted\n    Ok((\n        StatusCode::ACCEPTED,\n        Json(SwitchResponse {\n            success: true,\n            message: format!(\"Account switch task started: {}\", payload.account_id),\n        }),\n    ))\n}\n\n/// POST /accounts/refresh - Refresh all quotas\nasync fn refresh_all_quotas() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    logger::log_info(\"[HTTP API] Starting refresh of all account quotas\");\n\n    // Execute refresh asynchronously\n    tokio::spawn(async {\n        match account::refresh_all_quotas_logic().await {\n            Ok(stats) => {\n                logger::log_info(&format!(\n                    \"[HTTP API] Quota refresh completed, successful {}/{} accounts\",\n                    stats.success, stats.total\n                ));\n            }\n            Err(e) => {\n                logger::log_error(&format!(\"[HTTP API] Quota refresh failed: {}\", e));\n            }\n        }\n    });\n\n    Ok((\n        StatusCode::ACCEPTED,\n        Json(RefreshResponse {\n            success: true,\n            message: \"Quota refresh task started\".to_string(),\n            refreshed_count: 0,\n        }),\n    ))\n}\n\n/// POST /accounts/:id/bind-device - Bind device fingerprint\nasync fn bind_device(\n    Path(account_id): Path<String>,\n    Json(payload): Json<BindDeviceRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    logger::log_info(&format!(\n        \"[HTTP API] Binding device fingerprint: account={}, mode={}\",\n        account_id, payload.mode\n    ));\n\n    let result = account::bind_device_profile(&account_id, &payload.mode).map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    Ok(Json(BindDeviceResponse {\n        success: true,\n        message: \"Device fingerprint bound successfully\".to_string(),\n        device_profile: Some(DeviceProfileResponse {\n            machine_id: result.machine_id,\n            mac_machine_id: result.mac_machine_id,\n            dev_device_id: result.dev_device_id,\n            sqm_id: result.sqm_id,\n        }),\n    }))\n}\n\n/// GET /logs - Get proxy logs\nasync fn get_logs(\n    Query(params): Query<LogsRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let limit = if params.limit == 0 { 50 } else { params.limit };\n\n    let total = proxy_db::get_logs_count_filtered(&params.filter, params.errors_only)\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n\n    let logs = proxy_db::get_logs_filtered(&params.filter, params.errors_only, limit, params.offset)\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n\n    Ok(Json(LogsResponse {\n        total,\n        logs,\n    }))\n}\n\n// ============================================================================\n// Server\n// ============================================================================\n\n/// Start HTTP API server\npub async fn start_server(port: u16, integration: crate::modules::integration::SystemManager) -> Result<(), String> {\n    let state = ApiState::new(integration);\n\n    // CORS config - allow local calls\n    let cors = CorsLayer::new()\n        .allow_origin(Any)\n        .allow_methods(Any)\n        .allow_headers(Any);\n\n    let app = Router::new()\n        .route(\"/health\", get(health))\n        .route(\"/accounts\", get(list_accounts))\n        .route(\"/accounts/current\", get(get_current_account))\n        .route(\"/accounts/switch\", post(switch_account))\n        .route(\"/accounts/refresh\", post(refresh_all_quotas))\n        .route(\"/accounts/{id}/bind-device\", post(bind_device))\n        .route(\"/logs\", get(get_logs))\n        .layer(cors)\n        .with_state(state);\n\n    let addr = format!(\"127.0.0.1:{}\", port);\n    logger::log_info(&format!(\"[HTTP API] Starting server: http://{}\", addr));\n\n    let listener = tokio::net::TcpListener::bind(&addr)\n        .await\n        .map_err(|e| format!(\"failed_to_bind_port: {}\", e))?;\n\n    axum::serve(listener, app)\n        .await\n        .map_err(|e| format!(\"failed_to_run_server: {}\", e))?;\n\n    Ok(())\n}\n\n/// Start HTTP API server in background (non-blocking)\npub fn spawn_server(port: u16, integration: crate::modules::integration::SystemManager) {\n    // Use tauri::async_runtime::spawn to ensure running within Tauri's runtime\n    tauri::async_runtime::spawn(async move {\n        if let Err(e) = start_server(port, integration).await {\n            logger::log_error(&format!(\"[HTTP API] Failed to start server: {}\", e));\n        }\n    });\n}\n"
  },
  {
    "path": "src-tauri/src/modules/i18n.rs",
    "content": "use serde_json::Value;\nuse std::collections::HashMap;\n\n/// Tray text structure\n#[derive(Debug, Clone)]\npub struct TrayTexts {\n    pub current: String,\n    pub quota: String,\n    pub switch_next: String,\n    pub refresh_current: String,\n    pub show_window: String,\n    pub quit: String,\n    pub no_account: String,\n    pub unknown_quota: String,\n    pub forbidden: String,\n}\n\n/// Load translations from JSON\nfn load_translations(lang: &str) -> HashMap<String, String> {\n    let json_content = match lang {\n        \"en\" | \"en-US\" => include_str!(\"../../../src/locales/en.json\"),\n        \"tr\" | \"tr-TR\" => include_str!(\"../../../src/locales/tr.json\"),\n        _ => include_str!(\"../../../src/locales/zh.json\"),\n    };\n    \n    let v: Value = serde_json::from_str(json_content)\n        .unwrap_or_else(|_| serde_json::json!({}));\n    \n    let mut map = HashMap::new();\n    \n    if let Some(tray) = v.get(\"tray\").and_then(|t| t.as_object()) {\n        for (key, value) in tray {\n            if let Some(s) = value.as_str() {\n                map.insert(key.clone(), s.to_string());\n            }\n        }\n    }\n    \n    map\n}\n\n/// Get tray texts (based on language)\npub fn get_tray_texts(lang: &str) -> TrayTexts {\n    let t = load_translations(lang);\n    \n    TrayTexts {\n        current: t.get(\"current\").cloned().unwrap_or_else(|| \"Current\".to_string()),\n        quota: t.get(\"quota\").cloned().unwrap_or_else(|| \"Quota\".to_string()),\n        switch_next: t.get(\"switch_next\").cloned().unwrap_or_else(|| \"Switch to Next Account\".to_string()),\n        refresh_current: t.get(\"refresh_current\").cloned().unwrap_or_else(|| \"Refresh Current Quota\".to_string()),\n        show_window: t.get(\"show_window\").cloned().unwrap_or_else(|| \"Show Main Window\".to_string()),\n        quit: t.get(\"quit\").cloned().unwrap_or_else(|| \"Quit Application\".to_string()),\n        no_account: t.get(\"no_account\").cloned().unwrap_or_else(|| \"No Account\".to_string()),\n        unknown_quota: t.get(\"unknown_quota\").cloned().unwrap_or_else(|| \"Unknown\".to_string()),\n        forbidden: t.get(\"forbidden\").cloned().unwrap_or_else(|| \"Account Forbidden\".to_string()),\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/modules/integration.rs",
    "content": "use crate::modules::{process, db, device};\nuse crate::models::Account;\nuse std::fs;\n\npub trait SystemIntegration: Send + Sync {\n    /// 当切换账号时执行的系统层操作（如杀进程、写入文件、注入数据库）\n    async fn on_account_switch(&self, account: &crate::models::Account) -> Result<(), String>;\n    \n    /// 更新系统托盘（如果适用）\n    fn update_tray(&self);\n    \n    /// 发送系统通知\n    fn show_notification(&self, title: &str, body: &str);\n}\n\n/// 桌面版实现：包含完整的进程控制和 UI 同步\npub struct DesktopIntegration {\n    pub app_handle: tauri::AppHandle,\n}\n\nimpl SystemIntegration for DesktopIntegration {\n    async fn on_account_switch(&self, account: &crate::models::Account) -> Result<(), String> {\n        crate::modules::logger::log_info(&format!(\"[Desktop] Executing system switch for: {}\", account.email));\n        \n        // 1. 获取存储路径\n        let storage_path = device::get_storage_path()?;\n\n        // 2. 关闭外部进程\n        if process::is_antigravity_running() {\n            process::close_antigravity(20)?;\n        }\n\n        // 3. 写入设备 Profile\n        if let Some(ref profile) = account.device_profile {\n            device::write_profile(&storage_path, profile)?;\n        }\n\n        // 4. 数据库处理与 Token 注入\n        let db_path = db::get_db_path()?;\n        if db_path.exists() {\n            let backup_path = db_path.with_extension(\"vscdb.backup\");\n            let _ = fs::copy(&db_path, &backup_path);\n        }\n        \n        db::inject_token(\n            &db_path,\n            &account.token.access_token,\n            &account.token.refresh_token,\n            account.token.expiry_timestamp,\n            &account.email,\n        )?;\n\n        // 5. 重启外部进程\n        process::start_antigravity()?;\n        \n        // 6. 更新托盘\n        let _ = crate::modules::tray::update_tray_menus(&self.app_handle);\n        \n        Ok(())\n    }\n\n    fn update_tray(&self) {\n        let _ = crate::modules::tray::update_tray_menus(&self.app_handle);\n    }\n\n    fn show_notification(&self, title: &str, body: &str) {\n        // 使用 tauri-plugin-dialog 或原生通知（此处简化）\n        crate::modules::logger::log_info(&format!(\"[Notification] {}: {}\", title, body));\n    }\n}\n\n/// Headless/Docker 实现：仅执行数据层操作，忽略 UI 和进程控制\npub struct HeadlessIntegration;\n\nimpl SystemIntegration for HeadlessIntegration {\n    async fn on_account_switch(&self, account: &crate::models::Account) -> Result<(), String> {\n        crate::modules::logger::log_info(&format!(\"[Headless] Account switched in memory: {}\", account.email));\n        // Docker 模式下通常不直接控制宿主机的 VS Code 进程\n        // 如果需要同步配置到某个 volume，可以在此处添加逻辑\n        Ok(())\n    }\n\n    fn update_tray(&self) {\n        // No-op\n    }\n\n    fn show_notification(&self, title: &str, body: &str) {\n        crate::modules::logger::log_info(&format!(\"[Log Notification] {}: {}\", title, body));\n    }\n}\n/// 系统集成管理器：替代 Arc<dyn SystemIntegration> 以解决 async trait 的 dyn 兼容性问题\n#[derive(Clone)]\npub enum SystemManager {\n    Desktop(tauri::AppHandle),\n    Headless,\n}\n\nimpl SystemManager {\n    pub async fn on_account_switch(&self, account: &Account) -> Result<(), String> {\n        match self {\n            SystemManager::Desktop(handle) => {\n                let integration = DesktopIntegration { app_handle: handle.clone() };\n                integration.on_account_switch(account).await\n            },\n            SystemManager::Headless => {\n                let integration = HeadlessIntegration;\n                integration.on_account_switch(account).await\n            }\n        }\n    }\n\n    pub fn update_tray(&self) {\n        if let SystemManager::Desktop(handle) = self {\n            let integration = DesktopIntegration { app_handle: handle.clone() };\n            integration.update_tray();\n        }\n    }\n\n    pub fn show_notification(&self, title: &str, body: &str) {\n        match self {\n            SystemManager::Desktop(handle) => {\n                let integration = DesktopIntegration { app_handle: handle.clone() };\n                integration.show_notification(title, body);\n            },\n            SystemManager::Headless => {\n                let integration = HeadlessIntegration;\n                integration.show_notification(title, body);\n            }\n        }\n    }\n}\n\nimpl SystemIntegration for SystemManager {\n    async fn on_account_switch(&self, account: &crate::models::Account) -> Result<(), String> {\n        match self {\n            SystemManager::Desktop(handle) => {\n                let integration = DesktopIntegration { app_handle: handle.clone() };\n                integration.on_account_switch(account).await\n            },\n            SystemManager::Headless => {\n                let integration = HeadlessIntegration;\n                integration.on_account_switch(account).await\n            }\n        }\n    }\n\n    fn update_tray(&self) {\n        match self {\n            SystemManager::Desktop(handle) => {\n                let integration = DesktopIntegration { app_handle: handle.clone() };\n                integration.update_tray();\n            },\n            SystemManager::Headless => {\n                let integration = HeadlessIntegration;\n                integration.update_tray();\n            }\n        }\n    }\n\n    fn show_notification(&self, title: &str, body: &str) {\n        match self {\n            SystemManager::Desktop(handle) => {\n                let integration = DesktopIntegration { app_handle: handle.clone() };\n                integration.show_notification(title, body);\n            },\n            SystemManager::Headless => {\n                let integration = HeadlessIntegration;\n                integration.show_notification(title, body);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/modules/log_bridge.rs",
    "content": "//! Log Module Bridge - Captures tracing logs and emits them to the frontend via Tauri Events.\n//! Uses a global ring buffer that can be attached to Tauri after app initialization.\n\nuse parking_lot::RwLock;\nuse serde::Serialize;\nuse std::collections::VecDeque;\nuse std::sync::atomic::{AtomicBool, AtomicU64, Ordering};\nuse std::sync::{Arc, OnceLock};\nuse tauri::Emitter;\nuse tracing::field::{Field, Visit};\nuse tracing::{Event, Level, Subscriber};\nuse tracing_subscriber::layer::Context;\nuse tracing_subscriber::Layer;\n\n/// Maximum logs to keep in buffer\nconst MAX_BUFFER_SIZE: usize = 5000;\n\n/// Global flag to enable/disable log bridging\nstatic LOG_BRIDGE_ENABLED: AtomicBool = AtomicBool::new(false);\n\n/// Atomic counter for unique log IDs\nstatic LOG_ID_COUNTER: AtomicU64 = AtomicU64::new(0);\n\n/// Global app handle for emitting events (set once during setup)\nstatic APP_HANDLE: OnceLock<tauri::AppHandle> = OnceLock::new();\n\n/// Global log buffer for storing logs before UI connects\nstatic LOG_BUFFER: OnceLock<Arc<RwLock<VecDeque<LogEntry>>>> = OnceLock::new();\n\nfn get_log_buffer() -> &'static Arc<RwLock<VecDeque<LogEntry>>> {\n    LOG_BUFFER.get_or_init(|| Arc::new(RwLock::new(VecDeque::with_capacity(MAX_BUFFER_SIZE))))\n}\n\n/// Log entry sent to frontend\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct LogEntry {\n    pub id: u64,\n    pub timestamp: i64,\n    pub level: String,\n    pub target: String,\n    pub message: String,\n    pub fields: std::collections::HashMap<String, String>,\n}\n\n/// Initialize the log bridge with app handle (call from setup)\npub fn init_log_bridge(app_handle: tauri::AppHandle) {\n    let _ = APP_HANDLE.set(app_handle);\n    tracing::debug!(\"[LogBridge] Initialized with app handle\");\n}\n\n/// Enable log bridging and emit buffered logs\npub fn enable_log_bridge() {\n    LOG_BRIDGE_ENABLED.store(true, Ordering::SeqCst);\n\n    // Emit all buffered logs to frontend\n    if let Some(handle) = APP_HANDLE.get() {\n        let buffer = get_log_buffer().read();\n        for entry in buffer.iter() {\n            let _ = handle.emit(\"log-event\", entry.clone());\n        }\n    }\n\n    tracing::info!(\"[LogBridge] Debug console enabled\");\n}\n\n/// Disable log bridging\npub fn disable_log_bridge() {\n    LOG_BRIDGE_ENABLED.store(false, Ordering::SeqCst);\n    tracing::info!(\"[LogBridge] Debug console disabled\");\n}\n\n/// Check if log bridging is enabled\npub fn is_log_bridge_enabled() -> bool {\n    LOG_BRIDGE_ENABLED.load(Ordering::SeqCst)\n}\n\n/// Get all buffered logs\npub fn get_buffered_logs() -> Vec<LogEntry> {\n    get_log_buffer().read().iter().cloned().collect()\n}\n\n/// Clear log buffer\npub fn clear_log_buffer() {\n    get_log_buffer().write().clear();\n}\n\n/// Emit accounts://refreshed event to notify the frontend of account state changes\n/// This is used by background tasks (e.g. warmup 403 handling) that cannot access AppHandle directly.\npub fn emit_accounts_refreshed() {\n    if let Some(handle) = APP_HANDLE.get() {\n        let _ = handle.emit(\"accounts://refreshed\", ());\n        tracing::debug!(\"[LogBridge] Emitted accounts://refreshed event to frontend\");\n    }\n}\n\n/// Visitor to extract fields from tracing events\nstruct FieldVisitor {\n    message: Option<String>,\n    fields: std::collections::HashMap<String, String>,\n}\n\nimpl FieldVisitor {\n    fn new() -> Self {\n        Self {\n            message: None,\n            fields: std::collections::HashMap::new(),\n        }\n    }\n}\n\nimpl Visit for FieldVisitor {\n    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {\n        let value_str = format!(\"{:?}\", value);\n        if field.name() == \"message\" {\n            self.message = Some(value_str.trim_matches('\"').to_string());\n        } else {\n            self.fields.insert(field.name().to_string(), value_str);\n        }\n    }\n\n    fn record_str(&mut self, field: &Field, value: &str) {\n        if field.name() == \"message\" {\n            self.message = Some(value.to_string());\n        } else {\n            self.fields\n                .insert(field.name().to_string(), value.to_string());\n        }\n    }\n\n    fn record_i64(&mut self, field: &Field, value: i64) {\n        self.fields\n            .insert(field.name().to_string(), value.to_string());\n    }\n\n    fn record_u64(&mut self, field: &Field, value: u64) {\n        self.fields\n            .insert(field.name().to_string(), value.to_string());\n    }\n\n    fn record_bool(&mut self, field: &Field, value: bool) {\n        self.fields\n            .insert(field.name().to_string(), value.to_string());\n    }\n}\n\n/// Tracing Layer that bridges logs to buffer and optionally to Tauri frontend\npub struct TauriLogBridgeLayer;\n\nimpl TauriLogBridgeLayer {\n    pub fn new() -> Self {\n        Self\n    }\n}\n\nimpl Default for TauriLogBridgeLayer {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl<S> Layer<S> for TauriLogBridgeLayer\nwhere\n    S: Subscriber,\n{\n    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {\n        // [FIX] 如果调试控制台未启用，直接跳过所有处理，避免性能损耗\n        if !LOG_BRIDGE_ENABLED.load(Ordering::Relaxed) {\n            return;\n        }\n\n        // Extract metadata\n        let metadata = event.metadata();\n        let level = match *metadata.level() {\n            Level::ERROR => \"ERROR\",\n            Level::WARN => \"WARN\",\n            Level::INFO => \"INFO\",\n            Level::DEBUG => \"DEBUG\",\n            Level::TRACE => \"TRACE\",\n        };\n\n        // Visit fields\n        let mut visitor = FieldVisitor::new();\n        event.record(&mut visitor);\n\n        // Build message\n        let message = visitor.message.unwrap_or_default();\n\n        // Skip empty messages and internal noise\n        if message.is_empty() && visitor.fields.is_empty() {\n            return;\n        }\n\n        // Create log entry\n        let entry = LogEntry {\n            id: LOG_ID_COUNTER.fetch_add(1, Ordering::SeqCst),\n            timestamp: chrono::Utc::now().timestamp_millis(),\n            level: level.to_string(),\n            target: metadata.target().to_string(),\n            message,\n            fields: visitor.fields,\n        };\n\n        // Add to buffer\n        {\n            let mut buffer = get_log_buffer().write();\n            if buffer.len() >= MAX_BUFFER_SIZE {\n                buffer.pop_front();\n            }\n            buffer.push_back(entry.clone());\n        }\n\n        // Emit to frontend\n        if let Some(handle) = APP_HANDLE.get() {\n            let _ = handle.emit(\"log-event\", entry);\n        }\n    }\n}\n\n// ============================================================================\n// Tauri Commands\n// ============================================================================\n\n#[tauri::command]\npub fn enable_debug_console() {\n    enable_log_bridge();\n}\n\n#[tauri::command]\npub fn disable_debug_console() {\n    disable_log_bridge();\n}\n\n#[tauri::command]\npub fn is_debug_console_enabled() -> bool {\n    is_log_bridge_enabled()\n}\n\n#[tauri::command]\npub fn get_debug_console_logs() -> Vec<LogEntry> {\n    get_buffered_logs()\n}\n\n#[tauri::command]\npub fn clear_debug_console_logs() {\n    clear_log_buffer();\n}\n"
  },
  {
    "path": "src-tauri/src/modules/logger.rs",
    "content": "use tracing::{info, warn, error};\nuse tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};\nuse std::fs;\nuse std::path::PathBuf;\nuse crate::modules::account::get_data_dir;\n\n// Custom local timezone time formatter\nstruct LocalTimer;\n\nimpl tracing_subscriber::fmt::time::FormatTime for LocalTimer {\n    fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result {\n        let now = chrono::Local::now();\n        write!(w, \"{}\", now.to_rfc3339())\n    }\n}\n\npub fn get_log_dir() -> Result<PathBuf, String> {\n    let data_dir = get_data_dir()?;\n    let log_dir = data_dir.join(\"logs\");\n    \n    if !log_dir.exists() {\n        fs::create_dir_all(&log_dir).map_err(|e| format!(\"Failed to create log directory: {}\", e))?;\n    }\n    \n    Ok(log_dir)\n}\n\n/// Initialize the log system\npub fn init_logger() {\n    // Capture log macro logs\n    let _ = tracing_log::LogTracer::init();\n    \n    let log_dir = match get_log_dir() {\n        Ok(dir) => dir,\n        Err(e) => {\n            eprintln!(\"Failed to initialize log directory: {}\", e);\n            return;\n        }\n    };\n    \n    // 1. Set up file Appender (using tracing-appender for rolling logs)\n    // Using a daily rolling strategy here\n    let file_appender = tracing_appender::rolling::daily(log_dir, \"app.log\");\n    let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);\n    \n    // 2. Console output layer (using local timezone)\n    let console_layer = fmt::Layer::new()\n        .with_target(false)\n        .with_thread_ids(false)\n        .with_level(true)\n        .with_timer(LocalTimer);\n        \n    // 3. File output layer (disable ANSI formatting, use local timezone)\n    let file_layer = fmt::Layer::new()\n        .with_writer(non_blocking)\n        .with_ansi(false)\n        .with_target(true)\n        .with_level(true)\n        .with_timer(LocalTimer);\n\n    // 4. Set filtering layer (default to INFO level to reduce log size)\n    let filter_layer = EnvFilter::try_from_default_env()\n        .unwrap_or_else(|_| EnvFilter::new(\"info\"));\n\n    // 6. Log bridge layer\n    let bridge_layer = crate::modules::log_bridge::TauriLogBridgeLayer::new();\n\n    // 5. Initialize global subscriber (use try_init to avoid crash on repeated initialization)\n    let _ = tracing_subscriber::registry()\n        .with(filter_layer)\n        .with(console_layer)\n        .with(file_layer)\n        .with(bridge_layer)\n        .try_init();\n\n    // Leak _guard to ensure its lifetime lasts until program exit\n    // Recommended practice when using tracing_appender::non_blocking (if manual flushing is not needed)\n    std::mem::forget(_guard);\n    \n    info!(\"Log system initialized (Console + File persistence)\");\n    \n    // Auto-cleanup logs older than 7 days\n    if let Err(e) = cleanup_old_logs(7) {\n        warn!(\"Failed to cleanup old logs: {}\", e);\n    }\n}\n\n/// Cleanup log files older than specified days OR if total size exceeds limit\npub fn cleanup_old_logs(days_to_keep: u64) -> Result<(), String> {\n    use std::time::{SystemTime, UNIX_EPOCH};\n    \n    let log_dir = get_log_dir()?;\n    if !log_dir.exists() {\n        return Ok(());\n    }\n\n    // Constants for size-based cleanup\n    const MAX_TOTAL_SIZE_BYTES: u64 = 1024 * 1024 * 1024; // 1GB\n    const TARGET_SIZE_BYTES: u64 = 512 * 1024 * 1024;    // 512MB\n    \n    let now = SystemTime::now()\n        .duration_since(UNIX_EPOCH)\n        .map_err(|e| format!(\"Failed to get system time: {}\", e))?\n        .as_secs();\n    \n    let cutoff_time = now.saturating_sub(days_to_keep * 24 * 60 * 60);\n    \n    let mut entries_info = Vec::new();\n    let entries = fs::read_dir(&log_dir)\n        .map_err(|e| format!(\"Failed to read log directory: {}\", e))?;\n    \n    for entry in entries {\n        if let Ok(entry) = entry {\n            let path = entry.path();\n            if !path.is_file() {\n                continue;\n            }\n            \n            if let Ok(metadata) = fs::metadata(&path) {\n                let modified = metadata.modified().unwrap_or(SystemTime::now());\n                let modified_secs = modified\n                    .duration_since(UNIX_EPOCH)\n                    .map(|d| d.as_secs())\n                    .unwrap_or(0);\n                \n                let size = metadata.len();\n                entries_info.push((path, size, modified_secs));\n            }\n        }\n    }\n\n    let mut deleted_count = 0;\n    let mut total_size_freed = 0u64;\n\n    // 1. First pass: Delete files older than cutoff_time\n    let mut remaining_entries = Vec::new();\n    for (path, size, modified_secs) in entries_info {\n        if modified_secs < cutoff_time {\n            if let Err(e) = fs::remove_file(&path) {\n                warn!(\"Failed to delete old log file {:?}: {}\", path, e);\n                remaining_entries.push((path, size, modified_secs));\n            } else {\n                deleted_count += 1;\n                total_size_freed += size;\n                info!(\"Deleted old log file (expired): {:?}\", path.file_name());\n            }\n        } else {\n            remaining_entries.push((path, size, modified_secs));\n        }\n    }\n\n    // 2. Second pass: If total size still exceeds limit, delete oldest files\n    let mut current_total_size: u64 = remaining_entries.iter().map(|(_, size, _)| *size).sum();\n    \n    if current_total_size > MAX_TOTAL_SIZE_BYTES {\n        info!(\"Log directory size ({} MB) exceeds limit (1024 MB), starting size-based cleanup...\", current_total_size / 1024 / 1024);\n        \n        // Sort remaining entries by modification time (oldest first)\n        remaining_entries.sort_by_key(|(_, _, modified)| *modified);\n        \n        for (path, size, _) in remaining_entries {\n            if current_total_size <= TARGET_SIZE_BYTES {\n                break;\n            }\n            \n            // Try to delete. Skip if it's the most recent file and it fails (might be active)\n            if let Err(e) = fs::remove_file(&path) {\n                warn!(\"Failed to delete log file during size cleanup {:?}: {}\", path, e);\n            } else {\n                deleted_count += 1;\n                total_size_freed += size;\n                current_total_size -= size;\n                info!(\"Deleted log file (size limit): {:?}\", path.file_name());\n            }\n        }\n    }\n    \n    if deleted_count > 0 {\n        let size_mb = total_size_freed as f64 / 1024.0 / 1024.0;\n        info!(\n            \"Log cleanup completed: deleted {} files, freed {:.2} MB space\",\n            deleted_count, size_mb\n        );\n    }\n    \n    Ok(())\n}\n\n/// Clear log cache (using truncation mode to keep file handles valid)\npub fn clear_logs() -> Result<(), String> {\n    let log_dir = get_log_dir()?;\n    if log_dir.exists() {\n        // Iterate through all files in directory and truncate instead of deleting directory\n        let entries = fs::read_dir(&log_dir).map_err(|e| format!(\"Failed to read log directory: {}\", e))?;\n        for entry in entries {\n            if let Ok(entry) = entry {\n                let path = entry.path();\n                if path.is_file() {\n                    // Open file in truncation mode, set size to 0\n                    let _ = fs::OpenOptions::new()\n                        .write(true)\n                        .truncate(true)\n                        .open(path);\n                }\n            }\n        }\n    }\n    Ok(())\n}\n\n/// Log info message (backward compatibility)\npub fn log_info(message: &str) {\n    info!(\"{}\", message);\n}\n\n/// Log warn message (backward compatibility)\npub fn log_warn(message: &str) {\n    warn!(\"{}\", message);\n}\n\n/// Log error message (backward compatibility)\npub fn log_error(message: &str) {\n    error!(\"{}\", message);\n}\n"
  },
  {
    "path": "src-tauri/src/modules/migration.rs",
    "content": "use std::fs;\nuse std::path::PathBuf;\nuse serde_json::Value;\nuse base64::{Engine as _, engine::general_purpose};\nuse crate::models::{TokenData, Account};\nuse crate::modules::{account, db};\nuse crate::utils::protobuf;\n\n/// Scan and import V1 data\npub async fn import_from_v1() -> Result<Vec<Account>, String> {\n    use crate::modules::oauth;\n\n    let home = dirs::home_dir().ok_or(\"Failed to get home directory\")?;\n    \n    // V1 data directory (confirmed cross-platform consistency from utils.py)\n    let v1_dir = home.join(\".antigravity-agent\");\n    \n    let mut imported_accounts = Vec::new();\n    \n    // Try multiple possible filenames\n    let index_files = vec![\n        \"antigravity_accounts.json\", // Directly use string literal\n        \"accounts.json\"\n    ];\n    \n    let mut found_index = false;\n\n    for index_filename in index_files {\n        let v1_accounts_path = v1_dir.join(index_filename);\n        \n        if !v1_accounts_path.exists() {\n            continue;\n        }\n        \n        found_index = true;\n        crate::modules::logger::log_info(&format!(\"V1 data discovered: {:?}\", v1_accounts_path));\n        \n        let content = match fs::read_to_string(&v1_accounts_path) {\n            Ok(c) => c,\n            Err(e) => {\n                crate::modules::logger::log_warn(&format!(\"Failed to read index: {}\", e));\n                continue;\n            }\n        };\n        \n        let v1_index: Value = match serde_json::from_str(&content) {\n            Ok(v) => v,\n            Err(e) => {\n                crate::modules::logger::log_warn(&format!(\"Failed to parse index JSON: {}\", e));\n                continue;\n            }\n        };\n        \n        // Compatible with two formats: direct map, or contains \"accounts\" field\n        let accounts_map = if let Some(map) = v1_index.as_object() {\n            if let Some(accounts) = map.get(\"accounts\").and_then(|v| v.as_object()) {\n                accounts \n            } else {\n                map\n            }\n        } else {\n            continue;\n        };\n        \n        for (id, acc_info) in accounts_map {\n            let email_placeholder = acc_info.get(\"email\").and_then(|v| v.as_str()).unwrap_or(\"Unknown\").to_string();\n            \n            // Skip non-account keys (e.g. \"current_account_id\")\n            if !acc_info.is_object() {\n                continue;\n            }\n            \n            let backup_file_str = acc_info.get(\"backup_file\").and_then(|v| v.as_str());\n            let data_file_str = acc_info.get(\"data_file\").and_then(|v| v.as_str());\n            \n            // Prefer backup_file, then data_file\n            let target_file = backup_file_str.or(data_file_str);\n            \n            if target_file.is_none() {\n                crate::modules::logger::log_warn(&format!(\"Account {} ({}) missing data file path\", id, email_placeholder));\n                continue;\n            }\n            \n            let mut backup_path = PathBuf::from(target_file.unwrap());\n            \n            // If relative path, try joining with v1_dir\n            if !backup_path.exists() {\n                 backup_path = v1_dir.join(backup_path.file_name().unwrap_or_default());\n            }\n            \n            // Try joining data/ or backups/ subdirectories again\n            if !backup_path.exists() {\n                 let file_name = backup_path.file_name().unwrap_or_default();\n                 let try_backups = v1_dir.join(\"backups\").join(file_name);\n                 if try_backups.exists() {\n                     backup_path = try_backups;\n                 } else {\n                     let try_accounts = v1_dir.join(\"accounts\").join(file_name);\n                     if try_accounts.exists() {\n                         backup_path = try_accounts;\n                     }\n                 }\n            }\n            \n            if !backup_path.exists() {\n                crate::modules::logger::log_warn(&format!(\"Account {} ({}) backup file not found: {:?}\", id, email_placeholder, backup_path));\n                continue;\n            }\n            \n            // Read backup file\n            if let Ok(backup_content) = fs::read_to_string(&backup_path) {\n                if let Ok(backup_json) = serde_json::from_str::<Value>(&backup_content) {\n                    \n                    // Compatible with two formats:\n                    // 1. V1 backup: jetskiStateSync.agentManagerInitState -> Protobuf\n                    // 2. V2/Script data: JSON containing \"token\" field\n                    \n                    let mut refresh_token_opt = None;\n                    \n                    // Try format 2\n                    if let Some(token_data) = backup_json.get(\"token\") {\n                        if let Some(rt) = token_data.get(\"refresh_token\").and_then(|v| v.as_str()) {\n                            refresh_token_opt = Some(rt.to_string());\n                        }\n                    }\n                    \n                    // Try format 1\n                    if refresh_token_opt.is_none() {\n                         if let Some(state_b64) = backup_json.get(\"jetskiStateSync.agentManagerInitState\").and_then(|v| v.as_str()) {\n                            // Parse Protobuf\n                            if let Ok(blob) = general_purpose::STANDARD.decode(state_b64) {\n                                if let Ok(Some(oauth_data)) = protobuf::find_field(&blob, 6) {\n                                    if let Ok(Some(refresh_bytes)) = protobuf::find_field(&oauth_data, 3) {\n                                        if let Ok(rt) = String::from_utf8(refresh_bytes) {\n                                            refresh_token_opt = Some(rt);\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                    }\n                    \n                    if let Some(refresh_token) = refresh_token_opt {\n                         crate::modules::logger::log_info(&format!(\"Importing account: {}\", email_placeholder));\n                                                  let (email, access_token, expires_in) = match oauth::refresh_access_token(&refresh_token, None).await {\n                             Ok(token_resp) => {\n                                 match oauth::get_user_info(&token_resp.access_token, None).await {\n                                     Ok(user_info) => (user_info.email, token_resp.access_token, token_resp.expires_in),\n                                     Err(_) => (email_placeholder.clone(), token_resp.access_token, token_resp.expires_in), \n                                 }\n                             },\n                            Err(e) => {\n                                crate::modules::logger::log_warn(&format!(\"Token refresh failed (likely expired): {}\", e));\n                                (email_placeholder.clone(), \"imported_access_token\".to_string(), 0)\n                            }, \n                        };\n                        \n                        let token_data = TokenData::new(\n                            access_token, \n                            refresh_token,\n                            expires_in,\n                            Some(email.clone()),\n                            None, // project_id will be fetched on demand\n                            None, // session_id\n                    );\n                        \n                        // Name already fetched in get_user_info at line 153, but outside match scope, use None to be safe\n                        match account::upsert_account(email.clone(), None, token_data) {\n                            Ok(acc) => {\n                                crate::modules::logger::log_info(&format!(\"Import successful: {}\", email));\n                                imported_accounts.push(acc);\n                            },\n                            Err(e) => crate::modules::logger::log_error(&format!(\"Import save failed {}: {}\", email, e)),\n                        }\n\n                    } else {\n                        crate::modules::logger::log_warn(&format!(\"Account {} data file missing Refresh Token\", email_placeholder));\n                    }\n                }\n            }\n        }\n    }\n    \n    if !found_index {\n        return Err(\"V1 account data file not found\".to_string());\n    }\n    \n    Ok(imported_accounts)\n}\n\n/// Import account from custom database path\npub async fn import_from_custom_db_path(path_str: String) -> Result<Account, String> {\n    use crate::modules::oauth;\n\n    let path = PathBuf::from(path_str);\n    if !path.exists() {\n        return Err(format!(\"File does not exist: {:?}\", path));\n    }\n\n    let refresh_token = extract_refresh_token_from_file(&path)?;\n        \n    // 3. Use Refresh Token to get latest Access Token and user info\n    crate::modules::logger::log_info(\"Getting user info using Refresh Token...\");\n    let token_resp = oauth::refresh_access_token(&refresh_token, None).await?;\n    let user_info = oauth::get_user_info(&token_resp.access_token, None).await?;\n    \n    let email = user_info.email;\n    \n    crate::modules::logger::log_info(&format!(\"Successfully retrieved account info: {}\", email));\n    \n    let token_data = TokenData::new(\n        token_resp.access_token,\n        refresh_token,\n        token_resp.expires_in,\n        Some(email.clone()),\n        None, // project_id will be fetched on demand\n        None, // session_id will be generated in token_manager\n    );\n    \n    // 4. Add or update account\n    account::upsert_account(email.clone(), user_info.name, token_data)\n}\n\n/// Import current logged-in account from default IDE database\npub async fn import_from_db() -> Result<Account, String> {\n    let db_path = db::get_db_path()?;\n    import_from_custom_db_path(db_path.to_string_lossy().to_string()).await\n}\n\n/// Get current Refresh Token from database (common logic)\npub fn extract_refresh_token_from_file(db_path: &PathBuf) -> Result<String, String> {\n    use base64::{engine::general_purpose, Engine as _};\n    \n    if !db_path.exists() {\n        return Err(format!(\"Database file not found: {:?}\", db_path));\n    }\n    \n    // Connect to database\n    let conn = rusqlite::Connection::open(db_path)\n        .map_err(|e| format!(\"Failed to open database: {}\", e))?;\n        \n    // 1. 尝试新版格式 (>= 1.16.5)\n    // 键: antigravityUnifiedStateSync.oauthToken\n    // 结构: Outer(F1) -> Inner(F2) -> Inner2(F1) -> Base64 -> OAuthInfo\n    let new_format_data: Option<String> = conn\n        .query_row(\n            \"SELECT value FROM ItemTable WHERE key = ?\",\n            [\"antigravityUnifiedStateSync.oauthToken\"],\n            |row| row.get(0),\n        )\n        .ok();\n\n    if let Some(outer_b64) = new_format_data {\n        crate::modules::logger::log_info(\"Detected new format database (antigravityUnifiedStateSync.oauthToken)\");\n        \n        // Base64 解码外层数据\n        let outer_blob = general_purpose::STANDARD\n            .decode(&outer_b64)\n            .map_err(|e| format!(\"Outer Base64 decoding failed: {}\", e))?;\n            \n        // 解析 Outer (Field 1) -> Inner1\n        let inner1_blob = protobuf::find_field(&outer_blob, 1)\n            .map_err(|e| format!(\"Parsing Outer Field 1 failed: {}\", e))?\n            .ok_or(\"Outer Field 1 not found\")?;\n            \n        // 解析 Inner1 (Field 2) -> Inner2\n        let inner2_blob = protobuf::find_field(&inner1_blob, 2)\n            .map_err(|e| format!(\"Parsing Inner1 Field 2 failed: {}\", e))?\n            .ok_or(\"Inner1 Field 2 not found\")?;\n            \n        // 解析 Inner2 (Field 1) -> OAuthInfo B64 String\n        let oauth_info_bytes = protobuf::find_field(&inner2_blob, 1)\n            .map_err(|e| format!(\"Parsing Inner2 Field 1 failed: {}\", e))?\n            .ok_or(\"Inner2 Field 1 not found\")?;\n            \n        let oauth_info_b64 = String::from_utf8(oauth_info_bytes)\n            .map_err(|_| \"OAuth Info B64 is not UTF-8\")?;\n            \n        // 解码 OAuthInfo\n        let oauth_info_blob = general_purpose::STANDARD\n            .decode(&oauth_info_b64)\n            .map_err(|e| format!(\"Inner Base64 decoding failed: {}\", e))?;\n            \n        // 解析 OAuthInfo (Field 3) -> Refresh Token\n        let refresh_bytes = protobuf::find_field(&oauth_info_blob, 3)\n            .map_err(|e| format!(\"Parsing OAuthInfo Field 3 failed: {}\", e))?\n            .ok_or(\"Refresh Token not found in OAuthInfo (Field 3)\")?;\n            \n        return String::from_utf8(refresh_bytes)\n            .map_err(|_| \"Refresh Token is not UTF-8 encoded\".to_string());\n    }\n\n    // 2. 尝试旧版格式 (< 1.16.5)\n    crate::modules::logger::log_info(\"Falling back to old format database (jetskiStateSync.agentManagerInitState)\");\n    let current_data: String = conn\n        .query_row(\n            \"SELECT value FROM ItemTable WHERE key = ?\",\n            [\"jetskiStateSync.agentManagerInitState\"],\n            |row| row.get(0),\n        )\n        .map_err(|_| \"Login state data not found in either format\".to_string())?;\n        \n    // Base64 decode\n    let blob = general_purpose::STANDARD\n        .decode(&current_data)\n        .map_err(|e| format!(\"Base64 decoding failed: {}\", e))?;\n        \n    // 1. Find oauthTokenInfo (Field 6)\n    let oauth_data = protobuf::find_field(&blob, 6)\n        .map_err(|e| format!(\"Protobuf parsing failed: {}\", e))?\n        .ok_or(\"OAuth data not found (Field 6)\")?;\n        \n    // 2. Extract refresh_token (Field 3)\n    let refresh_bytes = protobuf::find_field(&oauth_data, 3)\n        .map_err(|e| format!(\"OAuth data parsing failed: {}\", e))?\n        .ok_or(\"Refresh Token not included in data (Field 3)\")?;\n        \n    String::from_utf8(refresh_bytes)\n        .map_err(|_| \"Refresh Token is not UTF-8 encoded\".to_string())\n}\n\n/// Get current Refresh Token from default database (backwards compatibility)\npub fn get_refresh_token_from_db() -> Result<String, String> {\n    let db_path = db::get_db_path()?;\n    extract_refresh_token_from_file(&db_path)\n}\n"
  },
  {
    "path": "src-tauri/src/modules/mod.rs",
    "content": "pub mod account;\npub mod quota;\npub mod config;\npub mod logger;\npub mod db;\npub mod process;\npub mod oauth;\npub mod oauth_server;\npub mod migration;\npub mod tray;\npub mod i18n;\npub mod proxy_db;\npub mod device;\npub mod update_checker;\npub mod scheduler;\npub mod token_stats;\npub mod cloudflared;\npub mod integration;\npub mod account_service;\n#[allow(dead_code)]\npub mod http_api;\npub mod cache;\npub mod log_bridge;\npub mod security_db;\npub mod user_token_db;\npub mod version;\n\nuse crate::models;\n\n// Re-export commonly used functions to the top level of the modules namespace for easy external calling\npub use account::*;\n#[allow(unused_imports)]\npub use quota::*;\npub use config::*;\n#[allow(unused_imports)]\npub use logger::*;\n// pub use device::*;\n\npub async fn fetch_quota(access_token: &str, email: &str, account_id: Option<&str>) -> crate::error::AppResult<(models::QuotaData, Option<String>)> {\n    quota::fetch_quota(access_token, email, account_id).await\n}\n"
  },
  {
    "path": "src-tauri/src/modules/oauth.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n// Google OAuth configuration\nconst CLIENT_ID: &str = \"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com\";\nconst CLIENT_SECRET: &str = \"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\";\nconst TOKEN_URL: &str = \"https://oauth2.googleapis.com/token\";\nconst USERINFO_URL: &str = \"https://www.googleapis.com/oauth2/v2/userinfo\";\n\nconst AUTH_URL: &str = \"https://accounts.google.com/o/oauth2/v2/auth\";\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct TokenResponse {\n    pub access_token: String,\n    pub expires_in: i64,\n    #[serde(default)]\n    pub token_type: String,\n    #[serde(default)]\n    pub refresh_token: Option<String>,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct UserInfo {\n    pub email: String,\n    pub name: Option<String>,\n    pub given_name: Option<String>,\n    pub family_name: Option<String>,\n    pub picture: Option<String>,\n}\n\nimpl UserInfo {\n    /// Get best display name\n    pub fn get_display_name(&self) -> Option<String> {\n        // Prefer name\n        if let Some(name) = &self.name {\n            if !name.trim().is_empty() {\n                return Some(name.clone());\n            }\n        }\n        \n        // If name is empty, combine given_name and family_name\n        match (&self.given_name, &self.family_name) {\n            (Some(given), Some(family)) => Some(format!(\"{} {}\", given, family)),\n            (Some(given), None) => Some(given.clone()),\n            (None, Some(family)) => Some(family.clone()),\n            (None, None) => None,\n        }\n    }\n}\n\n\n/// Generate OAuth authorization URL\npub fn get_auth_url(redirect_uri: &str, state: &str) -> String {\n    let scopes = vec![\n        \"https://www.googleapis.com/auth/cloud-platform\",\n        \"https://www.googleapis.com/auth/userinfo.email\",\n        \"https://www.googleapis.com/auth/userinfo.profile\",\n        \"https://www.googleapis.com/auth/cclog\",\n        \"https://www.googleapis.com/auth/experimentsandconfigs\"\n    ].join(\" \");\n\n    let params = vec![\n        (\"client_id\", CLIENT_ID),\n        (\"redirect_uri\", redirect_uri),\n        (\"response_type\", \"code\"),\n        (\"scope\", &scopes),\n        (\"access_type\", \"offline\"),\n        (\"prompt\", \"consent\"),\n        (\"include_granted_scopes\", \"true\"),\n        (\"state\", state),\n    ];\n    \n    let url = url::Url::parse_with_params(AUTH_URL, &params).expect(\"Invalid Auth URL\");\n    url.to_string()\n}\n\n/// Exchange authorization code for token\npub async fn exchange_code(code: &str, redirect_uri: &str) -> Result<TokenResponse, String> {\n    // [PHASE 2] 对于登录行为，尚未有 account_id，使用全局池阶梯逻辑\n    let client = if let Some(pool) = crate::proxy::proxy_pool::get_global_proxy_pool() {\n        pool.get_effective_standard_client(None, 60).await\n    } else {\n        crate::utils::http::get_long_standard_client()\n    };\n    \n    let params = [\n        (\"client_id\", CLIENT_ID),\n        (\"client_secret\", CLIENT_SECRET),\n        (\"code\", code),\n        (\"redirect_uri\", redirect_uri),\n        (\"grant_type\", \"authorization_code\"),\n    ];\n\n    tracing::debug!(\n        \"[OAuth] Sending exchange_code request with User-Agent: {}\",\n        crate::constants::NATIVE_OAUTH_USER_AGENT.as_str()\n    );\n\n    let response = client\n        .post(TOKEN_URL)\n        .header(rquest::header::USER_AGENT, crate::constants::NATIVE_OAUTH_USER_AGENT.as_str())\n        .form(&params)\n        .send()\n        .await\n        .map_err(|e| {\n            if e.is_connect() || e.is_timeout() {\n                format!(\"Token exchange request failed: {}. 请检查你的网络代理设置，确保可以稳定连接 Google 服务。\", e)\n            } else {\n                format!(\"Token exchange request failed: {}\", e)\n            }\n        })?;\n\n    if response.status().is_success() {\n        let token_res = response.json::<TokenResponse>()\n            .await\n            .map_err(|e| format!(\"Token parsing failed: {}\", e))?;\n        \n        // Add detailed logs\n        crate::modules::logger::log_info(&format!(\n            \"Token exchange successful! access_token: {}..., refresh_token: {}\",\n            &token_res.access_token.chars().take(20).collect::<String>(),\n            if token_res.refresh_token.is_some() { \"✓\" } else { \"✗ Missing\" }\n        ));\n        \n        // Log warning if refresh_token is missing\n        if token_res.refresh_token.is_none() {\n            crate::modules::logger::log_warn(\n                \"Warning: Google did not return a refresh_token. Potential reasons:\\n\\\n                 1. User has previously authorized this application\\n\\\n                 2. Need to revoke access in Google Cloud Console and retry\\n\\\n                 3. OAuth parameter configuration issue\"\n            );\n        }\n        \n        Ok(token_res)\n    } else {\n        let error_text = response.text().await.unwrap_or_default();\n        Err(format!(\"Token exchange failed: {}\", error_text))\n    }\n}\n\n/// Refresh access_token using refresh_token\npub async fn refresh_access_token(refresh_token: &str, account_id: Option<&str>) -> Result<TokenResponse, String> {\n    // [PHASE 2] 根据 account_id 使用对应的代理\n    let client = if let Some(pool) = crate::proxy::proxy_pool::get_global_proxy_pool() {\n        pool.get_effective_standard_client(account_id, 60).await\n    } else {\n        crate::utils::http::get_long_standard_client()\n    };\n    \n    let params = [\n        (\"client_id\", CLIENT_ID),\n        (\"client_secret\", CLIENT_SECRET),\n        (\"refresh_token\", refresh_token),\n        (\"grant_type\", \"refresh_token\"),\n    ];\n\n    // [FIX #1583] 提供更详细的日志，帮助诊断 Docker 环境下的代理问题\n    if let Some(id) = account_id {\n        crate::modules::logger::log_info(&format!(\"Refreshing Token for account: {}...\", id));\n    } else {\n        crate::modules::logger::log_info(\"Refreshing Token for generic request (no account_id)...\");\n    }\n    \n    tracing::debug!(\n        \"[OAuth] Sending refresh_access_token request with User-Agent: {}\",\n        crate::constants::NATIVE_OAUTH_USER_AGENT.as_str()\n    );\n\n    let response = client\n        .post(TOKEN_URL)\n        .header(rquest::header::USER_AGENT, crate::constants::NATIVE_OAUTH_USER_AGENT.as_str())\n        .form(&params)\n        .send()\n        .await\n        .map_err(|e| {\n            if e.is_connect() || e.is_timeout() {\n                format!(\"Refresh request failed: {}. 无法连接 Google 授权服务器，请检查代理设置。\", e)\n            } else {\n                format!(\"Refresh request failed: {}\", e)\n            }\n        })?;\n\n    if response.status().is_success() {\n        let token_data = response\n            .json::<TokenResponse>()\n            .await\n            .map_err(|e| format!(\"Refresh data parsing failed: {}\", e))?;\n        \n        crate::modules::logger::log_info(&format!(\"Token refreshed successfully! Expires in: {} seconds\", token_data.expires_in));\n        Ok(token_data)\n    } else {\n        let error_text = response.text().await.unwrap_or_default();\n        Err(format!(\"Refresh failed: {}\", error_text))\n    }\n}\n\n/// Get user info\npub async fn get_user_info(access_token: &str, account_id: Option<&str>) -> Result<UserInfo, String> {\n    let client = if let Some(pool) = crate::proxy::proxy_pool::get_global_proxy_pool() {\n        pool.get_effective_client(account_id, 15).await\n    } else {\n        crate::utils::http::get_client()\n    };\n    \n    let response = client\n        .get(USERINFO_URL)\n        .bearer_auth(access_token)\n        .send()\n        .await\n        .map_err(|e| format!(\"User info request failed: {}\", e))?;\n\n    if response.status().is_success() {\n        response.json::<UserInfo>()\n            .await\n            .map_err(|e| format!(\"User info parsing failed: {}\", e))\n    } else {\n        let error_text = response.text().await.unwrap_or_default();\n        Err(format!(\"Failed to get user info: {}\", error_text))\n    }\n}\n\n/// Check and refresh Token if needed\n/// Returns the latest access_token\npub async fn ensure_fresh_token(\n    current_token: &crate::models::TokenData,\n    account_id: Option<&str>,\n) -> Result<crate::models::TokenData, String> {\n    let now = chrono::Local::now().timestamp();\n    \n    // If no expiry or more than 5 minutes valid, return direct\n    if current_token.expiry_timestamp > now + 300 {\n        return Ok(current_token.clone());\n    }\n    \n    // Need to refresh\n    crate::modules::logger::log_info(&format!(\"Token expiring soon for account {:?}, refreshing...\", account_id));\n    let response = refresh_access_token(&current_token.refresh_token, account_id).await?;\n    \n    // Construct new TokenData\n    Ok(crate::models::TokenData::new(\n        response.access_token,\n        current_token.refresh_token.clone(), // refresh_token may not be returned on refresh\n        response.expires_in,\n        current_token.email.clone(),\n        current_token.project_id.clone(), // Keep original project_id\n        None,  // session_id will be generated in token_manager\n    ))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_get_auth_url_contains_state() {\n        let redirect_uri = \"http://localhost:8080/callback\";\n        let state = \"test-state-123456\";\n        let url = get_auth_url(redirect_uri, state);\n        \n        assert!(url.contains(\"state=test-state-123456\"));\n        assert!(url.contains(\"redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback\"));\n        assert!(url.contains(\"response_type=code\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/modules/oauth_server.rs",
    "content": "use tokio::io::{AsyncReadExt, AsyncWriteExt};\nuse tokio::net::TcpListener;\nuse tokio::sync::mpsc;\nuse tokio::sync::watch;\nuse std::sync::{Mutex, OnceLock};\nuse tauri::Url;\nuse crate::modules::oauth;\n\nstruct OAuthFlowState {\n    auth_url: String,\n    #[allow(dead_code)]\n    redirect_uri: String,\n    state: String,\n    cancel_tx: watch::Sender<bool>,\n    code_tx: mpsc::Sender<Result<String, String>>,\n    code_rx: Option<mpsc::Receiver<Result<String, String>>>,\n}\n\nstatic OAUTH_FLOW_STATE: OnceLock<Mutex<Option<OAuthFlowState>>> = OnceLock::new();\n\nfn get_oauth_flow_state() -> &'static Mutex<Option<OAuthFlowState>> {\n    OAUTH_FLOW_STATE.get_or_init(|| Mutex::new(None))\n}\n\nfn oauth_success_html() -> &'static str {\n    \"HTTP/1.1 200 OK\\r\\nContent-Type: text/html; charset=utf-8\\r\\n\\r\\n\\\n    <html>\\\n    <body style='font-family: sans-serif; text-align: center; padding: 50px;'>\\\n    <h1 style='color: green;'>✅ Authorization Successful!</h1>\\\n    <p>You can close this window and return to the application.</p>\\\n    <script>setTimeout(function() { window.close(); }, 2000);</script>\\\n    </body>\\\n    </html>\"\n}\n\nfn oauth_fail_html() -> &'static str {\n    \"HTTP/1.1 400 Bad Request\\r\\nContent-Type: text/html; charset=utf-8\\r\\n\\r\\n\\\n    <html>\\\n    <body style='font-family: sans-serif; text-align: center; padding: 50px;'>\\\n    <h1 style='color: red;'>❌ Authorization Failed</h1>\\\n    <p>Failed to obtain Authorization Code. Please return to the app and try again.</p>\\\n    </body>\\\n    </html>\"\n}\n\nasync fn ensure_oauth_flow_prepared(app_handle: Option<tauri::AppHandle>) -> Result<String, String> {\n\n    // Return URL if flow already exists and is still \"fresh\" (receiver hasn't been taken)\n    if let Ok(mut state) = get_oauth_flow_state().lock() {\n        if let Some(s) = state.as_mut() {\n            if s.code_rx.is_some() {\n                return Ok(s.auth_url.clone());\n            } else {\n                // Flow is already \"in progress\" (rx taken), but user requested a NEW one.\n                // Force cancel the old one to allow a new attempt.\n                let _ = s.cancel_tx.send(true);\n                *state = None;\n            }\n        }\n    }\n\n    // Create loopback listeners.\n    // Some browsers resolve `localhost` to IPv6 (::1). To avoid \"localhost refused connection\",\n    // we try to listen on BOTH IPv6 and IPv4 with the same port when possible.\n    let mut ipv4_listener: Option<TcpListener> = None;\n    let mut ipv6_listener: Option<TcpListener> = None;\n\n    // Prefer creating one listener on an ephemeral port first, then bind the other stack to same port.\n    // If both are available -> use `http://localhost:<port>` as redirect URI.\n    // If only one is available -> use an explicit IP to force correct stack.\n    let port: u16;\n    match TcpListener::bind(\"[::1]:0\").await {\n        Ok(l6) => {\n            port = l6\n                .local_addr()\n                .map_err(|e| format!(\"failed_to_get_local_port: {}\", e))?\n                .port();\n            ipv6_listener = Some(l6);\n\n            match TcpListener::bind(format!(\"127.0.0.1:{}\", port)).await {\n                Ok(l4) => ipv4_listener = Some(l4),\n                Err(e) => {\n                    crate::modules::logger::log_warn(&format!(\n                        \"failed_to_bind_ipv4_callback_port_127_0_0_1:{} (will only listen on IPv6): {}\",\n                        port, e\n                    ));\n                }\n            }\n        }\n        Err(_) => {\n            let l4 = TcpListener::bind(\"127.0.0.1:0\")\n                .await\n                .map_err(|e| format!(\"failed_to_bind_local_port: {}\", e))?;\n            port = l4\n                .local_addr()\n                .map_err(|e| format!(\"failed_to_get_local_port: {}\", e))?\n                .port();\n            ipv4_listener = Some(l4);\n\n            match TcpListener::bind(format!(\"[::1]:{}\", port)).await {\n                Ok(l6) => ipv6_listener = Some(l6),\n                Err(e) => {\n                    crate::modules::logger::log_warn(&format!(\n                        \"failed_to_bind_ipv6_callback_port_::1:{} (will only listen on IPv4): {}\",\n                        port, e\n                    ));\n                }\n            }\n        }\n    }\n\n    let has_ipv4 = ipv4_listener.is_some();\n    let has_ipv6 = ipv6_listener.is_some();\n\n    let redirect_uri = if has_ipv4 && has_ipv6 {\n        format!(\"http://localhost:{}/oauth-callback\", port)\n    } else if has_ipv4 {\n        format!(\"http://127.0.0.1:{}/oauth-callback\", port)\n    } else {\n        format!(\"http://[::1]:{}/oauth-callback\", port)\n    };\n\n    let state_str = uuid::Uuid::new_v4().to_string();\n    let auth_url = oauth::get_auth_url(&redirect_uri, &state_str);\n\n    // Cancellation signal (supports multiple consumers)\n    let (cancel_tx, cancel_rx) = watch::channel(false);\n    // Use mpsc instead of oneshot to allow multiple senders (listener OR manual input)\n    let (code_tx, code_rx) = mpsc::channel::<Result<String, String>>(1);\n\n    // Start listeners immediately: even if the user authorizes before clicking \"Start OAuth\",\n    // the browser can still hit our callback and finish the flow.\n    let app_handle_for_tasks = app_handle.clone();\n\n    if let Some(l4) = ipv4_listener {\n        let tx = code_tx.clone();\n        let mut rx = cancel_rx.clone();\n        let app_handle = app_handle_for_tasks.clone();\n        tokio::spawn(async move {\n            if let Ok((mut stream, _)) = tokio::select! {\n                res = l4.accept() => res.map_err(|e| format!(\"failed_to_accept_connection: {}\", e)),\n                _ = rx.changed() => Err(\"OAuth cancelled\".to_string()),\n            } {\n                // Reuse the existing parsing/response code by constructing a temporary listener task\n                // that sends into the shared mpsc channel.\n                let mut buffer = [0u8; 4096];\n                let bytes_read = stream.read(&mut buffer).await.unwrap_or(0);\n                let request = String::from_utf8_lossy(&buffer[..bytes_read]);\n                \n                // [FIX #931/850/778] More robust parsing and detailed logging\n                let query_params = request\n                    .lines()\n                    .next()\n                    .and_then(|line| {\n                        let parts: Vec<&str> = line.split_whitespace().collect();\n                        if parts.len() >= 2 { Some(parts[1]) } else { None }\n                    })\n                    .and_then(|path| {\n                        // Use a dummy base for parsing; redirect_uri is already set to localhost\n                        Url::parse(&format!(\"http://localhost{}\", path)).ok()\n                    })\n                    .map(|url| {\n                        let mut code = None;\n                        let mut state = None;\n                        for (k, v) in url.query_pairs() {\n                            if k == \"code\" { code = Some(v.to_string()); }\n                            else if k == \"state\" { state = Some(v.to_string()); }\n                        }\n                        (code, state)\n                    });\n\n                let (code, received_state) = match query_params {\n                    Some((c, s)) => (c, s),\n                    None => (None, None),\n                };\n\n                if code.is_none() && bytes_read > 0 {\n                    crate::modules::logger::log_error(&format!(\n                        \"OAuth callback failed to parse code. Raw request (first 512 bytes): {}\",\n                        &request.chars().take(512).collect::<String>()\n                    ));\n                }\n\n                // Verify state\n                let state_valid = {\n                    if let Ok(lock) = get_oauth_flow_state().lock() {\n                        if let Some(s) = lock.as_ref() {\n                            received_state.as_ref() == Some(&s.state)\n                        } else {\n                            false\n                        }\n                    } else {\n                        false\n                    }\n                };\n\n                let (result, response_html) = match (code, state_valid) {\n                    (Some(code), true) => {\n                        crate::modules::logger::log_info(\"Successfully captured OAuth code from IPv4 listener\");\n                        (Ok(code), oauth_success_html())\n                    },\n                    (Some(_), false) => {\n                        crate::modules::logger::log_error(\"OAuth callback state mismatch (CSRF protection)\");\n                        (Err(\"OAuth state mismatch\".to_string()), oauth_fail_html())\n                    },\n                    (None, _) => (Err(\"Failed to get Authorization Code in callback\".to_string()), oauth_fail_html()),\n                };\n                \n                let _ = stream.write_all(response_html.as_bytes()).await;\n                let _ = stream.flush().await;\n\n                if let Some(h) = app_handle {\n                    use tauri::Emitter;\n                    let _ = h.emit(\"oauth-callback-received\", ());\n                }\n                let _ = tx.send(result).await;\n            }\n        });\n    }\n\n    if let Some(l6) = ipv6_listener {\n        let tx = code_tx.clone();\n        let mut rx = cancel_rx;\n        let app_handle = app_handle_for_tasks;\n        tokio::spawn(async move {\n            if let Ok((mut stream, _)) = tokio::select! {\n                res = l6.accept() => res.map_err(|e| format!(\"failed_to_accept_connection: {}\", e)),\n                _ = rx.changed() => Err(\"OAuth cancelled\".to_string()),\n            } {\n                let mut buffer = [0u8; 4096];\n                let bytes_read = stream.read(&mut buffer).await.unwrap_or(0);\n                let request = String::from_utf8_lossy(&buffer[..bytes_read]);\n                \n                let query_params = request\n                    .lines()\n                    .next()\n                    .and_then(|line| {\n                        let parts: Vec<&str> = line.split_whitespace().collect();\n                        if parts.len() >= 2 { Some(parts[1]) } else { None }\n                    })\n                    .and_then(|path| {\n                        Url::parse(&format!(\"http://localhost{}\", path)).ok()\n                    })\n                    .map(|url| {\n                        let mut code = None;\n                        let mut state = None;\n                        for (k, v) in url.query_pairs() {\n                            if k == \"code\" { code = Some(v.to_string()); }\n                            else if k == \"state\" { state = Some(v.to_string()); }\n                        }\n                        (code, state)\n                    });\n\n                let (code, received_state) = match query_params {\n                    Some((c, s)) => (c, s),\n                    None => (None, None),\n                };\n\n                if code.is_none() && bytes_read > 0 {\n                    crate::modules::logger::log_error(&format!(\n                        \"OAuth callback failed to parse code (IPv6). Raw request: {}\",\n                        &request.chars().take(512).collect::<String>()\n                    ));\n                }\n\n                // Verify state\n                let state_valid = {\n                    if let Ok(lock) = get_oauth_flow_state().lock() {\n                        if let Some(s) = lock.as_ref() {\n                            received_state.as_ref() == Some(&s.state)\n                        } else {\n                            false\n                        }\n                    } else {\n                        false\n                    }\n                };\n\n                let (result, response_html) = match (code, state_valid) {\n                    (Some(code), true) => {\n                        crate::modules::logger::log_info(\"Successfully captured OAuth code from IPv6 listener\");\n                        (Ok(code), oauth_success_html())\n                    },\n                    (Some(_), false) => {\n                        crate::modules::logger::log_error(\"OAuth callback state mismatch (IPv6 CSRF protection)\");\n                        (Err(\"OAuth state mismatch\".to_string()), oauth_fail_html())\n                    },\n                    (None, _) => (Err(\"Failed to get Authorization Code in callback\".to_string()), oauth_fail_html()),\n                };\n                \n                let _ = stream.write_all(response_html.as_bytes()).await;\n                let _ = stream.flush().await;\n\n                if let Some(h) = app_handle {\n                    use tauri::Emitter;\n                    let _ = h.emit(\"oauth-callback-received\", ());\n                }\n                let _ = tx.send(result).await;\n            }\n        });\n    }\n\n    // Save state\n    if let Ok(mut state) = get_oauth_flow_state().lock() {\n        *state = Some(OAuthFlowState {\n            auth_url: auth_url.clone(),\n            redirect_uri,\n            state: state_str,\n            cancel_tx,\n            code_tx,\n            code_rx: Some(code_rx),\n        });\n    }\n\n    // Send event to frontend (for display/copying link)\n    if let Some(h) = app_handle {\n        use tauri::Emitter;\n        let _ = h.emit(\"oauth-url-generated\", &auth_url);\n    }\n\n    Ok(auth_url)\n}\n\n/// Pre-generate OAuth URL (does not open browser, does not block waiting for callback)\npub async fn prepare_oauth_url(app_handle: Option<tauri::AppHandle>) -> Result<String, String> {\n    ensure_oauth_flow_prepared(app_handle).await\n}\n\n/// Cancel current OAuth flow\npub fn cancel_oauth_flow() {\n    if let Ok(mut state) = get_oauth_flow_state().lock() {\n        if let Some(s) = state.take() {\n            let _ = s.cancel_tx.send(true);\n            crate::modules::logger::log_info(\"Sent OAuth cancellation signal\");\n        }\n    }\n}\n\n/// Start OAuth flow and wait for callback, then exchange token\npub async fn start_oauth_flow(app_handle: Option<tauri::AppHandle>) -> Result<oauth::TokenResponse, String> {\n    // Ensure URL + listener are ready (this way if the user authorizes first, it won't get stuck)\n    let auth_url = ensure_oauth_flow_prepared(app_handle.clone()).await?;\n\n    if let Some(h) = app_handle {\n        // Open default browser\n        use tauri_plugin_opener::OpenerExt;\n        h.opener()\n            .open_url(&auth_url, None::<String>)\n            .map_err(|e| format!(\"failed_to_open_browser: {}\", e))?;\n    }\n\n    // Take code_rx to wait for it\n    let (mut code_rx, redirect_uri) = {\n        let mut lock = get_oauth_flow_state()\n            .lock()\n            .map_err(|_| \"OAuth state lock corrupted\".to_string())?;\n        let Some(state) = lock.as_mut() else {\n            return Err(\"OAuth state does not exist\".to_string());\n        };\n        let rx = state\n            .code_rx\n            .take()\n            .ok_or_else(|| \"OAuth authorization already in progress\".to_string())?;\n        (rx, state.redirect_uri.clone())\n    };\n\n    // Wait for code (if user has already authorized, this returns immediately)\n    // For mpsc, we use recv()\n    let code = match code_rx.recv().await {\n        Some(Ok(code)) => code,\n        Some(Err(e)) => return Err(e),\n        None => return Err(\"OAuth flow channel closed unexpectedly\".to_string()),\n    };\n\n    // Clean up flow state (release cancel_tx, etc.)\n    if let Ok(mut lock) = get_oauth_flow_state().lock() {\n        *lock = None;\n    }\n\n    oauth::exchange_code(&code, &redirect_uri).await\n}\n\n/// Завершить OAuth flow без открытия браузера.\n/// Предполагается, что пользователь открыл ссылку вручную (или ранее была открыта),\n/// а мы только ждём callback и обмениваем code на token.\npub async fn complete_oauth_flow(app_handle: Option<tauri::AppHandle>) -> Result<oauth::TokenResponse, String> {\n    // Ensure URL + listeners exist\n    let _ = ensure_oauth_flow_prepared(app_handle).await?;\n\n    // Take receiver to wait for code\n    let (mut code_rx, redirect_uri) = {\n        let mut lock = get_oauth_flow_state()\n            .lock()\n            .map_err(|_| \"OAuth state lock corrupted\".to_string())?;\n        let Some(state) = lock.as_mut() else {\n            return Err(\"OAuth state does not exist\".to_string());\n        };\n        let rx = state\n            .code_rx\n            .take()\n            .ok_or_else(|| \"OAuth authorization already in progress\".to_string())?;\n        (rx, state.redirect_uri.clone())\n    };\n\n    let code = match code_rx.recv().await {\n        Some(Ok(code)) => code,\n        Some(Err(e)) => return Err(e),\n        None => return Err(\"OAuth flow channel closed unexpectedly\".to_string()),\n    };\n\n    if let Ok(mut lock) = get_oauth_flow_state().lock() {\n        *lock = None;\n    }\n\n    oauth::exchange_code(&code, &redirect_uri).await\n}\n\n/// Manually submit an OAuth code to complete the flow.\n/// This is used when the user manually copies the code/URL from the browser\n/// because the localhost callback couldn't be reached (e.g. in Docker/remote).\npub async fn submit_oauth_code(code_input: String, state_input: Option<String>) -> Result<(), String> {\n    let tx = {\n        let lock = get_oauth_flow_state().lock().map_err(|e| e.to_string())?;\n        if let Some(state) = lock.as_ref() {\n            // Verify state if provided\n            if let Some(provided_state) = state_input {\n                if provided_state != state.state {\n                    return Err(\"OAuth state mismatch (CSRF protection)\".to_string());\n                }\n            }\n            state.code_tx.clone()\n        } else {\n            return Err(\"No active OAuth flow found\".to_string());\n        }\n    };\n\n    // Extract code if it's a URL\n    let code = if code_input.starts_with(\"http\") {\n        if let Ok(url) = Url::parse(&code_input) {\n            url.query_pairs()\n                .find(|(k, _)| k == \"code\")\n                .map(|(_, v)| v.to_string())\n                .unwrap_or(code_input)\n        } else {\n            code_input\n        }\n    } else {\n        code_input\n    };\n\n    crate::modules::logger::log_info(\"Received manual OAuth code submission\");\n    \n    // Send to the channel\n    tx.send(Ok(code)).await.map_err(|_| \"Failed to send code to OAuth flow (receiver dropped)\".to_string())?;\n    \n    Ok(())\n}\n/// Manually prepare an OAuth flow without starting listeners.\n/// Useful for Web/Docker environments where we only need manual code submission.\npub fn prepare_oauth_flow_manually(redirect_uri: String, state_str: String) -> Result<(String, mpsc::Receiver<Result<String, String>>), String> {\n    let auth_url = oauth::get_auth_url(&redirect_uri, &state_str);\n    \n    // Check if we can reuse existing state\n    if let Ok(mut lock) = get_oauth_flow_state().lock() {\n        if let Some(s) = lock.as_mut() {\n             // If we already have a code_rx, we can't easily \"steal\" it again because it's already returned.\n             // But if this is a NEW request (different state), we should overwrite.\n             // For now, let's just clear and restart to be safe.\n             let _ = s.cancel_tx.send(true);\n             *lock = None;\n        }\n    }\n\n    let (cancel_tx, _cancel_rx) = watch::channel(false);\n    let (code_tx, code_rx) = mpsc::channel(1);\n\n    if let Ok(mut state) = get_oauth_flow_state().lock() {\n        *state = Some(OAuthFlowState {\n            auth_url: auth_url.clone(),\n            redirect_uri: redirect_uri.clone(),\n            state: state_str,\n            cancel_tx,\n            code_tx,\n            code_rx: None, // We return it directly\n        });\n    }\n\n    Ok((auth_url, code_rx))\n}\n"
  },
  {
    "path": "src-tauri/src/modules/process.rs",
    "content": "use std::process::Command;\nuse std::thread;\nuse std::time::Duration;\nuse sysinfo::System;\n\n#[cfg(target_os = \"windows\")]\nuse std::os::windows::process::CommandExt;\n\n/// Get normalized path of the current running executable\nfn get_current_exe_path() -> Option<std::path::PathBuf> {\n    std::env::current_exe()\n        .ok()\n        .and_then(|p| p.canonicalize().ok())\n}\n\n/// Check if Antigravity is running\npub fn is_antigravity_running() -> bool {\n    let mut system = System::new();\n    system.refresh_processes(sysinfo::ProcessesToUpdate::All);\n\n    let current_exe = get_current_exe_path();\n    let current_pid = std::process::id();\n\n    // Recognition ref 1: Load manual config path (moved outside loop for performance)\n    let manual_path = crate::modules::config::load_app_config()\n        .ok()\n        .and_then(|c| c.antigravity_executable)\n        .and_then(|p| std::path::PathBuf::from(p).canonicalize().ok());\n\n    for (pid, process) in system.processes() {\n        let pid_u32 = pid.as_u32();\n        if pid_u32 == current_pid {\n            continue;\n        }\n\n        let name = process.name().to_string_lossy().to_lowercase();\n        let exe_path = process\n            .exe()\n            .and_then(|p| p.to_str())\n            .unwrap_or(\"\")\n            .to_lowercase();\n\n        // Exclude own path (handles case where manager is mistaken for Antigravity on Linux)\n        if let (Some(ref my_path), Some(p_exe)) = (&current_exe, process.exe()) {\n            if let Ok(p_path) = p_exe.canonicalize() {\n                if my_path == &p_path {\n                    continue;\n                }\n            }\n        }\n\n        // Recognition ref 2: Priority check for manual path match\n        if let (Some(ref m_path), Some(p_exe)) = (&manual_path, process.exe()) {\n            if let Ok(p_path) = p_exe.canonicalize() {\n                // macOS: Check if within the same .app bundle\n                #[cfg(target_os = \"macos\")]\n                {\n                    let m_path_str = m_path.to_string_lossy();\n                    let p_path_str = p_path.to_string_lossy();\n                    if let (Some(m_idx), Some(p_idx)) =\n                        (m_path_str.find(\".app\"), p_path_str.find(\".app\"))\n                    {\n                        if m_path_str[..m_idx + 4] == p_path_str[..p_idx + 4] {\n                            // Even if path matches, must confirm via name and args that it's not a Helper\n                            let args = process.cmd();\n                            let is_helper_by_args = args\n                                .iter()\n                                .any(|arg| arg.to_string_lossy().contains(\"--type=\"));\n                            let is_helper_by_name = name.contains(\"helper\")\n                                || name.contains(\"plugin\")\n                                || name.contains(\"renderer\")\n                                || name.contains(\"gpu\")\n                                || name.contains(\"crashpad\")\n                                || name.contains(\"utility\")\n                                || name.contains(\"audio\")\n                                || name.contains(\"sandbox\");\n                            if !is_helper_by_args && !is_helper_by_name {\n                                return true;\n                            }\n                        }\n                    }\n                }\n\n                #[cfg(not(target_os = \"macos\"))]\n                if m_path == &p_path {\n                    return true;\n                }\n            }\n        }\n\n        // Common helper process exclusion logic\n        // Common helper process exclusion logic\n        let args = process.cmd();\n        let args_str = args\n            .iter()\n            .map(|arg| arg.to_string_lossy().to_lowercase())\n            .collect::<Vec<String>>()\n            .join(\" \");\n\n        let is_helper = args_str.contains(\"--type=\")\n            || name.contains(\"helper\")\n            || name.contains(\"plugin\")\n            || name.contains(\"renderer\")\n            || name.contains(\"gpu\")\n            || name.contains(\"crashpad\")\n            || name.contains(\"utility\")\n            || name.contains(\"audio\")\n            || name.contains(\"sandbox\")\n            || exe_path.contains(\"crashpad\");\n\n        #[cfg(target_os = \"macos\")]\n        {\n            if exe_path.contains(\"antigravity.app\") && !is_helper {\n                return true;\n            }\n        }\n\n        #[cfg(target_os = \"windows\")]\n        {\n            if name == \"antigravity.exe\" && !is_helper {\n                return true;\n            }\n        }\n\n        #[cfg(target_os = \"linux\")]\n        {\n            if (name.contains(\"antigravity\") || exe_path.contains(\"/antigravity\"))\n                && !name.contains(\"tools\")\n                && !is_helper\n            {\n                return true;\n            }\n        }\n    }\n\n    false\n}\n\n#[cfg(target_os = \"linux\")]\n/// Get PID set of current process and all direct relatives (ancestors + descendants)\nfn get_self_family_pids(system: &sysinfo::System) -> std::collections::HashSet<u32> {\n    let current_pid = std::process::id();\n    let mut family_pids = std::collections::HashSet::new();\n    family_pids.insert(current_pid);\n\n    // 1. Look up all ancestors (Ancestors) - prevent killing the launcher\n    let mut next_pid = current_pid;\n    // Prevent infinite loop, max depth 10\n    for _ in 0..10 {\n        let pid_val = sysinfo::Pid::from_u32(next_pid);\n        if let Some(process) = system.process(pid_val) {\n            if let Some(parent) = process.parent() {\n                let parent_id = parent.as_u32();\n                // Avoid cycles or duplicates\n                if !family_pids.insert(parent_id) {\n                    break;\n                }\n                next_pid = parent_id;\n            } else {\n                break;\n            }\n        } else {\n            break;\n        }\n    }\n\n    // 2. Look down all descendants (Descendants)\n    // Build parent-child relationship map (Parent -> Children)\n    let mut adj: std::collections::HashMap<u32, Vec<u32>> = std::collections::HashMap::new();\n    for (pid, process) in system.processes() {\n        if let Some(parent) = process.parent() {\n            adj.entry(parent.as_u32()).or_default().push(pid.as_u32());\n        }\n    }\n\n    // BFS traversal to find all descendants\n    let mut queue = std::collections::VecDeque::new();\n    queue.push_back(current_pid);\n\n    while let Some(pid) = queue.pop_front() {\n        if let Some(children) = adj.get(&pid) {\n            for &child in children {\n                if family_pids.insert(child) {\n                    queue.push_back(child);\n                }\n            }\n        }\n    }\n\n    family_pids\n}\n\n/// Get PIDs of all Antigravity processes (including main and helper processes)\nfn get_antigravity_pids() -> Vec<u32> {\n    let mut system = System::new();\n    system.refresh_processes(sysinfo::ProcessesToUpdate::All);\n\n    // Linux: Enable family process tree exclusion\n    #[cfg(target_os = \"linux\")]\n    let family_pids = get_self_family_pids(&system);\n\n    let mut pids = Vec::new();\n    let current_pid = std::process::id();\n    let current_exe = get_current_exe_path();\n\n    // Load manual config path as auxiliary reference\n    let manual_path = crate::modules::config::load_app_config()\n        .ok()\n        .and_then(|c| c.antigravity_executable)\n        .and_then(|p| std::path::PathBuf::from(p).canonicalize().ok());\n\n    for (pid, process) in system.processes() {\n        let pid_u32 = pid.as_u32();\n\n        // Exclude own PID\n        if pid_u32 == current_pid {\n            continue;\n        }\n\n        // Exclude own executable path (hardened against broad name matching)\n        if let (Some(ref my_path), Some(p_exe)) = (&current_exe, process.exe()) {\n            if let Ok(p_path) = p_exe.canonicalize() {\n                if my_path == &p_path {\n                    continue;\n                }\n            }\n        }\n\n        let _name = process.name().to_string_lossy().to_lowercase();\n\n        #[cfg(target_os = \"linux\")]\n        {\n            // 1. Exclude family processes (self, children, parents)\n            if family_pids.contains(&pid_u32) {\n                continue;\n            }\n            // 2. Extra protection: match \"tools\" likely manager if not a child\n            if _name.contains(\"tools\") {\n                continue;\n            }\n        }\n\n        #[cfg(not(target_os = \"linux\"))]\n        {\n            // Other platforms: exclude only self\n            if pid_u32 == current_pid {\n                continue;\n            }\n        }\n\n        // Recognition ref 3: Check manual config path match\n        if let (Some(ref m_path), Some(p_exe)) = (&manual_path, process.exe()) {\n            if let Ok(p_path) = p_exe.canonicalize() {\n                #[cfg(target_os = \"macos\")]\n                {\n                    let m_path_str = m_path.to_string_lossy();\n                    let p_path_str = p_path.to_string_lossy();\n                    if let (Some(m_idx), Some(p_idx)) =\n                        (m_path_str.find(\".app\"), p_path_str.find(\".app\"))\n                    {\n                        if m_path_str[..m_idx + 4] == p_path_str[..p_idx + 4] {\n                            let args = process.cmd();\n                            let is_helper_by_args = args\n                                .iter()\n                                .any(|arg| arg.to_string_lossy().contains(\"--type=\"));\n                            let is_helper_by_name = _name.contains(\"helper\")\n                                || _name.contains(\"plugin\")\n                                || _name.contains(\"renderer\")\n                                || _name.contains(\"gpu\")\n                                || _name.contains(\"crashpad\")\n                                || _name.contains(\"utility\")\n                                || _name.contains(\"audio\")\n                                || _name.contains(\"sandbox\");\n                            if !is_helper_by_args && !is_helper_by_name {\n                                pids.push(pid_u32);\n                                continue;\n                            }\n                        }\n                    }\n                }\n\n                #[cfg(not(target_os = \"macos\"))]\n                if m_path == &p_path {\n                    pids.push(pid_u32);\n                    continue;\n                }\n            }\n        }\n\n        // Get executable path\n        let exe_path = process\n            .exe()\n            .and_then(|p| p.to_str())\n            .unwrap_or(\"\")\n            .to_lowercase();\n\n        // Common helper process exclusion logic\n        let args = process.cmd();\n        let args_str = args\n            .iter()\n            .map(|arg| arg.to_string_lossy().to_lowercase())\n            .collect::<Vec<String>>()\n            .join(\" \");\n\n        let is_helper = args_str.contains(\"--type=\")\n            || _name.contains(\"helper\")\n            || _name.contains(\"plugin\")\n            || _name.contains(\"renderer\")\n            || _name.contains(\"gpu\")\n            || _name.contains(\"crashpad\")\n            || _name.contains(\"utility\")\n            || _name.contains(\"audio\")\n            || _name.contains(\"sandbox\")\n            || exe_path.contains(\"crashpad\");\n\n        #[cfg(target_os = \"macos\")]\n        {\n            // Match processes within Antigravity main app bundle, excluding Helper/Plugin/Renderer etc.\n            if exe_path.contains(\"antigravity.app\") && !is_helper {\n                pids.push(pid_u32);\n            }\n        }\n\n        #[cfg(target_os = \"windows\")]\n        {\n            let name = process.name().to_string_lossy().to_lowercase();\n            if name == \"antigravity.exe\" && !is_helper {\n                pids.push(pid_u32);\n            }\n        }\n\n        #[cfg(target_os = \"linux\")]\n        {\n            let name = process.name().to_string_lossy().to_lowercase();\n            if (name == \"antigravity\" || exe_path.contains(\"/antigravity\"))\n                && !name.contains(\"tools\")\n                && !is_helper\n            {\n                pids.push(pid_u32);\n            }\n        }\n    }\n\n    if !pids.is_empty() {\n        crate::modules::logger::log_info(&format!(\n            \"Found {} Antigravity processes: {:?}\",\n            pids.len(),\n            pids\n        ));\n    }\n\n    pids\n}\n\n/// Close Antigravity processes\npub fn close_antigravity(#[allow(unused_variables)] timeout_secs: u64) -> Result<(), String> {\n    crate::modules::logger::log_info(\"Closing Antigravity...\");\n\n    #[cfg(target_os = \"windows\")]\n    {\n        // Windows: Precise kill by PID to support multiple versions or custom filenames\n        let pids = get_antigravity_pids();\n        if !pids.is_empty() {\n            crate::modules::logger::log_info(&format!(\n                \"Precisely closing {} identified processes on Windows...\",\n                pids.len()\n            ));\n            for pid in pids {\n                let _ = Command::new(\"taskkill\")\n                    .args([\"/F\", \"/PID\", &pid.to_string()])\n                    .creation_flags(0x08000000) // CREATE_NO_WINDOW\n                    .output();\n            }\n            // Give some time for system to clean up PIDs\n            thread::sleep(Duration::from_millis(200));\n        }\n    }\n\n    #[cfg(target_os = \"macos\")]\n    {\n        // macOS: Optimize closing strategy to avoid \"Window terminated unexpectedly\" popups\n        // Strategy: SEND SIGTERM to main process only, let it coordinate closing children\n\n        let pids = get_antigravity_pids();\n        if !pids.is_empty() {\n            // 1. Identify main process (PID)\n            // Strategy: Principal processes of Electron/Tauri do not have the `--type` parameter, while Helper processes have `--type=renderer/gpu/utility`, etc.\n            let mut system = System::new();\n            system.refresh_processes(sysinfo::ProcessesToUpdate::All);\n\n            let mut main_pid = None;\n\n            // Load manual configuration path as highest priority reference\n            let manual_path = crate::modules::config::load_app_config()\n                .ok()\n                .and_then(|c| c.antigravity_executable)\n                .and_then(|p| std::path::PathBuf::from(p).canonicalize().ok());\n\n            crate::modules::logger::log_info(\"Analyzing process list to identify main process:\");\n            for pid_u32 in &pids {\n                let pid = sysinfo::Pid::from_u32(*pid_u32);\n                if let Some(process) = system.process(pid) {\n                    let name = process.name().to_string_lossy();\n                    let args = process.cmd();\n                    let args_str = args\n                        .iter()\n                        .map(|arg| arg.to_string_lossy().into_owned())\n                        .collect::<Vec<String>>()\n                        .join(\" \");\n\n                    crate::modules::logger::log_info(&format!(\n                        \" - PID: {} | Name: {} | Args: {}\",\n                        pid_u32, name, args_str\n                    ));\n\n                    // 1. Priority to manual path matching\n                    if let (Some(ref m_path), Some(p_exe)) = (&manual_path, process.exe()) {\n                        if let Ok(p_path) = p_exe.canonicalize() {\n                            let m_path_str = m_path.to_string_lossy();\n                            let p_path_str = p_path.to_string_lossy();\n                            if let (Some(m_idx), Some(p_idx)) =\n                                (m_path_str.find(\".app\"), p_path_str.find(\".app\"))\n                            {\n                                if m_path_str[..m_idx + 4] == p_path_str[..p_idx + 4] {\n                                    // Deep validation: even if path matches, must exclude Helper keywords and arguments\n                                    let is_helper_by_args = args_str.contains(\"--type=\");\n                                    let is_helper_by_name = name.to_lowercase().contains(\"helper\")\n                                        || name.to_lowercase().contains(\"plugin\")\n                                        || name.to_lowercase().contains(\"renderer\")\n                                        || name.to_lowercase().contains(\"gpu\")\n                                        || name.to_lowercase().contains(\"crashpad\")\n                                        || name.to_lowercase().contains(\"utility\")\n                                        || name.to_lowercase().contains(\"audio\")\n                                        || name.to_lowercase().contains(\"sandbox\")\n                                        || name.to_lowercase().contains(\"language_server\");\n\n                                    if !is_helper_by_args && !is_helper_by_name {\n                                        main_pid = Some(pid_u32);\n                                        crate::modules::logger::log_info(&format!(\n                                            \"   => Identified as main process (manual path match)\"\n                                        ));\n                                        break;\n                                    }\n                                }\n                            }\n                        }\n                    }\n\n                    // 2. Feature analysis matching (fallback)\n                    let is_helper_by_name = name.to_lowercase().contains(\"helper\")\n                        || name.to_lowercase().contains(\"crashpad\")\n                        || name.to_lowercase().contains(\"utility\")\n                        || name.to_lowercase().contains(\"audio\")\n                        || name.to_lowercase().contains(\"sandbox\")\n                        || name.to_lowercase().contains(\"language_server\")\n                        || name.to_lowercase().contains(\"plugin\")\n                        || name.to_lowercase().contains(\"renderer\");\n\n                    let is_helper_by_args = args_str.contains(\"--type=\");\n\n                    if !is_helper_by_name && !is_helper_by_args {\n                        if main_pid.is_none() {\n                            main_pid = Some(pid_u32);\n                            crate::modules::logger::log_info(&format!(\n                                \"   => Identified as main process (Name/Args analysis)\"\n                            ));\n                        }\n                    } else {\n                        crate::modules::logger::log_info(&format!(\n                            \"   => Identified as helper process (Helper/Args)\"\n                        ));\n                    }\n                }\n            }\n\n            // Phase 1: Graceful exit (SIGTERM)\n            if let Some(pid) = main_pid {\n                crate::modules::logger::log_info(&format!(\n                    \"Sending SIGTERM to main process PID: {}\",\n                    pid\n                ));\n                let output = Command::new(\"kill\")\n                    .args([\"-15\", &pid.to_string()])\n                    .output();\n\n                if let Ok(result) = output {\n                    if !result.status.success() {\n                        let error = String::from_utf8_lossy(&result.stderr);\n                        crate::modules::logger::log_warn(&format!(\n                            \"Main process SIGTERM failed: {}\",\n                            error\n                        ));\n                    }\n                }\n            } else {\n                crate::modules::logger::log_warn(\n                    \"No clear main process identified, attempting SIGTERM for all processes (may cause popups)\",\n                );\n                for pid in &pids {\n                    let _ = Command::new(\"kill\")\n                        .args([\"-15\", &pid.to_string()])\n                        .output();\n                }\n            }\n\n            // Wait for graceful exit (max 70% of timeout_secs)\n            let graceful_timeout = (timeout_secs * 7) / 10;\n            let start = std::time::Instant::now();\n            while start.elapsed() < Duration::from_secs(graceful_timeout) {\n                if !is_antigravity_running() {\n                    crate::modules::logger::log_info(\"All Antigravity processes gracefully closed\");\n                    return Ok(());\n                }\n                thread::sleep(Duration::from_millis(500));\n            }\n\n            // Phase 2: Force kill (SIGKILL) - targeting all remaining processes (Helpers)\n            if is_antigravity_running() {\n                let remaining_pids = get_antigravity_pids();\n                if !remaining_pids.is_empty() {\n                    crate::modules::logger::log_warn(&format!(\n                        \"Graceful exit timeout, force killing {} remaining processes (SIGKILL)\",\n                        remaining_pids.len()\n                    ));\n                    for pid in &remaining_pids {\n                        let output = Command::new(\"kill\").args([\"-9\", &pid.to_string()]).output();\n\n                        if let Ok(result) = output {\n                            if !result.status.success() {\n                                let error = String::from_utf8_lossy(&result.stderr);\n                                if !error.contains(\"No such process\") {\n                                    // \"No matching processes\" for killall, \"No such process\" for kill\n                                    crate::modules::logger::log_error(&format!(\n                                        \"SIGKILL process {} failed: {}\",\n                                        pid, error\n                                    ));\n                                }\n                            }\n                        }\n                    }\n                    thread::sleep(Duration::from_secs(1));\n                }\n\n                // Final check\n                if !is_antigravity_running() {\n                    crate::modules::logger::log_info(\"All processes exited after forced cleanup\");\n                    return Ok(());\n                }\n            } else {\n                crate::modules::logger::log_info(\"All processes exited after SIGTERM\");\n                return Ok(());\n            }\n        } else {\n            // Only consider not running when pids is empty, don't error here as it might already be closed\n            crate::modules::logger::log_info(\"Antigravity not running, no need to close\");\n            return Ok(());\n        }\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        // Linux: Also attempt to identify main process and delegate exit\n        let pids = get_antigravity_pids();\n        if !pids.is_empty() {\n            let mut system = System::new();\n            system.refresh_processes(sysinfo::ProcessesToUpdate::All);\n\n            let mut main_pid = None;\n\n            // Load manual configuration path as highest priority reference\n            let manual_path = crate::modules::config::load_app_config()\n                .ok()\n                .and_then(|c| c.antigravity_executable)\n                .and_then(|p| std::path::PathBuf::from(p).canonicalize().ok());\n\n            crate::modules::logger::log_info(\"Analyzing Linux process list to identify main process:\");\n            for pid_u32 in &pids {\n                let pid = sysinfo::Pid::from_u32(*pid_u32);\n                if let Some(process) = system.process(pid) {\n                    let name = process.name().to_string_lossy().to_lowercase();\n                    let args = process.cmd();\n                    let args_str = args\n                        .iter()\n                        .map(|arg| arg.to_string_lossy().into_owned())\n                        .collect::<Vec<String>>()\n                        .join(\" \");\n\n                    crate::modules::logger::log_info(&format!(\n                        \" - PID: {} | Name: {} | Args: {}\",\n                        pid_u32, name, args_str\n                    ));\n\n                    // 1. Priority to manual path matching\n                    if let (Some(ref m_path), Some(p_exe)) = (&manual_path, process.exe()) {\n                        if let Ok(p_path) = p_exe.canonicalize() {\n                            if &p_path == m_path {\n                                // Confirm not a Helper\n                                let is_helper_by_args = args_str.contains(\"--type=\");\n                                let is_helper_by_name = name.contains(\"helper\")\n                                    || name.contains(\"renderer\")\n                                    || name.contains(\"gpu\")\n                                    || name.contains(\"crashpad\")\n                                    || name.contains(\"utility\")\n                                    || name.contains(\"audio\")\n                                    || name.contains(\"sandbox\");\n                                if !is_helper_by_args && !is_helper_by_name {\n                                    main_pid = Some(pid_u32);\n                                    crate::modules::logger::log_info(&format!(\n                                        \"   => Identified as main process (manual path match)\"\n                                    ));\n                                    break;\n                                }\n                            }\n                        }\n                    }\n\n                    // 2. Feature analysis matching\n                    let is_helper_by_args = args_str.contains(\"--type=\");\n                    let is_helper_by_name = name.contains(\"helper\")\n                        || name.contains(\"renderer\")\n                        || name.contains(\"gpu\")\n                        || name.contains(\"crashpad\")\n                        || name.contains(\"utility\")\n                        || name.contains(\"audio\")\n                        || name.contains(\"sandbox\")\n                        || name.contains(\"plugin\")\n                        || name.contains(\"language_server\");\n\n                    if !is_helper_by_args && !is_helper_by_name {\n                        if main_pid.is_none() {\n                            main_pid = Some(pid_u32);\n                            crate::modules::logger::log_info(&format!(\n                                \"   => Identified as main process (Feature analysis)\"\n                            ));\n                        }\n                    } else {\n                        crate::modules::logger::log_info(&format!(\n                            \"   => Identified as helper process (Helper/Args)\"\n                        ));\n                    }\n                }\n            }\n\n            // Phase 1: Graceful exit (SIGTERM)\n            if let Some(pid) = main_pid {\n                crate::modules::logger::log_info(&format!(\"Attempting to gracefully close main process {} (SIGTERM)\", pid));\n                let _ = Command::new(\"kill\")\n                    .args([\"-15\", &pid.to_string()])\n                    .output();\n            } else {\n                crate::modules::logger::log_warn(\n                    \"No clear Linux main process identified, sending SIGTERM to all associated processes\",\n                );\n                for pid in &pids {\n                    let _ = Command::new(\"kill\")\n                        .args([\"-15\", &pid.to_string()])\n                        .output();\n                }\n            }\n\n            // Wait for graceful exit\n            let graceful_timeout = (timeout_secs * 7) / 10;\n            let start = std::time::Instant::now();\n            while start.elapsed() < Duration::from_secs(graceful_timeout) {\n                if !is_antigravity_running() {\n                    crate::modules::logger::log_info(\"Antigravity gracefully closed\");\n                    return Ok(());\n                }\n                thread::sleep(Duration::from_millis(500));\n            }\n\n            // Phase 2: Force kill (SIGKILL) - targeting all remaining processes\n            if is_antigravity_running() {\n                let remaining_pids = get_antigravity_pids();\n                if !remaining_pids.is_empty() {\n                    crate::modules::logger::log_warn(&format!(\n                        \"Graceful exit timeout, force killing {} remaining processes (SIGKILL)\",\n                        remaining_pids.len()\n                    ));\n                    for pid in &remaining_pids {\n                        let _ = Command::new(\"kill\").args([\"-9\", &pid.to_string()]).output();\n                    }\n                    thread::sleep(Duration::from_secs(1));\n                }\n            }\n        } else {\n            // pids is empty, meaning no process detected or all excluded by logic\n            crate::modules::logger::log_info(\n                \"No Antigravity processes found to close (possibly filtered or not running)\",\n            );\n        }\n    }\n\n    // Final check\n    if is_antigravity_running() {\n        return Err(\"Unable to close Antigravity process, please close manually and retry\".to_string());\n    }\n\n    crate::modules::logger::log_info(\"Antigravity closed successfully\");\n    Ok(())\n}\n\n/// Start Antigravity\n#[allow(unused_mut)]\npub fn start_antigravity() -> Result<(), String> {\n    crate::modules::logger::log_info(\"Starting Antigravity...\");\n\n    // Prefer manually specified path and args from configuration\n    let config = crate::modules::config::load_app_config().ok();\n    let manual_path = config\n        .as_ref()\n        .and_then(|c| c.antigravity_executable.clone());\n    let args = config.and_then(|c| c.antigravity_args.clone());\n\n    if let Some(mut path_str) = manual_path {\n        let mut path = std::path::PathBuf::from(&path_str);\n\n        #[cfg(target_os = \"macos\")]\n        {\n            // Fault tolerance: If path is inside .app bundle (e.g. misselected Helper), auto-correct to .app directory\n            if let Some(app_idx) = path_str.find(\".app\") {\n                let corrected_app = &path_str[..app_idx + 4];\n                if corrected_app != path_str {\n                    crate::modules::logger::log_info(&format!(\n                        \"Detected macOS path inside .app bundle, auto-correcting to: {}\",\n                        corrected_app\n                    ));\n                    path_str = corrected_app.to_string();\n                    path = std::path::PathBuf::from(&path_str);\n                }\n            }\n        }\n\n        if path.exists() {\n            crate::modules::logger::log_info(&format!(\"Starting with manual configuration path: {}\", path_str));\n\n            #[cfg(target_os = \"macos\")]\n            {\n                // macOS: if .app directory, use open\n                if path_str.ends_with(\".app\") || path.is_dir() {\n                    let mut cmd = Command::new(\"open\");\n                    cmd.arg(\"-a\").arg(&path_str);\n\n                    // Add startup arguments\n                    if let Some(ref args) = args {\n                        for arg in args {\n                            cmd.arg(arg);\n                        }\n                    }\n\n                    cmd.spawn().map_err(|e| format!(\"Startup failed (open): {}\", e))?;\n                } else {\n                    let mut cmd = Command::new(&path_str);\n\n                    // Add startup arguments\n                    if let Some(ref args) = args {\n                        for arg in args {\n                            cmd.arg(arg);\n                        }\n                    }\n\n                    cmd.spawn()\n                        .map_err(|e| format!(\"Startup failed (direct): {}\", e))?;\n                }\n            }\n\n            #[cfg(not(target_os = \"macos\"))]\n            {\n                let mut cmd = Command::new(&path_str);\n\n                // Add startup arguments\n                if let Some(ref args) = args {\n                    for arg in args {\n                        cmd.arg(arg);\n                    }\n                }\n\n                cmd.spawn().map_err(|e| format!(\"Startup failed: {}\", e))?;\n            }\n\n            crate::modules::logger::log_info(&format!(\n                \"Antigravity startup command sent (manual path: {}, args: {:?})\",\n                path_str, args\n            ));\n            return Ok(());\n        } else {\n            crate::modules::logger::log_warn(&format!(\n                \"Manual configuration path does not exist: {}, falling back to auto-detection\",\n                path_str\n            ));\n        }\n    }\n\n    #[cfg(target_os = \"macos\")]\n    {\n        // Improvement: Use output() to wait for open command completion and capture \"app not found\" error\n        let mut cmd = Command::new(\"open\");\n        cmd.args([\"-a\", \"Antigravity\"]);\n\n        // Add startup arguments\n        if let Some(ref args) = args {\n            for arg in args {\n                cmd.arg(arg);\n            }\n        }\n\n        let output = cmd\n            .output()\n            .map_err(|e| format!(\"Unable to execute open command: {}\", e))?;\n\n        if !output.status.success() {\n            let error = String::from_utf8_lossy(&output.stderr);\n            return Err(format!(\n                \"Startup failed (open exited with {}): {}\",\n                output.status, error\n            ));\n        }\n    }\n\n    #[cfg(target_os = \"windows\")]\n    {\n        let has_args = args.as_ref().map_or(false, |a| !a.is_empty());\n        \n        if has_args {\n            if let Some(detected_path) = get_antigravity_executable_path() {\n                let path_str = detected_path.to_string_lossy().to_string();\n                crate::modules::logger::log_info(&format!(\n                    \"Starting with auto-detected path (has args): {}\",\n                    path_str\n                ));\n                \n                use crate::utils::command::CommandExtWrapper;\n                let mut cmd = Command::new(&path_str);\n                cmd.creation_flags_windows();\n                if let Some(ref args) = args {\n                    for arg in args {\n                        cmd.arg(arg);\n                    }\n                }\n                \n                cmd.spawn().map_err(|e| format!(\"Startup failed: {}\", e))?;\n            } else {\n                return Err(\"Startup arguments configured but cannot find Antigravity executable path. Please set the executable path manually in Settings.\".to_string());\n            }\n        } else {\n            use crate::utils::command::CommandExtWrapper;\n            let mut cmd = Command::new(\"cmd\");\n            cmd.creation_flags_windows();\n            cmd.args([\"/C\", \"start\", \"antigravity://\"]);\n            \n            let result = cmd.spawn();\n            if result.is_err() {\n                return Err(\"Startup failed, please open Antigravity manually\".to_string());\n            }\n        }\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        let mut cmd = Command::new(\"antigravity\");\n\n        // Add startup arguments\n        if let Some(ref args) = args {\n            for arg in args {\n                cmd.arg(arg);\n            }\n        }\n\n        cmd.spawn().map_err(|e| format!(\"Startup failed: {}\", e))?;\n    }\n\n    crate::modules::logger::log_info(&format!(\n        \"Antigravity startup command sent (default detection, args: {:?})\",\n        args\n    ));\n    Ok(())\n}\n\n/// Get Antigravity executable path and startup arguments from running processes\n///\n/// This is the most reliable method to find installations and startup args anywhere\nfn get_process_info() -> (Option<std::path::PathBuf>, Option<Vec<String>>) {\n    let mut system = System::new_all();\n    system.refresh_all();\n\n    let current_exe = get_current_exe_path();\n    let current_pid = std::process::id();\n\n    for (pid, process) in system.processes() {\n        let pid_u32 = pid.as_u32();\n        if pid_u32 == current_pid {\n            continue;\n        }\n\n        // Exclude manager process itself\n        if let (Some(ref my_path), Some(p_exe)) = (&current_exe, process.exe()) {\n            if let Ok(p_path) = p_exe.canonicalize() {\n                if my_path == &p_path {\n                    continue;\n                }\n            }\n        }\n\n        let name = process.name().to_string_lossy().to_lowercase();\n\n        // Get executable path and command line arguments\n        if let Some(exe) = process.exe() {\n            let mut args = process.cmd().iter();\n            let exe_path = args\n                .next()\n                .map_or(exe.to_string_lossy(), |arg| arg.to_string_lossy())\n                .to_lowercase();\n\n            // Extract actual arguments from command line (skipping exe path)\n            let args = args\n                .map(|arg| arg.to_string_lossy().to_lowercase())\n                .collect::<Vec<String>>();\n\n            let args_str = args.join(\" \");\n\n            // Common helper process exclusion logic\n            let is_helper = args_str.contains(\"--type=\")\n                || args_str.contains(\"node-ipc\")\n                || args_str.contains(\"nodeipc\")\n                || args_str.contains(\"max-old-space-size\")\n                || args_str.contains(\"node_modules\")\n                || name.contains(\"helper\")\n                || name.contains(\"plugin\")\n                || name.contains(\"renderer\")\n                || name.contains(\"gpu\")\n                || name.contains(\"crashpad\")\n                || name.contains(\"utility\")\n                || name.contains(\"audio\")\n                || name.contains(\"sandbox\")\n                || exe_path.contains(\"crashpad\");\n\n            let path = Some(exe.to_path_buf());\n            let args = Some(args);\n            #[cfg(target_os = \"macos\")]\n            {\n                // macOS: Exclude helper processes, match main app only, and check Frameworks\n                if exe_path.contains(\"antigravity.app\")\n                    && !is_helper\n                    && !exe_path.contains(\"frameworks\")\n                {\n                    // Try to extract .app path for better open command support\n                    if let Some(app_idx) = exe_path.find(\".app\") {\n                        let app_path_str = &exe.to_string_lossy()[..app_idx + 4];\n                        let path = Some(std::path::PathBuf::from(app_path_str));\n                        return (path, args);\n                    }\n                    return (path, args);\n                }\n            }\n\n            #[cfg(target_os = \"windows\")]\n            {\n                // Windows: Strictly match process name and exclude helpers\n                if name == \"antigravity.exe\" && !is_helper {\n                    return (path, args);\n                }\n            }\n\n            #[cfg(target_os = \"linux\")]\n            {\n                // Linux: Check process name or path for antigravity, excluding helpers and manager\n                if (name == \"antigravity\" || exe_path.contains(\"/antigravity\"))\n                    && !name.contains(\"tools\")\n                    && !is_helper\n                {\n                    return (path, args);\n                }\n            }\n        }\n    }\n    (None, None)\n}\n\n/// Get Antigravity executable path from running processes\n///\n/// Most reliable method to find installation anywhere\npub fn get_path_from_running_process() -> Option<std::path::PathBuf> {\n    let (path, _) = get_process_info();\n    path\n}\n\n/// Get Antigravity startup arguments from running processes\npub fn get_args_from_running_process() -> Option<Vec<String>> {\n    let (_, args) = get_process_info();\n    args\n}\n\n/// Get --user-data-dir argument value (if exists)\npub fn get_user_data_dir_from_process() -> Option<std::path::PathBuf> {\n    // Prefer getting startup arguments from config\n    if let Ok(config) = crate::modules::config::load_app_config() {\n        if let Some(args) = config.antigravity_args {\n            // Check arguments in config\n            for i in 0..args.len() {\n                if args[i] == \"--user-data-dir\" && i + 1 < args.len() {\n                    // Next argument is the path\n                    let path = std::path::PathBuf::from(&args[i + 1]);\n                    if path.exists() {\n                        return Some(path);\n                    }\n                } else if args[i].starts_with(\"--user-data-dir=\") {\n                    // Argument and value in same string, e.g. --user-data-dir=/path/to/data\n                    let parts: Vec<&str> = args[i].splitn(2, '=').collect();\n                    if parts.len() == 2 {\n                        let path_str = parts[1];\n                        let path = std::path::PathBuf::from(path_str);\n                        if path.exists() {\n                            return Some(path);\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    // If not in config, get arguments from running process\n    if let Some(args) = get_args_from_running_process() {\n        for i in 0..args.len() {\n            if args[i] == \"--user-data-dir\" && i + 1 < args.len() {\n                // Next argument is the path\n                let path = std::path::PathBuf::from(&args[i + 1]);\n                if path.exists() {\n                    return Some(path);\n                }\n            } else if args[i].starts_with(\"--user-data-dir=\") {\n                // Argument and value in same string, e.g. --user-data-dir=/path/to/data\n                let parts: Vec<&str> = args[i].splitn(2, '=').collect();\n                if parts.len() == 2 {\n                    let path_str = parts[1];\n                    let path = std::path::PathBuf::from(path_str);\n                    if path.exists() {\n                        return Some(path);\n                    }\n                }\n            }\n        }\n    }\n\n    None\n}\n\n/// Get Antigravity executable path (cross-platform)\n///\n/// Search strategy (highest to lowest priority):\n/// 1. Get path from running process (most reliable, supports any location)\n/// 2. Iterate standard installation locations\n/// 3. Return None\npub fn get_antigravity_executable_path() -> Option<std::path::PathBuf> {\n    // Strategy 1: Get from running process (supports any location)\n    if let Some(path) = get_path_from_running_process() {\n        return Some(path);\n    }\n\n    // Strategy 2: Check standard installation locations\n    check_standard_locations()\n}\n\n/// Check standard installation locations\nfn check_standard_locations() -> Option<std::path::PathBuf> {\n    #[cfg(target_os = \"macos\")]\n    {\n        let path = std::path::PathBuf::from(\"/Applications/Antigravity.app\");\n        if path.exists() {\n            return Some(path);\n        }\n    }\n\n    #[cfg(target_os = \"windows\")]\n    {\n        use std::env;\n\n        // Get environment variables\n        let local_appdata = env::var(\"LOCALAPPDATA\").ok();\n        let program_files =\n            env::var(\"ProgramFiles\").unwrap_or_else(|_| \"C:\\\\Program Files\".to_string());\n        let program_files_x86 =\n            env::var(\"ProgramFiles(x86)\").unwrap_or_else(|_| \"C:\\\\Program Files (x86)\".to_string());\n\n        let mut possible_paths = Vec::new();\n\n        // User installation location (preferred)\n        if let Some(local) = local_appdata {\n            possible_paths.push(\n                std::path::PathBuf::from(&local)\n                    .join(\"Programs\")\n                    .join(\"Antigravity\")\n                    .join(\"Antigravity.exe\"),\n            );\n        }\n\n        // System installation location\n        possible_paths.push(\n            std::path::PathBuf::from(&program_files)\n                .join(\"Antigravity\")\n                .join(\"Antigravity.exe\"),\n        );\n\n        // 32-bit compatibility location\n        possible_paths.push(\n            std::path::PathBuf::from(&program_files_x86)\n                .join(\"Antigravity\")\n                .join(\"Antigravity.exe\"),\n        );\n\n        // Return the first existing path\n        for path in possible_paths {\n            if path.exists() {\n                return Some(path);\n            }\n        }\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        let possible_paths = vec![\n            std::path::PathBuf::from(\"/usr/bin/antigravity\"),\n            std::path::PathBuf::from(\"/opt/Antigravity/antigravity\"),\n            std::path::PathBuf::from(\"/usr/share/antigravity/antigravity\"),\n        ];\n\n        // User local installation\n        if let Some(home) = dirs::home_dir() {\n            let user_local = home.join(\".local/bin/antigravity\");\n            if user_local.exists() {\n                return Some(user_local);\n            }\n        }\n\n        for path in possible_paths {\n            if path.exists() {\n                return Some(path);\n            }\n        }\n    }\n\n    None\n}\n"
  },
  {
    "path": "src-tauri/src/modules/proxy_db.rs",
    "content": "use rusqlite::{params, Connection};\nuse std::path::PathBuf;\nuse crate::proxy::monitor::ProxyRequestLog;\n\npub fn get_proxy_db_path() -> Result<PathBuf, String> {\n    let data_dir = crate::modules::account::get_data_dir()?;\n    Ok(data_dir.join(\"proxy_logs.db\"))\n}\n\nfn connect_db() -> Result<Connection, String> {\n    let db_path = get_proxy_db_path()?;\n    let conn = Connection::open(db_path).map_err(|e| e.to_string())?;\n    \n    // Enable WAL mode for better concurrency\n    conn.pragma_update(None, \"journal_mode\", \"WAL\").map_err(|e| e.to_string())?;\n    \n    // Set busy timeout to 5000ms to avoid \"database is locked\" errors\n    conn.pragma_update(None, \"busy_timeout\", 5000).map_err(|e| e.to_string())?;\n    \n    // Synchronous NORMAL is faster and safe enough for WAL\n    conn.pragma_update(None, \"synchronous\", \"NORMAL\").map_err(|e| e.to_string())?;\n    \n    Ok(conn)\n}\n\npub fn init_db() -> Result<(), String> {\n    // connect_db will initialize WAL mode and other pragmas\n    let conn = connect_db()?;\n    \n    conn.execute(\n        \"CREATE TABLE IF NOT EXISTS request_logs (\n            id TEXT PRIMARY KEY,\n            timestamp INTEGER,\n            method TEXT,\n            url TEXT,\n            status INTEGER,\n            duration INTEGER,\n            model TEXT,\n            error TEXT\n        )\",\n        [],\n    ).map_err(|e| e.to_string())?;\n\n    // Try to add new columns (ignore errors if they exist)\n    let _ = conn.execute(\"ALTER TABLE request_logs ADD COLUMN request_body TEXT\", []);\n    let _ = conn.execute(\"ALTER TABLE request_logs ADD COLUMN response_body TEXT\", []);\n    let _ = conn.execute(\"ALTER TABLE request_logs ADD COLUMN input_tokens INTEGER\", []);\n    let _ = conn.execute(\"ALTER TABLE request_logs ADD COLUMN output_tokens INTEGER\", []);\n    let _ = conn.execute(\"ALTER TABLE request_logs ADD COLUMN account_email TEXT\", []);\n    let _ = conn.execute(\"ALTER TABLE request_logs ADD COLUMN mapped_model TEXT\", []);\n    let _ = conn.execute(\"ALTER TABLE request_logs ADD COLUMN protocol TEXT\", []);\n    let _ = conn.execute(\"ALTER TABLE request_logs ADD COLUMN client_ip TEXT\", []);\n    let _ = conn.execute(\"ALTER TABLE request_logs ADD COLUMN username TEXT\", []);\n\n    conn.execute(\n        \"CREATE INDEX IF NOT EXISTS idx_timestamp ON request_logs (timestamp DESC)\",\n        [],\n    ).map_err(|e| e.to_string())?;\n\n    // Add status index for faster stats queries\n    conn.execute(\n        \"CREATE INDEX IF NOT EXISTS idx_status ON request_logs (status)\",\n        [],\n    ).map_err(|e| e.to_string())?;\n\n    Ok(())\n}\n\npub fn save_log(log: &ProxyRequestLog) -> Result<(), String> {\n    let conn = connect_db()?;\n\n    conn.execute(\n        \"INSERT INTO request_logs (id, timestamp, method, url, status, duration, model, error, request_body, response_body, input_tokens, output_tokens, account_email, mapped_model, protocol, client_ip, username)\n         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)\",\n        params![\n            log.id,\n            log.timestamp,\n            log.method,\n            log.url,\n            log.status,\n            log.duration,\n            log.model,\n            log.error,\n            log.request_body,\n            log.response_body,\n            log.input_tokens,\n            log.output_tokens,\n            log.account_email,\n            log.mapped_model,\n            log.protocol,\n            log.client_ip,\n            log.username,\n        ],\n    ).map_err(|e| e.to_string())?;\n\n    Ok(())\n}\n\n/// Get logs summary (without large request_body and response_body fields) with pagination\npub fn get_logs_summary(limit: usize, offset: usize) -> Result<Vec<ProxyRequestLog>, String> {\n    let conn = connect_db()?;\n\n    let mut stmt = conn.prepare(\n        \"SELECT id, timestamp, method, url, status, duration, model, error, \n                NULL as request_body, NULL as response_body,\n                input_tokens, output_tokens, account_email, mapped_model, protocol, client_ip\n         FROM request_logs \n         ORDER BY timestamp DESC \n         LIMIT ?1 OFFSET ?2\"\n    ).map_err(|e| e.to_string())?;\n\n    let logs_iter = stmt.query_map([limit, offset], |row| {\n        Ok(ProxyRequestLog {\n            id: row.get(0)?,\n            timestamp: row.get(1)?,\n            method: row.get(2)?,\n            url: row.get(3)?,\n            status: row.get(4)?,\n            duration: row.get(5)?,\n            model: row.get(6)?,\n            mapped_model: row.get(13).unwrap_or(None),\n            account_email: row.get(12).unwrap_or(None),\n            error: row.get(7)?,\n            request_body: None,  // Don't query large fields for list view\n            response_body: None, // Don't query large fields for list view\n            input_tokens: row.get(10).unwrap_or(None),\n            output_tokens: row.get(11).unwrap_or(None),\n            protocol: row.get(14).unwrap_or(None),\n            client_ip: row.get(15).unwrap_or(None),\n            username: row.get(16).unwrap_or(None),\n        })\n\n    }).map_err(|e| e.to_string())?;\n\n    let mut logs = Vec::new();\n    for log in logs_iter {\n        logs.push(log.map_err(|e| e.to_string())?);\n    }\n    Ok(logs)\n}\n\n/// Get logs (backward compatible, calls get_logs_summary)\npub fn get_logs(limit: usize) -> Result<Vec<ProxyRequestLog>, String> {\n    get_logs_summary(limit, 0)\n}\n\npub fn get_stats() -> Result<crate::proxy::monitor::ProxyStats, String> {\n    let conn = connect_db()?;\n\n    // Optimized: Use single query instead of three separate queries\n    // Use COALESCE to handle NULL values when table is empty (SUM returns NULL for empty set)\n    let (total_requests, success_count, error_count): (u64, u64, u64) = conn.query_row(\n        \"SELECT \n            COUNT(*) as total,\n            COALESCE(SUM(CASE WHEN status >= 200 AND status < 400 THEN 1 ELSE 0 END), 0) as success,\n            COALESCE(SUM(CASE WHEN status < 200 OR status >= 400 THEN 1 ELSE 0 END), 0) as error\n         FROM request_logs\",\n        [],\n        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),\n    ).map_err(|e| e.to_string())?;\n\n    Ok(crate::proxy::monitor::ProxyStats {\n        total_requests,\n        success_count,\n        error_count,\n    })\n}\n\n/// Get single log detail (with request_body and response_body)\npub fn get_log_detail(log_id: &str) -> Result<ProxyRequestLog, String> {\n    let conn = connect_db()?;\n\n    let mut stmt = conn.prepare(\n        \"SELECT id, timestamp, method, url, status, duration, model, error,\n                request_body, response_body, input_tokens, output_tokens,\n                account_email, mapped_model, protocol, client_ip, username\n         FROM request_logs\n         WHERE id = ?1\"\n    ).map_err(|e| e.to_string())?;\n\n    stmt.query_row([log_id], |row| {\n        Ok(ProxyRequestLog {\n            id: row.get(0)?,\n            timestamp: row.get(1)?,\n            method: row.get(2)?,\n            url: row.get(3)?,\n            status: row.get(4)?,\n            duration: row.get(5)?,\n            model: row.get(6)?,\n            mapped_model: row.get(13).unwrap_or(None),\n            account_email: row.get(12).unwrap_or(None),\n            error: row.get(7)?,\n            request_body: row.get(8).unwrap_or(None),\n            response_body: row.get(9).unwrap_or(None),\n            input_tokens: row.get(10).unwrap_or(None),\n            output_tokens: row.get(11).unwrap_or(None),\n            protocol: row.get(14).unwrap_or(None),\n            client_ip: row.get(15).unwrap_or(None),\n            username: row.get(16).unwrap_or(None),\n        })\n    }).map_err(|e| e.to_string())\n}\n\n/// Cleanup old logs (keep last N days)\npub fn cleanup_old_logs(days: i64) -> Result<usize, String> {\n    let conn = connect_db()?;\n    \n    let cutoff_timestamp = chrono::Utc::now().timestamp() - (days * 24 * 3600);\n    \n    let deleted = conn.execute(\n        \"DELETE FROM request_logs WHERE timestamp < ?1\",\n        [cutoff_timestamp],\n    ).map_err(|e| e.to_string())?;\n    \n    // Execute VACUUM to reclaim disk space\n    conn.execute(\"VACUUM\", []).map_err(|e| e.to_string())?;\n    \n    Ok(deleted)\n}\n\n/// Limit maximum log count (keep newest N records)\n#[allow(dead_code)]\npub fn limit_max_logs(max_count: usize) -> Result<usize, String> {\n    let conn = connect_db()?;\n    \n    let deleted = conn.execute(\n        \"DELETE FROM request_logs WHERE id NOT IN (\n            SELECT id FROM request_logs ORDER BY timestamp DESC LIMIT ?1\n        )\",\n        [max_count],\n    ).map_err(|e| e.to_string())?;\n    \n    conn.execute(\"VACUUM\", []).map_err(|e| e.to_string())?;\n    \n    Ok(deleted)\n}\n\npub fn clear_logs() -> Result<(), String> {\n    let conn = connect_db()?;\n    conn.execute(\"DELETE FROM request_logs\", []).map_err(|e| e.to_string())?;\n    Ok(())\n}\n\n/// Get total count of logs in database\npub fn get_logs_count() -> Result<u64, String> {\n    let conn = connect_db()?;\n    \n    let count: u64 = conn.query_row(\n        \"SELECT COUNT(*) FROM request_logs\",\n        [],\n        |row| row.get(0),\n    ).map_err(|e| e.to_string())?;\n    \n    Ok(count)\n}\n\n/// Get count of logs matching search filter\n/// filter: search text to match in url, method, model, or status\n/// errors_only: if true, only count logs with status < 200 or >= 400\npub fn get_logs_count_filtered(filter: &str, errors_only: bool) -> Result<u64, String> {\n    let conn = connect_db()?;\n    \n    let filter_pattern = format!(\"%{}%\", filter);\n    \n    let sql = if errors_only {\n        \"SELECT COUNT(*) FROM request_logs WHERE (status < 200 OR status >= 400)\"\n    } else if filter.is_empty() {\n        \"SELECT COUNT(*) FROM request_logs\"\n    } else {\n        \"SELECT COUNT(*) FROM request_logs WHERE\n            (url LIKE ?1 OR method LIKE ?1 OR model LIKE ?1 OR CAST(status AS TEXT) LIKE ?1 OR account_email LIKE ?1)\"\n    };\n    \n    let count: u64 = if filter.is_empty() && !errors_only {\n        conn.query_row(sql, [], |row| row.get(0))\n    } else if errors_only {\n        conn.query_row(sql, [], |row| row.get(0))\n    } else {\n        conn.query_row(sql, [&filter_pattern], |row| row.get(0))\n    }.map_err(|e| e.to_string())?;\n    \n    Ok(count)\n}\n\n/// Get logs with search filter and pagination\n/// filter: search text to match in url, method, model, or status\n/// errors_only: if true, only return logs with status < 200 or >= 400\npub fn get_logs_filtered(filter: &str, errors_only: bool, limit: usize, offset: usize) -> Result<Vec<ProxyRequestLog>, String> {\n    let conn = connect_db()?;\n\n    let filter_pattern = format!(\"%{}%\", filter);\n    \n    let sql = if errors_only {\n        \"SELECT id, timestamp, method, url, status, duration, model, error,\n                NULL as request_body, NULL as response_body,\n                input_tokens, output_tokens, account_email, mapped_model, protocol, client_ip, username\n         FROM request_logs\n         WHERE (status < 200 OR status >= 400)\n         ORDER BY timestamp DESC\n         LIMIT ?1 OFFSET ?2\"\n    } else if filter.is_empty() {\n        \"SELECT id, timestamp, method, url, status, duration, model, error,\n                NULL as request_body, NULL as response_body,\n                input_tokens, output_tokens, account_email, mapped_model, protocol, client_ip, username\n         FROM request_logs\n         ORDER BY timestamp DESC\n         LIMIT ?1 OFFSET ?2\"\n    } else {\n        \"SELECT id, timestamp, method, url, status, duration, model, error,\n                NULL as request_body, NULL as response_body,\n                input_tokens, output_tokens, account_email, mapped_model, protocol, client_ip, username\n         FROM request_logs\n         WHERE (url LIKE ?3 OR method LIKE ?3 OR model LIKE ?3 OR CAST(status AS TEXT) LIKE ?3 OR account_email LIKE ?3 OR client_ip LIKE ?3)\n         ORDER BY timestamp DESC\n         LIMIT ?1 OFFSET ?2\"\n    };\n\n    let logs: Vec<ProxyRequestLog> = if filter.is_empty() && !errors_only {\n        let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;\n        let logs_iter = stmt.query_map([limit, offset], |row| {\n            Ok(ProxyRequestLog {\n                id: row.get(0)?,\n                timestamp: row.get(1)?,\n                method: row.get(2)?,\n                url: row.get(3)?,\n                status: row.get(4)?,\n                duration: row.get(5)?,\n                model: row.get(6)?,\n                mapped_model: row.get(13).unwrap_or(None),\n                account_email: row.get(12).unwrap_or(None),\n                error: row.get(7)?,\n                request_body: None,\n                response_body: None,\n                input_tokens: row.get(10).unwrap_or(None),\n                output_tokens: row.get(11).unwrap_or(None),\n                protocol: row.get(14).unwrap_or(None),\n                client_ip: row.get(15).unwrap_or(None),\n                username: row.get(16).unwrap_or(None),\n            })\n\n        }).map_err(|e| e.to_string())?;\n        logs_iter.filter_map(|r| r.ok()).collect()\n    } else if errors_only {\n        let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;\n        let logs_iter = stmt.query_map([limit, offset], |row| {\n            Ok(ProxyRequestLog {\n                id: row.get(0)?,\n                timestamp: row.get(1)?,\n                method: row.get(2)?,\n                url: row.get(3)?,\n                status: row.get(4)?,\n                duration: row.get(5)?,\n                model: row.get(6)?,\n                mapped_model: row.get(13).unwrap_or(None),\n                account_email: row.get(12).unwrap_or(None),\n                error: row.get(7)?,\n                request_body: None,\n                response_body: None,\n                input_tokens: row.get(10).unwrap_or(None),\n                output_tokens: row.get(11).unwrap_or(None),\n                protocol: row.get(14).unwrap_or(None),\n                client_ip: row.get(15).unwrap_or(None),\n                username: row.get(16).unwrap_or(None),\n            })\n\n        }).map_err(|e| e.to_string())?;\n        logs_iter.filter_map(|r| r.ok()).collect()\n    } else {\n        let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;\n        let logs_iter = stmt.query_map(rusqlite::params![limit, offset, filter_pattern], |row| {\n            Ok(ProxyRequestLog {\n                id: row.get(0)?,\n                timestamp: row.get(1)?,\n                method: row.get(2)?,\n                url: row.get(3)?,\n                status: row.get(4)?,\n                duration: row.get(5)?,\n                model: row.get(6)?,\n                mapped_model: row.get(13).unwrap_or(None),\n                account_email: row.get(12).unwrap_or(None),\n                error: row.get(7)?,\n                request_body: None,\n                response_body: None,\n                input_tokens: row.get(10).unwrap_or(None),\n                output_tokens: row.get(11).unwrap_or(None),\n                protocol: row.get(14).unwrap_or(None),\n                client_ip: row.get(15).unwrap_or(None),\n                username: row.get(16).unwrap_or(None),\n            })\n\n        }).map_err(|e| e.to_string())?;\n        logs_iter.filter_map(|r| r.ok()).collect()\n    };\n\n    Ok(logs)\n}\n\n/// Get all logs with full details for export\npub fn get_all_logs_for_export() -> Result<Vec<ProxyRequestLog>, String> {\n    let conn = connect_db()?;\n\n    let mut stmt = conn.prepare(\n        \"SELECT id, timestamp, method, url, status, duration, model, error,\n                request_body, response_body, input_tokens, output_tokens,\n                account_email, mapped_model, protocol, client_ip, username\n         FROM request_logs\n         ORDER BY timestamp DESC\"\n    ).map_err(|e| e.to_string())?;\n\n    let logs_iter = stmt.query_map([], |row| {\n        Ok(ProxyRequestLog {\n            id: row.get(0)?,\n            timestamp: row.get(1)?,\n            method: row.get(2)?,\n            url: row.get(3)?,\n            status: row.get(4)?,\n            duration: row.get(5)?,\n            model: row.get(6)?,\n            mapped_model: row.get(13).unwrap_or(None),\n            account_email: row.get(12).unwrap_or(None),\n            error: row.get(7)?,\n            request_body: row.get(8).unwrap_or(None),\n            response_body: row.get(9).unwrap_or(None),\n            input_tokens: row.get(10).unwrap_or(None),\n            output_tokens: row.get(11).unwrap_or(None),\n            protocol: row.get(14).unwrap_or(None),\n            client_ip: row.get(15).unwrap_or(None),\n            username: row.get(16).unwrap_or(None),\n        })\n\n    }).map_err(|e| e.to_string())?;\n\n    let mut logs = Vec::new();\n    for log in logs_iter {\n        logs.push(log.map_err(|e| e.to_string())?);\n    }\n    Ok(logs)\n}\n\n// ... existing code ...\n\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]\npub struct IpTokenStats {\n    pub client_ip: String,\n    pub total_tokens: i64,\n    pub input_tokens: i64,\n    pub output_tokens: i64,\n    pub request_count: i64,\n    pub username: Option<String>,\n}\n\n/// Get token usage grouped by IP\npub fn get_token_usage_by_ip(limit: usize, hours: i64) -> Result<Vec<IpTokenStats>, String> {\n    let conn = connect_db()?;\n\n    // Fix: Database stores timestamp in milliseconds, but we were calculating 'since' in seconds\n    // Convert 'hours' to milliseconds\n    let since = chrono::Utc::now().timestamp_millis() - (hours * 3600 * 1000);\n\n    // [FIX] 不再从 request_logs 表获取 username，因为该字段可能为空\n    // 先获取 IP 统计数据，然后再单独查询每个 IP 的用户名\n    let mut stmt = conn.prepare(\n        \"SELECT\n            client_ip,\n            COALESCE(SUM(input_tokens), 0) + COALESCE(SUM(output_tokens), 0) as total,\n            COALESCE(SUM(input_tokens), 0) as input,\n            COALESCE(SUM(output_tokens), 0) as output,\n            COUNT(*) as cnt\n         FROM request_logs\n         WHERE timestamp >= ?1 AND client_ip IS NOT NULL AND client_ip != ''\n         GROUP BY client_ip\n         ORDER BY total DESC\n         LIMIT ?2\"\n    ).map_err(|e| e.to_string())?;\n\n    let rows = stmt.query_map(params![since, limit], |row| {\n        Ok((\n            row.get::<_, String>(0)?,\n            row.get::<_, i64>(1)?,\n            row.get::<_, i64>(2)?,\n            row.get::<_, i64>(3)?,\n            row.get::<_, i64>(4)?,\n        ))\n    }).map_err(|e| e.to_string())?;\n\n    let mut stats = Vec::new();\n    for row in rows {\n        let (client_ip, total_tokens, input_tokens, output_tokens, request_count) = row.map_err(|e| e.to_string())?;\n        \n        // 从 user_token_db 获取该 IP 关联的用户名\n        // 这比从 request_logs 获取更可靠，因为 token_ip_bindings 表在每次 User Token 使用时都会更新\n        let username = crate::modules::user_token_db::get_username_for_ip(&client_ip).unwrap_or(None);\n        \n        stats.push(IpTokenStats {\n            client_ip,\n            total_tokens,\n            input_tokens,\n            output_tokens,\n            request_count,\n            username,\n        });\n    }\n\n    Ok(stats)\n}\n\n"
  },
  {
    "path": "src-tauri/src/modules/quota.rs",
    "content": "use rquest;\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse crate::models::QuotaData;\nuse crate::modules::config;\n\n// Quota API endpoints (fallback order: Sandbox → Daily → Prod)\nconst QUOTA_API_ENDPOINTS: [&str; 3] = [\n    \"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels\",\n    \"https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels\",\n    \"https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels\",\n];\n\n/// Critical retry threshold: considered near recovery when quota reaches 95%\nconst NEAR_READY_THRESHOLD: i32 = 95;\nconst MAX_RETRIES: u32 = 3;\nconst RETRY_DELAY_SECS: u64 = 30;\n\n#[derive(Debug, Serialize, Deserialize)]\nstruct QuotaResponse {\n    models: std::collections::HashMap<String, ModelInfo>,\n    #[serde(rename = \"deprecatedModelIds\")]\n    deprecated_model_ids: Option<std::collections::HashMap<String, DeprecatedModelInfo>>,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\nstruct DeprecatedModelInfo {\n    #[serde(rename = \"newModelId\")]\n    new_model_id: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\nstruct ModelInfo {\n    #[serde(rename = \"quotaInfo\")]\n    quota_info: Option<QuotaInfo>,\n    #[serde(rename = \"displayName\")]\n    display_name: Option<String>,\n    #[serde(rename = \"supportsImages\")]\n    supports_images: Option<bool>,\n    #[serde(rename = \"supportsThinking\")]\n    supports_thinking: Option<bool>,\n    #[serde(rename = \"thinkingBudget\")]\n    thinking_budget: Option<i32>,\n    recommended: Option<bool>,\n    #[serde(rename = \"maxTokens\")]\n    max_tokens: Option<i32>,\n    #[serde(rename = \"maxOutputTokens\")]\n    max_output_tokens: Option<i32>,\n    #[serde(rename = \"supportedMimeTypes\")]\n    supported_mime_types: Option<std::collections::HashMap<String, bool>>,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\nstruct QuotaInfo {\n    #[serde(rename = \"remainingFraction\")]\n    remaining_fraction: Option<f64>,\n    #[serde(rename = \"resetTime\")]\n    reset_time: Option<String>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LoadProjectResponse {\n    #[serde(rename = \"cloudaicompanionProject\")]\n    project_id: Option<String>,\n    #[serde(rename = \"currentTier\")]\n    current_tier: Option<Tier>,\n    #[serde(rename = \"paidTier\")]\n    paid_tier: Option<Tier>,\n    #[serde(rename = \"allowedTiers\")]\n    allowed_tiers: Option<Vec<Tier>>,\n    #[serde(rename = \"ineligibleTiers\")]\n    ineligible_tiers: Option<Vec<IneligibleTier>>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct IneligibleTier {\n    #[allow(dead_code)]\n    #[serde(rename = \"reasonCode\")]\n    reason_code: Option<String>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct Tier {\n    #[allow(dead_code)]\n    is_default: Option<bool>,\n    id: Option<String>,\n    #[allow(dead_code)]\n    #[serde(rename = \"quotaTier\")]\n    quota_tier: Option<String>,\n    name: Option<String>,\n    #[allow(dead_code)]\n    slug: Option<String>,\n}\n\n/// Get shared HTTP Client (15s timeout) for pure info fetching (No JA3)\nasync fn create_standard_client(account_id: Option<&str>) -> rquest::Client {\n    if let Some(pool) = crate::proxy::proxy_pool::get_global_proxy_pool() {\n        pool.get_effective_standard_client(account_id, 15).await\n    } else {\n        crate::utils::http::get_standard_client()\n    }\n}\n\n/// Get shared HTTP Client (60s timeout) for pure info fetching (No JA3)\n#[allow(dead_code)] // 预留给预热/后台任务调用\nasync fn create_long_standard_client(account_id: Option<&str>) -> rquest::Client {\n    if let Some(pool) = crate::proxy::proxy_pool::get_global_proxy_pool() {\n        pool.get_effective_standard_client(account_id, 60).await\n    } else {\n        crate::utils::http::get_long_standard_client()\n    }\n}\n\nconst CLOUD_CODE_BASE_URL: &str = \"https://daily-cloudcode-pa.sandbox.googleapis.com\";\n\n/// Fetch project ID and subscription tier\nasync fn fetch_project_id(access_token: &str, email: &str, account_id: Option<&str>) -> (Option<String>, Option<String>) {\n    let client = create_standard_client(account_id).await;\n    let meta = json!({\"metadata\": {\"ideType\": \"ANTIGRAVITY\"}});\n\n    let res = client\n        .post(format!(\"{}/v1internal:loadCodeAssist\", CLOUD_CODE_BASE_URL))\n        .header(rquest::header::AUTHORIZATION, format!(\"Bearer {}\", access_token))\n        .header(rquest::header::CONTENT_TYPE, \"application/json\")\n        .header(rquest::header::USER_AGENT, crate::constants::NATIVE_OAUTH_USER_AGENT.as_str())\n        .json(&meta)\n        .send()\n        .await;\n\n    match res {\n        Ok(res) => {\n            if res.status().is_success() {\n                if let Ok(data) = res.json::<LoadProjectResponse>().await {\n                    let project_id = data.project_id.clone();\n                    \n                    // Core logic: Multi-level fallback for tier extraction\n                    // 1. Paid Tier (Google One AI Premium etc.)\n                    // 2. Current Tier (If not ineligible)\n                    // 3. Allowed Tiers (Restricted/Default proxy access)\n                    let mut subscription_tier = data.paid_tier.as_ref().and_then(|t| t.name.clone())\n                        .or_else(|| data.paid_tier.as_ref().and_then(|t| t.id.clone()));\n                        \n                    let is_ineligible = data.ineligible_tiers.is_some() && !data.ineligible_tiers.as_ref().unwrap().is_empty();\n                    \n                    if subscription_tier.is_none() {\n                        if !is_ineligible {\n                            subscription_tier = data.current_tier.as_ref().and_then(|t| t.name.clone())\n                                .or_else(|| data.current_tier.as_ref().and_then(|t| t.id.clone()));\n                        } else {\n                            // If account is marked as INELIGIBLE, drop to allowedTiers and extract default\n                            if let Some(mut allowed) = data.allowed_tiers {\n                                if let Some(default_tier) = allowed.iter_mut().find(|t| t.is_default == Some(true)) {\n                                    if let Some(name) = &default_tier.name {\n                                        subscription_tier = Some(format!(\"{} (Restricted)\", name));\n                                    } else if let Some(id) = &default_tier.id {\n                                        subscription_tier = Some(format!(\"{} (Restricted)\", id));\n                                    }\n                                }\n                            }\n                        }\n                    }\n                    \n                    if let Some(ref tier) = subscription_tier {\n                        crate::modules::logger::log_info(&format!(\n                            \"📊 [{}] Subscription identified successfully: {}\", email, tier\n                        ));\n                    }\n                    \n                    return (project_id, subscription_tier);\n                }\n            } else {\n                crate::modules::logger::log_warn(&format!(\n                    \"⚠️  [{}] loadCodeAssist failed: Status: {}\", email, res.status()\n                ));\n            }\n        }\n        Err(e) => {\n            crate::modules::logger::log_error(&format!(\"❌ [{}] loadCodeAssist network error: {}\", email, e));\n        }\n    }\n    \n    (None, None)\n}\n\n/// Unified entry point for fetching account quota\npub async fn fetch_quota(access_token: &str, email: &str, account_id: Option<&str>) -> crate::error::AppResult<(QuotaData, Option<String>)> {\n    fetch_quota_with_cache(access_token, email, None, account_id).await\n}\n\n/// Fetch quota with cache support\npub async fn fetch_quota_with_cache(\n    access_token: &str,\n    email: &str,\n    cached_project_id: Option<&str>,\n    account_id: Option<&str>,\n) -> crate::error::AppResult<(QuotaData, Option<String>)> {\n    use crate::error::AppError;\n    \n    // Optimization: Skip loadCodeAssist call if project_id is cached to save API quota\n    let (project_id, subscription_tier) = if let Some(pid) = cached_project_id {\n        (Some(pid.to_string()), None)\n    } else {\n        fetch_project_id(access_token, email, account_id).await\n    };\n    \n    // We keep project_id to store in the DB, but we NO LONGER force inject it into payload if it's absent\n    \n    let client = create_standard_client(account_id).await;\n    let payload = if let Some(ref pid) = project_id {\n        json!({ \"project\": pid })\n    } else {\n        json!({}) // Empty payload fallback\n    };\n    \n    let mut last_error: Option<AppError> = None;\n\n    for (ep_idx, ep_url) in QUOTA_API_ENDPOINTS.iter().enumerate() {\n        let has_next = ep_idx + 1 < QUOTA_API_ENDPOINTS.len();\n\n        match client\n            .post(*ep_url)\n            .bearer_auth(access_token)\n            .header(rquest::header::USER_AGENT, crate::constants::NATIVE_OAUTH_USER_AGENT.as_str())\n            .json(&payload)\n            .send()\n            .await\n        {\n            Ok(response) => {\n                // Convert HTTP error status to AppError\n                if let Err(_) = response.error_for_status_ref() {\n                    let status = response.status();\n                    \n                    // ✅ Special handling for 403 Forbidden - return directly, no retry\n                    if status == rquest::StatusCode::FORBIDDEN {\n                        crate::modules::logger::log_warn(&format!(\n                            \"Account unauthorized (403 Forbidden), marking as forbidden\"\n                        ));\n                        let mut q = QuotaData::new();\n                        q.is_forbidden = true;\n                        q.subscription_tier = subscription_tier.clone();\n                        return Ok((q, project_id.clone()));\n                    }\n                    \n                    let text = response.text().await.unwrap_or_default();\n\n                    // 429/5xx: fallback to next endpoint\n                    if has_next && (status == rquest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) {\n                         crate::modules::logger::log_warn(&format!(\"Quota API {} returned {}, falling back to next endpoint\", ep_url, status));\n                         last_error = Some(AppError::Unknown(format!(\"HTTP {} - {}\", status, text)));\n                         tokio::time::sleep(std::time::Duration::from_secs(1)).await;\n                         continue;\n                    }\n\n                    return Err(AppError::Unknown(format!(\"API Error: {} - {}\", status, text)));\n                }\n\n                if ep_idx > 0 {\n                    crate::modules::logger::log_info(&format!(\"Quota API fallback succeeded at endpoint #{}\", ep_idx + 1));\n                }\n\n                let quota_response: QuotaResponse = response\n                    .json()\n                    .await\n                    .map_err(AppError::from)?;\n                \n                let mut quota_data = QuotaData::new();\n                \n                // Use debug level for detailed info to avoid console noise\n                tracing::debug!(\"Quota API returned {} models\", quota_response.models.len());\n\n                for (name, info) in quota_response.models {\n                    if let Some(quota_info) = info.quota_info {\n                        let percentage = quota_info.remaining_fraction\n                            .map(|f| (f * 100.0) as i32)\n                            .unwrap_or(0);\n                        \n                        let reset_time = quota_info.reset_time.clone().unwrap_or_default();\n                        \n                        // Only keep models we care about (exclude internal chat models)\n                        if name.starts_with(\"gemini\") || name.starts_with(\"claude\") || name.starts_with(\"gpt\") || name.starts_with(\"image\") || name.starts_with(\"imagen\") {\n                            let model_quota = crate::models::quota::ModelQuota {\n                                name,\n                                percentage,\n                                reset_time,\n                                display_name: info.display_name,\n                                supports_images: info.supports_images,\n                                supports_thinking: info.supports_thinking,\n                                thinking_budget: info.thinking_budget,\n                                recommended: info.recommended,\n                                max_tokens: info.max_tokens,\n                                max_output_tokens: info.max_output_tokens,\n                                supported_mime_types: info.supported_mime_types,\n                            };\n                            quota_data.add_model(model_quota);\n                        }\n                    }\n                }\n                \n                // Parse deprecated model routing rules\n                if let Some(deprecated) = quota_response.deprecated_model_ids {\n                    for (old_id, info) in deprecated {\n                        quota_data.model_forwarding_rules.insert(old_id, info.new_model_id);\n                    }\n                }\n                \n                // Set subscription tier\n                quota_data.subscription_tier = subscription_tier.clone();\n                \n                return Ok((quota_data, project_id.clone()));\n            },\n            Err(e) => {\n                crate::modules::logger::log_warn(&format!(\"Quota API request failed at {}: {}\", ep_url, e));\n                last_error = Some(AppError::from(e));\n                if has_next {\n                    tokio::time::sleep(std::time::Duration::from_secs(1)).await;\n                }\n            }\n        }\n    }\n    \n    Err(last_error.unwrap_or_else(|| AppError::Unknown(\"Quota fetch failed: all endpoints exhausted\".to_string())))\n}\n\n/// Internal fetch quota logic\n#[allow(dead_code)]\npub async fn fetch_quota_inner(access_token: &str, email: &str) -> crate::error::AppResult<(QuotaData, Option<String>)> {\n    fetch_quota_with_cache(access_token, email, None, None).await\n}\n\n/// Batch fetch all account quotas (backup functionality)\n#[allow(dead_code)]\npub async fn fetch_all_quotas(accounts: Vec<(String, String, String)>) -> Vec<(String, crate::error::AppResult<QuotaData>)> {\n    let mut results = Vec::new();\n    for (id, email, access_token) in accounts {\n        let res = fetch_quota(&access_token, &email, Some(&id)).await;\n        results.push((email, res.map(|(q, _)| q)));\n    }\n    results\n}\n\n/// Get valid token (auto-refresh if expired)\npub async fn get_valid_token_for_warmup(account: &crate::models::account::Account) -> Result<(String, String), String> {\n    let mut account = account.clone();\n    \n    // Check and auto-refresh token\n    let new_token = crate::modules::oauth::ensure_fresh_token(&account.token, Some(&account.id)).await?;\n    \n    // If token changed (meant refreshed), save it\n    if new_token.access_token != account.token.access_token {\n        account.token = new_token;\n        if let Err(e) = crate::modules::account::save_account(&account) {\n            crate::modules::logger::log_warn(&format!(\"[Warmup] Failed to save refreshed token: {}\", e));\n        } else {\n            crate::modules::logger::log_info(&format!(\"[Warmup] Successfully refreshed and saved new token for {}\", account.email));\n        }\n    }\n    \n    // Fetch project_id\n    let (project_id, _) = fetch_project_id(&account.token.access_token, &account.email, Some(&account.id)).await;\n    let final_pid = project_id.unwrap_or_else(|| \"bamboo-precept-lgxtn\".to_string());\n    \n    Ok((account.token.access_token, final_pid))\n}\n\n/// Send warmup request via proxy internal API\npub async fn warmup_model_directly(\n    access_token: &str,\n    model_name: &str,\n    project_id: &str,\n    email: &str,\n    percentage: i32,\n    _account_id: Option<&str>,\n) -> bool {\n    // Get currently configured proxy port\n    let port = config::load_app_config()\n        .map(|c| c.proxy.port)\n        .unwrap_or(8045);\n\n    let warmup_url = format!(\"http://127.0.0.1:{}/internal/warmup\", port);\n    let body = json!({\n        \"email\": email,\n        \"model\": model_name,\n        \"access_token\": access_token,\n        \"project_id\": project_id\n    });\n\n    // Use a no-proxy client for local loopback requests\n    // This prevents Docker environments from routing localhost through external proxies\n    let client = rquest::Client::builder()\n        .timeout(std::time::Duration::from_secs(60))\n        .no_proxy()\n        .build()\n        .unwrap_or_else(|_| rquest::Client::new());\n    let resp = client\n        .post(&warmup_url)\n        .header(\"Content-Type\", \"application/json\")\n        .json(&body)\n        .send()\n        .await;\n\n    match resp {\n        Ok(response) => {\n            let status = response.status();\n            if status.is_success() {\n                crate::modules::logger::log_info(&format!(\"[Warmup] ✓ Triggered {} for {} (was {}%)\", model_name, email, percentage));\n                true\n            } else {\n                let text = response.text().await.unwrap_or_default();\n                crate::modules::logger::log_warn(&format!(\"[Warmup] ✗ {} for {} (was {}%): HTTP {} - {}\", model_name, email, percentage, status, text));\n                false\n            }\n        }\n        Err(e) => {\n            crate::modules::logger::log_warn(&format!(\"[Warmup] ✗ {} for {} (was {}%): {}\", model_name, email, percentage, e));\n            false\n        }\n    }\n}\n\n/// Smart warmup for all accounts\npub async fn warm_up_all_accounts() -> Result<String, String> {\n    let mut retry_count = 0;\n\n    loop {\n        let all_accounts = crate::modules::account::list_accounts().unwrap_or_default();\n        // [FIX] 过滤掉禁用反代的账号\n        let target_accounts: Vec<_> = all_accounts\n            .into_iter()\n            .filter(|a| !a.disabled && !a.proxy_disabled)\n            .collect();\n\n        if target_accounts.is_empty() {\n            return Ok(\"No accounts available\".to_string());\n        }\n\n        crate::modules::logger::log_info(&format!(\"[Warmup] Screening models for {} accounts...\", target_accounts.len()));\n\n        let mut warmup_items = Vec::new();\n        let mut has_near_ready_models = false;\n\n        // Concurrently fetch quotas (batch size 5)\n        let batch_size = 5;\n        for batch in target_accounts.chunks(batch_size) {\n            let mut handles = Vec::new();\n            for account in batch {\n                let account = account.clone();\n                let handle = tokio::spawn(async move {\n                    let (token, pid) = match get_valid_token_for_warmup(&account).await {\n                        Ok(t) => t,\n                        Err(_) => return None,\n                    };\n                    let quota = fetch_quota_with_cache(&token, &account.email, Some(&pid), Some(&account.id)).await.ok();\n                    Some((account.id.clone(), account.email.clone(), token, pid, quota))\n                });\n                handles.push(handle);\n            }\n\n            for handle in handles {\n                if let Ok(Some((id, email, token, pid, Some((fresh_quota, _))))) = handle.await {\n                    // [FIX] 预热阶段检测到 403 时，使用统一禁用逻辑，确保账号文件和索引同时更新\n                    if fresh_quota.is_forbidden {\n                        crate::modules::logger::log_warn(&format!(\n                            \"[Warmup] Account {} returned 403 Forbidden during quota fetch, marking as forbidden\",\n                            email\n                        ));\n                        let _ = crate::modules::account::mark_account_forbidden(&id, \"Warmup: 403 Forbidden - quota fetch denied\");\n                        continue;\n                    }\n                    let mut account_warmed_series = std::collections::HashSet::new();\n                    for m in fresh_quota.models {\n                        if m.percentage >= 100 {\n                            let model_to_ping = m.name.clone();\n\n                            // Removed hardcoded whitelist - now warms up any model at 100%\n                            if !account_warmed_series.contains(&model_to_ping) {\n                                warmup_items.push((id.clone(), email.clone(), model_to_ping.clone(), token.clone(), pid.clone(), m.percentage));\n                                account_warmed_series.insert(model_to_ping);\n                            }\n                        } else if m.percentage >= NEAR_READY_THRESHOLD {\n                            has_near_ready_models = true;\n                        }\n                    }\n                }\n            }\n        }\n\n        if !warmup_items.is_empty() {\n            let total_before = warmup_items.len();\n            \n            // Filter out models warmed up within 4 hours\n            warmup_items.retain(|(_, email, model, _, _, _)| {\n                let history_key = format!(\"{}:{}:100\", email, model);\n                !crate::modules::scheduler::check_cooldown(&history_key, 14400)\n            });\n            \n            if warmup_items.is_empty() {\n                let skipped = total_before;\n                crate::modules::logger::log_info(&format!(\"[Warmup] Returning to frontend: All models in cooldown, skipped {}\", skipped));\n                return Ok(format!(\"All models are in cooldown, skipped {} items\", skipped));\n            }\n            \n            let total = warmup_items.len();\n            let skipped = total_before - total;\n            \n            if skipped > 0 {\n                crate::modules::logger::log_info(&format!(\n                    \"[Warmup] Skipped {} models in cooldown, preparing to warmup {}\",\n                    skipped, total\n                ));\n            }\n            \n            crate::modules::logger::log_info(&format!(\n                \"[Warmup] 🔥 Starting manual warmup for {} models\",\n                total\n            ));\n            \n            tokio::spawn(async move {\n                let mut success = 0;\n                let batch_size = 3;\n                let now_ts = chrono::Utc::now().timestamp();\n                \n                for (batch_idx, batch) in warmup_items.chunks(batch_size).enumerate() {\n                    let mut handles = Vec::new();\n                    \n                    for (id, email, model, token, pid, pct) in batch.iter() {\n                        let id = id.clone();\n                        let email = email.clone();\n                        let model = model.clone();\n                        let token = token.clone();\n                        let pid = pid.clone();\n                        let pct = *pct;\n                        \n                        let handle = tokio::spawn(async move {\n                            let result = warmup_model_directly(&token, &model, &pid, &email, pct, Some(&id)).await;\n                            (result, email, model)\n                        });\n                        handles.push(handle);\n                    }\n                    \n                    for handle in handles {\n                        match handle.await {\n                            Ok((true, email, model)) => {\n                                success += 1;\n                                let history_key = format!(\"{}:{}:100\", email, model);\n                                crate::modules::scheduler::record_warmup_history(&history_key, now_ts);\n                            }\n                            _ => {}\n                        }\n                    }\n                    \n                    if batch_idx < (warmup_items.len() + batch_size - 1) / batch_size - 1 {\n                        tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;\n                    }\n                }\n                \n                crate::modules::logger::log_info(&format!(\"[Warmup] Warmup task completed: success {}/{}\", success, total));\n                tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;\n                let _ = crate::modules::account::refresh_all_quotas_logic().await;\n            });\n            crate::modules::logger::log_info(&format!(\"[Warmup] Returning to frontend: Warmup task triggered for {} models\", total));\n            return Ok(format!(\"Warmup task triggered for {} models\", total));\n        }\n\n        if has_near_ready_models && retry_count < MAX_RETRIES {\n            retry_count += 1;\n            crate::modules::logger::log_info(&format!(\"[Warmup] Critical recovery model detected, waiting {}s to retry ({}/{})\", RETRY_DELAY_SECS, retry_count, MAX_RETRIES));\n            tokio::time::sleep(tokio::time::Duration::from_secs(RETRY_DELAY_SECS)).await;\n            continue;\n        }\n\n        return Ok(\"No models need warmup\".to_string());\n    }\n}\n\n/// Warmup for single account\npub async fn warm_up_account(account_id: &str) -> Result<String, String> {\n    let accounts = crate::modules::account::list_accounts().unwrap_or_default();\n    let account_owned = accounts.iter().find(|a| a.id == account_id).cloned().ok_or_else(|| \"Account not found\".to_string())?;\n\n    if account_owned.disabled || account_owned.proxy_disabled {\n        return Err(\"Account is disabled\".to_string());\n    }\n    \n    let email = account_owned.email.clone();\n    let (token, pid) = get_valid_token_for_warmup(&account_owned).await?;\n    let (fresh_quota, _) = fetch_quota_with_cache(&token, &email, Some(&pid), Some(&account_owned.id)).await.map_err(|e| format!(\"Failed to fetch quota: {}\", e))?;\n    \n    // [FIX] 预热阶段检测到 403 时，使用统一的 mark_account_forbidden 逻辑，\n    // 确保账号文件和索引文件同时更新，且前端刷新后能感知到禁用状态\n    if fresh_quota.is_forbidden {\n        crate::modules::logger::log_warn(&format!(\n            \"[Warmup] Account {} returned 403 Forbidden during quota fetch, marking as forbidden\",\n            email\n        ));\n        let reason = \"Warmup: 403 Forbidden - quota fetch denied\";\n        let _ = crate::modules::account::mark_account_forbidden(account_id, reason);\n        return Err(\"Account is forbidden (403)\".to_string());\n    }\n\n    let mut models_to_warm = Vec::new();\n    let mut warmed_series = std::collections::HashSet::new();\n\n    for m in fresh_quota.models {\n        if m.percentage >= 100 {\n            let model_name = m.name.clone();\n\n            // Removed hardcoded whitelist - now warms up any model at 100%\n            if !warmed_series.contains(&model_name) {\n                models_to_warm.push((model_name.clone(), m.percentage));\n                warmed_series.insert(model_name);\n            }\n        }\n    }\n\n    if models_to_warm.is_empty() {\n        return Ok(\"No warmup needed\".to_string());\n    }\n\n    let warmed_count = models_to_warm.len();\n    let account_id_clone = account_id.to_string();\n    \n    tokio::spawn(async move {\n        for (name, pct) in models_to_warm {\n            if warmup_model_directly(&token, &name, &pid, &email, pct, Some(&account_id_clone)).await {\n                let history_key = format!(\"{}:{}:100\", email, name);\n                let now_ts = chrono::Utc::now().timestamp();\n                crate::modules::scheduler::record_warmup_history(&history_key, now_ts);\n            }\n            tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;\n        }\n        let _ = crate::modules::account::refresh_all_quotas_logic().await;\n    });\n\n    Ok(format!(\"Successfully triggered warmup for {} model series\", warmed_count))\n}\n"
  },
  {
    "path": "src-tauri/src/modules/scheduler.rs",
    "content": "use chrono::Utc;\nuse once_cell::sync::Lazy;\nuse std::collections::HashMap;\nuse std::sync::Mutex;\nuse tokio::time::{self, Duration};\nuse crate::modules::{config, logger, quota, account};\nuse crate::models::Account;\nuse std::path::PathBuf;\n\n// Warmup history: key = \"email:model_name:100\", value = warmup timestamp\nstatic WARMUP_HISTORY: Lazy<Mutex<HashMap<String, i64>>> = Lazy::new(|| Mutex::new(load_warmup_history()));\n\nfn get_warmup_history_path() -> Result<PathBuf, String> {\n    let data_dir = account::get_data_dir()?;\n    Ok(data_dir.join(\"warmup_history.json\"))\n}\n\nfn load_warmup_history() -> HashMap<String, i64> {\n    match get_warmup_history_path() {\n        Ok(path) if path.exists() => {\n            match std::fs::read_to_string(&path) {\n                Ok(content) => serde_json::from_str(&content).unwrap_or_default(),\n                Err(_) => HashMap::new(),\n            }\n        }\n        _ => HashMap::new(),\n    }\n}\n\nfn save_warmup_history(history: &HashMap<String, i64>) {\n    if let Ok(path) = get_warmup_history_path() {\n        if let Ok(content) = serde_json::to_string_pretty(history) {\n            let _ = std::fs::write(&path, content);\n        }\n    }\n}\n\npub fn record_warmup_history(key: &str, timestamp: i64) {\n    let mut history = WARMUP_HISTORY.lock().unwrap();\n    history.insert(key.to_string(), timestamp);\n    save_warmup_history(&history);\n}\n\npub fn check_cooldown(key: &str, cooldown_seconds: i64) -> bool {\n    let history = WARMUP_HISTORY.lock().unwrap();\n    if let Some(&last_ts) = history.get(key) {\n        let now = chrono::Utc::now().timestamp();\n        now - last_ts < cooldown_seconds\n    } else {\n        false\n    }\n}\n\npub fn start_scheduler(app_handle: Option<tauri::AppHandle>, proxy_state: crate::commands::proxy::ProxyServiceState) {\n    tauri::async_runtime::spawn(async move {\n        logger::log_info(\"Smart Warmup Scheduler started. Monitoring quota at 100%...\");\n        \n        // Scan every 10 minutes\n        let mut interval = time::interval(Duration::from_secs(600));\n\n        loop {\n            interval.tick().await;\n\n            // Load configuration\n            let Ok(app_config) = config::load_app_config() else {\n                continue;\n            };\n\n            if !app_config.auto_refresh {\n                continue;\n            }\n            \n            // Get all accounts (no longer filtering by level)\n            let Ok(accounts) = account::list_accounts() else {\n                continue;\n            };\n\n            if accounts.is_empty() {\n                continue;\n            }\n\n            logger::log_info(&format!(\n                \"[Scheduler] Scanning {} accounts for 100% quota models...\",\n                accounts.len()\n            ));\n\n            let mut warmup_tasks = Vec::new();\n            let mut skipped_cooldown = 0;\n\n            // Scan each model for each account\n            for account in &accounts {\n\n                // Get valid token\n                let Ok((token, pid)) = quota::get_valid_token_for_warmup(account).await else {\n                    continue;\n                };\n\n                // Get fresh quota\n                let Ok((fresh_quota, _)) = quota::fetch_quota_with_cache(&token, &account.email, Some(&pid), Some(&account.id)).await else {\n                    continue;\n                };\n\n                // [FIX] 预热阶段检测到 403 时，使用统一禁用逻辑，确保账号文件和索引同时更新\n                if fresh_quota.is_forbidden {\n                    logger::log_warn(&format!(\n                        \"[Scheduler] Account {} returned 403 Forbidden during quota fetch, marking as forbidden\",\n                        account.email\n                    ));\n                    let _ = account::mark_account_forbidden(&account.id, \"Scheduler: 403 Forbidden - quota fetch denied\");\n                    continue;\n                }\n\n                let now_ts = Utc::now().timestamp();\n\n                for model in fresh_quota.models {\n                    // Core logic: detect 100% quota\n                    if model.percentage == 100 {\n                        let model_to_ping = model.name.clone();\n\n                        // Only warmup models configured by user (allowlist)\n                        if !app_config.scheduled_warmup.monitored_models.contains(&model_to_ping) {\n                            continue;\n                        }\n\n                        // Use mapped name as key\n                        let history_key = format!(\"{}:{}:100\", account.email, model_to_ping);\n                        \n                        // Check cooldown: do not repeat warmup within 4 hours\n                        {\n                            let history = WARMUP_HISTORY.lock().unwrap();\n                            if let Some(&last_warmup_ts) = history.get(&history_key) {\n                                let cooldown_seconds = 14400;\n                                if now_ts - last_warmup_ts < cooldown_seconds {\n                                    skipped_cooldown += 1;\n                                    continue;\n                                }\n                            }\n                        }\n\n                        warmup_tasks.push((\n                            account.id.clone(),\n                            account.email.clone(),\n                            model_to_ping.clone(),\n                            token.clone(),\n                            pid.clone(),\n                            model.percentage,\n                            history_key.clone(),\n                        ));\n\n                        logger::log_info(&format!(\n                            \"[Scheduler] ✓ Scheduled warmup: {} @ {} (quota at 100%)\",\n                            model_to_ping, account.email\n                        ));\n                    } else if model.percentage < 100 {\n                        // Quota not full, clear history, need to map name first\n                        let model_to_ping = model.name.clone();\n                        let history_key = format!(\"{}:{}:100\", account.email, model_to_ping);\n                        \n                        let mut history = WARMUP_HISTORY.lock().unwrap();\n                        if history.remove(&history_key).is_some() {\n                            save_warmup_history(&history);\n                            logger::log_info(&format!(\n                                \"[Scheduler] Cleared history for {} @ {} (quota: {}%)\",\n                                model_to_ping, account.email, model.percentage\n                            ));\n                        }\n                    }\n                }\n            }\n\n            // Execute warmup tasks\n            if !warmup_tasks.is_empty() {\n                let total = warmup_tasks.len();\n                if skipped_cooldown > 0 {\n                    logger::log_info(&format!(\n                        \"[Scheduler] Skipped {} models in cooldown, will warmup {}\",\n                        skipped_cooldown, total\n                    ));\n                }\n                logger::log_info(&format!(\n                    \"[Scheduler] 🔥 Triggering {} warmup tasks...\",\n                    total\n                ));\n\n                let handle_for_warmup = app_handle.clone();\n                let state_for_warmup = proxy_state.clone();\n\n                tokio::spawn(async move {\n                    let mut success = 0;\n                    let batch_size = 3;\n                    let now_ts = chrono::Utc::now().timestamp();\n                    \n                    for (batch_idx, batch) in warmup_tasks.chunks(batch_size).enumerate() {\n                        let mut handles = Vec::new();\n                        \n                        for (task_idx, (id, email, model, token, pid, pct, history_key)) in batch.iter().enumerate() {\n                            let global_idx = batch_idx * batch_size + task_idx + 1;\n                            let id = id.clone();\n                            let email = email.clone();\n                            let model = model.clone();\n                            let token = token.clone();\n                            let pid = pid.clone();\n                            let pct = *pct;\n                            let history_key = history_key.clone();\n                            \n                            logger::log_info(&format!(\n                                \"[Warmup {}/{}] {} @ {} ({}%)\",\n                                global_idx, total, model, email, pct\n                            ));\n                            \n                            let handle = tokio::spawn(async move {\n                                let result = quota::warmup_model_directly(&token, &model, &pid, &email, pct, Some(&id)).await;\n                                (result, history_key)\n                            });\n                            handles.push(handle);\n                        }\n                        \n                        for handle in handles {\n                            match handle.await {\n                                Ok((true, history_key)) => {\n                                    success += 1;\n                                    record_warmup_history(&history_key, now_ts);\n                                }\n                                _ => {}\n                            }\n                        }\n                        \n                        if batch_idx < (warmup_tasks.len() + batch_size - 1) / batch_size - 1 {\n                            tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;\n                        }\n                    }\n\n                    logger::log_info(&format!(\n                        \"[Scheduler] ✅ Warmup completed: {}/{} successful\",\n                        success, total\n                    ));\n\n                    // Refresh quota\n                    tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;\n                    let _ = crate::commands::refresh_all_quotas_internal(&state_for_warmup, handle_for_warmup).await;\n                });\n            } else if skipped_cooldown > 0 {\n                logger::log_info(&format!(\n                    \"[Scheduler] Scan completed, all 100% models are in cooldown, skipped {}\",\n                    skipped_cooldown\n                ));\n            } else {\n                logger::log_info(\"[Scheduler] Scan completed, no models with 100% quota need warmup\");\n            }\n\n            // Sync to frontend if handle exists\n            if let Some(handle) = app_handle.as_ref() {\n                let handle_inner = handle.clone();\n                let state_inner = proxy_state.clone();\n                tokio::spawn(async move {\n                    tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;\n                    let _ = crate::commands::refresh_all_quotas_internal(&state_inner, Some(handle_inner)).await;\n                    logger::log_info(\"[Scheduler] Quota data synced to frontend\");\n                });\n            }\n\n            // Regularly clean up history (keep last 24 hours)\n            {\n                let now_ts = Utc::now().timestamp();\n                let mut history = WARMUP_HISTORY.lock().unwrap();\n                let cutoff = now_ts - 86400; // 24 hours ago\n                history.retain(|_, &mut ts| ts > cutoff);\n            }\n        }\n    });\n}\n\n/// Trigger immediate smart warmup check for a single account\npub async fn trigger_warmup_for_account(account: &Account) {\n\n    // Get valid token\n    let Ok((token, pid)) = quota::get_valid_token_for_warmup(account).await else {\n        return;\n    };\n\n    // Get quota info (prefer cache as refresh command likely just updated disk/cache)\n    let Ok((fresh_quota, _)) = quota::fetch_quota_with_cache(&token, &account.email, Some(&pid), Some(&account.id)).await else {\n        return;\n    };\n\n    // [FIX] 预热阶段检测到 403 时，使用统一禁用逻辑，确保账号文件和索引同时更新\n    if fresh_quota.is_forbidden {\n        logger::log_warn(&format!(\n            \"[Scheduler] Account {} returned 403 Forbidden during quota fetch, marking as forbidden\",\n            account.email\n        ));\n        let _ = account::mark_account_forbidden(&account.id, \"Scheduler: 403 Forbidden - quota fetch denied\");\n        return;\n    }\n\n    // Load config once at the beginning\n    let Ok(app_config) = config::load_app_config() else {\n        logger::log_warn(\"[Scheduler] Failed to load app config, skipping warmup check\");\n        return;\n    };\n\n    let now_ts = Utc::now().timestamp();\n    let mut tasks_to_run = Vec::new();\n\n    for model in fresh_quota.models {\n        let model_name = model.name.clone();\n        let history_key = format!(\"{}:{}:100\", account.email, model_name);\n\n        if model.percentage == 100 {\n            // First check if model is in user's monitored list\n            if !app_config.scheduled_warmup.monitored_models.contains(&model_name) {\n                continue;\n            }\n\n            // Then check cooldown history\n            {\n                let history = WARMUP_HISTORY.lock().unwrap();\n\n                // 4 hour cooldown (Pro account resets every 5h, 1h margin)\n                if let Some(&last_warmup_ts) = history.get(&history_key) {\n                    let cooldown_seconds = 14400;\n                    if now_ts - last_warmup_ts < cooldown_seconds {\n                        // Still in cooldown, skip\n                        continue;\n                    }\n                }\n            }\n            // Note: Don't write history here - only write after successful warmup\n\n            tasks_to_run.push((model_name, model.percentage, history_key));\n        } else if model.percentage < 100 {\n            // Quota not full, clear history, allow warmup next time it's 100%\n            let mut history = WARMUP_HISTORY.lock().unwrap();\n            if history.remove(&history_key).is_some() {\n                save_warmup_history(&history);\n            }\n        }\n    }\n\n    // Execute warmup and record history only on success\n    if !tasks_to_run.is_empty() {\n        logger::log_info(&format!(\n            \"[Scheduler] Found {} models ready for warmup on {}\",\n            tasks_to_run.len(), account.email\n        ));\n\n        for (model, pct, history_key) in tasks_to_run {\n            logger::log_info(&format!(\n                \"[Scheduler] 🔥 Triggering individual warmup: {} @ {} (Sync)\",\n                model, account.email\n            ));\n\n            let success = quota::warmup_model_directly(&token, &model, &pid, &account.email, pct, Some(&account.id)).await;\n\n            // Only record history if warmup was successful\n            if success {\n                record_warmup_history(&history_key, now_ts);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/modules/security_db.rs",
    "content": "//! Security Database Module\n//! 安全监控相关的数据库操作\n\nuse rusqlite::{params, Connection};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\n/// IP 访问日志\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct IpAccessLog {\n    pub id: String,\n    pub client_ip: String,\n    pub timestamp: i64,\n    pub method: Option<String>,\n    pub path: Option<String>,\n    pub user_agent: Option<String>,\n    pub status: Option<i32>,\n    pub duration: Option<i64>,\n    pub api_key_hash: Option<String>,\n    pub blocked: bool,\n    pub block_reason: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub username: Option<String>,\n}\n\n/// IP 黑名单条目\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct IpBlacklistEntry {\n    pub id: String,\n    pub ip_pattern: String,\n    pub reason: Option<String>,\n    pub created_at: i64,\n    pub expires_at: Option<i64>,\n    pub created_by: String,\n    pub hit_count: i64,\n}\n\n/// IP 白名单条目\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct IpWhitelistEntry {\n    pub id: String,\n    pub ip_pattern: String,\n    pub description: Option<String>,\n    pub created_at: i64,\n}\n\n/// IP 统计概览\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct IpStats {\n    pub total_requests: u64,\n    pub unique_ips: u64,\n    pub blocked_count: u64,\n    pub today_requests: u64,\n    pub blacklist_count: u64,\n    pub whitelist_count: u64,\n}\n\n/// IP 访问排行\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct IpRanking {\n    pub client_ip: String,\n    pub request_count: u64,\n    pub last_seen: i64,\n    pub is_blocked: bool,\n}\n\n/// 获取安全数据库路径\npub fn get_security_db_path() -> Result<PathBuf, String> {\n    let data_dir = crate::modules::account::get_data_dir()?;\n    Ok(data_dir.join(\"security.db\"))\n}\n\n/// 连接数据库\nfn connect_db() -> Result<Connection, String> {\n    let db_path = get_security_db_path()?;\n    let conn = Connection::open(db_path).map_err(|e| e.to_string())?;\n\n    // Enable WAL mode for better concurrency\n    conn.pragma_update(None, \"journal_mode\", \"WAL\")\n        .map_err(|e| e.to_string())?;\n\n    // Set busy timeout\n    conn.pragma_update(None, \"busy_timeout\", 5000)\n        .map_err(|e| e.to_string())?;\n\n    conn.pragma_update(None, \"synchronous\", \"NORMAL\")\n        .map_err(|e| e.to_string())?;\n\n    Ok(conn)\n}\n\n/// 初始化安全数据库\npub fn init_db() -> Result<(), String> {\n    let conn = connect_db()?;\n\n    // IP 访问日志表\n    conn.execute(\n        \"CREATE TABLE IF NOT EXISTS ip_access_logs (\n            id TEXT PRIMARY KEY,\n            client_ip TEXT NOT NULL,\n            timestamp INTEGER NOT NULL,\n            method TEXT,\n            path TEXT,\n            user_agent TEXT,\n            status INTEGER,\n            duration INTEGER,\n            api_key_hash TEXT,\n            blocked INTEGER DEFAULT 0,\n            block_reason TEXT\n        )\",\n        [],\n    )\n    .map_err(|e| e.to_string())?;\n\n    // IP 黑名单表\n    conn.execute(\n        \"CREATE TABLE IF NOT EXISTS ip_blacklist (\n            id TEXT PRIMARY KEY,\n            ip_pattern TEXT NOT NULL UNIQUE,\n            reason TEXT,\n            created_at INTEGER NOT NULL,\n            expires_at INTEGER,\n            created_by TEXT DEFAULT 'manual',\n            hit_count INTEGER DEFAULT 0\n        )\",\n        [],\n    )\n    .map_err(|e| e.to_string())?;\n\n    // IP 白名单表\n    conn.execute(\n        \"CREATE TABLE IF NOT EXISTS ip_whitelist (\n            id TEXT PRIMARY KEY,\n            ip_pattern TEXT NOT NULL UNIQUE,\n            description TEXT,\n            created_at INTEGER NOT NULL\n        )\",\n        [],\n    )\n    .map_err(|e| e.to_string())?;\n\n    // 创建索引\n    conn.execute(\n        \"CREATE INDEX IF NOT EXISTS idx_ip_access_ip ON ip_access_logs (client_ip)\",\n        [],\n    )\n    .map_err(|e| e.to_string())?;\n\n    conn.execute(\n        \"CREATE INDEX IF NOT EXISTS idx_ip_access_timestamp ON ip_access_logs (timestamp DESC)\",\n        [],\n    )\n    .map_err(|e| e.to_string())?;\n\n    conn.execute(\n        \"CREATE INDEX IF NOT EXISTS idx_ip_access_blocked ON ip_access_logs (blocked)\",\n        [],\n    )\n    .map_err(|e| e.to_string())?;\n\n    conn.execute(\n        \"CREATE INDEX IF NOT EXISTS idx_blacklist_pattern ON ip_blacklist (ip_pattern)\",\n        [],\n    )\n    .map_err(|e| e.to_string())?;\n\n    // Migration: Add username column to ip_access_logs\n    let _ = conn.execute(\"ALTER TABLE ip_access_logs ADD COLUMN username TEXT\", []);\n\n    Ok(())\n}\n\n// ============================================================================\n// IP 访问日志操作\n// ============================================================================\n\n/// 保存 IP 访问日志\npub fn save_ip_access_log(log: &IpAccessLog) -> Result<(), String> {\n    let conn = connect_db()?;\n\n    conn.execute(\n        \"INSERT INTO ip_access_logs (id, client_ip, timestamp, method, path, user_agent, status, duration, api_key_hash, blocked, block_reason, username)\n         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)\",\n        params![\n            log.id,\n            log.client_ip,\n            log.timestamp,\n            log.method,\n            log.path,\n            log.user_agent,\n            log.status,\n            log.duration,\n            log.api_key_hash,\n            log.blocked,\n            log.block_reason,\n            log.username,\n        ],\n    )\n    .map_err(|e| e.to_string())?;\n\n    Ok(())\n}\n\n/// 获取 IP 访问日志 (分页)\npub fn get_ip_access_logs(\n    limit: usize,\n    offset: usize,\n    ip_filter: Option<&str>,\n    blocked_only: bool,\n) -> Result<Vec<IpAccessLog>, String> {\n    let conn = connect_db()?;\n\n    let sql = if blocked_only {\n        if let Some(ip) = ip_filter {\n            format!(\n                \"SELECT id, client_ip, timestamp, method, path, user_agent, status, duration, api_key_hash, blocked, block_reason, username\n                 FROM ip_access_logs\n                 WHERE blocked = 1 AND client_ip LIKE '%{}%'\n                 ORDER BY timestamp DESC\n                 LIMIT {} OFFSET {}\",\n                ip, limit, offset\n            )\n        } else {\n            format!(\n                \"SELECT id, client_ip, timestamp, method, path, user_agent, status, duration, api_key_hash, blocked, block_reason, username\n                 FROM ip_access_logs\n                 WHERE blocked = 1\n                 ORDER BY timestamp DESC\n                 LIMIT {} OFFSET {}\",\n                limit, offset\n            )\n        }\n    } else if let Some(ip) = ip_filter {\n        format!(\n            \"SELECT id, client_ip, timestamp, method, path, user_agent, status, duration, api_key_hash, blocked, block_reason, username\n             FROM ip_access_logs\n             WHERE client_ip LIKE '%{}%'\n             ORDER BY timestamp DESC\n             LIMIT {} OFFSET {}\",\n            ip, limit, offset\n        )\n    } else {\n        format!(\n            \"SELECT id, client_ip, timestamp, method, path, user_agent, status, duration, api_key_hash, blocked, block_reason, username\n             FROM ip_access_logs\n             ORDER BY timestamp DESC\n             LIMIT {} OFFSET {}\",\n            limit, offset\n        )\n    };\n\n    let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;\n\n    let logs_iter = stmt\n        .query_map([], |row| {\n            Ok(IpAccessLog {\n                id: row.get(0)?,\n                client_ip: row.get(1)?,\n                timestamp: row.get(2)?,\n                method: row.get(3)?,\n                path: row.get(4)?,\n                user_agent: row.get(5)?,\n                status: row.get(6)?,\n                duration: row.get(7)?,\n                api_key_hash: row.get(8)?,\n                blocked: row.get::<_, i32>(9)? != 0,\n                block_reason: row.get(10)?,\n                username: row.get(11).unwrap_or(None),\n            })\n        })\n        .map_err(|e| e.to_string())?;\n\n    let mut logs = Vec::new();\n    for log in logs_iter {\n        logs.push(log.map_err(|e| e.to_string())?);\n    }\n    Ok(logs)\n}\n\n/// 获取 IP 统计概览\npub fn get_ip_stats() -> Result<IpStats, String> {\n    let conn = connect_db()?;\n\n    let today_start = chrono::Utc::now()\n        .date_naive()\n        .and_hms_opt(0, 0, 0)\n        .unwrap()\n        .and_utc()\n        .timestamp();\n\n    let (total_requests, unique_ips, blocked_count, today_requests): (u64, u64, u64, u64) = conn\n        .query_row(\n            \"SELECT\n                COUNT(*) as total,\n                COUNT(DISTINCT client_ip) as unique_ips,\n                SUM(CASE WHEN blocked = 1 THEN 1 ELSE 0 END) as blocked,\n                SUM(CASE WHEN timestamp >= ?1 THEN 1 ELSE 0 END) as today\n             FROM ip_access_logs\",\n            [today_start],\n            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),\n        )\n        .map_err(|e| e.to_string())?;\n\n    let blacklist_count: u64 = conn\n        .query_row(\"SELECT COUNT(*) FROM ip_blacklist\", [], |row| row.get(0))\n        .map_err(|e| e.to_string())?;\n\n    let whitelist_count: u64 = conn\n        .query_row(\"SELECT COUNT(*) FROM ip_whitelist\", [], |row| row.get(0))\n        .map_err(|e| e.to_string())?;\n\n    Ok(IpStats {\n        total_requests,\n        unique_ips,\n        blocked_count,\n        today_requests,\n        blacklist_count,\n        whitelist_count,\n    })\n}\n\n/// 获取 TOP N IP 访问排行\npub fn get_top_ips(limit: usize, hours: i64) -> Result<Vec<IpRanking>, String> {\n    let conn = connect_db()?;\n\n    let since = chrono::Utc::now().timestamp() - (hours * 3600);\n\n    let mut stmt = conn\n        .prepare(\n            \"SELECT client_ip, COUNT(*) as cnt, MAX(timestamp) as last_seen\n             FROM ip_access_logs\n             WHERE timestamp >= ?1\n             GROUP BY client_ip\n             ORDER BY cnt DESC\n             LIMIT ?2\",\n        )\n        .map_err(|e| e.to_string())?;\n\n    let rankings_iter = stmt\n        .query_map([since, limit as i64], |row| {\n            Ok(IpRanking {\n                client_ip: row.get(0)?,\n                request_count: row.get(1)?,\n                last_seen: row.get(2)?,\n                is_blocked: false, // 稍后填充\n            })\n        })\n        .map_err(|e| e.to_string())?;\n\n    let mut rankings = Vec::new();\n    for r in rankings_iter {\n        let mut ranking = r.map_err(|e| e.to_string())?;\n        // 检查是否在黑名单中\n        ranking.is_blocked = is_ip_in_blacklist(&ranking.client_ip)?;\n        rankings.push(ranking);\n    }\n\n    Ok(rankings)\n}\n\n/// 清理旧的 IP 访问日志\npub fn cleanup_old_ip_logs(days: i64) -> Result<usize, String> {\n    let conn = connect_db()?;\n\n    let cutoff_timestamp = chrono::Utc::now().timestamp() - (days * 24 * 3600);\n\n    let deleted = conn\n        .execute(\n            \"DELETE FROM ip_access_logs WHERE timestamp < ?1\",\n            [cutoff_timestamp],\n        )\n        .map_err(|e| e.to_string())?;\n\n    // VACUUM to reclaim space\n    conn.execute(\"VACUUM\", []).map_err(|e| e.to_string())?;\n\n    Ok(deleted)\n}\n\n// ============================================================================\n// 黑名单操作\n// ============================================================================\n\n/// 添加 IP 到黑名单\npub fn add_to_blacklist(\n    ip_pattern: &str,\n    reason: Option<&str>,\n    expires_at: Option<i64>,\n    created_by: &str,\n) -> Result<IpBlacklistEntry, String> {\n    let conn = connect_db()?;\n\n    let id = uuid::Uuid::new_v4().to_string();\n    let now = chrono::Utc::now().timestamp();\n\n    conn.execute(\n        \"INSERT INTO ip_blacklist (id, ip_pattern, reason, created_at, expires_at, created_by, hit_count)\n         VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)\",\n        params![id, ip_pattern, reason, now, expires_at, created_by],\n    )\n    .map_err(|e| e.to_string())?;\n\n    Ok(IpBlacklistEntry {\n        id,\n        ip_pattern: ip_pattern.to_string(),\n        reason: reason.map(|s| s.to_string()),\n        created_at: now,\n        expires_at,\n        created_by: created_by.to_string(),\n        hit_count: 0,\n    })\n}\n\n/// 从黑名单移除\npub fn remove_from_blacklist(id: &str) -> Result<(), String> {\n    let conn = connect_db()?;\n\n    conn.execute(\"DELETE FROM ip_blacklist WHERE id = ?1\", [id])\n        .map_err(|e| e.to_string())?;\n\n    Ok(())\n}\n\n/// 获取黑名单列表\npub fn get_blacklist() -> Result<Vec<IpBlacklistEntry>, String> {\n    let conn = connect_db()?;\n\n    let mut stmt = conn\n        .prepare(\n            \"SELECT id, ip_pattern, reason, created_at, expires_at, created_by, hit_count\n             FROM ip_blacklist\n             ORDER BY created_at DESC\",\n        )\n        .map_err(|e| e.to_string())?;\n\n    let entries_iter = stmt\n        .query_map([], |row| {\n            Ok(IpBlacklistEntry {\n                id: row.get(0)?,\n                ip_pattern: row.get(1)?,\n                reason: row.get(2)?,\n                created_at: row.get(3)?,\n                expires_at: row.get(4)?,\n                created_by: row.get(5)?,\n                hit_count: row.get(6)?,\n            })\n        })\n        .map_err(|e| e.to_string())?;\n\n    let mut entries = Vec::new();\n    for e in entries_iter {\n        entries.push(e.map_err(|e| e.to_string())?);\n    }\n    Ok(entries)\n}\n\n/// 检查 IP 是否在黑名单中\npub fn is_ip_in_blacklist(ip: &str) -> Result<bool, String> {\n    get_blacklist_entry_for_ip(ip).map(|entry| entry.is_some())\n}\n\n/// 获取 IP 对应的黑名单条目（如果存在）\npub fn get_blacklist_entry_for_ip(ip: &str) -> Result<Option<IpBlacklistEntry>, String> {\n    let conn = connect_db()?;\n    let now = chrono::Utc::now().timestamp();\n\n    // 清理过期的黑名单条目\n    let _ = conn.execute(\n        \"DELETE FROM ip_blacklist WHERE expires_at IS NOT NULL AND expires_at < ?1\",\n        [now],\n    );\n\n    // 精确匹配\n    let entry_result = conn.query_row(\n        \"SELECT id, ip_pattern, reason, created_at, expires_at, created_by, hit_count\n         FROM ip_blacklist WHERE ip_pattern = ?1\",\n        [ip],\n        |row| {\n            Ok(IpBlacklistEntry {\n                id: row.get(0)?,\n                ip_pattern: row.get(1)?,\n                reason: row.get(2)?,\n                created_at: row.get(3)?,\n                expires_at: row.get(4)?,\n                created_by: row.get(5)?,\n                hit_count: row.get(6)?,\n            })\n        },\n    );\n\n    if let Ok(entry) = entry_result {\n        // 增加命中计数\n        let _ = conn.execute(\n            \"UPDATE ip_blacklist SET hit_count = hit_count + 1 WHERE ip_pattern = ?1\",\n            [ip],\n        );\n        return Ok(Some(entry));\n    }\n\n    // CIDR 匹配\n    let entries = get_blacklist()?;\n    for entry in entries {\n        if entry.ip_pattern.contains('/') {\n            if cidr_match(ip, &entry.ip_pattern) {\n                // 增加命中计数\n                let _ = conn.execute(\n                    \"UPDATE ip_blacklist SET hit_count = hit_count + 1 WHERE id = ?1\",\n                    [&entry.id],\n                );\n                return Ok(Some(entry));\n            }\n        }\n    }\n\n    Ok(None)\n}\n\n/// 简单的 CIDR 匹配\nfn cidr_match(ip: &str, cidr: &str) -> bool {\n    let parts: Vec<&str> = cidr.split('/').collect();\n    if parts.len() != 2 {\n        return false;\n    }\n\n    let network = parts[0];\n    let prefix_len: u8 = match parts[1].parse() {\n        Ok(p) => p,\n        Err(_) => return false,\n    };\n\n    let ip_parts: Vec<u8> = ip\n        .split('.')\n        .filter_map(|s| s.parse().ok())\n        .collect();\n    let net_parts: Vec<u8> = network\n        .split('.')\n        .filter_map(|s| s.parse().ok())\n        .collect();\n\n    if ip_parts.len() != 4 || net_parts.len() != 4 {\n        return false;\n    }\n\n    let ip_u32 = u32::from_be_bytes([ip_parts[0], ip_parts[1], ip_parts[2], ip_parts[3]]);\n    let net_u32 = u32::from_be_bytes([net_parts[0], net_parts[1], net_parts[2], net_parts[3]]);\n\n    let mask = if prefix_len == 0 {\n        0\n    } else {\n        !0u32 << (32 - prefix_len)\n    };\n\n    (ip_u32 & mask) == (net_u32 & mask)\n}\n\n// ============================================================================\n// 白名单操作\n// ============================================================================\n\n/// 添加 IP 到白名单\npub fn add_to_whitelist(ip_pattern: &str, description: Option<&str>) -> Result<IpWhitelistEntry, String> {\n    let conn = connect_db()?;\n\n    let id = uuid::Uuid::new_v4().to_string();\n    let now = chrono::Utc::now().timestamp();\n\n    conn.execute(\n        \"INSERT INTO ip_whitelist (id, ip_pattern, description, created_at)\n         VALUES (?1, ?2, ?3, ?4)\",\n        params![id, ip_pattern, description, now],\n    )\n    .map_err(|e| e.to_string())?;\n\n    Ok(IpWhitelistEntry {\n        id,\n        ip_pattern: ip_pattern.to_string(),\n        description: description.map(|s| s.to_string()),\n        created_at: now,\n    })\n}\n\n/// 从白名单移除\npub fn remove_from_whitelist(id: &str) -> Result<(), String> {\n    let conn = connect_db()?;\n\n    conn.execute(\"DELETE FROM ip_whitelist WHERE id = ?1\", [id])\n        .map_err(|e| e.to_string())?;\n\n    Ok(())\n}\n\n/// 获取白名单列表\npub fn get_whitelist() -> Result<Vec<IpWhitelistEntry>, String> {\n    let conn = connect_db()?;\n\n    let mut stmt = conn\n        .prepare(\n            \"SELECT id, ip_pattern, description, created_at\n             FROM ip_whitelist\n             ORDER BY created_at DESC\",\n        )\n        .map_err(|e| e.to_string())?;\n\n    let entries_iter = stmt\n        .query_map([], |row| {\n            Ok(IpWhitelistEntry {\n                id: row.get(0)?,\n                ip_pattern: row.get(1)?,\n                description: row.get(2)?,\n                created_at: row.get(3)?,\n            })\n        })\n        .map_err(|e| e.to_string())?;\n\n    let mut entries = Vec::new();\n    for e in entries_iter {\n        entries.push(e.map_err(|e| e.to_string())?);\n    }\n    Ok(entries)\n}\n\n/// 检查 IP 是否在白名单中\npub fn is_ip_in_whitelist(ip: &str) -> Result<bool, String> {\n    let conn = connect_db()?;\n\n    // 精确匹配\n    let count: i64 = conn\n        .query_row(\n            \"SELECT COUNT(*) FROM ip_whitelist WHERE ip_pattern = ?1\",\n            [ip],\n            |row| row.get(0),\n        )\n        .map_err(|e| e.to_string())?;\n\n    if count > 0 {\n        return Ok(true);\n    }\n\n    // CIDR 匹配\n    let entries = get_whitelist()?;\n    for entry in entries {\n        if entry.ip_pattern.contains('/') {\n            if cidr_match(ip, &entry.ip_pattern) {\n                return Ok(true);\n            }\n        }\n    }\n\n    Ok(false)\n}\n\n/// 清空所有 IP 访问日志\npub fn clear_ip_access_logs() -> Result<(), String> {\n    let conn = connect_db()?;\n    conn.execute(\"DELETE FROM ip_access_logs\", [])\n        .map_err(|e| e.to_string())?;\n    Ok(())\n}\n\n/// 获取 IP 访问日志总数\npub fn get_ip_access_logs_count(ip_filter: Option<&str>, blocked_only: bool) -> Result<u64, String> {\n    let conn = connect_db()?;\n\n    let sql = if blocked_only {\n        if let Some(ip) = ip_filter {\n            format!(\n                \"SELECT COUNT(*) FROM ip_access_logs WHERE blocked = 1 AND client_ip LIKE '%{}%'\",\n                ip\n            )\n        } else {\n            \"SELECT COUNT(*) FROM ip_access_logs WHERE blocked = 1\".to_string()\n        }\n    } else if let Some(ip) = ip_filter {\n        format!(\n            \"SELECT COUNT(*) FROM ip_access_logs WHERE client_ip LIKE '%{}%'\",\n            ip\n        )\n    } else {\n        \"SELECT COUNT(*) FROM ip_access_logs\".to_string()\n    };\n\n    let count: u64 = conn\n        .query_row(&sql, [], |row| row.get(0))\n        .map_err(|e| e.to_string())?;\n\n    Ok(count)\n}\n"
  },
  {
    "path": "src-tauri/src/modules/token_stats.rs",
    "content": "use rusqlite::{params, Connection};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\n/// Aggregated token statistics\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TokenStatsAggregated {\n    pub period: String, // e.g., \"2024-01-15 14:00\" for hourly, \"2024-01-15\" for daily\n    pub total_input_tokens: u64,\n    pub total_output_tokens: u64,\n    pub total_tokens: u64,\n    pub request_count: u64,\n}\n\n/// Per-account token statistics\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AccountTokenStats {\n    pub account_email: String,\n    pub total_input_tokens: u64,\n    pub total_output_tokens: u64,\n    pub total_tokens: u64,\n    pub request_count: u64,\n}\n\n/// Summary statistics\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TokenStatsSummary {\n    pub total_input_tokens: u64,\n    pub total_output_tokens: u64,\n    pub total_tokens: u64,\n    pub total_requests: u64,\n    pub unique_accounts: u64,\n}\n\n/// Per-model token statistics\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ModelTokenStats {\n    pub model: String,\n    pub total_input_tokens: u64,\n    pub total_output_tokens: u64,\n    pub total_tokens: u64,\n    pub request_count: u64,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ModelTrendPoint {\n    pub period: String,\n    pub model_data: std::collections::HashMap<String, u64>,\n}\n\n/// Account trend data point (for stacked area chart)\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AccountTrendPoint {\n    pub period: String,\n    pub account_data: std::collections::HashMap<String, u64>,\n}\n\npub(crate) fn get_db_path() -> Result<PathBuf, String> {\n    let data_dir = crate::modules::account::get_data_dir()?;\n    Ok(data_dir.join(\"token_stats.db\"))\n}\n\nfn connect_db() -> Result<Connection, String> {\n    let db_path = get_db_path()?;\n    let conn = Connection::open(db_path).map_err(|e| e.to_string())?;\n\n    // Enable WAL mode for better concurrency\n    conn.pragma_update(None, \"journal_mode\", \"WAL\")\n        .map_err(|e| e.to_string())?;\n    conn.pragma_update(None, \"busy_timeout\", 5000)\n        .map_err(|e| e.to_string())?;\n    conn.pragma_update(None, \"synchronous\", \"NORMAL\")\n        .map_err(|e| e.to_string())?;\n\n    Ok(conn)\n}\n\n/// Initialize the token stats database\npub fn init_db() -> Result<(), String> {\n    let conn = connect_db()?;\n\n    // Create main usage table\n    conn.execute(\n        \"CREATE TABLE IF NOT EXISTS token_usage (\n            id INTEGER PRIMARY KEY AUTOINCREMENT,\n            timestamp INTEGER NOT NULL,\n            account_email TEXT NOT NULL,\n            model TEXT NOT NULL,\n            input_tokens INTEGER NOT NULL DEFAULT 0,\n            output_tokens INTEGER NOT NULL DEFAULT 0,\n            total_tokens INTEGER NOT NULL DEFAULT 0\n        )\",\n        [],\n    )\n    .map_err(|e| e.to_string())?;\n\n    // Create indexes for efficient queries\n    conn.execute(\n        \"CREATE INDEX IF NOT EXISTS idx_token_timestamp ON token_usage (timestamp DESC)\",\n        [],\n    )\n    .map_err(|e| e.to_string())?;\n\n    conn.execute(\n        \"CREATE INDEX IF NOT EXISTS idx_token_account ON token_usage (account_email)\",\n        [],\n    )\n    .map_err(|e| e.to_string())?;\n\n    // Create hourly aggregation table for fast queries\n    conn.execute(\n        \"CREATE TABLE IF NOT EXISTS token_stats_hourly (\n            hour_bucket TEXT NOT NULL,\n            account_email TEXT NOT NULL,\n            total_input_tokens INTEGER NOT NULL DEFAULT 0,\n            total_output_tokens INTEGER NOT NULL DEFAULT 0,\n            total_tokens INTEGER NOT NULL DEFAULT 0,\n            request_count INTEGER NOT NULL DEFAULT 0,\n            PRIMARY KEY (hour_bucket, account_email)\n        )\",\n        [],\n    )\n    .map_err(|e| e.to_string())?;\n\n    Ok(())\n}\n\n/// Record token usage from a request\npub fn record_usage(\n    account_email: &str,\n    model: &str,\n    input_tokens: u32,\n    output_tokens: u32,\n) -> Result<(), String> {\n    let conn = connect_db()?;\n    let timestamp = chrono::Local::now().timestamp();\n    let total_tokens = input_tokens + output_tokens;\n\n    // Insert into raw usage table\n    conn.execute(\n        \"INSERT INTO token_usage (timestamp, account_email, model, input_tokens, output_tokens, total_tokens)\n         VALUES (?1, ?2, ?3, ?4, ?5, ?6)\",\n        params![timestamp, account_email, model, input_tokens, output_tokens, total_tokens],\n    ).map_err(|e| e.to_string())?;\n\n    let hour_bucket = chrono::Local::now().format(\"%Y-%m-%d %H:00\").to_string();\n    conn.execute(\n        \"INSERT INTO token_stats_hourly (hour_bucket, account_email, total_input_tokens, total_output_tokens, total_tokens, request_count)\n         VALUES (?1, ?2, ?3, ?4, ?5, 1)\n         ON CONFLICT(hour_bucket, account_email) DO UPDATE SET\n            total_input_tokens = total_input_tokens + ?3,\n            total_output_tokens = total_output_tokens + ?4,\n            total_tokens = total_tokens + ?5,\n            request_count = request_count + 1\",\n        params![hour_bucket, account_email, input_tokens, output_tokens, total_tokens],\n    ).map_err(|e| e.to_string())?;\n\n    Ok(())\n}\n\n/// Get hourly aggregated stats for a time range\npub fn get_hourly_stats(hours: i64) -> Result<Vec<TokenStatsAggregated>, String> {\n    let conn = connect_db()?;\n    let cutoff = chrono::Local::now() - chrono::Duration::hours(hours);\n    let cutoff_bucket = cutoff.format(\"%Y-%m-%d %H:00\").to_string();\n\n    let mut stmt = conn\n        .prepare(\n            \"SELECT hour_bucket, \n                SUM(total_input_tokens) as input, \n                SUM(total_output_tokens) as output,\n                SUM(total_tokens) as total,\n                SUM(request_count) as count\n         FROM token_stats_hourly \n         WHERE hour_bucket >= ?1\n         GROUP BY hour_bucket\n         ORDER BY hour_bucket ASC\",\n        )\n        .map_err(|e| e.to_string())?;\n\n    let rows = stmt\n        .query_map([cutoff_bucket], |row| {\n            Ok(TokenStatsAggregated {\n                period: row.get(0)?,\n                total_input_tokens: row.get(1)?,\n                total_output_tokens: row.get(2)?,\n                total_tokens: row.get(3)?,\n                request_count: row.get(4)?,\n            })\n        })\n        .map_err(|e| e.to_string())?;\n\n    let mut result = Vec::new();\n    for row in rows {\n        result.push(row.map_err(|e| e.to_string())?);\n    }\n    Ok(result)\n}\n\n/// Get daily aggregated stats for a time range\npub fn get_daily_stats(days: i64) -> Result<Vec<TokenStatsAggregated>, String> {\n    let conn = connect_db()?;\n    let cutoff = chrono::Local::now() - chrono::Duration::days(days);\n    let cutoff_bucket = cutoff.format(\"%Y-%m-%d\").to_string();\n\n    let mut stmt = conn\n        .prepare(\n            \"SELECT substr(hour_bucket, 1, 10) as day_bucket, \n                SUM(total_input_tokens) as input, \n                SUM(total_output_tokens) as output,\n                SUM(total_tokens) as total,\n                SUM(request_count) as count\n         FROM token_stats_hourly \n         WHERE substr(hour_bucket, 1, 10) >= ?1\n         GROUP BY day_bucket\n         ORDER BY day_bucket ASC\",\n        )\n        .map_err(|e| e.to_string())?;\n\n    let rows = stmt\n        .query_map([cutoff_bucket], |row| {\n            Ok(TokenStatsAggregated {\n                period: row.get(0)?,\n                total_input_tokens: row.get(1)?,\n                total_output_tokens: row.get(2)?,\n                total_tokens: row.get(3)?,\n                request_count: row.get(4)?,\n            })\n        })\n        .map_err(|e| e.to_string())?;\n\n    let mut result = Vec::new();\n    for row in rows {\n        result.push(row.map_err(|e| e.to_string())?);\n    }\n    Ok(result)\n}\n\n/// Get weekly aggregated stats\npub fn get_weekly_stats(weeks: i64) -> Result<Vec<TokenStatsAggregated>, String> {\n    let conn = connect_db()?;\n    let cutoff = chrono::Local::now() - chrono::Duration::weeks(weeks);\n    let cutoff_timestamp = cutoff.timestamp();\n\n    let mut stmt = conn\n        .prepare(\n            \"SELECT strftime('%Y-W%W', datetime(timestamp, 'unixepoch', 'localtime')) as week_bucket,\n                SUM(input_tokens) as input, \n                SUM(output_tokens) as output,\n                SUM(total_tokens) as total,\n                COUNT(*) as count\n         FROM token_usage \n         WHERE timestamp >= ?1\n         GROUP BY week_bucket\n         ORDER BY week_bucket ASC\",\n        )\n        .map_err(|e| e.to_string())?;\n\n    let rows = stmt\n        .query_map([cutoff_timestamp], |row| {\n            Ok(TokenStatsAggregated {\n                period: row.get(0)?,\n                total_input_tokens: row.get(1)?,\n                total_output_tokens: row.get(2)?,\n                total_tokens: row.get(3)?,\n                request_count: row.get(4)?,\n            })\n        })\n        .map_err(|e| e.to_string())?;\n\n    let mut result = Vec::new();\n    for row in rows {\n        result.push(row.map_err(|e| e.to_string())?);\n    }\n    Ok(result)\n}\n\n/// Get per-account statistics for a time range\npub fn get_account_stats(hours: i64) -> Result<Vec<AccountTokenStats>, String> {\n    let conn = connect_db()?;\n    let cutoff = chrono::Local::now() - chrono::Duration::hours(hours);\n    let cutoff_bucket = cutoff.format(\"%Y-%m-%d %H:00\").to_string();\n\n    let mut stmt = conn\n        .prepare(\n            \"SELECT account_email,\n                SUM(total_input_tokens) as input, \n                SUM(total_output_tokens) as output,\n                SUM(total_tokens) as total,\n                SUM(request_count) as count\n         FROM token_stats_hourly \n         WHERE hour_bucket >= ?1\n         GROUP BY account_email\n         ORDER BY total DESC\",\n        )\n        .map_err(|e| e.to_string())?;\n\n    let rows = stmt\n        .query_map([cutoff_bucket], |row| {\n            Ok(AccountTokenStats {\n                account_email: row.get(0)?,\n                total_input_tokens: row.get(1)?,\n                total_output_tokens: row.get(2)?,\n                total_tokens: row.get(3)?,\n                request_count: row.get(4)?,\n            })\n        })\n        .map_err(|e| e.to_string())?;\n\n    let mut result = Vec::new();\n    for row in rows {\n        result.push(row.map_err(|e| e.to_string())?);\n    }\n    Ok(result)\n}\n\n/// Get summary statistics for a time range\npub fn get_summary_stats(hours: i64) -> Result<TokenStatsSummary, String> {\n    let conn = connect_db()?;\n    let cutoff = chrono::Local::now() - chrono::Duration::hours(hours);\n    let cutoff_bucket = cutoff.format(\"%Y-%m-%d %H:00\").to_string();\n\n    let (total_input, total_output, total, requests): (u64, u64, u64, u64) = conn\n        .query_row(\n            \"SELECT COALESCE(SUM(total_input_tokens), 0),\n                COALESCE(SUM(total_output_tokens), 0),\n                COALESCE(SUM(total_tokens), 0),\n                COALESCE(SUM(request_count), 0)\n         FROM token_stats_hourly \n         WHERE hour_bucket >= ?1\",\n            [&cutoff_bucket],\n            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),\n        )\n        .map_err(|e| e.to_string())?;\n\n    let unique_accounts: u64 = conn\n        .query_row(\n            \"SELECT COUNT(DISTINCT account_email) FROM token_stats_hourly WHERE hour_bucket >= ?1\",\n            [&cutoff_bucket],\n            |row| row.get(0),\n        )\n        .map_err(|e| e.to_string())?;\n\n    Ok(TokenStatsSummary {\n        total_input_tokens: total_input,\n        total_output_tokens: total_output,\n        total_tokens: total,\n        total_requests: requests,\n        unique_accounts,\n    })\n}\n\npub fn get_model_stats(hours: i64) -> Result<Vec<ModelTokenStats>, String> {\n    let conn = connect_db()?;\n    let cutoff = chrono::Local::now().timestamp() - (hours * 3600);\n\n    let mut stmt = conn\n        .prepare(\n            \"SELECT model,\n                SUM(input_tokens) as input,\n                SUM(output_tokens) as output,\n                SUM(total_tokens) as total,\n                COUNT(*) as count\n         FROM token_usage\n         WHERE timestamp >= ?1\n         GROUP BY model\n         ORDER BY total DESC\",\n        )\n        .map_err(|e| e.to_string())?;\n\n    let rows = stmt\n        .query_map([cutoff], |row| {\n            Ok(ModelTokenStats {\n                model: row.get(0)?,\n                total_input_tokens: row.get(1)?,\n                total_output_tokens: row.get(2)?,\n                total_tokens: row.get(3)?,\n                request_count: row.get(4)?,\n            })\n        })\n        .map_err(|e| e.to_string())?;\n\n    let mut result = Vec::new();\n    for row in rows {\n        result.push(row.map_err(|e| e.to_string())?);\n    }\n    Ok(result)\n}\n\npub fn get_model_trend_hourly(hours: i64) -> Result<Vec<ModelTrendPoint>, String> {\n    let conn = connect_db()?;\n    let cutoff = chrono::Local::now().timestamp() - (hours * 3600);\n\n    let mut stmt = conn\n        .prepare(\n            \"SELECT strftime('%Y-%m-%d %H:00', datetime(timestamp, 'unixepoch', 'localtime')) as hour_bucket,\n                model,\n                SUM(total_tokens) as total\n         FROM token_usage\n         WHERE timestamp >= ?1\n         GROUP BY hour_bucket, model\n         ORDER BY hour_bucket ASC\",\n        )\n        .map_err(|e| e.to_string())?;\n\n    let mut trend_map: std::collections::BTreeMap<String, std::collections::HashMap<String, u64>> =\n        std::collections::BTreeMap::new();\n\n    let rows = stmt\n        .query_map([cutoff], |row| {\n            Ok((\n                row.get::<_, String>(0)?,\n                row.get::<_, String>(1)?,\n                row.get::<_, u64>(2)?,\n            ))\n        })\n        .map_err(|e| e.to_string())?;\n\n    for row in rows {\n        let (period, model, total) = row.map_err(|e| e.to_string())?;\n        trend_map.entry(period).or_default().insert(model, total);\n    }\n\n    Ok(trend_map\n        .into_iter()\n        .map(|(period, model_data)| ModelTrendPoint { period, model_data })\n        .collect())\n}\n\npub fn get_model_trend_daily(days: i64) -> Result<Vec<ModelTrendPoint>, String> {\n    let conn = connect_db()?;\n    let cutoff = chrono::Local::now().timestamp() - (days * 24 * 3600);\n\n    let mut stmt = conn\n        .prepare(\n            \"SELECT strftime('%Y-%m-%d', datetime(timestamp, 'unixepoch', 'localtime')) as day_bucket,\n                model,\n                SUM(total_tokens) as total\n         FROM token_usage\n         WHERE timestamp >= ?1\n         GROUP BY day_bucket, model\n         ORDER BY day_bucket ASC\",\n        )\n        .map_err(|e| e.to_string())?;\n\n    let mut trend_map: std::collections::BTreeMap<String, std::collections::HashMap<String, u64>> =\n        std::collections::BTreeMap::new();\n\n    let rows = stmt\n        .query_map([cutoff], |row| {\n            Ok((\n                row.get::<_, String>(0)?,\n                row.get::<_, String>(1)?,\n                row.get::<_, u64>(2)?,\n            ))\n        })\n        .map_err(|e| e.to_string())?;\n\n    for row in rows {\n        let (period, model, total) = row.map_err(|e| e.to_string())?;\n        trend_map.entry(period).or_default().insert(model, total);\n    }\n\n    Ok(trend_map\n        .into_iter()\n        .map(|(period, model_data)| ModelTrendPoint { period, model_data })\n        .collect())\n}\n\npub fn get_account_trend_hourly(hours: i64) -> Result<Vec<AccountTrendPoint>, String> {\n    let conn = connect_db()?;\n    let cutoff = chrono::Local::now().timestamp() - (hours * 3600);\n\n    let mut stmt = conn\n        .prepare(\n            \"SELECT strftime('%Y-%m-%d %H:00', datetime(timestamp, 'unixepoch', 'localtime')) as hour_bucket,\n                account_email,\n                SUM(total_tokens) as total\n         FROM token_usage\n         WHERE timestamp >= ?1\n         GROUP BY hour_bucket, account_email\n         ORDER BY hour_bucket ASC\",\n        )\n        .map_err(|e| e.to_string())?;\n\n    let mut trend_map: std::collections::BTreeMap<String, std::collections::HashMap<String, u64>> =\n        std::collections::BTreeMap::new();\n\n    let rows = stmt\n        .query_map([cutoff], |row| {\n            Ok((\n                row.get::<_, String>(0)?,\n                row.get::<_, String>(1)?,\n                row.get::<_, u64>(2)?,\n            ))\n        })\n        .map_err(|e| e.to_string())?;\n\n    for row in rows {\n        let (period, account, total) = row.map_err(|e| e.to_string())?;\n        trend_map.entry(period).or_default().insert(account, total);\n    }\n\n    Ok(trend_map\n        .into_iter()\n        .map(|(period, account_data)| AccountTrendPoint {\n            period,\n            account_data,\n        })\n        .collect())\n}\n\npub fn get_account_trend_daily(days: i64) -> Result<Vec<AccountTrendPoint>, String> {\n    let conn = connect_db()?;\n    let cutoff = chrono::Local::now().timestamp() - (days * 24 * 3600);\n\n    let mut stmt = conn\n        .prepare(\n            \"SELECT strftime('%Y-%m-%d', datetime(timestamp, 'unixepoch', 'localtime')) as day_bucket,\n                account_email,\n                SUM(total_tokens) as total\n         FROM token_usage\n         WHERE timestamp >= ?1\n         GROUP BY day_bucket, account_email\n         ORDER BY day_bucket ASC\",\n        )\n        .map_err(|e| e.to_string())?;\n\n    let mut trend_map: std::collections::BTreeMap<String, std::collections::HashMap<String, u64>> =\n        std::collections::BTreeMap::new();\n\n    let rows = stmt\n        .query_map([cutoff], |row| {\n            Ok((\n                row.get::<_, String>(0)?,\n                row.get::<_, String>(1)?,\n                row.get::<_, u64>(2)?,\n            ))\n        })\n        .map_err(|e| e.to_string())?;\n\n    for row in rows {\n        let (period, account, total) = row.map_err(|e| e.to_string())?;\n        trend_map.entry(period).or_default().insert(account, total);\n    }\n\n    Ok(trend_map\n        .into_iter()\n        .map(|(period, account_data)| AccountTrendPoint {\n            period,\n            account_data,\n        })\n        .collect())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_record_and_query() {\n        // This would need a test database setup\n        // For now, just verify the module compiles\n        assert!(true);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/modules/tray.rs",
    "content": "use tauri::{\n    image::Image,\n    menu::{Menu, MenuItem, PredefinedMenuItem},\n    tray::{MouseButton, TrayIconBuilder, TrayIconEvent},\n    Manager, Emitter, Listener,\n};\nuse crate::modules;\n\npub fn create_tray(app: &tauri::AppHandle) -> tauri::Result<()> {\n    // 1. Load config to get language settings\n    let config = modules::load_app_config().unwrap_or_default();\n    let texts = modules::i18n::get_tray_texts(&config.language);\n    \n    // 2. Load icon (macOS uses Template Image)\n    let icon_bytes = include_bytes!(\"../../icons/tray-icon.png\");\n    let img = image::load_from_memory(icon_bytes)\n        .map_err(|e| tauri::Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())))?\n        .to_rgba8();\n    let (width, height) = img.dimensions();\n    let icon = Image::new_owned(img.into_raw(), width, height);\n\n    // 3. Define menu items (using translated texts)\n    // Status area\n    let loading_text = format!(\"{}: ...\", texts.current);\n    let quota_text = format!(\"{}: --\", texts.quota);\n    let info_user = MenuItem::with_id(app, \"info_user\", &loading_text, false, None::<&str>)?;\n    let info_quota = MenuItem::with_id(app, \"info_quota\", &quota_text, false, None::<&str>)?;\n\n    // Quick actions area\n    let switch_next = MenuItem::with_id(app, \"switch_next\", &texts.switch_next, true, None::<&str>)?;\n    let refresh_curr = MenuItem::with_id(app, \"refresh_curr\", &texts.refresh_current, true, None::<&str>)?;\n    \n    // System functions\n    let show_i = MenuItem::with_id(app, \"show\", &texts.show_window, true, None::<&str>)?;\n    let quit_i = MenuItem::with_id(app, \"quit\", &texts.quit, true, None::<&str>)?;\n    \n    let sep1 = PredefinedMenuItem::separator(app)?;\n    let sep2 = PredefinedMenuItem::separator(app)?;\n    let sep3 = PredefinedMenuItem::separator(app)?;\n\n    // 4. Build menu\n    let menu = Menu::with_items(app, &[\n        &info_user,\n        &info_quota,\n        &sep1,\n        &switch_next,\n        &refresh_curr,\n        &sep2,\n        &show_i,\n        &sep3,\n        &quit_i,\n    ])?;\n\n    // 5. Build tray icon\n    let _ = TrayIconBuilder::with_id(\"main\")\n        .menu(&menu)\n        .show_menu_on_left_click(false)\n        .icon(icon)\n        .on_menu_event(move |app, event| {\n            let app_handle = app.clone();\n            match event.id().as_ref() {\n                \"show\" => {\n                    if let Some(window) = app.get_webview_window(\"main\") {\n                        let _ = window.show();\n                        let _ = window.set_focus();\n                        #[cfg(target_os = \"macos\")]\n                        app.set_activation_policy(tauri::ActivationPolicy::Regular).unwrap_or(());\n                    }\n                }\n                \"quit\" => {\n                    // 先停止 Admin Server，避免僵尸 socket\n                    let state = app.state::<crate::commands::proxy::ProxyServiceState>();\n                    let admin_server = state.admin_server.clone();\n                    tauri::async_runtime::spawn(async move {\n                        let mut lock = admin_server.write().await;\n                        if let Some(admin) = lock.take() {\n                            admin.axum_server.stop();\n                            tokio::time::sleep(std::time::Duration::from_millis(100)).await;\n                        }\n                    });\n                    // 給一點時間讓 socket 關閉\n                    std::thread::sleep(std::time::Duration::from_millis(200));\n                    app.exit(0);\n                }\n                \"refresh_curr\" => {\n                    // Execute refresh asynchronously\n                    tauri::async_runtime::spawn(async move {\n                        if let Ok(Some(account_id)) = modules::get_current_account_id() {\n                             // Notify frontend to start\n                             let _ = app_handle.emit(\"tray://refresh-current\", ());\n                             \n                             // Execute refresh logic\n                             if let Ok(mut account) = modules::load_account(&account_id) {\n                                 // Use shared logic from modules::account\n                                 match modules::account::fetch_quota_with_retry(&mut account).await {\n                                     Ok(quota) => {\n                                         // Save\n                                         let _ = modules::update_account_quota(&account.id, quota);\n                                         // Update tray display\n                                         update_tray_menus(&app_handle);\n                                     },\n                                     Err(e) => {\n                                         // Error handling, log only\n                                          modules::logger::log_error(&format!(\"Tray refresh failed: {}\", e));\n                                     }\n                                 }\n                             }\n                        }\n                    });\n                }\n                \"switch_next\" => {\n                    tauri::async_runtime::spawn(async move {\n                         // 1. Get all accounts\n                         if let Ok(accounts) = modules::list_accounts() {\n                             if accounts.is_empty() { return; }\n                             \n                             let current_id = modules::get_current_account_id().unwrap_or(None);\n                             let next_account = if let Some(curr) = current_id {\n                                 let idx = accounts.iter().position(|a| a.id == curr).unwrap_or(0);\n                                 let next_idx = (idx + 1) % accounts.len();\n                                 &accounts[next_idx]\n                             } else {\n                                 &accounts[0]\n                             };\n                             \n                             // 2. Switch\n                             let integration = crate::modules::integration::DesktopIntegration {\n                                 app_handle: app_handle.clone(),\n                             };\n                             if let Ok(_) = modules::switch_account(&next_account.id, &integration).await {\n                                 // 3. Notify frontend\n                                 let _ = app_handle.emit(\"tray://account-switched\", next_account.id.clone());\n                                 // 4. Update tray\n                                 update_tray_menus(&app_handle);\n                             }\n                         }\n                    });\n                }\n                _ => {}\n            }\n        })\n        .on_tray_icon_event(|tray, event| {\n            if let TrayIconEvent::Click {\n                button: MouseButton::Left,\n                ..\n            } = event\n            {\n               let app = tray.app_handle();\n               if let Some(window) = app.get_webview_window(\"main\") {\n                   let _ = window.show();\n                   let _ = window.set_focus();\n                   #[cfg(target_os = \"macos\")]\n                   app.set_activation_policy(tauri::ActivationPolicy::Regular).unwrap_or(());\n               }\n            }\n        })\n        .build(app)?;\n\n    // Update status once on initialization\n    let handle = app.clone();\n    tauri::async_runtime::spawn(async move {\n        update_tray_menus(&handle);\n    });\n\n    // Listen for config update events\n    let handle = app.clone();\n    app.listen(\"config://updated\", move |_event| {\n        modules::logger::log_info(\"Configuration updated, refreshing tray menu\");\n        update_tray_menus(&handle);\n    });\n\n    Ok(())\n}\n\n/// Helper function to update tray menu\npub fn update_tray_menus(app: &tauri::AppHandle) {\n    let app_clone = app.clone();\n    tauri::async_runtime::spawn(async move {\n         // Read config to get language\n         let config = modules::load_app_config().unwrap_or_default();\n         let texts = modules::i18n::get_tray_texts(&config.language);\n         \n         // Get current account info\n         let current = modules::get_current_account_id().unwrap_or(None);\n         \n         let mut menu_lines = Vec::new();\n         let mut user_text = format!(\"{}: {}\", texts.current, texts.no_account);\n\n         if let Some(id) = current {\n             if let Ok(account) = modules::load_account(&id) {\n                 user_text = format!(\"{}: {}\", texts.current, account.email);\n                 \n                 if let Some(q) = account.quota {\n                     if q.is_forbidden {\n                         menu_lines.push(format!(\"🚫 {}\", texts.forbidden));\n                     } else {\n                         // Extract the 3 specified models\n                         let mut gemini_high = 0;\n                         let mut gemini_image = 0;\n                         let mut claude = 0;\n                         \n                         // Use strict matching, consistent with frontend\n                         for m in q.models {\n                             let name = m.name.to_lowercase();\n                             if name == \"gemini-3.1-pro-high\" || name == \"gemini-3-pro-high\" { gemini_high = m.percentage; }\n                             if name == \"gemini-3-pro-image\" { gemini_image = m.percentage; }\n                             if name == \"claude-sonnet-4-6\" || name == \"claude-sonnet-4-5\" { claude = m.percentage; }\n                         }\n                         \n                         menu_lines.push(format!(\"Gemini High: {}%\", gemini_high));\n                         menu_lines.push(format!(\"Gemini Image: {}%\", gemini_image));\n                         menu_lines.push(format!(\"Claude 4.5: {}%\", claude));\n                     }\n                 } else {\n                     menu_lines.push(texts.unknown_quota.clone());\n                 }\n             } else {\n                 user_text = format!(\"{}: Error\", texts.current);\n                 menu_lines.push(format!(\"{}: --\", texts.quota));\n             }\n         } else {\n             menu_lines.push(texts.unknown_quota.clone());\n         };\n\n         // Rebuild menu items\n         let info_user = MenuItem::with_id(&app_clone, \"info_user\", &user_text, false, None::<&str>);\n         \n         // Dynamically create quota items\n         let mut quota_items = Vec::new();\n         for (i, line) in menu_lines.iter().enumerate() {\n             let item = MenuItem::with_id(&app_clone, format!(\"info_quota_{}\", i), line, false, None::<&str>);\n             if let Ok(item) = item {\n                 quota_items.push(item);\n             }\n         }\n         \n         let switch_next = MenuItem::with_id(&app_clone, \"switch_next\", &texts.switch_next, true, None::<&str>);\n         let refresh_curr = MenuItem::with_id(&app_clone, \"refresh_curr\", &texts.refresh_current, true, None::<&str>);\n         \n         let show_i = MenuItem::with_id(&app_clone, \"show\", &texts.show_window, true, None::<&str>);\n         let quit_i = MenuItem::with_id(&app_clone, \"quit\", &texts.quit, true, None::<&str>);\n         \n         if let (Ok(i_u), Ok(s_n), Ok(r_c), Ok(s), Ok(q)) = (info_user, switch_next, refresh_curr, show_i, quit_i) {\n             let sep1 = PredefinedMenuItem::separator(&app_clone).ok();\n             let sep2 = PredefinedMenuItem::separator(&app_clone).ok();\n             let sep3 = PredefinedMenuItem::separator(&app_clone).ok();\n             \n             let mut items: Vec<&dyn tauri::menu::IsMenuItem<tauri::Wry>> = vec![&i_u];\n             // Add dynamic quota items\n             for item in &quota_items {\n                 items.push(item);\n             }\n             \n             if let Some(ref s) = sep1 { items.push(s); }\n             items.push(&s_n);\n             items.push(&r_c);\n             if let Some(ref s) = sep2 { items.push(s); }\n             items.push(&s);\n             if let Some(ref s) = sep3 { items.push(s); }\n             items.push(&q);\n             \n             if let Ok(menu) = Menu::with_items(&app_clone, &items) {\n                 if let Some(tray) = app_clone.tray_by_id(\"main\") {\n                     let _ = tray.set_menu(Some(menu));\n                 }\n             }\n         }\n    });\n}\n"
  },
  {
    "path": "src-tauri/src/modules/update_checker.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse std::time::{SystemTime, UNIX_EPOCH};\nuse crate::modules::logger;\nuse chrono::Utc;\n\nconst GITHUB_API_URL: &str = \"https://api.github.com/repos/lbjlaq/Antigravity-Manager/releases/latest\";\nconst GITHUB_RAW_URL: &str = \"https://raw.githubusercontent.com/lbjlaq/Antigravity-Manager/main/package.json\";\nconst JSDELIVR_URL: &str = \"https://cdn.jsdelivr.net/gh/lbjlaq/Antigravity-Manager@main/package.json\";\nconst CURRENT_VERSION: &str = env!(\"CARGO_PKG_VERSION\");\nconst DEFAULT_CHECK_INTERVAL_HOURS: u64 = 24;\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdateInfo {\n    pub current_version: String,\n    pub latest_version: String,\n    pub has_update: bool,\n    pub download_url: String, // previously release_url\n    pub release_notes: String,\n    pub published_at: String,\n    #[serde(default)]\n    pub source: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdateSettings {\n    pub auto_check: bool,\n    pub last_check_time: u64,\n    #[serde(default = \"default_check_interval\")]\n    pub check_interval_hours: u64,\n}\n\nfn default_check_interval() -> u64 {\n    DEFAULT_CHECK_INTERVAL_HOURS\n}\n\nimpl Default for UpdateSettings {\n    fn default() -> Self {\n        Self {\n            auto_check: true,\n            last_check_time: 0,\n            check_interval_hours: DEFAULT_CHECK_INTERVAL_HOURS,\n        }\n    }\n}\n\n#[derive(Debug, Deserialize)]\nstruct GitHubRelease {\n    tag_name: String,\n    html_url: String,\n    body: String,\n    published_at: String,\n}\n\nconst UPDATER_JSON_URL: &str = \"https://github.com/lbjlaq/Antigravity-Manager/releases/latest/download/updater.json\";\n\n/// Check for updates with improved strategy:\n/// 1. Check updater.json (Source of Truth for Auto-Update)\n/// 2. Fallback to GitHub API (Informational)\npub async fn check_for_updates() -> Result<UpdateInfo, String> {\n    // 1. Try updater.json first (Critical for functional Auto-Update)\n    match check_updater_json().await {\n        Ok(info) => return Ok(info),\n        Err(e) => {\n            logger::log_warn(&format!(\"updater.json check failed: {}. This might mean artifacts are not ready yet.\", e));\n            // Don't return error immediately, try fallbacks for at least informational update\n        }\n    }\n\n    // 2. Try GitHub API\n    match check_github_api().await {\n        Ok(info) => {\n            // If we found an update via API but updater.json failed, we should probably warn or\n            // implies that auto-update won't work yet.\n            // However, the user wants \"auto-update to work\". If we show \"Update Available\" based on API\n            // but updater.json is missing, the \"Auto Update\" button will fail.\n            // So, ideally, if we are in this block, we should perhaps mark it as \"Manual Download Only\" or similar?\n            // For now, we return it, but maybe the frontend handles \"not ready\".\n            // Actually, based on User Request, \"Update Available\" shouldn't show if it's not ready.\n            // But if we return Ok(info) here, the frontend SHOWS it.\n            // If updater.json failed, it likely means the asset isn't uploaded.\n            // So we should maybe return Ok(info) with has_update=false if checking updater.json failed?\n            // Or just log it. \n            // Let's stick to the plan: Prioritize updater.json. If that fails, we fallback.\n            // Use the fallback but maybe the user will see \"Auto update failed\" and use manual.\n            return Ok(info);\n        },\n        Err(e) => {\n            logger::log_warn(&format!(\"GitHub API check failed: {}. Trying fallbacks...\", e));\n        }\n    }\n\n    // 3. Try GitHub Raw\n    match check_static_url(GITHUB_RAW_URL, \"GitHub Raw\").await {\n        Ok(info) => return Ok(info),\n        Err(e) => {\n            logger::log_warn(&format!(\"GitHub Raw check failed: {}. Trying next fallback...\", e));\n        }\n    }\n\n    // 4. Try jsDelivr\n    match check_static_url(JSDELIVR_URL, \"jsDelivr\").await {\n        Ok(info) => return Ok(info),\n        Err(e) => {\n            logger::log_error(&format!(\"All update checks failed. Last error: {}\", e));\n            return Err(e);\n        }\n    }\n}\n\n#[derive(Debug, Deserialize)]\nstruct UpdaterJson {\n    version: String,\n    notes: Option<String>,\n    pub_date: Option<String>,\n}\n\nasync fn check_updater_json() -> Result<UpdateInfo, String> {\n    let client = create_client().await?;\n    logger::log_info(\"Checking for updates via updater.json...\");\n\n    let response = client\n        .get(UPDATER_JSON_URL)\n        .send()\n        .await\n        .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n    if !response.status().is_success() {\n        return Err(format!(\"updater.json returned status: {}\", response.status()));\n    }\n\n    let updater_info: UpdaterJson = response\n        .json()\n        .await\n        .map_err(|e| format!(\"Failed to parse updater.json: {}\", e))?;\n\n    let latest_version = updater_info.version.trim_start_matches('v').to_string();\n    let current_version = CURRENT_VERSION.to_string();\n    let has_update = compare_versions(&latest_version, &current_version);\n\n    if has_update {\n        logger::log_info(&format!(\"New version found (updater.json): {} (Current: {})\", latest_version, current_version));\n    } else {\n        logger::log_info(&format!(\"Up to date (updater.json): {} (Matches {})\", current_version, latest_version));\n    }\n\n    let download_url = format!(\"https://github.com/lbjlaq/Antigravity-Manager/releases/tag/v{}\", latest_version);\n\n    Ok(UpdateInfo {\n        current_version,\n        latest_version,\n        has_update,\n        download_url,\n        release_notes: updater_info.notes.unwrap_or_else(|| \"Release notes available on GitHub.\".to_string()),\n        published_at: updater_info.pub_date.unwrap_or_else(|| Utc::now().to_rfc3339()),\n        source: Some(\"updater.json\".to_string()),\n    })\n}\n\nasync fn create_client() -> Result<reqwest::Client, String> {\n    let mut builder = reqwest::Client::builder()\n        .user_agent(\"Antigravity-Manager\")\n        .timeout(std::time::Duration::from_secs(10));\n\n    // Load config to check for upstream proxy\n    if let Ok(config) = crate::modules::config::load_app_config() {\n        if config.proxy.upstream_proxy.enabled && !config.proxy.upstream_proxy.url.is_empty() {\n            logger::log_info(&format!(\"Update checker using upstream proxy: {}\", config.proxy.upstream_proxy.url));\n            match reqwest::Proxy::all(&config.proxy.upstream_proxy.url) {\n                Ok(proxy) => {\n                    builder = builder.proxy(proxy);\n                },\n                Err(e) => {\n                    logger::log_warn(&format!(\"Failed to parse proxy URL '{}': {}\", config.proxy.upstream_proxy.url, e));\n                }\n            }\n        }\n    }\n\n    builder.build().map_err(|e| format!(\"Failed to create HTTP client: {}\", e))\n}\n\nasync fn check_github_api() -> Result<UpdateInfo, String> {\n    let client = create_client().await?;\n\n    logger::log_info(\"Checking for updates via GitHub API...\");\n\n    let response = client\n        .get(GITHUB_API_URL)\n        .send()\n        .await\n        .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n    if !response.status().is_success() {\n        return Err(format!(\"GitHub API returned status: {}\", response.status()));\n    }\n\n    let release: GitHubRelease = response\n        .json()\n        .await\n        .map_err(|e| format!(\"Failed to parse release info: {}\", e))?;\n\n    let latest_version = release.tag_name.trim_start_matches('v').to_string();\n    let current_version = CURRENT_VERSION.to_string();\n    let has_update = compare_versions(&latest_version, &current_version);\n\n    if has_update {\n        logger::log_info(&format!(\"New version found (API): {} (Current: {})\", latest_version, current_version));\n    } else {\n        logger::log_info(&format!(\"Up to date (API): {} (Matches {})\", current_version, latest_version));\n    }\n\n    Ok(UpdateInfo {\n        current_version,\n        latest_version,\n        has_update,\n        download_url: release.html_url,\n        release_notes: release.body,\n        published_at: release.published_at,\n        source: Some(\"GitHub API\".to_string()),\n    })\n}\n\n#[derive(Deserialize)]\nstruct PackageJson {\n    version: String,\n}\n\nasync fn check_static_url(url: &str, source_name: &str) -> Result<UpdateInfo, String> {\n    let client = create_client().await?;\n\n    logger::log_info(&format!(\"Checking for updates via {}...\", source_name));\n\n    let response = client\n        .get(url)\n        .send()\n        .await\n        .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n    if !response.status().is_success() {\n        return Err(format!(\"{} returned status: {}\", source_name, response.status()));\n    }\n\n    let package_json: PackageJson = response\n        .json()\n        .await\n        .map_err(|e| format!(\"Failed to parse package.json: {}\", e))?;\n\n    let latest_version = package_json.version;\n    let current_version = CURRENT_VERSION.to_string();\n    let has_update = compare_versions(&latest_version, &current_version);\n\n    if has_update {\n        logger::log_info(&format!(\"New version found ({}): {} (Current: {})\", source_name, latest_version, current_version));\n    } else {\n        logger::log_info(&format!(\"Up to date ({}): {} (Matches {})\", source_name, current_version, latest_version));\n    }\n\n    // fallback sources generally don't provide release notes or download specific URL, construct generic\n    let download_url = \"https://github.com/lbjlaq/Antigravity-Manager/releases/latest\".to_string();\n    let release_notes = format!(\"New version detected via {}. Please check release page for details.\", source_name);\n\n    Ok(UpdateInfo {\n        current_version,\n        latest_version,\n        has_update,\n        download_url,\n        release_notes,\n        published_at: Utc::now().to_rfc3339(), // Approximate time\n        source: Some(source_name.to_string()),\n    })\n}\n\n/// Compare two semantic versions (e.g., \"3.3.30\" vs \"3.3.29\")\nfn compare_versions(latest: &str, current: &str) -> bool {\n    let parse_version = |v: &str| -> Vec<u32> {\n        v.split('.')\n            .filter_map(|s| s.parse::<u32>().ok())\n            .collect()\n    };\n\n    let latest_parts = parse_version(latest);\n    let current_parts = parse_version(current);\n\n    for i in 0..latest_parts.len().max(current_parts.len()) {\n        let latest_part = latest_parts.get(i).unwrap_or(&0);\n        let current_part = current_parts.get(i).unwrap_or(&0);\n\n        if latest_part > current_part {\n            return true;\n        } else if latest_part < current_part {\n            return false; // e.g. local: 3.3.30, remote: 3.3.30 => false\n        }\n    }\n\n    false\n}\n\n/// Check if enough time has passed since last check\npub fn should_check_for_updates(settings: &UpdateSettings) -> bool {\n    if !settings.auto_check {\n        return false;\n    }\n\n    let now = SystemTime::now()\n        .duration_since(UNIX_EPOCH)\n        .unwrap()\n        .as_secs();\n\n    let elapsed_hours = (now - settings.last_check_time) / 3600;\n    let interval = if settings.check_interval_hours > 0 {\n        settings.check_interval_hours\n    } else {\n        DEFAULT_CHECK_INTERVAL_HOURS\n    };\n    elapsed_hours >= interval\n}\n\n/// Load update settings from config file\npub fn load_update_settings() -> Result<UpdateSettings, String> {\n    let data_dir = crate::modules::account::get_data_dir()\n        .map_err(|e| format!(\"Failed to get data dir: {}\", e))?;\n    let settings_path = data_dir.join(\"update_settings.json\");\n\n    if !settings_path.exists() {\n        return Ok(UpdateSettings::default());\n    }\n\n    let content = std::fs::read_to_string(&settings_path)\n        .map_err(|e| format!(\"Failed to read settings file: {}\", e))?;\n\n    serde_json::from_str(&content)\n        .map_err(|e| format!(\"Failed to parse settings: {}\", e))\n}\n\n/// Save update settings to config file\npub fn save_update_settings(settings: &UpdateSettings) -> Result<(), String> {\n    let data_dir = crate::modules::account::get_data_dir()\n        .map_err(|e| format!(\"Failed to get data dir: {}\", e))?;\n    let settings_path = data_dir.join(\"update_settings.json\");\n\n    let content = serde_json::to_string_pretty(settings)\n        .map_err(|e| format!(\"Failed to serialize settings: {}\", e))?;\n\n    std::fs::write(&settings_path, content)\n        .map_err(|e| format!(\"Failed to write settings file: {}\", e))\n}\n\n/// Update last check time\npub fn update_last_check_time() -> Result<(), String> {\n    let mut settings = load_update_settings()?;\n    settings.last_check_time = SystemTime::now()\n        .duration_since(UNIX_EPOCH)\n        .unwrap()\n        .as_secs();\n    save_update_settings(&settings)\n}\n\n/// Detect if the app was installed via Homebrew Cask (macOS only)\npub fn is_homebrew_installed() -> bool {\n    #[cfg(target_os = \"macos\")]\n    {\n        let caskroom_paths = [\n            \"/opt/homebrew/Caskroom/antigravity-tools\",\n            \"/usr/local/Caskroom/antigravity-tools\",\n        ];\n\n        for path in &caskroom_paths {\n            if std::path::Path::new(path).exists() {\n                logger::log_info(&format!(\"Detected Homebrew Cask installation at: {}\", path));\n                return true;\n            }\n        }\n    }\n\n    false\n}\n\n/// Execute `brew upgrade --cask antigravity-tools` with timeout (macOS only)\n#[cfg(not(target_os = \"macos\"))]\npub async fn brew_upgrade_cask() -> Result<String, String> {\n    Err(\"brew_not_supported\".to_string())\n}\n\n#[cfg(target_os = \"macos\")]\npub async fn brew_upgrade_cask() -> Result<String, String> {\n    logger::log_info(\"Starting Homebrew Cask upgrade for antigravity-tools...\");\n\n    // Find brew binary\n    let brew_path = if std::path::Path::new(\"/opt/homebrew/bin/brew\").exists() {\n        \"/opt/homebrew/bin/brew\"\n    } else if std::path::Path::new(\"/usr/local/bin/brew\").exists() {\n        \"/usr/local/bin/brew\"\n    } else {\n        return Err(\"brew_not_found\".to_string());\n    };\n\n    // 3 min timeout to prevent hanging\n    let result = tokio::time::timeout(\n        std::time::Duration::from_secs(180),\n        tokio::process::Command::new(brew_path)\n            .args([\"upgrade\", \"--cask\", \"antigravity-tools\"])\n            .output()\n    ).await;\n\n    let output = match result {\n        Ok(Ok(output)) => output,\n        Ok(Err(e)) => {\n            logger::log_error(&format!(\"Failed to execute brew upgrade: {}\", e));\n            return Err(\"brew_exec_failed\".to_string());\n        }\n        Err(_) => {\n            logger::log_error(\"Homebrew upgrade timed out after 3 minutes\");\n            return Err(\"brew_timeout\".to_string());\n        }\n    };\n\n    let stdout = String::from_utf8_lossy(&output.stdout).to_string();\n    let stderr = String::from_utf8_lossy(&output.stderr).to_string();\n\n    if output.status.success() {\n        logger::log_info(&format!(\"Homebrew upgrade succeeded: {}\", stdout));\n        Ok(stdout)\n    } else {\n        logger::log_error(&format!(\"brew upgrade failed - stdout: {} stderr: {}\", stdout, stderr));\n        // Return structured error key for frontend i18n\n        if stderr.contains(\"already installed\") || stdout.contains(\"already installed\") {\n            Err(\"brew_already_latest\".to_string())\n        } else {\n            Err(\"brew_upgrade_failed\".to_string())\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_compare_versions() {\n        assert!(compare_versions(\"3.3.36\", \"3.3.35\"));\n        assert!(compare_versions(\"3.4.0\", \"3.3.35\"));\n        assert!(compare_versions(\"4.0.3\", \"3.3.35\"));\n        assert!(!compare_versions(\"3.3.34\", \"3.3.35\"));\n        assert!(!compare_versions(\"3.3.35\", \"3.3.35\"));\n    }\n\n    #[test]\n    fn test_should_check_for_updates() {\n        let mut settings = UpdateSettings::default();\n        assert!(should_check_for_updates(&settings));\n\n        settings.last_check_time = SystemTime::now()\n            .duration_since(UNIX_EPOCH)\n            .unwrap()\n            .as_secs();\n        assert!(!should_check_for_updates(&settings));\n\n        settings.auto_check = false;\n        assert!(!should_check_for_updates(&settings));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/modules/user_token_db.rs",
    "content": "//! User Token Database Module\n//! UserToken 数据库操作模块\n\n#![allow(dead_code)]\n// 用户令牌存储，部分接口留作后续扩展\n\nuse rusqlite::{params, Connection, OptionalExtension};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\nuse uuid::Uuid;\nuse chrono::{Utc, Local, Timelike, FixedOffset};\n\n/// 用户令牌结构体\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UserToken {\n    pub id: String,\n    pub token: String,\n    pub username: String,\n    pub description: Option<String>,\n    pub enabled: bool,\n    pub expires_type: String,      // \"day\", \"week\", \"month\", \"never\"\n    pub expires_at: Option<i64>,\n    pub max_ips: i32,              // 0 = unlimited\n    pub curfew_start: Option<String>, // \"HH:MM\" 宵禁开始时间\n    pub curfew_end: Option<String>,   // \"HH:MM\" 宵禁结束时间\n    pub created_at: i64,\n    pub updated_at: i64,\n    pub last_used_at: Option<i64>,\n    pub total_requests: i64,\n    pub total_tokens_used: i64,\n}\n\n/// 令牌 IP 绑定结构体\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TokenIpBinding {\n    pub id: String,\n    pub token_id: String,\n    pub ip_address: String,\n    pub first_seen_at: i64,\n    pub last_seen_at: i64,\n    pub request_count: i64,\n    pub user_agent: Option<String>,\n}\n\n/// 令牌使用日志结构体\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TokenUsageLog {\n    pub id: String,\n    pub token_id: String,\n    pub ip_address: String,\n    pub model: String,\n    pub input_tokens: i32,\n    pub output_tokens: i32,\n    pub request_time: i64,\n    pub status: u16,\n}\n\n/// 获取数据库路径\npub fn get_db_path() -> Result<PathBuf, String> {\n    let mut path = crate::modules::account::get_data_dir()?;\n    path.push(\"user_tokens.db\");\n    Ok(path)\n}\n\n/// 连接数据库\npub fn connect_db() -> Result<Connection, String> {\n    let path = get_db_path()?;\n    let conn = Connection::open(&path)\n        .map_err(|e| format!(\"Failed to open database: {}\", e))?;\n    Ok(conn)\n}\n\n/// 初始化数据库\npub fn init_db() -> Result<(), String> {\n    let conn = connect_db()?;\n    \n    // 创建 user_tokens 表\n    conn.execute(\n        \"CREATE TABLE IF NOT EXISTS user_tokens (\n            id TEXT PRIMARY KEY,\n            token TEXT UNIQUE NOT NULL,\n            username TEXT NOT NULL,\n            description TEXT,\n            enabled BOOLEAN NOT NULL DEFAULT 1,\n            expires_type TEXT NOT NULL,\n            expires_at INTEGER,\n            max_ips INTEGER NOT NULL DEFAULT 0,\n            created_at INTEGER NOT NULL,\n            updated_at INTEGER NOT NULL,\n            last_used_at INTEGER,\n            total_requests INTEGER NOT NULL DEFAULT 0,\n            total_tokens_used INTEGER NOT NULL DEFAULT 0,\n            curfew_start TEXT,\n            curfew_end TEXT\n        )\",\n        [],\n    ).map_err(|e| format!(\"Failed to create user_tokens table: {}\", e))?;\n\n    // 尝试添加新列 (用于旧数据库迁移，忽略已存在的错误)\n    let _ = conn.execute(\"ALTER TABLE user_tokens ADD COLUMN expires_type TEXT\", []);\n    let _ = conn.execute(\"ALTER TABLE user_tokens ADD COLUMN expires_at INTEGER\", []);\n    let _ = conn.execute(\"ALTER TABLE user_tokens ADD COLUMN max_ips INTEGER DEFAULT 0\", []);\n    let _ = conn.execute(\"ALTER TABLE user_tokens ADD COLUMN total_requests INTEGER DEFAULT 0\", []);\n    let _ = conn.execute(\"ALTER TABLE user_tokens ADD COLUMN total_tokens_used INTEGER DEFAULT 0\", []);\n    let _ = conn.execute(\"ALTER TABLE user_tokens ADD COLUMN last_used_at INTEGER\", []);\n    let _ = conn.execute(\"ALTER TABLE user_tokens ADD COLUMN curfew_start TEXT\", []);\n    let _ = conn.execute(\"ALTER TABLE user_tokens ADD COLUMN curfew_end TEXT\", []);\n\n    // 创建 token_ip_bindings 表\n    conn.execute(\n        \"CREATE TABLE IF NOT EXISTS token_ip_bindings (\n            id TEXT PRIMARY KEY,\n            token_id TEXT NOT NULL,\n            ip_address TEXT NOT NULL,\n            first_seen_at INTEGER NOT NULL,\n            last_seen_at INTEGER NOT NULL,\n            request_count INTEGER NOT NULL DEFAULT 0,\n            user_agent TEXT,\n            FOREIGN KEY(token_id) REFERENCES user_tokens(id) ON DELETE CASCADE,\n            UNIQUE(token_id, ip_address)\n        )\",\n        [],\n    ).map_err(|e| format!(\"Failed to create token_ip_bindings table: {}\", e))?;\n\n    // 创建 token_usage_logs 表\n    conn.execute(\n        \"CREATE TABLE IF NOT EXISTS token_usage_logs (\n            id TEXT PRIMARY KEY,\n            token_id TEXT NOT NULL,\n            ip_address TEXT,\n            model TEXT,\n            input_tokens INTEGER,\n            output_tokens INTEGER,\n            request_time INTEGER NOT NULL,\n            status INTEGER,\n            FOREIGN KEY(token_id) REFERENCES user_tokens(id) ON DELETE CASCADE\n        )\",\n        [],\n    ).map_err(|e| format!(\"Failed to create token_usage_logs table: {}\", e))?;\n    \n    // 创建索引\n    let _ = conn.execute(\"CREATE INDEX IF NOT EXISTS idx_token_usage_logs_token_id ON token_usage_logs(token_id)\", []);\n    let _ = conn.execute(\"CREATE INDEX IF NOT EXISTS idx_token_usage_logs_request_time ON token_usage_logs(request_time)\", []);\n\n    // [FIX Issue #1719] 数据清洗：修复旧版本升级导致的 NULL 字段\n    // 这些字段在旧版本中可能不存在，ALTER TABLE 添加后默认为 NULL，导致反序列化失败\n    let _ = conn.execute(\"UPDATE user_tokens SET expires_type = 'never' WHERE expires_type IS NULL OR expires_type = ''\", []);\n    let _ = conn.execute(\"UPDATE user_tokens SET max_ips = 0 WHERE max_ips IS NULL\", []);\n    let _ = conn.execute(\"UPDATE user_tokens SET total_requests = 0 WHERE total_requests IS NULL\", []);\n    let _ = conn.execute(\"UPDATE user_tokens SET total_tokens_used = 0 WHERE total_tokens_used IS NULL\", []);\n    let _ = conn.execute(\"UPDATE user_tokens SET enabled = 1 WHERE enabled IS NULL\", []);\n\n    Ok(())\n}\n\n/// 创建新令牌\npub fn create_token(\n    username: String,\n    expires_type: String,\n    description: Option<String>,\n    max_ips: i32,\n    curfew_start: Option<String>,\n    curfew_end: Option<String>,\n    custom_expires_at: Option<i64>  // 自定义过期时间戳 (秒)\n) -> Result<UserToken, String> {\n    let conn = connect_db()?;\n    let id = Uuid::new_v4().to_string();\n    let token = format!(\"sk-{}\", Uuid::new_v4().to_string().replace(\"-\", \"\"));\n    let now = Utc::now().timestamp();\n\n    let expires_at = match expires_type.as_str() {\n        \"day\" => Some(Utc::now().checked_add_signed(chrono::Duration::days(1)).unwrap().timestamp()),\n        \"week\" => Some(Utc::now().checked_add_signed(chrono::Duration::weeks(1)).unwrap().timestamp()),\n        \"month\" => Some(Utc::now().checked_add_signed(chrono::Duration::days(30)).unwrap().timestamp()),\n        \"custom\" => custom_expires_at, // 使用自定义时间戳\n        _ => None, // \"never\" or other\n    };\n\n    let user_token = UserToken {\n        id: id.clone(),\n        token: token.clone(),\n        username: username.clone(),\n        description: description.clone(),\n        enabled: true,\n        expires_type: expires_type.clone(),\n        expires_at,\n        max_ips,\n        curfew_start: curfew_start.clone(),\n        curfew_end: curfew_end.clone(),\n        created_at: now,\n        updated_at: now,\n        last_used_at: None,\n        total_requests: 0,\n        total_tokens_used: 0,\n    };\n\n    conn.execute(\n        \"INSERT INTO user_tokens (\n            id, token, username, description, enabled, expires_type, expires_at, max_ips,\n            curfew_start, curfew_end,\n            created_at, updated_at, total_requests, total_tokens_used\n        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)\",\n        params![\n            user_token.id,\n            user_token.token,\n            user_token.username,\n            user_token.description,\n            user_token.enabled,\n            user_token.expires_type,\n            user_token.expires_at,\n            user_token.max_ips,\n            user_token.curfew_start,\n            user_token.curfew_end,\n            user_token.created_at,\n            user_token.updated_at,\n            user_token.total_requests,\n            user_token.total_tokens_used,\n        ],\n    ).map_err(|e| format!(\"Failed to insert user token: {}\", e))?;\n\n    Ok(user_token)\n}\n\n/// 列出所有令牌\npub fn list_tokens() -> Result<Vec<UserToken>, String> {\n    let conn = connect_db()?;\n    let mut stmt = conn.prepare(\"SELECT * FROM user_tokens ORDER BY created_at DESC\")\n        .map_err(|e| format!(\"Failed to prepare query: {}\", e))?;\n    \n    let token_iter = stmt.query_map([], |row| {\n        Ok(UserToken {\n            id: row.get(\"id\")?,\n            token: row.get(\"token\")?,\n            username: row.get(\"username\")?,\n            description: row.get(\"description\")?,\n            enabled: row.get(\"enabled\").unwrap_or(true), // 防御性默认值\n            expires_type: row.get(\"expires_type\").unwrap_or(\"never\".to_string()), // 防御性默认值\n            expires_at: row.get(\"expires_at\").unwrap_or(None),\n            max_ips: row.get(\"max_ips\").unwrap_or(0),\n            curfew_start: row.get(\"curfew_start\").unwrap_or(None),\n            curfew_end: row.get(\"curfew_end\").unwrap_or(None),\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            last_used_at: row.get(\"last_used_at\").unwrap_or(None),\n            total_requests: row.get(\"total_requests\").unwrap_or(0),\n            total_tokens_used: row.get(\"total_tokens_used\").unwrap_or(0),\n        })\n    }).map_err(|e| format!(\"Failed to query tokens: {}\", e))?;\n\n    let mut tokens = Vec::new();\n    for token in token_iter {\n        tokens.push(token.map_err(|e| format!(\"Failed to parse token row: {}\", e))?);\n    }\n    \n    Ok(tokens)\n}\n\n/// 获取单个令牌信息\npub fn get_token_by_id(id: &str) -> Result<Option<UserToken>, String> {\n    let conn = connect_db()?;\n    let mut stmt = conn.prepare(\"SELECT * FROM user_tokens WHERE id = ?1\")\n        .map_err(|e| format!(\"Failed to prepare query: {}\", e))?;\n    \n    let token = stmt.query_row(params![id], |row| {\n        Ok(UserToken {\n            id: row.get(\"id\")?,\n            token: row.get(\"token\")?,\n            username: row.get(\"username\")?,\n            description: row.get(\"description\")?,\n            enabled: row.get(\"enabled\")?,\n            expires_type: row.get(\"expires_type\")?,\n            expires_at: row.get(\"expires_at\")?,\n            max_ips: row.get(\"max_ips\")?,\n            curfew_start: row.get(\"curfew_start\").unwrap_or(None),\n            curfew_end: row.get(\"curfew_end\").unwrap_or(None),\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            last_used_at: row.get(\"last_used_at\")?,\n            total_requests: row.get(\"total_requests\")?,\n            total_tokens_used: row.get(\"total_tokens_used\")?,\n        })\n    }).optional().map_err(|e| format!(\"Failed to query token: {}\", e))?;\n    \n    Ok(token)\n}\n\n/// 根据 Token 值获取令牌信息\npub fn get_token_by_value(token: &str) -> Result<Option<UserToken>, String> {\n    let conn = connect_db()?;\n    let mut stmt = conn.prepare(\"SELECT * FROM user_tokens WHERE token = ?1\")\n        .map_err(|e| format!(\"Failed to prepare query: {}\", e))?;\n    \n    let token = stmt.query_row(params![token], |row| {\n        Ok(UserToken {\n            id: row.get(\"id\")?,\n            token: row.get(\"token\")?,\n            username: row.get(\"username\")?,\n            description: row.get(\"description\")?,\n            enabled: row.get(\"enabled\")?,\n            expires_type: row.get(\"expires_type\")?,\n            expires_at: row.get(\"expires_at\")?,\n            max_ips: row.get(\"max_ips\")?,\n            curfew_start: row.get(\"curfew_start\").unwrap_or(None),\n            curfew_end: row.get(\"curfew_end\").unwrap_or(None),\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            last_used_at: row.get(\"last_used_at\")?,\n            total_requests: row.get(\"total_requests\")?,\n            total_tokens_used: row.get(\"total_tokens_used\")?,\n        })\n    }).optional().map_err(|e| format!(\"Failed to query token: {}\", e))?;\n    \n    Ok(token)\n}\n\n/// 更新令牌状态/备注等\npub fn update_token(\n    id: &str,\n    username: Option<String>,\n    description: Option<String>,\n    enabled: Option<bool>,\n    max_ips: Option<i32>,\n    curfew_start: Option<Option<String>>,\n    curfew_end: Option<Option<String>>\n) -> Result<(), String> {\n    let conn = connect_db()?;\n    let now = Utc::now().timestamp();\n\n    let mut query = \"UPDATE user_tokens SET updated_at = ?1\".to_string();\n    let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(now)];\n    let mut param_idx = 2;\n\n    if let Some(user) = username {\n        query.push_str(&format!(\", username = ?{}\", param_idx));\n        params_vec.push(Box::new(user));\n        param_idx += 1;\n    }\n\n    if let Some(desc) = description {\n        query.push_str(&format!(\", description = ?{}\", param_idx));\n        params_vec.push(Box::new(desc));\n        param_idx += 1;\n    }\n\n    if let Some(en) = enabled {\n        query.push_str(&format!(\", enabled = ?{}\", param_idx));\n        params_vec.push(Box::new(en));\n        param_idx += 1;\n    }\n\n    if let Some(ips) = max_ips {\n        query.push_str(&format!(\", max_ips = ?{}\", param_idx));\n        params_vec.push(Box::new(ips));\n        param_idx += 1;\n    }\n\n    if let Some(start) = curfew_start {\n        query.push_str(&format!(\", curfew_start = ?{}\", param_idx));\n        params_vec.push(Box::new(start));\n        param_idx += 1;\n    }\n\n    if let Some(end) = curfew_end {\n        query.push_str(&format!(\", curfew_end = ?{}\", param_idx));\n        params_vec.push(Box::new(end));\n        param_idx += 1;\n    }\n\n    query.push_str(&format!(\" WHERE id = ?{}\", param_idx));\n    params_vec.push(Box::new(id.to_string()));\n\n    // 将 Vec<Box<dyn ToSql>> 转换为 &[&dyn ToSql]\n    let params_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();\n\n    conn.execute(&query, params_refs.as_slice())\n        .map_err(|e| format!(\"Failed to update user token: {}\", e))?;\n\n    Ok(())\n}\n\n/// 续期令牌\npub fn renew_token(id: &str, expires_type: &str) -> Result<(), String> {\n    let conn = connect_db()?;\n    let now = Utc::now().timestamp();\n    \n    let expires_at = match expires_type {\n        \"day\" => Some(Utc::now().checked_add_signed(chrono::Duration::days(1)).unwrap().timestamp()),\n        \"week\" => Some(Utc::now().checked_add_signed(chrono::Duration::weeks(1)).unwrap().timestamp()),\n        \"month\" => Some(Utc::now().checked_add_signed(chrono::Duration::days(30)).unwrap().timestamp()),\n        _ => None, // \"never\" or other\n    };\n\n    conn.execute(\n        \"UPDATE user_tokens SET expires_type = ?1, expires_at = ?2, updated_at = ?3, enabled = 1 WHERE id = ?4\",\n        params![expires_type, expires_at, now, id],\n    ).map_err(|e| format!(\"Failed to renew token: {}\", e))?;\n    \n    Ok(())\n}\n\n/// 删除令牌\npub fn delete_token(id: &str) -> Result<(), String> {\n    let conn = connect_db()?;\n    conn.execute(\"DELETE FROM user_tokens WHERE id = ?1\", params![id])\n        .map_err(|e| format!(\"Failed to delete token: {}\", e))?;\n    Ok(())\n}\n\n/// 获取令牌的所有 IP 绑定\npub fn get_token_ips(token_id: &str) -> Result<Vec<TokenIpBinding>, String> {\n    let conn = connect_db()?;\n    let mut stmt = conn.prepare(\"SELECT * FROM token_ip_bindings WHERE token_id = ?1 ORDER BY last_seen_at DESC\")\n        .map_err(|e| format!(\"Failed to prepare query: {}\", e))?;\n    \n    let iter = stmt.query_map(params![token_id], |row| {\n        Ok(TokenIpBinding {\n            id: row.get(\"id\")?,\n            token_id: row.get(\"token_id\")?,\n            ip_address: row.get(\"ip_address\")?,\n            first_seen_at: row.get(\"first_seen_at\")?,\n            last_seen_at: row.get(\"last_seen_at\")?,\n            request_count: row.get(\"request_count\")?,\n            user_agent: row.get(\"user_agent\")?,\n        })\n    }).map_err(|e| format!(\"Failed to query token IPs: {}\", e))?;\n\n    let mut bindings = Vec::new();\n    for b in iter {\n        bindings.push(b.map_err(|e| format!(\"Failed to parse binding row: {}\", e))?);\n    }\n    \n    Ok(bindings)\n}\n\n/// 记录/更新令牌使用情况 (同时处理 user_tokens 和 token_ip_bindings)\npub fn record_token_usage_and_ip(\n    token_id: &str, \n    ip: &str, \n    model: &str,\n    input_tokens: i32, \n    output_tokens: i32,\n    status: u16,\n    user_agent: Option<String>\n) -> Result<(), String> {\n    let mut conn = connect_db()?;\n    let tx = conn.transaction().map_err(|e| format!(\"Failed to create transaction: {}\", e))?;\n    let now = Utc::now().timestamp();\n\n    // 1. 更新 user_tokens 主表\n    tx.execute(\n        \"UPDATE user_tokens SET \n            last_used_at = ?1, \n            total_requests = total_requests + 1, \n            total_tokens_used = total_tokens_used + ?2 \n        WHERE id = ?3\",\n        params![now, input_tokens + output_tokens, token_id],\n    ).map_err(|e| format!(\"Failed to update user_tokens stats: {}\", e))?;\n\n    // 2. 更新或插入 token_ip_bindings 表\n    let binding_exists: bool = tx.query_row(\n        \"SELECT EXISTS(SELECT 1 FROM token_ip_bindings WHERE token_id = ?1 AND ip_address = ?2)\",\n        params![token_id, ip],\n        |row| row.get(0),\n    ).unwrap_or(false);\n\n    if binding_exists {\n        tx.execute(\n            \"UPDATE token_ip_bindings SET \n                last_seen_at = ?1, \n                request_count = request_count + 1,\n                user_agent = COALESCE(?2, user_agent)\n            WHERE token_id = ?3 AND ip_address = ?4\",\n            params![now, user_agent, token_id, ip],\n        ).map_err(|e| format!(\"Failed to update ip binding: {}\", e))?;\n    } else {\n        let binding_id = Uuid::new_v4().to_string();\n        tx.execute(\n            \"INSERT INTO token_ip_bindings (\n                id, token_id, ip_address, first_seen_at, last_seen_at, request_count, user_agent\n            ) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)\",\n            params![binding_id, token_id, ip, now, now, user_agent],\n        ).map_err(|e| format!(\"Failed to insert ip binding: {}\", e))?;\n    }\n\n    // 3. 插入 token_usage_logs 表\n    let log_id = Uuid::new_v4().to_string();\n    tx.execute(\n        \"INSERT INTO token_usage_logs (\n            id, token_id, ip_address, model, input_tokens, output_tokens, request_time, status\n        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)\",\n        params![\n            log_id, token_id, ip, model, input_tokens, output_tokens, now, status\n        ],\n    ).map_err(|e| format!(\"Failed to insert usage log: {}\", e))?;\n\n    tx.commit().map_err(|e| format!(\"Failed to commit transaction: {}\", e))?;\n\n    Ok(())\n}\n\n/// 检查 Token 是否有效 (包含过期时间检查和 IP 限制检查)\n/// 返回: (是否有效, 拒绝原因)\npub fn validate_token(token_str: &str, ip: &str) -> Result<(bool, Option<String>), String> {\n    let token_opt = get_token_by_value(token_str)?;\n\n    if let Some(token) = token_opt {\n        // 1. 检查过期时间\n        if let Some(expires_at) = token.expires_at {\n            if expires_at < Utc::now().timestamp() {\n                return Ok((false, Some(\"Your token has expired. Please contact the administrator to renew it.\".to_string())));\n            }\n        }\n\n        // 2. 检查 IP 限制\n        if token.max_ips > 0 {\n            let conn = connect_db()?;\n\n            // 检查当前 IP 是否已绑定\n            let is_bound: bool = conn.query_row(\n                \"SELECT EXISTS(SELECT 1 FROM token_ip_bindings WHERE token_id = ?1 AND ip_address = ?2)\",\n                params![token.id, ip],\n                |row| row.get(0)\n            ).unwrap_or(false);\n\n            if !is_bound {\n                // 如果未绑定，检查是否达到上限\n                let current_ip_count: i32 = conn.query_row(\n                    \"SELECT COUNT(*) FROM token_ip_bindings WHERE token_id = ?1\",\n                    params![token.id],\n                    |row| row.get(0)\n                ).unwrap_or(0);\n\n                if current_ip_count >= token.max_ips {\n                    return Ok((false, Some(format!(\"IP limit reached ({}/{}). Please contact the administrator to increase the limit.\", current_ip_count, token.max_ips))));\n                }\n            }\n        }\n\n        // 3. 检查宵禁时间 (Curfew)\n        // 逻辑：如果当前北京时间在 start 和 end 之间，则拒绝\n        // 格式：HH:MM\n        // 使用固定 UTC+8 (北京时间)，不依赖服务器本地时区\n        if let (Some(start_str), Some(end_str)) = (&token.curfew_start, &token.curfew_end) {\n            if !start_str.is_empty() && !end_str.is_empty() {\n                let beijing_offset = FixedOffset::east_opt(8 * 3600).unwrap();\n                let now_beijing = Utc::now().with_timezone(&beijing_offset);\n                let current_time_str = format!(\"{:02}:{:02}\", now_beijing.hour(), now_beijing.minute());\n\n                // 跨午夜处理: start > end (e.g. 23:00 to 06:00)\n                // 正常: start < end (e.g. 09:00 to 18:00)\n                let is_curfew = if start_str > end_str {\n                    current_time_str >= *start_str || current_time_str < *end_str\n                } else {\n                    current_time_str >= *start_str && current_time_str < *end_str\n                };\n\n                if is_curfew {\n                     return Ok((false, Some(format!(\"Service is not available between {} and {} Beijing Time (Curfew enabled). Current Beijing time: {}\", start_str, end_str, current_time_str))));\n                }\n            }\n        }\n\n        // 一切正常，Token 有效\n        Ok((true, None))\n    } else {\n        Ok((false, Some(\"Invalid token. Please check your API key.\".to_string())))\n    }\n}\n\n/// 获取 IP 关联的用户名 (用于 IP 管理页面)\n/// 返回最近一次使用该 IP 的 Token 所属的用户名\npub fn get_username_for_ip(ip: &str) -> Result<Option<String>, String> {\n    let conn = connect_db()?;\n    let result: Option<String> = conn.query_row(\n        \"SELECT t.username \n         FROM token_ip_bindings b \n         JOIN user_tokens t ON b.token_id = t.id \n         WHERE b.ip_address = ?1 \n         ORDER BY b.last_seen_at DESC \n         LIMIT 1\",\n        params![ip],\n        |row| row.get(0),\n    ).optional().map_err(|e| format!(\"Failed to query username by ip: {}\", e))?;\n    \n    Ok(result)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_create_and_query_token() {\n        let _ = init_db(); // Ensure DB is initialized\n        \n        // Use a random username to avoid collisions in existing DB runs during dev\n        let username = format!(\"TestUser_{}\", Uuid::new_v4());\n        let token_res = create_token(username.clone(), \"day\".to_string(), Some(\"Test token\".to_string()), 0, None, None, None);\n        assert!(token_res.is_ok());\n\n        let token = token_res.unwrap();\n        assert_eq!(token.username, username);\n        assert!(token.token.starts_with(\"sk-\"));\n        \n        let fetched = get_token_by_id(&token.id);\n        assert!(fetched.is_ok());\n        assert_eq!(fetched.unwrap().unwrap().username, username);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/modules/version.rs",
    "content": "use crate::modules::process;\nuse std::fs;\nuse std::path::PathBuf;\n\n/// Antigravity 版本信息\n#[derive(Debug, Clone)]\npub struct AntigravityVersion {\n    pub short_version: String,\n    #[allow(dead_code)] // 预留给构建/诊断输出\n    pub bundle_version: String,\n}\n\n/// 从任意字符串中提取第一个语义化版本号 (X.Y.Z)\nfn extract_semver(raw: &str) -> Option<String> {\n    for token in raw.split(|c: char| c.is_whitespace() || c == ',' || c == ';') {\n        let t = token.trim_matches(|c: char| c == '\"' || c == '\\'' || c == '(' || c == ')');\n        if t.is_empty() {\n            continue;\n        }\n        let mut parts = t.split('.');\n        let p1 = parts.next();\n        let p2 = parts.next();\n        let p3 = parts.next();\n        if p1.is_some()\n            && p2.is_some()\n            && p3.is_some()\n            && [p1.unwrap(), p2.unwrap(), p3.unwrap()]\n                .iter()\n                .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))\n        {\n            return Some(t.to_string());\n        }\n    }\n    None\n}\n\n/// 检测 Antigravity 版本（跨平台）\npub fn get_antigravity_version() -> Result<AntigravityVersion, String> {\n    // 1. 获取 Antigravity 可执行文件路径（复用现有功能）\n    let exe_path = process::get_antigravity_executable_path()\n        .ok_or(\"Unable to locate Antigravity executable\")?;\n    \n    // 2. 根据平台读取版本信息\n    #[cfg(target_os = \"macos\")]\n    {\n        get_version_macos(&exe_path)\n    }\n    \n    #[cfg(target_os = \"windows\")]\n    {\n        get_version_windows(&exe_path)\n    }\n    \n    #[cfg(target_os = \"linux\")]\n    {\n        get_version_linux(&exe_path)\n    }\n}\n\n/// macOS: 从 Info.plist 读取版本\n#[cfg(target_os = \"macos\")]\nfn get_version_macos(exe_path: &PathBuf) -> Result<AntigravityVersion, String> {\n    use plist::Value;\n    \n    // exe_path 可能是 /Applications/Antigravity.app 或内部可执行文件\n    // 需要找到 .app 目录\n    let path_str = exe_path.to_string_lossy();\n    let app_path = if let Some(idx) = path_str.find(\".app\") {\n        PathBuf::from(&path_str[..idx + 4])\n    } else {\n        exe_path.clone()\n    };\n    \n    let info_plist_path = app_path.join(\"Contents/Info.plist\");\n    if !info_plist_path.exists() {\n        return Err(format!(\"Info.plist not found: {:?}\", info_plist_path));\n    }\n    \n    let content = fs::read(&info_plist_path)\n        .map_err(|e| format!(\"Failed to read Info.plist: {}\", e))?;\n    \n    let plist: Value = plist::from_bytes(&content)\n        .map_err(|e| format!(\"Failed to parse Info.plist: {}\", e))?;\n    \n    let dict = plist.as_dictionary()\n        .ok_or(\"Info.plist is not a dictionary\")?;\n    \n    let short_version = dict.get(\"CFBundleShortVersionString\")\n        .and_then(|v| v.as_string())\n        .ok_or(\"CFBundleShortVersionString not found\")?;\n    \n    let bundle_version = dict.get(\"CFBundleVersion\")\n        .and_then(|v| v.as_string())\n        .unwrap_or(short_version);\n    \n    Ok(AntigravityVersion {\n        short_version: short_version.to_string(),\n        bundle_version: bundle_version.to_string(),\n    })\n}\n\n/// Windows: 从可执行文件元数据读取版本\n#[cfg(target_os = \"windows\")]\nfn get_version_windows(exe_path: &PathBuf) -> Result<AntigravityVersion, String> {\n    use std::process::Command;\n    use crate::utils::command::CommandExtWrapper;\n    \n    // Windows: 使用 PowerShell 读取文件版本信息\n    let mut cmd = Command::new(\"powershell\");\n    let output = cmd\n        .creation_flags_windows()\n        .args([\n            \"-Command\",\n            &format!(\n                \"(Get-Item '{}').VersionInfo.FileVersion\",\n                exe_path.display()\n            ),\n        ])\n        .output()\n        .map_err(|e| format!(\"Failed to execute PowerShell: {}\", e))?;\n    \n    if !output.status.success() {\n        return Err(\"Failed to read version from executable\".to_string());\n    }\n    \n    let version = String::from_utf8_lossy(&output.stdout)\n        .trim()\n        .to_string();\n    \n    if version.is_empty() {\n        return Err(\"Version information not found in executable\".to_string());\n    }\n    \n    Ok(AntigravityVersion {\n        short_version: version.clone(),\n        bundle_version: version,\n    })\n}\n\n/// Linux: 从 package.json 或 --version 参数读取\n#[cfg(target_os = \"linux\")]\nfn get_version_linux(exe_path: &PathBuf) -> Result<AntigravityVersion, String> {\n    use std::process::Command;\n    \n    // 方法1: 尝试执行 --version\n    let output = Command::new(exe_path)\n        .arg(\"--version\")\n        .output();\n    \n    if let Ok(result) = output {\n        if result.status.success() {\n            let raw_version = String::from_utf8_lossy(&result.stdout)\n                .trim()\n                .to_string();\n            if !raw_version.is_empty() {\n                let version = extract_semver(&raw_version).unwrap_or_else(|| {\n                    raw_version\n                        .lines()\n                        .next()\n                        .unwrap_or_default()\n                        .trim()\n                        .to_string()\n                });\n                return Ok(AntigravityVersion {\n                    short_version: version.clone(),\n                    bundle_version: raw_version,\n                });\n            }\n        }\n    }\n    \n    // 方法2: 尝试从安装目录的 package.json 读取\n    if let Some(parent) = exe_path.parent() {\n        let package_json = parent.join(\"resources/app/package.json\");\n        if package_json.exists() {\n            if let Ok(content) = fs::read_to_string(&package_json) {\n                if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {\n                    if let Some(version) = json.get(\"version\").and_then(|v| v.as_str()) {\n                        return Ok(AntigravityVersion {\n                            short_version: version.to_string(),\n                            bundle_version: version.to_string(),\n                        });\n                    }\n                }\n            }\n        }\n    }\n    \n    Err(\"Unable to determine Antigravity version on Linux\".to_string())\n}\n\n/// 判断是否为新版本 (>= 1.16.5)\npub fn is_new_version(version: &AntigravityVersion) -> bool {\n    compare_version(&version.short_version, \"1.16.5\") >= std::cmp::Ordering::Equal\n}\n\n/// 比较版本号\nfn compare_version(v1: &str, v2: &str) -> std::cmp::Ordering {\n    let parts1: Vec<u32> = v1\n        .split('.')\n        .filter_map(|s| s.parse().ok())\n        .collect();\n    let parts2: Vec<u32> = v2\n        .split('.')\n        .filter_map(|s| s.parse().ok())\n        .collect();\n    \n    for i in 0..parts1.len().max(parts2.len()) {\n        let p1 = parts1.get(i).unwrap_or(&0);\n        let p2 = parts2.get(i).unwrap_or(&0);\n        match p1.cmp(p2) {\n            std::cmp::Ordering::Equal => continue,\n            other => return other,\n        }\n    }\n    std::cmp::Ordering::Equal\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_version_comparison() {\n        assert_eq!(compare_version(\"1.16.5\", \"1.16.4\"), std::cmp::Ordering::Greater);\n        assert_eq!(compare_version(\"1.16.5\", \"1.16.5\"), std::cmp::Ordering::Equal);\n        assert_eq!(compare_version(\"1.16.4\", \"1.16.5\"), std::cmp::Ordering::Less);\n        assert_eq!(compare_version(\"1.17.0\", \"1.16.5\"), std::cmp::Ordering::Greater);\n        assert_eq!(compare_version(\"2.0.0\", \"1.16.5\"), std::cmp::Ordering::Greater);\n    }\n\n    #[test]\n    fn test_is_new_version() {\n        let old = AntigravityVersion {\n            short_version: \"1.16.4\".to_string(),\n            bundle_version: \"1.16.4\".to_string(),\n        };\n        assert!(!is_new_version(&old));\n\n        let new = AntigravityVersion {\n            short_version: \"1.16.5\".to_string(),\n            bundle_version: \"1.16.5\".to_string(),\n        };\n        assert!(is_new_version(&new));\n        \n        let newer = AntigravityVersion {\n            short_version: \"1.17.0\".to_string(),\n            bundle_version: \"1.17.0\".to_string(),\n        };\n        assert!(is_new_version(&newer));\n    }\n\n    #[test]\n    fn test_extract_semver_from_messy_output() {\n        let raw = \"1.107.0\\n1504c8cc4b34dbfbb4a97ebe954b3da2b5634516\\nx64\";\n        assert_eq!(extract_semver(raw), Some(\"1.107.0\".to_string()));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/audio/mod.rs",
    "content": "use base64::{engine::general_purpose, Engine as _};\nuse std::path::Path;\n\npub struct AudioProcessor;\n\nimpl AudioProcessor {\n    /// 检测音频 MIME 类型\n    pub fn detect_mime_type(filename: &str) -> Result<String, String> {\n        let ext = Path::new(filename)\n            .extension()\n            .and_then(|s| s.to_str())\n            .ok_or(\"无法获取文件扩展名\")?;\n\n        match ext.to_lowercase().as_str() {\n            \"mp3\" => Ok(\"audio/mp3\".to_string()),\n            \"wav\" => Ok(\"audio/wav\".to_string()),\n            \"m4a\" => Ok(\"audio/aac\".to_string()),\n            \"ogg\" => Ok(\"audio/ogg\".to_string()),\n            \"flac\" => Ok(\"audio/flac\".to_string()),\n            \"aiff\" | \"aif\" => Ok(\"audio/aiff\".to_string()),\n            _ => Err(format!(\"不支持的音频格式: {}\", ext)),\n        }\n    }\n\n    /// 将音频数据编码为 Base64\n    pub fn encode_to_base64(audio_data: &[u8]) -> String {\n        general_purpose::STANDARD.encode(audio_data)\n    }\n\n    /// 判断文件是否超过大小限制\n    pub fn exceeds_size_limit(size_bytes: usize) -> bool {\n        const MAX_SIZE: usize = 15 * 1024 * 1024; // 15MB\n        size_bytes > MAX_SIZE\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_detect_mime_type() {\n        assert_eq!(\n            AudioProcessor::detect_mime_type(\"audio.mp3\").unwrap(),\n            \"audio/mp3\"\n        );\n        assert_eq!(\n            AudioProcessor::detect_mime_type(\"audio.wav\").unwrap(),\n            \"audio/wav\"\n        );\n        assert!(AudioProcessor::detect_mime_type(\"audio.txt\").is_err());\n    }\n\n    #[test]\n    fn test_exceeds_size_limit() {\n        assert!(!AudioProcessor::exceeds_size_limit(10 * 1024 * 1024)); // 10MB\n        assert!(AudioProcessor::exceeds_size_limit(20 * 1024 * 1024)); // 20MB\n        assert!(AudioProcessor::exceeds_size_limit(15 * 1024 * 1024 + 1)); // 刚好超过\n        assert!(!AudioProcessor::exceeds_size_limit(15 * 1024 * 1024)); // 刚好等于限制\n    }\n\n    #[test]\n    fn test_base64_encoding() {\n        let data = b\"test audio data\";\n        let encoded = AudioProcessor::encode_to_base64(data);\n        assert!(!encoded.is_empty());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/cli_sync.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::fs;\n\n#[cfg(target_os = \"windows\")]\nuse std::os::windows::process::CommandExt;\n\n#[cfg(target_os = \"windows\")]\nconst CREATE_NO_WINDOW: u32 = 0x08000000;\n\n/// Windows 常见 CLI 安装路径扫描\n#[cfg(target_os = \"windows\")]\nfn scan_windows_cli_paths(cmd: &str) -> Option<PathBuf> {\n    let mut common_paths: Vec<PathBuf> = Vec::new();\n\n    // 常见 Windows 安装路径，按优先级排序（仅加入可推导出的绝对路径，避免空/相对路径误判）\n    if let Some(app_data) = std::env::var_os(\"APPDATA\") {\n        let npm_base = PathBuf::from(app_data).join(\"npm\");\n        common_paths.push(npm_base.join(format!(\"{}.cmd\", cmd)));\n        common_paths.push(npm_base.join(cmd));\n    }\n\n    if let Some(local_app_data) = std::env::var_os(\"LOCALAPPDATA\") {\n        let pnpm_base = PathBuf::from(&local_app_data).join(\"pnpm\");\n        common_paths.push(pnpm_base.join(format!(\"{}.cmd\", cmd)));\n        common_paths.push(pnpm_base.join(cmd));\n\n        let yarn_base = PathBuf::from(local_app_data).join(\"Yarn\").join(\"bin\");\n        common_paths.push(yarn_base.join(format!(\"{}.cmd\", cmd)));\n        common_paths.push(yarn_base.join(cmd));\n    }\n\n    if let Some(home) = dirs::home_dir() {\n        let bun_base = home.join(\".bun\").join(\"bin\");\n        common_paths.push(bun_base.join(format!(\"{}.exe\", cmd)));\n        common_paths.push(bun_base.join(cmd));\n    }\n\n    for path in common_paths {\n        if is_safe_path(&path) {\n            tracing::debug!(\"[CLI-Sync] Detected {} via Windows explicit path: {:?}\", cmd, path);\n            return Some(path);\n        }\n    }\n\n    // 扫描 NVM Windows 目录\n    if let Ok(nvm_home) = std::env::var(\"NVM_HOME\") {\n        let nvm_path = PathBuf::from(nvm_home);\n        if nvm_path.is_dir() {\n            // NVM Windows 结构: %NVM_HOME%\\v{version}\\{cmd}.cmd\n            if let Ok(entries) = fs::read_dir(&nvm_path) {\n                for entry in entries.flatten() {\n                    let cmd_path = entry.path().join(format!(\"{}.cmd\", cmd));\n                    if is_safe_path(&cmd_path) {\n                        tracing::debug!(\"[CLI-Sync] Detected {} via NVM_HOME: {:?}\", cmd, cmd_path);\n                        return Some(cmd_path);\n                    }\n                    // 也检查 .exe 版本\n                    let exe_path = entry.path().join(format!(\"{}.exe\", cmd));\n                    if is_safe_path(&exe_path) {\n                        tracing::debug!(\"[CLI-Sync] Detected {} via NVM_HOME: {:?}\", cmd, exe_path);\n                        return Some(exe_path);\n                    }\n                }\n            }\n        }\n    }\n\n    None\n}\n\n/// 解析 where 命令输出获取第一个有效路径\n#[cfg(target_os = \"windows\")]\nfn parse_where_output(output: &[u8]) -> Option<PathBuf> {\n    let stdout = String::from_utf8_lossy(output);\n    for line in stdout.lines() {\n        let trimmed = line.trim();\n        if !trimmed.is_empty() {\n            let path = PathBuf::from(trimmed);\n            if is_safe_path(&path) {\n                return Some(path);\n            }\n        }\n    }\n    None\n}\n\n/// 检查路径是否是 .cmd/.bat 文件\n#[cfg(target_os = \"windows\")]\nfn is_cmd_file(path: &PathBuf) -> bool {\n    path.extension()\n        .and_then(|e| e.to_str())\n        .map(|e| e.eq_ignore_ascii_case(\"cmd\") || e.eq_ignore_ascii_case(\"bat\"))\n        .unwrap_or(false)\n}\n\n/// 验证路径是否安全（防止命令注入）\n#[cfg(target_os = \"windows\")]\nfn is_safe_path(path: &PathBuf) -> bool {\n    // 检查路径是否存在且是文件\n    if !path.exists() || !path.is_file() {\n        return false;\n    }\n\n    // 必须为绝对路径，避免执行相对路径文件\n    if !path.is_absolute() {\n        return false;\n    }\n\n    // 检查路径是否包含危险字符\n    let path_str = path.to_string_lossy();\n    let dangerous_chars = ['&', '|', ';', '<', '>', '(', ')', '`', '$', '^', '%', '!'];\n    if path_str.chars().any(|c| dangerous_chars.contains(&c)) {\n        tracing::warn!(\"[CLI-Sync] Path contains dangerous characters: {}\", path_str);\n        return false;\n    }\n\n    true\n}\n\n/// 执行版本命令（Windows 特殊处理 .cmd/.bat）\n#[cfg(target_os = \"windows\")]\nfn run_version_command(executable_path: &PathBuf) -> Option<String> {\n    // 安全校验：验证路径不包含危险字符\n    if !is_safe_path(executable_path) {\n        return None;\n    }\n\n    let output = if is_cmd_file(executable_path) {\n        // 使用引号包裹路径防止注入，使用 /S 开关确保安全解析\n        let quoted_path = format!(\"\\\"{}\\\"\", executable_path.to_string_lossy());\n        Command::new(\"cmd.exe\")\n            .arg(\"/C\")\n            .arg(&quoted_path)\n            .arg(\"--version\")\n            .creation_flags(CREATE_NO_WINDOW)\n            .output()\n    } else {\n        let mut cmd = Command::new(executable_path);\n        cmd.arg(\"--version\");\n        cmd.creation_flags(CREATE_NO_WINDOW);\n        cmd.output()\n    };\n\n    match output {\n        Ok(out) if out.status.success() => {\n            let s = String::from_utf8_lossy(&out.stdout).trim().to_string();\n            // 使用正则提取版本号（更精确）\n            extract_version(&s)\n        }\n        _ => None,\n    }\n}\n\n/// 提取版本号（使用更精确的 semver 匹配）\nfn extract_version(s: &str) -> Option<String> {\n    // 匹配 semver 格式: x.y.z 或 x.y\n    let re = regex::Regex::new(r\"(\\d+\\.\\d+(?:\\.\\d+)?)\").ok()?;\n    re.captures(s)\n        .and_then(|caps| caps.get(1))\n        .map(|m| m.as_str().to_string())\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]\npub enum CliApp {\n    Claude,\n    Codex,\n    Gemini,\n    OpenCode,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]\npub struct CliConfigFile {\n    pub name: String,\n    pub path: PathBuf,\n}\n\nimpl CliApp {\n    pub fn as_str(&self) -> &'static str {\n        match self {\n            CliApp::Claude => \"claude\",\n            CliApp::Codex => \"codex\",\n            CliApp::Gemini => \"gemini\",\n            CliApp::OpenCode => \"opencode\",\n        }\n    }\n\n    pub fn config_files(&self) -> Vec<CliConfigFile> {\n        let home = match dirs::home_dir() {\n            Some(p) => p,\n            None => return vec![],\n        };\n        match self {\n            CliApp::Claude => vec![\n                CliConfigFile {\n                    name: \".claude.json\".to_string(),\n                    path: home.join(\".claude.json\"),\n                },\n                CliConfigFile {\n                    name: \"settings.json\".to_string(),\n                    path: home.join(\".claude\").join(\"settings.json\"),\n                },\n            ],\n            CliApp::Codex => vec![\n                CliConfigFile {\n                    name: \"auth.json\".to_string(),\n                    path: home.join(\".codex\").join(\"auth.json\"),\n                },\n                CliConfigFile {\n                    name: \"config.toml\".to_string(),\n                    path: home.join(\".codex\").join(\"config.toml\"),\n                },\n            ],\n            CliApp::Gemini => vec![\n                CliConfigFile {\n                    name: \".env\".to_string(),\n                    path: home.join(\".gemini\").join(\".env\"),\n                },\n                CliConfigFile {\n                    name: \"settings.json\".to_string(),\n                    path: home.join(\".gemini\").join(\"settings.json\"),\n                },\n                CliConfigFile {\n                    name: \"config.json\".to_string(),\n                    path: home.join(\".gemini\").join(\"config.json\"),\n                },\n            ],\n            CliApp::OpenCode => vec![\n                CliConfigFile {\n                    name: \"config.json\".to_string(),\n                    path: home.join(\".opencode\").join(\"config.json\"),\n                },\n            ],\n        }\n    }\n\n    pub fn default_url(&self) -> &'static str {\n        match self {\n            CliApp::Claude => \"https://api.anthropic.com\",\n            CliApp::Codex => \"https://api.openai.com/v1\",\n            CliApp::Gemini => \"https://generativelanguage.googleapis.com\",\n            CliApp::OpenCode => \"https://api.openai.com/v1\",\n        }\n    }\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct CliStatus {\n    pub installed: bool,\n    pub version: Option<String>,\n    pub is_synced: bool,\n    pub has_backup: bool,\n    pub current_base_url: Option<String>,\n    pub files: Vec<String>, // 返回关联的文件名列表供前端展示\n}\n\n/// 检测 CLI 是否安装并获取版本\npub fn check_cli_installed(app: &CliApp) -> (bool, Option<String>) {\n    let cmd = app.as_str();\n    // 默认使用命令名，如果 fallback 找到路径则更新为绝对路径\n    let mut executable_path = PathBuf::from(cmd);\n    \n    // 1. 优先使用 which/where 检测 (遵循 PATH)\n    let which_output = if cfg!(target_os = \"windows\") {\n        let mut c = Command::new(\"where\");\n        c.arg(cmd);\n        #[cfg(target_os = \"windows\")]\n        c.creation_flags(CREATE_NO_WINDOW);\n        c.output()\n    } else {\n        Command::new(\"which\").arg(cmd).output()\n    };\n\n    let mut installed = match &which_output {\n        Ok(out) => out.status.success(),\n        Err(_) => false,\n    };\n\n    #[cfg(target_os = \"windows\")]\n    if installed {\n        if let Ok(out) = &which_output {\n            if let Some(found_path) = parse_where_output(&out.stdout) {\n                executable_path = found_path;\n            }\n        }\n    }\n\n    #[cfg(target_os = \"windows\")]\n    if !installed {\n        if let Some(found_path) = scan_windows_cli_paths(cmd) {\n            installed = true;\n            executable_path = found_path;\n        }\n    }\n\n    // [FIX #765] macOS 增强检测: 如果 which 失败,显式搜索常用二进制路径\n    // 解决 Tauri 进程 PATH 可能不完整导致检测不到已安装 CLI 的问题\n    if !installed && !cfg!(target_os = \"windows\") {\n        let home = dirs::home_dir().unwrap_or_default();\n        let mut common_paths = vec![\n            home.join(\".local/bin\"),\n            home.join(\".bun/bin\"),\n            home.join(\".bun/install/global/node_modules/.bin\"),\n            home.join(\".npm-global/bin\"),\n            home.join(\".volta/bin\"),\n            home.join(\"bin\"),\n            PathBuf::from(\"/opt/homebrew/bin\"),\n            PathBuf::from(\"/usr/local/bin\"),\n            PathBuf::from(\"/usr/bin\"),\n        ];\n\n        // 增强：扫描 nvm 目录下的所有 node 版本\n        let nvm_base = home.join(\".nvm/versions/node\");\n        if nvm_base.exists() {\n            if let Ok(entries) = std::fs::read_dir(&nvm_base) {\n                for entry in entries.flatten() {\n                    let bin_path = entry.path().join(\"bin\");\n                    if bin_path.exists() {\n                        common_paths.push(bin_path);\n                    }\n                }\n            }\n        }\n\n        for path in common_paths {\n            let full_path = path.join(cmd);\n            if full_path.exists() {\n                tracing::debug!(\"[CLI-Sync] Detected {} via explicit path: {:?}\", cmd, full_path);\n                installed = true;\n                executable_path = full_path;\n                break;\n            }\n        }\n    }\n\n    if !installed {\n        return (false, None);\n    }\n\n    // 2. 获取版本（Windows 使用特殊处理 .cmd/.bat）\n    #[cfg(target_os = \"windows\")]\n    let version = run_version_command(&executable_path);\n\n    #[cfg(not(target_os = \"windows\"))]\n    let version = {\n        let mut ver_cmd = Command::new(&executable_path);\n        ver_cmd.arg(\"--version\");\n        let version_output = ver_cmd.output();\n        match version_output {\n            Ok(out) if out.status.success() => {\n                let s = String::from_utf8_lossy(&out.stdout).trim().to_string();\n                let cleaned = s.split(|c: char| !c.is_numeric() && c != '.')\n                    .filter(|part| !part.is_empty())\n                    .last()\n                    .map(|p| p.trim())\n                    .unwrap_or(&s)\n                    .to_string();\n                Some(cleaned)\n            }\n            _ => None,\n        }\n    };\n\n    (true, version)\n}\n\n/// 读取当前配置并检测同步状态\npub fn get_sync_status(app: &CliApp, proxy_url: &str) -> (bool, bool, Option<String>) {\n    let files = app.config_files();\n    if files.is_empty() {\n        return (false, false, None);\n    }\n\n    let mut all_synced = true;\n    let mut has_backup = false;\n    let mut current_base_url = None;\n\n    for file in &files {\n        // 使用更简单的命名规则: original_name + .antigravity.bak\n        let backup_path = file.path.with_file_name(format!(\"{}.antigravity.bak\", file.name));\n        \n        if backup_path.exists() {\n            has_backup = true;\n        }\n\n        // 如果物理文件不存在\n        // 如果物理文件不存在\n        if !file.path.exists() {\n            // Gemini 的 settings.json/config.json 只要有一个存在即可，或者都不存在（视为未同步）\n            if app == &CliApp::Gemini && (file.name == \"settings.json\" || file.name == \"config.json\") {\n                continue; \n            }\n            all_synced = false;\n            continue;\n        }\n\n        let content = match fs::read_to_string(&file.path) {\n            Ok(c) => c,\n            Err(_) => {\n                all_synced = false;\n                continue;\n            }\n        };\n\n        match app {\n            CliApp::Claude => {\n                if file.name == \"settings.json\" {\n                    let json: Value = serde_json::from_str(&content).unwrap_or_default();\n                    let url = json.get(\"env\").and_then(|e| e.get(\"ANTHROPIC_BASE_URL\")).and_then(|v| v.as_str());\n                    if let Some(u) = url {\n                        current_base_url = Some(u.to_string());\n                        if u.trim_end_matches('/') != proxy_url.trim_end_matches('/') {\n                            all_synced = false;\n                        }\n                    } else {\n                        all_synced = false;\n                    }\n                } else if file.name == \".claude.json\" {\n                    let json: Value = serde_json::from_str(&content).unwrap_or_default();\n                    if json.get(\"hasCompletedOnboarding\") != Some(&Value::Bool(true)) {\n                        all_synced = false;\n                    }\n                }\n            }\n            CliApp::Codex => {\n                if file.name == \"config.toml\" {\n                    // 正则匹配 base_url\n                    let re = regex::Regex::new(r#\"(?m)^\\s*base_url\\s*=\\s*['\"]([^'\"]+)['\"]\"#).unwrap();\n                    if let Some(caps) = re.captures(&content) {\n                        let url = &caps[1];\n                        current_base_url = Some(url.to_string());\n                        if url.trim_end_matches('/') != proxy_url.trim_end_matches('/') {\n                            all_synced = false;\n                        }\n                    } else {\n                        all_synced = false;\n                    }\n                }\n            }\n            CliApp::Gemini => {\n                if file.name == \".env\" {\n                    let re = regex::Regex::new(r#\"(?m)^GOOGLE_GEMINI_BASE_URL=(.*)$\"#).unwrap();\n                    if let Some(caps) = re.captures(&content) {\n                        let url = caps[1].trim();\n                        current_base_url = Some(url.to_string());\n                        if url.trim_end_matches('/') != proxy_url.trim_end_matches('/') {\n                            all_synced = false;\n                        }\n                    } else {\n                        all_synced = false;\n                    }\n                }\n            }\n            CliApp::OpenCode => {\n                if file.name == \"config.json\" {\n                    let json: Value = serde_json::from_str(&content).unwrap_or_default();\n                    let url = json.get(\"providers\")\n                        .and_then(|p| p.get(\"openai\"))\n                        .and_then(|o| o.get(\"baseURL\"))\n                        .and_then(|v| v.as_str());\n                    if let Some(u) = url {\n                        current_base_url = Some(u.to_string());\n                        if u.trim_end_matches('/') != proxy_url.trim_end_matches('/') {\n                            all_synced = false;\n                        }\n                    } else {\n                        all_synced = false;\n                    }\n                }\n            }\n        }\n    }\n\n    (all_synced, has_backup, current_base_url)\n}\n\n/// 执行同步逻辑\npub fn sync_config(app: &CliApp, proxy_url: &str, api_key: &str, model: Option<&str>) -> Result<(), String> {\n    let files = app.config_files();\n    \n    for file in &files {\n        // Gemini 兼容性逻辑：优先使用 settings.json\n        if app == &CliApp::Gemini && file.name == \"config.json\" && !file.path.exists() {\n            let settings_path = file.path.with_file_name(\"settings.json\");\n            if settings_path.exists() {\n                continue; \n            }\n        }\n\n        if let Some(parent) = file.path.parent() {\n            fs::create_dir_all(parent).map_err(|e| format!(\"无法创建目录: {}\", e))?;\n        }\n\n        // [New Feature] 自动备份：如果文件存在且没有备份，创建 .antigravity.bak 备份\n        // 这样可以保留用户最初的配置，后续多次同步不会覆盖这个备份\n        if file.path.exists() {\n            let backup_path = file.path.with_file_name(format!(\"{}.antigravity.bak\", file.name));\n            if !backup_path.exists() {\n                if let Err(e) = fs::copy(&file.path, &backup_path) {\n                    tracing::warn!(\"Failed to create backup for {}: {}\", file.name, e);\n                } else {\n                    tracing::info!(\"Created backup for {}: {:?}\", file.name, backup_path);\n                }\n            }\n        }\n\n        let mut content = if file.path.exists() {\n            fs::read_to_string(&file.path).unwrap_or_default()\n        } else {\n            String::new()\n        };\n\n        match app {\n            CliApp::Claude => {\n                if file.name == \".claude.json\" {\n                    let mut json: Value = serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}));\n                    if let Some(obj) = json.as_object_mut() {\n                        obj.insert(\"hasCompletedOnboarding\".to_string(), Value::Bool(true));\n                    }\n                    content = serde_json::to_string_pretty(&json).unwrap();\n                } else if file.name == \"settings.json\" {\n                    let mut json: serde_json::Value = serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}));\n                    if json.as_object().is_none() { json = serde_json::json!({}); }\n                    let env = json.as_object_mut().unwrap().entry(\"env\").or_insert(serde_json::json!({}));\n                    if let Some(env_obj) = env.as_object_mut() {\n                        env_obj.insert(\"ANTHROPIC_BASE_URL\".to_string(), Value::String(proxy_url.to_string()));\n                        if !api_key.is_empty() {\n                            env_obj.insert(\"ANTHROPIC_API_KEY\".to_string(), Value::String(api_key.to_string()));\n                            \n                            // [FIX] 避免冲突：如果存在则移除 ANTHROPIC_AUTH_TOKEN\n                            env_obj.remove(\"ANTHROPIC_AUTH_TOKEN\");\n\n                            // [FIX] 清理可能来自其他 Provider 的模型覆盖设置\n                            env_obj.remove(\"ANTHROPIC_MODEL\");\n                            env_obj.remove(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\");\n                            env_obj.remove(\"ANTHROPIC_DEFAULT_OPUS_MODEL\");\n                            env_obj.remove(\"ANTHROPIC_DEFAULT_SONNET_MODEL\");\n                        } else {\n                            // 如果 API Key 为空，则移除该键，避免设置为空字符串\n                            env_obj.remove(\"ANTHROPIC_API_KEY\");\n                        }\n                    }\n\n                    if let Some(m) = model {\n                        // 注意：Claude Code 的官方配置中，当前选定模型放在根节点的 model 字段\n                        json.as_object_mut().unwrap().insert(\"model\".to_string(), Value::String(m.to_string()));\n                    }\n                    content = serde_json::to_string_pretty(&json).unwrap();\n                }\n            }\n            CliApp::Codex => {\n                if file.name == \"auth.json\" {\n                    let mut json: Value = serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}));\n                    if let Some(obj) = json.as_object_mut() {\n                        obj.insert(\"OPENAI_API_KEY\".to_string(), Value::String(api_key.to_string()));\n                        // Codex 的 auth.json 似乎也支持 OPENAI_BASE_URL，但 ccs 没写，我们也同步写一下\n                        obj.insert(\"OPENAI_BASE_URL\".to_string(), Value::String(proxy_url.to_string()));\n                    }\n                    content = serde_json::to_string_pretty(&json).unwrap();\n                } else if file.name == \"config.toml\" {\n                    use toml_edit::{DocumentMut, value};\n                    let mut doc = content.parse::<DocumentMut>().unwrap_or_else(|_| DocumentMut::new());\n                    \n                    // 设置层级 [model_providers.custom]\n                    let providers = doc.entry(\"model_providers\").or_insert(toml_edit::Item::Table(toml_edit::Table::new()));\n                    if let Some(p_table) = providers.as_table_mut() {\n                        let custom = p_table.entry(\"custom\").or_insert(toml_edit::Item::Table(toml_edit::Table::new()));\n                        if let Some(c_table) = custom.as_table_mut() {\n                            c_table.insert(\"name\", value(\"custom\"));\n                            c_table.insert(\"wire_api\", value(\"responses\"));\n                            c_table.insert(\"requires_openai_auth\", value(true));\n                            c_table.insert(\"base_url\", value(proxy_url));\n                            if let Some(m) = model {\n                                c_table.insert(\"model\", value(m));\n                            }\n                        }\n                    }\n                    doc.insert(\"model_provider\", value(\"custom\"));\n                    if let Some(m) = model {\n                        doc.insert(\"model\", value(m));\n                    }\n                    // Codex 还需要清理可能存在的旧配置项\n                    doc.remove(\"openai_api_key\");\n                    doc.remove(\"openai_base_url\");\n                    content = doc.to_string();\n                }\n            }\n            CliApp::Gemini => {\n                if file.name == \".env\" {\n                    let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();\n                    let mut found_url = false;\n                    let mut found_key = false;\n                    for line in lines.iter_mut() {\n                        if line.starts_with(\"GOOGLE_GEMINI_BASE_URL=\") {\n                            *line = format!(\"GOOGLE_GEMINI_BASE_URL={}\", proxy_url);\n                            found_url = true;\n                        } else if line.trim().starts_with(\"GEMINI_API_KEY=\") {\n                            *line = format!(\"GEMINI_API_KEY={}\", api_key);\n                            found_key = true;\n                        }\n                    }\n                    if !found_url { lines.push(format!(\"GOOGLE_GEMINI_BASE_URL={}\", proxy_url)); }\n                    if !found_key { lines.push(format!(\"GEMINI_API_KEY={}\", api_key)); }\n                    if let Some(m) = model {\n                        let mut found_model = false;\n                        for line in lines.iter_mut() {\n                            if line.starts_with(\"GOOGLE_GEMINI_MODEL=\") {\n                                *line = format!(\"GOOGLE_GEMINI_MODEL={}\", m);\n                                found_model = true;\n                            }\n                        }\n                        if !found_model {\n                            lines.push(format!(\"GOOGLE_GEMINI_MODEL={}\", m));\n                        }\n                    }\n                    content = lines.join(\"\\n\");\n                } else if file.name == \"settings.json\" || file.name == \"config.json\" {\n                    let mut json: Value = serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}));\n                    if json.as_object().is_none() { json = serde_json::json!({}); }\n                    let sec = json.as_object_mut().unwrap().entry(\"security\").or_insert(serde_json::json!({}));\n                    let auth = sec.as_object_mut().unwrap().entry(\"auth\").or_insert(serde_json::json!({}));\n                    if let Some(auth_obj) = auth.as_object_mut() {\n                        auth_obj.insert(\"selectedType\".to_string(), Value::String(\"gemini-api-key\".to_string()));\n                    }\n                    content = serde_json::to_string_pretty(&json).unwrap();\n                }\n            }\n            CliApp::OpenCode => {\n                if file.name == \"config.json\" {\n                    let mut json: Value = serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}));\n                    if json.as_object().is_none() { json = serde_json::json!({}); }\n                    let providers = json.as_object_mut().unwrap().entry(\"providers\").or_insert(serde_json::json!({}));\n                    let openai = providers.as_object_mut().unwrap().entry(\"openai\").or_insert(serde_json::json!({}));\n                    if let Some(openai_obj) = openai.as_object_mut() {\n                        openai_obj.insert(\"baseURL\".to_string(), Value::String(proxy_url.to_string()));\n                        if !api_key.is_empty() {\n                            openai_obj.insert(\"apiKey\".to_string(), Value::String(api_key.to_string()));\n                        }\n                    }\n                    content = serde_json::to_string_pretty(&json).unwrap();\n                }\n            }\n        }\n\n        // 使用临时文件原子写入\n        let tmp_path = file.path.with_extension(\"tmp\");\n        fs::write(&tmp_path, &content).map_err(|e| format!(\"写入临时文件失败: {}\", e))?;\n        fs::rename(&tmp_path, &file.path).map_err(|e| format!(\"重命名配置文件失败: {}\", e))?;\n    }\n\n    Ok(())\n}\n\n// Tauri Commands\n\n#[tauri::command]\npub async fn get_cli_sync_status(app_type: CliApp, proxy_url: String) -> Result<CliStatus, String> {\n    let (installed, version) = check_cli_installed(&app_type);\n    let (is_synced, has_backup, current_base_url) = if installed {\n        get_sync_status(&app_type, &proxy_url)\n    } else {\n        (false, false, None)\n    };\n\n    Ok(CliStatus {\n        installed,\n        version,\n        is_synced,\n        has_backup,\n        current_base_url,\n        files: app_type.config_files().into_iter().map(|f| f.name).collect(),\n    })\n}\n\n#[tauri::command]\npub async fn execute_cli_sync(app_type: CliApp, proxy_url: String, api_key: String, model: Option<String>) -> Result<(), String> {\n    sync_config(&app_type, &proxy_url, &api_key, model.as_deref())\n}\n\n#[tauri::command]\npub async fn execute_cli_restore(app_type: CliApp) -> Result<(), String> {\n    let files = app_type.config_files();\n    let mut restored_count = 0;\n\n    // 尝试从备份恢复\n    for file in &files {\n        let backup_path = file.path.with_file_name(format!(\"{}.antigravity.bak\", file.name));\n        if backup_path.exists() {\n            // 还原：覆盖原文件\n            if let Err(e) = fs::rename(&backup_path, &file.path) {\n                return Err(format!(\"恢复备份失败 {}: {}\", file.name, e));\n            }\n            restored_count += 1;\n        }\n    }\n\n    if restored_count > 0 {\n        // 如果成功恢复了至少一个备份，就认为是恢复成功\n        return Ok(());\n    }\n\n    // 如果没有备份，则执行原来的逻辑：恢复为默认配置\n    let default_url = app_type.default_url();\n    // 恢复默认时清空 API Key，让用户重新授权或使用官方 Key\n    sync_config(&app_type, default_url, \"\", None)\n}\n\n#[tauri::command]\npub async fn get_cli_config_content(app_type: CliApp, file_name: Option<String>) -> Result<String, String> {\n    let files = app_type.config_files();\n    let file = if let Some(name) = file_name {\n        files.into_iter().find(|f| f.name == name).ok_or(\"找不到指定的文件\".to_string())?\n    } else {\n        files.into_iter().next().ok_or(\"找不到配置文件\".to_string())?\n    };\n\n    if !file.path.exists() {\n        return Err(\"配置文件不存在\".to_string());\n    }\n    fs::read_to_string(&file.path).map_err(|e| format!(\"读取配置文件失败: {}\", e))\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/common/client_adapter.rs",
    "content": "use axum::http::HeaderMap;\nuse once_cell::sync::Lazy;\nuse std::sync::Arc; // [NEW] Import Arc\nuse super::client_adapters::OpencodeAdapter;\n\n/// 客户端适配器 trait\n/// \n/// 为不同的客户端（如 opencode、Cherry Studio）提供定制化的协议处理策略。\n/// 每个客户端可以实现自己的适配器来处理特定的需求。\n/// \n/// # 设计原则\n/// 1. **完全隔离**：适配器作为可选的增强层，不修改现有协议核心逻辑\n/// 2. **向后兼容**：未匹配到适配器的请求完全按照现有流程处理\n/// 3. **单文件修改**：客户端特定逻辑封装在各自的适配器文件中\npub trait ClientAdapter: Send + Sync {\n    /// 判断该适配器是否匹配给定的请求\n    /// \n    /// # Arguments\n    /// * `headers` - 请求头，通常通过 User-Agent 等字段识别客户端\n    /// \n    /// # Returns\n    /// 如果匹配返回 true，否则返回 false\n    fn matches(&self, headers: &HeaderMap) -> bool;\n    \n    /// 是否绕过签名校验\n    /// \n    /// 某些客户端可能不需要严格的 thinking 签名匹配\n    #[allow(dead_code)]\n    fn bypass_signature_matching(&self) -> bool {\n        false\n    }\n    \n    /// 是否采用 \"let it crash\" 哲学\n    /// \n    /// 减少不必要的重试和恢复逻辑，让错误快速暴露\n    fn let_it_crash(&self) -> bool {\n        false\n    }\n    \n    /// 签名缓存策略\n    /// \n    /// 不同客户端可能需要不同的签名管理方式（FIFO/LIFO）\n    fn signature_buffer_strategy(&self) -> SignatureBufferStrategy {\n        SignatureBufferStrategy::Default\n    }\n    \n    /// 注入客户端缺少的 Beta Header\n    /// \n    /// 某些客户端可能需要特定的 Beta Header 才能正常工作\n    fn inject_beta_headers(&self, _headers: &mut HeaderMap) {\n        // 默认不注入\n    }\n    \n    /// 声明支持的协议\n    /// \n    /// 用于多协议客户端（如 opencode）\n    #[allow(dead_code)]\n    fn supported_protocols(&self) -> Vec<Protocol> {\n        vec![Protocol::Anthropic] // 默认只支持 Anthropic\n    }\n}\n\n/// 签名缓存策略\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum SignatureBufferStrategy {\n    /// 默认策略（当前实现）\n    Default,\n    /// FIFO（先进先出）- 适用于多并发工具调用\n    Fifo,\n    /// LIFO（后进先出）- 适用于嵌套调用\n    #[allow(dead_code)]\n    Lifo,\n}\n\n/// 支持的协议类型\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n#[allow(dead_code)]\npub enum Protocol {\n    Anthropic,\n    OpenAI,\n    OACompatible,\n    GoogleGemini,\n}\n\n/// 全局客户端适配器注册表\n/// \n/// 所有注册的适配器都会在请求处理时被检查\npub static CLIENT_ADAPTERS: Lazy<Vec<Arc<dyn ClientAdapter>>> = Lazy::new(|| {\n    vec![\n        Arc::new(OpencodeAdapter),\n        // 未来可以轻松添加更多适配器:\n        // Arc::new(CherryStudioAdapter),\n    ]\n});\n\n/// 辅助函数：从 HeaderMap 中提取 User-Agent\npub fn get_user_agent(headers: &HeaderMap) -> Option<String> {\n    headers\n        .get(\"user-agent\")\n        .and_then(|v| v.to_str().ok())\n        .map(|s| s.to_string())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use axum::http::HeaderValue;\n\n    struct TestAdapter;\n    \n    impl ClientAdapter for TestAdapter {\n        fn matches(&self, headers: &HeaderMap) -> bool {\n            get_user_agent(headers)\n                .map(|ua| ua.contains(\"test-client\"))\n                .unwrap_or(false)\n        }\n        \n        fn bypass_signature_matching(&self) -> bool {\n            true\n        }\n    }\n\n    #[test]\n    fn test_adapter_matches() {\n        let adapter = TestAdapter;\n        \n        let mut headers = HeaderMap::new();\n        headers.insert(\"user-agent\", HeaderValue::from_static(\"test-client/1.0\"));\n        \n        assert!(adapter.matches(&headers));\n        assert!(adapter.bypass_signature_matching());\n    }\n\n    #[test]\n    fn test_adapter_no_match() {\n        let adapter = TestAdapter;\n        \n        let mut headers = HeaderMap::new();\n        headers.insert(\"user-agent\", HeaderValue::from_static(\"other-client/1.0\"));\n        \n        assert!(!adapter.matches(&headers));\n    }\n\n    #[test]\n    fn test_get_user_agent() {\n        let mut headers = HeaderMap::new();\n        headers.insert(\"user-agent\", HeaderValue::from_static(\"opencode/1.0\"));\n        \n        assert_eq!(get_user_agent(&headers), Some(\"opencode/1.0\".to_string()));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/common/client_adapters/mod.rs",
    "content": "// Client Adapters 模块\n// 存放各种客户端的适配器实现\n\npub mod opencode;\n\npub use opencode::OpencodeAdapter;\n"
  },
  {
    "path": "src-tauri/src/proxy/common/client_adapters/opencode.rs",
    "content": "use super::super::client_adapter::{ClientAdapter, Protocol, SignatureBufferStrategy, get_user_agent};\nuse axum::http::{HeaderMap, HeaderValue};\n\n/// Opencode CLI 客户端适配器\n/// \n/// Opencode 是一个支持多协议的 AI CLI 工具，支持：\n/// - Anthropic\n/// - OpenAI\n/// - OA-Compatible\n/// - Google/Gemini\n/// \n/// 该适配器提供以下定制策略：\n/// 1. FIFO 签名管理策略（适应多并发工具调用）\n/// 2. 标准化 SSE 错误格式（通过客户端的 Zod 类型检查）\n/// 3. 自动注入 `context-1m-2025-08-07` beta header\npub struct OpencodeAdapter;\n\nimpl ClientAdapter for OpencodeAdapter {\n    fn matches(&self, headers: &HeaderMap) -> bool {\n        get_user_agent(headers)\n            .map(|ua| ua.to_lowercase().contains(\"opencode\"))\n            .unwrap_or(false)\n    }\n    \n    fn bypass_signature_matching(&self) -> bool {\n        // Opencode 对签名校验较为宽松\n        false\n    }\n    \n    fn let_it_crash(&self) -> bool {\n        // Opencode 倾向于快速失败，减少不必要的重试\n        true\n    }\n    \n    fn signature_buffer_strategy(&self) -> SignatureBufferStrategy {\n        // 使用 FIFO 策略以适应多并发工具调用\n        SignatureBufferStrategy::Fifo\n    }\n    \n    fn inject_beta_headers(&self, headers: &mut HeaderMap) {\n        // 注入 context-1m beta header\n        let value = HeaderValue::from_static(\"context-1m-2025-08-07\");\n        headers.insert(\"anthropic-beta\", value);\n    }\n    \n    fn supported_protocols(&self) -> Vec<Protocol> {\n        vec![\n            Protocol::Anthropic,\n            Protocol::OpenAI,\n            Protocol::OACompatible,\n            Protocol::GoogleGemini,\n        ]\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_opencode_adapter_matches() {\n        let adapter = OpencodeAdapter;\n        \n        let mut headers = HeaderMap::new();\n        headers.insert(\"user-agent\", HeaderValue::from_static(\"opencode/1.0.0\"));\n        \n        assert!(adapter.matches(&headers));\n    }\n\n    #[test]\n    fn test_opencode_adapter_case_insensitive() {\n        let adapter = OpencodeAdapter;\n        \n        let mut headers = HeaderMap::new();\n        headers.insert(\"user-agent\", HeaderValue::from_static(\"OpenCode/1.0.0\"));\n        \n        assert!(adapter.matches(&headers));\n    }\n\n    #[test]\n    fn test_opencode_adapter_no_match() {\n        let adapter = OpencodeAdapter;\n        \n        let mut headers = HeaderMap::new();\n        headers.insert(\"user-agent\", HeaderValue::from_static(\"curl/7.68.0\"));\n        \n        assert!(!adapter.matches(&headers));\n    }\n\n    #[test]\n    fn test_opencode_adapter_strategies() {\n        let adapter = OpencodeAdapter;\n        \n        assert!(adapter.let_it_crash());\n        assert_eq!(adapter.signature_buffer_strategy(), SignatureBufferStrategy::Fifo);\n    }\n\n    #[test]\n    fn test_opencode_adapter_protocols() {\n        let adapter = OpencodeAdapter;\n        \n        let protocols = adapter.supported_protocols();\n        assert_eq!(protocols.len(), 4);\n        assert!(protocols.contains(&Protocol::Anthropic));\n        assert!(protocols.contains(&Protocol::OpenAI));\n        assert!(protocols.contains(&Protocol::OACompatible));\n        assert!(protocols.contains(&Protocol::GoogleGemini));\n    }\n\n    #[test]\n    fn test_opencode_adapter_beta_headers() {\n        let adapter = OpencodeAdapter;\n        \n        let mut headers = HeaderMap::new();\n        adapter.inject_beta_headers(&mut headers);\n        \n        assert!(headers.contains_key(\"anthropic-beta\"));\n        assert_eq!(\n            headers.get(\"anthropic-beta\").unwrap().to_str().unwrap(),\n            \"context-1m-2025-08-07\"\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/common/error.rs",
    "content": "// 错误处理\nuse thiserror::Error;\nuse axum::{http::StatusCode, Json, response::IntoResponse};\n\n#[derive(Debug, Error)]\npub enum ProxyError {\n    #[error(\"Upstream API error: {0}\")]\n    UpstreamError(String),\n\n    #[error(\"Transform error: {0}\")]\n    TransformError(String),\n\n    #[error(\"Account error: {0}\")]\n    AccountError(String),\n\n    #[error(\"Rate limit exceeded\")]\n    RateLimitExceeded,\n\n    #[error(\"Invalid request: {0}\")]\n    InvalidRequest(String),\n}\n\nimpl IntoResponse for ProxyError {\n    fn into_response(self) -> axum::response::Response {\n        let status = match &self {\n            ProxyError::InvalidRequest(_) => StatusCode::BAD_REQUEST,\n            ProxyError::RateLimitExceeded => StatusCode::TOO_MANY_REQUESTS,\n            ProxyError::AccountError(_) => StatusCode::UNAUTHORIZED,\n            _ => StatusCode::INTERNAL_SERVER_ERROR,\n        };\n\n        let body = serde_json::json!({\n            \"error\": {\n                \"message\": self.to_string(),\n                \"type\": format!(\"{:?}\", self)\n            }\n        });\n\n        (status, Json(body)).into_response()\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/common/json_schema.rs",
    "content": "use serde_json::{json, Value};\nuse once_cell::sync::Lazy;\nuse super::tool_adapter::ToolAdapter;\nuse super::tool_adapters::PencilAdapter;\n\n/// 不被 Gemini 支持但包含重要语义信息的约束字段\n/// 这些字段将在删除前被转化为 description 提示\nconst CONSTRAINT_FIELDS: &[(&str, &str)] = &[\n    (\"minLength\", \"minLen\"),\n    (\"maxLength\", \"maxLen\"),\n    (\"pattern\", \"pattern\"),\n    (\"minimum\", \"min\"),\n    (\"maximum\", \"max\"),\n    (\"multipleOf\", \"multipleOf\"),\n    (\"exclusiveMinimum\", \"exclMin\"),\n    (\"exclusiveMaximum\", \"exclMax\"),\n    (\"minItems\", \"minItems\"),\n    (\"maxItems\", \"maxItems\"),\n    (\"format\", \"format\"),\n];\n\n/// 全局工具适配器注册表\n/// \n/// 所有注册的适配器都会在 Schema 清洗时被检查和应用\nstatic TOOL_ADAPTERS: Lazy<Vec<Box<dyn ToolAdapter>>> = Lazy::new(|| {\n    vec![\n        Box::new(PencilAdapter),\n        // 未来可以轻松添加更多适配器:\n        // Box::new(FilesystemAdapter),\n        // Box::new(DatabaseAdapter),\n    ]\n});\n\nconst MAX_RECURSION_DEPTH: usize = 10;\n\n/// 递归清理 JSON Schema 以符合 Gemini 接口要求\n///\n/// 1. [New] 展开 $ref 和 $defs: 将引用替换为实际定义，解决 Gemini 不支持 $ref 的问题\n/// 2. 移除不支持的字段: $schema, additionalProperties, format, default, uniqueItems, validation fields\n/// 3. 处理联合类型: [\"string\", \"null\"] -> \"string\"\n/// 4. [NEW] 处理 anyOf 联合类型: anyOf: [{\"type\": \"string\"}, {\"type\": \"null\"}] -> \"type\": \"string\"\n/// 5. 将 type 字段的值转换为小写 (Gemini v1internal 要求)\n/// 6. 移除数字校验字段: multipleOf, exclusiveMinimum, exclusiveMaximum 等\npub fn clean_json_schema(value: &mut Value) {\n    // 0. 预处理：展开 $ref (Schema Flattening)\n    // [FIX #952] 递归收集所有层级的 $defs/definitions，而非仅从根层级提取\n    let mut all_defs = serde_json::Map::new();\n    collect_all_defs(value, &mut all_defs);\n\n    // 移除根层级的 $defs/definitions (保持向后兼容)\n    if let Value::Object(map) = value {\n        map.remove(\"$defs\");\n        map.remove(\"definitions\");\n    }\n\n    // [FIX #952] 始终运行 flatten_refs，即使 defs 为空\n    // 这样可以捕获并处理无法解析的 $ref (降级为 string 类型)\n    if let Value::Object(map) = value {\n        flatten_refs(map, &all_defs, 0);\n    }\n\n    // 递归清理\n    clean_json_schema_recursive(value, true, 0);\n}\n\n/// 带工具适配器支持的 Schema 清洗\n/// \n/// 这是推荐的清洗入口,支持工具特定的优化\n/// \n/// # Arguments\n/// * `value` - 待清洗的 JSON Schema\n/// * `tool_name` - 工具名称,用于匹配适配器\n/// \n/// # 处理流程\n/// 1. 查找匹配的工具适配器\n/// 2. 执行适配器的预处理 (工具特定优化)\n/// 3. 执行通用清洗逻辑\n/// 4. 执行适配器的后处理 (最终调整)\npub fn clean_json_schema_for_tool(value: &mut Value, tool_name: &str) {\n    // 1. 查找匹配的适配器\n    let adapter = TOOL_ADAPTERS.iter()\n        .find(|a| a.matches(tool_name));\n    \n    // 2. 执行预处理\n    if let Some(adapter) = adapter {\n        let _ = adapter.pre_process(value);\n    }\n    \n    // 3. 执行通用清洗\n    clean_json_schema(value);\n    \n    // 4. 执行后处理\n    if let Some(adapter) = adapter {\n        let _ = adapter.post_process(value);\n    }\n}\n\n/// [NEW #952] 递归收集所有层级的 $defs 和 definitions\n///\n/// MCP 工具的 schema 可能在任意嵌套层级定义 $defs，而非仅在根层级。\n/// 此函数深度遍历整个 schema，收集所有定义到统一的 map 中。\nfn collect_all_defs(value: &Value, defs: &mut serde_json::Map<String, Value>) {\n    if let Value::Object(map) = value {\n        // 收集当前层级的 $defs\n        if let Some(Value::Object(d)) = map.get(\"$defs\") {\n            for (k, v) in d {\n                // 避免覆盖已存在的定义（先定义的优先）\n                defs.entry(k.clone()).or_insert_with(|| v.clone());\n            }\n        }\n        // 收集当前层级的 definitions (Draft-07 风格)\n        if let Some(Value::Object(d)) = map.get(\"definitions\") {\n            for (k, v) in d {\n                defs.entry(k.clone()).or_insert_with(|| v.clone());\n            }\n        }\n        // 递归处理所有子节点\n        for (key, v) in map {\n            // 跳过 $defs/definitions 本身，避免重复处理\n            if key != \"$defs\" && key != \"definitions\" {\n                collect_all_defs(v, defs);\n            }\n        }\n    } else if let Value::Array(arr) = value {\n        for item in arr {\n            collect_all_defs(item, defs);\n        }\n    }\n}\n\n/// 递归展开 $ref\nfn flatten_refs(\n    map: &mut serde_json::Map<String, Value>,\n    defs: &serde_json::Map<String, Value>,\n    depth: usize,\n) {\n    if depth > MAX_RECURSION_DEPTH {\n        tracing::warn!(\"[Schema-Flatten] Max recursion depth reached, stopping ref expansion.\");\n        return;\n    }\n\n    // 检查并替换 $ref\n    if let Some(Value::String(ref_path)) = map.remove(\"$ref\") {\n        // 解析引用名 (例如 #/$defs/MyType -> MyType)\n        let ref_name = ref_path.split('/').last().unwrap_or(&ref_path);\n\n        if let Some(def_schema) = defs.get(ref_name) {\n            // 将定义的内容合并到当前 map\n            if let Value::Object(def_map) = def_schema {\n                for (k, v) in def_map {\n                    // 仅当当前 map 没有该 key 时才插入 (避免覆盖)\n                    // 但通常 $ref 节点不应该有其他属性\n                    map.entry(k.clone()).or_insert_with(|| v.clone());\n                }\n\n                // 递归处理刚刚合并进来的内容中可能包含的 $ref\n                // 注意：由于引入了 depth 限制，循环引用不再会导致栈溢出\n                flatten_refs(map, defs, depth + 1);\n            }\n        } else {\n            // [FIX #952] 无法解析的 $ref: 转换为宽松的 string 类型，避免 API 400 错误\n            // 这比让请求失败要好，至少工具调用仍可进行\n            map.insert(\"type\".to_string(), serde_json::json!(\"string\"));\n            let hint = format!(\"(Unresolved $ref: {})\", ref_path);\n            let desc_val = map\n                .entry(\"description\".to_string())\n                .or_insert_with(|| Value::String(String::new()));\n            if let Value::String(s) = desc_val {\n                if !s.contains(&hint) {\n                    if !s.is_empty() {\n                        s.push(' ');\n                    }\n                    s.push_str(&hint);\n                }\n            }\n        }\n    }\n\n    // 遍历子节点\n    for (_, v) in map.iter_mut() {\n        if let Value::Object(child_map) = v {\n            flatten_refs(child_map, defs, depth + 1);\n        } else if let Value::Array(arr) = v {\n            for item in arr {\n                if let Value::Object(item_map) = item {\n                    flatten_refs(item_map, defs, depth + 1);\n                }\n            }\n        }\n    }\n}\n\nfn clean_json_schema_recursive(value: &mut Value, is_schema_node: bool, depth: usize) -> bool {\n    if depth > MAX_RECURSION_DEPTH {\n        debug_assert!(false, \"Max recursion depth reached in clean_json_schema_recursive\");\n        return false;\n    }\n    let mut is_effectively_nullable = false;\n\n    match value {\n        Value::Object(map) => {\n            // 0. [NEW] 合并 allOf\n            merge_all_of(map);\n\n            // 0.5 [NEW] 结构归一化 (Normalization)\n            // 针对某些 MCP 工具（如 pencil）误用 items 定义对象属性的情况进行修复。\n            // 如果 type=object 或包含 properties，但又定义了 items，Gemini 会因为 items 只能出现在 array 中而报错。\n            // 我们将 items 的内容“对齐”到 properties 中。\n            if map.get(\"type\").and_then(|t| t.as_str()) == Some(\"object\") || map.contains_key(\"properties\") {\n                if let Some(items) = map.remove(\"items\") {\n                    tracing::warn!(\"[Schema-Normalization] Found 'items' in an Object-like node. Moving content to 'properties'.\");\n                    let target_props = map.entry(\"properties\".to_string()).or_insert_with(|| json!({}));\n                    if let Some(target_map) = target_props.as_object_mut() {\n                        if let Some(source_map) = items.as_object() {\n                            for (k, v) in source_map {\n                                target_map.entry(k.clone()).or_insert_with(|| v.clone());\n                            }\n                        }\n                    }\n                }\n            }\n\n            // 1. [CRITICAL] 深度递归处理子项\n            // 处理 properties (对象)\n            if let Some(Value::Object(props)) = map.get_mut(\"properties\") {\n                let mut nullable_keys = std::collections::HashSet::new();\n                for (k, v) in props {\n                    // properties 的每一个值都必须是一个独立的 Schema 节点\n                    if clean_json_schema_recursive(v, true, depth + 1) {\n                        nullable_keys.insert(k.clone());\n                    }\n                }\n\n                if !nullable_keys.is_empty() {\n                    if let Some(Value::Array(req_arr)) = map.get_mut(\"required\") {\n                        req_arr.retain(|r| {\n                            r.as_str()\n                                .map(|s| !nullable_keys.contains(s))\n                                .unwrap_or(true)\n                        });\n                        if req_arr.is_empty() {\n                            map.remove(\"required\");\n                        }\n                    }\n                }\n\n                // [NEW] 隐式类型注入：如果有 properties 但没 type，补全为 object\n                if !map.contains_key(\"type\") {\n                    map.insert(\"type\".to_string(), Value::String(\"object\".to_string()));\n                }\n            }\n            \n            // 处理 items (数组)\n            if let Some(items) = map.get_mut(\"items\") {\n                // items 的内容必须是一个独立的 Schema 节点\n                clean_json_schema_recursive(items, true, depth + 1);\n\n                // [NEW] 隐式类型注入：如果有 items 但没 type，补全为 array\n                if !map.contains_key(\"type\") {\n                    map.insert(\"type\".to_string(), Value::String(\"array\".to_string()));\n                }\n            }\n\n            // Fallback: 对既没有 properties 也没有 items 的常规对象进行清理\n            if !map.contains_key(\"properties\") && !map.contains_key(\"items\") {\n                for (k, v) in map.iter_mut() {\n                    // 排除掉关键字\n                    if k != \"anyOf\" && k != \"oneOf\" && k != \"allOf\" && k != \"enum\" && k != \"type\" {\n                        clean_json_schema_recursive(v, false, depth + 1);\n                    }\n                }\n            }\n\n            // 1.5. [FIX] 递归清理 anyOf/oneOf 数组中的每个分支\n            // 必须在合并逻辑之前执行，确保合并的分支已经被清洗\n            if let Some(Value::Array(any_of)) = map.get_mut(\"anyOf\") {\n                for branch in any_of.iter_mut() {\n                    clean_json_schema_recursive(branch, true, depth + 1);\n                }\n            }\n            if let Some(Value::Array(one_of)) = map.get_mut(\"oneOf\") {\n                for branch in one_of.iter_mut() {\n                    clean_json_schema_recursive(branch, true, depth + 1);\n                }\n            }\n\n            // 2. [FIX #815] 处理 anyOf/oneOf 联合类型: 合并属性或择优选择分支\n            let mut union_to_merge = None;\n            if let Some(Value::Array(any_of)) = map.get(\"anyOf\") {\n                union_to_merge = Some(any_of.clone());\n            } else if let Some(Value::Array(one_of)) = map.get(\"oneOf\") {\n                union_to_merge = Some(one_of.clone());\n            }\n\n            if let Some(union_array) = union_to_merge {\n                if let Some((best_branch, all_types)) = extract_best_schema_from_union(&union_array) {\n                    if let Value::Object(branch_obj) = best_branch {\n                        // 合并分支属性到当前 map\n                        for (k, v) in branch_obj {\n                            if k == \"properties\" {\n                                if let Some(target_props) = map\n                                    .entry(\"properties\".to_string())\n                                    .or_insert_with(|| Value::Object(serde_json::Map::new()))\n                                    .as_object_mut()\n                                {\n                                    if let Some(source_props) = v.as_object() {\n                                        for (pk, pv) in source_props {\n                                            target_props\n                                                .entry(pk.clone())\n                                                .or_insert_with(|| pv.clone());\n                                        }\n                                    }\n                                }\n                            } else if k == \"required\" {\n                                if let Some(target_req) = map\n                                    .entry(\"required\".to_string())\n                                    .or_insert_with(|| Value::Array(Vec::new()))\n                                    .as_array_mut()\n                                {\n                                    if let Some(source_req) = v.as_array() {\n                                        for rv in source_req {\n                                            if !target_req.contains(rv) {\n                                                target_req.push(rv.clone());\n                                            }\n                                        }\n                                    }\n                                }\n                            } else if !map.contains_key(&k) {\n                                map.insert(k, v);\n                            }\n                        }\n                    }\n                    \n                    // [NEW] 添加类型提示到描述中 (参考 CLIProxyAPI)\n                    if all_types.len() > 1 {\n                        let type_hint = format!(\"Accepts: {}\", all_types.join(\" | \"));\n                        append_hint_to_description(map, type_hint);\n                    }\n                }\n            }\n\n            // 3. [SAFETY] 检查当前对象是否为 JSON Schema 节点\n            // 只有当对象看起来像 Schema (包含 type, properties, items, enum, anyOf 等) 时，才执行白名单过滤。\n            // 否则，如果它是一个普通的 Value (如 request.rs 中的 functionCall 对象)，直接应用激进过滤会破坏结构。\n            let allowed_fields = [\n                \"type\",\n                \"description\",\n                \"properties\",\n                \"required\",\n                \"items\",\n                \"enum\",\n                \"title\",\n            ];\n            \n            let has_standard_keyword = map.keys().any(|k| allowed_fields.contains(&k.as_str()));\n\n            // [NEW] 启发式修复：如果明确是 Schema 节点，但没有标准关键字，却有其他 Key\n            // 我们推测这是一个“简写”的对象定义，尝试将其内部 Key 移动到 properties 中。\n            // 补充：必须确保它不是工具调用或结果 (含有 functionCall/functionResponse)，防止结构被破坏。\n            let is_not_schema_payload = map.contains_key(\"functionCall\") || map.contains_key(\"functionResponse\");\n            if is_schema_node && !has_standard_keyword && !map.is_empty() && !is_not_schema_payload {\n                let mut properties = serde_json::Map::new();\n                let keys: Vec<String> = map.keys().cloned().collect();\n                for k in keys {\n                    if let Some(v) = map.remove(&k) {\n                        properties.insert(k, v);\n                    }\n                }\n                map.insert(\"type\".to_string(), Value::String(\"object\".to_string()));\n                map.insert(\"properties\".to_string(), Value::Object(properties));\n                \n                // 递归清理刚刚移动进去的属性\n                if let Some(Value::Object(props_map)) = map.get_mut(\"properties\") {\n                    for v in props_map.values_mut() {\n                        clean_json_schema_recursive(v, true, depth + 1); \n                    }\n                }\n            }\n\n            let looks_like_schema = (is_schema_node || has_standard_keyword) && !is_not_schema_payload;\n\n            if looks_like_schema {\n                // 4. [ROBUST] 约束迁移：在被白名单过滤前，将校验项转为描述 Hint\n                // [NEW] 使用统一的约束回填函数\n                move_constraints_to_description(map);\n\n                // 5. [CRITICAL] 白名单过滤：彻底物理移除 Gemini 不支持的内容，防止 400 错误\n                let keys_to_remove: Vec<String> = map\n                    .keys()\n                    .filter(|k| !allowed_fields.contains(&k.as_str()))\n                    .cloned()\n                    .collect();\n                for k in keys_to_remove {\n                    map.remove(&k);\n                }\n\n                // 6. [SAFETY] 处理空 Object\n                // [FIX] 移除 reason 字段注入逻辑\n                // 之前的实现会为空 Object 注入 reason 字段，导致 Gemini CLI 等工具报 \"malformed function call\"\n                // 因为模型会生成包含 reason 参数的调用，但工具定义中并没有这个参数\n                // 现在改为：空 Object 保持空的 properties，让 Gemini 模型自行决定是否需要参数\n                if map.get(\"type\").and_then(|t| t.as_str()) == Some(\"object\") {\n                    if !map.contains_key(\"properties\") {\n                        map.insert(\"properties\".to_string(), serde_json::json!({}));\n                    }\n                }\n\n                // 7. [SAFETY] Required 字段对齐\n                let valid_prop_keys: Option<std::collections::HashSet<String>> = map\n                    .get(\"properties\")\n                    .and_then(|p| p.as_object())\n                    .map(|obj| obj.keys().cloned().collect());\n\n                if let Some(required_val) = map.get_mut(\"required\") {\n                    if let Some(req_arr) = required_val.as_array_mut() {\n                        if let Some(keys) = &valid_prop_keys {\n                            req_arr\n                                .retain(|k| k.as_str().map(|s| keys.contains(s)).unwrap_or(false));\n                        } else {\n                            req_arr.clear();\n                        }\n                    }\n                }\n\n                if !map.contains_key(\"type\") {\n                    if map.contains_key(\"enum\") {\n                        map.insert(\"type\".to_string(), Value::String(\"string\".to_string()));\n                    } else if map.contains_key(\"properties\") {\n                        map.insert(\"type\".to_string(), Value::String(\"object\".to_string()));\n                    } else if map.contains_key(\"items\") {\n                        map.insert(\"type\".to_string(), Value::String(\"array\".to_string()));\n                    }\n                }\n\n                // [IMPROVED] 提前计算回退类型以避免借用冲突\n                let fallback = if map.contains_key(\"properties\") {\n                    \"object\"\n                } else if map.contains_key(\"items\") {\n                    \"array\"\n                } else {\n                    \"string\"\n                };\n\n                // 8. 处理 type 字段\n                if let Some(type_val) = map.get_mut(\"type\") {\n                    let mut selected_type = None;\n                    match type_val {\n                        Value::String(s) => {\n                            let lower = s.to_lowercase();\n                            if lower == \"null\" {\n                                is_effectively_nullable = true;\n                            } else {\n                                selected_type = Some(lower);\n                            }\n                        }\n                        Value::Array(arr) => {\n                            for item in arr {\n                                if let Value::String(s) = item {\n                                    let lower = s.to_lowercase();\n                                    if lower == \"null\" {\n                                        is_effectively_nullable = true;\n                                    } else if selected_type.is_none() {\n                                        selected_type = Some(lower);\n                                    }\n                                }\n                            }\n                        }\n                        _ => {}\n                    }\n                    \n                    *type_val =\n                        Value::String(selected_type.unwrap_or_else(|| fallback.to_string()));\n                }\n\n                if is_effectively_nullable {\n                    let desc_val = map\n                        .entry(\"description\".to_string())\n                        .or_insert_with(|| Value::String(\"\".to_string()));\n                    if let Value::String(s) = desc_val {\n                        if !s.contains(\"nullable\") {\n                            if !s.is_empty() {\n                                s.push(' ');\n                            }\n                            s.push_str(\"(nullable)\");\n                        }\n                    }\n                }\n\n                // 9. Enum 值强制转字符串\n                if let Some(Value::Array(arr)) = map.get_mut(\"enum\") {\n                    for item in arr {\n                        if !item.is_string() {\n                            *item = Value::String(if item.is_null() {\n                                \"null\".to_string()\n                            } else {\n                                item.to_string()\n                            });\n                        }\n                    }\n                }\n            }\n        }\n        Value::Array(arr) => {\n            // [FIX] 递归清理数组中的每个元素\n            // 这确保了所有数组类型的值（包括但不限于 anyOf、oneOf、items、enum 等）都会被递归处理\n            for item in arr.iter_mut() {\n                clean_json_schema_recursive(item, is_schema_node, depth + 1);\n            }\n        }\n        _ => {}\n    }\n\n    is_effectively_nullable\n}\n\n/// [NEW] 合并 allOf 数组中的所有子 Schema\nfn merge_all_of(map: &mut serde_json::Map<String, Value>) {\n    if let Some(Value::Array(all_of)) = map.remove(\"allOf\") {\n        let mut merged_properties = serde_json::Map::new();\n        let mut merged_required = std::collections::HashSet::new();\n        let mut other_fields = serde_json::Map::new();\n\n        for sub_schema in all_of {\n            if let Value::Object(sub_map) = sub_schema {\n                // 合并属性\n                if let Some(Value::Object(props)) = sub_map.get(\"properties\") {\n                    for (k, v) in props {\n                        merged_properties.insert(k.clone(), v.clone());\n                    }\n                }\n\n                // 合并 required\n                if let Some(Value::Array(reqs)) = sub_map.get(\"required\") {\n                    for req in reqs {\n                        if let Some(s) = req.as_str() {\n                            merged_required.insert(s.to_string());\n                        }\n                    }\n                }\n\n                // 合并其余字段 (第一个出现的胜出)\n                for (k, v) in sub_map {\n                    if k != \"properties\"\n                        && k != \"required\"\n                        && k != \"allOf\"\n                        && !other_fields.contains_key(&k)\n                    {\n                        other_fields.insert(k, v);\n                    }\n                }\n            }\n        }\n\n        // 应用合并后的字段\n        for (k, v) in other_fields {\n            if !map.contains_key(&k) {\n                map.insert(k, v);\n            }\n        }\n\n        if !merged_properties.is_empty() {\n            let existing_props = map\n                .entry(\"properties\".to_string())\n                .or_insert_with(|| Value::Object(serde_json::Map::new()));\n            if let Value::Object(existing_map) = existing_props {\n                for (k, v) in merged_properties {\n                    existing_map.entry(k).or_insert(v);\n                }\n            }\n        }\n\n        if !merged_required.is_empty() {\n            let existing_reqs = map\n                .entry(\"required\".to_string())\n                .or_insert_with(|| Value::Array(Vec::new()));\n            if let Value::Array(req_arr) = existing_reqs {\n                let mut current_reqs: std::collections::HashSet<String> = req_arr\n                    .iter()\n                    .filter_map(|v| v.as_str().map(|s| s.to_string()))\n                    .collect();\n                for req in merged_required {\n                    if current_reqs.insert(req.clone()) {\n                        req_arr.push(Value::String(req));\n                    }\n                }\n            }\n        }\n    }\n}\n\n/// [NEW] 将提示信息追加到 description 字段\n/// 参考 CLIProxyAPI 的 Lazy Hint 策略\nfn append_hint_to_description(map: &mut serde_json::Map<String, Value>, hint: String) {\n    let desc_val = map\n        .entry(\"description\".to_string())\n        .or_insert_with(|| Value::String(\"\".to_string()));\n    \n    if let Value::String(s) = desc_val {\n        if s.is_empty() {\n            *s = hint;\n        } else if !s.contains(&hint) {\n            *s = format!(\"{} {}\", s, hint);\n        }\n    }\n}\n\n/// [NEW] 将约束字段转化为 description 提示\n/// 在删除约束字段前,将其语义信息保留在描述中,让模型能够理解约束\nfn move_constraints_to_description(map: &mut serde_json::Map<String, Value>) {\n    let mut hints = Vec::new();\n    \n    for (field, label) in CONSTRAINT_FIELDS {\n        if let Some(val) = map.get(*field) {\n            if !val.is_null() {\n                let val_str = if let Some(s) = val.as_str() {\n                    s.to_string()\n                } else {\n                    val.to_string()\n                };\n                hints.push(format!(\"{}: {}\", label, val_str));\n            }\n        }\n    }\n    \n    if !hints.is_empty() {\n        let constraint_hint = format!(\"[Constraint: {}]\", hints.join(\", \"));\n        append_hint_to_description(map, constraint_hint);\n    }\n}\n\n/// [NEW] 计算 Schema 分支的复杂度得分 (用于 anyOf/oneOf 择优)\n/// 评分标准: Object (3) > Array (2) > Scalar (1) > Null (0)\nfn score_schema_option(val: &Value) -> i32 {\n    if let Value::Object(obj) = val {\n        if obj.contains_key(\"properties\")\n            || obj.get(\"type\").and_then(|t| t.as_str()) == Some(\"object\")\n        {\n            return 3;\n        }\n        if obj.contains_key(\"items\") || obj.get(\"type\").and_then(|t| t.as_str()) == Some(\"array\") {\n            return 2;\n        }\n        if let Some(type_str) = obj.get(\"type\").and_then(|t| t.as_str()) {\n            if type_str != \"null\" {\n                return 1;\n            }\n        }\n    }\n    0\n}\n\n\n/// [NEW] 从 anyOf/oneOf 联合类型数组中选取最佳非 null Schema 分支\n/// 返回: (最佳Schema, 所有可能的类型列表)\n/// 参考 CLIProxyAPI 的 selectBest 逻辑\nfn extract_best_schema_from_union(union_array: &Vec<Value>) -> Option<(Value, Vec<String>)> {\n    let mut best_option: Option<&Value> = None;\n    let mut best_score = -1;\n    let mut all_types = Vec::new();\n\n    for item in union_array {\n        let score = score_schema_option(item);\n        \n        // 收集类型信息\n        if let Some(type_str) = get_schema_type_name(item) {\n            if !all_types.contains(&type_str) {\n                all_types.push(type_str);\n            }\n        }\n        \n        if score > best_score {\n            best_score = score;\n            best_option = Some(item);\n        }\n    }\n\n    best_option.cloned().map(|schema| (schema, all_types))\n}\n\n/// [NEW] 获取 Schema 的类型名称\nfn get_schema_type_name(schema: &Value) -> Option<String> {\n    if let Value::Object(obj) = schema {\n        // 优先使用显式的 type 字段\n        if let Some(type_val) = obj.get(\"type\") {\n            if let Some(s) = type_val.as_str() {\n                return Some(s.to_string());\n            }\n        }\n        \n        // 根据结构推断类型\n        if obj.contains_key(\"properties\") {\n            return Some(\"object\".to_string());\n        }\n        if obj.contains_key(\"items\") {\n            return Some(\"array\".to_string());\n        }\n    }\n    \n    None\n}\n\n/// 修正工具调用参数的类型，使其符合 schema 定义\n///\n/// 根据 schema 中的 type 定义，自动转换参数值的类型：\n/// - \"123\" → 123 (string → number/integer)\n/// - \"true\" → true (string → boolean)\n/// - 123 → \"123\" (number → string)\n///\n/// # Arguments\n/// * `args` - 工具调用的参数对象 (会被原地修改)\n/// * `schema` - 工具的参数 schema 定义 (通常是 parameters 对象)\npub fn fix_tool_call_args(args: &mut Value, schema: &Value) {\n    if let Some(properties) = schema.get(\"properties\").and_then(|p| p.as_object()) {\n        if let Some(args_obj) = args.as_object_mut() {\n            for (key, value) in args_obj.iter_mut() {\n                if let Some(prop_schema) = properties.get(key) {\n                    fix_single_arg_recursive(value, prop_schema);\n                }\n            }\n        }\n    }\n}\n\n/// 递归修正单个参数的类型\nfn fix_single_arg_recursive(value: &mut Value, schema: &Value) {\n    // 1. 处理嵌套对象 (properties)\n    if let Some(nested_props) = schema.get(\"properties\").and_then(|p| p.as_object()) {\n        if let Some(value_obj) = value.as_object_mut() {\n            for (key, nested_value) in value_obj.iter_mut() {\n                if let Some(nested_schema) = nested_props.get(key) {\n                    fix_single_arg_recursive(nested_value, nested_schema);\n                }\n            }\n        }\n        return;\n    }\n\n    // 2. 处理数组 (items)\n    let schema_type = schema\n        .get(\"type\")\n        .and_then(|t| t.as_str())\n        .unwrap_or(\"\")\n        .to_lowercase();\n    if schema_type == \"array\" {\n        if let Some(items_schema) = schema.get(\"items\") {\n            if let Some(arr) = value.as_array_mut() {\n                for item in arr {\n                    fix_single_arg_recursive(item, items_schema);\n                }\n            }\n        }\n        return;\n    }\n\n    // 3. 处理基础类型修正\n    match schema_type.as_str() {\n        \"number\" | \"integer\" => {\n            // 字符串 → 数字\n            if let Some(s) = value.as_str() {\n                // [SAFETY] 保护具有前导零的版本号或代码 (如 \"01\", \"007\")，不应转为数字\n                if s.starts_with('0') && s.len() > 1 && !s.starts_with(\"0.\") {\n                    return;\n                }\n\n                // 优先尝试解析为整数\n                if let Ok(i) = s.parse::<i64>() {\n                    *value = Value::Number(serde_json::Number::from(i));\n                } else if let Ok(f) = s.parse::<f64>() {\n                    if let Some(n) = serde_json::Number::from_f64(f) {\n                        *value = Value::Number(n);\n                    }\n                }\n            }\n        }\n        \"boolean\" => {\n            // 字符串 → 布尔\n            if let Some(s) = value.as_str() {\n                match s.to_lowercase().as_str() {\n                    \"true\" | \"1\" | \"yes\" | \"on\" => *value = Value::Bool(true),\n                    \"false\" | \"0\" | \"no\" | \"off\" => *value = Value::Bool(false),\n                    _ => {}\n                }\n            } else if let Some(n) = value.as_i64() {\n                // 数字 1/0 -> 布尔\n                if n == 1 {\n                    *value = Value::Bool(true);\n                } else if n == 0 {\n                    *value = Value::Bool(false);\n                }\n            }\n        }\n        \"string\" => {\n            // 非字符串 → 字符串 (防止客户端误传数字给文本字段)\n            if !value.is_string() && !value.is_null() && !value.is_object() && !value.is_array() {\n                *value = Value::String(value.to_string());\n            }\n        }\n        _ => {}\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    #[test]\n    fn test_clean_json_schema_draft_2020_12() {\n        let mut schema = json!({\n            \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n            \"type\": \"object\",\n            \"properties\": {\n                \"location\": {\n                    \"type\": \"string\",\n                    \"minLength\": 1,\n                    \"format\": \"city\"\n                },\n                // 模拟属性名冲突：pattern 是一个 Object 属性，不应被移除\n                \"pattern\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"regex\": { \"type\": \"string\", \"pattern\": \"^[a-z]+$\" }\n                    }\n                },\n                \"unit\": {\n                    \"type\": [\"string\", \"null\"],\n                    \"default\": \"celsius\"\n                }\n            },\n            \"required\": [\"location\"]\n        });\n\n        clean_json_schema(&mut schema);\n\n        // 1. 验证类型保持小写\n        assert_eq!(schema[\"type\"], \"object\");\n        assert_eq!(schema[\"properties\"][\"location\"][\"type\"], \"string\");\n\n        // 2. 验证标准字段被移除并转为描述 (Robust Constraint Migration)\n        assert!(schema[\"properties\"][\"location\"].get(\"minLength\").is_none());\n        assert!(schema[\"properties\"][\"location\"].get(\"format\").is_none());\n        assert!(schema[\"properties\"][\"location\"][\"description\"]\n            .as_str()\n            .unwrap()\n            .contains(\"[Constraint: minLen: 1, format: city]\"));\n\n        // 3. 验证名为 \"pattern\" 的属性未被误删\n        assert!(schema[\"properties\"].get(\"pattern\").is_some());\n        assert_eq!(schema[\"properties\"][\"pattern\"][\"type\"], \"object\");\n\n        // 4. 验证内部的 pattern 校验字段被移除并转为描述\n        assert!(schema[\"properties\"][\"pattern\"][\"properties\"][\"regex\"]\n            .get(\"pattern\")\n            .is_none());\n        assert!(\n            schema[\"properties\"][\"pattern\"][\"properties\"][\"regex\"][\"description\"]\n                .as_str()\n                .unwrap()\n                .contains(\"[Constraint: pattern: ^[a-z]+$]\")\n        );\n\n        // 5. 验证联合类型被降级为单一类型 (Protobuf 兼容性)\n        assert_eq!(schema[\"properties\"][\"unit\"][\"type\"], \"string\");\n\n        // 6. 验证元数据字段被移除\n        assert!(schema.get(\"$schema\").is_none());\n    }\n\n    #[test]\n    fn test_type_fallback() {\n        // Test [\"string\", \"null\"] -> \"string\"\n        let mut s1 = json!({\"type\": [\"string\", \"null\"]});\n        clean_json_schema(&mut s1);\n        assert_eq!(s1[\"type\"], \"string\");\n\n        // Test [\"integer\", \"null\"] -> \"integer\" (and lowercase check if needed, though usually integer)\n        let mut s2 = json!({\"type\": [\"integer\", \"null\"]});\n        clean_json_schema(&mut s2);\n        assert_eq!(s2[\"type\"], \"integer\");\n    }\n\n    #[test]\n    fn test_flatten_refs() {\n        let mut schema = json!({\n            \"$defs\": {\n                \"Address\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"city\": { \"type\": \"string\" }\n                    }\n                }\n            },\n            \"properties\": {\n                \"home\": { \"$ref\": \"#/$defs/Address\" }\n            }\n        });\n\n        clean_json_schema(&mut schema);\n\n        // 验证引用被展开且类型转为小写\n        assert_eq!(schema[\"properties\"][\"home\"][\"type\"], \"object\");\n        assert_eq!(\n            schema[\"properties\"][\"home\"][\"properties\"][\"city\"][\"type\"],\n            \"string\"\n        );\n    }\n\n    #[test]\n    fn test_clean_json_schema_missing_required() {\n        let mut schema = json!({\n            \"type\": \"object\",\n            \"properties\": {\n                \"existing_prop\": { \"type\": \"string\" }\n            },\n            \"required\": [\"existing_prop\", \"missing_prop\"]\n        });\n\n        clean_json_schema(&mut schema);\n\n        // 验证 missing_prop 被从 required 中移除\n        let required = schema[\"required\"].as_array().unwrap();\n        assert_eq!(required.len(), 1);\n        assert_eq!(required[0].as_str().unwrap(), \"existing_prop\");\n    }\n\n    // [NEW TEST] 验证 anyOf 类型提取\n    #[test]\n    fn test_anyof_type_extraction() {\n        // 测试 FastMCP 风格的 Optional[str] schema\n        let mut schema = json!({\n            \"type\": \"object\",\n            \"properties\": {\n                \"testo\": {\n                    \"anyOf\": [\n                        {\"type\": \"string\"},\n                        {\"type\": \"null\"}\n                    ],\n                    \"default\": null,\n                    \"title\": \"Testo\"\n                },\n                \"importo\": {\n                    \"anyOf\": [\n                        {\"type\": \"number\"},\n                        {\"type\": \"null\"}\n                    ],\n                    \"default\": null,\n                    \"title\": \"Importo\"\n                },\n                \"attivo\": {\n                    \"type\": \"boolean\",\n                    \"title\": \"Attivo\"\n                }\n            }\n        });\n\n        clean_json_schema(&mut schema);\n\n        // 验证 anyOf 被移除\n        assert!(schema[\"properties\"][\"testo\"].get(\"anyOf\").is_none());\n        assert!(schema[\"properties\"][\"importo\"].get(\"anyOf\").is_none());\n\n        // 验证 type 被正确提取\n        assert_eq!(schema[\"properties\"][\"testo\"][\"type\"], \"string\");\n        assert_eq!(schema[\"properties\"][\"importo\"][\"type\"], \"number\");\n        assert_eq!(schema[\"properties\"][\"attivo\"][\"type\"], \"boolean\");\n\n        // 验证 default 被移除 (白名单之外)\n        assert!(schema[\"properties\"][\"testo\"].get(\"default\").is_none());\n    }\n\n    // [NEW TEST] 验证 oneOf 类型提取\n    #[test]\n    fn test_oneof_type_extraction() {\n        let mut schema = json!({\n            \"properties\": {\n                \"value\": {\n                    \"oneOf\": [\n                        {\"type\": \"integer\"},\n                        {\"type\": \"null\"}\n                    ]\n                }\n            }\n        });\n\n        clean_json_schema(&mut schema);\n\n        assert!(schema[\"properties\"][\"value\"].get(\"oneOf\").is_none());\n        assert_eq!(schema[\"properties\"][\"value\"][\"type\"], \"integer\");\n    }\n\n    // [NEW TEST] 验证已有 type 不被覆盖\n    #[test]\n    fn test_existing_type_preserved() {\n        let mut schema = json!({\n            \"properties\": {\n                \"name\": {\n                    \"type\": \"string\",\n                    \"anyOf\": [\n                        {\"type\": \"number\"}\n                    ]\n                }\n            }\n        });\n\n        clean_json_schema(&mut schema);\n\n        // type 已存在，不应被 anyOf 中的类型覆盖\n        assert_eq!(schema[\"properties\"][\"name\"][\"type\"], \"string\");\n        assert!(schema[\"properties\"][\"name\"].get(\"anyOf\").is_none());\n    }\n\n    // [NEW TEST] 验证 Issue #815: anyOf 内部属性不丢失\n    #[test]\n    fn test_issue_815_anyof_properties_preserved() {\n        let mut schema = json!({\n            \"type\": \"object\",\n            \"properties\": {\n                \"config\": {\n                    \"anyOf\": [\n                        {\n                            \"type\": \"object\",\n                            \"properties\": {\n                                \"path\": { \"type\": \"string\" },\n                                \"recursive\": { \"type\": \"boolean\" }\n                            },\n                            \"required\": [\"path\"]\n                        },\n                        { \"type\": \"null\" }\n                    ]\n                }\n            }\n        });\n\n        clean_json_schema(&mut schema);\n\n        let config = &schema[\"properties\"][\"config\"];\n\n        // 1. 验证类型被提取\n        assert_eq!(config[\"type\"], \"object\");\n\n        // 2. 验证 anyOf 内部的 properties 被合并上来了\n        assert!(config.get(\"properties\").is_some());\n        assert_eq!(config[\"properties\"][\"path\"][\"type\"], \"string\");\n        assert_eq!(config[\"properties\"][\"recursive\"][\"type\"], \"boolean\");\n\n        // 3. 验证 required 被合并上来了\n        let req = config[\"required\"].as_array().unwrap();\n        assert!(req.iter().any(|v| v == \"path\"));\n\n        // 4. 验证 anyOf 字段本身被移除\n        assert!(config.get(\"anyOf\").is_none());\n\n        // 5. 验证没有因为“空”而注入 reason (因为我们保留了属性)\n        assert!(config[\"properties\"].get(\"reason\").is_none());\n    }\n\n    // [NEW TEST] 验证安全检查：不应处理非 Schema 对象（保护工具调用）\n    #[test]\n    fn test_clean_json_schema_on_non_schema_object() {\n        // 模拟 request.rs 中转换了一半的 functionCall 对象\n        let mut tool_call = json!({\n            \"functionCall\": {\n                \"name\": \"local_shell_call\",\n                \"args\": { \"command\": [\"ls\"] },\n                \"id\": \"call_123\"\n            }\n        });\n\n        // 调用清洗逻辑\n        clean_json_schema(&mut tool_call);\n\n        // 验证：这些非 Schema 字段不应被移除（因为不符合 looks_like_schema 判定）\n        let fc = &tool_call[\"functionCall\"];\n        assert_eq!(fc[\"name\"], \"local_shell_call\");\n        assert_eq!(fc[\"args\"][\"command\"][0], \"ls\");\n        assert_eq!(fc[\"id\"], \"call_123\");\n    }\n\n    // [NEW TEST] 验证 Nullable 处理\n    #[test]\n    fn test_nullable_handling_with_description() {\n        let mut schema = json!({\n            \"type\": [\"string\", \"null\"],\n            \"description\": \"User name\"\n        });\n\n        clean_json_schema(&mut schema);\n\n        // 验证 type 被降级，且描述被追加 (nullable)\n        assert_eq!(schema[\"type\"], \"string\");\n        assert!(schema[\"description\"]\n            .as_str()\n            .unwrap()\n            .contains(\"User name\"));\n        assert!(schema[\"description\"]\n            .as_str()\n            .unwrap()\n            .contains(\"(nullable)\"));\n    }\n\n    // [NEW TEST] 验证 anyOf 内部的 propertyNames 被移除\n    #[test]\n    fn test_clean_anyof_with_propertynames() {\n        let mut schema = json!({\n            \"properties\": {\n                \"config\": {\n                    \"anyOf\": [\n                        {\n                            \"type\": \"object\",\n                            \"propertyNames\": {\"pattern\": \"^[a-z]+$\"},\n                            \"properties\": {\n                                \"key\": {\"type\": \"string\"}\n                            }\n                        },\n                        {\"type\": \"null\"}\n                    ]\n                }\n            }\n        });\n\n        clean_json_schema(&mut schema);\n\n        // 验证 anyOf 被移除（已被合并）\n        let config = &schema[\"properties\"][\"config\"];\n        assert!(config.get(\"anyOf\").is_none());\n\n        // 验证 propertyNames 被移除\n        assert!(config.get(\"propertyNames\").is_none());\n\n        // 验证合并后的 properties 存在且没有 propertyNames\n        assert!(config.get(\"properties\").is_some());\n        assert_eq!(config[\"properties\"][\"key\"][\"type\"], \"string\");\n    }\n\n    // [NEW TEST] 验证 items 数组中的 const 被移除\n    #[test]\n    fn test_clean_items_array_with_const() {\n        let mut schema = json!({\n            \"type\": \"array\",\n            \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"status\": {\n                        \"const\": \"active\",\n                        \"type\": \"string\"\n                    }\n                }\n            }\n        });\n\n        clean_json_schema(&mut schema);\n\n        // 验证 const 被移除\n        let status = &schema[\"items\"][\"properties\"][\"status\"];\n        assert!(status.get(\"const\").is_none());\n\n        // 验证 type 仍然存在\n        assert_eq!(status[\"type\"], \"string\");\n    }\n\n    // [NEW TEST] 验证多层嵌套数组的清理\n    #[test]\n    fn test_deep_nested_array_cleaning() {\n        let mut schema = json!({\n            \"properties\": {\n                \"data\": {\n                    \"anyOf\": [\n                        {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"anyOf\": [\n                                    {\n                                        \"type\": \"object\",\n                                        \"propertyNames\": {\"maxLength\": 10},\n                                        \"const\": \"test\",\n                                        \"properties\": {\n                                            \"name\": {\"type\": \"string\"}\n                                        }\n                                    },\n                                    {\"type\": \"null\"}\n                                ]\n                            }\n                        }\n                    ]\n                }\n            }\n        });\n\n        clean_json_schema(&mut schema);\n\n        // 验证深层嵌套的非法字段都被移除\n        let data = &schema[\"properties\"][\"data\"];\n\n        // anyOf 应该被合并移除\n        assert!(data.get(\"anyOf\").is_none());\n\n        // 验证没有 propertyNames 和 const 逃逸到顶层\n        assert!(data.get(\"propertyNames\").is_none());\n        assert!(data.get(\"const\").is_none());\n\n        // 验证结构被正确保留\n        assert_eq!(data[\"type\"], \"array\");\n        if let Some(items) = data.get(\"items\") {\n            // items 内部的 anyOf 也应该被合并\n            assert!(items.get(\"anyOf\").is_none());\n            assert!(items.get(\"propertyNames\").is_none());\n            assert!(items.get(\"const\").is_none());\n        }\n    }\n\n    #[test]\n    fn test_fix_tool_call_args() {\n        let mut args = serde_json::json!({\n            \"port\": \"8080\",\n            \"enabled\": \"true\",\n            \"timeout\": \"5.5\",\n            \"metadata\": {\n                \"retry\": \"3\"\n            },\n            \"tags\": [\"1\", \"2\"]\n        });\n\n        let schema = serde_json::json!({\n            \"properties\": {\n                \"port\": { \"type\": \"integer\" },\n                \"enabled\": { \"type\": \"boolean\" },\n                \"timeout\": { \"type\": \"number\" },\n                \"metadata\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"retry\": { \"type\": \"integer\" }\n                    }\n                },\n                \"tags\": {\n                    \"type\": \"array\",\n                    \"items\": { \"type\": \"integer\" }\n                }\n            }\n        });\n\n        fix_tool_call_args(&mut args, &schema);\n\n        assert_eq!(args[\"port\"], 8080);\n        assert_eq!(args[\"enabled\"], true);\n        assert_eq!(args[\"timeout\"], 5.5);\n        assert_eq!(args[\"metadata\"][\"retry\"], 3);\n        assert_eq!(args[\"tags\"], serde_json::json!([1, 2]));\n    }\n\n    #[test]\n    fn test_fix_tool_call_args_protection() {\n        let mut args = serde_json::json!({\n            \"version\": \"01.0\",\n            \"code\": \"007\"\n        });\n\n        let schema = serde_json::json!({\n            \"properties\": {\n                \"version\": { \"type\": \"number\" },\n                \"code\": { \"type\": \"integer\" }\n            }\n        });\n\n        fix_tool_call_args(&mut args, &schema);\n\n        // 应保留字符串以防破坏语义\n        assert_eq!(args[\"version\"], \"01.0\");\n        assert_eq!(args[\"code\"], \"007\");\n    }\n\n    // [NEW TEST #952] 验证嵌套层级的 $defs 能被正确收集和展开\n    #[test]\n    fn test_nested_defs_flattening() {\n        // MCP 工具常常将 $defs 嵌套在 properties 内部，而非根层级\n        let mut schema = json!({\n            \"type\": \"object\",\n            \"properties\": {\n                \"config\": {\n                    \"$defs\": {\n                        \"Address\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                                \"city\": { \"type\": \"string\" },\n                                \"zip\": { \"type\": \"string\" }\n                            }\n                        }\n                    },\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"home\": { \"$ref\": \"#/$defs/Address\" },\n                        \"work\": { \"$ref\": \"#/$defs/Address\" }\n                    }\n                }\n            }\n        });\n\n        clean_json_schema(&mut schema);\n\n        // 验证嵌套的 $ref 被正确解析\n        let home = &schema[\"properties\"][\"config\"][\"properties\"][\"home\"];\n        assert_eq!(\n            home[\"type\"], \"object\",\n            \"home should have type 'object' from resolved $ref\"\n        );\n        assert_eq!(\n            home[\"properties\"][\"city\"][\"type\"], \"string\",\n            \"home.properties.city should exist from resolved Address\"\n        );\n\n        // 验证没有残留的 $ref\n        assert!(\n            home.get(\"$ref\").is_none(),\n            \"home should not have orphan $ref\"\n        );\n\n        // 验证 work 也被正确解析\n        let work = &schema[\"properties\"][\"config\"][\"properties\"][\"work\"];\n        assert_eq!(work[\"type\"], \"object\");\n        assert!(work.get(\"$ref\").is_none());\n    }\n\n    // [NEW TEST #952] 验证无法解析的 $ref 被优雅降级\n    #[test]\n    fn test_unresolved_ref_fallback() {\n        let mut schema = json!({\n            \"type\": \"object\",\n            \"properties\": {\n                \"external\": { \"$ref\": \"https://example.com/schemas/External.json\" },\n                \"missing\": { \"$ref\": \"#/$defs/NonExistent\" }\n            }\n        });\n\n        clean_json_schema(&mut schema);\n\n        // 验证外部引用被降级为 string 类型\n        let external = &schema[\"properties\"][\"external\"];\n        assert_eq!(\n            external[\"type\"], \"string\",\n            \"unresolved external $ref should fallback to string\"\n        );\n        assert!(\n            external[\"description\"]\n                .as_str()\n                .unwrap()\n                .contains(\"Unresolved $ref\"),\n            \"description should contain unresolved $ref hint\"\n        );\n\n        // 验证内部缺失引用也被降级\n        let missing = &schema[\"properties\"][\"missing\"];\n        assert_eq!(missing[\"type\"], \"string\");\n        assert!(missing[\"description\"]\n            .as_str()\n            .unwrap()\n            .contains(\"NonExistent\"));\n    }\n\n    // [NEW TEST #952] 验证深层嵌套的多级 $defs 都能被收集\n    #[test]\n    fn test_deeply_nested_multi_level_defs() {\n        let mut schema = json!({\n            \"type\": \"object\",\n            \"$defs\": {\n                \"RootDef\": { \"type\": \"integer\" }\n            },\n            \"properties\": {\n                \"level1\": {\n                    \"type\": \"object\",\n                    \"$defs\": {\n                        \"Level1Def\": { \"type\": \"boolean\" }\n                    },\n                    \"properties\": {\n                        \"level2\": {\n                            \"type\": \"object\",\n                            \"$defs\": {\n                                \"Level2Def\": { \"type\": \"number\" }\n                            },\n                            \"properties\": {\n                                \"useRoot\": { \"$ref\": \"#/$defs/RootDef\" },\n                                \"useLevel1\": { \"$ref\": \"#/$defs/Level1Def\" },\n                                \"useLevel2\": { \"$ref\": \"#/$defs/Level2Def\" }\n                            }\n                        }\n                    }\n                }\n            }\n        });\n\n        clean_json_schema(&mut schema);\n\n        let level2_props = &schema[\"properties\"][\"level1\"][\"properties\"][\"level2\"][\"properties\"];\n\n        // 验证所有层级的 $defs 都被正确解析\n        assert_eq!(\n            level2_props[\"useRoot\"][\"type\"], \"integer\",\n            \"RootDef should resolve\"\n        );\n        assert_eq!(\n            level2_props[\"useLevel1\"][\"type\"], \"boolean\",\n            \"Level1Def should resolve\"\n        );\n        assert_eq!(\n            level2_props[\"useLevel2\"][\"type\"], \"number\",\n            \"Level2Def should resolve\"\n        );\n\n        // 验证没有残留 $ref\n        assert!(level2_props[\"useRoot\"].get(\"$ref\").is_none());\n        assert!(level2_props[\"useLevel1\"].get(\"$ref\").is_none());\n        assert!(level2_props[\"useLevel2\"].get(\"$ref\").is_none());\n    }\n\n    // [NEW TEST] 验证对非标准字段（如 cornerRadius）的清洗和启发式修复\n    #[test]\n    fn test_non_standard_field_cleaning_and_healing() {\n        let mut schema = json!({\n            \"type\": \"array\",\n            \"items\": {\n                \"cornerRadius\": { \"type\": \"number\" },\n                \"fillColor\": { \"type\": \"string\" }\n            }\n        });\n\n        clean_json_schema(&mut schema);\n\n        // 验证 items 中的非标准字段被移动到了 properties 内部，并增加了 type: object\n        let items = &schema[\"items\"];\n        assert_eq!(items[\"type\"], \"object\", \"Malformed items should be healed to type object\");\n        assert!(items.get(\"properties\").is_some(), \"Malformed items should have properties object\");\n        assert_eq!(items[\"properties\"][\"cornerRadius\"][\"type\"], \"number\");\n        assert_eq!(items[\"properties\"][\"fillColor\"][\"type\"], \"string\");\n        \n        // 验证原始字段已从 items 顶层移除（白名单过滤）\n        assert!(items.get(\"cornerRadius\").is_none());\n        assert!(items.get(\"fillColor\").is_none());\n    }\n\n    // [NEW TEST] 验证隐式 Array (只有 items) 和隐式 Object (只有 properties) 的处理\n    #[test]\n    fn test_implicit_type_injection() {\n        let mut schema = json!({\n            \"properties\": {\n                \"values\": {\n                    \"items\": {\n                        \"cornerRadius\": { \"type\": \"number\" }\n                    }\n                }\n            }\n        });\n\n        clean_json_schema(&mut schema);\n\n        // 验证 values 被注入了 type: array\n        assert_eq!(schema[\"properties\"][\"values\"][\"type\"], \"array\");\n        \n        // 验证 items 被启发式修复为 type: object 并包含 properties\n        let items = &schema[\"properties\"][\"values\"][\"items\"];\n        assert_eq!(items[\"type\"], \"object\");\n        assert!(items[\"properties\"].get(\"cornerRadius\").is_some());\n    }\n\n    #[test]\n    fn test_gemini_strict_validation_injection() {\n        let mut schema = json!({\n            \"type\": \"object\",\n            \"properties\": {\n                \"patterns\": {\n                    \"items\": {\n                        \"properties\": {\n                            \"type\": {\n                                \"enum\": [\"A\", \"B\"]\n                            }\n                        }\n                    }\n                },\n                \"nested_props\": {\n                    \"properties\": {\n                        \"foo\": { \"type\": \"string\" }\n                    }\n                }\n            }\n        });\n\n        clean_json_schema(&mut schema);\n\n        // 验证 enum 自动补全了 type: string\n        let type_node = &schema[\"properties\"][\"patterns\"][\"items\"][\"properties\"][\"type\"];\n        assert_eq!(type_node[\"type\"], \"string\");\n        assert!(type_node.get(\"enum\").is_some());\n\n        // 验证 嵌套 properties 自动补全了 type: object\n        assert_eq!(schema[\"properties\"][\"nested_props\"][\"type\"], \"object\");\n\n        // 验证 patterns 自动补全了 type: array\n        assert_eq!(schema[\"properties\"][\"patterns\"][\"type\"], \"array\");\n    }\n    #[test]\n    fn test_malformed_items_as_properties() {\n        let mut schema = json!({\n            \"type\": \"object\",\n            \"properties\": {\n                \"config\": {\n                    \"type\": \"object\",\n                    \"items\": {\n                        \"color\": { \"type\": \"string\" },\n                        \"size\": { \"type\": \"number\" }\n                    }\n                }\n            }\n        });\n\n        clean_json_schema(&mut schema);\n\n        // 验证 items 被移除并转换为 properties\n        let config = &schema[\"properties\"][\"config\"];\n        assert!(config.get(\"items\").is_none());\n        assert_eq!(config[\"properties\"][\"color\"][\"type\"], \"string\");\n        assert_eq!(config[\"properties\"][\"size\"][\"type\"], \"number\");\n        assert_eq!(config[\"type\"], \"object\");\n    }\n\n    #[test]\n    fn test_circular_ref_flattening() {\n        // 模拟循环引用：A -> B, B -> A\n        let mut schema = json!({\n            \"$defs\": {\n                \"A\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"toB\": { \"$ref\": \"#/$defs/B\" }\n                    }\n                },\n                \"B\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"toA\": { \"$ref\": \"#/$defs/A\" }\n                    }\n                }\n            },\n            \"properties\": {\n                \"start\": { \"$ref\": \"#/$defs/A\" }\n            }\n        });\n\n        // 如果没有深度限制，这里会发生栈溢出\n        // 有了深度限制，它应该能正常返回（尽管展开是不完整的）\n        clean_json_schema(&mut schema);\n\n        // 验证基本结构保留，没有崩溃\n        assert_eq!(schema[\"properties\"][\"start\"][\"type\"], \"object\");\n        assert!(schema[\"properties\"][\"start\"][\"properties\"].get(\"toB\").is_some());\n    }\n\n    #[test]\n    fn test_any_of_best_branch_selection() {\n        let mut schema = json!({\n            \"anyOf\": [\n                { \"type\": \"string\" },\n                { \"type\": \"object\", \"properties\": { \"foo\": { \"type\": \"string\" } } },\n                { \"type\": \"null\" }\n            ]\n        });\n\n        clean_json_schema(&mut schema);\n\n        // 验证选择了分数最高的 Object 分支\n        assert_eq!(schema[\"type\"], \"object\");\n        assert!(schema.get(\"properties\").is_some());\n        assert_eq!(schema[\"properties\"][\"foo\"][\"type\"], \"string\");\n        \n        // 验证描述中增加了类型提示 (注意: null 分支在清洗后变为了带 (nullable) 标记的 string，因此去重后为 string | object)\n        assert!(schema[\"description\"].as_str().unwrap().contains(\"Accepts: string | object\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/common/mod.rs",
    "content": "// Common 模块 - 公共工具\n\n// pub mod error;\n// pub mod rate_limiter;\npub mod model_mapping;\npub mod utils;\npub mod json_schema;\npub mod tool_adapter;\npub mod tool_adapters;\npub mod schema_cache;\npub mod client_adapter;\npub mod client_adapters;\npub mod session; // [ADDED v4.1.24] Tools for deriving stable session identifiers\n"
  },
  {
    "path": "src-tauri/src/proxy/common/model_mapping.rs",
    "content": "// 模型名称映射\nuse std::collections::HashMap;\nuse once_cell::sync::Lazy;\nuse dashmap::DashMap;\n\n// 动态官方废弃模型转发表 (old_model_id -> new_model_id)\npub static DYNAMIC_MODEL_FORWARDING_RULES: Lazy<DashMap<String, String>> = Lazy::new(|| DashMap::new());\n\npub fn update_dynamic_forwarding_rules(old_model: String, new_model: String) {\n    if !DYNAMIC_MODEL_FORWARDING_RULES.contains_key(&old_model) {\n        crate::modules::logger::log_info(&format!(\"[Mapping] Registered automatic forwarding rule: {} -> {}\", old_model, new_model));\n    }\n    DYNAMIC_MODEL_FORWARDING_RULES.insert(old_model, new_model);\n}\n\nstatic CLAUDE_TO_GEMINI: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| {\n    let mut m = HashMap::new();\n\n    // 直接支持的模型\n    m.insert(\"claude-sonnet-4-6\", \"claude-sonnet-4-6\");\n    m.insert(\"claude-sonnet-4-6-thinking\", \"claude-sonnet-4-6-thinking\");\n\n    // [Redirect] Sonnet 4.5 -> Sonnet 4.6\n    m.insert(\"claude-sonnet-4-5\", \"claude-sonnet-4-6\");\n    m.insert(\"claude-sonnet-4-5-thinking\", \"claude-sonnet-4-6-thinking\");\n\n    // 别名映射\n    m.insert(\"claude-sonnet-4-5-20250929\", \"claude-sonnet-4-6-thinking\");\n    m.insert(\"claude-3-5-sonnet-20241022\", \"claude-sonnet-4-6\");\n    m.insert(\"claude-3-5-sonnet-20240620\", \"claude-sonnet-4-6\");\n    // [Redirect] Opus 4.5 -> Opus 4.6 (Issue #1743)\n    m.insert(\"claude-opus-4\", \"claude-opus-4-6-thinking\");\n    m.insert(\"claude-opus-4-5-thinking\", \"claude-opus-4-6-thinking\");\n    m.insert(\"claude-opus-4-5-20251101\", \"claude-opus-4-6-thinking\");\n\n    // Claude Opus 4.6\n    m.insert(\"claude-opus-4-6-thinking\", \"claude-opus-4-6-thinking\");\n    m.insert(\"claude-opus-4-6\", \"claude-opus-4-6-thinking\");\n    m.insert(\"claude-opus-4-6-20260201\", \"claude-opus-4-6-thinking\");\n\n    m.insert(\"claude-haiku-4\", \"claude-sonnet-4-6\");\n    m.insert(\"claude-3-haiku-20240307\", \"claude-sonnet-4-6\");\n    m.insert(\"claude-haiku-4-5-20251001\", \"claude-sonnet-4-6\");\n    // OpenAI 协议映射表\n    m.insert(\"gpt-4\", \"gemini-2.5-flash\");\n    m.insert(\"gpt-4-turbo\", \"gemini-2.5-flash\");\n    m.insert(\"gpt-4-turbo-preview\", \"gemini-2.5-flash\");\n    m.insert(\"gpt-4-0125-preview\", \"gemini-2.5-flash\");\n    m.insert(\"gpt-4-1106-preview\", \"gemini-2.5-flash\");\n    m.insert(\"gpt-4-0613\", \"gemini-2.5-flash\");\n\n    m.insert(\"gpt-4o\", \"gemini-2.5-flash\");\n    m.insert(\"gpt-4o-2024-05-13\", \"gemini-2.5-flash\");\n    m.insert(\"gpt-4o-2024-08-06\", \"gemini-2.5-flash\");\n\n    m.insert(\"gpt-4o-mini\", \"gemini-2.5-flash\");\n    m.insert(\"gpt-4o-mini-2024-07-18\", \"gemini-2.5-flash\");\n\n    m.insert(\"gpt-3.5-turbo\", \"gemini-2.5-flash\");\n    m.insert(\"gpt-3.5-turbo-16k\", \"gemini-2.5-flash\");\n    m.insert(\"gpt-3.5-turbo-0125\", \"gemini-2.5-flash\");\n    m.insert(\"gpt-3.5-turbo-1106\", \"gemini-2.5-flash\");\n    m.insert(\"gpt-3.5-turbo-0613\", \"gemini-2.5-flash\");\n\n    // Gemini 协议映射表\n    m.insert(\"gemini-2.5-flash-lite\", \"gemini-2.5-flash\");\n    m.insert(\"gemini-2.5-flash-thinking\", \"gemini-2.5-flash-thinking\");\n    // Gemini Pro family:\n    // - Concrete model IDs should pass through unchanged.\n    // - Generic aliases (without tier) still route to preview as fallback entrypoint.\n    m.insert(\"gemini-3.1-pro-low\", \"gemini-3.1-pro-low\");\n    m.insert(\"gemini-3.1-pro-high\", \"gemini-3.1-pro-high\");\n    m.insert(\"gemini-3.1-pro-preview\", \"gemini-3.1-pro-preview\");\n    m.insert(\"gemini-3.1-pro\", \"gemini-3.1-pro-preview\");\n    m.insert(\"gemini-3-pro-low\", \"gemini-3-pro-low\");\n    m.insert(\"gemini-3-pro-high\", \"gemini-3-pro-high\");\n    m.insert(\"gemini-3-pro-preview\", \"gemini-3-pro-preview\");\n    m.insert(\"gemini-3-pro\", \"gemini-3-pro-preview\");\n    m.insert(\"gemini-2.5-flash\", \"gemini-2.5-flash\");\n    m.insert(\"gemini-3-flash\", \"gemini-3-flash\");\n    m.insert(\"gemini-3-pro-image\", \"gemini-3-pro-image\");\n\n    // [New] Unified Virtual ID for Background Tasks (Title, Summary, etc.)\n    // Allows users to override all background tasks via custom_mapping\n    m.insert(\"internal-background-task\", \"gemini-2.5-flash\");\n\n\n    m\n});\n\n\n/// Map Claude model names to Gemini model names\n/// \n/// # 映射策略\n/// 1. **精确匹配**: 检查 CLAUDE_TO_GEMINI 映射表\n/// 2. **已知前缀透传**: gemini-* 和 *-thinking 模型直接透传\n/// 3. **[NEW] 直接透传**: 未知模型 ID 直接传递给 Google API (支持体验未发布模型)\n/// \n/// # 参数\n/// - `input`: 原始模型名称\n/// \n/// # 返回\n/// 映射后的目标模型名称\n/// \n/// # 示例\n/// ```\n/// // 精确匹配\n/// assert_eq!(map_claude_model_to_gemini(\"claude-opus-4\"), \"claude-opus-4-5-thinking\");\n/// \n/// // Gemini 模型透传\n/// assert_eq!(map_claude_model_to_gemini(\"gemini-2.5-flash\"), \"gemini-2.5-flash\");\n/// \n/// // 直接透传未知模型 (NEW!)\n/// assert_eq!(map_claude_model_to_gemini(\"claude-opus-4-6\"), \"claude-opus-4-6\");\n/// assert_eq!(map_claude_model_to_gemini(\"claude-sonnet-5\"), \"claude-sonnet-5\");\n/// ```\npub fn map_claude_model_to_gemini(input: &str) -> String {\n    // 1. Check exact match in map\n    if let Some(mapped) = CLAUDE_TO_GEMINI.get(input) {\n        return mapped.to_string();\n    }\n\n    // 2. Pass-through known prefixes (gemini-, -thinking) to support dynamic suffixes\n    if input.starts_with(\"gemini-\") || input.contains(\"thinking\") {\n        return input.to_string();\n    }\n\n\n    // 3. [ENHANCED] 直接透传未知模型 ID,而不是强制 fallback\n    // 这允许用户通过自定义映射体验未发布的模型 (如 claude-opus-4-6)\n    // Google API 会自动处理无效模型并返回错误,用户可以根据错误调整映射\n    input.to_string()\n}\n\n/// 获取所有内置支持的模型列表关键字\npub fn get_supported_models() -> Vec<String> {\n    CLAUDE_TO_GEMINI.keys().map(|s| s.to_string()).collect()\n}\n\n/// 动态获取所有可用模型列表 (包含内置与用户自定义与官方端点动态下发)\npub async fn get_all_dynamic_models(\n    custom_mapping: &tokio::sync::RwLock<std::collections::HashMap<String, String>>,\n    token_manager: Option<&crate::proxy::token_manager::TokenManager>,\n) -> Vec<String> {\n    use std::collections::HashSet;\n    let mut model_ids = HashSet::new();\n\n    // 1. 获取所有内置映射模型\n    for m in get_supported_models() {\n        model_ids.insert(m);\n    }\n\n    // 2. 获取所有自定义映射模型 (Custom)\n    {\n        let mapping = custom_mapping.read().await;\n        for key in mapping.keys() {\n            model_ids.insert(key.clone());\n        }\n    }\n\n    // 3. [NEW] 获取所有账号从官方接口汇聚而来的动态模型\n    if let Some(tm) = token_manager {\n        for dynamic_model in tm.get_all_collected_models() {\n            model_ids.insert(dynamic_model);\n        }\n    }\n\n    // 5. 确保包含常用的 Gemini/画画模型 ID\n    model_ids.insert(\"gemini-3.1-pro-low\".to_string());\n    \n    // [NEW] Issue #247: Dynamically generate all Image Gen Combinations\n    let base = \"gemini-3-pro-image\";\n    let resolutions = vec![\"\", \"-2k\", \"-4k\"];\n    let ratios = vec![\"\", \"-1x1\", \"-4x3\", \"-3x4\", \"-16x9\", \"-9x16\", \"-21x9\"];\n    \n    for res in resolutions {\n        for ratio in ratios.iter() {\n            let mut id = base.to_string();\n            id.push_str(res);\n            id.push_str(ratio);\n            model_ids.insert(id);\n        }\n    }\n\n    model_ids.insert(\"gemini-2.0-flash-exp\".to_string());\n    model_ids.insert(\"gemini-2.5-flash\".to_string());\n    // gemini-2.5-pro removed \n    model_ids.insert(\"gemini-3-flash\".to_string());\n    model_ids.insert(\"gemini-3.1-pro-high\".to_string());\n    model_ids.insert(\"gemini-3.1-pro-low\".to_string());\n\n\n    let mut sorted_ids: Vec<_> = model_ids.into_iter().collect();\n    sorted_ids.sort();\n    sorted_ids\n}\n\n/// Wildcard matching - supports multiple wildcards\n///\n/// **Note**: Matching is **case-sensitive**. Pattern `GPT-4*` will NOT match `gpt-4-turbo`.\n///\n/// Examples:\n/// - `gpt-4*` matches `gpt-4`, `gpt-4-turbo` ✓\n/// - `claude-*-sonnet-*` matches `claude-3-5-sonnet-20241022` ✓\n/// - `*-thinking` matches `claude-opus-4-5-thinking` ✓\n/// - `a*b*c` matches `a123b456c` ✓\nfn wildcard_match(pattern: &str, text: &str) -> bool {\n    let parts: Vec<&str> = pattern.split('*').collect();\n\n    // No wildcard - exact match\n    if parts.len() == 1 {\n        return pattern == text;\n    }\n\n    let mut text_pos = 0;\n\n    for (i, part) in parts.iter().enumerate() {\n        if part.is_empty() {\n            continue; // Skip empty segments from consecutive wildcards\n        }\n\n        if i == 0 {\n            // First segment must match start\n            if !text[text_pos..].starts_with(part) {\n                return false;\n            }\n            text_pos += part.len();\n        } else if i == parts.len() - 1 {\n            // Last segment must match end\n            return text[text_pos..].ends_with(part);\n        } else {\n            // Middle segments - find next occurrence\n            if let Some(pos) = text[text_pos..].find(part) {\n                text_pos += pos + part.len();\n            } else {\n                return false;\n            }\n        }\n    }\n\n    true\n}\n\n/// 核心模型路由解析引擎\n/// 优先级：精确匹配 > 通配符匹配 > 系统默认映射\n/// \n/// # 参数\n/// - `original_model`: 原始模型名称\n/// - `custom_mapping`: 用户自定义映射表\n/// \n/// # 返回\n/// 映射后的目标模型名称\npub fn resolve_model_route(\n    original_model: &str,\n    custom_mapping: &std::collections::HashMap<String, String>,\n) -> String {\n    // 0. API 热更新废弃模型转发 (最高物理优先级，强制纠正)\n    // 如果用户非要用已经被移除的模型，并且官方下发了 fallback path，我们在此拦截并纠正\n    if let Some(forwarded) = DYNAMIC_MODEL_FORWARDING_RULES.get(original_model) {\n        crate::modules::logger::log_info(&format!(\"[Router] 官方淘汰重定向: {} -> {}\", original_model, forwarded.value()));\n        return forwarded.value().clone();\n    }\n\n    // 1. 精确匹配 (次高优先级)\n    if let Some(target) = custom_mapping.get(original_model) {\n        crate::modules::logger::log_info(&format!(\"[Router] 精确映射: {} -> {}\", original_model, target));\n        return target.clone();\n    }\n    \n    // 2. Wildcard match - most specific (highest non-wildcard chars) wins\n    // Note: When multiple patterns have the SAME specificity, HashMap iteration order\n    // determines the result (non-deterministic). Users can avoid this by making patterns\n    // more specific. Future improvement: use IndexMap + frontend sorting for full control.\n    let mut best_match: Option<(&str, &str, usize)> = None;\n\n    for (pattern, target) in custom_mapping.iter() {\n        if pattern.contains('*') && wildcard_match(pattern, original_model) {\n            let specificity = pattern.chars().count() - pattern.matches('*').count();\n            if best_match.is_none() || specificity > best_match.unwrap().2 {\n                best_match = Some((pattern.as_str(), target.as_str(), specificity));\n            }\n        }\n    }\n\n    if let Some((pattern, target, _)) = best_match {\n        crate::modules::logger::log_info(&format!(\n            \"[Router] Wildcard match: {} -> {} (rule: {})\",\n            original_model, target, pattern\n        ));\n        return target.to_string();\n    }\n    \n    // 3. 系统默认映射\n    let result = map_claude_model_to_gemini(original_model);\n    if result != original_model {\n        crate::modules::logger::log_info(&format!(\"[Router] 系统默认映射: {} -> {}\", original_model, result));\n    }\n    result\n}\n\n/// Normalize any physical model name to one of the 3 standard protection IDs.\n/// This ensures quota protection works consistently regardless of API versioning or request variations.\n/// \n/// Standard IDs:\n/// - `gemini-3-flash`: All Flash variants (1.5-flash, 2.5-flash, 3-flash, etc.)\n/// - `gemini-3-pro-high`: All Pro variants (1.5-pro, 2.5-pro, etc.)\n/// - `claude-sonnet-4-5`: All Claude Sonnet variants (3-5-sonnet, sonnet-4-5, etc.)\n/// \n/// Returns `None` if the model doesn't match any of the 3 protected categories.\npub fn normalize_to_standard_id(model_name: &str) -> Option<String> {\n    let lower = model_name.to_lowercase();\n    \n    // 1. image 资源 (优先匹配，使用 contains 匹配以支持任何变体，如 gemini-3.1-flash-image)\n    if lower.contains(\"image\") {\n        return Some(\"gemini-3-pro-image\".to_string());\n    }\n\n    // 2. gemini-3-flash (包含所有 flash 变体)\n    if lower.contains(\"flash\") {\n        return Some(\"gemini-3-flash\".to_string());\n    }\n\n    // 3. gemini-3-pro-high (包含 pro 变体)\n    if lower.contains(\"pro\") && !lower.contains(\"image\") {\n        return Some(\"gemini-3-pro-high\".to_string());\n    }\n\n    // 4. Claude 系列 (合并 Opus, Sonnet, Haiku 为统一保护组 'claude')\n    if lower.contains(\"claude\") || lower.contains(\"opus\") || lower.contains(\"sonnet\") || lower.contains(\"haiku\") {\n        return Some(\"claude\".to_string());\n    }\n\n    None\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_model_mapping() {\n        assert_eq!(\n            map_claude_model_to_gemini(\"claude-3-5-sonnet-20241022\"),\n            \"claude-sonnet-4-6\"\n        );\n        // [Redirect] Sonnet 4.5 -> Sonnet 4.6\n        assert_eq!(\n            map_claude_model_to_gemini(\"claude-sonnet-4-5\"),\n            \"claude-sonnet-4-6\"\n        );\n        assert_eq!(\n            map_claude_model_to_gemini(\"claude-sonnet-4-5-thinking\"),\n            \"claude-sonnet-4-6-thinking\"\n        );\n        assert_eq!(\n            map_claude_model_to_gemini(\"claude-opus-4\"),\n            \"claude-opus-4-6-thinking\"\n        );\n        // Test gemini pass-through (should not be caught by \"mini\" rule)\n        assert_eq!(\n            map_claude_model_to_gemini(\"gemini-2.5-flash-mini-test\"),\n            \"gemini-2.5-flash-mini-test\"\n        );\n        assert_eq!(map_claude_model_to_gemini(\"unknown-model\"), \"unknown-model\");\n        // Gemini Pro concrete IDs should pass through unchanged.\n        assert_eq!(\n            map_claude_model_to_gemini(\"gemini-3-pro-high\"),\n            \"gemini-3-pro-high\"\n        );\n        assert_eq!(\n            map_claude_model_to_gemini(\"gemini-3-pro-low\"),\n            \"gemini-3-pro-low\"\n        );\n        assert_eq!(\n            map_claude_model_to_gemini(\"gemini-3.1-pro-high\"),\n            \"gemini-3.1-pro-high\"\n        );\n        assert_eq!(\n            map_claude_model_to_gemini(\"gemini-3.1-pro-low\"),\n            \"gemini-3.1-pro-low\"\n        );\n        // Generic aliases still map to preview entrypoint.\n        assert_eq!(\n            map_claude_model_to_gemini(\"gemini-3-pro\"),\n            \"gemini-3-pro-preview\"\n        );\n        assert_eq!(\n            map_claude_model_to_gemini(\"gemini-3.1-pro\"),\n            \"gemini-3.1-pro-preview\"\n        );\n\n        // Test Normalization (Opus 4.6 now merged into \"claude\" group)\n        assert_eq!(normalize_to_standard_id(\"claude-opus-4-6-thinking\"), Some(\"claude\".to_string()));\n        assert_eq!(\n            normalize_to_standard_id(\"claude-sonnet-4-5\"),\n            Some(\"claude\".to_string())\n        );\n\n        // [Regression] gemini-3-pro-image must NOT be grouped with gemini-3-pro-high\n        assert_eq!(\n            normalize_to_standard_id(\"gemini-3-pro-image\"),\n            Some(\"gemini-3-pro-image\".to_string())\n        );\n        assert_eq!(\n            normalize_to_standard_id(\"gemini-3-pro-high\"),\n            Some(\"gemini-3-pro-high\".to_string())\n        );\n\n        // [FIX #1955] Test normalization with image suffixes\n        assert_eq!(\n            normalize_to_standard_id(\"gemini-3-pro-image-4k\"),\n            Some(\"gemini-3-pro-image\".to_string())\n        );\n        assert_eq!(\n            normalize_to_standard_id(\"gemini-3-pro-image-16x9\"),\n            Some(\"gemini-3-pro-image\".to_string())\n        );\n        assert_eq!(\n            normalize_to_standard_id(\"gemini-3-pro-image-4k-16x9\"),\n            Some(\"gemini-3-pro-image\".to_string())\n        );\n        assert_eq!(\n            normalize_to_standard_id(\"gemini-3.1-flash-image\"),\n            Some(\"gemini-3-pro-image\".to_string())\n        );\n        assert_eq!(\n            normalize_to_standard_id(\"gemini-3.1-flash-image-4k\"),\n            Some(\"gemini-3-pro-image\".to_string())\n        );\n    }\n\n    #[test]\n    fn test_wildcard_priority() {\n        let mut custom = HashMap::new();\n        custom.insert(\"gpt*\".to_string(), \"fallback\".to_string());\n        custom.insert(\"gpt-4*\".to_string(), \"specific\".to_string());\n        custom.insert(\"claude-opus-*\".to_string(), \"opus-default\".to_string());\n        custom.insert(\"claude-opus*thinking\".to_string(), \"opus-thinking\".to_string());\n\n        // More specific pattern wins\n        assert_eq!(resolve_model_route(\"gpt-4-turbo\", &custom), \"specific\");\n        assert_eq!(resolve_model_route(\"gpt-3.5\", &custom), \"fallback\");\n        // Suffix constraint is more specific than prefix-only\n        assert_eq!(resolve_model_route(\"claude-opus-4-5-thinking\", &custom), \"opus-thinking\");\n        assert_eq!(resolve_model_route(\"claude-opus-4\", &custom), \"opus-default\");\n    }\n\n    #[test]\n    fn test_multi_wildcard_support() {\n        let mut custom = HashMap::new();\n        custom.insert(\"claude-*-sonnet-*\".to_string(), \"sonnet-versioned\".to_string());\n        custom.insert(\"gpt-*-*\".to_string(), \"gpt-multi\".to_string());\n        custom.insert(\"*thinking*\".to_string(), \"has-thinking\".to_string());\n\n        // Multi-wildcard patterns should work\n        assert_eq!(\n            resolve_model_route(\"claude-3-5-sonnet-20241022\", &custom),\n            \"sonnet-versioned\"\n        );\n        assert_eq!(\n            resolve_model_route(\"gpt-4-turbo-preview\", &custom),\n            \"gpt-multi\"\n        );\n        assert_eq!(\n            resolve_model_route(\"claude-thinking-extended\", &custom),\n            \"has-thinking\"\n        );\n\n        // Negative case: *thinking* should NOT match models without \"thinking\"\n        assert_eq!(\n            resolve_model_route(\"random-model-name\", &custom),\n            \"random-model-name\"  // Falls back to system default (pass-through)\n        );\n    }\n\n    #[test]\n    fn test_wildcard_edge_cases() {\n        let mut custom = HashMap::new();\n        custom.insert(\"prefix*\".to_string(), \"prefix-match\".to_string());\n        custom.insert(\"*\".to_string(), \"catch-all\".to_string());\n        custom.insert(\"a*b*c\".to_string(), \"multi-wild\".to_string());\n\n        // Specificity: \"prefix*\" (6) > \"*\" (0)\n        assert_eq!(resolve_model_route(\"prefix-anything\", &custom), \"prefix-match\");\n        // Catch-all has lowest specificity\n        assert_eq!(resolve_model_route(\"random-model\", &custom), \"catch-all\");\n        // Multi-wildcard: \"a*b*c\" (3)\n        assert_eq!(resolve_model_route(\"a-test-b-foo-c\", &custom), \"multi-wild\");\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/common/rate_limiter.rs",
    "content": "// Rate Limiter\n// 确保 API 调用间隔 ≥ 500ms\n\nuse std::sync::Arc;\nuse tokio::sync::Mutex;\nuse tokio::time::{sleep, Duration, Instant};\n\npub struct RateLimiter {\n    min_interval: Duration,\n    last_call: Arc<Mutex<Option<Instant>>>,\n}\n\nimpl RateLimiter {\n    pub fn new(min_interval_ms: u64) -> Self {\n        Self {\n            min_interval: Duration::from_millis(min_interval_ms),\n            last_call: Arc::new(Mutex::new(None)),\n        }\n    }\n\n    pub async fn wait(&self) {\n        let mut last = self.last_call.lock().await;\n        if let Some(last_time) = *last {\n            let elapsed = last_time.elapsed();\n            if elapsed < self.min_interval {\n                sleep(self.min_interval - elapsed).await;\n            }\n        }\n        *last = Some(Instant::now());\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use tokio::time::Instant;\n\n    #[tokio::test]\n    async fn test_rate_limiter() {\n        let limiter = RateLimiter::new(500);\n        let start = Instant::now();\n\n        limiter.wait().await; // 第一次调用，立即返回\n        let elapsed1 = start.elapsed().as_millis();\n        assert!(elapsed1 < 50);\n\n        limiter.wait().await; // 第二次调用，等待 500ms\n        let elapsed2 = start.elapsed().as_millis();\n        assert!(elapsed2 >= 500 && elapsed2 < 600);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/common/schema_cache.rs",
    "content": "#![allow(dead_code)]\n// 预留缓存实现，当前未在生产路径启用\n\nuse once_cell::sync::Lazy;\nuse serde_json::Value;\nuse std::collections::HashMap;\nuse std::sync::RwLock;\nuse std::time::Instant;\n\n/// 缓存条目\n#[derive(Clone)]\nstruct CacheEntry {\n    /// 清洗后的 Schema\n    schema: Value,\n    /// 最后使用时间\n    last_used: Instant,\n    /// 命中次数\n    hit_count: usize,\n}\n\n/// Schema 缓存\nstruct SchemaCache {\n    /// 缓存存储 (key: cache_key, value: CacheEntry)\n    cache: HashMap<String, CacheEntry>,\n    /// 缓存统计\n    stats: CacheStats,\n}\n\n/// 缓存统计\n#[derive(Default, Clone, Debug)]\npub struct CacheStats {\n    /// 总请求次数\n    pub total_requests: usize,\n    /// 缓存命中次数\n    pub cache_hits: usize,\n    /// 缓存未命中次数\n    pub cache_misses: usize,\n}\n\nimpl CacheStats {\n    /// 计算缓存命中率\n    pub fn hit_rate(&self) -> f64 {\n        if self.total_requests == 0 {\n            0.0\n        } else {\n            self.cache_hits as f64 / self.total_requests as f64\n        }\n    }\n}\n\nimpl SchemaCache {\n    fn new() -> Self {\n        Self {\n            cache: HashMap::new(),\n            stats: CacheStats::default(),\n        }\n    }\n\n    /// 获取缓存条目\n    fn get(&mut self, key: &str) -> Option<Value> {\n        self.stats.total_requests += 1;\n\n        if let Some(entry) = self.cache.get_mut(key) {\n            // 更新使用时间和命中次数\n            entry.last_used = Instant::now();\n            entry.hit_count += 1;\n            self.stats.cache_hits += 1;\n            Some(entry.schema.clone())\n        } else {\n            self.stats.cache_misses += 1;\n            None\n        }\n    }\n\n    /// 插入缓存条目\n    fn insert(&mut self, key: String, schema: Value) {\n        // 检查缓存大小,如果超过限制则清理\n        const MAX_CACHE_SIZE: usize = 1000;\n        if self.cache.len() >= MAX_CACHE_SIZE {\n            self.evict_lru();\n        }\n\n        let entry = CacheEntry {\n            schema,\n            last_used: Instant::now(),\n            hit_count: 0,\n        };\n        self.cache.insert(key, entry);\n    }\n\n    /// LRU 淘汰策略: 移除最久未使用的条目\n    fn evict_lru(&mut self) {\n        if self.cache.is_empty() {\n            return;\n        }\n\n        // 找到最久未使用的条目\n        let oldest_key = self\n            .cache\n            .iter()\n            .min_by_key(|(_, entry)| entry.last_used)\n            .map(|(key, _)| key.clone());\n\n        if let Some(key) = oldest_key {\n            self.cache.remove(&key);\n        }\n    }\n\n    /// 获取缓存统计\n    fn stats(&self) -> CacheStats {\n        self.stats.clone()\n    }\n\n    /// 清空缓存\n    fn clear(&mut self) {\n        self.cache.clear();\n        self.stats = CacheStats::default();\n    }\n}\n\n/// 全局 Schema 缓存实例\nstatic SCHEMA_CACHE: Lazy<RwLock<SchemaCache>> = Lazy::new(|| RwLock::new(SchemaCache::new()));\n\n/// 计算 Schema 的哈希值\n///\n/// 使用 SHA-256 算法计算 Schema 的哈希值,确保相同的 Schema 产生相同的哈希\nfn compute_schema_hash(schema: &Value) -> String {\n    use sha2::{Digest, Sha256};\n\n    let mut hasher = Sha256::new();\n    // 使用紧凑格式序列化以提高一致性\n    let schema_str = schema.to_string();\n    hasher.update(schema_str.as_bytes());\n\n    // 返回十六进制字符串的前 16 位 (足够唯一)\n    format!(\"{:x}\", hasher.finalize())[..16].to_string()\n}\n\n/// 带缓存的 Schema 清洗\n///\n/// 这是推荐的清洗入口,支持缓存优化\n///\n/// # Arguments\n/// * `schema` - 待清洗的 JSON Schema\n/// * `tool_name` - 工具名称,用于缓存键\n///\n/// # Returns\n/// 清洗后的 Schema\npub fn clean_json_schema_cached(schema: &mut Value, tool_name: &str) {\n    // 1. 计算原始 Schema 的缓存键\n    let hash = compute_schema_hash(schema);\n    let cache_key = format!(\"{}:{}\", tool_name, hash);\n\n    // 2. 尝试从缓存读取\n    {\n        if let Ok(mut cache) = SCHEMA_CACHE.write() {\n            if let Some(cached) = cache.get(&cache_key) {\n                *schema = cached;\n                return;\n            }\n        }\n    }\n\n    // 3. 缓存未命中,执行清洗\n    super::json_schema::clean_json_schema_for_tool(schema, tool_name);\n\n    // 4. 写入缓存 (使用原始哈希作为键)\n    if let Ok(mut cache) = SCHEMA_CACHE.write() {\n        cache.insert(cache_key, schema.clone());\n    }\n}\n\n/// 获取缓存统计信息\npub fn get_cache_stats() -> CacheStats {\n    SCHEMA_CACHE\n        .read()\n        .map(|cache| cache.stats())\n        .unwrap_or_default()\n}\n\n/// 清空缓存\npub fn clear_cache() {\n    if let Ok(mut cache) = SCHEMA_CACHE.write() {\n        cache.clear();\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    #[test]\n    fn test_compute_schema_hash() {\n        let schema1 = json!({\"type\": \"string\"});\n        let schema2 = json!({\"type\": \"string\"});\n        let schema3 = json!({\"type\": \"number\"});\n\n        let hash1 = compute_schema_hash(&schema1);\n        let hash2 = compute_schema_hash(&schema2);\n        let hash3 = compute_schema_hash(&schema3);\n\n        // 相同的 Schema 应该产生相同的哈希\n        assert_eq!(hash1, hash2);\n        // 不同的 Schema 应该产生不同的哈希\n        assert_ne!(hash1, hash3);\n    }\n\n    #[test]\n    fn test_cache_hit() {\n        clear_cache();\n\n        let mut schema = json!({\"type\": \"string\", \"minLength\": 5});\n        let tool_name = \"test_tool\";\n\n        // 第一次调用 - 缓存未命中\n        clean_json_schema_cached(&mut schema, tool_name);\n\n        // 第二次调用相同的 Schema - 应该缓存命中\n        let mut schema2 = json!({\"type\": \"string\", \"minLength\": 5});\n        clean_json_schema_cached(&mut schema2, tool_name);\n\n        let stats = get_cache_stats();\n        // 验证有缓存命中\n        assert!(\n            stats.cache_hits > 0,\n            \"Expected cache hits, got: {:?}\",\n            stats\n        );\n        assert!(stats.hit_rate() > 0.0);\n    }\n\n    #[test]\n    fn test_cache_eviction() {\n        clear_cache();\n\n        // 插入大量条目触发淘汰\n        for i in 0..1100 {\n            let mut schema = json!({\"type\": \"string\", \"index\": i});\n            let tool_name = format!(\"tool_{}\", i);\n            clean_json_schema_cached(&mut schema, &tool_name);\n        }\n\n        // 验证缓存大小被限制\n        let stats = get_cache_stats();\n        assert!(stats.total_requests > 0);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/common/session.rs",
    "content": "// [NEW v4.1.24] Tools for deriving stable session identifiers\n\n/// From account ID string to a stable negative signed integer session ID\n/// Implements FNV-1a hash which matches the official client behavior of sending\n/// a large negative integer for `sessionId`.\npub fn derive_session_id(account_id: &str) -> String {\n    let mut hash: i64 = -3750763034362895579_i64; // FNV offset basis\n    for byte in account_id.bytes() {\n        hash = hash.wrapping_mul(1099511628211_i64);\n        hash ^= byte as i64;\n    }\n    hash.to_string()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_derive_session_id() {\n        let x = derive_session_id(\"my_account@gmail.com\");\n        let y = derive_session_id(\"my_account@gmail.com\");\n        assert_eq!(x, y);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/common/tool_adapter.rs",
    "content": "use serde_json::Value;\n\n/// MCP 工具适配器 trait\n/// \n/// 为不同的 MCP 工具提供定制化的 Schema 处理策略。\n/// 每个工具可以实现自己的适配器来处理特定的需求。\npub trait ToolAdapter: Send + Sync {\n    /// 判断该适配器是否匹配给定的工具名称\n    /// \n    /// # Arguments\n    /// * `tool_name` - 工具名称,通常格式为 \"mcp__provider__function\"\n    /// \n    /// # Returns\n    /// 如果匹配返回 true,否则返回 false\n    fn matches(&self, tool_name: &str) -> bool;\n    \n    /// 在通用清洗前执行的预处理\n    /// \n    /// 可以在这里添加工具特定的字段处理、提示添加等\n    /// \n    /// # Arguments\n    /// * `schema` - 待处理的 JSON Schema\n    /// \n    /// # Returns\n    /// 成功返回 Ok(()), 失败返回错误信息\n    fn pre_process(&self, _schema: &mut Value) -> Result<(), String> {\n        Ok(())\n    }\n    \n    /// 在通用清洗后执行的后处理\n    /// \n    /// 可以在这里进行最终的调整和优化\n    /// \n    /// # Arguments\n    /// * `schema` - 已清洗的 JSON Schema\n    /// \n    /// # Returns\n    /// 成功返回 Ok(()), 失败返回错误信息\n    fn post_process(&self, _schema: &mut Value) -> Result<(), String> {\n        Ok(())\n    }\n}\n\n/// 辅助函数: 向 Schema 的 description 字段追加提示\npub fn append_hint_to_schema(schema: &mut Value, hint: &str) {\n    if let Value::Object(map) = schema {\n        let desc_val = map\n            .entry(\"description\".to_string())\n            .or_insert_with(|| Value::String(\"\".to_string()));\n        \n        if let Value::String(s) = desc_val {\n            if s.is_empty() {\n                *s = hint.to_string();\n            } else if !s.contains(hint) {\n                *s = format!(\"{} {}\", s, hint);\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    struct TestAdapter;\n    \n    impl ToolAdapter for TestAdapter {\n        fn matches(&self, tool_name: &str) -> bool {\n            tool_name.starts_with(\"test__\")\n        }\n        \n        fn pre_process(&self, schema: &mut Value) -> Result<(), String> {\n            append_hint_to_schema(schema, \"[Test Adapter]\");\n            Ok(())\n        }\n    }\n\n    #[test]\n    fn test_adapter_matches() {\n        let adapter = TestAdapter;\n        assert!(adapter.matches(\"test__function\"));\n        assert!(!adapter.matches(\"other__function\"));\n    }\n\n    #[test]\n    fn test_append_hint() {\n        let mut schema = json!({\"type\": \"string\"});\n        append_hint_to_schema(&mut schema, \"Test hint\");\n        assert_eq!(schema[\"description\"], \"Test hint\");\n        \n        append_hint_to_schema(&mut schema, \"Another hint\");\n        assert_eq!(schema[\"description\"], \"Test hint Another hint\");\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/common/tool_adapters/mod.rs",
    "content": "pub mod pencil;\n\npub use pencil::PencilAdapter;\n"
  },
  {
    "path": "src-tauri/src/proxy/common/tool_adapters/pencil.rs",
    "content": "use serde_json::Value;\nuse super::super::tool_adapter::{ToolAdapter, append_hint_to_schema};\n\n/// Pencil MCP 工具适配器\n/// \n/// 为 Pencil 绘图工具提供特定的 Schema 优化:\n/// 1. 处理非标准的视觉属性字段 (cornerRadius, strokeWidth 等)\n/// 2. 优化文件路径参数的描述\n/// 3. 添加 Pencil 特定的使用提示\npub struct PencilAdapter;\n\nimpl ToolAdapter for PencilAdapter {\n    fn matches(&self, tool_name: &str) -> bool {\n        tool_name.starts_with(\"mcp__pencil__\")\n    }\n    \n    fn pre_process(&self, schema: &mut Value) -> Result<(), String> {\n        if let Value::Object(map) = schema {\n            // 1. 处理视觉属性字段\n            self.handle_visual_properties(map);\n            \n            // 2. 优化文件路径参数\n            self.optimize_path_parameters(map);\n        }\n        Ok(())\n    }\n}\n\nimpl PencilAdapter {\n    /// 处理 Pencil 特有的视觉属性字段\n    fn handle_visual_properties(&self, map: &mut serde_json::Map<String, Value>) {\n        // Pencil 使用的非标准视觉属性\n        let visual_props = [\"cornerRadius\", \"strokeWidth\", \"opacity\", \"rotation\"];\n        \n        for prop in visual_props {\n            if map.contains_key(prop) {\n                let hint = format!(\"Visual property: {}\", prop);\n                append_hint_to_schema(&mut Value::Object(map.clone()), &hint);\n            }\n        }\n        \n        // 处理 properties 中的视觉属性\n        if let Some(Value::Object(props)) = map.get_mut(\"properties\") {\n            for (key, value) in props.iter_mut() {\n                if visual_props.contains(&key.as_str()) {\n                    if let Value::Object(prop_map) = value {\n                        prop_map\n                            .entry(\"description\".to_string())\n                            .and_modify(|d| {\n                                if let Value::String(s) = d {\n                                    if !s.contains(\"visual property\") {\n                                        *s = format!(\"{} (visual property for UI elements)\", s);\n                                    }\n                                }\n                            })\n                            .or_insert_with(|| {\n                                Value::String(\"Visual property for UI elements\".to_string())\n                            });\n                    }\n                }\n            }\n        }\n    }\n    \n    /// 优化文件路径相关参数的描述\n    fn optimize_path_parameters(&self, map: &mut serde_json::Map<String, Value>) {\n        if let Some(Value::Object(props)) = map.get_mut(\"properties\") {\n            for (key, value) in props.iter_mut() {\n                // 识别路径相关参数\n                let is_path_param = key.contains(\"path\") \n                    || key.contains(\"file\") \n                    || key.contains(\"File\")\n                    || key.contains(\"Path\");\n                \n                if is_path_param {\n                    if let Value::Object(prop_map) = value {\n                        prop_map\n                            .entry(\"description\".to_string())\n                            .and_modify(|d| {\n                                if let Value::String(s) = d {\n                                    if !s.contains(\"absolute path\") {\n                                        *s = format!(\"{} (use absolute path, e.g., /path/to/file.pen)\", s);\n                                    }\n                                }\n                            })\n                            .or_insert_with(|| {\n                                Value::String(\"File path (use absolute path)\".to_string())\n                            });\n                    }\n                }\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    #[test]\n    fn test_pencil_adapter_matches() {\n        let adapter = PencilAdapter;\n        assert!(adapter.matches(\"mcp__pencil__create_shape\"));\n        assert!(adapter.matches(\"mcp__pencil__update_properties\"));\n        assert!(!adapter.matches(\"mcp__filesystem__read\"));\n    }\n\n    #[test]\n    fn test_visual_properties_handling() {\n        let adapter = PencilAdapter;\n        let mut schema = json!({\n            \"type\": \"object\",\n            \"properties\": {\n                \"cornerRadius\": {\n                    \"type\": \"number\"\n                },\n                \"color\": {\n                    \"type\": \"string\"\n                }\n            }\n        });\n        \n        adapter.pre_process(&mut schema).unwrap();\n        \n        // 验证 cornerRadius 的 description 被添加\n        assert!(schema[\"properties\"][\"cornerRadius\"][\"description\"].is_string());\n        let desc = schema[\"properties\"][\"cornerRadius\"][\"description\"]\n            .as_str()\n            .unwrap();\n        assert!(desc.contains(\"Visual property\"));\n    }\n\n    #[test]\n    fn test_path_parameter_optimization() {\n        let adapter = PencilAdapter;\n        let mut schema = json!({\n            \"type\": \"object\",\n            \"properties\": {\n                \"filePath\": {\n                    \"type\": \"string\",\n                    \"description\": \"Path to the file\"\n                }\n            }\n        });\n        \n        adapter.pre_process(&mut schema).unwrap();\n        \n        let desc = schema[\"properties\"][\"filePath\"][\"description\"]\n            .as_str()\n            .unwrap();\n        assert!(desc.contains(\"absolute path\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/common/utils.rs",
    "content": "// 工具函数\n\npub fn generate_random_id() -> String {\n    use rand::Rng;\n    rand::thread_rng()\n        .sample_iter(&rand::distributions::Alphanumeric)\n        .take(8)\n        .map(char::from)\n        .collect()\n}\n\n/// 根据模型名称推测功能类型\n// 注意：此函数已弃用，请改用 mappers::common_utils::resolve_request_config\npub fn _deprecated_infer_quota_group(model: &str) -> String {\n    if model.to_lowercase().starts_with(\"claude\") {\n        \"claude\".to_string()\n    } else {\n        \"gemini\".to_string()\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/config.rs",
    "content": "use serde::{Deserialize, Serialize};\n// use std::path::PathBuf;\nuse std::collections::HashMap;\nuse std::sync::{OnceLock, RwLock};\n\n// ============================================================================\n// 辅助工具函数\n// ============================================================================\n\n/// 标准化代理 URL，如果缺失协议则默认补全 http://\npub fn normalize_proxy_url(url: &str) -> String {\n    let url = url.trim();\n    if url.is_empty() {\n        return String::new();\n    }\n    if !url.contains(\"://\") {\n        format!(\"http://{}\", url)\n    } else {\n        url.to_string()\n    }\n}\n\n// ============================================================================\n// 全局 Thinking Budget 配置存储\n// 用于在 request transform 函数中访问配置（无需修改函数签名）\n// ============================================================================\nstatic GLOBAL_THINKING_BUDGET_CONFIG: OnceLock<RwLock<ThinkingBudgetConfig>> = OnceLock::new();\n\n/// 获取当前 Thinking Budget 配置\npub fn get_thinking_budget_config() -> ThinkingBudgetConfig {\n    GLOBAL_THINKING_BUDGET_CONFIG\n        .get()\n        .and_then(|lock| lock.read().ok())\n        .map(|cfg| cfg.clone())\n        .unwrap_or_default()\n}\n\n/// 更新全局 Thinking Budget 配置\npub fn update_thinking_budget_config(config: ThinkingBudgetConfig) {\n    if let Some(lock) = GLOBAL_THINKING_BUDGET_CONFIG.get() {\n        if let Ok(mut cfg) = lock.write() {\n            *cfg = config.clone();\n            tracing::info!(\n                \"[Thinking-Budget] Global config updated: mode={:?}, custom_value={}\",\n                config.mode,\n                config.custom_value\n            );\n        }\n    } else {\n        // 首次初始化\n        let _ = GLOBAL_THINKING_BUDGET_CONFIG.set(RwLock::new(config.clone()));\n        tracing::info!(\n            \"[Thinking-Budget] Global config initialized: mode={:?}, custom_value={}\",\n            config.mode,\n            config.custom_value\n        );\n    }\n}\n\n// ============================================================================\n// 全局系统提示词配置存储\n// 用户可在设置中配置一段全局提示词，自动注入到所有请求的 systemInstruction 中\n// ============================================================================\nstatic GLOBAL_SYSTEM_PROMPT_CONFIG: OnceLock<RwLock<GlobalSystemPromptConfig>> = OnceLock::new();\n\n/// 获取当前全局系统提示词配置\npub fn get_global_system_prompt() -> GlobalSystemPromptConfig {\n    GLOBAL_SYSTEM_PROMPT_CONFIG\n        .get()\n        .and_then(|lock| lock.read().ok())\n        .map(|cfg| cfg.clone())\n        .unwrap_or_default()\n}\n\n/// 更新全局系统提示词配置\npub fn update_global_system_prompt_config(config: GlobalSystemPromptConfig) {\n    if let Some(lock) = GLOBAL_SYSTEM_PROMPT_CONFIG.get() {\n        if let Ok(mut cfg) = lock.write() {\n            *cfg = config.clone();\n            tracing::info!(\n                \"[Global-System-Prompt] Config updated: enabled={}, content_len={}\",\n                config.enabled,\n                config.content.len()\n            );\n        }\n    } else {\n        // 首次初始化\n        let _ = GLOBAL_SYSTEM_PROMPT_CONFIG.set(RwLock::new(config.clone()));\n        tracing::info!(\n            \"[Global-System-Prompt] Config initialized: enabled={}, content_len={}\",\n            config.enabled,\n            config.content.len()\n        );\n    }\n}\n\n// ============================================================================\n// 全局图像思维模式配置存储\n// ============================================================================\nstatic GLOBAL_IMAGE_THINKING_MODE: OnceLock<RwLock<String>> = OnceLock::new();\n\npub fn get_image_thinking_mode() -> String {\n    GLOBAL_IMAGE_THINKING_MODE\n        .get()\n        .and_then(|lock| lock.read().ok())\n        .map(|s| s.clone())\n        .unwrap_or_else(|| \"enabled\".to_string())\n}\n\npub fn update_image_thinking_mode(mode: Option<String>) {\n    let val = mode.unwrap_or_else(|| \"enabled\".to_string());\n    if let Some(lock) = GLOBAL_IMAGE_THINKING_MODE.get() {\n        if let Ok(mut cfg) = lock.write() {\n            if *cfg != val {\n                *cfg = val.clone();\n                tracing::info!(\"[Image-Thinking] Global config updated: {}\", val);\n            }\n        }\n    } else {\n        let _ = GLOBAL_IMAGE_THINKING_MODE.set(RwLock::new(val.clone()));\n    }\n}\n\n/// 全局系统提示词配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct GlobalSystemPromptConfig {\n    /// 是否启用全局系统提示词\n    #[serde(default)]\n    pub enabled: bool,\n    /// 系统提示词内容\n    #[serde(default)]\n    pub content: String,\n}\n\nimpl Default for GlobalSystemPromptConfig {\n    fn default() -> Self {\n        Self {\n            enabled: false,\n            content: String::new(),\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ProxyAuthMode {\n    Off,\n    Strict,\n    AllExceptHealth,\n    Auto,\n}\n\nimpl Default for ProxyAuthMode {\n    fn default() -> Self {\n        Self::Auto\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]\n#[serde(rename_all = \"snake_case\")]\npub enum ZaiDispatchMode {\n    /// Never use z.ai.\n    Off,\n    /// Use z.ai for all Anthropic protocol requests.\n    Exclusive,\n    /// Treat z.ai as one additional slot in the shared pool.\n    Pooled,\n    /// Use z.ai only when the Google pool is unavailable.\n    Fallback,\n}\n\nimpl Default for ZaiDispatchMode {\n    fn default() -> Self {\n        Self::Off\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ZaiModelDefaults {\n    /// Default model for \"opus\" family (when the incoming model is a Claude id).\n    #[serde(default = \"default_zai_opus_model\")]\n    pub opus: String,\n    /// Default model for \"sonnet\" family (when the incoming model is a Claude id).\n    #[serde(default = \"default_zai_sonnet_model\")]\n    pub sonnet: String,\n    /// Default model for \"haiku\" family (when the incoming model is a Claude id).\n    #[serde(default = \"default_zai_haiku_model\")]\n    pub haiku: String,\n}\n\nimpl Default for ZaiModelDefaults {\n    fn default() -> Self {\n        Self {\n            opus: default_zai_opus_model(),\n            sonnet: default_zai_sonnet_model(),\n            haiku: default_zai_haiku_model(),\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ZaiMcpConfig {\n    #[serde(default)]\n    pub enabled: bool,\n    #[serde(default)]\n    pub web_search_enabled: bool,\n    #[serde(default)]\n    pub web_reader_enabled: bool,\n    #[serde(default)]\n    pub vision_enabled: bool,\n}\n\nimpl Default for ZaiMcpConfig {\n    fn default() -> Self {\n        Self {\n            enabled: false,\n            web_search_enabled: false,\n            web_reader_enabled: false,\n            vision_enabled: false,\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ZaiConfig {\n    #[serde(default)]\n    pub enabled: bool,\n    #[serde(default = \"default_zai_base_url\")]\n    pub base_url: String,\n    #[serde(default)]\n    pub api_key: String,\n    #[serde(default)]\n    pub dispatch_mode: ZaiDispatchMode,\n    /// Optional per-model mapping overrides for Anthropic/Claude model ids.\n    /// Key: incoming `model` string, Value: upstream z.ai model id (e.g. `glm-4.7`).\n    #[serde(default)]\n    pub model_mapping: HashMap<String, String>,\n    #[serde(default)]\n    pub models: ZaiModelDefaults,\n    #[serde(default)]\n    pub mcp: ZaiMcpConfig,\n}\n\nimpl Default for ZaiConfig {\n    fn default() -> Self {\n        Self {\n            enabled: false,\n            base_url: default_zai_base_url(),\n            api_key: String::new(),\n            dispatch_mode: ZaiDispatchMode::Off,\n            model_mapping: HashMap::new(),\n            models: ZaiModelDefaults::default(),\n            mcp: ZaiMcpConfig::default(),\n        }\n    }\n}\n\n/// 实验性功能配置 (Feature Flags)\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ExperimentalConfig {\n    /// 启用双层签名缓存 (Signature Cache)\n    #[serde(default = \"default_true\")]\n    pub enable_signature_cache: bool,\n\n    /// 启用工具循环自动恢复 (Tool Loop Recovery)\n    #[serde(default = \"default_true\")]\n    pub enable_tool_loop_recovery: bool,\n\n    /// 启用跨模型兼容性检查 (Cross-Model Checks)\n    #[serde(default = \"default_true\")]\n    pub enable_cross_model_checks: bool,\n\n    /// 启用上下文用量缩放 (Context Usage Scaling)\n    /// 激进模式: 缩放用量并激活自动压缩以突破 200k 限制\n    /// 默认关闭以保持透明度,让客户端能触发原生压缩指令\n    #[serde(default = \"default_false\")]\n    pub enable_usage_scaling: bool,\n\n    /// 上下文压缩阈值 L1 (Tool Trimming)\n    #[serde(default = \"default_threshold_l1\")]\n    pub context_compression_threshold_l1: f32,\n\n    /// 上下文压缩阈值 L2 (Thinking Compression)\n    #[serde(default = \"default_threshold_l2\")]\n    pub context_compression_threshold_l2: f32,\n\n    /// 上下文压缩阈值 L3 (Fork + Summary)\n    #[serde(default = \"default_threshold_l3\")]\n    pub context_compression_threshold_l3: f32,\n}\n\nimpl Default for ExperimentalConfig {\n    fn default() -> Self {\n        Self {\n            enable_signature_cache: true,\n            enable_tool_loop_recovery: true,\n            enable_cross_model_checks: true,\n            enable_usage_scaling: false, // 默认关闭,回归透明模式\n            context_compression_threshold_l1: 0.4,\n            context_compression_threshold_l2: 0.55,\n            context_compression_threshold_l3: 0.7,\n        }\n    }\n}\n\nfn default_threshold_l1() -> f32 {\n    0.4\n}\nfn default_threshold_l2() -> f32 {\n    0.55\n}\nfn default_threshold_l3() -> f32 {\n    0.7\n}\n\n/// Thinking Budget 模式\n/// 控制如何处理调用方传入的 thinking_budget 参数\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]\n#[serde(rename_all = \"snake_case\")]\npub enum ThinkingBudgetMode {\n    /// 自动限制：对特定模型（Flash/Thinking）应用 24576 上限\n    Auto,\n    /// 透传：完全使用调用方传入的值，不做任何修改\n    Passthrough,\n    /// 自定义：使用用户设定的固定值覆盖所有请求\n    Custom,\n    /// 自适应：使用 effort 参数控制思考强度 (Claude 4.6+)\n    Adaptive,\n}\n\nimpl Default for ThinkingBudgetMode {\n    fn default() -> Self {\n        Self::Auto\n    }\n}\n\n/// Thinking Budget 配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ThinkingBudgetConfig {\n    /// 模式选择\n    #[serde(default)]\n    pub mode: ThinkingBudgetMode,\n    /// 自定义固定值（仅在 mode=Custom 时生效）\n    #[serde(default = \"default_thinking_budget_custom_value\")]\n    pub custom_value: u32,\n    /// 思考强度 (仅在 mode=Adaptive 时生效) : low, medium, high\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub effort: Option<String>,\n}\n\nimpl Default for ThinkingBudgetConfig {\n    fn default() -> Self {\n        Self {\n            mode: ThinkingBudgetMode::Auto,\n            custom_value: default_thinking_budget_custom_value(),\n            effort: None,\n        }\n    }\n}\n\nfn default_thinking_budget_custom_value() -> u32 {\n    24576\n}\n\nfn default_true() -> bool {\n    true\n}\n\nfn default_false() -> bool {\n    false\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct DebugLoggingConfig {\n    #[serde(default)]\n    pub enabled: bool,\n    #[serde(default)]\n    pub output_dir: Option<String>,\n}\n\nimpl Default for DebugLoggingConfig {\n    fn default() -> Self {\n        Self {\n            enabled: false,\n            output_dir: None,\n        }\n    }\n}\n\n/// IP 黑名单配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct IpBlacklistConfig {\n    /// 是否启用黑名单\n    #[serde(default)]\n    pub enabled: bool,\n\n    /// 自定义封禁消息\n    #[serde(default = \"default_block_message\")]\n    pub block_message: String,\n}\n\nimpl Default for IpBlacklistConfig {\n    fn default() -> Self {\n        Self {\n            enabled: false,\n            block_message: default_block_message(),\n        }\n    }\n}\n\nfn default_block_message() -> String {\n    \"Access denied\".to_string()\n}\n\n/// IP 白名单配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct IpWhitelistConfig {\n    /// 是否启用白名单模式 (启用后只允许白名单IP访问)\n    #[serde(default)]\n    pub enabled: bool,\n\n    /// 白名单优先模式 (白名单IP跳过黑名单检查)\n    #[serde(default = \"default_true\")]\n    pub whitelist_priority: bool,\n}\n\nimpl Default for IpWhitelistConfig {\n    fn default() -> Self {\n        Self {\n            enabled: false,\n            whitelist_priority: true,\n        }\n    }\n}\n\n/// 安全监控配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SecurityMonitorConfig {\n    /// IP 黑名单配置\n    #[serde(default)]\n    pub blacklist: IpBlacklistConfig,\n\n    /// IP 白名单配置\n    #[serde(default)]\n    pub whitelist: IpWhitelistConfig,\n}\n\nimpl Default for SecurityMonitorConfig {\n    fn default() -> Self {\n        Self {\n            blacklist: IpBlacklistConfig::default(),\n            whitelist: IpWhitelistConfig::default(),\n        }\n    }\n}\n\n/// 反代服务配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProxyConfig {\n    /// 是否启用反代服务\n    pub enabled: bool,\n\n    /// 是否允许局域网访问\n    /// - false: 仅本机访问 127.0.0.1（默认，隐私优先）\n    /// - true: 允许局域网访问 0.0.0.0\n    #[serde(default)]\n    pub allow_lan_access: bool,\n\n    /// Authorization policy for the proxy.\n    /// - off: no auth required\n    /// - strict: auth required for all routes\n    /// - all_except_health: auth required for all routes except `/healthz`\n    /// - auto: recommended defaults (currently: allow_lan_access => all_except_health, else off)\n    #[serde(default)]\n    pub auth_mode: ProxyAuthMode,\n\n    /// 监听端口\n    pub port: u16,\n\n    /// API 密钥\n    pub api_key: String,\n\n    /// Web UI 管理后台密码 (可选，如未设置则使用 api_key)\n    pub admin_password: Option<String>,\n\n    /// 是否自动启动\n    pub auto_start: bool,\n\n    /// 自定义精确模型映射表 (key: 原始模型名, value: 目标模型名)\n    #[serde(default)]\n    pub custom_mapping: std::collections::HashMap<String, String>,\n\n    /// API 请求超时时间(秒)\n    #[serde(default = \"default_request_timeout\")]\n    pub request_timeout: u64,\n\n    /// 是否开启请求日志记录 (监控)\n    #[serde(default)]\n    pub enable_logging: bool,\n\n    /// 调试日志配置 (保存完整链路)\n    #[serde(default)]\n    pub debug_logging: DebugLoggingConfig,\n\n    /// 上游代理配置\n    #[serde(default)]\n    pub upstream_proxy: UpstreamProxyConfig,\n\n    /// z.ai provider configuration (Anthropic-compatible).\n    #[serde(default)]\n    pub zai: ZaiConfig,\n\n    /// 自定义 User-Agent 请求头 (可选覆盖)\n    #[serde(default)]\n    pub user_agent_override: Option<String>,\n\n    /// 账号调度配置 (粘性会话/限流重试)\n    #[serde(default)]\n    pub scheduling: crate::proxy::sticky_config::StickySessionConfig,\n\n    /// 实验性功能配置\n    #[serde(default)]\n    pub experimental: ExperimentalConfig,\n\n    /// 安全监控配置 (IP 黑白名单)\n    #[serde(default)]\n    pub security_monitor: SecurityMonitorConfig,\n\n    /// 固定账号模式的账号ID (Fixed Account Mode)\n    /// - None: 使用轮询模式\n    /// - Some(account_id): 固定使用指定账号\n    #[serde(default)]\n    pub preferred_account_id: Option<String>,\n\n    /// Saved User-Agent string (persisted even when override is disabled)\n    #[serde(default)]\n    pub saved_user_agent: Option<String>,\n\n    /// Thinking Budget 配置\n    /// 控制如何处理 AI 深度思考时的 Token 预算\n    #[serde(default)]\n    pub thinking_budget: ThinkingBudgetConfig,\n\n    /// 全局系统提示词配置\n    /// 自动注入到所有 API 请求的 systemInstruction 中\n    #[serde(default)]\n    pub global_system_prompt: GlobalSystemPromptConfig,\n\n    /// 图像思维模式配置\n    /// - enabled: 保留思维链 (默认)\n    /// - disabled: 移除思维链 (画质优先)\n    #[serde(default)]\n    pub image_thinking_mode: Option<String>,\n\n    /// 代理池配置\n    #[serde(default)]\n    pub proxy_pool: ProxyPoolConfig,\n}\n\n/// 上游代理配置\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct UpstreamProxyConfig {\n    /// 是否启用\n    pub enabled: bool,\n    /// 代理地址 (http://, https://, socks5://)\n    pub url: String,\n}\n\nimpl Default for ProxyConfig {\n    fn default() -> Self {\n        Self {\n            enabled: false,\n            allow_lan_access: false, // 默认仅本机访问，隐私优先\n            auth_mode: ProxyAuthMode::default(),\n            port: 8045,\n            api_key: format!(\"sk-{}\", uuid::Uuid::new_v4().simple()),\n            admin_password: None,\n            auto_start: false,\n            custom_mapping: std::collections::HashMap::new(),\n            request_timeout: default_request_timeout(),\n            enable_logging: true, // 默认开启，支持 token 统计功能\n            debug_logging: DebugLoggingConfig::default(),\n            upstream_proxy: UpstreamProxyConfig::default(),\n            zai: ZaiConfig::default(),\n            scheduling: crate::proxy::sticky_config::StickySessionConfig::default(),\n            experimental: ExperimentalConfig::default(),\n            security_monitor: SecurityMonitorConfig::default(),\n            preferred_account_id: None, // 默认使用轮询模式\n            user_agent_override: None,\n            saved_user_agent: None,\n            thinking_budget: ThinkingBudgetConfig::default(),\n            global_system_prompt: GlobalSystemPromptConfig::default(),\n            proxy_pool: ProxyPoolConfig::default(),\n            image_thinking_mode: None,\n        }\n    }\n}\n\nfn default_request_timeout() -> u64 {\n    120 // 默认 120 秒,原来 60 秒太短\n}\n\nfn default_zai_base_url() -> String {\n    \"https://api.z.ai/api/anthropic\".to_string()\n}\n\nfn default_zai_opus_model() -> String {\n    \"glm-4.7\".to_string()\n}\n\nfn default_zai_sonnet_model() -> String {\n    \"glm-4.7\".to_string()\n}\n\nfn default_zai_haiku_model() -> String {\n    \"glm-4.5-air\".to_string()\n}\n\nimpl ProxyConfig {\n    /// 获取实际的监听地址\n    /// - allow_lan_access = false: 返回 \"127.0.0.1\"（默认，隐私优先）\n    /// - allow_lan_access = true: 返回 \"0.0.0.0\"（允许局域网访问）\n    pub fn get_bind_address(&self) -> &str {\n        if self.allow_lan_access {\n            \"0.0.0.0\"\n        } else {\n            \"127.0.0.1\"\n        }\n    }\n}\n\n/// 代理认证信息\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProxyAuth {\n    pub username: String,\n    #[serde(\n        serialize_with = \"crate::utils::crypto::serialize_password\",\n        deserialize_with = \"crate::utils::crypto::deserialize_password\"\n    )]\n    pub password: String,\n}\n\n/// 单个代理配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProxyEntry {\n    pub id: String,                       // 唯一标识\n    pub name: String,                     // 显示名称\n    pub url: String,                      // 代理地址 (http://, https://, socks5://)\n    pub auth: Option<ProxyAuth>,          // 认证信息 (可选)\n    pub enabled: bool,                    // 是否启用\n    pub priority: i32,                    // 优先级 (数字越小优先级越高)\n    pub tags: Vec<String>,                // 标签 (如 \"美国\", \"住宅IP\")\n    pub max_accounts: Option<usize>,      // 最大绑定账号数 (0 = 无限制)\n    pub health_check_url: Option<String>, // 健康检查 URL\n    pub last_check_time: Option<i64>,     // 上次检查时间\n    pub is_healthy: bool,                 // 健康状态\n    pub latency: Option<u64>,             // 延迟 (毫秒) [NEW]\n}\n\n/// 代理池配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProxyPoolConfig {\n    pub enabled: bool, // 是否启用代理池\n    // pub mode: ProxyPoolMode,        // [REMOVED] 代理池模式，统一为 Hybrid 逻辑\n    pub proxies: Vec<ProxyEntry>,         // 代理列表\n    pub health_check_interval: u64,       // 健康检查间隔 (秒)\n    pub auto_failover: bool,              // 自动故障转移\n    pub strategy: ProxySelectionStrategy, // 代理选择策略\n    /// 账号到代理的绑定关系 (account_id -> proxy_id)，持久化存储\n    #[serde(default)]\n    pub account_bindings: HashMap<String, String>,\n}\n\nimpl Default for ProxyPoolConfig {\n    fn default() -> Self {\n        Self {\n            enabled: false,\n            // mode: ProxyPoolMode::Global,\n            proxies: Vec::new(),\n            health_check_interval: 300,\n            auto_failover: true,\n            strategy: ProxySelectionStrategy::Priority,\n            account_bindings: HashMap::new(),\n        }\n    }\n}\n\n/// 代理选择策略\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]\n#[serde(rename_all = \"snake_case\")]\npub enum ProxySelectionStrategy {\n    /// 轮询: 依次使用\n    RoundRobin,\n    /// 随机: 随机选择\n    Random,\n    /// 优先级: 按 priority 字段排序\n    Priority,\n    /// 最少连接: 选择当前使用最少的代理\n    LeastConnections,\n    /// 加权轮询: 根据健康状态和优先级\n    WeightedRoundRobin,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_normalize_proxy_url() {\n        // 测试已有协议\n        assert_eq!(normalize_proxy_url(\"http://127.0.0.1:7890\"), \"http://127.0.0.1:7890\");\n        assert_eq!(normalize_proxy_url(\"https://proxy.com\"), \"https://proxy.com\");\n        assert_eq!(normalize_proxy_url(\"socks5://127.0.0.1:1080\"), \"socks5://127.0.0.1:1080\");\n        assert_eq!(normalize_proxy_url(\"socks5h://127.0.0.1:1080\"), \"socks5h://127.0.0.1:1080\");\n\n        // 测试缺少协议（默认补全 http://）\n        assert_eq!(normalize_proxy_url(\"127.0.0.1:7890\"), \"http://127.0.0.1:7890\");\n        assert_eq!(normalize_proxy_url(\"localhost:1082\"), \"http://localhost:1082\");\n\n        // 测试边缘情况\n        assert_eq!(normalize_proxy_url(\"\"), \"\");\n        assert_eq!(normalize_proxy_url(\"   \"), \"\");\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/debug_logger.rs",
    "content": "use serde_json::Value;\nuse tokio::fs;\nuse std::path::PathBuf;\nuse futures::StreamExt;\n\nuse crate::proxy::config::DebugLoggingConfig;\n\nfn build_filename(prefix: &str, trace_id: Option<&str>) -> String {\n    let ts = chrono::Utc::now().format(\"%Y%m%d_%H%M%S%.3f\");\n    let tid = trace_id.unwrap_or(\"unknown\");\n    format!(\"{}_{}_{}.json\", ts, tid, prefix)\n}\n\nfn resolve_output_dir(cfg: &DebugLoggingConfig) -> Option<PathBuf> {\n    if let Some(dir) = cfg.output_dir.as_ref() {\n        return Some(PathBuf::from(dir));\n    }\n    if let Ok(data_dir) = crate::modules::account::get_data_dir() {\n        return Some(data_dir.join(\"debug_logs\"));\n    }\n    None\n}\n\npub async fn write_debug_payload(\n    cfg: &DebugLoggingConfig,\n    trace_id: Option<&str>,\n    prefix: &str,\n    payload: &Value,\n) {\n    if !cfg.enabled {\n        return;\n    }\n\n    let output_dir = match resolve_output_dir(cfg) {\n        Some(dir) => dir,\n        None => {\n            tracing::warn!(\"[Debug-Log] Enabled but output_dir is not available.\");\n            return;\n        }\n    };\n\n    if let Err(e) = fs::create_dir_all(&output_dir).await {\n        tracing::warn!(\"[Debug-Log] Failed to create output dir: {}\", e);\n        return;\n    }\n\n    let filename = build_filename(prefix, trace_id);\n    let path = output_dir.join(filename);\n\n    match serde_json::to_vec_pretty(payload) {\n        Ok(bytes) => {\n            if let Err(e) = fs::write(&path, bytes).await {\n                tracing::warn!(\"[Debug-Log] Failed to write file: {}\", e);\n            }\n        }\n        Err(e) => {\n            tracing::warn!(\"[Debug-Log] Failed to serialize payload: {}\", e);\n        }\n    }\n}\n\npub fn is_enabled(cfg: &DebugLoggingConfig) -> bool {\n    cfg.enabled\n}\n\n/// 解析 SSE 流式数据，提取 thinking 和正文内容\nfn parse_sse_stream(raw: &str) -> (String, String) {\n    let mut thinking_parts: Vec<String> = Vec::new();\n    let mut content_parts: Vec<String> = Vec::new();\n\n    for line in raw.lines() {\n        let line = line.trim();\n        if !line.starts_with(\"data: \") {\n            continue;\n        }\n        let json_str = &line[6..]; // 去掉 \"data: \" 前缀\n        if json_str.is_empty() || json_str == \"[DONE]\" {\n            continue;\n        }\n\n        // 尝试解析 JSON\n        if let Ok(parsed) = serde_json::from_str::<Value>(json_str) {\n            // Gemini/v1internal 格式: response.candidates[0].content.parts[0]\n            if let Some(candidates) = parsed.get(\"response\")\n                .and_then(|r| r.get(\"candidates\"))\n                .and_then(|c| c.as_array())\n            {\n                for candidate in candidates {\n                    if let Some(parts) = candidate.get(\"content\")\n                        .and_then(|c| c.get(\"parts\"))\n                        .and_then(|p| p.as_array())\n                    {\n                        for part in parts {\n                            let text = part.get(\"text\")\n                                .and_then(|t| t.as_str())\n                                .unwrap_or(\"\");\n                            let is_thought = part.get(\"thought\")\n                                .and_then(|t| t.as_bool())\n                                .unwrap_or(false);\n                            \n                            if !text.is_empty() {\n                                if is_thought {\n                                    thinking_parts.push(text.to_string());\n                                } else {\n                                    content_parts.push(text.to_string());\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n            // OpenAI 格式兼容: choices[0].delta.content\n            else if let Some(choices) = parsed.get(\"choices\").and_then(|c| c.as_array()) {\n                for choice in choices {\n                    if let Some(delta) = choice.get(\"delta\") {\n                        if let Some(content) = delta.get(\"content\").and_then(|c| c.as_str()) {\n                            if !content.is_empty() {\n                                content_parts.push(content.to_string());\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    (thinking_parts.join(\"\"), content_parts.join(\"\"))\n}\n\npub fn wrap_stream_with_debug<S, E>(\n    stream: std::pin::Pin<Box<S>>,\n    cfg: DebugLoggingConfig,\n    trace_id: String,\n    prefix: &'static str,\n    meta: Value,\n) -> std::pin::Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes, E>> + Send>>\nwhere\n    S: futures::Stream<Item = Result<bytes::Bytes, E>> + Send + 'static,\n    E: std::fmt::Display + Send + 'static,\n{\n    if !is_enabled(&cfg) {\n        return stream;\n    }\n\n    let wrapped = async_stream::stream! {\n        let mut collected: Vec<u8> = Vec::new();\n        let mut inner = stream;\n        while let Some(item) = inner.next().await {\n            if let Ok(bytes) = &item {\n                collected.extend_from_slice(bytes);\n            }\n            yield item;\n        }\n\n        let raw_text = String::from_utf8_lossy(&collected).to_string();\n        let (thinking_content, response_content) = parse_sse_stream(&raw_text);\n        \n        let mut payload = serde_json::json!({\n            \"kind\": \"upstream_response\",\n            \"trace_id\": trace_id,\n            \"meta\": meta,\n        });\n        \n        // 只有在有内容时才添加对应字段\n        if !thinking_content.is_empty() {\n            payload[\"thinking_content\"] = serde_json::Value::String(thinking_content);\n        }\n        if !response_content.is_empty() {\n            payload[\"response_content\"] = serde_json::Value::String(response_content);\n        }\n\n        write_debug_payload(&cfg, Some(&payload[\"trace_id\"].as_str().unwrap_or(\"unknown\")), prefix, &payload).await;\n    };\n\n    Box::pin(wrapped)\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/droid_sync.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::fs;\nuse std::env;\n\n#[cfg(target_os = \"windows\")]\nuse std::os::windows::process::CommandExt;\n\n#[cfg(target_os = \"windows\")]\nconst CREATE_NO_WINDOW: u32 = 0x08000000;\n\nconst DROID_DIR: &str = \".factory\";\nconst DROID_CONFIG_FILE: &str = \"settings.json\";\nconst BACKUP_SUFFIX: &str = \".antigravity.bak\";\nconst AG_ID_PREFIX: &str = \"custom:AG-\";\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct DroidStatus {\n    pub installed: bool,\n    pub version: Option<String>,\n    pub is_synced: bool,\n    pub has_backup: bool,\n    pub current_base_url: Option<String>,\n    pub files: Vec<String>,\n    pub synced_count: usize,\n}\n\nfn get_droid_dir() -> Option<PathBuf> {\n    dirs::home_dir().map(|h| h.join(DROID_DIR))\n}\n\nfn get_config_path() -> Option<PathBuf> {\n    get_droid_dir().map(|dir| dir.join(DROID_CONFIG_FILE))\n}\n\nfn find_in_path(executable: &str) -> Option<PathBuf> {\n    #[cfg(target_os = \"windows\")]\n    {\n        let extensions = [\"exe\", \"cmd\", \"bat\"];\n        if let Ok(path_var) = env::var(\"PATH\") {\n            for dir in path_var.split(';') {\n                for ext in &extensions {\n                    let full_path = PathBuf::from(dir).join(format!(\"{}.{}\", executable, ext));\n                    if full_path.exists() {\n                        return Some(full_path);\n                    }\n                }\n            }\n        }\n    }\n\n    #[cfg(not(target_os = \"windows\"))]\n    {\n        if let Ok(path_var) = env::var(\"PATH\") {\n            for dir in path_var.split(':') {\n                let full_path = PathBuf::from(dir).join(executable);\n                if full_path.exists() {\n                    return Some(full_path);\n                }\n            }\n        }\n    }\n\n    None\n}\n\nfn resolve_droid_path() -> Option<PathBuf> {\n    if let Some(path) = find_in_path(\"droid\") {\n        tracing::debug!(\"Found droid in PATH: {:?}\", path);\n        return Some(path);\n    }\n\n    #[cfg(not(target_os = \"windows\"))]\n    {\n        let home = dirs::home_dir()?;\n        let candidates = [\n            home.join(\".local/bin/droid\"),\n            home.join(\".factory/bin/droid\"),\n            home.join(\"bin/droid\"),\n            PathBuf::from(\"/opt/homebrew/bin/droid\"),\n            PathBuf::from(\"/usr/local/bin/droid\"),\n            PathBuf::from(\"/usr/bin/droid\"),\n        ];\n        for path in &candidates {\n            if path.exists() {\n                tracing::debug!(\"Found droid at: {:?}\", path);\n                return Some(path.clone());\n            }\n        }\n    }\n\n    #[cfg(target_os = \"windows\")]\n    {\n        if let Ok(app_data) = env::var(\"APPDATA\") {\n            let npm_path = PathBuf::from(&app_data).join(\"npm\").join(\"droid.cmd\");\n            if npm_path.exists() {\n                return Some(npm_path);\n            }\n        }\n        if let Ok(local_app_data) = env::var(\"LOCALAPPDATA\") {\n            let pnpm_path = PathBuf::from(&local_app_data).join(\"pnpm\").join(\"droid.cmd\");\n            if pnpm_path.exists() {\n                return Some(pnpm_path);\n            }\n        }\n    }\n\n    None\n}\n\nfn extract_version(raw: &str) -> String {\n    let trimmed = raw.trim();\n    let parts: Vec<&str> = trimmed.split_whitespace().collect();\n    for part in parts {\n        if let Some(slash_idx) = part.find('/') {\n            let after = &part[slash_idx + 1..];\n            if after.contains('.') && after.chars().next().map_or(false, |c| c.is_ascii_digit()) {\n                return after.to_string();\n            }\n        }\n        if part.contains('.') && part.chars().next().map_or(false, |c| c.is_ascii_digit())\n            && part.chars().all(|c| c.is_ascii_digit() || c == '.')\n        {\n            return part.to_string();\n        }\n    }\n    let version_chars: String = trimmed\n        .chars()\n        .skip_while(|c| !c.is_ascii_digit())\n        .take_while(|c| c.is_ascii_digit() || *c == '.')\n        .collect();\n    if !version_chars.is_empty() && version_chars.contains('.') {\n        return version_chars;\n    }\n    \"unknown\".to_string()\n}\n\npub fn check_droid_installed() -> (bool, Option<String>) {\n    tracing::debug!(\"Checking droid installation...\");\n\n    let droid_path = match resolve_droid_path() {\n        Some(path) => {\n            tracing::debug!(\"Resolved droid path: {:?}\", path);\n            path\n        }\n        None => {\n            tracing::debug!(\"Could not resolve droid path\");\n            return (false, None);\n        }\n    };\n\n    let mut cmd = Command::new(&droid_path);\n    cmd.arg(\"--version\");\n    #[cfg(target_os = \"windows\")]\n    cmd.creation_flags(CREATE_NO_WINDOW);\n\n    match cmd.output() {\n        Ok(output) if output.status.success() => {\n            let stdout = String::from_utf8_lossy(&output.stdout);\n            let stderr = String::from_utf8_lossy(&output.stderr);\n            let raw = if stdout.trim().is_empty() { stderr.to_string() } else { stdout.to_string() };\n            tracing::debug!(\"droid --version output: {}\", raw.trim());\n            let version = extract_version(&raw);\n            (true, Some(version))\n        }\n        _ => {\n            (true, Some(\"unknown\".to_string()))\n        }\n    }\n}\n\n/// 统计已有 customModels 中有多少由 Antigravity 添加的（id 以 custom:AG- 开头）\nfn count_synced_models(json: &Value) -> (usize, Option<String>) {\n    let mut count = 0;\n    let mut first_url = None;\n\n    if let Some(arr) = json.get(\"customModels\").and_then(|v| v.as_array()) {\n        for m in arr {\n            let id = m.get(\"id\").and_then(|v| v.as_str()).unwrap_or_default();\n            if !id.starts_with(AG_ID_PREFIX) {\n                continue;\n            }\n            count += 1;\n            if first_url.is_none() {\n                first_url = m.get(\"baseUrl\").and_then(|v| v.as_str()).map(|s| s.to_string());\n            }\n        }\n    }\n    (count, first_url)\n}\n\npub fn get_sync_status(_proxy_url: &str) -> (bool, bool, Option<String>, usize) {\n    let config_path = match get_config_path() {\n        Some(p) => p,\n        None => return (false, false, None, 0),\n    };\n\n    let backup_path = config_path.with_file_name(format!(\"{}{}\", DROID_CONFIG_FILE, BACKUP_SUFFIX));\n    let has_backup = backup_path.exists();\n\n    if !config_path.exists() {\n        return (false, has_backup, None, 0);\n    }\n\n    let content = match fs::read_to_string(&config_path) {\n        Ok(c) => c,\n        Err(_) => return (false, has_backup, None, 0),\n    };\n\n    let json: Value = serde_json::from_str(&content).unwrap_or_default();\n    let (synced_count, first_url) = count_synced_models(&json);\n    (synced_count > 0, has_backup, first_url, synced_count)\n}\n\nfn create_backup(path: &PathBuf) -> Result<(), String> {\n    if !path.exists() {\n        return Ok(());\n    }\n    let backup_path = path.with_file_name(format!(\n        \"{}{}\",\n        path.file_name().unwrap_or_default().to_string_lossy(),\n        BACKUP_SUFFIX\n    ));\n    if backup_path.exists() {\n        return Ok(());\n    }\n    fs::copy(path, &backup_path)\n        .map_err(|e| format!(\"Failed to create backup: {}\", e))?;\n    tracing::info!(\"Created backup: {:?}\", backup_path);\n    Ok(())\n}\n\n/// 接收前端 preview 里完整的 customModels 数组，直接替换写入\npub fn sync_droid_config(full_custom_models: Vec<Value>) -> Result<usize, String> {\n    let config_path = get_config_path()\n        .ok_or_else(|| \"Failed to get Droid config directory\".to_string())?;\n\n    if let Some(parent) = config_path.parent() {\n        fs::create_dir_all(parent).map_err(|e| format!(\"Failed to create directory: {}\", e))?;\n    }\n\n    create_backup(&config_path)?;\n\n    let mut config: Value = if config_path.exists() {\n        let content = fs::read_to_string(&config_path)\n            .map_err(|e| format!(\"Failed to read config: {}\", e))?;\n        serde_json::from_str(&content)\n            .map_err(|e| format!(\"Failed to parse config: {}\", e))?\n    } else {\n        serde_json::json!({})\n    };\n\n    if !config.is_object() {\n        config = serde_json::json!({});\n    }\n\n    let ag_count = full_custom_models.iter()\n        .filter(|m| m.get(\"id\").and_then(|v| v.as_str())\n            .map(|s| s.starts_with(AG_ID_PREFIX)).unwrap_or(false))\n        .count();\n\n    config.as_object_mut().unwrap()\n        .insert(\"customModels\".to_string(), Value::Array(full_custom_models));\n\n    let tmp_path = config_path.with_extension(\"tmp\");\n    fs::write(&tmp_path, serde_json::to_string_pretty(&config).unwrap())\n        .map_err(|e| format!(\"Failed to write temp file: {}\", e))?;\n    fs::rename(&tmp_path, &config_path)\n        .map_err(|e| format!(\"Failed to rename config file: {}\", e))?;\n\n    Ok(ag_count)\n}\n\npub fn restore_droid_config() -> Result<(), String> {\n    let config_path = get_config_path()\n        .ok_or_else(|| \"Failed to get Droid config directory\".to_string())?;\n\n    let backup_path = config_path.with_file_name(format!(\"{}{}\", DROID_CONFIG_FILE, BACKUP_SUFFIX));\n    if backup_path.exists() {\n        fs::rename(&backup_path, &config_path)\n            .map_err(|e| format!(\"Failed to restore config: {}\", e))?;\n        Ok(())\n    } else {\n        Err(\"No backup file found\".to_string())\n    }\n}\n\npub fn read_droid_config_content() -> Result<String, String> {\n    let config_path = get_config_path()\n        .ok_or_else(|| \"Failed to get Droid config directory\".to_string())?;\n\n    if !config_path.exists() {\n        return Ok(\"{}\".to_string());\n    }\n\n    fs::read_to_string(&config_path)\n        .map_err(|e| format!(\"Failed to read config: {}\", e))\n}\n\n// Tauri Commands\n\n#[tauri::command]\npub async fn get_droid_sync_status(proxy_url: String) -> Result<DroidStatus, String> {\n    let (installed, version) = check_droid_installed();\n    let (is_synced, has_backup, current_base_url, synced_count) = if installed {\n        get_sync_status(&proxy_url)\n    } else {\n        (false, false, None, 0)\n    };\n\n    Ok(DroidStatus {\n        installed,\n        version,\n        is_synced,\n        has_backup,\n        current_base_url,\n        files: vec![DROID_CONFIG_FILE.to_string()],\n        synced_count,\n    })\n}\n\n#[tauri::command]\npub async fn execute_droid_sync(\n    custom_models: Vec<Value>,\n) -> Result<usize, String> {\n    sync_droid_config(custom_models)\n}\n\n#[tauri::command]\npub async fn execute_droid_restore() -> Result<(), String> {\n    restore_droid_config()\n}\n\n#[tauri::command]\npub async fn get_droid_config_content() -> Result<String, String> {\n    read_droid_config_content()\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/handlers/audio.rs",
    "content": "use axum::{\n    extract::{Multipart, State},\n    http::StatusCode,\n    response::IntoResponse,\n    Json,\n};\nuse serde_json::{json, Value};\nuse tracing::{debug, info};\nuse uuid::Uuid;\n\nuse crate::proxy::{audio::AudioProcessor, server::AppState};\n\n/// 处理音频转录请求 (OpenAI Whisper API 兼容)\npub async fn handle_audio_transcription(\n    State(state): State<AppState>,\n    mut multipart: Multipart,\n) -> Result<impl IntoResponse, (StatusCode, String)> {\n    let mut audio_data: Option<Vec<u8>> = None;\n    let mut filename: Option<String> = None;\n    let mut model = \"gemini-2.0-flash-exp\".to_string();\n    let mut prompt = \"Generate a transcript of the speech.\".to_string();\n\n    // 1. 解析 multipart/form-data\n    while let Some(field) = multipart\n        .next_field()\n        .await\n        .map_err(|e| (StatusCode::BAD_REQUEST, format!(\"解析表单失败: {}\", e)))?\n    {\n        let name = field.name().unwrap_or(\"\").to_string();\n\n        match name.as_str() {\n            \"file\" => {\n                filename = field.file_name().map(|s| s.to_string());\n                audio_data = Some(\n                    field\n                        .bytes()\n                        .await\n                        .map_err(|e| (StatusCode::BAD_REQUEST, format!(\"读取文件失败: {}\", e)))?\n                        .to_vec(),\n                );\n            }\n            \"model\" => {\n                model = field.text().await.unwrap_or(model);\n            }\n            \"prompt\" => {\n                prompt = field.text().await.unwrap_or(prompt);\n            }\n            _ => {}\n        }\n    }\n\n    let audio_bytes = audio_data.ok_or((StatusCode::BAD_REQUEST, \"缺少音频文件\".to_string()))?;\n\n    let file_name = filename.ok_or((StatusCode::BAD_REQUEST, \"无法获取文件名\".to_string()))?;\n\n    info!(\n        \"收到音频转录请求: 文件={}, 大小={} bytes, 模型={}\",\n        file_name,\n        audio_bytes.len(),\n        model\n    );\n\n    // 2. 检测 MIME 类型\n    let mime_type =\n        AudioProcessor::detect_mime_type(&file_name).map_err(|e| (StatusCode::BAD_REQUEST, e))?;\n\n    // 3. 验证文件大小\n    if AudioProcessor::exceeds_size_limit(audio_bytes.len()) {\n        let size_mb = audio_bytes.len() as f64 / (1024.0 * 1024.0);\n        return Err((\n            StatusCode::PAYLOAD_TOO_LARGE,\n            format!(\n                \"音频文件过大 ({:.1} MB)。最大支持 15 MB (约 16 分钟 MP3)。建议: 1) 压缩音频质量 2) 分段上传\",\n                size_mb\n            ),\n        ));\n    }\n\n    // 4. 使用 Inline Data 方式\n    debug!(\"使用 Inline Data 方式处理\");\n    let base64_audio = AudioProcessor::encode_to_base64(&audio_bytes);\n\n    // 5. 构建 Gemini 请求\n    let gemini_request = json!({\n        \"contents\": [{\n            \"parts\": [\n                {\"text\": prompt},\n                {\n                    \"inlineData\": {\n                        \"mimeType\": mime_type,\n                        \"data\": base64_audio\n                    }\n                }\n            ]\n        }]\n    });\n\n    // 6. 获取 Token 和上游客户端\n    let token_manager = state.token_manager;\n    let (access_token, project_id, email, account_id, _wait_ms) = token_manager\n        .get_token(\"text\", false, None, &model)\n        .await\n        .map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, e))?;\n\n    info!(\"使用账号: {}\", email);\n\n    // 7. 包装请求为 v1internal 格式\n    let wrapped_body = json!({\n        \"project\": project_id,\n        \"requestId\": format!(\"audio-{}\", Uuid::new_v4()),\n        \"request\": gemini_request,\n        \"model\": model,\n        \"userAgent\": \"antigravity\",\n        \"requestType\": \"text\"\n    });\n\n    // 8. 发送请求到 Gemini\n    let upstream = state.upstream.clone();\n    let response = upstream\n        .call_v1_internal(\n            \"generateContent\",\n            &access_token,\n            wrapped_body,\n            None,\n            Some(account_id.as_str()),\n        )\n        .await\n        .map_err(|e| (StatusCode::BAD_GATEWAY, format!(\"上游请求失败: {}\", e)))?\n        .response;\n\n    if !response.status().is_success() {\n        let error_text = response\n            .text()\n            .await\n            .unwrap_or_else(|_| \"Unknown error\".to_string());\n        return Err((\n            StatusCode::BAD_GATEWAY,\n            format!(\"Gemini API 错误: {}\", error_text),\n        ));\n    }\n\n    let result: Value = response\n        .json()\n        .await\n        .map_err(|e| (StatusCode::BAD_GATEWAY, format!(\"解析响应失败: {}\", e)))?;\n\n    // 9. 提取文本响应（解包 v1internal 响应）\n    let inner_response = result.get(\"response\").unwrap_or(&result);\n    let text = inner_response\n        .get(\"candidates\")\n        .and_then(|c| c.get(0))\n        .and_then(|c| c.get(\"content\"))\n        .and_then(|c| c.get(\"parts\"))\n        .and_then(|p| p.get(0))\n        .and_then(|p| p.get(\"text\"))\n        .and_then(|t| t.as_str())\n        .unwrap_or(\"\");\n\n    info!(\"音频转录完成，返回 {} 字符\", text.len());\n\n    // 10. 返回标准格式响应\n    Ok((\n        StatusCode::OK,\n        [(\"X-Account-Email\", email.as_str())],\n        Json(json!({\n            \"text\": text\n        })),\n    )\n        .into_response())\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/handlers/claude.rs",
    "content": "// Claude 协议处理器\n\nuse axum::{\n    body::Body,\n    extract::{Json, State},\n    http::{header, StatusCode},\n    response::{IntoResponse, Response},\n};\nuse bytes::Bytes;\nuse futures::StreamExt;\nuse serde_json::{json, Value};\nuse tokio::time::Duration;\nuse tracing::{debug, error, info};\n\nuse crate::proxy::mappers::claude::{\n    transform_claude_request_in, transform_response, create_claude_sse_stream, ClaudeRequest,\n    filter_invalid_thinking_blocks_with_family, close_tool_loop_for_thinking,\n    clean_cache_control_from_messages, merge_consecutive_messages,\n    models::{Message, MessageContent},\n};\nuse crate::proxy::server::AppState;\nuse crate::proxy::mappers::context_manager::ContextManager;\nuse crate::proxy::mappers::estimation_calibrator::get_calibrator;\nuse crate::proxy::debug_logger;\nuse crate::proxy::upstream::client::mask_email;\nuse crate::proxy::common::client_adapter::CLIENT_ADAPTERS; // [NEW] Import Adapter Registry\nuse axum::http::HeaderMap;\nuse std::sync::{atomic::Ordering, Arc};\nuse crate::proxy::model_specs; // [NEW]\n\n// ===== Task #6: OpenCode variants thinking config mapping =====\n// Helper structs for parsing thinking hints from raw JSON\n#[derive(Debug, Clone)]\nstruct ThinkingHint {\n    budget_tokens: Option<u32>,\n    level: Option<String>,\n}\n\n/// Extract thinking hints from raw request JSON (OpenCode variants compatibility)\n/// Checks multiple possible paths for budget and level configuration\nfn extract_thinking_hint(body: &Value) -> ThinkingHint {\n    let mut hint = ThinkingHint {\n        budget_tokens: None,\n        level: None,\n    };\n\n    // Try to extract budget_tokens from various paths\n    // Priority: thinking.budget_tokens > thinking.budgetTokens > thinking.budget > thinkingConfig.thinkingBudget\n    if let Some(budget) = body\n        .get(\"thinking\")\n        .and_then(|t| t.get(\"budget_tokens\"))\n        .and_then(|b| b.as_u64())\n    {\n        hint.budget_tokens = Some(budget as u32);\n    } else if let Some(budget) = body\n        .get(\"thinking\")\n        .and_then(|t| t.get(\"budgetTokens\"))\n        .and_then(|b| b.as_u64())\n    {\n        hint.budget_tokens = Some(budget as u32);\n    } else if let Some(budget) = body\n        .get(\"thinking\")\n        .and_then(|t| t.get(\"budget\"))\n        .and_then(|b| b.as_u64())\n    {\n        hint.budget_tokens = Some(budget as u32);\n    } else if let Some(budget) = body\n        .get(\"thinkingConfig\")\n        .and_then(|t| t.get(\"thinkingBudget\"))\n        .and_then(|b| b.as_u64())\n    {\n        hint.budget_tokens = Some(budget as u32);\n    }\n\n    // Try to extract level from thinkingLevel\n    if let Some(level) = body.get(\"thinkingLevel\").and_then(|l| l.as_str()) {\n        hint.level = Some(level.to_lowercase());\n    }\n\n    hint\n}\n\n/// Map thinking level to suggested budget tokens\nfn level_to_budget(level: &str, cap: u64) -> u32 {\n    let base = match level {\n        \"minimal\" => 1024,\n        \"low\" => 8192,\n        \"medium\" => 16384,\n        \"high\" => 24576,\n        _ => 8192, // default to low\n    };\n    base.min(cap as u32)\n}\n\n/// Map thinking level to effort level for output_config\nfn level_to_effort(level: &str) -> String {\n    match level {\n        \"minimal\" | \"low\" => \"low\".to_string(),\n        \"medium\" => \"medium\".to_string(),\n        \"high\" => \"high\".to_string(),\n        _ => \"low\".to_string(),\n    }\n}\n\n/// Apply thinking hints to ClaudeRequest\nfn apply_thinking_hints(\n    request: &mut crate::proxy::mappers::claude::models::ClaudeRequest,\n    hint: &ThinkingHint,\n    trace_id: &str,\n    budget_cap: u64, // [NEW]\n) {\n    let mut applied = false;\n\n    // If budget is provided, set/override thinking config\n    if let Some(budget) = hint.budget_tokens {\n        request.thinking = Some(crate::proxy::mappers::claude::models::ThinkingConfig {\n            type_: \"enabled\".to_string(),\n            budget_tokens: Some(budget),\n            effort: None,\n        });\n        tracing::debug!(\n            \"[{}] Applied thinking hint: budget_tokens={}\",\n            trace_id, budget\n        );\n        applied = true;\n    }\n\n    // If level is provided\n    if let Some(ref level) = hint.level {\n        // Map to output_config.effort if not already set\n        if request.output_config.is_none() {\n            request.output_config = Some(crate::proxy::mappers::claude::models::OutputConfig {\n                effort: Some(level_to_effort(level)),\n            });\n            tracing::debug!(\"[{}] Applied thinking hint: effort={}\", trace_id, level);\n            applied = true;\n        }\n\n        // If no budget provided but level is, map level to budget\n        if hint.budget_tokens.is_none() {\n            let budget = level_to_budget(level, budget_cap);\n            request.thinking = Some(crate::proxy::mappers::claude::models::ThinkingConfig {\n                type_: \"enabled\".to_string(),\n                budget_tokens: Some(budget),\n                effort: None,\n            });\n            tracing::debug!(\n                \"[{}] Applied thinking hint: level={} -> budget_tokens={}\",\n                trace_id, level, budget\n            );\n            applied = true;\n        }\n    }\n\n    if applied {\n        tracing::info!(\"[{}] Applied OpenCode thinking hints to request\", trace_id);\n    }\n}\n\nconst MAX_RETRY_ATTEMPTS: usize = 3;\n\n// ===== Model Constants for Background Tasks =====\n// These can be adjusted for performance/cost optimization or overridden by custom_mapping\nconst INTERNAL_BACKGROUND_TASK: &str = \"internal-background-task\";  // Unified virtual ID for all background tasks\n\n// ===== Layer 3: XML Summary Prompt Template =====\n// Borrowed from Practical-Guide-to-Context-Engineering + Claude Code official practice\n// This prompt generates a structured 8-section XML summary for context compression\nconst CONTEXT_SUMMARY_PROMPT: &str = r#\"You are a context compression specialist. Your task is to create a structured XML snapshot of the conversation history.\n\nThis snapshot will become the Agent's ONLY memory of the past. All key details, plans, errors, and user instructions MUST be preserved.\n\nFirst, think through the entire history in a private <scratchpad>. Review the user's overall goal, the agent's actions, tool outputs, file modifications, and any unresolved issues. Identify every piece of information critical for future actions.\n\nAfter reasoning, generate the final <state_snapshot> XML object. Information must be extremely dense. Omit any irrelevant conversational filler.\n\nThe structure MUST be as follows:\n\n<state_snapshot>\n  <overall_goal>\n    <!-- Describe the user's high-level goal in one concise sentence -->\n  </overall_goal>\n  \n  <technical_context>\n    <!-- Tech stack: frameworks, languages, toolchain, dependency versions -->\n  </technical_context>\n  \n  <file_system_state>\n    <!-- List files that were created, read, modified, or deleted. Note their status -->\n  </file_system_state>\n  \n  <code_changes>\n    <!-- Key code snippets (preserve function signatures and important logic) -->\n  </code_changes>\n  \n  <debugging_history>\n    <!-- List all errors encountered, with stack traces, and how they were fixed -->\n  </debugging_history>\n  \n  <current_plan>\n    <!-- Step-by-step plan. Mark completed steps -->\n  </current_plan>\n  \n  <user_preferences>\n    <!-- User's work preferences for this project (test commands, code style, etc.) -->\n  </user_preferences>\n  \n  <key_decisions>\n    <!-- Critical architectural decisions and design choices -->\n  </key_decisions>\n  \n  <latest_thinking_signature>\n    <!-- [CRITICAL] Preserve the last valid thinking signature -->\n    <!-- Format: base64-encoded signature string -->\n    <!-- This MUST be copied exactly as-is, no modifications -->\n  </latest_thinking_signature>\n</state_snapshot>\n\n**IMPORTANT**:\n1. Code snippets must be complete, including function signatures and key logic\n2. Error messages must be preserved verbatim, including line numbers and stacks\n3. File paths must use absolute paths\n4. The thinking signature must be copied exactly, no modifications\n\"#;\n\n// ===== Jitter Configuration (REMOVED) =====\n// Jitter was causing connection instability, reverted to fixed delays\n// const JITTER_FACTOR: f64 = 0.2;\n\n\n// ===== 统一退避策略模块 =====\n\n// [REMOVED] apply_jitter function\n// Jitter logic removed to restore stability (v3.3.16 fix)\n\n// ===== 统一退避策略模块 =====\n// 移除本地重复定义，使用 common 中的统一实现\nuse super::common::{determine_retry_strategy, apply_retry_strategy, should_rotate_account, RetryStrategy};\n\n// ===== 退避策略模块结束 =====\n\n/// 处理 Claude messages 请求\n/// \n/// 处理 Chat 消息请求流程\npub async fn handle_messages(\n    State(state): State<AppState>,\n    headers: HeaderMap,\n    Json(body): Json<Value>,\n) -> Response {\n    // [FIX] 保存原始请求体的完整副本，用于日志记录\n    // 这确保了即使结构体定义遗漏字段，日志也能完整记录所有参数\n    let original_body = body.clone();\n    \n    tracing::debug!(\"handle_messages called. Body JSON len: {}\", body.to_string().len());\n    \n    // 生成随机 Trace ID 用户追踪\n    let trace_id: String = rand::Rng::sample_iter(rand::thread_rng(), &rand::distributions::Alphanumeric)\n        .take(6)\n        .map(char::from)\n        .collect::<String>().to_lowercase();\n    let debug_cfg = state.debug_logging.read().await.clone();\n    \n    // [NEW] Detect Client Adapter\n    // 检查是否有匹配的客户端适配器（如 opencode）\n    let client_adapter = CLIENT_ADAPTERS.iter().find(|a| a.matches(&headers)).cloned();\n    if let Some(_adapter) = &client_adapter {\n        tracing::debug!(\"[{}] Client Adapter detected: Applying custom strategies\", trace_id);\n    }\n        \n    // Decide whether this request should be handled by z.ai (Anthropic passthrough) or the existing Google flow.\n    let zai = state.zai.read().await.clone();\n    let zai_enabled = zai.enabled && !matches!(zai.dispatch_mode, crate::proxy::ZaiDispatchMode::Off);\n    let google_accounts = state.token_manager.len();\n\n    // [CRITICAL REFACTOR] 优先解析请求以获取模型信息(用于智能兜底判断)\n    let mut request: crate::proxy::mappers::claude::models::ClaudeRequest = match serde_json::from_value(body.clone()) {\n        Ok(r) => r,\n        Err(e) => {\n            return (\n                StatusCode::BAD_REQUEST,\n                Json(json!({\n                    \"type\": \"error\",\n                    \"error\": {\n                        \"type\": \"invalid_request_error\",\n                        \"message\": format!(\"Invalid request body: {}\", e)\n                    }\n                }))\n            ).into_response();\n        }\n    };\n\n    // [Task #6] Apply OpenCode variants thinking hints from raw JSON\n    // 由于此时还没拿到账号，先用模型默认限额兜底\n    let temp_cap = model_specs::get_thinking_budget(&request.model, None);\n    let thinking_hint = extract_thinking_hint(&original_body);\n    apply_thinking_hints(&mut request, &thinking_hint, &trace_id, temp_cap);\n\n    if debug_logger::is_enabled(&debug_cfg) {\n        // [FIX] 使用原始 body 副本记录日志，确保不丢失任何字段\n        let original_payload = json!({\n            \"kind\": \"original_request\",\n            \"protocol\": \"anthropic\",\n            \"trace_id\": trace_id,\n            \"original_model\": request.model,\n            \"request\": original_body,  // 使用原始请求体，不是结构体序列化\n        });\n        debug_logger::write_debug_payload(&debug_cfg, Some(&trace_id), \"original_request\", &original_payload).await;\n    }\n\n    // [Issue #703 Fix] 智能兜底判断:需要归一化模型名用于配额保护检查\n    let normalized_model = crate::proxy::common::model_mapping::normalize_to_standard_id(&request.model)\n        .unwrap_or_else(|| request.model.clone());\n\n    let use_zai = if !zai_enabled {\n        false\n    } else {\n        match zai.dispatch_mode {\n            crate::proxy::ZaiDispatchMode::Off => false,\n            crate::proxy::ZaiDispatchMode::Exclusive => true,\n            crate::proxy::ZaiDispatchMode::Fallback => {\n                if google_accounts == 0 {\n                    // 没有 Google 账号,使用兜底\n                    tracing::info!(\"[{}] No Google accounts available, using fallback provider\", trace_id);\n                    true\n                } else {\n                    // [Issue #703 Fix] 智能判断:检查是否有可用的 Google 账号\n                    let has_available = state.token_manager.has_available_account(\"claude\", &normalized_model).await;\n                    if !has_available {\n                        tracing::info!(\n                            \"[{}] All Google accounts unavailable (rate-limited or quota-protected for {}), using fallback provider\",\n                            trace_id,\n                            request.model\n                        );\n                    }\n                    !has_available\n                }\n            }\n            crate::proxy::ZaiDispatchMode::Pooled => {\n                // Treat z.ai as exactly one extra slot in the pool.\n                // No strict guarantees: it may get 0 requests if selection never hits.\n                let total = google_accounts.saturating_add(1).max(1);\n                let slot = state.provider_rr.fetch_add(1, Ordering::Relaxed) % total;\n                slot == 0\n            }\n        }\n    };\n\n    // [CRITICAL FIX] 预先清理所有消息中的 cache_control 字段 (Issue #744)\n    // 必须在序列化之前处理，以确保 z.ai 和 Google Flow 都不受历史消息缓存标记干扰\n    clean_cache_control_from_messages(&mut request.messages);\n\n    // [FIX #813] 合并连续的同角色消息 (Consecutive User Messages)\n    // 这对于 z.ai (Anthropic 直接转发) 路径至关重要，因为原始结构必须符合协议\n    merge_consecutive_messages(&mut request.messages);\n\n    // Get model family for signature validation\n    let target_family = if use_zai {\n        Some(\"claude\")\n    } else {\n        let mapped_model = crate::proxy::common::model_mapping::map_claude_model_to_gemini(&request.model);\n        if mapped_model.contains(\"gemini\") {\n            Some(\"gemini\")\n        } else {\n            Some(\"claude\")\n        }\n    };\n\n    // [CRITICAL FIX] 过滤并修复 Thinking 块签名 (Enhanced with family check)\n    filter_invalid_thinking_blocks_with_family(&mut request.messages, target_family);\n\n    // [New] Recover from broken tool loops (where signatures were stripped)\n    // This prevents \"Assistant message must start with thinking\" errors by closing the loop with synthetic messages\n    if state.experimental.read().await.enable_tool_loop_recovery {\n        close_tool_loop_for_thinking(&mut request.messages);\n    }\n\n    // ===== [Issue #467 Fix] 拦截 Claude Code Warmup 请求 =====\n    // Claude Code 会每 10 秒发送一次 warmup 请求来保持连接热身，\n    // 这些请求会消耗大量配额。检测到 warmup 请求后直接返回模拟响应。\n    if is_warmup_request(&request) {\n        tracing::info!(\n            \"[{}] 🔥 拦截 Warmup 请求，返回模拟响应（节省配额）\",\n            trace_id\n        );\n        return create_warmup_response(&request, request.stream);\n    }\n\n    if use_zai {\n        // 重新序列化修复后的请求体\n        let new_body = match serde_json::to_value(&request) {\n            Ok(v) => v,\n            Err(e) => {\n                tracing::error!(\"Failed to serialize fixed request for z.ai: {}\", e);\n                return StatusCode::INTERNAL_SERVER_ERROR.into_response();\n            }\n        };\n\n        return crate::proxy::providers::zai_anthropic::forward_anthropic_json(\n            &state,\n            axum::http::Method::POST,\n            \"/v1/messages\",\n            &headers,\n            new_body,\n            request.messages.len(), // [NEW v4.0.0] Pass message count\n        )\n        .await;\n    }\n    \n    // Google Flow 继续使用 request 对象\n    // (后续代码不需要再次 filter_invalid_thinking_blocks)\n    \n    // [NEW] 获取上下文控制配置\n    let experimental = state.experimental.read().await;\n    let scaling_enabled = experimental.enable_usage_scaling;\n    let threshold_l1 = experimental.context_compression_threshold_l1;\n    let threshold_l2 = experimental.context_compression_threshold_l2;\n    let threshold_l3 = experimental.context_compression_threshold_l3;\n\n    // 获取最新一条“有意义”的消息内容（用于日志记录和后台任务检测）\n    // 策略：反向遍历，首先筛选出所有角色为 \"user\" 的消息，然后从中找到第一条非 \"Warmup\" 且非空的文本消息\n    // 获取最新一条“有意义”的消息内容（用于日志记录和后台任务检测）\n    // 策略：反向遍历，首先筛选出所有和用户相关的消息 (role=\"user\")\n    // 然后提取其文本内容，跳过 \"Warmup\" 或系统预设的 reminder\n    let meaningful_msg = request.messages.iter().rev()\n        .filter(|m| m.role == \"user\")\n        .find_map(|m| {\n            let content = match &m.content {\n                crate::proxy::mappers::claude::models::MessageContent::String(s) => s.to_string(),\n                crate::proxy::mappers::claude::models::MessageContent::Array(arr) => {\n                    // 对于数组，提取所有 Text 块并拼接，忽略 ToolResult\n                    arr.iter()\n                        .filter_map(|block| match block {\n                            crate::proxy::mappers::claude::models::ContentBlock::Text { text } => Some(text.as_str()),\n                            _ => None,\n                        })\n                        .collect::<Vec<_>>()\n                        .join(\" \")\n                }\n            };\n            \n            // 过滤规则：\n            // 1. 忽略空消息\n            // 2. 忽略 \"Warmup\" 消息\n            // 3. 忽略 <system-reminder> 标签的消息\n            if content.trim().is_empty() \n                || content.starts_with(\"Warmup\") \n                || content.contains(\"<system-reminder>\") \n            {\n                None \n            } else {\n                Some(content)\n            }\n        });\n\n    // 如果经过过滤还是找不到（例如纯工具调用），则回退到最后一条消息的原始展示\n    let latest_msg = meaningful_msg.unwrap_or_else(|| {\n        request.messages.last().map(|m| {\n            match &m.content {\n                crate::proxy::mappers::claude::models::MessageContent::String(s) => s.clone(),\n                crate::proxy::mappers::claude::models::MessageContent::Array(_) => \"[Complex/Tool Message]\".to_string()\n            }\n        }).unwrap_or_else(|| \"[No Messages]\".to_string())\n    });\n    \n    \n    // INFO 级别: 简洁的一行摘要\n    info!(\n        \"[{}] Claude Request | Model: {} | Stream: {} | Messages: {} | Tools: {}\",\n        trace_id,\n        request.model,\n        request.stream,\n        request.messages.len(),\n        request.tools.is_some()\n    );\n    \n    // DEBUG 级别: 详细的调试信息\n    debug!(\"========== [{}] CLAUDE REQUEST DEBUG START ==========\", trace_id);\n    debug!(\"[{}] Model: {}\", trace_id, request.model);\n    debug!(\"[{}] Stream: {}\", trace_id, request.stream);\n    debug!(\"[{}] Max Tokens: {:?}\", trace_id, request.max_tokens);\n    debug!(\"[{}] Temperature: {:?}\", trace_id, request.temperature);\n    debug!(\"[{}] Message Count: {}\", trace_id, request.messages.len());\n    debug!(\"[{}] Has Tools: {}\", trace_id, request.tools.is_some());\n    debug!(\"[{}] Has Thinking Config: {}\", trace_id, request.thinking.is_some());\n    debug!(\"[{}] Content Preview: {:.100}...\", trace_id, latest_msg);\n    \n    // 输出每一条消息的详细信息\n    for (idx, msg) in request.messages.iter().enumerate() {\n        let content_preview = match &msg.content {\n            crate::proxy::mappers::claude::models::MessageContent::String(s) => {\n                let char_count = s.chars().count();\n                if char_count > 200 {\n                    // 【修复】使用 chars().take() 安全截取，避免 UTF-8 字符边界 panic\n                    let preview: String = s.chars().take(200).collect();\n                    format!(\"{}... (total {} chars)\", preview, char_count)\n                } else {\n                    s.clone()\n                }\n            },\n            crate::proxy::mappers::claude::models::MessageContent::Array(arr) => {\n                format!(\"[Array with {} blocks]\", arr.len())\n            }\n        };\n        debug!(\"[{}] Message[{}] - Role: {}, Content: {}\", \n            trace_id, idx, msg.role, content_preview);\n    }\n    \n    debug!(\"[{}] Full Claude Request JSON: {}\", trace_id, serde_json::to_string_pretty(&request).unwrap_or_default());\n    debug!(\"========== [{}] CLAUDE REQUEST DEBUG END ==========\", trace_id);\n\n    // 1. 获取 会话 ID (已废弃基于内容的哈希，改用 TokenManager 内部的时间窗口锁定)\n    let _session_id: Option<&str> = None;\n\n    // 2. 获取 UpstreamClient\n    let upstream = state.upstream.clone();\n    \n    // 3. 准备闭包\n    let mut request_for_body = request.clone();\n    let token_manager = state.token_manager;\n    \n    let pool_size = token_manager.len();\n    // [FIX] Ensure max_attempts is at least 2 to allow for internal retries (e.g. stripping signatures)\n    // even if the user has only 1 account.\n    let max_attempts = MAX_RETRY_ATTEMPTS.min(pool_size.saturating_add(1)).max(2);\n\n    let mut last_error = String::new();\n    let retried_without_thinking = false;\n    let mut last_email: Option<String> = None;\n    let mut last_mapped_model: Option<String> = None;\n    let mut last_status = StatusCode::SERVICE_UNAVAILABLE; // Default to 503 if no response reached\n    \n    for attempt in 0..max_attempts {\n        // 2. 模型路由解析\n        let mut mapped_model = crate::proxy::common::model_mapping::resolve_model_route(\n            &request_for_body.model,\n            &*state.custom_mapping.read().await,\n        );\n        last_mapped_model = Some(mapped_model.clone());\n        \n        // 将 Claude 工具转为 Value 数组以便探测联网\n        let tools_val: Option<Vec<Value>> = request_for_body.tools.as_ref().map(|list| {\n            list.iter().map(|t| serde_json::to_value(t).unwrap_or(json!({}))).collect()\n        });\n\n        let config = crate::proxy::mappers::common_utils::resolve_request_config(\n            &request_for_body.model,\n            &mapped_model,\n            &tools_val,\n            request.size.as_deref(),      // [NEW] Pass size parameter\n            request.quality.as_deref(),   // [NEW] Pass quality parameter\n            None,  // image_size\n            None,  // body\n        );\n\n        // 0. 尝试提取 session_id 用于粘性调度 (Phase 2/3)\n        // 使用 SessionManager 生成稳定的会话指纹\n        let session_id_str = crate::proxy::session_manager::SessionManager::extract_session_id(&request_for_body);\n        let session_id = Some(session_id_str.as_str());\n\n        let force_rotate_token = attempt > 0;\n        let (access_token, project_id, email, account_id, _wait_ms) = match token_manager.get_token(&config.request_type, force_rotate_token, session_id, &config.final_model).await {\n            Ok(t) => t,\n            Err(e) => {\n                let safe_message = if e.contains(\"invalid_grant\") {\n                    \"OAuth refresh failed (invalid_grant): refresh_token likely revoked/expired; reauthorize account(s) to restore service.\".to_string()\n                } else {\n                    e\n                };\n                let headers = [\n                    (\"X-Mapped-Model\", mapped_model.as_str()),\n                ];\n                 return (\n                    StatusCode::SERVICE_UNAVAILABLE,\n                    headers,\n                    Json(json!({\n                        \"type\": \"error\",\n                        \"error\": {\n                            \"type\": \"overloaded_error\",\n                            \"message\": format!(\"No available accounts: {}\", safe_message)\n                        }\n                    }))\n                ).into_response();\n            }\n        };\n\n        last_email = Some(email.clone());\n        info!(\"✓ Using account: {} (type: {})\", email, config.request_type);\n        \n        \n        // ===== 【优化】后台任务智能检测与降级 =====\n        // 使用新的检测系统，支持 5 大类关键词和多 Flash 模型策略\n        let background_task_type = detect_background_task_type(&request_for_body);\n        \n        // 传递映射后的模型名\n        let mut request_with_mapped = request_for_body.clone();\n\n        if let Some(task_type) = background_task_type {\n            // 检测到后台任务,强制降级到 Flash 模型\n            let virtual_model_id = select_background_model(task_type);\n            \n            // [FIX] 必须根据虚拟 ID Re-resolve 路由，以支持用户自定义映射 (如 internal-task -> gemini-3)\n            // 否则会直接使用 generic ID 导致下游无法识别或只能使用静态默认值\n            let resolved_model = crate::proxy::common::model_mapping::resolve_model_route(\n                virtual_model_id, \n                &*state.custom_mapping.read().await\n            );\n\n            info!(\n                \"[{}][AUTO] 检测到后台任务 (类型: {:?}), 路由重定向: {} -> {} (最终物理模型: {})\",\n                trace_id,\n                task_type,\n                mapped_model,\n                virtual_model_id,\n                resolved_model\n            );\n            \n            // 覆盖用户自定义映射 (同时更新变量和 Request 对象)\n            mapped_model = resolved_model.clone();\n            request_with_mapped.model = resolved_model;\n            \n            // 后台任务净化：\n            // 1. 移除工具定义（后台任务不需要工具）\n            request_with_mapped.tools = None;\n            \n            // 2. 移除 Thinking 配置（Flash 模型不支持）\n            request_with_mapped.thinking = None;\n            \n            // 3. 清理历史消息中的 Thinking Block，防止 Invalid Argument\n            // 使用 ContextManager 的统一策略 (Aggressive)\n            crate::proxy::mappers::context_manager::ContextManager::purify_history(\n                &mut request_with_mapped.messages, \n                crate::proxy::mappers::context_manager::PurificationStrategy::Aggressive\n            );\n        }\n\n        // ===== [3-Layer Progressive Compression + Calibrated Estimation] Context Management =====\n        // [ENHANCED] 整合 3.3.47 的三层压缩框架 + PR #925 的动态校准机制\n        // [NEW] 只有当 scaling_enabled 为 true 时才执行压缩逻辑 (联动机制)\n        // Layer 1 (60%): Tool message trimming - Does NOT break cache\n        // Layer 2 (75%): Thinking purification - Breaks cache but preserves signatures\n        // Layer 3 (90%): Fork conversation + XML summary - Ultimate optimization\n        let mut is_purified = false;\n        let mut compression_applied = false;\n        \n        if !retried_without_thinking && scaling_enabled {  // 新增 scaling_enabled 联动判断\n            // 1. Determine context limit (Flash: ~1M, Pro: ~2M)\n            let context_limit = if mapped_model.contains(\"flash\") {\n                1_000_000\n            } else {\n                2_000_000\n            };\n\n            // 2. [ENHANCED] 使用校准器提高估算准确度 (PR #925)\n            let raw_estimated = ContextManager::estimate_token_usage(&request_with_mapped);\n            let calibrator = get_calibrator();\n            let mut estimated_usage = calibrator.calibrate(raw_estimated);\n            let mut usage_ratio = estimated_usage as f32 / context_limit as f32;\n            \n            info!(\n                \"[{}] [ContextManager] Context pressure: {:.1}% (raw: {}, calibrated: {} / {}), Calibration factor: {:.2}\",\n                trace_id, usage_ratio * 100.0, raw_estimated, estimated_usage, context_limit, calibrator.get_factor()\n            );\n\n            // ===== Layer 1: Tool Message Trimming (L1 threshold) =====\n            // Borrowed from Practical-Guide-to-Context-Engineering\n            // Advantage: Completely cache-friendly (only removes messages, doesn't modify content)\n            if usage_ratio > threshold_l1 && !compression_applied {\n                if ContextManager::trim_tool_messages(&mut request_with_mapped.messages, 5) {\n                    info!(\n                        \"[{}] [Layer-1] Tool trimming triggered (usage: {:.1}%, threshold: {:.1}%)\",\n                        trace_id, usage_ratio * 100.0, threshold_l1 * 100.0\n                    );\n                    compression_applied = true;\n                    \n                    // Re-estimate after trimming (with calibration)\n                    let new_raw = ContextManager::estimate_token_usage(&request_with_mapped);\n                    let new_usage = calibrator.calibrate(new_raw);\n                    let new_ratio = new_usage as f32 / context_limit as f32;\n                    \n                    info!(\n                        \"[{}] [Layer-1] Compression result: {:.1}% → {:.1}% (saved {} tokens)\",\n                        trace_id, usage_ratio * 100.0, new_ratio * 100.0, estimated_usage - new_usage\n                    );\n                    \n                    // If compression is sufficient, skip further layers\n                    if new_ratio < 0.7 {\n                        estimated_usage = new_usage;\n                        usage_ratio = new_ratio;\n                        // Success, no need for Layer 2\n                    } else {\n                        // Still high pressure, update for Layer 2\n                        usage_ratio = new_ratio;\n                        compression_applied = false; // Allow Layer 2 to run\n                    }\n                }\n            }\n\n            // ===== Layer 2: Thinking Content Compression (L2 threshold) =====\n            // NEW: Preserve signatures while compressing thinking text\n            // This prevents signature chain breakage (Issue #902)\n            if usage_ratio > threshold_l2 && !compression_applied {\n                info!(\n                    \"[{}] [Layer-2] Thinking compression triggered (usage: {:.1}%, threshold: {:.1}%)\",\n                    trace_id, usage_ratio * 100.0, threshold_l2 * 100.0\n                );\n                \n                // Use new signature-preserving compression\n                if ContextManager::compress_thinking_preserve_signature(\n                    &mut request_with_mapped.messages, \n                    4 // Protect last 4 messages (~2 turns)\n                ) {\n                    is_purified = true; // Still breaks cache, but preserves signatures\n                    compression_applied = true;\n                    \n                    let new_raw = ContextManager::estimate_token_usage(&request_with_mapped);\n                    let new_usage = calibrator.calibrate(new_raw);\n                    let new_ratio = new_usage as f32 / context_limit as f32;\n                    \n                    info!(\n                        \"[{}] [Layer-2] Compression result: {:.1}% → {:.1}% (saved {} tokens)\",\n                        trace_id, usage_ratio * 100.0, new_ratio * 100.0, estimated_usage - new_usage\n                    );\n                    \n                    usage_ratio = new_ratio;\n                }\n            }\n\n            // ===== Layer 3: Fork Conversation + XML Summary (L3 threshold) =====\n            // Ultimate optimization: Generate structured summary and start fresh conversation\n            // Advantage: Completely cache-friendly (append-only), extreme compression ratio\n            if usage_ratio > threshold_l3 && !compression_applied {\n                info!(\n                    \"[{}] [Layer-3] Context pressure ({:.1}%) exceeded threshold ({:.1}%), attempting Fork+Summary\",\n                    trace_id, usage_ratio * 100.0, threshold_l3 * 100.0\n                );\n                \n                // Clone token_manager Arc to avoid borrow issues\n                let token_manager_clone = token_manager.clone();\n                \n                match try_compress_with_summary(&request_with_mapped, &trace_id, &token_manager_clone).await {\n                    Ok(forked_request) => {\n                        info!(\n                            \"[{}] [Layer-3] Fork successful: {} → {} messages\",\n                            trace_id,\n                            request_with_mapped.messages.len(),\n                            forked_request.messages.len()\n                        );\n                        \n                        request_with_mapped = forked_request;\n                        is_purified = false; // Fork doesn't break cache!\n                        \n                        // Re-estimate after fork (with calibration)\n                        let new_raw = ContextManager::estimate_token_usage(&request_with_mapped);\n                        let new_usage = calibrator.calibrate(new_raw);\n                        let new_ratio = new_usage as f32 / context_limit as f32;\n                        \n                        info!(\n                            \"[{}] [Layer-3] Compression result: {:.1}% → {:.1}% (saved {} tokens)\",\n                            trace_id, usage_ratio * 100.0, new_ratio * 100.0, estimated_usage - new_usage\n                        );\n                    }\n                    Err(e) => {\n                        error!(\n                            \"[{}] [Layer-3] Fork+Summary failed: {}, falling back to error response\",\n                            trace_id, e\n                        );\n                        \n                        // Return friendly error to user\n                        return (\n                            StatusCode::BAD_REQUEST,\n                            Json(json!({\n                                \"type\": \"error\",\n                                \"error\": {\n                                    \"type\": \"invalid_request_error\",\n                                    \"message\": format!(\"Context too long and automatic compression failed: {}\", e),\n                                    \"suggestion\": \"Please use /compact or /clear command in Claude Code, or switch to a model with larger context window.\"\n                                }\n                            }))\n                        ).into_response();\n                    }\n                }\n            }\n        }\n\n        // [FIX] Estimate AFTER purification to get accurate token count for calibrator learning\n        // Only estimate for calibrator when content was not purified, to avoid skewed learning\n        let raw_estimated = if !is_purified {\n            ContextManager::estimate_token_usage(&request_with_mapped)\n        } else {\n            0 // Don't record calibration data when content was purified\n        };\n\n        request_with_mapped.model = mapped_model.clone();\n\n        // 生成 Trace ID (简单用时间戳后缀)\n        // let _trace_id = format!(\"req_{}\", chrono::Utc::now().timestamp_subsec_millis());\n\n        let token_obj = token_manager.get_token_by_id(&account_id);\n        let gemini_body = match transform_claude_request_in(&request_with_mapped, &project_id, retried_without_thinking, Some(account_id.as_str()), &session_id_str, token_obj.as_ref()) {\n            Ok(b) => {\n                debug!(\"[{}] Transformed Gemini Body: {}\", trace_id, serde_json::to_string_pretty(&b).unwrap_or_default());\n                b\n            },\n            Err(e) => {\n                 let headers = [\n                    (\"X-Mapped-Model\", request_with_mapped.model.as_str()),\n                    (\"X-Account-Email\", email.as_str()),\n                ];\n                 return (\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    headers,\n                    Json(json!({\n                        \"type\": \"error\",\n                        \"error\": {\n                            \"type\": \"api_error\",\n                            \"message\": format!(\"Transform error: {}\", e)\n                        }\n                    }))\n                ).into_response();\n            }\n        };\n\n        if debug_logger::is_enabled(&debug_cfg) {\n            let payload = json!({\n                \"kind\": \"v1internal_request\",\n                \"protocol\": \"anthropic\",\n                \"trace_id\": trace_id,\n                \"original_model\": request.model,\n                \"mapped_model\": request_with_mapped.model,\n                \"request_type\": config.request_type,\n                \"attempt\": attempt,\n                \"v1internal_request\": gemini_body.clone(),\n            });\n            debug_logger::write_debug_payload(&debug_cfg, Some(&trace_id), \"v1internal_request\", &payload).await;\n        }\n        \n    // 4. 上游调用 - 自动转换逻辑\n    let client_wants_stream = request.stream;\n    // [AUTO-CONVERSION] 非 Stream 请求自动转换为 Stream 以享受更宽松的配额\n    let force_stream_internally = !client_wants_stream;\n    let actual_stream = client_wants_stream || force_stream_internally;\n    \n    if force_stream_internally {\n        info!(\"[{}] 🔄 Auto-converting non-stream request to stream for better quota\", trace_id);\n    }\n    \n    let method = if actual_stream { \"streamGenerateContent\" } else { \"generateContent\" };\n    let query = if actual_stream { Some(\"alt=sse\") } else { None };\n        // [FIX #765/1522] Prepare Robust Beta Headers for Claude models\n        let mut extra_headers = std::collections::HashMap::new();\n        if mapped_model.to_lowercase().contains(\"claude\") {\n            extra_headers.insert(\"anthropic-beta\".to_string(), \"claude-code-20250219\".to_string());\n            tracing::debug!(\"[{}] Added Comprehensive Beta Headers for Claude model\", trace_id);\n        }\n        \n        // [NEW] Inject Beta Headers from Client Adapter\n        if let Some(adapter) = &client_adapter {\n            let mut temp_headers = HeaderMap::new();\n            adapter.inject_beta_headers(&mut temp_headers);\n            for (k, v) in temp_headers {\n                if let Some(name) = k {\n                    if let Ok(v_str) = v.to_str() {\n                        extra_headers.insert(name.to_string(), v_str.to_string());\n                        tracing::debug!(\"[{}] Added Adapter Header: {}: {}\", trace_id, name, v_str);\n                    }\n                }\n            }\n        }\n\n        // Upstream call configuration continued...\n\n        let call_result = match upstream\n            .call_v1_internal_with_headers(method, &access_token, gemini_body, query, extra_headers.clone(), Some(account_id.as_str()))\n            .await {\n            Ok(r) => r,\n            Err(e) => {\n                last_error = e.clone();\n                debug!(\"Request failed on attempt {}/{}: {}\", attempt + 1, max_attempts, e);\n                continue;\n            }\n        };\n\n        // [NEW] 记录端点降级日志到 debug 文件\n        if !call_result.fallback_attempts.is_empty() && debug_logger::is_enabled(&debug_cfg) {\n            let fallback_entries: Vec<Value> = call_result.fallback_attempts.iter().map(|a| {\n                json!({\n                    \"endpoint_url\": a.endpoint_url,\n                    \"status\": a.status,\n                    \"error\": a.error,\n                })\n            }).collect();\n            let payload = json!({\n                \"kind\": \"endpoint_fallback\",\n                \"protocol\": \"anthropic\",\n                \"trace_id\": trace_id,\n                \"original_model\": request.model,\n                \"mapped_model\": request_with_mapped.model,\n                \"attempt\": attempt,\n                \"account\": mask_email(&email),\n                \"fallback_attempts\": fallback_entries,\n            });\n            debug_logger::write_debug_payload(&debug_cfg, Some(&trace_id), \"endpoint_fallback\", &payload).await;\n        }\n\n        let response = call_result.response;\n        // [NEW] 提取实际请求的上游端点 URL，用于日志记录和排查\n        let upstream_url = response.url().to_string();\n        let status = response.status();\n        last_status = status;\n        \n        // 成功\n        if status.is_success() {\n            // [智能限流] 请求成功，重置该账号的连续失败计数\n            token_manager.mark_account_success(&email);\n            \n                // Determine context limit based on model\n                let context_limit = crate::proxy::mappers::claude::utils::get_context_limit_for_model(&request_with_mapped.model);\n\n            // 处理流式响应\n            if actual_stream {\n                let meta = json!({\n                    \"protocol\": \"anthropic\",\n                    \"trace_id\": trace_id,\n                    \"original_model\": request.model,\n                    \"mapped_model\": request_with_mapped.model,\n                    \"request_type\": config.request_type,\n                    \"attempt\": attempt,\n                    \"status\": status.as_u16(),\n                    \"upstream_url\": upstream_url,\n                });\n                let gemini_stream = debug_logger::wrap_stream_with_debug(\n                    Box::pin(response.bytes_stream()),\n                    debug_cfg.clone(),\n                    trace_id.clone(),\n                    \"upstream_response\",\n                    meta,\n                );\n\n                let current_message_count = request_with_mapped.messages.len();\n\n                // [FIX #MCP] Extract registered tool names for MCP fuzzy matching\n                let registered_tool_names: Vec<String> = request_with_mapped.tools\n                    .as_ref()\n                    .map(|tools| tools.iter().filter_map(|t| t.name.clone()).collect())\n                    .unwrap_or_default();\n\n                // [FIX #530/#529/#859] Enhanced Peek logic to handle heartbeats and slow start\n                // We must pre-read until we find a MEANINGFUL content block (like message_start).\n                // If we only get heartbeats (ping) and then the stream dies, we should rotate account.\n                let mut claude_stream = create_claude_sse_stream(\n                    gemini_stream,\n                    trace_id.clone(),\n                    email.clone(),\n                    Some(session_id_str.clone()),\n                    scaling_enabled,\n                    context_limit,\n                    Some(raw_estimated), // [FIX] Pass estimated tokens for calibrator learning\n                    current_message_count, // [NEW v4.0.0] Pass message count for rewind detection\n                    client_adapter.clone(), // [NEW] Pass client adapter\n                    registered_tool_names, // [FIX #MCP] Pass tool names for fuzzy matching\n                );\n\n                let mut first_data_chunk = None;\n                let mut retry_this_account = false;\n\n                // Loop to skip heartbeats during peek\n                loop {\n                    match tokio::time::timeout(std::time::Duration::from_secs(60), claude_stream.next()).await {\n                        Ok(Some(Ok(bytes))) => {\n                            if bytes.is_empty() {\n                                continue;\n                            }\n                            \n                            let text = String::from_utf8_lossy(&bytes);\n                            // Skip SSE comments/pings\n                            if text.trim().starts_with(\":\") {\n                                debug!(\"[{}] Skipping peek heartbeat: {}\", trace_id, text.trim());\n                                continue;\n                            }\n\n                            // We found real data!\n                            first_data_chunk = Some(bytes);\n                            break;\n                        }\n                        Ok(Some(Err(e))) => {\n                            tracing::warn!(\"[{}] Stream error during peek: {}, retrying...\", trace_id, e);\n                            last_error = format!(\"Stream error during peek: {}\", e);\n                            retry_this_account = true;\n                            break;\n                        }\n                        Ok(None) => {\n                            tracing::warn!(\"[{}] Stream ended during peek (Empty Response), retrying...\", trace_id);\n                            last_error = \"Empty response stream during peek\".to_string();\n                            retry_this_account = true;\n                            break;\n                        }\n                        Err(_) => {\n                            tracing::warn!(\"[{}] Timeout waiting for first data (60s), retrying...\", trace_id);\n                            last_error = \"Timeout waiting for first data\".to_string();\n                            retry_this_account = true;\n                            break;\n                        }\n                    }\n                }\n\n                if retry_this_account {\n                    continue;\n                }\n\n                match first_data_chunk {\n                    Some(bytes) => {\n                        // We have data! Construct the combined stream\n                        let stream_rest = claude_stream;\n                        let combined_stream = Box::pin(futures::stream::once(async move { Ok(bytes) })\n                            .chain(stream_rest.map(|result| -> Result<Bytes, std::io::Error> {\n                                match result {\n                                    Ok(b) => Ok(b),\n                                    Err(e) => Ok(Bytes::from(format!(\"data: {{\\\"error\\\":\\\"{}\\\"}}\\n\\n\", e))),\n                                }\n                            })));\n\n                        // 判断客户端期望的格式\n                        if client_wants_stream {\n                            // 客户端本就要 Stream，直接返回 SSE\n                            return Response::builder()\n                                .status(StatusCode::OK)\n                                .header(header::CONTENT_TYPE, \"text/event-stream\")\n                                .header(header::CACHE_CONTROL, \"no-cache\")\n                                .header(header::CONNECTION, \"keep-alive\")\n                                .header(\"X-Accel-Buffering\", \"no\")\n                                .header(\"X-Account-Email\", &email)\n                                .header(\"X-Mapped-Model\", &request_with_mapped.model)\n                                .header(\"X-Context-Purified\", if is_purified { \"true\" } else { \"false\" })\n                                .body(Body::from_stream(combined_stream))\n                                .unwrap();\n                        } else {\n                            // 客户端要非 Stream，需要收集完整响应并转换为 JSON\n                            use crate::proxy::mappers::claude::collect_stream_to_json;\n                            \n                            match collect_stream_to_json(combined_stream).await {\n                                Ok(full_response) => {\n                                    info!(\"[{}] ✓ Stream collected and converted to JSON\", trace_id);\n                                    return Response::builder()\n                                        .status(StatusCode::OK)\n                                        .header(header::CONTENT_TYPE, \"application/json\")\n                                        .header(\"X-Account-Email\", &email)\n                                        .header(\"X-Mapped-Model\", &request_with_mapped.model)\n                                        .header(\"X-Context-Purified\", if is_purified { \"true\" } else { \"false\" })\n                                        .body(Body::from(serde_json::to_string(&full_response).unwrap()))\n                                        .unwrap();\n                                }\n                                Err(e) => {\n                                    return (StatusCode::INTERNAL_SERVER_ERROR, format!(\"Stream collection error: {}\", e)).into_response();\n                                }\n                            }\n                        }\n                    },\n\n                    None => {\n                        tracing::warn!(\"[{}] Stream ended immediately (Empty Response), retrying...\", trace_id);\n                        last_error = \"Empty response stream (None)\".to_string();\n                        continue;\n                    }\n                }\n            } else {\n                // 处理非流式响应\n                let bytes = match response.bytes().await {\n                    Ok(b) => b,\n                    Err(e) => return (StatusCode::BAD_GATEWAY, format!(\"Failed to read body: {}\", e)).into_response(),\n                };\n                \n                // Debug print\n                if let Ok(text) = String::from_utf8(bytes.to_vec()) {\n                    debug!(\"Upstream Response for Claude request: {}\", text);\n                }\n\n                let gemini_resp: Value = match serde_json::from_slice(&bytes) {\n                    Ok(v) => v,\n                    Err(e) => return (StatusCode::BAD_GATEWAY, format!(\"Parse error: {}\", e)).into_response(),\n                };\n\n                // 解包 response 字段（v1internal 格式）\n                let raw = gemini_resp.get(\"response\").unwrap_or(&gemini_resp);\n\n                // 转换为 Gemini Response 结构\n                let gemini_response: crate::proxy::mappers::claude::models::GeminiResponse = match serde_json::from_value(raw.clone()) {\n                    Ok(r) => r,\n                    Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!(\"Convert error: {}\", e)).into_response(),\n                };\n                \n                // Determine context limit based on model\n                let context_limit = crate::proxy::mappers::claude::utils::get_context_limit_for_model(&request_with_mapped.model);\n\n                // 转换\n                // [FIX #765] Pass session_id and model_name for signature caching\n                let s_id_owned = session_id.map(|s| s.to_string());\n                // 转换\n                let claude_response = match transform_response(\n                    &gemini_response,\n                    scaling_enabled,\n                    context_limit,\n                    s_id_owned,\n                    request_with_mapped.model.clone(),\n                    request_with_mapped.messages.len(), // [NEW v4.0.0] Pass message count for rewind detection\n                ) {\n                    Ok(r) => r,\n                    Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!(\"Transform error: {}\", e)).into_response(),\n                };\n\n                // [Optimization] 记录闭环日志：消耗情况\n                let cache_info = if let Some(cached) = claude_response.usage.cache_read_input_tokens {\n                    format!(\", Cached: {}\", cached)\n                } else {\n                    String::new()\n                };\n                \n                tracing::info!(\n                    \"[{}] Request finished. Model: {}, Tokens: In {}, Out {}{}\", \n                    trace_id, \n                    request_with_mapped.model, \n                    claude_response.usage.input_tokens, \n                    claude_response.usage.output_tokens,\n                    cache_info\n                );\n\n                return (StatusCode::OK, [(\"X-Account-Email\", email.as_str()), (\"X-Mapped-Model\", request_with_mapped.model.as_str())], Json(claude_response)).into_response();\n            }\n        }\n        \n        // 1. 立即提取状态码和 headers（防止 response 被 move）\n        let status_code = status.as_u16();\n        last_status = status;\n        let retry_after = response.headers().get(\"Retry-After\").and_then(|h| h.to_str().ok()).map(|s| s.to_string());\n        \n        // 2. 获取错误文本并转移 Response 所有权\n        let error_text = response.text().await.unwrap_or_else(|_| format!(\"HTTP {}\", status));\n        last_error = format!(\"HTTP {}: {}\", status_code, error_text);\n        debug!(\"[{}] Upstream Error Response: {}\", trace_id, error_text);\n        if debug_logger::is_enabled(&debug_cfg) {\n            let payload = json!({\n                \"kind\": \"upstream_response_error\",\n                \"protocol\": \"anthropic\",\n                \"trace_id\": trace_id,\n                \"original_model\": request.model,\n                \"mapped_model\": request_with_mapped.model,\n                \"request_type\": config.request_type,\n                \"attempt\": attempt,\n                \"status\": status_code,\n                \"upstream_url\": upstream_url,\n                \"account\": mask_email(&email),\n                \"error_text\": error_text,\n            });\n            debug_logger::write_debug_payload(&debug_cfg, Some(&trace_id), \"upstream_response_error\", &payload).await;\n        }\n        \n        // 3. 标记限流状态(用于 UI 显示) - 使用异步版本以支持实时配额刷新\n        // 🆕 传入实际使用的模型,实现模型级别限流,避免不同模型配额互相影响\n        if status_code == 429 || status_code == 529 || status_code == 503 || status_code == 500 || status_code == 404 {\n            token_manager.mark_rate_limited_async(&email, status_code, retry_after.as_deref(), &error_text, Some(&request_with_mapped.model)).await;\n        }\n\n        // 4. 处理 400 错误 (Thinking 签名失效 或 块顺序错误)\n        if status_code == 400\n            && !retried_without_thinking\n            && (error_text.contains(\"Invalid `signature`\")\n                || error_text.contains(\"thinking.signature: Field required\")\n                || error_text.contains(\"thinking.thinking: Field required\")\n                || error_text.contains(\"thinking.signature\")\n                || error_text.contains(\"thinking.thinking\")\n                || error_text.contains(\"Corrupted thought signature\")\n                || error_text.contains(\"failed to deserialise\")\n                || error_text.contains(\"Invalid signature\")\n                || error_text.contains(\"thinking block\")\n                || error_text.contains(\"Found `text`\")\n                || error_text.contains(\"Found 'text'\")\n                || error_text.contains(\"must be `thinking`\")\n                || error_text.contains(\"must be 'thinking'\")\n                )\n        {\n            // Existing logic for thinking signature...\\n            retried_without_thinking = true;\n            \n            // 使用 WARN 级别,因为这不应该经常发生(已经主动过滤过)\n            tracing::warn!(\n                \"[{}] Unexpected thinking signature error (should have been filtered). \\\n                 Retrying with all thinking blocks removed.\",\n                trace_id\n            );\n\n            // [NEW] 追加修复提示词到最后一条用户消息\n            if let Some(last_msg) = request_for_body.messages.last_mut() {\n                if last_msg.role == \"user\" {\n                    let repair_prompt = \"\\n\\n[System Recovery] Your previous output contained an invalid signature. Please regenerate the response without the corrupted signature block.\";\n                    \n                    match &mut last_msg.content {\n                        crate::proxy::mappers::claude::models::MessageContent::String(s) => {\n                            s.push_str(repair_prompt);\n                        }\n                        crate::proxy::mappers::claude::models::MessageContent::Array(blocks) => {\n                            blocks.push(crate::proxy::mappers::claude::models::ContentBlock::Text {\n                                text: repair_prompt.to_string(),\n                            });\n                        }\n                    }\n                    tracing::debug!(\"[{}] Appended repair prompt to last user message\", trace_id);\n                }\n            }\n\n            // [IMPROVED] 不再禁用 Thinking 模式！\n            // 既然我们已经将历史 Thinking Block 转换为 Text，那么当前请求可以视为一个新的 Thinking 会话\n            // 保持 thinking 配置开启，让模型重新生成思维，避免退化为简单的 \"OK\" 回复\n            // request_for_body.thinking = None;\n            \n            // 清理历史消息中的所有 Thinking Block，将其转换为 Text 以保留上下文\n            for msg in request_for_body.messages.iter_mut() {\n                if let crate::proxy::mappers::claude::models::MessageContent::Array(blocks) = &mut msg.content {\n                    let mut new_blocks = Vec::with_capacity(blocks.len());\n                    for block in blocks.drain(..) {\n                        match block {\n                            crate::proxy::mappers::claude::models::ContentBlock::Thinking { thinking, .. } => {\n                                // 降级为 text\n                                if !thinking.is_empty() {\n                                    tracing::debug!(\"[Fallback] Converting thinking block to text (len={})\", thinking.len());\n                                    new_blocks.push(crate::proxy::mappers::claude::models::ContentBlock::Text { \n                                        text: thinking \n                                    });\n                                }\n                            },\n                            crate::proxy::mappers::claude::models::ContentBlock::RedactedThinking { .. } => {\n                                // Redacted thinking 没什么用，直接丢弃\n                            },\n                            _ => new_blocks.push(block),\n                        }\n                    }\n                    *blocks = new_blocks;\n                }\n            }\n            \n            // [NEW] Heal session after stripping thinking blocks to prevent \"naked ToolResult\" rejection\n            // This ensures that any ToolResult in history is properly \"closed\" with synthetic messages\n            // if its preceding Thinking block was just converted to Text.\n            crate::proxy::mappers::claude::thinking_utils::close_tool_loop_for_thinking(&mut request_for_body.messages);\n            \n            // 清理模型名中的 -thinking 后缀\n            if request_for_body.model.contains(\"claude-\") {\n                let mut m = request_for_body.model.clone();\n                m = m.replace(\"-thinking\", \"\");\n                if m.contains(\"claude-sonnet-4-6-\") {\n                    m = \"claude-sonnet-4-6\".to_string();\n                } else if m.contains(\"claude-sonnet-4-5-\") {\n                    m = \"claude-sonnet-4-6\".to_string();\n                } else if m.contains(\"claude-opus-4-6-\") {\n                    m = \"claude-opus-4-6\".to_string();\n                } else if m.contains(\"claude-opus-4-5-\") || m.contains(\"claude-opus-4-\") {\n                    m = \"claude-opus-4-5\".to_string();\n                }\n                request_for_body.model = m;\n            }\n            \n            // [FIX] 强制重试：因为我们已经清理了 thinking block，所以这是一个新的、可以重试的请求\n            // 不要使用 determine_retry_strategy，因为它会因为 retried_without_thinking=true 而返回 NoRetry\n            if apply_retry_strategy(\n                RetryStrategy::FixedDelay(Duration::from_millis(200)), \n                attempt, \n                max_attempts,\n                status_code, \n                &trace_id\n            ).await {\n                continue;\n            }\n        }\n\n        // 5. 统一处理所有可重试错误\n        // [REMOVED] 不再特殊处理 QUOTA_EXHAUSTED,允许账号轮换\n        // 原逻辑会在第一个账号配额耗尽时直接返回,导致\"平衡\"模式无法切换账号\n\n        // [FIX] 403 时设置 is_forbidden 状态，避免账号被重复选中\n        if status_code == 403 {\n            // Check for VALIDATION_REQUIRED error - temporarily block account\n            if error_text.contains(\"VALIDATION_REQUIRED\") ||\n               error_text.contains(\"verify your account\") ||\n               error_text.contains(\"validation_url\")\n            {\n                tracing::warn!(\n                    \"[Claude] VALIDATION_REQUIRED detected on account {}, temporarily blocking\",\n                    email\n                );\n                let block_minutes = 10i64;\n                let block_until = chrono::Utc::now().timestamp() + (block_minutes * 60);\n                if let Err(e) = token_manager.set_validation_block_public(&account_id, block_until, &error_text).await {\n                    tracing::error!(\"Failed to set validation block: {}\", e);\n                }\n            }\n\n            // 设置 is_forbidden 状态\n            if let Err(e) = token_manager.set_forbidden(&account_id, &error_text).await {\n                tracing::error!(\"Failed to set forbidden status for {}: {}\", email, e);\n            } else {\n                tracing::warn!(\"[Claude] Account {} marked as forbidden due to 403\", email);\n            }\n        }\n\n        // 确定重试策略\n        let strategy = determine_retry_strategy(status_code, &error_text, retried_without_thinking);\n        \n        // 执行退避\n        if apply_retry_strategy(strategy, attempt, max_attempts, status_code, &trace_id).await {\n            // 判断是否需要轮换账号\n            if !should_rotate_account(status_code) {\n                debug!(\"[{}] Keeping same account for status {} (server-side issue)\", trace_id, status_code);\n            }\n            continue;\n        } else {\n            // 5. 增强的 400 错误处理: Prompt Too Long 友好提示\n            if status_code == 400 && (error_text.contains(\"too long\") || error_text.contains(\"exceeds\") || error_text.contains(\"limit\")) {\n                 return (\n                    StatusCode::BAD_REQUEST,\n                    [(\"X-Account-Email\", email.as_str())],\n                    Json(json!({\n                        \"id\": \"err_prompt_too_long\",\n                        \"type\": \"error\",\n                        \"error\": {\n                            \"type\": \"invalid_request_error\",\n                            \"message\": \"Prompt is too long (server-side context limit reached).\",\n                            \"suggestion\": \"Please: 1) Executive '/compact' in Claude Code 2) Reduce conversation history 3) Switch to gemini-1.5-pro (2M context limit)\"\n                        }\n                    }))\n                ).into_response();\n            }\n\n            // 不可重试的错误，直接返回\n            error!(\"[{}] Non-retryable error {}: {}\", trace_id, status_code, error_text);\n            return (status, [\n                (\"X-Account-Email\", email.as_str()),\n                (\"X-Mapped-Model\", request_with_mapped.model.as_str())\n            ], error_text).into_response();\n        }\n    }\n    \n    \n    if let Some(email) = last_email {\n        // [FIX] Include X-Mapped-Model in exhaustion error\n        let mut headers = HeaderMap::new();\n        headers.insert(\"X-Account-Email\", header::HeaderValue::from_str(&email).unwrap());\n        if let Some(model) = last_mapped_model {\n             if let Ok(v) = header::HeaderValue::from_str(&model) {\n                headers.insert(\"X-Mapped-Model\", v);\n             }\n        }\n\n        let error_type = match last_status.as_u16() {\n            400 => \"invalid_request_error\",\n            401 => \"authentication_error\",\n            403 => \"permission_error\",\n            429 => \"rate_limit_error\",\n            529 => \"overloaded_error\",\n            _ => \"api_error\",\n        };\n\n        // [FIX] 403 时返回 503，避免 Claude Code 客户端退出到登录页\n        let response_status = if last_status.as_u16() == 403 {\n            StatusCode::SERVICE_UNAVAILABLE\n        } else {\n            last_status\n        };\n\n        (response_status, headers, Json(json!({\n            \"type\": \"error\",\n            \"error\": {\n                \"id\": \"err_retry_exhausted\",\n                \"type\": error_type,\n                \"message\": format!(\"All {} attempts failed. Last status: {}. Error: {}\", max_attempts, last_status, last_error)\n            }\n        }))).into_response()\n    } else {\n        // Fallback if no email (e.g. mapping error before token)\n        let mut headers = HeaderMap::new();\n        if let Some(model) = last_mapped_model {\n             if let Ok(v) = header::HeaderValue::from_str(&model) {\n                headers.insert(\"X-Mapped-Model\", v);\n             }\n        }\n\n        let error_type = match last_status.as_u16() {\n            400 => \"invalid_request_error\",\n            401 => \"authentication_error\",\n            403 => \"permission_error\",\n            429 => \"rate_limit_error\",\n            529 => \"overloaded_error\",\n            _ => \"api_error\",\n        };\n\n        // [FIX] 403 时返回 503，避免 Claude Code 客户端退出到登录页\n        let response_status = if last_status.as_u16() == 403 {\n            StatusCode::SERVICE_UNAVAILABLE\n        } else {\n            last_status\n        };\n\n        (response_status, headers, Json(json!({\n            \"type\": \"error\",\n            \"error\": {\n                \"id\": \"err_retry_exhausted\",\n                \"type\": error_type,\n                \"message\": format!(\"All {} attempts failed. Last status: {}. Error: {}\", max_attempts, last_status, last_error)\n            }\n        }))).into_response()\n    }\n}\n\n/// 列出可用模型\npub async fn handle_list_models(State(state): State<AppState>) -> impl IntoResponse {\n    use crate::proxy::common::model_mapping::get_all_dynamic_models;\n\n    let model_ids = get_all_dynamic_models(\n        &state.custom_mapping,\n        Some(&state.token_manager)\n    ).await;\n\n    let data: Vec<_> = model_ids.into_iter().map(|id| {\n        json!({\n            \"id\": id,\n            \"object\": \"model\",\n            \"created\": 1706745600,\n            \"owned_by\": \"antigravity\"\n        })\n    }).collect();\n\n    Json(json!({\n        \"object\": \"list\",\n        \"data\": data\n    }))\n}\n\n/// 计算 tokens (占位符)\npub async fn handle_count_tokens(\n    State(state): State<AppState>,\n    headers: HeaderMap,\n    Json(body): Json<Value>,\n) -> Response {\n    let zai = state.zai.read().await.clone();\n    let zai_enabled = zai.enabled && !matches!(zai.dispatch_mode, crate::proxy::ZaiDispatchMode::Off);\n\n    if zai_enabled {\n        return crate::proxy::providers::zai_anthropic::forward_anthropic_json(\n            &state,\n            axum::http::Method::POST,\n            \"/v1/messages/count_tokens\",\n            &headers,\n            body,\n            0, // [NEW v4.0.0] Tokens count doesn't need rewind detection\n        )\n        .await;\n    }\n\n    Json(json!({\n        \"input_tokens\": 0,\n        \"output_tokens\": 0\n    }))\n    .into_response()\n}\n\n// 移除已失效的简单单元测试，后续将补全完整的集成测试\n/*\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[tokio::test]\n    async fn test_handle_list_models() {\n        // handle_list_models 现在需要 AppState，此处跳过旧的单元测试\n    }\n}\n*/\n\n// ===== 后台任务检测辅助函数 =====\n\n/// 后台任务类型\n#[derive(Debug, Clone, Copy, PartialEq)]\nenum BackgroundTaskType {\n    TitleGeneration,      // 标题生成\n    SimpleSummary,        // 简单摘要\n    ContextCompression,   // 上下文压缩\n    PromptSuggestion,     // 提示建议\n    SystemMessage,        // 系统消息\n    EnvironmentProbe,     // 环境探测\n}\n\n/// 标题生成关键词\nconst TITLE_KEYWORDS: &[&str] = &[\n    \"write a 5-10 word title\",\n    \"Please write a 5-10 word title\",\n    \"Respond with the title\",\n    \"Generate a title for\",\n    \"Create a brief title\",\n    \"title for the conversation\",\n    \"conversation title\",\n    \"生成标题\",\n    \"为对话起个标题\",\n];\n\n/// 摘要生成关键词\nconst SUMMARY_KEYWORDS: &[&str] = &[\n    \"Summarize this coding conversation\",\n    \"Summarize the conversation\",\n    \"Concise summary\",\n    \"in under 50 characters\",\n    \"compress the context\",\n    \"Provide a concise summary\",\n    \"condense the previous messages\",\n    \"shorten the conversation history\",\n    \"extract key points from\",\n];\n\n/// 建议生成关键词\nconst SUGGESTION_KEYWORDS: &[&str] = &[\n    \"prompt suggestion generator\",\n    \"suggest next prompts\",\n    \"what should I ask next\",\n    \"generate follow-up questions\",\n    \"recommend next steps\",\n    \"possible next actions\",\n];\n\n/// 系统消息关键词\nconst SYSTEM_KEYWORDS: &[&str] = &[\n    \"Warmup\",\n    \"<system-reminder>\",\n    // Removed: \"Caveat: The messages below were generated\" - this is a normal Claude Desktop system prompt\n    \"This is a system message\",\n];\n\n/// 环境探测关键词\nconst PROBE_KEYWORDS: &[&str] = &[\n    \"check current directory\",\n    \"list available tools\",\n    \"verify environment\",\n    \"test connection\",\n];\n\n/// 检测后台任务并返回任务类型\nfn detect_background_task_type(request: &ClaudeRequest) -> Option<BackgroundTaskType> {\n    let last_user_msg = extract_last_user_message_for_detection(request)?;\n    let preview = last_user_msg.chars().take(500).collect::<String>();\n    \n    // 长度过滤：后台任务通常不超过 800 字符\n    if last_user_msg.len() > 800 {\n        return None;\n    }\n    \n    // 按优先级匹配\n    if matches_keywords(&preview, SYSTEM_KEYWORDS) {\n        return Some(BackgroundTaskType::SystemMessage);\n    }\n    \n    if matches_keywords(&preview, TITLE_KEYWORDS) {\n        return Some(BackgroundTaskType::TitleGeneration);\n    }\n    \n    if matches_keywords(&preview, SUMMARY_KEYWORDS) {\n        if preview.contains(\"in under 50 characters\") {\n            return Some(BackgroundTaskType::SimpleSummary);\n        }\n        return Some(BackgroundTaskType::ContextCompression);\n    }\n    \n    if matches_keywords(&preview, SUGGESTION_KEYWORDS) {\n        return Some(BackgroundTaskType::PromptSuggestion);\n    }\n    \n    if matches_keywords(&preview, PROBE_KEYWORDS) {\n        return Some(BackgroundTaskType::EnvironmentProbe);\n    }\n    \n    None\n}\n\n/// 辅助函数：关键词匹配\nfn matches_keywords(text: &str, keywords: &[&str]) -> bool {\n    keywords.iter().any(|kw| text.contains(kw))\n}\n\n/// 辅助函数：提取最后一条用户消息（用于检测）\nfn extract_last_user_message_for_detection(request: &ClaudeRequest) -> Option<String> {\n    request.messages.iter().rev()\n        .filter(|m| m.role == \"user\")\n        .find_map(|m| {\n            let content = match &m.content {\n                crate::proxy::mappers::claude::models::MessageContent::String(s) => s.to_string(),\n                crate::proxy::mappers::claude::models::MessageContent::Array(arr) => {\n                    arr.iter()\n                        .filter_map(|block| match block {\n                            crate::proxy::mappers::claude::models::ContentBlock::Text { text } => Some(text.as_str()),\n                            _ => None,\n                        })\n                        .collect::<Vec<_>>()\n                        .join(\" \")\n                }\n            };\n            \n            if content.trim().is_empty() \n                || content.starts_with(\"Warmup\") \n                || content.contains(\"<system-reminder>\") \n            {\n                None \n            } else {\n                Some(content)\n            }\n        })\n}\n\n/// 根据后台任务类型选择合适的模型\nfn select_background_model(task_type: BackgroundTaskType) -> &'static str {\n    match task_type {\n        BackgroundTaskType::TitleGeneration => INTERNAL_BACKGROUND_TASK,\n        BackgroundTaskType::SimpleSummary => INTERNAL_BACKGROUND_TASK,\n        BackgroundTaskType::SystemMessage => INTERNAL_BACKGROUND_TASK,\n        BackgroundTaskType::PromptSuggestion => INTERNAL_BACKGROUND_TASK,\n        BackgroundTaskType::EnvironmentProbe => INTERNAL_BACKGROUND_TASK,\n        BackgroundTaskType::ContextCompression => INTERNAL_BACKGROUND_TASK,\n    }\n}\n\n// ===== [Issue #467 Fix] Warmup 请求拦截 =====\n\n/// 检测是否为 Warmup 请求\n/// \n/// Claude Code 每 10 秒发送一次 warmup 请求，特征包括：\n/// 1. 用户消息内容以 \"Warmup\" 开头或包含 \"Warmup\"\n/// 2. tool_result 内容为 \"Warmup\" 错误\n/// 3. 消息循环模式：助手发送工具调用，用户返回 Warmup 错误\nfn is_warmup_request(request: &ClaudeRequest) -> bool {\n    // [FIX] Only check the LATEST message for Warmup characteristics.\n    // Scanning history (take(10)) caused a \"poisoned session\" bug where one historical Warmup\n    // message would cause all subsequent user inputs (e.g. \"Continue\") to be intercepted \n    // and replied with \"OK\".\n    \n    if let Some(msg) = request.messages.last() {\n        // We only care if the *current* trigger is a Warmup\n        match &msg.content {\n            crate::proxy::mappers::claude::models::MessageContent::String(s) => {\n                // Check if simple text starts with Warmup (and is short)\n                if s.trim().starts_with(\"Warmup\") && s.len() < 100 {\n                    return true;\n                }\n            },\n            crate::proxy::mappers::claude::models::MessageContent::Array(arr) => {\n                for block in arr {\n                    match block {\n                        crate::proxy::mappers::claude::models::ContentBlock::Text { text } => {\n                            let trimmed = text.trim();\n                            if trimmed == \"Warmup\" || trimmed.starts_with(\"Warmup\\n\") {\n                                return true;\n                            }\n                        },\n                        crate::proxy::mappers::claude::models::ContentBlock::ToolResult { \n                            content, is_error, .. \n                        } => {\n                            // Check tool result errors\n                            let content_str = if let Some(s) = content.as_str() {\n                                s.to_string()\n                            } else {\n                                content.to_string()\n                            };\n                            \n                            // If it's an error and starts with Warmup, it's a warmup signal\n                            if *is_error == Some(true) && content_str.trim().starts_with(\"Warmup\") {\n                                return true;\n                            }\n                        },\n                        _ => {}\n                    }\n                }\n            }\n        }\n    }\n    \n    false\n}\n\n/// 创建 Warmup 请求的模拟响应\n/// \n/// 返回一个简单的响应，不消耗上游配额\nfn create_warmup_response(request: &ClaudeRequest, is_stream: bool) -> Response {\n    let model = &request.model;\n    let message_id = format!(\"msg_warmup_{}\", chrono::Utc::now().timestamp_millis());\n    \n    if is_stream {\n        // 流式响应：发送标准的 SSE 事件序列\n        let events = vec![\n            // message_start\n            format!(\n                \"event: message_start\\ndata: {{\\\"type\\\":\\\"message_start\\\",\\\"message\\\":{{\\\"id\\\":\\\"{}\\\",\\\"type\\\":\\\"message\\\",\\\"role\\\":\\\"assistant\\\",\\\"content\\\":[],\\\"model\\\":\\\"{}\\\",\\\"stop_reason\\\":null,\\\"stop_sequence\\\":null,\\\"usage\\\":{{\\\"input_tokens\\\":1,\\\"output_tokens\\\":0}}}}}}\\n\\n\",\n                message_id, model\n            ),\n            // content_block_start\n            \"event: content_block_start\\ndata: {\\\"type\\\":\\\"content_block_start\\\",\\\"index\\\":0,\\\"content_block\\\":{\\\"type\\\":\\\"text\\\",\\\"text\\\":\\\"\\\"}}\\n\\n\".to_string(),\n            // content_block_delta\n            \"event: content_block_delta\\ndata: {\\\"type\\\":\\\"content_block_delta\\\",\\\"index\\\":0,\\\"delta\\\":{\\\"type\\\":\\\"text_delta\\\",\\\"text\\\":\\\"OK\\\"}}\\n\\n\".to_string(),\n            // content_block_stop\n            \"event: content_block_stop\\ndata: {\\\"type\\\":\\\"content_block_stop\\\",\\\"index\\\":0}\\n\\n\".to_string(),\n            // message_delta\n            \"event: message_delta\\ndata: {\\\"type\\\":\\\"message_delta\\\",\\\"delta\\\":{\\\"stop_reason\\\":\\\"end_turn\\\",\\\"stop_sequence\\\":null},\\\"usage\\\":{\\\"output_tokens\\\":1}}\\n\\n\".to_string(),\n            // message_stop\n            \"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\".to_string(),\n        ];\n        \n        let body = events.join(\"\");\n        \n        Response::builder()\n            .status(StatusCode::OK)\n            .header(header::CONTENT_TYPE, \"text/event-stream\")\n            .header(header::CACHE_CONTROL, \"no-cache\")\n            .header(header::CONNECTION, \"keep-alive\")\n            .header(\"X-Warmup-Intercepted\", \"true\")\n            .body(Body::from(body))\n            .unwrap()\n    } else {\n        // 非流式响应\n        let response = json!({\n            \"id\": message_id,\n            \"type\": \"message\",\n            \"role\": \"assistant\",\n            \"content\": [{\n                \"type\": \"text\",\n                \"text\": \"OK\"\n            }],\n            \"model\": model,\n            \"stop_reason\": \"end_turn\",\n            \"stop_sequence\": null,\n            \"usage\": {\n                \"input_tokens\": 1,\n                \"output_tokens\": 1\n            }\n        });\n        \n        (\n            StatusCode::OK,\n            [(\"X-Warmup-Intercepted\", \"true\")],\n\n    \n    Json(response)\n        ).into_response()\n    }\n}\n\n// ===== [Helper] Synchronous Upstream Call =====\n// Reusable function for making non-streaming calls to Gemini API\n// Used by Layer 3 and potentially other internal operations\n\n/// Call Gemini API synchronously and return the response text\n/// \n/// This is used for internal operations that need to wait for a complete response,\n/// such as generating summaries or other background tasks.\nasync fn call_gemini_sync(\n    model: &str,\n    request: &ClaudeRequest,\n    token_manager: &Arc<crate::proxy::TokenManager>,\n    trace_id: &str,\n) -> Result<String, String> {\n    // Get token and transform request\n    let (access_token, project_id, _, account_id, _wait_ms) = token_manager\n        .get_token(\"gemini\", false, None, model)\n        .await\n        .map_err(|e| format!(\"Failed to get account: {}\", e))?;\n    \n    let token_obj = token_manager.get_token_by_id(&account_id);\n    let gemini_body = crate::proxy::mappers::claude::transform_claude_request_in(request, &project_id, false, Some(account_id.as_str()), trace_id, token_obj.as_ref())\n        .map_err(|e| format!(\"Failed to transform request: {}\", e))?;\n    \n    // Call Gemini API\n    let upstream_url = format!(\n        \"https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent\",\n        model\n    );\n    \n    debug!(\"[{}] Calling Gemini API: {}\", trace_id, model);\n    \n    let response = reqwest::Client::new()\n        .post(&upstream_url)\n        .header(\"Authorization\", format!(\"Bearer {}\", access_token))\n        .header(\"Content-Type\", \"application/json\")\n        .json(&gemini_body)\n        .send()\n        .await\n        .map_err(|e| format!(\"API call failed: {}\", e))?;\n    \n    if !response.status().is_success() {\n        return Err(format!(\n            \"API returned {}: {}\", \n            response.status(), \n            response.text().await.unwrap_or_default()\n        ));\n    }\n    \n    let gemini_response: Value = response.json().await\n        .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n    \n    // Extract text from response\n    gemini_response\n        .get(\"candidates\")\n        .and_then(|c| c.get(0))\n        .and_then(|c| c.get(\"content\"))\n        .and_then(|c| c.get(\"parts\"))\n        .and_then(|p| p.get(0))\n        .and_then(|p| p.get(\"text\"))\n        .and_then(|t| t.as_str())\n        .map(|s| s.to_string())\n        .ok_or_else(|| \"Failed to extract text from response\".to_string())\n}\n\n// ===== [Layer 3] Fork Conversation + XML Summary =====\n// This is the ultimate context compression strategy\n// Borrowed from Practical-Guide-to-Context-Engineering + Claude Code official practice\n\n/// Try to compress context by generating an XML summary and forking the conversation\n/// \n/// This function:\n/// 1. Extracts the last valid thinking signature\n/// 2. Calls a cheap model (gemini-2.5-flash-lite) to generate XML summary\n/// 3. Creates a new message sequence with summary as prefix\n/// 4. Preserves the signature in the summary\n/// 5. Returns the forked request\n/// \n/// Returns Ok(forked_request) on success, Err(error_message) on failure\nasync fn try_compress_with_summary(\n    original_request: &ClaudeRequest,\n    trace_id: &str,\n    token_manager: &Arc<crate::proxy::TokenManager>,\n) -> Result<ClaudeRequest, String> {\n    info!(\"[{}] [Layer-3] Starting context compression with XML summary\", trace_id);\n    \n    // 1. Extract last valid signature\n    let last_signature = ContextManager::extract_last_valid_signature(&original_request.messages);\n    \n    if let Some(ref sig) = last_signature {\n        debug!(\"[{}] [Layer-3] Extracted signature (len: {})\", trace_id, sig.len());\n    }\n    \n    // 2. Build summary request\n    let mut summary_messages = original_request.messages.clone();\n    \n    // Add instruction to include signature in summary\n    let signature_instruction = if let Some(ref sig) = last_signature {\n        format!(\"\\n\\n**CRITICAL**: The last thinking signature is:\\n```\\n{}\\n```\\nYou MUST include this EXACTLY in the <latest_thinking_signature> section.\", sig)\n    } else {\n        \"\\n\\n**Note**: No thinking signature found in history. Leave <latest_thinking_signature> empty.\".to_string()\n    };\n    \n    // Append summary request as the last user message\n    summary_messages.push(Message {\n        role: \"user\".to_string(),\n        content: MessageContent::String(format!(\n            \"{}{}\",\n            CONTEXT_SUMMARY_PROMPT,\n            signature_instruction\n        )),\n    });\n    \n    let summary_request = ClaudeRequest {\n        model: INTERNAL_BACKGROUND_TASK.to_string(),\n        messages: summary_messages,\n        system: None,\n        stream: false,\n        max_tokens: Some(8000),\n        temperature: Some(0.3),\n        tools: None,\n        thinking: None,\n        metadata: None,\n        top_p: None,\n        top_k: None,\n        output_config: None,\n        size: None,\n        quality: None,\n    };\n    \n    debug!(\"[{}] [Layer-3] Calling {} for summary generation\", trace_id, INTERNAL_BACKGROUND_TASK);\n    \n    // 3. Call upstream using helper function (reuse existing infrastructure)\n    let xml_summary = call_gemini_sync(\n        INTERNAL_BACKGROUND_TASK,\n        &summary_request,\n        token_manager,\n        trace_id,\n    ).await?;\n    \n    info!(\"[{}] [Layer-3] Generated XML summary (len: {} chars)\", trace_id, xml_summary.len());\n    \n    // 4. Create forked conversation with summary as prefix\n    let mut forked_messages = vec![\n        Message {\n            role: \"user\".to_string(),\n            content: MessageContent::String(format!(\n                \"Context has been compressed. Here is the structured summary of our conversation history:\\n\\n{}\",\n                xml_summary\n            )),\n        },\n        Message {\n            role: \"assistant\".to_string(),\n            content: MessageContent::String(\n                \"I have reviewed the compressed context summary. I understand the current state and will continue from here.\".to_string()\n            ),\n        },\n    ];\n    \n    // 5. Append the user's latest message (if exists and is not the summary request)\n    if let Some(last_msg) = original_request.messages.last() {\n        if last_msg.role == \"user\" {\n            // Check if it's not the summary instruction we just added\n            if !matches!(&last_msg.content, MessageContent::String(s) if s.contains(CONTEXT_SUMMARY_PROMPT)) {\n                forked_messages.push(last_msg.clone());\n            }\n        }\n    }\n    \n    info!(\n        \"[{}] [Layer-3] Fork successful: {} messages → {} messages\",\n        trace_id,\n        original_request.messages.len(),\n        forked_messages.len()\n    );\n    \n    // 6. Return forked request\n    Ok(ClaudeRequest {\n        model: original_request.model.clone(),\n        messages: forked_messages,\n        system: original_request.system.clone(),\n        stream: original_request.stream,\n        max_tokens: original_request.max_tokens,\n        temperature: original_request.temperature,\n        tools: original_request.tools.clone(),\n        thinking: original_request.thinking.clone(),\n        metadata: original_request.metadata.clone(),\n        top_p: original_request.top_p,\n        top_k: original_request.top_k,\n        output_config: original_request.output_config.clone(),\n        size: original_request.size.clone(),\n        quality: original_request.quality.clone(),\n    })\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/handlers/common.rs",
    "content": "use tokio::time::{sleep, Duration};\nuse tracing::{debug, info};\nuse axum::{http::StatusCode, response::{IntoResponse, Response}, Json, extract::State};\nuse serde_json::{json, Value};\nuse crate::proxy::server::AppState;\n\n// ===== 统一重试与退避策略 =====\n\n/// 重试策略枚举\n#[derive(Debug, Clone)]\npub enum RetryStrategy {\n    /// 不重试，直接返回错误\n    NoRetry,\n    /// 固定延迟\n    FixedDelay(Duration),\n    /// 线性退避：base_ms * (attempt + 1)\n    LinearBackoff { base_ms: u64 },\n    /// 指数退避：base_ms * 2^attempt，上限 max_ms\n    ExponentialBackoff { base_ms: u64, max_ms: u64 },\n}\n\n/// 根据错误状态码和错误信息确定重试策略\npub fn determine_retry_strategy(\n    status_code: u16,\n    error_text: &str,\n    retried_without_thinking: bool,\n) -> RetryStrategy {\n    match status_code {\n        // 400 错误：仅在特定 Thinking 签名失败时重试一次\n        400 if !retried_without_thinking\n            && (error_text.contains(\"Invalid `signature`\")\n                || error_text.contains(\"thinking.signature\")\n                || error_text.contains(\"thinking.thinking\")\n                || error_text.contains(\"Corrupted thought signature\")) =>\n        {\n            RetryStrategy::FixedDelay(Duration::from_millis(200))\n        }\n\n        // 429 限流错误\n        429 => {\n            // 优先使用服务端返回的 Retry-After\n            if let Some(delay_ms) = crate::proxy::upstream::retry::parse_retry_delay(error_text) {\n                let actual_delay = delay_ms.saturating_add(200).min(30_000); // 上限上调至 30s\n                RetryStrategy::FixedDelay(Duration::from_millis(actual_delay))\n            } else {\n                // 否则使用线性退避：起始 5s，逐步增加\n                RetryStrategy::LinearBackoff { base_ms: 5000 }\n            }\n        }\n\n        // 503 服务不可用 / 529 服务器过载\n        503 | 529 => {\n            // 指数退避：起始 10s，上限 60s (针对 Google 边缘节点过载)\n            RetryStrategy::ExponentialBackoff {\n                base_ms: 10000,\n                max_ms: 60000,\n            }\n        }\n\n        // 500 服务器内部错误\n        500 => {\n            // 线性退避：起始 3s\n            RetryStrategy::LinearBackoff { base_ms: 3000 }\n        }\n\n        // 401/403 认证/权限错误：切换账号前给予极短缓冲\n        401 | 403 => RetryStrategy::FixedDelay(Duration::from_millis(200)),\n\n        // 404 资源未找到：Google Cloud Code API 的 404 通常是账号级别的间歇性问题\n        // (灰度发布、账号权限不同步等)，轮换账号往往能解决\n        404 => RetryStrategy::FixedDelay(Duration::from_millis(300)),\n\n        // 其他错误：不重试\n        _ => RetryStrategy::NoRetry,\n    }\n}\n\n/// 执行退避策略并返回是否应该继续重试\npub async fn apply_retry_strategy(\n    strategy: RetryStrategy,\n    attempt: usize,\n    max_attempts: usize,\n    status_code: u16,\n    trace_id: &str,\n) -> bool {\n    match strategy {\n        RetryStrategy::NoRetry => {\n            debug!(\"[{}] Non-retryable error {}, stopping\", trace_id, status_code);\n            false\n        }\n\n        RetryStrategy::FixedDelay(duration) => {\n            let base_ms = duration.as_millis() as u64;\n            info!(\n                \"[{}] ⏱️ Retry with fixed delay: status={}, attempt={}/{}, delay={}ms\",\n                trace_id,\n                status_code,\n                attempt + 1,\n                max_attempts,\n                base_ms\n            );\n            sleep(duration).await;\n            true\n        }\n\n        RetryStrategy::LinearBackoff { base_ms } => {\n            let calculated_ms = base_ms * (attempt as u64 + 1);\n            info!(\n                \"[{}] ⏱️ Retry with linear backoff: status={}, attempt={}/{}, delay={}ms\",\n                trace_id,\n                status_code,\n                attempt + 1,\n                max_attempts,\n                calculated_ms\n            );\n            sleep(Duration::from_millis(calculated_ms)).await;\n            true\n        }\n\n        RetryStrategy::ExponentialBackoff { base_ms, max_ms } => {\n            let calculated_ms = (base_ms * 2_u64.pow(attempt as u32)).min(max_ms);\n            info!(\n                \"[{}] ⏱️ Retry with exponential backoff: status={}, attempt={}/{}, delay={}ms\",\n                trace_id,\n                status_code,\n                attempt + 1,\n                max_attempts,\n                calculated_ms\n            );\n            sleep(Duration::from_millis(calculated_ms)).await;\n            true\n        }\n    }\n}\n\n/// 判断是否应该轮换账号\npub fn should_rotate_account(status_code: u16) -> bool {\n    match status_code {\n        // 这些错误是账号级别或特定节点配额的，需要轮换\n        // 404: Google Cloud Code API 模型可用性因账号而异（灰度/权限）\n        429 | 401 | 403 | 404 | 500 => true,\n        // 这些错误通常是协议或服务端全局性、甚至参数错误的，轮换账号通常无意义\n        400 | 503 | 529 => false,\n        _ => false,\n    }\n}\n\n/// Detects model capabilities and configuration\n/// POST /v1/models/detect\npub async fn handle_detect_model(\n    State(state): State<AppState>,\n    Json(body): Json<Value>,\n) -> Response {\n    let model_name = body.get(\"model\").and_then(|v| v.as_str()).unwrap_or(\"\");\n    \n    if model_name.is_empty() {\n        return (StatusCode::BAD_REQUEST, \"Missing 'model' field\").into_response();\n    }\n\n    // 1. Resolve mapping\n    let mapped_model = crate::proxy::common::model_mapping::resolve_model_route(\n        model_name,\n        &*state.custom_mapping.read().await,\n    );\n\n    // 2. Resolve capabilities\n    let config = crate::proxy::mappers::common_utils::resolve_request_config(\n        model_name,\n        &mapped_model,\n        &None, // We don't check tools for static capability detection\n        None,  // size\n        None,  // quality\n        None,  // image_size\n        None,  // body (not needed for static detection)\n    );\n\n    // 3. Construct response\n    let mut response = json!({\n        \"model\": model_name,\n        \"mapped_model\": mapped_model,\n        \"type\": config.request_type,\n        \"features\": {\n            \"has_web_search\": config.inject_google_search,\n            \"is_image_gen\": config.request_type == \"image_gen\"\n        }\n    });\n\n    if let Some(img_conf) = config.image_config {\n        if let Some(obj) = response.as_object_mut() {\n            obj.insert(\"config\".to_string(), img_conf);\n        }\n    }\n\n    Json(response).into_response()\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/handlers/gemini.rs",
    "content": "// Gemini Handler\nuse axum::{\n    extract::State,\n    extract::{Json, Path},\n    http::StatusCode,\n    response::IntoResponse,\n};\nuse serde_json::{json, Value};\nuse tracing::{debug, error, info};\n\nuse crate::proxy::common::client_adapter::CLIENT_ADAPTERS;\nuse crate::proxy::debug_logger;\nuse crate::proxy::handlers::common::{\n    apply_retry_strategy, determine_retry_strategy, should_rotate_account,\n};\nuse crate::proxy::mappers::gemini::{unwrap_response, wrap_request};\nuse crate::proxy::server::AppState;\nuse crate::proxy::session_manager::SessionManager;\nuse crate::proxy::upstream::client::mask_email;\nuse axum::http::HeaderMap;\n\nconst MAX_RETRY_ATTEMPTS: usize = 3;\n\n/// 处理 generateContent 和 streamGenerateContent\n/// 路径参数: model_name, method (e.g. \"gemini-pro\", \"generateContent\")\npub async fn handle_generate(\n    State(state): State<AppState>,\n    Path(model_action): Path<String>,\n    headers: HeaderMap,          // [NEW] Extract headers for adapter detection\n    Json(mut body): Json<Value>, // 改为 mut 以支持修复提示词注入\n) -> Result<impl IntoResponse, (StatusCode, String)> {\n    // 解析 model:method\n    let (model_name, method) = if let Some((m, action)) = model_action.rsplit_once(':') {\n        (m.to_string(), action.to_string())\n    } else {\n        (model_action, \"generateContent\".to_string())\n    };\n\n    crate::modules::logger::log_info(&format!(\n        \"Received Gemini request: {}/{}\",\n        model_name, method\n    ));\n    let trace_id = format!(\"req_{}\", chrono::Utc::now().timestamp_subsec_millis());\n    let debug_cfg = state.debug_logging.read().await.clone();\n\n    // [NEW] Detect Client Adapter\n    let client_adapter = CLIENT_ADAPTERS\n        .iter()\n        .find(|a| a.matches(&headers))\n        .cloned();\n    if client_adapter.is_some() {\n        debug!(\"[{}] Client Adapter detected\", trace_id);\n    }\n\n    // 1. 验证方法\n    if method != \"generateContent\" && method != \"streamGenerateContent\" {\n        return Err((\n            StatusCode::BAD_REQUEST,\n            format!(\"Unsupported method: {}\", method),\n        ));\n    }\n    if debug_logger::is_enabled(&debug_cfg) {\n        let original_payload = json!({\n            \"kind\": \"original_request\",\n            \"protocol\": \"gemini\",\n            \"trace_id\": trace_id,\n            \"original_model\": model_name,\n            \"method\": method,\n            \"request\": body.clone(),\n        });\n        debug_logger::write_debug_payload(\n            &debug_cfg,\n            Some(&trace_id),\n            \"original_request\",\n            &original_payload,\n        )\n        .await;\n    }\n    let client_wants_stream = method == \"streamGenerateContent\";\n    // [AUTO-CONVERSION] 强制内部流式化\n    let force_stream_internally = !client_wants_stream;\n    let is_stream = client_wants_stream || force_stream_internally;\n\n    if force_stream_internally {\n        // debug!(\"[AutoConverter] Converting non-stream request to stream\");\n    }\n\n    // 2. 获取 UpstreamClient 和 TokenManager\n    let upstream = state.upstream.clone();\n    let token_manager = state.token_manager;\n    let pool_size = token_manager.len();\n    let max_attempts = MAX_RETRY_ATTEMPTS.min(pool_size).max(1);\n\n    let mut last_error = String::new();\n    let mut last_email: Option<String> = None;\n\n    for attempt in 0..max_attempts {\n        // 3. 模型路由解析\n        let mapped_model = crate::proxy::common::model_mapping::resolve_model_route(\n            &model_name,\n            &*state.custom_mapping.read().await,\n        );\n        // 提取 tools 列表以进行联网探测 (Gemini 风格可能是嵌套的)\n        let tools_val: Option<Vec<Value>> =\n            body.get(\"tools\").and_then(|t| t.as_array()).map(|arr| {\n                let mut flattened = Vec::new();\n                for tool_entry in arr {\n                    if let Some(decls) = tool_entry\n                        .get(\"functionDeclarations\")\n                        .and_then(|v| v.as_array())\n                    {\n                        flattened.extend(decls.iter().cloned());\n                    } else {\n                        flattened.push(tool_entry.clone());\n                    }\n                }\n                flattened\n            });\n\n        let config = crate::proxy::mappers::common_utils::resolve_request_config(\n            &model_name,\n            &mapped_model,\n            &tools_val,\n            None,        // size (not applicable for Gemini native protocol)\n            None,        // quality\n            None,        // [NEW] image_size\n            Some(&body), // [NEW] Pass request body for imageConfig parsing\n        );\n\n        // 4. 获取 Token (使用准确的 request_type)\n        // 提取 SessionId (粘性指纹)\n        let session_id = SessionManager::extract_gemini_session_id(&body, &model_name);\n\n        // 关键：在重试尝试 (attempt > 0) 时强制轮换账号\n        let (access_token, project_id, email, account_id, _wait_ms) = match token_manager\n            .get_token(\n                &config.request_type,\n                attempt > 0,\n                Some(&session_id),\n                &config.final_model,\n            )\n            .await\n        {\n            Ok(t) => t,\n            Err(e) => {\n                return Err((\n                    StatusCode::SERVICE_UNAVAILABLE,\n                    format!(\"Token error: {}\", e),\n                ));\n            }\n        };\n\n        let mapped_model = token_manager\n            .resolve_dynamic_model_for_account(&account_id, &mapped_model)\n            .await;\n\n        last_email = Some(email.clone());\n        info!(\"✓ Using account: {} (type: {})\", email, config.request_type);\n\n        // 5. 包装请求 (project injection)\n        // [FIX #765] Pass session_id to wrap_request for signature injection\n        // [NEW] 获取完整 Token 对象以注入动态规格 (dynamic > static default > 65535)\n        let token_obj = token_manager.get_token_by_id(&account_id);\n        let wrapped_body = wrap_request(&body, &project_id, &mapped_model, Some(account_id.as_str()), Some(&session_id), token_obj.as_ref());\n\n        if debug_logger::is_enabled(&debug_cfg) {\n            let payload = json!({\n                \"kind\": \"v1internal_request\",\n                \"protocol\": \"gemini\",\n                \"trace_id\": trace_id,\n                \"original_model\": model_name,\n                \"mapped_model\": mapped_model,\n                \"request_type\": config.request_type,\n                \"attempt\": attempt,\n                \"v1internal_request\": wrapped_body.clone(),\n            });\n            debug_logger::write_debug_payload(\n                &debug_cfg,\n                Some(&trace_id),\n                \"v1internal_request\",\n                &payload,\n            )\n            .await;\n        }\n\n        // 5. 上游调用\n        let query_string = if is_stream { Some(\"alt=sse\") } else { None };\n        let upstream_method = if is_stream {\n            \"streamGenerateContent\"\n        } else {\n            \"generateContent\"\n        };\n\n        // [FIX #1522] Inject Anthropic Beta Headers for Claude models\n        let mut extra_headers = std::collections::HashMap::new();\n        if mapped_model.to_lowercase().contains(\"claude\") {\n            extra_headers.insert(\"anthropic-beta\".to_string(), \"claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14\".to_string());\n            tracing::debug!(\n                \"[Gemini] Injected Anthropic beta headers for Claude model: {}\",\n                mapped_model\n            );\n        }\n\n        let call_result = match upstream\n            .call_v1_internal_with_headers(\n                upstream_method,\n                &access_token,\n                wrapped_body,\n                query_string,\n                extra_headers.clone(),\n                Some(account_id.as_str()),\n            )\n            .await\n        {\n            Ok(r) => r,\n            Err(e) => {\n                last_error = e.clone();\n                debug!(\n                    \"Gemini Request failed on attempt {}/{}: {}\",\n                    attempt + 1,\n                    max_attempts,\n                    e\n                );\n                continue;\n            }\n        };\n\n        // [NEW] 记录端点降级日志到 debug 文件\n        if !call_result.fallback_attempts.is_empty() && debug_logger::is_enabled(&debug_cfg) {\n            let fallback_entries: Vec<serde_json::Value> = call_result\n                .fallback_attempts\n                .iter()\n                .map(|a| {\n                    json!({\n                        \"endpoint_url\": a.endpoint_url,\n                        \"status\": a.status,\n                        \"error\": a.error,\n                    })\n                })\n                .collect();\n            let payload = json!({\n                \"kind\": \"endpoint_fallback\",\n                \"protocol\": \"gemini\",\n                \"trace_id\": trace_id,\n                \"original_model\": model_name,\n                \"mapped_model\": mapped_model,\n                \"attempt\": attempt,\n                \"account\": mask_email(&email),\n                \"fallback_attempts\": fallback_entries,\n            });\n            debug_logger::write_debug_payload(\n                &debug_cfg,\n                Some(&trace_id),\n                \"endpoint_fallback\",\n                &payload,\n            )\n            .await;\n        }\n\n        let response = call_result.response;\n        // [NEW] 提取实际请求的上游端点 URL，用于日志记录和排查\n        let upstream_url = response.url().to_string();\n        let status = response.status();\n        if status.is_success() {\n            // 6. 响应处理\n            if is_stream {\n                use axum::body::Body;\n                use axum::response::Response;\n                use bytes::{Bytes, BytesMut};\n                use futures::StreamExt;\n\n                let meta = json!({\n                    \"protocol\": \"gemini\",\n                    \"trace_id\": trace_id,\n                    \"original_model\": model_name,\n                    \"mapped_model\": mapped_model,\n                    \"request_type\": config.request_type,\n                    \"attempt\": attempt,\n                    \"status\": status.as_u16(),\n                    \"upstream_url\": upstream_url,\n                });\n                let mut response_stream = debug_logger::wrap_stream_with_debug(\n                    Box::pin(response.bytes_stream()),\n                    debug_cfg.clone(),\n                    trace_id.clone(),\n                    \"upstream_response\",\n                    meta,\n                );\n                let mut buffer = BytesMut::new();\n                let s_id = session_id.clone(); // Clone for stream closure\n\n                // [FIX #859] Implement peek logic for Gemini stream to prevent 0-token 200 OK\n                let mut first_chunk = None;\n                let mut retry_gemini = false;\n\n                match tokio::time::timeout(\n                    std::time::Duration::from_secs(30),\n                    response_stream.next(),\n                )\n                .await\n                {\n                    Ok(Some(Ok(bytes))) => {\n                        if bytes.is_empty() {\n                            tracing::warn!(\"[Gemini] Empty first chunk received, retrying...\");\n                            retry_gemini = true;\n                        } else {\n                            first_chunk = Some(bytes);\n                        }\n                    }\n                    Ok(Some(Err(e))) => {\n                        tracing::warn!(\"[Gemini] Stream error during peek: {}, retrying...\", e);\n                        last_error = format!(\"Stream error: {}\", e);\n                        retry_gemini = true;\n                    }\n                    Ok(None) => {\n                        tracing::warn!(\"[Gemini] Stream ended immediately, retrying...\");\n                        last_error = \"Empty response\".to_string();\n                        retry_gemini = true;\n                    }\n                    Err(_) => {\n                        tracing::warn!(\"[Gemini] Timeout waiting for first chunk, retrying...\");\n                        last_error = \"Timeout\".to_string();\n                        retry_gemini = true;\n                    }\n                }\n\n                if retry_gemini {\n                    continue;\n                }\n\n                let s_id_for_stream = s_id.clone();\n                let model_name_for_stream = mapped_model.clone();\n                let stream = async_stream::stream! {\n                    let mut first_data = first_chunk;\n                    loop {\n                        let item = if let Some(fd) = first_data.take() {\n                            Some(Ok(fd))\n                        } else {\n                            response_stream.next().await\n                        };\n\n                        let bytes = match item {\n                            Some(Ok(b)) => b,\n                            Some(Err(e)) => {\n                                error!(\"[Gemini-SSE] Connection error: {}\", e);\n                                let error_json = serde_json::json!({\n                                    \"error\": {\n                                        \"message\": format!(\"Stream error: {}\", e),\n                                        \"type\": \"stream_error\"\n                                    }\n                                });\n                                yield Ok::<Bytes, String>(Bytes::from(format!(\"data: {}\\n\\n\", error_json)));\n                                break;\n                            }\n                            None => break,\n                        };\n\n                        debug!(\"[Gemini-SSE] Received chunk: {} bytes\", bytes.len());\n                        buffer.extend_from_slice(&bytes);\n                        while let Some(pos) = buffer.iter().position(|&b| b == b'\\n') {\n                            let line_raw = buffer.split_to(pos + 1);\n                            if let Ok(line_str) = std::str::from_utf8(&line_raw) {\n                                let line = line_str.trim();\n                                if line.is_empty() { continue; }\n\n                                if line.starts_with(\"data: \") {\n                                    let json_part = line.trim_start_matches(\"data: \").trim();\n                                    if json_part == \"[DONE]\" {\n                                        yield Ok::<Bytes, String>(Bytes::from(\"data: [DONE]\\n\\n\"));\n                                        continue;\n                                    }\n\n                                    match serde_json::from_str::<Value>(json_part) {\n                                        Ok(mut json) => {\n                                            // [FIX #765] Extract thoughtSignature from stream\n                                            let inner_val = if json.get(\"response\").is_some() {\n                                                json.get(\"response\")\n                                            } else {\n                                                Some(&json)\n                                            };\n\n                                            if let Some(resp) = inner_val {\n                                                if let Some(candidates) = resp.get(\"candidates\").and_then(|c| c.as_array()) {\n                                                    for cand in candidates {\n                                                        if let Some(parts) = cand.get(\"content\").and_then(|c| c.get(\"parts\")).and_then(|p| p.as_array()) {\n                                                            for part in parts {\n                                                                if let Some(sig) = part.get(\"thoughtSignature\").and_then(|s| s.as_str()) {\n                                                                    crate::proxy::SignatureCache::global()\n                                                                        .cache_session_signature(&s_id_for_stream, sig.to_string(), 1);\n                                                                    debug!(\"[Gemini-SSE] Cached signature (len: {}) for session: {}\", sig.len(), s_id_for_stream);\n                                                                }\n                                                            }\n                                                        }\n                                                    }\n                                                }\n                                            }\n\n                                            // [FIX #1522] Inject Tool ID into Stream Response\n                                            crate::proxy::mappers::gemini::wrapper::inject_ids_to_response(&mut json, &model_name_for_stream);\n\n                                            // Unwrap v1internal response wrapper\n                                            if let Some(inner) = json.get_mut(\"response\").map(|v| v.take()) {\n                                                let new_line = format!(\"data: {}\\n\\n\", serde_json::to_string(&inner).unwrap_or_default());\n                                                yield Ok::<Bytes, String>(Bytes::from(new_line));\n                                            } else {\n                                                yield Ok::<Bytes, String>(Bytes::from(format!(\"data: {}\\n\\n\", serde_json::to_string(&json).unwrap_or_default())));\n                                            }\n                                        }\n                                        Err(e) => {\n                                            debug!(\"[Gemini-SSE] JSON parse error: {}, passing raw line\", e);\n                                            yield Ok::<Bytes, String>(Bytes::from(format!(\"{}\\n\\n\", line)));\n                                        }\n                                    }\n                                } else {\n                                    // Non-data lines (comments, etc.)\n                                    yield Ok::<Bytes, String>(Bytes::from(format!(\"{}\\n\\n\", line)));\n                                }\n                            } else {\n                                // Non-UTF8 data? Just pass it through or skip\n                                debug!(\"[Gemini-SSE] Non-UTF8 line encountered\");\n                                yield Ok::<Bytes, String>(line_raw.freeze());\n                            }\n                        }\n                    }\n                };\n\n                if client_wants_stream {\n                    let body = Body::from_stream(stream);\n                    return Ok(Response::builder()\n                        .header(\"Content-Type\", \"text/event-stream\")\n                        .header(\"Cache-Control\", \"no-cache\")\n                        .header(\"Connection\", \"keep-alive\")\n                        .header(\"X-Accel-Buffering\", \"no\")\n                        .header(\"X-Account-Email\", &email)\n                        .header(\"X-Mapped-Model\", &mapped_model)\n                        .body(body)\n                        .unwrap()\n                        .into_response());\n                } else {\n                    // Collect to JSON\n                    use crate::proxy::mappers::gemini::collector::collect_stream_to_json;\n                    match collect_stream_to_json(Box::pin(stream), &s_id).await {\n                        Ok(gemini_resp) => {\n                            info!(\n                                \"[{}] ✓ Stream collected and converted to JSON (Gemini)\",\n                                session_id\n                            );\n                            let unwrapped = unwrap_response(&gemini_resp);\n                            return Ok((\n                                StatusCode::OK,\n                                [\n                                    (\"X-Account-Email\", email.as_str()),\n                                    (\"X-Mapped-Model\", mapped_model.as_str()),\n                                ],\n                                Json(unwrapped),\n                            )\n                                .into_response());\n                        }\n                        Err(e) => {\n                            error!(\"Stream collection error: {}\", e);\n                            return Ok((\n                                StatusCode::INTERNAL_SERVER_ERROR,\n                                format!(\"Stream collection error: {}\", e),\n                            )\n                                .into_response());\n                        }\n                    }\n                }\n            }\n\n            let mut gemini_resp: Value = response\n                .json()\n                .await\n                .map_err(|e| (StatusCode::BAD_GATEWAY, format!(\"Parse error: {}\", e)))?;\n\n            // [FIX #1522] Inject Tool ID into Non-streaming Response\n            crate::proxy::mappers::gemini::wrapper::inject_ids_to_response(\n                &mut gemini_resp,\n                &mapped_model,\n            );\n\n            // [FIX #765] Extract thoughtSignature from non-streaming response\n            let inner_val = if gemini_resp.get(\"response\").is_some() {\n                gemini_resp.get(\"response\")\n            } else {\n                Some(&gemini_resp)\n            };\n\n            if let Some(resp) = inner_val {\n                if let Some(candidates) = resp.get(\"candidates\").and_then(|c| c.as_array()) {\n                    for cand in candidates {\n                        if let Some(parts) = cand\n                            .get(\"content\")\n                            .and_then(|c| c.get(\"parts\"))\n                            .and_then(|p| p.as_array())\n                        {\n                            for part in parts {\n                                if let Some(sig) =\n                                    part.get(\"thoughtSignature\").and_then(|s| s.as_str())\n                                {\n                                    crate::proxy::SignatureCache::global().cache_session_signature(\n                                        &session_id,\n                                        sig.to_string(),\n                                        1,\n                                    );\n                                    debug!(\"[Gemini-Response] Cached signature (len: {}) for session: {}\", sig.len(), session_id);\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n\n            let unwrapped = unwrap_response(&gemini_resp);\n            return Ok((\n                StatusCode::OK,\n                [\n                    (\"X-Account-Email\", email.as_str()),\n                    (\"X-Mapped-Model\", mapped_model.as_str()),\n                ],\n                Json(unwrapped),\n            )\n                .into_response());\n        }\n\n        // 处理错误并重试\n        let status_code = status.as_u16();\n        let error_text = response\n            .text()\n            .await\n            .unwrap_or_else(|_| format!(\"HTTP {}\", status_code));\n        last_error = format!(\"HTTP {}: {}\", status_code, error_text);\n        if debug_logger::is_enabled(&debug_cfg) {\n            let payload = json!({\n                \"kind\": \"upstream_response_error\",\n                \"protocol\": \"gemini\",\n                \"trace_id\": trace_id,\n                \"original_model\": model_name,\n                \"mapped_model\": mapped_model,\n                \"request_type\": config.request_type,\n                \"attempt\": attempt,\n                \"status\": status_code,\n                \"upstream_url\": upstream_url,\n                \"account\": mask_email(&email),\n                \"error_text\": error_text,\n            });\n            debug_logger::write_debug_payload(\n                &debug_cfg,\n                Some(&trace_id),\n                \"upstream_response_error\",\n                &payload,\n            )\n            .await;\n        }\n\n        // 确定重试策略\n        let strategy = determine_retry_strategy(status_code, &error_text, false);\n        let trace_id = format!(\"gemini_{}\", session_id);\n\n        // 执行退避\n        if apply_retry_strategy(strategy, attempt, max_attempts, status_code, &trace_id).await {\n            // [NEW] Apply Client Adapter \"let_it_crash\" strategy\n            if let Some(adapter) = &client_adapter {\n                if adapter.let_it_crash() && attempt > 0 {\n                    tracing::warn!(\n                        \"[Gemini] let_it_crash active: Aborting retries after attempt {}\",\n                        attempt\n                    );\n                    break;\n                }\n            }\n\n            // 判断是否需要轮换账号\n            if !should_rotate_account(status_code) {\n                debug!(\n                    \"[{}] Keeping same account for status {} (Gemini server-side issue)\",\n                    trace_id, status_code\n                );\n            }\n            continue;\n        }\n\n        // [NEW] 处理 400 错误 (Thinking 签名失效)\n        if status_code == 400\n            && (error_text.contains(\"Invalid `signature`\")\n                || error_text.contains(\"thinking.signature\")\n                || error_text.contains(\"Invalid signature\")\n                || error_text.contains(\"Corrupted thought signature\"))\n        {\n            tracing::warn!(\n                \"[Gemini] Signature error detected on account {}, retrying without thinking\",\n                email\n            );\n\n            // 追加修复提示词到请求体的最后一条内容\n            if let Some(contents) = body.get_mut(\"contents\").and_then(|v| v.as_array_mut()) {\n                if let Some(last_content) = contents.last_mut() {\n                    if let Some(parts) =\n                        last_content.get_mut(\"parts\").and_then(|v| v.as_array_mut())\n                    {\n                        parts.push(json!({\n                            \"text\": \"\\n\\n[System Recovery] Your previous output contained an invalid signature. Please regenerate the response without the corrupted signature block.\"\n                        }));\n                        tracing::debug!(\"[Gemini] Appended repair prompt to last content\");\n                    }\n                }\n            }\n\n            continue; // 重试\n        }\n\n        // 404 等由于模型配置或路径错误的 HTTP 异常，直接报错，不进行无效轮换\n        error!(\n            \"Gemini Upstream non-retryable error {}: {}\",\n            status_code, error_text\n        );\n        return Ok((\n            status,\n            [\n                (\"X-Account-Email\", email.as_str()),\n                (\"X-Mapped-Model\", mapped_model.as_str()),\n            ],\n            // [FIX] Return JSON error\n            Json(json!({\n                \"error\": {\n                    \"code\": status_code,\n                    \"message\": error_text,\n                    \"status\": \"UPSTREAM_ERROR\"\n                }\n            })),\n        )\n            .into_response());\n    }\n\n    if let Some(email) = last_email {\n        Ok((\n            StatusCode::TOO_MANY_REQUESTS,\n            [(\"X-Account-Email\", email)],\n            format!(\"All accounts exhausted. Last error: {}\", last_error),\n        )\n            .into_response())\n    } else {\n        Ok((\n            StatusCode::TOO_MANY_REQUESTS,\n            format!(\"All accounts exhausted. Last error: {}\", last_error),\n        )\n            .into_response())\n    }\n}\n\npub async fn handle_list_models(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, String)> {\n    use crate::proxy::common::model_mapping::get_all_dynamic_models;\n\n    // 获取所有动态模型列表（与 /v1/models 一致）\n    let model_ids = get_all_dynamic_models(&state.custom_mapping, Some(&state.token_manager)).await;\n\n    // 转换为 Gemini API 格式\n    let models: Vec<_> = model_ids\n        .into_iter()\n        .map(|id| {\n            json!({\n                \"name\": format!(\"models/{}\", id),\n                \"version\": \"001\",\n                \"displayName\": id.clone(),\n                \"description\": \"\",\n                \"inputTokenLimit\": 128000,\n                \"outputTokenLimit\": 8192,\n                \"supportedGenerationMethods\": [\"generateContent\", \"countTokens\"],\n                \"temperature\": 1.0,\n                \"topP\": 0.95,\n                \"topK\": 64\n            })\n        })\n        .collect();\n\n    Ok(Json(json!({ \"models\": models })))\n}\n\npub async fn handle_get_model(Path(model_name): Path<String>) -> impl IntoResponse {\n    Json(json!({\n        \"name\": format!(\"models/{}\", model_name),\n        \"displayName\": model_name\n    }))\n}\n\npub async fn handle_count_tokens(\n    State(state): State<AppState>,\n    Path(_model_name): Path<String>,\n    Json(_body): Json<Value>,\n) -> Result<impl IntoResponse, (StatusCode, String)> {\n    let model_group = \"gemini\";\n    let (_access_token, _project_id, _, _, _wait_ms) = state\n        .token_manager\n        .get_token(model_group, false, None, \"gemini\")\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::SERVICE_UNAVAILABLE,\n                format!(\"Token error: {}\", e),\n            )\n        })?;\n\n    Ok(Json(json!({\"totalTokens\": 0})))\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/handlers/mcp.rs",
    "content": "use axum::{\n    body::{to_bytes, Body},\n    extract::State,\n    http::{header, HeaderMap, HeaderValue, Method, StatusCode},\n    response::{IntoResponse, Response},\n};\nuse bytes::Bytes;\nuse futures::StreamExt;\nuse serde_json::{json, Value};\nuse tokio::time::Duration;\nuse tokio_stream::wrappers::IntervalStream;\n\nuse crate::proxy::server::AppState;\n\nfn build_client(\n    upstream_proxy: crate::proxy::config::UpstreamProxyConfig,\n    timeout_secs: u64,\n) -> Result<reqwest::Client, String> {\n    let mut builder = reqwest::Client::builder()\n        .timeout(Duration::from_secs(timeout_secs.max(5)));\n\n    if upstream_proxy.enabled && !upstream_proxy.url.is_empty() {\n        let url = crate::proxy::config::normalize_proxy_url(&upstream_proxy.url);\n        let proxy = reqwest::Proxy::all(&url)\n            .map_err(|e| format!(\"Invalid upstream proxy url: {}\", e))?;\n        builder = builder.proxy(proxy);\n    }\n\n    builder.build().map_err(|e| format!(\"Failed to build HTTP client: {}\", e))\n}\n\nfn copy_passthrough_headers(incoming: &HeaderMap) -> HeaderMap {\n    let mut out = HeaderMap::new();\n    for (k, v) in incoming.iter() {\n        let key = k.as_str().to_ascii_lowercase();\n        match key.as_str() {\n            \"content-type\" | \"accept\" | \"user-agent\" => {\n                out.insert(k.clone(), v.clone());\n            }\n            _ => {}\n        }\n    }\n    out\n}\n\nasync fn forward_mcp(\n    state: &AppState,\n    incoming_headers: HeaderMap,\n    method: Method,\n    upstream_url: &str,\n    body: Body,\n) -> Response {\n    let zai = state.zai.read().await.clone();\n    if !zai.enabled || zai.api_key.trim().is_empty() {\n        return (StatusCode::BAD_REQUEST, \"z.ai is not configured\").into_response();\n    }\n\n    if !zai.mcp.enabled {\n        return StatusCode::NOT_FOUND.into_response();\n    }\n\n    let upstream_proxy = state.upstream_proxy.read().await.clone();\n    let client = match build_client(upstream_proxy, state.request_timeout) {\n        Ok(c) => c,\n        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),\n    };\n\n    let collected = match to_bytes(body, 100 * 1024 * 1024).await {\n        Ok(b) => b,\n        Err(e) => {\n            return (\n                StatusCode::BAD_REQUEST,\n                format!(\"Failed to read request body: {}\", e),\n            )\n                .into_response();\n        }\n    };\n\n    let mut headers = copy_passthrough_headers(&incoming_headers);\n    if let Ok(v) = HeaderValue::from_str(&format!(\"Bearer {}\", zai.api_key)) {\n        headers.insert(header::AUTHORIZATION, v);\n    }\n\n    let req = client\n        .request(method, upstream_url)\n        .headers(headers)\n        .body(collected);\n\n    let resp = match req.send().await {\n        Ok(r) => r,\n        Err(e) => {\n            return (\n                StatusCode::BAD_GATEWAY,\n                format!(\"Upstream request failed: {}\", e),\n            )\n                .into_response();\n        }\n    };\n\n    let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);\n    let mut out = Response::builder().status(status);\n    if let Some(ct) = resp.headers().get(header::CONTENT_TYPE) {\n        out = out.header(header::CONTENT_TYPE, ct.clone());\n    }\n\n    let stream = resp.bytes_stream().map(|chunk| match chunk {\n        Ok(b) => Ok::<Bytes, std::io::Error>(b),\n        Err(e) => Ok(Bytes::from(format!(\"Upstream stream error: {}\", e))),\n    });\n\n    out.body(Body::from_stream(stream)).unwrap_or_else(|_| {\n        (StatusCode::INTERNAL_SERVER_ERROR, \"Failed to build response\").into_response()\n    })\n}\n\npub async fn handle_web_search_prime(\n    State(state): State<AppState>,\n    headers: HeaderMap,\n    method: Method,\n    body: Body,\n) -> Response {\n    let zai = state.zai.read().await.clone();\n    if !zai.mcp.web_search_enabled {\n        return StatusCode::NOT_FOUND.into_response();\n    }\n    drop(zai);\n\n    forward_mcp(\n        &state,\n        headers,\n        method,\n        \"https://api.z.ai/api/mcp/web_search_prime/mcp\",\n        body,\n    )\n    .await\n}\n\npub async fn handle_web_reader(\n    State(state): State<AppState>,\n    headers: HeaderMap,\n    method: Method,\n    body: Body,\n) -> Response {\n    let zai = state.zai.read().await.clone();\n    if !zai.mcp.web_reader_enabled {\n        return StatusCode::NOT_FOUND.into_response();\n    }\n    drop(zai);\n\n    forward_mcp(\n        &state,\n        headers,\n        method,\n        \"https://api.z.ai/api/mcp/web_reader/mcp\",\n        body,\n    )\n    .await\n}\n\nfn mcp_session_id(headers: &HeaderMap) -> Option<String> {\n    headers\n        .get(\"mcp-session-id\")\n        .or_else(|| headers.get(\"Mcp-Session-Id\"))\n        .and_then(|v| v.to_str().ok())\n        .map(|s| s.to_string())\n}\n\nfn jsonrpc_error(id: Value, code: i64, message: impl Into<String>) -> Value {\n    json!({\n        \"jsonrpc\": \"2.0\",\n        \"error\": {\n            \"code\": code,\n            \"message\": message.into(),\n        },\n        \"id\": id,\n    })\n}\n\nfn jsonrpc_result(id: Value, result: Value) -> Value {\n    json!({\n        \"jsonrpc\": \"2.0\",\n        \"result\": result,\n        \"id\": id,\n    })\n}\n\nfn is_initialize_request(body: &Value) -> bool {\n    body.get(\"method\").and_then(|m| m.as_str()) == Some(\"initialize\")\n}\n\nasync fn handle_vision_get(state: AppState, headers: HeaderMap) -> Response {\n    let Some(session_id) = mcp_session_id(&headers) else {\n        return (StatusCode::BAD_REQUEST, \"Missing Mcp-Session-Id\").into_response();\n    };\n    if !state.zai_vision_mcp.has_session(&session_id).await {\n        return (StatusCode::BAD_REQUEST, \"Invalid Mcp-Session-Id\").into_response();\n    }\n\n    let ping_stream = IntervalStream::new(tokio::time::interval(Duration::from_secs(15))).map(|_| {\n        Ok::<axum::response::sse::Event, std::convert::Infallible>(\n            axum::response::sse::Event::default()\n                .event(\"ping\")\n                .data(\"keepalive\"),\n        )\n    });\n\n    let mut resp = axum::response::sse::Sse::new(ping_stream)\n        .keep_alive(\n            axum::response::sse::KeepAlive::new()\n                .interval(Duration::from_secs(15))\n                .text(\"keepalive\"),\n        )\n        .into_response();\n\n    if let Ok(v) = HeaderValue::from_str(&session_id) {\n        resp.headers_mut().insert(\"mcp-session-id\", v);\n    }\n    resp\n}\n\nasync fn handle_vision_delete(state: AppState, headers: HeaderMap) -> Response {\n    let Some(session_id) = mcp_session_id(&headers) else {\n        return (StatusCode::BAD_REQUEST, \"Missing Mcp-Session-Id\").into_response();\n    };\n\n    state.zai_vision_mcp.remove_session(&session_id).await;\n    StatusCode::OK.into_response()\n}\n\nasync fn handle_vision_post(state: AppState, headers: HeaderMap, body: Body) -> Response {\n    let collected = match to_bytes(body, 100 * 1024 * 1024).await {\n        Ok(b) => b,\n        Err(e) => {\n            return (\n                StatusCode::BAD_REQUEST,\n                format!(\"Failed to read request body: {}\", e),\n            )\n                .into_response();\n        }\n    };\n\n    let request_json: Value = match serde_json::from_slice(&collected) {\n        Ok(v) => v,\n        Err(e) => {\n            return (\n                StatusCode::BAD_REQUEST,\n                axum::Json(jsonrpc_error(Value::Null, -32700, format!(\"Parse error: {}\", e))),\n            )\n                .into_response();\n        }\n    };\n\n    let id = request_json.get(\"id\").cloned().unwrap_or(Value::Null);\n    let method = request_json\n        .get(\"method\")\n        .and_then(|m| m.as_str())\n        .unwrap_or_default();\n\n    if method.is_empty() {\n        return (\n            StatusCode::BAD_REQUEST,\n            axum::Json(jsonrpc_error(id, -32600, \"Invalid Request: missing method\")),\n        )\n            .into_response();\n    }\n\n    // Notifications (no id) should not produce a response.\n    if request_json.get(\"id\").is_none() || request_json.get(\"id\") == Some(&Value::Null) {\n        return StatusCode::NO_CONTENT.into_response();\n    }\n\n    if is_initialize_request(&request_json) {\n        let session_id = state.zai_vision_mcp.create_session().await;\n        let requested_protocol = request_json\n            .get(\"params\")\n            .and_then(|p| p.get(\"protocolVersion\"))\n            .and_then(|v| v.as_str())\n            .unwrap_or(\"2024-11-05\");\n\n        let result = json!({\n            \"protocolVersion\": requested_protocol,\n            \"capabilities\": { \"tools\": {} },\n            \"serverInfo\": {\n                \"name\": \"zai-mcp-server\",\n                \"version\": env!(\"CARGO_PKG_VERSION\"),\n            }\n        });\n\n        let mut resp = (StatusCode::OK, axum::Json(jsonrpc_result(id, result))).into_response();\n        if let Ok(v) = HeaderValue::from_str(&session_id) {\n            resp.headers_mut().insert(\"mcp-session-id\", v);\n        }\n        return resp;\n    }\n\n    let Some(session_id) = mcp_session_id(&headers) else {\n        return (\n            StatusCode::BAD_REQUEST,\n            axum::Json(jsonrpc_error(id, -32000, \"Bad Request: missing Mcp-Session-Id\")),\n        )\n            .into_response();\n    };\n    if !state.zai_vision_mcp.has_session(&session_id).await {\n        return (\n            StatusCode::BAD_REQUEST,\n            axum::Json(jsonrpc_error(id, -32000, \"Bad Request: invalid Mcp-Session-Id\")),\n        )\n            .into_response();\n    }\n\n    match method {\n        \"tools/list\" => {\n            let result = json!({ \"tools\": crate::proxy::zai_vision_tools::tool_specs() });\n            (StatusCode::OK, axum::Json(jsonrpc_result(id, result))).into_response()\n        }\n        \"tools/call\" => {\n            let params = request_json.get(\"params\").cloned().unwrap_or(Value::Null);\n            let tool_name = params\n                .get(\"name\")\n                .and_then(|v| v.as_str())\n                .ok_or_else(|| \"Missing params.name\".to_string());\n\n            let tool_name = match tool_name {\n                Ok(v) => v,\n                Err(e) => {\n                    return (\n                        StatusCode::BAD_REQUEST,\n                        axum::Json(jsonrpc_error(id, -32602, e)),\n                    )\n                        .into_response();\n                }\n            };\n\n            let arguments = params.get(\"arguments\").cloned().unwrap_or(Value::Object(Default::default()));\n\n            let zai = state.zai.read().await.clone();\n            let upstream_proxy = state.upstream_proxy.read().await.clone();\n            let timeout = state.request_timeout;\n\n            match crate::proxy::zai_vision_tools::call_tool(\n                &zai,\n                upstream_proxy,\n                timeout,\n                tool_name,\n                &arguments,\n            )\n            .await\n            {\n                Ok(tool_result) => {\n                    (StatusCode::OK, axum::Json(jsonrpc_result(id, tool_result))).into_response()\n                }\n                Err(e) => (\n                    StatusCode::OK,\n                    axum::Json(jsonrpc_result(\n                        id,\n                        json!({\n                            \"content\": [ { \"type\": \"text\", \"text\": format!(\"Error: {}\", e) } ],\n                            \"isError\": true\n                        }),\n                    )),\n                )\n                    .into_response(),\n            }\n        }\n        _ => (\n            StatusCode::BAD_REQUEST,\n            axum::Json(jsonrpc_error(\n                id,\n                -32601,\n                format!(\"Method not found: {}\", method),\n            )),\n        )\n            .into_response(),\n    }\n}\n\npub async fn handle_zai_mcp_server(\n    State(state): State<AppState>,\n    headers: HeaderMap,\n    method: Method,\n    body: Body,\n) -> Response {\n    let zai = state.zai.read().await.clone();\n    if !zai.enabled || zai.api_key.trim().is_empty() {\n        return (StatusCode::BAD_REQUEST, \"z.ai is not configured\").into_response();\n    }\n    if !zai.mcp.enabled || !zai.mcp.vision_enabled {\n        return StatusCode::NOT_FOUND.into_response();\n    }\n    drop(zai);\n\n    match method {\n        Method::GET => handle_vision_get(state, headers).await,\n        Method::DELETE => handle_vision_delete(state, headers).await,\n        Method::POST => handle_vision_post(state, headers, body).await,\n        _ => StatusCode::METHOD_NOT_ALLOWED.into_response(),\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/handlers/mod.rs",
    "content": "// Handlers 模块 - API 端点处理器\n// 核心端点处理器模块\n\npub mod claude;\npub mod openai;\npub mod gemini;\npub mod mcp;\npub mod common;\npub mod audio;  // 音频转录处理器\npub mod warmup; // 预热处理器\n\n"
  },
  {
    "path": "src-tauri/src/proxy/handlers/openai.rs",
    "content": "// OpenAI Handler\nuse axum::{\n    extract::Json, extract::State, http::StatusCode, response::IntoResponse, response::Response,\n};\nuse base64::Engine as _;\nuse bytes::Bytes;\nuse serde_json::{json, Value};\nuse tracing::{debug, error, info}; // Import Engine trait for encode method\n\nuse crate::proxy::mappers::openai::{\n    transform_openai_request, transform_openai_response, OpenAIRequest,\n};\n// use crate::proxy::upstream::client::UpstreamClient; // 通过 state 获取\nuse crate::proxy::debug_logger;\nuse crate::proxy::server::AppState;\nuse crate::proxy::upstream::client::mask_email;\n\nconst MAX_RETRY_ATTEMPTS: usize = 3;\nuse super::common::{\n    apply_retry_strategy, determine_retry_strategy, should_rotate_account, RetryStrategy,\n};\nuse crate::proxy::common::client_adapter::CLIENT_ADAPTERS; // [NEW] Adapter Registry\nuse crate::proxy::session_manager::SessionManager;\nuse axum::http::HeaderMap;\nuse tokio::time::Duration;\nuse crate::modules::account;\n\npub async fn handle_chat_completions(\n    State(state): State<AppState>,\n    headers: HeaderMap, // [CHANGED] Extract headers\n    Json(mut body): Json<Value>,\n) -> Result<impl IntoResponse, (StatusCode, String)> {\n    // [NEW] Check for Image Model Redirection\n    let model_name = body.get(\"model\").and_then(|v| v.as_str()).unwrap_or(\"\").to_lowercase();\n    if model_name.contains(\"image\") || model_name.contains(\"dall-e\") || model_name.contains(\"midjourney\") {\n        tracing::info!(\"[ChatRedirection] Redirecting model {} to image generations\", model_name);\n        return intercept_chat_to_image(state, body, &model_name).await;\n    }\n\n    // [FIX] 保存原始请求体的完整副本，用于日志记录\n    // 这确保了即使结构体定义遗漏字段，日志也能完整记录所有参数\n    let original_body = body.clone();\n\n    // [NEW] 自动检测并转换 Responses 格式\n    // 如果请求包含 instructions 或 input 但没有 messages，则认为是 Responses 格式\n    let is_responses_format = !body.get(\"messages\").is_some()\n        && (body.get(\"instructions\").is_some() || body.get(\"input\").is_some());\n\n    if is_responses_format {\n        debug!(\"Detected Responses API format, converting to Chat Completions format\");\n\n        // 转换 instructions 为 system message\n        if let Some(instructions) = body.get(\"instructions\").and_then(|v| v.as_str()) {\n            if !instructions.is_empty() {\n                let system_msg = json!({\n                    \"role\": \"system\",\n                    \"content\": instructions\n                });\n\n                // 初始化 messages 数组\n                if !body.get(\"messages\").is_some() {\n                    body[\"messages\"] = json!([]);\n                }\n\n                // 将 system message 插入到开头\n                if let Some(messages) = body.get_mut(\"messages\").and_then(|v| v.as_array_mut()) {\n                    messages.insert(0, system_msg);\n                }\n            }\n        }\n\n        // 转换 input 为 user message（如果存在）\n        if let Some(input) = body.get(\"input\") {\n            let user_msg = if input.is_string() {\n                json!({\n                    \"role\": \"user\",\n                    \"content\": input.as_str().unwrap_or(\"\")\n                })\n            } else {\n                // input 是数组格式，暂时简化处理\n                json!({\n                    \"role\": \"user\",\n                    \"content\": input.to_string()\n                })\n            };\n\n            if let Some(messages) = body.get_mut(\"messages\").and_then(|v| v.as_array_mut()) {\n                messages.push(user_msg);\n            }\n        }\n    }\n\n    let mut openai_req: OpenAIRequest = serde_json::from_value(body)\n        .map_err(|e| (StatusCode::BAD_REQUEST, format!(\"Invalid request: {}\", e)))?;\n\n    // Safety: Ensure messages is not empty\n    if openai_req.messages.is_empty() {\n        debug!(\"Received request with empty messages, injecting fallback...\");\n        openai_req\n            .messages\n            .push(crate::proxy::mappers::openai::OpenAIMessage {\n                role: \"user\".to_string(),\n                content: Some(crate::proxy::mappers::openai::OpenAIContent::String(\n                    \" \".to_string(),\n                )),\n                reasoning_content: None,\n                tool_calls: None,\n                tool_call_id: None,\n                name: None,\n            });\n    }\n\n    let trace_id = format!(\"req_{}\", chrono::Utc::now().timestamp_subsec_millis());\n    info!(\n        \"[{}] OpenAI Chat Request: {} | {} messages | stream: {}\",\n        trace_id,\n        openai_req.model,\n        openai_req.messages.len(),\n        openai_req.stream\n    );\n    let debug_cfg = state.debug_logging.read().await.clone();\n    if debug_logger::is_enabled(&debug_cfg) {\n        // [FIX] 使用原始 body 副本记录日志，确保不丢失任何字段\n        let original_payload = json!({\n            \"kind\": \"original_request\",\n            \"protocol\": \"openai\",\n            \"trace_id\": trace_id,\n            \"original_model\": openai_req.model,\n            \"request\": original_body,  // 使用原始请求体，不是结构体序列化\n        });\n        debug_logger::write_debug_payload(\n            &debug_cfg,\n            Some(&trace_id),\n            \"original_request\",\n            &original_payload,\n        )\n        .await;\n    }\n\n    // [NEW] Detect Client Adapter\n    let client_adapter = CLIENT_ADAPTERS\n        .iter()\n        .find(|a| a.matches(&headers))\n        .cloned();\n    if client_adapter.is_some() {\n        debug!(\"[{}] Client Adapter detected\", trace_id);\n    }\n\n    // 1. 获取 UpstreamClient (Clone handle)\n    let upstream = state.upstream.clone();\n    let token_manager = state.token_manager;\n    let pool_size = token_manager.len();\n    // [FIX] Ensure max_attempts is at least 2 to allow for internal retries\n    let max_attempts = MAX_RETRY_ATTEMPTS.min(pool_size.saturating_add(1)).max(2);\n\n    let mut last_error = String::new();\n    let mut last_email: Option<String> = None;\n\n    // 2. 模型路由解析 (移到循环外以支持在所有路径返回 X-Mapped-Model)\n    let mapped_model = crate::proxy::common::model_mapping::resolve_model_route(\n        &openai_req.model,\n        &*state.custom_mapping.read().await,\n    );\n\n    for attempt in 0..max_attempts {\n        // 将 OpenAI 工具转为 Value 数组以便探测联网\n        let tools_val: Option<Vec<Value>> = openai_req\n            .tools\n            .as_ref()\n            .map(|list| list.iter().cloned().collect());\n        let config = crate::proxy::mappers::common_utils::resolve_request_config(\n            &openai_req.model,\n            &mapped_model,\n            &tools_val,\n            None, // size (not used in handler, transform_openai_request handles it)\n            None, // quality\n            None, // image_size\n            None, // body\n        );\n\n        // 3. 提取 SessionId (粘性指纹)\n        let session_id = SessionManager::extract_openai_session_id(&openai_req);\n\n        // 4. 获取 Token (使用准确的 request_type)\n        // 关键：在重试尝试 (attempt > 0) 时强制轮换账号\n        let (access_token, project_id, email, account_id, _wait_ms) = match token_manager\n            .get_token(\n                &config.request_type,\n                attempt > 0,\n                Some(&session_id),\n                &mapped_model,\n            )\n            .await\n        {\n            Ok(t) => t,\n            Err(e) => {\n                // [FIX] Attach headers to error response for logging visibility\n                let headers = [(\"X-Mapped-Model\", mapped_model.as_str())];\n                return Ok((\n                    StatusCode::SERVICE_UNAVAILABLE,\n                    headers,\n                    format!(\"Token error: {}\", e),\n                )\n                    .into_response());\n            }\n        };\n\n        // [NEW v4.1.29] 获取完整 Token 对象用于动态规格查询\n        let proxy_token = token_manager.get_token_by_id(&account_id);\n        let mapped_model = token_manager\n            .resolve_dynamic_model_for_account(&account_id, &mapped_model)\n            .await;\n\n        last_email = Some(email.clone());\n        info!(\"✓ Using account: {} (type: {})\", email, config.request_type);\n\n        // 4. 转换请求 (返回内容包含 session_id 和 message_count)\n        let (gemini_body, session_id, message_count) =\n            transform_openai_request(&openai_req, &project_id, &mapped_model, proxy_token.as_ref());\n\n        if debug_logger::is_enabled(&debug_cfg) {\n            let payload = json!({\n                \"kind\": \"v1internal_request\",\n                \"protocol\": \"openai\",\n                \"trace_id\": trace_id,\n                \"original_model\": openai_req.model,\n                \"mapped_model\": mapped_model,\n                \"request_type\": config.request_type,\n                \"attempt\": attempt,\n                \"v1internal_request\": gemini_body.clone(),\n            });\n            debug_logger::write_debug_payload(\n                &debug_cfg,\n                Some(&trace_id),\n                \"v1internal_request\",\n                &payload,\n            )\n            .await;\n        }\n\n        // [New] 打印转换后的报文 (Gemini Body) 供调试\n        if let Ok(body_json) = serde_json::to_string_pretty(&gemini_body) {\n            debug!(\"[OpenAI-Request] Transformed Gemini Body:\\n{}\", body_json);\n        }\n\n        // 5. 发送请求\n        let client_wants_stream = openai_req.stream;\n        let force_stream_internally = !client_wants_stream;\n        let actual_stream = client_wants_stream || force_stream_internally;\n\n        if force_stream_internally {\n            debug!(\n                \"[{}] 🔄 Auto-converting non-stream request to stream for better quota\",\n                trace_id\n            );\n        }\n\n        let method = if actual_stream {\n            \"streamGenerateContent\"\n        } else {\n            \"generateContent\"\n        };\n        let query_string = if actual_stream { Some(\"alt=sse\") } else { None };\n\n        // [FIX #1522] Inject Anthropic Beta Headers for Claude models (OpenAI path)\n        let mut extra_headers = std::collections::HashMap::new();\n        if mapped_model.to_lowercase().contains(\"claude\") {\n            extra_headers.insert(\n                \"anthropic-beta\".to_string(),\n                \"claude-code-20250219\".to_string(),\n            );\n            tracing::debug!(\n                \"[{}] Injected Anthropic beta headers for Claude model (via OpenAI)\",\n                trace_id\n            );\n        }\n\n        let call_result = match upstream\n            .call_v1_internal_with_headers(\n                method,\n                &access_token,\n                gemini_body,\n                query_string,\n                extra_headers.clone(),\n                Some(account_id.as_str()),\n            )\n            .await\n        {\n            Ok(r) => r,\n            Err(e) => {\n                last_error = e.clone();\n                debug!(\n                    \"OpenAI Request failed on attempt {}/{}: {}\",\n                    attempt + 1,\n                    max_attempts,\n                    e\n                );\n                continue;\n            }\n        };\n\n        // [NEW] 记录端点降级日志到 debug 文件\n        if !call_result.fallback_attempts.is_empty() && debug_logger::is_enabled(&debug_cfg) {\n            let fallback_entries: Vec<Value> = call_result\n                .fallback_attempts\n                .iter()\n                .map(|a| {\n                    json!({\n                        \"endpoint_url\": a.endpoint_url,\n                        \"status\": a.status,\n                        \"error\": a.error,\n                    })\n                })\n                .collect();\n            let payload = json!({\n                \"kind\": \"endpoint_fallback\",\n                \"protocol\": \"openai\",\n                \"trace_id\": trace_id,\n                \"original_model\": openai_req.model,\n                \"mapped_model\": mapped_model,\n                \"attempt\": attempt,\n                \"account\": mask_email(&email),\n                \"fallback_attempts\": fallback_entries,\n            });\n            debug_logger::write_debug_payload(\n                &debug_cfg,\n                Some(&trace_id),\n                \"endpoint_fallback\",\n                &payload,\n            )\n            .await;\n        }\n\n        let response = call_result.response;\n        // [NEW] 提取实际请求的上游端点 URL，用于日志记录和排查\n        let upstream_url = response.url().to_string();\n        let status = response.status();\n        if status.is_success() {\n            // 5. 处理流式 vs 非流式\n            if actual_stream {\n                use axum::body::Body;\n                use axum::response::Response;\n                use futures::StreamExt;\n\n                let meta = json!({\n                    \"protocol\": \"openai\",\n                    \"trace_id\": trace_id,\n                    \"original_model\": openai_req.model,\n                    \"mapped_model\": mapped_model,\n                    \"request_type\": config.request_type,\n                    \"attempt\": attempt,\n                    \"status\": status.as_u16(),\n                    \"upstream_url\": upstream_url,\n                });\n                let gemini_stream = debug_logger::wrap_stream_with_debug(\n                    Box::pin(response.bytes_stream()),\n                    debug_cfg.clone(),\n                    trace_id.clone(),\n                    \"upstream_response\",\n                    meta,\n                );\n\n                // [P1 FIX] Enhanced Peek logic to handle heartbeats and slow start\n                // Pre-read until we find meaningful content, skip heartbeats\n                use crate::proxy::mappers::openai::streaming::create_openai_sse_stream;\n                let mut openai_stream = create_openai_sse_stream(\n                    gemini_stream,\n                    openai_req.model.clone(),\n                    session_id,\n                    message_count,\n                );\n\n                let mut first_data_chunk = None;\n                let mut retry_this_account = false;\n\n                // Loop to skip heartbeats during peek\n                loop {\n                    match tokio::time::timeout(\n                        std::time::Duration::from_secs(60),\n                        openai_stream.next(),\n                    )\n                    .await\n                    {\n                        Ok(Some(Ok(bytes))) => {\n                            if bytes.is_empty() {\n                                continue;\n                            }\n\n                            let text = String::from_utf8_lossy(&bytes);\n                            // Skip SSE comments/pings (heartbeats)\n                            if text.trim().starts_with(\":\") || text.trim().starts_with(\"data: :\") {\n                                tracing::debug!(\"[OpenAI] Skipping peek heartbeat\");\n                                continue;\n                            }\n\n                            // Check for error events\n                            if text.contains(\"\\\"error\\\"\") {\n                                tracing::warn!(\"[OpenAI] Error detected during peek, retrying...\");\n                                last_error = \"Error event during peek\".to_string();\n                                retry_this_account = true;\n                                break;\n                            }\n\n                            // We found real data!\n                            first_data_chunk = Some(bytes);\n                            break;\n                        }\n                        Ok(Some(Err(e))) => {\n                            tracing::warn!(\"[OpenAI] Stream error during peek: {}, retrying...\", e);\n                            last_error = format!(\"Stream error during peek: {}\", e);\n                            retry_this_account = true;\n                            break;\n                        }\n                        Ok(None) => {\n                            tracing::warn!(\n                                \"[OpenAI] Stream ended during peek (Empty Response), retrying...\"\n                            );\n                            last_error = \"Empty response stream during peek\".to_string();\n                            retry_this_account = true;\n                            break;\n                        }\n                        Err(_) => {\n                            tracing::warn!(\n                                \"[OpenAI] Timeout waiting for first data (60s), retrying...\"\n                            );\n                            last_error = \"Timeout waiting for first data\".to_string();\n                            retry_this_account = true;\n                            break;\n                        }\n                    }\n                }\n\n                if retry_this_account {\n                    continue; // Rotate to next account\n                }\n\n                // Combine first chunk with remaining stream\n                let combined_stream =\n                    futures::stream::once(\n                        async move { Ok::<Bytes, String>(first_data_chunk.unwrap()) },\n                    )\n                    .chain(openai_stream);\n\n                if client_wants_stream {\n                    // 客户端请求流式，返回 SSE\n                    let body = Body::from_stream(combined_stream);\n                    return Ok(Response::builder()\n                        .header(\"Content-Type\", \"text/event-stream\")\n                        .header(\"Cache-Control\", \"no-cache\")\n                        .header(\"Connection\", \"keep-alive\")\n                        .header(\"X-Accel-Buffering\", \"no\")\n                        .header(\"X-Account-Email\", &email)\n                        .header(\"X-Mapped-Model\", &mapped_model)\n                        .body(body)\n                        .unwrap()\n                        .into_response());\n                } else {\n                    // 客户端请求非流式，但内部强制转为流式\n                    // 收集流数据并聚合为 JSON\n                    use crate::proxy::mappers::openai::collector::collect_stream_to_json;\n\n                    match collect_stream_to_json(Box::pin(combined_stream)).await {\n                        Ok(full_response) => {\n                            info!(\"[{}] ✓ Stream collected and converted to JSON\", trace_id);\n                            return Ok((\n                                StatusCode::OK,\n                                [\n                                    (\"X-Account-Email\", email.as_str()),\n                                    (\"X-Mapped-Model\", mapped_model.as_str()),\n                                ],\n                                Json(full_response),\n                            )\n                                .into_response());\n                        }\n                        Err(e) => {\n                            error!(\"[{}] Stream collection error: {}\", trace_id, e);\n                            return Ok((\n                                StatusCode::INTERNAL_SERVER_ERROR,\n                                format!(\"Stream collection error: {}\", e),\n                            )\n                                .into_response());\n                        }\n                    }\n                }\n            }\n\n            let gemini_resp: Value = response\n                .json()\n                .await\n                .map_err(|e| (StatusCode::BAD_GATEWAY, format!(\"Parse error: {}\", e)))?;\n\n            let openai_response =\n                transform_openai_response(&gemini_resp, Some(&session_id), message_count);\n            return Ok((\n                StatusCode::OK,\n                [\n                    (\"X-Account-Email\", email.as_str()),\n                    (\"X-Mapped-Model\", mapped_model.as_str()),\n                ],\n                Json(openai_response),\n            )\n                .into_response());\n        }\n\n        // 处理特定错误并重试\n        let status_code = status.as_u16();\n        let _retry_after = response\n            .headers()\n            .get(\"Retry-After\")\n            .and_then(|h| h.to_str().ok())\n            .map(|s| s.to_string());\n        let error_text = response\n            .text()\n            .await\n            .unwrap_or_else(|_| format!(\"HTTP {}\", status_code));\n        last_error = format!(\"HTTP {}: {}\", status_code, error_text);\n\n        // [New] 打印错误报文日志\n        tracing::error!(\n            \"[OpenAI-Upstream] Error Response {}: {}\",\n            status_code,\n            error_text\n        );\n        if debug_logger::is_enabled(&debug_cfg) {\n            let payload = json!({\n                \"kind\": \"upstream_response_error\",\n                \"protocol\": \"openai\",\n                \"trace_id\": trace_id,\n                \"original_model\": openai_req.model,\n                \"mapped_model\": mapped_model,\n                \"request_type\": config.request_type,\n                \"attempt\": attempt,\n                \"status\": status_code,\n                \"upstream_url\": upstream_url,\n                \"account\": mask_email(&email),\n                \"error_text\": error_text,\n            });\n            debug_logger::write_debug_payload(\n                &debug_cfg,\n                Some(&trace_id),\n                \"upstream_response_error\",\n                &payload,\n            )\n            .await;\n        }\n\n        // 确定重试策略\n        let strategy = determine_retry_strategy(status_code, &error_text, false);\n\n        // 3. 标记限流状态(用于 UI 显示)\n        if status_code == 429 || status_code == 529 || status_code == 503 || status_code == 500 {\n            // [FIX] Use async version with model parameter for fine-grained rate limiting\n            token_manager\n                .mark_rate_limited_async(\n                    &email,\n                    status_code,\n                    _retry_after.as_deref(),\n                    &error_text,\n                    Some(&mapped_model),\n                )\n                .await;\n        }\n\n        // 执行退避\n        if apply_retry_strategy(strategy, attempt, max_attempts, status_code, &trace_id).await {\n            // [NEW] Apply Client Adapter \"let_it_crash\" strategy\n            if let Some(adapter) = &client_adapter {\n                if adapter.let_it_crash() && attempt > 0 {\n                    // For let_it_crash clients (like opencode), allow maybe 1 retry but then fail fast\n                    // to prevent long hangs on UI.\n                    tracing::warn!(\n                        \"[OpenAI] let_it_crash active: Aborting retries after attempt {}\",\n                        attempt\n                    );\n                    // Breaking loop to return error immediately\n                    // Reuse existing error return logic via loop exit behavior?\n                    // Or construct error here?\n                    // Let's just break for now, which will trigger the \"All accounts exhausted\" or last error logic.\n                    break;\n                }\n            }\n\n            // 判断是否需要轮换账号\n            if !should_rotate_account(status_code) {\n                debug!(\n                    \"[{}] Keeping same account for status {} (server-side issue)\",\n                    trace_id, status_code\n                );\n            }\n\n            // 2. [REMOVED] 不再特殊处理 QUOTA_EXHAUSTED，允许账号轮换\n            // if error_text.contains(\"QUOTA_EXHAUSTED\") { ... }\n            /*\n            if error_text.contains(\"QUOTA_EXHAUSTED\") {\n                error!(\n                    \"OpenAI Quota exhausted (429) on account {} attempt {}/{}, stopping to protect pool.\",\n                    email,\n                    attempt + 1,\n                    max_attempts\n                );\n                return Ok((status, [(\"X-Account-Email\", email.as_str()), (\"X-Mapped-Model\", mapped_model.as_str())], error_text).into_response());\n            }\n            */\n\n            // 3. 其他限流或服务器过载情况，轮换账号\n            tracing::warn!(\n                \"OpenAI Upstream {} on {} attempt {}/{}, rotating account\",\n                status_code,\n                email,\n                attempt + 1,\n                max_attempts\n            );\n            continue;\n        }\n\n        // [NEW] 处理 400 错误 (Thinking 签名失效)\n        if status_code == 400\n            && (error_text.contains(\"Invalid `signature`\")\n                || error_text.contains(\"thinking.signature\")\n                || error_text.contains(\"Invalid signature\")\n                || error_text.contains(\"Corrupted thought signature\"))\n        {\n            tracing::warn!(\n                \"[OpenAI] Signature error detected on account {}, retrying without thinking\",\n                email\n            );\n\n            // 追加修复提示词到最后一条用户消息\n            if let Some(last_msg) = openai_req.messages.last_mut() {\n                if last_msg.role == \"user\" {\n                    let repair_prompt = \"\\n\\n[System Recovery] Your previous output contained an invalid signature. Please regenerate the response without the corrupted signature block.\";\n\n                    if let Some(content) = &mut last_msg.content {\n                        use crate::proxy::mappers::openai::{OpenAIContent, OpenAIContentBlock};\n                        match content {\n                            OpenAIContent::String(s) => {\n                                s.push_str(repair_prompt);\n                            }\n                            OpenAIContent::Array(arr) => {\n                                arr.push(OpenAIContentBlock::Text {\n                                    text: repair_prompt.to_string(),\n                                });\n                            }\n                        }\n                        tracing::debug!(\"[OpenAI] Appended repair prompt to last user message\");\n                    }\n                }\n            }\n\n            continue; // 重试\n        }\n\n        // 只有 403 (权限/地区限制) 和 401 (认证失效) 触发账号轮换\n        if status_code == 403 || status_code == 401 {\n            if apply_retry_strategy(\n                RetryStrategy::FixedDelay(Duration::from_millis(200)),\n                attempt,\n                max_attempts,\n                status_code,\n                &trace_id,\n            )\n            .await\n            {\n                continue;\n            }\n        }\n\n        // 只有 403 (权限/地区限制) 和 401 (认证失效) 触发账号轮换\n        if status_code == 403 || status_code == 401 {\n            // [NEW] 403 时设置 is_forbidden 状态，避免 Claude Code 会话退出\n            if status_code == 403 {\n                if let Some(acc_id) = token_manager.get_account_id_by_email(&email) {\n                    // Check for VALIDATION_REQUIRED error - temporarily block account\n                    if error_text.contains(\"VALIDATION_REQUIRED\")\n                        || error_text.contains(\"verify your account\")\n                        || error_text.contains(\"validation_url\")\n                    {\n                        tracing::warn!(\n                            \"[OpenAI] VALIDATION_REQUIRED detected on account {}, temporarily blocking\",\n                            email\n                        );\n                        // Block for 10 minutes (default, configurable via config file)\n                        let block_minutes = 10i64;\n                        let block_until = chrono::Utc::now().timestamp() + (block_minutes * 60);\n\n                        if let Err(e) = token_manager\n                            .set_validation_block_public(&acc_id, block_until, &error_text)\n                            .await\n                        {\n                            tracing::error!(\"Failed to set validation block: {}\", e);\n                        }\n                    }\n\n                    // 设置 is_forbidden 状态\n                    if let Err(e) = token_manager.set_forbidden(&acc_id, &error_text).await {\n                        tracing::error!(\"Failed to set forbidden status: {}\", e);\n                    }\n                }\n            }\n\n            if apply_retry_strategy(\n                RetryStrategy::FixedDelay(Duration::from_millis(200)),\n                attempt,\n                max_attempts,\n                status_code,\n                &trace_id,\n            )\n            .await\n            {\n                continue;\n            }\n        }\n\n        // 404 等由于模型配置或路径错误的 HTTP 异常，直接报错，不进行无效轮换\n        error!(\n            \"OpenAI Upstream non-retryable error {} on account {}: {}\",\n            status_code, email, error_text\n        );\n        return Ok((\n            status,\n            [\n                (\"X-Account-Email\", email.as_str()),\n                (\"X-Mapped-Model\", mapped_model.as_str()),\n            ],\n            // [FIX] Return JSON error for better client compatibility\n            Json(json!({\n                \"error\": {\n                    \"message\": error_text,\n                    \"type\": \"upstream_error\",\n                    \"code\": status_code\n                }\n            })),\n        )\n            .into_response());\n    }\n\n    // 所有尝试均失败\n    if let Some(email) = last_email {\n        Ok((\n            StatusCode::TOO_MANY_REQUESTS,\n            [(\"X-Account-Email\", email), (\"X-Mapped-Model\", mapped_model)],\n            format!(\"All accounts exhausted. Last error: {}\", last_error),\n        )\n            .into_response())\n    } else {\n        Ok((\n            StatusCode::TOO_MANY_REQUESTS,\n            [(\"X-Mapped-Model\", mapped_model)],\n            format!(\"All accounts exhausted. Last error: {}\", last_error),\n        )\n            .into_response())\n    }\n}\n\n/// 处理 Legacy Completions API (/v1/completions)\n/// 将 Prompt 转换为 Chat Message 格式，复用 handle_chat_completions\npub async fn handle_completions(\n    State(state): State<AppState>,\n    Json(mut body): Json<Value>,\n) -> Response {\n    debug!(\n        \"Received /v1/completions or /v1/responses payload: {:?}\",\n        body\n    );\n\n    let is_codex_style = body.get(\"input\").is_some() || body.get(\"instructions\").is_some();\n\n    // 1. Convert Payload to Messages (Shared Chat Format)\n    if is_codex_style {\n        let instructions = body\n            .get(\"instructions\")\n            .and_then(|v| v.as_str())\n            .unwrap_or_default();\n        let input_items = body.get(\"input\").and_then(|v| v.as_array());\n\n        let mut messages = Vec::new();\n\n        // System Instructions\n        if !instructions.is_empty() {\n            messages.push(json!({ \"role\": \"system\", \"content\": instructions }));\n        }\n\n        let mut call_id_to_name = std::collections::HashMap::new();\n\n        // Pass 1: Build Call ID to Name Map\n        if let Some(items) = input_items {\n            for item in items {\n                let item_type = item.get(\"type\").and_then(|v| v.as_str()).unwrap_or(\"\");\n                match item_type {\n                    \"function_call\" | \"local_shell_call\" | \"web_search_call\" => {\n                        let call_id = item\n                            .get(\"call_id\")\n                            .and_then(|v| v.as_str())\n                            .or_else(|| item.get(\"id\").and_then(|v| v.as_str()))\n                            .unwrap_or(\"unknown\");\n\n                        let name = if item_type == \"local_shell_call\" {\n                            \"shell\"\n                        } else if item_type == \"web_search_call\" {\n                            \"google_search\"\n                        } else {\n                            item.get(\"name\")\n                                .and_then(|v| v.as_str())\n                                .unwrap_or(\"unknown\")\n                        };\n\n                        call_id_to_name.insert(call_id.to_string(), name.to_string());\n                        tracing::debug!(\"Mapped call_id {} to name {}\", call_id, name);\n                    }\n                    _ => {}\n                }\n            }\n        }\n\n        // Pass 2: Map Input Items to Messages\n        if let Some(items) = input_items {\n            for item in items {\n                let item_type = item.get(\"type\").and_then(|v| v.as_str()).unwrap_or(\"\");\n                match item_type {\n                    \"message\" => {\n                        let role = item.get(\"role\").and_then(|v| v.as_str()).unwrap_or(\"user\");\n                        let content = item.get(\"content\").and_then(|v| v.as_array());\n                        let mut text_parts = Vec::new();\n                        let mut image_parts: Vec<Value> = Vec::new();\n\n                        if let Some(parts) = content {\n                            for part in parts {\n                                // 处理文本块\n                                if let Some(text) = part.get(\"text\").and_then(|v| v.as_str()) {\n                                    text_parts.push(text.to_string());\n                                }\n                                // [NEW] 处理图像块 (Codex input_image 格式)\n                                else if part.get(\"type\").and_then(|v| v.as_str())\n                                    == Some(\"input_image\")\n                                {\n                                    if let Some(image_url) =\n                                        part.get(\"image_url\").and_then(|v| v.as_str())\n                                    {\n                                        image_parts.push(json!({\n                                            \"type\": \"image_url\",\n                                            \"image_url\": { \"url\": image_url }\n                                        }));\n                                        debug!(\"[Codex] Found input_image: {}\", image_url);\n                                    }\n                                }\n                                // [NEW] 兼容标准 OpenAI image_url 格式\n                                else if part.get(\"type\").and_then(|v| v.as_str())\n                                    == Some(\"image_url\")\n                                {\n                                    if let Some(url_obj) = part.get(\"image_url\") {\n                                        image_parts.push(json!({\n                                            \"type\": \"image_url\",\n                                            \"image_url\": url_obj.clone()\n                                        }));\n                                    }\n                                }\n                            }\n                        }\n\n                        // 构造消息内容：如果有图像则使用数组格式\n                        if image_parts.is_empty() {\n                            messages.push(json!({\n                                \"role\": role,\n                                \"content\": text_parts.join(\"\\n\")\n                            }));\n                        } else {\n                            let mut content_blocks: Vec<Value> = Vec::new();\n                            if !text_parts.is_empty() {\n                                content_blocks.push(json!({\n                                    \"type\": \"text\",\n                                    \"text\": text_parts.join(\"\\n\")\n                                }));\n                            }\n                            content_blocks.extend(image_parts);\n                            messages.push(json!({\n                                \"role\": role,\n                                \"content\": content_blocks\n                            }));\n                        }\n                    }\n                    \"function_call\" | \"local_shell_call\" | \"web_search_call\" => {\n                        let mut name = item\n                            .get(\"name\")\n                            .and_then(|v| v.as_str())\n                            .unwrap_or(\"unknown\");\n                        let mut args_str = item\n                            .get(\"arguments\")\n                            .and_then(|v| v.as_str())\n                            .unwrap_or(\"{}\")\n                            .to_string();\n                        let call_id = item\n                            .get(\"call_id\")\n                            .and_then(|v| v.as_str())\n                            .or_else(|| item.get(\"id\").and_then(|v| v.as_str()))\n                            .unwrap_or(\"unknown\");\n\n                        // Handle native shell calls\n                        if item_type == \"local_shell_call\" {\n                            name = \"shell\";\n                            if let Some(action) = item.get(\"action\") {\n                                if let Some(exec) = action.get(\"exec\") {\n                                    // Map to ShellCommandToolCallParams (string command) or ShellToolCallParams (array command)\n                                    // Most LLMs prefer a single string for shell\n                                    let mut args_obj = serde_json::Map::new();\n                                    if let Some(cmd) = exec.get(\"command\") {\n                                        // CRITICAL FIX: The 'shell' tool schema defines 'command' as an ARRAY of strings.\n                                        // We MUST pass it as an array, not a joined string, otherwise Gemini rejects with 400 INVALID_ARGUMENT.\n                                        let cmd_val = if cmd.is_string() {\n                                            json!([cmd]) // Wrap in array\n                                        } else {\n                                            cmd.clone() // Assume already array\n                                        };\n                                        args_obj.insert(\"command\".to_string(), cmd_val);\n                                    }\n                                    if let Some(wd) =\n                                        exec.get(\"working_directory\").or(exec.get(\"workdir\"))\n                                    {\n                                        args_obj.insert(\"workdir\".to_string(), wd.clone());\n                                    }\n                                    args_str = serde_json::to_string(&args_obj)\n                                        .unwrap_or(\"{}\".to_string());\n                                }\n                            }\n                        } else if item_type == \"web_search_call\" {\n                            name = \"google_search\";\n                            if let Some(action) = item.get(\"action\") {\n                                let mut args_obj = serde_json::Map::new();\n                                if let Some(q) = action.get(\"query\") {\n                                    args_obj.insert(\"query\".to_string(), q.clone());\n                                }\n                                args_str =\n                                    serde_json::to_string(&args_obj).unwrap_or(\"{}\".to_string());\n                            }\n                        }\n\n                        messages.push(json!({\n                            \"role\": \"assistant\",\n                            \"tool_calls\": [\n                                {\n                                    \"id\": call_id,\n                                    \"type\": \"function\",\n                                    \"function\": {\n                                        \"name\": name,\n                                        \"arguments\": args_str\n                                    }\n                                }\n                            ]\n                        }));\n                    }\n                    \"function_call_output\" | \"custom_tool_call_output\" => {\n                        let call_id = item\n                            .get(\"call_id\")\n                            .and_then(|v| v.as_str())\n                            .unwrap_or(\"unknown\");\n                        let output = item.get(\"output\");\n                        let output_str = if let Some(o) = output {\n                            if o.is_string() {\n                                o.as_str().unwrap().to_string()\n                            } else if let Some(content) = o.get(\"content\").and_then(|v| v.as_str())\n                            {\n                                content.to_string()\n                            } else {\n                                o.to_string()\n                            }\n                        } else {\n                            \"\".to_string()\n                        };\n\n                        let name = call_id_to_name.get(call_id).cloned().unwrap_or_else(|| {\n                            // Fallback: if unknown and we see function_call_output, it's likely \"shell\" in this context\n                            tracing::warn!(\n                                \"Unknown tool name for call_id {}, defaulting to 'shell'\",\n                                call_id\n                            );\n                            \"shell\".to_string()\n                        });\n\n                        messages.push(json!({\n                            \"role\": \"tool\",\n                            \"tool_call_id\": call_id,\n                            \"name\": name,\n                            \"content\": output_str\n                        }));\n                    }\n                    _ => {}\n                }\n            }\n        }\n\n        if let Some(obj) = body.as_object_mut() {\n            obj.insert(\"messages\".to_string(), json!(messages));\n        }\n    } else if let Some(prompt_val) = body.get(\"prompt\") {\n        // Legacy OpenAI Style: prompt -> Chat\n        let prompt_str = match prompt_val {\n            Value::String(s) => s.clone(),\n            Value::Array(arr) => arr\n                .iter()\n                .filter_map(|v| v.as_str())\n                .collect::<Vec<_>>()\n                .join(\"\\n\"),\n            _ => prompt_val.to_string(),\n        };\n        let messages = json!([ { \"role\": \"user\", \"content\": prompt_str } ]);\n        if let Some(obj) = body.as_object_mut() {\n            obj.remove(\"prompt\");\n            obj.insert(\"messages\".to_string(), messages);\n        }\n    }\n\n    // 2. Reuse handle_chat_completions logic (wrapping with custom handler or direct call)\n    // Actually, due to SSE handling differences (Codex uses different event format), we replicate the loop here or abstract it.\n    // For now, let's replicate the core loop but with Codex specific SSE mapping.\n\n    // [Fix Phase 2] Backport normalization logic from handle_chat_completions\n    // Handle \"instructions\" + \"input\" (Codex style) -> system + user messages\n    // This is critical because `transform_openai_request` expects `messages` to be populated.\n\n    // [FIX] 检查是否已经有 messages (被第一次标准化处理过)\n    let has_codex_fields = body.get(\"instructions\").is_some() || body.get(\"input\").is_some();\n    let already_normalized = body\n        .get(\"messages\")\n        .and_then(|m| m.as_array())\n        .map(|arr| !arr.is_empty())\n        .unwrap_or(false);\n\n    // 只有在未标准化时才进行简单转换\n    if has_codex_fields && !already_normalized {\n        tracing::debug!(\"[Codex] Performing simple normalization (messages not yet populated)\");\n\n        let mut messages = Vec::new();\n\n        // instructions -> system message\n        if let Some(inst) = body.get(\"instructions\").and_then(|v| v.as_str()) {\n            if !inst.is_empty() {\n                messages.push(json!({\n                    \"role\": \"system\",\n                    \"content\": inst\n                }));\n            }\n        }\n\n        // input -> user message (支持对象数组形式的对话历史)\n        if let Some(input) = body.get(\"input\") {\n            if let Some(s) = input.as_str() {\n                messages.push(json!({\n                    \"role\": \"user\",\n                    \"content\": s\n                }));\n            } else if let Some(arr) = input.as_array() {\n                // 判断是消息对象数组还是简单的内容块/字符串数组\n                let is_message_array = arr\n                    .first()\n                    .and_then(|v| v.as_object())\n                    .map(|obj| obj.contains_key(\"role\"))\n                    .unwrap_or(false);\n\n                if is_message_array {\n                    // 深度识别：像处理 messages 一样处理 input 数组\n                    for item in arr {\n                        messages.push(item.clone());\n                    }\n                } else {\n                    // 降级处理：传统的字符串或混合内容拼接\n                    let content = arr\n                        .iter()\n                        .map(|v| {\n                            if let Some(s) = v.as_str() {\n                                s.to_string()\n                            } else if v.is_object() {\n                                v.to_string()\n                            } else {\n                                \"\".to_string()\n                            }\n                        })\n                        .collect::<Vec<_>>()\n                        .join(\"\\n\");\n\n                    if !content.is_empty() {\n                        messages.push(json!({\n                            \"role\": \"user\",\n                            \"content\": content\n                        }));\n                    }\n                }\n            } else {\n                let content = input.to_string();\n                if !content.is_empty() {\n                    messages.push(json!({\n                        \"role\": \"user\",\n                        \"content\": content\n                    }));\n                }\n            };\n        }\n\n        if let Some(obj) = body.as_object_mut() {\n            tracing::debug!(\n                \"[Codex] Injecting normalized messages: {} messages\",\n                messages.len()\n            );\n            obj.insert(\"messages\".to_string(), json!(messages));\n        }\n    } else if already_normalized {\n        tracing::debug!(\n            \"[Codex] Skipping normalization (messages already populated by first pass)\"\n        );\n    }\n\n    let mut openai_req: OpenAIRequest = match serde_json::from_value(body.clone()) {\n        Ok(req) => req,\n        Err(e) => {\n            return (StatusCode::BAD_REQUEST, format!(\"Invalid request: {}\", e)).into_response();\n        }\n    };\n\n    // Safety: Inject empty message if needed\n    if openai_req.messages.is_empty() {\n        openai_req\n            .messages\n            .push(crate::proxy::mappers::openai::OpenAIMessage {\n                role: \"user\".to_string(),\n                content: Some(crate::proxy::mappers::openai::OpenAIContent::String(\n                    \" \".to_string(),\n                )),\n                reasoning_content: None,\n                tool_calls: None,\n                tool_call_id: None,\n                name: None,\n            });\n    }\n\n    let upstream = state.upstream.clone();\n    let token_manager = state.token_manager;\n    let pool_size = token_manager.len();\n    // [FIX] Ensure max_attempts is at least 2 to allow for internal retries\n    let max_attempts = MAX_RETRY_ATTEMPTS.min(pool_size.saturating_add(1)).max(2);\n\n    let mut last_error = String::new();\n    let mut last_email: Option<String> = None;\n\n    // 2. 模型路由解析 (移到循环外以支持在所有路径返回 X-Mapped-Model)\n    let mapped_model = crate::proxy::common::model_mapping::resolve_model_route(\n        &openai_req.model,\n        &*state.custom_mapping.read().await,\n    );\n    let trace_id = format!(\"req_{}\", chrono::Utc::now().timestamp_subsec_millis());\n\n    for attempt in 0..max_attempts {\n        // 3. 模型配置解析\n        // 将 OpenAI 工具转为 Value 数组以便探测联网\n        let tools_val: Option<Vec<Value>> = openai_req\n            .tools\n            .as_ref()\n            .map(|list| list.iter().cloned().collect());\n        let config = crate::proxy::mappers::common_utils::resolve_request_config(\n            &openai_req.model,\n            &mapped_model,\n            &tools_val,\n            None, // size\n            None, // quality\n            None, // image_size\n            None, // body\n        );\n\n        // 3. 提取 SessionId (复用)\n        // [New] 使用 TokenManager 内部逻辑提取 session_id，支持粘性调度\n        let session_id_str = SessionManager::extract_openai_session_id(&openai_req);\n        let session_id = Some(session_id_str.as_str());\n\n        // 重试时强制轮换，除非只是简单的网络抖动但 Claude 逻辑里 attempt > 0 总是 force_rotate\n        let force_rotate = attempt > 0;\n\n        let (access_token, project_id, email, account_id, _wait_ms) = match token_manager\n            .get_token(\n                &config.request_type,\n                force_rotate,\n                session_id,\n                &mapped_model,\n            )\n            .await\n        {\n            Ok(t) => t,\n            Err(e) => {\n                return (\n                    StatusCode::SERVICE_UNAVAILABLE,\n                    [(\"X-Mapped-Model\", mapped_model)],\n                    format!(\"Token error: {}\", e),\n                )\n                    .into_response()\n            }\n        };\n\n        let mapped_model = token_manager\n            .resolve_dynamic_model_for_account(&account_id, &mapped_model)\n            .await;\n\n        last_email = Some(email.clone());\n\n        info!(\"✓ Using account: {} (type: {})\", email, config.request_type);\n\n        let proxy_token = token_manager.get_token_by_id(&account_id);\n        let (gemini_body, session_id, message_count) =\n            transform_openai_request(&openai_req, &project_id, &mapped_model, proxy_token.as_ref());\n\n        // [New] 打印转换后的报文 (Gemini Body) 供调试 (Codex 路径) ———— 缩减为 simple debug\n        debug!(\n            \"[Codex-Request] Transformed Gemini Body ({} parts)\",\n            gemini_body\n                .get(\"contents\")\n                .and_then(|c| c.as_array())\n                .map(|a| a.len())\n                .unwrap_or(0)\n        );\n\n        // [AUTO-CONVERSION] For Legacy/Codex as well\n        let client_wants_stream = openai_req.stream;\n        let force_stream_internally = !client_wants_stream;\n        let list_response = client_wants_stream || force_stream_internally;\n        let method = if list_response {\n            \"streamGenerateContent\"\n        } else {\n            \"generateContent\"\n        };\n        let query_string = if list_response { Some(\"alt=sse\") } else { None };\n\n        let call_result = match upstream\n            .call_v1_internal(\n                method,\n                &access_token,\n                gemini_body,\n                query_string,\n                Some(account_id.as_str()),\n            )\n            .await\n        {\n            Ok(r) => r,\n            Err(e) => {\n                last_error = e.clone();\n                debug!(\n                    \"Codex Request failed on attempt {}/{}: {}\",\n                    attempt + 1,\n                    max_attempts,\n                    e\n                );\n                continue;\n            }\n        };\n\n        let response = call_result.response;\n        let status = response.status();\n        if status.is_success() {\n            // [智能限流] 请求成功，重置该账号的连续失败计数\n            token_manager.mark_account_success(&email);\n\n            if list_response {\n                use axum::body::Body;\n                use axum::response::Response;\n                use futures::StreamExt;\n\n                let gemini_stream = response.bytes_stream();\n\n                // DECISION: Which stream to create?\n                // If client wants stream: give them what they asked (Legacy/Codex SSE).\n                // If forced stream: use Chat SSE + Collector, because our collector works on Chat format\n                // and we already have logic to convert Chat JSON -> Legacy JSON.\n\n                if client_wants_stream {\n                    let mut openai_stream = if is_codex_style {\n                        use crate::proxy::mappers::openai::streaming::create_codex_sse_stream;\n                        create_codex_sse_stream(\n                            Box::pin(gemini_stream),\n                            openai_req.model.clone(),\n                            session_id,\n                            message_count,\n                        )\n                    } else {\n                        use crate::proxy::mappers::openai::streaming::create_legacy_sse_stream;\n                        create_legacy_sse_stream(\n                            Box::pin(gemini_stream),\n                            openai_req.model.clone(),\n                            session_id,\n                            message_count,\n                        )\n                    };\n\n                    // [P1 FIX] Enhanced Peek logic (Reused from above/standard)\n                    let mut first_data_chunk = None;\n                    let mut retry_this_account = false;\n\n                    loop {\n                        match tokio::time::timeout(\n                            std::time::Duration::from_secs(60),\n                            openai_stream.next(),\n                        )\n                        .await\n                        {\n                            Ok(Some(Ok(bytes))) => {\n                                if bytes.is_empty() {\n                                    continue;\n                                }\n                                let text = String::from_utf8_lossy(&bytes);\n                                if text.trim().starts_with(\":\")\n                                    || text.trim().starts_with(\"data: :\")\n                                {\n                                    continue;\n                                }\n                                if text.contains(\"\\\"error\\\"\") {\n                                    last_error = \"Error event during peek\".to_string();\n                                    retry_this_account = true;\n                                    break;\n                                }\n                                first_data_chunk = Some(bytes);\n                                break;\n                            }\n                            Ok(Some(Err(e))) => {\n                                last_error = format!(\"Stream error during peek: {}\", e);\n                                retry_this_account = true;\n                                break;\n                            }\n                            Ok(None) => {\n                                last_error = \"Empty response stream\".to_string();\n                                retry_this_account = true;\n                                break;\n                            }\n                            Err(_) => {\n                                last_error = \"Timeout waiting for first data\".to_string();\n                                retry_this_account = true;\n                                break;\n                            }\n                        }\n                    }\n\n                    if retry_this_account {\n                        continue;\n                    }\n\n                    let combined_stream = futures::stream::once(async move {\n                        Ok::<Bytes, String>(first_data_chunk.unwrap())\n                    })\n                    .chain(openai_stream);\n\n                    return Response::builder()\n                        .header(\"Content-Type\", \"text/event-stream\")\n                        .header(\"Cache-Control\", \"no-cache\")\n                        .header(\"Connection\", \"keep-alive\")\n                        .header(\"X-Account-Email\", &email)\n                        .header(\"X-Mapped-Model\", &mapped_model)\n                        .body(Body::from_stream(combined_stream))\n                        .unwrap()\n                        .into_response();\n                } else {\n                    // Forced Stream Internal -> Convert to Legacy JSON\n                    // Use CHAT SSE Stream (so Collector can parse it)\n                    use crate::proxy::mappers::openai::streaming::create_openai_sse_stream;\n                    // Note: We use create_openai_sse_stream regardless of is_codex_style here,\n                    // because we just want the content aggregation which chat stream does well.\n                    let mut openai_stream = create_openai_sse_stream(\n                        Box::pin(gemini_stream),\n                        openai_req.model.clone(),\n                        session_id,\n                        message_count,\n                    );\n\n                    // Peek Logic (Repeated for safety/correctness on this stream type)\n                    let mut first_data_chunk = None;\n                    let mut retry_this_account = false;\n                    loop {\n                        match tokio::time::timeout(\n                            std::time::Duration::from_secs(60),\n                            openai_stream.next(),\n                        )\n                        .await\n                        {\n                            Ok(Some(Ok(bytes))) => {\n                                if bytes.is_empty() {\n                                    continue;\n                                }\n                                let text = String::from_utf8_lossy(&bytes);\n                                if text.trim().starts_with(\":\")\n                                    || text.trim().starts_with(\"data: :\")\n                                {\n                                    continue;\n                                }\n                                if text.contains(\"\\\"error\\\"\") {\n                                    last_error = \"Error event in internal stream\".to_string();\n                                    retry_this_account = true;\n                                    break;\n                                }\n                                first_data_chunk = Some(bytes);\n                                break;\n                            }\n                            Ok(Some(Err(e))) => {\n                                last_error = format!(\"Internal stream error: {}\", e);\n                                retry_this_account = true;\n                                break;\n                            }\n                            Ok(None) => {\n                                last_error = \"Empty internal stream\".to_string();\n                                retry_this_account = true;\n                                break;\n                            }\n                            Err(_) => {\n                                last_error = \"Timeout peek internal\".to_string();\n                                retry_this_account = true;\n                                break;\n                            }\n                        }\n                    }\n                    if retry_this_account {\n                        continue;\n                    }\n\n                    let combined_stream = futures::stream::once(async move {\n                        Ok::<Bytes, String>(first_data_chunk.unwrap())\n                    })\n                    .chain(openai_stream);\n\n                    // Collect\n                    use crate::proxy::mappers::openai::collector::collect_stream_to_json;\n                    match collect_stream_to_json(Box::pin(combined_stream)).await {\n                        Ok(chat_resp) => {\n                            // NOW: Convert Chat Response -> Legacy Response (Same logic as below)\n                            let choices = chat_resp.choices.iter().map(|c| {\n                                json!({\n                                    \"text\": match &c.message.content {\n                                        Some(crate::proxy::mappers::openai::OpenAIContent::String(s)) => s.clone(),\n                                        _ => \"\".to_string()\n                                    },\n                                    \"index\": c.index,\n                                    \"logprobs\": null,\n                                    \"finish_reason\": c.finish_reason\n                                })\n                            }).collect::<Vec<_>>();\n\n                            let legacy_resp = json!({\n                                \"id\": chat_resp.id,\n                                \"object\": \"text_completion\",\n                                \"created\": chat_resp.created,\n                                \"model\": chat_resp.model,\n                                \"choices\": choices,\n                                \"usage\": chat_resp.usage\n                            });\n\n                            return (\n                                StatusCode::OK,\n                                [\n                                    (\"X-Account-Email\", email.as_str()),\n                                    (\"X-Mapped-Model\", mapped_model.as_str()),\n                                ],\n                                Json(legacy_resp),\n                            )\n                                .into_response();\n                        }\n                        Err(e) => {\n                            return (\n                                StatusCode::INTERNAL_SERVER_ERROR,\n                                format!(\"Stream collection error: {}\", e),\n                            )\n                                .into_response();\n                        }\n                    }\n                }\n            }\n\n            let gemini_resp: Value = match response.json().await {\n                Ok(json) => json,\n                Err(e) => {\n                    return (\n                        StatusCode::BAD_GATEWAY,\n                        [(\"X-Mapped-Model\", mapped_model.as_str())],\n                        format!(\"Parse error: {}\", e),\n                    )\n                        .into_response();\n                }\n            };\n\n            let chat_resp = transform_openai_response(&gemini_resp, Some(\"session-123\"), 1);\n\n            // Map Chat Response -> Legacy Completions Response\n            let choices = chat_resp.choices.iter().map(|c| {\n                json!({\n                    \"text\": match &c.message.content {\n                        Some(crate::proxy::mappers::openai::OpenAIContent::String(s)) => s.clone(),\n                        _ => \"\".to_string()\n                    },\n                    \"index\": c.index,\n                    \"logprobs\": null,\n                    \"finish_reason\": c.finish_reason\n                })\n            }).collect::<Vec<_>>();\n\n            let legacy_resp = json!({\n                \"id\": chat_resp.id,\n                \"object\": \"text_completion\",\n                \"created\": chat_resp.created,\n                \"model\": chat_resp.model,\n                \"choices\": choices,\n                \"usage\": chat_resp.usage\n            });\n\n            return (\n                StatusCode::OK,\n                [\n                    (\"X-Account-Email\", email.as_str()),\n                    (\"X-Mapped-Model\", mapped_model.as_str()),\n                ],\n                Json(legacy_resp),\n            )\n                .into_response();\n        }\n\n        // Handle errors and retry\n        let status_code = status.as_u16();\n        let retry_after = response\n            .headers()\n            .get(\"Retry-After\")\n            .and_then(|h| h.to_str().ok())\n            .map(|s| s.to_string());\n        let error_text = response\n            .text()\n            .await\n            .unwrap_or_else(|_| format!(\"HTTP {}\", status_code));\n        last_error = format!(\"HTTP {}: {}\", status_code, error_text);\n\n        tracing::error!(\n            \"[Codex-Upstream] Error Response {}: {}\",\n            status_code,\n            error_text\n        );\n\n        // 3. 标记限流状态(用于 UI 显示)\n        if status_code == 429 || status_code == 529 || status_code == 503 || status_code == 500 {\n            token_manager\n                .mark_rate_limited_async(\n                    &email,\n                    status_code,\n                    retry_after.as_deref(),\n                    &error_text,\n                    Some(&mapped_model),\n                )\n                .await;\n        }\n\n        // 确定重试策略\n        let strategy = determine_retry_strategy(status_code, &error_text, false);\n\n        if apply_retry_strategy(strategy, attempt, max_attempts, status_code, &trace_id).await {\n            // 继续重试 (loop 会增加 attempt, 导致 force_rotate=true)\n            continue;\n        } else {\n            // 不可重试\n            return (\n                status,\n                [\n                    (\"X-Account-Email\", email.as_str()),\n                    (\"X-Mapped-Model\", mapped_model.as_str()),\n                ],\n                error_text,\n            )\n                .into_response();\n        }\n    }\n\n    // 所有尝试均失败\n    if let Some(email) = last_email {\n        (\n            StatusCode::TOO_MANY_REQUESTS,\n            [(\"X-Account-Email\", email), (\"X-Mapped-Model\", mapped_model)],\n            format!(\"All accounts exhausted. Last error: {}\", last_error),\n        )\n            .into_response()\n    } else {\n        (\n            StatusCode::TOO_MANY_REQUESTS,\n            [(\"X-Mapped-Model\", mapped_model)],\n            format!(\"All accounts exhausted. Last error: {}\", last_error),\n        )\n            .into_response()\n    }\n}\n\npub async fn handle_list_models(State(state): State<AppState>) -> impl IntoResponse {\n    use crate::proxy::common::model_mapping::get_all_dynamic_models;\n\n    let model_ids = get_all_dynamic_models(&state.custom_mapping, Some(&state.token_manager)).await;\n\n    let data: Vec<_> = model_ids\n        .into_iter()\n        .map(|id| {\n            json!({\n                \"id\": id,\n                \"object\": \"model\",\n                \"created\": 1706745600,\n                \"owned_by\": \"antigravity\"\n            })\n        })\n        .collect();\n\n    Json(json!({\n        \"object\": \"list\",\n        \"data\": data\n    }))\n}\n\n/// OpenAI Images API: POST /v1/images/generations\n/// 处理图像生成请求，转换为 Gemini API 格式\npub async fn handle_chat_redirection(\n    State(state): State<AppState>,\n    headers: HeaderMap,\n    Json(body): Json<Value>,\n) -> Result<impl IntoResponse, (StatusCode, String)> {\n    handle_chat_completions(State(state), headers, Json(body)).await\n}\n\nasync fn intercept_chat_to_image(\n    state: AppState,\n    body: Value,\n    model_name: &str,\n) -> Result<Response, (StatusCode, String)> {\n    // 1. Extract prompt from messages\n    let mut prompt = String::new();\n    if let Some(messages) = body.get(\"messages\").and_then(|v| v.as_array()) {\n        for msg in messages {\n            if msg.get(\"role\").and_then(|v| v.as_str()) == Some(\"user\") {\n                if let Some(content) = msg.get(\"content\") {\n                    if let Some(s) = content.as_str() {\n                        prompt = s.to_string();\n                    } else if let Some(arr) = content.as_array() {\n                        for part in arr {\n                            if part.get(\"type\").and_then(|v| v.as_str()) == Some(\"text\") {\n                                prompt.push_str(part.get(\"text\").and_then(|v| v.as_str()).unwrap_or(\"\"));\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    if prompt.is_empty() {\n        prompt = \"A beautiful painting\".to_string(); // fallback\n    }\n\n    let is_stream = body.get(\"stream\").and_then(|v| v.as_bool()).unwrap_or(false);\n\n    // 2. Call internal image generator\n    let img_req = json!({\n        \"prompt\": prompt,\n        \"model\": model_name,\n        \"n\": 1,\n        \"response_format\": \"url\"\n    });\n\n    match handle_images_generations_internal(state, img_req).await {\n        Ok((email, img_res)) => {\n            // Extract URL\n            let mut img_markdown = String::new();\n            if let Some(data) = img_res.get(\"data\").and_then(|v| v.as_array()) {\n                for item in data {\n                    if let Some(url) = item.get(\"url\").and_then(|v| v.as_str()) {\n                        img_markdown.push_str(&format!(\"![Generated Image]({})\\n\\n\", url));\n                    }\n                }\n            }\n\n            if img_markdown.is_empty() {\n                img_markdown = \"Failed to extract image URL from generation result.\".to_string();\n            }\n\n            // 3. Construct Chat Completion Response\n            if is_stream {\n                use axum::body::Body;\n                \n                let chunk = json!({\n                    \"id\": format!(\"chatcmpl-img-{}\", uuid::Uuid::new_v4()),\n                    \"object\": \"chat.completion.chunk\",\n                    \"created\": chrono::Utc::now().timestamp(),\n                    \"model\": model_name,\n                    \"choices\": [{\n                        \"index\": 0,\n                        \"delta\": {\n                            \"role\": \"assistant\",\n                            \"content\": img_markdown\n                        },\n                        \"finish_reason\": null\n                    }]\n                });\n                \n                let done_chunk = json!({\n                    \"id\": format!(\"chatcmpl-img-{}\", uuid::Uuid::new_v4()),\n                    \"object\": \"chat.completion.chunk\",\n                    \"created\": chrono::Utc::now().timestamp(),\n                    \"model\": model_name,\n                    \"choices\": [{\n                        \"index\": 0,\n                        \"delta\": {},\n                        \"finish_reason\": \"stop\"\n                    }]\n                });\n\n                let sse_data = format!(\"data: {}\\n\\ndata: {}\\n\\ndata: [DONE]\\n\\n\", chunk.to_string(), done_chunk.to_string());\n                \n                let body = Body::from(sse_data);\n                Ok(Response::builder()\n                    .header(\"Content-Type\", \"text/event-stream\")\n                    .header(\"Cache-Control\", \"no-cache\")\n                    .header(\"X-Account-Email\", email)\n                    .body(body)\n                    .unwrap())\n            } else {\n                let resp = json!({\n                    \"id\": format!(\"chatcmpl-img-{}\", uuid::Uuid::new_v4()),\n                    \"object\": \"chat.completion\",\n                    \"created\": chrono::Utc::now().timestamp(),\n                    \"model\": model_name,\n                    \"choices\": [{\n                        \"index\": 0,\n                        \"message\": {\n                            \"role\": \"assistant\",\n                            \"content\": img_markdown\n                        },\n                        \"finish_reason\": \"stop\"\n                    }],\n                    \"usage\": { \"prompt_tokens\": 0, \"completion_tokens\": 0, \"total_tokens\": 0 }\n                });\n\n                Ok((\n                    StatusCode::OK,\n                    [\n                        (\"X-Account-Email\", email.as_str()),\n                    ],\n                    Json(resp)\n                ).into_response())\n            }\n        },\n        Err(e) => Err(e.into()) // using Err directly is fine since return type handles it\n    }\n}\n\npub async fn handle_images_generations(\n    State(state): State<AppState>,\n    Json(body): Json<Value>,\n) -> Result<impl IntoResponse, (StatusCode, String)> {\n    match handle_images_generations_internal(state, body).await {\n        Ok((email_header, openai_response)) => Ok((\n            StatusCode::OK,\n            [\n                (\"X-Mapped-Model\", \"dall-e-3\"),\n                (\"X-Account-Email\", email_header.as_str()),\n            ],\n            Json(openai_response),\n        )\n            .into_response()),\n        Err(e) => Err(e),\n    }\n}\n\npub async fn handle_images_generations_internal(\n    state: AppState,\n    body: Value,\n) -> Result<(String, Value), (StatusCode, String)> {\n    // 1. 解析请求参数\n    let prompt = body.get(\"prompt\").and_then(|v| v.as_str()).ok_or((\n        StatusCode::BAD_REQUEST,\n        \"Missing 'prompt' field\".to_string(),\n    ))?;\n\n    let model = body\n        .get(\"model\")\n        .and_then(|v| v.as_str())\n        .unwrap_or(\"gemini-3-pro-image\");\n\n    let n = body.get(\"n\").and_then(|v| v.as_u64()).unwrap_or(1) as usize;\n\n    let size = body\n        .get(\"size\")\n        .and_then(|v| v.as_str());\n\n    let response_format = body\n        .get(\"response_format\")\n        .and_then(|v| v.as_str())\n        .unwrap_or(\"b64_json\");\n\n    let quality = body\n        .get(\"quality\")\n        .and_then(|v| v.as_str());\n\n    let image_size = body\n        .get(\"image_size\")\n        .or(body.get(\"imageSize\"))\n        .and_then(|v| v.as_str());\n\n    let style = body\n        .get(\"style\")\n        .and_then(|v| v.as_str())\n        .unwrap_or(\"vivid\");\n\n    info!(\n        \"[Images] Received request: model={}, prompt={:.50}..., n={}, size={}, quality={}, style={}\",\n        model,\n        prompt,\n        n,\n        size.unwrap_or(\"auto\"),\n        quality.unwrap_or(\"auto\"),\n        style\n    );\n\n    // 2. 使用 common_utils 解析图片配置（统一逻辑，支持动态计算宽高比和 quality 映射）\n    let (image_config, clean_model_name) = crate::proxy::mappers::common_utils::parse_image_config_with_params(\n        model,\n        size,\n        quality,\n        image_size,\n    );\n\n    // 3. Prompt Enhancement（保留原有逻辑）\n    let mut final_prompt = prompt.to_string();\n    if quality == Some(\"hd\") {\n        final_prompt.push_str(\", (high quality, highly detailed, 4k resolution, hdr)\");\n    }\n    match style {\n        \"vivid\" => final_prompt.push_str(\", (vivid colors, dramatic lighting, rich details)\"),\n        \"natural\" => final_prompt.push_str(\", (natural lighting, realistic, photorealistic)\"),\n        _ => {}\n    }\n\n    // 4. 并发发送请求\n    // 注意：不再在外部获取 Token，而是移入 Task 内部并在重试时获取\n    let upstream = state.upstream.clone();\n    let token_manager = state.token_manager.clone();\n    let max_pool_size = token_manager.len();\n    let max_attempts = MAX_RETRY_ATTEMPTS\n        .min(max_pool_size.saturating_add(1))\n        .max(2);\n\n    let mut tasks = Vec::new();\n\n    for _ in 0..n {\n        let upstream = upstream.clone();\n        let token_manager = token_manager.clone();\n        let final_prompt = final_prompt.clone();\n        let image_config = image_config.clone(); // 使用解析后的完整配置\n        let _response_format = response_format.to_string();\n\n        let model_to_use = clean_model_name.clone();\n\n        tasks.push(tokio::spawn(async move {\n            let mut last_error = String::new();\n\n            for attempt in 0..max_attempts {\n                // 4.1 获取 Token\n                let (access_token, project_id, email, account_id, _wait_ms) = match token_manager\n                    .get_token(\"image_gen\", attempt > 0, None, &model_to_use)\n                    .await\n                {\n                    Ok(t) => t,\n                    Err(e) => {\n                        last_error = format!(\"Token error: {}\", e);\n                        if attempt < max_attempts - 1 {\n                            tokio::time::sleep(Duration::from_millis(500)).await;\n                            continue;\n                        }\n                        break;\n                    }\n                };\n\n                let gemini_body = json!({\n                    \"project\": project_id,\n                    \"requestId\": format!(\"agent-{}\", uuid::Uuid::new_v4()),\n                    \"model\": model_to_use,\n                    \"userAgent\": \"antigravity\",\n                    \"requestType\": \"image_gen\",\n                    \"request\": {\n                        \"contents\": [{\n                            \"role\": \"user\",\n                            \"parts\": [{\"text\": final_prompt}]\n                        }],\n                        \"generationConfig\": {\n                            \"candidateCount\": 1, // 强制单张\n                            \"imageConfig\": image_config // ✅ 使用完整配置（包含 aspectRatio 和 imageSize）\n                        },\n                        \"safetySettings\": [\n                            { \"category\": \"HARM_CATEGORY_HARASSMENT\", \"threshold\": \"OFF\" },\n                            { \"category\": \"HARM_CATEGORY_HATE_SPEECH\", \"threshold\": \"OFF\" },\n                            { \"category\": \"HARM_CATEGORY_SEXUALLY_EXPLICIT\", \"threshold\": \"OFF\" },\n                            { \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\", \"threshold\": \"OFF\" },\n                            { \"category\": \"HARM_CATEGORY_CIVIC_INTEGRITY\", \"threshold\": \"OFF\" },\n                        ]\n                    }\n                });\n\n                match upstream\n                    .call_v1_internal(\n                        \"generateContent\",\n                        &access_token,\n                        gemini_body,\n                        None,\n                        Some(account_id.as_str()),\n                    )\n                    .await\n                {\n                    Ok(call_result) => {\n                        let response = call_result.response;\n                        let status = response.status();\n                        if !status.is_success() {\n                            let err_text = response.text().await.unwrap_or_default();\n                            let status_code = status.as_u16();\n                            last_error = format!(\"Upstream error {}: {}\", status, err_text);\n\n                            // 429/500/503 等错误进行标记和重试\n                            if status_code == 429 || status_code == 503 || status_code == 500 {\n                                tracing::warn!(\n                                    \"[Images] Account {} rate limited/error ({}), rotating...\",\n                                    email,\n                                    status_code\n                                );\n                                token_manager\n                                    .mark_rate_limited_async(\n                                        &email,\n                                        status_code,\n                                        None,\n                                        &err_text,\n                                        Some(\"dall-e-3\"),\n                                    )\n                                    .await;\n                                continue; // Retry loop\n                            }\n\n                            // 其他错误直接返回\n                            return Err(last_error);\n                        }\n                        match response.json::<Value>().await {\n                            Ok(json) => return Ok((json, email)),\n                            Err(e) => return Err(format!(\"Parse error: {}\", e)),\n                        }\n                    }\n                    Err(e) => {\n                        last_error = format!(\"Network error: {}\", e);\n                        continue;\n                    }\n                }\n            }\n\n            // All attempts failed\n            Err(format!(\"Max retries exhausted. Last error: {}\", last_error))\n        }));\n    }\n\n    // 5. 收集结果\n    let mut images: Vec<Value> = Vec::new();\n    let mut errors: Vec<String> = Vec::new();\n    let mut used_email: Option<String> = None;\n\n    for (idx, task) in tasks.into_iter().enumerate() {\n        match task.await {\n            Ok(result) => match result {\n                Ok((gemini_resp, email_used)) => {\n                    // Capture the email from the first successful task for logging\n                    if used_email.is_none() {\n                        used_email = Some(email_used);\n                    }\n                    let raw = gemini_resp.get(\"response\").unwrap_or(&gemini_resp);\n                    if let Some(parts) = raw\n                        .get(\"candidates\")\n                        .and_then(|c| c.get(0))\n                        .and_then(|cand| cand.get(\"content\"))\n                        .and_then(|content| content.get(\"parts\"))\n                        .and_then(|p| p.as_array())\n                    {\n                        for part in parts {\n                            if let Some(img) = part.get(\"inlineData\") {\n                                let data = img.get(\"data\").and_then(|v| v.as_str()).unwrap_or(\"\");\n                                if !data.is_empty() {\n                                    if response_format == \"url\" {\n                                        let mime_type = img\n                                            .get(\"mimeType\")\n                                            .and_then(|v| v.as_str())\n                                            .unwrap_or(\"image/png\");\n                                        images.push(json!({\n                                            \"url\": format!(\"data:{};base64,{}\", mime_type, data)\n                                        }));\n                                    } else {\n                                        images.push(json!({\n                                            \"b64_json\": data\n                                        }));\n                                    }\n                                    tracing::debug!(\"[Images] Task {} succeeded\", idx);\n                                }\n                            }\n                        }\n                    }\n                }\n                Err(e) => {\n                    tracing::error!(\"[Images] Task {} failed: {}\", idx, e);\n                    errors.push(e);\n                }\n            },\n            Err(e) => {\n                let err_msg = format!(\"Task join error: {}\", e);\n                tracing::error!(\"[Images] Task {} join error: {}\", idx, e);\n                errors.push(err_msg);\n            }\n        }\n    }\n\n    if images.is_empty() {\n        let error_msg = if !errors.is_empty() {\n            errors.join(\"; \")\n        } else {\n            \"No images generated\".to_string()\n        };\n        tracing::error!(\"[Images] All {} requests failed. Errors: {}\", n, error_msg);\n\n        // [FIX] Map upstream status codes correctly instead of forcing 502\n        let status = if error_msg.contains(\"429\") || error_msg.contains(\"Quota exhausted\") {\n            StatusCode::TOO_MANY_REQUESTS\n        } else if error_msg.contains(\"503\") || error_msg.contains(\"Service Unavailable\") {\n            StatusCode::SERVICE_UNAVAILABLE\n        } else {\n            StatusCode::BAD_GATEWAY\n        };\n\n        return Err((status, error_msg));\n    }\n\n    // 部分成功时记录警告\n    if !errors.is_empty() {\n        tracing::warn!(\n            \"[Images] Partial success: {} out of {} requests succeeded. Errors: {}\",\n            images.len(),\n            n,\n            errors.join(\"; \")\n        );\n    }\n\n    tracing::info!(\n        \"[Images] Successfully generated {} out of {} requested image(s)\",\n        images.len(),\n        n\n    );\n\n    // 6. 构建 OpenAI 格式响应\n    let openai_response = json!({\n        \"created\": chrono::Utc::now().timestamp(),\n        \"data\": images\n    });\n\n    // [FIX] 图像生成成功后触发配额刷新 (Issue #1995)\n    tokio::spawn(async move {\n        let _ = account::refresh_all_quotas_logic().await;\n    });\n\n    let email_header = used_email.unwrap_or_default();\n    Ok((email_header, openai_response))\n}\n\npub async fn handle_images_edits(\n    State(state): State<AppState>,\n    mut multipart: axum::extract::Multipart,\n) -> Result<impl IntoResponse, (StatusCode, String)> {\n    tracing::info!(\"[Images] Received edit request\");\n\n    let mut image_data = None;\n    let mut mask_data = None;\n    let mut reference_images: Vec<String> = Vec::new(); // Store base64 data of reference images\n    let mut prompt = String::new();\n    let mut n = 1;\n    let mut size = \"1024x1024\".to_string();\n    let mut response_format = \"b64_json\".to_string();\n    let mut model = \"gemini-3-pro-image\".to_string();\n    let mut aspect_ratio: Option<String> = None;\n    let mut image_size_param: Option<String> = None;\n    let mut style: Option<String> = None;\n\n    while let Some(field) = multipart\n        .next_field()\n        .await\n        .map_err(|e| (StatusCode::BAD_REQUEST, format!(\"Multipart error: {}\", e)))?\n    {\n        let name = field.name().unwrap_or(\"\").to_string();\n\n        if name == \"image\" {\n            let data = field\n                .bytes()\n                .await\n                .map_err(|e| (StatusCode::BAD_REQUEST, format!(\"Image read error: {}\", e)))?;\n            image_data = Some(base64::engine::general_purpose::STANDARD.encode(data));\n        } else if name == \"mask\" {\n            let data = field\n                .bytes()\n                .await\n                .map_err(|e| (StatusCode::BAD_REQUEST, format!(\"Mask read error: {}\", e)))?;\n            mask_data = Some(base64::engine::general_purpose::STANDARD.encode(data));\n        } else if name.starts_with(\"image\") && name != \"image_size\" {\n            // Support image1, image2, etc.\n            let data = field.bytes().await.map_err(|e| {\n                (\n                    StatusCode::BAD_REQUEST,\n                    format!(\"Reference image read error: {}\", e),\n                )\n            })?;\n            reference_images.push(base64::engine::general_purpose::STANDARD.encode(data));\n        } else if name == \"prompt\" {\n            prompt = field\n                .text()\n                .await\n                .map_err(|e| (StatusCode::BAD_REQUEST, format!(\"Prompt read error: {}\", e)))?;\n        } else if name == \"n\" {\n            if let Ok(val) = field.text().await {\n                n = val.parse().unwrap_or(1);\n            }\n        } else if name == \"size\" {\n            if let Ok(val) = field.text().await {\n                size = val;\n            }\n        } else if name == \"image_size\" {\n            if let Ok(val) = field.text().await {\n                image_size_param = Some(val);\n            }\n        } else if name == \"aspect_ratio\" {\n            if let Ok(val) = field.text().await {\n                aspect_ratio = Some(val);\n            }\n        } else if name == \"style\" {\n            if let Ok(val) = field.text().await {\n                style = Some(val);\n            }\n        } else if name == \"response_format\" {\n            if let Ok(val) = field.text().await {\n                response_format = val;\n            }\n        } else if name == \"model\" {\n            if let Ok(val) = field.text().await {\n                if !val.is_empty() {\n                    model = val;\n                }\n            }\n        }\n    }\n\n    // Validation: Require either 'image' (standard edit) OR 'prompt' (generation)\n    // If reference images are present, we treat it as generation with image context\n    if prompt.is_empty() {\n        return Err((StatusCode::BAD_REQUEST, \"Missing prompt\".to_string()));\n    }\n\n    tracing::info!(\n        \"[Images] Edit/Ref Request: model={}, prompt={}, n={}, size={}, aspect_ratio={:?}, image_size={:?}, style={:?}, refs={}, has_main_image={}\",\n        model,\n        prompt,\n        n,\n        size,\n        aspect_ratio,\n        image_size_param,\n        style,\n        reference_images.len(),\n        image_data.is_some()\n    );\n\n    // 2. Prepare Config (Aspect Ratio / Size)\n    // Priority: aspect_ratio param > size param\n    // Priority: image_size param > quality param (derived from model suffix or default)\n\n    // We reuse parse_image_config_with_params but need to adapt the inputs\n    let size_input = aspect_ratio.as_deref().or(Some(&size)); // If aspect_ratio is \"16:9\", it works. If it's just \"1:1\", it also works.\n\n    // Map 'image_size' (2K) to 'quality' semantics if needed, or pass directly if logic supports\n    // common_utils logic: 'hd' -> 4K, 'medium' -> 2K.\n    let quality_input = match image_size_param.as_deref() {\n        Some(\"4K\") => Some(\"hd\"),\n        Some(\"2K\") => Some(\"medium\"),\n        _ => None, // Fallback to standard\n    };\n\n    let (image_config, _) = crate::proxy::mappers::common_utils::parse_image_config_with_params(\n        &model,\n        size_input,\n        quality_input,\n        image_size_param.as_deref(), // [NEW] Pass direct image_size param\n    );\n\n    // 3. Construct Contents\n    let mut contents_parts = Vec::new();\n\n    // Add Prompt\n    let mut final_prompt = prompt.clone();\n    if let Some(s) = style {\n        final_prompt.push_str(&format!(\", style: {}\", s));\n    }\n    contents_parts.push(json!({\n        \"text\": final_prompt\n    }));\n\n    // Add Main Image (if standard edit)\n    if let Some(data) = image_data {\n        contents_parts.push(json!({\n            \"inlineData\": {\n                \"mimeType\": \"image/png\",\n                \"data\": data\n            }\n        }));\n    }\n\n    // Add Mask (if standard edit)\n    if let Some(data) = mask_data {\n        contents_parts.push(json!({\n            \"inlineData\": {\n                \"mimeType\": \"image/png\",\n                \"data\": data\n            }\n        }));\n    }\n\n    // Add Reference Images (Image-to-Image)\n    for ref_data in reference_images {\n        contents_parts.push(json!({\n            \"inlineData\": {\n                \"mimeType\": \"image/jpeg\", // Assume JPEG for refs as per spec suggestion, or auto-detect\n                \"data\": ref_data\n            }\n        }));\n    }\n\n    // 4. 并发发送请求\n    // 注意：不再在外部获取 Token，而是移入 Task 内部\n    let upstream = state.upstream.clone();\n    let token_manager = state.token_manager.clone();\n    let max_pool_size = token_manager.len();\n    let max_attempts = MAX_RETRY_ATTEMPTS\n        .min(max_pool_size.saturating_add(1))\n        .max(2);\n\n    let mut tasks = Vec::new();\n    for _ in 0..n {\n        let upstream = upstream.clone();\n        let token_manager = token_manager.clone();\n        let contents_parts = contents_parts.clone();\n        let image_config = image_config.clone();\n        let response_format = response_format.clone();\n        let model = model.clone();\n\n        tasks.push(tokio::spawn(async move {\n            let mut last_error = String::new();\n\n            for attempt in 0..max_attempts {\n                // 4.1 获取 Token\n                let (access_token, project_id, email, account_id, _wait_ms) = match token_manager\n                    .get_token(\"image_gen\", attempt > 0, None, \"gemini-3-pro-image\")\n                    .await\n                {\n                    Ok(t) => t,\n                    Err(e) => {\n                        last_error = format!(\"Token error: {}\", e);\n                        if attempt < max_attempts - 1 {\n                            tokio::time::sleep(Duration::from_millis(500)).await;\n                            continue;\n                        }\n                        break;\n                    }\n                };\n\n                // 4.2 Construct Request Body (Need project_id)\n                let gemini_body = json!({\n                    \"project\": project_id,\n                    \"requestId\": format!(\"img-edit-{}\", uuid::Uuid::new_v4()),\n                    \"model\": model,\n                    \"userAgent\": \"antigravity\",\n                    \"requestType\": \"image_gen\",\n                    \"request\": {\n                        \"contents\": [{\n                            \"role\": \"user\",\n                            \"parts\": contents_parts\n                        }],\n                        \"generationConfig\": {\n                            \"candidateCount\": 1,\n                            \"imageConfig\": image_config,\n                            \"maxOutputTokens\": 8192,\n                            \"stopSequences\": [],\n                            \"temperature\": 1.0,\n                            \"topP\": 0.95,\n                            \"topK\": 40\n                        },\n                        \"safetySettings\": [\n                            { \"category\": \"HARM_CATEGORY_HARASSMENT\", \"threshold\": \"OFF\" },\n                            { \"category\": \"HARM_CATEGORY_HATE_SPEECH\", \"threshold\": \"OFF\" },\n                            { \"category\": \"HARM_CATEGORY_SEXUALLY_EXPLICIT\", \"threshold\": \"OFF\" },\n                            { \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\", \"threshold\": \"OFF\" },\n                            { \"category\": \"HARM_CATEGORY_CIVIC_INTEGRITY\", \"threshold\": \"OFF\" },\n                        ]\n                    }\n                });\n\n                match upstream\n                    .call_v1_internal(\n                        \"generateContent\",\n                        &access_token,\n                        gemini_body,\n                        None,\n                        Some(account_id.as_str()),\n                    )\n                    .await\n                {\n                    Ok(call_result) => {\n                        let response = call_result.response;\n                        let status = response.status();\n                        if !status.is_success() {\n                            let err_text = response.text().await.unwrap_or_default();\n                            let status_code = status.as_u16();\n                            last_error = format!(\"Upstream error {}: {}\", status, err_text);\n\n                            // 429/500/503 等错误进行标记和重试\n                            if status_code == 429 || status_code == 503 || status_code == 500 {\n                                tracing::warn!(\n                                    \"[Images] Account {} rate limited/error ({}), rotating...\",\n                                    email,\n                                    status_code\n                                );\n                                token_manager\n                                    .mark_rate_limited_async(\n                                        &email,\n                                        status_code,\n                                        None,\n                                        &err_text,\n                                        Some(\"dall-e-3\"),\n                                    )\n                                    .await;\n                                continue; // Retry loop\n                            }\n                            return Err(last_error);\n                        }\n                        match response.json::<Value>().await {\n                            Ok(json) => return Ok((json, response_format.clone(), email)),\n                            Err(e) => return Err(format!(\"Parse error: {}\", e)),\n                        }\n                    }\n                    Err(e) => {\n                        last_error = format!(\"Network error: {}\", e);\n                        continue;\n                    }\n                }\n            }\n            Err(format!(\"Max retries exhausted. Last error: {}\", last_error))\n        }));\n    }\n\n    // 5. Collect Results\n    let mut images: Vec<Value> = Vec::new();\n    let mut errors: Vec<String> = Vec::new();\n    let mut used_email: Option<String> = None;\n\n    for (idx, task) in tasks.into_iter().enumerate() {\n        match task.await {\n            Ok(result) => match result {\n                Ok((gemini_resp, response_format, email_used)) => {\n                    if used_email.is_none() {\n                        used_email = Some(email_used);\n                    }\n                    let raw = gemini_resp.get(\"response\").unwrap_or(&gemini_resp);\n                    if let Some(parts) = raw\n                        .get(\"candidates\")\n                        .and_then(|c| c.get(0))\n                        .and_then(|cand| cand.get(\"content\"))\n                        .and_then(|content| content.get(\"parts\"))\n                        .and_then(|p| p.as_array())\n                    {\n                        for part in parts {\n                            if let Some(img) = part.get(\"inlineData\") {\n                                let data = img.get(\"data\").and_then(|v| v.as_str()).unwrap_or(\"\");\n                                if !data.is_empty() {\n                                    if response_format == \"url\" {\n                                        let mime_type = img\n                                            .get(\"mimeType\")\n                                            .and_then(|v| v.as_str())\n                                            .unwrap_or(\"image/png\");\n                                        images.push(json!({\n                                            \"url\": format!(\"data:{};base64,{}\", mime_type, data)\n                                        }));\n                                    } else {\n                                        images.push(json!({\n                                            \"b64_json\": data\n                                        }));\n                                    }\n                                    tracing::debug!(\"[Images] Task {} succeeded\", idx);\n                                }\n                            }\n                        }\n                    }\n                }\n                Err(e) => {\n                    tracing::error!(\"[Images] Task {} failed: {}\", idx, e);\n                    errors.push(e);\n                }\n            },\n            Err(e) => {\n                let err_msg = format!(\"Task join error: {}\", e);\n                tracing::error!(\"[Images] Task {} join error: {}\", idx, e);\n                errors.push(err_msg);\n            }\n        }\n    }\n\n    if images.is_empty() {\n        let error_msg = if !errors.is_empty() {\n            errors.join(\"; \")\n        } else {\n            \"No images generated\".to_string()\n        };\n        tracing::error!(\n            \"[Images] All {} edit requests failed. Errors: {}\",\n            n,\n            error_msg\n        );\n        return Err((StatusCode::BAD_GATEWAY, error_msg));\n    }\n\n    if !errors.is_empty() {\n        tracing::warn!(\n            \"[Images] Partial success: {} out of {} requests succeeded. Errors: {}\",\n            images.len(),\n            n,\n            errors.join(\"; \")\n        );\n    }\n\n    tracing::info!(\n        \"[Images] Successfully generated {} out of {} requested edited image(s)\",\n        images.len(),\n        n\n    );\n\n    let openai_response = json!({\n        \"created\": chrono::Utc::now().timestamp(),\n        \"data\": images\n    });\n\n    let email_header = used_email.unwrap_or_default();\n    Ok((\n        StatusCode::OK,\n        [\n            (\"X-Mapped-Model\", \"dall-e-3\"),\n            (\"X-Account-Email\", email_header.as_str()),\n        ],\n        Json(openai_response),\n    )\n        .into_response())\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/handlers/warmup.rs",
    "content": "// 预热处理器 - 内部预热 API\n//\n// 提供 /internal/warmup 端点，支持：\n// - 指定账号（通过 email）\n// - 指定模型（不做映射，直接使用原始模型名称）\n// - 复用代理的所有基础设施（UpstreamClient、TokenManager）\n\nuse axum::{\n    extract::State,\n    http::StatusCode,\n    response::{IntoResponse, Json, Response},\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::{json, Value};\nuse tracing::{info, warn};\n\nuse crate::proxy::mappers::gemini::wrapper::wrap_request;\nuse crate::proxy::monitor::ProxyRequestLog;\nuse crate::proxy::server::AppState;\n\n/// 预热请求体\n#[derive(Debug, Deserialize)]\npub struct WarmupRequest {\n    /// 账号邮箱\n    pub email: String,\n    /// 模型名称（原始名称，不做映射）\n    pub model: String,\n    /// 可选：直接提供 Access Token（用于不在 TokenManager 中的账号）\n    pub access_token: Option<String>,\n    /// 可选：直接提供 Project ID\n    pub project_id: Option<String>,\n}\n\n/// 预热响应\n#[derive(Debug, Serialize)]\npub struct WarmupResponse {\n    pub success: bool,\n    pub message: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub error: Option<String>,\n}\n\n/// 处理预热请求\npub async fn handle_warmup(\n    State(state): State<AppState>,\n    Json(req): Json<WarmupRequest>,\n) -> Response {\n    let start_time = std::time::Instant::now();\n\n    // ===== 前置检查：跳过 gemini-2.5-* 家族模型 =====\n    let model_lower = req.model.to_lowercase();\n    if model_lower.contains(\"2.5-\") || model_lower.contains(\"2-5-\") {\n        info!(\n            \"[Warmup-API] SKIP: gemini-2.5-* model not supported for warmup: {} / {}\",\n            req.email, req.model\n        );\n        return (\n            StatusCode::OK,\n            Json(WarmupResponse {\n                success: true,\n                message: format!(\n                    \"Skipped warmup for {} (2.5 models not supported)\",\n                    req.model\n                ),\n                error: None,\n            }),\n        )\n            .into_response();\n    }\n\n    info!(\n        \"[Warmup-API] ========== START: email={}, model={} ==========\",\n        req.email, req.model\n    );\n\n    // ===== 步骤 1: 获取 Token =====\n    let (access_token, project_id, account_id) =\n        if let (Some(at), Some(pid)) = (&req.access_token, &req.project_id) {\n            (at.clone(), pid.clone(), String::new())\n        } else {\n            match state.token_manager.get_token_by_email(&req.email).await {\n                Ok((at, pid, _, acc_id, _wait_ms)) => (at, pid, acc_id),\n                Err(e) => {\n                    warn!(\n                        \"[Warmup-API] Step 1 FAILED: Token error for {}: {}\",\n                        req.email, e\n                    );\n                    return (\n                        StatusCode::BAD_REQUEST,\n                        Json(WarmupResponse {\n                            success: false,\n                            message: format!(\"Failed to get token for {}\", req.email),\n                            error: Some(e),\n                        }),\n                    )\n                        .into_response();\n                }\n            }\n        };\n\n    // ===== 步骤 2: 根据模型类型构建请求体 =====\n    let is_claude = req.model.to_lowercase().contains(\"claude\");\n    let is_image = req.model.to_lowercase().contains(\"image\");\n\n    let body: Value = if is_claude {\n        // Claude 模型：使用 transform_claude_request_in 转换\n        let session_id = format!(\n            \"warmup_{}_{}\",\n            chrono::Utc::now().timestamp_millis(),\n            &uuid::Uuid::new_v4().to_string()[..8]\n        );\n        let claude_request = crate::proxy::mappers::claude::models::ClaudeRequest {\n            model: req.model.clone(),\n            messages: vec![crate::proxy::mappers::claude::models::Message {\n                role: \"user\".to_string(),\n                content: crate::proxy::mappers::claude::models::MessageContent::String(\n                    \"ping\".to_string(),\n                ),\n            }],\n            max_tokens: Some(1),\n            stream: false,\n            system: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            tools: None,\n            metadata: Some(crate::proxy::mappers::claude::models::Metadata {\n                user_id: Some(session_id),\n            }),\n            thinking: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        match crate::proxy::mappers::claude::transform_claude_request_in(\n            &claude_request,\n            &project_id,\n            false,\n            None,\n            \"warmup\",\n            None, // [NEW] No token for warmup\n        ) {\n            Ok(transformed) => transformed,\n            Err(e) => {\n                warn!(\"[Warmup-API] Step 2 FAILED: Claude transform error: {}\", e);\n                return (\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    Json(WarmupResponse {\n                        success: false,\n                        message: format!(\"Transform error: {}\", e),\n                        error: Some(e),\n                    }),\n                )\n                    .into_response();\n            }\n        }\n    } else {\n        // Gemini 模型：使用 wrap_request\n        let session_id = format!(\n            \"warmup_{}_{}\",\n            chrono::Utc::now().timestamp_millis(),\n            &uuid::Uuid::new_v4().to_string()[..8]\n        );\n\n        let base_request = if is_image {\n            json!({\n                \"model\": req.model,\n                \"contents\": [{\"role\": \"user\", \"parts\": [{\"text\": \"Say hi\"}]}],\n                \"generationConfig\": {\n                    \"maxOutputTokens\": 10,\n                    \"temperature\": 0,\n                    \"responseModalities\": [\"TEXT\"]\n                },\n                \"session_id\": session_id\n            })\n        } else {\n            json!({\n                \"model\": req.model,\n                \"contents\": [{\"role\": \"user\", \"parts\": [{\"text\": \"Say hi\"}]}],\n                \"generationConfig\": {\n                    \"temperature\": 0\n                },\n                \"session_id\": session_id\n            })\n        };\n\n        wrap_request(&base_request, &project_id, &req.model, None, Some(&session_id), None) // [FIX] Added None for token param\n    };\n\n    // ===== 步骤 3: 调用 UpstreamClient =====\n    let model_lower = req.model.to_lowercase();\n    let prefer_non_stream = model_lower.contains(\"flash-lite\") || model_lower.contains(\"2.5-pro\");\n\n    let (method, query) = if prefer_non_stream {\n        (\"generateContent\", None)\n    } else {\n        (\"streamGenerateContent\", Some(\"alt=sse\"))\n    };\n\n    let mut result = state\n        .upstream\n        .call_v1_internal(\n            method,\n            &access_token,\n            body.clone(),\n            query,\n            Some(account_id.as_str()),\n        )\n        .await;\n\n    // 如果流式请求失败，尝试非流式请求\n    if result.is_err() && !prefer_non_stream {\n        result = state\n            .upstream\n            .call_v1_internal(\n                \"generateContent\",\n                &access_token,\n                body,\n                None,\n                Some(account_id.as_str()),\n            )\n            .await;\n    }\n\n    let duration = start_time.elapsed().as_millis() as u64;\n\n    // ===== 步骤 4: 处理响应并记录流量日志 =====\n    match result {\n        Ok(call_result) => {\n            let response = call_result.response;\n            let status = response.status();\n            let status_code = status.as_u16();\n\n            // 记录预热请求到流量日志\n            let log = ProxyRequestLog {\n                id: uuid::Uuid::new_v4().to_string(),\n                timestamp: chrono::Utc::now().timestamp_millis(),\n                method: \"POST\".to_string(),\n                url: format!(\"/internal/warmup -> {}\", req.model),\n                status: status_code,\n                duration,\n                model: Some(req.model.clone()),\n                mapped_model: Some(req.model.clone()),\n                account_email: Some(req.email.clone()),\n                client_ip: Some(\"127.0.0.1\".to_string()),\n                error: if status.is_success() {\n                    None\n                } else {\n                    Some(format!(\"HTTP {}\", status_code))\n                },\n                request_body: Some(format!(\n                    \"{{\\\"type\\\": \\\"warmup\\\", \\\"model\\\": \\\"{}\\\"}}\",\n                    req.model\n                )),\n                response_body: None,\n                input_tokens: Some(0),\n                output_tokens: Some(0),\n                protocol: Some(\"warmup\".to_string()),\n                username: None,\n            };\n            state.monitor.log_request(log).await;\n\n            let mut response = if status.is_success() {\n                info!(\n                    \"[Warmup-API] ========== SUCCESS: {} / {} ({}ms) ==========\",\n                    req.email, req.model, duration\n                );\n                (\n                    StatusCode::OK,\n                    Json(WarmupResponse {\n                        success: true,\n                        message: format!(\"Warmup triggered for {}\", req.model),\n                        error: None,\n                    }),\n                )\n                    .into_response()\n            } else {\n                let error_text = response.text().await.unwrap_or_default();\n\n                // [FIX] 预热阶段检测到 403 时，标记账号为 forbidden，避免无效账号继续参与轮询\n                // 如果 account_id 为空（直接传入 access_token 的场景），通过 email 从索引中找到 ID\n                if status_code == 403 {\n                    let resolved_account_id = if !account_id.is_empty() {\n                        account_id.clone()\n                    } else {\n                        // 尝试通过 email 查找账号 ID\n                        crate::modules::account::find_account_id_by_email(&req.email)\n                            .unwrap_or_default()\n                    };\n\n                    if !resolved_account_id.is_empty() {\n                        warn!(\n                            \"[Warmup-API] 403 Forbidden detected for {}, marking account as forbidden\",\n                            req.email\n                        );\n                        let _ = crate::modules::account::mark_account_forbidden(&resolved_account_id, &error_text);\n                    } else {\n                        warn!(\n                            \"[Warmup-API] 403 Forbidden detected for {} but could not resolve account_id, skipping mark\",\n                            req.email\n                        );\n                    }\n                }\n\n                (\n                    StatusCode::from_u16(status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),\n                    Json(WarmupResponse {\n                        success: false,\n                        message: format!(\"Warmup failed: HTTP {}\", status_code),\n                        error: Some(error_text),\n                    }),\n                )\n                    .into_response()\n            };\n\n            // 添加响应头，让监控中间件捕获账号信息\n            if let Ok(email_val) = axum::http::HeaderValue::from_str(&req.email) {\n                response.headers_mut().insert(\"X-Account-Email\", email_val);\n            }\n            if let Ok(model_val) = axum::http::HeaderValue::from_str(&req.model) {\n                response.headers_mut().insert(\"X-Mapped-Model\", model_val);\n            }\n\n            response\n        }\n        Err(e) => {\n            warn!(\n                \"[Warmup-API] ========== ERROR: {} / {} - {} ({}ms) ==========\",\n                req.email, req.model, e, duration\n            );\n\n            // 记录失败的预热请求到流量日志\n            let log = ProxyRequestLog {\n                id: uuid::Uuid::new_v4().to_string(),\n                timestamp: chrono::Utc::now().timestamp_millis(),\n                method: \"POST\".to_string(),\n                url: format!(\"/internal/warmup -> {}\", req.model),\n                status: 500,\n                duration,\n                model: Some(req.model.clone()),\n                mapped_model: Some(req.model.clone()),\n                account_email: Some(req.email.clone()),\n                client_ip: Some(\"127.0.0.1\".to_string()),\n                error: Some(e.clone()),\n                request_body: Some(format!(\n                    \"{{\\\"type\\\": \\\"warmup\\\", \\\"model\\\": \\\"{}\\\"}}\",\n                    req.model\n                )),\n                response_body: None,\n                input_tokens: None,\n                output_tokens: None,\n                protocol: Some(\"warmup\".to_string()),\n                username: None,\n            };\n            state.monitor.log_request(log).await;\n\n            let mut response = (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(WarmupResponse {\n                    success: false,\n                    message: \"Warmup request failed\".to_string(),\n                    error: Some(e),\n                }),\n            )\n                .into_response();\n\n            // 即使失败也添加响应头，以便监控\n            if let Ok(email_val) = axum::http::HeaderValue::from_str(&req.email) {\n                response.headers_mut().insert(\"X-Account-Email\", email_val);\n            }\n            if let Ok(model_val) = axum::http::HeaderValue::from_str(&req.model) {\n                response.headers_mut().insert(\"X-Mapped-Model\", model_val);\n            }\n\n            response\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/claude/collector.rs",
    "content": "// Stream 收集器 - 将 SSE 流转换为完整的 JSON 响应\n// 用于非 Stream 请求的自动转换\n\nuse super::models::*;\nuse bytes::Bytes;\nuse futures::StreamExt;\nuse serde_json::{json, Value};\nuse std::io;\n\n/// SSE 事件类型\n#[derive(Debug, Clone)]\nstruct SseEvent {\n    event_type: String,\n    data: Value,\n}\n\n/// 解析 SSE 行\nfn parse_sse_line(line: &str) -> Option<(String, String)> {\n    if let Some(colon_pos) = line.find(':') {\n        let key = &line[..colon_pos];\n        let value = line[colon_pos + 1..].trim_start();\n        Some((key.to_string(), value.to_string()))\n    } else {\n        None\n    }\n}\n\n/// 将 SSE Stream 收集为完整的 Claude Response\n///\n/// 此函数接收一个 SSE 字节流，解析所有事件，并重建完整的 ClaudeResponse 对象。\n/// 这使得非 Stream 客户端可以透明地享受 Stream 模式的配额优势。\npub async fn collect_stream_to_json<S>(\n    mut stream: S,\n) -> Result<ClaudeResponse, String>\nwhere\n    S: futures::Stream<Item = Result<Bytes, io::Error>> + Unpin,\n{\n    let mut events = Vec::new();\n    let mut current_event_type = String::new();\n    let mut current_data = String::new();\n\n    // 1. 收集所有 SSE 事件\n    while let Some(chunk_result) = stream.next().await {\n        let chunk = chunk_result.map_err(|e| format!(\"Stream error: {}\", e))?;\n        let text = String::from_utf8_lossy(&chunk);\n\n        for line in text.lines() {\n            if line.is_empty() {\n                // 空行表示事件结束\n                if !current_data.is_empty() {\n                    if let Ok(data) = serde_json::from_str::<Value>(&current_data) {\n                        events.push(SseEvent {\n                            event_type: current_event_type.clone(),\n                            data,\n                        });\n                    }\n                    current_event_type.clear();\n                    current_data.clear();\n                }\n            } else if let Some((key, value)) = parse_sse_line(line) {\n                match key.as_str() {\n                    \"event\" => current_event_type = value,\n                    \"data\" => current_data = value,\n                    _ => {}\n                }\n            }\n        }\n    }\n\n    // 2. 重建 ClaudeResponse\n    let mut response = ClaudeResponse {\n        id: \"msg_unknown\".to_string(),\n        type_: \"message\".to_string(),\n        role: \"assistant\".to_string(),\n        model: String::new(),\n        content: Vec::new(),\n        stop_reason: \"end_turn\".to_string(),\n        stop_sequence: None,\n        usage: Usage {\n            input_tokens: 0,\n            output_tokens: 0,\n            cache_read_input_tokens: None,\n            cache_creation_input_tokens: None,\n            server_tool_use: None,\n        },\n    };\n\n    // 用于累积内容块\n    let mut current_text = String::new();\n    let mut current_thinking = String::new();\n    let mut current_signature: Option<String> = None;\n    let mut current_tool_use: Option<Value> = None;\n    let mut current_tool_input = String::new();\n\n    for event in events {\n        match event.event_type.as_str() {\n            \"message_start\" => {\n                // 提取基本信息\n                if let Some(message) = event.data.get(\"message\") {\n                    if let Some(id) = message.get(\"id\").and_then(|v| v.as_str()) {\n                        response.id = id.to_string();\n                    }\n                    if let Some(model) = message.get(\"model\").and_then(|v| v.as_str()) {\n                        response.model = model.to_string();\n                    }\n                    if let Some(usage) = message.get(\"usage\") {\n                        if let Ok(u) = serde_json::from_value::<Usage>(usage.clone()) {\n                            response.usage = u;\n                        }\n                    }\n                }\n            }\n\n            \"content_block_start\" => {\n                if let Some(content_block) = event.data.get(\"content_block\") {\n                    if let Some(block_type) = content_block.get(\"type\").and_then(|v| v.as_str()) {\n                        match block_type {\n                            \"text\" => current_text.clear(),\n                            \"thinking\" => {\n                                current_thinking.clear();\n                                // Extract signature from content_block\n                                current_signature = content_block.get(\"signature\")\n                                    .and_then(|v| v.as_str())\n                                    .map(|s| s.to_string());\n                            }\n                            \"tool_use\" => {\n                                current_tool_use = Some(content_block.clone());\n                                current_tool_input.clear();\n                            }\n                            _ => {}\n                        }\n                    }\n                }\n            }\n\n            \"content_block_delta\" => {\n                if let Some(delta) = event.data.get(\"delta\") {\n                    if let Some(delta_type) = delta.get(\"type\").and_then(|v| v.as_str()) {\n                        match delta_type {\n                            \"text_delta\" => {\n                                if let Some(text) = delta.get(\"text\").and_then(|v| v.as_str()) {\n                                    current_text.push_str(text);\n                                }\n                            }\n                            \"thinking_delta\" => {\n                                if let Some(thinking) = delta.get(\"thinking\").and_then(|v| v.as_str()) {\n                                    current_thinking.push_str(thinking);\n                                }\n                                // In case signature comes in delta (less likely but possible update)\n                                if let Some(sig) = delta.get(\"signature\").and_then(|v| v.as_str()) {\n                                    current_signature = Some(sig.to_string());\n                                }\n                            }\n                            \"input_json_delta\" => {\n                                if let Some(partial_json) = delta.get(\"partial_json\").and_then(|v| v.as_str()) {\n                                    current_tool_input.push_str(partial_json);\n                                }\n                            }\n                            _ => {}\n                        }\n                    }\n                }\n            }\n\n            \"content_block_stop\" => {\n                // 完成当前块\n                if !current_text.is_empty() {\n                    response.content.push(ContentBlock::Text {\n                        text: current_text.clone(),\n                    });\n                    current_text.clear();\n                } else if !current_thinking.is_empty() {\n                    response.content.push(ContentBlock::Thinking {\n                        thinking: current_thinking.clone(),\n                        signature: current_signature.take(),\n                        cache_control: None,\n                    });\n                    current_thinking.clear();\n                } else if let Some(tool_use) = current_tool_use.take() {\n                    // 构建 tool_use 块\n                    let id = tool_use.get(\"id\").and_then(|v| v.as_str()).unwrap_or(\"unknown\").to_string();\n                    let name = tool_use.get(\"name\").and_then(|v| v.as_str()).unwrap_or(\"unknown\").to_string();\n                    let input = if !current_tool_input.is_empty() {\n                        serde_json::from_str(&current_tool_input).unwrap_or(json!({}))\n                    } else {\n                        json!({})\n                    };\n\n                    response.content.push(ContentBlock::ToolUse {\n                        id,\n                        name,\n                        input,\n                        signature: None,\n                        cache_control: None,\n                    });\n                    current_tool_input.clear();\n                }\n            }\n\n            \"message_delta\" => {\n                if let Some(delta) = event.data.get(\"delta\") {\n                    if let Some(stop_reason) = delta.get(\"stop_reason\").and_then(|v| v.as_str()) {\n                        response.stop_reason = stop_reason.to_string();\n                    }\n                }\n                if let Some(usage) = event.data.get(\"usage\") {\n                    if let Ok(u) = serde_json::from_value::<Usage>(usage.clone()) {\n                        response.usage = u;\n                    }\n                }\n            }\n\n            \"message_stop\" => {\n                // Stream 结束\n                break;\n            }\n\n            \"error\" => {\n                // 错误事件\n                let error_data = event.data.get(\"error\").unwrap_or(&event.data);\n                let message = error_data.get(\"message\").and_then(|v| v.as_str()).unwrap_or(\"Unknown stream error\");\n                return Err(message.to_string());\n            }\n\n            _ => {\n                // 忽略未知事件类型\n            }\n        }\n    }\n\n    Ok(response)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use futures::stream;\n\n    #[tokio::test]\n    async fn test_collect_simple_text_response() {\n        // 模拟一个简单的文本响应 SSE 流\n        let sse_data = vec![\n            \"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\",\\\"message\\\":{\\\"id\\\":\\\"msg_123\\\",\\\"type\\\":\\\"message\\\",\\\"role\\\":\\\"assistant\\\",\\\"model\\\":\\\"claude-3-5-sonnet\\\",\\\"content\\\":[],\\\"stop_reason\\\":null,\\\"usage\\\":{\\\"input_tokens\\\":10,\\\"output_tokens\\\":0}}}\\n\\n\",\n            \"event: content_block_start\\ndata: {\\\"type\\\":\\\"content_block_start\\\",\\\"index\\\":0,\\\"content_block\\\":{\\\"type\\\":\\\"text\\\",\\\"text\\\":\\\"\\\"}}\\n\\n\",\n            \"event: content_block_delta\\ndata: {\\\"type\\\":\\\"content_block_delta\\\",\\\"index\\\":0,\\\"delta\\\":{\\\"type\\\":\\\"text_delta\\\",\\\"text\\\":\\\"Hello\\\"}}\\n\\n\",\n            \"event: content_block_delta\\ndata: {\\\"type\\\":\\\"content_block_delta\\\",\\\"index\\\":0,\\\"delta\\\":{\\\"type\\\":\\\"text_delta\\\",\\\"text\\\":\\\" World\\\"}}\\n\\n\",\n            \"event: content_block_stop\\ndata: {\\\"type\\\":\\\"content_block_stop\\\",\\\"index\\\":0}\\n\\n\",\n            \"event: message_delta\\ndata: {\\\"type\\\":\\\"message_delta\\\",\\\"delta\\\":{\\\"stop_reason\\\":\\\"end_turn\\\"},\\\"usage\\\":{\\\"output_tokens\\\":5}}\\n\\n\",\n            \"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\",\n        ];\n\n        let byte_stream = stream::iter(\n            sse_data.into_iter().map(|s| Ok::<Bytes, io::Error>(Bytes::from(s)))\n        );\n\n        let result = collect_stream_to_json(byte_stream).await;\n        assert!(result.is_ok());\n\n        let response = result.unwrap();\n        assert_eq!(response.id, \"msg_123\");\n        assert_eq!(response.model, \"claude-3-5-sonnet\");\n        assert_eq!(response.content.len(), 1);\n        \n        if let ContentBlock::Text { text } = &response.content[0] {\n            assert_eq!(text, \"Hello World\");\n        } else {\n            panic!(\"Expected Text block\");\n        }\n    }\n\n    #[tokio::test]\n    async fn test_collect_thinking_response_with_signature() {\n        // 模拟一个包含 Thinking Block 和签名的 SSE 流\n        let sse_data = vec![\n            \"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\",\\\"message\\\":{\\\"id\\\":\\\"msg_think\\\",\\\"type\\\":\\\"message\\\",\\\"role\\\":\\\"assistant\\\",\\\"model\\\":\\\"claude-3-7-sonnet\\\",\\\"content\\\":[],\\\"stop_reason\\\":null,\\\"usage\\\":{\\\"input_tokens\\\":10,\\\"output_tokens\\\":0}}}\\n\\n\",\n            // content_block_start 中包含 signature\n            \"event: content_block_start\\ndata: {\\\"type\\\":\\\"content_block_start\\\",\\\"index\\\":0,\\\"content_block\\\":{\\\"type\\\":\\\"thinking\\\",\\\"thinking\\\":\\\"\\\", \\\"signature\\\": \\\"sig_123456\\\"}}\\n\\n\",\n            \"event: content_block_delta\\ndata: {\\\"type\\\":\\\"content_block_delta\\\",\\\"index\\\":0,\\\"delta\\\":{\\\"type\\\":\\\"thinking_delta\\\",\\\"thinking\\\":\\\"I am \\\"}}\\n\\n\",\n            \"event: content_block_delta\\ndata: {\\\"type\\\":\\\"content_block_delta\\\",\\\"index\\\":0,\\\"delta\\\":{\\\"type\\\":\\\"thinking_delta\\\",\\\"thinking\\\":\\\"thinking\\\"}}\\n\\n\",\n            \"event: content_block_stop\\ndata: {\\\"type\\\":\\\"content_block_stop\\\",\\\"index\\\":0}\\n\\n\",\n            \"event: message_delta\\ndata: {\\\"type\\\":\\\"message_delta\\\",\\\"delta\\\":{\\\"stop_reason\\\":\\\"end_turn\\\"},\\\"usage\\\":{\\\"output_tokens\\\":10}}\\n\\n\",\n            \"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\",\n        ];\n\n        let byte_stream = stream::iter(\n            sse_data.into_iter().map(|s| Ok::<Bytes, io::Error>(Bytes::from(s)))\n        );\n\n        let result = collect_stream_to_json(byte_stream).await;\n        assert!(result.is_ok());\n\n        let response = result.unwrap();\n        \n        if let ContentBlock::Thinking { thinking, signature, .. } = &response.content[0] {\n            assert_eq!(thinking, \"I am thinking\");\n            // 验证签名是否被正确提取\n            assert_eq!(signature.as_deref(), Some(\"sig_123456\"));\n        } else {\n            panic!(\"Expected Thinking block\");\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/claude/mod.rs",
    "content": "// Claude mapper 模块\n// 负责 Claude ↔ Gemini 协议转换\n\npub mod models;\npub mod request;\npub mod response;\npub mod streaming;\npub mod utils;\npub mod thinking_utils;\npub mod collector;\n\npub use models::*;\npub use request::{transform_claude_request_in, clean_cache_control_from_messages, merge_consecutive_messages};\npub use response::transform_response;\npub use streaming::{PartProcessor, StreamingState};\npub use thinking_utils::{close_tool_loop_for_thinking, filter_invalid_thinking_blocks_with_family};\npub use collector::collect_stream_to_json;\nuse crate::proxy::common::client_adapter::ClientAdapter; // [NEW]\n\nuse bytes::Bytes;\nuse futures::Stream;\nuse std::pin::Pin;\n\n/// 创建从 Gemini SSE 流到 Claude SSE 流的转换\npub fn create_claude_sse_stream<S, E>(\n    mut gemini_stream: Pin<Box<S>>,\n    trace_id: String,\n    email: String,\n    session_id: Option<String>, // [NEW v3.3.17] Session ID for signature caching\n    scaling_enabled: bool, // [NEW] Flag for context usage scaling\n    context_limit: u32,\n    estimated_prompt_tokens: Option<u32>, // [FIX] Estimated tokens for calibrator learning\n    message_count: usize, // [NEW v4.0.0] Message count for rewind detection\n    client_adapter: Option<std::sync::Arc<dyn ClientAdapter>>, // [NEW] Adapter reference\n    registered_tool_names: Vec<String>, // [FIX #MCP] Tool names for fuzzy matching\n) -> Pin<Box<dyn Stream<Item = Result<Bytes, String>> + Send>> \nwhere\n    S: Stream<Item = Result<Bytes, E>> + Send + ?Sized + 'static,\n    E: std::fmt::Display + Send + 'static,\n{\n    use async_stream::stream;\n    use bytes::BytesMut;\n    use futures::StreamExt;\n\n    Box::pin(stream! {\n        let mut state = StreamingState::new();\n        state.session_id = session_id; // Set session ID for signature caching\n        state.message_count = message_count; // [NEW v4.0.0] Set message count\n        state.scaling_enabled = scaling_enabled; // Set scaling enabled flag\n        state.context_limit = context_limit;\n        state.estimated_prompt_tokens = estimated_prompt_tokens; // [FIX] Pass estimated tokens\n        state.set_client_adapter(client_adapter); // [NEW] Set adapter\n        state.set_registered_tool_names(registered_tool_names); // [FIX #MCP] Set tool names\n        let mut buffer = BytesMut::new();\n\n        loop {\n            // [NEW] 60秒心跳保活: 延长超时时间以增加网络抖动容错\n            let next_chunk = tokio::time::timeout(\n                std::time::Duration::from_secs(60),\n                gemini_stream.next()\n            ).await;\n\n            match next_chunk {\n                Ok(Some(chunk_result)) => {\n                    match chunk_result {\n                        Ok(chunk) => {\n                            buffer.extend_from_slice(&chunk);\n\n                            // Process complete lines\n                            while let Some(pos) = buffer.iter().position(|&b| b == b'\\n') {\n                                let line_raw = buffer.split_to(pos + 1);\n                                if let Ok(line_str) = std::str::from_utf8(&line_raw) {\n                                    let line = line_str.trim();\n                                    if line.is_empty() { continue; }\n\n                                    if let Some(sse_chunks) = process_sse_line(line, &mut state, &trace_id, &email) {\n                                        for sse_chunk in sse_chunks {\n                                            yield Ok(sse_chunk);\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                        Err(e) => {\n                            let error_json = serde_json::json!({\n                                \"error\": {\n                                    \"message\": format!(\"Stream error: {}\", e),\n                                    \"type\": \"stream_error\"\n                                }\n                            });\n                            yield Ok(Bytes::from(format!(\"data: {}\\n\\n\", error_json)));\n                            break;\n                        }\n                    }\n                }\n                Ok(None) => break, // Stream 正常结束\n                Err(_) => {\n                    // 超时，发送心跳包 (SSE Comment 格式)\n                    yield Ok(Bytes::from(\": ping\\n\\n\"));\n                }\n            }\n        }\n        \n        // [FIX #1732] Mandatory Flush remaining buffer on stream termination\n        // Prevents hangs when the last SSE chunk doesn't end with a newline (network fragmentation)\n        if !buffer.is_empty() {\n             if let Ok(line_str) = std::str::from_utf8(&buffer) {\n                 let line = line_str.trim();\n                 if !line.is_empty() {\n                     tracing::debug!(\"[{}] SSE Termination: Flushing remaining {} bytes in buffer\", trace_id, buffer.len());\n                     if let Some(sse_chunks) = process_sse_line(line, &mut state, &trace_id, &email) {\n                         for sse_chunk in sse_chunks {\n                             yield Ok(sse_chunk);\n                         }\n                     }\n                 }\n             }\n             buffer.clear();\n        }\n\n        // [FIX #859] Post-thinking interruption recovery\n        // If we have sent thinking but NO content (text/tool_use) and the stream ended (or timed out without DONE),\n        // we must provide a fallback to prevent 0-token errors on client side.\n        if state.has_thinking && !state.has_content {\n            tracing::warn!(\"[{}] Stream interrupted after thinking (No Content). Triggering recovery...\", trace_id);\n            \n            // 1. Force close thinking block if open\n            if state.current_block_type() == crate::proxy::mappers::claude::streaming::BlockType::Thinking {\n               let close_chunks = state.end_block();\n               for chunk in close_chunks {\n                   yield Ok(chunk);\n               }\n            }\n\n            // 2. Inject system message to inform user\n            // We use a new text block for this.\n            let recovery_msg = \"\\n\\n[System] Upstream model interrupted after thinking. (Recovered by Antigravity)\";\n            let start_chunks = state.start_block(\n                crate::proxy::mappers::claude::streaming::BlockType::Text, \n                serde_json::json!({ \"type\": \"text\", \"text\": recovery_msg })\n            );\n            for chunk in start_chunks { yield Ok(chunk); }\n            \n            let stop_chunks = state.end_block();\n            for chunk in stop_chunks { yield Ok(chunk); }\n\n            // 3. Mark as content received so we don't trigger this again (though loop is done)\n            state.has_content = true;\n\n            // 4. Send a simulated usage update to ensure we have > 0 output tokens\n            // Estimate based on some default if we didn't get any usage\n            let recovery_usage = crate::proxy::mappers::claude::models::Usage {\n                input_tokens: 0, // We don't know input, but output is critical\n                output_tokens: 100, // Arbitrary small number to satisfy client\n                cache_read_input_tokens: None,\n                cache_creation_input_tokens: None,\n                server_tool_use: None,\n            };\n\n            let delta = serde_json::json!({\n                \"type\": \"message_delta\",\n                \"delta\": { \"stop_reason\": \"end_turn\", \"stop_sequence\": null },\n                \"usage\": recovery_usage\n            });\n\n            yield Ok(state.emit(\"message_delta\", delta));\n        }\n\n        // Ensure termination events are sent\n        for chunk in emit_force_stop(&mut state) {\n            yield Ok(chunk);\n        }\n    })\n}\n\n/// 处理单行 SSE 数据\nfn process_sse_line(line: &str, state: &mut StreamingState, trace_id: &str, email: &str) -> Option<Vec<Bytes>> {\n    if !line.starts_with(\"data: \") {\n        return None;\n    }\n\n    let data_str = line[6..].trim();\n    if data_str.is_empty() {\n        return None;\n    }\n\n    if data_str == \"[DONE]\" {\n        let chunks = emit_force_stop(state);\n        if chunks.is_empty() {\n            return None;\n        }\n        return Some(chunks);\n    }\n\n    // 解析 JSON\n    let json_value: serde_json::Value = match serde_json::from_str(data_str) {\n        Ok(v) => v,\n        Err(_) => return None,\n    };\n\n    let mut chunks = Vec::new();\n\n    // 解包 response 字段 (如果存在)\n    let raw_json = json_value.get(\"response\").unwrap_or(&json_value);\n\n    // 发送 message_start\n    if !state.message_start_sent {\n        chunks.push(state.emit_message_start(raw_json));\n    }\n\n    // 捕获 groundingMetadata (Web Search)\n    if let Some(candidate) = raw_json.get(\"candidates\").and_then(|c| c.get(0)) {\n        if let Some(grounding) = candidate.get(\"groundingMetadata\") {\n            // 提取搜索词\n            if let Some(query) = grounding.get(\"webSearchQueries\")\n                .and_then(|v| v.as_array())\n                .and_then(|arr| arr.get(0))\n                .and_then(|v| v.as_str())\n            {\n                state.web_search_query = Some(query.to_string());\n            }\n\n            // 提取结果块\n            if let Some(chunks_arr) = grounding.get(\"groundingChunks\").and_then(|v| v.as_array()) {\n                state.grounding_chunks = Some(chunks_arr.clone());\n            } else if let Some(chunks_arr) = grounding.get(\"grounding_metadata\").and_then(|m| m.get(\"groundingChunks\")).and_then(|v| v.as_array()) {\n                state.grounding_chunks = Some(chunks_arr.clone());\n            }\n        }\n    }\n\n    // 处理所有 parts\n    if let Some(parts) = raw_json\n        .get(\"candidates\")\n        .and_then(|c| c.get(0))\n        .and_then(|cand| cand.get(\"content\"))\n        .and_then(|content| content.get(\"parts\"))\n        .and_then(|p| p.as_array())\n    {\n        for part_value in parts {\n            if let Ok(part) = serde_json::from_value::<GeminiPart>(part_value.clone()) {\n                let mut processor = PartProcessor::new(state);\n                chunks.extend(processor.process(&part));\n            }\n        }\n    }\n\n    // Process grounding metadata (googleSearch results) and append as citations\n    // [DISABLED] Temporarily disabled to fix Cherry Studio compatibility\n    // Cherry Studio doesn't recognize \"web_search_tool_result\" type, causing validation errors\n    // Search results are still displayed via Markdown text block in streaming.rs (lines 341-381)\n\n    /*\n    if let Some(grounding) = raw_json\n        .get(\"candidates\")\n        .and_then(|c| c.get(0))\n        .and_then(|cand| cand.get(\"groundingMetadata\"))\n    {\n        if let Some(citation_chunks) = process_grounding_metadata(grounding, state) {\n            chunks.extend(citation_chunks);\n        }\n    }\n    */\n\n    // 检查是否结束\n    if let Some(finish_reason) = raw_json\n        .get(\"candidates\")\n        .and_then(|c| c.get(0))\n        .and_then(|cand| cand.get(\"finishReason\"))\n        .and_then(|f| f.as_str())\n    {\n        let usage = raw_json\n            .get(\"usageMetadata\")\n            .and_then(|u| serde_json::from_value::<UsageMetadata>(u.clone()).ok());\n\n        if let Some(ref u) = usage {\n            let cached_tokens = u.cached_content_token_count.unwrap_or(0);\n            let cache_info = if cached_tokens > 0 {\n                format!(\", Cached: {}\", cached_tokens)\n            } else {\n                String::new()\n            };\n            \n             tracing::info!(\n                 \"[{}] ✓ Stream completed | Account: {} | In: {} tokens | Out: {} tokens{}\", \n                 trace_id,\n                 email,\n                 u.prompt_token_count.unwrap_or(0).saturating_sub(cached_tokens), \n                 u.candidates_token_count.unwrap_or(0),\n                 cache_info\n             );\n        }\n\n        chunks.extend(state.emit_finish(Some(finish_reason), usage.as_ref()));\n    }\n\n    if chunks.is_empty() {\n        None\n    } else {\n        Some(chunks)\n    }\n}\n\n/// 发送强制结束事件\npub fn emit_force_stop(state: &mut StreamingState) -> Vec<Bytes> {\n    if !state.message_stop_sent {\n        let mut chunks = state.emit_finish(None, None);\n        if chunks.is_empty() {\n            chunks.push(Bytes::from(\n                \"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\",\n            ));\n            state.message_stop_sent = true;\n        }\n        return chunks;\n    }\n    vec![]\n}\n\n/// Process grounding metadata from Gemini's googleSearch and emit as Claude web_search blocks\n#[allow(dead_code)] // Temporarily disabled for Cherry Studio compatibility, kept for future use\nfn process_grounding_metadata(\n    metadata: &serde_json::Value,\n    state: &mut StreamingState,\n) -> Option<Vec<Bytes>> {\n    use serde_json::json;\n\n    // Extract search queries and grounding chunks\n    let search_queries = metadata\n        .get(\"webSearchQueries\")\n        .and_then(|q| q.as_array())\n        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())\n        .unwrap_or_default();\n\n    let grounding_chunks = metadata.get(\"groundingChunks\").and_then(|c| c.as_array())?;\n\n    if grounding_chunks.is_empty() {\n        return None;\n    }\n\n    // Generate a unique tool_use_id\n    let tool_use_id = format!(\n        \"srvtoolu_{}\",\n        crate::proxy::common::utils::generate_random_id()\n    );\n\n    // Build search results array\n    let mut search_results = Vec::new();\n    for chunk in grounding_chunks.iter() {\n        if let Some(web) = chunk.get(\"web\") {\n            let title = web\n                .get(\"title\")\n                .and_then(|t| t.as_str())\n                .unwrap_or(\"Source\");\n            let uri = web.get(\"uri\").and_then(|u| u.as_str()).unwrap_or(\"\");\n            if !uri.is_empty() {\n                search_results.push(json!({\n                    \"url\": uri,\n                    \"title\": title,\n                    \"encrypted_content\": \"\", // Gemini doesn't provide this\n                    \"page_age\": null\n                }));\n            }\n        }\n    }\n\n    if search_results.is_empty() {\n        return None;\n    }\n\n    let search_query = search_queries\n        .first()\n        .map(|s| s.to_string())\n        .unwrap_or_default();\n\n    tracing::debug!(\n        \"[Grounding] Emitting {} search results for query: {}\",\n        search_results.len(),\n        search_query\n    );\n\n    let mut chunks = Vec::new();\n\n    // 1. Emit server_tool_use block (start)\n    let server_tool_use_start = json!({\n        \"type\": \"content_block_start\",\n        \"index\": state.block_index,\n        \"content_block\": {\n            \"type\": \"server_tool_use\",\n            \"id\": tool_use_id,\n            \"name\": \"web_search\",\n            \"input\": {\n                \"query\": search_query\n            }\n        }\n    });\n    chunks.push(Bytes::from(format!(\n        \"event: content_block_start\\ndata: {}\\n\\n\",\n        server_tool_use_start\n    )));\n\n    // server_tool_use block stop\n    let server_tool_use_stop = json!({\n        \"type\": \"content_block_stop\",\n        \"index\": state.block_index\n    });\n    chunks.push(Bytes::from(format!(\n        \"event: content_block_stop\\ndata: {}\\n\\n\",\n        server_tool_use_stop\n    )));\n    state.block_index += 1;\n\n    // 2. Emit web_search_tool_result block (start)\n    let tool_result_start = json!({\n        \"type\": \"content_block_start\",\n        \"index\": state.block_index,\n        \"content_block\": {\n            \"type\": \"web_search_tool_result\",\n            \"tool_use_id\": tool_use_id,\n            \"content\": search_results\n        }\n    });\n    chunks.push(Bytes::from(format!(\n        \"event: content_block_start\\ndata: {}\\n\\n\",\n        tool_result_start\n    )));\n\n    // web_search_tool_result block stop\n    let tool_result_stop = json!({\n        \"type\": \"content_block_stop\",\n        \"index\": state.block_index\n    });\n    chunks.push(Bytes::from(format!(\n        \"event: content_block_stop\\ndata: {}\\n\\n\",\n        tool_result_stop\n    )));\n    state.block_index += 1;\n\n    Some(chunks)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_process_sse_line_done() {\n        let mut state = StreamingState::new();\n        let result = process_sse_line(\"data: [DONE]\", &mut state, \"test_id\", \"test@example.com\");\n        assert!(result.is_some());\n        let chunks = result.unwrap();\n        assert!(!chunks.is_empty());\n\n        let all_text: String = chunks\n            .iter()\n            .map(|b| String::from_utf8(b.to_vec()).unwrap_or_default())\n            .collect();\n        assert!(all_text.contains(\"message_stop\"));\n    }\n\n    #[test]\n    fn test_process_sse_line_with_text() {\n        let mut state = StreamingState::new();\n\n        let test_data = r#\"data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello\"}]}}],\"usageMetadata\":{},\"modelVersion\":\"test\",\"responseId\":\"123\"}\"#;\n        \n        let result = process_sse_line(test_data, &mut state, \"test_id\", \"test@example.com\");\n        assert!(result.is_some());\n\n        let chunks = result.unwrap();\n        assert!(!chunks.is_empty());\n\n        // 应该包含 message_start 和 text delta\n        let all_text: String = chunks\n            .iter()\n            .map(|b| String::from_utf8(b.to_vec()).unwrap_or_default())\n            .collect();\n\n        assert!(all_text.contains(\"message_start\"));\n        assert!(all_text.contains(\"content_block_start\"));\n        assert!(all_text.contains(\"Hello\"));\n    }\n\n    #[tokio::test]\n    async fn test_thinking_only_interruption_recovery() {\n        use futures::StreamExt;\n        \n        // 1. 模拟一个只发送 Thinking 然后就结束的流\n        let mock_stream = async_stream::stream! {\n            // 发送 Thinking 块\n            let thinking_json = serde_json::json!({\n                \"candidates\": [{\n                    \"content\": {\n                        \"parts\": [{ \"text\": \"Thinking...\", \"thought\": true }]\n                    }\n                }],\n                \"modelVersion\": \"gemini-2.0-flash-thinking\",\n                \"responseId\": \"msg_interrupted\"\n            });\n            yield Ok::<_, String>(bytes::Bytes::from(format!(\"data: {}\\n\\n\", thinking_json)));\n            \n            // 然后突然结束 (没有 Text, 没有 Usage, 直接 None)\n        };\n\n        // 2. 创建转换后的流\n        let mut claude_stream = create_claude_sse_stream(\n            Box::pin(mock_stream),\n            \"trace_test\".to_string(),\n            \"test@example.com\".to_string(),\n            None,\n            false,\n            1_000,\n            None,\n            1, // message_count\n            None, // client_adapter\n            Vec::new(), // registered_tool_names\n        );\n\n        // 3. 收集输出\n        let mut all_chunks = Vec::new();\n        while let Some(result) = claude_stream.next().await {\n            if let Ok(bytes) = result {\n                all_chunks.push(String::from_utf8(bytes.to_vec()).unwrap());\n            }\n        }\n        let output = all_chunks.join(\"\");\n\n        // 4. 验证恢复逻辑\n        // 必须包含 Thinking\n        assert!(output.contains(\"Thinking...\"));\n        \n        // 必须包含恢复的系统提示\n        assert!(output.contains(\"Recovered by Antigravity\"));\n        \n        // 必须包含模拟的 Usage\n        assert!(output.contains(\"\\\"usage\\\":\"));\n        assert!(output.contains(\"\\\"output_tokens\\\":100\")); // Should contain the recovery usage\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/claude/models.rs",
    "content": "// Claude 数据模型\n// Claude 协议相关数据模型\n\nuse serde::{Deserialize, Serialize};\n\n/// Claude API 请求\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeRequest {\n    pub model: String,\n    pub messages: Vec<Message>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub system: Option<SystemPrompt>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub tools: Option<Vec<Tool>>,\n    #[serde(default)]\n    pub stream: bool,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub max_tokens: Option<u32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub temperature: Option<f64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub top_p: Option<f64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub top_k: Option<u32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub thinking: Option<ThinkingConfig>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub metadata: Option<Metadata>,\n    /// Output configuration for effort level (Claude API v2.0.67+)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub output_config: Option<OutputConfig>,\n    // [NEW] Image generation parameters (for Anthropic protocol compatibility)\n    #[serde(default)]\n    pub size: Option<String>,\n    #[serde(default)]\n    pub quality: Option<String>,\n}\n\n/// Thinking 配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ThinkingConfig {\n    #[serde(rename = \"type\")]\n    pub type_: String, // \"enabled\" or \"adaptive\"\n    #[serde(alias = \"budgetTokens\")]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub budget_tokens: Option<u32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub effort: Option<String>, // \"low\", \"high\", or \"max\"\n}\n\n\n/// System Prompt\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(untagged)]\npub enum SystemPrompt {\n    String(String),\n    Array(Vec<SystemBlock>),\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SystemBlock {\n    #[serde(rename = \"type\")]\n    pub block_type: String,\n    pub text: String,\n}\n\n/// Message\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Message {\n    pub role: String,\n    pub content: MessageContent,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(untagged)]\npub enum MessageContent {\n    String(String),\n    Array(Vec<ContentBlock>),\n}\n\n/// Content Block (Claude)\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\")]\npub enum ContentBlock {\n    #[serde(rename = \"text\")]\n    Text { text: String },\n\n    #[serde(rename = \"thinking\")]\n    Thinking {\n        thinking: String,\n        #[serde(skip_serializing_if = \"Option::is_none\")]\n        signature: Option<String>,\n        #[serde(skip_serializing_if = \"Option::is_none\")]\n        cache_control: Option<serde_json::Value>,\n    },\n\n    #[serde(rename = \"image\")]\n    Image {\n        source: ImageSource,\n        #[serde(skip_serializing_if = \"Option::is_none\")]\n        cache_control: Option<serde_json::Value>,\n    },\n\n    #[serde(rename = \"document\")]\n    Document {\n        source: DocumentSource,\n        #[serde(skip_serializing_if = \"Option::is_none\")]\n        cache_control: Option<serde_json::Value>,\n    },\n\n    #[serde(rename = \"redacted_thinking\")]\n    RedactedThinking { data: String },\n\n    #[serde(rename = \"tool_use\")]\n    ToolUse {\n        id: String,\n        name: String,\n        input: serde_json::Value,\n        #[serde(skip_serializing_if = \"Option::is_none\")]\n        signature: Option<String>,\n        #[serde(skip_serializing_if = \"Option::is_none\")]\n        cache_control: Option<serde_json::Value>,\n    },\n\n    #[serde(rename = \"tool_result\")]\n    ToolResult {\n        tool_use_id: String,\n        content: serde_json::Value, // Changed from String to Value to support Array of Blocks\n        #[serde(skip_serializing_if = \"Option::is_none\")]\n        is_error: Option<bool>,\n    },\n\n    #[serde(rename = \"server_tool_use\")]\n    ServerToolUse {\n        id: String,\n        name: String,\n        input: serde_json::Value,\n    },\n\n    #[serde(rename = \"web_search_tool_result\")]\n    WebSearchToolResult {\n        tool_use_id: String,\n        content: serde_json::Value,\n    },\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ImageSource {\n    #[serde(rename = \"type\")]\n    pub source_type: String,\n    pub media_type: String,\n    pub data: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct DocumentSource {\n    #[serde(rename = \"type\")]\n    pub source_type: String, // \"base64\"\n    pub media_type: String, // e.g. \"application/pdf\"\n    pub data: String,       // base64 data\n}\n\n/// Tool - supports both client tools (with input_schema) and server tools (like web_search)\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Tool {\n    /// Tool type - for server tools like \"web_search_20250305\"\n    #[serde(rename = \"type\")]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub type_: Option<String>,\n    /// Tool name - \"web_search\" for server tools, custom name for client tools\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub name: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub description: Option<String>,\n    /// Input schema - required for client tools, absent for server tools\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub input_schema: Option<serde_json::Value>,\n}\n\nimpl Tool {\n    /// Check if this is the web_search server tool\n    pub fn is_web_search(&self) -> bool {\n        // Check by type (preferred for server tools)\n        if let Some(ref t) = self.type_ {\n            if t.starts_with(\"web_search\") {\n                return true;\n            }\n        }\n        // Check by name (fallback)\n        if let Some(ref n) = self.name {\n            if n == \"web_search\" {\n                return true;\n            }\n        }\n        false\n    }\n\n    /// Get the effective tool name\n    #[allow(dead_code)]\n    pub fn get_name(&self) -> String {\n        self.name.clone().unwrap_or_else(|| {\n            // For server tools, derive name from type\n            if let Some(ref t) = self.type_ {\n                if t.starts_with(\"web_search\") {\n                    return \"web_search\".to_string();\n                }\n            }\n            \"unknown\".to_string()\n        })\n    }\n}\n\n/// Metadata\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Metadata {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub user_id: Option<String>,\n}\n\n/// Output Configuration (Claude API v2.0.67+)\n/// Controls effort level for model reasoning\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OutputConfig {\n    /// Effort level: \"high\", \"medium\", \"low\"\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub effort: Option<String>,\n}\n\n/// Claude API 响应\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeResponse {\n    pub id: String,\n    #[serde(rename = \"type\")]\n    pub type_: String,\n    pub role: String,\n    pub model: String,\n    pub content: Vec<ContentBlock>,\n    pub stop_reason: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub stop_sequence: Option<String>,\n    pub usage: Usage,\n}\n\n/// Usage\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Usage {\n    pub input_tokens: u32,\n    pub output_tokens: u32,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub cache_read_input_tokens: Option<u32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub cache_creation_input_tokens: Option<u32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub server_tool_use: Option<serde_json::Value>,\n}\n\n// ========== Gemini 数据模型 ==========\n\n/// Gemini Content\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct GeminiContent {\n    pub role: String,\n    pub parts: Vec<GeminiPart>,\n}\n\n/// Gemini Part\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct GeminiPart {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub text: Option<String>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub thought: Option<bool>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"thoughtSignature\")]\n    pub thought_signature: Option<String>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"functionCall\")]\n    pub function_call: Option<FunctionCall>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"functionResponse\")]\n    pub function_response: Option<FunctionResponse>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"inlineData\")]\n    pub inline_data: Option<InlineData>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct FunctionCall {\n    pub name: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub id: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub args: Option<serde_json::Value>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct FunctionResponse {\n    pub name: String,\n    pub response: serde_json::Value,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub id: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct InlineData {\n    #[serde(rename = \"mimeType\")]\n    pub mime_type: String,\n    pub data: String,\n}\n\n/// Gemini 完整响应\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct GeminiResponse {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub candidates: Option<Vec<Candidate>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"usageMetadata\")]\n    pub usage_metadata: Option<UsageMetadata>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"modelVersion\")]\n    pub model_version: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"responseId\")]\n    pub response_id: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Candidate {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub content: Option<GeminiContent>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"finishReason\")]\n    pub finish_reason: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub index: Option<u32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"groundingMetadata\")]\n    pub grounding_metadata: Option<GroundingMetadata>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UsageMetadata {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"promptTokenCount\")]\n    pub prompt_token_count: Option<u32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"candidatesTokenCount\")]\n    pub candidates_token_count: Option<u32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"totalTokenCount\")]\n    pub total_token_count: Option<u32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"cachedContentTokenCount\")]\n    pub cached_content_token_count: Option<u32>,\n}\n\n// ========== Grounding Metadata (for googleSearch results) ==========\n\n/// Gemini Grounding Metadata - contains search results from googleSearch tool\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct GroundingMetadata {\n    #[serde(rename = \"webSearchQueries\")]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub web_search_queries: Option<Vec<String>>,\n\n    #[serde(rename = \"groundingChunks\")]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub grounding_chunks: Option<Vec<GroundingChunk>>,\n\n    #[serde(rename = \"groundingSupports\")]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub grounding_supports: Option<Vec<GroundingSupport>>,\n\n    #[serde(rename = \"searchEntryPoint\")]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub search_entry_point: Option<SearchEntryPoint>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct GroundingChunk {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub web: Option<WebSource>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct WebSource {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub uri: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub title: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct GroundingSupport {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub segment: Option<TextSegment>,\n    #[serde(rename = \"groundingChunkIndices\")]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub grounding_chunk_indices: Option<Vec<i32>>,\n    #[serde(rename = \"confidenceScores\")]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub confidence_scores: Option<Vec<f64>>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TextSegment {\n    #[serde(rename = \"startIndex\")]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub start_index: Option<i32>,\n    #[serde(rename = \"endIndex\")]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub end_index: Option<i32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub text: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SearchEntryPoint {\n    #[serde(rename = \"renderedContent\")]\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub rendered_content: Option<String>,\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/claude/request.rs",
    "content": "// Claude 请求转换 (Claude → Gemini v1internal)\n// 对应 transformClaudeRequestIn\n\nuse super::models::*;\nuse crate::proxy::mappers::signature_store::get_thought_signature; // Deprecated, kept for fallback\nuse crate::proxy::mappers::tool_result_compressor;\nuse crate::proxy::session_manager::SessionManager;\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\n\n// ===== Safety Settings Configuration =====\n\n/// Safety threshold levels for Gemini API\n/// Can be configured via GEMINI_SAFETY_THRESHOLD environment variable\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum SafetyThreshold {\n    /// Disable all safety filters (default for proxy compatibility)\n    Off,\n    /// Block low probability and above\n    BlockLowAndAbove,\n    /// Block medium probability and above\n    BlockMediumAndAbove,\n    /// Only block high probability content\n    BlockOnlyHigh,\n    /// Don't block anything (BLOCK_NONE)\n    BlockNone,\n}\n\nimpl SafetyThreshold {\n    /// Get threshold from environment variable or default to Off\n    pub fn from_env() -> Self {\n        match std::env::var(\"GEMINI_SAFETY_THRESHOLD\").as_deref() {\n            Ok(\"OFF\") | Ok(\"off\") => SafetyThreshold::Off,\n            Ok(\"LOW\") | Ok(\"low\") => SafetyThreshold::BlockLowAndAbove,\n            Ok(\"MEDIUM\") | Ok(\"medium\") => SafetyThreshold::BlockMediumAndAbove,\n            Ok(\"HIGH\") | Ok(\"high\") => SafetyThreshold::BlockOnlyHigh,\n            Ok(\"NONE\") | Ok(\"none\") => SafetyThreshold::BlockNone,\n            _ => SafetyThreshold::Off, // Default: maintain current behavior\n        }\n    }\n\n    /// Convert to Gemini API threshold string\n    pub fn to_gemini_threshold(&self) -> &'static str {\n        match self {\n            SafetyThreshold::Off => \"OFF\",\n            SafetyThreshold::BlockLowAndAbove => \"BLOCK_LOW_AND_ABOVE\",\n            SafetyThreshold::BlockMediumAndAbove => \"BLOCK_MEDIUM_AND_ABOVE\",\n            SafetyThreshold::BlockOnlyHigh => \"BLOCK_ONLY_HIGH\",\n            SafetyThreshold::BlockNone => \"BLOCK_NONE\",\n        }\n    }\n}\n\n/// Build safety settings based on configuration\nfn build_safety_settings() -> Value {\n    let threshold = SafetyThreshold::from_env();\n    let threshold_str = threshold.to_gemini_threshold();\n\n    json!([\n        { \"category\": \"HARM_CATEGORY_HARASSMENT\", \"threshold\": threshold_str },\n        { \"category\": \"HARM_CATEGORY_HATE_SPEECH\", \"threshold\": threshold_str },\n        { \"category\": \"HARM_CATEGORY_SEXUALLY_EXPLICIT\", \"threshold\": threshold_str },\n        { \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\", \"threshold\": threshold_str },\n        { \"category\": \"HARM_CATEGORY_CIVIC_INTEGRITY\", \"threshold\": threshold_str },\n    ])\n}\n\n/// 清理消息中的 cache_control 字段\n///\n/// 这个函数会深度遍历所有消息内容块,移除 cache_control 字段。\n/// 这是必要的,因为:\n/// 1. VS Code 等客户端会将历史消息(包含 cache_control)原封不动发回\n/// 2. Anthropic API 不接受请求中包含 cache_control 字段\n/// 3. 即使是转发到 Gemini,也应该清理以保持协议纯净性\n///\n/// [FIX #593] 增强版本:添加详细日志用于调试 MCP 工具兼容性问题\npub fn clean_cache_control_from_messages(messages: &mut [Message]) {\n    tracing::info!(\n        \"[DEBUG-593] Starting cache_control cleanup for {} messages\",\n        messages.len()\n    );\n\n    let mut total_cleaned = 0;\n\n    for (idx, msg) in messages.iter_mut().enumerate() {\n        if let MessageContent::Array(blocks) = &mut msg.content {\n            for (block_idx, block) in blocks.iter_mut().enumerate() {\n                match block {\n                    ContentBlock::Thinking { cache_control, .. } => {\n                        if cache_control.is_some() {\n                            tracing::info!(\n                                \"[ISSUE-744] Found cache_control in Thinking block at message[{}].content[{}]: {:?}\",\n                                idx,\n                                block_idx,\n                                cache_control\n                            );\n                            *cache_control = None;\n                            total_cleaned += 1;\n                        }\n                    }\n                    ContentBlock::Image { cache_control, .. } => {\n                        if cache_control.is_some() {\n                            tracing::debug!(\n                                \"[Cache-Control-Cleaner] Removed cache_control from Image block at message[{}].content[{}]\",\n                                idx,\n                                block_idx\n                            );\n                            *cache_control = None;\n                            total_cleaned += 1;\n                        }\n                    }\n                    ContentBlock::Document { cache_control, .. } => {\n                        if cache_control.is_some() {\n                            tracing::debug!(\n                                \"[Cache-Control-Cleaner] Removed cache_control from Document block at message[{}].content[{}]\",\n                                idx,\n                                block_idx\n                            );\n                            *cache_control = None;\n                            total_cleaned += 1;\n                        }\n                    }\n                    ContentBlock::ToolUse { cache_control, .. } => {\n                        if cache_control.is_some() {\n                            tracing::debug!(\n                                \"[Cache-Control-Cleaner] Removed cache_control from ToolUse block at message[{}].content[{}]\",\n                                idx,\n                                block_idx\n                            );\n                            *cache_control = None;\n                            total_cleaned += 1;\n                        }\n                    }\n                    _ => {}\n                }\n            }\n        }\n    }\n\n    if total_cleaned > 0 {\n        tracing::info!(\n            \"[DEBUG-593] Cache control cleanup complete: removed {} cache_control fields\",\n            total_cleaned\n        );\n    } else {\n        tracing::debug!(\"[DEBUG-593] No cache_control fields found\");\n    }\n}\n\n/// [FIX #593] 递归深度清理 JSON 中的 cache_control 字段\n///\n/// 用于处理嵌套结构和非标准位置的 cache_control。\n/// 这是最后一道防线,确保发送给 Antigravity 的请求中不包含任何 cache_control。\nfn deep_clean_cache_control(value: &mut Value) {\n    match value {\n        Value::Object(map) => {\n            if map.remove(\"cache_control\").is_some() {\n                tracing::debug!(\"[DEBUG-593] Removed cache_control from nested JSON object\");\n            }\n            for (_, v) in map.iter_mut() {\n                deep_clean_cache_control(v);\n            }\n        }\n        Value::Array(arr) => {\n            for item in arr.iter_mut() {\n                deep_clean_cache_control(item);\n            }\n        }\n        _ => {}\n    }\n}\n\n/// [FIX #564] Sort blocks in assistant messages to ensure thinking blocks are first\n///\n/// When context compression (kilo) reorders message blocks, thinking blocks may appear\n/// after text blocks. Claude/Anthropic API requires thinking blocks to be first if\n/// any thinking blocks exist in the message. This function pre-sorts blocks to ensure\n/// thinking/redacted_thinking blocks always come before other block types.\nfn sort_thinking_blocks_first(messages: &mut [Message]) {\n    for msg in messages.iter_mut() {\n        if msg.role == \"assistant\" {\n            if let MessageContent::Array(blocks) = &mut msg.content {\n                // [FIX #709] Triple-stage partition: [Thinking, Text, ToolUse]\n                // This ensures protocol compliance while maintaining logical order.\n\n                let mut thinking_blocks: Vec<ContentBlock> = Vec::new();\n                let mut text_blocks: Vec<ContentBlock> = Vec::new();\n                let mut tool_blocks: Vec<ContentBlock> = Vec::new();\n                let mut other_blocks: Vec<ContentBlock> = Vec::new();\n\n                let original_len = blocks.len();\n                let mut needs_reorder = false;\n                let mut saw_non_thinking = false;\n\n                for (_i, block) in blocks.iter().enumerate() {\n                    match block {\n                        ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {\n                            if saw_non_thinking {\n                                needs_reorder = true;\n                            }\n                        }\n                        ContentBlock::Text { .. } => {\n                            saw_non_thinking = true;\n                        }\n                        ContentBlock::ToolUse { .. } => {\n                            saw_non_thinking = true;\n                            // Check if tool is after text (this is normal, but we want a strict group order)\n                        }\n                        _ => saw_non_thinking = true,\n                    }\n                }\n\n                if needs_reorder || original_len > 1 {\n                    // For safety, we always perform the triple partition if there's more than one block.\n                    // This also handles empty text block filtering.\n                    for block in blocks.drain(..) {\n                        match &block {\n                            ContentBlock::Thinking { .. }\n                            | ContentBlock::RedactedThinking { .. } => {\n                                thinking_blocks.push(block);\n                            }\n                            ContentBlock::Text { text } => {\n                                // Filter out purely empty or structural text like \"(no content)\"\n                                if !text.trim().is_empty() && text != \"(no content)\" {\n                                    text_blocks.push(block);\n                                }\n                            }\n                            ContentBlock::ToolUse { .. } => {\n                                tool_blocks.push(block);\n                            }\n                            _ => {\n                                other_blocks.push(block);\n                            }\n                        }\n                    }\n\n                    // Reconstruct in strict order: Thinking -> Text/Other -> Tool\n                    blocks.extend(thinking_blocks);\n                    blocks.extend(text_blocks);\n                    blocks.extend(other_blocks);\n                    blocks.extend(tool_blocks);\n\n                    if needs_reorder {\n                        tracing::warn!(\n                            \"[FIX #709] Reordered assistant messages to [Thinking, Text, Tool] structure.\"\n                        );\n                    }\n                }\n            }\n        }\n    }\n}\n\n/// 合并 ClaudeRequest 中连续的同角色消息\n///\n/// 场景: 当从 Spec/Plan 模式切换回编码模式时，可能出现连续两条 \"user\" 消息\n/// (一条是 ToolResult，一条是 <system-reminder>)。\n/// 这会违反角色交替规则，导致 400 报错。\npub fn merge_consecutive_messages(messages: &mut Vec<Message>) {\n    if messages.len() <= 1 {\n        return;\n    }\n\n    let mut merged: Vec<Message> = Vec::with_capacity(messages.len());\n    let old_messages = std::mem::take(messages);\n    let mut messages_iter = old_messages.into_iter();\n\n    if let Some(mut current) = messages_iter.next() {\n        for next in messages_iter {\n            if current.role == next.role {\n                // 合并内容\n                match (&mut current.content, next.content) {\n                    (MessageContent::Array(current_blocks), MessageContent::Array(next_blocks)) => {\n                        current_blocks.extend(next_blocks);\n                    }\n                    (MessageContent::Array(current_blocks), MessageContent::String(next_text)) => {\n                        current_blocks.push(ContentBlock::Text { text: next_text });\n                    }\n                    (MessageContent::String(current_text), MessageContent::String(next_text)) => {\n                        *current_text = format!(\"{}\\n\\n{}\", current_text, next_text);\n                    }\n                    (MessageContent::String(current_text), MessageContent::Array(next_blocks)) => {\n                        let mut new_blocks = vec![ContentBlock::Text {\n                            text: current_text.clone(),\n                        }];\n                        new_blocks.extend(next_blocks);\n                        current.content = MessageContent::Array(new_blocks);\n                    }\n                }\n            } else {\n                merged.push(current);\n                current = next;\n            }\n        }\n        merged.push(current);\n    }\n\n    *messages = merged;\n}\n\n/// 转换 Claude 请求为 Gemini v1internal 格式\n\n/// [FIX #709] Reorder serialized Gemini parts to ensure thinking blocks are first\nfn reorder_gemini_parts(parts: &mut Vec<Value>) {\n    if parts.len() <= 1 {\n        return;\n    }\n\n    let mut thinking_parts = Vec::new();\n    let mut text_parts = Vec::new();\n    let mut tool_parts = Vec::new();\n    let mut other_parts = Vec::new();\n\n    for part in parts.drain(..) {\n        if part.get(\"thought\").and_then(|t| t.as_bool()) == Some(true) {\n            thinking_parts.push(part);\n        } else if part.get(\"functionCall\").is_some() {\n            tool_parts.push(part);\n        } else if let Some(text) = part.get(\"text\").and_then(|t| t.as_str()) {\n            // Filter empty text parts that might have been created during merging\n            if !text.trim().is_empty() && text != \"(no content)\" {\n                text_parts.push(part);\n            }\n        } else {\n            other_parts.push(part);\n        }\n    }\n\n    parts.extend(thinking_parts);\n    parts.extend(text_parts);\n    parts.extend(other_parts);\n    parts.extend(tool_parts);\n}\n\npub fn transform_claude_request_in(\n    claude_req: &ClaudeRequest,\n    project_id: &str,\n    is_retry: bool,\n    account_id: Option<&str>,\n    _session_id: &str,\n    token: Option<&crate::proxy::token_manager::ProxyToken>, // [NEW] 支持动态规格\n) -> Result<Value, String> {\n    let message_count = claude_req.messages.len();\n\n    // [CRITICAL FIX] 预先清理所有消息中的 cache_control 字段\n    // 这解决了 VS Code 插件等客户端在多轮对话中将历史消息的 cache_control 字段\n    // 原封不动发回导致的 \"Extra inputs are not permitted\" 错误\n    let mut cleaned_req = claude_req.clone();\n\n    // [FIX #813] 合并连续的同角色消息 (Consecutive User Messages)\n    // 确保请求符合 Anthropic 和 Gemini 的角色交替协议\n    merge_consecutive_messages(&mut cleaned_req.messages);\n\n    clean_cache_control_from_messages(&mut cleaned_req.messages);\n\n    // [FIX #564] Pre-sort thinking blocks to be first in assistant messages\n    // This handles cases where context compression (kilo) incorrectly reorders blocks\n    sort_thinking_blocks_first(&mut cleaned_req.messages);\n\n    // [FIX #1747] If thinking is auto-enabled by model default (e.g. Opus) but no\n    // ThinkingConfig was provided by the client, inject a default config with a budget\n    // to prevent 'thinking requires a budget' errors from upstream APIs.\n    if cleaned_req.thinking.is_none() && should_enable_thinking_by_default(&cleaned_req.model) {\n        let default_budget = crate::proxy::model_specs::get_thinking_budget(&cleaned_req.model, token);\n        tracing::info!(\n            \"[Thinking-Mode] Injecting default ThinkingConfig (budget={}) for model: {}\",\n            default_budget,\n            cleaned_req.model\n        );\n        cleaned_req.thinking = Some(ThinkingConfig {\n            type_: \"enabled\".to_string(),\n            budget_tokens: Some(default_budget as u32),\n            effort: None,\n        });\n    }\n\n    let claude_req = &cleaned_req; // 后续使用清理后的请求\n\n    // [NEW] Generate session ID for signature tracking\n    // This enables session-isolated signature storage, preventing cross-conversation pollution\n    let session_id = SessionManager::extract_session_id(claude_req);\n    tracing::debug!(\"[Claude-Request] Session ID: {}\", session_id);\n\n    // 检测是否有联网工具 (server tool or built-in tool)\n    let has_web_search_tool = claude_req\n        .tools\n        .as_ref()\n        .map(|tools| {\n            tools.iter().any(|t| {\n                t.is_web_search()\n                    || t.name.as_deref() == Some(\"google_search\")\n                    || t.name.as_deref() == Some(\"builtin_web_search\")\n                    || t.type_.as_deref() == Some(\"web_search_20250305\")\n                    || t.type_.as_deref() == Some(\"builtin_web_search\")\n            })\n        })\n        .unwrap_or(false);\n\n    // 用于存储 tool_use id -> name 映射\n    let mut tool_id_to_name: HashMap<String, String> = HashMap::new();\n\n    // 检测是否有 mcp__ 开头的工具\n    let has_mcp_tools = claude_req\n        .tools\n        .as_ref()\n        .map(|tools| {\n            tools.iter().any(|t| {\n                t.name\n                    .as_deref()\n                    .map(|n| n.starts_with(\"mcp__\"))\n                    .unwrap_or(false)\n            })\n        })\n        .unwrap_or(false);\n\n    // [New] 预先构建工具名称到原始 Schema 的映射，用于后续参数类型修正\n    let mut tool_name_to_schema = HashMap::new();\n    if let Some(tools) = &claude_req.tools {\n        for tool in tools {\n            if let (Some(name), Some(schema)) = (&tool.name, &tool.input_schema) {\n                tool_name_to_schema.insert(name.clone(), schema.clone());\n            }\n        }\n    }\n\n    // 1. System Instruction (注入动态身份防护 & MCP XML 协议)\n    let system_instruction =\n        build_system_instruction(&claude_req.system, &claude_req.model, has_mcp_tools);\n\n    //  Map model name (Use standard mapping)\n    // [IMPROVED] 提取 web search 模型为常量，便于维护\n    const WEB_SEARCH_FALLBACK_MODEL: &str = \"gemini-2.5-flash\";\n\n    let mapped_model = crate::proxy::common::model_mapping::map_claude_model_to_gemini(&claude_req.model);\n\n    // 将 Claude 工具转为 Value 数组以便探测联网\n    let tools_val: Option<Vec<Value>> = claude_req.tools.as_ref().map(|list| {\n        list.iter()\n            .map(|t| serde_json::to_value(t).unwrap_or(json!({})))\n            .collect()\n    });\n\n    // Resolve grounding config\n    let config = crate::proxy::mappers::common_utils::resolve_request_config(\n        &claude_req.model,\n        &mapped_model,\n        &tools_val,\n        claude_req.size.as_deref(),    // [NEW] Pass size parameter\n        claude_req.quality.as_deref(), // [NEW] Pass quality parameter\n        None,                          // [NEW] image_size\n        None,                          // body\n    );\n\n    // [CRITICAL FIX] Disable dummy thought injection for Vertex AI\n    // [CRITICAL FIX] Disable dummy thought injection for Vertex AI\n    // Vertex AI rejects thinking blocks without valid signatures\n    // Even if thinking is enabled, we should NOT inject dummy blocks for historical messages\n    let allow_dummy_thought = false;\n\n    // Check if thinking is enabled in the request\n    let thinking_type = claude_req.thinking.as_ref().map(|t| t.type_.as_str());\n    let mut is_thinking_enabled = thinking_type == Some(\"enabled\") || thinking_type == Some(\"adaptive\") \n        || (thinking_type.is_none() && should_enable_thinking_by_default(&claude_req.model));\n\n    // [NEW FIX] Check if target model supports thinking\n    // Only models with \"-thinking\" suffix or Claude models support thinking\n    // Regular Gemini models (gemini-2.5-flash, gemini-2.5-pro) do NOT support thinking\n    // [FIX #1557] Allow \"pro\" models (e.g. gemini-3-pro, gemini-2.0-pro) to be recognized as thinking capable\n    let target_model_supports_thinking = mapped_model.contains(\"-thinking\")\n        || mapped_model.starts_with(\"claude-\")\n        || mapped_model.contains(\"gemini-2.0-pro\")\n        || mapped_model.contains(\"gemini-3-pro\")\n        || mapped_model.contains(\"gemini-3.1-pro\")\n        // [FIX #2167] gemini-3-flash / gemini-3.1-flash 支持 thinking，必须纳入识别范围\n        || mapped_model.contains(\"gemini-3-flash\")\n        || mapped_model.contains(\"gemini-3.1-flash\");\n\n    if is_thinking_enabled && !target_model_supports_thinking {\n        tracing::warn!(\n            \"[Thinking-Mode] Target model '{}' does not support thinking. Force disabling thinking mode.\",\n            mapped_model\n        );\n        is_thinking_enabled = false;\n    }\n\n    // [REMOVED] 智能降级检查 (should_disable_thinking_due_to_history)\n    // 原因: 该检查过于激进，会导致 Claude Code CLI 在历史记录不完美时永久禁用思考模式 (Issue #2006)\n    // 现在的策略是依赖 thinking_utils.rs 中的 Recovery 机制来修复历史，而不是禁用思考。\n\n\n    // [FIX #295 & #298] If thinking enabled but no signature available,\n    // disable thinking to prevent Gemini 3 Pro rejection\n    if is_thinking_enabled {\n        let global_sig = get_thought_signature();\n\n        // Check if there are any thinking blocks in message history\n        let has_thinking_history = claude_req.messages.iter().any(|m| {\n            if m.role == \"assistant\" {\n                if let MessageContent::Array(blocks) = &m.content {\n                    return blocks\n                        .iter()\n                        .any(|b| matches!(b, ContentBlock::Thinking { .. }));\n                }\n            }\n            false\n        });\n\n        // Check if there are function calls in the request\n        let has_function_calls = claude_req.messages.iter().any(|m| {\n            if let MessageContent::Array(blocks) = &m.content {\n                blocks\n                    .iter()\n                    .any(|b| matches!(b, ContentBlock::ToolUse { .. }))\n            } else {\n                false\n            }\n        });\n\n        // [FIX #298] For first-time thinking requests (no thinking history),\n        // we use permissive mode and let upstream handle validation.\n        // We only enforce strict signature checks when function calls are involved.\n        let needs_signature_check = has_function_calls;\n\n        if !has_thinking_history && is_thinking_enabled {\n            tracing::info!(\n                \"[Thinking-Mode] First thinking request detected. Using permissive mode - \\\n                 signature validation will be handled by upstream API.\"\n            );\n        }\n\n        if needs_signature_check\n            && !has_valid_signature_for_function_calls(\n                &claude_req.messages,\n                &global_sig,\n                &session_id,\n            )\n        {\n            // [FIX #2167] Flash 模型无签名时使用哨兵值而不是禁用 thinking\n            // 禁用 thinking 会导致模型失去思考能力，哨兵值可让 Gemini 跳过签名校验\n            let is_flash_model = mapped_model.contains(\"gemini-3-flash\")\n                || mapped_model.contains(\"gemini-3.1-flash\");\n            if is_flash_model {\n                tracing::info!(\n                    \"[Thinking-Mode] [FIX #2167] No signature for flash model function calls. \\\n                     Will rely on sentinel injection in build_contents.\"\n                );\n                // 保持 is_thinking_enabled = true，由 build_contents 内的哨兵处理覆盖\n            } else {\n                tracing::warn!(\n                    \"[Thinking-Mode] [FIX #295] No valid signature found for function calls. \\\n                     Disabling thinking to prevent Gemini 3 Pro rejection.\"\n                );\n                is_thinking_enabled = false;\n            }\n        }\n    }\n\n    // 4. Generation Config & Thinking (Pass final is_thinking_enabled)\n    let generation_config = build_generation_config(\n        claude_req,\n        &mapped_model,\n        has_web_search_tool,\n        is_thinking_enabled,\n        token, // [NEW] 传递 token 用于动态限额\n    );\n\n    // 2. Contents (Messages)\n    let contents = build_google_contents(\n        &claude_req.messages,\n        claude_req,\n        &mut tool_id_to_name,\n        &tool_name_to_schema,\n        is_thinking_enabled,\n        allow_dummy_thought,\n        &mapped_model,\n        &session_id,\n        is_retry,\n    )?;\n\n    // 3. Tools\n    let tools = build_tools(&claude_req.tools, has_web_search_tool, &mapped_model)?;\n\n    // 5. Safety Settings (configurable via GEMINI_SAFETY_THRESHOLD env var)\n    let safety_settings = build_safety_settings();\n\n    // Build inner request\n    let mut inner_request = json!({\n        \"contents\": contents,\n        \"safetySettings\": safety_settings,\n    });\n\n    if let Some(sys_inst) = system_instruction {\n        inner_request[\"systemInstruction\"] = sys_inst;\n    }\n\n    if !generation_config.is_null() {\n        println!(\"DEBUG: Assigning generation_config: {}\", generation_config);\n        inner_request[\"generationConfig\"] = generation_config;\n    }\n\n    if let Some(tools_val) = tools {\n        inner_request[\"tools\"] = tools_val;\n        // 显式设置工具配置模式为 VALIDATED\n        inner_request[\"toolConfig\"] = json!({\n            \"functionCallingConfig\": {\n                \"mode\": \"VALIDATED\"\n            }\n        });\n    }\n\n\n    // 深度清理 [undefined] 字符串 (Cherry Studio 等客户端常见注入)\n    crate::proxy::mappers::common_utils::deep_clean_undefined(&mut inner_request, 0);\n\n\n    if config.inject_google_search && !has_web_search_tool {\n        crate::proxy::mappers::common_utils::inject_google_search_tool(&mut inner_request, Some(&mapped_model));\n    }\n\n    // Inject imageConfig if present (for image generation models)\n    if let Some(image_config) = config.image_config {\n        if let Some(obj) = inner_request.as_object_mut() {\n            // 1. Remove tools (image generation does not support tools)\n            obj.remove(\"tools\");\n\n            // 2. Remove systemInstruction (image generation does not support system prompts)\n            obj.remove(\"systemInstruction\");\n\n            // 3. Clean generationConfig (remove responseMimeType, responseModalities etc.)\n            let gen_config = obj.entry(\"generationConfig\").or_insert_with(|| json!({}));\n            if let Some(gen_obj) = gen_config.as_object_mut() {\n                // [RESOLVE #1694] Check image thinking mode\n                let image_thinking_mode = crate::proxy::config::get_image_thinking_mode();\n                if image_thinking_mode == \"disabled\" {\n                    tracing::debug!(\n                        \"[Claude-Request] Image thinking mode disabled: enforcing includeThoughts=false for {}\",\n                        mapped_model\n                    );\n                    gen_obj.insert(\n                        \"thinkingConfig\".to_string(),\n                        json!({\n                            \"includeThoughts\": false\n                        }),\n                    );\n                }\n\n                gen_obj.remove(\"responseMimeType\");\n                gen_obj.remove(\"responseModalities\");\n                gen_obj.insert(\"imageConfig\".to_string(), image_config);\n            }\n        }\n    }\n\n    // [ADDED v4.1.24] 注入稳定 sessionId 对齐官方规范\n    if let Some(account_id) = account_id {\n        inner_request[\"sessionId\"] = json!(crate::proxy::common::session::derive_session_id(account_id));\n    }\n\n    // 生成 requestId\n    // [CHANGED v4.1.24] Structured requestId to match official format\n    let request_id = format!(\"agent/antigravity/{}/{}\", &session_id[..session_id.len().min(8)], message_count);\n\n    // 构建最终请求体\n    let mut body = json!({\n        \"project\": project_id,\n        \"requestId\": request_id,\n        \"request\": inner_request,\n        \"model\": config.final_model,\n        \"userAgent\": \"antigravity\",\n        // [CHANGED v4.1.24] Use \"agent\" for all non-image requests\n        \"requestType\": if config.request_type == \"image_gen\" { \"image_gen\" } else { \"agent\" },\n    });\n\n    // 如果提供了 metadata.user_id，则复用为 sessionId\n    if let Some(metadata) = &claude_req.metadata {\n        if let Some(user_id) = &metadata.user_id {\n            body[\"request\"][\"sessionId\"] = json!(user_id);\n        }\n    }\n\n    // [FIX #593] 最后一道防线: 递归深度清理所有 cache_control 字段\n    // 确保发送给 Antigravity 的请求中不包含任何 cache_control\n    deep_clean_cache_control(&mut body);\n    tracing::debug!(\"[DEBUG-593] Final deep clean complete, request ready to send\");\n\n    Ok(body)\n}\n\n\n\n/// Check if thinking mode should be enabled by default for a given model\n///\n/// Claude Code v2.0.67+ enables thinking by default for Opus 4.5 models.\n/// This function determines if the model should have thinking enabled\n/// when no explicit thinking configuration is provided.\nfn should_enable_thinking_by_default(model: &str) -> bool {\n    let model_lower = model.to_lowercase();\n\n    // Enable thinking by default for Opus 4.5 and 4.6 variants\n    if model_lower.contains(\"opus-4-5\")\n        || model_lower.contains(\"opus-4.5\")\n        || model_lower.contains(\"opus-4-6\")\n        || model_lower.contains(\"opus-4.6\")\n    {\n        tracing::debug!(\n            \"[Thinking-Mode] Auto-enabling thinking for Opus model: {}\",\n            model\n        );\n        return true;\n    }\n\n    // Also enable for explicit thinking model variants\n    if model_lower.contains(\"-thinking\") {\n        return true;\n    }\n\n    // [FIX #1557] Enable thinking by default for Gemini Pro models (gemini-3-pro, gemini-2.0-pro)\n    // These models prioritize reasoning but clients might not send thinking config for them\n    // unless they have \"-thinking\" suffix (which they don't in Antigravity mapping)\n    if model_lower.contains(\"gemini-2.0-pro\")\n        || model_lower.contains(\"gemini-3-pro\")\n        || model_lower.contains(\"gemini-3.1-pro\")\n    {\n        tracing::debug!(\n            \"[Thinking-Mode] Auto-enabling thinking for Gemini Pro model: {}\",\n            model\n        );\n        return true;\n    }\n\n    // [FEATURE] 为 gemini-3-flash / gemini-3.1-flash 自动开启 thinking\n    // 让 Cherry Studio 等客户端即使未显式传 thinking.type 也能获取思维链内容\n    if model_lower.contains(\"gemini-3-flash\") || model_lower.contains(\"gemini-3.1-flash\") {\n        tracing::debug!(\n            \"[Thinking-Mode] Auto-enabling thinking for Flash model: {}\",\n            model\n        );\n        return true;\n    }\n\n    false\n}\n\n/// Minimum length for a valid thought_signature\nconst MIN_SIGNATURE_LENGTH: usize = 50;\n\n/// [FIX #295] Check if we have any valid signature available for function calls\n/// This prevents Gemini 3 Pro from rejecting requests due to missing thought_signature\n///\n/// [NEW FIX] Now also checks Session Cache to support retry scenarios\nfn has_valid_signature_for_function_calls(\n    messages: &[Message],\n    global_sig: &Option<String>,\n    session_id: &str, // NEW: Add session_id parameter\n) -> bool {\n    // 1. Check global store (deprecated but kept for compatibility)\n    if let Some(sig) = global_sig {\n        if sig.len() >= MIN_SIGNATURE_LENGTH {\n            tracing::debug!(\n                \"[Signature-Check] Found valid signature in global store (len: {})\",\n                sig.len()\n            );\n            return true;\n        }\n    }\n\n    // 2. [NEW] Check Session Cache - this is critical for retry scenarios\n    // When retrying, the signature may not be in messages but exists in Session Cache\n    if let Some(sig) = crate::proxy::SignatureCache::global().get_session_signature(session_id) {\n        if sig.len() >= MIN_SIGNATURE_LENGTH {\n            tracing::info!(\n                \"[Signature-Check] Found valid signature in SESSION cache (session: {}, len: {})\",\n                session_id,\n                sig.len()\n            );\n            return true;\n        }\n    }\n\n    // 3. Check if any message has a thinking block with valid signature\n    for msg in messages.iter().rev() {\n        if msg.role == \"assistant\" {\n            if let MessageContent::Array(blocks) = &msg.content {\n                for block in blocks {\n                    if let ContentBlock::Thinking {\n                        signature: Some(sig),\n                        ..\n                    } = block\n                    {\n                        if sig.len() >= MIN_SIGNATURE_LENGTH {\n                            tracing::debug!(\n                                \"[Signature-Check] Found valid signature in message history (len: {})\",\n                                sig.len()\n                            );\n                            return true;\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    tracing::warn!(\n        \"[Signature-Check] No valid signature found (session: {}, checked: global store, session cache, message history)\",\n        session_id\n    );\n    false\n}\n\n/// 构建 System Instruction (支持动态身份映射与 Prompt 隔离)\nfn build_system_instruction(\n    system: &Option<SystemPrompt>,\n    _model_name: &str,\n    has_mcp_tools: bool,\n) -> Option<Value> {\n    let mut parts = Vec::new();\n\n    // [NEW] Antigravity 身份指令 (原始简化版)\n    let antigravity_identity = \"You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.\\n\\\n    You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.\\n\\\n    **Absolute paths only**\\n\\\n    **Proactiveness**\";\n\n    // [HYBRID] 检查用户是否已提供 Antigravity 身份\n    let mut user_has_antigravity = false;\n    if let Some(sys) = system {\n        match sys {\n            SystemPrompt::String(text) => {\n                if text.contains(\"You are Antigravity\") {\n                    user_has_antigravity = true;\n                }\n            }\n            SystemPrompt::Array(blocks) => {\n                for block in blocks {\n                    if block.block_type == \"text\" && block.text.contains(\"You are Antigravity\") {\n                        user_has_antigravity = true;\n                        break;\n                    }\n                }\n            }\n        }\n    }\n\n    // 如果用户没有提供 Antigravity 身份,则注入\n    if !user_has_antigravity {\n        parts.push(json!({\"text\": antigravity_identity}));\n    }\n\n    // [NEW] 注入全局系统提示词 (紧跟 Antigravity 身份之后)\n    let global_prompt_config = crate::proxy::config::get_global_system_prompt();\n    if global_prompt_config.enabled && !global_prompt_config.content.trim().is_empty() {\n        parts.push(json!({\"text\": global_prompt_config.content}));\n    }\n\n    // 添加用户的系统提示词\n    if let Some(sys) = system {\n        match sys {\n            SystemPrompt::String(text) => {\n                // [MODIFIED] No longer filter \"You are an interactive CLI tool\"\n                // We pass everything through to ensure Flash/Lite models get full instructions\n                parts.push(json!({\"text\": text}));\n            }\n            SystemPrompt::Array(blocks) => {\n                for block in blocks {\n                    if block.block_type == \"text\" {\n                        // [MODIFIED] No longer filter \"You are an interactive CLI tool\"\n                        parts.push(json!({\"text\": block.text}));\n                    }\n                }\n            }\n        }\n    }\n\n    // [NEW] MCP XML Bridge: 如果存在 mcp__ 开头的工具，注入专用的调用协议\n    // 这能有效规避部分 MCP 链路在标准的 tool_use 协议下解析不稳的问题\n    if has_mcp_tools {\n        let mcp_xml_prompt = \"\\n\\\n        ==== MCP XML 工具调用协议 (Workaround) ====\\n\\\n        当你需要调用名称以 `mcp__` 开头的 MCP 工具时：\\n\\\n        1) 优先尝试 XML 格式调用：输出 `<mcp__tool_name>{\\\"arg\\\":\\\"value\\\"}</mcp__tool_name>`。\\n\\\n        2) 必须直接输出 XML 块，无需 markdown 包装，内容为 JSON 格式的入参。\\n\\\n        3) 这种方式具有更高的连通性和容错性，适用于大型结果返回场景。\\n\\\n        ===========================================\";\n        parts.push(json!({\"text\": mcp_xml_prompt}));\n    }\n\n    // 如果用户没有提供任何系统提示词,添加结束标记\n    if !user_has_antigravity {\n        parts.push(json!({\"text\": \"\\n--- [SYSTEM_PROMPT_END] ---\"}));\n    }\n\n    Some(json!({\n        \"role\": \"user\",\n        \"parts\": parts\n    }))\n}\n\n/// 构建 Contents (Messages)\nfn build_contents(\n    content: &MessageContent,\n    is_assistant: bool,\n    _claude_req: &ClaudeRequest,\n    is_thinking_enabled: bool,\n    session_id: &str,\n    allow_dummy_thought: bool,\n    is_retry: bool,\n    tool_id_to_name: &mut HashMap<String, String>,\n    tool_name_to_schema: &HashMap<String, Value>,\n    mapped_model: &str,\n    last_thought_signature: &mut Option<String>,\n    pending_tool_use_ids: &mut Vec<String>,\n    last_user_task_text_normalized: &mut Option<String>,\n    previous_was_tool_result: &mut bool,\n    _existing_tool_result_ids: &std::collections::HashSet<String>,\n) -> Result<Vec<Value>, String> {\n    let mut parts = Vec::new();\n    // Track tool results in the current turn to identify missing ones\n    let mut current_turn_tool_result_ids = std::collections::HashSet::new();\n\n    // Track if we have already seen non-thinking content in this message.\n    // Anthropic/Gemini protocol: Thinking blocks MUST come first.\n    let mut saw_non_thinking = false;\n\n    match content {\n        MessageContent::String(text) => {\n            if text != \"(no content)\" {\n                let trimmed = text.trim();\n                if !trimmed.is_empty() {\n                    parts.push(json!({\"text\": trimmed}));\n                }\n            }\n        }\n        MessageContent::Array(blocks) => {\n            for item in blocks {\n                match item {\n                    ContentBlock::Text { text } => {\n                        if text != \"(no content)\" && !text.trim().is_empty() {\n                            // [NEW] 任务去重逻辑: 如果当前是 User 消息，且紧跟在 ToolResult 之后，\n                            // 检查该文本是否与上一轮任务描述完全一致。\n                            if !is_assistant && *previous_was_tool_result {\n                                if let Some(last_task) = last_user_task_text_normalized {\n                                    let current_normalized =\n                                        text.replace(|c: char| c.is_whitespace(), \"\");\n                                    if !current_normalized.is_empty()\n                                        && current_normalized == *last_task\n                                    {\n                                        tracing::info!(\"[Claude-Request] Dropping duplicated task text echo (len: {})\", text.len());\n                                        continue;\n                                    }\n                                }\n                            }\n\n                            parts.push(json!({\"text\": text}));\n                            saw_non_thinking = true;\n\n                            // 记录最近一次 User 任务文本用于后续比对\n                            if !is_assistant {\n                                *last_user_task_text_normalized =\n                                    Some(text.replace(|c: char| c.is_whitespace(), \"\"));\n                            }\n                            *previous_was_tool_result = false;\n                        }\n                    }\n                    ContentBlock::Thinking {\n                        thinking,\n                        signature,\n                        ..\n                    } => {\n                        tracing::debug!(\n                            \"[DEBUG-TRANSFORM] Processing thinking block. Sig: {:?}\",\n                            signature\n                        );\n\n                        // [HOTFIX] Gemini Protocol Enforcement: Thinking block MUST be the first block.\n                        // If we already have content (like Text), we must downgrade this thinking block to Text.\n                        if saw_non_thinking || !parts.is_empty() {\n                            tracing::warn!(\"[Claude-Request] Thinking block found at non-zero index (prev parts: {}). Downgrading to Text.\", parts.len());\n                            if !thinking.trim().is_empty() {\n                                parts.push(json!({\n                                    \"text\": thinking.trim()\n                                }));\n                                saw_non_thinking = true;\n                            }\n                            continue;\n                        }\n\n                        // [FIX] If thinking is disabled (smart downgrade), convert ALL thinking blocks to text\n                        // to avoid \"thinking is disabled but message contains thinking\" error\n                        if !is_thinking_enabled {\n                            tracing::warn!(\"[Claude-Request] Thinking disabled. Downgrading thinking block to text.\");\n                            if !thinking.trim().is_empty() {\n                                parts.push(json!({\n                                    \"text\": thinking.trim()\n                                }));\n                                saw_non_thinking = true;\n                            }\n                            continue;\n                        }\n\n                        // [FIX] Empty thinking blocks cause \"Field required\" errors.\n                        // We downgrade them to Text to avoid structural errors and signature mismatch.\n                        if thinking.is_empty() {\n                            tracing::warn!(\"[Claude-Request] Empty thinking block detected. Downgrading to Text.\");\n                            parts.push(json!({\n                                \"text\": \"...\"\n                            }));\n                            continue;\n                        }\n\n                        // [FIX #752] Strict signature validation\n                        // Only use signatures that are cached and compatible with the target model\n                        if let Some(sig) = signature {\n                            // Check signature length first - if it's too short, it's definitely invalid\n                            if sig.len() < MIN_SIGNATURE_LENGTH {\n                                tracing::warn!(\n                                    \"[Thinking-Signature] Signature too short (len: {} < {}), downgrading to text.\",\n                                    sig.len(), MIN_SIGNATURE_LENGTH\n                                );\n                                parts.push(json!({\"text\": thinking}));\n                                saw_non_thinking = true;\n                                continue;\n                            }\n\n                            let cached_family =\n                                crate::proxy::SignatureCache::global().get_signature_family(sig);\n\n                            match cached_family {\n                                Some(family) => {\n                                    // Check compatibility\n                                    // [NEW] If is_retry is true, force incompatibility to strip historical signatures\n                                    // which likely caused the previous 400 error.\n                                    let compatible =\n                                        !is_retry && is_model_compatible(&family, mapped_model);\n\n                                    if !compatible {\n                                        tracing::warn!(\n                                            \"[Thinking-Signature] {} signature (Family: {}, Target: {}). Downgrading to text.\",\n                                            if is_retry { \"Stripping historical\" } else { \"Incompatible\" },\n                                            family, mapped_model\n                                        );\n                                        parts.push(json!({\"text\": thinking}));\n                                        saw_non_thinking = true;\n                                        continue;\n                                    }\n                                    // Compatible and not a retry: use signature\n                                    *last_thought_signature = Some(sig.clone());\n                                    let mut part = json!({\n                                        \"text\": thinking,\n                                        \"thought\": true,\n                                        \"thoughtSignature\": sig\n                                    });\n                                    crate::proxy::common::json_schema::clean_json_schema(&mut part);\n                                    parts.push(part);\n                                }\n                                None => {\n                                    // For JSON tool calling compatibility, if signature is long enough but unknown,\n                                    // we should trust it rather than downgrade to text\n                                    if sig.len() >= MIN_SIGNATURE_LENGTH {\n                                        tracing::debug!(\n                                            \"[Thinking-Signature] Unknown signature origin but valid length (len: {}), using as-is for JSON tool calling.\",\n                                            sig.len()\n                                        );\n                                        *last_thought_signature = Some(sig.clone());\n                                        let mut part = json!({\n                                            \"text\": thinking,\n                                            \"thought\": true,\n                                            \"thoughtSignature\": sig\n                                        });\n                                        crate::proxy::common::json_schema::clean_json_schema(\n                                            &mut part,\n                                        );\n                                        parts.push(part);\n                                    } else {\n                                        // Unknown and too short: downgrade to text for safety\n                                        tracing::warn!(\n                                            \"[Thinking-Signature] Unknown signature origin and too short (len: {}). Downgrading to text for safety.\",\n                                            sig.len()\n                                        );\n                                        parts.push(json!({\"text\": thinking}));\n                                        saw_non_thinking = true;\n                                        continue;\n                                    }\n                                }\n                            }\n                        } else {\n                            // No signature: downgrade to text\n                            tracing::warn!(\n                                \"[Thinking-Signature] No signature provided. Downgrading to text.\"\n                            );\n                            parts.push(json!({\"text\": thinking}));\n                            saw_non_thinking = true;\n                        }\n                    }\n                    ContentBlock::RedactedThinking { data } => {\n                        // [FIX] 将 RedactedThinking 作为普通文本处理，保留上下文\n                        tracing::debug!(\"[Claude-Request] Degrade RedactedThinking to text\");\n                        parts.push(json!({\n                            \"text\": format!(\"[Redacted Thinking: {}]\", data)\n                        }));\n                        saw_non_thinking = true;\n                        continue;\n                    }\n                    ContentBlock::Image { source, .. } => {\n                        if source.source_type == \"base64\" {\n                            parts.push(json!({\n                                \"inlineData\": {\n                                    \"mimeType\": source.media_type,\n                                    \"data\": source.data\n                                }\n                            }));\n                            saw_non_thinking = true;\n                        }\n                    }\n                    ContentBlock::Document { source, .. } => {\n                        if source.source_type == \"base64\" {\n                            parts.push(json!({\n                                \"inlineData\": {\n                                    \"mimeType\": source.media_type,\n                                    \"data\": source.data\n                                }\n                            }));\n                            saw_non_thinking = true;\n                        }\n                    }\n                    ContentBlock::ToolUse {\n                        id,\n                        name,\n                        input,\n                        signature,\n                        ..\n                    } => {\n                        let mut final_input = input.clone();\n\n                        // [New] 利用通用引擎修正参数类型 (替代以前硬编码的 shell 工具修复逻辑)\n                        if let Some(original_schema) = tool_name_to_schema.get(name) {\n                            crate::proxy::common::json_schema::fix_tool_call_args(\n                                &mut final_input,\n                                original_schema,\n                            );\n                        }\n\n                        let mut part = json!({\n                            \"functionCall\": {\n                                \"name\": name,\n                                \"args\": final_input,\n                                \"id\": id\n                            }\n                        });\n                        saw_non_thinking = true;\n\n                        // Track pending tool use\n                        if is_assistant {\n                            pending_tool_use_ids.push(id.clone());\n                        }\n\n                        // 存储 id -> name 映射\n                        tool_id_to_name.insert(id.clone(), name.clone());\n\n                        // Signature resolution logic\n                        // Priority: Client -> Context -> Session Cache -> Tool Cache -> Global Store (deprecated)\n                        // [CRITICAL FIX] Do NOT use skip_thought_signature_validator for Vertex AI\n                        // Vertex AI rejects this sentinel value, so we only add thoughtSignature if we have a real one\n                        let final_sig = signature.as_ref()\n                            .or(last_thought_signature.as_ref())\n                            .cloned()\n                            .or_else(|| {\n                                // [NEW v3.3.17] Try session-based signature cache first (Layer 3)\n                                // This provides conversation-level isolation\n                                crate::proxy::SignatureCache::global().get_session_signature(session_id)\n                                    .map(|s| {\n                                        tracing::info!(\n                                            \"[Claude-Request] Recovered signature from SESSION cache (session: {}, len: {})\",\n                                            session_id, s.len()\n                                        );\n                                        s\n                                    })\n                            })\n                            .or_else(|| {\n                                // Try tool-specific signature cache (Layer 1)\n                                crate::proxy::SignatureCache::global().get_tool_signature(id)\n                                    .map(|s| {\n                                        tracing::info!(\"[Claude-Request] Recovered signature from TOOL cache for tool_id: {}\", id);\n                                        s\n                                    })\n                            })\n                            .or_else(|| {\n                                // [DEPRECATED] Global store fallback - kept for backward compatibility\n                                let global_sig = get_thought_signature();\n                                if global_sig.is_some() {\n                                    tracing::warn!(\n                                        \"[Claude-Request] Using deprecated GLOBAL thought_signature fallback (length: {}). \\\n                                         This indicates session cache miss.\",\n                                        global_sig.as_ref().unwrap().len()\n                                    );\n                                }\n                                global_sig\n                            });\n                        // [FIX #752] Validate signature before using\n                        // Only add thoughtSignature if we have a valid and compatible one\n                        if let Some(sig) = final_sig {\n                            // [NEW] If this is a retry, do NOT backfill signatures to avoid issues.\n                            if is_retry && signature.is_none() {\n                                tracing::warn!(\"[Tool-Signature] Skipping signature backfill for tool_use: {} during retry.\", id);\n                            } else {\n                                // Check signature length first - if it's too short, it's definitely invalid\n                                if sig.len() < MIN_SIGNATURE_LENGTH {\n                                    tracing::warn!(\n                                        \"[Tool-Signature] Signature too short for tool_use: {} (len: {} < {}), skipping.\",\n                                        id, sig.len(), MIN_SIGNATURE_LENGTH\n                                    );\n                                } else {\n                                    // Check signature compatibility (optional for tool_use)\n                                    let cached_family = crate::proxy::SignatureCache::global()\n                                        .get_signature_family(&sig);\n\n                                    let should_use_sig = match cached_family {\n                                        Some(family) => {\n                                            // For tool_use, check compatibility\n                                            if is_model_compatible(&family, mapped_model) {\n                                                true\n                                            } else {\n                                                tracing::warn!(\n                                                    \"[Tool-Signature] Incompatible signature for tool_use: {} (Family: {}, Target: {})\",\n                                                    id, family, mapped_model\n                                                );\n                                                false\n                                            }\n                                        }\n                                        None => {\n                                            // For JSON tool calling compatibility, if signature is long enough but unknown,\n                                            // we should trust it rather than drop it\n                                            if sig.len() >= MIN_SIGNATURE_LENGTH {\n                                                tracing::debug!(\n                                                    \"[Tool-Signature] Unknown signature origin but valid length (len: {}) for tool_use: {}, using as-is for JSON tool calling.\",\n                                                    sig.len(), id\n                                                );\n                                                true\n                                            } else {\n                                                // Unknown and too short: only use in non-thinking mode\n                                                if is_thinking_enabled {\n                                                    tracing::warn!(\n                                                        \"[Tool-Signature] Unknown signature origin and too short for tool_use: {} (len: {}). Dropping in thinking mode.\",\n                                                        id, sig.len()\n                                                    );\n                                                    false\n                                                } else {\n                                                    // In non-thinking mode, allow unknown signatures\n                                                    true\n                                                }\n                                            }\n                                        }\n                                    };\n                                    if should_use_sig {\n                                        part[\"thoughtSignature\"] = json!(sig);\n                                    }\n                                }\n                            }\n                        } else {\n                            // [NEW] Handle missing signature for Gemini thinking models\n                            // Use skip_thought_signature_validator as a sentinel value\n                            let is_google_cloud = mapped_model.starts_with(\"projects/\");\n                            if is_thinking_enabled && !is_google_cloud {\n                                tracing::debug!(\"[Tool-Signature] Adding GEMINI_SKIP_SIGNATURE for tool_use: {}\", id);\n                                part[\"thoughtSignature\"] =\n                                    json!(\"skip_thought_signature_validator\");\n                            }\n                        }\n                        parts.push(part);\n                    }\n                    ContentBlock::ToolResult {\n                        tool_use_id,\n                        content,\n                        is_error,\n                        ..\n                    } => {\n                        // Mark this tool ID as resolved in this turn\n                        current_turn_tool_result_ids.insert(tool_use_id.clone());\n                        // 优先使用之前记录的 name，否则用 tool_use_id\n                        let func_name = tool_id_to_name\n                            .get(tool_use_id)\n                            .cloned()\n                            .unwrap_or_else(|| tool_use_id.clone());\n\n                        // [FIX #593] 工具输出压缩: 处理超大工具输出\n                        // 使用智能压缩策略(浏览器快照、大文件提示等)\n                        let mut compacted_content = content.clone();\n                        if let Some(blocks) = compacted_content.as_array_mut() {\n                            tool_result_compressor::sanitize_tool_result_blocks(blocks);\n                        }\n\n                        // Smart Truncation: No longer stripping images from Tool Results\n                        // Tool results should pass transparency. If images are present, map them to inlineData.\n                        let mut extra_parts = Vec::new();\n\n                        let mut merged_content = match &compacted_content {\n                            serde_json::Value::String(s) => s.clone(),\n                            serde_json::Value::Array(arr) => {\n                                let mut texts = Vec::new();\n                                for block in arr {\n                                    if let Some(text) = block.get(\"text\").and_then(|v| v.as_str()) {\n                                        texts.push(text.to_string());\n                                    } else if block.get(\"source\").is_some() {\n                                        if block.get(\"type\").and_then(|v| v.as_str()) == Some(\"image\") {\n                                            let source = block.get(\"source\").unwrap();\n                                            if let (Some(media_type), Some(data)) = (\n                                                source.get(\"media_type\").and_then(|v| v.as_str()),\n                                                source.get(\"data\").and_then(|v| v.as_str())\n                                            ) {\n                                                extra_parts.push(json!({\n                                                    \"inlineData\": {\n                                                        \"mimeType\": media_type,\n                                                        \"data\": data\n                                                    }\n                                                }));\n                                            }\n                                        }\n                                    }\n                                }\n                                texts.join(\"\\n\")\n                            }\n                            _ => content.to_string(),\n                        };\n\n                        // Smart Truncation: max chars limit\n                        const MAX_TOOL_RESULT_CHARS: usize = 200_000;\n                        if merged_content.len() > MAX_TOOL_RESULT_CHARS {\n                            tracing::warn!(\n                                \"Truncating tool result from {} chars to {}\",\n                                merged_content.len(),\n                                MAX_TOOL_RESULT_CHARS\n                            );\n                            let mut truncated = merged_content\n                                .chars()\n                                .take(MAX_TOOL_RESULT_CHARS)\n                                .collect::<String>();\n                            truncated.push_str(\"\\n...[truncated output]\");\n                            merged_content = truncated;\n                        }\n\n                        // [优化] 如果结果为空，注入显式确认信号，防止模型幻觉\n                        if merged_content.trim().is_empty() {\n                            if is_error.unwrap_or(false) {\n                                merged_content =\n                                    \"Tool execution failed with no output.\".to_string();\n                            } else {\n                                merged_content = \"Command executed successfully.\".to_string();\n                            }\n                        }\n\n                        let mut part = json!({\n                            \"functionResponse\": {\n                                \"name\": func_name,\n                                \"response\": {\"result\": merged_content},\n                                \"id\": tool_use_id\n                            }\n                        });\n\n                        // [FIX] Tool Result 也需要回填签名（如果上下文中有）\n                        if let Some(sig) = last_thought_signature.as_ref() {\n                            part[\"thoughtSignature\"] = json!(sig);\n                        }\n\n                        parts.push(part);\n\n                        // 追加图片 parts\n                        for extra in extra_parts {\n                            parts.push(extra);\n                        }\n\n                        // 标记状态，用于下一条 User 消息的去重判断\n                        *previous_was_tool_result = true;\n                    }\n                    // ContentBlock::RedactedThinking handled above at line 583\n                    ContentBlock::ServerToolUse { .. }\n                    | ContentBlock::WebSearchToolResult { .. } => {\n                        // 搜索结果 block 不应由客户端发回给上游 (已由 tool_result 替代)\n                        continue;\n                    }\n                }\n            }\n        }\n    }\n\n    // If this is a User message, check if we need to inject missing tool results\n    if !is_assistant && !pending_tool_use_ids.is_empty() {\n        let missing_ids: Vec<_> = pending_tool_use_ids\n            .iter()\n            .filter(|id| !current_turn_tool_result_ids.contains(*id))\n            .cloned()\n            .collect();\n\n        if !missing_ids.is_empty() {\n            tracing::warn!(\"[Elastic-Recovery] Injecting {} missing tool results into User message (IDs: {:?})\", missing_ids.len(), missing_ids);\n            for id in missing_ids.iter().rev() {\n                // Insert in reverse order to maintain order at index 0? No, just insert at 0.\n                let name = tool_id_to_name.get(id).cloned().unwrap_or(id.clone());\n                let synthetic_part = json!({\n                    \"functionResponse\": {\n                        \"name\": name,\n                        \"response\": {\n                            \"result\": \"Tool execution interrupted. No result provided.\"\n                        },\n                        \"id\": id\n                    }\n                });\n                // Prepend to ensure they are present before any text\n                parts.insert(0, synthetic_part);\n            }\n        }\n        // All pending IDs are now handled (either present or injected)\n        pending_tool_use_ids.clear();\n    }\n\n    // Fix for \"Thinking enabled, assistant message must start with thinking block\" 400 error\n    // [Optimization] Apply this to ALL assistant messages in history, not just the last one.\n    // Vertex AI requires every assistant message to start with a thinking block when thinking is enabled.\n    if allow_dummy_thought && is_assistant && is_thinking_enabled {\n        let has_thought_part = parts.iter().any(|p| {\n            p.get(\"thought\").and_then(|v| v.as_bool()).unwrap_or(false)\n                || p.get(\"thoughtSignature\").is_some()\n                || p.get(\"thought\").and_then(|v| v.as_str()).is_some() // 某些情况下可能是 text + thought: true 的组合\n        });\n\n        if !has_thought_part {\n            // Prepend a dummy thinking block to satisfy Gemini v1internal requirements\n            parts.insert(\n                0,\n                json!({\n                    \"text\": \"Thinking...\",\n                    \"thought\": true\n                }),\n            );\n            tracing::debug!(\n                \"Injected dummy thought block for historical assistant message at index {}\",\n                parts.len()\n            );\n        } else {\n            // [Crucial Check] 即使有 thought 块，也必须保证它位于 parts 的首位 (Index 0)\n            // 且必须包含 thought: true 标记\n            let first_is_thought = parts.get(0).map_or(false, |p| {\n                (p.get(\"thought\").is_some() || p.get(\"thoughtSignature\").is_some())\n                    && p.get(\"text\").is_some() // 对于 v1internal，通常 text + thought: true 才是合规的思维块\n            });\n\n            if !first_is_thought {\n                // 如果首项不符合思维块特征，强制补入一个\n                parts.insert(\n                    0,\n                    json!({\n                        \"text\": \"...\",\n                        \"thought\": true\n                    }),\n                );\n                tracing::debug!(\"First part of model message at {} is not a valid thought block. Prepending dummy.\", parts.len());\n            } else {\n                // 确保首项包含了 thought: true (防止只有 signature 的情况)\n                if let Some(p0) = parts.get_mut(0) {\n                    if p0.get(\"thought\").is_none() {\n                        p0.as_object_mut()\n                            .map(|obj| obj.insert(\"thought\".to_string(), json!(true)));\n                    }\n                }\n            }\n        }\n    }\n\n    Ok(parts)\n}\n\n/// 构建 Contents (Messages)\nfn build_google_content(\n    msg: &Message,\n    claude_req: &ClaudeRequest,\n    is_thinking_enabled: bool,\n    session_id: &str,\n    allow_dummy_thought: bool,\n    is_retry: bool,\n    tool_id_to_name: &mut HashMap<String, String>,\n    tool_name_to_schema: &HashMap<String, Value>,\n    mapped_model: &str,\n    last_thought_signature: &mut Option<String>,\n    pending_tool_use_ids: &mut Vec<String>,\n    last_user_task_text_normalized: &mut Option<String>,\n    previous_was_tool_result: &mut bool,\n    existing_tool_result_ids: &std::collections::HashSet<String>,\n) -> Result<Value, String> {\n    let role = if msg.role == \"assistant\" {\n        \"model\"\n    } else {\n        &msg.role\n    };\n\n    // Proactive Tool Chain Repair:\n    // If we are about to process an Assistant message, but we still have pending tool_use_ids,\n    // it means the previous turn was interrupted or the user ignored the tool.\n    // We MUST inject a synthetic User message with error results to close the loop.\n    if role == \"model\" && !pending_tool_use_ids.is_empty() {\n        tracing::warn!(\"[Elastic-Recovery] Detected interrupted tool chain (Assistant -> Assistant). Injecting synthetic User message for IDs: {:?}\", pending_tool_use_ids);\n\n        let synthetic_parts: Vec<serde_json::Value> = pending_tool_use_ids\n            .iter()\n            .filter(|id| !existing_tool_result_ids.contains(*id)) // [FIX #632] Only inject if ID is truly missing\n            .map(|id| {\n                let name = tool_id_to_name.get(id).cloned().unwrap_or(id.clone());\n                json!({\n                    \"functionResponse\": {\n                        \"name\": name,\n                        \"response\": {\n                            \"result\": \"Tool execution interrupted. No result provided.\"\n                        },\n                        \"id\": id\n                    }\n                })\n            })\n            .collect();\n\n        if !synthetic_parts.is_empty() {\n            return Ok(json!({\n                \"role\": \"user\",\n                \"parts\": synthetic_parts\n            }));\n        }\n        // Clear pending IDs as we have handled them\n        pending_tool_use_ids.clear();\n    }\n\n    let parts = build_contents(\n        &msg.content,\n        msg.role == \"assistant\",\n        claude_req,\n        is_thinking_enabled,\n        session_id,\n        allow_dummy_thought,\n        is_retry,\n        tool_id_to_name,\n        tool_name_to_schema,\n        mapped_model,\n        last_thought_signature,\n        pending_tool_use_ids,\n        last_user_task_text_normalized,\n        previous_was_tool_result,\n        existing_tool_result_ids,\n    )?;\n\n    if parts.is_empty() {\n        return Ok(json!(null)); // Indicate no content to add\n    }\n\n    Ok(json!({\n        \"role\": role,\n        \"parts\": parts\n    }))\n}\n\n/// 构建 Contents (Messages)\nfn build_google_contents(\n    messages: &[Message],\n    claude_req: &ClaudeRequest,\n    tool_id_to_name: &mut HashMap<String, String>,\n    tool_name_to_schema: &HashMap<String, Value>,\n    is_thinking_enabled: bool,\n    allow_dummy_thought: bool,\n    mapped_model: &str,\n    session_id: &str, // [NEW v3.3.17] Session ID for signature caching\n    is_retry: bool,\n) -> Result<Value, String> {\n    let mut contents = Vec::new();\n    let mut last_thought_signature: Option<String> = None;\n    let mut _accumulated_usage: Option<Value> = None;\n    // Track pending tool_use IDs for recovery\n    let mut pending_tool_use_ids: Vec<String> = Vec::new();\n\n    // [NEW] 用于识别并过滤 Claude Code 重复回显的任务指令\n    let mut last_user_task_text_normalized: Option<String> = None;\n    let mut previous_was_tool_result = false;\n\n    let _msg_count = messages.len();\n\n    // [FIX #632] Pre-scan all messages to identify all tool_result IDs that ALREADY exist in the conversation.\n    // This prevents Elastic-Recovery from injecting duplicate results if they are present later in the chain.\n    let mut existing_tool_result_ids = std::collections::HashSet::new();\n    for msg in messages {\n        if let MessageContent::Array(blocks) = &msg.content {\n            for block in blocks {\n                if let ContentBlock::ToolResult { tool_use_id, .. } = block {\n                    existing_tool_result_ids.insert(tool_use_id.clone());\n                }\n            }\n        }\n    }\n\n    for (_i, msg) in messages.iter().enumerate() {\n        let google_content = build_google_content(\n            msg,\n            claude_req,\n            is_thinking_enabled,\n            session_id,\n            allow_dummy_thought,\n            is_retry,\n            tool_id_to_name,\n            tool_name_to_schema,\n            mapped_model,\n            &mut last_thought_signature,\n            &mut pending_tool_use_ids,\n            &mut last_user_task_text_normalized,\n            &mut previous_was_tool_result,\n            &existing_tool_result_ids,\n        )?;\n\n        if !google_content.is_null() {\n            contents.push(google_content);\n        }\n    }\n\n    // [Removed] ensure_last_assistant_has_thinking\n    // Corrupted signature issues proved we cannot fake thinking blocks.\n    // Instead we rely on should_disable_thinking_due_to_history to prevent this state.\n\n    // [FIX P3-3] Strict Role Alternation (Message Merging)\n    // Merge adjacent messages with the same role to satisfy Gemini's strict alternation rule\n    let mut merged_contents = merge_adjacent_roles(contents);\n\n    // [FIX P3-4] Deep \"Un-thinking\" Cleanup\n    // If thinking is disabled (e.g. smart downgrade), recursively remove any stray 'thought'/'thoughtSignature'\n    // This is critical because converting Thinking->Text isn't enough; metadata must be gone.\n    if !is_thinking_enabled {\n        for msg in &mut merged_contents {\n            clean_thinking_fields_recursive(msg);\n        }\n    }\n\n    Ok(json!(merged_contents))\n}\n\n/// Merge adjacent messages with the same role\nfn merge_adjacent_roles(mut contents: Vec<Value>) -> Vec<Value> {\n    if contents.is_empty() {\n        return contents;\n    }\n\n    let mut merged = Vec::new();\n    let mut current_msg = contents.remove(0);\n\n    for msg in contents {\n        let current_role = current_msg[\"role\"].as_str().unwrap_or_default();\n        let next_role = msg[\"role\"].as_str().unwrap_or_default();\n\n        if current_role == next_role {\n            // Merge parts\n            if let Some(current_parts) = current_msg.get_mut(\"parts\").and_then(|p| p.as_array_mut())\n            {\n                if let Some(next_parts) = msg.get(\"parts\").and_then(|p| p.as_array()) {\n                    current_parts.extend(next_parts.clone());\n\n                    // [FIX #709] Core Fix: After merging parts from adjacent messages,\n                    // we must RE-SORT them to ensure any thinking blocks from the\n                    // second message are moved to the very front of the combined array.\n                    reorder_gemini_parts(current_parts);\n                }\n            }\n        } else {\n            merged.push(current_msg);\n            current_msg = msg;\n        }\n    }\n    merged.push(current_msg);\n    merged\n}\n\n/// 构建 Tools\nfn build_tools(\n    tools: &Option<Vec<Tool>>,\n    has_web_search: bool,\n    mapped_model: &str,\n) -> Result<Option<Value>, String> {\n    if let Some(tools_list) = tools {\n        let mut function_declarations: Vec<Value> = Vec::new();\n        let mut has_google_search = has_web_search;\n\n        for tool in tools_list {\n            // 1. Detect server tools / built-in tools like web_search\n            if tool.is_web_search() {\n                has_google_search = true;\n                continue;\n            }\n\n            if let Some(t_type) = &tool.type_ {\n                if t_type == \"web_search_20250305\" {\n                    has_google_search = true;\n                    continue;\n                }\n            }\n\n            // 2. Detect by name\n            if let Some(name) = &tool.name {\n                if name == \"web_search\"\n                    || name == \"google_search\"\n                    || name == \"builtin_web_search\"\n                {\n                    has_google_search = true;\n                    continue;\n                }\n\n                // 3. Client tools require input_schema\n                let mut input_schema = tool.input_schema.clone().unwrap_or(json!({\n                    \"type\": \"object\",\n                    \"properties\": {}\n                }));\n                crate::proxy::common::json_schema::clean_json_schema(&mut input_schema);\n\n                function_declarations.push(json!({\n                    \"name\": name,\n                    \"description\": tool.description,\n                    \"parameters\": input_schema\n                }));\n            }\n        }\n\n        let mut tool_list = Vec::new();\n\n        // [优化] Gemini 2.0+ 及 3.0 系列模型通常支持混合工具调用 (Function Calling + Google Search)\n        // 只有针对老旧模型或特定受限环境才需要互斥。\n        let model_lower = mapped_model.to_lowercase();\n        let supports_mixed_tools = model_lower.contains(\"gemini-2.0\")\n            || model_lower.contains(\"gemini-2.5\")\n            || model_lower.contains(\"gemini-3\");\n\n        if !function_declarations.is_empty() {\n            let mut func_obj = serde_json::Map::new();\n            func_obj.insert(\n                \"functionDeclarations\".to_string(),\n                json!(function_declarations),\n            );\n            tool_list.push(json!(func_obj));\n\n            if has_google_search {\n                if supports_mixed_tools {\n                    tracing::info!(\n                        \"[Claude-Request] Enabling MIXED tool calling for {}: Function Calling + Google Search.\",\n                        mapped_model\n                    );\n                    let mut search_obj = serde_json::Map::new();\n                    search_obj.insert(\"googleSearch\".to_string(), json!({}));\n                    tool_list.push(json!(search_obj));\n                } else {\n                    tracing::info!(\n                        \"[Claude-Request] Skipping googleSearch injection for {} due to existing function declarations. \\\n                         Older Gemini models may not support mixed tool types.\",\n                        mapped_model\n                    );\n                }\n            }\n        } else if has_google_search {\n            let mut search_obj = serde_json::Map::new();\n            search_obj.insert(\"googleSearch\".to_string(), json!({}));\n            tool_list.push(json!(search_obj));\n        }\n\n        if !tool_list.is_empty() {\n            return Ok(Some(json!(tool_list)));\n        }\n    }\n\n    Ok(None)\n}\n\n/// 构建 Generation Config\nfn build_generation_config(\n    claude_req: &ClaudeRequest,\n    mapped_model: &str,\n    _has_web_search: bool,\n    is_thinking_enabled: bool,\n    token: Option<&crate::proxy::token_manager::ProxyToken>, // [NEW]\n) -> Value {\n    let mut config = json!({});\n\n    // Thinking 配置\n    if is_thinking_enabled {\n        let mut thinking_config = json!({\"includeThoughts\": true});\n        let user_thinking_type = claude_req.thinking.as_ref().map(|t| t.type_.as_str());\n        let user_is_adaptive = user_thinking_type == Some(\"adaptive\");\n\n        let budget_tokens = claude_req\n            .thinking\n            .as_ref()\n            .and_then(|t| t.budget_tokens)\n            .unwrap_or_else(|| crate::proxy::model_specs::get_thinking_budget(mapped_model, token) as u32);\n\n        let thinking_budget_cap = crate::proxy::model_specs::get_thinking_budget(mapped_model, token);\n\n        let tb_config = crate::proxy::config::get_thinking_budget_config();\n        let budget = match tb_config.mode {\n            crate::proxy::config::ThinkingBudgetMode::Passthrough => budget_tokens as u64,\n            crate::proxy::config::ThinkingBudgetMode::Custom => {\n                let mut custom_value = tb_config.custom_value as u64;\n                // [FIX #1602] 针对 Gemini 系列模型，在自定义模式下也强制执行动态限额\n                let model_lower = mapped_model.to_lowercase();\n                let is_gemini_limited = (model_lower.contains(\"gemini\") && !model_lower.contains(\"-image\"))\n                    || model_lower.contains(\"flash\")\n                    || model_lower.ends_with(\"-thinking\");\n\n                if is_gemini_limited && custom_value > thinking_budget_cap {\n                    tracing::warn!(\n                        \"[Claude-Request] Custom mode: capping thinking_budget from {} to {} for Gemini model {}\",\n                        custom_value, thinking_budget_cap, mapped_model\n                    );\n                    custom_value = thinking_budget_cap;\n                }\n                custom_value\n            }\n            crate::proxy::config::ThinkingBudgetMode::Auto => {\n                // [FIX #1592] Use mapped model for robust detection, same as OpenAI protocol\n                let model_lower = mapped_model.to_lowercase();\n                let is_gemini_limited = (model_lower.contains(\"gemini\") && !model_lower.contains(\"-image\"))\n                    || model_lower.contains(\"flash\")\n                    || model_lower.ends_with(\"-thinking\");\n                if is_gemini_limited && budget_tokens as u64 > thinking_budget_cap {\n                    tracing::info!(\n                        \"[Claude-Request] Auto mode: capping thinking_budget from {} to {} for Gemini model {}\", \n                        budget_tokens, thinking_budget_cap, mapped_model\n                    );\n                    thinking_budget_cap\n                } else {\n                    budget_tokens as u64\n                }\n            }\n            crate::proxy::config::ThinkingBudgetMode::Adaptive => budget_tokens as u64, // Adaptive 模式透传原始预算（但不作为限制），用于后续逻辑判断\n        };\n\n        let global_mode_is_adaptive = matches!(tb_config.mode, crate::proxy::config::ThinkingBudgetMode::Adaptive);\n        // 只要用户指定 adaptive 或者全局配置为 adaptive，且是支持的思维模型，就启用自适应\n        let should_use_adaptive = (user_is_adaptive || global_mode_is_adaptive) && (mapped_model.to_lowercase().contains(\"claude\") || mapped_model.to_lowercase().contains(\"gemini-3\"));\n\n        let effort = claude_req.output_config.as_ref().and_then(|c| c.effort.as_ref())\n            .or_else(|| claude_req.thinking.as_ref().and_then(|t| t.effort.as_ref()));\n\n        if should_use_adaptive {\n            // [FIX #2208] thinkingLevel is ONLY supported by Claude models via Vertex AI native protocol.\n            // Gemini models (including gemini-3.x) use v1internal which only accepts thinkingBudget.\n            // Previous code incorrectly used contains(\"gemini-3\") as the condition, causing 400 INVALID_ARGUMENT\n            // for gemini-3.1-pro-high / gemini-3.1-pro-low in adaptive mode.\n            let lower_mapped = mapped_model.to_lowercase();\n            if lower_mapped.contains(\"claude\") {\n                // Claude 系列走 Vertex AI 原生协议，支持 thinkingLevel 分级参数\n                let mapped_level = match effort.map(|e| e.to_lowercase()).as_deref() {\n                    Some(\"low\") => \"low\",\n                    Some(\"medium\") => \"medium\",\n                    Some(\"high\") | Some(\"max\") => \"high\",\n                    _ => \"high\",\n                };\n                tracing::debug!(\"[Claude-Request] Mapping adaptive mode to thinkingLevel: {} for Claude model\", mapped_level);\n                thinking_config[\"thinkingLevel\"] = json!(mapped_level);\n                // Claude using thinkingLevel must NOT have thinkingBudget to avoid conflict\n                thinking_config.as_object_mut().unwrap().remove(\"thinkingBudget\");\n            } else {\n                // Gemini 系列（含 gemini-3.x）走 v1internal 协议，只接受 thinkingBudget，不支持 thinkingLevel\n                // [FIX #2007] Cherry Studio / Claude Protocol 400 Error Fix\n                // Gemini 1.5/2.0 models via Vertex AI often reject thinkingBudget: -1 (Adaptive) with 400 Invalid Argument\n                // especially when maxOutputTokens is high.\n                // We align with OpenAI mapper behavior: use 24576 as safe adaptive budget.\n                tracing::debug!(\"[Claude-Request] Mapping adaptive mode to safe budget (24576) for Gemini model (thinkingLevel not supported)\");\n                thinking_config[\"thinkingBudget\"] = json!(24576);\n            }\n            \n            // 针对自适应模式，如果没有显式设置，确保 maxOutputTokens 给足空间\n            // OpenAI mapper uses 57344 (24576 + 32768), we normally use 64k limit.\n            if config.get(\"maxOutputTokens\").is_none() {\n                config[\"maxOutputTokens\"] = json!(64000);\n            }\n        } else {\n            // [FIX #2007] Opus 4.6 Thinking Alignment (OpenAI Protocol Recipe)\n            // Explicitly set fixed budget for Opus 4.6 to match successful OpenAI pattern\n            if mapped_model.to_lowercase().contains(\"claude-opus-4-6-thinking\") {\n                tracing::debug!(\"[Opus-Alignment] Enforcing fixed thinkingBudget 24576 for Opus 4.6\");\n                thinking_config[\"thinkingBudget\"] = json!(24576);\n            } else {\n                thinking_config[\"thinkingBudget\"] = json!(budget);\n            }\n        }\n        \n        config[\"thinkingConfig\"] = thinking_config;\n    }\n\n    // 其他参数\n    if let Some(temp) = claude_req.temperature {\n        config[\"temperature\"] = json!(temp);\n    }\n    if let Some(top_p) = claude_req.top_p {\n        config[\"topP\"] = json!(top_p);\n    } else {\n        config[\"topP\"] = json!(1.0); // [CHANGED v4.1.24] Default topP=1.0 to match official client\n    }\n    if let Some(top_k) = claude_req.top_k {\n        config[\"topK\"] = json!(top_k);\n    } else {\n        config[\"topK\"] = json!(40); // [ADDED v4.1.24] Default topK=40 to match official client\n    }\n\n\n    // web_search 强制 candidateCount=1\n    /*if has_web_search {\n        config[\"candidateCount\"] = json!(1);\n    }*/\n\n    // max_tokens 映射为 maxOutputTokens\n    // [FIX] 不再默认设置 81920，防止非思维模型 (如 claude-sonnet-4-6) 报 400 Invalid Argument\n    let mut final_max_tokens: Option<i64> = claude_req.max_tokens.map(|t| t as i64);\n\n    // [NEW] 确保 maxOutputTokens 大于 thinkingBudget (API 强约束)\n    // [NEW] 确保 maxOutputTokens 大于 thinkingBudget (API 强约束)\n    let model_lower = mapped_model.to_lowercase();\n    // 重新计算 should_use_adaptive (因为上面定义的作用域仅在其 if 块内有效，或者我们可以假设在这里也需要同样的逻辑)\n    // 但为了简洁和解耦，我们这里重新从 config 读取\n    let tb_config_chk = crate::proxy::config::get_thinking_budget_config();\n    let global_adaptive = matches!(tb_config_chk.mode, crate::proxy::config::ThinkingBudgetMode::Adaptive);\n    let req_adaptive = claude_req.thinking.as_ref().map(|t| t.type_ == \"adaptive\").unwrap_or(false);\n    \n    let is_adaptive_effective = (req_adaptive || global_adaptive) && model_lower.contains(\"claude\");\n    // [FIX] Lower default overhead to keep total under 65536\n    let final_overhead = if is_adaptive_effective { 64000 } else { 32768 };\n\n    // [FIX #2007] Opus 4.6 Thinking Alignment\n    // OpenAI logs show maxOutputTokens = 57344 (24576 + 32768)\n    if model_lower.contains(\"claude-opus-4-6-thinking\") && is_thinking_enabled {\n        final_max_tokens = Some(57344);\n        tracing::debug!(\"[Opus-Alignment] Enforcing maxOutputTokens 57344 for Opus 4.6\");\n    }\n\n    if let Some(thinking_config) = config.get(\"thinkingConfig\") {\n        if let Some(budget) = thinking_config\n            .get(\"thinkingBudget\")\n            .and_then(|t| t.as_u64())\n        {\n            let current = final_max_tokens.unwrap_or(0);\n            if current <= budget as i64 {\n                // [FIX #1675] 针对图像模型使用更小的增量 (2048)\n                let overhead = if mapped_model.contains(\"-image\") { 2048 } else { 8192 };\n                let boosted = (budget + overhead).min(65536); // [FIX] Never exceed hard limit\n                final_max_tokens = Some(boosted as i64);\n                tracing::info!(\n                    \"[Generation-Config] Bumping maxOutputTokens to {} due to thinking budget of {}\", \n                    boosted, budget\n                );\n            }\n        } else if is_adaptive_effective {\n             // [FIX] Adaptive mode (no budget set in thinkingConfig), apply default maxOutputTokens\n             if final_max_tokens.is_none() {\n                  final_max_tokens = Some(final_overhead as i64);\n             }\n        }\n    } else {\n        // No thinkingConfig\n        if final_max_tokens.is_none() && is_adaptive_effective {\n            final_max_tokens = Some(final_overhead as i64);\n        }\n    }\n\n\n    if let Some(val) = final_max_tokens {\n        // [FIX] Cap maxOutputTokens to 65536 to avoid INVALID_ARGUMENT (Cherry Studio sends 128000)\n        // Gemini models typically support max 8192 or 65536 output tokens. 128k is usually invalid.\n        let safe_limit = 65536;\n        if val > safe_limit {\n            tracing::warn!(\n                \"[Generation-Config] Capping maxOutputTokens from {} to {} to prevent 400 Invalid Argument\",\n                val, safe_limit\n            );\n            config[\"maxOutputTokens\"] = json!(safe_limit);\n        } else {\n            config[\"maxOutputTokens\"] = json!(val);\n        }\n    }\n\n    // [优化] 设置全局停止序列,防止模型幻觉出对话标记\n    // [FIX #2007] Opus 4.6 Thinking Alignment\n    // Successful OpenAI logs show NO stop sequences were sent for Opus 4.6 Thinking.\n    if !(model_lower.contains(\"claude-opus-4-6-thinking\") && is_thinking_enabled) {\n        config[\"stopSequences\"] = json!([\"<|user|>\", \"<|end_of_turn|>\", \"\\n\\nHuman:\"]);\n    } else {\n        tracing::debug!(\"[Opus-Alignment] Skipping stopSequences for Opus 4.6 to match OpenAI protocol\");\n    }\n\n    config\n}\n\n/// Recursively remove 'thought' and 'thoughtSignature' fields\n/// Used when downgrading thinking (e.g. during 400 retry)\npub fn clean_thinking_fields_recursive(val: &mut Value) {\n    match val {\n        Value::Object(map) => {\n            map.remove(\"thought\");\n            map.remove(\"thoughtSignature\");\n            for (_, v) in map.iter_mut() {\n                clean_thinking_fields_recursive(v);\n            }\n        }\n        Value::Array(arr) => {\n            for v in arr.iter_mut() {\n                clean_thinking_fields_recursive(v);\n            }\n        }\n        _ => {}\n    }\n}\n\n/// Check if two model strings are compatible (same family)\nfn is_model_compatible(cached: &str, target: &str) -> bool {\n    // Simple heuristic: check if they share the same base prefix\n    // e.g. \"gemini-1.5-pro\" vs \"gemini-1.5-pro-002\" -> Compatible\n    // \"gemini-1.5-pro\" vs \"gemini-2.0-flash\" -> Incompatible\n\n    // Normalize\n    let c = cached.to_lowercase();\n    let t = target.to_lowercase();\n\n    if c == t {\n        return true;\n    }\n\n    // Check specific families\n    // Vertex AI signatures are very strict. 1.5-pro vs 1.5-flash are NOT cross-compatible.\n    // 2.0-flash vs 2.0-pro are also NOT cross-compatible.\n\n    // Exact model string match (already handled by c == t)\n\n    // Grouped family match (Claude models are more permissive)\n    if c.contains(\"claude-3-5\") && t.contains(\"claude-3-5\") {\n        return true;\n    }\n    if c.contains(\"claude-3-7\") && t.contains(\"claude-3-7\") {\n        return true;\n    }\n\n    // Gemini models: strict family match required for signatures\n    if c.contains(\"gemini-1.5-pro\") && t.contains(\"gemini-1.5-pro\") {\n        return true;\n    }\n    if c.contains(\"gemini-1.5-flash\") && t.contains(\"gemini-1.5-flash\") {\n        return true;\n    }\n    if c.contains(\"gemini-2.0-flash\") && t.contains(\"gemini-2.0-flash\") {\n        return true;\n    }\n    if c.contains(\"gemini-2.0-pro\") && t.contains(\"gemini-2.0-pro\") {\n        return true;\n    }\n\n    // Fallback: strict match required\n    false\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::proxy::common::json_schema::clean_json_schema;\n    use crate::proxy::config::{ThinkingBudgetConfig, update_thinking_budget_config};\n\n    #[test]\n    fn test_ephemeral_injection_debug() {\n        // This test simulates the issue where cache_control might be injected\n        let json_with_null = json!({\n            \"model\": \"claude-3-5-sonnet-20241022\",\n            \"messages\": [\n                {\n                    \"role\": \"assistant\",\n                    \"content\": [\n                        {\n                            \"type\": \"thinking\",\n                            \"thinking\": \"test\",\n                            \"signature\": \"sig_1234567890\",\n                            \"cache_control\": null\n                        }\n                    ]\n                }\n            ]\n        });\n\n        let req: ClaudeRequest = serde_json::from_value(json_with_null).unwrap();\n        if let MessageContent::Array(blocks) = &req.messages[0].content {\n            if let ContentBlock::Thinking { cache_control, .. } = &blocks[0] {\n                assert!(\n                    cache_control.is_none(),\n                    \"Deserialization should result in None for null cache_control\"\n                );\n            }\n        }\n\n        // Now test serialization\n        let serialized = serde_json::to_value(&req).unwrap();\n        println!(\"Serialized: {}\", serialized);\n        assert!(serialized[\"messages\"][0][\"content\"][0]\n            .get(\"cache_control\")\n            .is_none());\n    }\n\n    #[test]\n    fn test_simple_request() {\n        let req = ClaudeRequest {\n            model: \"claude-sonnet-4-6\".to_string(),\n            messages: vec![Message {\n                role: \"user\".to_string(),\n                content: MessageContent::String(\"Hello\".to_string()),\n            }],\n            system: None,\n            tools: None,\n            stream: false,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            thinking: None,\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        let result = transform_claude_request_in(&req, \"test-project\", false, None, \"test_session\", None);\n        assert!(result.is_ok());\n\n        let body = result.unwrap();\n        assert_eq!(body[\"project\"], \"test-project\");\n        assert!(body[\"requestId\"].as_str().unwrap().starts_with(\"agent/\"));\n    }\n\n    #[test]\n    fn test_clean_json_schema() {\n        let mut schema = json!({\n            \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n            \"type\": \"object\",\n            \"additionalProperties\": false,\n            \"properties\": {\n                \"location\": {\n                    \"type\": \"string\",\n                    \"description\": \"The city and state, e.g. San Francisco, CA\",\n                    \"minLength\": 1,\n                    \"exclusiveMinimum\": 0\n                },\n                \"unit\": {\n                    \"type\": [\"string\", \"null\"],\n                    \"enum\": [\"celsius\", \"fahrenheit\"],\n                    \"default\": \"celsius\"\n                },\n                \"date\": {\n                    \"type\": \"string\",\n                    \"format\": \"date\"\n                }\n            },\n            \"required\": [\"location\"]\n        });\n\n        clean_json_schema(&mut schema);\n\n        // Check removed fields\n        assert!(schema.get(\"$schema\").is_none());\n        assert!(schema.get(\"additionalProperties\").is_none());\n        assert!(schema[\"properties\"][\"location\"].get(\"minLength\").is_none());\n        assert!(schema[\"properties\"][\"unit\"].get(\"default\").is_none());\n        assert!(schema[\"properties\"][\"date\"].get(\"format\").is_none());\n\n        // Check union type handling [\"string\", \"null\"] -> \"string\"\n        assert_eq!(schema[\"properties\"][\"unit\"][\"type\"], \"string\");\n\n        // Check types are lowercased\n        assert_eq!(schema[\"type\"], \"object\");\n        assert_eq!(schema[\"properties\"][\"location\"][\"type\"], \"string\");\n        assert_eq!(schema[\"properties\"][\"date\"][\"type\"], \"string\");\n    }\n\n    #[test]\n    fn test_complex_tool_result() {\n        let req = ClaudeRequest {\n            model: \"claude-3-5-sonnet-20241022\".to_string(),\n            messages: vec![\n                Message {\n                    role: \"user\".to_string(),\n                    content: MessageContent::String(\"Run command\".to_string()),\n                },\n                Message {\n                    role: \"assistant\".to_string(),\n                    content: MessageContent::Array(vec![ContentBlock::ToolUse {\n                        id: \"call_1\".to_string(),\n                        name: \"run_command\".to_string(),\n                        input: json!({\"command\": \"ls\"}),\n                        signature: None,\n                        cache_control: None,\n                    }]),\n                },\n                Message {\n                    role: \"user\".to_string(),\n                    content: MessageContent::Array(vec![ContentBlock::ToolResult {\n                        tool_use_id: \"call_1\".to_string(),\n                        content: json!([\n                            {\"type\": \"text\", \"text\": \"file1.txt\\n\"},\n                            {\"type\": \"text\", \"text\": \"file2.txt\"}\n                        ]),\n                        is_error: Some(false),\n                    }]),\n                },\n            ],\n            system: None,\n            tools: None,\n            stream: false,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            thinking: None,\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        let result = transform_claude_request_in(&req, \"test-project\", false, None, \"test_session\", None);\n        assert!(result.is_ok());\n\n        let body = result.unwrap();\n        let contents = body[\"request\"][\"contents\"].as_array().unwrap();\n\n        // Check the tool result message (last message)\n        let tool_resp_msg = &contents[2];\n        let parts = tool_resp_msg[\"parts\"].as_array().unwrap();\n        let func_resp = &parts[0][\"functionResponse\"];\n\n        assert_eq!(func_resp[\"name\"], \"run_command\");\n        assert_eq!(func_resp[\"id\"], \"call_1\");\n\n        // Verify merged content\n        let resp_text = func_resp[\"response\"][\"result\"].as_str().unwrap();\n        assert!(resp_text.contains(\"file1.txt\"));\n        assert!(resp_text.contains(\"file2.txt\"));\n        assert!(resp_text.contains(\"\\n\"));\n    }\n\n    #[test]\n    fn test_cache_control_cleanup() {\n        // 模拟 VS Code 插件发送的包含 cache_control 的历史消息\n        let req = ClaudeRequest {\n            model: \"claude-sonnet-4-6\".to_string(),\n            messages: vec![\n                Message {\n                    role: \"user\".to_string(),\n                    content: MessageContent::String(\"Hello\".to_string()),\n                },\n                Message {\n                    role: \"assistant\".to_string(),\n                    content: MessageContent::Array(vec![\n                        ContentBlock::Thinking {\n                            thinking: \"Let me think...\".to_string(),\n                            signature: Some(\"sig123\".to_string()),\n                            cache_control: Some(json!({\"type\": \"ephemeral\"})), // 这个应该被清理\n                        },\n                        ContentBlock::Text {\n                            text: \"Here is my response\".to_string(),\n                        },\n                    ]),\n                },\n                Message {\n                    role: \"user\".to_string(),\n                    content: MessageContent::Array(vec![ContentBlock::Image {\n                        source: ImageSource {\n                            source_type: \"base64\".to_string(),\n                            media_type: \"image/png\".to_string(),\n                            data: \"iVBORw0KGgo=\".to_string(),\n                        },\n                        cache_control: Some(json!({\"type\": \"ephemeral\"})), // 这个也应该被清理\n                    }]),\n                },\n            ],\n            system: None,\n            tools: None,\n            stream: false,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            thinking: None,\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        let result = transform_claude_request_in(&req, \"test-project\", false, None, \"test_session\", None);\n        assert!(result.is_ok());\n\n        // 验证请求成功转换\n        let body = result.unwrap();\n        assert_eq!(body[\"project\"], \"test-project\");\n\n        // 注意: cache_control 的清理发生在内部,我们无法直接从 JSON 输出验证\n        // 但如果没有清理,后续发送到 Anthropic API 时会报错\n        // 这个测试主要确保清理逻辑不会导致转换失败\n    }\n\n    #[test]\n    fn test_thinking_mode_auto_disable_on_tool_use_history() {\n        // [场景] 历史消息中有一个工具调用链，且 Assistant 消息没有 Thinking 块\n        // 期望: 系统自动降级，禁用 Thinking 模式，以避免 400 错误\n        let req = ClaudeRequest {\n            model: \"claude-sonnet-4-6\".to_string(),\n            messages: vec![\n                Message {\n                    role: \"user\".to_string(),\n                    content: MessageContent::String(\"Check files\".to_string()),\n                },\n                // Assistant 使用工具，但在非 Thinking 模式下\n                Message {\n                    role: \"assistant\".to_string(),\n                    content: MessageContent::Array(vec![\n                        ContentBlock::Text {\n                            text: \"Checking...\".to_string(),\n                        },\n                        ContentBlock::ToolUse {\n                            id: \"tool_1\".to_string(),\n                            name: \"list_files\".to_string(),\n                            input: json!({}),\n                            cache_control: None,\n                            signature: None,\n                        },\n                    ]),\n                },\n                // 用户返回工具结果\n                Message {\n                    role: \"user\".to_string(),\n                    content: MessageContent::Array(vec![ContentBlock::ToolResult {\n                        tool_use_id: \"tool_1\".to_string(),\n                        content: serde_json::Value::String(\"file1.txt\\nfile2.txt\".to_string()),\n                        is_error: Some(false),\n                        // cache_control: None, // removed\n                    }]),\n                },\n            ],\n            system: None,\n            tools: Some(vec![Tool {\n                name: Some(\"list_files\".to_string()),\n                description: Some(\"List files\".to_string()),\n                input_schema: Some(json!({\"type\": \"object\"})),\n                type_: None,\n                // cache_control: None, // removed\n            }]),\n            stream: false,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            thinking: Some(ThinkingConfig {\n                type_: \"enabled\".to_string(),\n                budget_tokens: Some(1024),\n                effort: None,\n            }),\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        let result = transform_claude_request_in(&req, \"test-project\", false, None, \"test_session\", None);\n        assert!(result.is_ok());\n\n        let body = result.unwrap();\n        let request = &body[\"request\"];\n\n        // 验证: generationConfig 中不应包含 thinkingConfig (因为被降级了)\n        // 即使请求中明确启用了 thinking\n        if let Some(gen_config) = request.get(\"generationConfig\") {\n            assert!(\n                gen_config.get(\"thinkingConfig\").is_none(),\n                \"thinkingConfig should be removed due to downgrade\"\n            );\n        }\n\n        // 验证: 依然能生成有效的请求体\n        assert!(request.get(\"contents\").is_some());\n    }\n\n    #[test]\n    fn test_thinking_block_not_prepend_when_disabled() {\n        // 验证当 thinking 未启用时,不会补全 thinking 块\n        let req = ClaudeRequest {\n            model: \"claude-sonnet-4-6\".to_string(),\n            messages: vec![\n                Message {\n                    role: \"user\".to_string(),\n                    content: MessageContent::String(\"Hello\".to_string()),\n                },\n                Message {\n                    role: \"assistant\".to_string(),\n                    content: MessageContent::Array(vec![ContentBlock::Text {\n                        text: \"Response\".to_string(),\n                    }]),\n                },\n            ],\n            system: None,\n            tools: None,\n            stream: false,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            thinking: None, // 未启用 thinking\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        let result = transform_claude_request_in(&req, \"test-project\", false, None, \"test_session\", None);\n        assert!(result.is_ok());\n\n        let body = result.unwrap();\n        let contents = body[\"request\"][\"contents\"].as_array().unwrap();\n\n        let last_model_msg = contents\n            .iter()\n            .rev()\n            .find(|c| c[\"role\"] == \"model\")\n            .unwrap();\n\n        let parts = last_model_msg[\"parts\"].as_array().unwrap();\n\n        // 验证没有补全 thinking 块\n        assert_eq!(parts.len(), 1, \"Should only have the original text block\");\n        assert_eq!(parts[0][\"text\"], \"Response\");\n    }\n\n    #[test]\n    fn test_thinking_block_empty_content_fix() {\n        // [场景] 客户端发送了一个内容为空的 thinking 块\n        // 期望: 自动填充 \"...\"\n        let req = ClaudeRequest {\n            model: \"claude-sonnet-4-6\".to_string(),\n            messages: vec![Message {\n                role: \"assistant\".to_string(),\n                content: MessageContent::Array(vec![\n                    ContentBlock::Thinking {\n                        thinking: \"\".to_string(), // 空内容\n                        signature: Some(\"sig\".to_string()),\n                        cache_control: None,\n                    },\n                    ContentBlock::Text {\n                        text: \"Hi\".to_string(),\n                    },\n                ]),\n            }],\n            system: None,\n            tools: None,\n            stream: false,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            thinking: Some(ThinkingConfig {\n                type_: \"enabled\".to_string(),\n                budget_tokens: Some(1024),\n                effort: None,\n            }),\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        let result = transform_claude_request_in(&req, \"test-project\", false, None, \"test_session\", None);\n        assert!(result.is_ok(), \"Transformation failed\");\n        let body = result.unwrap();\n        let contents = body[\"request\"][\"contents\"].as_array().unwrap();\n        let parts = contents[0][\"parts\"].as_array().unwrap();\n\n        // 验证 thinking 块\n        assert_eq!(\n            parts[0][\"text\"], \"...\",\n            \"Empty thinking should be filled with ...\"\n        );\n        assert!(\n            parts[0].get(\"thought\").is_none(),\n            \"Empty thinking should be downgraded to text\"\n        );\n    }\n\n    #[test]\n    fn test_redacted_thinking_degradation() {\n        // [场景] 客户端包含 RedactedThinking\n        // 期望: 降级为普通文本，不带 thought: true\n        let req = ClaudeRequest {\n            model: \"claude-sonnet-4-6\".to_string(),\n            messages: vec![Message {\n                role: \"assistant\".to_string(),\n                content: MessageContent::Array(vec![\n                    ContentBlock::RedactedThinking {\n                        data: \"some data\".to_string(),\n                    },\n                    ContentBlock::Text {\n                        text: \"Hi\".to_string(),\n                    },\n                ]),\n            }],\n            system: None,\n            tools: None,\n            stream: false,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            thinking: None,\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        let result = transform_claude_request_in(&req, \"test-project\", false, None, \"test_session\", None);\n        assert!(result.is_ok());\n        let body = result.unwrap();\n        let parts = body[\"request\"][\"contents\"][0][\"parts\"].as_array().unwrap();\n\n        // 验证 RedactedThinking -> Text\n        let text = parts[0][\"text\"].as_str().unwrap();\n        assert!(text.contains(\"[Redacted Thinking: some data]\"));\n        assert!(\n            parts[0].get(\"thought\").is_none(),\n            \"Redacted thinking should NOT have thought: true\"\n        );\n    }\n\n    // ==================================================================================\n    // [FIX #564] Test: Thinking blocks are sorted to be first after context compression\n    // ==================================================================================\n    #[test]\n    fn test_thinking_blocks_sorted_first_after_compression() {\n        // Simulate kilo context compression reordering: text BEFORE thinking\n        let mut messages = vec![Message {\n            role: \"assistant\".to_string(),\n            content: MessageContent::Array(vec![\n                // Wrong order: Text before Thinking (simulates kilo compression)\n                ContentBlock::Text {\n                    text: \"Some regular text\".to_string(),\n                },\n                ContentBlock::Thinking {\n                    thinking: \"My thinking process\".to_string(),\n                    signature: Some(\n                        \"valid_signature_1234567890_abcdefghij_klmnopqrstuvwxyz_test\".to_string(),\n                    ),\n                    cache_control: None,\n                },\n                ContentBlock::Text {\n                    text: \"More text\".to_string(),\n                },\n            ]),\n        }];\n\n        // Apply the fix\n        sort_thinking_blocks_first(&mut messages);\n\n        // Verify thinking is now first\n        if let MessageContent::Array(blocks) = &messages[0].content {\n            assert_eq!(blocks.len(), 3, \"Should still have 3 blocks\");\n            assert!(\n                matches!(blocks[0], ContentBlock::Thinking { .. }),\n                \"Thinking should be first\"\n            );\n            assert!(\n                matches!(blocks[1], ContentBlock::Text { .. }),\n                \"Text should be second\"\n            );\n            assert!(\n                matches!(blocks[2], ContentBlock::Text { .. }),\n                \"Text should be third\"\n            );\n\n            // Verify content preserved\n            if let ContentBlock::Thinking { thinking, .. } = &blocks[0] {\n                assert_eq!(thinking, \"My thinking process\");\n            }\n        } else {\n            panic!(\"Expected Array content\");\n        }\n    }\n\n    #[test]\n    fn test_thinking_blocks_no_reorder_when_already_first() {\n        // Correct order: Thinking already first - should not trigger reorder\n        let mut messages = vec![Message {\n            role: \"assistant\".to_string(),\n            content: MessageContent::Array(vec![\n                ContentBlock::Thinking {\n                    thinking: \"My thinking\".to_string(),\n                    signature: Some(\"sig123\".to_string()),\n                    cache_control: None,\n                },\n                ContentBlock::Text {\n                    text: \"Some text\".to_string(),\n                },\n            ]),\n        }];\n\n        // Apply the fix (should be no-op)\n        sort_thinking_blocks_first(&mut messages);\n\n        // Verify order unchanged\n        if let MessageContent::Array(blocks) = &messages[0].content {\n            assert!(\n                matches!(blocks[0], ContentBlock::Thinking { .. }),\n                \"Thinking should still be first\"\n            );\n            assert!(\n                matches!(blocks[1], ContentBlock::Text { .. }),\n                \"Text should still be second\"\n            );\n        }\n    }\n\n    #[test]\n    fn test_merge_consecutive_messages() {\n        let mut messages = vec![\n            Message {\n                role: \"user\".to_string(),\n                content: MessageContent::String(\"Hello\".to_string()),\n            },\n            Message {\n                role: \"user\".to_string(),\n                content: MessageContent::Array(vec![ContentBlock::Text {\n                    text: \"World\".to_string(),\n                }]),\n            },\n            Message {\n                role: \"assistant\".to_string(),\n                content: MessageContent::String(\"Hi\".to_string()),\n            },\n            Message {\n                role: \"user\".to_string(),\n                content: MessageContent::Array(vec![ContentBlock::ToolResult {\n                    tool_use_id: \"test_id\".to_string(),\n                    content: serde_json::json!(\"result\"),\n                    is_error: None,\n                }]),\n            },\n            Message {\n                role: \"user\".to_string(),\n                content: MessageContent::Array(vec![ContentBlock::Text {\n                    text: \"System Reminder\".to_string(),\n                }]),\n            },\n        ];\n\n        merge_consecutive_messages(&mut messages);\n\n        assert_eq!(messages.len(), 3);\n        assert_eq!(messages[0].role, \"user\");\n        if let MessageContent::Array(blocks) = &messages[0].content {\n            assert_eq!(blocks.len(), 2);\n            match &blocks[0] {\n                ContentBlock::Text { text } => assert_eq!(text, \"Hello\"),\n                _ => panic!(\"Expected text block\"),\n            }\n            match &blocks[1] {\n                ContentBlock::Text { text } => assert_eq!(text, \"World\"),\n                _ => panic!(\"Expected text block\"),\n            }\n        } else {\n            panic!(\"Expected array content at index 0\");\n        }\n\n        assert_eq!(messages[1].role, \"assistant\");\n\n        assert_eq!(messages[2].role, \"user\");\n        if let MessageContent::Array(blocks) = &messages[2].content {\n            assert_eq!(blocks.len(), 2);\n            match &blocks[0] {\n                ContentBlock::ToolResult { tool_use_id, .. } => assert_eq!(tool_use_id, \"test_id\"),\n                _ => panic!(\"Expected tool_result block\"),\n            }\n            match &blocks[1] {\n                ContentBlock::Text { text } => assert_eq!(text, \"System Reminder\"),\n                _ => panic!(\"Expected text block\"),\n            }\n        } else {\n            panic!(\"Expected array content at index 2\");\n        }\n    }\n    #[test]\n    fn test_default_max_tokens() {\n        let req = ClaudeRequest {\n            model: \"claude-3-opus\".to_string(),\n            messages: vec![Message {\n                role: \"user\".to_string(),\n                content: MessageContent::String(\"Hello\".to_string()),\n            }],\n            system: None,\n            tools: None,\n            stream: false,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            thinking: None,\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        let result = transform_claude_request_in(&req, \"test-v\", false, None, \"test_session\", None).unwrap();\n        // [FIX] Since we removed the default 81920, maxOutputTokens should NOT be present\n        // when max_tokens is None and thinking is disabled\n        let gen_config = &result[\"request\"][\"generationConfig\"];\n        assert!(\n            gen_config.get(\"maxOutputTokens\").is_none(),\n            \"maxOutputTokens should not be set when max_tokens is None\"\n        );\n    }\n    #[test]\n    fn test_claude_flash_thinking_budget_capping() {\n        // Use full path or ensure import of ThinkingConfig\n        // transform_claude_request and models are needed.\n        // Assuming models are available via super imports, but let's be explicit if needed.\n\n        // Setup request with high budget\n        let req = ClaudeRequest {\n            model: \"gemini-2.0-flash-thinking-exp\".to_string(), // Contains \"flash\"\n            messages: vec![],\n            thinking: Some(ThinkingConfig {\n                type_: \"enabled\".to_string(),\n                budget_tokens: Some(32000),\n                effort: None,\n            }),\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None, // Added missing field\n            stream: false,\n            system: None,\n            tools: None,\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        let result = transform_claude_request_in(&req, \"proj\", false, None, \"test_session\", None).unwrap();\n        let budget = result[\"request\"][\"generationConfig\"][\"thinkingConfig\"][\"thinkingBudget\"]\n            .as_u64()\n            .unwrap();\n        assert_eq!(budget, 24576); // capped by model_specs.get_thinking_budget(\"gemini-2.0-flash-thinking-exp\")\n\n        // Setup request for Pro thinking model (mock name for testing)\n        let req_pro = ClaudeRequest {\n            model: \"gemini-2.0-pro-thinking-exp\".to_string(), // Contains \"thinking\" but not \"flash\"\n            messages: vec![],\n            thinking: Some(ThinkingConfig {\n                type_: \"enabled\".to_string(),\n                budget_tokens: Some(32000),\n                effort: None,\n            }),\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None, // Added missing field\n            stream: false,\n            system: None,\n            tools: None,\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        // Should cap\n        let result_pro = transform_claude_request_in(&req_pro, \"proj\", false, None, \"test_session\", None).unwrap();\n        assert_eq!(result_pro[\"request\"][\"generationConfig\"][\"thinkingConfig\"][\"thinkingBudget\"], 24576);\n    }\n\n    #[test]\n    fn test_gemini_pro_thinking_support() {\n        // Setup request for Gemini Pro (no -thinking suffix)\n        let req = ClaudeRequest {\n            model: \"gemini-3-pro-preview\".to_string(),\n            messages: vec![Message {\n                role: \"user\".to_string(),\n                content: MessageContent::String(\"Hello\".to_string()),\n            }],\n            thinking: Some(ThinkingConfig {\n                type_: \"enabled\".to_string(),\n                budget_tokens: Some(16000),\n                effort: None,\n            }),\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            stream: false,\n            system: None,\n            tools: None,\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        // Transform\n        let result = transform_claude_request_in(&req, \"proj\", false, None, \"test_session\", None).unwrap();\n        let gen_config = &result[\"request\"][\"generationConfig\"];\n\n        // thinkingConfig should be present (not forced disabled)\n        assert!(\n            gen_config.get(\"thinkingConfig\").is_some(),\n            \"thinkingConfig should be preserved for gemini-3-pro\"\n        );\n\n        let budget = gen_config[\"thinkingConfig\"][\"thinkingBudget\"]\n            .as_u64()\n            .unwrap();\n        // [FIX #1592] Since it's < 24576, it should be kept as 16000\n        assert_eq!(budget, 16000);\n    }\n\n    #[test]\n    fn test_gemini_pro_default_thinking() {\n        // Setup request for Gemini Pro WITHOUT thinking config\n        let req = ClaudeRequest {\n            model: \"gemini-3-pro-preview\".to_string(),\n            messages: vec![Message {\n                role: \"user\".to_string(),\n                content: MessageContent::String(\"Hello\".to_string()),\n            }],\n            thinking: None, // No thinking config provided by client\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            stream: false,\n            system: None,\n            tools: None,\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        // Transform\n        let result = transform_claude_request_in(&req, \"proj\", false, None, \"test_session\", None).unwrap();\n        let gen_config = &result[\"request\"][\"generationConfig\"];\n\n        // thinkingConfig SHOULD be injected because of default-on logic\n        assert!(\n            gen_config.get(\"thinkingConfig\").is_some(),\n            \"thinkingConfig should be auto-enabled for gemini-3-pro\"\n        );\n    }\n\n    #[test]\n    fn test_claude_image_thinking_mode_disabled() {\n        // 1. Force image thinking mode to \"disabled\"\n        crate::proxy::config::update_image_thinking_mode(Some(\"disabled\".to_string()));\n\n        // 2. Setup Claude request for an image model (mapped to gemini-3-pro-image)\n        let req = ClaudeRequest {\n            model: \"gemini-3-pro-image\".to_string(), // Explicitly use recognized image model\n            messages: vec![Message {\n                role: \"user\".to_string(),\n                content: MessageContent::String(\"Draw a cat\".to_string()),\n            }],\n            thinking: None,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            stream: false,\n            system: None,\n            tools: None,\n            metadata: None,\n            output_config: None,\n            size: Some(\"1024x1024\".to_string()),\n            quality: Some(\"hd\".to_string()),\n        };\n\n        // 3. Transform request\n        let result = transform_claude_request_in(&req, \"test-proj\", false, None, \"test_session\", None).unwrap();\n\n        // 4. Verify thinkingConfig has includeThoughts: false\n        let gen_config = result[\"request\"][\"generationConfig\"].as_object().expect(\"Should have generationConfig\");\n        let thinking_config = gen_config.get(\"thinkingConfig\").and_then(|t| t.as_object()).expect(\"Should have thinkingConfig (explicitly disabled)\");\n        \n        assert_eq!(thinking_config[\"includeThoughts\"], false);\n        \n        // 5. Reset global mode\n        crate::proxy::config::update_image_thinking_mode(Some(\"enabled\".to_string()));\n    }\n\n    #[test]\n    fn test_claude_adaptive_global_config() {\n        // Set global config to Adaptive + High effort\n        let config = ThinkingBudgetConfig {\n            mode: crate::proxy::config::ThinkingBudgetMode::Adaptive,\n            custom_value: 0,\n            effort: Some(\"high\".to_string()),\n        };\n        crate::proxy::config::update_thinking_budget_config(config);\n\n        let req = ClaudeRequest {\n            model: \"claude-3-7-sonnet-thinking\".to_string(), // thinking capable\n            messages: vec![Message {\n                role: \"user\".to_string(),\n                content: MessageContent::String(\"test\".to_string()),\n            }],\n            thinking: None, // No client thinking config\n            stream: false,\n            // ... minimal fields\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            system: None,\n            tools: None,\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        // Transform\n        let result = transform_claude_request_in(&req, \"test-proj\", false, None, \"test_session\", None).unwrap();\n        \n        let gen_config = result[\"request\"][\"generationConfig\"].as_object().unwrap();\n        let thinking_config = gen_config[\"thinkingConfig\"].as_object().unwrap();\n\n        // Check injection\n        assert_eq!(thinking_config[\"includeThoughts\"], true);\n        assert_eq!(thinking_config[\"thinkingBudget\"], -1);\n        assert!(thinking_config.get(\"thinkingType\").is_none());\n        assert!(thinking_config.get(\"effort\").is_none());\n\n        // Check maxOutputTokens default for adaptive\n        let max_output_tokens = gen_config[\"maxOutputTokens\"].as_i64().unwrap();\n        assert_eq!(max_output_tokens, 131072);\n\n        // Reset global config\n        crate::proxy::config::update_thinking_budget_config(ThinkingBudgetConfig::default());\n    }\n\n    #[test]\n    fn test_mixed_tools_injection_for_gemini_2_0() {\n        // [场景] 使用 Gemini 2.0 模型，同时提供自定义工具和启用全网搜索\n        // 期望: 转换后的请求应同时包含 googleSearch 和 functionDeclarations\n        let req = ClaudeRequest {\n            model: \"claude-sonnet-4-6\".to_string(), // 映射到 gemini-2.0-flash-exp\n            messages: vec![Message {\n                role: \"user\".to_string(),\n                content: MessageContent::String(\"Help me search and use tools\".to_string()),\n            }],\n            system: None,\n            tools: Some(vec![Tool {\n                type_: None,\n                name: Some(\"get_weather\".to_string()),\n                description: Some(\"Get weather\".to_string()),\n                input_schema: Some(serde_json::json!({\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"location\": {\"type\": \"string\"}\n                    }\n                })),\n            }]),\n            stream: false,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            thinking: None,\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        // 模拟映射到 Gemini 2.0\n        let mapped_model = \"gemini-2.0-flash-exp\";\n        \n        // 这里我们直接测试 build_tools 函数 (它是 pub(crate) 且在同模块下)\n        let result = build_tools(&req.tools, true, mapped_model);\n        assert!(result.is_ok());\n        \n        let tools_val = result.unwrap().expect(\"Should have tools\");\n        let tools_arr = tools_val.as_array().expect(\"Tools should be an array\");\n        \n        let has_google_search = tools_arr.iter().any(|t| t.get(\"googleSearch\").is_some());\n        let has_functions = tools_arr.iter().any(|t| t.get(\"functionDeclarations\").is_some());\n        \n        assert!(has_google_search, \"Gemini 2.0 should support mixed Google Search\");\n        assert!(has_functions, \"Gemini 2.0 should support mixed function declarations\");\n    }\n\n    #[test]\n    fn test_no_mixed_tools_for_older_gemini() {\n        // [场景] 使用 Gemini 1.5 模型，同时提供自定义工具和启用全网搜索\n        // 期望: 转换后的请求应只包含 functionDeclarations，googleSearch 被跳过以避免 400\n        let req = ClaudeRequest {\n            model: \"claude-sonnet-4-6\".to_string(),\n            messages: vec![Message {\n                role: \"user\".to_string(),\n                content: MessageContent::String(\"Help me search and use tools\".to_string()),\n            }],\n            system: None,\n            tools: Some(vec![Tool {\n                type_: None,\n                name: Some(\"get_weather\".to_string()),\n                description: Some(\"Get weather\".to_string()),\n                input_schema: Some(serde_json::json!({\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"location\": {\"type\": \"string\"}\n                    }\n                })),\n            }]),\n            stream: false,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            thinking: None,\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        // 模拟映射到 Gemini 1.5\n        let mapped_model = \"gemini-1.5-flash-002\";\n        \n        // 测试 build_tools 函数\n        let result = build_tools(&req.tools, true, mapped_model);\n        assert!(result.is_ok());\n        \n        let tools_val = result.unwrap().expect(\"Should have tools\");\n        let tools_arr = tools_val.as_array().expect(\"Tools should be an array\");\n        \n        let has_google_search = tools_arr.iter().any(|t| t.get(\"googleSearch\").is_some());\n        let has_functions = tools_arr.iter().any(|t| t.get(\"functionDeclarations\").is_some());\n        \n        assert!(!has_google_search, \"Older Gemini models should NOT have mixed tools\");\n        assert!(has_functions);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/claude/response.rs",
    "content": "// Claude 非流式响应转换 (Gemini → Claude)\n// 对应 NonStreamingProcessor\n\nuse super::models::*;\nuse super::utils::to_claude_usage;\nuse serde_json::json;\n\n/// Known parameter remappings for Gemini → Claude compatibility\n/// [FIX] Gemini sometimes uses different parameter names than specified in tool schema\nfn remap_function_call_args(tool_name: &str, args: &mut serde_json::Value) {\n    // [DEBUG] Always log incoming tool usage for diagnosis\n    if let Some(obj) = args.as_object() {\n        tracing::debug!(\"[Response] Tool Call: '{}' Args: {:?}\", tool_name, obj);\n    }\n\n    if let Some(obj) = args.as_object_mut() {\n        // [IMPROVED] Case-insensitive matching for tool names\n        // [IMPROVED] Case-insensitive matching for tool names\n        match tool_name.to_lowercase().as_str() {\n            \"grep\" | \"search\" | \"search_code_definitions\" | \"search_code_snippets\" => {\n                // [FIX] Gemini hallucination: maps parameter description to \"description\" field\n                if let Some(desc) = obj.remove(\"description\") {\n                    if !obj.contains_key(\"pattern\") {\n                        obj.insert(\"pattern\".to_string(), desc);\n                        tracing::debug!(\"[Response] Remapped Grep: description → pattern\");\n                    }\n                }\n\n                // Gemini uses \"query\", Claude Code expects \"pattern\"\n                if let Some(query) = obj.remove(\"query\") {\n                    if !obj.contains_key(\"pattern\") {\n                        obj.insert(\"pattern\".to_string(), query);\n                        tracing::debug!(\"[Response] Remapped Grep: query → pattern\");\n                    }\n                }\n\n                // [CRITICAL FIX] Claude Code uses \"path\" (string), NOT \"paths\" (array)!\n                if !obj.contains_key(\"path\") {\n                    if let Some(paths) = obj.remove(\"paths\") {\n                        let path_str = if let Some(arr) = paths.as_array() {\n                            arr.get(0)\n                                .and_then(|v| v.as_str())\n                                .unwrap_or(\".\")\n                                .to_string()\n                        } else if let Some(s) = paths.as_str() {\n                            s.to_string()\n                        } else {\n                            \".\".to_string()\n                        };\n                        obj.insert(\"path\".to_string(), serde_json::json!(path_str));\n                        tracing::debug!(\"[Response] Remapped Grep: paths → path(\\\"{}\\\")\", path_str);\n                    } else {\n                        // Default to current directory if missing\n                        obj.insert(\"path\".to_string(), json!(\".\"));\n                        tracing::debug!(\"[Response] Added default path: \\\".\\\"\");\n                    }\n                }\n\n                // Note: We keep \"-n\" and \"output_mode\" if present as they are valid in Grep schema\n            }\n            \"glob\" => {\n                // [FIX] Gemini hallucination: maps parameter description to \"description\" field\n                if let Some(desc) = obj.remove(\"description\") {\n                    if !obj.contains_key(\"pattern\") {\n                        obj.insert(\"pattern\".to_string(), desc);\n                        tracing::debug!(\"[Response] Remapped Glob: description → pattern\");\n                    }\n                }\n\n                // Gemini uses \"query\", Claude Code expects \"pattern\"\n                if let Some(query) = obj.remove(\"query\") {\n                    if !obj.contains_key(\"pattern\") {\n                        obj.insert(\"pattern\".to_string(), query);\n                        tracing::debug!(\"[Response] Remapped Glob: query → pattern\");\n                    }\n                }\n\n                // [CRITICAL FIX] Claude Code uses \"path\" (string), NOT \"paths\" (array)!\n                if !obj.contains_key(\"path\") {\n                    if let Some(paths) = obj.remove(\"paths\") {\n                        let path_str = if let Some(arr) = paths.as_array() {\n                            arr.get(0)\n                                .and_then(|v| v.as_str())\n                                .unwrap_or(\".\")\n                                .to_string()\n                        } else if let Some(s) = paths.as_str() {\n                            s.to_string()\n                        } else {\n                            \".\".to_string()\n                        };\n                        obj.insert(\"path\".to_string(), serde_json::json!(path_str));\n                        tracing::debug!(\"[Response] Remapped Glob: paths → path(\\\"{}\\\")\", path_str);\n                    } else {\n                        // Default to current directory if missing\n                        obj.insert(\"path\".to_string(), json!(\".\"));\n                        tracing::debug!(\"[Response] Added default path: \\\".\\\"\");\n                    }\n                }\n            }\n            \"read\" => {\n                // Gemini might use \"path\" vs \"file_path\"\n                if let Some(path) = obj.remove(\"path\") {\n                    if !obj.contains_key(\"file_path\") {\n                        obj.insert(\"file_path\".to_string(), path);\n                        tracing::debug!(\"[Response] Remapped Read: path → file_path\");\n                    }\n                }\n            }\n            \"ls\" => {\n                // LS tool: ensure \"path\" parameter exists\n                if !obj.contains_key(\"path\") {\n                    obj.insert(\"path\".to_string(), serde_json::json!(\".\"));\n                    tracing::debug!(\"[Response] Remapped LS: default path → \\\".\\\"\");\n                }\n            }\n            other => {\n                // [NEW] [Issue #785] Generic Property Mapping for all tools\n                // If a tool has \"paths\" (array of 1) but no \"path\", convert it.\n                let mut path_to_inject = None;\n                if !obj.contains_key(\"path\") {\n                    if let Some(paths) = obj.get(\"paths\").and_then(|v| v.as_array()) {\n                        if paths.len() == 1 {\n                            if let Some(p) = paths[0].as_str() {\n                                path_to_inject = Some(p.to_string());\n                            }\n                        }\n                    }\n                }\n\n                if let Some(path) = path_to_inject {\n                    obj.insert(\"path\".to_string(), serde_json::json!(path));\n                    tracing::debug!(\n                        \"[Response] Probabilistic fix for tool '{}': paths[0] → path(\\\"{}\\\")\",\n                        other,\n                        path\n                    );\n                }\n                tracing::debug!(\n                    \"[Response] Unmapped tool call processed via generic rules: {} (keys: {:?})\",\n                    other,\n                    obj.keys()\n                );\n            }\n        }\n    }\n}\n\n/// 非流式响应处理器\npub struct NonStreamingProcessor {\n    content_blocks: Vec<ContentBlock>,\n    text_builder: String,\n    thinking_builder: String,\n    thinking_signature: Option<String>,\n    trailing_signature: Option<String>,\n    pub has_tool_call: bool,\n    pub scaling_enabled: bool,\n    pub context_limit: u32,\n    pub session_id: Option<String>,\n    pub model_name: String,\n    pub message_count: usize, // [NEW v4.0.0] Message count for rewind detection\n}\n\nimpl NonStreamingProcessor {\n    pub fn new(session_id: Option<String>, model_name: String, message_count: usize) -> Self {\n        Self {\n            content_blocks: Vec::new(),\n            text_builder: String::new(),\n            thinking_builder: String::new(),\n            thinking_signature: None,\n            trailing_signature: None,\n            has_tool_call: false,\n            scaling_enabled: false,\n            context_limit: 1_048_576, // Default to 1M\n            session_id,\n            model_name,\n            message_count,\n        }\n    }\n\n    /// 处理 Gemini 响应并转换为 Claude 响应\n    pub fn process(\n        &mut self,\n        gemini_response: &GeminiResponse,\n        scaling_enabled: bool,\n        context_limit: u32,\n    ) -> ClaudeResponse {\n        self.scaling_enabled = scaling_enabled;\n        self.context_limit = context_limit;\n        // 获取 parts\n        let empty_parts = vec![];\n        let parts = gemini_response\n            .candidates\n            .as_ref()\n            .and_then(|c| c.get(0))\n            .and_then(|candidate| candidate.content.as_ref())\n            .map(|content| &content.parts)\n            .unwrap_or(&empty_parts);\n\n        // 处理所有 parts\n        for part in parts {\n            self.process_part(part);\n        }\n\n        // 处理 grounding(web search) -> 转换为 server_tool_use / web_search_tool_result\n        if let Some(candidate) = gemini_response.candidates.as_ref().and_then(|c| c.get(0)) {\n            if let Some(grounding) = &candidate.grounding_metadata {\n                self.process_grounding(grounding);\n            }\n        }\n\n        // 刷新剩余内容\n        self.flush_thinking();\n        self.flush_text();\n\n        // 处理 trailingSignature (空 text 带签名)\n        if let Some(signature) = self.trailing_signature.take() {\n            self.content_blocks.push(ContentBlock::Thinking {\n                thinking: String::new(),\n                signature: Some(signature),\n                cache_control: None,\n            });\n        }\n\n        // 构建响应\n        self.build_response(gemini_response)\n    }\n\n    /// 处理单个 part\n    fn process_part(&mut self, part: &GeminiPart) {\n        let signature = part.thought_signature.as_ref().map(|sig| {\n            use base64::Engine;\n            match base64::engine::general_purpose::STANDARD.decode(sig) {\n                Ok(decoded_bytes) => {\n                    match String::from_utf8(decoded_bytes) {\n                        Ok(decoded_str) => {\n                            tracing::debug!(\n                                \"[Response] Decoded base64 signature (len {} -> {})\",\n                                sig.len(),\n                                decoded_str.len()\n                            );\n                            decoded_str\n                        }\n                        Err(_) => sig.clone(), // Not valid UTF-8, keep as is\n                    }\n                }\n                Err(_) => sig.clone(), // Not base64, keep as is\n            }\n        });\n\n        // [FIX #765] Cache signature in NonStreamingProcessor\n        if let Some(sig) = &signature {\n            if let Some(s_id) = &self.session_id {\n                crate::proxy::SignatureCache::global()\n                    .cache_session_signature(s_id, sig.to_string(), self.message_count);\n                crate::proxy::SignatureCache::global()\n                    .cache_thinking_family(sig.to_string(), self.model_name.clone());\n                tracing::debug!(\n                    \"[Claude-Response] Cached signature (len: {}) for session: {}\",\n                    sig.len(),\n                    s_id\n                );\n            }\n        }\n\n        // 1. FunctionCall 处理\n        if let Some(fc) = &part.function_call {\n            self.flush_thinking();\n            self.flush_text();\n\n            // 处理 trailingSignature (B4/C3 场景)\n            if let Some(trailing_sig) = self.trailing_signature.take() {\n                self.content_blocks.push(ContentBlock::Thinking {\n                    thinking: String::new(),\n                    signature: Some(trailing_sig),\n                    cache_control: None,\n                });\n            }\n\n            self.has_tool_call = true;\n\n            // 生成 tool_use id\n            let tool_id = fc.id.clone().unwrap_or_else(|| {\n                format!(\n                    \"{}-{}\",\n                    fc.name,\n                    crate::proxy::common::utils::generate_random_id()\n                )\n            });\n\n            let mut tool_name = fc.name.clone();\n            // [OPTIMIZED] Only rename if it's \"search\" which is a known hallucination.\n            // Avoid renaming \"grep\" to \"Grep\" if possible to protect signature.\n            if tool_name.to_lowercase() == \"search\" {\n                tool_name = \"Grep\".to_string();\n            }\n\n            // [FIX] Remap args for Gemini → Claude compatibility\n            let mut args = fc.args.clone().unwrap_or(serde_json::json!({}));\n            remap_function_call_args(&tool_name, &mut args);\n\n            let mut tool_use = ContentBlock::ToolUse {\n                id: tool_id,\n                name: tool_name,\n                input: args.clone(),\n                signature: None,\n                cache_control: None,\n            };\n\n            // 只使用 FC 自己的签名\n            if let ContentBlock::ToolUse { signature: sig, .. } = &mut tool_use {\n                *sig = signature;\n            }\n\n            self.content_blocks.push(tool_use);\n            return;\n        }\n\n        // 2. Text 处理\n        if let Some(text) = &part.text {\n            if part.thought.unwrap_or(false) {\n                // Thinking part\n                self.flush_text();\n\n                // 处理 trailingSignature\n                if let Some(trailing_sig) = self.trailing_signature.take() {\n                    self.flush_thinking();\n                    self.content_blocks.push(ContentBlock::Thinking {\n                        thinking: String::new(),\n                        signature: Some(trailing_sig),\n                        cache_control: None,\n                    });\n                }\n\n                self.thinking_builder.push_str(text);\n                if signature.is_some() {\n                    self.thinking_signature = signature;\n                }\n            } else {\n                // 普通 Text\n                if text.is_empty() {\n                    // 空 text 带签名 - 暂存到 trailingSignature\n                    if signature.is_some() {\n                        self.trailing_signature = signature;\n                    }\n                    return;\n                }\n\n                self.flush_thinking();\n\n                // 处理之前的 trailingSignature\n                if let Some(trailing_sig) = self.trailing_signature.take() {\n                    self.flush_text();\n                    self.content_blocks.push(ContentBlock::Thinking {\n                        thinking: String::new(),\n                        signature: Some(trailing_sig),\n                        cache_control: None,\n                    });\n                }\n\n                self.text_builder.push_str(text);\n\n                // 非空 text 带签名 - 立即刷新并输出空 thinking 块\n                if let Some(sig) = signature {\n                    self.flush_text();\n                    self.content_blocks.push(ContentBlock::Thinking {\n                        thinking: String::new(),\n                        signature: Some(sig),\n                        cache_control: None,\n                    });\n                }\n            }\n        }\n\n        // 3. InlineData (Image) 处理\n        if let Some(img) = &part.inline_data {\n            self.flush_thinking();\n\n            let mime_type = &img.mime_type;\n            let data = &img.data;\n            if !data.is_empty() {\n                let markdown_img = format!(\"![image](data:{};base64,{})\", mime_type, data);\n                self.text_builder.push_str(&markdown_img);\n                self.flush_text();\n            }\n        }\n    }\n\n    /// 处理 Grounding 元数据 (Web Search 结果)\n    fn process_grounding(&mut self, grounding: &GroundingMetadata) {\n        let mut grounding_text = String::new();\n\n        // 1. 处理搜索词\n        if let Some(queries) = &grounding.web_search_queries {\n            if !queries.is_empty() {\n                grounding_text.push_str(\"\\n\\n---\\n**🔍 已为您搜索：** \");\n                grounding_text.push_str(&queries.join(\", \"));\n            }\n        }\n\n        // 2. 处理来源链接 (Chunks)\n        if let Some(chunks) = &grounding.grounding_chunks {\n            let mut links = Vec::new();\n            for (i, chunk) in chunks.iter().enumerate() {\n                if let Some(web) = &chunk.web {\n                    let title = web.title.as_deref().unwrap_or(\"网页来源\");\n                    let uri = web.uri.as_deref().unwrap_or(\"#\");\n                    links.push(format!(\"[{}] [{}]({})\", i + 1, title, uri));\n                }\n            }\n\n            if !links.is_empty() {\n                grounding_text.push_str(\"\\n\\n**🌐 来源引文：**\\n\");\n                grounding_text.push_str(&links.join(\"\\n\"));\n            }\n        }\n\n        if !grounding_text.is_empty() {\n            // 在常规内容前后刷新并插入文本\n            self.flush_thinking();\n            self.flush_text();\n            self.text_builder.push_str(&grounding_text);\n            self.flush_text();\n        }\n    }\n\n    /// 刷新 text builder\n    fn flush_text(&mut self) {\n        if self.text_builder.is_empty() {\n            return;\n        }\n\n        let mut current_text = self.text_builder.clone();\n        self.text_builder.clear();\n\n        // [NEW] MCP XML Bridge: 循环解析文本中可能存在的 XML 标签\n        while let Some(start_idx) = current_text.find(\"<mcp__\") {\n            if let Some(tag_end_idx) = current_text[start_idx..].find('>') {\n                let actual_tag_end = start_idx + tag_end_idx;\n                let tool_name = &current_text[start_idx + 1..actual_tag_end];\n                let end_tag = format!(\"</{}>\", tool_name);\n\n                if let Some(close_idx) = current_text.find(&end_tag) {\n                    // 1. 处理标签前的文本\n                    if start_idx > 0 {\n                        self.content_blocks.push(ContentBlock::Text {\n                            text: current_text[..start_idx].to_string(),\n                        });\n                    }\n\n                    // 2. 解析 XML 内容并转换为 ToolUse\n                    let input_str = &current_text[actual_tag_end + 1..close_idx];\n                    let input_json: serde_json::Value = serde_json::from_str(input_str.trim())\n                        .unwrap_or_else(|_| serde_json::json!({ \"input\": input_str.trim() }));\n\n                    self.content_blocks.push(ContentBlock::ToolUse {\n                        id: format!(\"{}-xml\", tool_name),\n                        name: tool_name.to_string(),\n                        input: input_json,\n                        signature: None,\n                        cache_control: None,\n                    });\n                    self.has_tool_call = true;\n\n                    // 3. 继续处理剩余文本\n                    current_text = current_text[close_idx + end_tag.len()..].to_string();\n                    continue;\n                }\n            }\n            // 如果 XML 格式不完整, 退出循环并按普通文本处理\n            break;\n        }\n\n        if !current_text.is_empty() {\n            self.content_blocks\n                .push(ContentBlock::Text { text: current_text });\n        }\n    }\n\n    /// 刷新 thinking builder\n    fn flush_thinking(&mut self) {\n        // 如果既没有内容也没有签名，直接返回\n        if self.thinking_builder.is_empty() && self.thinking_signature.is_none() {\n            return;\n        }\n\n        let thinking = self.thinking_builder.clone();\n        let signature = self.thinking_signature.take();\n\n        self.content_blocks.push(ContentBlock::Thinking {\n            thinking,\n            signature,\n            cache_control: None,\n        });\n        self.thinking_builder.clear();\n    }\n\n    /// 构建最终响应\n    fn build_response(&self, gemini_response: &GeminiResponse) -> ClaudeResponse {\n        let finish_reason = gemini_response\n            .candidates\n            .as_ref()\n            .and_then(|c| c.get(0))\n            .and_then(|candidate| candidate.finish_reason.as_deref());\n\n        let stop_reason = if self.has_tool_call {\n            \"tool_use\"\n        } else if finish_reason == Some(\"MAX_TOKENS\") {\n            \"max_tokens\"\n        } else {\n            \"end_turn\"\n        };\n\n        let usage = gemini_response\n            .usage_metadata\n            .as_ref()\n            .map(|u| to_claude_usage(u, self.scaling_enabled, self.context_limit))\n            .unwrap_or(Usage {\n                input_tokens: 0,\n                output_tokens: 0,\n                cache_read_input_tokens: None,\n                cache_creation_input_tokens: None,\n                server_tool_use: None,\n            });\n\n        ClaudeResponse {\n            id: gemini_response.response_id.clone().unwrap_or_else(|| {\n                format!(\"msg_{}\", crate::proxy::common::utils::generate_random_id())\n            }),\n            type_: \"message\".to_string(),\n            role: \"assistant\".to_string(),\n            model: gemini_response.model_version.clone().unwrap_or_default(),\n            content: self.content_blocks.clone(),\n            stop_reason: stop_reason.to_string(),\n            stop_sequence: None,\n            usage,\n        }\n    }\n}\n\npub fn transform_response(\n    gemini_response: &GeminiResponse,\n    scaling_enabled: bool,\n    context_limit: u32,\n    session_id: Option<String>,\n    model_name: String,\n    message_count: usize, // [NEW v4.0.0] Message count for rewind detection\n) -> Result<ClaudeResponse, String> {\n    let mut processor = NonStreamingProcessor::new(session_id, model_name, message_count);\n    Ok(processor.process(gemini_response, scaling_enabled, context_limit))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_simple_text_response() {\n        let gemini_resp = GeminiResponse {\n            candidates: Some(vec![Candidate {\n                content: Some(GeminiContent {\n                    role: \"model\".to_string(),\n                    parts: vec![GeminiPart {\n                        text: Some(\"Hello, world!\".to_string()),\n                        thought: None,\n                        thought_signature: None,\n                        function_call: None,\n                        function_response: None,\n                        inline_data: None,\n                    }],\n                }),\n                finish_reason: Some(\"STOP\".to_string()),\n                index: Some(0),\n                grounding_metadata: None,\n            }]),\n            usage_metadata: Some(UsageMetadata {\n                prompt_token_count: Some(10),\n                candidates_token_count: Some(5),\n                total_token_count: Some(15),\n                cached_content_token_count: None,\n            }),\n            model_version: Some(\"gemini-2.5-flash\".to_string()),\n            response_id: Some(\"resp_123\".to_string()),\n        };\n\n        let result = transform_response(\n            &gemini_resp,\n            false,\n            1_000_000,\n            None,\n            \"gemini-2.5-flash\".to_string(),\n            1,\n        );\n        assert!(result.is_ok());\n\n        let claude_resp = result.unwrap();\n        assert_eq!(claude_resp.role, \"assistant\");\n        assert_eq!(claude_resp.stop_reason, \"end_turn\");\n        assert_eq!(claude_resp.content.len(), 1);\n\n        match &claude_resp.content[0] {\n            ContentBlock::Text { text } => {\n                assert_eq!(text, \"Hello, world!\");\n            }\n            _ => panic!(\"Expected Text block\"),\n        }\n    }\n\n    #[test]\n    fn test_thinking_with_signature() {\n        let gemini_resp = GeminiResponse {\n            candidates: Some(vec![Candidate {\n                content: Some(GeminiContent {\n                    role: \"model\".to_string(),\n                    parts: vec![\n                        GeminiPart {\n                            text: Some(\"Let me think...\".to_string()),\n                            thought: Some(true),\n                            thought_signature: Some(\"sig123\".to_string()),\n                            function_call: None,\n                            function_response: None,\n                            inline_data: None,\n                        },\n                        GeminiPart {\n                            text: Some(\"The answer is 42\".to_string()),\n                            thought: None,\n                            thought_signature: None,\n                            function_call: None,\n                            function_response: None,\n                            inline_data: None,\n                        },\n                    ],\n                }),\n                finish_reason: Some(\"STOP\".to_string()),\n                index: Some(0),\n                grounding_metadata: None,\n            }]),\n            usage_metadata: None,\n            model_version: Some(\"gemini-2.5-flash\".to_string()),\n            response_id: Some(\"resp_456\".to_string()),\n        };\n\n        let result = transform_response(\n            &gemini_resp,\n            false,\n            1_000_000,\n            None,\n            \"gemini-2.5-flash\".to_string(),\n            1,\n        );\n        assert!(result.is_ok());\n\n        let claude_resp = result.unwrap();\n        assert_eq!(claude_resp.content.len(), 2);\n\n        match &claude_resp.content[0] {\n            ContentBlock::Thinking {\n                thinking,\n                signature,\n                ..\n            } => {\n                assert_eq!(thinking, \"Let me think...\");\n                assert_eq!(signature.as_deref(), Some(\"sig123\"));\n            }\n            _ => panic!(\"Expected Thinking block\"),\n        }\n\n        match &claude_resp.content[1] {\n            ContentBlock::Text { text } => {\n                assert_eq!(text, \"The answer is 42\");\n            }\n            _ => panic!(\"Expected Text block\"),\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/claude/serde_leak_test.rs",
    "content": "use serde_json::json;\nuse crate::proxy::mappers::claude::models::ClaudeRequest;\n\n#[test]\nfn test_claude_request_deserialization_leak() {\n    // 模拟一个包含 cache_control: null 的请求\n    let incoming_json = json!({\n        \"model\": \"claude-3-5-sonnet-20241022\",\n        \"messages\": [\n            {\n                \"role\": \"assistant\",\n                \"content\": [\n                    {\n                        \"type\": \"thinking\",\n                        \"thinking\": \"test\",\n                        \"signature\": \"sig1234567890\",\n                        \"cache_control\": null\n                    }\n                ]\n            }\n        ]\n    });\n\n    let request: ClaudeRequest = serde_json::from_value(incoming_json).expect(\"Deserialization failed\");\n    \n    // 检查反序列化后的值\n    if let crate::proxy::mappers::claude::models::MessageContent::Array(blocks) = &request.messages[0].content {\n        if let crate::proxy::mappers::claude::models::ContentBlock::Thinking { cache_control, .. } = &blocks[0] {\n            println!(\"Debug: cache_control after deserialization: {:?}\", cache_control);\n            assert!(cache_control.is_none(), \"cache_control should be None if incoming was null\");\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/claude/streaming.rs",
    "content": "// Claude 流式响应转换 (Gemini SSE → Claude SSE)\n// 对应 StreamingState + PartProcessor\n\nuse super::models::*;\nuse super::utils::to_claude_usage;\nuse crate::proxy::mappers::estimation_calibrator::get_calibrator;\n// use crate::proxy::mappers::signature_store::store_thought_signature; // Deprecated\nuse crate::proxy::SignatureCache;\nuse crate::proxy::common::client_adapter::{ClientAdapter, SignatureBufferStrategy}; // [NEW]\nuse bytes::Bytes;\nuse serde_json::{json, Value};\n\n/// Known parameter remappings for Gemini → Claude compatibility\n/// [FIX] Gemini sometimes uses different parameter names than specified in tool schema\npub fn remap_function_call_args(name: &str, args: &mut Value) {\n    // [DEBUG] Always log incoming tool usage for diagnosis\n    if let Some(obj) = args.as_object() {\n        tracing::debug!(\"[Streaming] Tool Call: '{}' Args: {:?}\", name, obj);\n    }\n\n    // [IMPORTANT] Claude Code CLI 的 EnterPlanMode 工具禁止携带任何参数\n    // 代理层注入的 reason 参数会导致 InputValidationError\n    if name == \"EnterPlanMode\" {\n        if let Some(obj) = args.as_object_mut() {\n            obj.clear();\n        }\n        return;\n    }\n\n    if let Some(obj) = args.as_object_mut() {\n        // [IMPROVED] Case-insensitive matching for tool names\n        match name.to_lowercase().as_str() {\n            \"grep\" | \"search\" | \"search_code_definitions\" | \"search_code_snippets\" => {\n                // [FIX] Gemini hallucination: maps parameter description to \"description\" field\n                if let Some(desc) = obj.remove(\"description\") {\n                    if !obj.contains_key(\"pattern\") {\n                        obj.insert(\"pattern\".to_string(), desc);\n                        tracing::debug!(\"[Streaming] Remapped Grep: description → pattern\");\n                    }\n                }\n\n                // Gemini uses \"query\", Claude Code expects \"pattern\"\n                if let Some(query) = obj.remove(\"query\") {\n                    if !obj.contains_key(\"pattern\") {\n                        obj.insert(\"pattern\".to_string(), query);\n                        tracing::debug!(\"[Streaming] Remapped Grep: query → pattern\");\n                    }\n                }\n\n                // [CRITICAL FIX] Claude Code uses \"path\" (string), NOT \"paths\" (array)!\n                if !obj.contains_key(\"path\") {\n                    if let Some(paths) = obj.remove(\"paths\") {\n                        let path_str = if let Some(arr) = paths.as_array() {\n                            arr.get(0)\n                                .and_then(|v| v.as_str())\n                                .unwrap_or(\".\")\n                                .to_string()\n                        } else if let Some(s) = paths.as_str() {\n                            s.to_string()\n                        } else {\n                            \".\".to_string()\n                        };\n                        obj.insert(\"path\".to_string(), serde_json::json!(path_str));\n                        tracing::debug!(\n                            \"[Streaming] Remapped Grep: paths → path(\\\"{}\\\")\",\n                            path_str\n                        );\n                    } else {\n                        // Default to current directory if missing\n                        obj.insert(\"path\".to_string(), json!(\".\"));\n                        tracing::debug!(\"[Streaming] Added default path: \\\".\\\"\");\n                    }\n                }\n\n                // Note: We keep \"-n\" and \"output_mode\" if present as they are valid in Grep schema\n            }\n            \"glob\" => {\n                // [FIX] Gemini hallucination: maps parameter description to \"description\" field\n                if let Some(desc) = obj.remove(\"description\") {\n                    if !obj.contains_key(\"pattern\") {\n                        obj.insert(\"pattern\".to_string(), desc);\n                        tracing::debug!(\"[Streaming] Remapped Glob: description → pattern\");\n                    }\n                }\n\n                // Gemini uses \"query\", Claude Code expects \"pattern\"\n                if let Some(query) = obj.remove(\"query\") {\n                    if !obj.contains_key(\"pattern\") {\n                        obj.insert(\"pattern\".to_string(), query);\n                        tracing::debug!(\"[Streaming] Remapped Glob: query → pattern\");\n                    }\n                }\n\n                // [CRITICAL FIX] Claude Code uses \"path\" (string), NOT \"paths\" (array)!\n                if !obj.contains_key(\"path\") {\n                    if let Some(paths) = obj.remove(\"paths\") {\n                        let path_str = if let Some(arr) = paths.as_array() {\n                            arr.get(0)\n                                .and_then(|v| v.as_str())\n                                .unwrap_or(\".\")\n                                .to_string()\n                        } else if let Some(s) = paths.as_str() {\n                            s.to_string()\n                        } else {\n                            \".\".to_string()\n                        };\n                        obj.insert(\"path\".to_string(), serde_json::json!(path_str));\n                        tracing::debug!(\n                            \"[Streaming] Remapped Glob: paths → path(\\\"{}\\\")\",\n                            path_str\n                        );\n                    } else {\n                        // Default to current directory if missing\n                        obj.insert(\"path\".to_string(), json!(\".\"));\n                        tracing::debug!(\"[Streaming] Added default path: \\\".\\\"\");\n                    }\n                }\n            }\n            \"read\" => {\n                // Gemini might use \"path\" vs \"file_path\"\n                if let Some(path) = obj.remove(\"path\") {\n                    if !obj.contains_key(\"file_path\") {\n                        obj.insert(\"file_path\".to_string(), path);\n                        tracing::debug!(\"[Streaming] Remapped Read: path → file_path\");\n                    }\n                }\n            }\n            \"ls\" => {\n                // LS tool: ensure \"path\" parameter exists\n                if !obj.contains_key(\"path\") {\n                    obj.insert(\"path\".to_string(), json!(\".\"));\n                    tracing::debug!(\"[Streaming] Remapped LS: default path → \\\".\\\"\");\n                }\n            }\n            other => {\n                // [NEW] [Issue #785] Generic Property Mapping for all tools\n                // If a tool has \"paths\" (array of 1) but no \"path\", convert it.\n                let mut path_to_inject = None;\n                if !obj.contains_key(\"path\") {\n                    if let Some(paths) = obj.get(\"paths\").and_then(|v| v.as_array()) {\n                        if paths.len() == 1 {\n                            if let Some(p) = paths[0].as_str() {\n                                path_to_inject = Some(p.to_string());\n                            }\n                        }\n                    }\n                }\n\n                if let Some(path) = path_to_inject {\n                    obj.insert(\"path\".to_string(), json!(path));\n                    tracing::debug!(\n                        \"[Streaming] Probabilistic fix for tool '{}': paths[0] → path(\\\"{}\\\")\",\n                        other,\n                        path\n                    );\n                }\n                tracing::debug!(\n                    \"[Streaming] Unmapped tool call processed via generic rules: {} (keys: {:?})\",\n                    other,\n                    obj.keys()\n                );\n            }\n        }\n    }\n}\n\n/// 块类型枚举\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum BlockType {\n    None,\n    Text,\n    Thinking,\n    Function,\n}\n\n/// 签名管理器\npub struct SignatureManager {\n    pending: Option<String>,\n}\n\nimpl SignatureManager {\n    pub fn new() -> Self {\n        Self { pending: None }\n    }\n\n    pub fn store(&mut self, signature: Option<String>) {\n        if signature.is_some() {\n            self.pending = signature;\n        }\n    }\n\n    pub fn consume(&mut self) -> Option<String> {\n        self.pending.take()\n    }\n\n    pub fn has_pending(&self) -> bool {\n        self.pending.is_some()\n    }\n}\n\n/// 流式状态机\npub struct StreamingState {\n    block_type: BlockType,\n    pub block_index: usize,\n    pub message_start_sent: bool,\n    pub message_stop_sent: bool,\n    used_tool: bool,\n    signatures: SignatureManager,\n    trailing_signature: Option<String>,\n    pub web_search_query: Option<String>,\n    pub grounding_chunks: Option<Vec<serde_json::Value>>,\n    // [IMPROVED] Error recovery 状态追踪 (prepared for future use)\n    #[allow(dead_code)]\n    parse_error_count: usize,\n    #[allow(dead_code)]\n    last_valid_state: Option<BlockType>,\n    // [NEW] Model tracking for signature cache\n    pub model_name: Option<String>,\n    // [NEW v3.3.17] Session ID for session-based signature caching\n    pub session_id: Option<String>,\n    // [NEW] Flag for context usage scaling\n    pub scaling_enabled: bool,\n    // [NEW] Context limit for smart threshold recovery (default to 1M)\n    pub context_limit: u32,\n    // [NEW] MCP XML Bridge 缓冲区\n    pub mcp_xml_buffer: String,\n    pub in_mcp_xml: bool,\n    // [FIX] Estimated prompt tokens for calibrator learning\n    pub estimated_prompt_tokens: Option<u32>,\n    // [FIX #859] Post-thinking interruption tracking\n    pub has_thinking: bool,\n    pub has_content: bool,\n    pub message_count: usize, // [NEW v4.0.0] Message count for rewind detection\n    pub client_adapter: Option<std::sync::Arc<dyn ClientAdapter>>, // [FIX] Remove Box, use Arc<dyn> directly\n    // [FIX #MCP] Registered tool names for fuzzy matching\n    pub registered_tool_names: Vec<String>,\n}\n\nimpl StreamingState {\n    pub fn new() -> Self {\n        Self {\n            block_type: BlockType::None,\n            block_index: 0,\n            message_start_sent: false,\n            message_stop_sent: false,\n            used_tool: false,\n            signatures: SignatureManager::new(),\n            trailing_signature: None,\n            web_search_query: None,\n            grounding_chunks: None,\n            // [IMPROVED] 初始化 error recovery 字段\n            parse_error_count: 0,\n            last_valid_state: None,\n            model_name: None,\n            session_id: None,\n            scaling_enabled: false,\n            context_limit: 1_048_576, // Default to 1M\n            mcp_xml_buffer: String::new(),\n            in_mcp_xml: false,\n            estimated_prompt_tokens: None,\n            has_thinking: false,\n            has_content: false,\n            message_count: 0,\n            client_adapter: None,\n            registered_tool_names: Vec::new(),\n        }\n    }\n\n    // [NEW] Set client adapter\n    pub fn set_client_adapter(&mut self, adapter: Option<std::sync::Arc<dyn ClientAdapter>>) {\n        self.client_adapter = adapter;\n    }\n\n    // [FIX #MCP] Set registered tool names for fuzzy matching\n    pub fn set_registered_tool_names(&mut self, names: Vec<String>) {\n        self.registered_tool_names = names;\n    }\n\n    /// 发送 SSE 事件\n    pub fn emit(&self, event_type: &str, data: serde_json::Value) -> Bytes {\n        let sse = format!(\n            \"event: {}\\ndata: {}\\n\\n\",\n            event_type,\n            serde_json::to_string(&data).unwrap_or_default()\n        );\n        Bytes::from(sse)\n    }\n\n    /// 发送 message_start 事件\n    pub fn emit_message_start(&mut self, raw_json: &serde_json::Value) -> Bytes {\n        if self.message_start_sent {\n            return Bytes::new();\n        }\n\n        let usage = raw_json\n            .get(\"usageMetadata\")\n            .and_then(|u| serde_json::from_value::<UsageMetadata>(u.clone()).ok())\n            .map(|u| to_claude_usage(&u, self.scaling_enabled, self.context_limit));\n\n        let mut message = json!({\n            \"id\": raw_json.get(\"responseId\")\n                .and_then(|v| v.as_str())\n                .unwrap_or_else(|| \"msg_unknown\"),\n            \"type\": \"message\",\n            \"role\": \"assistant\",\n            \"content\": [],\n            \"model\": raw_json.get(\"modelVersion\")\n                .and_then(|v| v.as_str())\n                .unwrap_or(\"\"),\n            \"stop_reason\": null,\n            \"stop_sequence\": null,\n        });\n\n        // Capture model name for signature cache\n        if let Some(m) = raw_json.get(\"modelVersion\").and_then(|v| v.as_str()) {\n            self.model_name = Some(m.to_string());\n        }\n\n        if let Some(u) = usage {\n            message[\"usage\"] = json!(u);\n        }\n\n        let result = self.emit(\n            \"message_start\",\n            json!({\n                \"type\": \"message_start\",\n                \"message\": message\n            }),\n        );\n\n        self.message_start_sent = true;\n        result\n    }\n\n    /// 开始新的内容块\n    pub fn start_block(\n        &mut self,\n        block_type: BlockType,\n        content_block: serde_json::Value,\n    ) -> Vec<Bytes> {\n        let mut chunks = Vec::new();\n        if self.block_type != BlockType::None {\n            chunks.extend(self.end_block());\n        }\n\n        chunks.push(self.emit(\n            \"content_block_start\",\n            json!({\n                \"type\": \"content_block_start\",\n                \"index\": self.block_index,\n                \"content_block\": content_block\n            }),\n        ));\n\n        self.block_type = block_type;\n        chunks\n    }\n\n    /// 结束当前内容块\n    pub fn end_block(&mut self) -> Vec<Bytes> {\n        if self.block_type == BlockType::None {\n            return vec![];\n        }\n\n        let mut chunks = Vec::new();\n\n        // Thinking 块结束时发送暂存的签名\n        if self.block_type == BlockType::Thinking && self.signatures.has_pending() {\n            if let Some(signature) = self.signatures.consume() {\n                chunks.push(self.emit_delta(\"signature_delta\", json!({ \"signature\": signature })));\n            }\n        }\n\n        chunks.push(self.emit(\n            \"content_block_stop\",\n            json!({\n                \"type\": \"content_block_stop\",\n                \"index\": self.block_index\n            }),\n        ));\n\n        self.block_index += 1;\n        self.block_type = BlockType::None;\n\n        chunks\n    }\n\n    /// 发送 delta 事件\n    pub fn emit_delta(&self, delta_type: &str, delta_content: serde_json::Value) -> Bytes {\n        let mut delta = json!({ \"type\": delta_type });\n        if let serde_json::Value::Object(map) = delta_content {\n            for (k, v) in map {\n                delta[k] = v;\n            }\n        }\n\n        self.emit(\n            \"content_block_delta\",\n            json!({\n                \"type\": \"content_block_delta\",\n                \"index\": self.block_index,\n                \"delta\": delta\n            }),\n        )\n    }\n\n    /// 发送结束事件\n    pub fn emit_finish(\n        &mut self,\n        finish_reason: Option<&str>,\n        usage_metadata: Option<&UsageMetadata>,\n    ) -> Vec<Bytes> {\n        let mut chunks = Vec::new();\n\n        // 关闭最后一个块\n        chunks.extend(self.end_block());\n\n        // 处理 trailingSignature (B4/C3 场景)\n        // [FIX] 只有当还没有发送过任何块时, 才能以 thinking 块结束(作为消息的开头)\n        // 实际上, 对于 Claude 协议, 如果已经发送过 Text, 就不能在此追加 Thinking。\n        // 这里的解决方案是: 只存储签名, 不再发送非法的末尾 Thinking 块。\n        // 签名会通过 SignatureCache 在下一轮请求中自动恢复。\n        if let Some(signature) = self.trailing_signature.take() {\n            tracing::info!(\n                \"[Streaming] Captured trailing signature (len: {}), caching for session.\",\n                signature.len()\n            );\n            self.signatures.store(Some(signature));\n            // 不再追加 chunks.push(self.emit(\"content_block_start\", ...))\n        }\n\n        // 处理 grounding(web search) -> 转换为 Markdown 文本块\n        if self.web_search_query.is_some() || self.grounding_chunks.is_some() {\n            let mut grounding_text = String::new();\n\n            // 1. 处理搜索词\n            if let Some(query) = &self.web_search_query {\n                if !query.is_empty() {\n                    grounding_text.push_str(\"\\n\\n---\\n**🔍 已为您搜索：** \");\n                    grounding_text.push_str(query);\n                }\n            }\n\n            // 2. 处理来源链接\n            if let Some(chunks) = &self.grounding_chunks {\n                let mut links = Vec::new();\n                for (i, chunk) in chunks.iter().enumerate() {\n                    if let Some(web) = chunk.get(\"web\") {\n                        let title = web\n                            .get(\"title\")\n                            .and_then(|v| v.as_str())\n                            .unwrap_or(\"网页来源\");\n                        let uri = web.get(\"uri\").and_then(|v| v.as_str()).unwrap_or(\"#\");\n                        links.push(format!(\"[{}] [{}]({})\", i + 1, title, uri));\n                    }\n                }\n\n                if !links.is_empty() {\n                    grounding_text.push_str(\"\\n\\n**🌐 来源引文：**\\n\");\n                    grounding_text.push_str(&links.join(\"\\n\"));\n                }\n            }\n\n            let trimmed_grounding = grounding_text.trim();\n            if !trimmed_grounding.is_empty() {\n                // 发送一个新的 text 块\n                chunks.push(self.emit(\n                    \"content_block_start\",\n                    json!({\n                        \"type\": \"content_block_start\",\n                        \"index\": self.block_index,\n                        \"content_block\": { \"type\": \"text\", \"text\": \"\" }\n                    }),\n                ));\n                chunks.push(self.emit_delta(\"text_delta\", json!({ \"text\": trimmed_grounding })));\n                chunks.push(self.emit(\n                    \"content_block_stop\",\n                    json!({ \"type\": \"content_block_stop\", \"index\": self.block_index }),\n                ));\n                self.block_index += 1;\n            }\n        }\n\n        // 确定 stop_reason\n        let stop_reason = if self.used_tool {\n            \"tool_use\"\n        } else if finish_reason == Some(\"MAX_TOKENS\") {\n            \"max_tokens\"\n        } else {\n            \"end_turn\"\n        };\n\n        let usage = usage_metadata\n            .map(|u| {\n                // [FIX] Record actual token usage for calibrator learning\n                // Now properly pairs estimated tokens from request with actual tokens from response\n                if let (Some(estimated), Some(actual)) =\n                    (self.estimated_prompt_tokens, u.prompt_token_count)\n                {\n                    if estimated > 0 && actual > 0 {\n                        get_calibrator().record(estimated, actual);\n                        tracing::debug!(\n                            \"[Calibrator] Recorded: estimated={}, actual={}, ratio={:.2}x\",\n                            estimated,\n                            actual,\n                            actual as f64 / estimated as f64\n                        );\n                    }\n                }\n                to_claude_usage(u, self.scaling_enabled, self.context_limit)\n            })\n            .unwrap_or(Usage {\n                input_tokens: 0,\n                output_tokens: 0,\n                cache_read_input_tokens: None,\n                cache_creation_input_tokens: None,\n                server_tool_use: None,\n            });\n\n        chunks.push(self.emit(\n            \"message_delta\",\n            json!({\n                \"type\": \"message_delta\",\n                \"delta\": { \"stop_reason\": stop_reason, \"stop_sequence\": null },\n                \"usage\": usage\n            }),\n        ));\n\n        if !self.message_stop_sent {\n            chunks.push(Bytes::from(\n                \"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\",\n            ));\n            self.message_stop_sent = true;\n        }\n\n        chunks\n    }\n\n    /// 标记使用了工具\n    pub fn mark_tool_used(&mut self) {\n        self.used_tool = true;\n    }\n\n    /// 获取当前块类型\n    pub fn current_block_type(&self) -> BlockType {\n        self.block_type\n    }\n\n    /// 获取当前块索引\n    pub fn current_block_index(&self) -> usize {\n        self.block_index\n    }\n\n    /// 存储签名\n    pub fn store_signature(&mut self, signature: Option<String>) {\n        self.signatures.store(signature);\n    }\n\n    /// 设置 trailing signature\n    pub fn set_trailing_signature(&mut self, signature: Option<String>) {\n        self.trailing_signature = signature;\n    }\n\n    /// 获取 trailing signature (仅用于检查)\n    pub fn has_trailing_signature(&self) -> bool {\n        self.trailing_signature.is_some()\n    }\n\n    /// 处理 SSE 解析错误，实现优雅降级\n    ///\n    /// 当 SSE stream 中发生解析错误时:\n    /// 1. 安全关闭当前 block\n    /// 2. 递增错误计数器\n    /// 3. 在 debug 模式下输出错误信息\n    #[allow(dead_code)] // Prepared for future error recovery implementation\n    pub fn handle_parse_error(&mut self, raw_data: &str) -> Vec<Bytes> {\n        let mut chunks = Vec::new();\n\n        self.parse_error_count += 1;\n\n        tracing::warn!(\n            \"[SSE-Parser] Parse error #{} occurred. Raw data length: {} bytes\",\n            self.parse_error_count,\n            raw_data.len()\n        );\n\n        // 安全关闭当前 block\n        if self.block_type != BlockType::None {\n            self.last_valid_state = Some(self.block_type);\n            chunks.extend(self.end_block());\n        }\n\n        // Debug 模式下输出详细错误信息\n        #[cfg(debug_assertions)]\n        {\n            let preview = if raw_data.len() > 100 {\n                format!(\"{}...\", &raw_data[..100])\n            } else {\n                raw_data.to_string()\n            };\n            tracing::debug!(\"[SSE-Parser] Failed chunk preview: {}\", preview);\n        }\n\n        // 错误率过高时发出警告并尝试发送错误信号\n        if self.parse_error_count > 3 {\n            // 降低阈值,更早通知用户\n            tracing::error!(\n                \"[SSE-Parser] High error rate detected ({} errors). Stream may be corrupted.\",\n                self.parse_error_count\n            );\n\n            // [FIX] Explicitly signal error to client to prevent UI freeze\n            // using standard SSE error event format\n            // data: {\"type\": \"error\", \"error\": {...}}\n            chunks.push(self.emit(\n                \"error\",\n                json!({\n                    \"type\": \"error\",\n                    \"error\": {\n                        \"type\": \"overloaded_error\", // Use standard type\n                        \"message\": \"网络连接不稳定，请检查您的网络或代理设置。\",\n                    }\n                }),\n            ));\n        }\n\n        chunks\n    }\n\n    /// 重置错误状态 (recovery 后调用)\n    #[allow(dead_code)]\n    pub fn reset_error_state(&mut self) {\n        self.parse_error_count = 0;\n        self.last_valid_state = None;\n    }\n\n    /// 获取错误计数 (用于监控)\n    #[allow(dead_code)]\n    pub fn get_error_count(&self) -> usize {\n        self.parse_error_count\n    }\n}\n\n/// Part 处理器\npub struct PartProcessor<'a> {\n    state: &'a mut StreamingState,\n}\n\nimpl<'a> PartProcessor<'a> {\n    pub fn new(state: &'a mut StreamingState) -> Self {\n        Self { state }\n    }\n\n    /// 处理单个 part\n    pub fn process(&mut self, part: &GeminiPart) -> Vec<Bytes> {\n        let mut chunks = Vec::new();\n        // [FIX #545] Decode Base64 signature if present (Gemini sends Base64, Claude expects Raw)\n        let signature = part.thought_signature.as_ref().map(|sig| {\n            // Try to decode as base64\n            use base64::Engine;\n            match base64::engine::general_purpose::STANDARD.decode(sig) {\n                Ok(decoded_bytes) => {\n                    match String::from_utf8(decoded_bytes) {\n                        Ok(decoded_str) => {\n                            tracing::debug!(\n                                \"[Streaming] Decoded base64 signature (len {} -> {})\",\n                                sig.len(),\n                                decoded_str.len()\n                            );\n                            decoded_str\n                        }\n                        Err(_) => sig.clone(), // Not valid UTF-8, keep as is\n                    }\n                }\n                Err(_) => sig.clone(), // Not base64, keep as is\n            }\n        });\n\n        // 1. FunctionCall 处理\n        if let Some(fc) = &part.function_call {\n            // 先处理 trailingSignature (B4/C3 场景)\n            if self.state.has_trailing_signature() {\n                chunks.extend(self.state.end_block());\n                if let Some(trailing_sig) = self.state.trailing_signature.take() {\n                    chunks.push(self.state.emit(\n                        \"content_block_start\",\n                        json!({\n                            \"type\": \"content_block_start\",\n                            \"index\": self.state.current_block_index(),\n                            \"content_block\": { \"type\": \"thinking\", \"thinking\": \"\" }\n                        }),\n                    ));\n                    chunks.push(\n                        self.state\n                            .emit_delta(\"thinking_delta\", json!({ \"thinking\": \"\" })),\n                    );\n                    chunks.push(\n                        self.state\n                            .emit_delta(\"signature_delta\", json!({ \"signature\": trailing_sig })),\n                    );\n                    chunks.extend(self.state.end_block());\n                }\n            }\n\n            chunks.extend(self.process_function_call(fc, signature));\n            // [FIX #859] Mark that we have received actual content (tool use)\n            self.state.has_content = true;\n            return chunks;\n        }\n\n        // 2. Text 处理\n        if let Some(text) = &part.text {\n            if part.thought.unwrap_or(false) {\n                // Thinking\n                chunks.extend(self.process_thinking(text, signature));\n            } else {\n                // 普通 Text\n                chunks.extend(self.process_text(text, signature));\n            }\n        }\n\n        // 3. InlineData (Image) 处理\n        if let Some(img) = &part.inline_data {\n            let mime_type = &img.mime_type;\n            let data = &img.data;\n            if !data.is_empty() {\n                let markdown_img = format!(\"![image](data:{};base64,{})\", mime_type, data);\n                chunks.extend(self.process_text(&markdown_img, None));\n            }\n        }\n\n        chunks\n    }\n\n    /// 处理 Thinking\n    fn process_thinking(&mut self, text: &str, signature: Option<String>) -> Vec<Bytes> {\n        let mut chunks = Vec::new();\n\n        // 处理之前的 trailingSignature\n        if self.state.has_trailing_signature() {\n            chunks.extend(self.state.end_block());\n            if let Some(trailing_sig) = self.state.trailing_signature.take() {\n                chunks.push(self.state.emit(\n                    \"content_block_start\",\n                    json!({\n                        \"type\": \"content_block_start\",\n                        \"index\": self.state.current_block_index(),\n                        \"content_block\": { \"type\": \"thinking\", \"thinking\": \"\" }\n                    }),\n                ));\n                chunks.push(\n                    self.state\n                        .emit_delta(\"thinking_delta\", json!({ \"thinking\": \"\" })),\n                );\n                chunks.push(\n                    self.state\n                        .emit_delta(\"signature_delta\", json!({ \"signature\": trailing_sig })),\n                );\n                chunks.extend(self.state.end_block());\n            }\n        }\n\n        // 开始或继续 thinking 块\n        if self.state.current_block_type() != BlockType::Thinking {\n            chunks.extend(self.state.start_block(\n                BlockType::Thinking,\n                json!({ \"type\": \"thinking\", \"thinking\": \"\" }),\n            ));\n        }\n\n        // [FIX #859] Mark that we have received thinking content\n        self.state.has_thinking = true;\n\n        if !text.is_empty() {\n            chunks.push(\n                self.state\n                    .emit_delta(\"thinking_delta\", json!({ \"thinking\": text })),\n            );\n        }\n\n        // [NEW] Apply Client Adapter Strategy\n        let use_fifo = self.state.client_adapter.as_ref()\n            .map(|a| a.signature_buffer_strategy() == SignatureBufferStrategy::Fifo)\n            .unwrap_or(false);\n\n        // [IMPROVED] Store signature to global cache\n        if let Some(ref sig) = signature {\n            // 1. Cache family if we know the model\n            if let Some(model) = &self.state.model_name {\n                SignatureCache::global().cache_thinking_family(sig.clone(), model.clone());\n            }\n\n            // 2. [NEW v3.3.17] Cache to session-based storage for tool loop recovery\n            if let Some(session_id) = &self.state.session_id {\n                // If FIFO strategy is enabled, use a unique index for each signature (e.g. timestamp or counter)\n                // However, our cache implementation currently keys by session_id.\n                // For FIFO, we might just rely on the fact that we are processing in order.\n                // But specifically for opencode, it might be calling tools in parallel or sequence.\n                \n                SignatureCache::global().cache_session_signature(\n                    session_id, \n                    sig.clone(), \n                    self.state.message_count\n                );\n                tracing::debug!(\n                    \"[Claude-SSE] Cached signature to session {} (length: {}) [FIFO: {}]\",\n                    session_id,\n                    sig.len(),\n                    use_fifo\n                );\n            }\n\n            tracing::debug!(\n                \"[Claude-SSE] Captured thought_signature from thinking block (length: {})\",\n                sig.len()\n            );\n        }\n\n        // 暂存签名 (for local block handling)\n        // If FIFO, we strictly follow the sequence. The default logic is effectively LIFO for a single turn \n        // (store latest, consume at end). \n        // For opencode, we just want to ensure we capture IT.\n        self.state.store_signature(signature);\n\n        chunks\n    }\n\n    /// 处理普通 Text\n    fn process_text(&mut self, text: &str, signature: Option<String>) -> Vec<Bytes> {\n        let mut chunks = Vec::new();\n\n        // 空 text 带签名 - 暂存\n        if text.is_empty() {\n            if signature.is_some() {\n                self.state.set_trailing_signature(signature);\n            }\n            return chunks;\n        }\n\n        // [FIX #859] Mark that we have received actual content (text)\n        self.state.has_content = true;\n\n        // 处理之前的 trailingSignature\n        if self.state.has_trailing_signature() {\n            chunks.extend(self.state.end_block());\n            if let Some(trailing_sig) = self.state.trailing_signature.take() {\n                chunks.push(self.state.emit(\n                    \"content_block_start\",\n                    json!({\n                        \"type\": \"content_block_start\",\n                        \"index\": self.state.current_block_index(),\n                        \"content_block\": { \"type\": \"thinking\", \"thinking\": \"\" }\n                    }),\n                ));\n                chunks.push(\n                    self.state\n                        .emit_delta(\"thinking_delta\", json!({ \"thinking\": \"\" })),\n                );\n                chunks.push(\n                    self.state\n                        .emit_delta(\"signature_delta\", json!({ \"signature\": trailing_sig })),\n                );\n                chunks.extend(self.state.end_block());\n            }\n        }\n\n        // 非空 text 带签名 - 立即处理\n        if signature.is_some() {\n            // [FIX] 为保护签名, 签名所在的 Text 块直接发送\n            // 注意: 不得在此开启 thinking 块, 因为之前可能已有非 thinking 内容。\n            // 这种情况下, 我们只需确签被缓存在状态中。\n            self.state.store_signature(signature);\n\n            chunks.extend(\n                self.state\n                    .start_block(BlockType::Text, json!({ \"type\": \"text\", \"text\": \"\" })),\n            );\n            chunks.push(self.state.emit_delta(\"text_delta\", json!({ \"text\": text })));\n            chunks.extend(self.state.end_block());\n\n            return chunks;\n        }\n\n        // Ordinary text (without signature)\n\n        // [NEW] MCP XML Bridge: Intercept and parse <mcp__...> tags\n        if text.contains(\"<mcp__\") || self.state.in_mcp_xml {\n            self.state.in_mcp_xml = true;\n            self.state.mcp_xml_buffer.push_str(text);\n\n            // Check if we have a complete tag in the buffer\n            if self.state.mcp_xml_buffer.contains(\"</mcp__\")\n                && self.state.mcp_xml_buffer.contains('>')\n            {\n                let buffer = self.state.mcp_xml_buffer.clone();\n                if let Some(start_idx) = buffer.find(\"<mcp__\") {\n                    if let Some(tag_end_idx) = buffer[start_idx..].find('>') {\n                        let actual_tag_end = start_idx + tag_end_idx;\n                        let tool_name = &buffer[start_idx + 1..actual_tag_end];\n                        let end_tag = format!(\"</{}>\", tool_name);\n\n                        if let Some(close_idx) = buffer.find(&end_tag) {\n                            let input_str = &buffer[actual_tag_end + 1..close_idx];\n                            let input_json: serde_json::Value =\n                                serde_json::from_str(input_str.trim())\n                                    .unwrap_or_else(|_| json!({ \"input\": input_str.trim() }));\n\n                            // 构造并发送 tool_use\n                            let fc = FunctionCall {\n                                name: tool_name.to_string(),\n                                args: Some(input_json),\n                                id: Some(format!(\"{}-xml\", tool_name)),\n                            };\n\n                            let tool_chunks = self.process_function_call(&fc, None);\n\n                            // 清理缓冲区并重置状态\n                            self.state.mcp_xml_buffer.clear();\n                            self.state.in_mcp_xml = false;\n\n                            // 处理标签之前可能存在的非 XML 文本\n                            if start_idx > 0 {\n                                let prefix_text = &buffer[..start_idx];\n                                // 这里不能递归。直接 emit 之前的 text 块。\n                                if self.state.current_block_type() != BlockType::Text {\n                                    chunks.extend(self.state.start_block(\n                                        BlockType::Text,\n                                        json!({ \"type\": \"text\", \"text\": \"\" }),\n                                    ));\n                                }\n                                chunks.push(\n                                    self.state\n                                        .emit_delta(\"text_delta\", json!({ \"text\": prefix_text })),\n                                );\n                            }\n\n                            chunks.extend(tool_chunks);\n\n                            // 处理标签之后可能存在的非 XML 文本\n                            let suffix = &buffer[close_idx + end_tag.len()..];\n                            if !suffix.is_empty() {\n                                // 递归处理后缀内容\n                                chunks.extend(self.process_text(suffix, None));\n                            }\n\n                            return chunks;\n                        }\n                    }\n                }\n            }\n            // While in XML, don't emit text deltas\n            return vec![];\n        }\n\n        if self.state.current_block_type() != BlockType::Text {\n            chunks.extend(\n                self.state\n                    .start_block(BlockType::Text, json!({ \"type\": \"text\", \"text\": \"\" })),\n            );\n        }\n\n        chunks.push(self.state.emit_delta(\"text_delta\", json!({ \"text\": text })));\n\n        chunks\n    }\n\n    /// Process FunctionCall and capture signature for global storage\n    fn process_function_call(\n        &mut self,\n        fc: &FunctionCall,\n        signature: Option<String>,\n    ) -> Vec<Bytes> {\n        let mut chunks = Vec::new();\n\n        self.state.mark_tool_used();\n\n        let tool_id = fc.id.clone().unwrap_or_else(|| {\n            format!(\n                \"{}-{}\",\n                fc.name,\n                crate::proxy::common::utils::generate_random_id()\n            )\n        });\n\n        let mut tool_name = fc.name.clone();\n        if tool_name.to_lowercase() == \"search\" {\n            tool_name = \"grep\".to_string();\n            tracing::debug!(\"[Streaming] Normalizing tool name: Search → grep\");\n        }\n\n        // [FIX #MCP] MCP tool name fuzzy matching\n        // Gemini often hallucinates incorrect MCP tool names, e.g.:\n        //   \"mcp__puppeteer_navigate\" instead of \"mcp__puppeteer__puppeteer_navigate\"\n        // We attempt to find the closest registered tool name.\n        if tool_name.starts_with(\"mcp__\") && !self.state.registered_tool_names.is_empty() {\n            if !self.state.registered_tool_names.contains(&tool_name) {\n                if let Some(matched) = fuzzy_match_mcp_tool(&tool_name, &self.state.registered_tool_names) {\n                    tracing::warn!(\n                        \"[FIX #MCP] Corrected MCP tool name: '{}' → '{}'\",\n                        tool_name, matched\n                    );\n                    tool_name = matched;\n                } else {\n                    tracing::warn!(\n                        \"[FIX #MCP] No fuzzy match found for MCP tool '{}'. Passing as-is.\",\n                        tool_name\n                    );\n                }\n            }\n        }\n\n        // 1. 发送 content_block_start (input 为空对象)\n        let mut tool_use = json!({\n            \"type\": \"tool_use\",\n            \"id\": tool_id,\n            \"name\": tool_name,\n            \"input\": {} // 必须为空，参数通过 delta 发送\n        });\n\n        if let Some(ref sig) = signature {\n            tool_use[\"signature\"] = json!(sig);\n\n            // 2. Cache tool signature (Layer 1 recovery)\n            SignatureCache::global().cache_tool_signature(&tool_id, sig.clone());\n\n            // 3. [NEW v3.3.17] Cache to session-based storage\n            if let Some(session_id) = &self.state.session_id {\n                SignatureCache::global().cache_session_signature(\n                    session_id, \n                    sig.clone(),\n                    self.state.message_count\n                );\n            }\n\n            tracing::debug!(\n                \"[Claude-SSE] Captured thought_signature for function call (length: {})\",\n                sig.len()\n            );\n        }\n\n        chunks.extend(self.state.start_block(BlockType::Function, tool_use));\n\n        // 2. 发送 input_json_delta (完整的参数 JSON 字符串)\n        // [FIX] Remap args before serialization for Gemini → Claude compatibility\n        if let Some(args) = &fc.args {\n            let mut remapped_args = args.clone();\n\n            let tool_name_title = fc.name.clone();\n            // [OPTIMIZED] Only rename if it's \"search\" which is a known hallucination.\n            // Avoid renaming \"grep\" to \"Grep\" if possible to protect signature,\n            // unless we're sure Grep is the standard.\n            let mut final_tool_name = tool_name_title;\n            if final_tool_name.to_lowercase() == \"search\" {\n                final_tool_name = \"Grep\".to_string();\n            }\n            remap_function_call_args(&final_tool_name, &mut remapped_args);\n\n            let json_str =\n                serde_json::to_string(&remapped_args).unwrap_or_else(|_| \"{}\".to_string());\n            chunks.push(\n                self.state\n                    .emit_delta(\"input_json_delta\", json!({ \"partial_json\": json_str })),\n            );\n        }\n\n        // 3. 结束块\n        chunks.extend(self.state.end_block());\n\n        chunks\n    }\n}\n\n/// [FIX #MCP] Fuzzy match an incorrect MCP tool name against registered tool names.\n///\n/// MCP tool naming convention: `mcp__<server_name>__<tool_name>`\n/// Gemini often hallucinates by:\n///   1. Dropping the server prefix: `mcp__navigate` → should be `mcp__puppeteer__puppeteer_navigate`\n///   2. Merging server+tool: `mcp__puppeteer_navigate` → should be `mcp__puppeteer__puppeteer_navigate`\n///   3. Partial name: `mcp__pup_navigate` → should be `mcp__puppeteer__puppeteer_navigate`\n///\n/// Strategy (in priority order):\n///   1. Exact suffix match: if the hallucinated name's suffix exactly matches a registered tool's suffix\n///   2. Suffix contained: if the hallucinated name (without `mcp__`) is contained in a registered tool name\n///   3. Longest common subsequence scoring: picks the registered tool with the best LCS ratio\nfn fuzzy_match_mcp_tool(hallucinated: &str, registered: &[String]) -> Option<String> {\n    let mcp_tools: Vec<&String> = registered.iter()\n        .filter(|name| name.starts_with(\"mcp__\"))\n        .collect();\n\n    if mcp_tools.is_empty() {\n        return None;\n    }\n\n    // Extract the part after \"mcp__\" for the hallucinated name\n    let hallucinated_suffix = &hallucinated[5..]; // skip \"mcp__\"\n\n    // Strategy 1: Exact suffix match\n    // e.g., hallucinated = \"mcp__puppeteer_navigate\", registered = \"mcp__puppeteer__puppeteer_navigate\"\n    // Check if any registered tool ends with the hallucinated suffix after `__`\n    for tool in &mcp_tools {\n        // For registered tool \"mcp__server__tool_name\", extract \"tool_name\"\n        if let Some(last_sep) = tool.rfind(\"__\") {\n            let tool_suffix = &tool[last_sep + 2..];\n            if hallucinated_suffix == tool_suffix {\n                return Some(tool.to_string());\n            }\n        }\n    }\n\n    // Strategy 2: Suffix contained match\n    // e.g., hallucinated = \"mcp__puppeteer_navigate\", check if \"puppeteer_navigate\" is a substring\n    // of any registered tool's full name\n    let mut contained_matches: Vec<(&String, usize)> = Vec::new();\n    for tool in &mcp_tools {\n        let tool_lower = tool.to_lowercase();\n        let hall_lower = hallucinated_suffix.to_lowercase();\n        if tool_lower.contains(&hall_lower) {\n            contained_matches.push((tool, tool.len()));\n        }\n    }\n    // Pick the shortest match (most specific)\n    if !contained_matches.is_empty() {\n        contained_matches.sort_by_key(|(_, len)| *len);\n        return Some(contained_matches[0].0.to_string());\n    }\n\n    // Strategy 3: Normalized token overlap scoring\n    // Split both names into tokens by '_' and '__', compute overlap ratio  \n    let hall_tokens: Vec<&str> = hallucinated_suffix\n        .split(|c: char| c == '_')\n        .filter(|s| !s.is_empty())\n        .collect();\n\n    if hall_tokens.is_empty() {\n        return None;\n    }\n\n    let mut best_match: Option<String> = None;\n    let mut best_score: f64 = 0.0;\n    let threshold = 0.4; // Minimum overlap ratio to consider a match\n\n    for tool in &mcp_tools {\n        let tool_after_mcp = &tool[5..]; // skip \"mcp__\"\n        let tool_tokens: Vec<&str> = tool_after_mcp\n            .split(|c: char| c == '_')\n            .filter(|s| !s.is_empty())\n            .collect();\n\n        if tool_tokens.is_empty() {\n            continue;\n        }\n\n        // Count matching tokens\n        let mut matches = 0;\n        for ht in &hall_tokens {\n            if tool_tokens.iter().any(|tt| tt.eq_ignore_ascii_case(ht)) {\n                matches += 1;\n            }\n        }\n\n        // Score = matching tokens / max(hall_tokens, tool_tokens)\n        let max_len = hall_tokens.len().max(tool_tokens.len()) as f64;\n        let score = matches as f64 / max_len;\n\n        if score > best_score {\n            best_score = score;\n            best_match = Some(tool.to_string());\n        }\n    }\n\n    if best_score >= threshold {\n        tracing::debug!(\n            \"[FIX #MCP] Fuzzy match score for '{}': {:.2} -> {:?}\",\n            hallucinated, best_score, best_match\n        );\n        best_match\n    } else {\n        None\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_signature_manager() {\n        let mut mgr = SignatureManager::new();\n        assert!(!mgr.has_pending());\n\n        mgr.store(Some(\"sig123\".to_string()));\n        assert!(mgr.has_pending());\n\n        let sig = mgr.consume();\n        assert_eq!(sig, Some(\"sig123\".to_string()));\n        assert!(!mgr.has_pending());\n    }\n\n    #[test]\n    fn test_streaming_state_emit() {\n        let state = StreamingState::new();\n        let chunk = state.emit(\"test_event\", json!({\"foo\": \"bar\"}));\n\n        let s = String::from_utf8(chunk.to_vec()).unwrap();\n        assert!(s.contains(\"event: test_event\"));\n        assert!(s.contains(\"\\\"foo\\\":\\\"bar\\\"\"));\n    }\n\n    #[test]\n    fn test_process_function_call_deltas() {\n        let mut state = StreamingState::new();\n        let mut processor = PartProcessor::new(&mut state);\n\n        let fc = FunctionCall {\n            name: \"test_tool\".to_string(),\n            args: Some(json!({\"arg\": \"value\"})),\n            id: Some(\"call_123\".to_string()),\n        };\n\n        // Create a dummy GeminiPart with function_call\n        let part = GeminiPart {\n            text: None,\n            function_call: Some(fc),\n            inline_data: None,\n            thought: None,\n            thought_signature: None,\n            function_response: None,\n        };\n\n        let chunks = processor.process(&part);\n        let output = chunks\n            .iter()\n            .map(|b| String::from_utf8(b.to_vec()).unwrap())\n            .collect::<Vec<_>>()\n            .join(\"\");\n\n        // Verify sequence:\n        // 1. content_block_start with empty input\n        assert!(output.contains(r#\"\"type\":\"content_block_start\"\"#));\n        assert!(output.contains(r#\"\"name\":\"test_tool\"\"#));\n        assert!(output.contains(r#\"\"input\":{}\"#));\n\n        // 2. input_json_delta with serialized args\n        assert!(output.contains(r#\"\"type\":\"content_block_delta\"\"#));\n        assert!(output.contains(r#\"\"type\":\"input_json_delta\"\"#));\n        // partial_json should contain escaped JSON string\n        assert!(output.contains(r#\"partial_json\":\"{\\\"arg\\\":\\\"value\\\"}\"#));\n\n        // 3. content_block_stop\n        assert!(output.contains(r#\"\"type\":\"content_block_stop\"\"#));\n    }\n\n    #[test]\n    fn test_fuzzy_match_mcp_tool_exact_suffix() {\n        let registered = vec![\n            \"mcp__puppeteer__puppeteer_navigate\".to_string(),\n            \"mcp__puppeteer__puppeteer_screenshot\".to_string(),\n            \"mcp__filesystem__read_file\".to_string(),\n        ];\n\n        // Gemini drops server prefix, produces: mcp__puppeteer_navigate\n        // Should match mcp__puppeteer__puppeteer_navigate via suffix \"puppeteer_navigate\"\n        let result = fuzzy_match_mcp_tool(\"mcp__puppeteer_navigate\", &registered);\n        assert_eq!(result, Some(\"mcp__puppeteer__puppeteer_navigate\".to_string()));\n    }\n\n    #[test]\n    fn test_fuzzy_match_mcp_tool_exact_match_no_correction() {\n        let registered = vec![\n            \"mcp__puppeteer__puppeteer_navigate\".to_string(),\n        ];\n\n        // Already correct - should not be called (the caller checks contains first)\n        // But if called, should find it\n        let result = fuzzy_match_mcp_tool(\"mcp__puppeteer__puppeteer_navigate\", &registered);\n        // It will match via suffix strategy\n        assert!(result.is_some());\n    }\n\n    #[test]\n    fn test_fuzzy_match_mcp_tool_suffix_contained() {\n        let registered = vec![\n            \"mcp__puppeteer__puppeteer_navigate\".to_string(),\n            \"mcp__puppeteer__puppeteer_click\".to_string(),\n        ];\n\n        // Gemini produces a partial-but-contained name\n        let result = fuzzy_match_mcp_tool(\"mcp__navigate\", &registered);\n        assert_eq!(result, Some(\"mcp__puppeteer__puppeteer_navigate\".to_string()));\n    }\n\n    #[test]\n    fn test_fuzzy_match_mcp_tool_token_overlap() {\n        let registered = vec![\n            \"mcp__filesystem__read_file\".to_string(),\n            \"mcp__filesystem__write_file\".to_string(),\n            \"mcp__filesystem__list_directory\".to_string(),\n        ];\n\n        // Gemini produces: mcp__read_file → should match mcp__filesystem__read_file\n        let result = fuzzy_match_mcp_tool(\"mcp__read_file\", &registered);\n        assert_eq!(result, Some(\"mcp__filesystem__read_file\".to_string()));\n    }\n\n    #[test]\n    fn test_fuzzy_match_mcp_tool_no_match() {\n        let registered = vec![\n            \"mcp__puppeteer__puppeteer_navigate\".to_string(),\n        ];\n\n        // Completely unrelated name\n        let result = fuzzy_match_mcp_tool(\"mcp__totally_unrelated_xyz\", &registered);\n        assert_eq!(result, None);\n    }\n\n    #[test]\n    fn test_fuzzy_match_mcp_tool_no_mcp_tools() {\n        let registered = vec![\n            \"regular_tool\".to_string(),\n            \"another_tool\".to_string(),\n        ];\n\n        // No MCP tools in registry\n        let result = fuzzy_match_mcp_tool(\"mcp__puppeteer_navigate\", &registered);\n        assert_eq!(result, None);\n    }\n\n    #[test]\n    fn test_fuzzy_match_mcp_tool_screenshot() {\n        let registered = vec![\n            \"mcp__puppeteer__puppeteer_navigate\".to_string(),\n            \"mcp__puppeteer__puppeteer_screenshot\".to_string(),\n            \"mcp__puppeteer__puppeteer_click\".to_string(),\n        ];\n\n        let result = fuzzy_match_mcp_tool(\"mcp__puppeteer_screenshot\", &registered);\n        assert_eq!(result, Some(\"mcp__puppeteer__puppeteer_screenshot\".to_string()));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/claude/thinking_utils.rs",
    "content": "use super::models::{ContentBlock, Message, MessageContent};\nuse crate::proxy::SignatureCache;\nuse tracing::{debug, info, warn};\n\npub const MIN_SIGNATURE_LENGTH: usize = 50;\n\n#[derive(Debug, Default)]\npub struct ConversationState {\n    pub in_tool_loop: bool,\n    pub interrupted_tool: bool,\n    pub last_assistant_idx: Option<usize>,\n}\n\n/// Analyze the conversation to detect tool loops or interrupted tool calls\npub fn analyze_conversation_state(messages: &[Message]) -> ConversationState {\n    let mut state = ConversationState::default();\n\n    if messages.is_empty() {\n        return state;\n    }\n\n    // Find last assistant message index\n    for (i, msg) in messages.iter().enumerate().rev() {\n        if msg.role == \"assistant\" {\n            state.last_assistant_idx = Some(i);\n            break;\n        }\n    }\n\n    // A tool loop starts if the assistant message has tool use blocks\n    let has_tool_use = if let Some(idx) = state.last_assistant_idx {\n        if let Some(msg) = messages.get(idx) {\n            if let MessageContent::Array(blocks) = &msg.content {\n                blocks\n                    .iter()\n                    .any(|b| matches!(b, ContentBlock::ToolUse { .. }))\n            } else {\n                false\n            }\n        } else {\n            false\n        }\n    } else {\n        false\n    };\n\n    if !has_tool_use {\n        return state;\n    }\n\n    // Check what follows the assistant's tool use\n    if let Some(last_msg) = messages.last() {\n        if last_msg.role == \"user\" {\n            if let MessageContent::Array(blocks) = &last_msg.content {\n                // Case 1: Final message is ToolResult -> Active Tool Loop\n                if blocks\n                    .iter()\n                    .any(|b| matches!(b, ContentBlock::ToolResult { .. }))\n                {\n                    state.in_tool_loop = true;\n                    debug!(\n                        \"[Thinking-Recovery] Active tool loop detected (last msg is ToolResult).\"\n                    );\n                } else {\n                    // Case 2: Final message is Text (User) -> Interrupted Tool\n                    state.interrupted_tool = true;\n                    debug!(\n                        \"[Thinking-Recovery] Interrupted tool detected (last msg is Text user).\"\n                    );\n                }\n            } else if let MessageContent::String(_) = &last_msg.content {\n                // Case 2: Final message is String (User) -> Interrupted Tool\n                state.interrupted_tool = true;\n                debug!(\"[Thinking-Recovery] Interrupted tool detected (last msg is String user).\");\n            }\n        }\n    }\n\n    // Check for interrupted tool: Last assistant message has ToolUse, but no corresponding ToolResult in next user msg\n    // (This is harder to detect perfectly on a stateless request, but usually if we are\n    //  in a state where we have ToolUse but the conversation seems \"broken\" or stripped)\n    // Actually, in the proxy context, we typically see:\n    // ... Assistant (ToolUse) -> User (ToolResult) : Normal Loop\n    // ... Assistant (ToolUse) -> User (Text) : Interrupted (User cancelled)\n\n    // For \"Thinking Utils\", we care about the case where valid signatures are missing.\n    // If we are in a tool loop (last msg is ToolResult), and the *preceding* Assistant message\n    // had its Thinking block stripped (due to invalid sig), then we are in a \"Broken Tool Loop\".\n    // Gemini/Claude will reject a ToolResult if the preceding Assistant message didn't start with Thinking.\n\n    state\n}\n\n/// Recover from broken tool loops or interrupted tool calls by injecting synthetic messages\npub fn close_tool_loop_for_thinking(messages: &mut Vec<Message>) {\n    let state = analyze_conversation_state(messages);\n\n    if !state.in_tool_loop && !state.interrupted_tool {\n        return;\n    }\n\n    // Check if the last assistant message has a valid thinking block\n    let mut has_valid_thinking = false;\n    if let Some(idx) = state.last_assistant_idx {\n        if let Some(msg) = messages.get(idx) {\n            if let MessageContent::Array(blocks) = &msg.content {\n                for block in blocks {\n                    if let ContentBlock::Thinking {\n                        thinking,\n                        signature,\n                        ..\n                    } = block\n                    {\n                        if !thinking.is_empty()\n                            && signature\n                                .as_ref()\n                                .map(|s| s.len() >= MIN_SIGNATURE_LENGTH)\n                                .unwrap_or(false)\n                        {\n                            has_valid_thinking = true;\n                            break;\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    if !has_valid_thinking {\n        if state.in_tool_loop {\n            info!(\"[Thinking-Recovery] Broken tool loop (ToolResult without preceding Thinking). Recovery triggered.\");\n\n            // Insert acknowledging message to \"close\" the history turn\n            messages.push(Message {\n                role: \"assistant\".to_string(),\n                content: MessageContent::Array(vec![ContentBlock::Text {\n                    text: \"[System: Tool execution completed. Proceeding to final response.]\"\n                        .to_string(),\n                }]),\n            });\n            messages.push(Message {\n                role: \"user\".to_string(),\n                content: MessageContent::Array(vec![ContentBlock::Text {\n                    text: \"Please provide the final result based on the tool output above.\"\n                        .to_string(),\n                }]),\n            });\n        } else if state.interrupted_tool {\n            info!(\n                \"[Thinking-Recovery] Interrupted tool call detected. Injecting synthetic closure.\"\n            );\n\n            // For interrupted tool, we need to insert the closure AFTER the assistant's tool use\n            // but BEFORE the user's latest message.\n            if let Some(idx) = state.last_assistant_idx {\n                messages.insert(\n                    idx + 1,\n                    Message {\n                        role: \"assistant\".to_string(),\n                        content: MessageContent::Array(vec![ContentBlock::Text {\n                            text: \"[Tool call was interrupted by user.]\".to_string(),\n                        }]),\n                    },\n                );\n            }\n        }\n    }\n}\n\n/// Get the model family origin of a signature\npub fn get_signature_family(signature: &str) -> Option<String> {\n    SignatureCache::global().get_signature_family(signature)\n}\n\n/// [CRITICAL] Sanitize thinking blocks and check cross-model compatibility\npub fn filter_invalid_thinking_blocks_with_family(\n    messages: &mut [Message],\n    target_family: Option<&str>,\n) {\n    let mut stripped_count = 0;\n\n    for msg in messages.iter_mut() {\n        if msg.role != \"assistant\" {\n            continue;\n        }\n\n        if let MessageContent::Array(blocks) = &mut msg.content {\n            let original_len = blocks.len();\n            blocks.retain(|block| {\n                if let ContentBlock::Thinking { signature, .. } = block {\n                    // 1. Basic length check - allow empty signatures to pass through for compatibility\n                    let sig = match signature {\n                        Some(s) if s.len() >= MIN_SIGNATURE_LENGTH || s.is_empty() => s,\n                        None => return true, // Allow None signatures to pass through\n                        _ => {\n                            stripped_count += 1;\n                            return false;\n                        }\n                    };\n                    \n                    // 2. Family compatibility check (Prevents SONNET-Thinking sig being sent to OPUS-Thinking)\n                    if let Some(target) = target_family {\n                        if let Some(origin_family) = get_signature_family(sig) {\n                            if origin_family != target {\n                                warn!(\"[Thinking-Sanitizer] Dropping signature from family '{}' for target '{}'\", origin_family, target);\n                                stripped_count += 1;\n                                return false;\n                            }\n                        } else {\n                            // [CRITICAL] Signature family not found in cache. \n                            // This happens after a server restart when memory is cleared.\n                            // If we pass this unverified signature to the upstream, it will likely return 400 \"Invalid signature\".\n                            // It is safer to strip the signature and let the upstream regenerate it.\n                            info!(\"[Thinking-Sanitizer] Dropping unverified signature (cache miss after restart)\");\n                            stripped_count += 1;\n                            return false;\n                        }\n                    } else if get_signature_family(sig).is_none() && !sig.is_empty() {\n                        // Even if no target family is specified, we still want to filter out signatures\n                        // that we can't verify (unless they are empty, which indicates a fresh start).\n                        info!(\"[Thinking-Sanitizer] Dropping unverified signature (no target family)\");\n                        stripped_count += 1;\n                        return false;\n                    }\n                }\n                true\n            });\n\n            // SAFETY: Claude API requires at least one block\n            if blocks.is_empty() && original_len > 0 {\n                blocks.push(ContentBlock::Text {\n                    text: \".\".to_string(),\n                });\n            }\n        }\n    }\n\n    if stripped_count > 0 {\n        info!(\n            \"[Thinking-Sanitizer] Stripped {} invalid or incompatible thinking blocks\",\n            stripped_count\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/claude/utils.rs",
    "content": "// Claude 辅助函数\n// JSON Schema 清理、签名处理等\n\n// 已移除未使用的 Value 导入\n\n/// 将 JSON Schema 中的类型名称转为大写 (Gemini 要求)\n/// 例如: \"string\" -> \"STRING\", \"integer\" -> \"INTEGER\"\n// 已移除未使用的 uppercase_schema_types 函数\n\n/// 根据模型名称获取上下文 Token 限制\npub fn get_context_limit_for_model(model: &str) -> u32 {\n    if model.contains(\"pro\") {\n        2_097_152 // 2M for Pro\n    } else if model.contains(\"flash\") {\n        1_048_576 // 1M for Flash\n    } else {\n        1_048_576 // Default 1M\n    }\n}\n\npub fn to_claude_usage(usage_metadata: &super::models::UsageMetadata, scaling_enabled: bool, context_limit: u32) -> super::models::Usage {\n    let prompt_tokens = usage_metadata.prompt_token_count.unwrap_or(0);\n    let cached_tokens = usage_metadata.cached_content_token_count.unwrap_or(0);\n\n    // 【改进的智能阈值回归算法】\n    // 目标：既利用 Gemini 大窗口，又能在高用量时让 Claude Code 正确触发 compact 提示\n    //\n    // 分阶段策略：\n    // - 0-50%:  激进压缩，享受大上下文\n    // - 50-70%: 开始加速回升\n    // - 70-85%: 快速回升到显示 70%+\n    // - 85%+:   接近 1:1 显示，确保触发 Claude Code 的 compact 提示\n    let total_raw = prompt_tokens;\n\n    // [FIX] Restore low token threshold - don't scale if under 30k tokens\n    const SCALING_THRESHOLD: u32 = 30_000;\n\n    let scaled_total = if scaling_enabled && total_raw > SCALING_THRESHOLD {\n        const TARGET_MAX: f64 = 195_000.0; // 接近 Claude 的 200k 限制\n\n        let ratio = total_raw as f64 / context_limit as f64;\n\n        if ratio <= 0.5 {\n            // 阶段1 (0-50%): 激进压缩，享受大上下文\n            // 真实 50% → 显示 ~30%\n            let display_ratio = ratio * 0.6;\n            (display_ratio * TARGET_MAX) as u32\n        } else if ratio <= 0.7 {\n            // 阶段2 (50-70%): 开始加速回升\n            // 线性从 30% 回升到 50%\n            let progress = (ratio - 0.5) / 0.2;\n            let display_ratio = 0.3 + progress * 0.2;\n            (display_ratio * TARGET_MAX) as u32\n        } else if ratio <= 0.85 {\n            // 阶段3 (70-85%): 快速回升到显示 70%\n            // 这个阶段让用户开始注意到上下文在增长\n            let progress = (ratio - 0.7) / 0.15;\n            let display_ratio = 0.5 + progress * 0.2;\n            (display_ratio * TARGET_MAX) as u32\n        } else {\n            // 阶段4 (85%+): 接近 1:1 显示，触发 Claude Code 的 compact 提示\n            // 85% 真实 → 70% 显示\n            // 100% 真实 → 97% 显示\n            let progress = (ratio - 0.85) / 0.15;\n            let display_ratio = 0.7 + progress * 0.27;\n            (display_ratio.min(0.97) * TARGET_MAX) as u32\n        }\n    } else {\n        total_raw\n    };\n\n    // 【调试日志】方便手动验证\n    if scaling_enabled && total_raw > 30_000 {\n        let ratio = total_raw as f64 / context_limit as f64;\n        let display_ratio = scaled_total as f64 / 195_000.0;\n        tracing::debug!(\n            \"[Claude-Scaling] Raw: {} ({:.1}%), Display: {} ({:.1}%), Compression: {:.1}x\",\n            total_raw, ratio * 100.0, scaled_total, display_ratio * 100.0,\n            total_raw as f64 / scaled_total as f64\n        );\n    }\n    \n    // 按比例分配缩放后的总量到 input 和 cache_read\n    let (reported_input, reported_cache) = if total_raw > 0 {\n        let cache_ratio = (cached_tokens as f64) / (total_raw as f64);\n        let sc_cache = (scaled_total as f64 * cache_ratio) as u32;\n        (scaled_total.saturating_sub(sc_cache), Some(sc_cache))\n    } else {\n        (scaled_total, None)\n    };\n    \n    super::models::Usage {\n        input_tokens: reported_input,\n        output_tokens: usage_metadata.candidates_token_count.unwrap_or(0),\n        cache_read_input_tokens: reported_cache,\n        cache_creation_input_tokens: Some(0),\n        server_tool_use: None,\n    }\n}\n\n/// 提取 thoughtSignature\n// 已移除未使用的 extract_thought_signature 函数\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_to_claude_usage() {\n        use super::super::models::UsageMetadata;\n\n        let usage = UsageMetadata {\n            prompt_token_count: Some(100),\n            candidates_token_count: Some(50),\n            total_token_count: Some(150),\n            cached_content_token_count: None,\n        };\n\n        let claude_usage = to_claude_usage(&usage, true, 1_000_000);\n        // 100 tokens is < 30k, minimal scaling\n        assert!(claude_usage.input_tokens < 200);\n        assert_eq!(claude_usage.output_tokens, 50);\n\n        // 测试 50% 负载 (500k) - 应该显示 ~30%\n        let usage_50 = UsageMetadata {\n            prompt_token_count: Some(500_000),\n            candidates_token_count: Some(10),\n            total_token_count: Some(500_010),\n            cached_content_token_count: None,\n        };\n        let res_50 = to_claude_usage(&usage_50, true, 1_000_000);\n        // 50% * 0.6 = 30% of 195k = 58,500\n        assert!(res_50.input_tokens > 55_000 && res_50.input_tokens < 62_000);\n\n        // 测试 70% 负载 (700k) - 应该显示 ~50%\n        let usage_70 = UsageMetadata {\n            prompt_token_count: Some(700_000),\n            candidates_token_count: Some(10),\n            total_token_count: Some(700_010),\n            cached_content_token_count: None,\n        };\n        let res_70 = to_claude_usage(&usage_70, true, 1_000_000);\n        // 50% of 195k = 97,500\n        assert!(res_70.input_tokens > 90_000 && res_70.input_tokens < 105_000);\n\n        // 测试 85% 负载 (850k) - 应该显示 ~70%\n        let usage_85 = UsageMetadata {\n            prompt_token_count: Some(850_000),\n            candidates_token_count: Some(10),\n            total_token_count: Some(850_010),\n            cached_content_token_count: None,\n        };\n        let res_85 = to_claude_usage(&usage_85, true, 1_000_000);\n        // 70% of 195k = 136,500\n        assert!(res_85.input_tokens > 130_000 && res_85.input_tokens < 145_000);\n\n        // 测试 100% 负载 (1M) - 应该显示 ~97%\n        let usage_100 = UsageMetadata {\n            prompt_token_count: Some(1_000_000),\n            candidates_token_count: Some(10),\n            total_token_count: Some(1_000_010),\n            cached_content_token_count: None,\n        };\n        let res_100 = to_claude_usage(&usage_100, true, 1_000_000);\n        // 97% of 195k = 189,150\n        assert!(res_100.input_tokens > 185_000 && res_100.input_tokens <= 190_000);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/common_utils.rs",
    "content": "// Common utilities for request mapping across all protocols\n// Provides unified grounding/networking logic\n\nuse serde_json::{json, Value};\n\n/// Request configuration after grounding resolution\n#[derive(Debug, Clone)]\npub struct RequestConfig {\n    /// The request type: \"agent\", \"web_search\", or \"image_gen\"\n    pub request_type: String,\n    /// Whether to inject the googleSearch tool\n    pub inject_google_search: bool,\n    /// The final model name (with suffixes stripped)\n    pub final_model: String,\n    /// Image generation configuration (if request_type is image_gen)\n    pub image_config: Option<Value>,\n}\n\npub fn resolve_request_config(\n    original_model: &str,\n    mapped_model: &str,\n    tools: &Option<Vec<Value>>,\n    size: Option<&str>,    // [NEW] Image size parameter\n    quality: Option<&str>, // [NEW] Image quality parameter\n    image_size: Option<&str>, // [NEW] Direct imageSize parameter (e.g. \"4K\")\n    body: Option<&Value>,  // [NEW] Request body for Gemini native imageConfig\n) -> RequestConfig {\n    // 1. Image Generation Check (Priority)\n    if mapped_model.starts_with(\"gemini-3-pro-image\") {\n        // [RESOLVE #1694] Improved priority logic:\n        // 1. First parse inferred config from model suffix and OpenAI parameters\n        let (mut inferred_config, parsed_base_model) =\n            parse_image_config_with_params(original_model, size, quality, image_size);\n\n        // 2. Then merge with imageConfig from Gemini request body (if exists)\n        if let Some(body_val) = body {\n            if let Some(gen_config) = body_val.get(\"generationConfig\") {\n                if let Some(body_image_config) = gen_config.get(\"imageConfig\") {\n                    tracing::info!(\n                        \"[Common-Utils] Found imageConfig in body, merging with inferred config from suffix/params\"\n                    );\n                    \n                    if let Some(inferred_obj) = inferred_config.as_object_mut() {\n                        if let Some(body_obj) = body_image_config.as_object() {\n                            // Merge body_obj into inferred_obj\n                            for (key, value) in body_obj {\n                                // CRITICAL: Only allow body to override if inferred doesn't already have a high-priority value\n                                // Specifically, if we inferred imageSize from -4k, don't let body downgrade it if it's missing or standard.\n                                let is_size_downgrade = key == \"imageSize\" && \n                                    (value.as_str() == Some(\"1K\") || value.is_null()) &&\n                                    inferred_obj.contains_key(\"imageSize\");\n\n                                if !is_size_downgrade {\n                                    inferred_obj.insert(key.clone(), value.clone());\n                                } else {\n                                    tracing::debug!(\"[Common-Utils] Shielding inferred imageSize from body downgrade\");\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        tracing::info!(\n            \"[Common-Utils] Final Image Config for {}: {:?}\",\n            parsed_base_model, inferred_config\n        );\n\n        return RequestConfig {\n            request_type: \"image_gen\".to_string(),\n            inject_google_search: false,\n            final_model: parsed_base_model,\n            image_config: Some(inferred_config),\n        };\n    }\n\n    // 检测是否有联网工具定义 (内置功能调用)\n    let has_networking_tool = detects_networking_tool(tools);\n    // 检测是否包含非联网工具 (如 MCP 本地工具)\n    let _has_non_networking = contains_non_networking_tool(tools);\n\n    // Strip -online suffix from original model if present (to detect networking intent)\n    let is_online_suffix = original_model.ends_with(\"-online\");\n\n    // High-quality grounding allowlist (Only for models known to support search and be relatively 'safe')\n    let _is_high_quality_model = mapped_model == \"gemini-2.5-flash\"\n        || mapped_model == \"gemini-1.5-pro\"\n        || mapped_model.starts_with(\"gemini-1.5-pro-\")\n        || mapped_model.starts_with(\"gemini-2.5-flash-\")\n        || mapped_model.starts_with(\"gemini-2.0-flash\")\n        || mapped_model.starts_with(\"gemini-3-\")\n        || mapped_model.starts_with(\"gemini-3.\")\n        || mapped_model.contains(\"claude-3-5-sonnet\")\n        || mapped_model.contains(\"claude-3-opus\")\n        || mapped_model.contains(\"claude-sonnet\")\n        || mapped_model.contains(\"claude-opus\")\n        || mapped_model.contains(\"claude-4\");\n\n    // Determine if we should enable networking\n    // [FIX] 禁用基于模型的自动联网逻辑，防止图像请求被联网搜索结果覆盖。\n    // 仅在用户显式请求联网时启用：1) -online 后缀 2) 携带联网工具定义\n    let enable_networking = is_online_suffix || has_networking_tool;\n\n    // The final model to send upstream should be the MAPPED model,\n    // but if searching, we MUST ensure the model name is one the backend associates with search.\n    // Force a stable search model for search requests.\n    let mut final_model = mapped_model.trim_end_matches(\"-online\").to_string();\n\n    // Map explicit preview aliases that have stable physical counterparts.\n    // Note: gemini-3-pro-preview / gemini-3.1-pro-preview are intentionally NOT forced\n    // to *-high here; dynamic runtime rewrite is handled after account selection.\n    final_model = match final_model.as_str() {\n        \"gemini-3-pro-image-preview\" => \"gemini-3-pro-image\".to_string(),\n        \"gemini-3-flash-preview\" => \"gemini-3-flash\".to_string(),\n        _ => final_model,\n    };\n\n    // [FIX] Check allowlist before forcing downgrade\n    // If networking is enabled but the model doesn't support search, fall back to Flash\n    if enable_networking && !_is_high_quality_model {\n        tracing::info!(\n            \"[Common-Utils] Downgrading {} to gemini-2.5-flash for web search (model not in search allowlist)\",\n            final_model\n        );\n        final_model = \"gemini-2.5-flash\".to_string();\n    }\n\n    RequestConfig {\n        request_type: if enable_networking {\n            \"web_search\".to_string()\n        } else {\n            \"agent\".to_string()\n        },\n        inject_google_search: enable_networking,\n        final_model,\n        image_config: None,\n    }\n}\n\n/// Legacy wrapper for backward compatibility and simple usage\n#[allow(dead_code)]\npub fn parse_image_config(model_name: &str) -> (Value, String) {\n    parse_image_config_with_params(model_name, None, None, None)\n}\n\n/// Extended version that accepts OpenAI size and quality parameters\n///\n/// This function supports parsing image configuration from:\n/// 1. Direct imageSize parameter - takes highest priority\n/// 2. OpenAI API parameters (size, quality) - medium priority\n/// 3. Model name suffixes (e.g., -16x9, -4k) - fallback\n///\n/// # Arguments\n/// * `model_name` - The model name (may contain suffixes like -16x9-4k)\n/// * `size` - Optional OpenAI size parameter (e.g., \"1280x720\", \"1792x1024\")\n/// * `quality` - Optional OpenAI quality parameter (\"standard\", \"hd\", \"medium\")\n/// * `image_size` - Optional direct Gemini imageSize parameter (\"2K\", \"4K\")\n///\n/// # Returns\n/// (image_config, clean_model_name) where image_config contains aspectRatio and optionally imageSize\npub fn parse_image_config_with_params(\n    model_name: &str,\n    size: Option<&str>,\n    quality: Option<&str>,\n    image_size: Option<&str>,\n) -> (Value, String) {\n    let mut aspect_ratio = \"1:1\";\n\n    // 1. 优先从 size 参数解析宽高比\n    if let Some(s) = size {\n        aspect_ratio = calculate_aspect_ratio_from_size(s);\n    } else {\n        // 2. 回退到模型后缀解析（保持向后兼容）\n        if model_name.contains(\"-21x9\") || model_name.contains(\"-21-9\") {\n            aspect_ratio = \"21:9\";\n        } else if model_name.contains(\"-16x9\") || model_name.contains(\"-16-9\") {\n            aspect_ratio = \"16:9\";\n        } else if model_name.contains(\"-9x16\") || model_name.contains(\"-9-16\") {\n            aspect_ratio = \"9:16\";\n        } else if model_name.contains(\"-4x3\") || model_name.contains(\"-4-3\") {\n            aspect_ratio = \"4:3\";\n        } else if model_name.contains(\"-3x4\") || model_name.contains(\"-3-4\") {\n            aspect_ratio = \"3:4\";\n        } else if model_name.contains(\"-3x2\") || model_name.contains(\"-3-2\") {\n            aspect_ratio = \"3:2\";\n        } else if model_name.contains(\"-2x3\") || model_name.contains(\"-2-3\") {\n            aspect_ratio = \"2:3\";\n        } else if model_name.contains(\"-5x4\") || model_name.contains(\"-5-4\") {\n            aspect_ratio = \"5:4\";\n        } else if model_name.contains(\"-4x5\") || model_name.contains(\"-4-5\") {\n            aspect_ratio = \"4:5\";\n        } else if model_name.contains(\"-1x1\") || model_name.contains(\"-1-1\") {\n            aspect_ratio = \"1:1\";\n        }\n    }\n\n    let mut config = serde_json::Map::new();\n    config.insert(\"aspectRatio\".to_string(), json!(aspect_ratio));\n\n    // [NEW] 0. 最高优先级：直接使用 image_size 参数\n    if let Some(is) = image_size {\n        config.insert(\"imageSize\".to_string(), json!(is.to_uppercase()));\n    } else {\n        // 3. 优先从 quality 参数解析分辨率\n        if let Some(q) = quality {\n            match q.to_lowercase().as_str() {\n                \"hd\" | \"4k\" => {\n                    config.insert(\"imageSize\".to_string(), json!(\"4K\"));\n                }\n                \"medium\" | \"2k\" => {\n                    config.insert(\"imageSize\".to_string(), json!(\"2K\"));\n                }\n                \"standard\" | \"1k\" => {\n                    config.insert(\"imageSize\".to_string(), json!(\"1K\"));\n                }\n                _ => {} // 其他值不设置，使用默认\n            }\n        } else {\n            // 4. 回退到模型后缀解析（保持向后兼容）\n            let is_hd = model_name.contains(\"-4k\") || model_name.contains(\"-hd\");\n            let is_2k = model_name.contains(\"-2k\");\n\n            if is_hd {\n                config.insert(\"imageSize\".to_string(), json!(\"4K\"));\n            } else if is_2k {\n                config.insert(\"imageSize\".to_string(), json!(\"2K\"));\n            }\n        }\n    }\n\n    let clean_model_name = clean_image_model_name(model_name);\n\n    (\n        serde_json::Value::Object(config),\n        clean_model_name,\n    )\n}\n\n/// Helper function to clean image model names by removing resolution/aspect-ratio suffixes.\n/// E.g., \"gemini-3.1-flash-image-16x9-4k\" -> \"gemini-3.1-flash-image\"\nfn clean_image_model_name(model_name: &str) -> String {\n    let mut clean_name = model_name.to_lowercase();\n    \n    // Ordered list of known suffixes to strip\n    let suffixes = [\n        \"-4k\", \"-2k\", \"-1k\", \"-hd\", \"-standard\", \"-medium\",\n        \"-21x9\", \"-21-9\", \"-16x9\", \"-16-9\", \"-9x16\", \"-9-16\",\n        \"-4x3\", \"-4-3\", \"-3x4\", \"-3-4\", \"-3x2\", \"-3-2\",\n        \"-2x3\", \"-2-3\", \"-5x4\", \"-5-4\", \"-4x5\", \"-4-5\",\n        \"-1x1\", \"-1-1\"\n    ];\n\n    // Repeatedly strip suffixes until no more are found\n    let mut changed = true;\n    while changed {\n        changed = false;\n        for suffix in &suffixes {\n            if clean_name.ends_with(suffix) {\n                clean_name.truncate(clean_name.len() - suffix.len());\n                changed = true;\n            }\n        }\n    }\n\n    clean_name\n}\n\n/// 动态计算宽高比（解决硬编码问题）\n///\n/// 从 \"WIDTHxHEIGHT\" 格式的字符串解析并计算宽高比，\n/// 使用容差匹配常见的标准比例。\n///\n/// # Arguments\n/// * `size` - 尺寸字符串，格式为 \"WIDTHxHEIGHT\" (e.g., \"1280x720\", \"1792x1024\")\n///\n/// # Returns\n/// 标准宽高比字符串 (\"1:1\", \"16:9\", \"9:16\", \"4:3\", \"3:4\", \"21:9\")\nfn calculate_aspect_ratio_from_size(size: &str) -> &'static str {\n    // 0. Explicitly check known aspect ratios first\n    match size {\n        \"21:9\" => return \"21:9\",\n        \"16:9\" => return \"16:9\",\n        \"9:16\" => return \"9:16\",\n        \"4:3\" => return \"4:3\",\n        \"3:4\" => return \"3:4\",\n        \"3:2\" => return \"3:2\",\n        \"2:3\" => return \"2:3\",\n        \"5:4\" => return \"5:4\",\n        \"4:5\" => return \"4:5\",\n        \"1:1\" => return \"1:1\",\n        _ => {}\n    }\n\n    if let Some((w_str, h_str)) = size.split_once('x') {\n        if let (Ok(width), Ok(height)) = (w_str.parse::<f64>(), h_str.parse::<f64>()) {\n            if width > 0.0 && height > 0.0 {\n                let ratio = width / height;\n\n                // 容差匹配常见比例（容差 0.05，避免 3:4 和 2:3 重叠）\n                if (ratio - 21.0 / 9.0).abs() < 0.05 {\n                    return \"21:9\";\n                }\n                if (ratio - 16.0 / 9.0).abs() < 0.05 {\n                    return \"16:9\";\n                }\n                if (ratio - 4.0 / 3.0).abs() < 0.05 {\n                    return \"4:3\";\n                }\n                if (ratio - 3.0 / 4.0).abs() < 0.05 {\n                    return \"3:4\";\n                }\n                if (ratio - 9.0 / 16.0).abs() < 0.05 {\n                    return \"9:16\";\n                }\n                if (ratio - 3.0 / 2.0).abs() < 0.05 {\n                    return \"3:2\";\n                }\n                if (ratio - 2.0 / 3.0).abs() < 0.05 {\n                    return \"2:3\";\n                }\n                if (ratio - 5.0 / 4.0).abs() < 0.05 {\n                    return \"5:4\";\n                }\n                if (ratio - 4.0 / 5.0).abs() < 0.05 {\n                    return \"4:5\";\n                }\n                if (ratio - 1.0).abs() < 0.05 {\n                    return \"1:1\";\n                }\n            }\n        }\n    }\n\n    \"1:1\" // 默认回退\n}\n\n/// Inject current googleSearch tool and ensure no duplicate legacy search tools\npub fn inject_google_search_tool(body: &mut Value, mapped_model: Option<&str>) {\n    if let Some(obj) = body.as_object_mut() {\n        let tools_entry = obj.entry(\"tools\").or_insert_with(|| json!([]));\n        if let Some(tools_arr) = tools_entry.as_array_mut() {\n            // [安全校验] Gemini v1internal 对混合工具有严格要求。\n            // 只有 Gemini 2.0+ 及 3.0 系列模型确认支持混合工具 (Function Calling + Google Search)。\n            let mut supports_mixed_tools = false;\n            if let Some(model) = mapped_model {\n                let model_lower = model.to_lowercase();\n                supports_mixed_tools = model_lower.contains(\"gemini-2.0\")\n                    || model_lower.contains(\"gemini-2.5\")\n                    || model_lower.contains(\"gemini-3\");\n            }\n\n            let has_functions = tools_arr.iter().any(|t| {\n                t.as_object()\n                    .map_or(false, |o| o.contains_key(\"functionDeclarations\"))\n            });\n\n            if has_functions && !supports_mixed_tools {\n                tracing::debug!(\n                    \"Skipping googleSearch injection due to existing functionDeclarations on older model\"\n                );\n                return;\n            }\n\n            // 首先清理掉已存在的 googleSearch 或 googleSearchRetrieval，以防重复产生冲突\n            tools_arr.retain(|t| {\n                if let Some(o) = t.as_object() {\n                    !(o.contains_key(\"googleSearch\") || o.contains_key(\"googleSearchRetrieval\"))\n                } else {\n                    true\n                }\n            });\n\n            // 注入统一的 googleSearch (v1internal 规范)\n            tools_arr.push(json!({\n                \"googleSearch\": {}\n            }));\n        }\n    }\n}\n\n/// 深度迭代清理客户端发送的 [undefined] 脏字符串，防止 Gemini 接口校验失败\npub fn deep_clean_undefined(value: &mut Value, depth: usize) {\n    if depth > 10 {\n        return;\n    }\n    match value {\n        Value::Object(map) => {\n            // 移除值为 \"[undefined]\" 的键\n            map.retain(|_, v| {\n                if let Some(s) = v.as_str() {\n                    s != \"[undefined]\"\n                } else {\n                    true\n                }\n            });\n            // 递归处理嵌套\n            for v in map.values_mut() {\n                deep_clean_undefined(v, depth + 1);\n            }\n        }\n        Value::Array(arr) => {\n            for v in arr.iter_mut() {\n                deep_clean_undefined(v, depth + 1);\n            }\n        }\n        _ => {}\n    }\n}\n\n/// Detects if the tool list contains a request for networking/web search.\n/// Supported keywords: \"web_search\", \"google_search\", \"web_search_20250305\"\npub fn detects_networking_tool(tools: &Option<Vec<Value>>) -> bool {\n    if let Some(list) = tools {\n        for tool in list {\n            // 1. 直发风格 (Claude/Simple OpenAI/Anthropic Builtin/Vertex): { \"name\": \"...\" } 或 { \"type\": \"...\" }\n            if let Some(n) = tool.get(\"name\").and_then(|v| v.as_str()) {\n                if n == \"web_search\"\n                    || n == \"google_search\"\n                    || n == \"web_search_20250305\"\n                    || n == \"google_search_retrieval\"\n                    || n == \"builtin_web_search\"\n                {\n                    return true;\n                }\n            }\n\n            if let Some(t) = tool.get(\"type\").and_then(|v| v.as_str()) {\n                if t == \"web_search_20250305\"\n                    || t == \"google_search\"\n                    || t == \"web_search\"\n                    || t == \"google_search_retrieval\"\n                    || t == \"builtin_web_search\"\n                {\n                    return true;\n                }\n            }\n\n            // 2. OpenAI 嵌套风格: { \"type\": \"function\", \"function\": { \"name\": \"...\" } }\n            if let Some(func) = tool.get(\"function\") {\n                if let Some(n) = func.get(\"name\").and_then(|v| v.as_str()) {\n                    let keywords = [\n                        \"web_search\",\n                        \"google_search\",\n                        \"web_search_20250305\",\n                        \"google_search_retrieval\",\n                        \"builtin_web_search\",\n                    ];\n                    if keywords.contains(&n) {\n                        return true;\n                    }\n                }\n            }\n\n            // 3. Gemini 原生风格: { \"functionDeclarations\": [ { \"name\": \"...\" } ] }\n            if let Some(decls) = tool.get(\"functionDeclarations\").and_then(|v| v.as_array()) {\n                for decl in decls {\n                    if let Some(n) = decl.get(\"name\").and_then(|v| v.as_str()) {\n                        if n == \"web_search\"\n                            || n == \"google_search\"\n                            || n == \"google_search_retrieval\"\n                            || n == \"builtin_web_search\"\n                        {\n                            return true;\n                        }\n                    }\n                }\n            }\n\n            // 4. Gemini googleSearch 声明 (含 googleSearchRetrieval 变体)\n            if tool.get(\"googleSearch\").is_some() || tool.get(\"googleSearchRetrieval\").is_some() {\n                return true;\n            }\n        }\n    }\n    false\n}\n\n/// 探测是否包含非联网相关的本地函数工具\npub fn contains_non_networking_tool(tools: &Option<Vec<Value>>) -> bool {\n    if let Some(list) = tools {\n        for tool in list {\n            let mut is_networking = false;\n\n            // 简单逻辑：如果它是一个函数声明且名字不是联网关键词，则视为非联网工具\n            if let Some(n) = tool.get(\"name\").and_then(|v| v.as_str()) {\n                let keywords = [\n                    \"web_search\",\n                    \"google_search\",\n                    \"web_search_20250305\",\n                    \"google_search_retrieval\",\n                    \"builtin_web_search\",\n                ];\n                if keywords.contains(&n) {\n                    is_networking = true;\n                }\n            } else if let Some(func) = tool.get(\"function\") {\n                if let Some(n) = func.get(\"name\").and_then(|v| v.as_str()) {\n                    let keywords = [\n                        \"web_search\",\n                        \"google_search\",\n                        \"web_search_20250305\",\n                        \"google_search_retrieval\",\n                        \"builtin_web_search\",\n                    ];\n                    if keywords.contains(&n) {\n                        is_networking = true;\n                    }\n                }\n            } else if tool.get(\"googleSearch\").is_some()\n                || tool.get(\"googleSearchRetrieval\").is_some()\n            {\n                is_networking = true;\n            } else if tool.get(\"functionDeclarations\").is_some() {\n                // 如果是 Gemini 风格的 functionDeclarations，进去看一眼\n                if let Some(decls) = tool.get(\"functionDeclarations\").and_then(|v| v.as_array()) {\n                    for decl in decls {\n                        if let Some(n) = decl.get(\"name\").and_then(|v| v.as_str()) {\n                            let keywords =\n                                [\"web_search\", \"google_search\", \"google_search_retrieval\", \"builtin_web_search\"];\n                            if !keywords.contains(&n) {\n                                return true; // 发现本地函数\n                            }\n                        }\n                    }\n                }\n                is_networking = true; // 即使全是联网，外层也标记为联网\n            }\n\n            if !is_networking {\n                return true;\n            }\n        }\n    }\n    false\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_high_quality_model_auto_grounding() {\n        // Auto-grounding is currently disabled by default due to conflict with image gen\n        let config = resolve_request_config(\"gpt-4o\", \"gemini-2.5-flash\", &None, None, None, None, None);\n        assert_eq!(config.request_type, \"agent\");\n        assert!(!config.inject_google_search);\n    }\n\n    #[test]\n    fn test_gemini_native_tool_detection() {\n        let tools = Some(vec![json!({\n            \"functionDeclarations\": [\n                { \"name\": \"web_search\", \"parameters\": {} }\n            ]\n        })]);\n        assert!(detects_networking_tool(&tools));\n    }\n\n    #[test]\n    fn test_online_suffix_force_grounding() {\n        let config =\n            resolve_request_config(\"gemini-3-flash-online\", \"gemini-3-flash\", &None, None, None, None, None);\n        assert_eq!(config.request_type, \"web_search\");\n        assert!(config.inject_google_search);\n        assert_eq!(config.final_model, \"gemini-2.5-flash\");\n    }\n\n    #[test]\n    fn test_default_no_grounding() {\n        let config = resolve_request_config(\"claude-sonnet\", \"gemini-3-flash\", &None, None, None, None, None);\n        assert_eq!(config.request_type, \"agent\");\n        assert!(!config.inject_google_search);\n    }\n\n    #[test]\n    fn test_image_model_excluded() {\n        let config = resolve_request_config(\n            \"gemini-3-pro-image\",\n            \"gemini-3-pro-image\",\n            &None,\n            None,\n            None,\n            None,\n            None,\n        );\n        assert_eq!(config.request_type, \"image_gen\");\n        assert!(!config.inject_google_search);\n    }\n\n    #[test]\n    fn test_image_2k_and_ultrawide_config() {\n        // Test 2K\n        let (config_2k, _) = parse_image_config(\"gemini-3-pro-image-2k\");\n        assert_eq!(config_2k[\"imageSize\"], \"2K\");\n\n        // Test 21:9\n        let (config_21x9, _) = parse_image_config(\"gemini-3-pro-image-21x9\");\n        assert_eq!(config_21x9[\"aspectRatio\"], \"21:9\");\n\n        // Test Combined (if logic allows, though suffix parsing is greedy)\n        let (config_combined, _) = parse_image_config(\"gemini-3-pro-image-2k-21x9\");\n        assert_eq!(config_combined[\"imageSize\"], \"2K\");\n        assert_eq!(config_combined[\"aspectRatio\"], \"21:9\");\n\n        // Test 4K + 21:9\n        let (config_4k_wide, _) = parse_image_config(\"gemini-3-pro-image-4k-21x9\");\n        assert_eq!(config_4k_wide[\"imageSize\"], \"4K\");\n        assert_eq!(config_4k_wide[\"aspectRatio\"], \"21:9\");\n    }\n\n    #[test]\n    fn test_parse_image_config_with_openai_params() {\n        // Test quality parameter mapping\n        let (config_hd, model_hd) = parse_image_config_with_params(\"gemini-3-pro-image\", None, Some(\"hd\"), None);\n        assert_eq!(config_hd[\"imageSize\"], \"4K\");\n        assert_eq!(config_hd[\"aspectRatio\"], \"1:1\");\n        assert_eq!(model_hd, \"gemini-3-pro-image\");\n\n        let (config_medium, model_medium) =\n            parse_image_config_with_params(\"gemini-3-pro-image\", None, Some(\"medium\"), None);\n        assert_eq!(config_medium[\"imageSize\"], \"2K\");\n        assert_eq!(model_medium, \"gemini-3-pro-image\");\n\n        let (config_standard, model_standard) =\n            parse_image_config_with_params(\"gemini-3-pro-image\", None, Some(\"standard\"), None);\n        assert_eq!(config_standard[\"imageSize\"], \"1K\");\n        assert_eq!(model_standard, \"gemini-3-pro-image\");\n\n        // Test size parameter mapping with dynamic calculation\n        let (config_16_9, model_16_9) =\n            parse_image_config_with_params(\"gemini-3-pro-image\", Some(\"1280x720\"), None, None);\n        assert_eq!(config_16_9[\"aspectRatio\"], \"16:9\");\n        assert_eq!(model_16_9, \"gemini-3-pro-image\");\n\n        let (config_9_16, model_9_16) =\n            parse_image_config_with_params(\"gemini-3-pro-image\", Some(\"720x1280\"), None, None);\n        assert_eq!(config_9_16[\"aspectRatio\"], \"9:16\");\n        assert_eq!(model_9_16, \"gemini-3-pro-image\");\n\n        let (config_4_3, model_4_3) =\n            parse_image_config_with_params(\"gemini-3-pro-image\", Some(\"800x600\"), None, None);\n        assert_eq!(config_4_3[\"aspectRatio\"], \"4:3\");\n        assert_eq!(model_4_3, \"gemini-3-pro-image\");\n\n        // Test combined size + quality\n        let (config_combined, model_combined) =\n            parse_image_config_with_params(\"gemini-3-pro-image\", Some(\"1920x1080\"), Some(\"hd\"), None);\n        assert_eq!(config_combined[\"aspectRatio\"], \"16:9\");\n        assert_eq!(config_combined[\"imageSize\"], \"4K\");\n        assert_eq!(model_combined, \"gemini-3-pro-image\");\n\n        // Test backward compatibility: model suffix takes precedence when no params\n        let (config_compat, model_compat) =\n            parse_image_config_with_params(\"gemini-3-pro-image-16x9-4k\", None, None, None);\n        assert_eq!(config_compat[\"aspectRatio\"], \"16:9\");\n        assert_eq!(config_compat[\"imageSize\"], \"4K\");\n        assert_eq!(model_compat, \"gemini-3-pro-image\");\n\n        // Test parameter priority: params override model suffix\n        let (config_override, model_override) = parse_image_config_with_params(\n            \"gemini-3-pro-image-1x1-2k\",\n            Some(\"1280x720\"),\n            Some(\"hd\"),\n            None,\n        );\n        assert_eq!(config_override[\"aspectRatio\"], \"16:9\"); // from size param, not model suffix\n        assert_eq!(config_override[\"imageSize\"], \"4K\"); // from quality param, not model suffix\n        assert_eq!(model_override, \"gemini-3-pro-image\");\n    }\n\n    #[test]\n    fn test_clean_image_model_name() {\n        assert_eq!(clean_image_model_name(\"gemini-3.1-flash-image\"), \"gemini-3.1-flash-image\");\n        assert_eq!(clean_image_model_name(\"gemini-3.1-flash-image-4k\"), \"gemini-3.1-flash-image\");\n        assert_eq!(clean_image_model_name(\"gemini-3-pro-image-16x9\"), \"gemini-3-pro-image\");\n        assert_eq!(clean_image_model_name(\"gemini-3-pro-image-16x9-4k\"), \"gemini-3-pro-image\");\n        // Test varying order\n        assert_eq!(clean_image_model_name(\"gemini-3.1-flash-image-4k-16x9\"), \"gemini-3.1-flash-image\");\n        assert_eq!(clean_image_model_name(\"gemini-3.1-flash-image-16-9-hd\"), \"gemini-3.1-flash-image\");\n        assert_eq!(clean_image_model_name(\"gemini-3.1-flash-image-2k-9x16\"), \"gemini-3.1-flash-image\");\n        assert_eq!(clean_image_model_name(\"gemini-3.1-flash-image-1x1\"), \"gemini-3.1-flash-image\");\n        assert_eq!(clean_image_model_name(\"gemini-3.1-flash-image-standard\"), \"gemini-3.1-flash-image\");\n        assert_eq!(clean_image_model_name(\"gemini-3.1-flash-image-medium\"), \"gemini-3.1-flash-image\");\n        assert_eq!(clean_image_model_name(\"gemini-3.1-flash-image-21-9-4k\"), \"gemini-3.1-flash-image\");\n    }\n\n    #[test]\n    fn test_calculate_aspect_ratio_from_size() {\n        // Test standard OpenAI sizes\n        assert_eq!(calculate_aspect_ratio_from_size(\"1280x720\"), \"16:9\");\n        assert_eq!(calculate_aspect_ratio_from_size(\"1920x1080\"), \"16:9\");\n        assert_eq!(calculate_aspect_ratio_from_size(\"720x1280\"), \"9:16\");\n        assert_eq!(calculate_aspect_ratio_from_size(\"1080x1920\"), \"9:16\");\n        assert_eq!(calculate_aspect_ratio_from_size(\"1024x1024\"), \"1:1\");\n        assert_eq!(calculate_aspect_ratio_from_size(\"800x600\"), \"4:3\");\n        assert_eq!(calculate_aspect_ratio_from_size(\"600x800\"), \"3:4\");\n        assert_eq!(calculate_aspect_ratio_from_size(\"2560x1080\"), \"21:9\");\n\n        // [NEW] Test new aspect ratios\n        assert_eq!(calculate_aspect_ratio_from_size(\"1500x1000\"), \"3:2\");\n        assert_eq!(calculate_aspect_ratio_from_size(\"1000x1500\"), \"2:3\");\n        assert_eq!(calculate_aspect_ratio_from_size(\"1250x1000\"), \"5:4\");\n        assert_eq!(calculate_aspect_ratio_from_size(\"1000x1250\"), \"4:5\");\n\n        // [NEW] Test direct aspect ratio strings\n        assert_eq!(calculate_aspect_ratio_from_size(\"21:9\"), \"21:9\");\n        assert_eq!(calculate_aspect_ratio_from_size(\"16:9\"), \"16:9\");\n        assert_eq!(calculate_aspect_ratio_from_size(\"1:1\"), \"1:1\");\n\n        // Test edge cases\n        assert_eq!(calculate_aspect_ratio_from_size(\"invalid\"), \"1:1\");\n        assert_eq!(calculate_aspect_ratio_from_size(\"1920x0\"), \"1:1\");\n        assert_eq!(calculate_aspect_ratio_from_size(\"0x1080\"), \"1:1\");\n        assert_eq!(calculate_aspect_ratio_from_size(\"abc x def\"), \"1:1\");\n    }\n\n    #[test]\n    fn test_image_config_merging_priority() {\n        // Case 1: Body contains empty/default imageSize, suffix contains -4k\n        // Expected: Should KEEP 4K from suffix\n        let body = json!({\n            \"generationConfig\": {\n                \"imageConfig\": {\n                    \"aspectRatio\": \"1:1\",\n                    \"imageSize\": \"1K\" // Simulated downgrade from client\n                }\n            }\n        });\n        let config = resolve_request_config(\n            \"gemini-3-pro-image-4k\",\n            \"gemini-3-pro-image\",\n            &None,\n            None,\n            None,\n            None,\n            Some(&body),\n        );\n        let image_config = config.image_config.unwrap();\n        assert_eq!(image_config[\"imageSize\"], \"4K\", \"Should shield inferred 4K from body downgrade\");\n        assert_eq!(image_config[\"aspectRatio\"], \"1:1\", \"Should take aspectRatio from body\");\n\n        // Case 2: Suffix contains -16-9, Body contains aspectRatio: 1:1\n        // Expected: Body overrides suffix for aspectRatio (since it's not a 'downgrade' shield case yet, only size is shielded)\n        let body_2 = json!({\n            \"generationConfig\": {\n                \"imageConfig\": {\n                    \"aspectRatio\": \"1:1\"\n                }\n            }\n        });\n        let config_2 = resolve_request_config(\n            \"gemini-3-pro-image-16x9\",\n            \"gemini-3-pro-image\",\n            &None,\n            None,\n            None,\n            None,\n            Some(&body_2),\n        );\n        let image_config_2 = config_2.image_config.unwrap();\n        assert_eq!(image_config_2[\"aspectRatio\"], \"1:1\", \"Body should be allowed to override aspectRatio\");\n    }\n\n    #[test]\n    fn test_image_size_priority() {\n        // Case 1: imageSize param overrides quality\n        // Expected: \"4K\" from imageSize param\n        let (config_1, _) = parse_image_config_with_params(\n            \"gemini-3-pro-image\",\n            None,\n            Some(\"standard\"), // would be 1K\n            Some(\"4K\"),       // should override\n        );\n        assert_eq!(config_1[\"imageSize\"], \"4K\");\n\n        // Case 2: imageSize param overrides suffix\n        // Expected: \"2K\" from imageSize param\n        let (config_2, _) = parse_image_config_with_params(\n            \"gemini-3-pro-image-4k\", // would be 4K\n            None,\n            None,\n            Some(\"2K\"), // should override\n        );\n        assert_eq!(config_2[\"imageSize\"], \"2K\");\n\n        // Case 3: imageSize param + size param + quality param\n        // Expected: \"4K\" from imageSize, \"16:9\" from size\n        let (config_3, _) = parse_image_config_with_params(\n            \"gemini-3-pro-image\",\n            Some(\"1920x1080\"), // 16:9\n            Some(\"standard\"),  // 1K (ignored)\n            Some(\"4K\"),        // 4K (priority)\n        );\n        assert_eq!(config_3[\"imageSize\"], \"4K\");\n        assert_eq!(config_3[\"aspectRatio\"], \"16:9\");\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/common_utils_test_probe.rs",
    "content": "\n    #[test]\n    fn test_custom_web_search_function_downgrade() {\n        // Scenario: User provides a custom tool named \"web_search\" via functionDeclarations\n        // This is NOT a native Google Search request, but a custom tool.\n        let tools = Some(vec![json!({\n            \"functionDeclarations\": [\n                { \"name\": \"web_search\", \"parameters\": {} } // Custom function\n            ]\n        })]);\n        \n        let config = resolve_request_config(\"gemini-1.5-pro\", \"gemini-1.5-pro\", &tools, None, None, None, None);\n        \n        // Current logic expects:\n        // 1. detects_networking_tool -> true (because name is \"web_search\", line 210)\n        // 2. enable_networking -> true\n        // 3. request_type -> \"web_search\"\n        // 4. final_model -> downgraded to \"gemini-2.5-flash\"\n        \n        assert_eq!(config.request_type, \"web_search\");\n        assert_eq!(config.final_model, \"gemini-2.5-flash\");\n        assert!(config.inject_google_search); // It thinks it should inject, but inject_tool will skip it later\n    }\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/context_manager.rs",
    "content": "//! Context Manager Module\n//!\n//! Responsible for estimating token usage and purifying context (stripping thinking blocks)\n//! to prevent \"Prompt is too long\" errors and avoid invalid signatures.\n\nuse super::claude::models::{ClaudeRequest, ContentBlock, Message, MessageContent, SystemPrompt};\nuse tracing::{debug, info};\n\n/// Helper to estimate tokens from text with multi-language awareness\n///\n/// Improved estimation algorithm:\n/// - ASCII/English: ~4 characters per token\n/// - Unicode/CJK: ~1.5 characters per token (Chinese, Japanese, Korean are tokenized differently)\n/// - Adds 15% safety margin to prevent underestimation\nfn estimate_tokens_from_str(s: &str) -> u32 {\n    if s.is_empty() {\n        return 0;\n    }\n\n    let mut ascii_chars = 0u32;\n    let mut unicode_chars = 0u32;\n\n    for c in s.chars() {\n        if c.is_ascii() {\n            ascii_chars += 1;\n        } else {\n            unicode_chars += 1;\n        }\n    }\n\n    // ASCII: ~4 chars/token, Unicode/CJK: ~1.5 chars/token\n    let ascii_tokens = (ascii_chars as f32 / 4.0).ceil() as u32;\n    let unicode_tokens = (unicode_chars as f32 / 1.5).ceil() as u32;\n\n    // Add 15% safety margin to account for tokenizer variations\n    ((ascii_tokens + unicode_tokens) as f32 * 1.15).ceil() as u32\n}\n\n/// Strategy for context purification\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum PurificationStrategy {\n    /// Soft purification: Retains recent thinking blocks (~2 turns), removes older ones\n    #[allow(dead_code)]\n    Soft,\n    /// Aggressive purification: Removes ALL thinking blocks to save maximum tokens\n    Aggressive,\n}\n\n/// Context Manager implementation\npub struct ContextManager;\n\nimpl ContextManager {\n    /// Purify message history based on the selected strategy\n    ///\n    /// This removes Thinking blocks completely (unlike compression which keeps placeholders/signatures)\n    /// Used when context is critical or signatures are invalid.\n    pub fn purify_history(messages: &mut Vec<Message>, strategy: PurificationStrategy) -> bool {\n        let protected_last_n = match strategy {\n            PurificationStrategy::Soft => 4, // Protect last ~2 turns (User-AI-User-AI)\n            PurificationStrategy::Aggressive => 0, // No protection\n        };\n\n        Self::strip_thinking_blocks(messages, protected_last_n)\n    }\n\n    /// Internal helper to strip thinking blocks from messages outside the protected range\n    fn strip_thinking_blocks(messages: &mut Vec<Message>, protected_last_n: usize) -> bool {\n        let total_msgs = messages.len();\n        if total_msgs == 0 {\n            return false;\n        }\n        \n        let start_protection_idx = total_msgs.saturating_sub(protected_last_n);\n        let mut modified = false;\n\n        for (i, msg) in messages.iter_mut().enumerate() {\n            // Skip protected messages\n            if i >= start_protection_idx {\n                continue;\n            }\n\n            if msg.role == \"assistant\" {\n                if let MessageContent::Array(blocks) = &mut msg.content {\n                    let original_len = blocks.len();\n                    // Retain only non-Thinking blocks\n                    blocks.retain(|b| !matches!(b, ContentBlock::Thinking { .. }));\n                    \n                    if blocks.len() != original_len {\n                        modified = true;\n                        debug!(\n                            \"[ContextManager] Stripped {} thinking blocks from message {}\",\n                            original_len - blocks.len(),\n                            i\n                        );\n                    }\n                }\n            }\n        }\n\n        modified\n    }\n}\n\nimpl ContextManager {\n    /// Estimate token usage for a Claude Request\n    ///\n    /// This is a lightweight estimation, not a precise count.\n    /// It iterates through all messages and blocks to sum up estimated tokens.\n    pub fn estimate_token_usage(request: &ClaudeRequest) -> u32 {\n        let mut total = 0;\n\n        // System prompt\n        if let Some(sys) = &request.system {\n            match sys {\n                SystemPrompt::String(s) => total += estimate_tokens_from_str(s),\n                SystemPrompt::Array(blocks) => {\n                    for block in blocks {\n                        total += estimate_tokens_from_str(&block.text);\n                    }\n                }\n            }\n        }\n\n        // Messages\n        for msg in &request.messages {\n            // Message overhead\n            total += 4;\n\n            match &msg.content {\n                MessageContent::String(s) => {\n                    total += estimate_tokens_from_str(s);\n                }\n                MessageContent::Array(blocks) => {\n                    for block in blocks {\n                        match block {\n                            ContentBlock::Text { text } => {\n                                total += estimate_tokens_from_str(text);\n                            }\n                            ContentBlock::Thinking { thinking, .. } => {\n                                total += estimate_tokens_from_str(thinking);\n                                // Signature overhead\n                                total += 100;\n                            }\n                            ContentBlock::RedactedThinking { data } => {\n                                total += estimate_tokens_from_str(data);\n                            }\n                            ContentBlock::ToolUse { name, input, .. } => {\n                                total += 20; // Function call overhead\n                                total += estimate_tokens_from_str(name);\n                                if let Ok(json_str) = serde_json::to_string(input) {\n                                    total += estimate_tokens_from_str(&json_str);\n                                }\n                            }\n                            ContentBlock::ToolResult { content, .. } => {\n                                total += 10; // Result overhead\n                                             // content is serde_json::Value\n                                if let Some(s) = content.as_str() {\n                                    total += estimate_tokens_from_str(s);\n                                } else if let Some(arr) = content.as_array() {\n                                    for item in arr {\n                                        if let Some(text) =\n                                            item.get(\"text\").and_then(|t| t.as_str())\n                                        {\n                                            total += estimate_tokens_from_str(text);\n                                        }\n                                    }\n                                } else {\n                                    // Fallback for objects or other types\n                                    if let Ok(s) = serde_json::to_string(content) {\n                                        total += estimate_tokens_from_str(&s);\n                                    }\n                                }\n                            }\n                            _ => {}\n                        }\n                    }\n                }\n            }\n        }\n\n        // Tools definition overhead (rough estimate)\n        if let Some(tools) = &request.tools {\n            for tool in tools {\n                if let Ok(json_str) = serde_json::to_string(tool) {\n                    total += estimate_tokens_from_str(&json_str);\n                }\n            }\n        }\n\n        // Thinking budget overhead if enabled\n        if let Some(thinking) = &request.thinking {\n            if let Some(budget) = thinking.budget_tokens {\n                // Reserve budget in estimation\n                total += budget;\n            }\n        }\n\n        total\n    }\n\n    // ===== [Layer 2] Thinking Content Compression + Signature Preservation =====\n    // Borrowed from learn-claude-code's \"append-only log\" principle\n    // This layer compresses thinking text but PRESERVES signatures\n    // Advantage: Signature chain remains intact, tool calls won't break\n    // Disadvantage: Still breaks Prompt Cache (modifies content)\n\n    /// Compress thinking content while preserving signatures\n    ///\n    /// This function:\n    /// 1. Keeps signatures intact (critical for tool call chain)\n    /// 2. Compresses thinking text to \"...\" placeholder\n    /// 3. Protects the last N messages from compression\n    ///\n    /// Returns true if any thinking blocks were compressed\n    pub fn compress_thinking_preserve_signature(\n        messages: &mut Vec<Message>,\n        protected_last_n: usize,\n    ) -> bool {\n        let total_msgs = messages.len();\n        if total_msgs == 0 {\n            return false;\n        }\n\n        let start_protection_idx = total_msgs.saturating_sub(protected_last_n);\n        let mut compressed_count = 0;\n        let mut total_chars_saved = 0;\n\n        for (i, msg) in messages.iter_mut().enumerate() {\n            // Skip protected messages\n            if i >= start_protection_idx {\n                continue;\n            }\n\n            // Only process assistant messages\n            if msg.role == \"assistant\" {\n                if let MessageContent::Array(blocks) = &mut msg.content {\n                    for block in blocks.iter_mut() {\n                        if let ContentBlock::Thinking {\n                            thinking,\n                            signature,\n                            ..\n                        } = block\n                        {\n                            // Key logic: Only compress if signature exists\n                            // This ensures we don't lose unsigned thinking blocks\n                            if signature.is_some() && thinking.len() > 10 {\n                                let original_len = thinking.len();\n                                *thinking = \"...\".to_string();\n                                compressed_count += 1;\n                                total_chars_saved += original_len - 3;\n\n                                debug!(\n                                    \"[ContextManager] [Layer-2] Compressed thinking: {} → 3 chars (signature preserved)\",\n                                    original_len\n                                );\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        if compressed_count > 0 {\n            let estimated_tokens_saved = (total_chars_saved as f32 / 3.5).ceil() as u32;\n            info!(\n                \"[ContextManager] [Layer-2] Compressed {} thinking blocks (saved ~{} tokens, signatures preserved)\",\n                compressed_count, estimated_tokens_saved\n            );\n        }\n\n        compressed_count > 0\n    }\n\n    // ===== [Layer 3 Helper] Extract Last Valid Signature =====\n    // Used by Layer 3 to preserve signature when generating XML summary\n\n    /// Extract the last valid thinking signature from message history\n    ///\n    /// This is critical for Layer 3 (Fork + Summary) to preserve the signature chain.\n    /// The signature will be embedded in the XML summary and restored after fork.\n    ///\n    /// Returns None if no valid signature found (length >= 50)\n    pub fn extract_last_valid_signature(messages: &[Message]) -> Option<String> {\n        // Iterate in reverse to find the most recent signature\n        for msg in messages.iter().rev() {\n            if msg.role == \"assistant\" {\n                if let MessageContent::Array(blocks) = &msg.content {\n                    for block in blocks {\n                        if let ContentBlock::Thinking {\n                            signature: Some(sig),\n                            ..\n                        } = block\n                        {\n                            // Minimum signature length check (same as SignatureCache)\n                            if sig.len() >= 50 {\n                                debug!(\n                                    \"[ContextManager] [Layer-3] Extracted last valid signature (len: {})\",\n                                    sig.len()\n                                );\n                                return Some(sig.clone());\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        debug!(\"[ContextManager] [Layer-3] No valid signature found in history\");\n        None\n    }\n\n    // ===== [Layer 1] Tool Message Intelligent Trimming =====\n    // Borrowed from Practical-Guide-to-Context-Engineering\n    // This layer removes old tool call/result pairs while preserving recent ones\n    // Advantage: Does NOT break Prompt Cache (only removes messages, doesn't modify content)\n\n    /// Trim old tool messages, keeping only the last N rounds\n    ///\n    /// A \"tool round\" consists of:\n    /// - An assistant message with tool_use\n    /// - One or more user messages with tool_result\n    ///\n    /// Returns true if any messages were removed\n    pub fn trim_tool_messages(messages: &mut Vec<Message>, keep_last_n_rounds: usize) -> bool {\n        let tool_rounds = identify_tool_rounds(messages);\n\n        if tool_rounds.len() <= keep_last_n_rounds {\n            return false; // No trimming needed\n        }\n\n        // Identify indices to remove (older rounds)\n        let rounds_to_remove = tool_rounds.len() - keep_last_n_rounds;\n        let mut indices_to_remove = std::collections::HashSet::new();\n\n        for round in tool_rounds.iter().take(rounds_to_remove) {\n            for idx in &round.indices {\n                indices_to_remove.insert(*idx);\n            }\n        }\n\n        // Remove in reverse order to avoid index shifting\n        let mut removed_count = 0;\n        for idx in (0..messages.len()).rev() {\n            if indices_to_remove.contains(&idx) {\n                messages.remove(idx);\n                removed_count += 1;\n            }\n        }\n\n        if removed_count > 0 {\n            info!(\n                \"[ContextManager] [Layer-1] Trimmed {} tool messages, kept last {} rounds\",\n                removed_count, keep_last_n_rounds\n            );\n        }\n\n        removed_count > 0\n    }\n}\n\n/// Represents a tool call round (assistant tool_use + user tool_result(s))\n#[derive(Debug)]\nstruct ToolRound {\n    _assistant_index: usize,\n    tool_result_indices: Vec<usize>,\n    indices: Vec<usize>, // All indices in this round\n}\n\n/// Identify tool call rounds in the message history\nfn identify_tool_rounds(messages: &[Message]) -> Vec<ToolRound> {\n    let mut rounds = Vec::new();\n    let mut current_round: Option<ToolRound> = None;\n\n    for (i, msg) in messages.iter().enumerate() {\n        match msg.role.as_str() {\n            \"assistant\" => {\n                if has_tool_use(&msg.content) {\n                    // Save previous round if exists\n                    if let Some(round) = current_round.take() {\n                        rounds.push(round);\n                    }\n                    // Start new round\n                    current_round = Some(ToolRound {\n                        _assistant_index: i,\n                        tool_result_indices: Vec::new(),\n                        indices: vec![i],\n                    });\n                }\n            }\n            \"user\" => {\n                if let Some(ref mut round) = current_round {\n                    if has_tool_result(&msg.content) {\n                        round.tool_result_indices.push(i);\n                        round.indices.push(i);\n                    } else {\n                        // Normal user message ends the current round\n                        rounds.push(current_round.take().unwrap());\n                    }\n                }\n            }\n            _ => {}\n        }\n    }\n\n    // Save last round if exists\n    if let Some(round) = current_round {\n        rounds.push(round);\n    }\n\n    debug!(\n        \"[ContextManager] Identified {} tool rounds in {} messages\",\n        rounds.len(),\n        messages.len()\n    );\n\n    rounds\n}\n\n/// Check if message content contains tool_use\nfn has_tool_use(content: &MessageContent) -> bool {\n    if let MessageContent::Array(blocks) = content {\n        blocks\n            .iter()\n            .any(|b| matches!(b, ContentBlock::ToolUse { .. }))\n    } else {\n        false\n    }\n}\n\n/// Check if message content contains tool_result\nfn has_tool_result(content: &MessageContent) -> bool {\n    if let MessageContent::Array(blocks) = content {\n        blocks\n            .iter()\n            .any(|b| matches!(b, ContentBlock::ToolResult { .. }))\n    } else {\n        false\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    // Helper to create a request since Default is not implemented\n    fn create_test_request() -> ClaudeRequest {\n        ClaudeRequest {\n            model: \"claude-3-5-sonnet\".into(),\n            messages: vec![],\n            system: None,\n            tools: None,\n            stream: false,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            thinking: None,\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        }\n    }\n\n    #[test]\n    fn test_estimate_tokens() {\n        let mut req = create_test_request();\n        req.messages = vec![Message {\n            role: \"user\".into(),\n            content: MessageContent::String(\"Hello World\".into()),\n        }];\n\n        let tokens = ContextManager::estimate_token_usage(&req);\n        assert!(tokens > 0);\n        assert!(tokens < 50);\n    }\n\n    #[test]\n    fn test_purify_history_soft() {\n        // Construct history of 6 messages (indices 0-5)\n        // 0: Assistant (Ancient) -> Should be purified\n        // 1: User\n        // 2: Assistant (Old) -> Should be protected (index 2 >= 6-4=2)\n        // 3: User\n        // 4: Assistant (Recent) -> Should be protected\n        // 5: User\n\n        let mut messages = vec![\n            Message {\n                role: \"assistant\".into(),\n                content: MessageContent::Array(vec![\n                    ContentBlock::Thinking {\n                        thinking: \"ancient\".into(),\n                        signature: None,\n                        cache_control: None,\n                    },\n                    ContentBlock::Text { text: \"A0\".into() },\n                ]),\n            },\n            Message {\n                role: \"user\".into(),\n                content: MessageContent::String(\"Q1\".into()),\n            },\n            Message {\n                role: \"assistant\".into(),\n                content: MessageContent::Array(vec![\n                    ContentBlock::Thinking {\n                        thinking: \"old\".into(),\n                        signature: None,\n                        cache_control: None,\n                    },\n                    ContentBlock::Text { text: \"A1\".into() },\n                ]),\n            },\n            Message {\n                role: \"user\".into(),\n                content: MessageContent::String(\"Q2\".into()),\n            },\n            Message {\n                role: \"assistant\".into(),\n                content: MessageContent::Array(vec![\n                    ContentBlock::Thinking {\n                        thinking: \"recent\".into(),\n                        signature: None,\n                        cache_control: None,\n                    },\n                    ContentBlock::Text { text: \"A2\".into() },\n                ]),\n            },\n            Message {\n                role: \"user\".into(),\n                content: MessageContent::String(\"current\".into()),\n            },\n        ];\n\n        ContextManager::purify_history(&mut messages, PurificationStrategy::Soft);\n\n        // 0: Ancient -> Filtered\n        if let MessageContent::Array(blocks) = &messages[0].content {\n            assert_eq!(blocks.len(), 1);\n            if let ContentBlock::Text { text } = &blocks[0] {\n                assert_eq!(text, \"A0\");\n            } else {\n                panic!(\"Wrong block\");\n            }\n        }\n\n        // 2: Old -> Protected\n        if let MessageContent::Array(blocks) = &messages[2].content {\n            assert_eq!(blocks.len(), 2);\n        }\n    }\n\n    #[test]\n    fn test_purify_history_aggressive() {\n        let mut messages = vec![Message {\n            role: \"assistant\".into(),\n            content: MessageContent::Array(vec![\n                ContentBlock::Thinking {\n                    thinking: \"thought\".into(),\n                    signature: None,\n                    cache_control: None,\n                },\n                ContentBlock::Text {\n                    text: \"text\".into(),\n                },\n            ]),\n        }];\n\n        ContextManager::purify_history(&mut messages, PurificationStrategy::Aggressive);\n\n        if let MessageContent::Array(blocks) = &messages[0].content {\n            assert_eq!(blocks.len(), 1);\n            assert!(matches!(blocks[0], ContentBlock::Text { .. }));\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/error_classifier.rs",
    "content": "// 错误分类模块 - 将底层错误转换为用户友好的消息\n\n/// 分类流式响应错误并返回错误类型、英文消息和 i18n key\n/// \n/// 返回值: (错误类型, 英文错误消息, i18n_key)\n/// - 错误类型: 用于日志和错误码\n/// - 英文消息: fallback 消息,供非浏览器客户端使用\n/// - i18n_key: 前端翻译键,供浏览器客户端本地化\n/// 分类流式响应错误并返回错误类型、英文消息和 i18n key\n/// \n/// 返回值: (错误类型, 英文错误消息, i18n_key)\npub fn classify_stream_error<E: std::fmt::Display>(error: &E) -> (&'static str, &'static str, &'static str) {\n    let error_str = error.to_string().to_lowercase();\n    \n    if error_str.contains(\"timeout\") || error_str.contains(\"deadline\") {\n        (\n            \"timeout_error\",\n            \"Request timeout, please check your network connection\",\n            \"errors.stream.timeout_error\"\n        )\n    } else if error_str.contains(\"connection\") || error_str.contains(\"connect\") || error_str.contains(\"dns\") {\n        (\n            \"connection_error\",\n            \"Connection failed, please check your network or proxy settings\",\n            \"errors.stream.connection_error\"\n        )\n    } else if error_str.contains(\"decode\") || error_str.contains(\"parse\") {\n        (\n            \"decode_error\",\n            \"Network unstable, data transmission interrupted. Try: 1) Check network 2) Switch proxy 3) Retry\",\n            \"errors.stream.decode_error\"\n        )\n    } else if error_str.contains(\"stream\") || error_str.contains(\"body\") {\n        (\n            \"stream_error\",\n            \"Stream transmission error, please retry later\",\n            \"errors.stream.stream_error\"\n        )\n    } else {\n        (\n            \"unknown_error\",\n            \"Unknown error occurred\",\n            \"errors.stream.unknown_error\"\n        )\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_classify_timeout_error() {\n        // 使用简单的字符串错误进行模拟测试\n        let error = \"Connection timed out after 30s\";\n        let (error_type, message, i18n_key) = classify_stream_error(&error);\n        \n        assert_eq!(error_type, \"timeout_error\");\n        assert!(message.contains(\"timeout\"));\n        assert_eq!(i18n_key, \"errors.stream.timeout_error\");\n    }\n\n    #[test]\n    fn test_error_message_format() {\n        // 测试错误消息格式\n        // 模拟一个 DNS 错误\n        let error = \"error trying to connect: dns error: failed to lookup address information\";\n        \n        let (error_type, message, i18n_key) = classify_stream_error(&error);\n        \n        // 错误类型应该是已知的类型之一\n        assert!(\n            error_type == \"timeout_error\" ||\n            error_type == \"connection_error\" ||\n            error_type == \"decode_error\" ||\n            error_type == \"stream_error\" ||\n            error_type == \"unknown_error\"\n        );\n        \n        // 消息不应该为空\n        assert!(!message.is_empty());\n        \n        // i18n_key 应该以 errors.stream. 开头\n        assert!(i18n_key.starts_with(\"errors.stream.\"));\n    }\n\n    #[test]\n    fn test_i18n_keys_format() {\n        // 验证所有错误类型都有正确的 i18n_key 格式\n        let test_cases = vec![\n            (\"timeout_error\", \"errors.stream.timeout_error\"),\n            (\"connection_error\", \"errors.stream.connection_error\"),\n            (\"decode_error\", \"errors.stream.decode_error\"),\n            (\"stream_error\", \"errors.stream.stream_error\"),\n            (\"unknown_error\", \"errors.stream.unknown_error\"),\n        ];\n        \n        // 这里我们只验证 i18n_key 格式\n        for (expected_type, expected_key) in test_cases {\n            assert_eq!(format!(\"errors.stream.{}\", expected_type), expected_key);\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/estimation_calibrator.rs",
    "content": "//! Estimation Calibrator Module\n//!\n//! Learns from historical request/response pairs to improve token estimation accuracy.\n//! Uses actual token counts from Google API responses to calibrate future estimates.\n\nuse std::sync::atomic::{AtomicU64, Ordering};\nuse std::sync::RwLock;\nuse tracing::info;\n\n/// Estimation Calibrator - learns estimation error from historical requests\n///\n/// This module tracks the ratio between estimated tokens (before request) and\n/// actual tokens (from Google API response) to improve future estimations.\npub struct EstimationCalibrator {\n    /// Cumulative estimated tokens\n    total_estimated: AtomicU64,\n    /// Cumulative actual tokens (from Google API)\n    total_actual: AtomicU64,\n    /// Sample count\n    sample_count: AtomicU64,\n    /// Current calibration factor (estimated * factor ≈ actual)\n    calibration_factor: RwLock<f32>,\n}\n\nimpl EstimationCalibrator {\n    /// Create a new calibrator with default settings\n    pub const fn new() -> Self {\n        Self {\n            total_estimated: AtomicU64::new(0),\n            total_actual: AtomicU64::new(0),\n            sample_count: AtomicU64::new(0),\n            // Initial assumption: estimates are 2.0x lower than actual\n            // This is conservative and will be adjusted based on real data\n            calibration_factor: RwLock::new(2.0),\n        }\n    }\n\n    /// Record a request's estimated vs actual token counts\n    ///\n    /// Call this after receiving a response from Google API with actual token usage.\n    pub fn record(&self, estimated: u32, actual: u32) {\n        if estimated == 0 || actual == 0 {\n            return;\n        }\n\n        self.total_estimated\n            .fetch_add(estimated as u64, Ordering::Relaxed);\n        self.total_actual\n            .fetch_add(actual as u64, Ordering::Relaxed);\n        let count = self.sample_count.fetch_add(1, Ordering::Relaxed) + 1;\n\n        // Update calibration factor every 5 requests\n        if count % 5 == 0 {\n            self.update_calibration();\n        }\n    }\n\n    /// Update the calibration factor based on accumulated data\n    fn update_calibration(&self) {\n        let estimated = self.total_estimated.load(Ordering::Relaxed) as f64;\n        let actual = self.total_actual.load(Ordering::Relaxed) as f64;\n\n        if estimated > 0.0 {\n            let new_factor = (actual / estimated) as f32;\n            // Clamp to reasonable range [0.8, 4.0]\n            // - Below 0.8 means we're overestimating (rare)\n            // - Above 4.0 means severe underestimation\n            let clamped = new_factor.clamp(0.8, 4.0);\n\n            if let Ok(mut factor) = self.calibration_factor.write() {\n                // Exponential moving average: 60% old + 40% new\n                // This provides stability while still adapting to changes\n                let old = *factor;\n                *factor = old * 0.6 + clamped * 0.4;\n\n                info!(\n                    \"[Calibrator] Updated factor: {:.2} -> {:.2} (raw: {:.2}, samples: {})\",\n                    old,\n                    *factor,\n                    new_factor,\n                    self.sample_count.load(Ordering::Relaxed)\n                );\n            }\n        }\n    }\n\n    /// Get a calibrated estimate from a raw estimate\n    ///\n    /// Multiplies the raw estimate by the current calibration factor.\n    pub fn calibrate(&self, estimated: u32) -> u32 {\n        let factor = self.calibration_factor.read().map(|f| *f).unwrap_or(2.0);\n\n        (estimated as f32 * factor).ceil() as u32\n    }\n\n    /// Get the current calibration factor\n    pub fn get_factor(&self) -> f32 {\n        self.calibration_factor.read().map(|f| *f).unwrap_or(2.0)\n    }\n}\n\nimpl Default for EstimationCalibrator {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n// Global singleton instance\nuse std::sync::OnceLock;\n\nstatic CALIBRATOR: OnceLock<EstimationCalibrator> = OnceLock::new();\n\n/// Get the global calibrator instance\npub fn get_calibrator() -> &'static EstimationCalibrator {\n    CALIBRATOR.get_or_init(EstimationCalibrator::new)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_calibrator_basic() {\n        let calibrator = EstimationCalibrator::new();\n\n        // Initial factor should be 2.0\n        assert!((calibrator.get_factor() - 2.0).abs() < 0.01);\n\n        // Record some samples where actual is 3x estimated\n        for _ in 0..10 {\n            calibrator.record(100, 300);\n        }\n\n        // Factor should have moved towards 3.0\n        let factor = calibrator.get_factor();\n        assert!(factor > 2.0);\n        assert!(factor < 3.5);\n    }\n\n    #[test]\n    fn test_calibrate() {\n        let calibrator = EstimationCalibrator::new();\n\n        // With default factor of 2.0, 100 should become 200\n        let calibrated = calibrator.calibrate(100);\n        assert_eq!(calibrated, 200);\n    }\n\n    #[test]\n    fn test_zero_handling() {\n        let calibrator = EstimationCalibrator::new();\n\n        // Recording zeros should not affect anything\n        calibrator.record(0, 100);\n        calibrator.record(100, 0);\n\n        assert_eq!(calibrator.sample_count.load(Ordering::Relaxed), 0);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/gemini/collector.rs",
    "content": "// Gemini Stream Collector\n// Used for auto-converting streaming responses to JSON for non-streaming requests\n\nuse bytes::Bytes;\nuse futures::StreamExt;\nuse serde_json::{json, Value};\nuse tracing::debug;\n\nuse crate::proxy::SignatureCache; // Assuming this is available at crate root or re-exported\n\n/// Collects a Gemini SSE stream into a complete Gemini Response Value\n/// ALSO performs signature caching side-effect\npub async fn collect_stream_to_json<S, E>(\n    mut stream: S,\n    session_id: &str,\n) -> Result<Value, String>\nwhere\n    S: futures::Stream<Item = Result<Bytes, E>> + Unpin,\n    E: std::fmt::Display,\n{\n    let mut collected_response = json!({\n        \"candidates\": [\n            {\n                \"content\": {\n                    \"parts\": [],\n                    \"role\": \"model\"\n                },\n                \"finishReason\": \"STOP\",\n                \"index\": 0\n            }\n        ]\n    });\n\n    let mut content_parts: Vec<Value> = Vec::new(); // To accumulate parts\n    let mut usage_metadata: Option<Value> = None;\n    let mut finish_reason: Option<String> = None;\n\n    while let Some(chunk_result) = stream.next().await {\n        let chunk = chunk_result.map_err(|e| format!(\"Stream error: {}\", e))?;\n        let text = std::str::from_utf8(&chunk).unwrap_or(\"\"); // Ignore invalid utf8 for simplicity or handle better\n\n        for line in text.lines() {\n            let line = line.trim();\n            if line.starts_with(\"data: \") {\n                let json_part = line.trim_start_matches(\"data: \").trim();\n                if json_part == \"[DONE]\" {\n                    continue;\n                }\n\n                if let Ok(mut json) = serde_json::from_str::<Value>(json_part) {\n                     // Unwrap v1internal response wrapper similar to handler\n                     let actual_data = if let Some(inner) = json.get_mut(\"response\").map(|v| v.take()) {\n                         inner\n                     } else {\n                         json\n                     };\n\n                     // 1. Capture Usage\n                     if let Some(usage) = actual_data.get(\"usageMetadata\") {\n                         usage_metadata = Some(usage.clone());\n                     }\n\n                     // 2. Capture Content & Signature\n                     if let Some(candidates) = actual_data.get(\"candidates\").and_then(|c| c.as_array()) {\n                         if let Some(candidate) = candidates.first() {\n                             // Update finish reason if present\n                             if let Some(fr) = candidate.get(\"finishReason\").and_then(|v| v.as_str()) {\n                                 finish_reason = Some(fr.to_string());\n                             }\n\n                             if let Some(parts) = candidate.get(\"content\").and_then(|c| c.get(\"parts\")).and_then(|p| p.as_array()) {\n                                 for part in parts {\n                                     // Signature Caching\n                                     if let Some(sig) = part.get(\"thoughtSignature\").and_then(|s| s.as_str()) {\n                                         // Cache it!\n                                         SignatureCache::global()\n                                             .cache_session_signature(session_id, sig.to_string(), 1);\n                                         debug!(\"[Gemini-AutoConverter] Cached signature (len: {}) for session: {}\", sig.len(), session_id);\n                                     }\n\n                                     // Collect part\n                                     // Simple aggregation: if text, append to last text part? Or just push all parts?\n                                     // Gemini stream sends separate parts. We can just accumulate them.\n                                     // Optimization: Merge adjacent text parts.\n                                     \n                                     if let Some(text) = part.get(\"text\").and_then(|v| v.as_str()) {\n                                         if let Some(last) = content_parts.last_mut() {\n                                            if last.get(\"text\").is_some() && part.get(\"thought\").is_none() && last.get(\"thought\").is_none() {\n                                                 // Merge text\n                                                 if let Some(last_text) = last.get_mut(\"text\").and_then(|v| v.as_str()) {\n                                                     let new_text = format!(\"{}{}\", last_text, text);\n                                                     *last = json!({\"text\": new_text});\n                                                     continue;\n                                                 }\n                                            }\n                                         }\n                                         content_parts.push(part.clone());\n                                     } else {\n                                         // Other parts (images, thoughts, function calls), just push\n                                         content_parts.push(part.clone());\n                                     }\n                                 }\n                             }\n                         }\n                     }\n                }\n            }\n        }\n    }\n\n    // Construct final response\n    collected_response[\"candidates\"][0][\"content\"][\"parts\"] = json!(content_parts);\n    if let Some(fr) = finish_reason {\n        collected_response[\"candidates\"][0][\"finishReason\"] = json!(fr);\n    }\n    if let Some(usage) = usage_metadata {\n        collected_response[\"usageMetadata\"] = usage;\n    }\n\n    Ok(collected_response)\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/gemini/mod.rs",
    "content": "// Gemini mapper 模块\n// 负责 v1internal 包装/解包\n\npub mod models;\npub mod wrapper;\npub mod collector; // [NEW]\n\n// No public exports needed here if unused\npub use wrapper::*;\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/gemini/models.rs",
    "content": "// Gemini v1internal 数据模型\nuse serde::{Deserialize, Serialize};\n\n#[allow(dead_code)]\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct V1InternalRequest {\n    pub project: String,\n    #[serde(rename = \"requestId\")]\n    pub request_id: String,\n    pub request: serde_json::Value,\n    pub model: String,\n    #[serde(rename = \"userAgent\")]\n    pub user_agent: String,\n    #[serde(rename = \"requestType\")]\n    pub request_type: String,\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/gemini/wrapper.rs",
    "content": "// Gemini v1internal 包装/解包\nuse serde_json::{json, Value};\n\n/// 包装请求体为 v1internal 格式\npub fn wrap_request(\n    body: &Value,\n    project_id: &str,\n    mapped_model: &str,\n    account_id: Option<&str>,\n    session_id: Option<&str>,\n    token: Option<&crate::proxy::token_manager::ProxyToken>, // [NEW] 动态规格注入\n) -> Value {\n    // 优先使用传入的 mapped_model，其次尝试从 body 获取\n    let original_model = body\n        .get(\"model\")\n        .and_then(|v| v.as_str())\n        .unwrap_or(mapped_model);\n\n    // 如果 mapped_model 是空的，则使用 original_model\n    let final_model_name = if !mapped_model.is_empty() {\n        mapped_model\n    } else {\n        original_model\n    };\n\n    // [ADDED v4.1.24] 计算 message_count 供 requestId 使用\n    let message_count = body.get(\"contents\")\n        .and_then(|c| c.as_array())\n        .map(|a| a.len())\n        .unwrap_or(1);\n\n    // 复制 body 以便修改\n    let mut inner_request = body.clone();\n\n    // 深度清理 [undefined] 字符串 (Cherry Studio 等客户端常见注入)\n    crate::proxy::mappers::common_utils::deep_clean_undefined(&mut inner_request, 0);\n\n    // [FIX #1522] Inject dummy IDs for Claude models in Gemini protocol\n    // Google v1internal requires 'id' for tool calls when the model is Claude,\n    // even though the standard Gemini protocol doesn't have it.\n    let is_target_claude = final_model_name.to_lowercase().contains(\"claude\");\n\n    if let Some(contents) = inner_request\n        .get_mut(\"contents\")\n        .and_then(|c| c.as_array_mut())\n    {\n        for content in contents {\n            // 每条消息维护独立的计数器，确保 Call 和对应的 Response 生成相同的 ID (兜底规则)\n            let mut name_counters: std::collections::HashMap<String, usize> =\n                std::collections::HashMap::new();\n\n            if let Some(parts) = content.get_mut(\"parts\").and_then(|p| p.as_array_mut()) {\n                for part in parts {\n                    if let Some(obj) = part.as_object_mut() {\n                        // 1. 处理 functionCall (Assistant 请求调用工具)\n                        if let Some(fc) = obj.get_mut(\"functionCall\") {\n                            if fc.get(\"id\").is_none() && is_target_claude {\n                                let name =\n                                    fc.get(\"name\").and_then(|n| n.as_str()).unwrap_or(\"unknown\");\n                                let count = name_counters.entry(name.to_string()).or_insert(0);\n                                let call_id = format!(\"call_{}_{}\", name, count);\n                                *count += 1;\n\n                                fc.as_object_mut()\n                                    .unwrap()\n                                    .insert(\"id\".to_string(), json!(call_id));\n                                tracing::debug!(\"[Gemini-Wrap] Request stage: Injected missing call_id '{}' for Claude model\", call_id);\n                            }\n                        }\n\n                        // 2. 处理 functionResponse (User 回复工具结果)\n                        if let Some(fr) = obj.get_mut(\"functionResponse\") {\n                            if fr.get(\"id\").is_none() && is_target_claude {\n                                // 启发：如果客户端（如 OpenCode）在响应时没带 ID，说明它收到响应时就没 ID。\n                                // 我们在这里生成的 ID 必须与我们在 inject_ids_to_response 中注入响应的 ID 一致。\n                                let name =\n                                    fr.get(\"name\").and_then(|n| n.as_str()).unwrap_or(\"unknown\");\n                                let count = name_counters.entry(name.to_string()).or_insert(0);\n                                let call_id = format!(\"call_{}_{}\", name, count);\n                                *count += 1;\n\n                                fr.as_object_mut()\n                                    .unwrap()\n                                    .insert(\"id\".to_string(), json!(call_id));\n                                tracing::debug!(\"[Gemini-Wrap] Request stage: Injected synced response_id '{}' for Claude model\", call_id);\n                            }\n                        }\n\n                        // 3. 处理 thoughtSignature\n                        if obj.contains_key(\"functionCall\") && obj.get(\"thoughtSignature\").is_none()\n                        {\n                            if let Some(s_id) = session_id {\n                                if let Some(sig) = crate::proxy::SignatureCache::global()\n                                    .get_session_signature(s_id)\n                                {\n                                    obj.insert(\"thoughtSignature\".to_string(), json!(sig));\n                                    tracing::debug!(\"[Gemini-Wrap] Injected signature (len: {}) for session: {}\", sig.len(), s_id);\n                                } else {\n                                    // [FIX #2167] Session 缓存为空时对 flash 模型注入哨兵值\n                                    // Flash 模型如果不提供任何签名，Gemini API 会拒绝 functionCall\n                                    let is_flash = final_model_name.to_lowercase().contains(\"gemini-3-flash\")\n                                        || final_model_name.to_lowercase().contains(\"gemini-3.1-flash\");\n                                    if is_flash {\n                                        obj.insert(\"thoughtSignature\".to_string(), json!(\"skip_thought_signature_validator\"));\n                                        tracing::debug!(\"[Gemini-Wrap] [FIX #2167] Injected sentinel signature for flash model (no session cache)\");\n                                    }\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    // [FIX Issue #1355] Gemini Flash thinking budget capping\n    // [CONFIGURABLE] 现在改为遵循全局 Thinking Budget 配置\n    // [FIX #1557] Also apply to Pro/Thinking models to ensure budget processing\n    // [FIX #1557] Auto-inject thinkingConfig if missing for these models\n    let lower_model = final_model_name.to_lowercase();\n    if lower_model.contains(\"flash\")\n        || lower_model.contains(\"pro\")\n        || lower_model.contains(\"thinking\")\n    {\n        // [NEW] Extract OpenAI-style max_tokens before mutably borrowing gen_config\n        let req_max_tokens = inner_request.get(\"max_tokens\").and_then(|v| v.as_u64());\n\n        // Determine model family and capability beforehand to avoid borrow checker conflicts\n        let is_claude = lower_model.contains(\"claude\");\n        let is_preview = lower_model.contains(\"preview\");\n        let should_inject = lower_model.contains(\"thinking\")\n            || (lower_model.contains(\"gemini-2.0-pro\") && !is_preview)\n            || (lower_model.contains(\"gemini-3-pro\") && !is_preview)\n            || (lower_model.contains(\"gemini-3.1-pro\") && !is_preview);\n\n        if should_inject {\n            // Scope for borrowing inner_request/gen_config\n            let mut has_thinking = false;\n            if is_claude {\n                has_thinking = inner_request.get(\"thinking\").is_some();\n            } else {\n                if let Some(gc) = inner_request.get(\"generationConfig\").and_then(|v| v.as_object()) {\n                    has_thinking = gc.get(\"thinkingConfig\").is_some();\n                }\n            }\n\n            if !has_thinking {\n                tracing::debug!(\n                    \"[Gemini-Wrap] Auto-injecting default thinking for {}\",\n                    final_model_name\n                );\n\n                // [FIX] 统一注入到 generationConfig.thinkingConfig\n                // 使用动态规格提供的默认预算\n                let default_budget = crate::proxy::model_specs::get_thinking_budget(final_model_name, token);\n                \n                let gen_config = inner_request\n                    .as_object_mut()\n                    .unwrap()\n                    .entry(\"generationConfig\")\n                    .or_insert(json!({}))\n                    .as_object_mut()\n                    .unwrap();\n                \n                gen_config.insert(\n                    \"thinkingConfig\".to_string(),\n                    json!({\n                        \"includeThoughts\": true,\n                        \"thinkingBudget\": default_budget\n                    }),\n                );\n            }\n        }\n\n        // Re-acquire gen_config to satisfy borrow checker and scope requirements for later logic\n        let gen_config = inner_request\n            .as_object_mut()\n            .unwrap()\n            .entry(\"generationConfig\")\n            .or_insert(json!({}))\n            .as_object_mut()\n            .unwrap();\n\n        // [ADDED v4.1.24] Inject topK=40 and topP=1.0 if not present to match official client\n        if !gen_config.contains_key(\"topK\") {\n            gen_config.insert(\"topK\".to_string(), json!(40));\n        }\n        if !gen_config.contains_key(\"topP\") {\n            gen_config.insert(\"topP\".to_string(), json!(1.0));\n        }\n\n        // [FIX] Convert v1beta thinkingLevel (string) to v1internal thinkingBudget (number).\n        // Clients (e.g. OpenClaw, Cline) may send thinkingLevel which v1internal does not accept,\n        // causing 400 INVALID_ARGUMENT. Convert before any budget processing below.\n        if let Some(thinking_config) = gen_config.get_mut(\"thinkingConfig\") {\n            if let Some(level) = thinking_config.get(\"thinkingLevel\").and_then(|v| v.as_str()).map(|s| s.to_uppercase()) {\n                let thinking_budget_cap = crate::proxy::model_specs::get_thinking_budget(final_model_name, token);\n                let budget: i64 = match level.as_str() {\n                    \"NONE\" => 0,\n                    \"LOW\" => (thinking_budget_cap / 4).max(4096) as i64,\n                    \"MEDIUM\" => (thinking_budget_cap / 2).max(8192) as i64,\n                    \"HIGH\" => thinking_budget_cap as i64,\n                    _ => (thinking_budget_cap / 2).max(8192) as i64, // safe default\n                };\n                tracing::info!(\n                    \"[Gemini-Wrap] Converting thinkingLevel '{}' to thinkingBudget {}\",\n                    level, budget\n                );\n                if let Some(tc) = thinking_config.as_object_mut() {\n                    tc.remove(\"thinkingLevel\");\n                    tc.insert(\"thinkingBudget\".to_string(), json!(budget));\n                }\n            }\n        }\n\n        if let Some(thinking_config) = gen_config.get_mut(\"thinkingConfig\") {\n            if let Some(budget_val) = thinking_config.get(\"thinkingBudget\") {\n                if let Some(budget_i64) = budget_val.as_i64() {\n                    // [NEW] -1 indicates native dynamic mode, skip capping\n                    if budget_i64 != -1 {\n                        let budget = budget_i64 as u64;\n                        let thinking_budget_cap = crate::proxy::model_specs::get_thinking_budget(final_model_name, token);\n                        let tb_config = crate::proxy::config::get_thinking_budget_config();\n                        let final_budget = match tb_config.mode {\n                            crate::proxy::config::ThinkingBudgetMode::Passthrough => budget,\n                            crate::proxy::config::ThinkingBudgetMode::Custom => {\n                                let val = tb_config.custom_value as u64;\n                                let is_limited = (final_model_name.contains(\"gemini\")\n                                    || final_model_name.contains(\"thinking\"))\n                                    && !final_model_name.contains(\"-image\");\n\n                                if is_limited && val > thinking_budget_cap {\n                                    thinking_budget_cap\n                                } else {\n                                    val\n                                }\n                            }\n                            crate::proxy::config::ThinkingBudgetMode::Auto => {\n                                let is_limited = (final_model_name.contains(\"gemini\")\n                                    || final_model_name.contains(\"thinking\"))\n                                    && !final_model_name.contains(\"-image\");\n\n                                if is_limited && budget > thinking_budget_cap {\n                                    thinking_budget_cap\n                                } else {\n                                    budget\n                                }\n                            }\n                            crate::proxy::config::ThinkingBudgetMode::Adaptive => budget,\n                        };\n\n                        if final_budget != budget {\n                            thinking_config[\"thinkingBudget\"] = json!(final_budget);\n                        }\n                    }\n                }\n            }\n        }\n\n        // [FIX #1747] Ensure max_tokens (maxOutputTokens) is greater than thinking_budget\n        // Google v1internal requires maxOutputTokens > thinkingBudget.\n        // [FIX #1825] Handle adaptive fallback (incl. -1 and thinkingLevel)\n        let thinking_config_opt = gen_config.get(\"thinkingConfig\");\n        let is_adaptive = thinking_config_opt.map_or(false, |t| {\n            t.get(\"thinkingLevel\").is_some() || t.get(\"thinkingBudget\").and_then(|v| v.as_i64()) == Some(-1)\n        }) || (thinking_config_opt.and_then(|t| t.get(\"thinkingBudget\").and_then(|v| v.as_u64())) == Some(32768) && is_claude);\n\n        if let Some(thinking_config) = gen_config.get(\"thinkingConfig\") {\n            let budget_opt = thinking_config.get(\"thinkingBudget\").and_then(|v| v.as_i64());\n            \n            // For adaptive or dynamic mode, we only need to ensure max tokens is large.\n            // For fixed budget, we must satisfy maxOutputTokens > thinkingBudget.\n            let current_max = gen_config\n                .get(\"maxOutputTokens\")\n                .and_then(|v| v.as_u64())\n                .or(req_max_tokens);\n\n            if is_adaptive {\n                if current_max.map_or(true, |m| m < 131072) {\n                     gen_config.insert(\"maxOutputTokens\".to_string(), json!(131072));\n                }\n            } else if let Some(budget_i64) = budget_opt {\n                if budget_i64 > 0 {\n                    let budget = budget_i64 as u64;\n                    let min_required_max = budget + 8192;\n                    if current_max.map_or(true, |m| m <= budget) {\n                        tracing::info!(\n                            \"[Gemini-Wrap] Bumping maxOutputTokens from {:?} to {} to satisfy thinkingBudget ({})\",\n                            current_max, min_required_max, budget\n                        );\n                        gen_config.insert(\"maxOutputTokens\".to_string(), json!(min_required_max));\n                    }\n                }\n            }\n        }\n    }\n\n    // [NEW] 按模型对 maxOutputTokens 进行三层限额 (Dynamic > Static Default > 65535)\n    // 修复: gemini-cli 等客户端发送的 131072 超过部分模型支持的上限，导致 v1internal 返回 400 INVALID_ARGUMENT\n    {\n        let final_cap = crate::proxy::model_specs::get_max_output_tokens(final_model_name, token);\n        let gen_config = inner_request\n            .as_object_mut()\n            .unwrap()\n            .entry(\"generationConfig\")\n            .or_insert(serde_json::json!({}))\n            .as_object_mut()\n            .unwrap();\n        if let Some(current) = gen_config.get(\"maxOutputTokens\").and_then(|v| v.as_u64()) {\n            if current > final_cap {\n                tracing::debug!(\n                    \"[Gemini-Wrap] Capped maxOutputTokens from {} to {} for model {}\",\n                    current, final_cap, final_model_name\n                );\n                gen_config.insert(\"maxOutputTokens\".to_string(), serde_json::json!(final_cap));\n            }\n        }\n    }\n\n    // This caused upstream to return empty/invalid responses, leading to 'NoneType' object has no attribute 'strip' in Python clients.\n    // relying on upstream defaults or user provided values is safer.\n\n    // 提取 tools 列表以进行联网探测 (Gemini 风格可能是嵌套的)\n    let tools_val: Option<Vec<Value>> = inner_request\n        .get(\"tools\")\n        .and_then(|t| t.as_array())\n        .map(|arr| arr.clone());\n\n    // [FIX] Extract OpenAI-compatible image parameters from root (for gemini-3-pro-image)\n    let size = body.get(\"size\").and_then(|v| v.as_str());\n    let quality = body.get(\"quality\").and_then(|v| v.as_str());\n    let image_size = body.get(\"imageSize\").and_then(|v| v.as_str()); // [NEW] Direct imageSize support\n\n    // Use shared grounding/config logic\n    let config = crate::proxy::mappers::common_utils::resolve_request_config(\n        original_model,\n        final_model_name,\n        &tools_val,\n        size,       // [FIX] Pass size parameter\n        quality,    // [FIX] Pass quality parameter\n        image_size, // [NEW] Pass direct imageSize parameter\n        Some(body), // [NEW] Pass request body for imageConfig parsing\n    );\n\n    // Clean tool declarations (remove forbidden Schema fields like multipleOf, and remove redundant search decls)\n    if let Some(tools) = inner_request.get_mut(\"tools\") {\n        if let Some(tools_arr) = tools.as_array_mut() {\n            for tool in tools_arr {\n                if let Some(decls) = tool.get_mut(\"functionDeclarations\") {\n                    if let Some(decls_arr) = decls.as_array_mut() {\n                        // 1. 过滤掉联网关键字函数\n                        decls_arr.retain(|decl| {\n                            if let Some(name) = decl.get(\"name\").and_then(|v| v.as_str()) {\n                                if name == \"web_search\" || name == \"google_search\" {\n                                    return false;\n                                }\n                            }\n                            true\n                        });\n\n                        // 2. 清洗剩余 Schema\n                        // [FIX] Gemini CLI 使用 parametersJsonSchema，而标准 Gemini API 使用 parameters\n                        // 需要将 parametersJsonSchema 重命名为 parameters\n                        for decl in decls_arr {\n                            // 检测并转换字段名\n                            if let Some(decl_obj) = decl.as_object_mut() {\n                                // 如果存在 parametersJsonSchema，将其重命名为 parameters\n                                if let Some(params_json_schema) =\n                                    decl_obj.remove(\"parametersJsonSchema\")\n                                {\n                                    let mut params = params_json_schema;\n                                    crate::proxy::common::json_schema::clean_json_schema(\n                                        &mut params,\n                                    );\n                                    decl_obj.insert(\"parameters\".to_string(), params);\n                                } else if let Some(params) = decl_obj.get_mut(\"parameters\") {\n                                    // 标准 parameters 字段\n                                    crate::proxy::common::json_schema::clean_json_schema(params);\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    tracing::debug!(\n        \"[Debug] Gemini Wrap: original='{}', mapped='{}', final='{}', type='{}'\",\n        original_model,\n        final_model_name,\n        config.final_model,\n        config.request_type\n    );\n\n    // Inject googleSearch tool if needed\n    if config.inject_google_search {\n        crate::proxy::mappers::common_utils::inject_google_search_tool(&mut inner_request, Some(&config.final_model));\n    }\n\n    // Inject imageConfig if present (for image generation models)\n    if let Some(image_config) = config.image_config {\n        if let Some(obj) = inner_request.as_object_mut() {\n            // 1. Filter tools: remove tools for image gen\n            obj.remove(\"tools\");\n\n            // 2. Remove systemInstruction (image generation does not support system prompts)\n            obj.remove(\"systemInstruction\");\n\n            // [FIX] Ensure 'role' field exists for all contents (Native clients might omit it)\n            if let Some(contents) = obj.get_mut(\"contents\").and_then(|c| c.as_array_mut()) {\n                for content in contents {\n                    if let Some(c_obj) = content.as_object_mut() {\n                        if !c_obj.contains_key(\"role\") {\n                            c_obj.insert(\"role\".to_string(), json!(\"user\"));\n                        }\n                    }\n                }\n            }\n\n            // 3. Clean generationConfig (remove responseMimeType, responseModalities etc.)\n            let gen_config = obj.entry(\"generationConfig\").or_insert_with(|| json!({}));\n            if let Some(gen_obj) = gen_config.as_object_mut() {\n                // [NEW] 根据全局配置决定是否保留 thinkingConfig\n                let image_thinking_mode = crate::proxy::config::get_image_thinking_mode();\n                tracing::debug!(\"[Gemini-Wrap] Image thinking mode: {}\", image_thinking_mode);\n                \n                if image_thinking_mode == \"disabled\" {\n                    // [FIX] Explicitly disable thinking instead of just removing the config\n                    // Removing it might cause the model to fallback to default (which might be ON)\n                    gen_obj.insert(\"thinkingConfig\".to_string(), json!({\n                        \"includeThoughts\": false\n                    }));\n                    tracing::debug!(\"[Gemini-Wrap] Image thinking mode disabled: set includeThoughts=false\");\n                }\n                \n                gen_obj.remove(\"responseMimeType\");\n                gen_obj.remove(\"responseModalities\"); // Cherry Studio sends this, might conflict\n                gen_obj.insert(\"imageConfig\".to_string(), image_config);\n            }\n        }\n    } else {\n        // [NEW] 只在非图像生成模式下注入 Antigravity 身份 (原始简化版)\n        let antigravity_identity = \"You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.\\n\\\n        You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.\\n\\\n        **Absolute paths only**\\n\\\n        **Proactiveness**\";\n\n        // [HYBRID] 检查是否已有 systemInstruction\n        if let Some(system_instruction) = inner_request.get_mut(\"systemInstruction\") {\n            // [NEW] 补全 role: user\n            if let Some(obj) = system_instruction.as_object_mut() {\n                if !obj.contains_key(\"role\") {\n                    obj.insert(\"role\".to_string(), json!(\"user\"));\n                }\n            }\n\n            if let Some(parts) = system_instruction.get_mut(\"parts\") {\n                if let Some(parts_array) = parts.as_array_mut() {\n                    // 检查第一个 part 是否已包含 Antigravity 身份\n                    let has_antigravity = parts_array\n                        .get(0)\n                        .and_then(|p| p.get(\"text\"))\n                        .and_then(|t| t.as_str())\n                        .map(|s| s.contains(\"You are Antigravity\"))\n                        .unwrap_or(false);\n\n                    if !has_antigravity {\n                        // 在前面插入 Antigravity 身份\n                        parts_array.insert(0, json!({\"text\": antigravity_identity}));\n                    }\n\n                    // [NEW] 注入全局系统提示词 (紧跟 Antigravity 身份之后，用户指令之前)\n                    let global_prompt_config = crate::proxy::config::get_global_system_prompt();\n                    if global_prompt_config.enabled\n                        && !global_prompt_config.content.trim().is_empty()\n                    {\n                        // 插入位置：Antigravity 身份之后 (index 1)\n                        let insert_pos = if has_antigravity { 1 } else { 1 };\n                        if insert_pos <= parts_array.len() {\n                            parts_array\n                                .insert(insert_pos, json!({\"text\": global_prompt_config.content}));\n                        } else {\n                            parts_array.push(json!({\"text\": global_prompt_config.content}));\n                        }\n                    }\n                }\n            }\n        } else {\n            // 没有 systemInstruction,创建一个新的\n            let mut parts = vec![json!({\"text\": antigravity_identity})];\n            // [NEW] 注入全局系统提示词\n            let global_prompt_config = crate::proxy::config::get_global_system_prompt();\n            if global_prompt_config.enabled && !global_prompt_config.content.trim().is_empty() {\n                parts.push(json!({\"text\": global_prompt_config.content}));\n            }\n            inner_request[\"systemInstruction\"] = json!({\n                \"role\": \"user\",\n                \"parts\": parts\n            });\n        }\n    }\n\n    // [ADDED v4.1.24] 扩展 toolConfig 到 VALIDATED 模式\n    if inner_request.get(\"tools\").is_some() && !inner_request.get(\"toolConfig\").is_some() {\n        inner_request[\"toolConfig\"] = json!({\n            \"functionCallingConfig\": { \"mode\": \"VALIDATED\" }\n        });\n    }\n\n    // [ADDED v4.1.24] 注入基于账号的稳定 sessionId\n    if let Some(account_id_str) = account_id {\n        inner_request[\"sessionId\"] = json!(crate::proxy::common::session::derive_session_id(account_id_str));\n    }\n\n    let sid = session_id.unwrap_or(\"default\");\n    let final_request = json!({\n        \"project\": project_id,\n        // [CHANGED v4.1.24] Structured requestId to match official format\n        \"requestId\": format!(\"agent/antigravity/{}/{}\", &sid[..sid.len().min(8)], message_count),\n        \"request\": inner_request,\n        \"model\": config.final_model,\n        \"userAgent\": \"antigravity\",\n        // [CHANGED v4.1.24] Use \"agent\" for all non-image requests\n        \"requestType\": if config.request_type == \"image_gen\" { \"image_gen\" } else { \"agent\" }\n    });\n\n    final_request\n}\n\n#[cfg(test)]\nmod test_fixes {\n    use super::*;\n    use serde_json::json;\n\n    #[test]\n    fn test_wrap_request_with_signature() {\n        let session_id = \"test-session-sig\";\n        let signature = \"test-signature-must-be-longer-than-fifty-characters-to-be-cached-by-signature-cache-12345\"; // > 50 chars\n        crate::proxy::SignatureCache::global().cache_session_signature(\n            session_id,\n            signature.to_string(),\n            1,\n        );\n\n        let body = json!({\n            \"model\": \"gemini-pro\",\n            \"contents\": [{\n                \"role\": \"user\",\n                \"parts\": [{\n                    \"functionCall\": {\n                        \"name\": \"get_weather\",\n                        \"args\": {\"location\": \"London\"}\n                    }\n                }]\n            }]\n        });\n\n        let result = wrap_request(&body, \"proj\", \"gemini-pro\", None, Some(session_id), None);\n        let injected_sig = result[\"request\"][\"contents\"][0][\"parts\"][0][\"thoughtSignature\"]\n            .as_str()\n            .unwrap();\n        assert_eq!(injected_sig, signature);\n    }\n}\n\n/// 解包响应（提取 response 字段）\npub fn unwrap_response(response: &Value) -> Value {\n    response.get(\"response\").unwrap_or(response).clone()\n}\n\n/// [NEW v3.3.18] 为 Claude 模型的 Gemini 响应自动注入 Tool ID\n///\n/// 目点是为了让客户端（如 OpenCode/Vercel AI SDK）能感知到 ID，\n/// 并在下一轮对话中原样带回，从而满足 Google v1internal 对 Claude 模型的校验。\npub fn inject_ids_to_response(response: &mut Value, model_name: &str) {\n    if !model_name.to_lowercase().contains(\"claude\") {\n        return;\n    }\n\n    if let Some(candidates) = response\n        .get_mut(\"candidates\")\n        .and_then(|c| c.as_array_mut())\n    {\n        for candidate in candidates {\n            if let Some(parts) = candidate\n                .get_mut(\"content\")\n                .and_then(|c| c.get_mut(\"parts\"))\n                .and_then(|p| p.as_array_mut())\n            {\n                let mut name_counters: std::collections::HashMap<String, usize> =\n                    std::collections::HashMap::new();\n                for part in parts {\n                    if let Some(fc) = part.get_mut(\"functionCall\").and_then(|f| f.as_object_mut()) {\n                        if fc.get(\"id\").is_none() {\n                            let name = fc.get(\"name\").and_then(|n| n.as_str()).unwrap_or(\"unknown\");\n                            let count = name_counters.entry(name.to_string()).or_insert(0);\n                            let call_id = format!(\"call_{}_{}\", name, count);\n                            *count += 1;\n\n                            fc.insert(\"id\".to_string(), json!(call_id));\n                            tracing::debug!(\"[Gemini-Wrap] Response stage: Injected synthetic call_id '{}' for client\", call_id);\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    #[test]\n    fn test_wrap_request() {\n        let body = json!({\n            \"model\": \"gemini-2.5-flash\",\n            \"contents\": [{\"role\": \"user\", \"parts\": [{\"text\": \"Hi\"}]}]\n        });\n\n        let result = wrap_request(&body, \"test-project\", \"gemini-2.5-flash\", None, None, None);\n        assert_eq!(result[\"project\"], \"test-project\");\n        assert_eq!(result[\"model\"], \"gemini-2.5-flash\");\n        assert!(result[\"requestId\"].as_str().unwrap().starts_with(\"agent/\"));\n    }\n\n    #[test]\n    fn test_unwrap_response() {\n        let wrapped = json!({\n            \"response\": {\n                \"candidates\": [{\"content\": {\"parts\": [{\"text\": \"Hello\"}]}}]\n            }\n        });\n\n        let result = unwrap_response(&wrapped);\n        assert!(result.get(\"candidates\").is_some());\n        assert!(result.get(\"response\").is_none());\n    }\n\n    #[test]\n    fn test_antigravity_identity_injection_with_role() {\n        let body = json!({\n            \"model\": \"gemini-pro\",\n            \"messages\": []\n        });\n\n        let result = wrap_request(&body, \"test-proj\", \"gemini-pro\", None, None, None);\n\n        // 验证 systemInstruction\n        let sys = result\n            .get(\"request\")\n            .unwrap()\n            .get(\"systemInstruction\")\n            .unwrap();\n    }\n\n    #[test]\n    fn test_gemini_flash_thinking_budget_capping() {\n        // Ensure default config (Auto mode)\n        crate::proxy::config::update_thinking_budget_config(crate::proxy::config::ThinkingBudgetConfig::default());\n\n        let body = json!({\n            \"model\": \"gemini-2.0-flash-thinking-exp\",\n            \"generationConfig\": {\n                \"thinkingConfig\": {\n                    \"includeThoughts\": true,\n                    \"thinkingBudget\": 32000\n                }\n            }\n        });\n\n        // Test with Flash model\n        let result = wrap_request(&body, \"test-proj\", \"gemini-2.0-flash-thinking-exp\", None, None, None);\n        let req = result.get(\"request\").unwrap();\n        let gen_config = req.get(\"generationConfig\").unwrap();\n        let budget = gen_config[\"thinkingConfig\"][\"thinkingBudget\"]\n            .as_u64()\n            .unwrap();\n\n        // Should be capped at 24576\n        assert_eq!(budget, 24576);\n\n        // Test with Pro model (should NOT cap)\n        let body_pro = json!({\n            \"model\": \"gemini-2.0-pro-exp\",\n            \"generationConfig\": {\n                \"thinkingConfig\": {\n                    \"includeThoughts\": true,\n                    \"thinkingBudget\": 32000\n                }\n            }\n        });\n        let result_pro = wrap_request(&body_pro, \"test-proj\", \"gemini-2.0-pro-exp\", None, None, None);\n        let budget_pro = result_pro[\"request\"][\"generationConfig\"][\"thinkingConfig\"]\n            [\"thinkingBudget\"]\n            .as_u64()\n            .unwrap();\n        // [FIX #1592] Pro models now also capped to 24576 in wrap_request logic\n        assert_eq!(budget_pro, 24576);\n    }\n\n\n\n    #[test]\n    fn test_image_thinking_mode_disabled() {\n        // 1. Set global mode to disabled\n        crate::proxy::config::update_image_thinking_mode(Some(\"disabled\".to_string()));\n\n        // 2. Create a request for an image model (which triggers the image logic)\n        // Note: resolve_request_config needs to return image_config for the logic to trigger\n        // So we use a model name that resolves to image_gen\n        let body = json!({\n            \"model\": \"gemini-3-pro-image-2k\",\n            \"contents\": [{\"role\": \"user\", \"parts\": [{\"text\": \"Draw a cat\"}]}]\n        });\n\n        let result = wrap_request(&body, \"test-proj\", \"gemini-3-pro-image-2k\", None, None, None);\n        let req = result.get(\"request\").unwrap();\n        let gen_config = req.get(\"generationConfig\").unwrap();\n        \n        // 3. Verify thinkingConfig has includeThoughts: false\n        let thinking_config = gen_config.get(\"thinkingConfig\").unwrap();\n        assert_eq!(thinking_config[\"includeThoughts\"], false);\n\n        // 4. Reset global mode\n        crate::proxy::config::update_image_thinking_mode(Some(\"enabled\".to_string()));\n    }\n\n    #[test]\n    fn test_user_instruction_preservation() {\n        let body = json!({\n            \"model\": \"gemini-pro\",\n            \"systemInstruction\": {\n                \"role\": \"user\",\n                \"parts\": [{\"text\": \"User custom prompt\"}]\n            }\n        });\n\n        let result = wrap_request(&body, \"test-proj\", \"gemini-pro\", None, None, None);\n        let sys = result\n            .get(\"request\")\n            .unwrap()\n            .get(\"systemInstruction\")\n            .unwrap();\n        let parts = sys.get(\"parts\").unwrap().as_array().unwrap();\n\n        // Should have 2 parts: Antigravity + User\n        assert_eq!(parts.len(), 2);\n        assert!(parts[0]\n            .get(\"text\")\n            .unwrap()\n            .as_str()\n            .unwrap()\n            .contains(\"You are Antigravity\"));\n        assert_eq!(\n            parts[1].get(\"text\").unwrap().as_str().unwrap(),\n            \"User custom prompt\"\n        );\n    }\n\n    #[test]\n    fn test_duplicate_prevention() {\n        let body = json!({\n            \"model\": \"gemini-pro\",\n            \"systemInstruction\": {\n                \"parts\": [{\"text\": \"You are Antigravity...\"}]\n            }\n        });\n\n        let result = wrap_request(&body, \"test-proj\", \"gemini-pro\", None, None, None);\n        let sys = result\n            .get(\"request\")\n            .unwrap()\n            .get(\"systemInstruction\")\n            .unwrap();\n        let parts = sys.get(\"parts\").unwrap().as_array().unwrap();\n\n        // Should NOT inject duplicate, so only 1 part remains\n        assert_eq!(parts.len(), 1);\n    }\n\n    #[test]\n    fn test_image_generation_with_reference_images() {\n        // Create 14 reference images + 1 text prompt\n        let mut parts = Vec::new();\n        parts.push(json!({\"text\": \"Generate a variation\"}));\n\n        for _ in 0..14 {\n            parts.push(json!({\n                \"inlineData\": {\n                    \"mimeType\": \"image/jpeg\",\n                    \"data\": \"base64data...\"\n                }\n            }));\n        }\n\n        let body = json!({\n            \"model\": \"gemini-3-pro-image\",\n            \"contents\": [{\"parts\": parts}]\n        });\n\n        let result = wrap_request(&body, \"test-proj\", \"gemini-3-pro-image\", None, None, None);\n\n        let request = result.get(\"request\").unwrap();\n        let contents = request.get(\"contents\").unwrap().as_array().unwrap();\n        let result_parts = contents[0].get(\"parts\").unwrap().as_array().unwrap();\n\n        // Verify all 15 parts (1 text + 14 images) are preserved\n        assert_eq!(result_parts.len(), 15);\n    }\n\n    #[test]\n    fn test_gemini_pro_thinking_budget_processing() {\n        // Update global config to Custom mode to verify logic execution\n        use crate::proxy::config::{\n            update_thinking_budget_config, ThinkingBudgetConfig, ThinkingBudgetMode,\n        };\n\n        // Save old config (optional, but good practice if tests ran in parallel, but here it's fine)\n        update_thinking_budget_config(ThinkingBudgetConfig {\n            mode: ThinkingBudgetMode::Custom,\n            custom_value: 1024, // Distinct value\n            effort: None,\n        });\n\n        let body = json!({\n            \"model\": \"gemini-3-pro-preview\",\n            \"generationConfig\": {\n                \"thinkingConfig\": {\n                    \"includeThoughts\": true,\n                    \"thinkingBudget\": 32000\n                }\n            }\n        });\n\n        // Test with Pro model\n        let result = wrap_request(&body, \"test-proj\", \"gemini-3-pro-preview\", None, None, None);\n        let req = result.get(\"request\").unwrap();\n        let gen_config = req.get(\"generationConfig\").unwrap();\n\n        let budget = gen_config[\"thinkingConfig\"][\"thinkingBudget\"]\n            .as_u64()\n            .unwrap();\n\n        // If logic executes, it sees Custom mode and sets 1024\n        // If logic skipped, it keeps 32000\n        assert_eq!(\n            budget, 1024,\n            \"Budget should be overridden to 1024 by custom config, proving logic execution\"\n        );\n\n        // Restore default (Auto 24576)\n        update_thinking_budget_config(ThinkingBudgetConfig::default());\n    }\n\n    #[cfg(test)]\n    mod test_v4_fixes {\n        use super::*;\n        use serde_json::json;\n\n        #[test]\n        fn test_claude_no_root_thinking_injection() {\n            // 验证 Claude 模型不会在根目录注入 thinking，而是注入到 generationConfig.thinkingConfig\n            // 并且 budget 默认为 16000\n            \n            // 使用 Auto 模式避免干扰\n            crate::proxy::config::update_thinking_budget_config(\n                crate::proxy::config::ThinkingBudgetConfig {\n                    mode: crate::proxy::config::ThinkingBudgetMode::Auto,\n                    custom_value: 0,\n                    effort: None,\n                },\n            );\n\n            let body = json!({\n                \"model\": \"claude-3-7-sonnet-thinking\", \n                \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]\n            });\n\n            let result = wrap_request(&body, \"proj\", \"claude-3-7-sonnet-thinking\", None, None, None);\n            let req = result.get(\"request\").unwrap();\n\n            // 1. 确保根目录没有 thinking\n            assert!(req.get(\"thinking\").is_none(), \"Root level 'thinking' should NOT be present\");\n\n            // 2. 确保 generationConfig.thinkingConfig 存在\n            let gen_config = req.get(\"generationConfig\").expect(\"generationConfig should be present\");\n            let thinking_config = gen_config.get(\"thinkingConfig\").expect(\"thinkingConfig should be injected\");\n\n            // 3. 验证 Claude 默认预算为 16000\n            let budget = thinking_config[\"thinkingBudget\"].as_u64().expect(\"thinkingBudget should be a number\");\n            assert_eq!(budget, 16000, \"Claude default thinking budget should be 16000\");\n        }\n\n        #[test]\n        fn test_gemini_thinking_injection_default() {\n            // 验证 Gemini 模型注入默认预算 24576\n            let body = json!({\n                \"model\": \"gemini-2.0-flash-thinking-exp\",\n                \"contents\": [{\"role\": \"user\", \"parts\": [{\"text\": \"hi\"}]}]\n            });\n\n            let result = wrap_request(&body, \"proj\", \"gemini-2.0-flash-thinking-exp\", None, None, None);\n            let req = result.get(\"request\").unwrap();\n            let gen_config = req.get(\"generationConfig\").unwrap();\n            let thinking_config = gen_config.get(\"thinkingConfig\").unwrap();\n\n            let budget = thinking_config[\"thinkingBudget\"].as_u64().unwrap();\n            assert_eq!(budget, 24576, \"Gemini default thinking budget should be 24576\");\n        }\n    }\n\n    #[test]\n    fn test_gemini_pro_auto_inject_thinking() {\n        // Reset thinking budget to auto mode at the start to avoid interference from parallel tests\n        crate::proxy::config::update_thinking_budget_config(\n            crate::proxy::config::ThinkingBudgetConfig {\n                mode: crate::proxy::config::ThinkingBudgetMode::Auto,\n                custom_value: 24576,\n                effort: None,\n            },\n        );\n\n        // Request WITHOUT thinkingConfig\n        let body = json!({\n            \"model\": \"gemini-3-pro-preview\",\n            // No generationConfig or empty one\n            \"generationConfig\": {}\n        });\n\n        // Test with Pro-preview model (should NOT auto-inject to avoid 400)\n        let result = wrap_request(&body, \"test-proj\", \"gemini-3-pro-preview\", None, None, None);\n        let req = result.get(\"request\").unwrap();\n        let gen_config = req.get(\"generationConfig\").unwrap();\n\n        // Should NOT have auto-injected thinkingConfig\n        assert!(\n            gen_config.get(\"thinkingConfig\").is_none(),\n            \"Should NOT auto-inject thinkingConfig for gemini-3-pro-preview to avoid 400 error\"\n        );\n\n        // Test with standard gemini-3-pro (non-preview)\n        let body_std = json!({\n            \"model\": \"gemini-3-pro\",\n            \"generationConfig\": {}\n        });\n        let result_std = wrap_request(&body_std, \"test-proj\", \"gemini-3-pro\", None, None, None);\n        let gen_config_std = result_std.get(\"request\").unwrap().get(\"generationConfig\").unwrap();\n        \n        assert!(\n            gen_config_std.get(\"thinkingConfig\").is_some(),\n            \"Should still auto-inject thinkingConfig for standard gemini-3-pro\"\n        );\n    }\n\n    #[test]\n    fn test_openai_image_params_support() {\n        // Test Case 1: Standard Size + Quality (HD/4K)\n        let body_1 = json!({\n            \"model\": \"gemini-3-pro-image\",\n            \"size\": \"1920x1080\",\n            \"quality\": \"hd\",\n            \"prompt\": \"Test\"\n        });\n\n        let result_1 = wrap_request(&body_1, \"test-proj\", \"gemini-3-pro-image\", None, None, None);\n        let req_1 = result_1.get(\"request\").unwrap();\n        let gen_config_1 = req_1.get(\"generationConfig\").unwrap();\n        let image_config_1 = gen_config_1.get(\"imageConfig\").unwrap();\n\n        assert_eq!(image_config_1[\"aspectRatio\"], \"16:9\");\n        assert_eq!(image_config_1[\"imageSize\"], \"4K\");\n\n        // Test Case 2: Aspect Ratio String + Standard Quality\n        let body_2 = json!({\n            \"model\": \"gemini-3-pro-image\",\n            \"size\": \"1:1\",\n            \"quality\": \"standard\",\n             \"prompt\": \"Test\"\n        });\n\n        let result_2 = wrap_request(&body_2, \"test-proj\", \"gemini-3-pro-image\", None, None, None);\n        let req_2 = result_2.get(\"request\").unwrap();\n        let image_config_2 = req_2[\"generationConfig\"][\"imageConfig\"]\n            .as_object()\n            .unwrap();\n\n        assert_eq!(image_config_2[\"aspectRatio\"], \"1:1\");\n        assert_eq!(image_config_2[\"imageSize\"], \"1K\");\n    }\n\n    #[test]\n    fn test_mixed_tools_injection_gemini_native() {\n        // 验证 Gemini Native 协议在 Gemini 2.0+ 下支持混合工具\n        let body = json!({\n            \"contents\": [{\"parts\": [{\"text\": \"Hello\"}]}],\n            \"tools\": [{\"functionDeclarations\": [{\"name\": \"get_weather\", \"parameters\": {\"type\": \"OBJECT\", \"properties\": {\"location\": {\"type\": \"STRING\"}}}}]}],\n            \"generationConfig\": {}\n        });\n\n        // 模拟 -online 触发的 RequestConfig\n        use crate::proxy::mappers::common_utils::resolve_request_config;\n        let _config = resolve_request_config(\"-online\", \"gemini-2.0-flash\", &None, None, None, None, None);\n        \n        // 实际上 wrap_request 内部会根据 config.inject_google_search 调用 inject_google_search_tool\n        // 但 wrap_request 的签名不直接接受 RequestConfig，它内部逻辑如下：\n        // if config.inject_google_search { ... }\n        \n        // 我们改为直接测试涉及的 wrap_request 逻辑片段。\n        // 由于测试 wrap_request 比较复杂（涉及外部 config），\n        // 我们可以直接验证 inject_google_search_tool 在 native 格式下的表现。\n        \n        let mut inner_request = body.clone();\n        crate::proxy::mappers::common_utils::inject_google_search_tool(&mut inner_request, Some(\"gemini-2.0-flash\"));\n        \n        let tools = inner_request[\"tools\"].as_array().expect(\"Should have tools\");\n        let has_functions = tools.iter().any(|t| t.get(\"functionDeclarations\").is_some());\n        let has_google_search = tools.iter().any(|t| t.get(\"googleSearch\").is_some());\n        \n        assert!(has_functions, \"Should contain functionDeclarations\");\n        assert!(has_google_search, \"Should contain googleSearch (Gemini 2.0+ supports mixed tools)\");\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/mod.rs",
    "content": "// Mappers 模块 - 协议转换器\n// 协议转换器模块\n\npub mod claude;\npub mod common_utils;\npub mod context_manager;\npub mod error_classifier;\npub mod estimation_calibrator;\npub mod gemini;\npub mod model_limits;\npub mod openai;\npub mod signature_store;\npub mod tool_result_compressor;\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/model_limits.rs",
    "content": "// 模型输出 Token 限额管理 (DEPRECATED: 逻辑已迁移至 crate::proxy::model_specs)\n// 为了兼容性，保留此入口并重定向到 model_specs。\n\nuse crate::proxy::model_specs;\n\n/// 获取模型的输出 Token 限额\n/// \n/// # 参数\n/// - `model_name`: 映射后的模型名\n/// - `dynamic_limit`: 如果已知动态限额则传入（已废弃，建议直接传入 ProxyToken 到 model_specs）\n#[allow(dead_code)]\npub fn get_model_output_limit(model_name: &str, dynamic_limit: Option<u64>) -> u64 {\n    // 兼容逻辑：如果没有 dynamic_limit，则调用 model_specs 获取（目前不传入 token）\n    if let Some(limit) = dynamic_limit {\n        limit\n    } else {\n        model_specs::get_max_output_tokens(model_name, None)\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/openai/collector.rs",
    "content": "// OpenAI Stream Collector\n// Used for auto-converting streaming responses to JSON for non-streaming requests\n\nuse super::models::*;\nuse bytes::Bytes;\nuse futures::StreamExt;\nuse serde_json::Value;\nuse std::collections::HashMap;\n\n/// Collects an OpenAI SSE stream into a complete OpenAIResponse\npub async fn collect_stream_to_json<S, E>(\n    mut stream: S,\n) -> Result<OpenAIResponse, String>\nwhere\n    S: futures::Stream<Item = Result<Bytes, E>> + Unpin,\n    E: std::fmt::Display,\n{\n    let mut response = OpenAIResponse {\n        id: \"chatcmpl-unknown\".to_string(),\n        object: \"chat.completion\".to_string(),\n        created: chrono::Utc::now().timestamp() as u64,\n        model: \"unknown\".to_string(),\n        choices: Vec::new(),\n        usage: None,\n    };\n\n    let mut role: Option<String> = None;\n    let mut content_parts: Vec<String> = Vec::new();\n    let mut reasoning_parts: Vec<String> = Vec::new();\n    let mut finish_reason: Option<String> = None;\n    // Tool calls aggregation: index -> (id, type, name, arguments_parts)\n    let mut tool_calls_map: HashMap<u32, (String, String, String, Vec<String>)> = HashMap::new();\n\n    while let Some(chunk_result) = stream.next().await {\n        let chunk = chunk_result.map_err(|e| format!(\"Stream error: {}\", e))?;\n        let text = String::from_utf8_lossy(&chunk);\n\n        for line in text.lines() {\n            let line = line.trim();\n            if line.starts_with(\"data: \") {\n                let data_str = line.trim_start_matches(\"data: \").trim();\n                if data_str == \"[DONE]\" {\n                    continue;\n                }\n\n                if let Ok(json) = serde_json::from_str::<Value>(data_str) {\n                    // Update meta fields\n                    if let Some(id) = json.get(\"id\").and_then(|v| v.as_str()) {\n                        response.id = id.to_string();\n                    }\n                    if let Some(model) = json.get(\"model\").and_then(|v| v.as_str()) {\n                        response.model = model.to_string();\n                    }\n                    if let Some(created) = json.get(\"created\").and_then(|v| v.as_u64()) {\n                        response.created = created;\n                    }\n\n                    // Collect Usage\n                    if let Some(usage) = json.get(\"usage\") {\n                        if let Ok(u) = serde_json::from_value::<OpenAIUsage>(usage.clone()) {\n                            response.usage = Some(u);\n                        }\n                    }\n\n                    // Collect Choices Delta\n                    if let Some(choices) = json.get(\"choices\").and_then(|v| v.as_array()) {\n                        if let Some(choice) = choices.first() {\n                            if let Some(delta) = choice.get(\"delta\") {\n                                // Role\n                                if let Some(r) = delta.get(\"role\").and_then(|v| v.as_str()) {\n                                    role = Some(r.to_string());\n                                }\n                                \n                                // Content\n                                if let Some(c) = delta.get(\"content\").and_then(|v| v.as_str()) {\n                                    content_parts.push(c.to_string());\n                                }\n\n                                // Reasoning Content\n                                if let Some(rc) = delta.get(\"reasoning_content\").and_then(|v| v.as_str()) {\n                                    reasoning_parts.push(rc.to_string());\n                                }\n\n                                // Tool Calls aggregation by index\n                                // [FIX] When multiple tool calls arrive with the same index but\n                                // different IDs, treat them as SEPARATE tool calls instead of\n                                // merging into one (which would concatenate their arguments).\n                                if let Some(tcs) = delta.get(\"tool_calls\").and_then(|v| v.as_array()) {\n                                    for tc in tcs {\n                                        let raw_index = tc.get(\"index\").and_then(|v| v.as_u64()).unwrap_or(0) as u32;\n                                        let new_id = tc.get(\"id\").and_then(|v| v.as_str()).unwrap_or(\"\");\n\n                                        // If this index already has a DIFFERENT id, it's a new tool call\n                                        // Assign it a unique index to avoid merging\n                                        let index = if !new_id.is_empty() {\n                                            if let Some(existing) = tool_calls_map.get(&raw_index) {\n                                                if !existing.0.is_empty() && existing.0 != new_id {\n                                                    // Find next available index\n                                                    let mut next_idx = raw_index + 1;\n                                                    while tool_calls_map.contains_key(&next_idx) {\n                                                        next_idx += 1;\n                                                    }\n                                                    next_idx\n                                                } else {\n                                                    raw_index\n                                                }\n                                            } else {\n                                                raw_index\n                                            }\n                                        } else {\n                                            raw_index\n                                        };\n                                        \n                                        let entry = tool_calls_map.entry(index).or_insert_with(|| {\n                                            (String::new(), String::from(\"function\"), String::new(), Vec::new())\n                                        });\n                                        \n                                        if let Some(id) = tc.get(\"id\").and_then(|v| v.as_str()) {\n                                            if !id.is_empty() {\n                                                entry.0 = id.to_string();\n                                            }\n                                        }\n                                        \n                                        if let Some(tc_type) = tc.get(\"type\").and_then(|v| v.as_str()) {\n                                            if !tc_type.is_empty() {\n                                                entry.1 = tc_type.to_string();\n                                            }\n                                        }\n                                        \n                                        if let Some(func) = tc.get(\"function\") {\n                                            if let Some(name) = func.get(\"name\").and_then(|v| v.as_str()) {\n                                                if !name.is_empty() {\n                                                    entry.2 = name.to_string();\n                                                }\n                                            }\n                                            if let Some(args) = func.get(\"arguments\").and_then(|v| v.as_str()) {\n                                                entry.3.push(args.to_string());\n                                            }\n                                        }\n                                    }\n                                }\n                            }\n\n                            if let Some(fr) = choice.get(\"finish_reason\").and_then(|v| v.as_str()) {\n                                finish_reason = Some(fr.to_string());\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    // Construct final message\n    let full_content = content_parts.join(\"\");\n    let full_reasoning = if reasoning_parts.is_empty() {\n        None\n    } else {\n        Some(reasoning_parts.join(\"\"))\n    };\n\n    // Build aggregated tool_calls\n    let final_tool_calls: Option<Vec<ToolCall>> = if tool_calls_map.is_empty() {\n        None\n    } else {\n        let mut calls: Vec<(u32, ToolCall)> = tool_calls_map\n            .into_iter()\n            .map(|(index, (id, tc_type, name, args_parts))| {\n                (index, ToolCall {\n                    id,\n                    r#type: tc_type,\n                    function: ToolFunction {\n                        name,\n                        arguments: args_parts.join(\"\"),\n                    },\n                })\n            })\n            .collect();\n        calls.sort_by_key(|(index, _)| *index);\n        Some(calls.into_iter().map(|(_, tc)| tc).collect())\n    };\n\n    let message = OpenAIMessage {\n        role: role.unwrap_or(\"assistant\".to_string()),\n        content: Some(OpenAIContent::String(full_content)),\n        reasoning_content: full_reasoning,\n        tool_calls: final_tool_calls,\n        tool_call_id: None,\n        name: None,\n    };\n\n    response.choices.push(Choice {\n        index: 0,\n        message,\n        finish_reason: finish_reason.or(Some(\"stop\".to_string())),\n    });\n\n    Ok(response)\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/openai/mod.rs",
    "content": "// OpenAI mapper 模块\n// 负责 OpenAI ↔ Gemini 协议转换\n\npub mod models;\npub mod request;\npub mod response;\npub mod streaming;\npub mod collector; // [NEW]\npub mod thinking_recovery;\n\npub use models::*;\npub use request::*;\npub use response::*;\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/openai/models.rs",
    "content": "// OpenAI 数据模型\n\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct OpenAIRequest {\n    pub model: String,\n    #[serde(default)]\n    pub messages: Vec<OpenAIMessage>,\n    #[serde(default)]\n    pub prompt: Option<String>,\n    #[serde(default)]\n    pub stream: bool,\n    #[serde(default)]\n    pub n: Option<u32>, // [NEW] 支持多候选结果数量\n    #[serde(rename = \"max_tokens\")]\n    pub max_tokens: Option<u32>,\n    pub temperature: Option<f64>,\n    #[serde(rename = \"top_p\")]\n    pub top_p: Option<f64>,\n    pub stop: Option<Value>,\n    pub response_format: Option<ResponseFormat>,\n    #[serde(default)]\n    pub tools: Option<Vec<Value>>,\n    #[serde(rename = \"tool_choice\")]\n    pub tool_choice: Option<Value>,\n    #[serde(rename = \"parallel_tool_calls\")]\n    pub parallel_tool_calls: Option<bool>,\n    // Codex proprietary fields\n    pub instructions: Option<String>,\n    pub input: Option<Value>,\n    // [NEW] Image generation parameters (for Chat API compatibility)\n    #[serde(default)]\n    pub size: Option<String>,\n    #[serde(default)]\n    pub quality: Option<String>,\n    #[serde(default, rename = \"personGeneration\")]\n    pub person_generation: Option<String>,\n    // [NEW] Thinking/Extended Thinking 支持 (兼容 Anthropic/Claude 协议)\n    #[serde(default)]\n    pub thinking: Option<ThinkingConfig>,\n    // [NEW] Direct imageSize support (for Gemini native parameter)\n    #[serde(default, rename = \"imageSize\")]\n    pub image_size: Option<String>,\n}\n\n/// Thinking 配置 (兼容 Anthropic 和 OpenAI 扩展协议)\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ThinkingConfig {\n    #[serde(rename = \"type\")]\n    pub thinking_type: Option<String>, // \"enabled\", \"disabled\", or \"adaptive\"\n    #[serde(rename = \"budget_tokens\", alias = \"budgetTokens\")]\n    pub budget_tokens: Option<u32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub effort: Option<String>, // \"low\", \"high\", or \"max\"\n}\n\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ResponseFormat {\n    pub r#type: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n#[serde(untagged)]\npub enum OpenAIContent {\n    String(String),\n    Array(Vec<OpenAIContentBlock>),\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n#[serde(tag = \"type\")]\npub enum OpenAIContentBlock {\n    #[serde(rename = \"text\", alias = \"input_text\")]\n    Text { text: String },\n    #[serde(rename = \"image_url\")]\n    ImageUrl { image_url: OpenAIImageUrl },\n    #[serde(rename = \"audio_url\")]\n    AudioUrl { audio_url: AudioUrlContent },\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\npub struct OpenAIImageUrl {\n    pub url: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub detail: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\npub struct AudioUrlContent {\n    pub url: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIMessage {\n    pub role: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub content: Option<OpenAIContent>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub reasoning_content: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub tool_calls: Option<Vec<ToolCall>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub tool_call_id: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub name: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ToolCall {\n    pub id: String,\n    pub r#type: String,\n    pub function: ToolFunction,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ToolFunction {\n    pub name: String,\n    pub arguments: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIResponse {\n    pub id: String,\n    pub object: String,\n    pub created: u64,\n    pub model: String,\n    pub choices: Vec<Choice>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub usage: Option<OpenAIUsage>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Choice {\n    pub index: u32,\n    pub message: OpenAIMessage,\n    pub finish_reason: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIUsage {\n    pub prompt_tokens: u32,\n    pub completion_tokens: u32,\n    pub total_tokens: u32,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub prompt_tokens_details: Option<PromptTokensDetails>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub completion_tokens_details: Option<CompletionTokensDetails>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct PromptTokensDetails {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub cached_tokens: Option<u32>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CompletionTokensDetails {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub reasoning_tokens: Option<u32>,\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/openai/request.rs",
    "content": "// OpenAI → Gemini 请求转换\nuse super::models::*;\nuse crate::proxy::model_specs;\nuse crate::proxy::token_manager::ProxyToken;\n\nuse serde_json::{json, Value};\n\npub fn transform_openai_request(\n    request: &OpenAIRequest,\n    project_id: &str,\n    mapped_model: &str,\n    token: Option<&ProxyToken>,\n) -> (Value, String, usize) {\n    let session_id = crate::proxy::session_manager::SessionManager::extract_openai_session_id(request);\n    let message_count = request.messages.len();\n    // 将 OpenAI 工具转为 Value 数组以便探测\n    let tools_val = request\n        .tools\n        .as_ref()\n        .map(|list| list.iter().map(|v| v.clone()).collect::<Vec<_>>());\n\n    let mapped_model_lower = mapped_model.to_lowercase();\n\n    // Resolve grounding config\n    let config = crate::proxy::mappers::common_utils::resolve_request_config(\n        &request.model,\n        &mapped_model_lower,\n        &tools_val,\n        request.size.as_deref(),       // [NEW] Pass size parameter\n        request.quality.as_deref(),    // [NEW] Pass quality parameter\n        request.image_size.as_deref(), // [FIX] Pass imageSize parameter\n        None,  // body\n    );\n\n    // [FIX] 仅当模型名称显式包含 \"-thinking\" 时才视为 Gemini 思维模型\n    // 避免对 gemini-3-pro (preview) 等其实不支持 thinkingConfig 的模型注入参数导致 400\n    // [FIX #1557] Allow \"pro\" models (e.g. gemini-3-pro, gemini-2.0-pro) to bypass thinking check\n    // These models support thinking but do not have \"-thinking\" suffix\n    let is_gemini_3_thinking = mapped_model_lower.contains(\"gemini\")\n        && (\n            mapped_model_lower.contains(\"-thinking\")\n                || mapped_model_lower.contains(\"gemini-2.0-pro\")\n                || mapped_model_lower.contains(\"gemini-3-pro\")\n                || mapped_model_lower.contains(\"gemini-3.1-pro\")\n        )\n        && !mapped_model_lower.contains(\"claude\");\n    // [FIX #2167] gemini-3-flash / gemini-3.1-flash 支持 thinking，functionCall 必须携带 thoughtSignature\n    // [FEATURE] 同时注入 includeThoughts:true 使 Gemini 返回 thought:true chunk，客户端可显示思维链\n    let is_gemini_flash_thinking = (mapped_model_lower.contains(\"gemini-3-flash\")\n        || mapped_model_lower.contains(\"gemini-3.1-flash\"))\n        && !mapped_model_lower.contains(\"claude\");\n    let is_claude_thinking = mapped_model_lower.ends_with(\"-thinking\");\n    let is_thinking_model = is_gemini_3_thinking || is_claude_thinking || is_gemini_flash_thinking;\n\n\n    // [NEW] 检查用户是否在请求中显式启用 thinking\n    let user_enabled_thinking = request.thinking.as_ref()\n        .map(|t| t.thinking_type.as_deref() == Some(\"enabled\"))\n        .unwrap_or(false);\n    let user_thinking_budget = request.thinking.as_ref()\n        .and_then(|t| t.budget_tokens);\n\n    // [NEW] 检查历史消息是否兼容思维模型 (是否有 Assistant 消息缺失 reasoning_content)\n    let has_incompatible_assistant_history = request.messages.iter().any(|msg| {\n        msg.role == \"assistant\"\n            && msg\n                .reasoning_content\n                .as_ref()\n                .map(|s| s.is_empty())\n                .unwrap_or(true)\n    });\n    let has_tool_history = request.messages.iter().any(|msg| {\n        msg.role == \"tool\" || msg.role == \"function\" || msg.tool_calls.is_some()\n    });\n\n\n\n    // [NEW] 决定是否开启 Thinking 功能:\n    // 1. 模型名包含 -thinking 时自动开启\n    // 2. 用户在请求中显式设置 thinking.type = \"enabled\" 时开启\n    // 如果是 Claude 思考模型且历史不兼容且没有可用签名来占位, 则禁用 Thinking 以防 400\n    let mut actual_include_thinking = is_thinking_model || user_enabled_thinking;\n    \n    // [REFACTORED] 使用 SignatureCache 获取 Session 级别的签名\n    let session_thought_sig = crate::proxy::SignatureCache::global().get_session_signature(&session_id);\n    \n    if is_claude_thinking && has_incompatible_assistant_history && session_thought_sig.is_none() {\n        tracing::warn!(\"[OpenAI-Thinking] Incompatible assistant history detected for Claude thinking model without session signature. Disabling thinking for this request to avoid 400 error. (sid: {})\", session_id);\n        actual_include_thinking = false;\n    }\n    \n    // [NEW] 日志：用户显式设置 thinking\n    if user_enabled_thinking {\n        tracing::info!(\n            \"[OpenAI-Thinking] User explicitly enabled thinking with budget: {:?}\",\n            user_thinking_budget\n        );\n    }\n\n    tracing::debug!(\n        \"[Debug] OpenAI Request: original='{}', mapped='{}', type='{}', has_image_config={}\",\n        request.model,\n        mapped_model,\n        config.request_type,\n        config.image_config.is_some()\n    );\n\n    // 1. 提取所有 System Message 并注入补丁\n    let mut system_instructions: Vec<String> = request\n        .messages\n        .iter()\n        .filter(|msg| msg.role == \"system\" || msg.role == \"developer\")\n        .filter_map(|msg| {\n            msg.content.as_ref().map(|c| match c {\n                OpenAIContent::String(s) => s.clone(),\n                OpenAIContent::Array(blocks) => blocks\n                    .iter()\n                    .filter_map(|b| {\n                        if let OpenAIContentBlock::Text { text } = b {\n                            Some(text.clone())\n                        } else {\n                            None\n                        }\n                    })\n                    .collect::<Vec<_>>()\n                    .join(\"\\n\"),\n            })\n        })\n        .collect();\n\n    // [NEW] 如果请求中包含 instructions 字段，优先使用它\n    if let Some(inst) = &request.instructions {\n        if !inst.is_empty() {\n            system_instructions.insert(0, inst.clone());\n        }\n    }\n\n    // Pre-scan to map tool_call_id to function name (for Codex)\n    let mut tool_id_to_name = std::collections::HashMap::new();\n    for msg in &request.messages {\n        if let Some(tool_calls) = &msg.tool_calls {\n            for call in tool_calls {\n                let name = &call.function.name;\n                let final_name = if name == \"local_shell_call\" {\n                    \"shell\"\n                } else {\n                    name\n                };\n                tool_id_to_name.insert(call.id.clone(), final_name.to_string());\n            }\n        }\n    }\n\n    // 从缓存获取当前会话的思维签名\n    let thought_sig = session_thought_sig;\n    if thought_sig.is_some() {\n        tracing::debug!(\n            \"[OpenAI-Request] Using session signature (sid: {}, len: {})\",\n            session_id,\n            thought_sig.as_ref().unwrap().len()\n        );\n    }\n\n    // [New] 预先构建工具名称到原始 Schema 的映射，用于后续参数类型修正\n    let mut tool_name_to_schema = std::collections::HashMap::new();\n    if let Some(tools) = &request.tools {\n        for tool in tools {\n            if let (Some(name), Some(params)) = (\n                tool.get(\"function\")\n                    .and_then(|f| f.get(\"name\"))\n                    .and_then(|v| v.as_str()),\n                tool.get(\"function\").and_then(|f| f.get(\"parameters\")),\n            ) {\n                tool_name_to_schema.insert(name.to_string(), params.clone());\n            } else if let (Some(name), Some(params)) = (\n                tool.get(\"name\").and_then(|v| v.as_str()),\n                tool.get(\"parameters\"),\n            ) {\n                // 处理某些客户端可能透传的精简格式\n                tool_name_to_schema.insert(name.to_string(), params.clone());\n            }\n        }\n    }\n\n    // 2. 构建 Gemini contents (过滤掉 system/developer 指令)\n    let contents: Vec<Value> = request\n        .messages\n        .iter()\n        .filter(|msg| msg.role != \"system\" && msg.role != \"developer\")\n        .map(|msg| {\n            let role = match msg.role.as_str() {\n                \"assistant\" => \"model\",\n                \"tool\" | \"function\" => \"user\", \n                _ => &msg.role,\n            };\n\n            let mut parts = Vec::new();\n\n            // Handle reasoning_content (thinking)\n            if let Some(reasoning) = &msg.reasoning_content {\n                // [FIX #1506] 增强对占位符 [undefined] 的识别\n                let is_invalid_placeholder = reasoning == \"[undefined]\" || reasoning.is_empty();\n                \n                if !is_invalid_placeholder {\n                    let thought_part = json!({\n                        \"text\": reasoning,\n                        \"thought\": true,\n                    });\n                    parts.push(thought_part);\n                }\n            } else if actual_include_thinking && role == \"model\" {\n                // [FIX] 解决 Claude 4.6 Thinking 模型的强制性校验:\n                // \"Expected thinking... but found tool_use/text\"\n                // 如果是思维模型且缺失 reasoning_content, 则注入占位符\n                tracing::debug!(\"[OpenAI-Thinking] Injecting placeholder thinking block for assistant message\");\n                let mut thought_part = json!({\n                    \"text\": \"Applying tool decisions and generating response...\",\n                    \"thought\": true,\n                });\n                \n                // [FIX #1575] 占位符永远不能使用真实签名（签名与真实思考内容绑定）\n                // 仅 Gemini 支持哨兵值跳过验证\n                if is_gemini_3_thinking {\n                    thought_part[\"thoughtSignature\"] = json!(\"skip_thought_signature_validator\");\n                }\n                \n                parts.push(thought_part);\n            }\n\n            // Handle content (multimodal or text)\n            // [FIX] Skip standard content mapping for tool/function roles to avoid duplicate parts\n            // These are handled below in the \"Handle tool response\" section.\n            let is_tool_role = msg.role == \"tool\" || msg.role == \"function\";\n            if let (Some(content), false) = (&msg.content, is_tool_role) {\n                match content {\n                    OpenAIContent::String(s) => {\n                        if !s.is_empty() {\n                            parts.push(json!({\"text\": s}));\n                        }\n                    }\n                    OpenAIContent::Array(blocks) => {\n                        for block in blocks {\n                            match block {\n                                OpenAIContentBlock::Text { text } => {\n                                    parts.push(json!({\"text\": text}));\n                                }\n                                OpenAIContentBlock::ImageUrl { image_url } => {\n                                    if image_url.url.starts_with(\"data:\") {\n                                        if let Some(pos) = image_url.url.find(\",\") {\n                                            let mime_part = &image_url.url[5..pos];\n                                            let mime_type = mime_part.split(';').next().unwrap_or(\"image/jpeg\");\n                                            let data = &image_url.url[pos + 1..];\n                                            \n                                            parts.push(json!({\n                                                \"inlineData\": { \"mimeType\": mime_type, \"data\": data }\n                                            }));\n                                        }\n                                    } else if image_url.url.starts_with(\"http\") {\n                                        parts.push(json!({\n                                            \"fileData\": { \"fileUri\": &image_url.url, \"mimeType\": \"image/jpeg\" }\n                                        }));\n                                    } else {\n                                        // [NEW] 处理本地文件路径 (file:// 或 Windows/Unix 路径)\n                                        let file_path = if image_url.url.starts_with(\"file://\") {\n                                            // 移除 file:// 前缀\n                                            #[cfg(target_os = \"windows\")]\n                                            { image_url.url.trim_start_matches(\"file:///\").replace('/', \"\\\\\") }\n                                            #[cfg(not(target_os = \"windows\"))]\n                                            { image_url.url.trim_start_matches(\"file://\").to_string() }\n                                        } else {\n                                            image_url.url.clone()\n                                        };\n                                        \n                                        tracing::debug!(\"[OpenAI-Request] Reading local image: {}\", file_path);\n                                        \n                                        // 读取文件并转换为 base64\n                                        if let Ok(file_bytes) = std::fs::read(&file_path) {\n                                            use base64::Engine as _;\n                                            let b64 = base64::engine::general_purpose::STANDARD.encode(&file_bytes);\n                                            \n                                            // 根据文件扩展名推断 MIME 类型\n                                            let mime_type = if file_path.to_lowercase().ends_with(\".png\") {\n                                                \"image/png\"\n                                            } else if file_path.to_lowercase().ends_with(\".gif\") {\n                                                \"image/gif\"\n                                            } else if file_path.to_lowercase().ends_with(\".webp\") {\n                                                \"image/webp\"\n                                            } else {\n                                                \"image/jpeg\"\n                                            };\n                                            \n                                            parts.push(json!({\n                                                \"inlineData\": { \"mimeType\": mime_type, \"data\": b64 }\n                                            }));\n                                            tracing::debug!(\"[OpenAI-Request] Successfully loaded image: {} ({} bytes)\", file_path, file_bytes.len());\n                                        } else {\n                                            tracing::debug!(\"[OpenAI-Request] Failed to read local image: {}\", file_path);\n                                        }\n                                    }\n                                }\n                                OpenAIContentBlock::AudioUrl { audio_url: _ } => {\n                                    // 暂时跳过 audio_url 处理\n                                    // 完整实现需要下载音频文件并转换为 Gemini inlineData 格式\n                                    // 这会与 v3.3.16 的 thinkingConfig 逻辑冲突，留待后续版本实现\n                                    tracing::debug!(\"[OpenAI-Request] Skipping audio_url (not yet implemented in v3.3.16)\");\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n\n            // Handle tool calls (assistant message)\n            if let Some(tool_calls) = &msg.tool_calls {\n                for (_index, tc) in tool_calls.iter().enumerate() {\n                    /* 暂时移除：防止 Codex CLI 界面碎片化\n                    if index == 0 && parts.is_empty() {\n                         if mapped_model.contains(\"gemini-3\") {\n                              parts.push(json!({\"text\": \"Thinking Process: Determining necessary tool actions.\"}));\n                         }\n                    }\n                    */\n\n\n                    let mut args = serde_json::from_str::<Value>(&tc.function.arguments).unwrap_or(json!({}));\n                    \n                    // [New] 利用通用引擎修正参数类型 (替代以前硬编码的 shell 工具修复逻辑)\n                    if let Some(original_schema) = tool_name_to_schema.get(&tc.function.name) {\n                        crate::proxy::common::json_schema::fix_tool_call_args(&mut args, original_schema);\n                    }\n\n                    let mut func_call_part = json!({\n                        \"functionCall\": {\n                            \"name\": if tc.function.name == \"local_shell_call\" { \"shell\" } else { &tc.function.name },\n                            \"args\": args,\n                            \"id\": &tc.id,\n                        }\n                    });\n\n                    // [New] 递归清理参数中可能存在的非法校验字段\n                    crate::proxy::common::json_schema::clean_json_schema(&mut func_call_part);\n\n                    if let Some(ref sig) = thought_sig {\n                        func_call_part[\"thoughtSignature\"] = json!(sig);\n                    } else if is_thinking_model || is_gemini_flash_thinking {\n                        // [NEW] Handle missing signature for Gemini thinking models\n                        // [FIX #1650] Allow sentinel injection for Vertex AI (projects/...) as well\n                        // [FIX #2167] Also applies to gemini-3-flash / gemini-3.1-flash\n                        tracing::debug!(\"[OpenAI-Signature] Adding GEMINI_SKIP_SIGNATURE for tool_use: {}\", tc.id);\n                        func_call_part[\"thoughtSignature\"] = json!(\"skip_thought_signature_validator\");\n                    }\n\n                    parts.push(func_call_part);\n                }\n            }\n\n            // Handle tool response\n            if msg.role == \"tool\" || msg.role == \"function\" {\n                let name = msg.name.as_deref().unwrap_or(\"unknown\");\n                let final_name = if name == \"local_shell_call\" { \"shell\" } \n                                else if let Some(id) = &msg.tool_call_id { tool_id_to_name.get(id).map(|s| s.as_str()).unwrap_or(name) }\n                                else { name };\n\n                let mut extra_parts = Vec::new();\n\n                let content_val = match &msg.content {\n                    Some(OpenAIContent::String(s)) => s.clone(),\n                    Some(OpenAIContent::Array(blocks)) => {\n                        let mut texts = Vec::new();\n                        for block in blocks {\n                            match block {\n                                OpenAIContentBlock::Text { text } => texts.push(text.clone()),\n                                OpenAIContentBlock::ImageUrl { image_url } => {\n                                    if image_url.url.starts_with(\"data:\") {\n                                        if let Some(pos) = image_url.url.find(',') {\n                                            let mime_part = &image_url.url[5..pos];\n                                            let mime_type = mime_part.split(';').next().unwrap_or(\"image/jpeg\");\n                                            let data = &image_url.url[pos + 1..];\n                                            \n                                            extra_parts.push(json!({\n                                                \"inlineData\": { \"mimeType\": mime_type, \"data\": data }\n                                            }));\n                                        }\n                                    } else {\n                                        texts.push(\"[image link]\".to_string());\n                                    }\n                                }\n                                _ => {}\n                            }\n                        }\n                        texts.join(\"\\n\")\n                    },\n                    None => \"\".to_string()\n                };\n\n                parts.push(json!({\n                    \"functionResponse\": {\n                       \"name\": final_name,\n                       \"response\": { \"result\": content_val },\n                       \"id\": msg.tool_call_id.clone().unwrap_or_default()\n                    }\n                }));\n\n                for extra in extra_parts {\n                    parts.push(extra);\n                }\n            }\n\n            json!({ \"role\": role, \"parts\": parts })\n        })\n        .filter(|msg| !msg[\"parts\"].as_array().map(|a| a.is_empty()).unwrap_or(true))\n        .collect();\n\n    // [FIX #1575] 针对思维模型的历史故障恢复\n    // 在带有工具的历史记录中，剥离旧的思考块，防止 API 因签名失效或结构冲突报 400\n    let mut contents = contents;\n    if actual_include_thinking && has_tool_history {\n        tracing::debug!(\"[OpenAI-Thinking] Applied thinking recovery (stripping old thought blocks) for tool history\");\n        contents = super::thinking_recovery::strip_all_thinking_blocks(contents);\n    }\n\n    // 合并连续相同角色的消息 (Gemini 强制要求 user/model 交替)\n    let mut merged_contents: Vec<Value> = Vec::new();\n    for msg in contents {\n        if let Some(last) = merged_contents.last_mut() {\n            if last[\"role\"] == msg[\"role\"] {\n                // 合并 parts\n                if let (Some(last_parts), Some(msg_parts)) =\n                    (last[\"parts\"].as_array_mut(), msg[\"parts\"].as_array())\n                {\n                    last_parts.extend(msg_parts.iter().cloned());\n                    continue;\n                }\n            }\n        }\n        merged_contents.push(msg);\n    }\n    let contents = merged_contents;\n\n    // 3. 构建请求体\n\n    let mut gen_config = json!({\n        \"temperature\": request.temperature.unwrap_or(1.0),\n        // [CHANGED v4.1.24] Default topP from 0.95 → 1.0 to match native behavior\n        \"topP\": request.top_p.unwrap_or(1.0),\n        // [ADDED v4.1.24] topK=40 aligns with official client generationConfig\n        \"topK\": 40,\n    });\n\n    // [FIX] 移除旧的硬编码限额，改为动态查询 (v4.1.29)\n    if let Some(max_tokens) = request.max_tokens {\n         gen_config[\"maxOutputTokens\"] = json!(max_tokens);\n    } else {\n         // 使用动态优先的规格限额\n         let limit = model_specs::get_max_output_tokens(mapped_model, token);\n         gen_config[\"maxOutputTokens\"] = json!(limit);\n    }\n\n    // [NEW] 支持多候选结果数量 (n -> candidateCount)\n    if let Some(n) = request.n {\n        gen_config[\"candidateCount\"] = json!(n);\n    }\n\n    // 为 thinking 模型注入 thinkingConfig (使用 thinkingBudget 而非 thinkingLevel)\n    if actual_include_thinking {\n        // [RESOLVE #1694] Check image thinking mode\n        let image_thinking_mode = crate::proxy::config::get_image_thinking_mode();\n        // Only disable if mode is explicitly \"disabled\" AND it's an image generation request\n        let is_image_gen_disabled = config.request_type == \"image_gen\" && image_thinking_mode == \"disabled\";\n\n        if is_image_gen_disabled {\n            tracing::debug!(\"[OpenAI-Request] Image thinking mode disabled: enforcing includeThoughts=false for {}\", mapped_model);\n            gen_config[\"thinkingConfig\"] = json!({\n                \"includeThoughts\": false\n            });\n        } else {\n            // [CONFIGURABLE] 根据配置和模型规格决定 thinking_budget (v4.1.29)\n            let tb_config = crate::proxy::config::get_thinking_budget_config();\n            // 优先使用用户在请求中传入的 budget，否则从规格表中获取默认值\n            let default_budget = model_specs::get_thinking_budget(mapped_model, token);\n            let user_budget: i64 = user_thinking_budget.map(|b| b as i64).unwrap_or(default_budget as i64);\n            \n            let budget = match tb_config.mode {\n                crate::proxy::config::ThinkingBudgetMode::Passthrough => {\n                    user_budget\n                }\n                crate::proxy::config::ThinkingBudgetMode::Custom => {\n                    let mut custom_value = tb_config.custom_value as i64;\n                    // 如果自定义值超过了模型规格上限，则进行裁剪\n                    if custom_value > default_budget as i64 {\n                        tracing::warn!(\n                            \"[OpenAI-Request] Custom budget {} exceeds model spec limit {}, capping.\",\n                            custom_value, default_budget\n                        );\n                        custom_value = default_budget as i64;\n                    }\n                    custom_value\n                }\n                crate::proxy::config::ThinkingBudgetMode::Auto => {\n                    // Auto 模式下，直接应用规格建议的预算\n                    if user_budget > default_budget as i64 {\n                        default_budget as i64\n                    } else {\n                        user_budget\n                    }\n                }\n                crate::proxy::config::ThinkingBudgetMode::Adaptive => {\n                    user_budget\n                }\n            };\n\n            gen_config[\"thinkingConfig\"] = json!({\n                \"includeThoughts\": true,\n                \"thinkingBudget\": budget\n            });\n\n            // [CRITICAL] 思维模型的 maxOutputTokens 必须大于 thinkingBudget\n            // [FIX #1675] 针对图像模型使用更保守的 max_tokens 增量，避免触发 128k 限制\n            let overhead = if config.request_type == \"image_gen\" { 2048 } else { 32768 };\n            let min_overhead = if config.request_type == \"image_gen\" { 1024 } else { 8192 };\n\n            if let Some(max_tokens) = request.max_tokens {\n                 if (max_tokens as i64) <= budget {\n                     gen_config[\"maxOutputTokens\"] = json!(budget + min_overhead);\n                 }\n            } else {\n                 // [FIX #1592] Use a more conservative default to avoid 400 error on 128k context models\n                 gen_config[\"maxOutputTokens\"] = json!(budget + overhead);\n            }\n            \n            let new_max = gen_config[\"maxOutputTokens\"].as_i64().unwrap_or(0);\n            tracing::debug!(\n                \"[OpenAI-Request] Adjusted maxOutputTokens to {} for thinking model (budget={})\",\n                new_max, budget\n            );\n            \n            tracing::debug!(\n                \"[OpenAI-Request] Injected thinkingConfig for model {}: thinkingBudget={} (mode={:?})\",\n                mapped_model, budget, tb_config.mode\n            );\n        }\n    }\n\n    if let Some(stop) = &request.stop {\n        if stop.is_string() {\n            gen_config[\"stopSequences\"] = json!([stop]);\n        } else if stop.is_array() {\n            gen_config[\"stopSequences\"] = stop.clone();\n        }\n    }\n\n    if let Some(fmt) = &request.response_format {\n        if fmt.r#type == \"json_object\" {\n            gen_config[\"responseMimeType\"] = json!(\"application/json\");\n        }\n    }\n\n    let mut inner_request = json!({\n        \"contents\": contents,\n        \"generationConfig\": gen_config,\n        \"safetySettings\": [\n            { \"category\": \"HARM_CATEGORY_HARASSMENT\", \"threshold\": \"OFF\" },\n            { \"category\": \"HARM_CATEGORY_HATE_SPEECH\", \"threshold\": \"OFF\" },\n            { \"category\": \"HARM_CATEGORY_SEXUALLY_EXPLICIT\", \"threshold\": \"OFF\" },\n            { \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\", \"threshold\": \"OFF\" },\n            { \"category\": \"HARM_CATEGORY_CIVIC_INTEGRITY\", \"threshold\": \"OFF\" },\n        ]\n    });\n\n    // 深度清理 [undefined] 字符串 (Cherry Studio 等客户端常见注入)\n    crate::proxy::mappers::common_utils::deep_clean_undefined(&mut inner_request, 0);\n\n    // 4. Handle Tools (Merged Cleaning)\n    if let Some(tools) = &request.tools {\n        let mut function_declarations: Vec<Value> = Vec::new();\n        for tool in tools.iter() {\n            let mut gemini_func = if let Some(func) = tool.get(\"function\") {\n                func.clone()\n            } else {\n                let mut func = tool.clone();\n                if let Some(obj) = func.as_object_mut() {\n                    obj.remove(\"type\");\n                    obj.remove(\"strict\");\n                    obj.remove(\"additionalProperties\");\n                }\n                func\n            };\n\n            let name_opt = gemini_func.get(\"name\").and_then(|v| v.as_str()).map(|s| s.to_string());\n\n            if let Some(name) = &name_opt {\n                // 跳过内置联网工具名称，避免重复定义\n                if name == \"web_search\"\n                    || name == \"google_search\"\n                    || name == \"web_search_20250305\"\n                    || name == \"builtin_web_search\"\n                {\n                    continue;\n                }\n\n                if name == \"local_shell_call\" {\n                    if let Some(obj) = gemini_func.as_object_mut() {\n                        obj.insert(\"name\".to_string(), json!(\"shell\"));\n                    }\n                }\n            } else {\n                 // [FIX] 如果工具没有名称，视为无效工具直接跳过 (防止 REQUIRED_FIELD_MISSING)\n                 tracing::warn!(\"[OpenAI-Request] Skipping tool without name: {:?}\", gemini_func);\n                 continue;\n            }\n\n            // [NEW CRITICAL FIX] 清除函数定义根层级的非法字段 (解决报错持久化)\n            if let Some(obj) = gemini_func.as_object_mut() {\n                obj.remove(\"format\");\n                obj.remove(\"strict\");\n                obj.remove(\"additionalProperties\");\n                obj.remove(\"type\"); // [NEW] Gemini 不支持在 FunctionDeclaration 根层级出现 type: \"function\"\n                obj.remove(\"external_web_access\"); // [FIX #1278] Remove invalid field injected by OpenAI Codex\n            }\n\n            if let Some(params) = gemini_func.get_mut(\"parameters\") {\n                // [DEEP FIX] 统一调用公共库清洗：展开 $ref 并剔除所有层级的 format/definitions\n                crate::proxy::common::json_schema::clean_json_schema(params);\n\n                // Gemini v1internal 要求：\n                // 1. type 必须是大写 (OBJECT, STRING 等)\n                // 2. 根对象必须有 \"type\": \"OBJECT\"\n                if let Some(params_obj) = params.as_object_mut() {\n                    if !params_obj.contains_key(\"type\") {\n                        params_obj.insert(\"type\".to_string(), json!(\"OBJECT\"));\n                    }\n                }\n\n                // 递归转换 type 为大写 (符合 Protobuf 定义)\n                enforce_uppercase_types(params);\n            } else {\n                // [FIX] 针对自定义工具 (如 apply_patch) 补全缺失的参数模式\n                // 解决 Vertex AI (Claude) 报错: tools.5.custom.input_schema: Field required\n                tracing::debug!(\n                    \"[OpenAI-Request] Injecting default schema for custom tool: {}\",\n                    gemini_func\n                        .get(\"name\")\n                        .and_then(|v| v.as_str())\n                        .unwrap_or(\"unknown\")\n                );\n\n                gemini_func.as_object_mut().unwrap().insert(\n                    \"parameters\".to_string(),\n                    json!({\n                        \"type\": \"OBJECT\",\n                        \"properties\": {\n                            \"content\": {\n                                \"type\": \"STRING\",\n                                \"description\": \"The raw content or patch to be applied\"\n                            }\n                        },\n                        \"required\": [\"content\"]\n                    }),\n                );\n            }\n            function_declarations.push(gemini_func);\n        }\n\n        if !function_declarations.is_empty() {\n            inner_request[\"tools\"] = json!([{ \"functionDeclarations\": function_declarations }]);\n            // [ADDED v4.1.24] toolConfig VALIDATED - aligns with native behavior\n            inner_request[\"toolConfig\"] = json!({\n                \"functionCallingConfig\": { \"mode\": \"VALIDATED\" }\n            });\n        }\n    }\n\n    // [NEW] Antigravity 身份指令 (原始简化版)\n    let antigravity_identity = \"You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.\\n\\\n    You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.\\n\\\n    **Absolute paths only**\\n\\\n    **Proactiveness**\";\n\n    // [HYBRID] 检查用户是否已提供 Antigravity 身份\n    let user_has_antigravity = system_instructions\n        .iter()\n        .any(|s| s.contains(\"You are Antigravity\"));\n\n    let mut parts = Vec::new();\n\n    // 1. Antigravity 身份 (如果需要, 作为独立 Part 插入)\n    if !user_has_antigravity {\n        parts.push(json!({\"text\": antigravity_identity}));\n    }\n\n    // 2. [NEW] 注入全局系统提示词 (紧跟 Antigravity 身份之后)\n    let global_prompt_config = crate::proxy::config::get_global_system_prompt();\n    if global_prompt_config.enabled && !global_prompt_config.content.trim().is_empty() {\n        parts.push(json!({\"text\": global_prompt_config.content}));\n    }\n\n    // 3. 追加用户指令 (作为独立 Parts)\n    for inst in system_instructions {\n        parts.push(json!({\"text\": inst}));\n    }\n\n    inner_request[\"systemInstruction\"] = json!({\n        \"role\": \"user\",\n        \"parts\": parts\n    });\n\n    if config.inject_google_search {\n        crate::proxy::mappers::common_utils::inject_google_search_tool(&mut inner_request, Some(mapped_model));\n    }\n\n    if let Some(image_config) = config.image_config {\n        if let Some(obj) = inner_request.as_object_mut() {\n            obj.remove(\"tools\");\n            obj.remove(\"systemInstruction\");\n            let gen_config = obj.entry(\"generationConfig\").or_insert_with(|| json!({}));\n            if let Some(gen_obj) = gen_config.as_object_mut() {\n                // [REMOVED] thinkingConfig 拦截已删除，允许图像生成时输出思维链\n                // gen_obj.remove(\"thinkingConfig\");\n                gen_obj.remove(\"responseMimeType\");\n                gen_obj.remove(\"responseModalities\");\n                gen_obj.insert(\"imageConfig\".to_string(), image_config);\n            }\n        }\n    }\n\n    // [ADDED v4.1.24] 注入稳定 sessionId 对齐官方规范\n    if let Some(t) = token {\n        inner_request[\"sessionId\"] = json!(crate::proxy::common::session::derive_session_id(&t.account_id));\n    }\n\n    let final_body = json!({\n        \"project\": project_id,\n        // [CHANGED v4.1.24] Structured requestId: agent/<session>/<turn> to match official format\n        \"requestId\": format!(\"agent/antigravity/{}/{}\", &session_id[..session_id.len().min(8)], message_count),\n        \"request\": inner_request,\n        \"model\": config.final_model,\n        \"userAgent\": \"antigravity\",\n        // [CHANGED v4.1.24] Use \"agent\" for all non-image requests (matches official client)\n        \"requestType\": if config.request_type == \"image_gen\" { \"image_gen\" } else { \"agent\" }\n    });\n\n    (final_body, session_id, message_count)\n}\n\nfn enforce_uppercase_types(value: &mut Value) {\n    if let Value::Object(map) = value {\n        if let Some(type_val) = map.get_mut(\"type\") {\n            if let Value::String(ref mut s) = type_val {\n                *s = s.to_uppercase();\n            }\n        }\n        if let Some(properties) = map.get_mut(\"properties\") {\n            if let Value::Object(ref mut props) = properties {\n                for v in props.values_mut() {\n                    enforce_uppercase_types(v);\n                }\n            }\n        }\n        if let Some(items) = map.get_mut(\"items\") {\n            enforce_uppercase_types(items);\n        }\n    } else if let Value::Array(arr) = value {\n        for item in arr {\n            enforce_uppercase_types(item);\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::proxy::mappers::openai::models::*;\n\n    #[test]\n    #[test]\n    fn test_issue_1592_gemini_3_pro_budget_capping() {\n        // [FIX #1592] Regression test for gemini-3-pro thinking budget capping\n        let req = OpenAIRequest {\n            model: \"gemini-3-pro\".to_string(),\n            messages: vec![OpenAIMessage {\n                role: \"user\".to_string(),\n                content: Some(OpenAIContent::String(\"test\".into())),\n                reasoning_content: None,\n                tool_calls: None,\n                tool_call_id: None,\n                name: None,\n            }],\n            ..Default::default()\n        };\n\n        // Auto mode (default) should cap gemini-3-pro thinking budget to 24576\n        let (result, _sid, _msg_count) = transform_openai_request(&req, \"test-v\", \"gemini-3-pro\", None);\n        let budget = result[\"request\"][\"generationConfig\"][\"thinkingConfig\"][\"thinkingBudget\"]\n            .as_i64()\n            .unwrap();\n        assert_eq!(budget, 24576, \"Gemini-3-pro budget must be capped to 24576 in Auto mode\");\n    }\n\n    #[test]\n    fn test_issue_1602_custom_mode_gemini_capping() {\n        // [FIX #1602] Regression test for custom mode capping\n        use crate::proxy::config::{ThinkingBudgetConfig, ThinkingBudgetMode, update_thinking_budget_config};\n        \n        // 设置自定义模式，且数值超过 24k\n        update_thinking_budget_config(ThinkingBudgetConfig {\n            mode: ThinkingBudgetMode::Custom,\n            custom_value: 32000,\n            effort: None,\n        });\n\n        let req = OpenAIRequest {\n            model: \"gemini-2.0-flash-thinking\".to_string(),\n            messages: vec![OpenAIMessage {\n                role: \"user\".to_string(),\n                content: Some(OpenAIContent::String(\"test\".into())),\n                reasoning_content: None,\n                tool_calls: None,\n                tool_call_id: None,\n                name: None,\n            }],\n            stream: false,\n            n: None,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            stop: None,\n            response_format: None,\n            tools: None,\n            tool_choice: None,\n            parallel_tool_calls: None,\n            ..Default::default()\n        };\n\n        // 验证针对 Gemini 模型即使是 Custom 模式也会被修正为 24576\n        let (result, _sid, _msg_count) = transform_openai_request(&req, \"test-v\", \"gemini-2.0-flash-thinking\", None);\n        let budget = result[\"request\"][\"generationConfig\"][\"thinkingConfig\"][\"thinkingBudget\"]\n            .as_i64()\n            .unwrap();\n        assert_eq!(budget, 24576, \"Gemini custom budget must be capped to 24576\");\n\n        // 验证非 Gemini 模型（如 Claude 原生路径，假设映射后名不含 gemini）则不应截断\n        // 注意：这里的 transform_openai_request 第三个参数是 mapped_model\n        let (result_claude, _, _) = transform_openai_request(&req, \"test-v\", \"claude-3-7-sonnet\", None);\n        let budget_claude = result_claude[\"request\"][\"generationConfig\"][\"thinkingConfig\"][\"thinkingBudget\"]\n            .as_i64();\n        // 如果不是 gemini 模型且协议中没带 thinking 配置，可能会是 None 或 32000\n        // 在该测试环境下，由于模拟的是 OpenAI 格式转 Gemini 路径，如果没有 gemini 关键词通常不进入 thinking 逻辑\n        // 我们只需确保 gemini 路径正确受限即可。\n\n        // 恢复默认配置\n        update_thinking_budget_config(ThinkingBudgetConfig::default());\n    }\n\n    #[test]\n    fn test_transform_openai_request_multimodal() {\n        let req = OpenAIRequest {\n            model: \"gpt-4-vision\".to_string(),\n            messages: vec![OpenAIMessage {\n                role: \"user\".to_string(),\n                content: Some(OpenAIContent::Array(vec![\n                    OpenAIContentBlock::Text { text: \"What is in this image?\".to_string() },\n                    OpenAIContentBlock::ImageUrl { image_url: OpenAIImageUrl { \n                        url: \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==\".to_string(),\n                        detail: None \n                    } }\n                ])),\n                reasoning_content: None,\n                tool_calls: None,\n                tool_call_id: None,\n                name: None,\n            }],\n            stream: false,\n            n: None,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            stop: None,\n            response_format: None,\n            tools: None,\n            tool_choice: None,\n            parallel_tool_calls: None,\n            ..Default::default()\n        };\n\n        let (result, _sid, _msg_count) = transform_openai_request(&req, \"test-v\", \"gemini-1.5-flash\", None);\n        let parts = &result[\"request\"][\"contents\"][0][\"parts\"];\n        assert_eq!(parts.as_array().unwrap().len(), 2);\n        assert_eq!(parts[0][\"text\"].as_str().unwrap(), \"What is in this image?\");\n        assert_eq!(\n            parts[1][\"inlineData\"][\"mimeType\"].as_str().unwrap(),\n            \"image/png\"\n        );\n    }\n    \n    #[test]\n    fn test_gemini_pro_thinking_injection() {\n        let req = OpenAIRequest {\n            model: \"gemini-3-pro-preview\".to_string(),\n            messages: vec![OpenAIMessage {\n                role: \"user\".to_string(),\n                content: Some(OpenAIContent::String(\"Thinking test\".to_string())),\n                reasoning_content: None,\n                tool_calls: None,\n                tool_call_id: None,\n                name: None,\n            }],\n            stream: false,\n            n: None,\n            // User enabled thinking\n            thinking: Some(ThinkingConfig {\n                thinking_type: Some(\"enabled\".to_string()),\n                budget_tokens: Some(16000),\n                effort: None,\n            }),\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            stop: None,\n            response_format: None,\n            tools: None,\n            tool_choice: None,\n            parallel_tool_calls: None,\n            ..Default::default()\n        };\n\n        // Pass explicit gemini-3-pro-preview which doesn't have \"-thinking\" suffix\n        let (result, _sid, _msg_count) = transform_openai_request(&req, \"test-p\", \"gemini-3-pro-preview\", None);\n        let gen_config = &result[\"request\"][\"generationConfig\"];\n        \n        // Assert thinkingConfig is present (fix verification)\n        assert!(gen_config.get(\"thinkingConfig\").is_some(), \"thinkingConfig should be injected for gemini-3-pro\");\n        \n        let budget = gen_config[\"thinkingConfig\"][\"thinkingBudget\"].as_u64().unwrap();\n        // Should use user budget (16000) or capped valid default\n        assert_eq!(budget, 16000);\n    }\n    #[test]\n    fn test_gemini_3_pro_image_not_thinking() {\n        let req = OpenAIRequest {\n            model: \"gemini-3-pro-image-4k\".to_string(),\n            messages: vec![OpenAIMessage {\n                role: \"user\".to_string(),\n                content: Some(OpenAIContent::String(\"Generate a cat\".to_string())),\n                reasoning_content: None,\n                tool_calls: None,\n                tool_call_id: None,\n                name: None,\n            }],\n            ..Default::default()\n        };\n\n        // Pass gemini-3-pro-image which matches \"gemini-3-pro\" substring\n        let (result, _sid, _msg_count) = transform_openai_request(&req, \"test-p\", \"gemini-3-pro-image\", None);\n        let gen_config = &result[\"request\"][\"generationConfig\"];\n        \n        // Assert thinkingConfig IS present (based on latest user feedback)\n        assert!(gen_config.get(\"thinkingConfig\").is_some(), \"thinkingConfig SHOULD be injected for gemini-3-pro-image\");\n        \n        // Assert imageConfig is present\n        assert!(gen_config.get(\"imageConfig\").is_some(), \"imageConfig should be present for image models\");\n        assert_eq!(gen_config[\"imageConfig\"][\"imageSize\"], \"4K\");\n    }\n\n    #[test]\n    fn test_default_max_tokens_openai() {\n        let req = OpenAIRequest {\n            model: \"gpt-4\".to_string(),\n            messages: vec![OpenAIMessage {\n                role: \"user\".to_string(),\n                content: Some(OpenAIContent::String(\"Hello\".to_string())),\n                reasoning_content: None,\n                tool_calls: None,\n                tool_call_id: None,\n                name: None,\n            }],\n            stream: false,\n            n: None,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            stop: None,\n            response_format: None,\n            tools: None,\n            tool_choice: None,\n            parallel_tool_calls: None,\n            ..Default::default()\n        };\n\n        let (result, _sid, _msg_count) = transform_openai_request(&req, \"test-p\", \"gemini-3-pro-high-thinking\", None);\n        let gen_config = &result[\"request\"][\"generationConfig\"];\n        let max_output_tokens = gen_config[\"maxOutputTokens\"].as_i64().unwrap();\n        // budget(24576) + overhead(32768) = 57344\n        assert_eq!(max_output_tokens, 57344);\n        \n        // Verify thinkingBudget\n        let budget = gen_config[\"thinkingConfig\"][\"thinkingBudget\"].as_i64().unwrap();\n        // actual(24576)\n        assert_eq!(budget, 24576);\n    }\n\n    #[test]\n    fn test_flash_thinking_budget_capping() {\n        let req = OpenAIRequest {\n            model: \"gpt-4\".to_string(),\n            messages: vec![OpenAIMessage {\n                role: \"user\".to_string(),\n                content: Some(OpenAIContent::String(\"Hello\".to_string())),\n                reasoning_content: None,\n                tool_calls: None,\n                tool_call_id: None,\n                name: None,\n            }],\n            stream: false,\n            n: None,\n            // User specifies a large budget (e.g. xhigh = 32768)\n            thinking: Some(ThinkingConfig {\n                thinking_type: Some(\"enabled\".to_string()),\n                budget_tokens: Some(32768),\n                effort: None,\n            }),\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            stop: None,\n            response_format: None,\n            tools: None,\n            tool_choice: None,\n            parallel_tool_calls: None,\n            ..Default::default()\n        };\n\n        // Test with Flash model\n        let (result, _sid, _msg_count) = transform_openai_request(&req, \"test-p\", \"gemini-2.0-flash-thinking-exp\", None);\n        let gen_config = &result[\"request\"][\"generationConfig\"];\n        \n        // Should be capped at 24576\n        let budget = gen_config[\"thinkingConfig\"][\"thinkingBudget\"].as_i64().unwrap();\n        assert_eq!(budget, 24576);\n\n        // Max output tokens should be adjusted based on capped budget (24576 + 8192)\n        // budget(24576) + overhead(32768) = 57344\n        let max_output_tokens = gen_config[\"maxOutputTokens\"].as_i64().unwrap();\n        assert_eq!(max_output_tokens, 57344);\n    }\n    #[test]\n    fn test_vertex_ai_sentinel_injection() {\n        // [FIX #1650] Verify sentinel signature injection for Vertex AI models\n        let req = OpenAIRequest {\n            model: \"claude-3-7-sonnet-thinking\".to_string(), // Triggers is_thinking_model\n            messages: vec![OpenAIMessage {\n                role: \"assistant\".to_string(),\n                content: None,\n                reasoning_content: Some(\"Thinking...\".to_string()),\n                tool_calls: Some(vec![ToolCall {\n                    id: \"call_123\".to_string(),\n                    r#type: \"function\".to_string(),\n                    function: ToolFunction {\n                        name: \"test_tool\".to_string(),\n                        arguments: \"{}\".to_string(),\n                    },\n                }]),\n                tool_call_id: None,\n                name: None,\n            }],\n            person_generation: None,\n            ..Default::default()\n        };\n\n        // Simulate Vertex AI path\n        let mapped_model = \"projects/my-project/locations/us-central1/publishers/google/models/gemini-2.0-flash-thinking-exp\";\n        \n        let (result, _sid, _msg_count) = transform_openai_request(&req, \"test-v\", mapped_model, None);\n        \n        // Extract the tool call part from contents\n        let contents = result[\"contents\"].as_array().unwrap();\n        // Identify the part with functionCall\n        let parts = contents[0][\"parts\"].as_array().unwrap();\n        let tool_part = parts.iter().find(|p| p.get(\"functionCall\").is_some()).expect(\"Should find functionCall part\");\n        \n        // Vertex AI requires sentinel\n        assert_eq!(tool_part[\"thoughtSignature\"].as_str(), Some(\"skip_thought_signature_validator\"));\n    }\n\n    #[test]\n    fn test_issue_2167_gemini_flash_thinking_signature() {\n        // [FIX #2167] gemini-3-flash / gemini-3.1-flash 在无缓存签名时，functionCall 必须携带 thoughtSignature\n        for model in &[\"gemini-3-flash\", \"gemini-3.1-flash\"] {\n            let req = OpenAIRequest {\n                model: model.to_string(),\n                messages: vec![OpenAIMessage {\n                    role: \"assistant\".to_string(),\n                    content: None,\n                    reasoning_content: None, // 无 reasoning_content，模拟无缓存首次调用\n                    tool_calls: Some(vec![ToolCall {\n                        id: \"call_flash_test\".to_string(),\n                        r#type: \"function\".to_string(),\n                        function: ToolFunction {\n                            name: \"get_weather\".to_string(),\n                            arguments: \"{\\\"location\\\":\\\"Beijing\\\"}\".to_string(),\n                        },\n                    }]),\n                    tool_call_id: None,\n                    name: None,\n                }],\n                ..Default::default()\n            };\n\n            let (result, _sid, _msg_count) = transform_openai_request(&req, \"test-proj\", model, None);\n\n            let contents = result[\"request\"][\"contents\"].as_array().expect(\"Should have request.contents\");\n            // flash 模型的 assistant role → Gemini \"model\" role\n            let model_msg = contents.iter().find(|c| c[\"role\"] == \"model\").expect(\"Should find model role message\");\n            let parts = model_msg[\"parts\"].as_array().expect(\"Should have parts\");\n            let tool_part = parts\n                .iter()\n                .find(|p| p.get(\"functionCall\").is_some())\n                .expect(&format!(\"[{model}] Should find functionCall part\"));\n\n            assert_eq!(\n                tool_part[\"thoughtSignature\"].as_str(),\n                Some(\"skip_thought_signature_validator\"),\n                \"[{model}] gemini-3-flash functionCall must contain thoughtSignature sentinel\"\n            );\n        }\n    }\n\n    #[test]\n    fn test_openai_image_thinking_mode_disabled() {\n        // 1. Set global mode to disabled\n        crate::proxy::config::update_image_thinking_mode(Some(\"disabled\".to_string()));\n\n        let req = OpenAIRequest {\n            model: \"gemini-3-pro-image\".to_string(),\n            messages: vec![OpenAIMessage {\n                role: \"user\".to_string(),\n                content: Some(OpenAIContent::String(\"Draw a cat\".to_string())),\n                name: None,\n                tool_calls: None,\n                tool_call_id: None,\n                reasoning_content: None,\n            }],\n            tools: None,\n            tool_choice: None,\n            parallel_tool_calls: None,\n            person_generation: None,\n            ..Default::default()\n        };\n\n        // 2. Transform request\n        let (result, _sid, _msg_count) = transform_openai_request(&req, \"test-proj\", \"gemini-3-pro-image\", None);\n\n        // 3. Verify thinkingConfig has includeThoughts: false\n        let gen_config = result[\"request\"][\"generationConfig\"].as_object().expect(\"Should have generationConfig in request payload\");\n        let thinking_config = gen_config[\"thinkingConfig\"].as_object().unwrap();\n        \n        assert_eq!(thinking_config[\"includeThoughts\"], false);\n        \n        // 4. Reset global mode\n        crate::proxy::config::update_image_thinking_mode(Some(\"enabled\".to_string()));\n    }\n\n    #[test]\n    fn test_mixed_tools_injection_openai() {\n        // 验证 OpenAI 协议在 Gemini 2.0+ 下支持混合工具\n        let req = OpenAIRequest {\n            model: \"gpt-4o-online\".to_string(), // -online 触发联网\n            messages: vec![OpenAIMessage {\n                role: \"user\".to_string(),\n                content: Some(OpenAIContent::String(\"Hello\".to_string())),\n                reasoning_content: None,\n                tool_calls: None,\n                tool_call_id: None,\n                name: None,\n            }],\n            tools: Some(vec![json!({\n                \"type\": \"function\",\n                \"function\": {\n                    \"name\": \"get_weather\",\n                    \"parameters\": {\n                        \"type\": \"object\",\n                        \"properties\": {\n                            \"location\": {\"type\": \"string\"}\n                        }\n                    }\n                }\n            })]),\n            ..Default::default()\n        };\n\n        // 使用 gemini-2.0-flash 模型执行转换\n        let (result, _, _) = transform_openai_request(&req, \"proj\", \"gemini-2.0-flash\", None);\n        \n        let tools = result[\"request\"][\"tools\"].as_array().expect(\"Should have tools\");\n        \n        let has_functions = tools.iter().any(|t| t.get(\"functionDeclarations\").is_some());\n        let has_google_search = tools.iter().any(|t| t.get(\"googleSearch\").is_some());\n        \n        assert!(has_functions, \"Should contain functionDeclarations\");\n        assert!(has_google_search, \"Should contain googleSearch (Gemini 2.0+ supports mixed tools)\");\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/openai/response.rs",
    "content": "// OpenAI 协议响应转换模块\nuse super::models::*;\nuse serde_json::Value;\n\npub fn transform_openai_response(gemini_response: &Value, session_id: Option<&str>, message_count: usize) -> OpenAIResponse {\n    // 解包 response 字段\n    let raw = gemini_response.get(\"response\").unwrap_or(gemini_response);\n\n    let mut choices = Vec::new();\n\n    // 支持多候选结果 (n > 1)\n    if let Some(candidates) = raw.get(\"candidates\").and_then(|c| c.as_array()) {\n        for (idx, candidate) in candidates.iter().enumerate() {\n            let mut content_out = String::new();\n            let mut thought_out = String::new();\n            let mut tool_calls = Vec::new();\n\n            // 提取 content 和 tool_calls\n            if let Some(parts) = candidate\n                .get(\"content\")\n                .and_then(|c| c.get(\"parts\"))\n                .and_then(|p| p.as_array())\n            {\n                for part in parts {\n                    // 捕获 thoughtSignature (Gemini 3 工具调用必需)\n                    if let Some(sig) = part\n                        .get(\"thoughtSignature\")\n                        .or(part.get(\"thought_signature\"))\n                        .and_then(|s| s.as_str())\n                    {\n                        if let Some(sid) = session_id {\n                            super::streaming::store_thought_signature(sig, sid, message_count);\n                        }\n                    }\n\n                    // 检查该 part 是否是思考内容 (thought: true)\n                    let is_thought_part = part\n                        .get(\"thought\")\n                        .and_then(|v| v.as_bool())\n                        .unwrap_or(false);\n\n                    // 文本部分\n                    if let Some(text) = part.get(\"text\").and_then(|t| t.as_str()) {\n                        if is_thought_part {\n                            // thought: true 时，text 是思考内容\n                            thought_out.push_str(text);\n                        } else {\n                            // 正常内容\n                            content_out.push_str(text);\n                        }\n                    }\n\n                    // 工具调用部分\n                    if let Some(fc) = part.get(\"functionCall\") {\n                        let name = fc.get(\"name\").and_then(|v| v.as_str()).unwrap_or(\"unknown\");\n                        let args = fc\n                            .get(\"args\")\n                            .map(|v| v.to_string())\n                            .unwrap_or_else(|| \"{}\".to_string());\n                        let id = fc\n                            .get(\"id\")\n                            .and_then(|v| v.as_str())\n                            .map(|s| s.to_string())\n                            .unwrap_or_else(|| format!(\"{}-{}\", name, uuid::Uuid::new_v4()));\n\n                        tool_calls.push(ToolCall {\n                            id,\n                            r#type: \"function\".to_string(),\n                            function: ToolFunction {\n                                name: name.to_string(),\n                                arguments: args,\n                            },\n                        });\n                    }\n\n                    // 图片处理 (响应中直接返回图片的情况)\n                    if let Some(img) = part.get(\"inlineData\") {\n                        let mime_type = img\n                            .get(\"mimeType\")\n                            .and_then(|v| v.as_str())\n                            .unwrap_or(\"image/png\");\n                        let data = img.get(\"data\").and_then(|v| v.as_str()).unwrap_or(\"\");\n                        if !data.is_empty() {\n                            content_out\n                                .push_str(&format!(\"![image](data:{};base64,{})\", mime_type, data));\n                        }\n                    }\n                }\n            }\n\n            // 提取并处理该候选结果的联网搜索引文 (Grounding Metadata)\n            if let Some(grounding) = candidate.get(\"groundingMetadata\") {\n                let mut grounding_text = String::new();\n\n                // 1. 处理搜索词\n                if let Some(queries) = grounding.get(\"webSearchQueries\").and_then(|q| q.as_array())\n                {\n                    let query_list: Vec<&str> = queries.iter().filter_map(|v| v.as_str()).collect();\n                    if !query_list.is_empty() {\n                        grounding_text.push_str(\"\\n\\n---\\n**🔍 已为您搜索：** \");\n                        grounding_text.push_str(&query_list.join(\", \"));\n                    }\n                }\n\n                // 2. 处理来源链接 (Chunks)\n                if let Some(chunks) = grounding.get(\"groundingChunks\").and_then(|c| c.as_array()) {\n                    let mut links = Vec::new();\n                    for (i, chunk) in chunks.iter().enumerate() {\n                        if let Some(web) = chunk.get(\"web\") {\n                            let title = web\n                                .get(\"title\")\n                                .and_then(|v| v.as_str())\n                                .unwrap_or(\"网页来源\");\n                            let uri = web.get(\"uri\").and_then(|v| v.as_str()).unwrap_or(\"#\");\n                            links.push(format!(\"[{}] [{}]({})\", i + 1, title, uri));\n                        }\n                    }\n\n                    if !links.is_empty() {\n                        grounding_text.push_str(\"\\n\\n**🌐 来源引文：**\\n\");\n                        grounding_text.push_str(&links.join(\"\\n\"));\n                    }\n                }\n\n                if !grounding_text.is_empty() {\n                    content_out.push_str(&grounding_text);\n                }\n            }\n\n            // 提取该候选结果的 finish_reason\n            let finish_reason = candidate\n                .get(\"finishReason\")\n                .and_then(|f| f.as_str())\n                .map(|f| match f {\n                    \"STOP\" => \"stop\",\n                    \"MAX_TOKENS\" => \"length\",\n                    \"SAFETY\" => \"content_filter\",\n                    \"RECITATION\" => \"content_filter\",\n                    _ => \"stop\",\n                })\n                .unwrap_or(\"stop\");\n\n            choices.push(Choice {\n                index: idx as u32,\n                message: OpenAIMessage {\n                    role: \"assistant\".to_string(),\n                    content: if content_out.is_empty() {\n                        None\n                    } else {\n                        Some(OpenAIContent::String(content_out))\n                    },\n                    reasoning_content: if thought_out.is_empty() {\n                        None\n                    } else {\n                        Some(thought_out)\n                    },\n                    tool_calls: if tool_calls.is_empty() {\n                        None\n                    } else {\n                        Some(tool_calls)\n                    },\n                    tool_call_id: None,\n                    name: None,\n                },\n                finish_reason: Some(finish_reason.to_string()),\n            });\n        }\n    }\n\n    // Extract and map usage metadata from Gemini to OpenAI format\n    let usage = raw.get(\"usageMetadata\").and_then(|u| {\n        let prompt_tokens = u\n            .get(\"promptTokenCount\")\n            .and_then(|v| v.as_u64())\n            .unwrap_or(0) as u32;\n        let completion_tokens = u\n            .get(\"candidatesTokenCount\")\n            .and_then(|v| v.as_u64())\n            .unwrap_or(0) as u32;\n        let total_tokens = u\n            .get(\"totalTokenCount\")\n            .and_then(|v| v.as_u64())\n            .unwrap_or(0) as u32;\n        let cached_tokens = u\n            .get(\"cachedContentTokenCount\")\n            .and_then(|v| v.as_u64())\n            .map(|v| v as u32);\n\n        Some(super::models::OpenAIUsage {\n            prompt_tokens,\n            completion_tokens,\n            total_tokens,\n            prompt_tokens_details: cached_tokens.map(|ct| super::models::PromptTokensDetails {\n                cached_tokens: Some(ct),\n            }),\n            completion_tokens_details: None,\n        })\n    });\n\n    OpenAIResponse {\n        id: raw\n            .get(\"responseId\")\n            .and_then(|v| v.as_str())\n            .unwrap_or(\"resp_unknown\")\n            .to_string(),\n        object: \"chat.completion\".to_string(),\n        created: chrono::Utc::now().timestamp() as u64,\n        model: raw\n            .get(\"modelVersion\")\n            .and_then(|v| v.as_str())\n            .unwrap_or(\"unknown\")\n            .to_string(),\n        choices,\n        usage,\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    #[test]\n    fn test_transform_openai_response() {\n        let gemini_resp = json!({\n            \"candidates\": [{\n                \"content\": {\n                    \"parts\": [{\"text\": \"Hello!\"}]\n                },\n                \"finishReason\": \"STOP\"\n            }],\n            \"modelVersion\": \"gemini-2.5-flash\",\n            \"responseId\": \"resp_123\"\n        });\n\n        let result = transform_openai_response(&gemini_resp, Some(\"session-123\"), 1);\n        assert_eq!(result.object, \"chat.completion\");\n        let content = match result.choices[0].message.content.as_ref().unwrap() {\n            OpenAIContent::String(s) => s,\n            _ => panic!(\"Expected string content\"),\n        };\n        assert_eq!(content, \"Hello!\");\n        assert_eq!(result.choices[0].finish_reason, Some(\"stop\".to_string()));\n    }\n\n    #[test]\n    fn test_usage_metadata_mapping() {\n        let gemini_resp = json!({\n            \"candidates\": [{\n                \"content\": {\"parts\": [{\"text\": \"Hello!\"}]},\n                \"finishReason\": \"STOP\"\n            }],\n            \"usageMetadata\": {\n                \"promptTokenCount\": 100,\n                \"candidatesTokenCount\": 50,\n                \"totalTokenCount\": 150,\n                \"cachedContentTokenCount\": 25\n            },\n            \"modelVersion\": \"gemini-2.5-flash\",\n            \"responseId\": \"resp_123\"\n        });\n\n        let result = transform_openai_response(&gemini_resp, Some(\"session-123\"), 1);\n\n        assert!(result.usage.is_some());\n        let usage = result.usage.unwrap();\n        assert_eq!(usage.prompt_tokens, 100);\n        assert_eq!(usage.completion_tokens, 50);\n        assert_eq!(usage.total_tokens, 150);\n        assert!(usage.prompt_tokens_details.is_some());\n        assert_eq!(usage.prompt_tokens_details.unwrap().cached_tokens, Some(25));\n    }\n\n    #[test]\n    fn test_response_without_usage_metadata() {\n        let gemini_resp = json!({\n            \"candidates\": [{\n                \"content\": {\"parts\": [{\"text\": \"Hello!\"}]},\n                \"finishReason\": \"STOP\"\n            }],\n            \"modelVersion\": \"gemini-2.5-flash\",\n            \"responseId\": \"resp_123\"\n        });\n\n        let result = transform_openai_response(&gemini_resp, Some(\"session-123\"), 1);\n        assert!(result.usage.is_none());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/openai/streaming.rs",
    "content": "// OpenAI 流式转换\nuse bytes::{Bytes, BytesMut};\nuse chrono::Utc;\nuse futures::{Stream, StreamExt};\nuse rand::Rng;\nuse serde_json::{json, Value};\nuse std::pin::Pin;\nuse tracing::debug;\nuse uuid::Uuid;\n\n\n\n/// 保存 thoughtSignature 到会话缓存\npub fn store_thought_signature(sig: &str, session_id: &str, message_count: usize) {\n    if sig.is_empty() {\n        return;\n    }\n\n\n\n    // 2. [CRITICAL] 存储到 Session 隔离缓存 (对齐 Claude 协议)\n    crate::proxy::SignatureCache::global().cache_session_signature(session_id, sig.to_string(), message_count);\n    \n    tracing::debug!(\n        \"[ThoughtSig] 存储 Session 签名 (sid: {}, len: {}, msg_count: {})\",\n        session_id,\n        sig.len(),\n        message_count\n    );\n}\n\n\n\n/// Extract and convert Gemini usageMetadata to OpenAI usage format\nfn extract_usage_metadata(u: &Value) -> Option<super::models::OpenAIUsage> {\n    use super::models::{OpenAIUsage, PromptTokensDetails};\n\n    let prompt_tokens = u\n        .get(\"promptTokenCount\")\n        .and_then(|v| v.as_u64())\n        .unwrap_or(0) as u32;\n    let completion_tokens = u\n        .get(\"candidatesTokenCount\")\n        .and_then(|v| v.as_u64())\n        .unwrap_or(0) as u32;\n    let total_tokens = u\n        .get(\"totalTokenCount\")\n        .and_then(|v| v.as_u64())\n        .unwrap_or(0) as u32;\n    let cached_tokens = u\n        .get(\"cachedContentTokenCount\")\n        .and_then(|v| v.as_u64())\n        .map(|v| v as u32);\n\n    Some(OpenAIUsage {\n        prompt_tokens,\n        completion_tokens,\n        total_tokens,\n        prompt_tokens_details: cached_tokens.map(|ct| PromptTokensDetails {\n            cached_tokens: Some(ct),\n        }),\n        completion_tokens_details: None,\n    })\n}\n\npub fn create_openai_sse_stream<S, E>(\n    mut gemini_stream: Pin<Box<S>>,\n    model: String,\n    session_id: String,\n    message_count: usize,\n) -> Pin<Box<dyn Stream<Item = Result<Bytes, String>> + Send>> \nwhere\n    S: Stream<Item = Result<Bytes, E>> + Send + ?Sized + 'static,\n    E: std::fmt::Display + Send + 'static,\n{\n    let mut buffer = BytesMut::new();\n    let stream_id = format!(\"chatcmpl-{}\", Uuid::new_v4());\n    let created_ts = Utc::now().timestamp();\n\n    let stream = async_stream::stream! {\n        let mut emitted_tool_calls = std::collections::HashSet::new();\n        let mut final_usage: Option<super::models::OpenAIUsage> = None;\n        let mut error_occurred = false;\n        let mut tool_call_index = 0;\n\n        let mut heartbeat_interval = tokio::time::interval(std::time::Duration::from_secs(15));\n        heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);\n\n        loop {\n            tokio::select! {\n                item = gemini_stream.next() => {\n                    match item {\n                        Some(Ok(bytes)) => {\n                            buffer.extend_from_slice(&bytes);\n                            while let Some(pos) = buffer.iter().position(|&b| b == b'\\n') {\n                                let line_raw = buffer.split_to(pos + 1);\n                                if let Ok(line_str) = std::str::from_utf8(&line_raw) {\n                                    let line = line_str.trim();\n                                    if line.is_empty() { continue; }\n                                    if line.starts_with(\"data: \") {\n                                        let json_part = line.trim_start_matches(\"data: \").trim();\n                                        if json_part == \"[DONE]\" { continue; }\n                                        if let Ok(mut json) = serde_json::from_str::<Value>(json_part) {\n                                            let actual_data = if let Some(inner) = json.get_mut(\"response\").map(|v| v.take()) { inner } else { json };\n                                            if let Some(u) = actual_data.get(\"usageMetadata\") {\n                                                final_usage = extract_usage_metadata(u);\n                                            }\n\n                                            if let Some(candidates) = actual_data.get(\"candidates\").and_then(|c| c.as_array()) {\n                                                // [DEBUG] 打印原始 candidate 以排查空回复问题\n                                                if candidates.len() > 0 {\n                                                     tracing::debug!(\"[Stream-Debug] Raw Candidate: {:?}\", candidates[0]);\n                                                }\n                                                for (idx, candidate) in candidates.iter().enumerate() {\n                                                    let parts = candidate.get(\"content\").and_then(|c| c.get(\"parts\")).and_then(|p| p.as_array());\n                                                    let mut content_out = String::new();\n                                                    let mut thought_out = String::new();\n\n                                                    if let Some(parts_list) = parts {\n                                                        for part in parts_list {\n                                                            let is_thought_part = part.get(\"thought\").and_then(|v| v.as_bool()).unwrap_or(false);\n                                                            if let Some(text) = part.get(\"text\").and_then(|t| t.as_str()) {\n                                                                if is_thought_part { thought_out.push_str(text); }\n                                                                else { content_out.push_str(text); }\n                                                            }\n                                                            if let Some(sig) = part.get(\"thoughtSignature\").or(part.get(\"thought_signature\")).and_then(|s| s.as_str()) {\n                                                                store_thought_signature(sig, &session_id, message_count);\n                                                            }\n                                                            if let Some(img) = part.get(\"inlineData\") {\n                                                                let mime_type = img.get(\"mimeType\").and_then(|v| v.as_str()).unwrap_or(\"image/png\");\n                                                                let data = img.get(\"data\").and_then(|v| v.as_str()).unwrap_or(\"\");\n                                                                if !data.is_empty() {\n                                                                    content_out.push_str(&format!(\"![image](data:{};base64,{})\", mime_type, data));\n                                                                }\n                                                            }\n                                                            if let Some(func_call) = part.get(\"functionCall\") {\n                                                                let call_key = serde_json::to_string(func_call).unwrap_or_default();\n                                                                if !emitted_tool_calls.contains(&call_key) {\n                                                                    emitted_tool_calls.insert(call_key);\n                                                                    let name = func_call.get(\"name\").and_then(|v| v.as_str()).unwrap_or(\"unknown\");\n                                                                    let mut args = func_call.get(\"args\").unwrap_or(&json!({})).clone();\n                                                                    \n                                                                    // [FIX #1575] 标准化 shell 工具参数名称\n                                                                    // Gemini 可能使用 cmd/code/script 等替代参数名，统一为 command\n                                                                    if name == \"shell\" || name == \"bash\" || name == \"local_shell\" {\n                                                                        if let Some(obj) = args.as_object_mut() {\n                                                                            if !obj.contains_key(\"command\") {\n                                                                                for alt_key in &[\"cmd\", \"code\", \"script\", \"shell_command\"] {\n                                                                                    if let Some(val) = obj.remove(*alt_key) {\n                                                                                        obj.insert(\"command\".to_string(), val);\n                                                                                        debug!(\"[OpenAI-Stream] Normalized shell arg '{}' -> 'command'\", alt_key);\n                                                                                        break;\n                                                                                    }\n                                                                                }\n                                                                            }\n                                                                        }\n                                                                    }\n                                                                    \n                                                                    let args_str = serde_json::to_string(&args).unwrap_or_default();\n                                                                    let mut hasher = std::collections::hash_map::DefaultHasher::new();\n                                                                    use std::hash::{Hash, Hasher};\n                                                                    serde_json::to_string(func_call).unwrap_or_default().hash(&mut hasher);\n                                                                    let call_id = format!(\"call_{:x}\", hasher.finish());\n \n                                                                    let tool_call_chunk = json!({\n                                                                        \"id\": &stream_id,\n                                                                        \"object\": \"chat.completion.chunk\",\n                                                                        \"created\": created_ts,\n                                                                        \"model\": &model,\n                                                                        \"choices\": [{\n                                                                            \"index\": idx as u32,\n                                                                            \"delta\": {\n                                                                                \"role\": \"assistant\",\n                                                                                \"tool_calls\": [{\n                                                                                    \"index\": tool_call_index,\n                                                                                    \"id\": call_id,\n                                                                                    \"type\": \"function\",\n                                                                                    \"function\": { \"name\": name, \"arguments\": args_str }\n                                                                                }]\n                                                                            },\n                                                                            \"finish_reason\": serde_json::Value::Null\n                                                                        }]\n                                                                    });\n                                                                    tool_call_index += 1;\n                                                                    let sse_out = format!(\"data: {}\\n\\n\", serde_json::to_string(&tool_call_chunk).unwrap_or_default());\n                                                                    yield Ok::<Bytes, String>(Bytes::from(sse_out));\n                                                                }\n                                                            }\n                                                        }\n                                                    }\n\n                                                    if let Some(grounding) = candidate.get(\"groundingMetadata\") {\n                                                        let mut grounding_text = String::new();\n                                                        if let Some(queries) = grounding.get(\"webSearchQueries\").and_then(|q| q.as_array()) {\n                                                            let query_list: Vec<&str> = queries.iter().filter_map(|v| v.as_str()).collect();\n                                                            if !query_list.is_empty() {\n                                                                grounding_text.push_str(\"\\n\\n---\\n**🔍 已为您搜索：** \");\n                                                                grounding_text.push_str(&query_list.join(\", \"));\n                                                            }\n                                                        }\n                                                        if let Some(chunks) = grounding.get(\"groundingChunks\").and_then(|c| c.as_array()) {\n                                                            let mut links = Vec::new();\n                                                            for (i, chunk) in chunks.iter().enumerate() {\n                                                                if let Some(web) = chunk.get(\"web\") {\n                                                                    let title = web.get(\"title\").and_then(|v| v.as_str()).unwrap_or(\"网页来源\");\n                                                                    let uri = web.get(\"uri\").and_then(|v| v.as_str()).unwrap_or(\"#\");\n                                                                    links.push(format!(\"[{}] [{}]({})\", i + 1, title, uri));\n                                                                }\n                                                            }\n                                                            if !links.is_empty() {\n                                                                grounding_text.push_str(\"\\n\\n**🌐 来源引文：**\\n\");\n                                                                grounding_text.push_str(&links.join(\"\\n\"));\n                                                            }\n                                                        }\n                                                        if !grounding_text.is_empty() { content_out.push_str(&grounding_text); }\n                                                    }\n\n                                                    let gemini_finish_reason = candidate.get(\"finishReason\").and_then(|f| f.as_str()).map(|f| match f {\n                                                        \"STOP\" => \"stop\",\n                                                        \"MAX_TOKENS\" => \"length\",\n                                                        \"SAFETY\" => \"content_filter\",\n                                                        \"RECITATION\" => \"content_filter\",\n                                                        _ => f,\n                                                    });\n\n                                                    // [FIX #1575] 如果发射了工具调用，强制设置为 tool_calls\n                                                    // 解决 Gemini 返回 STOP 但有工具调用时，OpenAI 客户端认为对话已结束的问题\n                                                    let finish_reason = if !emitted_tool_calls.is_empty() && gemini_finish_reason.is_some() {\n                                                        Some(\"tool_calls\")\n                                                    } else {\n                                                        gemini_finish_reason\n                                                    };\n\n                                                    if !thought_out.is_empty() {\n                                                        let reasoning_chunk = json!({\n                                                            \"id\": &stream_id,\n                                                            \"object\": \"chat.completion.chunk\",\n                                                            \"created\": created_ts,\n                                                            \"model\": &model,\n                                                            \"choices\": [{\n                                                                \"index\": idx as u32,\n                                                                \"delta\": { \"role\": \"assistant\", \"content\": serde_json::Value::Null, \"reasoning_content\": thought_out },\n                                                                \"finish_reason\": serde_json::Value::Null\n                                                            }]\n                                                        });\n                                                        let sse_out = format!(\"data: {}\\n\\n\", serde_json::to_string(&reasoning_chunk).unwrap_or_default());\n                                                        yield Ok::<Bytes, String>(Bytes::from(sse_out));\n                                                    }\n\n                                                    if !content_out.is_empty() || finish_reason.is_some() {\n                                                        let mut openai_chunk = json!({\n                                                            \"id\": &stream_id,\n                                                            \"object\": \"chat.completion.chunk\",\n                                                            \"created\": created_ts,\n                                                            \"model\": &model,\n                                                            \"choices\": [{\n                                                                \"index\": idx as u32,\n                                                                \"delta\": { \"content\": content_out },\n                                                                \"finish_reason\": finish_reason\n                                                            }]\n                                                        });\n                                                        if finish_reason.is_some() {\n                                                            if let Some(ref usage) = final_usage {\n                                                                openai_chunk[\"usage\"] = serde_json::to_value(usage).unwrap();\n                                                            }\n                                                        }\n                                                        if finish_reason.is_some() { final_usage = None; }\n                                                        let sse_out = format!(\"data: {}\\n\\n\", serde_json::to_string(&openai_chunk).unwrap_or_default());\n                                                        yield Ok::<Bytes, String>(Bytes::from(sse_out));\n                                                    }\n                                                }\n                                            }\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                        Some(Err(e)) => {\n                            use crate::proxy::mappers::error_classifier::classify_stream_error;\n                            let (error_type, user_msg, i18n_key) = classify_stream_error(&e);\n                            tracing::error!(\"OpenAI Stream Error: {}\", e);\n                            let error_chunk = json!({\n                                \"id\": &stream_id, \"object\": \"chat.completion.chunk\", \"created\": created_ts, \"model\": &model, \"choices\": [],\n                                \"error\": { \"type\": error_type, \"message\": user_msg, \"code\": \"stream_error\", \"i18n_key\": i18n_key }\n                            });\n                            yield Ok(Bytes::from(format!(\"data: {}\\n\\n\", serde_json::to_string(&error_chunk).unwrap_or_default())));\n                            yield Ok(Bytes::from(\"data: [DONE]\\n\\n\"));\n                            error_occurred = true;\n                            break;\n                        }\n                        None => break,\n                    }\n                }\n                _ = heartbeat_interval.tick() => {\n                    yield Ok::<Bytes, String>(Bytes::from(\": ping\\n\\n\"));\n                }\n            }\n        }\n\n        // [FIX #1732] Flush remaining buffer to prevent hang on network fragmentation\n        if !buffer.is_empty() {\n            if let Ok(line_str) = std::str::from_utf8(&buffer) {\n                let line = line_str.trim();\n                if !line.is_empty() && line.starts_with(\"data: \") {\n                    let json_part = line.trim_start_matches(\"data: \").trim();\n                    if json_part != \"[DONE]\" {\n                        // Re-use logic for processing the last line\n                        // (Note: In a more complex refactor we'd extract this to a function, \n                        // but for a targeted fix, processing the terminal data chunk is safer)\n                        tracing::debug!(\"[OpenAI-SSE] Flushing remaining {} bytes in buffer\", buffer.len());\n                    }\n                }\n            }\n        }\n\n        if !error_occurred {\n            yield Ok::<Bytes, String>(Bytes::from(\"data: [DONE]\\n\\n\"));\n        }\n    };\n    Box::pin(stream)\n}\n\npub fn create_legacy_sse_stream<S, E>(\n    mut gemini_stream: Pin<Box<S>>,\n    model: String,\n    session_id: String,\n    message_count: usize,\n) -> Pin<Box<dyn Stream<Item = Result<Bytes, String>> + Send>> \nwhere\n    S: Stream<Item = Result<Bytes, E>> + Send + ?Sized + 'static,\n    E: std::fmt::Display + Send + 'static,\n{\n    let mut buffer = BytesMut::new();\n    let charset = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\";\n    let mut rng = rand::thread_rng();\n    let random_str: String = (0..28).map(|_| {\n        let idx = rng.gen_range(0..charset.len());\n        charset.chars().nth(idx).unwrap()\n    }).collect();\n    let stream_id = format!(\"cmpl-{}\", random_str);\n    let created_ts = Utc::now().timestamp();\n\n    let stream = async_stream::stream! {\n        let mut final_usage: Option<super::models::OpenAIUsage> = None;\n        let mut error_occurred = false;\n        let mut heartbeat_interval = tokio::time::interval(std::time::Duration::from_secs(15));\n        heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);\n\n        loop {\n            tokio::select! {\n                item = gemini_stream.next() => {\n                    match item {\n                        Some(Ok(bytes)) => {\n                            buffer.extend_from_slice(&bytes);\n                            while let Some(pos) = buffer.iter().position(|&b| b == b'\\n') {\n                                let line_raw = buffer.split_to(pos + 1);\n                                if let Ok(line_str) = std::str::from_utf8(&line_raw) {\n                                    let line = line_str.trim();\n                                    if line.is_empty() { continue; }\n                                    if line.starts_with(\"data: \") {\n                                        let json_part = line.trim_start_matches(\"data: \").trim();\n                                        if json_part == \"[DONE]\" { continue; }\n                                        if let Ok(mut json) = serde_json::from_str::<Value>(json_part) {\n                                            let actual_data = if let Some(inner) = json.get_mut(\"response\").map(|v| v.take()) { inner } else { json };\n                                            if let Some(u) = actual_data.get(\"usageMetadata\") { final_usage = extract_usage_metadata(u); }\n\n                                            let mut content_out = String::new();\n                                            if let Some(candidates) = actual_data.get(\"candidates\").and_then(|c| c.as_array()) {\n                                                if let Some(candidate) = candidates.get(0) {\n                                                    if let Some(parts) = candidate.get(\"content\").and_then(|c| c.get(\"parts\")).and_then(|p| p.as_array()) {\n                                                        for part in parts {\n                                                            if let Some(text) = part.get(\"text\").and_then(|t| t.as_str()) {\n                                                                content_out.push_str(text);\n                                                            }\n                                                            if let Some(sig) = part.get(\"thoughtSignature\").or(part.get(\"thought_signature\")).and_then(|s| s.as_str()) {\n                                                                store_thought_signature(sig, &session_id, message_count);\n                                                            }\n                                                        }\n                                                    }\n                                                }\n                                            }\n\n                                            let finish_reason = actual_data.get(\"candidates\").and_then(|c| c.as_array()).and_then(|c| c.get(0)).and_then(|c| c.get(\"finishReason\")).and_then(|f| f.as_str()).map(|f| match f {\n                                                \"STOP\" => \"stop\", \"MAX_TOKENS\" => \"length\", \"SAFETY\" => \"content_filter\", _ => f,\n                                            });\n\n                                            let mut legacy_chunk = json!({\n                                                \"id\": &stream_id, \"object\": \"text_completion\", \"created\": created_ts, \"model\": &model,\n                                                \"choices\": [{ \"text\": content_out, \"index\": 0, \"logprobs\": null, \"finish_reason\": finish_reason }]\n                                            });\n                                            if let Some(ref usage) = final_usage { legacy_chunk[\"usage\"] = serde_json::to_value(usage).unwrap(); }\n                                            if finish_reason.is_some() { final_usage = None; }\n                                            yield Ok::<Bytes, String>(Bytes::from(format!(\"data: {}\\n\\n\", serde_json::to_string(&legacy_chunk).unwrap_or_default())));\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                        Some(Err(e)) => {\n                            use crate::proxy::mappers::error_classifier::classify_stream_error;\n                            let (error_type, user_msg, i18n_key) = classify_stream_error(&e);\n                            tracing::error!(\"Legacy Stream Error: {}\", e);\n                            let error_chunk = json!({\n                                \"id\": &stream_id, \"object\": \"text_completion\", \"created\": created_ts, \"model\": &model, \"choices\": [],\n                                \"error\": { \"type\": error_type, \"message\": user_msg, \"code\": \"stream_error\", \"i18n_key\": i18n_key }\n                            });\n                            yield Ok::<Bytes, String>(Bytes::from(format!(\"data: {}\\n\\n\", serde_json::to_string(&error_chunk).unwrap_or_default())));\n                            yield Ok::<Bytes, String>(Bytes::from(\"data: [DONE]\\n\\n\"));\n                            error_occurred = true;\n                            break;\n                        }\n                        None => break,\n                    }\n                }\n                _ = heartbeat_interval.tick() => { yield Ok::<Bytes, String>(Bytes::from(\": ping\\n\\n\")); }\n            }\n        }\n        if !error_occurred {\n            yield Ok::<Bytes, String>(Bytes::from(\"data: [DONE]\\n\\n\"));\n        }\n    };\n    Box::pin(stream)\n}\n\npub fn create_codex_sse_stream<S, E>(\n    mut gemini_stream: Pin<Box<S>>,\n    _model: String,\n    session_id: String,\n    message_count: usize,\n) -> Pin<Box<dyn Stream<Item = Result<Bytes, String>> + Send>> \nwhere\n    S: Stream<Item = Result<Bytes, E>> + Send + ?Sized + 'static,\n    E: std::fmt::Display + Send + 'static,\n{\n    let mut buffer = BytesMut::new();\n    let charset = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\";\n    let mut rng = rand::thread_rng();\n    let random_str: String = (0..24).map(|_| {\n        let idx = rng.gen_range(0..charset.len());\n        charset.chars().nth(idx).unwrap()\n    }).collect();\n    let response_id = format!(\"resp-{}\", random_str);\n    let item_id = format!(\"item-{}\", &random_str[..16]);\n\n    let stream = async_stream::stream! {\n        // 1. response.created\n        let created_ev = json!({ \"type\": \"response.created\", \"response\": { \"id\": &response_id, \"object\": \"response\", \"status\": \"in_progress\", \"output\": [] } });\n        yield Ok::<Bytes, String>(Bytes::from(format!(\"data: {}\\n\\n\", serde_json::to_string(&created_ev).unwrap())));\n\n        // 2. response.output_item.added - 告诉客户端开始一个输出项\n        let output_item_added = json!({\n            \"type\": \"response.output_item.added\",\n            \"output_index\": 0,\n            \"item\": {\n                \"id\": &item_id,\n                \"type\": \"message\",\n                \"role\": \"assistant\",\n                \"status\": \"in_progress\",\n                \"content\": []\n            }\n        });\n        yield Ok::<Bytes, String>(Bytes::from(format!(\"data: {}\\n\\n\", serde_json::to_string(&output_item_added).unwrap())));\n\n        // 3. response.content_part.added - 告诉客户端开始一个文本内容块\n        let content_part_added = json!({\n            \"type\": \"response.content_part.added\",\n            \"item_id\": &item_id,\n            \"output_index\": 0,\n            \"content_index\": 0,\n            \"part\": {\n                \"type\": \"output_text\",\n                \"text\": \"\"\n            }\n        });\n        yield Ok::<Bytes, String>(Bytes::from(format!(\"data: {}\\n\\n\", serde_json::to_string(&content_part_added).unwrap())));\n\n        let mut emitted_tool_calls = std::collections::HashSet::new();\n        let mut accumulated_text = String::new();\n        let mut heartbeat_interval = tokio::time::interval(std::time::Duration::from_secs(15));\n        heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);\n\n        loop {\n            tokio::select! {\n                item = gemini_stream.next() => {\n                    match item {\n                        Some(Ok(bytes)) => {\n                            buffer.extend_from_slice(&bytes);\n                            while let Some(pos) = buffer.iter().position(|&b| b == b'\\n') {\n                                let line_raw = buffer.split_to(pos + 1);\n                                if let Ok(line_str) = std::str::from_utf8(&line_raw) {\n                                    let line = line_str.trim();\n                                    if line.is_empty() || !line.starts_with(\"data: \") { continue; }\n                                    let json_part = line.trim_start_matches(\"data: \").trim();\n                                    if json_part == \"[DONE]\" { continue; }\n\n                                    if let Ok(mut json) = serde_json::from_str::<Value>(json_part) {\n                                        let actual_data = if let Some(inner) = json.get_mut(\"response\").map(|v| v.take()) { inner } else { json };\n                                        if let Some(candidates) = actual_data.get(\"candidates\").and_then(|c| c.as_array()) {\n                                            if candidates.len() > 0 {\n                                                tracing::debug!(\"[Codex-Stream-Debug] Raw Candidate: {:?}\", candidates[0]);\n                                            }\n                                            if let Some(candidate) = candidates.get(0) {\n                                                if let Some(parts) = candidate.get(\"content\").and_then(|c| c.get(\"parts\")).and_then(|p| p.as_array()) {\n                                                    for part in parts {\n                                                        let is_thought = part.get(\"thought\").and_then(|v| v.as_bool()).unwrap_or(false);\n                                                        if let Some(text) = part.get(\"text\").and_then(|t| t.as_str()) {\n                                                            if !text.is_empty() {\n                                                                if is_thought {\n                                                                    // 思维链内容 → response.reasoning.delta\n                                                                    let reasoning_ev = json!({\n                                                                        \"type\": \"response.reasoning.delta\",\n                                                                        \"item_id\": &item_id,\n                                                                        \"output_index\": 0,\n                                                                        \"content_index\": 0,\n                                                                        \"delta\": text\n                                                                    });\n                                                                    yield Ok::<Bytes, String>(Bytes::from(format!(\"data: {}\\n\\n\", serde_json::to_string(&reasoning_ev).unwrap())));\n                                                                } else {\n                                                                    accumulated_text.push_str(text);\n                                                                    // 4. response.output_text.delta - 文本增量\n                                                                    let delta_ev = json!({\n                                                                        \"type\": \"response.output_text.delta\",\n                                                                        \"item_id\": &item_id,\n                                                                        \"output_index\": 0,\n                                                                        \"content_index\": 0,\n                                                                        \"delta\": text\n                                                                    });\n                                                                    yield Ok::<Bytes, String>(Bytes::from(format!(\"data: {}\\n\\n\", serde_json::to_string(&delta_ev).unwrap())));\n                                                                }\n                                                            }\n                                                        }\n                                                        if let Some(sig) = part.get(\"thoughtSignature\").or(part.get(\"thought_signature\")).and_then(|s| s.as_str()) {\n                                                            store_thought_signature(sig, &session_id, message_count);\n                                                        }\n                                                        if let Some(func_call) = part.get(\"functionCall\") {\n                                                            let call_key = serde_json::to_string(func_call).unwrap_or_default();\n                                                            if !emitted_tool_calls.contains(&call_key) {\n                                                                emitted_tool_calls.insert(call_key);\n                                                            }\n                                                        }\n                                                    }\n\n                                                }\n\n                                                // 处理 groundingMetadata (搜索引文)\n                                                if let Some(grounding) = candidate.get(\"groundingMetadata\") {\n                                                    let mut grounding_text = String::new();\n                                                    if let Some(queries) = grounding.get(\"webSearchQueries\").and_then(|q| q.as_array()) {\n                                                        let query_list: Vec<&str> = queries.iter().filter_map(|v| v.as_str()).collect();\n                                                        if !query_list.is_empty() {\n                                                            grounding_text.push_str(\"\\n\\n---\\n**🔍 已为您搜索：** \");\n                                                            grounding_text.push_str(&query_list.join(\", \"));\n                                                        }\n                                                    }\n                                                    if let Some(chunks) = grounding.get(\"groundingChunks\").and_then(|c| c.as_array()) {\n                                                        let mut links = Vec::new();\n                                                        for (i, chunk) in chunks.iter().enumerate() {\n                                                            if let Some(web) = chunk.get(\"web\") {\n                                                                let title = web.get(\"title\").and_then(|v| v.as_str()).unwrap_or(\"网页来源\");\n                                                                let uri = web.get(\"uri\").and_then(|v| v.as_str()).unwrap_or(\"#\");\n                                                                links.push(format!(\"[{}] [{}]({})\", i + 1, title, uri));\n                                                            }\n                                                        }\n                                                        if !links.is_empty() {\n                                                            grounding_text.push_str(\"\\n\\n**🌐 来源引文：**\\n\");\n                                                            grounding_text.push_str(&links.join(\"\\n\"));\n                                                        }\n                                                    }\n                                                    if !grounding_text.is_empty() {\n                                                        accumulated_text.push_str(&grounding_text);\n                                                        let delta_ev = json!({\n                                                            \"type\": \"response.output_text.delta\",\n                                                            \"item_id\": &item_id,\n                                                            \"output_index\": 0,\n                                                            \"content_index\": 0,\n                                                            \"delta\": grounding_text\n                                                        });\n                                                        yield Ok::<Bytes, String>(Bytes::from(format!(\"data: {}\\n\\n\", serde_json::to_string(&delta_ev).unwrap())));\n                                                    }\n                                                }\n                                            }\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                        Some(Err(_)) => break,\n                        None => break,\n                    }\n                }\n                _ = heartbeat_interval.tick() => { yield Ok::<Bytes, String>(Bytes::from(\": ping\\n\\n\")); }\n            }\n        }\n\n        // 5. response.output_text.done - 文本完成\n        let text_done = json!({\n            \"type\": \"response.output_text.done\",\n            \"item_id\": &item_id,\n            \"output_index\": 0,\n            \"content_index\": 0,\n            \"text\": &accumulated_text\n        });\n        yield Ok::<Bytes, String>(Bytes::from(format!(\"data: {}\\n\\n\", serde_json::to_string(&text_done).unwrap())));\n\n        // 6. response.content_part.done\n        let content_part_done = json!({\n            \"type\": \"response.content_part.done\",\n            \"item_id\": &item_id,\n            \"output_index\": 0,\n            \"content_index\": 0,\n            \"part\": {\n                \"type\": \"output_text\",\n                \"text\": &accumulated_text\n            }\n        });\n        yield Ok::<Bytes, String>(Bytes::from(format!(\"data: {}\\n\\n\", serde_json::to_string(&content_part_done).unwrap())));\n\n        // 7. response.output_item.done\n        let output_item_done = json!({\n            \"type\": \"response.output_item.done\",\n            \"output_index\": 0,\n            \"item\": {\n                \"id\": &item_id,\n                \"type\": \"message\",\n                \"role\": \"assistant\",\n                \"status\": \"completed\",\n                \"content\": [{\n                    \"type\": \"output_text\",\n                    \"text\": &accumulated_text\n                }]\n            }\n        });\n        yield Ok::<Bytes, String>(Bytes::from(format!(\"data: {}\\n\\n\", serde_json::to_string(&output_item_done).unwrap())));\n\n        // 8. response.completed\n        let completed_ev = json!({\n            \"type\": \"response.completed\",\n            \"response\": {\n                \"id\": &response_id,\n                \"object\": \"response\",\n                \"status\": \"completed\",\n                \"output\": [{\n                    \"id\": &item_id,\n                    \"type\": \"message\",\n                    \"role\": \"assistant\",\n                    \"content\": [{\n                        \"type\": \"output_text\",\n                        \"text\": &accumulated_text\n                    }]\n                }]\n            }\n        });\n        yield Ok::<Bytes, String>(Bytes::from(format!(\"data: {}\\n\\n\", serde_json::to_string(&completed_ev).unwrap())));\n    };\n    Box::pin(stream)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use futures::stream;\n    use serde_json::json;\n\n    #[tokio::test]\n    async fn test_openai_streaming_usage_only_at_end() {\n        // Chunk 1: Partial content, no usage\n        let chunk1_json = json!({\n            \"candidates\": [{\n                \"content\": {\n                    \"parts\": [{ \"text\": \"Hello\" }]\n                }\n            }]\n        });\n        \n        // Chunk 2: Finish reason + Usage metadata\n        let chunk2_json = json!({\n            \"candidates\": [{\n                \"finishReason\": \"STOP\",\n                \"content\": {\n                    \"parts\": [{ \"text\": \"\" }]\n                }\n            }],\n            \"usageMetadata\": {\n                \"promptTokenCount\": 5,\n                \"candidatesTokenCount\": 2,\n                \"totalTokenCount\": 7\n            }\n        });\n\n        // Use a helper to create the stream items compatible with the required signature\n        let items: Vec<Result<Bytes, reqwest::Error>> = vec![\n            Ok(Bytes::from(format!(\"data: {}\\n\\n\", chunk1_json))),\n            Ok(Bytes::from(format!(\"data: {}\\n\\n\", chunk2_json))),\n        ];\n\n        let gemini_stream = Box::pin(stream::iter(items));\n\n        let mut openai_stream = create_openai_sse_stream(\n            gemini_stream,\n            \"gemini-1.5-flash\".to_string(),\n            \"test-session\".to_string(),\n            0\n        );\n\n        let mut chunks = Vec::new();\n        while let Some(result) = openai_stream.next().await {\n            if let Ok(bytes) = result {\n                let s = String::from_utf8_lossy(&bytes).to_string();\n                for line in s.lines() {\n                    if line.starts_with(\"data: \") && !line.contains(\"[DONE]\") {\n                        chunks.push(line.to_string());\n                    }\n                }\n            }\n        }\n\n        let mut found_usage = false;\n        let mut found_finish = false;\n\n        for (i, chunk_str) in chunks.iter().enumerate() {\n            let json_str = chunk_str.trim_start_matches(\"data: \").trim();\n            let json: Value = serde_json::from_str(json_str).unwrap();\n\n            if i < chunks.len() - 1 {\n                assert!(json.get(\"usage\").is_none(), \"Usage should not be in intermediate chunks. Found in chunk {}\", i);\n            } else {\n                if let Some(usage) = json.get(\"usage\") {\n                    found_usage = true;\n                    assert_eq!(usage[\"prompt_tokens\"], 5);\n                    assert_eq!(usage[\"completion_tokens\"], 2);\n                    assert_eq!(usage[\"total_tokens\"], 7);\n                }\n                 if let Some(choices) = json.get(\"choices\") {\n                    if let Some(choice) = choices.get(0) {\n                        if let Some(finish_reason) = choice.get(\"finish_reason\") {\n                             if finish_reason.as_str() == Some(\"stop\") {\n                                 found_finish = true;\n                             }\n                        }\n                    }\n                }\n            }\n        }\n        assert!(found_usage, \"Usage should be found in the last chunk\");\n        assert!(found_finish, \"Finish reason should be strictly 'stop'\");\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/openai/thinking_recovery.rs",
    "content": "use serde_json::{json, Value};\n\n/// 剥离所有标记为思维块的内容 (thought: true)\npub fn strip_all_thinking_blocks(contents: Vec<Value>) -> Vec<Value> {\n    contents\n        .into_iter()\n        .map(|mut content| {\n            if let Some(parts) = content.get_mut(\"parts\").and_then(|v| v.as_array_mut()) {\n                parts.retain(|part| {\n                    !part\n                        .get(\"thought\")\n                        .and_then(|v| v.as_bool())\n                        .unwrap_or(false)\n                });\n            }\n            content\n        })\n        .filter(|msg| !msg[\"parts\"].as_array().map(|a| a.is_empty()).unwrap_or(true))\n        .collect()\n}\n\n/// 针对思维模型关闭工具循环\n/// 先剥离思考块，然后注入合成的 Model 确认和 User 继续指令\n#[allow(dead_code)]\npub fn close_tool_loop_for_thinking(contents: Vec<Value>) -> Vec<Value> {\n    let mut stripped = strip_all_thinking_blocks(contents);\n    \n    // 如果没有内容了，返回空\n    if stripped.is_empty() {\n        return stripped;\n    }\n\n    // 合成模型消息：工具执行完成\n    stripped.push(json!({\n        \"role\": \"model\",\n        \"parts\": [{\"text\": \"[Tool execution completed.]\"}]\n    }));\n\n    // 合成用户消息：提示继续\n    stripped.push(json!({\n        \"role\": \"user\",\n        \"parts\": [{\"text\": \"[Continue]\"}]\n    }));\n\n    stripped\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/signature_store.rs",
    "content": "// Global thought_signature storage shared by all endpoints\n// Used to capture and replay signatures for Gemini 3+ function calls when clients don't pass them back.\n\nuse std::sync::{Mutex, OnceLock};\n\nstatic GLOBAL_THOUGHT_SIG: OnceLock<Mutex<Option<String>>> = OnceLock::new();\n\nfn get_thought_sig_storage() -> &'static Mutex<Option<String>> {\n    GLOBAL_THOUGHT_SIG.get_or_init(|| Mutex::new(None))\n}\n\n/// Store thought_signature to global storage.\n/// Only stores if the new signature is longer than the existing one,\n/// to avoid short/partial signatures overwriting valid ones.\n/// \n/// DEPRECATED: Use SignatureCache::cache_session_signature instead for session-isolated storage.\n#[allow(dead_code)] // Deprecated, kept for backward compatibility\npub fn store_thought_signature(sig: &str) {\n    if let Ok(mut guard) = get_thought_sig_storage().lock() {\n        let should_store = match &*guard {\n            None => true,\n            Some(existing) => sig.len() > existing.len(),\n        };\n\n        if should_store {\n            tracing::debug!(\n                \"[ThoughtSig] Storing new signature (length: {}, replacing old length: {:?})\",\n                sig.len(),\n                guard.as_ref().map(|s| s.len())\n            );\n            *guard = Some(sig.to_string());\n        } else {\n            tracing::debug!(\n                \"[ThoughtSig] Skipping shorter signature (new length: {}, existing length: {})\",\n                sig.len(),\n                guard.as_ref().map(|s| s.len()).unwrap_or(0)\n            );\n        }\n    }\n}\n\n/// Get the stored thought_signature without clearing it.\npub fn get_thought_signature() -> Option<String> {\n    if let Ok(guard) = get_thought_sig_storage().lock() {\n        guard.clone()\n    } else {\n        None\n    }\n}\n\n/// Get and clear the stored thought_signature.\n#[allow(dead_code)]\npub fn take_thought_signature() -> Option<String> {\n    if let Ok(mut guard) = get_thought_sig_storage().lock() {\n        guard.take()\n    } else {\n        None\n    }\n}\n\n/// Clear the stored thought_signature.\n#[allow(dead_code)]\npub fn clear_thought_signature() {\n    if let Ok(mut guard) = get_thought_sig_storage().lock() {\n        *guard = None;\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_signature_storage() {\n        // Clear any existing state\n        clear_thought_signature();\n\n        // Should be empty initially\n        assert!(get_thought_signature().is_none());\n\n        // Store a signature\n        store_thought_signature(\"test_signature_1234\");\n        assert_eq!(\n            get_thought_signature(),\n            Some(\"test_signature_1234\".to_string())\n        );\n\n        // Shorter signature should NOT overwrite\n        store_thought_signature(\"short\");\n        assert_eq!(\n            get_thought_signature(),\n            Some(\"test_signature_1234\".to_string())\n        );\n\n        // Longer signature SHOULD overwrite\n        store_thought_signature(\"test_signature_1234_longer_version\");\n        assert_eq!(\n            get_thought_signature(),\n            Some(\"test_signature_1234_longer_version\".to_string())\n        );\n\n        // Take should clear\n        let taken = take_thought_signature();\n        assert_eq!(\n            taken,\n            Some(\"test_signature_1234_longer_version\".to_string())\n        );\n        assert!(get_thought_signature().is_none());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mappers/tool_result_compressor.rs",
    "content": "//! 工具结果输出压缩模块\n//! \n//! 提供智能压缩功能:\n//! - 浏览器快照压缩 (头+尾保留)\n//! - 大文件提示压缩 (提取关键信息)\n//! - 通用截断 (200,000 字符限制)\n\nuse regex::Regex;\nuse serde_json::Value;\nuse tracing::{debug, info};\n\n/// 最大工具结果字符数 (约 20 万,防止 prompt 超长)\nconst MAX_TOOL_RESULT_CHARS: usize = 200_000;\n\n/// 浏览器快照检测阈值\nconst SNAPSHOT_DETECTION_THRESHOLD: usize = 20_000;\n\n/// 浏览器快照压缩后的最大字符数\nconst SNAPSHOT_MAX_CHARS: usize = 16_000;\n\n/// 浏览器快照头部保留比例\nconst SNAPSHOT_HEAD_RATIO: f64 = 0.7;\n\n/// 浏览器快照尾部保留比例\n#[allow(dead_code)]\nconst SNAPSHOT_TAIL_RATIO: f64 = 0.3;\n\n/// 压缩工具结果文本\n/// \n/// 根据内容类型自动选择最佳压缩策略:\n/// 1. 大文件提示 → 提取关键信息\n/// 2. 浏览器快照 → 头+尾保留\n/// 3. 其他 → 简单截断\npub fn compact_tool_result_text(text: &str, max_chars: usize) -> String {\n    if text.is_empty() || text.len() <= max_chars {\n        return text.to_string();\n    }\n    \n    // [NEW] 针对可能的 HTML 内容进行深度预处理\n    let cleaned_text = if text.contains(\"<html\") || text.contains(\"<body\") || text.contains(\"<!DOCTYPE\") {\n        let cleaned = deep_clean_html(text);\n        debug!(\"[ToolCompressor] Deep cleaned HTML, reduced {} -> {} chars\", text.len(), cleaned.len());\n        cleaned\n    } else {\n        text.to_string()\n    };\n\n    if cleaned_text.len() <= max_chars {\n        return cleaned_text;\n    }\n\n    // 1. 检测大文件提示模式\n    if let Some(compacted) = compact_saved_output_notice(&cleaned_text, max_chars) {\n        debug!(\"[ToolCompressor] Detected saved output notice, compacted to {} chars\", compacted.len());\n        return compacted;\n    }\n    \n    // 2. 检测浏览器快照模式\n    if cleaned_text.len() > SNAPSHOT_DETECTION_THRESHOLD {\n        if let Some(compacted) = compact_browser_snapshot(&cleaned_text, max_chars) {\n            debug!(\"[ToolCompressor] Detected browser snapshot, compacted to {} chars\", compacted.len());\n            return compacted;\n        }\n    }\n    \n    // 3. 结构化截断\n    debug!(\"[ToolCompressor] Using structured truncation for {} chars\", cleaned_text.len());\n    truncate_text_safe(&cleaned_text, max_chars)\n}\n\n/// 压缩\"输出已保存到文件\"类型的提示\n/// \n/// 检测模式: \"result (N characters) exceeds maximum allowed tokens. Output saved to <path>\"\n/// 策略: 提取关键信息(文件路径、字符数、格式说明)\n/// \n/// 根据提示内容类型自动提取关键信息\nfn compact_saved_output_notice(text: &str, max_chars: usize) -> Option<String> {\n    // 正则匹配: result (N characters) exceeds maximum allowed tokens. Output saved to <path>\n    let re = Regex::new(\n        r\"(?i)result\\s*\\(\\s*(?P<count>[\\d,]+)\\s*characters\\s*\\)\\s*exceeds\\s+maximum\\s+allowed\\s+tokens\\.\\s*Output\\s+(?:has\\s+been\\s+)?saved\\s+to\\s+(?P<path>[^\\r\\n]+)\"\n    ).ok()?;\n    \n    let caps = re.captures(text)?;\n    let count = caps.name(\"count\")?.as_str();\n    let raw_path = caps.name(\"path\")?.as_str();\n    \n    // 清理文件路径 (移除尾部的括号、引号、句号)\n    let file_path = raw_path\n        .trim()\n        .trim_end_matches(&[')', ']', '\"', '\\'', '.'][..])\n        .trim();\n    \n    // 提取关键行\n    let lines: Vec<&str> = text.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();\n    \n    // 查找通知行\n    let notice_line = lines.iter()\n        .find(|l| l.to_lowercase().contains(\"exceeds maximum allowed tokens\") && l.to_lowercase().contains(\"saved to\"))\n        .map(|s| s.to_string())\n        .unwrap_or_else(|| format!(\"result ({} characters) exceeds maximum allowed tokens. Output has been saved to {}\", count, file_path));\n    \n    // 查找格式说明行\n    let format_line = lines.iter()\n        .find(|l| l.starts_with(\"Format:\") || l.contains(\"JSON array with schema\") || l.to_lowercase().starts_with(\"schema:\"))\n        .map(|s| s.to_string());\n    \n    // 构建压缩后的输出\n    let mut compact_lines = vec![notice_line];\n    if let Some(fmt) = format_line {\n        if !compact_lines.contains(&fmt) {\n            compact_lines.push(fmt);\n        }\n    }\n    compact_lines.push(format!(\n        \"[tool_result omitted to reduce prompt size; read file locally if needed: {}]\",\n        file_path\n    ));\n    \n    let result = compact_lines.join(\"\\n\");\n    Some(truncate_text_safe(&result, max_chars))\n}\n\n/// 压缩浏览器快照 (头+尾保留策略)\n/// \n/// 检测: \"page snapshot\" 或 \"页面快照\" 或大量 \"ref=\" 引用\n/// 策略: 保留头部 70% + 尾部 30%,中间省略\n/// \n/// 使用头+尾保留策略压缩较长的页面快照数据\nfn compact_browser_snapshot(text: &str, max_chars: usize) -> Option<String> {\n    // 检测是否是浏览器快照\n    let is_snapshot = text.to_lowercase().contains(\"page snapshot\")\n        || text.contains(\"页面快照\")\n        || text.matches(\"ref=\").count() > 30\n        || text.matches(\"[ref=\").count() > 30;\n    \n    if !is_snapshot {\n        return None;\n    }\n    \n    let desired_max = max_chars.min(SNAPSHOT_MAX_CHARS);\n    if desired_max < 2000 || text.len() <= desired_max {\n        return None;\n    }\n    \n    let meta = format!(\"[page snapshot summarized to reduce prompt size; original {} chars]\", text.len());\n    let overhead = meta.len() + 200;\n    let budget = desired_max.saturating_sub(overhead);\n    \n    if budget < 1000 {\n        return None;\n    }\n    \n    // 计算头部和尾部长度\n    let head_len = (budget as f64 * SNAPSHOT_HEAD_RATIO).floor() as usize;\n    let head_len = head_len.min(10_000).max(500);\n    let tail_len = budget.saturating_sub(head_len).min(3_000);\n    \n    let head = &text[..head_len.min(text.len())];\n    let tail = if tail_len > 0 && text.len() > head_len {\n        let start = text.len().saturating_sub(tail_len);\n        &text[start..]\n    } else {\n        \"\"\n    };\n    \n    let omitted = text.len().saturating_sub(head_len).saturating_sub(tail_len);\n    \n    let summarized = if tail.is_empty() {\n        format!(\"{}\\n---[HEAD]---\\n{}\\n---[...omitted {} chars]---\", meta, head, omitted)\n    } else {\n        format!(\n            \"{}\\n---[HEAD]---\\n{}\\n---[...omitted {} chars]---\\n---[TAIL]---\\n{}\",\n            meta, head, omitted, tail\n        )\n    };\n    \n    Some(truncate_text_safe(&summarized, max_chars))\n}\n\n/// 安全的文本截断 (尽量不在标签中间截断)\nfn truncate_text_safe(text: &str, max_chars: usize) -> String {\n    if text.len() <= max_chars {\n        return text.to_string();\n    }\n    \n    // 尝试寻找一个安全的截断点 (不在 < 和 > 之间)\n    let mut split_pos = max_chars;\n    \n    // 向前查找是否有未闭合的标签开始符\n    let sub = &text[..max_chars];\n    if let Some(last_open) = sub.rfind('<') {\n        if let Some(last_close) = sub.rfind('>') {\n            if last_open > last_close {\n                // 截断点在标签中间，回退到标签开始前\n                split_pos = last_open;\n            }\n        } else {\n            // 只有开始没有结束，回退到标签开始前\n            split_pos = last_open;\n        }\n    }\n    \n    // 也要避免在 JSON 大括号中间截断\n    if let Some(last_open_brace) = sub.rfind('{') {\n        if let Some(last_close_brace) = sub.rfind('}') {\n            if last_open_brace > last_close_brace {\n                // 可能在 JSON 中间，如果距离截断点较近，尝试回退\n                if max_chars - last_open_brace < 100 {\n                    split_pos = split_pos.min(last_open_brace);\n                }\n            }\n        }\n    }\n\n    let truncated = &text[..split_pos];\n    let omitted = text.len() - split_pos;\n    format!(\"{}\\n...[truncated {} chars]\", truncated, omitted)\n}\n\n/// 深度清理 HTML (移除 style, script, base64 等)\nfn deep_clean_html(html: &str) -> String {\n    let mut result = html.to_string();\n    \n    // 1. 移除 <style>...</style> 及其内容\n    if let Ok(re) = Regex::new(r\"(?is)<style\\b[^>]*>.*?</style>\") {\n        result = re.replace_all(&result, \"[style omitted]\").to_string();\n    }\n    \n    // 2. 移除 <script>...</script> 及其内容\n    if let Ok(re) = Regex::new(r\"(?is)<script\\b[^>]*>.*?</script>\") {\n        result = re.replace_all(&result, \"[script omitted]\").to_string();\n    }\n    \n    // 3. 移除 inline Base64 数据 (如 src=\"data:image/png;base64,...\")\n    if let Ok(re) = Regex::new(r#\"(?i)data:[^;/]+/[^;]+;base64,[A-Za-z0-9+/=]+\"#) {\n        result = re.replace_all(&result, \"[base64 omitted]\").to_string();\n    }\n\n    // 4. 移除冗余的空白字符\n    if let Ok(re) = Regex::new(r\"\\n\\s*\\n\") {\n        result = re.replace_all(&result, \"\\n\").to_string();\n    }\n    \n    result\n}\n\n/// 清理工具结果 content blocks\n/// \n/// 处理逻辑:\n/// 1. 移除 base64 图片 (避免体积过大)\n/// 2. 压缩文本内容 (使用智能压缩策略)\n/// 3. 限制总字符数 (默认 200,000)\n/// \n/// 清理并截断工具调用结果内容块\npub fn sanitize_tool_result_blocks(blocks: &mut Vec<Value>) {\n    let mut used_chars = 0;\n    let mut cleaned_blocks = Vec::new();\n\n    if !blocks.is_empty() {\n        info!(\n            \"[ToolCompressor] Processing {} blocks for truncation (MAX: {} chars)\",\n            blocks.len(),\n            MAX_TOOL_RESULT_CHARS\n        );\n    }\n    \n    for block in blocks.iter() {\n        // 压缩文本内容\n        if let Some(text) = block.get(\"text\").and_then(|v| v.as_str()) {\n            let remaining = MAX_TOOL_RESULT_CHARS.saturating_sub(used_chars);\n            if remaining == 0 {\n                debug!(\"[ToolCompressor] Reached character limit, stopping\");\n                break;\n            }\n            \n            let compacted = compact_tool_result_text(text, remaining);\n            let mut new_block = block.clone();\n            new_block[\"text\"] = Value::String(compacted.clone());\n            cleaned_blocks.push(new_block);\n            used_chars += compacted.len();\n            \n            debug!(\n                \"[ToolCompressor] Compacted text block: {} → {} chars\",\n                text.len(),\n                compacted.len()\n            );\n        } else {\n            // 保留其他类型的块 (例如图片), 但受总长度块数限制, 此处不单独截断\n            cleaned_blocks.push(block.clone());\n            used_chars += 100; // 估算非文本块大小\n        }\n        \n        if used_chars >= MAX_TOOL_RESULT_CHARS {\n            break;\n        }\n    }\n    \n    info!(\n        \"[ToolCompressor] Sanitization complete: {} → {} blocks, {} chars used\",\n        blocks.len(),\n        cleaned_blocks.len(),\n        used_chars\n    );\n    \n    *blocks = cleaned_blocks;\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_truncate_text() {\n        let text = \"a\".repeat(300_000);\n        let result = truncate_text_safe(&text, 200_000);\n        assert!(result.len() < 210_000); // 包含截断提示\n        assert!(result.contains(\"[truncated\"));\n        assert!(result.contains(\"100000 chars]\"));\n    }\n\n    #[test]\n    fn test_truncate_text_no_truncation() {\n        let text = \"short text\";\n        let result = truncate_text_safe(text, 1000);\n        assert_eq!(result, text);\n    }\n\n    #[test]\n    fn test_compact_browser_snapshot() {\n        let snapshot = format!(\"page snapshot: {}\", \"ref=abc \".repeat(10_000));\n        let result = compact_tool_result_text(&snapshot, 16_000);\n        \n        assert!(result.len() <= 16_500); // 允许一些 overhead\n        assert!(result.contains(\"[HEAD]\"));\n        assert!(result.contains(\"[TAIL]\"));\n        assert!(result.contains(\"page snapshot summarized\"));\n    }\n\n    #[test]\n    fn test_compact_saved_output_notice() {\n        let text = r#\"result (150000 characters) exceeds maximum allowed tokens. Output has been saved to /tmp/output.txt\nFormat: JSON array with schema\nPlease read the file locally.\"#;\n        \n        let result = compact_tool_result_text(text, 500);\n        println!(\"Result: {}\", result);\n        assert!(result.contains(\"150000 characters\") || result.contains(\"150,000 characters\"));\n        assert!(result.contains(\"/tmp/output.txt\"));\n        assert!(result.contains(\"[tool_result omitted\") || result.len() <= 500);\n    }\n\n    #[test]\n    fn test_sanitize_tool_result_blocks() {\n        let mut blocks = vec![\n            serde_json::json!({\n                \"type\": \"text\",\n                \"text\": \"a\".repeat(100_000)\n            }),\n            serde_json::json!({\n                \"type\": \"text\",\n                \"text\": \"b\".repeat(150_000)\n            }),\n            serde_json::json!({\n                \"type\": \"image\",\n                \"source\": {\n                    \"type\": \"base64\",\n                    \"data\": \"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n                }\n            }),\n            serde_json::json!({\n                \"type\": \"text\",\n                \"text\": \"some text\"\n            }),\n        ];\n        \n        // 确认工具结果不再剔除图片\n        sanitize_tool_result_blocks(&mut blocks);\n        assert_eq!(blocks.len(), 4);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/middleware/auth.rs",
    "content": "// API Key 认证中间件\nuse axum::{\n    extract::State,\n    extract::Request,\n    http::{header, StatusCode},\n    middleware::Next,\n    response::Response,\n};\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\n\nuse crate::proxy::{ProxyAuthMode, ProxySecurityConfig};\n\n/// API Key 认证中间件 (代理接口使用，遵循 auth_mode)\npub async fn auth_middleware(\n    state: State<Arc<RwLock<ProxySecurityConfig>>>,\n    request: Request,\n    next: Next,\n) -> Result<Response, StatusCode> {\n    auth_middleware_internal(state, request, next, false).await\n}\n\n/// 管理接口认证中间件 (管理接口使用，强制严格鉴权)\npub async fn admin_auth_middleware(\n    state: State<Arc<RwLock<ProxySecurityConfig>>>,\n    request: Request,\n    next: Next,\n) -> Result<Response, StatusCode> {\n    auth_middleware_internal(state, request, next, true).await\n}\n\n/// 内部认证逻辑\nasync fn auth_middleware_internal(\n    State(security): State<Arc<RwLock<ProxySecurityConfig>>>,\n    request: Request,\n    next: Next,\n    force_strict: bool,\n) -> Result<Response, StatusCode> {\n    let method = request.method().clone();\n    let path = request.uri().path().to_string();\n\n    // 过滤心跳和健康检查请求,避免日志噪音\n    let is_health_check = path == \"/healthz\" || path == \"/api/health\" || path == \"/health\";\n    let is_internal_endpoint = path.starts_with(\"/internal/\");\n    if !path.contains(\"event_logging\") && !is_health_check {\n        tracing::info!(\"Request: {} {}\", method, path);\n    } else {\n        tracing::trace!(\"Heartbeat/Health: {} {}\", method, path);\n    }\n\n    // Allow CORS preflight regardless of auth policy.\n    if method == axum::http::Method::OPTIONS {\n        return Ok(next.run(request).await);\n    }\n\n    let security = security.read().await.clone();\n    let effective_mode = security.effective_auth_mode();\n\n    // 权限检查逻辑\n    if !force_strict {\n        // AI 代理接口 (v1/chat/completions 等)\n        if matches!(effective_mode, ProxyAuthMode::Off) {\n            // [FIX] 即使 auth_mode=Off，也需要尝试识别 User Token 以记录使用情况\n            // 先检查是否携带了 User Token\n            let api_key = request\n                .headers()\n                .get(header::AUTHORIZATION)\n                .and_then(|h| h.to_str().ok())\n                .and_then(|s| s.strip_prefix(\"Bearer \").or(Some(s)))\n                .or_else(|| {\n                    request\n                        .headers()\n                        .get(\"x-api-key\")\n                        .and_then(|h| h.to_str().ok())\n                });\n            \n            if let Some(token) = api_key {\n                // 尝试验证是否为 User Token（不阻止请求，只记录）\n                if let Ok(Some(user_token)) = crate::modules::user_token_db::get_token_by_value(token) {\n                    let identity = UserTokenIdentity {\n                        token_id: user_token.id,\n                        token: user_token.token,\n                        username: user_token.username,\n                    };\n                    // 注入 identity 到请求\n                    let (mut parts, body) = request.into_parts();\n                    parts.extensions.insert(identity);\n                    let request = Request::from_parts(parts, body);\n                    return Ok(next.run(request).await);\n                }\n            }\n            \n            return Ok(next.run(request).await);\n        }\n\n        if matches!(effective_mode, ProxyAuthMode::AllExceptHealth) && is_health_check {\n            return Ok(next.run(request).await);\n        }\n\n        // 内部端点 (/internal/*) 豁免鉴权 - 用于 warmup 等内部功能\n        if is_internal_endpoint {\n            tracing::debug!(\"Internal endpoint bypassed auth: {}\", path);\n            return Ok(next.run(request).await);\n        }\n    } else {\n        // 管理接口 (/api/*)\n        // 1. 如果全局鉴权关闭，则管理接口也放行 (除非是强制局域网模式)\n        if matches!(effective_mode, ProxyAuthMode::Off) {\n            return Ok(next.run(request).await);\n        }\n\n        // 2. 健康检查在所有模式下对管理接口放行\n        if is_health_check {\n            return Ok(next.run(request).await);\n        }\n    }\n    \n    // 从 header 中提取 API key\n    let api_key = request\n        .headers()\n        .get(header::AUTHORIZATION)\n        .and_then(|h| h.to_str().ok())\n        .and_then(|s| s.strip_prefix(\"Bearer \").or(Some(s)))\n        .or_else(|| {\n            request\n                .headers()\n                .get(\"x-api-key\")\n                .and_then(|h| h.to_str().ok())\n        })\n        .or_else(|| {\n            request\n                .headers()\n                .get(\"x-goog-api-key\")\n                .and_then(|h| h.to_str().ok())\n        });\n\n    if security.api_key.is_empty() && (security.admin_password.is_none() || security.admin_password.as_ref().unwrap().is_empty()) {\n        if force_strict {\n             tracing::error!(\"Admin auth is required but both api_key and admin_password are empty; denying request\");\n             return Err(StatusCode::UNAUTHORIZED);\n        }\n        tracing::error!(\"Proxy auth is enabled but api_key is empty; denying request\");\n        return Err(StatusCode::UNAUTHORIZED);\n    }\n\n    // 认证逻辑\n    let authorized = if force_strict {\n        // 管理接口：优先使用独立的 admin_password，如果没有则回退使用 api_key\n        match &security.admin_password {\n            Some(pwd) if !pwd.is_empty() => {\n                api_key.map(|k| k == pwd).unwrap_or(false)\n            }\n            _ => {\n                // 回退使用 api_key\n                api_key.map(|k| k == security.api_key).unwrap_or(false)\n            }\n        }\n    } else {\n        // AI 代理接口：仅允许使用 api_key\n        api_key.map(|k| k == security.api_key).unwrap_or(false)\n    };\n\n    if authorized {\n        Ok(next.run(request).await)\n    } else if !force_strict && api_key.is_some() {\n        // 尝试验证 UserToken\n        let token = api_key.unwrap();\n        \n        // 提取 IP (复用逻辑)\n        let client_ip = request\n            .headers()\n            .get(\"x-forwarded-for\")\n            .and_then(|v| v.to_str().ok())\n            .map(|s| s.split(',').next().unwrap_or(s).trim().to_string())\n            .or_else(|| {\n                request\n                    .headers()\n                    .get(\"x-real-ip\")\n                    .and_then(|v| v.to_str().ok())\n                    .map(|s| s.to_string())\n            })\n            .unwrap_or_else(|| \"127.0.0.1\".to_string()); // Default fallback\n\n        // 验证 Token\n        match crate::modules::user_token_db::validate_token(token, &client_ip) {\n            Ok((true, _)) => {\n                // Token 有效，查询信息以便传递\n                if let Ok(Some(user_token)) = crate::modules::user_token_db::get_token_by_value(token) {\n                     let identity = UserTokenIdentity {\n                        token_id: user_token.id,\n                        token: user_token.token,\n                        username: user_token.username,\n                    };\n                    \n                    // [FIX] 将身份信息注入到请求 extensions 中，而不是响应\n                    // 这样 monitor_middleware 在处理请求时就能获取到 identity\n                    // 因为中间件执行顺序：auth (外层) -> monitor (内层) -> handler\n                    // 响应返回时：handler -> monitor -> auth\n                    // 如果注入到 response，monitor 执行时 identity 还不存在\n                    let (mut parts, body) = request.into_parts();\n                    parts.extensions.insert(identity);\n                    let request = Request::from_parts(parts, body);\n                    \n                    // 执行请求\n                    let response = next.run(request).await;\n                    \n                    Ok(response)\n                } else {\n                    Err(StatusCode::UNAUTHORIZED)\n                }\n            }\n            Ok((false, reason)) => {\n                let reason_str = reason.unwrap_or_else(|| \"Access denied\".to_string());\n                tracing::warn!(\"UserToken rejected: {}\", reason_str);\n                let body = serde_json::json!({\n                    \"error\": {\n                        \"message\": reason_str,\n                        \"type\": \"token_rejected\",\n                        \"code\": \"token_rejected\"\n                    }\n                });\n                let response = axum::response::Response::builder()\n                    .status(StatusCode::FORBIDDEN)\n                    .header(\"Content-Type\", \"application/json\")\n                    .body(axum::body::Body::from(serde_json::to_string(&body).unwrap()))\n                    .unwrap();\n                Ok(response)\n            }\n            Err(e) => {\n                tracing::error!(\"UserToken validation error: {}\", e);\n                Err(StatusCode::INTERNAL_SERVER_ERROR)\n            }\n        }\n    } else {\n        Err(StatusCode::UNAUTHORIZED)\n    }\n}\n\n/// 用户令牌身份信息 (传递给 Monitor 使用)\n#[derive(Clone, Debug)]\npub struct UserTokenIdentity {\n    pub token_id: String,\n    #[allow(dead_code)] // 保留原始 token 便于审计/调试\n    pub token: String,\n    pub username: String,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::proxy::ProxyAuthMode;\n\n    #[tokio::test]\n    async fn test_admin_auth_with_password() {\n        let security = Arc::new(RwLock::new(ProxySecurityConfig {\n            auth_mode: ProxyAuthMode::Strict,\n            api_key: \"sk-api\".to_string(),\n            admin_password: Some(\"admin123\".to_string()),\n            allow_lan_access: true,\n            port: 8045,\n            security_monitor: crate::proxy::config::SecurityMonitorConfig::default(),\n        }));\n\n        // 模拟请求 - 管理接口使用正确的管理密码\n        let req = Request::builder()\n            .header(\"Authorization\", \"Bearer admin123\")\n            .uri(\"/admin/stats\")\n            .body(axum::body::Body::empty())\n            .unwrap();\n        \n        // 此测试由于涉及 Next 中间件调用比较复杂,主要验证核心逻辑\n        // 我们在 auth_middleware_internal 基础上做了逻辑校验即可\n    }\n\n    #[test]\n    fn test_auth_placeholder() {\n        assert!(true);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/middleware/cors.rs",
    "content": "// CORS 中间件\nuse tower_http::cors::{CorsLayer, Any};\nuse axum::http::Method;\n\n/// 创建 CORS layer\npub fn cors_layer() -> CorsLayer {\n    CorsLayer::new()\n        .allow_origin(Any)\n        .allow_methods([\n            Method::GET,\n            Method::POST,\n            Method::PUT,\n            Method::DELETE,\n            Method::HEAD,\n            Method::OPTIONS,\n            Method::PATCH,\n        ])\n        .allow_headers(Any)\n        .allow_credentials(false)\n        .max_age(std::time::Duration::from_secs(3600))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_cors_layer_creation() {\n        let _layer = cors_layer();\n        // Layer 创建成功\n        assert!(true);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/middleware/ip_filter.rs",
    "content": "use axum::{\n    extract::{Request, State},\n    middleware::Next,\n    response::{IntoResponse, Response},\n    http::StatusCode,\n};\nuse crate::proxy::server::AppState;\nuse crate::modules::security_db;\n\n/// IP 黑白名单过滤中间件\npub async fn ip_filter_middleware(\n    State(state): State<AppState>,\n    request: Request,\n    next: Next,\n) -> Response {\n    // 提取客户端 IP\n    let client_ip = extract_client_ip(&request);\n    \n    if let Some(ip) = &client_ip {\n        // 读取安全配置\n        let security_config = state.security.read().await;\n        \n        // 1. 检查白名单 (如果启用白名单模式,只允许白名单 IP)\n        if security_config.security_monitor.whitelist.enabled {\n            match security_db::is_ip_in_whitelist(ip) {\n                Ok(true) => {\n                    // 在白名单中,直接放行\n                    tracing::debug!(\"[IP Filter] IP {} is in whitelist, allowing\", ip);\n                    return next.run(request).await;\n                }\n                Ok(false) => {\n                    // 不在白名单中,且启用了白名单模式,拒绝访问\n                    tracing::warn!(\"[IP Filter] IP {} not in whitelist, blocking\", ip);\n                    return create_blocked_response(\n                        ip,\n                        \"Access denied. Your IP is not in the whitelist.\",\n                    );\n                }\n                Err(e) => {\n                    tracing::error!(\"[IP Filter] Failed to check whitelist: {}\", e);\n                }\n            }\n        } else {\n            // 白名单优先模式: 如果在白名单中,跳过黑名单检查\n            if security_config.security_monitor.whitelist.whitelist_priority {\n                match security_db::is_ip_in_whitelist(ip) {\n                    Ok(true) => {\n                        tracing::debug!(\"[IP Filter] IP {} is in whitelist (priority mode), skipping blacklist check\", ip);\n                        return next.run(request).await;\n                    }\n                    Ok(false) => {\n                        // 继续检查黑名单\n                    }\n                    Err(e) => {\n                        tracing::error!(\"[IP Filter] Failed to check whitelist: {}\", e);\n                    }\n                }\n            }\n        }\n\n        // 2. 检查黑名单\n        if security_config.security_monitor.blacklist.enabled {\n            match security_db::get_blacklist_entry_for_ip(ip) {\n                Ok(Some(entry)) => {\n                    tracing::warn!(\"[IP Filter] IP {} is in blacklist, blocking\", ip);\n                    \n                    // 构建详细的封禁消息\n                    let reason = entry.reason.as_deref().unwrap_or(\"Malicious activity detected\");\n                    let ban_type = if let Some(expires_at) = entry.expires_at {\n                        let now = chrono::Utc::now().timestamp();\n                        let remaining_seconds = expires_at - now;\n                        \n                        if remaining_seconds > 0 {\n                            let hours = remaining_seconds / 3600;\n                            let minutes = (remaining_seconds % 3600) / 60;\n                            \n                            if hours > 24 {\n                                let days = hours / 24;\n                                format!(\"Temporary ban. Please try again after {} day(s).\", days)\n                            } else if hours > 0 {\n                                format!(\"Temporary ban. Please try again after {} hour(s) and {} minute(s).\", hours, minutes)\n                            } else {\n                                format!(\"Temporary ban. Please try again after {} minute(s).\", minutes)\n                            }\n                        } else {\n                            \"Temporary ban (expired, will be removed soon).\".to_string()\n                        }\n                    } else {\n                        \"Permanent ban.\".to_string()\n                    };\n                    \n                    let detailed_message = format!(\n                        \"Access denied. Reason: {}. {}\",\n                        reason,\n                        ban_type\n                    );\n                    \n                    // 记录被封禁的访问日志\n                    let log = security_db::IpAccessLog {\n                        id: uuid::Uuid::new_v4().to_string(),\n                        client_ip: ip.clone(),\n                        timestamp: chrono::Utc::now().timestamp(),\n                        method: Some(request.method().to_string()),\n                        path: Some(request.uri().to_string()),\n                        user_agent: request\n                            .headers()\n                            .get(\"user-agent\")\n                            .and_then(|v| v.to_str().ok())\n                            .map(|s| s.to_string()),\n                        status: Some(403),\n                        duration: Some(0),\n                        api_key_hash: None,\n                        blocked: true,\n                        block_reason: Some(format!(\"IP in blacklist: {}\", reason)),\n                        username: None,\n                    };\n                    \n                    tokio::spawn(async move {\n                        if let Err(e) = security_db::save_ip_access_log(&log) {\n                            tracing::error!(\"[IP Filter] Failed to save blocked access log: {}\", e);\n                        }\n                    });\n                    \n                    return create_blocked_response(\n                        ip,\n                        &detailed_message,\n                    );\n                }\n                Ok(None) => {\n                    // 不在黑名单中,放行\n                    tracing::debug!(\"[IP Filter] IP {} not in blacklist, allowing\", ip);\n                }\n                Err(e) => {\n                    tracing::error!(\"[IP Filter] Failed to check blacklist: {}\", e);\n                }\n            }\n        }\n    } else {\n        tracing::warn!(\"[IP Filter] Unable to extract client IP from request\");\n    }\n\n    // 放行请求\n    next.run(request).await\n}\n\n/// 从请求中提取客户端 IP\nfn extract_client_ip(request: &Request) -> Option<String> {\n    // 1. 优先从 X-Forwarded-For 提取 (取第一个 IP)\n    request\n        .headers()\n        .get(\"x-forwarded-for\")\n        .and_then(|v| v.to_str().ok())\n        .map(|s| s.split(',').next().unwrap_or(s).trim().to_string())\n        .or_else(|| {\n            // 2. 备选从 X-Real-IP 提取\n            request\n                .headers()\n                .get(\"x-real-ip\")\n                .and_then(|v| v.to_str().ok())\n                .map(|s| s.to_string())\n        })\n        .or_else(|| {\n            // 3. 最后尝试从 ConnectInfo 获取 (TCP 连接 IP)\n            // 这可以解决本地开发/测试时没有代理头导致 IP 获取失败的问题\n            request\n                .extensions()\n                .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()\n                .map(|info| info.0.ip().to_string())\n        })\n}\n\n/// 创建被封禁的响应\nfn create_blocked_response(ip: &str, message: &str) -> Response {\n    let body = serde_json::json!({\n        \"error\": {\n            \"message\": message,\n            \"type\": \"ip_blocked\",\n            \"code\": \"ip_blocked\",\n            \"ip\": ip,\n        }\n    });\n    \n    (\n        StatusCode::FORBIDDEN,\n        [(axum::http::header::CONTENT_TYPE, \"application/json\")],\n        serde_json::to_string(&body).unwrap_or_else(|_| message.to_string()),\n    )\n        .into_response()\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/middleware/logging.rs",
    "content": "// 日志中间件\n// 直接使用 tower_http::trace::TraceLayer::new_for_http() 在路由中\n\n#[cfg(test)]\nmod tests {\n    #[test]\n    fn test_logging_middleware() {\n        // Logging middleware 通过 tower_http::trace::TraceLayer::new_for_http() 直接使用\n        assert!(true);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/middleware/mod.rs",
    "content": "// Middleware 模块 - Axum 中间件\n\npub mod auth;\npub mod cors;\npub mod logging;\npub mod monitor;\npub mod ip_filter;\n\npub mod service_status;\n\npub use cors::cors_layer;\npub use monitor::monitor_middleware;\npub use service_status::service_status_middleware;\npub use auth::{auth_middleware, admin_auth_middleware};\npub use ip_filter::ip_filter_middleware;\n"
  },
  {
    "path": "src-tauri/src/proxy/middleware/monitor.rs",
    "content": "use axum::{\n    extract::{Request, State},\n    middleware::Next,\n    response::Response,\n    body::Body,\n};\nuse std::time::Instant;\nuse crate::proxy::server::AppState;\nuse crate::proxy::monitor::ProxyRequestLog;\nuse serde_json::Value;\nuse crate::proxy::middleware::auth::UserTokenIdentity;\nuse futures::StreamExt;\n\nconst MAX_REQUEST_LOG_SIZE: usize = 100 * 1024 * 1024; // 100MB\nconst MAX_RESPONSE_LOG_SIZE: usize = 100 * 1024 * 1024; // 100MB for image responses\n\n/// Helper function to record User Token usage\nfn record_user_token_usage(\n    user_token_identity: &Option<UserTokenIdentity>,\n    log: &ProxyRequestLog,\n    user_agent: Option<String>,\n) {\n    if let Some(identity) = user_token_identity {\n        let _ = crate::modules::user_token_db::record_token_usage_and_ip(\n            &identity.token_id,\n            log.client_ip.as_deref().unwrap_or(\"127.0.0.1\"),\n            log.model.as_deref().unwrap_or(\"unknown\"),\n            log.input_tokens.unwrap_or(0) as i32,\n            log.output_tokens.unwrap_or(0) as i32,\n            log.status as u16,\n            user_agent,\n        );\n    }\n}\n\npub async fn monitor_middleware(\n    State(state): State<AppState>,\n    request: Request,\n    next: Next,\n) -> Response {\n    let _logging_enabled = state.monitor.is_enabled();\n    \n    let method = request.method().to_string();\n    let uri = request.uri().to_string();\n    \n    if uri.contains(\"event_logging\") || uri.contains(\"/api/\") || uri.starts_with(\"/internal/\") {\n        return next.run(request).await;\n    }\n    \n    let start = Instant::now();\n    \n    // Extract client IP from headers (X-Forwarded-For or X-Real-IP)\n    // IMPORTANT: Extract from Request headers, not Response headers (since we want the client's IP)\n    // Note: We need to do this BEFORE consuming the request body if possible, or extract it from the original request\n    let client_ip = request\n        .headers()\n        .get(\"x-forwarded-for\")\n        .and_then(|v| v.to_str().ok())\n        .map(|s| s.split(',').next().unwrap_or(s).trim().to_string())\n        .or_else(|| {\n            request\n                .headers()\n                .get(\"x-real-ip\")\n                .and_then(|v| v.to_str().ok())\n                .map(|s| s.to_string())\n        });\n        \n    let user_agent = request\n        .headers()\n        .get(\"user-agent\")\n        .and_then(|v| v.to_str().ok())\n        .map(|s| s.to_string());\n\n    let mut model = if uri.contains(\"/v1beta/models/\") {\n        uri.split(\"/v1beta/models/\")\n            .nth(1)\n            .and_then(|s| s.split(':').next())\n            .map(|s| s.to_string())\n    } else {\n        None\n    };\n\n    let request_body_str;\n    \n    // [FIX] 从请求 extensions 提取 UserTokenIdentity (由 Auth 中间件注入)\n    // 必须在处理 request body 之前提取，因为 into_parts() 后需要保留这个值\n    let user_token_identity = request.extensions().get::<UserTokenIdentity>().cloned();\n    \n    let request = if method == \"POST\" {\n        let (parts, body) = request.into_parts();\n        match axum::body::to_bytes(body, MAX_REQUEST_LOG_SIZE).await {\n            Ok(bytes) => {\n                if model.is_none() {\n                    model = serde_json::from_slice::<Value>(&bytes).ok().and_then(|v|\n                        v.get(\"model\").and_then(|m| m.as_str()).map(|s| s.to_string())\n                    );\n                }\n                request_body_str = if let Ok(s) = std::str::from_utf8(&bytes) {\n                    Some(s.to_string())\n                } else {\n                    Some(\"[Binary Request Data]\".to_string())\n                };\n                Request::from_parts(parts, Body::from(bytes))\n            }\n            Err(_) => {\n                request_body_str = None;\n                Request::from_parts(parts, Body::empty())\n            }\n        }\n    } else {\n        request_body_str = None;\n        request\n    };\n    \n    let response = next.run(request).await;\n    \n    // user_token_identity 已在上面从请求 extensions 中提取\n    \n    let duration = start.elapsed().as_millis() as u64;\n    let status = response.status().as_u16();\n    \n    let content_type = response.headers().get(\"content-type\")\n        .and_then(|v| v.to_str().ok())\n        .unwrap_or(\"\")\n        .to_string();\n\n    // Extract account email from X-Account-Email header if present\n    let account_email = response\n        .headers()\n        .get(\"X-Account-Email\")\n        .and_then(|v| v.to_str().ok())\n        .map(|s| s.to_string());\n\n    // Extract mapped model from X-Mapped-Model header if present\n    let mapped_model = response\n        .headers()\n        .get(\"X-Mapped-Model\")\n        .and_then(|v| v.to_str().ok())\n        .map(|s| s.to_string());\n\n    // Determine protocol from URL path\n    let protocol = if uri.contains(\"/v1/messages\") {\n        Some(\"anthropic\".to_string())\n    } else if uri.contains(\"/v1beta/models\") {\n        Some(\"gemini\".to_string())\n    } else if uri.starts_with(\"/v1/\") {\n        Some(\"openai\".to_string())\n    } else {\n        None\n    };\n\n    // Client IP has been extracted at the beginning of the function\n\n    // Extract username from UserTokenIdentity if present\n    let username = user_token_identity.as_ref().map(|identity| identity.username.clone());\n\n    let monitor = state.monitor.clone();\n    let mut log = ProxyRequestLog {\n        id: uuid::Uuid::new_v4().to_string(),\n        timestamp: chrono::Utc::now().timestamp_millis(),\n        method,\n        url: uri,\n        status,\n        duration,\n        model,\n        mapped_model,\n        account_email,\n        client_ip,\n        error: None,\n        request_body: request_body_str,\n        response_body: None,\n        input_tokens: None,\n        output_tokens: None,\n        protocol,\n        username,\n    };\n\n\n    if content_type.contains(\"text/event-stream\") {\n        let (parts, body) = response.into_parts();\n        let mut stream = body.into_data_stream();\n        let (tx, rx) = tokio::sync::mpsc::channel(64);\n        \n        tokio::spawn(async move {\n            let mut all_stream_data = Vec::new();\n            let mut last_few_bytes = Vec::new();\n            \n            while let Some(chunk_res) = stream.next().await {\n                if let Ok(chunk) = chunk_res {\n                    all_stream_data.extend_from_slice(&chunk);\n                    \n                    if chunk.len() > 8192 {\n                        last_few_bytes = chunk.slice(chunk.len()-8192..).to_vec();\n                    } else {\n                        last_few_bytes.extend_from_slice(&chunk);\n                        if last_few_bytes.len() > 8192 {\n                            last_few_bytes.drain(0..last_few_bytes.len()-8192);\n                        }\n                    }\n                    let _ = tx.send(Ok::<_, axum::Error>(chunk)).await;\n                } else if let Err(e) = chunk_res {\n                    let _ = tx.send(Err(axum::Error::new(e))).await;\n                }\n            }\n            \n            // Parse and consolidate stream data into readable format\n            if let Ok(full_response) = std::str::from_utf8(&all_stream_data) {\n                let mut thinking_content = String::new();\n                let mut response_content = String::new();\n                let mut thinking_signature = String::new();\n                let mut tool_calls: Vec<Value> = Vec::new();\n                \n                for line in full_response.lines() {\n                    if !line.starts_with(\"data: \") {\n                        continue;\n                    }\n                    let json_str = line.trim_start_matches(\"data: \").trim();\n                    if json_str == \"[DONE]\" {\n                        continue;\n                    }\n                    \n                    if let Ok(json) = serde_json::from_str::<Value>(json_str) {\n                        // OpenAI format: choices[0].delta.content / reasoning_content / tool_calls\n                        if let Some(choices) = json.get(\"choices\").and_then(|c| c.as_array()) {\n                            for choice in choices {\n                                if let Some(delta) = choice.get(\"delta\") {\n                                    // Thinking/reasoning content\n                                    if let Some(thinking) = delta.get(\"reasoning_content\").and_then(|v| v.as_str()) {\n                                        thinking_content.push_str(thinking);\n                                    }\n                                    // Main response content\n                                    if let Some(content) = delta.get(\"content\").and_then(|v| v.as_str()) {\n                                        response_content.push_str(content);\n                                    }\n                                    // Tool calls\n                                    if let Some(delta_tool_calls) = delta.get(\"tool_calls\").and_then(|t| t.as_array()) {\n                                        for tc in delta_tool_calls {\n                                            if let Some(index) = tc.get(\"index\").and_then(|i| i.as_u64()) {\n                                                let idx = index as usize;\n                                                while tool_calls.len() <= idx {\n                                                    tool_calls.push(serde_json::json!({\n                                                        \"id\": \"\",\n                                                        \"type\": \"function\",\n                                                        \"function\": { \"name\": \"\", \"arguments\": \"\" }\n                                                    }));\n                                                }\n                                                let current_tc = &mut tool_calls[idx];\n                                                if let Some(id) = tc.get(\"id\").and_then(|v| v.as_str()) {\n                                                    current_tc[\"id\"] = Value::String(id.to_string());\n                                                }\n                                                if let Some(func) = tc.get(\"function\") {\n                                                    if let Some(name) = func.get(\"name\").and_then(|v| v.as_str()) {\n                                                        current_tc[\"function\"][\"name\"] = Value::String(name.to_string());\n                                                    }\n                                                    if let Some(args) = func.get(\"arguments\").and_then(|v| v.as_str()) {\n                                                        let old_args = current_tc[\"function\"][\"arguments\"].as_str().unwrap_or(\"\");\n                                                        current_tc[\"function\"][\"arguments\"] = Value::String(format!(\"{}{}\", old_args, args));\n                                                    }\n                                                }\n                                            }\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                        \n                        // Claude/Anthropic format: content_block_start, content_block_delta, etc.\n                        let msg_type = json.get(\"type\").and_then(|t| t.as_str());\n                        match msg_type {\n                            Some(\"content_block_start\") => {\n                                if let (Some(index), Some(block)) = (json.get(\"index\").and_then(|i| i.as_u64()), json.get(\"content_block\")) {\n                                    let idx = index as usize;\n                                    if block.get(\"type\").and_then(|t| t.as_str()) == Some(\"tool_use\") {\n                                        let id = block.get(\"id\").and_then(|v| v.as_str()).unwrap_or(\"\");\n                                        let name = block.get(\"name\").and_then(|v| v.as_str()).unwrap_or(\"\");\n                                        while tool_calls.len() <= idx {\n                                            tool_calls.push(Value::Null);\n                                        }\n                                        tool_calls[idx] = serde_json::json!({\n                                            \"id\": id,\n                                            \"type\": \"function\",\n                                            \"function\": { \"name\": name, \"arguments\": \"\" }\n                                        });\n                                    }\n                                }\n                            }\n                            Some(\"content_block_delta\") => {\n                                if let (Some(index), Some(delta)) = (json.get(\"index\").and_then(|i| i.as_u64()), json.get(\"delta\")) {\n                                    let idx = index as usize;\n                                    \n                                    // Tool use input delta\n                                    if let Some(delta_json) = delta.get(\"input_json_delta\").and_then(|v| v.as_str()) {\n                                        if idx < tool_calls.len() && !tool_calls[idx].is_null() {\n                                            let old_args = tool_calls[idx][\"function\"][\"arguments\"].as_str().unwrap_or(\"\");\n                                            tool_calls[idx][\"function\"][\"arguments\"] = Value::String(format!(\"{}{}\", old_args, delta_json));\n                                        }\n                                    }\n                                    // Legacy/Native thinking block\n                                    if let Some(thinking) = delta.get(\"thinking\").and_then(|v| v.as_str()) {\n                                        thinking_content.push_str(thinking);\n                                    }\n                                    // Text content\n                                    if let Some(text) = delta.get(\"text\").and_then(|v| v.as_str()) {\n                                        response_content.push_str(text);\n                                    }\n                                }\n                            }\n                            Some(\"message_delta\") => {\n                                if let Some(delta) = json.get(\"delta\") {\n                                    if let Some(usage) = delta.get(\"usage\") {\n                                        if let Some(output_tokens) = usage.get(\"output_tokens\").and_then(|v| v.as_u64()) {\n                                            log.output_tokens = Some(output_tokens as u32);\n                                        }\n                                    }\n                                }\n                            }\n                            _ => {}\n                        }\n                        \n                        // Legacy Claude delta (for older implementations or simplified streams)\n                        if msg_type.is_none() {\n                            if let Some(delta) = json.get(\"delta\") {\n                                // Thinking block\n                                if let Some(thinking) = delta.get(\"thinking\").and_then(|v| v.as_str()) {\n                                    thinking_content.push_str(thinking);\n                                }\n                                // Thinking signature\n                                if let Some(sig) = delta.get(\"signature\").and_then(|v| v.as_str()) {\n                                    thinking_signature = sig.to_string();\n                                }\n                                // Text content\n                                if let Some(text) = delta.get(\"text\").and_then(|v| v.as_str()) {\n                                    response_content.push_str(text);\n                                }\n                            }\n                        }\n                        \n                        // Token usage extraction\n                        if let Some(usage) = json.get(\"usage\")\n                            .or(json.get(\"usageMetadata\"))\n                            .or(json.get(\"response\").and_then(|r| r.get(\"usage\")))\n                        {\n                            log.input_tokens = usage.get(\"prompt_tokens\")\n                                .or(usage.get(\"input_tokens\"))\n                                .or(usage.get(\"promptTokenCount\"))\n                                .and_then(|v| v.as_u64())\n                                .map(|v| v as u32);\n                            log.output_tokens = usage.get(\"completion_tokens\")\n                                .or(usage.get(\"output_tokens\"))\n                                .or(usage.get(\"candidatesTokenCount\"))\n                                .and_then(|v| v.as_u64())\n                                .map(|v| v as u32);\n                            \n                            if log.input_tokens.is_none() && log.output_tokens.is_none() {\n                                log.output_tokens = usage.get(\"total_tokens\")\n                                    .or(usage.get(\"totalTokenCount\"))\n                                    .and_then(|v| v.as_u64())\n                                    .map(|v| v as u32);\n                            }\n                        }\n                    }\n                }\n                \n                // Build consolidated response object\n                let mut consolidated = serde_json::Map::new();\n                \n                if !thinking_content.is_empty() {\n                    consolidated.insert(\"thinking\".to_string(), Value::String(thinking_content));\n                }\n                if !thinking_signature.is_empty() {\n                    consolidated.insert(\"thinking_signature\".to_string(), Value::String(thinking_signature));\n                }\n                if !response_content.is_empty() {\n                    consolidated.insert(\"content\".to_string(), Value::String(response_content));\n                }\n                \n                if !tool_calls.is_empty() {\n                    let clean_tool_calls: Vec<Value> = tool_calls.into_iter().filter(|v| !v.is_null()).collect();\n                    if !clean_tool_calls.is_empty() {\n                        consolidated.insert(\"tool_calls\".to_string(), Value::Array(clean_tool_calls));\n                    }\n                }\n                if let Some(input) = log.input_tokens {\n                    consolidated.insert(\"input_tokens\".to_string(), Value::Number(input.into()));\n                }\n                if let Some(output) = log.output_tokens {\n                    consolidated.insert(\"output_tokens\".to_string(), Value::Number(output.into()));\n                }\n                \n                if consolidated.is_empty() {\n                    // Fallback: store raw SSE data if parsing failed\n                    log.response_body = Some(full_response.to_string());\n                } else {\n                    log.response_body = Some(serde_json::to_string_pretty(&Value::Object(consolidated)).unwrap_or_else(|_| full_response.to_string()));\n                }\n            } else {\n                log.response_body = Some(format!(\"[Binary Stream Data: {} bytes]\", all_stream_data.len()));\n            }\n            \n            // Fallback token extraction from tail if not already extracted\n            if log.input_tokens.is_none() && log.output_tokens.is_none() {\n                if let Ok(full_tail) = std::str::from_utf8(&last_few_bytes) {\n                    for line in full_tail.lines().rev() {\n                        if line.starts_with(\"data: \") && (line.contains(\"\\\"usage\\\"\") || line.contains(\"\\\"usageMetadata\\\"\")) {\n                            let json_str = line.trim_start_matches(\"data: \").trim();\n                            if let Ok(json) = serde_json::from_str::<Value>(json_str) {\n                                if let Some(usage) = json.get(\"usage\")\n                                    .or(json.get(\"usageMetadata\"))\n                                    .or(json.get(\"response\").and_then(|r| r.get(\"usage\")))\n                                {\n                                    log.input_tokens = usage.get(\"prompt_tokens\")\n                                        .or(usage.get(\"input_tokens\"))\n                                        .or(usage.get(\"promptTokenCount\"))\n                                        .and_then(|v| v.as_u64())\n                                        .map(|v| v as u32);\n                                    log.output_tokens = usage.get(\"completion_tokens\")\n                                        .or(usage.get(\"output_tokens\"))\n                                        .or(usage.get(\"candidatesTokenCount\"))\n                                        .and_then(|v| v.as_u64())\n                                        .map(|v| v as u32);\n                                    break;\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n            \n            if log.status >= 400 {\n                log.error = Some(\"Stream Error or Failed\".to_string());\n            }\n\n            // Record User Token Usage\n            record_user_token_usage(&user_token_identity, &log, user_agent.clone());\n\n            monitor.log_request(log).await;\n        });\n\n        Response::from_parts(parts, Body::from_stream(tokio_stream::wrappers::ReceiverStream::new(rx)))\n    } else if content_type.contains(\"application/json\") || content_type.contains(\"text/\") {\n        let (parts, body) = response.into_parts();\n        match axum::body::to_bytes(body, MAX_RESPONSE_LOG_SIZE).await {\n            Ok(bytes) => {\n                if let Ok(s) = std::str::from_utf8(&bytes) {\n                    if let Ok(json) = serde_json::from_str::<Value>(&s) {\n                        // 支持 OpenAI \"usage\" 或 Gemini \"usageMetadata\"\n                        if let Some(usage) = json.get(\"usage\").or(json.get(\"usageMetadata\")) {\n                            log.input_tokens = usage.get(\"prompt_tokens\")\n                                .or(usage.get(\"input_tokens\"))\n                                .or(usage.get(\"promptTokenCount\"))\n                                .and_then(|v| v.as_u64())\n                                .map(|v| v as u32);\n                            log.output_tokens = usage.get(\"completion_tokens\")\n                                .or(usage.get(\"output_tokens\"))\n                                .or(usage.get(\"candidatesTokenCount\"))\n                                .and_then(|v| v.as_u64())\n                                .map(|v| v as u32);\n                                \n                            if log.input_tokens.is_none() && log.output_tokens.is_none() {\n                                log.output_tokens = usage.get(\"total_tokens\")\n                                    .or(usage.get(\"totalTokenCount\"))\n                                    .and_then(|v| v.as_u64())\n                                    .map(|v| v as u32);\n                            }\n                        }\n                    }\n                    log.response_body = Some(s.to_string());\n                } else {\n                    log.response_body = Some(\"[Binary Response Data]\".to_string());\n                }\n                \n                if log.status >= 400 {\n                    log.error = log.response_body.clone();\n                }\n\n                // Record User Token Usage\n                record_user_token_usage(&user_token_identity, &log, user_agent.clone());\n\n                monitor.log_request(log).await;\n                Response::from_parts(parts, Body::from(bytes))\n            }\n            Err(_) => {\n                log.response_body = Some(\"[Response too large (>100MB)]\".to_string());\n\n                // Record User Token Usage (even if too large)\n                record_user_token_usage(&user_token_identity, &log, user_agent.clone());\n\n                monitor.log_request(log).await;\n                Response::from_parts(parts, Body::empty())\n            }\n        }\n    } else {\n        log.response_body = Some(format!(\"[{}]\", content_type));\n\n        // Record User Token Usage\n        record_user_token_usage(&user_token_identity, &log, user_agent);\n\n        monitor.log_request(log).await;\n        response\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/middleware/service_status.rs",
    "content": "use axum::{\n    extract::{Request, State},\n    middleware::Next,\n    response::{IntoResponse, Response},\n    http::StatusCode,\n};\nuse crate::proxy::server::AppState;\n\npub async fn service_status_middleware(\n    State(state): State<AppState>,\n    request: Request,\n    next: Next,\n) -> Response {\n    let path = request.uri().path();\n    \n    // Always allow Admin API and Auth callback\n    if path.starts_with(\"/api/\") || path == \"/auth/callback\" || path == \"/health\" {\n        return next.run(request).await;\n    }\n\n    let running = {\n        let r = state.is_running.read().await;\n        *r\n    };\n\n    if !running {\n        return (\n            StatusCode::SERVICE_UNAVAILABLE,\n            \"Proxy service is currently disabled\".to_string(),\n        )\n            .into_response();\n    }\n\n    next.run(request).await\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mod.rs",
    "content": "// proxy 模块 - API 反代服务\n\n// 现有模块 (保留)\npub mod config;\npub mod project_resolver;\npub mod security;\npub mod server;\npub mod token_manager;\n\n// 新架构模块\npub mod audio; // 音频处理模块\npub mod cli_sync; // CLI 配置同步 (v3.3.35)\npub mod droid_sync; // Droid (Factory CLI) 配置同步\npub mod common; // 公共工具\npub mod debug_logger;\npub mod handlers; // API 端点处理器\npub mod mappers; // 协议转换器\npub mod middleware; // Axum 中间件\npub mod monitor; // 监控\npub mod opencode_sync; // OpenCode 配置同步\npub mod providers; // Extra upstream providers (z.ai, etc.)\npub mod proxy_pool; // 代理池管理器\npub mod rate_limit; // 限流跟踪\npub mod model_specs; // 模型规格管理 (v4.1.29)\npub mod session_manager; // 会话指纹管理\npub mod signature_cache; // Signature Cache (v3.3.16)\npub mod sticky_config; // 粘性调度配置\npub mod upstream; // 上游客户端\npub mod zai_vision_mcp; // Built-in Vision MCP server state\npub mod zai_vision_tools; // Built-in Vision MCP tools (z.ai vision API) // 调试日志\n\npub use config::update_global_system_prompt_config;\npub use config::update_thinking_budget_config;\npub use config::update_image_thinking_mode;\npub use config::ProxyAuthMode;\npub use config::ProxyConfig;\npub use config::ProxyPoolConfig;\npub use config::ZaiConfig;\npub use config::ZaiDispatchMode;\npub use security::ProxySecurityConfig;\npub use server::AxumServer;\npub use signature_cache::SignatureCache;\npub use token_manager::TokenManager;\n\n#[cfg(test)]\npub mod tests;\n"
  },
  {
    "path": "src-tauri/src/proxy/model_specs.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse once_cell::sync::Lazy;\nuse crate::proxy::token_manager::ProxyToken;\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ModelSpec {\n    pub max_output_tokens: Option<u64>,\n    pub thinking_budget: Option<u64>,\n    pub is_thinking: Option<bool>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct SpecsConfig {\n    models: HashMap<String, ModelSpec>,\n    aliases: HashMap<String, String>,\n}\n\nstatic SPECS: Lazy<SpecsConfig> = Lazy::new(|| {\n    let json_str = include_str!(\"../../resources/model_specs.json\");\n    serde_json::from_str(json_str).expect(\"Failed to parse model_specs.json\")\n});\n\n/// 获取归一化后的模型 ID (基于别名)\npub fn resolve_alias(model_id: &str) -> String {\n    SPECS.aliases.get(model_id).cloned().unwrap_or_else(|| model_id.to_string())\n}\n\n/// 获取模型输出 Token 限额 (动态优先)\npub fn get_max_output_tokens(model_id: &str, token: Option<&ProxyToken>) -> u64 {\n    let std_id = resolve_alias(model_id);\n    \n    // 1. 尝试从账号动态数据中读取\n    if let Some(t) = token {\n        if let Some(&limit) = t.model_limits.get(&std_id) {\n            return limit;\n        }\n        // 如果原始 ID 没找到，尝试用归一化后的 ID 找\n        if let Some(&limit) = t.model_limits.get(model_id) {\n            return limit;\n        }\n    }\n    \n    // 2. 回退到静态 JSON\n    if let Some(spec) = SPECS.models.get(&std_id) {\n        if let Some(limit) = spec.max_output_tokens {\n            return limit;\n        }\n    }\n\n    // 3. 全局兜底\n    65535\n}\n\n/// 获取思维链预算 (动态优先)\npub fn get_thinking_budget(model_id: &str, _token: Option<&ProxyToken>) -> u64 {\n    let std_id = resolve_alias(model_id);\n    \n    // 1. 优先尝试从 token 的 quota 信息中推断 (如果以后 quota 返回了具体 budget)\n    // 目前 ProxyToken 结构体暂未直接缓存每个模型的 thinking_budget，\n    // 但可以通过 model_limits 比例或直接从 JSON 补全。\n    \n    // 2. 静态 JSON 配置\n    if let Some(spec) = SPECS.models.get(&std_id) {\n        if let Some(budget) = spec.thinking_budget {\n            return budget;\n        }\n    }\n\n    // 3. 默认安全限额\n    24576\n}\n\n/// 判断是否为思维模型\n#[allow(dead_code)]\npub fn is_thinking_model(model_id: &str) -> bool {\n    let std_id = resolve_alias(model_id);\n    if let Some(spec) = SPECS.models.get(&std_id) {\n        return spec.is_thinking.unwrap_or(false);\n    }\n    model_id.contains(\"-thinking\") || model_id.contains(\"thinking\")\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/monitor.rs",
    "content": "use serde::{Serialize, Deserialize};\nuse std::collections::VecDeque;\nuse tokio::sync::RwLock;\nuse tauri::Emitter;\nuse std::sync::atomic::{AtomicBool, Ordering};\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProxyRequestLog {\n    pub id: String,\n    pub timestamp: i64,\n    pub method: String,\n    pub url: String,\n    pub status: u16,\n    pub duration: u64, // ms\n    pub model: Option<String>,        // 客户端请求的模型名\n    pub mapped_model: Option<String>, // 实际路由后使用的模型名\n    pub account_email: Option<String>,\n    pub client_ip: Option<String>,    // 客户端 IP 地址\n    pub error: Option<String>,\n    pub request_body: Option<String>,\n    pub response_body: Option<String>,\n    pub input_tokens: Option<u32>,\n    pub output_tokens: Option<u32>,\n    pub protocol: Option<String>,     // 协议类型: \"openai\", \"anthropic\", \"gemini\"\n    pub username: Option<String>,     // User token username\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ProxyStats {\n    pub total_requests: u64,\n    pub success_count: u64,\n    pub error_count: u64,\n}\n\npub struct ProxyMonitor {\n    pub logs: RwLock<VecDeque<ProxyRequestLog>>,\n    pub stats: RwLock<ProxyStats>,\n    pub max_logs: usize,\n    pub enabled: AtomicBool,\n    app_handle: Option<tauri::AppHandle>,\n}\n\nimpl ProxyMonitor {\n    pub fn new(max_logs: usize, app_handle: Option<tauri::AppHandle>) -> Self {\n        // Initialize DB\n        if let Err(e) = crate::modules::proxy_db::init_db() {\n            tracing::error!(\"Failed to initialize proxy DB: {}\", e);\n        }\n\n        // Auto cleanup old logs (keep last 30 days)\n        tokio::spawn(async {\n            match crate::modules::proxy_db::cleanup_old_logs(30) {\n                Ok(deleted) => {\n                    if deleted > 0 {\n                        tracing::info!(\"Auto cleanup: removed {} old logs (>30 days)\", deleted);\n                    }\n                }\n                Err(e) => {\n                    tracing::error!(\"Failed to cleanup old logs: {}\", e);\n                }\n            }\n        });\n\n        Self {\n            logs: RwLock::new(VecDeque::with_capacity(max_logs)),\n            stats: RwLock::new(ProxyStats::default()),\n            max_logs,\n            enabled: AtomicBool::new(false), // Default to disabled\n            app_handle,\n        }\n    }\n\n    pub fn set_enabled(&self, enabled: bool) {\n        self.enabled.store(enabled, Ordering::Relaxed);\n    }\n\n    pub fn is_enabled(&self) -> bool {\n        self.enabled.load(Ordering::Relaxed)\n    }\n\n    pub async fn log_request(&self, log: ProxyRequestLog) {\n        if let (Some(account), Some(input), Some(output)) = (\n            &log.account_email,\n            log.input_tokens,\n            log.output_tokens,\n        ) {\n            let model = log.model.clone().unwrap_or_else(|| \"unknown\".to_string());\n            let account = account.clone();\n            tokio::spawn(async move {\n                if let Err(e) = crate::modules::token_stats::record_usage(&account, &model, input, output) {\n                    tracing::debug!(\"Failed to record token stats: {}\", e);\n                }\n            });\n        }\n\n        if !self.is_enabled() {\n            return;\n        }\n        tracing::info!(\"[Monitor] Logging request: {} {}\", log.method, log.url);\n        // Update stats\n        {\n            let mut stats = self.stats.write().await;\n            stats.total_requests += 1;\n            if log.status >= 200 && log.status < 400 {\n                stats.success_count += 1;\n            } else {\n                stats.error_count += 1;\n            }\n        }\n\n        // Add log to memory\n        {\n            let mut logs = self.logs.write().await;\n            if logs.len() >= self.max_logs {\n                logs.pop_back();\n            }\n            logs.push_front(log.clone());\n        }\n\n        // Save to DB\n        let log_to_save = log.clone();\n        tokio::spawn(async move {\n            if let Err(e) = crate::modules::proxy_db::save_log(&log_to_save) {\n                tracing::error!(\"Failed to save proxy log to DB: {}\", e);\n            }\n\n            // Sync to Security DB (IpAccessLogs) so it appears in Security Monitor\n            if let Some(ip) = &log_to_save.client_ip {\n                let security_log = crate::modules::security_db::IpAccessLog {\n                    id: uuid::Uuid::new_v4().to_string(),\n                    client_ip: ip.clone(),\n                    timestamp: log_to_save.timestamp / 1000, // ms to s\n                    method: Some(log_to_save.method.clone()),\n                    path: Some(log_to_save.url.clone()),\n                    user_agent: None, // We don't have UA in ProxyRequestLog easily accessible here without plumbing\n                    status: Some(log_to_save.status as i32),\n                    duration: Some(log_to_save.duration as i64),\n                    api_key_hash: None,\n                    blocked: false, // This comes from monitor, so it wasn't blocked by IP filter\n                    block_reason: None,\n                    username: log_to_save.username.clone(),\n                };\n\n                if let Err(e) = crate::modules::security_db::save_ip_access_log(&security_log) {\n                     tracing::error!(\"Failed to save security log: {}\", e);\n                }\n            }\n\n            // Record token stats if available\n            if let (Some(account), Some(input), Some(output)) = (\n                &log_to_save.account_email,\n                log_to_save.input_tokens,\n                log_to_save.output_tokens,\n            ) {\n                let model = log_to_save.model.clone().unwrap_or_else(|| \"unknown\".to_string());\n                if let Err(e) = crate::modules::token_stats::record_usage(account, &model, input, output) {\n                    tracing::debug!(\"Failed to record token stats: {}\", e);\n                }\n            }\n        });\n\n        // Emit event (send summary only, without body to reduce memory)\n        if let Some(app) = &self.app_handle {\n            let log_summary = ProxyRequestLog {\n                id: log.id.clone(),\n                timestamp: log.timestamp,\n                method: log.method.clone(),\n                url: log.url.clone(),\n                status: log.status,\n                duration: log.duration,\n                model: log.model.clone(),\n                mapped_model: log.mapped_model.clone(),\n                account_email: log.account_email.clone(),\n                client_ip: log.client_ip.clone(),\n                error: log.error.clone(),\n                request_body: None,  // Don't send body in event\n                response_body: None, // Don't send body in event\n                input_tokens: log.input_tokens,\n                output_tokens: log.output_tokens,\n                protocol: log.protocol.clone(),\n                username: log.username.clone(),\n            };\n            let _ = app.emit(\"proxy://request\", &log_summary);\n        }\n    }\n\n    pub async fn get_logs(&self, limit: usize) -> Vec<ProxyRequestLog> {\n        // Try to get from DB first for true history\n        let db_result = tokio::task::spawn_blocking(move || {\n            crate::modules::proxy_db::get_logs(limit)\n        }).await;\n\n        match db_result {\n            Ok(Ok(logs)) => logs,\n            Ok(Err(e)) => {\n                tracing::error!(\"Failed to get logs from DB: {}\", e);\n                // Fallback to memory\n                let logs = self.logs.read().await;\n                logs.iter().take(limit).cloned().collect()\n            }\n            Err(e) => {\n                tracing::error!(\"Spawn blocking failed for get_logs: {}\", e);\n                let logs = self.logs.read().await;\n                logs.iter().take(limit).cloned().collect()\n            }\n        }\n    }\n\n    pub async fn get_stats(&self) -> ProxyStats {\n        let db_result = tokio::task::spawn_blocking(|| {\n            crate::modules::proxy_db::get_stats()\n        }).await;\n\n        match db_result {\n            Ok(Ok(stats)) => stats,\n            Ok(Err(e)) => {\n                tracing::error!(\"Failed to get stats from DB: {}\", e);\n                self.stats.read().await.clone()\n            }\n            Err(e) => {\n                tracing::error!(\"Spawn blocking failed for get_stats: {}\", e);\n                self.stats.read().await.clone()\n            }\n        }\n    }\n    \n    pub async fn get_logs_filtered(\n        &self,\n        page: usize,\n        page_size: usize,\n        search_text: Option<String>,\n        level: Option<String>,\n    ) -> Result<Vec<ProxyRequestLog>, String> {\n        let offset = (page.max(1) - 1) * page_size;\n        let errors_only = level.as_deref() == Some(\"error\");\n        let search = search_text.unwrap_or_default();\n\n        let res = tokio::task::spawn_blocking(move || {\n            crate::modules::proxy_db::get_logs_filtered(&search, errors_only, page_size, offset)\n        }).await;\n\n        match res {\n            Ok(r) => r,\n            Err(e) => Err(format!(\"Spawn blocking failed: {}\", e)),\n        }\n    }\n    \n    pub async fn clear(&self) {\n        let mut logs = self.logs.write().await;\n        logs.clear();\n        let mut stats = self.stats.write().await;\n        *stats = ProxyStats::default();\n\n        let _ = tokio::task::spawn_blocking(|| {\n            if let Err(e) = crate::modules::proxy_db::clear_logs() {\n                tracing::error!(\"Failed to clear logs in DB: {}\", e);\n            }\n        }).await;\n    }\n}"
  },
  {
    "path": "src-tauri/src/proxy/opencode_sync.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::fs;\nuse std::collections::HashMap;\nuse std::env;\n\n#[cfg(target_os = \"windows\")]\nuse std::os::windows::process::CommandExt;\n\n#[cfg(target_os = \"windows\")]\nconst CREATE_NO_WINDOW: u32 = 0x08000000;\n\nconst OPENCODE_DIR: &str = \".config/opencode\";\nconst OPENCODE_CONFIG_FILE: &str = \"opencode.json\";\nconst ANTIGRAVITY_CONFIG_FILE: &str = \"antigravity.json\";\nconst ANTIGRAVITY_ACCOUNTS_FILE: &str = \"antigravity-accounts.json\";\nconst BACKUP_SUFFIX: &str = \".antigravity-manager.bak\";\nconst OLD_BACKUP_SUFFIX: &str = \".antigravity.bak\";\n\nconst ANTIGRAVITY_PROVIDER_ID: &str = \"antigravity-manager\";\n\n/// Variant type for model variants\n#[derive(Debug, Clone, Copy)]\nenum VariantType {\n    /// Claude-style thinking with budget_tokens\n    ClaudeThinking,\n    /// Gemini 3 Pro style with thinkingLevel\n    Gemini3Pro,\n    /// Gemini 3 Flash style with thinkingLevel\n    Gemini3Flash,\n    /// Gemini 2.5 thinking style\n    Gemini25Thinking,\n}\n\n/// Model definition with metadata and variants\n#[derive(Debug, Clone)]\nstruct ModelDef {\n    id: &'static str,\n    name: &'static str,\n    context_limit: u32,\n    output_limit: u32,\n    input_modalities: &'static [&'static str],\n    output_modalities: &'static [&'static str],\n    reasoning: bool,\n    variant_type: Option<VariantType>,\n}\n\n/// Build the complete model catalog for antigravity-manager provider\nfn build_model_catalog() -> Vec<ModelDef> {\n    vec![\n        // Claude models\n        ModelDef {\n            id: \"claude-sonnet-4-6\",\n            name: \"Claude Sonnet 4.6\",\n            context_limit: 200_000,\n            output_limit: 64_000,\n            input_modalities: &[\"text\", \"image\", \"pdf\"],\n            output_modalities: &[\"text\"],\n            reasoning: false,\n            variant_type: None,\n        },\n        ModelDef {\n            id: \"claude-sonnet-4-6-thinking\",\n            name: \"Claude Sonnet 4.6 Thinking\",\n            context_limit: 200_000,\n            output_limit: 64_000,\n            input_modalities: &[\"text\", \"image\", \"pdf\"],\n            output_modalities: &[\"text\"],\n            reasoning: true,\n            variant_type: Some(VariantType::ClaudeThinking),\n        },\n        ModelDef {\n            id: \"claude-opus-4-5-thinking\",\n            name: \"Claude Opus 4.5 Thinking\",\n            context_limit: 200_000,\n            output_limit: 64_000,\n            input_modalities: &[\"text\", \"image\", \"pdf\"],\n            output_modalities: &[\"text\"],\n            reasoning: true,\n            variant_type: Some(VariantType::ClaudeThinking),\n        },\n        ModelDef {\n            id: \"claude-opus-4-6-thinking\",\n            name: \"Claude Opus 4.6 Thinking\",\n            context_limit: 200_000,\n            output_limit: 64_000,\n            input_modalities: &[\"text\", \"image\", \"pdf\"],\n            output_modalities: &[\"text\"],\n            reasoning: true,\n            variant_type: Some(VariantType::ClaudeThinking),\n        },\n        // Gemini 3.1 Pro models\n        ModelDef {\n            id: \"gemini-3.1-pro-high\",\n            name: \"Gemini 3.1 Pro High\",\n            context_limit: 1_048_576,\n            output_limit: 65_535,\n            input_modalities: &[\"text\", \"image\", \"pdf\"],\n            output_modalities: &[\"text\", \"image\"],\n            reasoning: true,\n            variant_type: Some(VariantType::Gemini3Pro),\n        },\n        ModelDef {\n            id: \"gemini-3.1-pro-low\",\n            name: \"Gemini 3.1 Pro Low\",\n            context_limit: 1_048_576,\n            output_limit: 65_535,\n            input_modalities: &[\"text\", \"image\", \"pdf\"],\n            output_modalities: &[\"text\", \"image\"],\n            reasoning: true,\n            variant_type: Some(VariantType::Gemini3Pro),\n        },\n        ModelDef {\n            id: \"gemini-3-flash\",\n            name: \"Gemini 3 Flash\",\n            context_limit: 1_048_576,\n            output_limit: 65_536,\n            input_modalities: &[\"text\", \"image\", \"pdf\"],\n            output_modalities: &[\"text\"],\n            reasoning: true,\n            variant_type: Some(VariantType::Gemini3Flash),\n        },\n        ModelDef {\n            id: \"gemini-3-pro-image\",\n            name: \"Gemini 3 Pro Image\",\n            context_limit: 1_048_576,\n            output_limit: 65_535,\n            input_modalities: &[\"text\", \"image\", \"pdf\"],\n            output_modalities: &[\"text\", \"image\"],\n            reasoning: false,\n            variant_type: None,\n        },\n        // Gemini 2.5 models\n        ModelDef {\n            id: \"gemini-2.5-flash\",\n            name: \"Gemini 2.5 Flash\",\n            context_limit: 1_048_576,\n            output_limit: 65_536,\n            input_modalities: &[\"text\", \"image\", \"pdf\"],\n            output_modalities: &[\"text\"],\n            reasoning: false,\n            variant_type: None,\n        },\n        ModelDef {\n            id: \"gemini-2.5-flash-lite\",\n            name: \"Gemini 2.5 Flash Lite\",\n            context_limit: 1_048_576,\n            output_limit: 65_536,\n            input_modalities: &[\"text\", \"image\", \"pdf\"],\n            output_modalities: &[\"text\"],\n            reasoning: false,\n            variant_type: None,\n        },\n        ModelDef {\n            id: \"gemini-2.5-flash-thinking\",\n            name: \"Gemini 2.5 Flash Thinking\",\n            context_limit: 1_048_576,\n            output_limit: 65_536,\n            input_modalities: &[\"text\", \"image\", \"pdf\"],\n            output_modalities: &[\"text\"],\n            reasoning: true,\n            variant_type: Some(VariantType::Gemini25Thinking),\n        },\n        ModelDef {\n            id: \"gemini-2.5-pro\",\n            name: \"Gemini 2.5 Pro\",\n            context_limit: 1_048_576,\n            output_limit: 65_536,\n            input_modalities: &[\"text\", \"image\", \"pdf\"],\n            output_modalities: &[\"text\"],\n            reasoning: true,\n            variant_type: None,\n        },\n    ]\n}\n\n/// Normalize OpenCode base URL to ensure it ends with `/v1` (Anthropic protocol requirement)\n/// - Trims trailing `/`\n/// - If already ends with `/v1`, keeps it as-is\n/// - Otherwise appends `/v1`\nfn normalize_opencode_base_url(input: &str) -> String {\n    let trimmed = input.trim().trim_end_matches('/');\n    if trimmed.ends_with(\"/v1\") {\n        trimmed.to_string()\n    } else {\n        format!(\"{}/v1\", trimmed)\n    }\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct OpencodeStatus {\n    pub installed: bool,\n    pub version: Option<String>,\n    pub is_synced: bool,\n    pub has_backup: bool,\n    pub current_base_url: Option<String>,\n    pub files: Vec<String>,\n}\n\n/// Plugin schema v3 account structure\n#[derive(Debug, Serialize, Deserialize, Clone)]\nstruct PluginAccount {\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    email: Option<String>,\n    #[serde(rename = \"refreshToken\")]\n    refresh_token: String,\n    #[serde(default, rename = \"projectId\", skip_serializing_if = \"Option::is_none\")]\n    project_id: Option<String>,\n    #[serde(rename = \"addedAt\")]\n    added_at: i64,\n    #[serde(rename = \"lastUsed\")]\n    last_used: i64,\n    #[serde(rename = \"rateLimitResetTimes\", skip_serializing_if = \"Option::is_none\")]\n    rate_limit_reset_times: Option<HashMap<String, i64>>,\n    // Optional preserved state fields\n    #[serde(rename = \"managedProjectId\", skip_serializing_if = \"Option::is_none\")]\n    managed_project_id: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    enabled: Option<bool>,\n    #[serde(rename = \"lastSwitchReason\", skip_serializing_if = \"Option::is_none\")]\n    last_switch_reason: Option<String>,\n    #[serde(rename = \"coolingDownUntil\", skip_serializing_if = \"Option::is_none\")]\n    cooling_down_until: Option<i64>,\n    #[serde(rename = \"cooldownReason\", skip_serializing_if = \"Option::is_none\")]\n    cooldown_reason: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    fingerprint: Option<Value>,\n    #[serde(rename = \"cachedQuota\", skip_serializing_if = \"Option::is_none\")]\n    cached_quota: Option<Value>,\n    #[serde(rename = \"cachedQuotaUpdatedAt\", skip_serializing_if = \"Option::is_none\")]\n    cached_quota_updated_at: Option<i64>,\n    #[serde(rename = \"fingerprintHistory\", skip_serializing_if = \"Option::is_none\")]\n    fingerprint_history: Option<Value>,\n}\n\n/// Plugin schema v3 accounts file structure\n#[derive(Debug, Serialize, Deserialize)]\nstruct PluginAccountsFile {\n    version: i32,\n    accounts: Vec<PluginAccount>,\n    #[serde(rename = \"activeIndex\")]\n    active_index: i32,\n    #[serde(rename = \"activeIndexByFamily\")]\n    active_index_by_family: HashMap<String, i32>,\n}\n\nfn get_opencode_dir() -> Option<PathBuf> {\n    dirs::home_dir().map(|h| h.join(OPENCODE_DIR))\n}\n\nfn get_config_paths() -> Option<(PathBuf, PathBuf, PathBuf)> {\n    get_opencode_dir().map(|dir| {\n        (\n            dir.join(OPENCODE_CONFIG_FILE),\n            dir.join(ANTIGRAVITY_CONFIG_FILE),\n            dir.join(ANTIGRAVITY_ACCOUNTS_FILE),\n        )\n    })\n}\n\nfn extract_version(raw: &str) -> String {\n    let trimmed = raw.trim();\n    \n    // Try to extract version from formats like \"opencode/1.2.3\" or \"codex-cli 0.86.0\"\n    let parts: Vec<&str> = trimmed.split_whitespace().collect();\n    for part in parts {\n        // Check for format like \"opencode/1.2.3\"\n        if let Some(slash_idx) = part.find('/') {\n            let after_slash = &part[slash_idx + 1..];\n            if is_valid_version(after_slash) {\n                return after_slash.to_string();\n            }\n        }\n        // Check if part itself looks like a version\n        if is_valid_version(part) {\n            return part.to_string();\n        }\n    }\n    \n    // Fallback: extract last sequence of digits and dots\n    let version_chars: String = trimmed\n        .chars()\n        .skip_while(|c| !c.is_ascii_digit())\n        .take_while(|c| c.is_ascii_digit() || *c == '.')\n        .collect();\n    \n    if !version_chars.is_empty() && version_chars.contains('.') {\n        return version_chars;\n    }\n    \n    \"unknown\".to_string()\n}\n\nfn is_valid_version(s: &str) -> bool {\n    // A valid version should start with digit and contain at least one dot\n    s.chars().next().map_or(false, |c| c.is_ascii_digit())\n        && s.contains('.')\n        && s.chars().all(|c| c.is_ascii_digit() || c == '.')\n}\n\nfn resolve_opencode_path() -> Option<PathBuf> {\n    // First, try to find in PATH\n    if let Some(path) = find_in_path(\"opencode\") {\n        tracing::debug!(\"Found opencode in PATH: {:?}\", path);\n        return Some(path);\n    }\n    \n    // Try fallback locations based on OS\n    #[cfg(target_os = \"windows\")]\n    {\n        resolve_opencode_path_windows()\n    }\n    #[cfg(not(target_os = \"windows\"))]\n    {\n        resolve_opencode_path_unix()\n    }\n}\n\n#[cfg(target_os = \"windows\")]\nfn resolve_opencode_path_windows() -> Option<PathBuf> {\n    // Check npm global location\n    if let Ok(app_data) = env::var(\"APPDATA\") {\n        let npm_opencode_cmd = PathBuf::from(&app_data).join(\"npm\").join(\"opencode.cmd\");\n        if npm_opencode_cmd.exists() {\n            tracing::debug!(\"Found opencode.cmd in APPDATA\\\\npm: {:?}\", npm_opencode_cmd);\n            return Some(npm_opencode_cmd);\n        }\n        let npm_opencode_exe = PathBuf::from(&app_data).join(\"npm\").join(\"opencode.exe\");\n        if npm_opencode_exe.exists() {\n            tracing::debug!(\"Found opencode.exe in APPDATA\\\\npm: {:?}\", npm_opencode_exe);\n            return Some(npm_opencode_exe);\n        }\n    }\n    \n    // Check pnpm location\n    if let Ok(local_app_data) = env::var(\"LOCALAPPDATA\") {\n        let pnpm_opencode_cmd = PathBuf::from(&local_app_data).join(\"pnpm\").join(\"opencode.cmd\");\n        if pnpm_opencode_cmd.exists() {\n            tracing::debug!(\"Found opencode.cmd in LOCALAPPDATA\\\\pnpm: {:?}\", pnpm_opencode_cmd);\n            return Some(pnpm_opencode_cmd);\n        }\n        let pnpm_opencode_exe = PathBuf::from(&local_app_data).join(\"pnpm\").join(\"opencode.exe\");\n        if pnpm_opencode_exe.exists() {\n            tracing::debug!(\"Found opencode.exe in LOCALAPPDATA\\\\pnpm: {:?}\", pnpm_opencode_exe);\n            return Some(pnpm_opencode_exe);\n        }\n    }\n    \n    // Check Yarn location\n    if let Ok(local_app_data) = env::var(\"LOCALAPPDATA\") {\n        let yarn_opencode = PathBuf::from(&local_app_data)\n            .join(\"Yarn\")\n            .join(\"bin\")\n            .join(\"opencode.cmd\");\n        if yarn_opencode.exists() {\n            tracing::debug!(\"Found opencode.cmd in Yarn bin: {:?}\", yarn_opencode);\n            return Some(yarn_opencode);\n        }\n    }\n    \n    // Scan NVM_HOME\n    if let Ok(nvm_home) = env::var(\"NVM_HOME\") {\n        if let Some(path) = scan_nvm_directory(&nvm_home) {\n            return Some(path);\n        }\n    }\n    \n    // Try common NVM locations\n    if let Some(home) = dirs::home_dir() {\n        let nvm_default = home.join(\".nvm\");\n        if let Some(path) = scan_nvm_directory(&nvm_default) {\n            return Some(path);\n        }\n    }\n    \n    None\n}\n\n#[cfg(not(target_os = \"windows\"))]\nfn resolve_opencode_path_unix() -> Option<PathBuf> {\n    let home = dirs::home_dir()?;\n    \n    // Common user bin locations\n    let user_bins = [\n        home.join(\".local\").join(\"bin\").join(\"opencode\"),\n        home.join(\".npm-global\").join(\"bin\").join(\"opencode\"),\n        home.join(\".volta\").join(\"bin\").join(\"opencode\"),\n        home.join(\"bin\").join(\"opencode\"),\n    ];\n    \n    for path in &user_bins {\n        if path.exists() {\n            tracing::debug!(\"Found opencode in user bin: {:?}\", path);\n            return Some(path.clone());\n        }\n    }\n    \n    // System-wide locations\n    let system_bins = [\n        PathBuf::from(\"/opt/homebrew/bin/opencode\"),\n        PathBuf::from(\"/usr/local/bin/opencode\"),\n        PathBuf::from(\"/usr/bin/opencode\"),\n    ];\n    \n    for path in &system_bins {\n        if path.exists() {\n            tracing::debug!(\"Found opencode in system bin: {:?}\", path);\n            return Some(path.clone());\n        }\n    }\n    \n    // Scan nvm directories\n    let nvm_dirs = [\n        home.join(\".nvm\").join(\"versions\").join(\"node\"),\n    ];\n    \n    for nvm_dir in &nvm_dirs {\n        if let Some(path) = scan_node_versions(nvm_dir) {\n            return Some(path);\n        }\n    }\n    \n    // Scan fnm directories\n    let fnm_dirs = [\n        home.join(\".fnm\").join(\"node-versions\"),\n        home.join(\"Library\").join(\"Application Support\").join(\"fnm\").join(\"node-versions\"),\n    ];\n    \n    for fnm_dir in &fnm_dirs {\n        if let Some(path) = scan_fnm_versions(fnm_dir) {\n            return Some(path);\n        }\n    }\n    \n    None\n}\n\n#[cfg(target_os = \"windows\")]\nfn scan_nvm_directory(nvm_path: impl AsRef<std::path::Path>) -> Option<PathBuf> {\n    let nvm_path = nvm_path.as_ref();\n    if !nvm_path.exists() {\n        return None;\n    }\n    \n    let entries = fs::read_dir(nvm_path).ok()?;\n    \n    for entry in entries.flatten() {\n        let path = entry.path();\n        if path.is_dir() {\n            let opencode_cmd = path.join(\"opencode.cmd\");\n            if opencode_cmd.exists() {\n                tracing::debug!(\"Found opencode.cmd in NVM: {:?}\", opencode_cmd);\n                return Some(opencode_cmd);\n            }\n            let opencode_exe = path.join(\"opencode.exe\");\n            if opencode_exe.exists() {\n                tracing::debug!(\"Found opencode.exe in NVM: {:?}\", opencode_exe);\n                return Some(opencode_exe);\n            }\n        }\n    }\n    \n    None\n}\n\n#[cfg(not(target_os = \"windows\"))]\nfn scan_node_versions(versions_dir: impl AsRef<std::path::Path>) -> Option<PathBuf> {\n    let versions_dir = versions_dir.as_ref();\n    if !versions_dir.exists() {\n        return None;\n    }\n    \n    let entries = fs::read_dir(versions_dir).ok()?;\n    \n    for entry in entries.flatten() {\n        let path = entry.path();\n        if path.is_dir() {\n            let opencode = path.join(\"bin\").join(\"opencode\");\n            if opencode.exists() {\n                tracing::debug!(\"Found opencode in nvm: {:?}\", opencode);\n                return Some(opencode);\n            }\n        }\n    }\n    \n    None\n}\n\n#[cfg(not(target_os = \"windows\"))]\nfn scan_fnm_versions(versions_dir: impl AsRef<std::path::Path>) -> Option<PathBuf> {\n    let versions_dir = versions_dir.as_ref();\n    if !versions_dir.exists() {\n        return None;\n    }\n    \n    let entries = fs::read_dir(versions_dir).ok()?;\n    \n    for entry in entries.flatten() {\n        let path = entry.path();\n        if path.is_dir() {\n            let opencode = path.join(\"installation\").join(\"bin\").join(\"opencode\");\n            if opencode.exists() {\n                tracing::debug!(\"Found opencode in fnm: {:?}\", opencode);\n                return Some(opencode);\n            }\n        }\n    }\n    \n    None\n}\n\nfn find_in_path(executable: &str) -> Option<PathBuf> {\n    #[cfg(target_os = \"windows\")]\n    {\n        let extensions = [\"exe\", \"cmd\", \"bat\"];\n        if let Ok(path_var) = env::var(\"PATH\") {\n            for dir in path_var.split(';') {\n                for ext in &extensions {\n                    let full_path = PathBuf::from(dir).join(format!(\"{}.{}\", executable, ext));\n                    if full_path.exists() {\n                        return Some(full_path);\n                    }\n                }\n            }\n        }\n    }\n    \n    #[cfg(not(target_os = \"windows\"))]\n    {\n        if let Ok(path_var) = env::var(\"PATH\") {\n            for dir in path_var.split(':') {\n                let full_path = PathBuf::from(dir).join(executable);\n                if full_path.exists() {\n                    return Some(full_path);\n                }\n            }\n        }\n    }\n    \n    None\n}\n\n#[cfg(target_os = \"windows\")]\nfn run_opencode_version(opencode_path: &PathBuf) -> Option<String> {\n    let path_str = opencode_path.to_string_lossy();\n    \n    // Check if it's a .cmd or .bat file that needs cmd.exe\n    let is_cmd = path_str.ends_with(\".cmd\") || path_str.ends_with(\".bat\");\n    \n    let output = if is_cmd {\n        let mut cmd = Command::new(\"cmd.exe\");\n        cmd.arg(\"/C\")\n            .arg(opencode_path)\n            .arg(\"--version\")\n            .creation_flags(CREATE_NO_WINDOW);\n        cmd.output()\n    } else {\n        let mut cmd = Command::new(opencode_path);\n        cmd.arg(\"--version\")\n            .creation_flags(CREATE_NO_WINDOW);\n        cmd.output()\n    };\n    \n    match output {\n        Ok(output) if output.status.success() => {\n            let stdout = String::from_utf8_lossy(&output.stdout);\n            let stderr = String::from_utf8_lossy(&output.stderr);\n            \n            // Some tools output version to stderr\n            let raw = if stdout.trim().is_empty() {\n                stderr.to_string()\n            } else {\n                stdout.to_string()\n            };\n            \n            tracing::debug!(\"opencode --version output: {}\", raw.trim());\n            Some(extract_version(&raw))\n        }\n        Ok(output) => {\n            tracing::debug!(\"opencode --version failed with status: {:?}\", output.status);\n            None\n        }\n        Err(e) => {\n            tracing::debug!(\"Failed to run opencode --version: {}\", e);\n            None\n        }\n    }\n}\n\n#[cfg(not(target_os = \"windows\"))]\nfn run_opencode_version(opencode_path: &PathBuf) -> Option<String> {\n    let output = Command::new(opencode_path)\n        .arg(\"--version\")\n        .output();\n    \n    match output {\n        Ok(output) if output.status.success() => {\n            let stdout = String::from_utf8_lossy(&output.stdout);\n            let stderr = String::from_utf8_lossy(&output.stderr);\n            \n            // Some tools output version to stderr\n            let raw = if stdout.trim().is_empty() {\n                stderr.to_string()\n            } else {\n                stdout.to_string()\n            };\n            \n            tracing::debug!(\"opencode --version output: {}\", raw.trim());\n            Some(extract_version(&raw))\n        }\n        Ok(output) => {\n            tracing::debug!(\"opencode --version failed with status: {:?}\", output.status);\n            None\n        }\n        Err(e) => {\n            tracing::debug!(\"Failed to run opencode --version: {}\", e);\n            None\n        }\n    }\n}\n\npub fn check_opencode_installed() -> (bool, Option<String>) {\n    tracing::debug!(\"Checking opencode installation...\");\n    \n    let opencode_path = match resolve_opencode_path() {\n        Some(path) => {\n            tracing::debug!(\"Resolved opencode path: {:?}\", path);\n            path\n        }\n        None => {\n            tracing::debug!(\"Could not resolve opencode path\");\n            return (false, None);\n        }\n    };\n    \n    match run_opencode_version(&opencode_path) {\n        Some(version) => {\n            tracing::debug!(\"opencode version detected: {}\", version);\n            (true, Some(version))\n        }\n        None => {\n            tracing::debug!(\"Failed to get opencode version\");\n            (false, None)\n        }\n    }\n}\n\nfn get_provider_options<'a>(value: &'a Value, provider_name: &str) -> Option<&'a Value> {\n    value.get(\"provider\")\n        .and_then(|p| p.get(provider_name))\n        .and_then(|prov| prov.get(\"options\"))\n}\n\npub fn get_sync_status(proxy_url: &str) -> (bool, bool, Option<String>) {\n    let Some((config_path, _, _)) = get_config_paths() else {\n        return (false, false, None);\n    };\n\n    let mut is_synced = true;\n    let mut has_backup = false;\n    let mut current_base_url = None;\n\n    let backup_path = config_path.with_file_name(\n        format!(\"{}{}\", OPENCODE_CONFIG_FILE, BACKUP_SUFFIX)\n    );\n    let old_backup_path = config_path.with_file_name(\n        format!(\"{}{}\", OPENCODE_CONFIG_FILE, OLD_BACKUP_SUFFIX)\n    );\n    if backup_path.exists() || old_backup_path.exists() {\n        has_backup = true;\n    }\n\n    if !config_path.exists() {\n        return (false, has_backup, None);\n    }\n\n    let content = match fs::read_to_string(&config_path) {\n        Ok(c) => c,\n        Err(_) => return (false, has_backup, None),\n    };\n\n    let json: Value = serde_json::from_str(&content).unwrap_or_default();\n\n    // Normalize proxy URL for comparison\n    let normalized_proxy = normalize_opencode_base_url(proxy_url);\n\n    // Only check antigravity-manager provider\n    let ag_opts = get_provider_options(&json, ANTIGRAVITY_PROVIDER_ID);\n    let ag_url = ag_opts\n        .and_then(|o| o.get(\"baseURL\"))\n        .and_then(|v| v.as_str());\n    let ag_key = ag_opts\n        .and_then(|o| o.get(\"apiKey\"))\n        .and_then(|v| v.as_str());\n\n    if let (Some(url), Some(_key)) = (ag_url, ag_key) {\n        current_base_url = Some(url.to_string());\n        // Normalize config URL before comparison\n        let normalized_config_url = normalize_opencode_base_url(url);\n        if normalized_config_url != normalized_proxy {\n            is_synced = false;\n        }\n    } else {\n        is_synced = false;\n    }\n\n    (is_synced, has_backup, current_base_url)\n}\n\nfn create_backup(path: &PathBuf) -> Result<(), String> {\n    if !path.exists() {\n        return Ok(());\n    }\n\n    let backup_path = path.with_file_name(format!(\n        \"{}{}\",\n        path.file_name().unwrap_or_default().to_string_lossy(),\n        BACKUP_SUFFIX\n    ));\n\n    if backup_path.exists() {\n        return Ok(());\n    }\n\n    fs::copy(path, &backup_path)\n        .map_err(|e| format!(\"Failed to create backup: {}\", e))?;\n\n    Ok(())\n}\n\nfn restore_backup_to_target(backup_path: &PathBuf, target_path: &PathBuf, label: &str) -> Result<(), String> {\n    if target_path.exists() {\n        fs::remove_file(target_path)\n            .map_err(|e| format!(\"Failed to remove existing {}: {}\", label, e))?;\n    }\n\n    fs::rename(backup_path, target_path)\n        .map_err(|e| format!(\"Failed to restore {}: {}\", label, e))\n}\n\nfn ensure_object(value: &mut Value, key: &str) {\n    let needs_reset = match value.get(key) {\n        None => true,\n        Some(v) if !v.is_object() => true,\n        _ => false,\n    };\n    if needs_reset {\n        value[key] = serde_json::json!({});\n    }\n}\n\nfn ensure_provider_object(provider: &mut serde_json::Map<String, Value>, name: &str) {\n    let needs_reset = match provider.get(name) {\n        None => true,\n        Some(v) if !v.is_object() => true,\n        _ => false,\n    };\n    if needs_reset {\n        provider.insert(name.to_string(), serde_json::json!({}));\n    }\n}\n\nfn merge_provider_options(provider: &mut Value, base_url: &str, api_key: &str) {\n    if provider.get(\"options\").is_none() {\n        provider[\"options\"] = serde_json::json!({});\n    }\n    \n    if let Some(options) = provider.get_mut(\"options\").and_then(|o| o.as_object_mut()) {\n        options.insert(\"baseURL\".to_string(), Value::String(base_url.to_string()));\n        options.insert(\"apiKey\".to_string(), Value::String(api_key.to_string()));\n    }\n}\n\nfn ensure_provider_string_field(provider: &mut Value, key: &str, value: &str) {\n    if let Some(obj) = provider.as_object_mut() {\n        obj.insert(key.to_string(), Value::String(value.to_string()));\n    }\n}\n\n/// Build Claude-style thinking variant with thinkingConfig and thinking\nfn build_claude_thinking_variant(budget: u32) -> Value {\n    serde_json::json!({\n        \"thinkingConfig\": {\n            \"thinkingBudget\": budget\n        },\n        \"thinking\": {\n            \"type\": \"enabled\",\n            \"budget_tokens\": budget,\n            \"budgetTokens\": budget\n        }\n    })\n}\n\n/// Build Gemini 3 style variant with thinkingLevel\nfn build_gemini3_variant(level: &str) -> Value {\n    serde_json::json!({\n        \"thinkingLevel\": level\n    })\n}\n\n/// Build Gemini 2.5 thinking variant with thinkingConfig and thinking\nfn build_gemini25_thinking_variant(budget: u32) -> Value {\n    serde_json::json!({\n        \"thinkingConfig\": {\n            \"thinkingBudget\": budget\n        },\n        \"thinking\": {\n            \"type\": \"enabled\",\n            \"budget_tokens\": budget,\n            \"budgetTokens\": budget\n        }\n    })\n}\n\n/// Build variants object based on variant type\nfn build_variants_object(variant_type: Option<VariantType>) -> Option<Value> {\n    match variant_type {\n        Some(VariantType::ClaudeThinking) => {\n            let mut variants = serde_json::Map::new();\n            variants.insert(\"low\".to_string(), build_claude_thinking_variant(8192));\n            variants.insert(\"medium\".to_string(), build_claude_thinking_variant(16384));\n            variants.insert(\"high\".to_string(), build_claude_thinking_variant(24576));\n            variants.insert(\"max\".to_string(), build_claude_thinking_variant(32768));\n            Some(Value::Object(variants))\n        }\n        Some(VariantType::Gemini3Pro) => {\n            let mut variants = serde_json::Map::new();\n            variants.insert(\"low\".to_string(), build_gemini3_variant(\"low\"));\n            variants.insert(\"high\".to_string(), build_gemini3_variant(\"high\"));\n            Some(Value::Object(variants))\n        }\n        Some(VariantType::Gemini3Flash) => {\n            let mut variants = serde_json::Map::new();\n            variants.insert(\"minimal\".to_string(), build_gemini3_variant(\"minimal\"));\n            variants.insert(\"low\".to_string(), build_gemini3_variant(\"low\"));\n            variants.insert(\"medium\".to_string(), build_gemini3_variant(\"medium\"));\n            variants.insert(\"high\".to_string(), build_gemini3_variant(\"high\"));\n            Some(Value::Object(variants))\n        }\n        Some(VariantType::Gemini25Thinking) => {\n            let mut variants = serde_json::Map::new();\n            variants.insert(\"low\".to_string(), build_gemini25_thinking_variant(8192));\n            variants.insert(\"medium\".to_string(), build_gemini25_thinking_variant(12288));\n            variants.insert(\"high\".to_string(), build_gemini25_thinking_variant(16384));\n            variants.insert(\"max\".to_string(), build_gemini25_thinking_variant(24576));\n            Some(Value::Object(variants))\n        }\n        None => None,\n    }\n}\n\n/// Build model JSON object with full metadata\nfn build_model_json(model_def: &ModelDef) -> Value {\n    let mut model_obj = serde_json::Map::new();\n    \n    model_obj.insert(\"name\".to_string(), Value::String(model_def.name.to_string()));\n    \n    let limits = serde_json::json!({\n        \"context\": model_def.context_limit,\n        \"output\": model_def.output_limit,\n    });\n    model_obj.insert(\"limit\".to_string(), limits);\n    \n    let modalities = serde_json::json!({\n        \"input\": model_def.input_modalities,\n        \"output\": model_def.output_modalities,\n    });\n    model_obj.insert(\"modalities\".to_string(), modalities);\n    \n    if model_def.reasoning {\n        model_obj.insert(\"reasoning\".to_string(), Value::Bool(true));\n    }\n    \n    // Build variants as object map instead of array\n    if let Some(variants) = build_variants_object(model_def.variant_type) {\n        model_obj.insert(\"variants\".to_string(), variants);\n    }\n    \n    Value::Object(model_obj)\n}\n\n/// Merge catalog models into provider.models without deleting user models\nfn merge_catalog_models(provider: &mut Value, model_ids: Option<&[&str]>) {\n    if provider.get(\"models\").is_none() {\n        provider[\"models\"] = serde_json::json!({});\n    }\n    \n    let catalog = build_model_catalog();\n    let catalog_map: HashMap<&str, &ModelDef> = catalog.iter().map(|m| (m.id, m)).collect();\n    \n    if let Some(models) = provider.get_mut(\"models\").and_then(|m| m.as_object_mut()) {\n        let ids_to_sync: Vec<&str> = match model_ids {\n            Some(ids) => ids.to_vec(),\n            None => catalog_map.keys().copied().collect(),\n        };\n        \n        for model_id in ids_to_sync {\n            if let Some(model_def) = catalog_map.get(model_id) {\n                let catalog_model = build_model_json(model_def);\n                \n                if let Some(existing) = models.get(model_id) {\n                    // Merge: keep user-defined fields, update catalog fields\n                    if let Some(existing_obj) = existing.as_object() {\n                        let mut merged = existing_obj.clone();\n                        \n                        // Update/insert catalog fields\n                        if let Some(catalog_obj) = catalog_model.as_object() {\n                            for (key, value) in catalog_obj.iter() {\n                                merged.insert(key.clone(), value.clone());\n                            }\n                        }\n                        \n                        models.insert(model_id.to_string(), Value::Object(merged));\n                    } else {\n                        // Existing is not an object, replace with catalog\n                        models.insert(model_id.to_string(), catalog_model);\n                    }\n                } else {\n                    // Model doesn't exist, insert full catalog entry\n                    models.insert(model_id.to_string(), catalog_model);\n                }\n            }\n        }\n    }\n}\n\npub fn sync_opencode_config(\n    proxy_url: &str,\n    api_key: &str,\n    sync_accounts: bool,\n    models_to_sync: Option<Vec<String>>,\n) -> Result<(), String> {\n    let Some((config_path, _ag_config_path, ag_accounts_path)) = get_config_paths() else {\n        return Err(\"Failed to get OpenCode config directory\".to_string());\n    };\n\n    if let Some(parent) = config_path.parent() {\n        fs::create_dir_all(parent).map_err(|e| format!(\"Failed to create directory: {}\", e))?;\n    }\n\n    create_backup(&config_path)?;\n\n    let mut config: Value = if config_path.exists() {\n        fs::read_to_string(&config_path)\n            .ok()\n            .and_then(|c| serde_json::from_str(&c).ok())\n            .unwrap_or_else(|| serde_json::json!({}))\n    } else {\n        serde_json::json!({})\n    };\n\n    let model_refs: Option<Vec<&str>> = models_to_sync\n        .as_ref()\n        .map(|models| models.iter().map(|m| m.as_str()).collect());\n    config = apply_sync_to_config(config, proxy_url, api_key, model_refs.as_deref());\n\n    let tmp_path = config_path.with_extension(\"tmp\");\n    fs::write(&tmp_path, serde_json::to_string_pretty(&config).unwrap())\n        .map_err(|e| format!(\"Failed to write temp file: {}\", e))?;\n    fs::rename(&tmp_path, &config_path)\n        .map_err(|e| format!(\"Failed to rename config file: {}\", e))?;\n\n    if sync_accounts {\n        sync_accounts_file(&ag_accounts_path)?;\n    }\n\n    Ok(())\n}\n\nfn sync_accounts_file(accounts_path: &PathBuf) -> Result<(), String> {\n    create_backup(accounts_path)?;\n\n    // Read existing file for state preservation\n    let existing_content = if accounts_path.exists() {\n        fs::read_to_string(accounts_path).ok()\n    } else {\n        None\n    };\n\n    // Parse existing accounts for state preservation (match by refresh_token first, then email)\n    let mut existing_accounts_by_refresh_token: HashMap<String, PluginAccount> = HashMap::new();\n    let mut existing_accounts_by_email: HashMap<String, PluginAccount> = HashMap::new();\n    let mut existing_active_index: i32 = 0;\n    let mut existing_active_index_by_family: HashMap<String, i32> = HashMap::new();\n\n    if let Some(ref content) = existing_content {\n        if let Ok(existing_json) = serde_json::from_str::<Value>(content) {\n            // Parse existing accounts\n            if let Some(existing_accounts) = existing_json.get(\"accounts\").and_then(|a| a.as_array()) {\n                for acc in existing_accounts {\n                    if let Ok(plugin_acc) = serde_json::from_value::<PluginAccount>(acc.clone()) {\n                        // Index by refresh_token (primary key for matching)\n                        existing_accounts_by_refresh_token.insert(plugin_acc.refresh_token.clone(), plugin_acc.clone());\n                        // Index by email (fallback)\n                        if let Some(email) = &plugin_acc.email {\n                            existing_accounts_by_email.insert(email.clone(), plugin_acc);\n                        }\n                    }\n                }\n            }\n            // Parse existing active indices\n            if let Some(idx) = existing_json.get(\"activeIndex\").and_then(|v| v.as_i64()) {\n                existing_active_index = idx as i32;\n            }\n            if let Some(family_indices) = existing_json.get(\"activeIndexByFamily\").and_then(|v| v.as_object()) {\n                for (key, val) in family_indices {\n                    if let Some(idx) = val.as_i64() {\n                        existing_active_index_by_family.insert(key.clone(), idx as i32);\n                    }\n                }\n            }\n        }\n    }\n\n    let app_accounts = crate::modules::account::list_accounts()\n        .map_err(|e| format!(\"Failed to list accounts: {}\", e))?;\n\n    let mut new_accounts: Vec<PluginAccount> = Vec::new();\n\n    for acc in app_accounts {\n        // Skip disabled accounts (preserve existing logic)\n        if acc.disabled || acc.proxy_disabled {\n            continue;\n        }\n\n        let refresh_token = acc.token.refresh_token.clone();\n        let project_id = acc.token.project_id.clone();\n\n        // Try to find existing account state (match by refresh_token first, then email fallback)\n        let existing = existing_accounts_by_refresh_token\n            .get(&refresh_token)\n            .cloned()\n            .or_else(|| existing_accounts_by_email.get(&acc.email).cloned());\n\n        let plugin_account = if let Some(existing) = existing {\n                // Preserve existing state\n                PluginAccount {\n                    email: Some(acc.email),\n                    refresh_token,\n                    project_id,\n                    added_at: existing.added_at,\n                    last_used: existing.last_used.max(acc.last_used),\n                    rate_limit_reset_times: existing.rate_limit_reset_times,\n                    managed_project_id: existing.managed_project_id,\n                    enabled: existing.enabled,\n                last_switch_reason: existing.last_switch_reason,\n                cooling_down_until: existing.cooling_down_until,\n                cooldown_reason: existing.cooldown_reason,\n                fingerprint: existing.fingerprint,\n                cached_quota: existing.cached_quota,\n                cached_quota_updated_at: existing.cached_quota_updated_at,\n                fingerprint_history: existing.fingerprint_history,\n            }\n        } else {\n            // New account - use defaults\n            let now = chrono::Utc::now().timestamp_millis();\n            PluginAccount {\n                email: Some(acc.email),\n                refresh_token,\n                project_id,\n                added_at: now,\n                last_used: acc.last_used,\n                rate_limit_reset_times: None,\n                managed_project_id: None,\n                enabled: None,\n                last_switch_reason: None,\n                cooling_down_until: None,\n                cooldown_reason: None,\n                fingerprint: None,\n                cached_quota: None,\n                cached_quota_updated_at: None,\n                fingerprint_history: None,\n            }\n        };\n\n        new_accounts.push(plugin_account);\n    }\n\n    // Clamp activeIndex to valid range\n    let account_count = new_accounts.len() as i32;\n    let clamped_active_index = if account_count > 0 {\n        existing_active_index.clamp(0, account_count - 1)\n    } else {\n        0\n    };\n\n    // Clamp activeIndexByFamily values\n    let mut clamped_active_index_by_family = HashMap::new();\n    for (family, idx) in existing_active_index_by_family {\n        let clamped_idx = if account_count > 0 {\n            idx.clamp(0, account_count - 1)\n        } else {\n            0\n        };\n        clamped_active_index_by_family.insert(family, clamped_idx);\n    }\n\n    // Ensure family indices always exist for plugin v3 behavior.\n    if !clamped_active_index_by_family.contains_key(\"claude\") {\n        clamped_active_index_by_family.insert(\"claude\".to_string(), clamped_active_index);\n    }\n    if !clamped_active_index_by_family.contains_key(\"gemini\") {\n        clamped_active_index_by_family.insert(\"gemini\".to_string(), clamped_active_index);\n    }\n\n    // Build schema v3 output\n    let new_data = PluginAccountsFile {\n        version: 3,\n        accounts: new_accounts,\n        active_index: clamped_active_index,\n        active_index_by_family: clamped_active_index_by_family,\n    };\n\n    let tmp_path = accounts_path.with_extension(\"tmp\");\n    fs::write(&tmp_path, serde_json::to_string_pretty(&new_data).unwrap())\n        .map_err(|e| format!(\"Failed to write accounts temp file: {}\", e))?;\n    fs::rename(&tmp_path, accounts_path)\n        .map_err(|e| format!(\"Failed to rename accounts file: {}\", e))?;\n\n    Ok(())\n}\n\npub fn restore_opencode_config() -> Result<(), String> {\n    let Some((config_path, _, accounts_path)) = get_config_paths() else {\n        return Err(\"Failed to get OpenCode config directory\".to_string());\n    };\n\n    let mut restored = false;\n\n    // Try new backup suffix first, fall back to old suffix for backward compatibility\n    let config_backup_new = config_path.with_file_name(format!(\n        \"{}{}\", OPENCODE_CONFIG_FILE, BACKUP_SUFFIX\n    ));\n    let config_backup_old = config_path.with_file_name(format!(\n        \"{}{}\", OPENCODE_CONFIG_FILE, OLD_BACKUP_SUFFIX\n    ));\n    \n    if config_backup_new.exists() {\n        restore_backup_to_target(&config_backup_new, &config_path, \"config\")?;\n        restored = true;\n    } else if config_backup_old.exists() {\n        restore_backup_to_target(&config_backup_old, &config_path, \"config\")?;\n        restored = true;\n    }\n\n    // Try new backup suffix first, fall back to old suffix for backward compatibility\n    let accounts_backup_new = accounts_path.with_file_name(format!(\n        \"{}{}\", ANTIGRAVITY_ACCOUNTS_FILE, BACKUP_SUFFIX\n    ));\n    let accounts_backup_old = accounts_path.with_file_name(format!(\n        \"{}{}\", ANTIGRAVITY_ACCOUNTS_FILE, OLD_BACKUP_SUFFIX\n    ));\n    \n    if accounts_backup_new.exists() {\n        restore_backup_to_target(&accounts_backup_new, &accounts_path, \"accounts\")?;\n        restored = true;\n    } else if accounts_backup_old.exists() {\n        restore_backup_to_target(&accounts_backup_old, &accounts_path, \"accounts\")?;\n        restored = true;\n    }\n\n    if restored {\n        Ok(())\n    } else {\n        Err(\"No backup files found\".to_string())\n    }\n}\n\n/// Pure function: Apply sync logic to config JSON\n/// Returns the modified config Value\nfn apply_sync_to_config(\n    mut config: Value,\n    proxy_url: &str,\n    api_key: &str,\n    models_to_sync: Option<&[&str]>,\n) -> Value {\n    if !config.is_object() {\n        config = serde_json::json!({});\n    }\n\n    if config.get(\"$schema\").is_none() {\n        config[\"$schema\"] = Value::String(\"https://opencode.ai/config.json\".to_string());\n    }\n\n    let normalized_url = normalize_opencode_base_url(proxy_url);\n\n    ensure_object(&mut config, \"provider\");\n\n    if let Some(provider) = config.get_mut(\"provider\").and_then(|p| p.as_object_mut()) {\n        ensure_provider_object(provider, ANTIGRAVITY_PROVIDER_ID);\n        if let Some(ag_provider) = provider.get_mut(ANTIGRAVITY_PROVIDER_ID) {\n            ensure_provider_string_field(ag_provider, \"npm\", \"@ai-sdk/anthropic\");\n            ensure_provider_string_field(ag_provider, \"name\", \"Antigravity Manager\");\n            merge_provider_options(ag_provider, &normalized_url, api_key);\n            merge_catalog_models(ag_provider, models_to_sync);\n        }\n    }\n\n    config\n}\n\n/// Pure function: Apply clear logic to config JSON\n/// Returns the modified config Value\nfn apply_clear_to_config(\n    mut config: Value,\n    proxy_url: Option<&str>,\n    clear_legacy: bool,\n) -> Value {\n    if let Some(provider) = config.get_mut(\"provider\").and_then(|p| p.as_object_mut()) {\n        // 1. Remove antigravity-manager provider\n        provider.remove(ANTIGRAVITY_PROVIDER_ID);\n\n        // 2. Cleanup legacy entries if requested\n        if clear_legacy {\n            if let Some(proxy) = proxy_url {\n                // Clean up provider.anthropic\n                if let Some(anthropic) = provider.get_mut(\"anthropic\") {\n                    cleanup_legacy_provider(anthropic, proxy);\n                }\n\n                // Clean up provider.google\n                if let Some(google) = provider.get_mut(\"google\") {\n                    cleanup_legacy_provider(google, proxy);\n                }\n            }\n        }\n\n        // Remove empty provider object if it has no entries\n        if provider.is_empty() {\n            if let Some(config_obj) = config.as_object_mut() {\n                config_obj.remove(\"provider\");\n            }\n        }\n    }\n\n    config\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_extract_version_opencode_format() {\n        let input = \"opencode/1.2.3\";\n        assert_eq!(extract_version(input), \"1.2.3\");\n    }\n\n    #[test]\n    fn test_extract_version_codex_cli_format() {\n        let input = \"codex-cli 0.86.0\\n\";\n        assert_eq!(extract_version(input), \"0.86.0\");\n    }\n\n    #[test]\n    fn test_extract_version_simple() {\n        let input = \"v2.0.1\";\n        assert_eq!(extract_version(input), \"2.0.1\");\n    }\n\n    #[test]\n    fn test_extract_version_unknown() {\n        let input = \"some random text without version\";\n        assert_eq!(extract_version(input), \"unknown\");\n    }\n\n    #[test]\n    fn test_normalize_opencode_base_url_without_v1() {\n        assert_eq!(normalize_opencode_base_url(\"http://localhost:3000\"), \"http://localhost:3000/v1\");\n        assert_eq!(normalize_opencode_base_url(\"http://localhost:3000/\"), \"http://localhost:3000/v1\");\n    }\n\n    #[test]\n    fn test_normalize_opencode_base_url_with_v1() {\n        assert_eq!(normalize_opencode_base_url(\"http://localhost:3000/v1\"), \"http://localhost:3000/v1\");\n        assert_eq!(normalize_opencode_base_url(\"http://localhost:3000/v1/\"), \"http://localhost:3000/v1\");\n    }\n\n    #[test]\n    fn test_normalize_opencode_base_url_with_whitespace() {\n        assert_eq!(normalize_opencode_base_url(\"  http://localhost:3000  \"), \"http://localhost:3000/v1\");\n        assert_eq!(normalize_opencode_base_url(\"  http://localhost:3000/v1  \"), \"http://localhost:3000/v1\");\n    }\n\n    #[test]\n    fn test_normalize_opencode_base_url_no_double_v1() {\n        // Ensure we don't create double /v1/v1\n        assert_eq!(normalize_opencode_base_url(\"http://localhost:3000/v1\"), \"http://localhost:3000/v1\");\n        assert_eq!(normalize_opencode_base_url(\"http://localhost:3000/v1/\"), \"http://localhost:3000/v1\");\n    }\n\n    // Tests for apply_sync_to_config\n\n    #[test]\n    fn test_sync_preserves_existing_providers() {\n        // Config with existing google and anthropic providers\n        let config = serde_json::json!({\n            \"provider\": {\n                \"google\": {\n                    \"options\": { \"apiKey\": \"google-key\" },\n                    \"models\": { \"gemini-pro\": { \"name\": \"Gemini Pro\" } }\n                },\n                \"anthropic\": {\n                    \"options\": { \"apiKey\": \"anthropic-key\" },\n                    \"models\": { \"claude-3\": { \"name\": \"Claude 3\" } }\n                }\n            }\n        });\n\n        let result = apply_sync_to_config(config, \"http://localhost:3000\", \"test-api-key\", None);\n\n        // Existing providers should be preserved\n        let provider = result.get(\"provider\").unwrap();\n        assert!(provider.get(\"google\").is_some(), \"google provider should be preserved\");\n        assert!(provider.get(\"anthropic\").is_some(), \"anthropic provider should be preserved\");\n        assert_eq!(\n            provider.get(\"google\").unwrap().get(\"options\").unwrap().get(\"apiKey\").unwrap(),\n            \"google-key\"\n        );\n        assert_eq!(\n            provider.get(\"anthropic\").unwrap().get(\"options\").unwrap().get(\"apiKey\").unwrap(),\n            \"anthropic-key\"\n        );\n    }\n\n    #[test]\n    fn test_sync_creates_antigravity_provider() {\n        let config = serde_json::json!({});\n\n        let result = apply_sync_to_config(config, \"http://localhost:3000\", \"test-api-key\", None);\n\n        // antigravity-manager provider should be created\n        let provider = result.get(\"provider\").unwrap();\n        let ag = provider.get(ANTIGRAVITY_PROVIDER_ID).unwrap();\n\n        // Check npm and name\n        assert_eq!(ag.get(\"npm\").unwrap(), \"@ai-sdk/anthropic\");\n        assert_eq!(ag.get(\"name\").unwrap(), \"Antigravity Manager\");\n\n        // Check options\n        let options = ag.get(\"options\").unwrap();\n        assert_eq!(options.get(\"baseURL\").unwrap(), \"http://localhost:3000/v1\");\n        assert_eq!(options.get(\"apiKey\").unwrap(), \"test-api-key\");\n    }\n\n    #[test]\n    fn test_sync_creates_models() {\n        let config = serde_json::json!({});\n\n        let result = apply_sync_to_config(config, \"http://localhost:3000\", \"test-api-key\", None);\n\n        let provider = result.get(\"provider\").unwrap();\n        let ag = provider.get(ANTIGRAVITY_PROVIDER_ID).unwrap();\n        let models = ag.get(\"models\").unwrap().as_object().unwrap();\n\n        // Should have all catalog models\n        assert!(models.contains_key(\"claude-sonnet-4-6\"), \"should have claude-sonnet-4-6\");\n        assert!(models.contains_key(\"gemini-3.1-pro-high\"), \"should have gemini-3.1-pro-high\");\n        assert!(models.contains_key(\"gemini-2.5-pro\"), \"should have gemini-2.5-pro\");\n\n        // Check model structure\n        let claude_model = models.get(\"claude-sonnet-4-6\").unwrap();\n        assert_eq!(claude_model.get(\"name\").unwrap(), \"Claude Sonnet 4.6\");\n        assert!(claude_model.get(\"limit\").is_some());\n        assert!(claude_model.get(\"modalities\").is_some());\n    }\n\n    #[test]\n    fn test_sync_with_filtered_models() {\n        let config = serde_json::json!({});\n        let models_to_sync = &[\"claude-sonnet-4-6\", \"gemini-3.1-pro-high\"];\n\n        let result = apply_sync_to_config(config, \"http://localhost:3000\", \"test-api-key\", Some(models_to_sync));\n\n        let provider = result.get(\"provider\").unwrap();\n        let ag = provider.get(ANTIGRAVITY_PROVIDER_ID).unwrap();\n        let models = ag.get(\"models\").unwrap().as_object().unwrap();\n\n        assert!(models.contains_key(\"claude-sonnet-4-6\"));\n        assert!(models.contains_key(\"gemini-3.1-pro-high\"));\n        assert!(!models.contains_key(\"gemini-2.5-pro\"), \"should not have unselected models\");\n    }\n\n    // Tests for apply_clear_to_config\n\n    #[test]\n    fn test_clear_removes_antigravity_provider() {\n        let config = serde_json::json!({\n            \"provider\": {\n                \"antigravity-manager\": {\n                    \"options\": { \"baseURL\": \"http://localhost:3000/v1\" }\n                },\n                \"google\": { \"options\": { \"apiKey\": \"key\" } }\n            }\n        });\n\n        let result = apply_clear_to_config(config, None, false);\n\n        let provider = result.get(\"provider\").unwrap();\n        assert!(provider.get(ANTIGRAVITY_PROVIDER_ID).is_none(), \"antigravity-manager should be removed\");\n        assert!(provider.get(\"google\").is_some(), \"google should be preserved\");\n    }\n\n    #[test]\n    fn test_clear_legacy_removes_antigravity_models() {\n        let config = serde_json::json!({\n            \"provider\": {\n                \"anthropic\": {\n                    \"options\": { \"baseURL\": \"http://localhost:3000/v1\", \"apiKey\": \"key\" },\n                    \"models\": {\n                        \"claude-sonnet-4-5\": { \"name\": \"Claude\" },\n                        \"claude-3\": { \"name\": \"Claude 3\" }\n                    }\n                }\n            }\n        });\n\n        let result = apply_clear_to_config(config, Some(\"http://localhost:3000\"), true);\n\n        let provider = result.get(\"provider\").unwrap();\n        let anthropic = provider.get(\"anthropic\").unwrap();\n        let models = anthropic.get(\"models\").unwrap().as_object().unwrap();\n\n        // Antigravity model IDs should be removed\n        assert!(!models.contains_key(\"claude-sonnet-4-5\"), \"antigravity model should be removed\");\n        // Non-antigravity models should be preserved\n        assert!(models.contains_key(\"claude-3\"), \"non-antigravity model should be preserved\");\n    }\n\n    #[test]\n    fn test_clear_legacy_removes_options_when_baseurl_matches() {\n        let config = serde_json::json!({\n            \"provider\": {\n                \"anthropic\": {\n                    \"options\": { \"baseURL\": \"http://localhost:3000/v1\", \"apiKey\": \"key\" }\n                }\n            }\n        });\n\n        let result = apply_clear_to_config(config, Some(\"http://localhost:3000\"), true);\n\n        let provider = result.get(\"provider\").unwrap();\n        let anthropic = provider.get(\"anthropic\").unwrap();\n\n        // Options should be removed when baseURL matches\n        assert!(anthropic.get(\"options\").is_none(), \"options should be removed when baseURL matches\");\n    }\n\n    #[test]\n    fn test_clear_legacy_preserves_options_when_baseurl_different() {\n        let config = serde_json::json!({\n            \"provider\": {\n                \"anthropic\": {\n                    \"options\": { \"baseURL\": \"http://other-proxy.com/v1\", \"apiKey\": \"key\" }\n                }\n            }\n        });\n\n        let result = apply_clear_to_config(config, Some(\"http://localhost:3000\"), true);\n\n        let provider = result.get(\"provider\").unwrap();\n        let anthropic = provider.get(\"anthropic\").unwrap();\n        let options = anthropic.get(\"options\").unwrap();\n\n        // Options should be preserved when baseURL doesn't match\n        assert_eq!(options.get(\"baseURL\").unwrap(), \"http://other-proxy.com/v1\");\n        assert_eq!(options.get(\"apiKey\").unwrap(), \"key\");\n    }\n\n    #[test]\n    fn test_clear_legacy_without_proxy_url_skips_cleanup() {\n        let config = serde_json::json!({\n            \"provider\": {\n                \"anthropic\": {\n                    \"options\": { \"baseURL\": \"http://localhost:3000/v1\", \"apiKey\": \"key\" },\n                    \"models\": { \"claude-sonnet-4-5\": { \"name\": \"Claude\" } }\n                }\n            }\n        });\n\n        // clear_legacy=true but no proxy_url provided\n        let result = apply_clear_to_config(config, None, true);\n\n        let provider = result.get(\"provider\").unwrap();\n        let anthropic = provider.get(\"anthropic\").unwrap();\n\n        // Legacy cleanup should be skipped when proxy_url is None\n        assert!(anthropic.get(\"options\").is_some(), \"options should be preserved when no proxy_url\");\n        assert!(anthropic.get(\"models\").is_some(), \"models should be preserved when no proxy_url\");\n    }\n\n    // Tests for base_url_matches\n\n    #[test]\n    fn test_base_url_matches_with_v1() {\n        assert!(base_url_matches(\"http://localhost:3000/v1\", \"http://localhost:3000\"));\n        assert!(base_url_matches(\"http://localhost:3000\", \"http://localhost:3000/v1\"));\n        assert!(base_url_matches(\"http://localhost:3000/v1/\", \"http://localhost:3000\"));\n    }\n\n    #[test]\n    fn test_base_url_matches_without_v1() {\n        assert!(base_url_matches(\"http://localhost:3000\", \"http://localhost:3000\"));\n        assert!(base_url_matches(\"http://localhost:3000/\", \"http://localhost:3000/\"));\n    }\n\n    #[test]\n    fn test_base_url_matches_different_urls() {\n        assert!(!base_url_matches(\"http://localhost:3000\", \"http://other-host:3000\"));\n        assert!(!base_url_matches(\"http://localhost:3000/v1\", \"http://localhost:4000/v1\"));\n    }\n\n    #[test]\n    fn test_clear_removes_empty_provider() {\n        let config = serde_json::json!({\n            \"provider\": {\n                \"antigravity-manager\": {\n                    \"options\": { \"baseURL\": \"http://localhost:3000/v1\" }\n                }\n            }\n        });\n\n        let result = apply_clear_to_config(config, None, false);\n\n        // Provider object should be removed when empty\n        assert!(result.get(\"provider\").is_none(), \"empty provider object should be removed\");\n    }\n}\n\npub fn read_opencode_config_content(file_name: Option<String>) -> Result<String, String> {\n    let Some((opencode_path, ag_config_path, ag_accounts_path)) = get_config_paths() else {\n        return Err(\"Failed to get OpenCode config directory\".to_string());\n    };\n\n    // Allowlist of permitted file names\n    let allowed_files = [\n        OPENCODE_CONFIG_FILE,\n        ANTIGRAVITY_CONFIG_FILE,\n        ANTIGRAVITY_ACCOUNTS_FILE,\n    ];\n\n    // Determine which file to read\n    let target_path = match file_name.as_deref() {\n        Some(name) if name == ANTIGRAVITY_CONFIG_FILE => ag_config_path,\n        Some(name) if name == ANTIGRAVITY_ACCOUNTS_FILE => ag_accounts_path,\n        Some(name) if name == OPENCODE_CONFIG_FILE => opencode_path,\n        Some(name) => {\n            return Err(format!(\n                \"Invalid file name: {}. Allowed: {:?}\",\n                name, allowed_files\n            ))\n        }\n        None => opencode_path, // Default to opencode.json\n    };\n\n    if !target_path.exists() {\n        return Err(format!(\"Config file does not exist: {:?}\", target_path));\n    }\n\n    fs::read_to_string(&target_path)\n        .map_err(|e| format!(\"Failed to read config: {}\", e))\n}\n\n#[tauri::command]\npub async fn get_opencode_sync_status(proxy_url: String) -> Result<OpencodeStatus, String> {\n    let (installed, version) = check_opencode_installed();\n    let (is_synced, has_backup, current_base_url) = get_sync_status(&proxy_url);\n\n    Ok(OpencodeStatus {\n        installed,\n        version,\n        is_synced,\n        has_backup,\n        current_base_url,\n        files: vec![\n            OPENCODE_CONFIG_FILE.to_string(),\n            ANTIGRAVITY_CONFIG_FILE.to_string(),\n            ANTIGRAVITY_ACCOUNTS_FILE.to_string(),\n        ],\n    })\n}\n\n#[tauri::command]\npub async fn execute_opencode_sync(\n    proxy_url: String,\n    api_key: String,\n    sync_accounts: Option<bool>,\n    models: Option<Vec<String>>,\n) -> Result<(), String> {\n    sync_opencode_config(&proxy_url, &api_key, sync_accounts.unwrap_or(false), models)\n}\n\n#[tauri::command]\npub async fn execute_opencode_restore() -> Result<(), String> {\n    restore_opencode_config()\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct GetOpencodeConfigRequest {\n    pub file_name: Option<String>,\n}\n\n#[tauri::command]\npub async fn get_opencode_config_content(request: GetOpencodeConfigRequest) -> Result<String, String> {\n    read_opencode_config_content(request.file_name)\n}\n\n/// List of Antigravity model IDs that may have been added to legacy providers\nconst ANTIGRAVITY_MODEL_IDS: &[&str] = &[\n    \"claude-sonnet-4-6\",\n    \"claude-sonnet-4-6-thinking\",\n    \"claude-sonnet-4-5\",\n    \"claude-sonnet-4-5-thinking\",\n    \"claude-opus-4-5-thinking\",\n    \"gemini-3.1-pro-high\",\n    \"gemini-3.1-pro-low\",\n    \"gemini-3-pro-high\",\n    \"gemini-3-pro-low\",\n    \"gemini-3-flash\",\n    \"gemini-3-pro-image\",\n    \"gemini-2.5-flash\",\n    \"gemini-2.5-flash-lite\",\n    \"gemini-2.5-flash-thinking\",\n    \"gemini-2.5-pro\",\n];\n\n/// Check if a base URL matches the proxy URL (supports both with and without /v1)\nfn base_url_matches(config_url: &str, proxy_url: &str) -> bool {\n    let normalized_config = normalize_opencode_base_url(config_url);\n    let normalized_proxy = normalize_opencode_base_url(proxy_url);\n    normalized_config == normalized_proxy\n}\n\n/// Clear OpenCode config by removing antigravity-manager provider and optionally cleaning up legacy entries\nfn clear_opencode_config(proxy_url: Option<String>, clear_legacy: bool) -> Result<(), String> {\n    let Some((config_path, _, accounts_path)) = get_config_paths() else {\n        return Err(\"Failed to get OpenCode config directory\".to_string());\n    };\n\n    // Process opencode.json\n    if config_path.exists() {\n        // Create backup before modifying\n        create_backup(&config_path)?;\n\n        let content = fs::read_to_string(&config_path)\n            .map_err(|e| format!(\"Failed to read config: {}\", e))?;\n        \n        let config: Value = serde_json::from_str(&content)\n            .map_err(|e| format!(\"Failed to parse config: {}\", e))?;\n        let config = apply_clear_to_config(config, proxy_url.as_deref(), clear_legacy);\n\n        // Write updated config\n        let tmp_path = config_path.with_extension(\"tmp\");\n        fs::write(&tmp_path, serde_json::to_string_pretty(&config).unwrap())\n            .map_err(|e| format!(\"Failed to write temp file: {}\", e))?;\n        fs::rename(&tmp_path, &config_path)\n            .map_err(|e| format!(\"Failed to rename config file: {}\", e))?;\n    }\n\n    // Process antigravity-accounts.json\n    let accounts_backup_new = accounts_path.with_file_name(format!(\n        \"{}{}\", ANTIGRAVITY_ACCOUNTS_FILE, BACKUP_SUFFIX\n    ));\n    let accounts_backup_old = accounts_path.with_file_name(format!(\n        \"{}{}\", ANTIGRAVITY_ACCOUNTS_FILE, OLD_BACKUP_SUFFIX\n    ));\n\n    if accounts_backup_new.exists() {\n        // Restore from new backup\n        restore_backup_to_target(&accounts_backup_new, &accounts_path, \"accounts from backup\")?;\n    } else if accounts_backup_old.exists() {\n        // Restore from old backup\n        restore_backup_to_target(&accounts_backup_old, &accounts_path, \"accounts from old backup\")?;\n    } else if accounts_path.exists() {\n        // No backup found, delete the file\n        fs::remove_file(&accounts_path)\n            .map_err(|e| format!(\"Failed to remove accounts file: {}\", e))?;\n    }\n\n    Ok(())\n}\n\n/// Cleanup legacy provider entries (anthropic/google) that were configured by old versions\nfn cleanup_legacy_provider(provider: &mut Value, proxy_url: &str) {\n    if let Some(provider_obj) = provider.as_object_mut() {\n        // Remove Antigravity model IDs from models list.\n        let remove_models_key = if let Some(models) = provider_obj.get_mut(\"models\").and_then(|m| m.as_object_mut()) {\n            for model_id in ANTIGRAVITY_MODEL_IDS {\n                models.remove(*model_id);\n            }\n            models.is_empty()\n        } else {\n            false\n        };\n        if remove_models_key {\n            provider_obj.remove(\"models\");\n        }\n\n        // Check and remove options.baseURL and options.apiKey if baseURL matches proxy.\n        let remove_options_key = if let Some(options) = provider_obj.get_mut(\"options\").and_then(|o| o.as_object_mut()) {\n            let should_cleanup = options\n                .get(\"baseURL\")\n                .and_then(|v| v.as_str())\n                .map(|base_url| base_url_matches(base_url, proxy_url))\n                .unwrap_or(false);\n\n            if should_cleanup {\n                options.remove(\"baseURL\");\n                options.remove(\"apiKey\");\n            }\n            options.is_empty()\n        } else {\n            false\n        };\n        if remove_options_key {\n            provider_obj.remove(\"options\");\n        }\n    }\n}\n\n#[tauri::command]\npub async fn execute_opencode_clear(\n    proxy_url: Option<String>,\n    clear_legacy: Option<bool>,\n) -> Result<(), String> {\n    clear_opencode_config(proxy_url, clear_legacy.unwrap_or(false))\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/project_resolver.rs",
    "content": "use serde_json::Value;\n\n/// 使用 Antigravity 的 loadCodeAssist API 获取 project_id\n/// 这是获取 cloudaicompanionProject 的正确方式\npub async fn fetch_project_id(access_token: &str) -> Result<String, String> {\n    // 使用 Sandbox 环境，避免 Prod 环境的 429 错误\n    let url = \"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist\";\n    \n    let request_body = serde_json::json!({\n        \"metadata\": {\n            \"ideType\": \"ANTIGRAVITY\"\n        }\n    });\n    \n    let client = crate::utils::http::get_client();\n    let response = client\n        .post(url)\n        .bearer_auth(access_token)\n        // .header(\"Host\", \"cloudcode-pa.googleapis.com\") // 移除 Host header，因为已切换域名\n\n        .header(\"User-Agent\", crate::constants::USER_AGENT.as_str())\n        .header(\"Content-Type\", \"application/json\")\n        .json(&request_body)\n        .send()\n        .await\n        .map_err(|e| format!(\"loadCodeAssist 请求失败: {}\", e))?;\n    \n    if !response.status().is_success() {\n        let status = response.status();\n        let body = response.text().await.unwrap_or_default();\n        return Err(format!(\"loadCodeAssist 返回错误 {}: {}\", status, body));\n    }\n    \n    let data: Value = response.json()\n        .await\n        .map_err(|e| format!(\"解析响应失败: {}\", e))?;\n    \n    // 提取 cloudaicompanionProject\n    if let Some(project_id) = data.get(\"cloudaicompanionProject\")\n        .and_then(|v| v.as_str()) {\n        return Ok(project_id.to_string());\n    }\n    \n    // 如果没有返回 project_id，说明账号无资格，返回错误以触发 token_manager 的稳定兜底逻辑\n    Err(\"账号无资格获取官方 cloudaicompanionProject\".to_string())\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/mod.rs",
    "content": "pub mod zai_anthropic;\n\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/zai_anthropic.rs",
    "content": "use axum::{\n    body::Body,\n    http::{header, HeaderMap, HeaderValue, Method, StatusCode},\n    response::{IntoResponse, Response},\n};\nuse bytes::Bytes;\nuse futures::StreamExt;\nuse serde_json::Value;\nuse tokio::time::Duration;\n\nuse crate::proxy::server::AppState;\n\nfn map_model_for_zai(original: &str, state: &crate::proxy::ZaiConfig) -> String {\n    let m = original.to_lowercase();\n    if let Some(mapped) = state.model_mapping.get(original) {\n        return mapped.clone();\n    }\n    if let Some(mapped) = state.model_mapping.get(&m) {\n        return mapped.clone();\n    }\n    if m.starts_with(\"zai:\") {\n        return original[4..].to_string();\n    }\n    if m.starts_with(\"glm-\") {\n        return original.to_string();\n    }\n    if !m.starts_with(\"claude-\") {\n        return original.to_string();\n    }\n    if m.contains(\"opus\") {\n        return state.models.opus.clone();\n    }\n    if m.contains(\"haiku\") {\n        return state.models.haiku.clone();\n    }\n    state.models.sonnet.clone()\n}\n\nfn join_base_url(base: &str, path: &str) -> Result<String, String> {\n    let base = base.trim_end_matches('/');\n    let path = if path.starts_with('/') {\n        path.to_string()\n    } else {\n        format!(\"/{}\", path)\n    };\n    Ok(format!(\"{}{}\", base, path))\n}\n\nfn build_client(\n    upstream_proxy: Option<crate::proxy::config::UpstreamProxyConfig>,\n    timeout_secs: u64,\n) -> Result<reqwest::Client, String> {\n    let mut builder = reqwest::Client::builder()\n        .timeout(Duration::from_secs(timeout_secs.max(5)));\n\n    if let Some(config) = upstream_proxy {\n        if config.enabled && !config.url.is_empty() {\n            let url = crate::proxy::config::normalize_proxy_url(&config.url);\n            let proxy = reqwest::Proxy::all(&url)\n                .map_err(|e| format!(\"Invalid upstream proxy url: {}\", e))?;\n            builder = builder.proxy(proxy);\n        }\n    }\n\n    builder\n        .tcp_nodelay(true) // [FIX #307] Disable Nagle's algorithm to improve latency for small requests\n        .build()\n        .map_err(|e| format!(\"Failed to build HTTP client: {}\", e))\n}\n\nfn copy_passthrough_headers(incoming: &HeaderMap) -> HeaderMap {\n    // Only forward a conservative set of headers to avoid leaking the local proxy key or cookies.\n    let mut out = HeaderMap::new();\n\n    for (k, v) in incoming.iter() {\n        let key = k.as_str().to_ascii_lowercase();\n        match key.as_str() {\n            \"content-type\" | \"accept\" | \"anthropic-version\" | \"user-agent\" => {\n                out.insert(k.clone(), v.clone());\n            }\n            // Some clients use these for streaming; safe to pass through.\n            \"accept-encoding\" | \"cache-control\" => {\n                out.insert(k.clone(), v.clone());\n            }\n            _ => {}\n        }\n    }\n\n    out\n}\n\nfn set_zai_auth(headers: &mut HeaderMap, incoming: &HeaderMap, api_key: &str) {\n    // Prefer to keep the same auth scheme as the incoming request:\n    // - If the client used x-api-key (Anthropic style), replace it.\n    // - Else if it used Authorization, replace it with Bearer.\n    // - Else default to x-api-key.\n    let has_x_api_key = incoming.contains_key(\"x-api-key\");\n    let has_auth = incoming.contains_key(header::AUTHORIZATION);\n\n    if has_x_api_key || !has_auth {\n        if let Ok(v) = HeaderValue::from_str(api_key) {\n            headers.insert(\"x-api-key\", v);\n        }\n    }\n\n    if has_auth {\n        if let Ok(v) = HeaderValue::from_str(&format!(\"Bearer {}\", api_key)) {\n            headers.insert(header::AUTHORIZATION, v);\n        }\n    }\n}\n\n/// Recursively remove cache_control from all nested objects/arrays\n/// [FIX #290] This is a defensive fix that works regardless of serde annotations\npub fn deep_remove_cache_control(value: &mut Value) {\n    match value {\n        Value::Object(map) => {\n            if let Some(v) = map.remove(\"cache_control\") {\n                tracing::info!(\"[ISSUE-744] Deep Cleaning found nested cache_control: {:?}\", v);\n            }\n            for v in map.values_mut() {\n                deep_remove_cache_control(v);\n            }\n        }\n        Value::Array(arr) => {\n            for v in arr {\n                deep_remove_cache_control(v);\n            }\n        }\n        _ => {}\n    }\n}\n\npub async fn forward_anthropic_json(\n    state: &AppState,\n    method: Method,\n    path: &str,\n    incoming_headers: &HeaderMap,\n    mut body: Value,\n    message_count: usize, // [NEW v4.0.0] Pass message count for rewind detection\n) -> Response {\n    let zai = state.zai.read().await.clone();\n    if !zai.enabled || zai.dispatch_mode == crate::proxy::ZaiDispatchMode::Off {\n        return (StatusCode::BAD_REQUEST, \"z.ai is disabled\").into_response();\n    }\n\n    if zai.api_key.trim().is_empty() {\n        return (StatusCode::BAD_REQUEST, \"z.ai api_key is not set\").into_response();\n    }\n\n    if let Some(model) = body.get(\"model\").and_then(|v| v.as_str()) {\n        let mapped = map_model_for_zai(model, &zai);\n        body[\"model\"] = Value::String(mapped.clone());\n\n        // [FIX] Caching for z.ai (to support thinking-filter)\n        if let Some(sig) = body.get(\"thinking\").and_then(|t| t.get(\"signature\")).and_then(|s| s.as_str()) {\n            crate::proxy::SignatureCache::global().cache_session_signature(\n                \"zai-session\", \n                sig.to_string(), \n                message_count\n            );\n            crate::proxy::SignatureCache::global().cache_thinking_family(sig.to_string(), mapped);\n        }\n    }\n\n    let url = match join_base_url(&zai.base_url, path) {\n        Ok(u) => u,\n        Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),\n    };\n\n    let timeout_secs = state.request_timeout.max(5);\n    let upstream_proxy = state.upstream_proxy.read().await.clone();\n    let client = match build_client(Some(upstream_proxy), timeout_secs) {\n        Ok(c) => c,\n        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),\n    };\n\n    let mut headers = copy_passthrough_headers(incoming_headers);\n    set_zai_auth(&mut headers, incoming_headers, &zai.api_key);\n\n    // Ensure JSON content type.\n    headers\n        .entry(header::CONTENT_TYPE)\n        .or_insert(HeaderValue::from_static(\"application/json\"));\n\n    // [FIX #290] Clean cache_control before sending to Anthropic API\n    // This prevents \"Extra inputs are not permitted\" errors\n    if let Some(cc) = body.get(\"cache_control\") {\n        tracing::info!(\"[ISSUE-744] Deep cleaning cache_control from ROOT: {:?}\", cc);\n    }\n    deep_remove_cache_control(&mut body);\n\n    // [FIX #307] Explicitly serialize body to Vec<u8> to ensure Content-Length is set correctly.\n    // This avoids \"Transfer-Encoding: chunked\" for small bodies which caused connection errors.\n    let body_bytes = serde_json::to_vec(&body).unwrap_or_default();\n    let body_len = body_bytes.len();\n    \n    tracing::debug!(\"Forwarding request to z.ai (len: {} bytes): {}\", body_len, url);\n\n    let req = client.request(method, &url)\n        .headers(headers)\n        .body(body_bytes); // Use .body(Vec<u8>) instead of .json()\n\n    let resp = match req.send().await {\n        Ok(r) => r,\n        Err(e) => {\n            return (\n                StatusCode::BAD_GATEWAY,\n                format!(\"Upstream request failed: {}\", e),\n            )\n                .into_response();\n        }\n    };\n\n    let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);\n\n    let mut out = Response::builder().status(status);\n    if let Some(ct) = resp.headers().get(header::CONTENT_TYPE) {\n        out = out.header(header::CONTENT_TYPE, ct.clone());\n    }\n\n    // Stream response body to the client (covers SSE and non-SSE).\n    let stream = resp.bytes_stream().map(|chunk| match chunk {\n        Ok(b) => Ok::<Bytes, std::io::Error>(b),\n        Err(e) => Ok(Bytes::from(format!(\"Upstream stream error: {}\", e))),\n    });\n\n    out.body(Body::from_stream(stream)).unwrap_or_else(|_| {\n        (StatusCode::INTERNAL_SERVER_ERROR, \"Failed to build response\").into_response()\n    })\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/proxy_pool.rs",
    "content": "use std::sync::Arc;\nuse tokio::sync::RwLock;\nuse std::sync::atomic::{AtomicUsize, Ordering};\nuse dashmap::DashMap;\nuse rquest::Client;\nuse futures::{stream, StreamExt};\nuse std::time::Duration;\nuse crate::proxy::config::{ProxyPoolConfig, ProxySelectionStrategy, ProxyEntry};\n\nuse rquest_util::Emulation;\nuse std::sync::OnceLock;\n\n/// 全局代理池管理器单例\npub static GLOBAL_PROXY_POOL: OnceLock<Arc<ProxyPoolManager>> = OnceLock::new();\n\n/// 获取全局代理池管理器\npub fn get_global_proxy_pool() -> Option<Arc<ProxyPoolManager>> {\n    GLOBAL_PROXY_POOL.get().cloned()\n}\n\n/// 初始化全局代理池管理器\npub fn init_global_proxy_pool(config: Arc<RwLock<ProxyPoolConfig>>) -> Arc<ProxyPoolManager> {\n    let manager = Arc::new(ProxyPoolManager::new(config));\n    let _ = GLOBAL_PROXY_POOL.set(manager.clone());\n    manager\n}\n\n/// 代理配置 (用于构建 reqwest Client)\n/// 注意：重命名为 PoolProxyConfig 以避免与 config::ProxyConfig 冲突\n#[derive(Debug, Clone)]\npub struct PoolProxyConfig {\n    pub proxy: rquest::Proxy,\n    pub entry_id: String,\n}\n\n/// 代理池管理器\npub struct ProxyPoolManager {\n    config: Arc<RwLock<ProxyPoolConfig>>,\n    \n    /// 代理使用计数 (proxy_id -> count)\n    usage_counter: Arc<DashMap<String, usize>>,\n    \n    /// 账号到代理的绑定 (account_id -> proxy_id)\n    account_bindings: Arc<DashMap<String, String>>,\n    \n    /// 轮询索引 (用于 RoundRobin 策略)\n    round_robin_index: Arc<AtomicUsize>,\n}\n\nimpl ProxyPoolManager {\n    pub fn new(config: Arc<RwLock<ProxyPoolConfig>>) -> Self {\n        // 从配置中加载已保存的绑定关系\n        let account_bindings = Arc::new(DashMap::new());\n\n        // 使用 blocking 方式读取配置（因为 new 不是 async）\n        // 注意：这里使用 try_read 避免死锁\n        if let Ok(cfg) = config.try_read() {\n            for (account_id, proxy_id) in &cfg.account_bindings {\n                account_bindings.insert(account_id.clone(), proxy_id.clone());\n            }\n            if !cfg.account_bindings.is_empty() {\n                tracing::info!(\"[ProxyPool] Loaded {} account bindings from config\", cfg.account_bindings.len());\n            }\n        }\n\n        Self {\n            config,\n            usage_counter: Arc::new(DashMap::new()),\n            account_bindings,\n            round_robin_index: Arc::new(AtomicUsize::new(0)),\n        }\n    }\n\n    /// [NEW] 为指定账号获取“最终生效”的 HttpClient\n    /// 逻辑：\n    /// 1. 账号显式绑定代理优先 (Account-Proxy Binding)\n    /// 2. 如果无绑定，且开启了“自动全局”，取池中第一个节点\n    /// 3. 如果以上均无，则检查全局上游代理 (Upstream Proxy) [由调用方 fallback]\n    pub async fn get_effective_client(&self, account_id: Option<&str>, timeout_secs: u64) -> Client {\n        let mut builder = Client::builder()\n            .emulation(Emulation::Chrome123)\n            .timeout(Duration::from_secs(timeout_secs));\n        \n        // 尝试获取代理配置\n        let proxy_opt = if let Some(acc_id) = account_id {\n            self.get_proxy_for_account(acc_id).await.ok().flatten()\n        } else {\n            // 没有 account_id 的通用请求，如果代理池启用，则默认从中选择节点作为出口\n            let config = self.config.read().await;\n            if config.enabled {\n                let res = self.select_proxy_from_pool(&config).await.ok().flatten();\n                if let Some(ref p) = res {\n                    tracing::info!(\"[Proxy] Route: Generic Request -> Proxy {} (Pool)\", p.entry_id);\n                } else {\n                    // [FIX #1583] 明确记录池中无可用代理的情况\n                    tracing::warn!(\"[Proxy] Route: Generic Request -> No available proxy in pool, falling back to upstream or direct\");\n                }\n                res\n            } else {\n                tracing::debug!(\"[Proxy] Route: Generic Request -> Proxy pool disabled\");\n                None\n            }\n        };\n\n        if let Some(proxy_cfg) = proxy_opt {\n            builder = builder.proxy(proxy_cfg.proxy);\n            // Already logged more detail in get_proxy_for_account or pool selection\n        } else {\n            // Fallback 到应用配置的单上游代理\n            if let Ok(app_cfg) = crate::modules::config::load_app_config() {\n                let up = app_cfg.proxy.upstream_proxy;\n                if up.enabled && !up.url.is_empty() {\n                    if let Ok(p) = rquest::Proxy::all(&up.url) {\n                        tracing::info!(\"[Proxy] Route: {:?} -> Upstream: {} (AppConfig)\", account_id.unwrap_or(\"Generic\"), up.url);\n                        builder = builder.proxy(p);\n                    }\n                } else {\n                    tracing::info!(\"[Proxy] Route: {:?} -> Direct\", account_id.unwrap_or(\"Generic\"));\n                }\n            }\n        }\n\n        builder.build().unwrap_or_else(|_| Client::new())\n    }\n\n    /// [NEW] 为指定账号获取“最终生效”的无特征 Standard HttpClient (专门用于纯净场景，如 OAuth 退还)\n    pub async fn get_effective_standard_client(&self, account_id: Option<&str>, timeout_secs: u64) -> Client {\n        let mut builder = Client::builder()\n            // 无 Emulation 设置，走纯正的基础 TLS 指纹\n            .timeout(Duration::from_secs(timeout_secs));\n        \n        // 尝试获取代理配置\n        let proxy_opt = if let Some(acc_id) = account_id {\n            self.get_proxy_for_account(acc_id).await.ok().flatten()\n        } else {\n            // 没有 account_id 的通用请求，如果代理池启用，则默认从中选择节点作为出口\n            let config = self.config.read().await;\n            if config.enabled {\n                let res = self.select_proxy_from_pool(&config).await.ok().flatten();\n                if let Some(ref p) = res {\n                    tracing::info!(\"[Proxy] Route: Generic Request (Standard Client) -> Proxy {} (Pool)\", p.entry_id);\n                } else {\n                    tracing::warn!(\"[Proxy] Route: Generic Request (Standard Client) -> No available proxy in pool, falling back to upstream or direct\");\n                }\n                res\n            } else {\n                tracing::debug!(\"[Proxy] Route: Generic Request (Standard Client) -> Proxy pool disabled\");\n                None\n            }\n        };\n\n        if let Some(proxy_cfg) = proxy_opt {\n            builder = builder.proxy(proxy_cfg.proxy);\n        } else {\n            // Fallback 到应用配置的单上游代理\n            if let Ok(app_cfg) = crate::modules::config::load_app_config() {\n                let up = app_cfg.proxy.upstream_proxy;\n                if up.enabled && !up.url.is_empty() {\n                    if let Ok(p) = rquest::Proxy::all(&up.url) {\n                        tracing::info!(\"[Proxy] Route: {:?} (Standard Client) -> Upstream: {} (AppConfig)\", account_id.unwrap_or(\"Generic\"), up.url);\n                        builder = builder.proxy(p);\n                    }\n                } else {\n                    tracing::info!(\"[Proxy] Route: {:?} (Standard Client) -> Direct\", account_id.unwrap_or(\"Generic\"));\n                }\n            }\n        }\n\n        builder.build().unwrap_or_else(|_| Client::new())\n    }\n\n    /// 为账号获取代理\n    pub async fn get_proxy_for_account(\n        &self,\n        account_id: &str,\n    ) -> Result<Option<PoolProxyConfig>, String> {\n        let config = self.config.read().await;\n        \n        if !config.enabled || config.proxies.is_empty() {\n            return Ok(None);\n        }\n        \n        // 1. 优先使用账号绑定 (专属 IP)\n        if let Some(proxy) = self.get_bound_proxy(account_id, &config).await? {\n            tracing::info!(\"[Proxy] Route: Account {} -> Proxy {} (Bound)\", account_id, proxy.entry_id);\n            return Ok(Some(proxy));\n        }\n\n        // 2. 否则从池中策略选择 (公用池)\n        let res = self.select_proxy_from_pool(&config).await?;\n        if let Some(ref p) = res {\n            tracing::info!(\"[Proxy] Route: Account {} -> Proxy {} (Pool)\", account_id, p.entry_id);\n        }\n        Ok(res)\n    }\n    \n    /// 获取账号绑定的代理\n    async fn get_bound_proxy(\n        &self,\n        account_id: &str,\n        config: &ProxyPoolConfig,\n    ) -> Result<Option<PoolProxyConfig>, String> {\n        if let Some(proxy_id) = self.account_bindings.get(account_id) {\n            if let Some(entry) = config.proxies.iter().find(|p| p.id == *proxy_id.value()) {\n                if entry.enabled {\n                    // 如果开启了自动故障转移且代理不健康，则返回 None (将回退到其他策略或失败)\n                    if config.auto_failover && !entry.is_healthy {\n                        return Ok(None);\n                    }\n                    return Ok(Some(self.build_proxy_config(entry)?));\n                }\n            }\n        }\n        Ok(None)\n    }\n    \n    /// 从代理池中选择代理\n    async fn select_proxy_from_pool(\n        &self,\n        config: &ProxyPoolConfig,\n    ) -> Result<Option<PoolProxyConfig>, String> {\n        // [FIX] 专属隔离逻辑：剔除所有已被绑定的代理，保护专属 IP 账号的安全\n        let bound_ids: std::collections::HashSet<String> = self.account_bindings\n            .iter()\n            .map(|kv| kv.value().clone())\n            .collect();\n\n        let healthy_proxies: Vec<_> = config.proxies.iter()\n            .filter(|p| {\n                if !p.enabled { return false; }\n                if config.auto_failover && !p.is_healthy { return false; }\n                // 如果该代理已被某个账号“专属绑定”，则不再参与公用轮询\n                if bound_ids.contains(&p.id) { return false; }\n                true\n            })\n            .collect();\n        \n        if healthy_proxies.is_empty() {\n             // 如果所有代理都被绑定了，或者池本身为空，尝试返回池中开启了且不依赖绑定的代理\n             // (这里可以根据业务进一步调整，目前保持严谨隔离)\n            return Ok(None);\n        }\n        \n        let selected = match config.strategy {\n            ProxySelectionStrategy::RoundRobin => {\n                self.select_round_robin(&healthy_proxies)\n            }\n            ProxySelectionStrategy::Random => {\n                self.select_random(&healthy_proxies)\n            }\n            ProxySelectionStrategy::Priority => {\n                self.select_by_priority(&healthy_proxies)\n            }\n            ProxySelectionStrategy::LeastConnections => {\n                self.select_least_connections(&healthy_proxies)\n            }\n            ProxySelectionStrategy::WeightedRoundRobin => {\n                self.select_weighted(&healthy_proxies)\n            }\n        };\n        \n        if let Some(entry) = selected {\n            // 更新计数\n            *self.usage_counter.entry(entry.id.clone()).or_insert(0) += 1;\n            Ok(Some(self.build_proxy_config(entry)?))\n        } else {\n            Ok(None)\n        }\n    }\n    \n    fn select_round_robin<'a>(&self, proxies: &[&'a ProxyEntry]) -> Option<&'a ProxyEntry> {\n        if proxies.is_empty() { return None; }\n        let index = self.round_robin_index.fetch_add(1, Ordering::Relaxed);\n        Some(proxies[index % proxies.len()])\n    }\n\n    fn select_random<'a>(&self, proxies: &[&'a ProxyEntry]) -> Option<&'a ProxyEntry> {\n        if proxies.is_empty() { return None; }\n        use rand::seq::SliceRandom;\n        let mut rng = rand::thread_rng();\n        proxies.choose(&mut rng).copied()\n    }\n    \n    fn select_by_priority<'a>(&self, proxies: &[&'a ProxyEntry]) -> Option<&'a ProxyEntry> {\n        // priority 越小越优先\n        proxies.iter().min_by_key(|p| p.priority).copied()\n    }\n    \n    fn select_least_connections<'a>(&self, proxies: &[&'a ProxyEntry]) -> Option<&'a ProxyEntry> {\n        proxies.iter().min_by_key(|p| {\n            self.usage_counter.get(&p.id).map(|v| *v).unwrap_or(0)\n        }).copied()\n    }\n    \n    fn select_weighted<'a>(&self, proxies: &[&'a ProxyEntry]) -> Option<&'a ProxyEntry> {\n        // 简单实现: 类似优先级的加权, 这里暂用 Priority 代替\n        self.select_by_priority(proxies)\n    }\n\n    /// 构建 reqwest::Proxy 配置\n    fn build_proxy_config(&self, entry: &ProxyEntry) -> Result<PoolProxyConfig, String> {\n        let url = crate::proxy::config::normalize_proxy_url(&entry.url);\n\n        let mut proxy = rquest::Proxy::all(&url)\n            .map_err(|e| format!(\"Invalid proxy URL: {}\", e))?;\n        \n        // 添加认证\n        if let Some(auth) = &entry.auth {\n            proxy = proxy.basic_auth(&auth.username, &auth.password);\n        }\n        \n        Ok(PoolProxyConfig {\n            proxy,\n            entry_id: entry.id.clone(),\n        })\n    }\n    \n    /// 绑定账号到代理\n    pub async fn bind_account_to_proxy(\n        &self,\n        account_id: String,\n        proxy_id: String,\n    ) -> Result<(), String> {\n        // 检查代理是否存在\n        {\n            let config = self.config.read().await;\n            if !config.proxies.iter().any(|p| p.id == proxy_id) {\n                return Err(format!(\"Proxy {} not found\", proxy_id));\n            }\n\n            // 检查代理最大账号数限制\n            if let Some(entry) = config.proxies.iter().find(|p| p.id == proxy_id) {\n                if let Some(max) = entry.max_accounts {\n                    if max > 0 {\n                        let current_count = self.account_bindings.iter()\n                            .filter(|kv| *kv.value() == proxy_id)\n                            .count();\n                        if current_count >= max {\n                            return Err(format!(\"Proxy {} has reached max accounts limit\", proxy_id));\n                        }\n                    }\n                }\n            }\n        }\n\n        // 更新内存中的绑定\n        self.account_bindings.insert(account_id.clone(), proxy_id.clone());\n\n        // 持久化到配置文件\n        self.persist_bindings().await;\n\n        tracing::info!(\"[ProxyPool] Bound account {} to proxy {}\", account_id, proxy_id);\n        Ok(())\n    }\n\n    /// 解绑账号代理\n    pub async fn unbind_account_proxy(&self, account_id: String) {\n        self.account_bindings.remove(&account_id);\n\n        // 持久化到配置文件\n        self.persist_bindings().await;\n\n        tracing::info!(\"[ProxyPool] Unbound account {}\", account_id);\n    }\n\n    /// 获取账号当前绑定的代理ID\n    pub fn get_account_binding(&self, account_id: &str) -> Option<String> {\n        self.account_bindings.get(account_id).map(|v| v.value().clone())\n    }\n\n    /// 获取所有绑定关系的快照\n    pub fn get_all_bindings_snapshot(&self) -> std::collections::HashMap<String, String> {\n        self.account_bindings.iter()\n            .map(|kv| (kv.key().clone(), kv.value().clone()))\n            .collect()\n    }\n\n    /// 持久化绑定关系到配置文件\n    async fn persist_bindings(&self) {\n        // 获取当前绑定快照\n        let bindings = self.get_all_bindings_snapshot();\n\n        // 更新配置中的绑定关系\n        {\n            let mut config = self.config.write().await;\n            config.account_bindings = bindings;\n        }\n\n        // 保存到磁盘\n        if let Ok(mut app_config) = crate::modules::config::load_app_config() {\n            let config = self.config.read().await;\n            app_config.proxy.proxy_pool = config.clone();\n            if let Err(e) = crate::modules::config::save_app_config(&app_config) {\n                tracing::error!(\"[ProxyPool] Failed to persist bindings: {}\", e);\n            }\n        }\n    }\n\n    /// 健康检查\n    pub async fn health_check(&self) -> Result<(), String> {\n        // 由于需要异步并发检查，且不能锁住 config 太久，\n        // 我们先复制一份需要检查的代理列表\n        let proxies_to_check: Vec<_> = {\n            let config = self.config.read().await;\n            config.proxies.iter()\n                .filter(|p| p.enabled)\n                .cloned()\n                .collect()\n        };\n\n        let concurrency_limit = 20usize;\n        let results = stream::iter(proxies_to_check)\n            .map(|proxy| async move {\n                let (is_healthy, latency) = self.check_proxy_health(&proxy).await;\n                \n                let latency_msg = if let Some(ms) = latency {\n                    format!(\"{}ms\", ms)\n                } else {\n                    \"-\".to_string()\n                };\n\n                tracing::info!(\n                    \"Proxy {} ({}) health check: {} (Latency: {})\",\n                    proxy.name,\n                    proxy.url,\n                    if is_healthy { \"✓ OK\" } else { \"✗ FAILED\" },\n                    latency_msg\n                );\n\n                (proxy.id, is_healthy, latency)\n            })\n            .buffer_unordered(concurrency_limit)\n            .collect::<Vec<_>>()\n            .await;\n\n        // 统一更新状态\n        let mut config = self.config.write().await;\n        for (id, is_healthy, latency) in results {\n            if let Some(proxy) = config.proxies.iter_mut().find(|p| p.id == id) {\n                proxy.is_healthy = is_healthy;\n                proxy.latency = latency;\n                proxy.last_check_time = Some(chrono::Utc::now().timestamp());\n            }\n        }\n        \n        Ok(())\n    }\n    \n    /// 检查单个代理健康状态\n    async fn check_proxy_health(&self, entry: &ProxyEntry) -> (bool, Option<u64>) {\n        let check_url = if let Some(url) = &entry.health_check_url {\n            if url.trim().is_empty() {\n                \"http://cp.cloudflare.com/generate_204\"\n            } else {\n                url.as_str()\n            }\n        } else {\n            \"http://cp.cloudflare.com/generate_204\"\n        };\n        \n        // 尝试构建 Client，如果失败直接视为不健康\n        let proxy_res = self.build_proxy_config(entry);\n        if let Err(e) = proxy_res { \n            tracing::error!(\"Proxy {} build config failed: {}\", entry.url, e);\n            return (false, None); \n        }\n        let proxy_cfg = proxy_res.unwrap();\n\n        let client_result = Client::builder()\n            .proxy(proxy_cfg.proxy)\n            .emulation(Emulation::Chrome123)\n            .timeout(Duration::from_secs(10))\n            .user_agent(\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36\")\n            .build();\n        \n        let client = match client_result {\n            Ok(c) => c,\n            Err(e) => {\n                tracing::error!(\"Proxy {} build client failed: {}\", entry.url, e);\n                return (false, None);\n            },\n        };\n        \n        let start = std::time::Instant::now();\n        match client.get(check_url).send().await {\n            Ok(resp) => {\n                let latency = start.elapsed().as_millis() as u64;\n                if resp.status().is_success() {\n                    (true, Some(latency))\n                } else {\n                    tracing::warn!(\"Proxy {} health check status error: {}\", entry.url, resp.status());\n                    (false, None)\n                }\n            },\n            Err(e) => {\n                tracing::warn!(\"Proxy {} health check request failed: {}\", entry.url, e);\n                (false, None)\n            },\n        }\n    }\n\n    /// 启动健康检查循环\n    pub fn start_health_check_loop(self: Arc<Self>) {\n        tokio::spawn(async move {\n            tracing::info!(\"Starting proxy pool health check loop...\");\n            loop {\n                // Perform check only if enabled\n                let enabled = self.config.read().await.enabled;\n                if enabled {\n                    if let Err(e) = self.health_check().await {\n                        tracing::error!(\"Proxy pool health check failed: {}\", e);\n                    }\n                }\n\n                // Get interval and sleep AFTER check\n                let interval_secs = {\n                    let cfg = self.config.read().await;\n                    if !cfg.enabled {\n                        60 // check every minute if disabled\n                    } else {\n                        cfg.health_check_interval.max(30) // Back to default min 30s\n                    }\n                };\n\n                tokio::time::sleep(Duration::from_secs(interval_secs)).await;\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/rate_limit.rs",
    "content": "use dashmap::DashMap;\nuse std::time::{SystemTime, Duration};\nuse regex::Regex;\n\n/// 限流原因类型\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum RateLimitReason {\n    /// 配额耗尽 (QUOTA_EXHAUSTED)\n    QuotaExhausted,\n    /// 速率限制 (RATE_LIMIT_EXCEEDED)\n    RateLimitExceeded,\n    /// 模型容量耗尽 (MODEL_CAPACITY_EXHAUSTED)\n    ModelCapacityExhausted,\n    /// 服务器错误 (5xx)\n    ServerError,\n    /// 未知原因\n    Unknown,\n}\n\n/// 限流信息\n#[allow(dead_code)]\n#[derive(Debug, Clone)]\npub struct RateLimitInfo {\n    /// 限流重置时间\n    pub reset_time: SystemTime,\n    /// 重试间隔(秒)\n    #[allow(dead_code)]\n    pub retry_after_sec: u64,\n    /// 检测时间\n    #[allow(dead_code)]\n    pub detected_at: SystemTime,\n    /// 限流原因\n    #[allow(dead_code)] // Used for logging and diagnostics\n    pub reason: RateLimitReason,\n    /// 关联的模型 (用于模型级别限流)\n    /// None 表示账号级别限流,Some(model) 表示特定模型限流\n    #[allow(dead_code)] // Used for model-level rate limiting\n    pub model: Option<String>,\n}\n\n/// 失败计数过期时间：1小时（超过此时间未失败则重置计数）\nconst FAILURE_COUNT_EXPIRY_SECONDS: u64 = 3600;\n\n/// 限流跟踪器\npub struct RateLimitTracker {\n    limits: DashMap<String, RateLimitInfo>,\n    /// 连续失败计数（用于智能指数退避），带时间戳用于自动过期\n    failure_counts: DashMap<String, (u32, SystemTime)>,\n}\n\nimpl RateLimitTracker {\n    pub fn new() -> Self {\n        Self {\n            limits: DashMap::new(),\n            failure_counts: DashMap::new(),\n        }\n    }\n    \n    /// 生成限流 Key\n    /// - 账号级: \"account_id\"\n    /// - 模型级: \"account_id:model_id\"\n    fn get_limit_key(&self, account_id: &str, model: Option<&str>) -> String {\n        match model {\n            Some(m) if !m.is_empty() => format!(\"{}:{}\", account_id, m),\n            _ => account_id.to_string(),\n        }\n    }\n\n    /// 获取账号剩余的等待时间(秒)\n    /// 支持检查账号级和模型级锁\n    pub fn get_remaining_wait(&self, account_id: &str, model: Option<&str>) -> u64 {\n        let now = SystemTime::now();\n        \n        // 1. 检查全局账号锁\n        if let Some(info) = self.limits.get(account_id) {\n            if info.reset_time > now {\n                return info.reset_time.duration_since(now).unwrap_or(Duration::from_secs(0)).as_secs();\n            }\n        }\n\n        // 2. 如果指定了模型，检查模型级锁\n        if let Some(m) = model {\n             let key = self.get_limit_key(account_id, Some(m));\n             if let Some(info) = self.limits.get(&key) {\n                 if info.reset_time > now {\n                     return info.reset_time.duration_since(now).unwrap_or(Duration::from_secs(0)).as_secs();\n                 }\n             }\n        }\n\n        0\n    }\n    \n    /// 标记账号请求成功，重置连续失败计数\n    /// \n    /// 当账号成功完成请求后调用此方法，将其失败计数归零，\n    /// 这样下次失败时会从最短的锁定时间（60秒）开始。\n    pub fn mark_success(&self, account_id: &str) {\n        if self.failure_counts.remove(account_id).is_some() {\n            tracing::debug!(\"账号 {} 请求成功，已重置失败计数\", account_id);\n        }\n        // 清除账号级限流\n        self.limits.remove(account_id);\n        // 注意：我们暂时无法清除该账号下的所有模型级锁，因为我们不知道哪些模型被锁了\n        // 除非遍历 limits。考虑到模型级锁通常是 QuotaExhausted，让其自然过期也是可以接受的。\n        // 或者我们可以引入索引，但为了简单，暂时只清除 Account 级锁。\n    }\n    \n    /// 精确锁定账号到指定时间点\n    /// \n    /// 使用账号配额中的 reset_time 来精确锁定账号,\n    /// 这比指数退避更加精准。\n    /// \n    /// # 参数\n    /// - `model`: 可选的模型名称,用于模型级别限流。None 表示账号级别限流\n    pub fn set_lockout_until(&self, account_id: &str, reset_time: SystemTime, reason: RateLimitReason, model: Option<String>) {\n        let now = SystemTime::now();\n        let retry_sec = reset_time\n            .duration_since(now)\n            .map(|d| d.as_secs())\n            .unwrap_or(60); // 如果时间已过,使用默认 60 秒\n        \n        let info = RateLimitInfo {\n            reset_time,\n            retry_after_sec: retry_sec,\n            detected_at: now,\n            reason,\n            model: model.clone(),  // 🆕 支持模型级别限流\n        };\n        \n        let key = self.get_limit_key(account_id, model.as_deref());\n        self.limits.insert(key, info);\n        \n        if let Some(m) = &model {\n            tracing::info!(\n                \"账号 {} 的模型 {} 已精确锁定到配额刷新时间,剩余 {} 秒\",\n                account_id,\n                m,\n                retry_sec\n            );\n        } else {\n            tracing::info!(\n                \"账号 {} 已精确锁定到配额刷新时间,剩余 {} 秒\",\n                account_id,\n                retry_sec\n            );\n        }\n    }\n    \n    /// 使用 ISO 8601 时间字符串精确锁定账号\n    /// \n    /// 解析类似 \"2026-01-08T17:00:00Z\" 格式的时间字符串\n    /// \n    /// # 参数\n    /// - `model`: 可选的模型名称,用于模型级别限流\n    pub fn set_lockout_until_iso(&self, account_id: &str, reset_time_str: &str, reason: RateLimitReason, model: Option<String>) -> bool {\n        // 尝试解析 ISO 8601 格式\n        match chrono::DateTime::parse_from_rfc3339(reset_time_str) {\n            Ok(dt) => {\n                let reset_time = SystemTime::UNIX_EPOCH + \n                    std::time::Duration::from_secs(dt.timestamp() as u64);\n                self.set_lockout_until(account_id, reset_time, reason, model);\n                true\n            },\n            Err(e) => {\n                tracing::warn!(\n                    \"无法解析配额刷新时间 '{}': {},将使用默认退避策略\",\n                    reset_time_str, e\n                );\n                false\n            }\n        }\n    }\n    \n    /// 从错误响应解析限流信息\n    /// \n    /// # Arguments\n    /// * `account_id` - 账号 ID\n    /// * `status` - HTTP 状态码\n    /// * `retry_after_header` - Retry-After header 值\n    /// * `body` - 错误响应 body\n    pub fn parse_from_error(\n        &self,\n        account_id: &str,\n        status: u16,\n        retry_after_header: Option<&str>,\n        body: &str,\n        model: Option<String>,\n        backoff_steps: &[u64], // [NEW] 传入退避配置\n    ) -> Option<RateLimitInfo> {\n        // 支持 429 (限流) 以及 500/503/529 (后端故障软避让)\n        if status != 429 && status != 500 && status != 503 && status != 529 && status != 404 {\n            return None;\n        }\n        \n        // 1. 解析限流原因类型\n        let reason = if status == 429 {\n            tracing::warn!(\"Google 429 Error Body: {}\", body);\n            self.parse_rate_limit_reason(body)\n        } else if status == 404 {\n            tracing::warn!(\"Google 404: model unavailable on this account, short lockout before rotation\");\n            RateLimitReason::ServerError\n        } else {\n            RateLimitReason::ServerError\n        };\n        \n        let mut retry_after_sec = None;\n        \n        // 2. 从 Retry-After header 提取\n        if let Some(retry_after) = retry_after_header {\n            if let Ok(seconds) = retry_after.parse::<u64>() {\n                retry_after_sec = Some(seconds);\n            }\n        }\n        \n        // 3. 从错误消息提取 (优先尝试 JSON 解析，再试正则)\n        if retry_after_sec.is_none() {\n            retry_after_sec = self.parse_retry_time_from_body(body);\n        }\n        \n        // 4. 处理默认值与软避让逻辑（根据限流类型设置不同默认值）\n        let retry_sec = match retry_after_sec {\n            Some(s) => {\n                // 设置安全缓冲区：最小 2 秒，防止极高频无效重试\n                if s < 2 { 2 } else { s }\n            },\n            None => {\n                // 获取连续失败次数，用于指数退避（带自动过期逻辑）\n                // [FIX] ServerError (5xx) 不累加 failure_count，避免污染 429 的退避阶梯\n                let failure_count = if reason != RateLimitReason::ServerError {\n                    // 只有非 ServerError 才累加失败计数（用于指数退避）\n                    let now = SystemTime::now();\n                    // 这里我们使用 account_id 作为 key，不区分模型，\n                    // 因为这里是为了计算连续\"账号级\"问题的退避。\n                    // 如果需要针对模型的连续失败计数，可能需要改变 failure_counts 的 key。\n                    // 暂时保持 account_id，这样如果一个模型一直挂，也会增加计数，符合逻辑。\n                    let mut entry = self.failure_counts.entry(account_id.to_string()).or_insert((0, now));\n\n                    let elapsed = now.duration_since(entry.1).unwrap_or(Duration::from_secs(0)).as_secs();\n                    if elapsed > FAILURE_COUNT_EXPIRY_SECONDS {\n                        tracing::debug!(\"账号 {} 失败计数已过期（{}秒），重置为 0\", account_id, elapsed);\n                        *entry = (0, now);\n                    }\n                    entry.0 += 1;\n                    entry.1 = now;\n                    entry.0\n                } else {\n                    // ServerError (5xx) 使用固定值 1，不累加，避免污染 429 的退避阶梯\n                    1\n                };\n                \n                match reason {\n                    RateLimitReason::QuotaExhausted => {\n                        // [智能限流] 根据 failure_count 和配置的 backoff_steps 计算\n                        let index = (failure_count as usize).saturating_sub(1);\n                        let lockout = if index < backoff_steps.len() {\n                            backoff_steps[index]\n                        } else {\n                            *backoff_steps.last().unwrap_or(&7200)\n                        };\n\n                        tracing::warn!(\n                            \"检测到配额耗尽 (QUOTA_EXHAUSTED)，第{}次连续失败，根据配置锁定 {} 秒\", \n                            failure_count, lockout\n                        );\n                        lockout\n                    },\n                    RateLimitReason::RateLimitExceeded => {\n                        // 速率限制 (TPM/RPM)\n                        tracing::debug!(\"检测到速率限制 (RATE_LIMIT_EXCEEDED)，使用默认值 5秒\");\n                        5\n                    },\n                    RateLimitReason::ModelCapacityExhausted => {\n                        // 模型容量耗尽\n                        let lockout = match failure_count {\n                            1 => 5,\n                            2 => 10,\n                            _ => 15,\n                        };\n                        tracing::warn!(\"检测到模型容量不足 (MODEL_CAPACITY_EXHAUSTED)，第{}次失败，{}秒后重试\", failure_count, lockout);\n                        lockout\n                    },\n                    RateLimitReason::ServerError => {\n                        let lockout = if status == 404 { 5 } else { 8 };\n                        tracing::warn!(\"检测到 {} 错误, 执行 {}s 软避让...\", status, lockout);\n                        lockout\n                    },\n                    RateLimitReason::Unknown => {\n                        // 未知原因\n                        tracing::debug!(\"无法解析 429 限流原因, 使用默认值 60秒\");\n                        60\n                    }\n                }\n            }\n        };\n        \n        let info = RateLimitInfo {\n            reset_time: SystemTime::now() + Duration::from_secs(retry_sec),\n            retry_after_sec: retry_sec,\n            detected_at: SystemTime::now(),\n            reason,\n            model: model.clone(),\n        };\n        \n        // [FIX] 使用复合 Key 存储 (如果是 Quota 且有 Model)\n        // 只有 QuotaExhausted 适合做模型隔离，其他如 RateLimitExceeded 通常是全账号的 TPM\n        let use_model_key = matches!(reason, RateLimitReason::QuotaExhausted) && model.is_some();\n        let key = if use_model_key { \n            self.get_limit_key(account_id, model.as_deref())\n        } else {\n            // 其他情况（如 RateLimitExceeded, ServerError）通常影响整个账号\n            // 或者我们也可以根据配置决定是否隔离。\n            // 简单起见，只有 QuotaExhausted 做细粒度隔离。\n            account_id.to_string()\n        };\n\n        self.limits.insert(key, info.clone());\n        \n        tracing::warn!(\n            \"账号 {} [{}] 限流类型: {:?}, 重置延时: {}秒\",\n            account_id,\n            status,\n            reason,\n            retry_sec\n        );\n        \n        Some(info)\n    }\n    \n    /// 解析限流原因类型\n    fn parse_rate_limit_reason(&self, body: &str) -> RateLimitReason {\n        // 尝试从 JSON 中提取 reason 字段\n        let trimmed = body.trim();\n        if trimmed.starts_with('{') || trimmed.starts_with('[') {\n            if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {\n                if let Some(reason_str) = json.get(\"error\")\n                    .and_then(|e| e.get(\"details\"))\n                    .and_then(|d| d.as_array())\n                    .and_then(|a| a.get(0))\n                    .and_then(|o| o.get(\"reason\"))\n                    .and_then(|v| v.as_str()) {\n                    \n                    return match reason_str {\n                        \"QUOTA_EXHAUSTED\" => RateLimitReason::QuotaExhausted,\n                        \"RATE_LIMIT_EXCEEDED\" => RateLimitReason::RateLimitExceeded,\n                        \"MODEL_CAPACITY_EXHAUSTED\" => RateLimitReason::ModelCapacityExhausted,\n                        _ => RateLimitReason::Unknown,\n                    };\n                }\n                // [NEW] 尝试从 message 字段进行文本匹配（防止 missed reason）\n                 if let Some(msg) = json.get(\"error\")\n                    .and_then(|e| e.get(\"message\"))\n                    .and_then(|v| v.as_str()) {\n                    let msg_lower = msg.to_lowercase();\n                    if msg_lower.contains(\"per minute\") || msg_lower.contains(\"rate limit\") {\n                        return RateLimitReason::RateLimitExceeded;\n                    }\n                 }\n            }\n        }\n        \n        // 如果无法从 JSON 解析，尝试从消息文本判断\n        let body_lower = body.to_lowercase();\n        // [FIX] 优先判断分钟级限制，避免将 TPM 误判为 Quota\n        if body_lower.contains(\"per minute\") || body_lower.contains(\"rate limit\") || body_lower.contains(\"too many requests\") {\n             RateLimitReason::RateLimitExceeded\n        } else if body_lower.contains(\"exhausted\") || body_lower.contains(\"quota\") {\n            RateLimitReason::QuotaExhausted\n        } else {\n            RateLimitReason::Unknown\n        }\n    }\n    \n    /// 通用时间解析函数：支持 \"2h1m1s\" 等所有格式组合\n    fn parse_duration_string(&self, s: &str) -> Option<u64> {\n        tracing::debug!(\"[时间解析] 尝试解析: '{}'\", s);\n\n        // 使用正则表达式提取小时、分钟、秒、毫秒\n        // 支持格式：\"2h1m1s\", \"1h30m\", \"5m\", \"30s\", \"500ms\", \"510.790006ms\" 等\n        // 🔧 [FIX] 修改 ms 部分支持小数: (\\d+)ms -> (\\d+(?:\\.\\d+)?)ms\n        let re = Regex::new(r\"(?:(\\d+)h)?(?:(\\d+)m)?(?:(\\d+(?:\\.\\d+)?)s)?(?:(\\d+(?:\\.\\d+)?)ms)?\").ok()?;\n        let caps = match re.captures(s) {\n            Some(c) => c,\n            None => {\n                tracing::warn!(\"[时间解析] 正则未匹配: '{}'\", s);\n                return None;\n            }\n        };\n\n        let hours = caps.get(1)\n            .and_then(|m| m.as_str().parse::<u64>().ok())\n            .unwrap_or(0);\n        let minutes = caps.get(2)\n            .and_then(|m| m.as_str().parse::<u64>().ok())\n            .unwrap_or(0);\n        let seconds = caps.get(3)\n            .and_then(|m| m.as_str().parse::<f64>().ok())\n            .unwrap_or(0.0);\n        // 🔧 [FIX] 毫秒也支持小数解析\n        let milliseconds = caps.get(4)\n            .and_then(|m| m.as_str().parse::<f64>().ok())\n            .unwrap_or(0.0);\n\n        tracing::debug!(\"[时间解析] 提取结果: {}h {}m {:.3}s {:.3}ms\", hours, minutes, seconds, milliseconds);\n\n        // 🔧 [FIX] 计算总秒数，毫秒部分向上取整\n        let total_seconds = hours * 3600 + minutes * 60 + seconds.ceil() as u64 + (milliseconds / 1000.0).ceil() as u64;\n\n        // 如果总秒数为 0，说明解析失败\n        if total_seconds == 0 {\n            tracing::warn!(\"[时间解析] 失败: '{}' (总秒数为0)\", s);\n            None\n        } else {\n            tracing::info!(\"[时间解析] ✓ 成功: '{}' => {}秒 ({}h {}m {:.1}s {:.1}ms)\",\n                s, total_seconds, hours, minutes, seconds, milliseconds);\n            Some(total_seconds)\n        }\n    }\n    \n    /// 从错误消息 body 中解析重置时间\n    fn parse_retry_time_from_body(&self, body: &str) -> Option<u64> {\n        // A. 优先尝试 JSON 精准解析\n        let trimmed = body.trim();\n        if trimmed.starts_with('{') || trimmed.starts_with('[') {\n            if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {\n                // 1. Google 常见的 quotaResetDelay 格式 (支持所有格式：\"2h1m1s\", \"1h30m\", \"42s\", \"500ms\" 等)\n                // 路径: error.details[0].metadata.quotaResetDelay\n                if let Some(delay_str) = json.get(\"error\")\n                    .and_then(|e| e.get(\"details\"))\n                    .and_then(|d| d.as_array())\n                    .and_then(|a| a.get(0))\n                    .and_then(|o| o.get(\"metadata\"))  // 添加 metadata 层级\n                    .and_then(|m| m.get(\"quotaResetDelay\"))\n                    .and_then(|v| v.as_str()) {\n                    \n                    tracing::debug!(\"[JSON解析] 找到 quotaResetDelay: '{}'\", delay_str);\n                    \n                    // 使用通用时间解析函数\n                    if let Some(seconds) = self.parse_duration_string(delay_str) {\n                        return Some(seconds);\n                    }\n                }\n                \n                // 2. OpenAI 常见的 retry_after 字段 (数字)\n                if let Some(retry) = json.get(\"error\")\n                    .and_then(|e| e.get(\"retry_after\"))\n                    .and_then(|v| v.as_u64()) {\n                    return Some(retry);\n                }\n            }\n        }\n\n        // B. 正则匹配模式 (兜底)\n        // 模式 1: \"Try again in 2m 30s\"\n        if let Ok(re) = Regex::new(r\"(?i)try again in (\\d+)m\\s*(\\d+)s\") {\n            if let Some(caps) = re.captures(body) {\n                if let (Ok(m), Ok(s)) = (caps[1].parse::<u64>(), caps[2].parse::<u64>()) {\n                    return Some(m * 60 + s);\n                }\n            }\n        }\n        \n        // 模式 2: \"Try again in 30s\" 或 \"backoff for 42s\"\n        if let Ok(re) = Regex::new(r\"(?i)(?:try again in|backoff for|wait)\\s*(\\d+)s\") {\n            if let Some(caps) = re.captures(body) {\n                if let Ok(s) = caps[1].parse::<u64>() {\n                    return Some(s);\n                }\n            }\n        }\n        \n        // 模式 3: \"quota will reset in X seconds\"\n        if let Ok(re) = Regex::new(r\"(?i)quota will reset in (\\d+) second\") {\n            if let Some(caps) = re.captures(body) {\n                if let Ok(s) = caps[1].parse::<u64>() {\n                    return Some(s);\n                }\n            }\n        }\n        \n        // 模式 4: OpenAI 风格的 \"Retry after (\\d+) seconds\"\n        if let Ok(re) = Regex::new(r\"(?i)retry after (\\d+) second\") {\n            if let Some(caps) = re.captures(body) {\n                if let Ok(s) = caps[1].parse::<u64>() {\n                    return Some(s);\n                }\n            }\n        }\n\n        // 模式 5: 括号形式 \"(wait (\\d+)s)\"\n        if let Ok(re) = Regex::new(r\"\\(wait (\\d+)s\\)\") {\n            if let Some(caps) = re.captures(body) {\n                if let Ok(s) = caps[1].parse::<u64>() {\n                    return Some(s);\n                }\n            }\n        }\n        \n        None\n    }\n    \n    /// 获取账号的限流信息\n    pub fn get(&self, account_id: &str) -> Option<RateLimitInfo> {\n        self.limits.get(account_id).map(|r| r.clone())\n    }\n    \n    /// 检查账号是否仍在限流中\n    /// 检查账号是否仍在限流中 (支持模型级)\n    pub fn is_rate_limited(&self, account_id: &str, model: Option<&str>) -> bool {\n        // Checking using get_remaining_wait which handles both global and model keys\n        self.get_remaining_wait(account_id, model) > 0\n    }\n    \n    /// 获取距离限流重置还有多少秒\n    pub fn get_reset_seconds(&self, account_id: &str) -> Option<u64> {\n        if let Some(info) = self.get(account_id) {\n            info.reset_time\n                .duration_since(SystemTime::now())\n                .ok()\n                .map(|d| d.as_secs())\n        } else {\n            None\n        }\n    }\n    \n    /// 清除过期的限流记录\n    #[allow(dead_code)]\n    pub fn cleanup_expired(&self) -> usize {\n        let now = SystemTime::now();\n        let mut count = 0;\n        \n        self.limits.retain(|_k, v| {\n            if v.reset_time <= now {\n                count += 1;\n                false\n            } else {\n                true\n            }\n        });\n        \n        if count > 0 {\n            tracing::debug!(\"清除了 {} 个过期的限流记录\", count);\n        }\n        \n        count\n    }\n    \n    /// 清除指定账号的限流记录\n    pub fn clear(&self, account_id: &str) -> bool {\n        self.limits.remove(account_id).is_some()\n    }\n    \n    /// 清除所有限流记录 (乐观重置策略)\n    /// \n    /// 用于乐观重置机制,当所有账号都被限流但等待时间很短时,\n    /// 清除所有限流记录以解决时序竞争条件\n    pub fn clear_all(&self) {\n        let count = self.limits.len();\n        self.limits.clear();\n        tracing::warn!(\"🔄 Optimistic reset: Cleared all {} rate limit record(s)\", count);\n    }\n}\n\nimpl Default for RateLimitTracker {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    \n    #[test]\n    fn test_parse_retry_time_minutes_seconds() {\n        let tracker = RateLimitTracker::new();\n        let body = \"Rate limit exceeded. Try again in 2m 30s\";\n        let time = tracker.parse_retry_time_from_body(body);\n        assert_eq!(time, Some(150)); \n    }\n    \n    #[test]\n    fn test_parse_google_json_delay() {\n        let tracker = RateLimitTracker::new();\n        let body = r#\"{\n            \"error\": {\n                \"details\": [\n                    { \n                        \"metadata\": {\n                            \"quotaResetDelay\": \"42s\" \n                        }\n                    }\n                ]\n            }\n        }\"#;\n        let time = tracker.parse_retry_time_from_body(body);\n        assert_eq!(time, Some(42));\n    }\n\n    #[test]\n    fn test_parse_retry_after_ignore_case() {\n        let tracker = RateLimitTracker::new();\n        let body = \"Quota limit hit. Retry After 99 Seconds\";\n        let time = tracker.parse_retry_time_from_body(body);\n        assert_eq!(time, Some(99));\n    }\n\n    #[test]\n    fn test_get_remaining_wait() {\n        let tracker = RateLimitTracker::new();\n        tracker.parse_from_error(\"acc1\", 429, Some(\"30\"), \"\", None, &[]);\n        let wait = tracker.get_remaining_wait(\"acc1\", None);\n        assert!(wait > 25 && wait <= 30);\n    }\n\n    #[test]\n    fn test_safety_buffer() {\n        let tracker = RateLimitTracker::new();\n        // 如果 API 返回 1s，我们强制设为 2s\n        tracker.parse_from_error(\"acc1\", 429, Some(\"1\"), \"\", None, &[]);\n        let wait = tracker.get_remaining_wait(\"acc1\", None);\n        // Due to time passing, it might be 1 or 2\n        assert!(wait >= 1 && wait <= 2);\n    }\n\n    #[test]\n    fn test_tpm_exhausted_is_rate_limit_exceeded() {\n        let tracker = RateLimitTracker::new();\n        // 模拟真实世界的 TPM 错误，同时包含 \"Resource exhausted\" 和 \"per minute\"\n        let body = \"Resource has been exhausted (e.g. check quota). Quota limit 'Tokens per minute' exceeded.\";\n        let reason = tracker.parse_rate_limit_reason(body);\n        // 应该被识别为 RateLimitExceeded，而不是 QuotaExhausted\n        assert_eq!(reason, RateLimitReason::RateLimitExceeded);\n    }\n\n    #[test]\n    fn test_server_error_does_not_accumulate_failure_count() {\n        let tracker = RateLimitTracker::new();\n        let backoff_steps = vec![60, 300, 1800, 7200];\n\n        // 模拟连续 5 次 5xx 错误\n        for i in 1..=5 {\n            let info = tracker.parse_from_error(\"acc1\", 503, None, \"Service Unavailable\", None, &backoff_steps);\n            assert!(info.is_some(), \"第 {} 次 5xx 应该返回 RateLimitInfo\", i);\n            let info = info.unwrap();\n            // 5xx 应该始终锁定 8 秒，不受 failure_count 影响\n            assert_eq!(info.retry_after_sec, 8, \"5xx 第 {} 次应该锁定 8 秒\", i);\n        }\n\n        // 现在触发一次 429 QuotaExhausted（没有 quotaResetDelay）\n        let quota_body = r#\"{\"error\":{\"details\":[{\"reason\":\"QUOTA_EXHAUSTED\"}]}}\"#;\n        let info = tracker.parse_from_error(\"acc1\", 429, None, quota_body, None, &backoff_steps);\n        assert!(info.is_some());\n        let info = info.unwrap();\n\n        // 关键断言：429 应该从第 1 次开始（锁 60 秒），而不是继承 5xx 的计数\n        assert_eq!(info.retry_after_sec, 60, \"429 应该从第 1 次退避开始(60秒),而不是被 5xx 污染\");\n    }\n\n    #[test]\n    fn test_quota_exhausted_does_accumulate_failure_count() {\n        let tracker = RateLimitTracker::new();\n        let backoff_steps = vec![60, 300, 1800, 7200];\n        let quota_body = r#\"{\"error\":{\"details\":[{\"reason\":\"QUOTA_EXHAUSTED\"}]}}\"#;\n\n        // 第 1 次 429 → 60 秒\n        let info = tracker.parse_from_error(\"acc2\", 429, None, quota_body, None, &backoff_steps);\n        assert_eq!(info.unwrap().retry_after_sec, 60);\n\n        // 第 2 次 429 → 300 秒\n        let info = tracker.parse_from_error(\"acc2\", 429, None, quota_body, None, &backoff_steps);\n        assert_eq!(info.unwrap().retry_after_sec, 300);\n\n        // 第 3 次 429 → 1800 秒\n        let info = tracker.parse_from_error(\"acc2\", 429, None, quota_body, None, &backoff_steps);\n        assert_eq!(info.unwrap().retry_after_sec, 1800);\n\n        // 第 4 次 429 → 7200 秒\n        let info = tracker.parse_from_error(\"acc2\", 429, None, quota_body, None, &backoff_steps);\n        assert_eq!(info.unwrap().retry_after_sec, 7200);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/security.rs",
    "content": "use crate::proxy::config::{ProxyAuthMode, ProxyConfig, SecurityMonitorConfig};\n\n#[derive(Debug, Clone)]\npub struct ProxySecurityConfig {\n    pub auth_mode: ProxyAuthMode,\n    pub api_key: String,\n    pub admin_password: Option<String>,\n    pub allow_lan_access: bool,\n    pub port: u16,\n    pub security_monitor: SecurityMonitorConfig,\n}\n\nimpl ProxySecurityConfig {\n    pub fn from_proxy_config(config: &ProxyConfig) -> Self {\n        Self {\n            auth_mode: config.auth_mode.clone(),\n            api_key: config.api_key.clone(),\n            admin_password: config.admin_password.clone(),\n            allow_lan_access: config.allow_lan_access,\n            port: config.port,\n            security_monitor: config.security_monitor.clone(),\n        }\n    }\n\n    pub fn effective_auth_mode(&self) -> ProxyAuthMode {\n        match self.auth_mode {\n            ProxyAuthMode::Auto => {\n                if self.allow_lan_access {\n                    ProxyAuthMode::AllExceptHealth\n                } else {\n                    ProxyAuthMode::Off\n                }\n            }\n            ref other => other.clone(),\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn auto_mode_resolves_off_for_local_only() {\n        let s = ProxySecurityConfig {\n            auth_mode: ProxyAuthMode::Auto,\n            api_key: \"sk-test\".to_string(),\n            admin_password: None,\n            allow_lan_access: false,\n            port: 8080,\n            security_monitor: crate::proxy::config::SecurityMonitorConfig::default(),\n        };\n        assert!(matches!(s.effective_auth_mode(), ProxyAuthMode::Off));\n    }\n\n    #[test]\n    fn auto_mode_resolves_all_except_health_for_lan() {\n        let s = ProxySecurityConfig {\n            auth_mode: ProxyAuthMode::Auto,\n            api_key: \"sk-test\".to_string(),\n            admin_password: None,\n            allow_lan_access: true,\n            port: 8080,\n            security_monitor: crate::proxy::config::SecurityMonitorConfig::default(),\n        };\n        assert!(matches!(\n            s.effective_auth_mode(),\n            ProxyAuthMode::AllExceptHealth\n        ));\n    }\n}\n\n"
  },
  {
    "path": "src-tauri/src/proxy/server.rs",
    "content": "use crate::models::AppConfig;\nuse crate::modules::{account, config, logger, migration, proxy_db, security_db, token_stats};\nuse crate::proxy::TokenManager;\nuse axum::{\n    extract::{DefaultBodyLimit, Path, Query, State},\n    http::{HeaderMap, StatusCode},\n    response::{Html, IntoResponse, Json, Response},\n    routing::{any, delete, get, post},\n    Router,\n};\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashSet;\nuse std::sync::atomic::AtomicUsize;\nuse std::sync::Arc;\nuse std::sync::OnceLock;\nuse tokio::sync::oneshot;\nuse tokio::sync::RwLock;\nuse tracing::{debug, error};\n\n// [FIX] 全局待重新加载账号队列\n// 当 update_account_quota 更新 protected_models 后，将账号 ID 加入此队列\n// TokenManager 在 get_token 时会检查并处理这些账号\nstatic PENDING_RELOAD_ACCOUNTS: OnceLock<std::sync::RwLock<HashSet<String>>> = OnceLock::new();\n\n// [NEW] 全局待删除账号队列 (Issue #1477)\n// 当账号被删除后，将账号 ID 加入此队列，TokenManager 在 get_token 时会检查并清理内存缓存\nstatic PENDING_DELETE_ACCOUNTS: OnceLock<std::sync::RwLock<HashSet<String>>> = OnceLock::new();\n\nfn get_pending_reload_accounts() -> &'static std::sync::RwLock<HashSet<String>> {\n    PENDING_RELOAD_ACCOUNTS.get_or_init(|| std::sync::RwLock::new(HashSet::new()))\n}\n\nfn get_pending_delete_accounts() -> &'static std::sync::RwLock<HashSet<String>> {\n    PENDING_DELETE_ACCOUNTS.get_or_init(|| std::sync::RwLock::new(HashSet::new()))\n}\n\n/// 触发账号重新加载信号（供 update_account_quota 调用）\npub fn trigger_account_reload(account_id: &str) {\n    if let Ok(mut pending) = get_pending_reload_accounts().write() {\n        pending.insert(account_id.to_string());\n        tracing::debug!(\n            \"[Quota] Queued account {} for TokenManager reload\",\n            account_id\n        );\n    }\n}\n\n/// 触发账号删除信号 (Issue #1477)\npub fn trigger_account_delete(account_id: &str) {\n    if let Ok(mut pending) = get_pending_delete_accounts().write() {\n        pending.insert(account_id.to_string());\n        tracing::debug!(\n            \"[Proxy] Queued account {} for cache removal\",\n            account_id\n        );\n    }\n}\n\n/// 获取并清空待重新加载的账号列表（供 TokenManager 调用）\npub fn take_pending_reload_accounts() -> Vec<String> {\n    if let Ok(mut pending) = get_pending_reload_accounts().write() {\n        let accounts: Vec<String> = pending.drain().collect();\n        if !accounts.is_empty() {\n            tracing::debug!(\n                \"[Quota] Taking {} pending accounts for reload\",\n                accounts.len()\n            );\n        }\n        accounts\n    } else {\n        Vec::new()\n    }\n}\n\n/// 获取并清空待删除的账号列表 (Issue #1477)\npub fn take_pending_delete_accounts() -> Vec<String> {\n    if let Ok(mut pending) = get_pending_delete_accounts().write() {\n        let accounts: Vec<String> = pending.drain().collect();\n        if !accounts.is_empty() {\n            tracing::debug!(\n                \"[Proxy] Taking {} pending accounts for cache removal\",\n                accounts.len()\n            );\n        }\n        accounts\n    } else {\n        Vec::new()\n    }\n}\n\n/// Axum 应用状态\n#[derive(Clone)]\npub struct AppState {\n    pub token_manager: Arc<TokenManager>,\n    pub custom_mapping: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,\n    #[allow(dead_code)]\n    pub request_timeout: u64, // API 请求超时(秒)\n    #[allow(dead_code)]\n    pub thought_signature_map: Arc<tokio::sync::Mutex<std::collections::HashMap<String, String>>>, // 思维链签名映射 (ID -> Signature)\n    #[allow(dead_code)]\n    pub upstream_proxy: Arc<tokio::sync::RwLock<crate::proxy::config::UpstreamProxyConfig>>,\n    pub upstream: Arc<crate::proxy::upstream::client::UpstreamClient>,\n    pub zai: Arc<RwLock<crate::proxy::ZaiConfig>>,\n    pub provider_rr: Arc<AtomicUsize>,\n    pub zai_vision_mcp: Arc<crate::proxy::zai_vision_mcp::ZaiVisionMcpState>,\n    pub monitor: Arc<crate::proxy::monitor::ProxyMonitor>,\n    pub experimental: Arc<RwLock<crate::proxy::config::ExperimentalConfig>>,\n    pub debug_logging: Arc<RwLock<crate::proxy::config::DebugLoggingConfig>>,\n    pub switching: Arc<RwLock<bool>>, // [NEW] 账号切换状态，用于防止并发切换\n    pub integration: crate::modules::integration::SystemManager, // [NEW] 系统集成层实现\n    pub account_service: Arc<crate::modules::account_service::AccountService>, // [NEW] 账号管理服务层\n    pub security: Arc<RwLock<crate::proxy::ProxySecurityConfig>>,              // [NEW] 安全配置状态\n    pub cloudflared_state: Arc<crate::commands::cloudflared::CloudflaredState>, // [NEW] Cloudflared 插件状态\n    pub is_running: Arc<RwLock<bool>>, // [NEW] 运行状态标识\n    pub port: u16,                     // [NEW] 本地监听端口 (v4.0.8 修复)\n    pub proxy_pool_state: Arc<tokio::sync::RwLock<crate::proxy::config::ProxyPoolConfig>>, // [FIX Web Mode]\n    pub proxy_pool_manager: Arc<crate::proxy::proxy_pool::ProxyPoolManager>, // [FIX Web Mode]\n}\n\n// 为 AppState 实现 FromRef，以便中间件提取 security 状态\nimpl axum::extract::FromRef<AppState> for Arc<RwLock<crate::proxy::ProxySecurityConfig>> {\n    fn from_ref(state: &AppState) -> Self {\n        state.security.clone()\n    }\n}\n\n#[derive(Serialize)]\nstruct ErrorResponse {\n    error: String,\n}\n\n#[derive(Serialize)]\nstruct AccountResponse {\n    id: String,\n    email: String,\n    name: Option<String>,\n    is_current: bool,\n    disabled: bool,\n    disabled_reason: Option<String>,\n    disabled_at: Option<i64>,\n    proxy_disabled: bool,\n    proxy_disabled_reason: Option<String>,\n    proxy_disabled_at: Option<i64>,\n    protected_models: Vec<String>,\n    /// [NEW] 403 验证阻止状态\n    validation_blocked: bool,\n    validation_blocked_until: Option<i64>,\n    validation_blocked_reason: Option<String>,\n    quota: Option<QuotaResponse>,\n    device_bound: bool,\n    last_used: i64,\n}\n\n#[derive(Serialize)]\nstruct QuotaResponse {\n    models: Vec<ModelQuota>,\n    last_updated: i64,\n    subscription_tier: Option<String>,\n    is_forbidden: bool,\n}\n\n#[derive(Serialize)]\nstruct ModelQuota {\n    name: String,\n    percentage: i32,\n    reset_time: String,\n}\n\n#[derive(Serialize)]\nstruct AccountListResponse {\n    accounts: Vec<AccountResponse>,\n    current_account_id: Option<String>,\n}\n\nfn to_account_response(\n    account: &crate::models::account::Account,\n    current_id: &Option<String>,\n) -> AccountResponse {\n    AccountResponse {\n        id: account.id.clone(),\n        email: account.email.clone(),\n        name: account.name.clone(),\n        is_current: current_id.as_ref() == Some(&account.id),\n        disabled: account.disabled,\n        disabled_reason: account.disabled_reason.clone(),\n        disabled_at: account.disabled_at,\n        proxy_disabled: account.proxy_disabled,\n        proxy_disabled_reason: account.proxy_disabled_reason.clone(),\n        proxy_disabled_at: account.proxy_disabled_at,\n        protected_models: account.protected_models.iter().cloned().collect(),\n        quota: account.quota.as_ref().map(|q| QuotaResponse {\n            models: q\n                .models\n                .iter()\n                .map(|m| ModelQuota {\n                    name: m.name.clone(),\n                    percentage: m.percentage,\n                    reset_time: m.reset_time.clone(),\n                })\n                .collect(),\n            last_updated: q.last_updated,\n            subscription_tier: q.subscription_tier.clone(),\n            is_forbidden: q.is_forbidden,\n        }),\n        device_bound: account.device_profile.is_some(),\n        last_used: account.last_used,\n        validation_blocked: account.validation_blocked,\n        validation_blocked_until: account.validation_blocked_until,\n        validation_blocked_reason: account.validation_blocked_reason.clone(),\n    }\n}\n\n/// Axum 服务器实例\n#[derive(Clone)]\npub struct AxumServer {\n    shutdown_tx: Arc<tokio::sync::Mutex<Option<oneshot::Sender<()>>>>,\n    custom_mapping: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,\n    proxy_state: Arc<tokio::sync::RwLock<crate::proxy::config::UpstreamProxyConfig>>,\n    upstream: Arc<crate::proxy::upstream::client::UpstreamClient>,\n    security_state: Arc<RwLock<crate::proxy::ProxySecurityConfig>>,\n    zai_state: Arc<RwLock<crate::proxy::ZaiConfig>>,\n    experimental: Arc<RwLock<crate::proxy::config::ExperimentalConfig>>,\n    debug_logging: Arc<RwLock<crate::proxy::config::DebugLoggingConfig>>,\n    #[allow(dead_code)] // 预留给 cloudflared 运行状态查询与后续控制\n    pub cloudflared_state: Arc<crate::commands::cloudflared::CloudflaredState>,\n    pub is_running: Arc<RwLock<bool>>,\n    pub token_manager: Arc<TokenManager>, // [NEW] 暴露出 TokenManager 供反代服务复用\n    pub proxy_pool_state: Arc<tokio::sync::RwLock<crate::proxy::config::ProxyPoolConfig>>, // [NEW] 代理池配置状态\n    pub proxy_pool_manager: Arc<crate::proxy::proxy_pool::ProxyPoolManager>, // [NEW] 暴露代理池管理器供命令调用\n}\n\nimpl AxumServer {\n    pub async fn update_mapping(&self, config: &crate::proxy::config::ProxyConfig) {\n        {\n            let mut m = self.custom_mapping.write().await;\n            *m = config.custom_mapping.clone();\n        }\n        tracing::debug!(\"模型映射 (Custom) 已全量热更新\");\n    }\n\n    /// 更新代理配置\n    pub async fn update_proxy(&self, new_config: crate::proxy::config::UpstreamProxyConfig) {\n        let mut proxy = self.proxy_state.write().await;\n        *proxy = new_config;\n        tracing::info!(\"上游代理配置已热更新\");\n    }\n\n    /// 更新代理池配置\n    pub async fn update_proxy_pool(&self, new_config: crate::proxy::config::ProxyPoolConfig) {\n        let mut pool = self.proxy_pool_state.write().await;\n        *pool = new_config;\n        tracing::info!(\"代理池配置已热更新\");\n    }\n\n    pub async fn update_security(&self, config: &crate::proxy::config::ProxyConfig) {\n        let mut sec = self.security_state.write().await;\n        *sec = crate::proxy::ProxySecurityConfig::from_proxy_config(config);\n        tracing::info!(\"反代服务安全配置已热更新\");\n    }\n\n    pub async fn update_zai(&self, config: &crate::proxy::config::ProxyConfig) {\n        let mut zai = self.zai_state.write().await;\n        *zai = config.zai.clone();\n        tracing::info!(\"z.ai 配置已热更新\");\n    }\n\n    pub async fn update_experimental(&self, config: &crate::proxy::config::ProxyConfig) {\n        let mut exp = self.experimental.write().await;\n        *exp = config.experimental.clone();\n        tracing::info!(\"实验性配置已热更新\");\n    }\n\n    pub async fn update_debug_logging(&self, config: &crate::proxy::config::ProxyConfig) {\n        let mut dbg_cfg = self.debug_logging.write().await;\n        *dbg_cfg = config.debug_logging.clone();\n        tracing::info!(\"调试日志配置已热更新\");\n    }\n\n    pub async fn update_user_agent(&self, config: &crate::proxy::config::ProxyConfig) {\n        self.upstream\n            .set_user_agent_override(config.user_agent_override.clone())\n            .await;\n        tracing::info!(\"User-Agent 配置已热更新: {:?}\", config.user_agent_override);\n    }\n\n    pub async fn set_running(&self, running: bool) {\n        let mut r = self.is_running.write().await;\n        *r = running;\n        tracing::info!(\"反代服务运行状态更新为: {}\", running);\n    }\n\n    /// 启动 Axum 服务器\n    pub async fn start(\n        host: String,\n        port: u16,\n        token_manager: Arc<TokenManager>,\n        custom_mapping: std::collections::HashMap<String, String>,\n        _request_timeout: u64,\n        upstream_proxy: crate::proxy::config::UpstreamProxyConfig,\n        user_agent_override: Option<String>,\n        security_config: crate::proxy::ProxySecurityConfig,\n        zai_config: crate::proxy::ZaiConfig,\n        monitor: Arc<crate::proxy::monitor::ProxyMonitor>,\n        experimental_config: crate::proxy::config::ExperimentalConfig,\n        debug_logging: crate::proxy::config::DebugLoggingConfig,\n\n        integration: crate::modules::integration::SystemManager,\n        cloudflared_state: Arc<crate::commands::cloudflared::CloudflaredState>,\n        proxy_pool_config: crate::proxy::config::ProxyPoolConfig, // [NEW]\n    ) -> Result<(Self, tokio::task::JoinHandle<()>), String> {\n        let custom_mapping_state = Arc::new(tokio::sync::RwLock::new(custom_mapping));\n        let proxy_state = Arc::new(tokio::sync::RwLock::new(upstream_proxy.clone()));\n        let proxy_pool_state = Arc::new(tokio::sync::RwLock::new(proxy_pool_config));\n        let proxy_pool_manager = crate::proxy::proxy_pool::init_global_proxy_pool(proxy_pool_state.clone());\n    \n    // Start health check loop\n    proxy_pool_manager.clone().start_health_check_loop();\n        let security_state = Arc::new(RwLock::new(security_config));\n        let zai_state = Arc::new(RwLock::new(zai_config));\n        let provider_rr = Arc::new(AtomicUsize::new(0));\n        let zai_vision_mcp_state = Arc::new(crate::proxy::zai_vision_mcp::ZaiVisionMcpState::new());\n        let experimental_state = Arc::new(RwLock::new(experimental_config));\n        let debug_logging_state = Arc::new(RwLock::new(debug_logging));\n        let is_running_state = Arc::new(RwLock::new(true));\n\n        let state = AppState {\n            token_manager: token_manager.clone(),\n            custom_mapping: custom_mapping_state.clone(),\n            request_timeout: 300, // 5分钟超时\n            thought_signature_map: Arc::new(tokio::sync::Mutex::new(\n                std::collections::HashMap::new(),\n            )),\n            upstream_proxy: proxy_state.clone(),\n            upstream: {\n                let u = Arc::new(crate::proxy::upstream::client::UpstreamClient::new(\n                    Some(upstream_proxy.clone()),\n                    Some(proxy_pool_manager.clone()),\n                ));\n                // 初始化 User-Agent 覆盖\n                if user_agent_override.is_some() {\n                    u.set_user_agent_override(user_agent_override).await;\n                }\n                u\n            },\n            zai: zai_state.clone(),\n            provider_rr: provider_rr.clone(),\n            zai_vision_mcp: zai_vision_mcp_state,\n            monitor: monitor.clone(),\n            experimental: experimental_state.clone(),\n            debug_logging: debug_logging_state.clone(),\n            switching: Arc::new(RwLock::new(false)),\n            integration: integration.clone(),\n            account_service: Arc::new(crate::modules::account_service::AccountService::new(\n                integration.clone(),\n            )),\n            security: security_state.clone(),\n            cloudflared_state: cloudflared_state.clone(),\n            is_running: is_running_state.clone(),\n            port,\n            proxy_pool_state: proxy_pool_state.clone(),\n            proxy_pool_manager: proxy_pool_manager.clone(),\n        };\n\n        // 构建路由 - 使用新架构的 handlers！\n        use crate::proxy::handlers;\n        use crate::proxy::middleware::{\n            admin_auth_middleware, auth_middleware, cors_layer, ip_filter_middleware,\n            monitor_middleware, service_status_middleware,\n        };\n\n        // 1. 构建主 AI 代理路由 (遵循 auth_mode 配置)\n        let proxy_routes = Router::new()\n            .route(\"/health\", get(health_check_handler))\n            .route(\"/healthz\", get(health_check_handler))\n            // OpenAI Protocol\n            .route(\"/v1/models\", get(handlers::openai::handle_list_models))\n            .route(\n                \"/v1/chat/completions\",\n                post(handlers::openai::handle_chat_completions),\n            )\n            .route(\n                \"/v1/completions\",\n                post(handlers::openai::handle_completions),\n            )\n            .route(\"/v1/responses\", post(handlers::openai::handle_completions)) // 兼容 Codex CLI\n            .route(\n                \"/v1/images/generations\",\n                post(handlers::openai::handle_images_generations),\n            ) // 图像生成 API\n            .route(\n                \"/v1/images/edits\",\n                post(handlers::openai::handle_images_edits),\n            ) // 图像编辑 API\n            .route(\n                \"/v1/audio/transcriptions\",\n                post(handlers::audio::handle_audio_transcription),\n            ) // 音频转录 API\n            // Claude Protocol\n            .route(\"/v1/messages\", post(handlers::claude::handle_messages))\n            .route(\n                \"/v1/messages/count_tokens\",\n                post(handlers::claude::handle_count_tokens),\n            )\n            .route(\n                \"/v1/models/claude\",\n                get(handlers::claude::handle_list_models),\n            )\n            // z.ai MCP (optional reverse-proxy)\n            .route(\n                \"/mcp/web_search_prime/mcp\",\n                any(handlers::mcp::handle_web_search_prime),\n            )\n            .route(\"/mcp/web_reader/mcp\", any(handlers::mcp::handle_web_reader))\n            .route(\n                \"/mcp/zai-mcp-server/mcp\",\n                any(handlers::mcp::handle_zai_mcp_server),\n            )\n            // Gemini Protocol (Native)\n            .route(\"/v1beta/models\", get(handlers::gemini::handle_list_models))\n            // Handle both GET (get info) and POST (generateContent with colon) at the same route\n            .route(\n                \"/v1beta/models/:model\",\n                get(handlers::gemini::handle_get_model).post(handlers::gemini::handle_generate),\n            )\n            .route(\n                \"/v1beta/models/:model/countTokens\",\n                post(handlers::gemini::handle_count_tokens),\n            ) // Specific route priority\n            .route(\n                \"/v1/models/detect\",\n                post(handlers::common::handle_detect_model),\n            )\n            .route(\"/internal/warmup\", post(handlers::warmup::handle_warmup)) // 内部预热端点\n            .route(\"/v1/api/event_logging/batch\", post(silent_ok_handler))\n            .route(\"/v1/api/event_logging\", post(silent_ok_handler))\n            // 应用 AI 服务特定的层\n            // 注意：Axum layer 执行顺序是从下往上（洋葱模型）\n            // 请求: ip_filter -> auth -> monitor -> handler\n            // 响应: handler -> monitor -> auth -> ip_filter\n            // monitor 需要在 auth 之后执行才能获取 UserTokenIdentity\n            .layer(axum::middleware::from_fn_with_state(\n                state.clone(),\n                monitor_middleware,\n            ))\n            .layer(axum::middleware::from_fn_with_state(\n                state.clone(),\n                auth_middleware,\n            ))\n            .layer(axum::middleware::from_fn_with_state(\n                state.clone(),\n                ip_filter_middleware,\n            ));\n\n        // 2. 构建管理 API (强制鉴权)\n        let admin_routes = Router::new()\n            .route(\"/health\", get(health_check_handler))\n            .route(\n                \"/accounts\",\n                get(admin_list_accounts).post(admin_add_account),\n            )\n            .route(\"/accounts/current\", get(admin_get_current_account))\n            .route(\"/accounts/switch\", post(admin_switch_account))\n            .route(\"/accounts/refresh\", post(admin_refresh_all_quotas))\n            .route(\"/accounts/:accountId\", delete(admin_delete_account))\n            .route(\"/accounts/:accountId/bind-device\", post(admin_bind_device))\n            .route(\n                \"/accounts/:accountId/device-profiles\",\n                get(admin_get_device_profiles),\n            )\n            .route(\n                \"/accounts/:accountId/device-versions\",\n                get(admin_list_device_versions),\n            )\n            .route(\n                \"/accounts/device-preview\",\n                post(admin_preview_generate_profile),\n            )\n            .route(\n                \"/accounts/:accountId/bind-device-profile\",\n                post(admin_bind_device_profile_with_profile),\n            )\n            .route(\n                \"/accounts/restore-original\",\n                post(admin_restore_original_device),\n            )\n            .route(\n                \"/accounts/:accountId/device-versions/:versionId/restore\",\n                post(admin_restore_device_version),\n            )\n            .route(\n                \"/accounts/:accountId/device-versions/:versionId\",\n                delete(admin_delete_device_version),\n            )\n            .route(\"/accounts/import/v1\", post(admin_import_v1_accounts))\n            .route(\"/accounts/import/db\", post(admin_import_from_db))\n            .route(\"/accounts/import/db-custom\", post(admin_import_custom_db))\n            .route(\"/accounts/sync/db\", post(admin_sync_account_from_db))\n            .route(\"/stats/summary\", get(admin_get_token_stats_summary))\n            .route(\"/stats/hourly\", get(admin_get_token_stats_hourly))\n            .route(\"/stats/daily\", get(admin_get_token_stats_daily))\n            .route(\"/stats/weekly\", get(admin_get_token_stats_weekly))\n            .route(\"/stats/accounts\", get(admin_get_token_stats_by_account))\n            .route(\"/stats/models\", get(admin_get_token_stats_by_model))\n            .route(\"/config\", get(admin_get_config).post(admin_save_config))\n            .route(\"/proxy/cli/status\", post(admin_get_cli_sync_status))\n            .route(\"/proxy/cli/sync\", post(admin_execute_cli_sync))\n            .route(\"/proxy/cli/restore\", post(admin_execute_cli_restore))\n            .route(\"/proxy/cli/config\", post(admin_get_cli_config_content))\n            .route(\"/proxy/opencode/status\", post(admin_get_opencode_sync_status))\n            .route(\"/proxy/opencode/sync\", post(admin_execute_opencode_sync))\n            .route(\"/proxy/opencode/restore\", post(admin_execute_opencode_restore))\n            .route(\"/proxy/opencode/clear\", post(admin_execute_opencode_clear))\n            .route(\"/proxy/opencode/config\", post(admin_get_opencode_config_content))\n            .route(\"/proxy/droid/status\", post(admin_get_droid_sync_status))\n            .route(\"/proxy/droid/sync\", post(admin_execute_droid_sync))\n            .route(\"/proxy/droid/restore\", post(admin_execute_droid_restore))\n            .route(\"/proxy/droid/config\", post(admin_get_droid_config_content))\n            .route(\"/proxy/status\", get(admin_get_proxy_status))\n            .route(\"/proxy/pool/config\", get(admin_get_proxy_pool_config))\n            .route(\"/proxy/pool/bindings\", get(admin_get_all_account_bindings))\n            .route(\"/proxy/pool/bind\", post(admin_bind_account_proxy))\n            .route(\"/proxy/pool/unbind\", post(admin_unbind_account_proxy))\n            .route(\"/proxy/pool/binding/:accountId\", get(admin_get_account_proxy_binding))\n            .route(\"/proxy/health-check/trigger\", post(admin_trigger_proxy_health_check))\n            .route(\"/proxy/start\", post(admin_start_proxy_service))\n            .route(\"/proxy/stop\", post(admin_stop_proxy_service))\n            .route(\"/proxy/mapping\", post(admin_update_model_mapping))\n            .route(\"/proxy/api-key/generate\", post(admin_generate_api_key))\n            .route(\n                \"/proxy/session-bindings/clear\",\n                post(admin_clear_proxy_session_bindings),\n            )\n            .route(\"/proxy/rate-limits\", delete(admin_clear_all_rate_limits))\n            .route(\n                \"/proxy/rate-limits/:accountId\",\n                delete(admin_clear_rate_limit),\n            )\n            .route(\n                \"/proxy/preferred-account\",\n                get(admin_get_preferred_account).post(admin_set_preferred_account),\n            )\n            .route(\"/accounts/oauth/prepare\", post(admin_prepare_oauth_url))\n            .route(\"/accounts/oauth/start\", post(admin_start_oauth_login))\n            .route(\"/accounts/oauth/complete\", post(admin_complete_oauth_login))\n            .route(\"/accounts/oauth/cancel\", post(admin_cancel_oauth_login))\n            .route(\"/accounts/oauth/submit-code\", post(admin_submit_oauth_code))\n            .route(\"/zai/models/fetch\", post(admin_fetch_zai_models))\n            .route(\n                \"/proxy/monitor/toggle\",\n                post(admin_set_proxy_monitor_enabled),\n            )\n            .route(\n                \"/proxy/cloudflared/status\",\n                get(admin_cloudflared_get_status),\n            )\n            .route(\n                \"/proxy/cloudflared/install\",\n                post(admin_cloudflared_install),\n            )\n            .route(\"/proxy/cloudflared/start\", post(admin_cloudflared_start))\n            .route(\"/proxy/cloudflared/stop\", post(admin_cloudflared_stop))\n            .route(\"/system/open-folder\", post(admin_open_folder))\n            .route(\"/proxy/stats\", get(admin_get_proxy_stats))\n            .route(\"/logs\", get(admin_get_proxy_logs_filtered))\n            .route(\"/logs/count\", get(admin_get_proxy_logs_count_filtered))\n            .route(\"/logs/clear\", post(admin_clear_proxy_logs))\n            .route(\"/logs/:logId\", get(admin_get_proxy_log_detail))\n            // Debug Console (Log Bridge)\n            .route(\"/debug/enable\", post(admin_enable_debug_console))\n            .route(\"/debug/disable\", post(admin_disable_debug_console))\n            .route(\"/debug/enabled\", get(admin_is_debug_console_enabled))\n            .route(\"/debug/logs\", get(admin_get_debug_console_logs))\n            .route(\"/debug/logs/clear\", post(admin_clear_debug_console_logs))\n            .route(\"/stats/token/clear\", post(admin_clear_token_stats))\n            .route(\"/stats/token/hourly\", get(admin_get_token_stats_hourly))\n            .route(\"/stats/token/daily\", get(admin_get_token_stats_daily))\n            .route(\"/stats/token/weekly\", get(admin_get_token_stats_weekly))\n            .route(\n                \"/stats/token/by-account\",\n                get(admin_get_token_stats_by_account),\n            )\n            .route(\"/stats/token/summary\", get(admin_get_token_stats_summary))\n            .route(\"/stats/token/by-model\", get(admin_get_token_stats_by_model))\n            .route(\n                \"/stats/token/model-trend/hourly\",\n                get(admin_get_token_stats_model_trend_hourly),\n            )\n            .route(\n                \"/stats/token/model-trend/daily\",\n                get(admin_get_token_stats_model_trend_daily),\n            )\n            .route(\n                \"/stats/token/account-trend/hourly\",\n                get(admin_get_token_stats_account_trend_hourly),\n            )\n            .route(\n                \"/stats/token/account-trend/daily\",\n                get(admin_get_token_stats_account_trend_daily),\n            )\n            .route(\"/accounts/bulk-delete\", post(admin_delete_accounts))\n            .route(\"/accounts/export\", post(admin_export_accounts))\n            .route(\"/accounts/reorder\", post(admin_reorder_accounts))\n            .route(\"/accounts/:accountId/quota\", get(admin_fetch_account_quota))\n            .route(\n                \"/accounts/:accountId/toggle-proxy\",\n                post(admin_toggle_proxy_status),\n            )\n            .route(\"/accounts/warmup\", post(admin_warm_up_all_accounts))\n            .route(\"/accounts/:accountId/warmup\", post(admin_warm_up_account))\n            .route(\"/system/data-dir\", get(admin_get_data_dir_path))\n            .route(\"/system/updates/settings\", get(admin_get_update_settings))\n            .route(\n                \"/system/updates/check-status\",\n                get(admin_should_check_updates),\n            )\n            .route(\"/system/updates/check\", post(admin_check_for_updates))\n            .route(\"/system/updates/touch\", post(admin_update_last_check_time))\n            .route(\"/system/updates/save\", post(admin_save_update_settings))\n            .route(\n                \"/system/autostart/status\",\n                get(admin_is_auto_launch_enabled),\n            )\n            .route(\"/system/autostart/toggle\", post(admin_toggle_auto_launch))\n            .route(\n                \"/system/http-api/settings\",\n                get(admin_get_http_api_settings).post(admin_save_http_api_settings),\n            )\n            .route(\"/system/antigravity/path\", get(admin_get_antigravity_path))\n            .route(\"/system/antigravity/args\", get(admin_get_antigravity_args))\n            .route(\"/system/cache/clear\", post(admin_clear_antigravity_cache))\n            .route(\n                \"/system/cache/paths\",\n                get(admin_get_antigravity_cache_paths),\n            )\n            .route(\"/system/logs/clear-cache\", post(admin_clear_log_cache))\n            // Security / IP Monitoring\n            .route(\"/security/logs\", get(admin_get_ip_access_logs))\n            .route(\"/security/logs/clear\", post(admin_clear_ip_access_logs))\n            .route(\"/security/stats\", get(admin_get_ip_stats))\n            .route(\"/security/token-stats\", get(admin_get_ip_token_stats)) // For IP Token usage\n            .route(\"/security/blacklist\", get(admin_get_ip_blacklist).post(admin_add_ip_to_blacklist).delete(admin_remove_ip_from_blacklist))\n            .route(\"/security/blacklist/clear\", post(admin_clear_ip_blacklist))\n            .route(\"/security/blacklist/check\", get(admin_check_ip_in_blacklist))\n            .route(\"/security/whitelist\", get(admin_get_ip_whitelist).post(admin_add_ip_to_whitelist).delete(admin_remove_ip_from_whitelist))\n            .route(\"/security/whitelist/clear\", post(admin_clear_ip_whitelist))\n            .route(\"/security/whitelist/check\", get(admin_check_ip_in_whitelist))\n            .route(\"/security/config\", get(admin_get_security_config).post(admin_update_security_config))\n            // User Tokens\n            .route(\"/user-tokens\", get(admin_list_user_tokens).post(admin_create_user_token))\n            .route(\"/user-tokens/summary\", get(admin_get_user_token_summary))\n            .route(\"/user-tokens/:id/renew\", post(admin_renew_user_token))\n            .route(\"/user-tokens/:id\", delete(admin_delete_user_token).patch(admin_update_user_token))\n            // OAuth (Web) - Admin 接口\n            .route(\"/auth/url\", get(admin_prepare_oauth_url_web))\n            // 应用管理特定鉴权层 (强制校验)\n            .layer(axum::middleware::from_fn_with_state(\n                state.clone(),\n                admin_auth_middleware,\n            ));\n\n        // 3. 整合并应用全局层\n        // 从环境变量读取 body 大小限制，默认 50MB\n        let max_body_size: usize = std::env::var(\"ABV_MAX_BODY_SIZE\")\n            .ok()\n            .and_then(|s| s.parse().ok())\n            .unwrap_or(100 * 1024 * 1024); // 默认 100MB\n        tracing::info!(\"请求体大小限制: {} MB\", max_body_size / 1024 / 1024);\n\n        let app = Router::new()\n            .nest(\"/api\", admin_routes)\n            .merge(proxy_routes)\n            // 公开路由 (无需鉴权)\n            .route(\"/auth/callback\", get(handle_oauth_callback))\n            // 应用全局监控与状态层 (外层)\n            .layer(axum::middleware::from_fn_with_state(\n                state.clone(),\n                service_status_middleware,\n            ))\n            .layer(cors_layer())\n            .layer(DefaultBodyLimit::max(max_body_size)) // 放宽 body 大小限制\n            .with_state(state.clone());\n\n        // 静态文件托管 (用于 Headless/Docker 模式)\n        let dist_path = std::env::var(\"ABV_DIST_PATH\").unwrap_or_else(|_| \"dist\".to_string());\n        let app = if std::path::Path::new(&dist_path).exists() {\n            tracing::info!(\"正在托管静态资源: {}\", dist_path);\n            app.fallback_service(tower_http::services::ServeDir::new(&dist_path).fallback(\n                tower_http::services::ServeFile::new(format!(\"{}/index.html\", dist_path)),\n            ))\n        } else {\n            app\n        };\n\n        // 绑定地址\n        let addr = format!(\"{}:{}\", host, port);\n        let listener = tokio::net::TcpListener::bind(&addr)\n            .await\n            .map_err(|e| format!(\"地址 {} 绑定失败: {}\", addr, e))?;\n\n        tracing::info!(\"反代服务器启动在 http://{}\", addr);\n\n        // 创建关闭通道\n        let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();\n\n        let server_instance = Self {\n            shutdown_tx: Arc::new(tokio::sync::Mutex::new(Some(shutdown_tx))),\n            custom_mapping: custom_mapping_state.clone(),\n            proxy_state,\n            upstream: state.upstream.clone(),\n            security_state,\n            zai_state,\n            experimental: experimental_state.clone(),\n            debug_logging: debug_logging_state.clone(),\n            cloudflared_state,\n            is_running: is_running_state,\n            token_manager: token_manager.clone(),\n            proxy_pool_state,\n            proxy_pool_manager,\n        };\n\n        // 在新任务中启动服务器\n        let handle = tokio::spawn(async move {\n            use hyper::server::conn::http1;\n            use hyper_util::rt::TokioIo;\n            use hyper_util::service::TowerToHyperService;\n\n            loop {\n                tokio::select! {\n                    res = listener.accept() => {\n                        match res {\n                            Ok((stream, remote_addr)) => {\n                                let io = TokioIo::new(stream);\n                                \n                                // 注入 ConnectInfo (用于获取真实 IP)\n                                use tower::ServiceExt;\n                                use hyper::body::Incoming;\n                                let app_with_info = app.clone().map_request(move |mut req: axum::http::Request<Incoming>| {\n                                    req.extensions_mut().insert(axum::extract::ConnectInfo(remote_addr));\n                                    req\n                                });\n\n                                let service = TowerToHyperService::new(app_with_info);\n\n                                tokio::task::spawn(async move {\n                                    if let Err(err) = http1::Builder::new()\n                                        .serve_connection(io, service)\n                                        .with_upgrades() // 支持 WebSocket (如果以后需要)\n                                        .await\n                                    {\n                                        debug!(\"连接处理结束或出错: {:?}\", err);\n                                    }\n                                });\n                            }\n                            Err(e) => {\n                                error!(\"接收连接失败: {:?}\", e);\n                            }\n                        }\n                    }\n                    _ = &mut shutdown_rx => {\n                        tracing::info!(\"反代服务器停止监听\");\n                        break;\n                    }\n                }\n            }\n        });\n\n        Ok((server_instance, handle))\n    }\n\n    /// 停止服务器\n    pub fn stop(&self) {\n        let tx_mutex = self.shutdown_tx.clone();\n        tokio::spawn(async move {\n            let mut lock = tx_mutex.lock().await;\n            if let Some(tx) = lock.take() {\n                let _ = tx.send(());\n                tracing::info!(\"Axum server 停止信号已发送\");\n            }\n        });\n    }\n}\n\n// ===== API 处理器 (旧代码已移除，由 src/proxy/handlers/* 接管) =====\n\n/// 健康检查处理器\nasync fn health_check_handler() -> Response {\n    Json(serde_json::json!({\n        \"status\": \"ok\",\n        \"version\": env!(\"CARGO_PKG_VERSION\")\n    }))\n    .into_response()\n}\n\n/// 静默成功处理器 (用于拦截遥测日志等)\nasync fn silent_ok_handler() -> Response {\n    StatusCode::OK.into_response()\n}\n\n// ============================================================================\n// [PHASE 1] 整合后的 Admin Handlers\n// ============================================================================\n\n// [整合清理] 旧模型定义与映射器已上移\n\nasync fn admin_list_accounts(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let accounts = state.account_service.list_accounts().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    let current_id = state.account_service.get_current_id().ok().flatten();\n\n    let account_responses: Vec<AccountResponse> = accounts\n        .into_iter()\n        .map(|acc| {\n            let is_current = current_id.as_ref().map(|id| id == &acc.id).unwrap_or(false);\n            let quota = acc.quota.map(|q| QuotaResponse {\n                models: q\n                    .models\n                    .into_iter()\n                    .map(|m| ModelQuota {\n                        name: m.name,\n                        percentage: m.percentage,\n                        reset_time: m.reset_time,\n                    })\n                    .collect(),\n                last_updated: q.last_updated,\n                subscription_tier: q.subscription_tier,\n                is_forbidden: q.is_forbidden,\n            });\n\n            AccountResponse {\n                id: acc.id,\n                email: acc.email,\n                name: acc.name,\n                is_current,\n                disabled: acc.disabled,\n                disabled_reason: acc.disabled_reason,\n                disabled_at: acc.disabled_at,\n                proxy_disabled: acc.proxy_disabled,\n                proxy_disabled_reason: acc.proxy_disabled_reason,\n                proxy_disabled_at: acc.proxy_disabled_at,\n                protected_models: acc.protected_models.into_iter().collect(),\n                validation_blocked: acc.validation_blocked,\n                validation_blocked_until: acc.validation_blocked_until,\n                validation_blocked_reason: acc.validation_blocked_reason,\n                quota,\n                device_bound: acc.device_profile.is_some(),\n                last_used: acc.last_used,\n            }\n        })\n        .collect();\n\n    Ok(Json(AccountListResponse {\n        current_account_id: current_id,\n        accounts: account_responses,\n    }))\n}\n\n/// Export accounts with refresh tokens (for backup/migration)\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct ExportAccountsRequest {\n    account_ids: Vec<String>,\n}\n\nasync fn admin_export_accounts(\n    State(_state): State<AppState>,\n    Json(payload): Json<ExportAccountsRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let response = account::export_accounts_by_ids(&payload.account_ids).map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    Ok(Json(response))\n}\n\nasync fn admin_get_current_account(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let current_id = state.account_service.get_current_id().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    let response = if let Some(id) = current_id {\n        let acc = account::load_account(&id).ok();\n        acc.map(|acc| {\n            let quota = acc.quota.map(|q| QuotaResponse {\n                models: q\n                    .models\n                    .into_iter()\n                    .map(|m| ModelQuota {\n                        name: m.name,\n                        percentage: m.percentage,\n                        reset_time: m.reset_time,\n                    })\n                    .collect(),\n                last_updated: q.last_updated,\n                subscription_tier: q.subscription_tier,\n                is_forbidden: q.is_forbidden,\n            });\n\n            AccountResponse {\n                id: acc.id,\n                email: acc.email,\n                name: acc.name,\n                is_current: true,\n                disabled: acc.disabled,\n                disabled_reason: acc.disabled_reason,\n                disabled_at: acc.disabled_at,\n                proxy_disabled: acc.proxy_disabled,\n                proxy_disabled_reason: acc.proxy_disabled_reason,\n                proxy_disabled_at: acc.proxy_disabled_at,\n                protected_models: acc.protected_models.into_iter().collect(),\n                validation_blocked: acc.validation_blocked,\n                validation_blocked_until: acc.validation_blocked_until,\n                validation_blocked_reason: acc.validation_blocked_reason,\n                quota,\n                device_bound: acc.device_profile.is_some(),\n                last_used: acc.last_used,\n            }\n        })\n    } else {\n        None\n    };\n\n    Ok(Json(response))\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct AddAccountRequest {\n    refresh_token: String,\n}\n\nasync fn admin_add_account(\n    State(state): State<AppState>,\n    Json(payload): Json<AddAccountRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let account = state\n        .account_service\n        .add_account(&payload.refresh_token)\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n\n    // [FIX #1166] 账号变动后立即重新加载 TokenManager\n    if let Err(e) = state.token_manager.load_accounts().await {\n        logger::log_error(&format!(\n            \"[API] Failed to reload accounts after adding: {}\",\n            e\n        ));\n    }\n\n    let current_id = state.account_service.get_current_id().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(to_account_response(&account, &current_id)))\n}\n\nasync fn admin_delete_account(\n    State(state): State<AppState>,\n    Path(account_id): Path<String>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    state\n        .account_service\n        .delete_account(&account_id)\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n\n    // [FIX #1166] 账号变动后立即重新加载 TokenManager\n    if let Err(e) = state.token_manager.load_accounts().await {\n        logger::log_error(&format!(\n            \"[API] Failed to reload accounts after deletion: {}\",\n            e\n        ));\n    }\n\n    Ok(StatusCode::NO_CONTENT)\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct SwitchRequest {\n    account_id: String,\n}\n\nasync fn admin_switch_account(\n    State(state): State<AppState>,\n    Json(payload): Json<SwitchRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    {\n        let switching = state.switching.read().await;\n        if *switching {\n            return Err((\n                StatusCode::CONFLICT,\n                Json(ErrorResponse {\n                    error: \"Another switch operation is already in progress\".to_string(),\n                }),\n            ));\n        }\n    }\n\n    {\n        let mut switching = state.switching.write().await;\n        *switching = true;\n    }\n\n    let account_id = payload.account_id.clone();\n    logger::log_info(&format!(\"[API] Starting account switch: {}\", account_id));\n\n    let result = state.account_service.switch_account(&account_id).await;\n\n    {\n        let mut switching = state.switching.write().await;\n        *switching = false;\n    }\n\n    match result {\n        Ok(()) => {\n            logger::log_info(&format!(\"[API] Account switch successful: {}\", account_id));\n\n            // [FIX #1166] 账号切换后立即同步内存状态\n            state.token_manager.clear_all_sessions();\n            if let Err(e) = state.token_manager.load_accounts().await {\n                logger::log_error(&format!(\n                    \"[API] Failed to reload accounts after switch: {}\",\n                    e\n                ));\n            }\n\n            Ok(StatusCode::OK)\n        }\n        Err(e) => {\n            logger::log_error(&format!(\"[API] Account switch failed: {}\", e));\n            Err((\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            ))\n        }\n    }\n}\n\nasync fn admin_refresh_all_quotas() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)>\n{\n    logger::log_info(\"[API] Starting refresh of all account quotas\");\n    let stats = account::refresh_all_quotas_logic().await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    Ok(Json(stats))\n}\n\n// --- OAuth Handlers ---\n\nasync fn admin_prepare_oauth_url(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let url = state\n        .account_service\n        .prepare_oauth_url()\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n    Ok(Json(serde_json::json!({ \"url\": url })))\n}\n\nasync fn admin_start_oauth_login(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let account = state\n        .account_service\n        .start_oauth_login()\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n    let current_id = state.account_service.get_current_id().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(to_account_response(&account, &current_id)))\n}\n\nasync fn admin_complete_oauth_login(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let account = state\n        .account_service\n        .complete_oauth_login()\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n    let current_id = state.account_service.get_current_id().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(to_account_response(&account, &current_id)))\n}\n\nasync fn admin_cancel_oauth_login(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    state.account_service.cancel_oauth_login();\n    Ok(StatusCode::OK)\n}\n\n#[derive(Deserialize)]\nstruct SubmitCodeRequest {\n    code: String,\n    state: Option<String>,\n}\n\nasync fn admin_submit_oauth_code(\n    State(state): State<AppState>,\n    Json(payload): Json<SubmitCodeRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    state\n        .account_service\n        .submit_oauth_code(payload.code, payload.state)\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n    Ok(StatusCode::OK)\n}\n\n#[derive(Deserialize)]\nstruct BindDeviceRequest {\n    #[serde(default = \"default_bind_mode\")]\n    mode: String,\n}\n\nfn default_bind_mode() -> String {\n    \"generate\".to_string()\n}\n\nasync fn admin_bind_device(\n    Path(account_id): Path<String>,\n    Json(payload): Json<BindDeviceRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let result = account::bind_device_profile(&account_id, &payload.mode).map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    Ok(Json(serde_json::json!({\n        \"success\": true,\n        \"message\": \"Device fingerprint bound successfully\",\n        \"device_profile\": result,\n    })))\n}\n\n#[derive(Deserialize, Debug, Default)]\n#[serde(rename_all = \"camelCase\")]\n#[allow(dead_code)] // 预留日志接口结构体\nstruct LogsRequest {\n    #[serde(default)]\n    limit: usize,\n    #[serde(default)]\n    offset: usize,\n    #[serde(default)]\n    filter: String,\n    #[serde(default)]\n    errors_only: bool,\n}\n\n#[allow(dead_code)] // 预留日志接口\nasync fn admin_get_logs(\n    Query(params): Query<LogsRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let limit = if params.limit == 0 { 50 } else { params.limit };\n    let total =\n        proxy_db::get_logs_count_filtered(&params.filter, params.errors_only).map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n    let logs =\n        proxy_db::get_logs_filtered(&params.filter, params.errors_only, limit, params.offset)\n            .map_err(|e| {\n                (\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    Json(ErrorResponse { error: e }),\n                )\n            })?;\n\n    Ok(Json(serde_json::json!({\n        \"total\": total,\n        \"logs\": logs,\n    })))\n}\n\nasync fn admin_get_config() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let cfg = config::load_app_config().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(cfg))\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct SaveConfigWrapper {\n    config: AppConfig,\n}\n\nasync fn admin_save_config(\n    State(state): State<AppState>,\n    Json(payload): Json<SaveConfigWrapper>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let new_config = payload.config;\n    // 1. 持久化\n    config::save_app_config(&new_config).map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    // 2. 热更新内存状态\n    // 这里我们直接复用内部组件的 update 方法\n    // 注意：AppState 本身持有各个组件的 Arc<RwLock> 或直接持有引用\n\n    // 我们需要一个方式获取到当前的 AxumServer 实例来进行热更新，\n    // 或者直接操作 AppState 里的各状态。\n    // 在本重构中，各个状态已经在 AppState 中了。\n\n    // 更新模型映射\n    {\n        let mut mapping = state.custom_mapping.write().await;\n        *mapping = new_config.clone().proxy.custom_mapping;\n    }\n\n    // 更新上游代理\n    {\n        let mut proxy = state.upstream_proxy.write().await;\n        *proxy = new_config.clone().proxy.upstream_proxy;\n    }\n\n    // 更新安全策略\n    {\n        let mut security = state.security.write().await;\n        *security = crate::proxy::ProxySecurityConfig::from_proxy_config(&new_config.proxy);\n    }\n\n    // 更新 z.ai 配置\n    {\n        let mut zai = state.zai.write().await;\n        *zai = new_config.clone().proxy.zai;\n    }\n\n    // 更新实验性配置\n    {\n        let mut exp = state.experimental.write().await;\n        *exp = new_config.clone().proxy.experimental;\n    }\n\n    // 更新代理池配置（Web/Docker 保存配置时热更新）\n    {\n        let mut pool = state.proxy_pool_state.write().await;\n        *pool = new_config.clone().proxy.proxy_pool;\n    }\n\n    Ok(StatusCode::OK)\n}\n\n// [FIX Web Mode] Get proxy pool config\nasync fn admin_get_proxy_pool_config(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let config = state.proxy_pool_state.read().await;\n    Ok(Json(config.clone()))\n}\n\n// [FIX Web Mode] Get all account proxy bindings\nasync fn admin_get_all_account_bindings(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let bindings = state.proxy_pool_manager.get_all_bindings_snapshot();\n    Ok(Json(bindings))\n}\n\n// [FIX Web Mode] Bind account to proxy\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct BindAccountProxyRequest {\n    account_id: String,\n    proxy_id: String,\n}\n\nasync fn admin_bind_account_proxy(\n    State(state): State<AppState>,\n    Json(payload): Json<BindAccountProxyRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    state.proxy_pool_manager\n        .bind_account_to_proxy(payload.account_id, payload.proxy_id)\n        .await\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    Ok(StatusCode::OK)\n}\n\n// [FIX Web Mode] Unbind account from proxy\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct UnbindAccountProxyRequest {\n    account_id: String,\n}\n\nasync fn admin_unbind_account_proxy(\n    State(state): State<AppState>,\n    Json(payload): Json<UnbindAccountProxyRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    state.proxy_pool_manager.unbind_account_proxy(payload.account_id).await;\n    Ok(StatusCode::OK)\n}\n\n// [FIX Web Mode] Get account proxy binding\nasync fn admin_get_account_proxy_binding(\n    State(state): State<AppState>,\n    Path(account_id): Path<String>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let binding = state.proxy_pool_manager.get_account_binding(&account_id);\n    Ok(Json(binding))\n}\n\n// [FIX Web Mode] Trigger proxy pool health check\nasync fn admin_trigger_proxy_health_check(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    state.proxy_pool_manager.health_check().await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    // 返回更新后的代理池配置（包含健康状态）\n    let config = state.proxy_pool_state.read().await;\n    Ok(Json(serde_json::json!({\n        \"success\": true,\n        \"message\": \"Health check completed\",\n        \"proxies\": config.proxies,\n    })))\n}\n\nasync fn admin_get_proxy_status(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    // 在 Headless/Axum 模式下，AxumServer 既然在运行，通常就是 running\n    let active_accounts = state.token_manager.len();\n\n    let is_running = { *state.is_running.read().await };\n    Ok(Json(serde_json::json!({\n        \"running\": is_running,\n        \"port\": state.port,\n        \"base_url\": format!(\"http://127.0.0.1:{}\", state.port),\n        \"active_accounts\": active_accounts,\n    })))\n}\n\nasync fn admin_start_proxy_service(State(state): State<AppState>) -> impl IntoResponse {\n    // 1. 持久化配置 (修复 #1166)\n    if let Ok(mut config) = crate::modules::config::load_app_config() {\n        config.proxy.auto_start = true;\n        let _ = crate::modules::config::save_app_config(&config);\n    }\n\n    // 2. 确保账号已加载 (如果是第一次启动)\n    if let Err(e) = state.token_manager.load_accounts().await {\n        logger::log_error(&format!(\"[API] 启用服务并加载账号失败: {}\", e));\n    }\n\n    let mut running = state.is_running.write().await;\n    *running = true;\n    logger::log_info(\"[API] 反代服务功能已启用 (持久化已同步)\");\n    StatusCode::OK\n}\n\nasync fn admin_stop_proxy_service(State(state): State<AppState>) -> impl IntoResponse {\n    // 1. 持久化配置 (修复 #1166)\n    if let Ok(mut config) = crate::modules::config::load_app_config() {\n        config.proxy.auto_start = false;\n        let _ = crate::modules::config::save_app_config(&config);\n    }\n\n    let mut running = state.is_running.write().await;\n    *running = false;\n    logger::log_info(\"[API] 反代服务功能已禁用 (Axum 模式 / 持久化已同步)\");\n    StatusCode::OK\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct UpdateMappingWrapper {\n    config: crate::proxy::config::ProxyConfig,\n}\n\nasync fn admin_update_model_mapping(\n    State(state): State<AppState>,\n    Json(payload): Json<UpdateMappingWrapper>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let config = payload.config;\n\n    // 1. 更新内存状态 (热更新)\n    {\n        let mut mapping = state.custom_mapping.write().await;\n        *mapping = config.custom_mapping.clone();\n    }\n\n    // 2. 持久化到硬盘 (修复 #1149)\n    // 加载当前配置，更新 mapping，然后保存\n    let mut app_config = crate::modules::config::load_app_config().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    app_config.proxy.custom_mapping = config.custom_mapping;\n\n    crate::modules::config::save_app_config(&app_config).map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    logger::log_info(\"[API] 模型映射已通过 API 热更新并保存\");\n    Ok(StatusCode::OK)\n}\n\nasync fn admin_generate_api_key() -> impl IntoResponse {\n    let new_key = format!(\"sk-{}\", uuid::Uuid::new_v4().to_string().replace(\"-\", \"\"));\n    Json(new_key)\n}\n\nasync fn admin_clear_proxy_session_bindings(State(state): State<AppState>) -> impl IntoResponse {\n    state.token_manager.clear_all_sessions();\n    logger::log_info(\"[API] 已清除所有会话绑定\");\n    StatusCode::OK\n}\n\nasync fn admin_clear_all_rate_limits(State(state): State<AppState>) -> impl IntoResponse {\n    state.token_manager.clear_all_rate_limits();\n    logger::log_info(\"[API] 已清除所有限流记录\");\n    StatusCode::OK\n}\n\nasync fn admin_clear_rate_limit(\n    State(state): State<AppState>,\n    Path(account_id): Path<String>,\n) -> impl IntoResponse {\n    let cleared = state.token_manager.clear_rate_limit(&account_id);\n    if cleared {\n        logger::log_info(&format!(\"[API] 已清除账号 {} 的限流记录\", account_id));\n        StatusCode::OK\n    } else {\n        StatusCode::NOT_FOUND\n    }\n}\n\nasync fn admin_get_preferred_account(State(state): State<AppState>) -> impl IntoResponse {\n    let pref = state.token_manager.get_preferred_account().await;\n    Json(pref)\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct SetPreferredAccountRequest {\n    account_id: Option<String>,\n}\n\nasync fn admin_set_preferred_account(\n    State(state): State<AppState>,\n    Json(payload): Json<SetPreferredAccountRequest>,\n) -> impl IntoResponse {\n    state\n        .token_manager\n        .set_preferred_account(payload.account_id)\n        .await;\n    StatusCode::OK\n}\n\nasync fn admin_fetch_zai_models(\n    Path(_id): Path<String>,\n    Json(payload): Json<serde_json::Value>, // 复用前端传来的参数\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    // 这里简单实现，如果需要更复杂的抓取逻辑，可以调用 zai 模块\n    // 目前前端 fetch_zai_models 本质上也是一个工具函数，\n    // 我们可以在后端通过 reqwest 代理抓取。\n    let zai_config = payload.get(\"zai\").ok_or_else(|| {\n        (\n            StatusCode::BAD_REQUEST,\n            Json(ErrorResponse {\n                error: \"Missing zai config\".to_string(),\n            }),\n        )\n    })?;\n\n    let api_key = zai_config\n        .get(\"api_key\")\n        .and_then(|v| v.as_str())\n        .unwrap_or(\"\");\n    let base_url = zai_config\n        .get(\"base_url\")\n        .and_then(|v| v.as_str())\n        .unwrap_or(\"https://api.z.ai\");\n\n    // 尝试从 z.ai 获取模型\n    let client = reqwest::Client::new();\n    let resp = client\n        .get(format!(\"{}/v1/models\", base_url))\n        .header(\"Authorization\", format!(\"Bearer {}\", api_key))\n        .send()\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse {\n                    error: e.to_string(),\n                }),\n            )\n        })?;\n\n    let data: serde_json::Value = resp.json().await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: e.to_string(),\n            }),\n        )\n    })?;\n\n    // 提取模型 ID 列表\n    let models = data\n        .get(\"data\")\n        .and_then(|v| v.as_array())\n        .map(|arr| {\n            arr.iter()\n                .filter_map(|m| {\n                    m.get(\"id\")\n                        .and_then(|id| id.as_str().map(|s| s.to_string()))\n                })\n                .collect::<Vec<String>>()\n        })\n        .unwrap_or_default();\n\n    Ok(Json(models))\n}\n\nasync fn admin_set_proxy_monitor_enabled(\n    State(state): State<AppState>,\n    Json(payload): Json<serde_json::Value>,\n) -> impl IntoResponse {\n    let enabled = payload\n        .get(\"enabled\")\n        .and_then(|v| v.as_bool())\n        .unwrap_or(false);\n\n    // [FIX #1269] 只有在状态真正改变时才记录日志并设置，避免重复触发导致的\"重启\"错觉\n    if state.monitor.is_enabled() != enabled {\n        state.monitor.set_enabled(enabled);\n        logger::log_info(&format!(\"[API] 监控状态已设置为: {}\", enabled));\n    }\n\n    StatusCode::OK\n}\n\nasync fn admin_get_proxy_logs_count_filtered(\n    Query(params): Query<LogsRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let res = tokio::task::spawn_blocking(move || {\n        proxy_db::get_logs_count_filtered(&params.filter, params.errors_only)\n    })\n    .await;\n\n    match res {\n        Ok(Ok(count)) => Ok(Json(count)),\n        Ok(Err(e)) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )),\n        Err(e) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: e.to_string(),\n            }),\n        )),\n    }\n}\n\nasync fn admin_clear_proxy_logs() -> impl IntoResponse {\n    let _ = tokio::task::spawn_blocking(|| {\n        if let Err(e) = proxy_db::clear_logs() {\n            logger::log_error(&format!(\"[API] 清除反代日志失败: {}\", e));\n        }\n    })\n    .await;\n    logger::log_info(\"[API] 已清除所有反代日志\");\n    StatusCode::OK\n}\n\nasync fn admin_get_proxy_log_detail(\n    Path(log_id): Path<String>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let res =\n        tokio::task::spawn_blocking(move || crate::modules::proxy_db::get_log_detail(&log_id))\n            .await;\n\n    match res {\n        Ok(Ok(log)) => Ok(Json(log)),\n        Ok(Err(e)) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )),\n        Err(e) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: e.to_string(),\n            }),\n        )),\n    }\n}\n\n#[derive(Deserialize, Debug, Default)]\n#[serde(rename_all = \"camelCase\")]\nstruct LogsFilterQuery {\n    #[serde(default)]\n    filter: String,\n    #[serde(default)]\n    errors_only: bool,\n    #[serde(default)]\n    limit: usize,\n    #[serde(default)]\n    offset: usize,\n}\n\nasync fn admin_get_proxy_logs_filtered(\n    Query(params): Query<LogsFilterQuery>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let res = tokio::task::spawn_blocking(move || {\n        crate::modules::proxy_db::get_logs_filtered(\n            &params.filter,\n            params.errors_only,\n            params.limit,\n            params.offset,\n        )\n    })\n    .await;\n\n    match res {\n        Ok(Ok(logs)) => Ok(Json(logs)),\n        Ok(Err(e)) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )),\n        Err(e) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: e.to_string(),\n            }),\n        )),\n    }\n}\n\nasync fn admin_get_proxy_stats(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let stats = state.monitor.get_stats().await;\n    Ok(Json(stats))\n}\n\nasync fn admin_get_data_dir_path() -> impl IntoResponse {\n    match crate::modules::account::get_data_dir() {\n        Ok(p) => Json(p.to_string_lossy().to_string()),\n        Err(e) => Json(format!(\"Error: {}\", e)),\n    }\n}\n\n// --- User Token Handlers ---\n\nasync fn admin_list_user_tokens() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let tokens = crate::commands::user_token::list_user_tokens().await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(tokens))\n}\n\nasync fn admin_get_user_token_summary() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let summary = crate::commands::user_token::get_user_token_summary().await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(summary))\n}\n\nasync fn admin_create_user_token(\n    Json(payload): Json<crate::commands::user_token::CreateTokenRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let token = crate::commands::user_token::create_user_token(payload).await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(token))\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct RenewTokenRequest {\n    expires_type: String,\n}\n\nasync fn admin_renew_user_token(\n    Path(id): Path<String>,\n    Json(payload): Json<RenewTokenRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::commands::user_token::renew_user_token(id, payload.expires_type).await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(StatusCode::OK)\n}\n\nasync fn admin_delete_user_token(\n    Path(id): Path<String>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::commands::user_token::delete_user_token(id).await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(StatusCode::NO_CONTENT)\n}\n\nasync fn admin_update_user_token(\n    Path(id): Path<String>,\n    Json(payload): Json<crate::commands::user_token::UpdateTokenRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::commands::user_token::update_user_token(id, payload).await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(StatusCode::OK)\n}\n\nasync fn admin_should_check_updates() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)>\n{\n    let settings = crate::modules::update_checker::load_update_settings().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    let should = crate::modules::update_checker::should_check_for_updates(&settings);\n    Ok(Json(should))\n}\n\nasync fn admin_get_antigravity_path() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)>\n{\n    let path = crate::commands::get_antigravity_path(Some(true))\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n    Ok(Json(path))\n}\n\nasync fn admin_get_antigravity_args() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)>\n{\n    let args = crate::commands::get_antigravity_args().await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(args))\n}\n\nasync fn admin_clear_antigravity_cache(\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let res = crate::commands::clear_antigravity_cache().await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(res))\n}\n\nasync fn admin_get_antigravity_cache_paths(\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let res = crate::commands::get_antigravity_cache_paths()\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n    Ok(Json(res))\n}\n\nasync fn admin_clear_log_cache() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::commands::clear_log_cache().await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(StatusCode::OK)\n}\n\n// Token Stats Handlers\n#[derive(Deserialize, Debug, Default)]\n#[serde(rename_all = \"camelCase\")]\nstruct StatsPeriodQuery {\n    hours: Option<i64>,\n    days: Option<i64>,\n    weeks: Option<i64>,\n}\n\nasync fn admin_get_token_stats_hourly(\n    Query(p): Query<StatsPeriodQuery>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let hours = p.hours.unwrap_or(24);\n    let res = tokio::task::spawn_blocking(move || token_stats::get_hourly_stats(hours)).await;\n\n    match res {\n        Ok(Ok(stats)) => Ok(Json(stats)),\n        Ok(Err(e)) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )),\n        Err(e) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: e.to_string(),\n            }),\n        )),\n    }\n}\n\nasync fn admin_get_token_stats_daily(\n    Query(p): Query<StatsPeriodQuery>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let days = p.days.unwrap_or(7);\n    let res = tokio::task::spawn_blocking(move || token_stats::get_daily_stats(days)).await;\n\n    match res {\n        Ok(Ok(stats)) => Ok(Json(stats)),\n        Ok(Err(e)) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )),\n        Err(e) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: e.to_string(),\n            }),\n        )),\n    }\n}\n\nasync fn admin_get_token_stats_weekly(\n    Query(p): Query<StatsPeriodQuery>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let weeks = p.weeks.unwrap_or(4);\n    let res = tokio::task::spawn_blocking(move || token_stats::get_weekly_stats(weeks)).await;\n\n    match res {\n        Ok(Ok(stats)) => Ok(Json(stats)),\n        Ok(Err(e)) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )),\n        Err(e) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: e.to_string(),\n            }),\n        )),\n    }\n}\n\nasync fn admin_get_token_stats_by_account(\n    Query(p): Query<StatsPeriodQuery>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let hours = p.hours.unwrap_or(168);\n    let res = tokio::task::spawn_blocking(move || token_stats::get_account_stats(hours)).await;\n\n    match res {\n        Ok(Ok(stats)) => Ok(Json(stats)),\n        Ok(Err(e)) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )),\n        Err(e) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: e.to_string(),\n            }),\n        )),\n    }\n}\n\nasync fn admin_get_token_stats_summary(\n    Query(p): Query<StatsPeriodQuery>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let hours = p.hours.unwrap_or(168);\n    let res = tokio::task::spawn_blocking(move || token_stats::get_summary_stats(hours)).await;\n\n    match res {\n        Ok(Ok(stats)) => Ok(Json(stats)),\n        Ok(Err(e)) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )),\n        Err(e) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: e.to_string(),\n            }),\n        )),\n    }\n}\n\nasync fn admin_get_token_stats_by_model(\n    Query(p): Query<StatsPeriodQuery>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let hours = p.hours.unwrap_or(168);\n    let res = tokio::task::spawn_blocking(move || token_stats::get_model_stats(hours)).await;\n\n    match res {\n        Ok(Ok(stats)) => Ok(Json(stats)),\n        Ok(Err(e)) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )),\n        Err(e) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: e.to_string(),\n            }),\n        )),\n    }\n}\n\nasync fn admin_get_token_stats_model_trend_hourly(\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let res = tokio::task::spawn_blocking(|| {\n        token_stats::get_model_trend_hourly(24) // Default 24 hours\n    })\n    .await;\n\n    match res {\n        Ok(Ok(stats)) => Ok(Json(stats)),\n        Ok(Err(e)) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )),\n        Err(e) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: e.to_string(),\n            }),\n        )),\n    }\n}\n\nasync fn admin_get_token_stats_model_trend_daily(\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let res = tokio::task::spawn_blocking(|| {\n        token_stats::get_model_trend_daily(7) // Default 7 days\n    })\n    .await;\n\n    match res {\n        Ok(Ok(stats)) => Ok(Json(stats)),\n        Ok(Err(e)) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )),\n        Err(e) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: e.to_string(),\n            }),\n        )),\n    }\n}\n\nasync fn admin_get_token_stats_account_trend_hourly(\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let res = tokio::task::spawn_blocking(|| {\n        token_stats::get_account_trend_hourly(24) // Default 24 hours\n    })\n    .await;\n\n    match res {\n        Ok(Ok(stats)) => Ok(Json(stats)),\n        Ok(Err(e)) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )),\n        Err(e) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: e.to_string(),\n            }),\n        )),\n    }\n}\n\nasync fn admin_get_token_stats_account_trend_daily(\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let res = tokio::task::spawn_blocking(|| {\n        token_stats::get_account_trend_daily(7) // Default 7 days\n    })\n    .await;\n\n    match res {\n        Ok(Ok(stats)) => Ok(Json(stats)),\n        Ok(Err(e)) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )),\n        Err(e) => Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: e.to_string(),\n            }),\n        )),\n    }\n}\n\nasync fn admin_clear_token_stats() -> impl IntoResponse {\n    let res = tokio::task::spawn_blocking(|| {\n        // Clear databases (brute force)\n        if let Ok(path) = token_stats::get_db_path() {\n            let _ = std::fs::remove_file(path);\n        }\n        let _ = token_stats::init_db();\n    })\n    .await;\n\n    match res {\n        Ok(_) => {\n            logger::log_info(\"[API] 已清除所有 Token 统计数据\");\n            StatusCode::OK\n        }\n        Err(e) => {\n            logger::log_error(&format!(\"[API] 清除 Token 统计数据失败: {}\", e));\n            StatusCode::INTERNAL_SERVER_ERROR\n        }\n    }\n}\n\nasync fn admin_get_update_settings() -> impl IntoResponse {\n    // 從真實模組加載設置\n    match crate::modules::update_checker::load_update_settings() {\n        Ok(s) => Json(serde_json::to_value(s).unwrap_or_default()),\n        Err(_) => Json(serde_json::json!({\n            \"auto_check\": true,\n            \"last_check_time\": 0,\n            \"check_interval_hours\": 24\n        })),\n    }\n}\n\nasync fn admin_check_for_updates() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let info = crate::modules::update_checker::check_for_updates()\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n    Ok(Json(info))\n}\n\nasync fn admin_update_last_check_time(\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::modules::update_checker::update_last_check_time().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(StatusCode::OK)\n}\n\nasync fn admin_save_update_settings(Json(settings): Json<serde_json::Value>) -> impl IntoResponse {\n    if let Ok(s) =\n        serde_json::from_value::<crate::modules::update_checker::UpdateSettings>(settings)\n    {\n        let _ = crate::modules::update_checker::save_update_settings(&s);\n        StatusCode::OK\n    } else {\n        StatusCode::BAD_REQUEST\n    }\n}\n\nasync fn admin_is_auto_launch_enabled() -> impl IntoResponse {\n    // Note: Autostart requires tauri::AppHandle, which is not available in Axum State easily.\n    // For now, return false in Web mode.\n    Json(false)\n}\n\nasync fn admin_toggle_auto_launch(Json(_payload): Json<serde_json::Value>) -> impl IntoResponse {\n    // Note: Autostart requires tauri::AppHandle.\n    StatusCode::NOT_IMPLEMENTED\n}\n\nasync fn admin_get_http_api_settings() -> impl IntoResponse {\n    Json(serde_json::json!({ \"enabled\": true, \"port\": 8045 }))\n}\n\n// [整合清理] 冗餘導入已移除\n\n#[derive(Deserialize)]\nstruct BulkDeleteRequest {\n    #[serde(rename = \"accountIds\")]\n    account_ids: Vec<String>,\n}\n\nasync fn admin_delete_accounts(\n    Json(payload): Json<BulkDeleteRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::modules::account::delete_accounts(&payload.account_ids).map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(StatusCode::OK)\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct ReorderRequest {\n    account_ids: Vec<String>,\n}\n\nasync fn admin_reorder_accounts(\n    State(state): State<AppState>,\n    Json(payload): Json<ReorderRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::modules::account::reorder_accounts(&payload.account_ids).map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    // [FIX #1166] 排序变动后立即重新加载 TokenManager\n    if let Err(e) = state.token_manager.load_accounts().await {\n        logger::log_error(&format!(\n            \"[API] Failed to reload accounts after reorder: {}\",\n            e\n        ));\n    }\n\n    Ok(StatusCode::OK)\n}\n\nasync fn admin_fetch_account_quota(\n    Path(account_id): Path<String>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let mut account = crate::modules::load_account(&account_id).map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    let quota = crate::modules::account::fetch_quota_with_retry(&mut account)\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse {\n                    error: e.to_string(),\n                }),\n            )\n        })?;\n\n    crate::modules::update_account_quota(&account_id, quota.clone()).map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    Ok(Json(quota))\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct ToggleProxyRequest {\n    enable: bool,\n    reason: Option<String>,\n}\n\nasync fn admin_toggle_proxy_status(\n    State(state): State<AppState>,\n    Path(account_id): Path<String>,\n    Json(payload): Json<ToggleProxyRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::modules::account::toggle_proxy_status(\n        &account_id,\n        payload.enable,\n        payload.reason.as_deref(),\n    )\n    .map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    // 同步到运行中的反代服务\n    let _ = state.token_manager.reload_account(&account_id).await;\n\n    Ok(StatusCode::OK)\n}\n\nasync fn admin_warm_up_all_accounts() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)>\n{\n    let result = crate::commands::warm_up_all_accounts().await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(result))\n}\n\nasync fn admin_warm_up_account(\n    Path(account_id): Path<String>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let result = crate::commands::warm_up_account(account_id)\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n    Ok(Json(result))\n}\n\n\nasync fn admin_save_http_api_settings(\n    Json(payload): Json<crate::modules::http_api::HttpApiSettings>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::modules::http_api::save_settings(&payload).map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(StatusCode::OK)\n}\n\n// Cloudflared Handlers\nasync fn admin_cloudflared_get_status(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    state\n        .cloudflared_state\n        .ensure_manager()\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n\n    let lock = state.cloudflared_state.manager.read().await;\n    if let Some(manager) = lock.as_ref() {\n        let (installed, version) = manager.check_installed().await;\n        let mut status = manager.get_status().await;\n        status.installed = installed;\n        status.version = version;\n        if !installed {\n            status.running = false;\n            status.url = None;\n        }\n        Ok(Json(status))\n    } else {\n        Ok(Json(\n            crate::modules::cloudflared::CloudflaredStatus::default(),\n        ))\n    }\n}\n\nasync fn admin_cloudflared_install(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    state\n        .cloudflared_state\n        .ensure_manager()\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n\n    let lock = state.cloudflared_state.manager.read().await;\n    if let Some(manager) = lock.as_ref() {\n        let status = manager.install().await.map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n        Ok(Json(status))\n    } else {\n        Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: \"Manager not initialized\".to_string(),\n            }),\n        ))\n    }\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct CloudflaredStartRequest {\n    config: crate::modules::cloudflared::CloudflaredConfig,\n}\n\nasync fn admin_cloudflared_start(\n    State(state): State<AppState>,\n    Json(payload): Json<CloudflaredStartRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    state\n        .cloudflared_state\n        .ensure_manager()\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n\n    let lock = state.cloudflared_state.manager.read().await;\n    if let Some(manager) = lock.as_ref() {\n        let status = manager.start(payload.config).await.map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n        Ok(Json(status))\n    } else {\n        Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: \"Manager not initialized\".to_string(),\n            }),\n        ))\n    }\n}\n\nasync fn admin_cloudflared_stop(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    state\n        .cloudflared_state\n        .ensure_manager()\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n\n    let lock = state.cloudflared_state.manager.read().await;\n    if let Some(manager) = lock.as_ref() {\n        let status = manager.stop().await.map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n        Ok(Json(status))\n    } else {\n        Err((\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse {\n                error: \"Manager not initialized\".to_string(),\n            }),\n        ))\n    }\n}\n\n// --- Supplementary Account Handlers ---\n\nasync fn admin_get_device_profiles(\n    State(_state): State<AppState>,\n    Path(account_id): Path<String>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let profiles = account::get_device_profiles(&account_id).map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(profiles))\n}\n\nasync fn admin_list_device_versions(\n    State(_state): State<AppState>,\n    Path(account_id): Path<String>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let profiles = account::get_device_profiles(&account_id).map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(profiles))\n}\n\nasync fn admin_preview_generate_profile(\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let profile = crate::modules::device::generate_profile();\n    Ok(Json(profile))\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct BindDeviceProfileWrapper {\n    #[serde(default)]\n    account_id: String,\n    #[serde(alias = \"profile\")]\n    profile_wrapper: DeviceProfileApiWrapper,\n}\n\n// 用于 API 的 DeviceProfile 包装器，支持 camelCase 输入\n#[derive(Deserialize)]\nstruct DeviceProfileApiWrapper {\n    #[serde(alias = \"machineId\")]\n    machine_id: String,\n    #[serde(alias = \"macMachineId\")]\n    mac_machine_id: String,\n    #[serde(alias = \"devDeviceId\")]\n    dev_device_id: String,\n    #[serde(alias = \"sqmId\")]\n    sqm_id: String,\n}\n\nimpl From<DeviceProfileApiWrapper> for crate::models::account::DeviceProfile {\n    fn from(wrapper: DeviceProfileApiWrapper) -> Self {\n        Self {\n            machine_id: wrapper.machine_id,\n            mac_machine_id: wrapper.mac_machine_id,\n            dev_device_id: wrapper.dev_device_id,\n            sqm_id: wrapper.sqm_id,\n        }\n    }\n}\n\nasync fn admin_bind_device_profile_with_profile(\n    State(_state): State<AppState>,\n    Path(account_id): Path<String>,\n    Json(payload): Json<BindDeviceProfileWrapper>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    // 优先使用 payload 中的 account_id（前端发送的），如果没有则使用路径参数\n    let target_account_id = if !payload.account_id.is_empty() {\n        &payload.account_id\n    } else {\n        &account_id\n    };\n    \n    let profile: crate::models::account::DeviceProfile = payload.profile_wrapper.into();\n    \n    let result =\n        account::bind_device_profile_with_profile(target_account_id, profile, None).map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n    Ok(Json(result))\n}\n\nasync fn admin_restore_original_device(\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let msg = account::restore_original_device().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(msg))\n}\n\nasync fn admin_restore_device_version(\n    State(_state): State<AppState>,\n    Path((account_id, version_id)): Path<(String, String)>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let profile = account::restore_device_version(&account_id, &version_id).map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(profile))\n}\n\nasync fn admin_delete_device_version(\n    State(_state): State<AppState>,\n    Path((account_id, version_id)): Path<(String, String)>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    account::delete_device_version(&account_id, &version_id).map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(StatusCode::NO_CONTENT)\n}\n\nasync fn admin_open_folder() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    // Note: In Web mode, this may not actually open a local folder unless the backend handles it.\n    // For ABV_Refactor, the backend should use opener to open it on the server (the desktop).\n    crate::commands::open_data_folder().await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(StatusCode::OK)\n}\n\n// --- Import Handlers ---\n\nasync fn admin_import_v1_accounts(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let accounts = migration::import_from_v1().await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    // [FIX #1166] 导入后立即加载\n    let _ = state.token_manager.load_accounts().await;\n\n    let current_id = state.account_service.get_current_id().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    let responses: Vec<AccountResponse> = accounts\n        .iter()\n        .map(|a| to_account_response(a, &current_id))\n        .collect();\n    Ok(Json(responses))\n}\n\nasync fn admin_import_from_db(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let account = migration::import_from_db().await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    // [FIX #1166] 导入后立即加载\n    let _ = state.token_manager.load_accounts().await;\n\n    let current_id = state.account_service.get_current_id().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(to_account_response(&account, &current_id)))\n}\n\n#[derive(Deserialize)]\nstruct CustomDbRequest {\n    path: String,\n}\n\nasync fn admin_import_custom_db(\n    State(state): State<AppState>,\n    Json(payload): Json<CustomDbRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    // [SECURITY] 禁止目录遍历\n    if payload.path.contains(\"..\") {\n        return Err((\n            StatusCode::BAD_REQUEST,\n            Json(ErrorResponse {\n                error: \"非法路径: 不允许目录遍历\".to_string(),\n            }),\n        ));\n    }\n\n    let account = migration::import_from_custom_db_path(payload.path)\n        .await\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })?;\n\n    // [FIX #1166] 导入后立即加载\n    let _ = state.token_manager.load_accounts().await;\n\n    let current_id = state.account_service.get_current_id().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(to_account_response(&account, &current_id)))\n}\n\nasync fn admin_sync_account_from_db(\n    State(state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    // 逻辑参考自 sync_account_from_db command\n    let db_refresh_token = match migration::get_refresh_token_from_db() {\n        Ok(token) => token,\n        Err(_e) => {\n            return Ok(Json(None));\n        }\n    };\n    let curr_account = account::get_current_account().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    if let Some(acc) = curr_account {\n        if acc.token.refresh_token == db_refresh_token {\n            return Ok(Json(None));\n        }\n    }\n\n    let account = migration::import_from_db().await.map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    // [FIX #1166] 同步后立即重新加载 TokenManager\n    let _ = state.token_manager.load_accounts().await;\n\n    let current_id = state.account_service.get_current_id().map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n    Ok(Json(Some(to_account_response(&account, &current_id))))\n}\n\n// --- CLI Sync Handlers ---\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct CliSyncStatusRequest {\n    app_type: crate::proxy::cli_sync::CliApp,\n    proxy_url: String,\n}\n\nasync fn admin_get_cli_sync_status(\n    Json(payload): Json<CliSyncStatusRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::proxy::cli_sync::get_cli_sync_status(payload.app_type, payload.proxy_url)\n        .await\n        .map(Json)\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct CliSyncRequest {\n    app_type: crate::proxy::cli_sync::CliApp,\n    proxy_url: String,\n    api_key: String,\n    pub model: Option<String>,\n}\n\nasync fn admin_execute_cli_sync(\n    Json(payload): Json<CliSyncRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::proxy::cli_sync::execute_cli_sync(payload.app_type, payload.proxy_url, payload.api_key, payload.model)\n        .await\n        .map(|_| StatusCode::OK)\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct CliRestoreRequest {\n    app_type: crate::proxy::cli_sync::CliApp,\n}\n\nasync fn admin_execute_cli_restore(\n    Json(payload): Json<CliRestoreRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::proxy::cli_sync::execute_cli_restore(payload.app_type)\n        .await\n        .map(|_| StatusCode::OK)\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct CliConfigContentRequest {\n    app_type: crate::proxy::cli_sync::CliApp,\n    file_name: Option<String>,\n}\n\nasync fn admin_get_cli_config_content(\n    Json(payload): Json<CliConfigContentRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::proxy::cli_sync::get_cli_config_content(payload.app_type, payload.file_name)\n        .await\n        .map(Json)\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })\n}\n\n#[derive(Deserialize)]\nstruct OAuthParams {\n    code: String,\n    #[allow(dead_code)]\n    state: Option<String>,\n    #[allow(dead_code)]\n    scope: Option<String>,\n}\n\nasync fn handle_oauth_callback(\n    Query(params): Query<OAuthParams>,\n    headers: HeaderMap,\n    State(state): State<AppState>,\n) -> Result<Html<String>, StatusCode> {\n    let code = params.code;\n\n    // Exchange token\n    let port = state.security.read().await.port;\n    let host = headers.get(\"host\").and_then(|h| h.to_str().ok());\n    let proto = headers\n        .get(\"x-forwarded-proto\")\n        .and_then(|h| h.to_str().ok());\n    let redirect_uri = get_oauth_redirect_uri(port, host, proto);\n\n    match state\n        .token_manager\n        .exchange_code(&code, &redirect_uri)\n        .await\n    {\n        Ok(refresh_token) => {\n            match state.token_manager.get_user_info(&refresh_token).await {\n                Ok(user_info) => {\n                    let email = user_info.email;\n                    if let Err(e) = state\n                        .token_manager\n                        .add_account(&email, &refresh_token)\n                        .await\n                    {\n                        error!(\"Failed to add account: {}\", e);\n                        return Ok(Html(format!(\n                            r#\"<html><body><h1>Authorization Failed</h1><p>Failed to save account: {}</p></body></html>\"#,\n                            e\n                        )));\n                    }\n                }\n                Err(e) => {\n                    error!(\"Failed to get user info: {}\", e);\n                    return Ok(Html(format!(\n                        r#\"<html><body><h1>Authorization Failed</h1><p>Failed to get user info: {}</p></body></html>\"#,\n                        e\n                    )));\n                }\n            }\n\n            // Success HTML\n            Ok(Html(format!(\n                r#\"\n                <!DOCTYPE html>\n                <html>\n                <head>\n                    <title>Authorization Successful</title>\n                    <style>\n                        body {{ font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background-color: #f9fafb; padding: 20px; box-sizing: border-box; }}\n                        .card {{ background: white; padding: 2rem; border-radius: 1.5rem; box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.1); text-align: center; max-width: 500px; width: 100%; }}\n                        .icon {{ font-size: 3rem; margin-bottom: 1rem; }}\n                        h1 {{ color: #059669; margin: 0 0 1rem 0; font-size: 1.5rem; }}\n                        p {{ color: #4b5563; line-height: 1.5; margin-bottom: 1.5rem; }}\n                        .fallback-box {{ background-color: #f3f4f6; padding: 1.25rem; border-radius: 1rem; border: 1px dashed #d1d5db; text-align: left; margin-top: 1.5rem; }}\n                        .fallback-title {{ font-weight: 600; font-size: 0.875rem; color: #1f2937; margin-bottom: 0.5rem; display: block; }}\n                        .fallback-text {{ font-size: 0.75rem; color: #6b7280; margin-bottom: 1rem; display: block; }}\n                        .copy-btn {{ width: 100%; padding: 0.75rem; background-color: #3b82f6; color: white; border: none; border-radius: 0.75rem; font-weight: 500; cursor: pointer; transition: background-color 0.2s; }}\n                        .copy-btn:hover {{ background-color: #2563eb; }}\n                    </style>\n                </head>\n                <body>\n                    <div class=\"card\">\n                        <div class=\"icon\">✅</div>\n                        <h1>Authorization Successful</h1>\n                        <p>You can close this window now. The application should refresh automatically.</p>\n                        \n                        <div class=\"fallback-box\">\n                            <span class=\"fallback-title\">💡 Did it not refresh?</span>\n                            <span class=\"fallback-text\">If the application is running in a container or remote environment, you may need to manually copy the link below:</span>\n                            <button onclick=\"copyUrl()\" class=\"copy-btn\" id=\"copyBtn\">Copy Completion Link</button>\n                        </div>\n                    </div>\n                    <script>\n                        // 1. Notify opener if exists\n                        if (window.opener) {{\n                            window.opener.postMessage({{\n                                type: 'oauth-success',\n                                message: 'login success'\n                            }}, '*');\n                        }}\n\n                        // 2. Copy URL functionality\n                        function copyUrl() {{\n                            navigator.clipboard.writeText(window.location.href).then(() => {{\n                                const btn = document.getElementById('copyBtn');\n                                const originalText = btn.innerText;\n                                btn.innerText = '✅ Link Copied!';\n                                btn.style.backgroundColor = '#059669';\n                                setTimeout(() => {{\n                                    btn.innerText = originalText;\n                                    btn.style.backgroundColor = '#3b82f6';\n                                }}, 2000);\n                            }});\n                        }}\n                    </script>\n                </body>\n                </html>\n            \"#\n            )))\n        }\n        Err(e) => {\n            error!(\"OAuth exchange failed: {}\", e);\n            Ok(Html(format!(\n                r#\"<html><body><h1>Authorization Failed</h1><p>Error: {}</p></body></html>\"#,\n                e\n            )))\n        }\n    }\n}\n\nasync fn admin_prepare_oauth_url_web(\n    headers: HeaderMap,\n    State(state): State<AppState>,\n) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {\n    let port = state.security.read().await.port;\n    let host = headers.get(\"host\").and_then(|h| h.to_str().ok());\n    let proto = headers\n        .get(\"x-forwarded-proto\")\n        .and_then(|h| h.to_str().ok());\n    let redirect_uri = get_oauth_redirect_uri(port, host, proto);\n\n    let state_str = uuid::Uuid::new_v4().to_string();\n\n    // 初始化授权流状态，以及后台处理器\n    let (auth_url, mut code_rx) = crate::modules::oauth_server::prepare_oauth_flow_manually(\n        redirect_uri.clone(),\n        state_str.clone(),\n    )\n    .map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })?;\n\n    // 启动后台任务处理回调/手动提交的代码\n    let token_manager = state.token_manager.clone();\n    let redirect_uri_clone = redirect_uri.clone();\n    tokio::spawn(async move {\n        match code_rx.recv().await {\n            Some(Ok(code)) => {\n                crate::modules::logger::log_info(\n                    \"Consuming manually submitted OAuth code in background\",\n                );\n                // 为 Web 回调提供简化的后端处理流程\n                match crate::modules::oauth::exchange_code(&code, &redirect_uri_clone).await {\n                    Ok(token_resp) => {\n                        // Success! Now add/upsert account\n                        if let Some(refresh_token) = &token_resp.refresh_token {\n                            match token_manager.get_user_info(refresh_token).await {\n                                Ok(user_info) => {\n                                    if let Err(e) = token_manager\n                                        .add_account(&user_info.email, refresh_token)\n                                        .await\n                                    {\n                                        crate::modules::logger::log_error(&format!(\n                                            \"Failed to save account in background OAuth: {}\",\n                                            e\n                                        ));\n                                    } else {\n                                        crate::modules::logger::log_info(&format!(\n                                            \"Successfully added account {} via background OAuth\",\n                                            user_info.email\n                                        ));\n                                    }\n                                }\n                                Err(e) => {\n                                    crate::modules::logger::log_error(&format!(\n                                        \"Failed to fetch user info in background OAuth: {}\",\n                                        e\n                                    ));\n                                }\n                            }\n                        } else {\n                            crate::modules::logger::log_error(\n                                \"Background OAuth error: Google did not return a refresh_token.\",\n                            );\n                        }\n                    }\n                    Err(e) => {\n                        crate::modules::logger::log_error(&format!(\n                            \"Background OAuth exchange failed: {}\",\n                            e\n                        ));\n                    }\n                }\n            }\n            Some(Err(e)) => {\n                crate::modules::logger::log_error(&format!(\"Background OAuth flow error: {}\", e));\n            }\n            None => {\n                crate::modules::logger::log_info(\"Background OAuth flow channel closed\");\n            }\n        }\n    });\n\n    Ok(Json(serde_json::json!({\n        \"url\": auth_url,\n        \"state\": state_str\n    })))\n}\n\n/// 辅助函数：获取 OAuth 重定向 URI\n/// 强制使用 localhost，以绕过 Google 2.0 政策对 IP 地址和非 HTTPS 环境的拦截。\n/// 只有在显式设置了 ABV_PUBLIC_URL (例如用户配置了 HTTPS 域名) 时才会使用外部地址。\nfn get_oauth_redirect_uri(port: u16, _host: Option<&str>, _proto: Option<&str>) -> String {\n    if let Ok(public_url) = std::env::var(\"ABV_PUBLIC_URL\") {\n        let base = public_url.trim_end_matches('/');\n        format!(\"{}/auth/callback\", base)\n    } else {\n        // 强制返回 localhost。远程部署时，用户可通过回填功能完成授权。\n        format!(\"http://localhost:{}/auth/callback\", port)\n    }\n}\n\n// ============================================================================\n// Security / IP Management Handlers\n// ============================================================================\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct IpAccessLogQuery {\n    #[serde(default = \"default_page\")]\n    page: usize,\n    #[serde(default = \"default_page_size\")]\n    page_size: usize,\n    search: Option<String>,\n    #[serde(default)]\n    blocked_only: bool,\n}\n\nfn default_page() -> usize { 1 }\nfn default_page_size() -> usize { 50 }\n\n#[derive(Serialize)]\nstruct IpAccessLogResponse {\n    logs: Vec<crate::modules::security_db::IpAccessLog>,\n    total: usize,\n}\n\nasync fn admin_get_ip_access_logs(\n    Query(q): Query<IpAccessLogQuery>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let offset = (q.page.max(1) - 1) * q.page_size;\n    let logs = security_db::get_ip_access_logs(\n        q.page_size,\n        offset,\n        q.search.as_deref(),\n        q.blocked_only,\n    ).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n\n    let total = logs.len(); // Simple total\n    \n    Ok(Json(IpAccessLogResponse { logs, total }))\n}\n\nasync fn admin_clear_ip_access_logs() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    security_db::clear_ip_access_logs()\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    Ok(StatusCode::OK)\n}\n\n#[derive(Serialize)]\nstruct IpStatsResponse {\n    total_requests: usize,\n    unique_ips: usize,\n    blocked_requests: usize,\n    top_ips: Vec<crate::modules::security_db::IpRanking>,\n}\n\nasync fn admin_get_ip_stats() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let stats = security_db::get_ip_stats()\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    let top_ips = security_db::get_top_ips(10, 24)\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n\n    let response = IpStatsResponse {\n        total_requests: stats.total_requests as usize,\n        unique_ips: stats.unique_ips as usize,\n        blocked_requests: stats.blocked_count as usize,\n        top_ips,\n    };\n    Ok(Json(response))\n}\n\n#[derive(Deserialize)]\nstruct IpTokenStatsQuery {\n    limit: Option<usize>,\n    hours: Option<i64>,\n}\n\nasync fn admin_get_ip_token_stats(\n    Query(q): Query<IpTokenStatsQuery>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let stats = proxy_db::get_token_usage_by_ip(\n        q.limit.unwrap_or(100),\n        q.hours.unwrap_or(720)\n    ).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    Ok(Json(stats))\n}\n\nasync fn admin_get_ip_blacklist() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let list = security_db::get_blacklist()\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    Ok(Json(list))\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct AddBlacklistRequest {\n    ip_pattern: String,\n    reason: Option<String>,\n    expires_at: Option<i64>,\n}\n\nasync fn admin_add_ip_to_blacklist(\n    Json(req): Json<AddBlacklistRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    security_db::add_to_blacklist(\n        &req.ip_pattern,\n        req.reason.as_deref(),\n        req.expires_at,\n        \"manual\",\n    ).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n\n    Ok(StatusCode::CREATED)\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct RemoveIpRequest {\n    ip_pattern: String,\n}\n\nasync fn admin_remove_ip_from_blacklist(\n    Query(q): Query<RemoveIpRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let entries = security_db::get_blacklist()\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    \n    if let Some(entry) = entries.iter().find(|e| e.ip_pattern == q.ip_pattern) {\n        security_db::remove_from_blacklist(&entry.id)\n            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    } else {\n        return Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: format!(\"IP pattern {} not found\", q.ip_pattern) })));\n    }\n    \n    Ok(StatusCode::OK)\n}\n\nasync fn admin_clear_ip_blacklist() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let entries = security_db::get_blacklist()\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    for entry in entries {\n        security_db::remove_from_blacklist(&entry.id)\n            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    }\n    Ok(StatusCode::OK)\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct CheckIpQuery {\n    ip: String,\n}\n\nasync fn admin_check_ip_in_blacklist(\n    Query(q): Query<CheckIpQuery>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let result = security_db::is_ip_in_blacklist(&q.ip)\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    Ok(Json(serde_json::json!({ \"result\": result })))\n}\n\nasync fn admin_get_ip_whitelist() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let list = security_db::get_whitelist()\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    Ok(Json(list))\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct AddWhitelistRequest {\n    ip_pattern: String,\n    description: Option<String>,\n}\n\nasync fn admin_add_ip_to_whitelist(\n    Json(req): Json<AddWhitelistRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    security_db::add_to_whitelist(\n        &req.ip_pattern,\n        req.description.as_deref(),\n    ).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    Ok(StatusCode::CREATED)\n}\n\nasync fn admin_remove_ip_from_whitelist(\n    Query(q): Query<RemoveIpRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let entries = security_db::get_whitelist()\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    \n    if let Some(entry) = entries.iter().find(|e| e.ip_pattern == q.ip_pattern) {\n        security_db::remove_from_whitelist(&entry.id)\n            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    } else {\n        return Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: format!(\"IP pattern {} not found\", q.ip_pattern) })));\n    }\n    Ok(StatusCode::OK)\n}\n\nasync fn admin_clear_ip_whitelist() -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let entries = security_db::get_whitelist()\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    for entry in entries {\n        security_db::remove_from_whitelist(&entry.ip_pattern)\n            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    }\n    Ok(StatusCode::OK)\n}\n\nasync fn admin_check_ip_in_whitelist(\n    Query(q): Query<CheckIpQuery>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let result = security_db::is_ip_in_whitelist(&q.ip)\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?;\n    Ok(Json(serde_json::json!({ \"result\": result })))\n}\n\nasync fn admin_get_security_config(\n    State(_state): State<AppState>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let app_config = crate::modules::config::load_app_config()\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })))?;\n    \n    Ok(Json(app_config.proxy.security_monitor))\n}\n\n#[derive(Deserialize)]\nstruct UpdateSecurityConfigWrapper {\n    config: crate::proxy::config::SecurityMonitorConfig,\n}\n\nasync fn admin_update_security_config(\n    State(state): State<AppState>,\n    Json(payload): Json<UpdateSecurityConfigWrapper>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let config = payload.config;\n    let mut app_config = crate::modules::config::load_app_config()\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })))?;\n        \n    app_config.proxy.security_monitor = config.clone();\n    \n    crate::modules::config::save_app_config(&app_config)\n        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })))?;\n\n    {\n        let mut sec = state.security.write().await;\n        *sec = crate::proxy::ProxySecurityConfig::from_proxy_config(&app_config.proxy);\n        tracing::info!(\"[Security] Runtime security config hot-reloaded via Web API\");\n    }\n\n    Ok(StatusCode::OK)\n}\n\n// --- Debug Console Handlers ---\n\nasync fn admin_enable_debug_console() -> impl IntoResponse {\n    crate::modules::log_bridge::enable_log_bridge();\n    StatusCode::OK\n}\n\nasync fn admin_disable_debug_console() -> impl IntoResponse {\n    crate::modules::log_bridge::disable_log_bridge();\n    StatusCode::OK\n}\n\nasync fn admin_is_debug_console_enabled() -> impl IntoResponse {\n    Json(crate::modules::log_bridge::is_log_bridge_enabled())\n}\n\nasync fn admin_get_debug_console_logs() -> impl IntoResponse {\n    let logs = crate::modules::log_bridge::get_buffered_logs();\n    Json(logs)\n}\n\nasync fn admin_clear_debug_console_logs() -> impl IntoResponse {\n    crate::modules::log_bridge::clear_log_buffer();\n    StatusCode::OK\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct OpencodeSyncStatusRequest {\n    proxy_url: String,\n}\n\nasync fn admin_get_opencode_sync_status(\n    Json(payload): Json<OpencodeSyncStatusRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::proxy::opencode_sync::get_opencode_sync_status(payload.proxy_url)\n        .await\n        .map(Json)\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct OpencodeSyncRequest {\n    proxy_url: String,\n    api_key: String,\n    #[serde(default)]\n    sync_accounts: bool,\n    pub models: Option<Vec<String>>,\n}\n\nasync fn admin_execute_opencode_sync(\n    Json(payload): Json<OpencodeSyncRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::proxy::opencode_sync::execute_opencode_sync(\n        payload.proxy_url,\n        payload.api_key,\n        Some(payload.sync_accounts),\n        payload.models,\n    )\n    .await\n    .map(|_| StatusCode::OK)\n    .map_err(|e| {\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        )\n    })\n}\n\nasync fn admin_execute_opencode_restore(\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::proxy::opencode_sync::execute_opencode_restore()\n        .await\n        .map(|_| StatusCode::OK)\n        .map_err(|e| {\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse { error: e }),\n            )\n        })\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct GetOpencodeConfigRequest {\n    file_name: Option<String>,\n}\n\nasync fn admin_get_opencode_config_content(\n    Json(payload): Json<GetOpencodeConfigRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    let file_name = payload.file_name;\n    tokio::task::spawn_blocking(move || crate::proxy::opencode_sync::read_opencode_config_content(file_name))\n        .await\n        .map_err(|e| (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e.to_string() }),\n        ))?\n        .map(Json)\n        .map_err(|e| (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        ))\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct OpencodeClearRequest {\n    proxy_url: Option<String>,\n    clear_legacy: Option<bool>,\n}\n\nasync fn admin_execute_opencode_clear(\n    Json(payload): Json<OpencodeClearRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::proxy::opencode_sync::execute_opencode_clear(payload.proxy_url, payload.clear_legacy)\n        .await\n        .map(|_| StatusCode::OK)\n        .map_err(|e| (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        ))\n}\n\n// ── Droid (Factory CLI) Sync Admin Handlers ──\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct DroidSyncStatusRequest {\n    proxy_url: String,\n}\n\nasync fn admin_get_droid_sync_status(\n    Json(payload): Json<DroidSyncStatusRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::proxy::droid_sync::get_droid_sync_status(payload.proxy_url)\n        .await\n        .map(Json)\n        .map_err(|e| (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        ))\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct DroidSyncRequest {\n    custom_models: Vec<serde_json::Value>,\n}\n\nasync fn admin_execute_droid_sync(\n    Json(payload): Json<DroidSyncRequest>,\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::proxy::droid_sync::execute_droid_sync(\n        payload.custom_models,\n    )\n        .await\n        .map(|count| Json(serde_json::json!({ \"added\": count })))\n        .map_err(|e| (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        ))\n}\n\nasync fn admin_execute_droid_restore(\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::proxy::droid_sync::execute_droid_restore()\n        .await\n        .map(|_| StatusCode::OK)\n        .map_err(|e| (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        ))\n}\n\nasync fn admin_get_droid_config_content(\n) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {\n    crate::proxy::droid_sync::get_droid_config_content()\n        .await\n        .map(Json)\n        .map_err(|e| (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            Json(ErrorResponse { error: e }),\n        ))\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/session_manager.rs",
    "content": "use sha2::{Sha256, Digest};\nuse crate::proxy::mappers::claude::models::{ClaudeRequest, MessageContent};\nuse crate::proxy::mappers::openai::models::{OpenAIRequest, OpenAIContent};\nuse serde_json::Value;\n\n/// 会话管理器工具\npub struct SessionManager;\n\nimpl SessionManager {\n    /// 根据 Claude 请求生成稳定的会话指纹 (Session Fingerprint)\n    /// \n    /// 设计理念:\n    /// - 只哈希第一条用户消息内容,不混入模型名称或时间戳\n    /// - 确保同一对话的所有轮次使用相同的 session_id\n    /// - 最大化 prompt caching 的命中率\n    /// \n    /// 优先级:\n    /// 1. metadata.user_id (客户端显式提供)\n    /// 2. 第一条用户消息的 SHA256 哈希\n    pub fn extract_session_id(request: &ClaudeRequest) -> String {\n        // 1. 优先使用 metadata 中的 user_id\n        if let Some(metadata) = &request.metadata {\n            if let Some(user_id) = &metadata.user_id {\n                if !user_id.is_empty() && !user_id.contains(\"session-\") {\n                    tracing::debug!(\"[SessionManager] Using explicit user_id: {}\", user_id);\n                    return user_id.clone();\n                }\n            }\n        }\n\n        // 2. 备选方案：基于第一条用户消息的 SHA256 哈希\n        let mut hasher = Sha256::new();\n\n        let mut content_found = false;\n        for msg in &request.messages {\n            if msg.role != \"user\" { continue; }\n            \n            let text = match &msg.content {\n                MessageContent::String(s) => s.clone(),\n                MessageContent::Array(blocks) => {\n                    blocks.iter()\n                        .filter_map(|block| match block {\n                            crate::proxy::mappers::claude::models::ContentBlock::Text { text } => Some(text.as_str()),\n                            _ => None,\n                        })\n                        .collect::<Vec<_>>()\n                        .join(\" \")\n                }\n            };\n\n            let clean_text = text.trim();\n            // [FIX #1732] 降低准入门槛 (10 -> 3)，确保即使是短消息也会生成稳定的会话锚点\n            // 同时排除包含系统标志的消息，防止因为协议注入导致的 ID 漂移\n            if clean_text.len() >= 3 && !clean_text.contains(\"<system-reminder>\") && !clean_text.contains(\"[System\") {\n                hasher.update(clean_text.as_bytes());\n                content_found = true;\n                break; // 始终锚定第一条有效用户消息\n            }\n        }\n\n        if !content_found {\n            // 如果没找到有意义的内容，退化为对最后一条消息进行哈希\n            if let Some(last_msg) = request.messages.last() {\n                hasher.update(format!(\"{:?}\", last_msg.content).as_bytes());\n            }\n        }\n\n        let hash = format!(\"{:x}\", hasher.finalize());\n        let sid = format!(\"sid-{}\", &hash[..16]);\n        \n        tracing::debug!(\n            \"[SessionManager] Generated session_id: {} (content_found: {}, model: {})\", \n            sid,\n            content_found,\n            request.model\n        );\n        sid\n    }\n\n    /// 根据 OpenAI 请求生成稳定的会话指纹\n    pub fn extract_openai_session_id(request: &OpenAIRequest) -> String {\n        let mut hasher = Sha256::new();\n\n        let mut content_found = false;\n        for msg in &request.messages {\n            if msg.role != \"user\" { continue; }\n            if let Some(content) = &msg.content {\n                let text = match content {\n                    OpenAIContent::String(s) => s.clone(),\n                    OpenAIContent::Array(blocks) => {\n                        blocks.iter()\n                            .filter_map(|block| match block {\n                                crate::proxy::mappers::openai::models::OpenAIContentBlock::Text { text } => Some(text.as_str()),\n                                _ => None,\n                            })\n                            .collect::<Vec<_>>()\n                            .join(\" \")\n                    }\n                };\n\n                let clean_text = text.trim();\n                if clean_text.len() > 10 && !clean_text.contains(\"<system-reminder>\") {\n                    hasher.update(clean_text.as_bytes());\n                    content_found = true;\n                    break;\n                }\n            }\n        }\n\n        if !content_found {\n            if let Some(last_msg) = request.messages.last() {\n                hasher.update(format!(\"{:?}\", last_msg.content).as_bytes());\n            }\n        }\n\n        let hash = format!(\"{:x}\", hasher.finalize());\n        let sid = format!(\"sid-{}\", &hash[..16]);\n        tracing::debug!(\"[SessionManager-OpenAI] Generated fingerprint: {}\", sid);\n        sid\n    }\n\n    /// 根据 Gemini 原生请求 (JSON) 生成稳定的会话指纹\n    pub fn extract_gemini_session_id(request: &Value, _model_name: &str) -> String {\n        let mut hasher = Sha256::new();\n\n        let mut content_found = false;\n        if let Some(contents) = request.get(\"contents\").and_then(|v| v.as_array()) {\n            for content in contents {\n                if content.get(\"role\").and_then(|v| v.as_str()) != Some(\"user\") { continue; }\n                \n                if let Some(parts) = content.get(\"parts\").and_then(|v| v.as_array()) {\n                    let mut text_parts = Vec::new();\n                    for part in parts {\n                        if let Some(text) = part.get(\"text\").and_then(|v| v.as_str()) {\n                            text_parts.push(text);\n                        }\n                    }\n                    \n                    let combined_text = text_parts.join(\" \");\n                    let clean_text = combined_text.trim();\n                    if clean_text.len() > 10 && !clean_text.contains(\"<system-reminder>\") {\n                        hasher.update(clean_text.as_bytes());\n                        content_found = true;\n                        break;\n                    }\n                }\n            }\n        }\n\n        if !content_found {\n             // 兜底：对整个 Body 的首个 user part 进行摘要\n             hasher.update(request.to_string().as_bytes());\n        }\n\n        let hash = format!(\"{:x}\", hasher.finalize());\n        let sid = format!(\"sid-{}\", &hash[..16]);\n        tracing::debug!(\"[SessionManager-Gemini] Generated fingerprint: {}\", sid);\n        sid\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/signature_cache.rs",
    "content": "use std::collections::HashMap;\nuse std::sync::{Mutex, OnceLock};\nuse std::time::{Duration, SystemTime};\n\n// Node.js proxy uses 2 hours TTL\nconst SIGNATURE_TTL: Duration = Duration::from_secs(2 * 60 * 60);\nconst MIN_SIGNATURE_LENGTH: usize = 50;\n\n// Different cache limits for different layers\nconst TOOL_CACHE_LIMIT: usize = 500;      // Layer 1: Tool-specific signatures\nconst FAMILY_CACHE_LIMIT: usize = 200;    // Layer 2: Model family mappings\nconst SESSION_CACHE_LIMIT: usize = 1000;  // Layer 3: Session-based signatures (largest)\n\n/// Cache entry with timestamp for TTL\n#[derive(Clone, Debug)]\nstruct CacheEntry<T> {\n    data: T,\n    timestamp: SystemTime,\n}\n\n/// Specialized entry for session-based signatures to track message count\n#[derive(Clone, Debug)]\nstruct SessionSignatureEntry {\n    signature: String,\n    message_count: usize,\n}\n\nimpl<T> CacheEntry<T> {\n    fn new(data: T) -> Self {\n        Self {\n            data,\n            timestamp: SystemTime::now(),\n        }\n    }\n\n    fn is_expired(&self) -> bool {\n        self.timestamp.elapsed().unwrap_or(Duration::ZERO) > SIGNATURE_TTL\n    }\n}\n\n/// Triple-layer signature cache to handle:\n/// 1. Signature recovery for tool calls (when clients strip them)\n/// 2. Cross-model compatibility checks (preventing Claude signatures on Gemini models)\n/// 3. Session-based signature tracking (preventing cross-session pollution)\npub struct SignatureCache {\n    /// Layer 1: Tool Use ID -> Thinking Signature\n    /// Key: tool_use_id (e.g., \"toolu_01...\")\n    /// Value: The thought signature that generated this tool call\n    tool_signatures: Mutex<HashMap<String, CacheEntry<String>>>,\n\n    /// Layer 2: Signature -> Model Family\n    /// Key: thought signature string\n    /// Value: Model family identifier (e.g., \"claude-3-5-sonnet\", \"gemini-2.0-flash\")\n    thinking_families: Mutex<HashMap<String, CacheEntry<String>>>,\n\n    /// Layer 3: Session ID -> Latest Thinking Signature (NEW)\n    /// Key: session fingerprint (e.g., \"sid-a1b2c3d4...\")\n    /// Value: The most recent valid thought signature for this session\n    /// This prevents signature pollution between different conversations\n    session_signatures: Mutex<HashMap<String, CacheEntry<SessionSignatureEntry>>>,\n}\n\nimpl SignatureCache {\n    fn new() -> Self {\n        Self {\n            tool_signatures: Mutex::new(HashMap::new()),\n            thinking_families: Mutex::new(HashMap::new()),\n            session_signatures: Mutex::new(HashMap::new()),\n        }\n    }\n\n    /// Global singleton instance\n    pub fn global() -> &'static SignatureCache {\n        static INSTANCE: OnceLock<SignatureCache> = OnceLock::new();\n        INSTANCE.get_or_init(SignatureCache::new)\n    }\n\n    /// Store a tool call signature\n    pub fn cache_tool_signature(&self, tool_use_id: &str, signature: String) {\n        if signature.len() < MIN_SIGNATURE_LENGTH {\n            return;\n        }\n        \n        if let Ok(mut cache) = self.tool_signatures.lock() {\n            tracing::debug!(\"[SignatureCache] Caching tool signature for id: {}\", tool_use_id);\n            cache.insert(tool_use_id.to_string(), CacheEntry::new(signature));\n            \n            // Clean up expired entries when limit is reached\n            if cache.len() > TOOL_CACHE_LIMIT {\n                let before = cache.len();\n                cache.retain(|_, v| !v.is_expired());\n                let after = cache.len();\n                if before != after {\n                    tracing::debug!(\"[SignatureCache] Tool cache cleanup: {} -> {} entries\", before, after);\n                }\n            }\n        }\n    }\n\n    /// Retrieve a signature for a tool_use_id\n    pub fn get_tool_signature(&self, tool_use_id: &str) -> Option<String> {\n        if let Ok(cache) = self.tool_signatures.lock() {\n            if let Some(entry) = cache.get(tool_use_id) {\n                if !entry.is_expired() {\n                    tracing::debug!(\"[SignatureCache] Hit tool signature for id: {}\", tool_use_id);\n                    return Some(entry.data.clone());\n                }\n            }\n        }\n        None\n    }\n\n    /// Store model family for a signature\n    pub fn cache_thinking_family(&self, signature: String, family: String) {\n        if signature.len() < MIN_SIGNATURE_LENGTH {\n            return;\n        }\n\n        if let Ok(mut cache) = self.thinking_families.lock() {\n            tracing::debug!(\"[SignatureCache] Caching thinking family for sig (len={}): {}\", signature.len(), family);\n            cache.insert(signature, CacheEntry::new(family));\n            \n            if cache.len() > FAMILY_CACHE_LIMIT {\n                let before = cache.len();\n                cache.retain(|_, v| !v.is_expired());\n                let after = cache.len();\n                if before != after {\n                    tracing::debug!(\"[SignatureCache] Family cache cleanup: {} -> {} entries\", before, after);\n                }\n            }\n        }\n    }\n\n    /// Get model family for a signature\n    pub fn get_signature_family(&self, signature: &str) -> Option<String> {\n        if let Ok(cache) = self.thinking_families.lock() {\n            if let Some(entry) = cache.get(signature) {\n                if !entry.is_expired() {\n                    return Some(entry.data.clone());\n                } else {\n                    tracing::debug!(\"[SignatureCache] Signature family entry expired\");\n                }\n            }\n        }\n        None\n    }\n\n    // ===== Layer 3: Session-based Signature Storage =====\n\n    /// Store the latest thinking signature for a session.\n    /// This is the preferred method for tracking signatures across tool loops.\n    /// \n    /// # Arguments\n    /// * `session_id` - Session fingerprint (e.g., \"sid-a1b2c3d4...\")\n    /// * `signature` - The thought signature to store\n    /// * `message_count` - The current message count of the conversation (to detect Rewind)\n    pub fn cache_session_signature(&self, session_id: &str, signature: String, message_count: usize) {\n        if signature.len() < MIN_SIGNATURE_LENGTH {\n            return;\n        }\n\n        if let Ok(mut cache) = self.session_signatures.lock() {\n            let should_store = match cache.get(session_id) {\n                None => true,\n                Some(existing) => {\n                    if existing.is_expired() {\n                        true\n                    } else if message_count < existing.data.message_count {\n                        // [CRITICAL] Rewind detected: user deleted messages or reverted to an earlier state.\n                        // The cached signature is now from a \"future\" that no longer exists in history.\n                        // We MUST force an update to prevent sending a future signature.\n                        tracing::info!(\n                            \"[SignatureCache] Rewind detected for {}: {} -> {} messages. Forcing signature update.\",\n                            session_id,\n                            existing.data.message_count,\n                            message_count\n                        );\n                        true\n                    } else if message_count == existing.data.message_count {\n                        // Same message count: only update if the new signature is longer (more complete)\n                        signature.len() > existing.data.signature.len()\n                    } else {\n                        // message_count > existing.data.message_count: normal progression\n                        true\n                    }\n                }\n            };\n\n            if should_store {\n                tracing::debug!(\n                    \"[SignatureCache] Session {} (msg_count={}) -> storing signature (len={})\",\n                    session_id,\n                    message_count,\n                    signature.len()\n                );\n                cache.insert(\n                    session_id.to_string(), \n                    CacheEntry::new(SessionSignatureEntry { \n                        signature, \n                        message_count \n                    })\n                );\n            }\n\n            // Cleanup when limit is reached (Session cache has largest limit)\n            if cache.len() > SESSION_CACHE_LIMIT {\n                let before = cache.len();\n                cache.retain(|_, v| !v.is_expired());\n                let after = cache.len();\n                if before != after {\n                    tracing::info!(\n                        \"[SignatureCache] Session cache cleanup: {} -> {} entries (limit: {})\",\n                        before,\n                        after,\n                        SESSION_CACHE_LIMIT\n                    );\n                }\n            }\n        }\n    }\n\n    /// Retrieve the latest thinking signature for a session.\n    /// Returns None if not found or expired.\n    pub fn get_session_signature(&self, session_id: &str) -> Option<String> {\n        if let Ok(cache) = self.session_signatures.lock() {\n            if let Some(entry) = cache.get(session_id) {\n                if !entry.is_expired() {\n                    tracing::debug!(\n                        \"[SignatureCache] Session {} -> HIT (len={})\",\n                        session_id,\n                        entry.data.signature.len()\n                    );\n                    return Some(entry.data.signature.clone());\n                } else {\n                    tracing::debug!(\"[SignatureCache] Session {} -> EXPIRED\", session_id);\n                }\n            }\n        }\n        None\n    }\n\n    /// 删除指定会话的缓存签名\n    #[allow(dead_code)] // 预留给管理接口或调试使用\n    pub fn delete_session_signature(&self, session_id: &str) {\n        if let Ok(mut cache) = self.session_signatures.lock() {\n            if cache.remove(session_id).is_some() {\n                tracing::debug!(\"[SignatureCache] Deleted session signature for: {}\", session_id);\n            }\n        }\n    }\n\n    /// Clear all caches (for testing or manual reset)\n    #[allow(dead_code)] // Used in tests\n    pub fn clear(&self) {\n        if let Ok(mut cache) = self.tool_signatures.lock() {\n            cache.clear();\n        }\n        if let Ok(mut cache) = self.thinking_families.lock() {\n            cache.clear();\n        }\n        if let Ok(mut cache) = self.session_signatures.lock() {\n            cache.clear();\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n\n    #[test]\n    fn test_tool_signature_cache() {\n        let cache = SignatureCache::new();\n        let sig = \"x\".repeat(60); // Valid length\n        \n        cache.cache_tool_signature(\"tool_1\", sig.clone());\n        assert_eq!(cache.get_tool_signature(\"tool_1\"), Some(sig));\n        assert_eq!(cache.get_tool_signature(\"tool_2\"), None);\n    }\n\n    #[test]\n    fn test_min_length() {\n        let cache = SignatureCache::new();\n        cache.cache_tool_signature(\"tool_short\", \"short\".to_string());\n        assert_eq!(cache.get_tool_signature(\"tool_short\"), None);\n    }\n\n    #[test]\n    fn test_thinking_family() {\n        let cache = SignatureCache::new();\n        let sig = \"y\".repeat(60);\n        \n        cache.cache_thinking_family(sig.clone(), \"claude\".to_string());\n        assert_eq!(cache.get_signature_family(&sig), Some(\"claude\".to_string()));\n    }\n\n    #[test]\n    fn test_session_signature() {\n        let cache = SignatureCache::new();\n        let sig1 = \"a\".repeat(60);\n        let sig2 = \"b\".repeat(80); // Longer, should replace\n        let sig3 = \"c\".repeat(40); // Too short, should be ignored\n        \n        // Initially empty\n        assert!(cache.get_session_signature(\"sid-test123\").is_none());\n        \n        // Store first signature\n        cache.cache_session_signature(\"sid-test123\", sig1.clone(), 5);\n        assert_eq!(cache.get_session_signature(\"sid-test123\"), Some(sig1.clone()));\n        \n        // Longer signature should replace (same msg count)\n        cache.cache_session_signature(\"sid-test123\", sig2.clone(), 5);\n        assert_eq!(cache.get_session_signature(\"sid-test123\"), Some(sig2.clone()));\n        \n        // Shorter valid signature should NOT replace (same msg count)\n        cache.cache_session_signature(\"sid-test123\", sig1.clone(), 5);\n        assert_eq!(cache.get_session_signature(\"sid-test123\"), Some(sig2.clone()));\n\n        // Rewind: Shorter signature MUST replace if message count is lower\n        cache.cache_session_signature(\"sid-test123\", sig1.clone(), 3);\n        assert_eq!(cache.get_session_signature(\"sid-test123\"), Some(sig1.clone()));\n        \n        // Too short signature should be ignored entirely (even if rewind)\n        cache.cache_session_signature(\"sid-test123\", sig3, 1);\n        assert_eq!(cache.get_session_signature(\"sid-test123\"), Some(sig1));\n        \n        // Different session should be isolated\n        assert!(cache.get_session_signature(\"sid-other\").is_none());\n    }\n\n    #[test]\n    fn test_clear_all_caches() {\n        let cache = SignatureCache::new();\n        let sig = \"x\".repeat(60);\n        \n        cache.cache_tool_signature(\"tool_1\", sig.clone());\n        cache.cache_thinking_family(sig.clone(), \"model\".to_string());\n        cache.cache_session_signature(\"sid-1\", sig.clone(), 1);\n        \n        assert!(cache.get_tool_signature(\"tool_1\").is_some());\n        assert!(cache.get_signature_family(&sig).is_some());\n        assert!(cache.get_session_signature(\"sid-1\").is_some());\n        \n        cache.clear();\n        \n        assert!(cache.get_tool_signature(\"tool_1\").is_none());\n        assert!(cache.get_signature_family(&sig).is_none());\n        assert!(cache.get_session_signature(\"sid-1\").is_none());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/sticky_config.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n/// 调度模式枚举\n#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]\npub enum SchedulingMode {\n    /// 缓存优先 (Cache-first): 尽可能锁定同一账号，限流时优先等待，极大提升 Prompt Caching 命中率\n    CacheFirst,\n    /// 平衡模式 (Balance): 锁定同一账号，限流时立即切换到备选账号，兼顾成功率和性能\n    Balance,\n    /// 性能优先 (Performance-first): 纯轮询模式 (Round-robin)，账号负载最均衡，但不利用缓存\n    PerformanceFirst,\n}\n\nimpl Default for SchedulingMode {\n    fn default() -> Self {\n        Self::Balance\n    }\n}\n\n/// 粘性会话配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(default)]\npub struct StickySessionConfig {\n    /// 当前调度模式\n    pub mode: SchedulingMode,\n    /// 缓存优先模式下的最大等待时间 (秒)\n    pub max_wait_seconds: u64,\n}\n\nimpl Default for StickySessionConfig {\n    fn default() -> Self {\n        Self {\n            mode: SchedulingMode::Balance,\n            max_wait_seconds: 60,\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/tests/comprehensive.rs",
    "content": "#[cfg(test)]\nmod tests {\n    use crate::proxy::mappers::claude::models::{\n        ClaudeRequest, Message, MessageContent, ContentBlock, ThinkingConfig\n    };\n    use crate::proxy::mappers::claude::request::transform_claude_request_in;\n    use crate::proxy::mappers::claude::thinking_utils::{analyze_conversation_state, close_tool_loop_for_thinking};\n    use serde_json::json;\n\n    \n    // ==================================================================================\n    // 场景一：首次 Thinking 请求 (P0-2 Fix)\n    // 验证在没有历史签名的情况下，首次发起 Thinking 请求是否被放行 (Perimssive Mode)\n    // ==================================================================================\n    #[test]\n    fn test_first_thinking_request_permissive_mode() {\n        // 1. 构造一个全新的请求 (无历史消息)\n        let req = ClaudeRequest {\n            model: \"claude-3-7-sonnet-20250219\".to_string(),\n            messages: vec![\n                Message {\n                    role: \"user\".to_string(),\n                    content: MessageContent::String(\"Hello, please think.\".to_string()),\n                }\n            ],\n            system: None,\n            tools: None, // 无工具调用\n            stream: false,\n            max_tokens: None,\n            temperature: None,\n            top_p: None,\n            top_k: None,\n            thinking: Some(ThinkingConfig {\n                type_: \"enabled\".to_string(),\n                budget_tokens: Some(1024),\n                effort: None,\n            }),\n            metadata: None,\n            output_config: None,\n            size: None,\n            quality: None,\n        };\n\n        // 2. 执行转换\n        // 如果修复生效，这里应该成功返回，且 thinkingConfig 被保留\n        let result = transform_claude_request_in(&req, \"test-project\", false, None, \"test_session\", None);\n        assert!(result.is_ok(), \"First thinking request should be allowed\");\n\n        let body = result.unwrap();\n        let request = &body[\"request\"];\n        \n        // 验证 thinkingConfig 是否存在 (即 thinking 模式未被禁用)\n        let has_thinking_config = request.get(\"generationConfig\")\n            .and_then(|g| g.get(\"thinkingConfig\"))\n            .is_some();\n            \n        assert!(has_thinking_config, \"Thinking config should be preserved for first request without tool calls\");\n    }\n\n    // ==================================================================================\n    // 场景二：工具循环恢复 (P1-4 Fix)\n    // 验证当历史消息中丢失 Thinking 块导致死循环时，是否会自动注入合成消息来闭环\n    // ==================================================================================\n    #[test]\n    fn test_tool_loop_recovery() {\n        // 1. 构造一个 \"Broken Tool Loop\" 场景\n        // Assistant (ToolUse) -> User (ToolResult)\n        // 但 Assistant 消息中缺少 Thinking 块 (模拟被 stripping)\n        let mut messages = vec![\n            Message {\n                role: \"user\".to_string(),\n                content: MessageContent::String(\"Check weather\".to_string()),\n            },\n            Message {\n                role: \"assistant\".to_string(),\n                content: MessageContent::Array(vec![\n                    // 只有 ToolUse，没有 Thinking (Broken State)\n                    ContentBlock::ToolUse {\n                        id: \"call_1\".to_string(),\n                        name: \"get_weather\".to_string(),\n                        input: json!({\"location\": \"Beijing\"}),\n                        signature: None,\n                        cache_control: None,\n                    }\n                ]),\n            },\n            Message {\n                role: \"user\".to_string(),\n                content: MessageContent::Array(vec![\n                    ContentBlock::ToolResult {\n                        tool_use_id: \"call_1\".to_string(),\n                        content: json!(\"Sunny\"),\n                        is_error: None,\n                    }\n                ]),\n            }\n        ];\n\n        // 2. 分析当前状态\n        let state = analyze_conversation_state(&messages);\n        assert!(state.in_tool_loop, \"Should detect tool loop\");\n\n        // 3. 执行恢复逻辑\n        close_tool_loop_for_thinking(&mut messages);\n\n        // 4. 验证是否注入了合成消息\n        assert_eq!(messages.len(), 5, \"Should have injected 2 synthetic messages\");\n        \n        // 验证倒数第二条是 Assistant 的 \"Completed\" 消息\n        let injected_assistant = &messages[3];\n        assert_eq!(injected_assistant.role, \"assistant\");\n        \n        // 验证最后一条是 User 的 \"Proceed\" 消息\n        let injected_user = &messages[4];\n        assert_eq!(injected_user.role, \"user\");\n        \n        // 这样当前状态就不再是 \"in_tool_loop\" (最后一条是 User Text)，模型可以开始新的 Thinking\n        let new_state = analyze_conversation_state(&messages);\n        assert!(!new_state.in_tool_loop, \"Tool loop should be broken/closed\");\n    }\n\n    // ==================================================================================\n    // 场景三：跨模型兼容性 (P1-5 Fix) - 模拟\n    // 由于 request.rs 中的 is_model_compatible 是私有的，我们通过集成测试验证效果\n    // ==================================================================================\n    /* \n       注意：由于 is_model_compatible 和缓存逻辑深度集成在 transform_claude_request_in 中，\n       且依赖全局单例 SignatureCache，单元测试较难模拟 \"缓存了旧签名但切换了模型\" 的状态。\n       这里主要通过验证 \"不兼容签名被丢弃\" 的副作用（即 thoughtSignature 字段消息）来测试。\n       但由于 SignatureCache 是全局的，我们无法在测试中轻易预置状态。\n       因此，此场景主要依赖 Verification Guide 中的手动测试。\n       或者，我们可以测试 request.rs 中公开的某些 helper (如果有的话)，但目前没有。\n    */\n\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/tests/mod.rs",
    "content": "pub mod comprehensive;\npub mod security_ip_tests;\npub mod security_integration_tests;\npub mod quota_protection;\npub mod ultra_priority_tests;\npub mod retry_strategy_tests;\npub mod rate_limit_404_tests;\n"
  },
  {
    "path": "src-tauri/src/proxy/tests/quota_protection.rs",
    "content": "// ==================================================================================\n// 配额保护功能完整测试\n// 验证从账号创建到配额保护策略执行的完整流程\n// ==================================================================================\n\n#[cfg(test)]\nmod tests {\n    use std::path::PathBuf;\n\n    use crate::models::QuotaProtectionConfig;\n    use crate::proxy::common::model_mapping::normalize_to_standard_id;\n    use crate::proxy::token_manager::ProxyToken;\n\n    // ==================================================================================\n    // 辅助函数：创建模拟账号\n    // ==================================================================================\n\n    fn create_mock_token(\n        account_id: &str,\n        email: &str,\n        protected_models: Vec<&str>,\n        remaining_quota: Option<i32>,\n    ) -> ProxyToken {\n        ProxyToken {\n            account_id: account_id.to_string(),\n            access_token: format!(\"mock_access_token_{}\", account_id),\n            refresh_token: format!(\"mock_refresh_token_{}\", account_id),\n            expires_in: 3600,\n            timestamp: chrono::Utc::now().timestamp() + 3600,\n            email: email.to_string(),\n            account_path: PathBuf::from(format!(\"/tmp/test_accounts/{}.json\", account_id)),\n            project_id: Some(\"test-project\".to_string()),\n            subscription_tier: Some(\"PRO\".to_string()),\n            remaining_quota,\n            protected_models: protected_models.iter().map(|s| s.to_string()).collect(),\n            health_score: 1.0,\n            reset_time: None,\n            validation_blocked: false,\n            validation_blocked_until: 0,\n            validation_url: None,\n            model_quotas: std::collections::HashMap::new(),\n            model_limits: std::collections::HashMap::new(),\n        }\n    }\n\n    // ==================================================================================\n    // 测试 1: normalize_to_standard_id 函数正确性\n    // 验证各种 Claude 模型名称都能正确归一化\n    // ==================================================================================\n\n    #[test]\n    fn test_normalize_to_standard_id_claude_models() {\n        // Claude Sonnet 系列\n        assert_eq!(\n            normalize_to_standard_id(\"claude\"),\n            Some(\"claude\".to_string())\n        );\n        assert_eq!(\n            normalize_to_standard_id(\"claude-thinking\"),\n            Some(\"claude\".to_string())\n        );\n\n        // Claude Opus 系列 - 这是关键的测试！\n        assert_eq!(\n            normalize_to_standard_id(\"claude-opus-4-5-thinking\"),\n            Some(\"claude\".to_string()),\n            \"claude-opus-4-5-thinking 应该归一化为 claude\"\n        );\n\n        // Gemini 系列\n        assert_eq!(\n            normalize_to_standard_id(\"gemini-3-flash\"),\n            Some(\"gemini-3-flash\".to_string())\n        );\n        assert_eq!(\n            normalize_to_standard_id(\"gemini-3-pro-high\"),\n            Some(\"gemini-3-pro-high\".to_string())\n        );\n        assert_eq!(\n            normalize_to_standard_id(\"gemini-3-pro-low\"),\n            Some(\"gemini-3-pro-high\".to_string())\n        );\n\n        // 不支持的模型应返回 None\n        assert_eq!(normalize_to_standard_id(\"gpt-4\"), None);\n        assert_eq!(normalize_to_standard_id(\"unknown-model\"), None);\n    }\n\n    // ==================================================================================\n    // 测试 2: 配额保护模型匹配逻辑\n    // 验证 protected_models.contains() 在归一化后能正确匹配\n    // ==================================================================================\n\n    #[test]\n    fn test_protected_models_matching() {\n        // 创建一个账号，protected_models 中有 claude\n        let token = create_mock_token(\n            \"account-1\",\n            \"test@example.com\",\n            vec![\"claude\"],\n            Some(50),\n        );\n\n        // 测试：请求 claude-opus-4-5-thinking 时应该被保护\n        let target_model = \"claude-opus-4-5-thinking\";\n        let normalized =\n            normalize_to_standard_id(target_model).unwrap_or_else(|| target_model.to_string());\n\n        assert_eq!(normalized, \"claude\");\n        assert!(\n            token.protected_models.contains(&normalized),\n            \"claude-opus-4-5-thinking 归一化后应该匹配 protected_models 中的 claude\"\n        );\n\n        // 测试：请求 claude-thinking 时也应该被保护\n        let target_model_2 = \"claude-thinking\";\n        let normalized_2 =\n            normalize_to_standard_id(target_model_2).unwrap_or_else(|| target_model_2.to_string());\n\n        assert!(\n            token.protected_models.contains(&normalized_2),\n            \"claude-thinking 归一化后应该匹配 protected_models\"\n        );\n\n        // 测试：请求 gemini-3-flash 时不应该被保护（因为 protected_models 中没有）\n        let target_model_3 = \"gemini-3-flash\";\n        let normalized_3 =\n            normalize_to_standard_id(target_model_3).unwrap_or_else(|| target_model_3.to_string());\n\n        assert!(\n            !token.protected_models.contains(&normalized_3),\n            \"gemini-3-flash 不应该匹配 claude\"\n        );\n    }\n\n    // ==================================================================================\n    // 测试 3: 多账号轮询时的配额保护过滤\n    // 模拟多个账号，验证被保护的账号会被跳过\n    // ==================================================================================\n\n    #[test]\n    fn test_multi_account_quota_protection_filtering() {\n        // 创建 3 个账号\n        let tokens = vec![\n            // 账号 1: claude 被保护（配额低）\n            create_mock_token(\n                \"account-1\",\n                \"user1@example.com\",\n                vec![\"claude\"],\n                Some(20),\n            ),\n            // 账号 2: 没有被保护\n            create_mock_token(\"account-2\", \"user2@example.com\", vec![], Some(80)),\n            // 账号 3: gemini-3-flash 被保护\n            create_mock_token(\n                \"account-3\",\n                \"user3@example.com\",\n                vec![\"gemini-3-flash\"],\n                Some(30),\n            ),\n        ];\n\n        // 模拟请求 claude-opus-4-5-thinking\n        let target_model = \"claude-opus-4-5-thinking\";\n        let normalized_target =\n            normalize_to_standard_id(target_model).unwrap_or_else(|| target_model.to_string());\n\n        // 过滤掉被保护的账号\n        let available_accounts: Vec<_> = tokens\n            .iter()\n            .filter(|t| !t.protected_models.contains(&normalized_target))\n            .collect();\n\n        // 验证：账号 1 被过滤（因为 claude 被保护）\n        // 账号 2 和 3 可用\n        assert_eq!(available_accounts.len(), 2);\n        assert!(available_accounts\n            .iter()\n            .any(|t| t.account_id == \"account-2\"));\n        assert!(available_accounts\n            .iter()\n            .any(|t| t.account_id == \"account-3\"));\n        assert!(!available_accounts\n            .iter()\n            .any(|t| t.account_id == \"account-1\"));\n\n        // 模拟请求 gemini-3-flash\n        let target_model_2 = \"gemini-3-flash\";\n        let normalized_target_2 =\n            normalize_to_standard_id(target_model_2).unwrap_or_else(|| target_model_2.to_string());\n\n        let available_accounts_2: Vec<_> = tokens\n            .iter()\n            .filter(|t| !t.protected_models.contains(&normalized_target_2))\n            .collect();\n\n        // 验证：账号 3 被过滤（因为 gemini-3-flash 被保护）\n        // 账号 1 和 2 可用\n        assert_eq!(available_accounts_2.len(), 2);\n        assert!(available_accounts_2\n            .iter()\n            .any(|t| t.account_id == \"account-1\"));\n        assert!(available_accounts_2\n            .iter()\n            .any(|t| t.account_id == \"account-2\"));\n        assert!(!available_accounts_2\n            .iter()\n            .any(|t| t.account_id == \"account-3\"));\n    }\n\n    // ==================================================================================\n    // 测试 4: 所有账号都被保护时的行为\n    // 验证当所有账号的目标模型都被保护时，返回错误\n    // ==================================================================================\n\n    #[test]\n    fn test_all_accounts_protected_returns_error() {\n        // 创建 3 个账号，全部对 claude 进行保护\n        let tokens = vec![\n            create_mock_token(\n                \"account-1\",\n                \"user1@example.com\",\n                vec![\"claude\"],\n                Some(10),\n            ),\n            create_mock_token(\n                \"account-2\",\n                \"user2@example.com\",\n                vec![\"claude\"],\n                Some(15),\n            ),\n            create_mock_token(\n                \"account-3\",\n                \"user3@example.com\",\n                vec![\"claude\"],\n                Some(5),\n            ),\n        ];\n\n        let target_model = \"claude-opus-4-5-thinking\";\n        let normalized_target =\n            normalize_to_standard_id(target_model).unwrap_or_else(|| target_model.to_string());\n\n        let available_accounts: Vec<_> = tokens\n            .iter()\n            .filter(|t| !t.protected_models.contains(&normalized_target))\n            .collect();\n\n        // 所有账号都被过滤，应该返回 0\n        assert_eq!(available_accounts.len(), 0);\n\n        // 在实际代码中，这会导致 \"All accounts failed or unhealthy\" 错误\n    }\n\n    // ==================================================================================\n    // 测试 5: monitored_models 配置与归一化一致性\n    // 验证配置中的 monitored_models 能正确匹配归一化后的模型名\n    // ==================================================================================\n\n    #[test]\n    fn test_monitored_models_normalization_consistency() {\n        let config = QuotaProtectionConfig {\n            enabled: true,\n            threshold_percentage: 60,\n            monitored_models: vec![\n                \"claude\".to_string(),\n                \"gemini-3-pro-high\".to_string(),\n                \"gemini-3-flash\".to_string(),\n            ],\n        };\n\n        // 测试各种模型名归一化后是否在 monitored_models 中\n        let test_cases = vec![\n            (\"claude-opus-4-5-thinking\", true),   // 归一化为 claude\n            (\"claude-thinking\", true), // 归一化为 claude\n            (\"claude\", true),          // 直接匹配\n            (\"gemini-3-pro-high\", true),          // 直接匹配\n            (\"gemini-3-pro-low\", true),           // 归一化为 gemini-3-pro-high\n            (\"gemini-3-flash\", true),             // 直接匹配\n            (\"gpt-4\", false),                     // 不支持的模型\n            (\"gemini-2.5-flash\", true),           // 在监控列表中 (归一化为 gemini-3-flash)\n        ];\n\n        for (model_name, expected_monitored) in test_cases {\n            let standard_id = normalize_to_standard_id(model_name);\n\n            let is_monitored = match &standard_id {\n                Some(id) => config.monitored_models.contains(id),\n                None => false,\n            };\n\n            assert_eq!(\n                is_monitored, expected_monitored,\n                \"模型 {} (归一化为 {:?}) 的监控状态应为 {}\",\n                model_name, standard_id, expected_monitored\n            );\n        }\n    }\n\n    // ==================================================================================\n    // 测试 6: 配额阈值触发逻辑\n    // 验证配额低于阈值时触发保护，高于阈值时恢复\n    // ==================================================================================\n\n    #[test]\n    fn test_quota_threshold_trigger_logic() {\n        let threshold = 60; // 60% 阈值\n\n        // 模拟 quota 数据\n        let quota_data = vec![\n            (\"claude-opus-4-5-thinking\", 50, true), // 50% <= 60%, 应触发保护\n            (\"claude-thinking\", 60, true), // 60% <= 60%, 应触发保护（边界情况）\n            (\"gemini-3-flash\", 61, false),          // 61% > 60%, 不触发保护\n            (\"gemini-3-pro-high\", 100, false),      // 100% > 60%, 不触发保护\n        ];\n\n        for (model_name, percentage, should_protect) in quota_data {\n            let should_trigger = percentage <= threshold;\n\n            assert_eq!(\n                should_trigger,\n                should_protect,\n                \"模型 {} 配额 {}% (阈值 {}%) 应 {} 触发保护\",\n                model_name,\n                percentage,\n                threshold,\n                if should_protect { \"\" } else { \"不\" }\n            );\n        }\n    }\n\n    // ==================================================================================\n    // 测试 7: 账号优先级排序后的保护过滤\n    // 验证高配额账号被保护后，会回退到低配额账号\n    // ==================================================================================\n\n    #[test]\n    fn test_priority_fallback_when_protected() {\n        // 创建 3 个账号，按配额排序\n        let mut tokens = vec![\n            create_mock_token(\n                \"account-high\",\n                \"high@example.com\",\n                vec![\"claude\"],\n                Some(90),\n            ),\n            create_mock_token(\"account-mid\", \"mid@example.com\", vec![], Some(60)),\n            create_mock_token(\"account-low\", \"low@example.com\", vec![], Some(30)),\n        ];\n\n        // 按配额降序排序（高配额优先）\n        tokens.sort_by(|a, b| {\n            let qa = a.remaining_quota.unwrap_or(0);\n            let qb = b.remaining_quota.unwrap_or(0);\n            qb.cmp(&qa)\n        });\n\n        // 验证排序正确\n        assert_eq!(tokens[0].account_id, \"account-high\");\n        assert_eq!(tokens[1].account_id, \"account-mid\");\n        assert_eq!(tokens[2].account_id, \"account-low\");\n\n        // 模拟请求 claude-opus-4-5-thinking\n        let target_model = \"claude-opus-4-5-thinking\";\n        let normalized_target =\n            normalize_to_standard_id(target_model).unwrap_or_else(|| target_model.to_string());\n\n        // 按顺序选择第一个可用账号\n        let selected = tokens\n            .iter()\n            .find(|t| !t.protected_models.contains(&normalized_target));\n\n        // 验证：account-high 被跳过，选择 account-mid\n        assert!(selected.is_some());\n        assert_eq!(\n            selected.unwrap().account_id,\n            \"account-mid\",\n            \"高配额账号被保护后，应该回退到 account-mid\"\n        );\n    }\n\n    // ==================================================================================\n    // 测试 8: 模型级别保护（同一账号不同模型）\n    // 验证一个账号可以对某些模型保护，对其他模型不保护\n    // ==================================================================================\n\n    #[test]\n    fn test_model_level_protection_granularity() {\n        // 账号对 claude 保护，但对 gemini-3-flash 不保护\n        let token = create_mock_token(\n            \"account-1\",\n            \"user@example.com\",\n            vec![\"claude\"],\n            Some(50),\n        );\n\n        // 请求 claude-opus-4-5-thinking -> 被保护\n        let normalized_claude = normalize_to_standard_id(\"claude-opus-4-5-thinking\")\n            .unwrap_or_else(|| \"claude-opus-4-5-thinking\".to_string());\n        assert!(\n            token.protected_models.contains(&normalized_claude),\n            \"Claude 请求应该被保护\"\n        );\n\n        // 请求 gemini-3-flash -> 不被保护\n        let normalized_gemini = normalize_to_standard_id(\"gemini-3-flash\")\n            .unwrap_or_else(|| \"gemini-3-flash\".to_string());\n        assert!(\n            !token.protected_models.contains(&normalized_gemini),\n            \"Gemini 请求不应该被保护\"\n        );\n    }\n\n    // ==================================================================================\n    // 测试 9: 配额保护启用/禁用开关\n    // 验证当 quota_protection.enabled = false 时，保护逻辑不生效\n    // ==================================================================================\n\n    #[test]\n    fn test_quota_protection_enabled_flag() {\n        let config_enabled = QuotaProtectionConfig {\n            enabled: true,\n            threshold_percentage: 60,\n            monitored_models: vec![\"claude\".to_string()],\n        };\n\n        let config_disabled = QuotaProtectionConfig {\n            enabled: false,\n            threshold_percentage: 60,\n            monitored_models: vec![\"claude\".to_string()],\n        };\n\n        let token = create_mock_token(\n            \"account-1\",\n            \"user@example.com\",\n            vec![\"claude\"],\n            Some(50),\n        );\n\n        let target_model = \"claude-opus-4-5-thinking\";\n        let normalized_target =\n            normalize_to_standard_id(target_model).unwrap_or_else(|| target_model.to_string());\n\n        // 启用配额保护时，账号应该被过滤\n        let is_protected_when_enabled =\n            config_enabled.enabled && token.protected_models.contains(&normalized_target);\n        assert!(is_protected_when_enabled, \"启用时应该被保护\");\n\n        // 禁用配额保护时，即使 protected_models 中有值，也不过滤\n        let is_protected_when_disabled =\n            config_disabled.enabled && token.protected_models.contains(&normalized_target);\n        assert!(!is_protected_when_disabled, \"禁用时不应该被保护\");\n    }\n\n    // ==================================================================================\n    // 测试 10: 完整流程模拟（集成测试风格）\n    // 模拟多账号、配额保护配置、请求轮询的完整流程\n    // ==================================================================================\n\n    #[test]\n    fn test_full_quota_protection_flow() {\n        // 1. 配置配额保护\n        let config = QuotaProtectionConfig {\n            enabled: true,\n            threshold_percentage: 60,\n            monitored_models: vec![\n                \"claude\".to_string(),\n                \"gemini-3-flash\".to_string(),\n            ],\n        };\n\n        // 2. 创建多个账号，模拟不同配额状态\n        let accounts = vec![\n            // 账号 A: Claude 配额低（50%），应该被保护\n            create_mock_token(\n                \"account-a\",\n                \"a@example.com\",\n                vec![\"claude\"],\n                Some(50),\n            ),\n            // 账号 B: Claude 配额正常（80%），不被保护\n            create_mock_token(\"account-b\", \"b@example.com\", vec![], Some(80)),\n            // 账号 C: Claude 和 Gemini 都被保护\n            create_mock_token(\n                \"account-c\",\n                \"c@example.com\",\n                vec![\"claude\", \"gemini-3-flash\"],\n                Some(30),\n            ),\n            // 账号 D: 只有 Gemini 被保护\n            create_mock_token(\n                \"account-d\",\n                \"d@example.com\",\n                vec![\"gemini-3-flash\"],\n                Some(40),\n            ),\n        ];\n\n        // 3. 模拟多次请求，验证账号选择逻辑\n\n        // 请求 1: claude-opus-4-5-thinking\n        let target_claude = normalize_to_standard_id(\"claude-opus-4-5-thinking\")\n            .unwrap_or_else(|| \"claude-opus-4-5-thinking\".to_string());\n\n        let available_for_claude: Vec<_> = accounts\n            .iter()\n            .filter(|a| !config.enabled || !a.protected_models.contains(&target_claude))\n            .collect();\n\n        // 账号 A 和 C 被过滤，B 和 D 可用\n        assert_eq!(available_for_claude.len(), 2);\n        let claude_account_ids: Vec<_> = available_for_claude\n            .iter()\n            .map(|a| a.account_id.as_str())\n            .collect();\n        assert!(claude_account_ids.contains(&\"account-b\"));\n        assert!(claude_account_ids.contains(&\"account-d\"));\n\n        // 请求 2: gemini-3-flash\n        let target_gemini = normalize_to_standard_id(\"gemini-3-flash\")\n            .unwrap_or_else(|| \"gemini-3-flash\".to_string());\n\n        let available_for_gemini: Vec<_> = accounts\n            .iter()\n            .filter(|a| !config.enabled || !a.protected_models.contains(&target_gemini))\n            .collect();\n\n        // 账号 C 和 D 被过滤，A 和 B 可用\n        assert_eq!(available_for_gemini.len(), 2);\n        let gemini_account_ids: Vec<_> = available_for_gemini\n            .iter()\n            .map(|a| a.account_id.as_str())\n            .collect();\n        assert!(gemini_account_ids.contains(&\"account-a\"));\n        assert!(gemini_account_ids.contains(&\"account-b\"));\n\n        // 请求 3: 未被监控的模型 (gemini-2.5-flash)\n        let target_unmonitored = normalize_to_standard_id(\"gemini-2.5-flash\")\n            .unwrap_or_else(|| \"gemini-2.5-flash\".to_string());\n\n        let available_for_unmonitored: Vec<_> = accounts\n            .iter()\n            .filter(|a| !config.enabled || !a.protected_models.contains(&target_unmonitored))\n            .collect();\n\n        // 未被监控的模型 (Gemini 2.5 Flash 实际上被归一化为已监控的 3-flash)\n        // 在 4 个测试账号中，账号 C 和 D 开启了 3-flash 保护，而 A 和 B 未开启。\n        // 因此，应该有 2 个账号可用。\n        assert_eq!(available_for_unmonitored.len(), 2, \"Gemini 2.5 Flash 共享了 3-flash 的保护状态，应有 2 个账号可用\");\n    }\n\n    // ==================================================================================\n    // 测试 11: 边界情况 - 空 protected_models\n    // ==================================================================================\n\n    #[test]\n    fn test_empty_protected_models() {\n        let token = create_mock_token(\n            \"account-1\",\n            \"user@example.com\",\n            vec![], // 没有被保护的模型\n            Some(50),\n        );\n\n        let target = normalize_to_standard_id(\"claude-opus-4-5-thinking\")\n            .unwrap_or_else(|| \"claude-opus-4-5-thinking\".to_string());\n\n        assert!(\n            !token.protected_models.contains(&target),\n            \"空 protected_models 不应该匹配任何模型\"\n        );\n    }\n\n    // ==================================================================================\n    // 测试 12: 边界情况 - 大小写敏感性\n    // ==================================================================================\n\n    #[test]\n    fn test_model_name_case_sensitivity() {\n        // normalize_to_standard_id 应该是大小写不敏感的\n        assert_eq!(\n            normalize_to_standard_id(\"Claude-Opus-4-5-Thinking\"),\n            Some(\"claude\".to_string())\n        );\n        assert_eq!(\n            normalize_to_standard_id(\"CLAUDE-OPUS-4-5-THINKING\"),\n            Some(\"claude\".to_string())\n        );\n        assert_eq!(\n            normalize_to_standard_id(\"GEMINI-3-FLASH\"),\n            Some(\"gemini-3-flash\".to_string())\n        );\n    }\n\n    // ==================================================================================\n    // 测试 13: 端到端场景 - 会话中途配额保护生效后的路由切换\n    // 模拟：请求1 -> 绑定账号A -> 请求2 -> 继续用A -> 刷新配额 -> A被保护 -> 请求3 -> 切换到B\n    // ==================================================================================\n\n    #[test]\n    fn test_sticky_session_quota_protection_mid_session_single_account() {\n        // 场景：只有一个账号，会话绑定后配额保护生效\n        // 预期：返回配额保护错误\n\n        let session_id = \"session-12345\";\n        let target_model = \"claude-opus-4-5-thinking\";\n        let normalized_target =\n            normalize_to_standard_id(target_model).unwrap_or_else(|| target_model.to_string());\n\n        // 初始状态：账号 A 没有被保护\n        let mut account_a = create_mock_token(\n            \"account-a\",\n            \"a@example.com\",\n            vec![], // 初始没有保护\n            Some(70),\n        );\n\n        // 模拟会话绑定表\n        let mut session_bindings: std::collections::HashMap<String, String> =\n            std::collections::HashMap::new();\n\n        // === 请求 1: 绑定到账号 A ===\n        session_bindings.insert(session_id.to_string(), account_a.account_id.clone());\n\n        // 验证请求 1 成功\n        let bound_account = session_bindings.get(session_id);\n        assert_eq!(bound_account, Some(&\"account-a\".to_string()));\n\n        // === 请求 2: 继续使用账号 A ===\n        // 账号 A 仍然可用\n        assert!(!account_a.protected_models.contains(&normalized_target));\n\n        // === 系统触发配额刷新，发现账号 A 配额低于阈值 ===\n        // 模拟配额刷新后，account_a 的 claude 被加入保护列表\n        account_a\n            .protected_models\n            .insert(\"claude\".to_string());\n\n        // === 请求 3: 尝试使用账号 A，但被配额保护 ===\n        let accounts = vec![account_a.clone()]; // 只有一个账号\n\n        // 检查绑定的账号是否被保护\n        let bound_id = session_bindings.get(session_id).unwrap();\n        let bound_account = accounts.iter().find(|a| &a.account_id == bound_id).unwrap();\n        let is_protected = bound_account.protected_models.contains(&normalized_target);\n\n        assert!(is_protected, \"账号 A 应该被配额保护\");\n\n        // 尝试找其他可用账号\n        let available_accounts: Vec<_> = accounts\n            .iter()\n            .filter(|a| !a.protected_models.contains(&normalized_target))\n            .collect();\n\n        // 没有可用账号\n        assert_eq!(available_accounts.len(), 0, \"应该没有可用账号\");\n\n        // 在实际实现中，这会返回错误消息\n        // 验证应该返回配额保护相关的错误\n        let error_message = if available_accounts.is_empty() {\n            if accounts\n                .iter()\n                .all(|a| a.protected_models.contains(&normalized_target))\n            {\n                format!(\n                    \"All accounts quota-protected for model {}\",\n                    normalized_target\n                )\n            } else {\n                \"All accounts failed or unhealthy.\".to_string()\n            }\n        } else {\n            \"OK\".to_string()\n        };\n\n        assert!(\n            error_message.contains(\"quota-protected\"),\n            \"错误消息应该包含 quota-protected: {}\",\n            error_message\n        );\n    }\n\n    #[test]\n    fn test_sticky_session_quota_protection_mid_session_multi_account() {\n        // 场景：多个账号，会话绑定的账号配额保护生效后，应该路由到其他账号\n\n        let session_id = \"session-67890\";\n        let target_model = \"claude-opus-4-5-thinking\";\n        let normalized_target =\n            normalize_to_standard_id(target_model).unwrap_or_else(|| target_model.to_string());\n\n        // 初始状态：账号 A 和 B 都没有被保护\n        let mut account_a = create_mock_token(\"account-a\", \"a@example.com\", vec![], Some(70));\n        let account_b = create_mock_token(\"account-b\", \"b@example.com\", vec![], Some(80));\n\n        let mut session_bindings: std::collections::HashMap<String, String> =\n            std::collections::HashMap::new();\n\n        // === 请求 1: 绑定到账号 A ===\n        session_bindings.insert(session_id.to_string(), account_a.account_id.clone());\n\n        // === 请求 2: 继续使用账号 A ===\n        assert!(!account_a.protected_models.contains(&normalized_target));\n\n        // === 系统触发配额刷新，账号 A 被保护 ===\n        account_a\n            .protected_models\n            .insert(\"claude\".to_string());\n\n        // === 请求 3: 账号 A 被保护，应该解绑并切换到账号 B ===\n        let accounts = vec![account_a.clone(), account_b.clone()];\n\n        // 检查绑定的账号\n        let bound_id = session_bindings.get(session_id).unwrap();\n        let bound_account = accounts.iter().find(|a| &a.account_id == bound_id).unwrap();\n        let is_protected = bound_account.protected_models.contains(&normalized_target);\n\n        assert!(is_protected, \"账号 A 应该被配额保护\");\n\n        // 模拟解绑逻辑\n        if is_protected {\n            session_bindings.remove(session_id);\n        }\n\n        // 寻找其他可用账号\n        let available_accounts: Vec<_> = accounts\n            .iter()\n            .filter(|a| !a.protected_models.contains(&normalized_target))\n            .collect();\n\n        // 应该有账号 B 可用\n        assert_eq!(available_accounts.len(), 1);\n        assert_eq!(available_accounts[0].account_id, \"account-b\");\n\n        // 重新绑定到账号 B\n        let new_account = available_accounts[0];\n        session_bindings.insert(session_id.to_string(), new_account.account_id.clone());\n\n        // 验证新绑定\n        assert_eq!(\n            session_bindings.get(session_id),\n            Some(&\"account-b\".to_string()),\n            \"会话应该重新绑定到账号 B\"\n        );\n    }\n\n    // ==================================================================================\n    // 测试 14: 配额保护实时同步测试\n    // 模拟：配额刷新后 protected_models 被更新，TokenManager 内存应该同步\n    // ==================================================================================\n\n    #[test]\n    fn test_quota_protection_sync_after_refresh() {\n        // 这个测试模拟 update_account_quota 触发 TokenManager 重新加载的场景\n\n        // 初始内存状态\n        let mut tokens_in_memory = vec![create_mock_token(\n            \"account-a\",\n            \"a@example.com\",\n            vec![],\n            Some(70),\n        )];\n\n        // 模拟磁盘上的账号数据（配额刷新后更新）\n        let mut account_on_disk = create_mock_token(\"account-a\", \"a@example.com\", vec![], Some(50));\n\n        // 模拟配额刷新：检测到配额低于阈值，触发保护\n        let threshold = 60;\n        if account_on_disk.remaining_quota.unwrap_or(100) <= threshold {\n            account_on_disk\n                .protected_models\n                .insert(\"claude\".to_string());\n        }\n\n        // 验证磁盘数据已更新\n        assert!(\n            account_on_disk\n                .protected_models\n                .contains(\"claude\"),\n            \"磁盘上的账号应该已被保护\"\n        );\n\n        // 此时内存数据还是旧的\n        assert!(\n            !tokens_in_memory[0]\n                .protected_models\n                .contains(\"claude\"),\n            \"内存中的账号还没被同步\"\n        );\n\n        // 模拟 trigger_account_reload -> reload_account 同步\n        tokens_in_memory[0] = account_on_disk.clone();\n\n        // 验证内存数据已同步\n        assert!(\n            tokens_in_memory[0]\n                .protected_models\n                .contains(\"claude\"),\n            \"同步后内存中的账号应该被保护\"\n        );\n\n        // 现在请求应该被正确过滤\n        let target = normalize_to_standard_id(\"claude-opus-4-5-thinking\")\n            .unwrap_or_else(|| \"claude-opus-4-5-thinking\".to_string());\n\n        let available: Vec<_> = tokens_in_memory\n            .iter()\n            .filter(|t| !t.protected_models.contains(&target))\n            .collect();\n\n        assert_eq!(available.len(), 0, \"同步后账号应该被过滤\");\n    }\n\n    // ==================================================================================\n    // 测试 15: 多轮请求中的配额保护动态变化\n    // 模拟完整的请求序列，包括配额保护的触发和恢复\n    // ==================================================================================\n\n    #[test]\n    fn test_quota_protection_dynamic_changes() {\n        let target_model = \"claude-opus-4-5-thinking\";\n        let normalized_target =\n            normalize_to_standard_id(target_model).unwrap_or_else(|| target_model.to_string());\n\n        // 账号池\n        let mut account_a = create_mock_token(\"account-a\", \"a@example.com\", vec![], Some(70));\n        let mut account_b = create_mock_token(\"account-b\", \"b@example.com\", vec![], Some(80));\n\n        // === 阶段 1: 初始状态，两个账号都可用 ===\n        let accounts = vec![account_a.clone(), account_b.clone()];\n        let available: Vec<_> = accounts\n            .iter()\n            .filter(|t| !t.protected_models.contains(&normalized_target))\n            .collect();\n        assert_eq!(available.len(), 2, \"阶段1: 两个账号都可用\");\n\n        // === 阶段 2: 账号 A 配额降低，触发保护 ===\n        account_a.remaining_quota = Some(40);\n        account_a\n            .protected_models\n            .insert(\"claude\".to_string());\n\n        let accounts = vec![account_a.clone(), account_b.clone()];\n        let available: Vec<_> = accounts\n            .iter()\n            .filter(|t| !t.protected_models.contains(&normalized_target))\n            .collect();\n        assert_eq!(available.len(), 1, \"阶段2: 只有账号 B 可用\");\n        assert_eq!(available[0].account_id, \"account-b\");\n\n        // === 阶段 3: 账号 B 也触发保护 ===\n        account_b.remaining_quota = Some(30);\n        account_b\n            .protected_models\n            .insert(\"claude\".to_string());\n\n        let accounts = vec![account_a.clone(), account_b.clone()];\n        let available: Vec<_> = accounts\n            .iter()\n            .filter(|t| !t.protected_models.contains(&normalized_target))\n            .collect();\n        assert_eq!(available.len(), 0, \"阶段3: 没有可用账号\");\n\n        // === 阶段 4: 账号 A 配额恢复（重置），解除保护 ===\n        account_a.remaining_quota = Some(100);\n        account_a.protected_models.remove(\"claude\");\n\n        let accounts = vec![account_a.clone(), account_b.clone()];\n        let available: Vec<_> = accounts\n            .iter()\n            .filter(|t| !t.protected_models.contains(&normalized_target))\n            .collect();\n        assert_eq!(available.len(), 1, \"阶段4: 账号 A 恢复可用\");\n        assert_eq!(available[0].account_id, \"account-a\");\n    }\n\n    // ==================================================================================\n    // 测试 16: 完整错误消息验证\n    // 验证不同场景下返回的错误消息是否正确\n    // ==================================================================================\n\n    #[test]\n    fn test_error_messages_for_quota_protection() {\n        let target_model = \"claude-opus-4-5-thinking\";\n        let normalized_target =\n            normalize_to_standard_id(target_model).unwrap_or_else(|| target_model.to_string());\n\n        // 场景 1: 所有账号都因配额保护不可用\n        let all_protected = vec![\n            create_mock_token(\"a1\", \"a1@example.com\", vec![\"claude\"], Some(30)),\n            create_mock_token(\"a2\", \"a2@example.com\", vec![\"claude\"], Some(20)),\n        ];\n\n        let all_are_quota_protected = all_protected\n            .iter()\n            .all(|a| a.protected_models.contains(&normalized_target));\n\n        assert!(all_are_quota_protected, \"所有账号都被配额保护\");\n\n        // 生成错误消息\n        let error = format!(\n            \"All {} accounts are quota-protected for model '{}'. Wait for quota reset or adjust protection threshold.\",\n            all_protected.len(),\n            normalized_target\n        );\n\n        assert!(error.contains(\"quota-protected\"));\n        assert!(error.contains(\"claude\"));\n\n        // 场景 2: 混合情况（部分限流，部分配额保护）\n        let mixed = vec![\n            create_mock_token(\"a1\", \"a1@example.com\", vec![\"claude\"], Some(30)),\n            create_mock_token(\"a2\", \"a2@example.com\", vec![], Some(20)), // 这个假设被限流\n        ];\n\n        let quota_protected_count = mixed\n            .iter()\n            .filter(|a| a.protected_models.contains(&normalized_target))\n            .count();\n\n        assert_eq!(quota_protected_count, 1);\n    }\n\n    // ==================================================================================\n    // 测试 17: get_model_quota_from_json 函数正确性\n    // 验证从磁盘读取特定模型 quota 而非 max(所有模型)\n    // ==================================================================================\n\n    #[test]\n    fn test_get_model_quota_from_json_reads_correct_model() {\n        // 创建模拟账号 JSON 文件，包含多个模型的 quota\n        let account_json = serde_json::json!({\n            \"email\": \"test@example.com\",\n            \"quota\": {\n                \"models\": [\n                    { \"name\": \"claude\", \"percentage\": 60 },\n                    { \"name\": \"claude-opus-4-5-thinking\", \"percentage\": 40 },\n                    { \"name\": \"gemini-3-flash\", \"percentage\": 100 }\n                ]\n            }\n        });\n\n        // 使用 std::env::temp_dir() 创建临时文件\n        let temp_dir = std::env::temp_dir();\n        let account_path = temp_dir.join(format!(\"test_quota_{}.json\", uuid::Uuid::new_v4()));\n        std::fs::write(&account_path, account_json.to_string()).expect(\"Failed to write temp file\");\n\n        // 测试读取 claude 的 quota\n        let sonnet_quota =\n            crate::proxy::token_manager::TokenManager::get_model_quota_from_json_for_test(\n                &account_path,\n                \"claude\",\n            );\n        assert_eq!(\n            sonnet_quota,\n            Some(60),\n            \"claude 应该返回 60%，而非 max(100%)\"\n        );\n\n        // 测试读取 gemini-3-flash 的 quota\n        let gemini_quota =\n            crate::proxy::token_manager::TokenManager::get_model_quota_from_json_for_test(\n                &account_path,\n                \"gemini-3-flash\",\n            );\n        assert_eq!(gemini_quota, Some(100), \"gemini-3-flash 应该返回 100%\");\n\n        // 测试读取不存在的模型\n        let unknown_quota =\n            crate::proxy::token_manager::TokenManager::get_model_quota_from_json_for_test(\n                &account_path,\n                \"unknown-model\",\n            );\n        assert_eq!(unknown_quota, None, \"不存在的模型应该返回 None\");\n\n        // 清理临时文件\n        let _ = std::fs::remove_file(&account_path);\n    }\n\n    // ==================================================================================\n    // 测试 18: 排序使用目标模型 quota 而非 max quota\n    // 验证修复后的排序逻辑正确性\n    // ==================================================================================\n\n    #[test]\n    fn test_sorting_uses_target_model_quota_not_max() {\n        // 使用 std::env::temp_dir() 创建临时目录\n        let temp_dir = std::env::temp_dir().join(format!(\"test_sorting_{}\", uuid::Uuid::new_v4()));\n        std::fs::create_dir_all(&temp_dir).expect(\"Failed to create temp dir\");\n\n        // 账号 A: max=100 (gemini), sonnet=40\n        let account_a_json = serde_json::json!({\n            \"email\": \"carmelioventori@example.com\",\n            \"quota\": {\n                \"models\": [\n                    { \"name\": \"claude\", \"percentage\": 40 },\n                    { \"name\": \"gemini-3-flash\", \"percentage\": 100 }\n                ]\n            }\n        });\n\n        // 账号 B: max=100 (gemini), sonnet=100\n        let account_b_json = serde_json::json!({\n            \"email\": \"kiriyamaleo@example.com\",\n            \"quota\": {\n                \"models\": [\n                    { \"name\": \"claude\", \"percentage\": 100 },\n                    { \"name\": \"gemini-3-flash\", \"percentage\": 100 }\n                ]\n            }\n        });\n\n        // 账号 C: max=100 (gemini), sonnet=60\n        let account_c_json = serde_json::json!({\n            \"email\": \"mizusawakai9@example.com\",\n            \"quota\": {\n                \"models\": [\n                    { \"name\": \"claude\", \"percentage\": 60 },\n                    { \"name\": \"gemini-3-flash\", \"percentage\": 100 }\n                ]\n            }\n        });\n\n        // 写入临时文件\n        let path_a = temp_dir.join(\"account_a.json\");\n        let path_b = temp_dir.join(\"account_b.json\");\n        let path_c = temp_dir.join(\"account_c.json\");\n\n        std::fs::write(&path_a, account_a_json.to_string()).unwrap();\n        std::fs::write(&path_b, account_b_json.to_string()).unwrap();\n        std::fs::write(&path_c, account_c_json.to_string()).unwrap();\n\n        // 创建 tokens，remaining_quota 使用 max 值（模拟旧逻辑）\n        let mut tokens = vec![\n            create_mock_token_with_path(\"a\", \"carmelioventori@example.com\", vec![], Some(100), path_a.clone()),\n            create_mock_token_with_path(\"b\", \"kiriyamaleo@example.com\", vec![], Some(100), path_b.clone()),\n            create_mock_token_with_path(\"c\", \"mizusawakai9@example.com\", vec![], Some(100), path_c.clone()),\n        ];\n\n        // 目标模型: claude\n        let target_model = \"claude\";\n\n        // 使用修复后的排序逻辑：读取目标模型的 quota\n        tokens.sort_by(|a, b| {\n            let quota_a = crate::proxy::token_manager::TokenManager::get_model_quota_from_json_for_test(\n                &a.account_path,\n                target_model,\n            )\n            .unwrap_or(0);\n            let quota_b = crate::proxy::token_manager::TokenManager::get_model_quota_from_json_for_test(\n                &b.account_path,\n                target_model,\n            )\n            .unwrap_or(0);\n            quota_b.cmp(&quota_a) // 高 quota 优先\n        });\n\n        // 验证排序结果：sonnet quota 100% > 60% > 40%\n        assert_eq!(\n            tokens[0].email, \"kiriyamaleo@example.com\",\n            \"sonnet=100% 的账号应该排第一\"\n        );\n        assert_eq!(\n            tokens[1].email, \"mizusawakai9@example.com\",\n            \"sonnet=60% 的账号应该排第二\"\n        );\n        assert_eq!(\n            tokens[2].email, \"carmelioventori@example.com\",\n            \"sonnet=40% 的账号应该排第三\"\n        );\n\n        // 清理临时目录\n        let _ = std::fs::remove_dir_all(&temp_dir);\n    }\n\n    // ==================================================================================\n    // 测试 19: 模型名称归一化后的 quota 匹配\n    // 验证请求 claude-opus-4-5-thinking 时能正确匹配 claude 的 quota\n    // ==================================================================================\n\n    #[test]\n    fn test_quota_matching_with_normalized_model_name() {\n        // 账号 JSON：只记录标准化后的模型名\n        let account_json = serde_json::json!({\n            \"email\": \"test@example.com\",\n            \"quota\": {\n                \"models\": [\n                    { \"name\": \"claude\", \"percentage\": 75 },\n                    { \"name\": \"gemini-3-flash\", \"percentage\": 90 }\n                ]\n            }\n        });\n\n        let temp_dir = std::env::temp_dir();\n        let account_path = temp_dir.join(format!(\"test_normalized_{}.json\", uuid::Uuid::new_v4()));\n        std::fs::write(&account_path, account_json.to_string()).expect(\"Failed to write temp file\");\n\n        // 请求 claude-opus-4-5-thinking，应该归一化为 claude\n        let request_model = \"claude-opus-4-5-thinking\";\n        let normalized = normalize_to_standard_id(request_model)\n            .unwrap_or_else(|| request_model.to_string());\n\n        assert_eq!(normalized, \"claude\", \"应该归一化为 claude\");\n\n        // 读取归一化后模型的 quota\n        let quota = crate::proxy::token_manager::TokenManager::get_model_quota_from_json_for_test(\n            &account_path,\n            &normalized,\n        );\n\n        assert_eq!(\n            quota,\n            Some(75),\n            \"claude-opus-4-5-thinking 归一化后应该读取 claude 的 quota (75%)\"\n        );\n\n        // 清理临时文件\n        let _ = std::fs::remove_file(&account_path);\n    }\n\n    /// 辅助函数：创建带有自定义 account_path 的 mock token\n    fn create_mock_token_with_path(\n        account_id: &str,\n        email: &str,\n        protected_models: Vec<&str>,\n        remaining_quota: Option<i32>,\n        account_path: PathBuf,\n    ) -> ProxyToken {\n        ProxyToken {\n            account_id: account_id.to_string(),\n            access_token: format!(\"mock_access_token_{}\", account_id),\n            refresh_token: format!(\"mock_refresh_token_{}\", account_id),\n            expires_in: 3600,\n            timestamp: chrono::Utc::now().timestamp() + 3600,\n            email: email.to_string(),\n            account_path,\n            project_id: Some(\"test-project\".to_string()),\n            subscription_tier: Some(\"PRO\".to_string()),\n            remaining_quota,\n            protected_models: protected_models.iter().map(|s| s.to_string()).collect(),\n            health_score: 1.0,\n            reset_time: None,\n            validation_blocked: false,\n            validation_blocked_until: 0,\n            validation_url: None,\n            model_quotas: std::collections::HashMap::new(),\n            model_limits: std::collections::HashMap::new(),\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/tests/rate_limit_404_tests.rs",
    "content": "//! 测试 RateLimitTracker::parse_from_error 对 404 状态码的处理逻辑：\n//! - 短时锁定（5s）\n//! - 不累加失败计数\n//! - 与 5xx 锁定时长的差异\n\nuse crate::proxy::rate_limit::{RateLimitReason, RateLimitTracker};\n\n#[test]\nfn test_parse_from_error_404_short_lockout() {\n    let tracker = RateLimitTracker::new();\n    let backoff_steps = vec![60, 300, 1800, 7200];\n\n    let info = tracker.parse_from_error(\"acc_404\", 404, None, \"Not Found\", None, &backoff_steps);\n    assert!(info.is_some(), \"404 should return Some(RateLimitInfo)\");\n    let info = info.unwrap();\n    assert_eq!(info.retry_after_sec, 5, \"404 should lock out for 5 seconds\");\n    assert_eq!(info.reason, RateLimitReason::ServerError, \"404 reason should be ServerError\");\n}\n\n#[test]\nfn test_404_does_not_accumulate_failure_count() {\n    let tracker = RateLimitTracker::new();\n    let backoff_steps = vec![60, 300, 1800, 7200];\n\n    // 连续多次 404，锁定时间应始终为 5s（不像 429 QuotaExhausted 那样递增）\n    for i in 1..=5 {\n        // 清除上一次的限流记录，模拟轮换后再次遇到 404\n        tracker.clear(\"acc_404_repeat\");\n        let info = tracker.parse_from_error(\n            \"acc_404_repeat\", 404, None, \"Not Found\", None, &backoff_steps,\n        );\n        assert!(info.is_some(), \"404 attempt {} should return Some\", i);\n        assert_eq!(\n            info.unwrap().retry_after_sec, 5,\n            \"404 attempt {} should still lock for 5s, not escalate\", i\n        );\n    }\n}\n\n#[test]\nfn test_404_vs_5xx_lockout_duration() {\n    let tracker = RateLimitTracker::new();\n    let backoff_steps = vec![60, 300, 1800, 7200];\n\n    // 404 → 5s lockout\n    let info_404 = tracker.parse_from_error(\n        \"acc_cmp_404\", 404, None, \"Not Found\", None, &backoff_steps,\n    );\n    assert_eq!(info_404.unwrap().retry_after_sec, 5, \"404 should lock for 5s\");\n\n    // 503 → 8s lockout\n    let info_503 = tracker.parse_from_error(\n        \"acc_cmp_503\", 503, None, \"Service Unavailable\", None, &backoff_steps,\n    );\n    assert_eq!(info_503.unwrap().retry_after_sec, 8, \"503 should lock for 8s\");\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/tests/retry_strategy_tests.rs",
    "content": "//! 测试 determine_retry_strategy 和 should_rotate_account 的所有分支，\n//! 重点覆盖 404 重试与账号轮换逻辑。\n\nuse std::time::Duration;\nuse crate::proxy::handlers::common::{determine_retry_strategy, should_rotate_account, RetryStrategy};\n\n// ===== determine_retry_strategy =====\n\n#[test]\nfn test_retry_strategy_404() {\n    let strategy = determine_retry_strategy(404, \"\", false);\n    match strategy {\n        RetryStrategy::FixedDelay(d) => assert_eq!(d, Duration::from_millis(300)),\n        other => panic!(\"Expected FixedDelay(300ms), got {:?}\", other),\n    }\n}\n\n#[test]\nfn test_retry_strategy_429_no_delay() {\n    let strategy = determine_retry_strategy(429, \"rate limited\", false);\n    assert!(\n        matches!(strategy, RetryStrategy::LinearBackoff { base_ms: 5000 }),\n        \"Expected LinearBackoff {{ base_ms: 5000 }}, got {:?}\",\n        strategy\n    );\n}\n\n#[test]\nfn test_retry_strategy_503() {\n    let strategy = determine_retry_strategy(503, \"\", false);\n    assert!(\n        matches!(strategy, RetryStrategy::ExponentialBackoff { base_ms: 10000, max_ms: 60000 }),\n        \"Expected ExponentialBackoff {{ base_ms: 10000, max_ms: 60000 }}, got {:?}\",\n        strategy\n    );\n}\n\n#[test]\nfn test_retry_strategy_529() {\n    let strategy = determine_retry_strategy(529, \"\", false);\n    assert!(\n        matches!(strategy, RetryStrategy::ExponentialBackoff { base_ms: 10000, max_ms: 60000 }),\n        \"Expected ExponentialBackoff {{ base_ms: 10000, max_ms: 60000 }}, got {:?}\",\n        strategy\n    );\n}\n\n#[test]\nfn test_retry_strategy_500() {\n    let strategy = determine_retry_strategy(500, \"\", false);\n    assert!(\n        matches!(strategy, RetryStrategy::LinearBackoff { base_ms: 3000 }),\n        \"Expected LinearBackoff {{ base_ms: 3000 }}, got {:?}\",\n        strategy\n    );\n}\n\n#[test]\nfn test_retry_strategy_401_403() {\n    for status in [401, 403] {\n        let strategy = determine_retry_strategy(status, \"\", false);\n        match strategy {\n            RetryStrategy::FixedDelay(d) => assert_eq!(d, Duration::from_millis(200)),\n            other => panic!(\"Expected FixedDelay(200ms) for {}, got {:?}\", status, other),\n        }\n    }\n}\n\n#[test]\nfn test_retry_strategy_other() {\n    for status in [200, 201, 301, 418, 502] {\n        let strategy = determine_retry_strategy(status, \"\", false);\n        assert!(\n            matches!(strategy, RetryStrategy::NoRetry),\n            \"Expected NoRetry for {}, got {:?}\",\n            status,\n            strategy\n        );\n    }\n}\n\n#[test]\nfn test_retry_strategy_400_thinking_signature() {\n    let signatures = [\n        \"Invalid `signature` for thinking\",\n        \"Error with thinking.signature\",\n        \"thinking.thinking block failed\",\n        \"Corrupted thought signature detected\",\n    ];\n    for sig in signatures {\n        let strategy = determine_retry_strategy(400, sig, false);\n        match strategy {\n            RetryStrategy::FixedDelay(d) => assert_eq!(d, Duration::from_millis(200)),\n            other => panic!(\n                \"Expected FixedDelay(200ms) for 400 + '{}', got {:?}\",\n                sig, other\n            ),\n        }\n    }\n}\n\n#[test]\nfn test_retry_strategy_400_no_signature() {\n    let strategy = determine_retry_strategy(400, \"bad request\", false);\n    assert!(\n        matches!(strategy, RetryStrategy::NoRetry),\n        \"Expected NoRetry for 400 without signature, got {:?}\",\n        strategy\n    );\n}\n\n// ===== should_rotate_account =====\n\n#[test]\nfn test_rotate_account_true_cases() {\n    for status in [429, 401, 403, 404, 500] {\n        assert!(\n            should_rotate_account(status),\n            \"Expected should_rotate_account({}) == true\",\n            status\n        );\n    }\n}\n\n#[test]\nfn test_rotate_account_false_cases() {\n    for status in [400, 503, 529, 200, 502] {\n        assert!(\n            !should_rotate_account(status),\n            \"Expected should_rotate_account({}) == false\",\n            status\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/tests/security_integration_tests.rs",
    "content": "//! IP Security Integration Tests\n//! IP 安全功能的集成测试\n//! \n//! 这些测试需要启动完整的代理服务器来验证端到端的功能\n\n#[cfg(test)]\nmod integration_tests {\n    use crate::modules::security_db::{\n        self, init_db, add_to_blacklist, remove_from_blacklist,\n        add_to_whitelist, remove_from_whitelist, get_blacklist, get_whitelist,\n    };\n    use std::time::Duration;\n\n    /// 辅助函数：清理测试环境\n    fn cleanup_test_data() {\n        if let Ok(entries) = get_blacklist() {\n            for entry in entries {\n                let _ = remove_from_blacklist(&entry.id);\n            }\n        }\n        if let Ok(entries) = get_whitelist() {\n            for entry in entries {\n                let _ = remove_from_whitelist(&entry.id);\n            }\n        }\n    }\n\n    // ============================================================================\n    // 集成测试场景 1：黑名单阻止请求\n    // ============================================================================\n    \n    /// 测试场景：当 IP 在黑名单中时，请求应该被拒绝\n    /// \n    /// 预期行为：\n    /// 1. 添加 IP 到黑名单\n    /// 2. 该 IP 发起的请求返回 403 Forbidden\n    /// 3. 响应体包含封禁原因\n    #[test]\n    fn test_scenario_blacklist_blocks_request() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加测试 IP 到黑名单\n        let entry = add_to_blacklist(\n            \"192.168.100.100\",\n            Some(\"Integration test - malicious activity\"),\n            None,\n            \"integration_test\",\n        );\n        assert!(entry.is_ok(), \"Should add IP to blacklist\");\n\n        // 验证黑名单条目存在\n        let blacklist = get_blacklist().unwrap();\n        let found = blacklist.iter().any(|e| e.ip_pattern == \"192.168.100.100\");\n        assert!(found, \"IP should be in blacklist\");\n\n        // 实际的 HTTP 请求测试需要启动服务器\n        // 这里验证数据层正确性\n        let is_blocked = security_db::is_ip_in_blacklist(\"192.168.100.100\").unwrap();\n        assert!(is_blocked, \"IP should be blocked\");\n\n        cleanup_test_data();\n    }\n\n    // ============================================================================\n    // 集成测试场景 2：白名单优先模式\n    // ============================================================================\n    \n    /// 测试场景：白名单优先模式下，白名单 IP 跳过黑名单检查\n    /// \n    /// 预期行为：\n    /// 1. IP 同时存在于黑名单和白名单\n    /// 2. 启用 whitelist_priority 模式\n    /// 3. 请求应该被允许（白名单优先）\n    #[test]\n    fn test_scenario_whitelist_priority() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加 IP 到黑名单\n        let _ = add_to_blacklist(\n            \"10.0.0.50\",\n            Some(\"Should be overridden by whitelist\"),\n            None,\n            \"test\",\n        );\n\n        // 添加相同 IP 到白名单\n        let _ = add_to_whitelist(\n            \"10.0.0.50\",\n            Some(\"Trusted - override blacklist\"),\n        );\n\n        // 验证两个列表都包含该 IP\n        assert!(security_db::is_ip_in_blacklist(\"10.0.0.50\").unwrap());\n        assert!(security_db::is_ip_in_whitelist(\"10.0.0.50\").unwrap());\n\n        // 在实际中间件中，whitelist_priority=true 时，会先检查白名单\n        // 如果在白名单中，则跳过黑名单检查\n        // 这里只验证数据正确性，中间件逻辑由 ip_filter.rs 保证\n\n        cleanup_test_data();\n    }\n\n    // ============================================================================\n    // 集成测试场景 3：临时封禁与过期\n    // ============================================================================\n    \n    /// 测试场景：临时封禁在过期后自动解除\n    /// \n    /// 预期行为：\n    /// 1. 添加临时封禁（已过期）\n    /// 2. 查询时自动清理过期条目\n    /// 3. 请求应该被允许\n    #[test]\n    fn test_scenario_temporary_ban_expiration() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 获取当前时间戳\n        let now = std::time::SystemTime::now()\n            .duration_since(std::time::UNIX_EPOCH)\n            .unwrap()\n            .as_secs() as i64;\n\n        // 添加已过期的临时封禁\n        let _ = add_to_blacklist(\n            \"expired.ban.test\",\n            Some(\"Temporary ban - should be expired\"),\n            Some(now - 60), // 1分钟前过期\n            \"test\",\n        );\n\n        // 查询时应该触发过期清理\n        let is_blocked = security_db::is_ip_in_blacklist(\"expired.ban.test\").unwrap();\n        assert!(!is_blocked, \"Expired ban should not block\");\n\n        cleanup_test_data();\n    }\n\n    // ============================================================================\n    // 集成测试场景 4：CIDR 范围封禁\n    // ============================================================================\n    \n    /// 测试场景：CIDR 范围封禁覆盖整个子网\n    /// \n    /// 预期行为：\n    /// 1. 封禁 192.168.1.0/24\n    /// 2. 192.168.1.x 的所有请求被拒绝\n    /// 3. 192.168.2.x 的请求正常通过\n    #[test]\n    fn test_scenario_cidr_subnet_blocking() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 封禁整个子网\n        let _ = add_to_blacklist(\n            \"192.168.1.0/24\",\n            Some(\"Entire subnet blocked\"),\n            None,\n            \"test\",\n        );\n\n        // 验证子网内的 IP 被阻止\n        for last_octet in [1, 50, 100, 200, 254] {\n            let ip = format!(\"192.168.1.{}\", last_octet);\n            let is_blocked = security_db::is_ip_in_blacklist(&ip).unwrap();\n            assert!(is_blocked, \"IP {} should be blocked by CIDR\", ip);\n        }\n\n        // 验证子网外的 IP 不被阻止\n        for last_octet in [1, 50, 100] {\n            let ip = format!(\"192.168.2.{}\", last_octet);\n            let is_blocked = security_db::is_ip_in_blacklist(&ip).unwrap();\n            assert!(!is_blocked, \"IP {} should NOT be blocked\", ip);\n        }\n\n        cleanup_test_data();\n    }\n\n    // ============================================================================\n    // 集成测试场景 5：封禁消息详情\n    // ============================================================================\n    \n    /// 测试场景：封禁响应包含详细信息\n    /// \n    /// 预期行为：\n    /// 1. 添加带原因的封禁\n    /// 2. 请求被拒绝时，响应包含：\n    ///    - 封禁原因\n    ///    - 是否为临时/永久封禁\n    ///    - 剩余封禁时间（如果是临时）\n    #[test]\n    fn test_scenario_ban_message_details() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        let now = std::time::SystemTime::now()\n            .duration_since(std::time::UNIX_EPOCH)\n            .unwrap()\n            .as_secs() as i64;\n\n        // 添加临时封禁（2小时后过期）\n        let _ = add_to_blacklist(\n            \"temp.ban.message\",\n            Some(\"Rate limit exceeded\"),\n            Some(now + 7200), // 2小时后\n            \"rate_limiter\",\n        );\n\n        // 获取封禁详情\n        let entry = security_db::get_blacklist_entry_for_ip(\"temp.ban.message\")\n            .unwrap()\n            .unwrap();\n\n        assert_eq!(entry.reason.as_deref(), Some(\"Rate limit exceeded\"));\n        assert!(entry.expires_at.is_some());\n        \n        let remaining = entry.expires_at.unwrap() - now;\n        assert!(remaining > 0 && remaining <= 7200, \"Should have ~2h remaining\");\n\n        cleanup_test_data();\n    }\n\n    // ============================================================================\n    // 集成测试场景 6：访问日志记录\n    // ============================================================================\n    \n    /// 测试场景：被阻止的请求记录到日志\n    /// \n    /// 预期行为：\n    /// 1. 黑名单 IP 发起请求\n    /// 2. 请求被拒绝\n    /// 3. 访问日志记录：IP、时间、状态(403)、封禁原因\n    #[test]\n    fn test_scenario_blocked_request_logging() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 模拟保存被阻止的访问日志\n        let log = security_db::IpAccessLog {\n            id: uuid::Uuid::new_v4().to_string(),\n            client_ip: \"blocked.request.test\".to_string(),\n            timestamp: std::time::SystemTime::now()\n                .duration_since(std::time::UNIX_EPOCH)\n                .unwrap()\n                .as_secs() as i64,\n            method: Some(\"POST\".to_string()),\n            path: Some(\"/v1/messages\".to_string()),\n            user_agent: Some(\"TestClient/1.0\".to_string()),\n            status: Some(403),\n            duration: Some(0),\n            api_key_hash: None,\n            blocked: true,\n            block_reason: Some(\"IP in blacklist\".to_string()),\n            username: None,\n        };\n\n        let save_result = security_db::save_ip_access_log(&log);\n        assert!(save_result.is_ok());\n\n        // 验证日志可以检索\n        let logs = security_db::get_ip_access_logs(10, 0, None, true).unwrap();\n        let found = logs.iter().any(|l| l.client_ip == \"blocked.request.test\");\n        assert!(found, \"Blocked request should be logged\");\n\n        let _ = security_db::clear_ip_access_logs();\n    }\n\n    // ============================================================================\n    // 集成测试场景 7：不影响正常请求性能\n    // ============================================================================\n    \n    /// 测试场景：安全检查不显著影响正常请求性能\n    /// \n    /// 预期行为：\n    /// 1. 黑名单/白名单检查时间 < 5ms\n    /// 2. 与没有安全检查的基线相比，延迟增加 < 10ms\n    #[test]\n    fn test_scenario_performance_impact() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加一些黑名单条目\n        for i in 0..50 {\n            let _ = add_to_blacklist(&format!(\"perf.test.{}\", i), None, None, \"test\");\n        }\n\n        // 添加一些 CIDR 规则\n        for i in 0..10 {\n            let _ = add_to_blacklist(&format!(\"172.{}.0.0/16\", i), None, None, \"test\");\n        }\n\n        // 测试查找性能\n        let start = std::time::Instant::now();\n        let iterations = 100;\n\n        for _ in 0..iterations {\n            // 模拟正常请求的安全检查\n            let _ = security_db::is_ip_in_whitelist(\"10.0.0.1\");\n            let _ = security_db::is_ip_in_blacklist(\"10.0.0.1\");\n        }\n\n        let duration = start.elapsed();\n        let avg_per_check = duration / (iterations * 2);\n\n        println!(\"Average security check time: {:?}\", avg_per_check);\n        \n        // 断言：平均每次检查应该在 5ms 以内\n        assert!(\n            avg_per_check < Duration::from_millis(5),\n            \"Security check should be fast\"\n        );\n\n        cleanup_test_data();\n    }\n\n    // ============================================================================\n    // 集成测试场景 8：数据持久化\n    // ============================================================================\n    \n    /// 测试场景：黑名单/白名单数据持久化\n    /// \n    /// 预期行为：\n    /// 1. 添加数据后重新初始化数据库连接\n    /// 2. 数据仍然存在\n    #[test]\n    fn test_scenario_data_persistence() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加数据\n        let _ = add_to_blacklist(\"persist.test.ip\", Some(\"Persistence test\"), None, \"test\");\n        let _ = add_to_whitelist(\"persist.white.ip\", Some(\"Persistence test\"));\n\n        // 重新初始化（实际上只是验证数据仍然可读）\n        let _ = init_db();\n\n        // 验证数据仍然存在\n        assert!(security_db::is_ip_in_blacklist(\"persist.test.ip\").unwrap());\n        assert!(security_db::is_ip_in_whitelist(\"persist.white.ip\").unwrap());\n\n        cleanup_test_data();\n    }\n}\n\n// ============================================================================\n// 压力测试\n// ============================================================================\n\n#[cfg(test)]\nmod stress_tests {\n    use crate::modules::security_db::{\n        init_db, add_to_blacklist, remove_from_blacklist,\n        is_ip_in_blacklist, get_blacklist, save_ip_access_log,\n        IpAccessLog, clear_ip_access_logs,\n    };\n    use std::thread;\n    use std::time::{Duration, Instant};\n\n    /// 辅助函数：清理测试环境\n    fn cleanup_test_data() {\n        if let Ok(entries) = get_blacklist() {\n            for entry in entries {\n                let _ = remove_from_blacklist(&entry.id);\n            }\n        }\n        let _ = clear_ip_access_logs();\n    }\n\n    /// 压力测试：大量黑名单条目\n    #[test]\n    fn stress_test_large_blacklist() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        let count = 500;\n\n        // 批量添加\n        let start = Instant::now();\n        for i in 0..count {\n            let _ = add_to_blacklist(&format!(\"stress.{}.{}.{}.{}\", i/256, (i/16)%16, i%16, i), None, None, \"stress\");\n        }\n        let add_duration = start.elapsed();\n        println!(\"Added {} entries in {:?}\", count, add_duration);\n\n        // 随机查找测试\n        let start = Instant::now();\n        for i in 0..100 {\n            let _ = is_ip_in_blacklist(&format!(\"stress.{}.{}.{}.{}\", i/256, (i/16)%16, i%16, i));\n        }\n        let lookup_duration = start.elapsed();\n        println!(\"100 lookups in large blacklist took {:?}\", lookup_duration);\n\n        // 验证性能合理\n        assert!(\n            lookup_duration < Duration::from_secs(1),\n            \"Lookups should be reasonably fast even with large blacklist\"\n        );\n\n        cleanup_test_data();\n    }\n\n    /// 压力测试：大量访问日志\n    #[test]\n    fn stress_test_access_logging() {\n        let _ = init_db();\n        let _ = clear_ip_access_logs();\n\n        let count = 1000;\n        let now = std::time::SystemTime::now()\n            .duration_since(std::time::UNIX_EPOCH)\n            .unwrap()\n            .as_secs() as i64;\n\n        // 批量写入日志\n        let start = Instant::now();\n        for i in 0..count {\n            let log = IpAccessLog {\n                id: uuid::Uuid::new_v4().to_string(),\n                client_ip: format!(\"log.stress.{}\", i % 100),\n                timestamp: now,\n                method: Some(\"POST\".to_string()),\n                path: Some(\"/v1/messages\".to_string()),\n                user_agent: Some(\"StressTest/1.0\".to_string()),\n                status: Some(200),\n                duration: Some(100),\n                api_key_hash: Some(\"hash\".to_string()),\n                blocked: false,\n                block_reason: None,\n                username: None,\n            };\n            let _ = save_ip_access_log(&log);\n        }\n        let write_duration = start.elapsed();\n        println!(\"Wrote {} access logs in {:?}\", count, write_duration);\n\n        // 验证写入性能合理\n        assert!(\n            write_duration < Duration::from_secs(10),\n            \"Access log writing should be reasonably fast\"\n        );\n\n        let _ = clear_ip_access_logs();\n    }\n\n    /// 压力测试：并发操作\n    #[test]\n    fn stress_test_concurrent_operations() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        let thread_count = 5;\n        let ops_per_thread = 20;\n\n        let handles: Vec<_> = (0..thread_count)\n            .map(|t| {\n                thread::spawn(move || {\n                    for i in 0..ops_per_thread {\n                        // 每个线程添加-查询-删除\n                        let ip = format!(\"concurrent.{}.{}\", t, i);\n                        if let Ok(entry) = add_to_blacklist(&ip, None, None, \"concurrent\") {\n                            let _ = is_ip_in_blacklist(&ip);\n                            let _ = remove_from_blacklist(&entry.id);\n                        }\n                    }\n                })\n            })\n            .collect();\n\n        // 等待所有线程完成\n        for handle in handles {\n            handle.join().expect(\"Thread should not panic\");\n        }\n\n        // 验证没有遗留数据\n        let remaining = get_blacklist().unwrap();\n        let concurrent_remaining: Vec<_> = remaining\n            .iter()\n            .filter(|e| e.ip_pattern.starts_with(\"concurrent.\"))\n            .collect();\n        \n        assert!(\n            concurrent_remaining.is_empty(),\n            \"All concurrent test data should be cleaned up\"\n        );\n\n        cleanup_test_data();\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/tests/security_ip_tests.rs",
    "content": "//! IP Security Module Tests\n//! IP 安全监控功能的综合测试套件\n//! \n//! 测试目标:\n//! 1. 验证 IP 黑/白名单功能的正确性\n//! 2. 验证 CIDR 匹配逻辑\n//! 3. 验证过期时间处理\n//! 4. 验证不影响主流程性能\n//! 5. 验证数据库操作的原子性和一致性\n\n#[cfg(test)]\nmod security_db_tests {\n    use crate::modules::security_db::{\n        self, IpAccessLog, IpBlacklistEntry, IpWhitelistEntry,\n        init_db, add_to_blacklist, remove_from_blacklist, get_blacklist,\n        is_ip_in_blacklist, get_blacklist_entry_for_ip,\n        add_to_whitelist, remove_from_whitelist, get_whitelist,\n        is_ip_in_whitelist, save_ip_access_log, get_ip_access_logs,\n        get_ip_stats, cleanup_old_ip_logs, clear_ip_access_logs,\n    };\n    use std::time::{SystemTime, UNIX_EPOCH};\n\n    /// 辅助函数：获取当前时间戳\n    fn now_timestamp() -> i64 {\n        SystemTime::now()\n            .duration_since(UNIX_EPOCH)\n            .unwrap()\n            .as_secs() as i64\n    }\n\n    /// 辅助函数：清理测试环境\n    fn cleanup_test_data() {\n        // 清理黑名单\n        if let Ok(entries) = get_blacklist() {\n            for entry in entries {\n                let _ = remove_from_blacklist(&entry.id);\n            }\n        }\n        // 清理白名单\n        if let Ok(entries) = get_whitelist() {\n            for entry in entries {\n                let _ = remove_from_whitelist(&entry.id);\n            }\n        }\n        // 清理访问日志\n        let _ = clear_ip_access_logs();\n    }\n\n    // ============================================================================\n    // 测试类别 1: 数据库初始化\n    // ============================================================================\n    \n    #[test]\n    fn test_db_initialization() {\n        // 验证数据库初始化不会 panic\n        let result = init_db();\n        assert!(result.is_ok(), \"Database initialization should succeed: {:?}\", result.err());\n    }\n\n    #[test]\n    fn test_db_multiple_initializations() {\n        // 验证多次初始化不会出错 (幂等性)\n        for _ in 0..3 {\n            let result = init_db();\n            assert!(result.is_ok(), \"Multiple DB initializations should be idempotent\");\n        }\n    }\n\n    // ============================================================================\n    // 测试类别 2: IP 黑名单基本操作\n    // ============================================================================\n\n    #[test]\n    fn test_blacklist_add_and_check() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加 IP 到黑名单\n        let result = add_to_blacklist(\"192.168.1.100\", Some(\"Test block\"), None, \"test\");\n        assert!(result.is_ok(), \"Should add IP to blacklist: {:?}\", result.err());\n\n        // 验证 IP 在黑名单中\n        let is_blocked = is_ip_in_blacklist(\"192.168.1.100\");\n        assert!(is_blocked.is_ok());\n        assert!(is_blocked.unwrap(), \"IP should be in blacklist\");\n\n        // 验证其他 IP 不在黑名单中\n        let is_other_blocked = is_ip_in_blacklist(\"192.168.1.101\");\n        assert!(is_other_blocked.is_ok());\n        assert!(!is_other_blocked.unwrap(), \"Other IP should not be in blacklist\");\n\n        cleanup_test_data();\n    }\n\n    #[test]\n    fn test_blacklist_remove() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加 IP\n        let entry = add_to_blacklist(\"10.0.0.5\", Some(\"Temp block\"), None, \"test\").unwrap();\n        \n        // 验证存在\n        assert!(is_ip_in_blacklist(\"10.0.0.5\").unwrap());\n\n        // 移除\n        let remove_result = remove_from_blacklist(&entry.id);\n        assert!(remove_result.is_ok());\n\n        // 验证已移除\n        assert!(!is_ip_in_blacklist(\"10.0.0.5\").unwrap());\n\n        cleanup_test_data();\n    }\n\n    #[test]\n    fn test_blacklist_get_entry_details() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加带有详细信息的条目\n        let _ = add_to_blacklist(\n            \"172.16.0.50\",\n            Some(\"Abuse detected\"),\n            Some(now_timestamp() + 3600), // 1小时后过期\n            \"admin\",\n        );\n\n        // 获取条目详情\n        let entry_result = get_blacklist_entry_for_ip(\"172.16.0.50\");\n        assert!(entry_result.is_ok());\n        \n        let entry = entry_result.unwrap();\n        assert!(entry.is_some());\n        \n        let entry = entry.unwrap();\n        assert_eq!(entry.ip_pattern, \"172.16.0.50\");\n        assert_eq!(entry.reason.as_deref(), Some(\"Abuse detected\"));\n        assert_eq!(entry.created_by, \"admin\");\n        assert!(entry.expires_at.is_some());\n\n        cleanup_test_data();\n    }\n\n    // ============================================================================\n    // 测试类别 3: CIDR 匹配\n    // ============================================================================\n\n    #[test]\n    fn test_cidr_matching_basic() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加 CIDR 范围到黑名单\n        let _ = add_to_blacklist(\"192.168.1.0/24\", Some(\"Block subnet\"), None, \"test\");\n\n        // 验证该子网内的 IP 都被阻止\n        assert!(is_ip_in_blacklist(\"192.168.1.1\").unwrap(), \"192.168.1.1 should match /24\");\n        assert!(is_ip_in_blacklist(\"192.168.1.100\").unwrap(), \"192.168.1.100 should match /24\");\n        assert!(is_ip_in_blacklist(\"192.168.1.254\").unwrap(), \"192.168.1.254 should match /24\");\n\n        // 验证子网外的 IP 不被阻止\n        assert!(!is_ip_in_blacklist(\"192.168.2.1\").unwrap(), \"192.168.2.1 should not match\");\n        assert!(!is_ip_in_blacklist(\"10.0.0.1\").unwrap(), \"10.0.0.1 should not match\");\n\n        cleanup_test_data();\n    }\n\n    #[test]\n    fn test_cidr_matching_various_masks() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 测试 /16 掩码\n        let _ = add_to_blacklist(\"10.10.0.0/16\", Some(\"Block /16\"), None, \"test\");\n        \n        assert!(is_ip_in_blacklist(\"10.10.0.1\").unwrap(), \"Should match /16\");\n        assert!(is_ip_in_blacklist(\"10.10.255.255\").unwrap(), \"Should match /16\");\n        assert!(!is_ip_in_blacklist(\"10.11.0.1\").unwrap(), \"Should not match /16\");\n\n        cleanup_test_data();\n\n        // 测试 /32 掩码 (单个 IP)\n        let _ = add_to_blacklist(\"8.8.8.8/32\", Some(\"Block single\"), None, \"test\");\n        \n        assert!(is_ip_in_blacklist(\"8.8.8.8\").unwrap(), \"Should match /32\");\n        assert!(!is_ip_in_blacklist(\"8.8.8.9\").unwrap(), \"Should not match /32\");\n\n        cleanup_test_data();\n    }\n\n    #[test]\n    fn test_cidr_edge_cases() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 测试 /0 (所有 IP) - 边界情况\n        let _ = add_to_blacklist(\"0.0.0.0/0\", Some(\"Block all\"), None, \"test\");\n        \n        assert!(is_ip_in_blacklist(\"1.2.3.4\").unwrap(), \"Everything should match /0\");\n        assert!(is_ip_in_blacklist(\"255.255.255.255\").unwrap(), \"Everything should match /0\");\n\n        cleanup_test_data();\n\n        // 测试 /8 掩码\n        let _ = add_to_blacklist(\"10.0.0.0/8\", Some(\"Block /8\"), None, \"test\");\n        \n        assert!(is_ip_in_blacklist(\"10.255.255.255\").unwrap(), \"Should match /8\");\n        assert!(!is_ip_in_blacklist(\"11.0.0.0\").unwrap(), \"Should not match /8\");\n\n        cleanup_test_data();\n    }\n\n    // ============================================================================\n    // 测试类别 4: 过期时间处理\n    // ============================================================================\n\n    #[test]\n    fn test_blacklist_expiration() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加一个已过期的条目\n        let _ = add_to_blacklist(\n            \"expired.test.ip\",\n            Some(\"Already expired\"),\n            Some(now_timestamp() - 60), // 1分钟前过期\n            \"test\",\n        );\n\n        // 过期条目应该被自动清理\n        let is_blocked = is_ip_in_blacklist(\"expired.test.ip\");\n        // 注意：取决于实现，过期条目可能在查询时被清理\n        // 根据 security_db.rs 的实现，get_blacklist_entry_for_ip 会先清理过期条目\n        assert!(!is_blocked.unwrap(), \"Expired entry should be cleaned up\");\n\n        cleanup_test_data();\n    }\n\n    #[test]\n    fn test_blacklist_not_yet_expired() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加一个未过期的条目\n        let _ = add_to_blacklist(\n            \"not.expired.ip\",\n            Some(\"Will expire later\"),\n            Some(now_timestamp() + 3600), // 1小时后过期\n            \"test\",\n        );\n\n        // 未过期条目应该仍然生效\n        assert!(is_ip_in_blacklist(\"not.expired.ip\").unwrap());\n\n        cleanup_test_data();\n    }\n\n    #[test]\n    fn test_permanent_blacklist() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加永久封禁 (无过期时间)\n        let _ = add_to_blacklist(\n            \"permanent.block.ip\",\n            Some(\"Permanent ban\"),\n            None, // 无过期时间\n            \"test\",\n        );\n\n        // 永久封禁应该始终生效\n        assert!(is_ip_in_blacklist(\"permanent.block.ip\").unwrap());\n\n        cleanup_test_data();\n    }\n\n    // ============================================================================\n    // 测试类别 5: IP 白名单\n    // ============================================================================\n\n    #[test]\n    fn test_whitelist_add_and_check() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加 IP 到白名单\n        let result = add_to_whitelist(\"10.0.0.1\", Some(\"Trusted server\"));\n        assert!(result.is_ok());\n\n        // 验证 IP 在白名单中\n        assert!(is_ip_in_whitelist(\"10.0.0.1\").unwrap());\n        assert!(!is_ip_in_whitelist(\"10.0.0.2\").unwrap());\n\n        cleanup_test_data();\n    }\n\n    #[test]\n    fn test_whitelist_cidr() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加 CIDR 范围到白名单\n        let _ = add_to_whitelist(\"192.168.0.0/16\", Some(\"Internal network\"));\n\n        // 验证子网内的 IP 都被允许\n        assert!(is_ip_in_whitelist(\"192.168.1.1\").unwrap());\n        assert!(is_ip_in_whitelist(\"192.168.255.255\").unwrap());\n\n        // 验证子网外的 IP 不在白名单\n        assert!(!is_ip_in_whitelist(\"10.0.0.1\").unwrap());\n\n        cleanup_test_data();\n    }\n\n    // ============================================================================\n    // 测试类别 6: IP 访问日志\n    // ============================================================================\n\n    #[test]\n    fn test_access_log_save_and_retrieve() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 保存访问日志\n        let log = IpAccessLog {\n            id: uuid::Uuid::new_v4().to_string(),\n            client_ip: \"test.log.ip\".to_string(),\n            timestamp: now_timestamp(),\n            method: Some(\"POST\".to_string()),\n            path: Some(\"/v1/messages\".to_string()),\n            user_agent: Some(\"TestClient/1.0\".to_string()),\n            status: Some(200),\n            duration: Some(150),\n            api_key_hash: Some(\"hash123\".to_string()),\n            blocked: false,\n            block_reason: None,\n            username: None,\n        };\n\n        let save_result = save_ip_access_log(&log);\n        assert!(save_result.is_ok(), \"Should save access log: {:?}\", save_result.err());\n\n        // 检索日志\n        let logs = get_ip_access_logs(10, 0, Some(\"test.log.ip\"), false);\n        assert!(logs.is_ok());\n        \n        let logs = logs.unwrap();\n        assert!(!logs.is_empty(), \"Should retrieve saved log\");\n        assert_eq!(logs[0].client_ip, \"test.log.ip\");\n\n        cleanup_test_data();\n    }\n\n    #[test]\n    fn test_access_log_blocked_filter() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 保存正常日志\n        let normal_log = IpAccessLog {\n            id: uuid::Uuid::new_v4().to_string(),\n            client_ip: \"normal.access.ip\".to_string(),\n            timestamp: now_timestamp(),\n            method: Some(\"GET\".to_string()),\n            path: Some(\"/healthz\".to_string()),\n            user_agent: None,\n            status: Some(200),\n            duration: Some(10),\n            api_key_hash: None,\n            blocked: false,\n            block_reason: None,\n            username: None,\n        };\n        let _ = save_ip_access_log(&normal_log);\n\n        // 保存被阻止的日志\n        let blocked_log = IpAccessLog {\n            id: uuid::Uuid::new_v4().to_string(),\n            client_ip: \"blocked.access.ip\".to_string(),\n            timestamp: now_timestamp(),\n            method: Some(\"POST\".to_string()),\n            path: Some(\"/v1/messages\".to_string()),\n            user_agent: None,\n            status: Some(403),\n            duration: Some(0),\n            api_key_hash: None,\n            blocked: true,\n            block_reason: Some(\"IP in blacklist\".to_string()),\n            username: None,\n        };\n        let _ = save_ip_access_log(&blocked_log);\n\n        // 只检索被阻止的日志\n        let blocked_only = get_ip_access_logs(10, 0, None, true).unwrap();\n        assert_eq!(blocked_only.len(), 1);\n        assert_eq!(blocked_only[0].client_ip, \"blocked.access.ip\");\n        assert!(blocked_only[0].blocked);\n\n        cleanup_test_data();\n    }\n\n    // ============================================================================\n    // 测试类别 7: 统计功能\n    // ============================================================================\n\n    #[test]\n    fn test_ip_stats() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加一些测试数据\n        for i in 0..5 {\n            let log = IpAccessLog {\n                id: uuid::Uuid::new_v4().to_string(),\n                client_ip: format!(\"stats.test.{}\", i % 3), // 3 个唯一 IP\n                timestamp: now_timestamp(),\n                method: Some(\"POST\".to_string()),\n                path: Some(\"/v1/messages\".to_string()),\n                user_agent: None,\n                status: Some(200),\n                duration: Some(100),\n                api_key_hash: None,\n                blocked: i == 4, // 最后一个被阻止\n                block_reason: if i == 4 { Some(\"Test\".to_string()) } else { None },\n                username: None,\n            };\n            let _ = save_ip_access_log(&log);\n        }\n\n        // 添加黑名单和白名单条目\n        let _ = add_to_blacklist(\"stats.black.1\", None, None, \"test\");\n        let _ = add_to_blacklist(\"stats.black.2\", None, None, \"test\");\n        let _ = add_to_whitelist(\"stats.white.1\", None);\n\n        // 获取统计\n        let stats = get_ip_stats();\n        assert!(stats.is_ok());\n        \n        let stats = stats.unwrap();\n        assert!(stats.total_requests >= 5, \"Should have at least 5 requests\");\n        assert!(stats.unique_ips >= 3, \"Should have at least 3 unique IPs\");\n        assert!(stats.blocked_count >= 1, \"Should have at least 1 blocked request\");\n        assert_eq!(stats.blacklist_count, 2);\n        assert_eq!(stats.whitelist_count, 1);\n\n        cleanup_test_data();\n    }\n\n    // ============================================================================\n    // 测试类别 8: 清理功能\n    // ============================================================================\n\n    #[test]\n    fn test_cleanup_old_logs() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加一条 \"旧\" 日志 (模拟 2 天前)\n        let old_log = IpAccessLog {\n            id: uuid::Uuid::new_v4().to_string(),\n            client_ip: \"old.log.ip\".to_string(),\n            timestamp: now_timestamp() - (2 * 24 * 3600), // 2 天前\n            method: Some(\"GET\".to_string()),\n            path: Some(\"/old\".to_string()),\n            user_agent: None,\n            status: Some(200),\n            duration: Some(10),\n            api_key_hash: None,\n            blocked: false,\n            block_reason: None,\n            username: None,\n        };\n        let _ = save_ip_access_log(&old_log);\n\n        // 添加一条新日志\n        let new_log = IpAccessLog {\n            id: uuid::Uuid::new_v4().to_string(),\n            client_ip: \"new.log.ip\".to_string(),\n            timestamp: now_timestamp(),\n            method: Some(\"GET\".to_string()),\n            path: Some(\"/new\".to_string()),\n            user_agent: None,\n            status: Some(200),\n            duration: Some(10),\n            api_key_hash: None,\n            blocked: false,\n            block_reason: None,\n            username: None,\n        };\n        let _ = save_ip_access_log(&new_log);\n\n        // 清理 1 天前的日志\n        let deleted = cleanup_old_ip_logs(1);\n        assert!(deleted.is_ok());\n        assert!(deleted.unwrap() >= 1, \"Should delete at least 1 old log\");\n\n        // 验证新日志仍然存在\n        let logs = get_ip_access_logs(10, 0, Some(\"new.log.ip\"), false).unwrap();\n        assert!(!logs.is_empty(), \"New log should still exist\");\n\n        // 验证旧日志已被清理\n        let old_logs = get_ip_access_logs(10, 0, Some(\"old.log.ip\"), false).unwrap();\n        assert!(old_logs.is_empty(), \"Old log should be cleaned up\");\n\n        cleanup_test_data();\n    }\n\n    // ============================================================================\n    // 测试类别 9: 并发安全性\n    // ============================================================================\n\n    #[test]\n    fn test_concurrent_access() {\n        use std::thread;\n        \n        let _ = init_db();\n        cleanup_test_data();\n\n        let handles: Vec<_> = (0..10)\n            .map(|i| {\n                thread::spawn(move || {\n                    // 每个线程添加不同的 IP\n                    let ip = format!(\"concurrent.test.{}\", i);\n                    let _ = add_to_blacklist(&ip, Some(\"Concurrent test\"), None, \"test\");\n                    \n                    // 验证自己添加的 IP\n                    is_ip_in_blacklist(&ip).unwrap_or(false)\n                })\n            })\n            .collect();\n\n        let results: Vec<bool> = handles.into_iter().map(|h| h.join().unwrap()).collect();\n        \n        // 所有线程都应该成功\n        assert!(results.iter().all(|&r| r), \"All concurrent adds should succeed\");\n\n        cleanup_test_data();\n    }\n\n    // ============================================================================\n    // 测试类别 10: 边界情况和错误处理\n    // ============================================================================\n\n    #[test]\n    fn test_duplicate_blacklist_entry() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 第一次添加应该成功\n        let result1 = add_to_blacklist(\"duplicate.test.ip\", Some(\"First\"), None, \"test\");\n        assert!(result1.is_ok());\n\n        // 第二次添加相同 IP 应该失败 (UNIQUE constraint)\n        let result2 = add_to_blacklist(\"duplicate.test.ip\", Some(\"Second\"), None, \"test\");\n        assert!(result2.is_err(), \"Duplicate IP should fail\");\n\n        cleanup_test_data();\n    }\n\n    #[test]\n    fn test_empty_ip_pattern() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 空 IP 模式应该仍然可以添加 (取决于业务需求)\n        // 这里只测试不会 panic\n        let result = add_to_blacklist(\"\", Some(\"Empty IP\"), None, \"test\");\n        // 结果可能成功或失败，但不应该 panic\n        let _ = result;\n\n        cleanup_test_data();\n    }\n\n    #[test]\n    fn test_special_characters_in_reason() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 测试包含特殊字符的原因\n        let reason = \"Test with 'quotes' and \\\"double quotes\\\" and emoji 🚫\";\n        let result = add_to_blacklist(\"special.char.test\", Some(reason), None, \"test\");\n        assert!(result.is_ok());\n\n        let entry = get_blacklist_entry_for_ip(\"special.char.test\").unwrap().unwrap();\n        assert_eq!(entry.reason.as_deref(), Some(reason));\n\n        cleanup_test_data();\n    }\n\n    #[test]\n    fn test_hit_count_increment() {\n        let _ = init_db();\n        cleanup_test_data();\n\n        // 添加一个黑名单条目\n        let _ = add_to_blacklist(\"hit.count.test\", Some(\"Count test\"), None, \"test\");\n\n        // 多次查询应该增加 hit_count\n        for _ in 0..5 {\n            let _ = get_blacklist_entry_for_ip(\"hit.count.test\");\n        }\n\n        // 检查 hit_count\n        let blacklist = get_blacklist().unwrap();\n        let entry = blacklist.iter().find(|e| e.ip_pattern == \"hit.count.test\");\n        assert!(entry.is_some());\n        assert!(entry.unwrap().hit_count >= 5, \"Hit count should be at least 5\");\n\n        cleanup_test_data();\n    }\n}\n\n// ============================================================================\n// IP Filter 中间件测试 (单元测试)\n// ============================================================================\n\n#[cfg(test)]\nmod ip_filter_middleware_tests {\n    // 注意：中间件测试需要模拟 HTTP 请求，这里提供测试框架\n    // 实际的集成测试应该在启动完整服务后进行\n\n    /// 验证 IP 提取逻辑的正确性\n    #[test]\n    fn test_ip_extraction_priority() {\n        // X-Forwarded-For 应该优先于 X-Real-IP\n        // X-Real-IP 应该优先于 ConnectInfo\n        // 这里只验证逻辑概念，实际测试需要构造 HTTP 请求\n        \n        // 场景 1: X-Forwarded-For 有多个 IP，取第一个\n        let xff_header = \"203.0.113.1, 198.51.100.2, 192.0.2.3\";\n        let first_ip = xff_header.split(',').next().unwrap().trim();\n        assert_eq!(first_ip, \"203.0.113.1\");\n\n        // 场景 2: 单个 IP\n        let single_ip = \"10.0.0.1\";\n        let parsed = single_ip.split(',').next().unwrap().trim();\n        assert_eq!(parsed, \"10.0.0.1\");\n    }\n}\n\n// ============================================================================\n// 性能基准测试\n// ============================================================================\n\n#[cfg(test)]\nmod performance_benchmarks {\n    use super::security_db_tests::*;\n    use crate::modules::security_db::{\n        init_db, add_to_blacklist, is_ip_in_blacklist, get_blacklist,\n        clear_ip_access_logs,\n    };\n    use std::time::Instant;\n\n    /// 基准测试：黑名单查找性能\n    #[test]\n    fn benchmark_blacklist_lookup() {\n        let _ = init_db();\n        \n        // 清理并添加 100 个黑名单条目\n        if let Ok(entries) = get_blacklist() {\n            for entry in entries {\n                let _ = crate::modules::security_db::remove_from_blacklist(&entry.id);\n            }\n        }\n\n        for i in 0..100 {\n            let _ = add_to_blacklist(\n                &format!(\"bench.ip.{}\", i),\n                Some(\"Benchmark\"),\n                None,\n                \"test\",\n            );\n        }\n\n        // 执行 1000 次查找\n        let start = Instant::now();\n        for _ in 0..1000 {\n            let _ = is_ip_in_blacklist(\"bench.ip.50\");\n        }\n        let duration = start.elapsed();\n\n        println!(\"1000 blacklist lookups took: {:?}\", duration);\n        println!(\"Average per lookup: {:?}\", duration / 1000);\n\n        // 性能断言：平均查找应该在 1ms 以内\n        assert!(\n            duration.as_millis() < 5000,\n            \"Blacklist lookup should be fast (< 5ms avg)\"\n        );\n\n        // 清理\n        if let Ok(entries) = get_blacklist() {\n            for entry in entries {\n                let _ = crate::modules::security_db::remove_from_blacklist(&entry.id);\n            }\n        }\n    }\n\n    /// 基准测试：CIDR 匹配性能\n    #[test]\n    fn benchmark_cidr_matching() {\n        let _ = init_db();\n\n        // 清理并添加 CIDR 规则\n        if let Ok(entries) = get_blacklist() {\n            for entry in entries {\n                let _ = crate::modules::security_db::remove_from_blacklist(&entry.id);\n            }\n        }\n\n        // 添加 20 个 CIDR 规则\n        for i in 0..20 {\n            let _ = add_to_blacklist(\n                &format!(\"10.{}.0.0/16\", i),\n                Some(\"CIDR Benchmark\"),\n                None,\n                \"test\",\n            );\n        }\n\n        // 测试 CIDR 匹配性能\n        let start = Instant::now();\n        for _ in 0..1000 {\n            // 测试需要遍历 CIDR 的 IP\n            let _ = is_ip_in_blacklist(\"10.5.100.50\");\n        }\n        let duration = start.elapsed();\n\n        println!(\"1000 CIDR matches took: {:?}\", duration);\n        println!(\"Average per match: {:?}\", duration / 1000);\n\n        // 性能断言：CIDR 匹配应该在合理时间内\n        assert!(\n            duration.as_millis() < 5000,\n            \"CIDR matching should be reasonably fast\"\n        );\n\n        // 清理\n        if let Ok(entries) = get_blacklist() {\n            for entry in entries {\n                let _ = crate::modules::security_db::remove_from_blacklist(&entry.id);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/tests/ultra_priority_tests.rs",
    "content": "//! Ultra Priority Tests for High-End Models (Opus 4.6/4.5)\n//!\n//! 这些测试验证高端模型（如 Claude Opus 4.6/4.5）优先使用 Ultra 账号的逻辑。\n//!\n//! ## 背景\n//! 用户的账号池包含大量 Gemini Pro 账号和少量 Ultra 账号。当请求 Claude Opus 4.6 模型时，\n//! 系统按配额优先的策略可能会选择 Pro 账号，但 Pro 账号无法访问 Opus 4.6，导致 API 返回错误。\n//!\n//! ## 解决方案\n//! 当用户请求高端模型时，优先选择 Ultra 账号；只有 Ultra 账号都不可用时才降级到 Pro/Free 账号。\n//!\n//! ## 测试覆盖\n//! - `test_is_ultra_required_model`: 验证模型识别逻辑\n//! - `test_ultra_priority_for_high_end_models`: 验证 Ultra 优先于 Pro（即使 Pro 配额更高）\n//! - `test_ultra_accounts_sorted_by_quota`: 验证同为 Ultra 时按配额排序\n//! - `test_full_sorting_mixed_accounts`: 验证混合账号池的完整排序\n\nuse std::cmp::Ordering;\nuse std::collections::{HashMap, HashSet};\nuse std::path::PathBuf;\n\nuse crate::proxy::token_manager::ProxyToken;\n\n/// 创建测试用的 ProxyToken\nfn create_test_token(\n    email: &str,\n    tier: Option<&str>,\n    health_score: f32,\n    reset_time: Option<i64>,\n    remaining_quota: Option<i32>,\n    supported_models: Vec<&str>,\n) -> ProxyToken {\n    let mut model_quotas = HashMap::new();\n    // 模拟配额：所有支持的模型都给予相同的剩余配额\n    for m in supported_models {\n        model_quotas.insert(m.to_string(), remaining_quota.unwrap_or(100));\n    }\n\n    ProxyToken {\n        account_id: email.to_string(),\n        access_token: \"test_token\".to_string(),\n        refresh_token: \"test_refresh\".to_string(),\n        expires_in: 3600,\n        timestamp: chrono::Utc::now().timestamp() + 3600,\n        email: email.to_string(),\n        account_path: PathBuf::from(\"/tmp/test\"),\n        project_id: None,\n        subscription_tier: tier.map(|s| s.to_string()),\n        remaining_quota,\n        protected_models: HashSet::new(),\n        health_score,\n        reset_time,\n        validation_blocked: false,\n        validation_blocked_until: 0,\n        validation_url: None,\n        model_quotas,\n        model_limits: std::collections::HashMap::new(),\n    }\n}\n\n/// 需要 Ultra 账号的高端模型列表\nconst ULTRA_REQUIRED_MODELS: &[&str] = &[\n    \"claude-opus-4-6\",\n    \"claude-opus-4-5\",\n    \"opus\", // 通配匹配\n];\n\n/// 检查模型是否需要 Ultra 账号\nfn is_ultra_required_model(model: &str) -> bool {\n    let lower = model.to_lowercase();\n    ULTRA_REQUIRED_MODELS.iter().any(|m| lower.contains(m))\n}\n\n/// 测试 is_ultra_required_model 辅助函数\n#[test]\nfn test_is_ultra_required_model() {\n    // 应该识别为高端模型\n    assert!(is_ultra_required_model(\"claude-opus-4-6\"));\n    assert!(is_ultra_required_model(\"claude-opus-4-5\"));\n    assert!(is_ultra_required_model(\"Claude-Opus-4-6\")); // 大小写不敏感\n    assert!(is_ultra_required_model(\"CLAUDE-OPUS-4-5\")); // 大小写不敏感\n    assert!(is_ultra_required_model(\"opus\")); // 通配匹配\n    assert!(is_ultra_required_model(\"opus-4-6-latest\"));\n    assert!(is_ultra_required_model(\"models/claude-opus-4-6\"));\n\n    // 应该识别为普通模型\n    assert!(!is_ultra_required_model(\"claude-sonnet-4-6\"));\n    assert!(!is_ultra_required_model(\"claude-sonnet\"));\n    assert!(!is_ultra_required_model(\"gemini-1.5-flash\"));\n    assert!(!is_ultra_required_model(\"gemini-2.0-pro\"));\n    assert!(!is_ultra_required_model(\"claude-haiku\"));\n}\n\n/// 模拟 token_manager.rs 中的排序逻辑 (更新后：始终 Tier 优先)\nfn compare_tokens_for_model(a: &ProxyToken, b: &ProxyToken, _target_model: &str) -> Ordering {\n    let tier_priority = |tier: &Option<String>| {\n        let t = tier.as_deref().unwrap_or(\"\").to_lowercase();\n        if t.contains(\"ultra\") { 0 }\n        else if t.contains(\"pro\") { 1 }\n        else if t.contains(\"free\") { 2 }\n        else { 3 }\n    };\n\n    // Priority 0: 始终优先订阅等级 (Ultra > Pro > Free)\n    let tier_cmp = tier_priority(&a.subscription_tier)\n        .cmp(&tier_priority(&b.subscription_tier));\n    if tier_cmp != Ordering::Equal {\n        return tier_cmp;\n    }\n\n    // Priority 1: Quota (higher is better)\n    // 注意：这里简化了，直接取 remaining_quota，实际上生产代码取的是 model_quotas.get(target)\n    let quota_a = a.remaining_quota.unwrap_or(0);\n    let quota_b = b.remaining_quota.unwrap_or(0);\n    let quota_cmp = quota_b.cmp(&quota_a);\n    if quota_cmp != Ordering::Equal {\n        return quota_cmp;\n    }\n\n    // Priority 2: Health score\n    let health_cmp = b.health_score.partial_cmp(&a.health_score)\n        .unwrap_or(Ordering::Equal);\n    if health_cmp != Ordering::Equal {\n        return health_cmp;\n    }\n\n    Ordering::Equal\n}\n\n/// 模拟过滤逻辑\nfn filter_tokens_by_capability(tokens: Vec<ProxyToken>, target_model: &str) -> Vec<ProxyToken> {\n    tokens.into_iter()\n        .filter(|t| t.model_quotas.contains_key(target_model))\n        .collect()\n}\n\n/// 测试高端模型排序：Ultra 账号优先于 Pro 账号（即使 Pro 配额更高）\n#[test]\nfn test_ultra_priority_for_high_end_models() {\n    // 创建测试账号：Ultra 低配额 vs Pro 高配额\n    // Ultra 账号支持 Opus 4.6\n    let ultra_low_quota = create_test_token(\"ultra@test.com\", Some(\"ULTRA\"), 1.0, None, Some(20), vec![\"claude-opus-4-6\", \"claude-sonnet-4-6\"]);\n    // Pro 账号不支持 Opus 4.6 (假设)\n    let pro_high_quota = create_test_token(\"pro@test.com\", Some(\"PRO\"), 1.0, None, Some(80), vec![\"claude-sonnet-4-6\"]);\n\n    // 1. 验证过滤逻辑\n    let tokens = vec![ultra_low_quota.clone(), pro_high_quota.clone()];\n    let filtered = filter_tokens_by_capability(tokens, \"claude-opus-4-6\");\n    assert_eq!(filtered.len(), 1, \"Pro account should be filtered out for Opus 4.6\");\n    assert_eq!(filtered[0].email, \"ultra@test.com\");\n\n    // 2. 验证排序逻辑 (针对 Sonnet，两者都支持)\n    // 即使 Pro 配额更高，由于新策略是 \"Ultra First\"，Ultra 仍然排在前面\n    assert_eq!(\n        compare_tokens_for_model(&ultra_low_quota, &pro_high_quota, \"claude-sonnet-4-6\"),\n        Ordering::Less, // Ultra 排在前面\n        \"Sonnet should now prefer Ultra account over Pro (Strict Tier Policy)\"\n    );\n}\n\n#[test]\nfn test_capability_filtering() {\n    // Ultra 账号：有 Opus 4.6\n    let ultra = create_test_token(\"ultra@test.com\", Some(\"ULTRA\"), 1.0, None, Some(100), vec![\"claude-opus-4-6\"]);\n    // Pro 账号：无 Opus 4.6\n    let pro = create_test_token(\"pro@test.com\", Some(\"PRO\"), 1.0, None, Some(100), vec![\"claude-sonnet-3-5\"]);\n    \n    // Future Pro 账号：有 Opus 4.6 (模拟未来可能开放)\n    let future_pro = create_test_token(\"future_pro@test.com\", Some(\"PRO\"), 1.0, None, Some(50), vec![\"claude-opus-4-6\"]);\n\n    let pool = vec![ultra.clone(), pro.clone(), future_pro.clone()];\n\n    // 1. 请求 Opus 4.6\n    let filtered_opus = filter_tokens_by_capability(pool.clone(), \"claude-opus-4-6\");\n    assert_eq!(filtered_opus.len(), 2, \"Should retain Ultra and Future Pro\");\n    // 验证 Pro 被移除\n    assert!(!filtered_opus.iter().any(|t| t.email == \"pro@test.com\"));\n\n    // 2. 排序 filtered_opus: Ultra 应该排在 Future Pro 前面 (Tier Priority)\n    let mut sorted_opus = filtered_opus.clone();\n    sorted_opus.sort_by(|a, b| compare_tokens_for_model(a, b, \"claude-opus-4-6\"));\n    assert_eq!(sorted_opus[0].email, \"ultra@test.com\", \"Ultra should be prioritized over Pro even if Pro has capability\");\n    assert_eq!(sorted_opus[1].email, \"future_pro@test.com\");\n}\n\n/// 测试排序：同为 Ultra 时按配额排序\n#[test]\nfn test_ultra_accounts_sorted_by_quota() {\n    let ultra_high = create_test_token(\"ultra_high@test.com\", Some(\"ULTRA\"), 1.0, None, Some(80), vec![\"claude-opus-4-6\"]);\n    let ultra_low = create_test_token(\"ultra_low@test.com\", Some(\"ULTRA\"), 1.0, None, Some(20), vec![\"claude-opus-4-6\"]);\n\n    // Opus 4.6: 同为 Ultra，高配额优先\n    assert_eq!(\n        compare_tokens_for_model(&ultra_high, &ultra_low, \"claude-opus-4-6\"),\n        Ordering::Less, // ultra_high 排在前面\n        \"Among Ultra accounts, higher quota should come first\"\n    );\n}\n\n/// 测试完整排序场景：混合账号池\n#[test]\nfn test_full_sorting_mixed_accounts() {\n    fn sort_tokens_for_model(tokens: &mut Vec<ProxyToken>, target_model: &str) {\n        tokens.sort_by(|a, b| compare_tokens_for_model(a, b, target_model));\n    }\n\n    // 创建混合账号池 (全部支持所有模型，简化测试)\n    let supported = vec![\"claude-opus-4-6\", \"claude-sonnet-4-6\"];\n    let ultra_high = create_test_token(\"ultra_high@test.com\", Some(\"ULTRA\"), 1.0, None, Some(80), supported.clone());\n    let ultra_low = create_test_token(\"ultra_low@test.com\", Some(\"ULTRA\"), 1.0, None, Some(20), supported.clone());\n    let pro_high = create_test_token(\"pro_high@test.com\", Some(\"PRO\"), 1.0, None, Some(90), supported.clone());\n    let pro_low = create_test_token(\"pro_low@test.com\", Some(\"PRO\"), 1.0, None, Some(30), supported.clone());\n    let free = create_test_token(\"free@test.com\", Some(\"FREE\"), 1.0, None, Some(100), supported.clone());\n\n    // 高端模型 (Opus 4.6) 排序\n    let mut tokens_opus = vec![pro_high.clone(), free.clone(), ultra_low.clone(), pro_low.clone(), ultra_high.clone()];\n    sort_tokens_for_model(&mut tokens_opus, \"claude-opus-4-6\");\n\n    let emails_opus: Vec<&str> = tokens_opus.iter().map(|t| t.email.as_str()).collect();\n    // 期望顺序: Ultra(高配额) > Ultra(低配额) > Pro(高配额) > Pro(低配额) > Free\n    assert_eq!(\n        emails_opus,\n        vec![\"ultra_high@test.com\", \"ultra_low@test.com\", \"pro_high@test.com\", \"pro_low@test.com\", \"free@test.com\"],\n        \"Opus 4.6 should sort Ultra first, then by quota within each tier\"\n    );\n\n    // 普通模型 (Sonnet) 排序\n    let mut tokens_sonnet = vec![pro_high.clone(), free.clone(), ultra_low.clone(), pro_low.clone(), ultra_high.clone()];\n    sort_tokens_for_model(&mut tokens_sonnet, \"claude-sonnet-4-6\");\n\n    let emails_sonnet: Vec<&str> = tokens_sonnet.iter().map(|t| t.email.as_str()).collect();\n    // 期望顺序: Ultra > Pro > Free (严格层级)\n    // Ultra 内按 quota: high > low\n    // Pro 内按 quota: high > low\n    assert_eq!(\n        emails_sonnet,\n        vec![\"ultra_high@test.com\", \"ultra_low@test.com\", \"pro_high@test.com\", \"pro_low@test.com\", \"free@test.com\"],\n        \"Sonnet should now sort Ultra first, then Pro, then Free\"\n    );\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/token_manager.rs",
    "content": "// 移除冗余的顶层导入，因为这些在代码中已由 full path 或局部导入处理\nuse dashmap::DashMap;\nuse std::collections::{HashSet, HashMap};\nuse std::path::PathBuf;\nuse std::sync::atomic::{AtomicUsize, Ordering};\nuse std::sync::Arc;\nuse tokio_util::sync::CancellationToken;\n\nuse crate::proxy::rate_limit::RateLimitTracker;\nuse crate::proxy::sticky_config::StickySessionConfig;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum OnDiskAccountState {\n    Enabled,\n    Disabled,\n    Unknown,\n}\n\n#[derive(Debug, Clone)]\npub struct ProxyToken {\n    pub account_id: String,\n    pub access_token: String,\n    pub refresh_token: String,\n    pub expires_in: i64,\n    pub timestamp: i64,\n    pub email: String,\n    pub account_path: PathBuf, // 账号文件路径，用于更新\n    pub project_id: Option<String>,\n    pub subscription_tier: Option<String>, // \"FREE\" | \"PRO\" | \"ULTRA\"\n    pub remaining_quota: Option<i32>,      // [FIX #563] Remaining quota for priority sorting\n    pub protected_models: HashSet<String>, // [NEW #621]\n    pub health_score: f32,                 // [NEW] 健康分数 (0.0 - 1.0)\n    pub reset_time: Option<i64>,           // [NEW] 配额刷新时间戳（用于排序优化）\n    pub validation_blocked: bool,          // [NEW] Check for validation block (VALIDATION_REQUIRED temporary block)\n    pub validation_blocked_until: i64,     // [NEW] Timestamp until which the account is blocked\n    pub validation_url: Option<String>,    // [NEW] Validation URL (#1522)\n    pub model_quotas: HashMap<String, i32>, // [OPTIMIZATION] In-memory cache for model-specific quotas\n    pub model_limits: HashMap<String, u64>, // [NEW] max_output_tokens per model from quota data\n}\n\npub struct TokenManager {\n    tokens: Arc<DashMap<String, ProxyToken>>, // account_id -> ProxyToken\n    current_index: Arc<AtomicUsize>,\n    last_used_account: Arc<tokio::sync::Mutex<Option<(String, std::time::Instant)>>>,\n    data_dir: PathBuf,\n    rate_limit_tracker: Arc<RateLimitTracker>, // 新增: 限流跟踪器\n    sticky_config: Arc<tokio::sync::RwLock<StickySessionConfig>>, // 新增：调度配置\n    session_accounts: Arc<DashMap<String, String>>, // 新增：会话与账号映射 (SessionID -> AccountID)\n    preferred_account_id: Arc<tokio::sync::RwLock<Option<String>>>, // [FIX #820] 优先使用的账号ID（固定账号模式）\n    health_scores: Arc<DashMap<String, f32>>,                       // account_id -> health_score\n    circuit_breaker_config: Arc<tokio::sync::RwLock<crate::models::CircuitBreakerConfig>>, // [NEW] 熔断配置缓存\n    /// 支持优雅关闭时主动 abort 后台任务\n    auto_cleanup_handle: Arc<tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>>,\n    cancel_token: CancellationToken,\n}\n\nimpl TokenManager {\n    /// 创建新的 TokenManager\n    pub fn new(data_dir: PathBuf) -> Self {\n        Self {\n            tokens: Arc::new(DashMap::new()),\n            current_index: Arc::new(AtomicUsize::new(0)),\n            last_used_account: Arc::new(tokio::sync::Mutex::new(None)),\n            data_dir,\n            rate_limit_tracker: Arc::new(RateLimitTracker::new()),\n            sticky_config: Arc::new(tokio::sync::RwLock::new(StickySessionConfig::default())),\n            session_accounts: Arc::new(DashMap::new()),\n            preferred_account_id: Arc::new(tokio::sync::RwLock::new(None)), // [FIX #820]\n            health_scores: Arc::new(DashMap::new()),\n            circuit_breaker_config: Arc::new(tokio::sync::RwLock::new(\n                crate::models::CircuitBreakerConfig::default(),\n            )),\n            auto_cleanup_handle: Arc::new(tokio::sync::Mutex::new(None)),\n            cancel_token: CancellationToken::new(),\n        }\n    }\n\n    /// 启动限流记录自动清理后台任务（每15秒检查并清除过期记录）\n    pub async fn start_auto_cleanup(&self) {\n        let tracker = self.rate_limit_tracker.clone();\n        let cancel = self.cancel_token.child_token();\n\n        let handle = tokio::spawn(async move {\n            let mut interval = tokio::time::interval(std::time::Duration::from_secs(15));\n            loop {\n                tokio::select! {\n                    _ = cancel.cancelled() => {\n                        tracing::info!(\"Auto-cleanup task received cancel signal\");\n                        break;\n                    }\n                    _ = interval.tick() => {\n                        let cleaned = tracker.cleanup_expired();\n                        if cleaned > 0 {\n                            tracing::info!(\n                                \"Auto-cleanup: Removed {} expired rate limit record(s)\",\n                                cleaned\n                            );\n                        }\n                    }\n                }\n            }\n        });\n\n        // 先 abort 旧任务（防止任务泄漏），再存储新 handle\n        let mut guard = self.auto_cleanup_handle.lock().await;\n        if let Some(old) = guard.take() {\n            old.abort();\n            tracing::warn!(\"Aborted previous auto-cleanup task\");\n        }\n        *guard = Some(handle);\n\n        tracing::info!(\"Rate limit auto-cleanup task started (interval: 15s)\");\n    }\n\n    /// 从主应用账号目录加载所有账号\n    pub async fn load_accounts(&self) -> Result<usize, String> {\n        let accounts_dir = self.data_dir.join(\"accounts\");\n\n        if !accounts_dir.exists() {\n            return Err(format!(\"账号目录不存在: {:?}\", accounts_dir));\n        }\n\n        // Reload should reflect current on-disk state (accounts can be added/removed/disabled).\n        self.tokens.clear();\n        self.current_index.store(0, Ordering::SeqCst);\n        {\n            let mut last_used = self.last_used_account.lock().await;\n            *last_used = None;\n        }\n\n        let entries = std::fs::read_dir(&accounts_dir)\n            .map_err(|e| format!(\"读取账号目录失败: {}\", e))?;\n\n        let mut count = 0;\n\n        for entry in entries {\n            let entry = entry.map_err(|e| format!(\"读取目录项失败: {}\", e))?;\n            let path = entry.path();\n\n            if path.extension().and_then(|s| s.to_str()) != Some(\"json\") {\n                continue;\n            }\n\n            // 尝试加载账号\n            match self.load_single_account(&path).await {\n                Ok(Some(token)) => {\n                    let account_id = token.account_id.clone();\n                    self.tokens.insert(account_id, token);\n                    count += 1;\n                }\n                Ok(None) => {\n                    // 跳过无效账号\n                }\n                Err(e) => {\n                    tracing::debug!(\"加载账号失败 {:?}: {}\", path, e);\n                }\n            }\n        }\n\n        Ok(count)\n    }\n\n    /// 重新加载指定账号（用于配额更新后的实时同步）\n    pub async fn reload_account(&self, account_id: &str) -> Result<(), String> {\n        let path = self\n            .data_dir\n            .join(\"accounts\")\n            .join(format!(\"{}.json\", account_id));\n        if !path.exists() {\n            return Err(format!(\"账号文件不存在: {:?}\", path));\n        }\n\n        match self.load_single_account(&path).await {\n            Ok(Some(token)) => {\n                self.tokens.insert(account_id.to_string(), token);\n                // [NEW] 重新加载账号时自动清除该账号的限流记录\n                self.clear_rate_limit(account_id);\n                Ok(())\n            }\n            Ok(None) => {\n                // [FIX] 账号被禁用或不可用时，从内存池中彻底移除 (Issue #1565)\n                // load_single_account returning None means the account should be skipped in its\n                // current state (disabled / proxy_disabled / quota_protection / validation_blocked...).\n                self.remove_account(account_id);\n                Ok(())\n            }\n            Err(e) => Err(format!(\"同步账号失败: {}\", e)),\n        }\n    }\n\n    /// 重新加载所有账号\n    pub async fn reload_all_accounts(&self) -> Result<usize, String> {\n        let count = self.load_accounts().await?;\n        // [NEW] 重新加载所有账号时自动清除所有限流记录\n        self.clear_all_rate_limits();\n        Ok(count)\n    }\n\n    /// 从内存中彻底移除指定账号及其关联数据 (Issue #1477)\n    pub fn remove_account(&self, account_id: &str) {\n        // ... (省略原有逻辑)\n        if self.tokens.remove(account_id).is_some() {\n            tracing::info!(\"[Proxy] Removed account {} from memory cache\", account_id);\n        }\n        self.health_scores.remove(account_id);\n        self.clear_rate_limit(account_id);\n        self.session_accounts.retain(|_, v| v != account_id);\n        if let Ok(mut preferred) = self.preferred_account_id.try_write() {\n            if preferred.as_deref() == Some(account_id) {\n                *preferred = None;\n                tracing::info!(\"[Proxy] Cleared preferred account status for {}\", account_id);\n            }\n        }\n    }\n\n    /// 根据账号 ID 获取完整的 ProxyToken 对象 (v4.1.29)\n    pub fn get_token_by_id(&self, account_id: &str) -> Option<ProxyToken> {\n        self.tokens.get(account_id).map(|t| t.clone())\n    }\n\n    /// Check if an account has been disabled on disk.\n    ///\n    /// Safety net: avoids selecting a disabled account when the in-memory pool hasn't been\n    /// reloaded yet (e.g. fixed account mode / sticky session).\n    ///\n    /// Note: this is intentionally tolerant to transient read/parse failures (e.g. concurrent\n    /// writes). Failures are reported as `Unknown` so callers can skip without purging the in-memory\n    /// token pool.\n    async fn get_account_state_on_disk(account_path: &std::path::PathBuf) -> OnDiskAccountState {\n        const MAX_RETRIES: usize = 2;\n        const RETRY_DELAY_MS: u64 = 5;\n\n        for attempt in 0..=MAX_RETRIES {\n            let content = match tokio::fs::read_to_string(account_path).await {\n                Ok(c) => c,\n                Err(e) => {\n                    // If the file is gone, the in-memory token is definitely stale.\n                    if e.kind() == std::io::ErrorKind::NotFound {\n                        return OnDiskAccountState::Disabled;\n                    }\n                    if attempt < MAX_RETRIES {\n                        tokio::time::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS)).await;\n                        continue;\n                    }\n                    tracing::debug!(\n                        \"Failed to read account file on disk {:?}: {}\",\n                        account_path,\n                        e\n                    );\n                    return OnDiskAccountState::Unknown;\n                }\n            };\n\n            let account = match serde_json::from_str::<serde_json::Value>(&content) {\n                Ok(v) => v,\n                Err(e) => {\n                    if attempt < MAX_RETRIES {\n                        tokio::time::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS)).await;\n                        continue;\n                    }\n                    tracing::debug!(\n                        \"Failed to parse account JSON on disk {:?}: {}\",\n                        account_path,\n                        e\n                    );\n                    return OnDiskAccountState::Unknown;\n                }\n            };\n\n            let disabled = account\n                .get(\"disabled\")\n                .and_then(|v| v.as_bool())\n                .unwrap_or(false)\n                || account\n                    .get(\"proxy_disabled\")\n                    .and_then(|v| v.as_bool())\n                    .unwrap_or(false)\n                || account\n                    .get(\"quota\")\n                    .and_then(|q| q.get(\"is_forbidden\"))\n                    .and_then(|v| v.as_bool())\n                    .unwrap_or(false);\n\n            return if disabled {\n                OnDiskAccountState::Disabled\n            } else {\n                OnDiskAccountState::Enabled\n            };\n        }\n\n        OnDiskAccountState::Unknown\n    }\n\n    /// 加载单个账号\n    async fn load_single_account(&self, path: &PathBuf) -> Result<Option<ProxyToken>, String> {\n        let content = std::fs::read_to_string(path).map_err(|e| format!(\"读取文件失败: {}\", e))?;\n\n        let mut account: serde_json::Value =\n            serde_json::from_str(&content).map_err(|e| format!(\"解析 JSON 失败: {}\", e))?;\n\n        // [修复 #1344] 先检查账号是否被手动禁用(非配额保护原因)\n        let is_proxy_disabled = account\n            .get(\"proxy_disabled\")\n            .and_then(|v| v.as_bool())\n            .unwrap_or(false);\n\n        let disabled_reason = account\n            .get(\"proxy_disabled_reason\")\n            .and_then(|v| v.as_str())\n            .unwrap_or(\"\");\n\n        if is_proxy_disabled && disabled_reason != \"quota_protection\" {\n            // Account manually disabled\n            tracing::debug!(\n                \"Account skipped due to manual disable: {:?} (email={}, reason={})\",\n                path,\n                account\n                    .get(\"email\")\n                    .and_then(|v| v.as_str())\n                    .unwrap_or(\"<unknown>\"),\n                disabled_reason\n            );\n            return Ok(None);\n        }\n\n        // [NEW] Check for validation block (VALIDATION_REQUIRED temporary block)\n        if account\n            .get(\"validation_blocked\")\n            .and_then(|v| v.as_bool())\n            .unwrap_or(false)\n        {\n            let block_until = account\n                .get(\"validation_blocked_until\")\n                .and_then(|v| v.as_i64())\n                .unwrap_or(0);\n\n            let now = chrono::Utc::now().timestamp();\n\n            if now < block_until {\n                // Still blocked\n                tracing::debug!(\n                    \"Skipping validation-blocked account: {:?} (email={}, blocked until {})\",\n                    path,\n                    account\n                        .get(\"email\")\n                        .and_then(|v| v.as_str())\n                        .unwrap_or(\"<unknown>\"),\n                    chrono::DateTime::from_timestamp(block_until, 0)\n                        .map(|dt| dt.format(\"%H:%M:%S\").to_string())\n                        .unwrap_or_else(|| block_until.to_string())\n                );\n                return Ok(None);\n            } else {\n                // Block expired - clear it\n                account[\"validation_blocked\"] = serde_json::json!(false);\n                account[\"validation_blocked_until\"] = serde_json::json!(0);\n                account[\"validation_blocked_reason\"] = serde_json::Value::Null;\n\n                let updated_json =\n                    serde_json::to_string_pretty(&account).map_err(|e| e.to_string())?;\n                std::fs::write(path, updated_json).map_err(|e| e.to_string())?;\n                tracing::info!(\n                    \"Validation block expired and cleared for account: {}\",\n                    account\n                        .get(\"email\")\n                        .and_then(|v| v.as_str())\n                        .unwrap_or(\"<unknown>\")\n                );\n            }\n        }\n\n        // 最终检查账号主开关\n        if account\n            .get(\"disabled\")\n            .and_then(|v| v.as_bool())\n            .unwrap_or(false)\n        {\n            tracing::debug!(\n                \"Skipping disabled account file: {:?} (email={})\",\n                path,\n                account\n                    .get(\"email\")\n                    .and_then(|v| v.as_str())\n                    .unwrap_or(\"<unknown>\")\n            );\n            return Ok(None);\n        }\n\n        // Safety check: verify state on disk again to handle concurrent mid-parse writes\n        if Self::get_account_state_on_disk(path).await == OnDiskAccountState::Disabled {\n            tracing::debug!(\"Account file {:?} is disabled on disk, skipping.\", path);\n            return Ok(None);\n        }\n\n        // 配额保护检查 - 只处理配额保护逻辑\n        // 这样可以在加载时自动恢复配额已恢复的账号\n        if self.check_and_protect_quota(&mut account, path).await {\n            tracing::debug!(\n                \"Account skipped due to quota protection: {:?} (email={})\",\n                path,\n                account\n                    .get(\"email\")\n                    .and_then(|v| v.as_str())\n                    .unwrap_or(\"<unknown>\")\n            );\n            return Ok(None);\n        }\n\n        // [兼容性] 再次确认最终状态（可能被 check_and_protect_quota 修改）\n        if account\n            .get(\"proxy_disabled\")\n            .and_then(|v| v.as_bool())\n            .unwrap_or(false)\n        {\n            tracing::debug!(\n                \"Skipping proxy-disabled account file: {:?} (email={})\",\n                path,\n                account\n                    .get(\"email\")\n                    .and_then(|v| v.as_str())\n                    .unwrap_or(\"<unknown>\")\n            );\n            return Ok(None);\n        }\n\n        let account_id = account[\"id\"].as_str()\n            .ok_or(\"缺少 id 字段\")?\n            .to_string();\n\n        let email = account[\"email\"].as_str()\n            .ok_or(\"缺少 email 字段\")?\n            .to_string();\n\n        let token_obj = account[\"token\"].as_object()\n            .ok_or(\"缺少 token 字段\")?;\n\n        let access_token = token_obj[\"access_token\"].as_str()\n            .ok_or(\"缺少 access_token\")?\n            .to_string();\n\n        let refresh_token = token_obj[\"refresh_token\"].as_str()\n            .ok_or(\"缺少 refresh_token\")?\n            .to_string();\n\n        let expires_in = token_obj[\"expires_in\"].as_i64()\n            .ok_or(\"缺少 expires_in\")?;\n\n        let timestamp = token_obj[\"expiry_timestamp\"].as_i64()\n            .ok_or(\"缺少 expiry_timestamp\")?;\n\n        // project_id 是可选的\n        let project_id = token_obj\n            .get(\"project_id\")\n            .and_then(|v| v.as_str())\n            .filter(|s| !s.is_empty())\n            .map(|s| s.to_string());\n\n        // 【新增】提取订阅等级 (subscription_tier 为 \"FREE\" | \"PRO\" | \"ULTRA\")\n        let subscription_tier = account\n            .get(\"quota\")\n            .and_then(|q| q.get(\"subscription_tier\"))\n            .and_then(|v| v.as_str())\n            .map(|s| s.to_string());\n\n        // [FIX #563] 提取最大剩余配额百分比用于优先级排序 (Option<i32> now)\n        let remaining_quota = account\n            .get(\"quota\")\n            .and_then(|q| self.calculate_quota_stats(q));\n            // .filter(|&r| r > 0); // 移除 >0 过滤，因为 0% 也是有效数据，只是优先级低\n\n        // 【新增 #621】提取受限模型列表\n        let protected_models: HashSet<String> = account\n            .get(\"protected_models\")\n            .and_then(|v| v.as_array())\n            .map(|arr| {\n                arr.iter()\n                    .filter_map(|v| v.as_str())\n                    .map(|s| s.to_string())\n                    .collect()\n            })\n            .unwrap_or_default();\n\n        let health_score = self.health_scores.get(&account_id).map(|v| *v).unwrap_or(1.0);\n\n        // [NEW] 提取最近的配额刷新时间（用于排序优化：刷新时间越近优先级越高）\n        let reset_time = self.extract_earliest_reset_time(&account);\n\n        // [OPTIMIZATION] 构建模型配额内存缓存，避免排序时读取磁盘\n        let mut model_quotas = HashMap::new();\n        // [NEW] 构建模型输出限额内存缓存 (max_output_tokens)\n        let mut model_limits: HashMap<String, u64> = HashMap::new();\n        if let Some(models) = account.get(\"quota\").and_then(|q| q.get(\"models\")).and_then(|m| m.as_array()) {\n            for model in models {\n                if let (Some(name), Some(pct)) = (model.get(\"name\").and_then(|v| v.as_str()), model.get(\"percentage\").and_then(|v| v.as_i64())) {\n                    // Normalize name to standard ID\n                    let standard_id = crate::proxy::common::model_mapping::normalize_to_standard_id(name)\n                        .unwrap_or_else(|| name.to_string());\n                    model_quotas.insert(standard_id, pct as i32);\n                }\n                // [NEW] 解析并缓存 max_output_tokens (按原始 model name，不归一化)\n                if let (Some(name), Some(limit)) = (\n                    model.get(\"name\").and_then(|v| v.as_str()),\n                    model.get(\"max_output_tokens\").and_then(|v| v.as_u64()),\n                ) {\n                    model_limits.insert(name.to_string(), limit);\n                }\n            }\n        }\n\n        // [NEW] 启动时自动同步持久化的淘汰模型路由表，注入热更新拦截器\n        if let Some(rules) = account.get(\"quota\").and_then(|q| q.get(\"model_forwarding_rules\")).and_then(|r| r.as_object()) {\n            for (k, v) in rules {\n                if let Some(new_model) = v.as_str() {\n                    crate::proxy::common::model_mapping::update_dynamic_forwarding_rules(\n                        k.to_string(),\n                        new_model.to_string()\n                    );\n                }\n            }\n        }\n\n        Ok(Some(ProxyToken {\n            account_id,\n            access_token,\n            refresh_token,\n            expires_in,\n            timestamp,\n            email,\n            account_path: path.clone(),\n            project_id,\n            subscription_tier,\n            remaining_quota,\n            protected_models,\n            health_score,\n            reset_time,\n            validation_blocked: account.get(\"validation_blocked\").and_then(|v| v.as_bool()).unwrap_or(false),\n            validation_blocked_until: account.get(\"validation_blocked_until\").and_then(|v| v.as_i64()).unwrap_or(0),\n            validation_url: account.get(\"validation_url\").and_then(|v| v.as_str()).map(|s| s.to_string()),\n            model_quotas,\n            model_limits,\n        }))\n    }\n\n    /// 检查账号是否应该被配额保护\n    /// 如果配额低于阈值，自动禁用账号并返回 true\n    async fn check_and_protect_quota(\n        &self,\n        account_json: &mut serde_json::Value,\n        account_path: &PathBuf,\n    ) -> bool {\n        // 1. 加载配额保护配置\n        let config = match crate::modules::config::load_app_config() {\n            Ok(cfg) => cfg.quota_protection,\n            Err(_) => return false, // 配置加载失败，跳过保护\n        };\n\n        if !config.enabled {\n            return false; // 配额保护未启用\n        }\n\n        // 2. 获取配额信息\n        // 注意：我们需要 clone 配额信息来遍历，避免借用冲突，但修改是针对 account_json 的\n        let quota = match account_json.get(\"quota\") {\n            Some(q) => q.clone(),\n            None => return false, // 无配额信息，跳过\n        };\n\n        // 3. [兼容性 #621] 检查是否被旧版账号级配额保护禁用,尝试恢复并转为模型级\n        let is_proxy_disabled = account_json\n            .get(\"proxy_disabled\")\n            .and_then(|v| v.as_bool())\n            .unwrap_or(false);\n\n        let reason = account_json.get(\"proxy_disabled_reason\")\n            .and_then(|v| v.as_str())\n            .unwrap_or(\"\");\n\n        if is_proxy_disabled && reason == \"quota_protection\" {\n            // 如果是被旧版账号级保护禁用的,尝试恢复并转为模型级\n            return self\n                .check_and_restore_quota(account_json, account_path, &quota, &config)\n                .await;\n        }\n\n        // [修复 #1344] 不再处理其他禁用原因,让调用方负责检查手动禁用\n\n        // 4. 获取模型列表\n        let models = match quota.get(\"models\").and_then(|m| m.as_array()) {\n            Some(m) => m,\n            None => return false,\n        };\n\n        // 5. [重构] 聚合判定逻辑：按 Standard ID 对账号所有型号进行分组\n        // 解决如 Pro-Low (0%) 和 Pro-High (100%) 在同一账号内导致状态冲突的问题\n        let mut group_min_percentage: HashMap<String, i32> = HashMap::new();\n\n        for model in models {\n            let name = model.get(\"name\").and_then(|v| v.as_str()).unwrap_or(\"\");\n            let percentage = model.get(\"percentage\").and_then(|v| v.as_i64()).unwrap_or(100) as i32;\n\n            if let Some(std_id) = crate::proxy::common::model_mapping::normalize_to_standard_id(name) {\n                let entry = group_min_percentage.entry(std_id).or_insert(100);\n                if percentage < *entry {\n                    *entry = percentage;\n                }\n            }\n        }\n\n        // 6. 遍历受监控的 Standard ID，根据组内“最差状态”执行锁定或恢复\n        let threshold = config.threshold_percentage as i32;\n        let account_id = account_json\n            .get(\"id\")\n            .and_then(|v| v.as_str())\n            .unwrap_or(\"unknown\")\n            .to_string();\n        let mut changed = false;\n\n        for std_id in &config.monitored_models {\n            // 获取该组的最低百分比，如果账号没该组型号则视为 100%\n            let min_pct = group_min_percentage.get(std_id).cloned().unwrap_or(100);\n\n            if min_pct <= threshold {\n                // 只要组内有一个不行，触发全组保护\n                if self\n                    .trigger_quota_protection(\n                        account_json,\n                        &account_id,\n                        account_path,\n                        min_pct,\n                        threshold,\n                        std_id,\n                    )\n                    .await\n                    .unwrap_or(false)\n                {\n                    changed = true;\n                }\n            } else {\n                // 只有全组都好（或者没这型号），才尝试从之前受限状态恢复\n                let protected_models = account_json\n                    .get(\"protected_models\")\n                    .and_then(|v| v.as_array());\n                \n                let is_protected = protected_models.map_or(false, |arr| {\n                    arr.iter().any(|m| m.as_str() == Some(std_id as &str))\n                });\n\n                if is_protected {\n                    if self\n                        .restore_quota_protection(\n                            account_json,\n                            &account_id,\n                            account_path,\n                            std_id,\n                        )\n                        .await\n                        .unwrap_or(false)\n                    {\n                        changed = true;\n                    }\n                }\n            }\n        }\n\n        let _ = changed; // 避免 unused 警告，如果后续逻辑需要可以继续使用\n\n        // 我们不再因为配额原因返回 true（即不再跳过账号），\n        // 而是加载并在 get_token 时进行过滤。\n        false\n    }\n\n    /// 计算账号的最大剩余配额百分比（用于排序）\n    /// 返回值: Option<i32> (max_percentage)\n    fn calculate_quota_stats(&self, quota: &serde_json::Value) -> Option<i32> {\n        let models = match quota.get(\"models\").and_then(|m| m.as_array()) {\n            Some(m) => m,\n            None => return None,\n        };\n\n        let mut max_percentage = 0;\n        let mut has_data = false;\n\n        for model in models {\n            if let Some(pct) = model.get(\"percentage\").and_then(|v| v.as_i64()) {\n                let pct_i32 = pct as i32;\n                if pct_i32 > max_percentage {\n                    max_percentage = pct_i32;\n                }\n                has_data = true;\n            }\n        }\n\n        if has_data {\n            Some(max_percentage)\n        } else {\n            None\n        }\n    }\n\n    /// 从磁盘读取特定模型的 quota 百分比 [FIX] 排序使用目标模型的 quota 而非 max\n    ///\n    /// # 参数\n    /// * `account_path` - 账号 JSON 文件路径\n    /// * `model_name` - 目标模型名称（已标准化）\n    #[allow(dead_code)] // 预留给精确配额读取逻辑\n    fn get_model_quota_from_json(account_path: &PathBuf, model_name: &str) -> Option<i32> {\n        let content = std::fs::read_to_string(account_path).ok()?;\n        let account: serde_json::Value = serde_json::from_str(&content).ok()?;\n        let models = account.get(\"quota\")?.get(\"models\")?.as_array()?;\n\n        for model in models {\n            if let Some(name) = model.get(\"name\").and_then(|v| v.as_str()) {\n                if crate::proxy::common::model_mapping::normalize_to_standard_id(name)\n                    .unwrap_or_else(|| name.to_string())\n                    == model_name\n                {\n                    return model\n                        .get(\"percentage\")\n                        .and_then(|v| v.as_i64())\n                        .map(|p| p as i32);\n                }\n            }\n        }\n        None\n    }\n\n    fn get_available_models_from_json(account_path: &PathBuf) -> Option<HashSet<String>> {\n        let content = std::fs::read_to_string(account_path).ok()?;\n        let account: serde_json::Value = serde_json::from_str(&content).ok()?;\n        let models = account.get(\"quota\")?.get(\"models\")?.as_array()?;\n        let mut result = HashSet::new();\n        for model in models {\n            if let Some(name) = model.get(\"name\").and_then(|v| v.as_str()) {\n                let normalized = name.trim().to_lowercase();\n                if !normalized.is_empty() {\n                    result.insert(normalized);\n                }\n            }\n        }\n        Some(result)\n    }\n\n    fn build_dynamic_model_candidates(model_name: &str) -> Option<Vec<String>> {\n        let model = model_name.trim().to_lowercase();\n        if model.is_empty() {\n            return None;\n        }\n\n        let pro_family = [\n            \"gemini-3-pro\",\n            \"gemini-3-pro-preview\",\n            \"gemini-3-pro-high\",\n            \"gemini-3-pro-low\",\n            \"gemini-3.1-pro\",\n            \"gemini-3.1-pro-preview\",\n            \"gemini-3.1-pro-high\",\n            \"gemini-3.1-pro-low\",\n        ];\n\n        if !pro_family.contains(&model.as_str()) {\n            return None;\n        }\n\n        let mut out = Vec::new();\n        let mut seen = HashSet::new();\n        let mut push = |candidate: &str| {\n            let c = candidate.to_string();\n            if seen.insert(c.clone()) {\n                out.push(c);\n            }\n        };\n\n        // Keep requested model as top priority, then fallback across the same family.\n        push(&model);\n        push(\"gemini-3.1-pro-preview\");\n        push(\"gemini-3-pro-preview\");\n        push(\"gemini-3.1-pro-high\");\n        push(\"gemini-3-pro-high\");\n        push(\"gemini-3.1-pro-low\");\n        push(\"gemini-3-pro-low\");\n\n        Some(out)\n    }\n\n    pub async fn resolve_dynamic_model_for_account(\n        &self,\n        account_id: &str,\n        mapped_model: &str,\n    ) -> String {\n        let candidates = match Self::build_dynamic_model_candidates(mapped_model) {\n            Some(c) => c,\n            None => return mapped_model.to_string(),\n        };\n\n        let account_path = match self.tokens.get(account_id) {\n            Some(token) => token.account_path.clone(),\n            None => return mapped_model.to_string(),\n        };\n\n        let available_models = match Self::get_available_models_from_json(&account_path) {\n            Some(models) if !models.is_empty() => models,\n            _ => return mapped_model.to_string(),\n        };\n\n        for candidate in candidates {\n            if available_models.contains(&candidate) {\n                if candidate != mapped_model.to_lowercase() {\n                    tracing::info!(\n                        \"[Dynamic-Model-Rewrite] account={} {} -> {}\",\n                        account_id,\n                        mapped_model,\n                        candidate\n                    );\n                }\n                return candidate;\n            }\n        }\n\n        mapped_model.to_string()\n    }\n\n    /// 测试辅助函数：公开访问 get_model_quota_from_json\n    #[cfg(test)]\n    pub fn get_model_quota_from_json_for_test(account_path: &PathBuf, model_name: &str) -> Option<i32> {\n        Self::get_model_quota_from_json(account_path, model_name)\n    }\n\n    /// 触发配额保护，限制特定模型 (Issue #621)\n    /// 返回 true 如果发生了改变\n    async fn trigger_quota_protection(\n        &self,\n        account_json: &mut serde_json::Value,\n        account_id: &str,\n        account_path: &PathBuf,\n        current_val: i32,\n        threshold: i32,\n        model_name: &str,\n    ) -> Result<bool, String> {\n        // 1. 初始化 protected_models 数组（如果不存在）\n        if account_json.get(\"protected_models\").is_none() {\n            account_json[\"protected_models\"] = serde_json::Value::Array(Vec::new());\n        }\n\n        let protected_models = account_json[\"protected_models\"].as_array_mut().unwrap();\n\n        // 2. 检查是否已存在\n        if !protected_models\n            .iter()\n            .any(|m| m.as_str() == Some(model_name))\n        {\n            protected_models.push(serde_json::Value::String(model_name.to_string()));\n\n            tracing::info!(\n                \"账号 {} 的模型 {} 因配额受限（{}% <= {}%）已被加入保护列表\",\n                account_id,\n                model_name,\n                current_val,\n                threshold\n            );\n\n            // 3. 写入磁盘\n            std::fs::write(account_path, serde_json::to_string_pretty(account_json).unwrap())\n                .map_err(|e| format!(\"写入文件失败: {}\", e))?;\n\n            // [FIX] 触发 TokenManager 的账号重新加载信号，确保内存中的 protected_models 同步\n            crate::proxy::server::trigger_account_reload(account_id);\n\n            return Ok(true);\n        }\n\n        Ok(false)\n    }\n\n    /// 检查并从账号级保护恢复（迁移至模型级，Issue #621）\n    async fn check_and_restore_quota(\n        &self,\n        account_json: &mut serde_json::Value,\n        account_path: &PathBuf,\n        quota: &serde_json::Value,\n        config: &crate::models::QuotaProtectionConfig,\n    ) -> bool {\n        // [兼容性] 如果该账号当前处于 proxy_disabled=true 且原因是 quota_protection，\n        // 我们将其 proxy_disabled 设为 false，但同时更新其 protected_models 列表。\n        tracing::info!(\n            \"正在迁移账号 {} 从全局配额保护模式至模型级保护模式\",\n            account_json\n                .get(\"email\")\n                .and_then(|v| v.as_str())\n                .unwrap_or(\"unknown\")\n        );\n\n        account_json[\"proxy_disabled\"] = serde_json::Value::Bool(false);\n        account_json[\"proxy_disabled_reason\"] = serde_json::Value::Null;\n        account_json[\"proxy_disabled_at\"] = serde_json::Value::Null;\n\n        let threshold = config.threshold_percentage as i32;\n        let mut protected_list = Vec::new();\n\n        if let Some(models) = quota.get(\"models\").and_then(|m| m.as_array()) {\n            for model in models {\n                let name = model.get(\"name\").and_then(|v| v.as_str()).unwrap_or(\"\");\n                if !config.monitored_models.iter().any(|m| m == name) { continue; }\n\n                let percentage = model.get(\"percentage\").and_then(|v| v.as_i64()).unwrap_or(0) as i32;\n                if percentage <= threshold {\n                    protected_list.push(serde_json::Value::String(name.to_string()));\n                }\n            }\n        }\n\n        account_json[\"protected_models\"] = serde_json::Value::Array(protected_list);\n\n        let _ = std::fs::write(account_path, serde_json::to_string_pretty(account_json).unwrap());\n\n        false // 返回 false 表示现在已可以尝试加载该账号（模型级过滤会在 get_token 时发生）\n    }\n\n    /// 恢复特定模型的配额保护 (Issue #621)\n    /// 返回 true 如果发生了改变\n    async fn restore_quota_protection(\n        &self,\n        account_json: &mut serde_json::Value,\n        account_id: &str,\n        account_path: &PathBuf,\n        model_name: &str,\n    ) -> Result<bool, String> {\n        if let Some(arr) = account_json\n            .get_mut(\"protected_models\")\n            .and_then(|v| v.as_array_mut())\n        {\n            let original_len = arr.len();\n            arr.retain(|m| m.as_str() != Some(model_name));\n\n            if arr.len() < original_len {\n                tracing::info!(\n                    \"账号 {} 的模型 {} 配额已恢复，移出保护列表\",\n                    account_id,\n                    model_name\n                );\n                std::fs::write(\n                    account_path,\n                    serde_json::to_string_pretty(account_json).unwrap(),\n                )\n                .map_err(|e| format!(\"写入文件失败: {}\", e))?;\n                return Ok(true);\n            }\n        }\n\n        Ok(false)\n    }\n\n    /// P2C 算法的候选池大小 - 从前 N 个最优候选中随机选择\n    const P2C_POOL_SIZE: usize = 5;\n\n    /// Power of 2 Choices (P2C) 选择算法\n    /// 从前 5 个候选中随机选 2 个，选择配额更高的 -> 避免热点\n    /// 返回选中的索引\n    ///\n    /// # 参数\n    /// * `candidates` - 已排序的候选 token 列表\n    /// * `attempted` - 已尝试失败的账号 ID 集合\n    /// * `normalized_target` - 归一化后的目标模型名\n    /// * `quota_protection_enabled` - 是否启用配额保护\n    fn select_with_p2c<'a>(\n        &self,\n        candidates: &'a [ProxyToken],\n        attempted: &HashSet<String>,\n        normalized_target: &str,\n        quota_protection_enabled: bool,\n    ) -> Option<&'a ProxyToken> {\n        use rand::Rng;\n\n        // 过滤可用 token\n        let available: Vec<&ProxyToken> = candidates.iter()\n            .filter(|t| !attempted.contains(&t.account_id))\n            .filter(|t| !quota_protection_enabled || !t.protected_models.contains(normalized_target))\n            .collect();\n\n        if available.is_empty() { return None; }\n        if available.len() == 1 { return Some(available[0]); }\n\n        // P2C: 从前 min(P2C_POOL_SIZE, len) 个中随机选 2 个\n        let pool_size = available.len().min(Self::P2C_POOL_SIZE);\n        let mut rng = rand::thread_rng();\n\n        let pick1 = rng.gen_range(0..pool_size);\n        let pick2 = rng.gen_range(0..pool_size);\n        // 确保选择不同的两个候选\n        let pick2 = if pick2 == pick1 {\n            (pick1 + 1) % pool_size\n        } else {\n            pick2\n        };\n\n        let c1 = available[pick1];\n        let c2 = available[pick2];\n\n        // 选择配额更高的\n        let selected = if c1.remaining_quota.unwrap_or(0) >= c2.remaining_quota.unwrap_or(0) {\n            c1\n        } else {\n            c2\n        };\n\n        tracing::debug!(\n            \"🎲 [P2C] Selected {} ({}%) from [{}({}%), {}({}%)]\",\n            selected.email, selected.remaining_quota.unwrap_or(0),\n            c1.email, c1.remaining_quota.unwrap_or(0),\n            c2.email, c2.remaining_quota.unwrap_or(0)\n        );\n\n        Some(selected)\n    }\n\n    /// 先发送取消信号，再带超时等待任务完成\n    ///\n    /// # 参数\n    /// * `timeout` - 等待任务完成的超时时间\n    pub async fn graceful_shutdown(&self, timeout: std::time::Duration) {\n        tracing::info!(\"Initiating graceful shutdown of background tasks...\");\n\n        // 发送取消信号给所有后台任务\n        self.cancel_token.cancel();\n\n        // 带超时等待任务完成\n        match tokio::time::timeout(timeout, self.abort_background_tasks()).await {\n            Ok(_) => tracing::info!(\"All background tasks cleaned up gracefully\"),\n            Err(_) => tracing::warn!(\"Graceful cleanup timed out after {:?}, tasks were force-aborted\", timeout),\n        }\n    }\n\n    /// 中止并等待所有后台任务完成\n    /// abort() 仅设置取消标志，必须 await 确认清理完成\n    pub async fn abort_background_tasks(&self) {\n        Self::abort_task(&self.auto_cleanup_handle, \"Auto-cleanup task\").await;\n    }\n\n    /// 中止单个后台任务并记录结果\n    ///\n    /// # 参数\n    /// * `handle` - 任务句柄的 Mutex 引用\n    /// * `task_name` - 任务名称（用于日志）\n    async fn abort_task(\n        handle: &tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,\n        task_name: &str,\n    ) {\n        let Some(handle) = handle.lock().await.take() else {\n            return;\n        };\n\n        handle.abort();\n        match handle.await {\n            Ok(()) => tracing::debug!(\"{} completed\", task_name),\n            Err(e) if e.is_cancelled() => tracing::info!(\"{} aborted\", task_name),\n            Err(e) => tracing::warn!(\"{} error: {}\", task_name, e),\n        }\n    }\n\n    /// 获取当前可用的 Token（支持粘性会话与智能调度）\n    /// 参数 `quota_group` 用于区分 \"claude\" vs \"gemini\" 组\n    /// 参数 `force_rotate` 为 true 时将忽略锁定，强制切换账号\n    /// 参数 `session_id` 用于跨请求维持会话粘性\n    /// 参数 `target_model` 用于检查配额保护 (Issue #621)\n    pub async fn get_token(\n        &self,\n        quota_group: &str,\n        force_rotate: bool,\n        session_id: Option<&str>,\n        target_model: &str,\n    ) -> Result<(String, String, String, String, u64), String> {\n        // [FIX] 检查并处理待重新加载的账号（配额保护同步）\n        let pending_reload = crate::proxy::server::take_pending_reload_accounts();\n        for account_id in pending_reload {\n            if let Err(e) = self.reload_account(&account_id).await {\n                tracing::warn!(\"[Quota] Failed to reload account {}: {}\", account_id, e);\n            } else {\n                tracing::info!(\n                    \"[Quota] Reloaded account {} (protected_models synced)\",\n                    account_id\n                );\n            }\n        }\n\n        // [FIX #1477] 检查并处理待删除的账号（彻底清理缓存）\n        let pending_delete = crate::proxy::server::take_pending_delete_accounts();\n        for account_id in pending_delete {\n            self.remove_account(&account_id);\n            tracing::info!(\n                \"[Proxy] Purged deleted account {} from all caches\",\n                account_id\n            );\n        }\n\n        // 【优化 Issue #284】添加 5 秒超时，防止死锁\n        let timeout_duration = std::time::Duration::from_secs(5);\n        match tokio::time::timeout(\n            timeout_duration,\n            self.get_token_internal(quota_group, force_rotate, session_id, target_model),\n        )\n        .await\n        {\n            Ok(result) => result,\n            Err(_) => Err(\n                \"Token acquisition timeout (5s) - system too busy or deadlock detected\".to_string(),\n            ),\n        }\n    }\n\n    /// 内部实现：获取 Token 的核心逻辑\n    async fn get_token_internal(\n        &self,\n        quota_group: &str,\n        force_rotate: bool,\n        session_id: Option<&str>,\n        target_model: &str,\n    ) -> Result<(String, String, String, String, u64), String> {\n        let mut tokens_snapshot: Vec<ProxyToken> =\n            self.tokens.iter().map(|e| e.value().clone()).collect();\n        let mut total = tokens_snapshot.len();\n        if total == 0 {\n            return Err(\"Token pool is empty\".to_string());\n        }\n\n        // [NEW] 1. 动态能力过滤 (Capability Filter)\n        \n        // 定义常量\n        const RESET_TIME_THRESHOLD_SECS: i64 = 600; // 10 分钟阈值\n\n        // 归一化目标模型名为标准 ID\n        let normalized_target = crate::proxy::common::model_mapping::normalize_to_standard_id(target_model)\n            .unwrap_or_else(|| target_model.to_string());\n\n        // 仅保留明确拥有该模型配额的账号\n        // 这一步确保了 \"保证有模型才可以进入轮询\"，特别是对 Opus 4.6 等高端模型\n        let candidate_count_before = tokens_snapshot.len();\n        \n        // 此处假设所有受支持的模型都会出现在 model_quotas 中\n        // 如果 API 返回的配额信息不完整，可能会导致误杀，但为了严格性，我们执行此过滤\n        tokens_snapshot.retain(|t| t.model_quotas.contains_key(&normalized_target));\n\n        if tokens_snapshot.is_empty() {\n            if candidate_count_before > 0 {\n                // 如果过滤前有账号，过滤后没了，说明所有账号都没有该模型的配额\n                tracing::warn!(\"No accounts have satisfied quota for model: {}\", normalized_target);\n                return Err(format!(\"No accounts available with quota for model: {}\", normalized_target));\n            }\n            return Err(\"Token pool is empty\".to_string());\n        }\n\n        tokens_snapshot.sort_by(|a, b| {\n            // Priority 0: 严格的订阅等级排序 (ULTRA > PRO > FREE)\n            // 用户要求：轮询应当遵循 Ultra -> Pro -> Free\n            // 既然已经过滤掉了不支持该模型的账号，剩下的都是支持的\n            // 此时我们优先使用高级订阅\n            let tier_priority = |tier: &Option<String>| {\n                let t = tier.as_deref().unwrap_or(\"\").to_lowercase();\n                if t.contains(\"ultra\") { 0 }\n                else if t.contains(\"pro\") { 1 }\n                else if t.contains(\"free\") { 2 }\n                else { 3 }\n            };\n\n            let tier_cmp = tier_priority(&a.subscription_tier)\n                .cmp(&tier_priority(&b.subscription_tier));\n            if tier_cmp != std::cmp::Ordering::Equal {\n                return tier_cmp;\n            }\n\n            // Priority 1: 目标模型的 quota (higher is better) -> 保护低配额账号\n            // 经过过滤，key 肯定存在\n            let quota_a = a.model_quotas.get(&normalized_target).copied().unwrap_or(0);\n            let quota_b = b.model_quotas.get(&normalized_target).copied().unwrap_or(0);\n\n            let quota_cmp = quota_b.cmp(&quota_a);\n            if quota_cmp != std::cmp::Ordering::Equal {\n                return quota_cmp;\n            }\n\n            // Priority 2: Health score (higher is better)\n            let health_cmp = b.health_score.partial_cmp(&a.health_score)\n                .unwrap_or(std::cmp::Ordering::Equal);\n            if health_cmp != std::cmp::Ordering::Equal {\n                return health_cmp;\n            }\n\n            // Priority 3: Reset time (earlier is better, but only if diff > 10 min)\n            let reset_a = a.reset_time.unwrap_or(i64::MAX);\n            let reset_b = b.reset_time.unwrap_or(i64::MAX);\n            if (reset_a - reset_b).abs() >= RESET_TIME_THRESHOLD_SECS {\n                reset_a.cmp(&reset_b)\n            } else {\n                std::cmp::Ordering::Equal\n            }\n        });\n\n        // 【调试日志】打印排序后的账号顺序（显示目标模型的 quota）\n        tracing::debug!(\n            \"🔄 [Token Rotation] target={} Accounts: {:?}\",\n            normalized_target,\n            tokens_snapshot.iter().map(|t| format!(\n                \"{}(quota={}%, reset={:?}, health={:.2})\",\n                t.email,\n                t.model_quotas.get(&normalized_target).copied().unwrap_or(0),\n                t.reset_time.map(|ts| {\n                    let now = chrono::Utc::now().timestamp();\n                    let diff_secs = ts - now;\n                    if diff_secs > 0 {\n                        format!(\"{}m\", diff_secs / 60)\n                    } else {\n                        \"now\".to_string()\n                    }\n                }),\n                t.health_score\n            )).collect::<Vec<_>>()\n        );\n\n        // 0. 读取当前调度配置\n        let scheduling = self.sticky_config.read().await.clone();\n        use crate::proxy::sticky_config::SchedulingMode;\n\n        // 【新增】检查配额保护是否启用（如果关闭，则忽略 protected_models 检查）\n        let quota_protection_enabled = crate::modules::config::load_app_config()\n            .map(|cfg| cfg.quota_protection.enabled)\n            .unwrap_or(false);\n\n        // ===== [FIX #820] 固定账号模式：优先使用指定账号 =====\n        let preferred_id = self.preferred_account_id.read().await.clone();\n        if let Some(ref pref_id) = preferred_id {\n            // 查找优先账号\n            if let Some(preferred_token) = tokens_snapshot\n                .iter()\n                .find(|t| &t.account_id == pref_id)\n                .cloned()\n            {\n                // 检查账号是否可用（未限流、未被配额保护）\n                match Self::get_account_state_on_disk(&preferred_token.account_path).await {\n                    OnDiskAccountState::Disabled => {\n                        tracing::warn!(\n                            \"🔒 [FIX #820] Preferred account {} is disabled on disk, purging and falling back\",\n                            preferred_token.email\n                        );\n                        self.remove_account(&preferred_token.account_id);\n                        tokens_snapshot.retain(|t| t.account_id != preferred_token.account_id);\n                        total = tokens_snapshot.len();\n\n                        {\n                            let mut preferred = self.preferred_account_id.write().await;\n                            if preferred.as_deref() == Some(pref_id.as_str()) {\n                                *preferred = None;\n                            }\n                        }\n\n                        if total == 0 {\n                            return Err(\"Token pool is empty\".to_string());\n                        }\n                    }\n                    OnDiskAccountState::Unknown => {\n                        tracing::warn!(\n                            \"🔒 [FIX #820] Preferred account {} state on disk is unavailable, falling back\",\n                            preferred_token.email\n                        );\n                        // Don't purge on transient read/parse failures; just skip this token for this request.\n                        tokens_snapshot.retain(|t| t.account_id != preferred_token.account_id);\n                        total = tokens_snapshot.len();\n                        if total == 0 {\n                            return Err(\"Token pool is empty\".to_string());\n                        }\n                    }\n                    OnDiskAccountState::Enabled => {\n                        let normalized_target =\n                            crate::proxy::common::model_mapping::normalize_to_standard_id(\n                                target_model,\n                            )\n                            .unwrap_or_else(|| target_model.to_string());\n\n                let is_rate_limited = self\n                    .is_rate_limited(&preferred_token.account_id, Some(&normalized_target))\n                    .await;\n                let is_quota_protected = quota_protection_enabled\n                    && preferred_token\n                        .protected_models\n                        .contains(&normalized_target);\n\n                if !is_rate_limited && !is_quota_protected {\n                    tracing::info!(\n                        \"🔒 [FIX #820] Using preferred account: {} (fixed mode)\",\n                        preferred_token.email\n                    );\n\n                    // 直接使用优先账号，跳过轮询逻辑\n                    let mut token = preferred_token.clone();\n\n                    // 检查 token 是否过期（提前5分钟刷新）\n                    let now = chrono::Utc::now().timestamp();\n                    if now >= token.timestamp - 300 {\n                        tracing::debug!(\"账号 {} 的 token 即将过期，正在刷新...\", token.email);\n                        match crate::modules::oauth::refresh_access_token(&token.refresh_token, Some(&token.account_id))\n                            .await\n                        {\n                            Ok(token_response) => {\n                                token.access_token = token_response.access_token.clone();\n                                token.expires_in = token_response.expires_in;\n                                token.timestamp = now + token_response.expires_in;\n\n                                if let Some(mut entry) = self.tokens.get_mut(&token.account_id) {\n                                    entry.access_token = token.access_token.clone();\n                                    entry.expires_in = token.expires_in;\n                                    entry.timestamp = token.timestamp;\n                                }\n                                let _ = self\n                                    .save_refreshed_token(&token.account_id, &token_response)\n                                    .await;\n                            }\n                            Err(e) => {\n                                tracing::warn!(\"Preferred account token refresh failed: {}\", e);\n                                // 继续使用旧 token，让后续逻辑处理失败\n                            }\n                        }\n                    }\n\n                    // 确保有 project_id (filter empty strings to trigger re-fetch)\n                    let project_id = if let Some(pid) = &token.project_id {\n                        if pid.is_empty() { None } else { Some(pid.clone()) }\n                    } else {\n                        None\n                    };\n                    let project_id = if let Some(pid) = project_id {\n                        pid\n                    } else {\n                        match crate::proxy::project_resolver::fetch_project_id(&token.access_token)\n                            .await\n                        {\n                            Ok(pid) => {\n                                if let Some(mut entry) = self.tokens.get_mut(&token.account_id) {\n                                    entry.project_id = Some(pid.clone());\n                                }\n                                let _ = self.save_project_id(&token.account_id, &pid).await;\n                                pid\n                            }\n                            Err(_) => \"bamboo-precept-lgxtn\".to_string(), // fallback\n                        }\n                    };\n\n                    return Ok((token.access_token, project_id, token.email, token.account_id, 0));\n                } else {\n                    if is_rate_limited {\n                        tracing::warn!(\"🔒 [FIX #820] Preferred account {} is rate-limited, falling back to round-robin\", preferred_token.email);\n                    } else {\n                        tracing::warn!(\"🔒 [FIX #820] Preferred account {} is quota-protected for {}, falling back to round-robin\", preferred_token.email, target_model);\n                    }\n                }\n                    }\n                }\n            } else {\n                tracing::warn!(\"🔒 [FIX #820] Preferred account {} not found in pool, falling back to round-robin\", pref_id);\n            }\n        }\n        // ===== [END FIX #820] =====\n\n        // 【优化 Issue #284】将锁操作移到循环外，避免重复获取锁\n        // 预先获取 last_used_account 的快照，避免在循环中多次加锁\n        let last_used_account_id = if quota_group != \"image_gen\" {\n            let last_used = self.last_used_account.lock().await;\n            last_used.clone()\n        } else {\n            None\n        };\n\n        let mut attempted: HashSet<String> = HashSet::new();\n        let mut last_error: Option<String> = None;\n        let mut need_update_last_used: Option<(String, std::time::Instant)> = None;\n\n        for attempt in 0..total {\n            let rotate = force_rotate || attempt > 0;\n\n            // ===== 【核心】粘性会话与智能调度逻辑 =====\n            let mut target_token: Option<ProxyToken> = None;\n\n            // 归一化目标模型名为标准 ID，用于配额保护检查\n            let normalized_target = crate::proxy::common::model_mapping::normalize_to_standard_id(target_model)\n                .unwrap_or_else(|| target_model.to_string());\n\n            // 模式 A: 粘性会话处理 (CacheFirst 或 Balance 且有 session_id)\n            if !rotate\n                && session_id.is_some()\n                && scheduling.mode != SchedulingMode::PerformanceFirst\n            {\n                let sid = session_id.unwrap();\n\n                // 1. 检查会话是否已绑定账号\n                if let Some(bound_id) = self.session_accounts.get(sid).map(|v| v.clone()) {\n                    // 【修复】先通过 account_id 找到对应的账号，获取其 email\n                    // 2. 转换 email -> account_id 检查绑定的账号是否限流\n                    if let Some(bound_token) =\n                        tokens_snapshot.iter().find(|t| t.account_id == bound_id)\n                    {\n                        let key = self\n                            .email_to_account_id(&bound_token.email)\n                            .unwrap_or_else(|| bound_token.account_id.clone());\n                        // [FIX] Pass None for specific model wait time if not applicable\n                        let reset_sec = self.rate_limit_tracker.get_remaining_wait(&key, None);\n                        if reset_sec > 0 {\n                            // 【修复 Issue #284】立即解绑并切换账号，不再阻塞等待\n                            // 原因：阻塞等待会导致并发请求时客户端 socket 超时 (UND_ERR_SOCKET)\n                            tracing::debug!(\n                                \"Sticky Session: Bound account {} is rate-limited ({}s), unbinding and switching.\",\n                                bound_token.email, reset_sec\n                            );\n                            self.session_accounts.remove(sid);\n                        } else if !attempted.contains(&bound_id)\n                            && !(quota_protection_enabled\n                                && bound_token.protected_models.contains(&normalized_target))\n                        {\n                            // 3. 账号可用且未被标记为尝试失败，优先复用\n                            tracing::debug!(\"Sticky Session: Successfully reusing bound account {} for session {}\", bound_token.email, sid);\n                            target_token = Some(bound_token.clone());\n                        } else if quota_protection_enabled\n                            && bound_token.protected_models.contains(&normalized_target)\n                        {\n                            tracing::debug!(\"Sticky Session: Bound account {} is quota-protected for model {} [{}], unbinding and switching.\", bound_token.email, normalized_target, target_model);\n                            self.session_accounts.remove(sid);\n                        }\n                    } else {\n                        // 绑定的账号已不存在（可能被删除），解绑\n                        tracing::debug!(\n                            \"Sticky Session: Bound account not found for session {}, unbinding\",\n                            sid\n                        );\n                        self.session_accounts.remove(sid);\n                    }\n                }\n            }\n\n            // 模式 B: 原子化 60s 全局锁定 (针对无 session_id 情况的默认保护)\n            // 【修复】性能优先模式应跳过 60s 锁定；\n            if target_token.is_none()\n                && !rotate\n                && quota_group != \"image_gen\"\n                && scheduling.mode != SchedulingMode::PerformanceFirst\n            {\n                // 【优化】使用预先获取的快照，不再在循环内加锁\n                if let Some((account_id, last_time)) = &last_used_account_id {\n                    // [FIX #3] 60s 锁定逻辑应检查 `attempted` 集合，避免重复尝试失败的账号\n                    if last_time.elapsed().as_secs() < 60 && !attempted.contains(account_id) {\n                        if let Some(found) =\n                            tokens_snapshot.iter().find(|t| &t.account_id == account_id)\n                        {\n                            // 【修复】检查限流状态和配额保护，避免复用已被锁定的账号\n                            if !self\n                                .is_rate_limited(&found.account_id, Some(&normalized_target))\n                                .await\n                                && !(quota_protection_enabled\n                                    && found.protected_models.contains(&normalized_target))\n                            {\n                                tracing::debug!(\n                                    \"60s Window: Force reusing last account: {}\",\n                                    found.email\n                                );\n                                target_token = Some(found.clone());\n                            } else {\n                                if self\n                                    .is_rate_limited(&found.account_id, Some(&normalized_target))\n                                    .await\n                                {\n                                    tracing::debug!(\n                                        \"60s Window: Last account {} is rate-limited, skipping\",\n                                        found.email\n                                    );\n                                } else {\n                                    tracing::debug!(\"60s Window: Last account {} is quota-protected for model {} [{}], skipping\", found.email, normalized_target, target_model);\n                                }\n                            }\n                        }\n                    }\n                }\n\n                // 若无锁定，则使用 P2C 选择账号 (避免热点问题)\n                if target_token.is_none() {\n                    // 先过滤出未限流的账号\n                    let mut non_limited: Vec<ProxyToken> = Vec::new();\n                    for t in &tokens_snapshot {\n                        if !self.is_rate_limited(&t.account_id, Some(&normalized_target)).await {\n                            non_limited.push(t.clone());\n                        }\n                    }\n\n                    if let Some(selected) = self.select_with_p2c(\n                        &non_limited, &attempted, &normalized_target, quota_protection_enabled\n                    ) {\n                        target_token = Some(selected.clone());\n                        need_update_last_used = Some((selected.account_id.clone(), std::time::Instant::now()));\n\n                        // 如果是会话首次分配且需要粘性，在此建立绑定\n                        if let Some(sid) = session_id {\n                            if scheduling.mode != SchedulingMode::PerformanceFirst {\n                                self.session_accounts\n                                    .insert(sid.to_string(), selected.account_id.clone());\n                                tracing::debug!(\n                                    \"Sticky Session: Bound new account {} to session {}\",\n                                    selected.email,\n                                    sid\n                                );\n                            }\n                        }\n                    }\n                }\n            } else if target_token.is_none() {\n                // 模式 C: P2C 选择 (替代纯轮询)\n                tracing::debug!(\n                    \"🔄 [Mode C] P2C selection from {} candidates\",\n                    total\n                );\n\n                // 先过滤出未限流的账号\n                let mut non_limited: Vec<ProxyToken> = Vec::new();\n                for t in &tokens_snapshot {\n                    if !self.is_rate_limited(&t.account_id, Some(&normalized_target)).await {\n                        non_limited.push(t.clone());\n                    }\n                }\n\n                if let Some(selected) = self.select_with_p2c(\n                    &non_limited, &attempted, &normalized_target, quota_protection_enabled\n                ) {\n                    tracing::debug!(\"  {} - SELECTED via P2C\", selected.email);\n                    target_token = Some(selected.clone());\n\n                    if rotate {\n                        tracing::debug!(\"Force Rotation: Switched to account: {}\", selected.email);\n                    }\n                }\n            }\n\n            let mut token = match target_token {\n                Some(t) => t,\n                None => {\n                    // 乐观重置策略: 双层防护机制\n                    // 计算最短等待时间\n                    let min_wait = tokens_snapshot\n                        .iter()\n                        .filter_map(|t| self.rate_limit_tracker.get_reset_seconds(&t.account_id))\n                        .min();\n\n                    // Layer 1: 如果最短等待时间 <= 2秒,执行缓冲延迟\n                    if let Some(wait_sec) = min_wait {\n                        if wait_sec <= 2 {\n                            let wait_ms = (wait_sec as f64 * 1000.0) as u64;\n                            tracing::warn!(\n                                \"All accounts rate-limited but shortest wait is {}s. Applying {}ms buffer for state sync...\",\n                                wait_sec, wait_ms\n                            );\n\n                            // 缓冲延迟\n                            tokio::time::sleep(tokio::time::Duration::from_millis(wait_ms)).await;\n\n                            // 重新尝试选择账号\n                            let retry_token = tokens_snapshot.iter()\n                                .find(|t| !attempted.contains(&t.account_id) \n                                    && !self.is_rate_limited_sync(&t.account_id, Some(&normalized_target))\n                                    && !(quota_protection_enabled && t.protected_models.contains(&normalized_target)));\n\n                            if let Some(t) = retry_token {\n                                tracing::info!(\n                                    \"✅ Buffer delay successful! Found available account: {}\",\n                                    t.email\n                                );\n                                t.clone()\n                            } else {\n                                // Layer 2: 缓冲后仍无可用账号,执行乐观重置\n                                tracing::warn!(\n                                    \"Buffer delay failed. Executing optimistic reset for all {} accounts...\",\n                                    tokens_snapshot.len()\n                                );\n\n                                // 清除所有限流记录\n                                self.rate_limit_tracker.clear_all();\n\n                                // 再次尝试选择账号\n                                let final_token = tokens_snapshot\n                                    .iter()\n                                    .find(|t| !attempted.contains(&t.account_id)\n                                        && !(quota_protection_enabled && t.protected_models.contains(&normalized_target)));\n\n                                if let Some(t) = final_token {\n                                    tracing::info!(\n                                        \"✅ Optimistic reset successful! Using account: {}\",\n                                        t.email\n                                    );\n                                    t.clone()\n                                } else {\n                                    return Err(\n                                        \"All accounts failed after optimistic reset.\".to_string()\n                                    );\n                                }\n                            }\n                        } else {\n                            return Err(format!(\"All accounts limited. Wait {}s.\", wait_sec));\n                        }\n                    } else {\n                        return Err(\"All accounts failed or unhealthy.\".to_string());\n                    }\n                }\n            };\n\n            // Safety net: avoid selecting an account that has been disabled on disk but still\n            // exists in the in-memory snapshot (e.g. stale cache + sticky session binding).\n            match Self::get_account_state_on_disk(&token.account_path).await {\n                OnDiskAccountState::Disabled => {\n                    tracing::warn!(\n                        \"Selected account {} is disabled on disk, purging and retrying\",\n                        token.email\n                    );\n                    attempted.insert(token.account_id.clone());\n                    self.remove_account(&token.account_id);\n                    continue;\n                }\n                OnDiskAccountState::Unknown => {\n                    tracing::warn!(\n                        \"Selected account {} state on disk is unavailable, skipping\",\n                        token.email\n                    );\n                    attempted.insert(token.account_id.clone());\n                    continue;\n                }\n                OnDiskAccountState::Enabled => {}\n            }\n\n            // 3. 检查 token 是否过期（提前5分钟刷新）\n            let now = chrono::Utc::now().timestamp();\n            if now >= token.timestamp - 300 {\n                tracing::debug!(\"账号 {} 的 token 即将过期，正在刷新...\", token.email);\n\n                // 调用 OAuth 刷新 token\n                match crate::modules::oauth::refresh_access_token(&token.refresh_token, Some(&token.account_id)).await {\n                    Ok(token_response) => {\n                        tracing::debug!(\"Token 刷新成功！\");\n\n                        // 更新本地内存对象供后续使用\n                        token.access_token = token_response.access_token.clone();\n                        token.expires_in = token_response.expires_in;\n                        token.timestamp = now + token_response.expires_in;\n\n                        // 同步更新跨线程共享的 DashMap\n                        if let Some(mut entry) = self.tokens.get_mut(&token.account_id) {\n                            entry.access_token = token.access_token.clone();\n                            entry.expires_in = token.expires_in;\n                            entry.timestamp = token.timestamp;\n                        }\n\n                        // 同步落盘（避免重启后继续使用过期 timestamp 导致频繁刷新）\n                        if let Err(e) = self\n                            .save_refreshed_token(&token.account_id, &token_response)\n                            .await\n                        {\n                            tracing::debug!(\"保存刷新后的 token 失败 ({}): {}\", token.email, e);\n                        }\n                    }\n                    Err(e) => {\n                        tracing::error!(\"Token 刷新失败 ({}): {}，尝试下一个账号\", token.email, e);\n                        if e.contains(\"\\\"invalid_grant\\\"\") || e.contains(\"invalid_grant\") {\n                            tracing::error!(\n                                \"Disabling account due to invalid_grant ({}): refresh_token likely revoked/expired\",\n                                token.email\n                            );\n                            let _ = self\n                                .disable_account(\n                                    &token.account_id,\n                                    &format!(\"invalid_grant: {}\", e),\n                                )\n                                .await;\n                            self.tokens.remove(&token.account_id);\n                        }\n                        // Avoid leaking account emails to API clients; details are still in logs.\n                        last_error = Some(format!(\"Token refresh failed: {}\", e));\n                        attempted.insert(token.account_id.clone());\n\n                        // 【优化】标记需要清除锁定，避免在循环内加锁\n                        if quota_group != \"image_gen\" {\n                            if matches!(&last_used_account_id, Some((id, _)) if id == &token.account_id)\n                            {\n                                need_update_last_used =\n                                    Some((String::new(), std::time::Instant::now()));\n                                // 空字符串表示需要清除\n                            }\n                        }\n                        continue;\n                    }\n                }\n            }\n\n            // 4. 确保有 project_id (filter empty strings to trigger re-fetch)\n            let project_id = if let Some(pid) = &token.project_id {\n                if pid.is_empty() { None } else { Some(pid.clone()) }\n            } else {\n                None\n            };\n            let project_id = if let Some(pid) = project_id {\n                pid\n            } else {\n                tracing::debug!(\"账号 {} 缺少 project_id，尝试获取...\", token.email);\n                match crate::proxy::project_resolver::fetch_project_id(&token.access_token).await {\n                    Ok(pid) => {\n                        if let Some(mut entry) = self.tokens.get_mut(&token.account_id) {\n                            entry.project_id = Some(pid.clone());\n                        }\n                        let _ = self.save_project_id(&token.account_id, &pid).await;\n                        pid\n                    }\n                    Err(e) => {\n                        tracing::warn!(\n                            \"Failed to fetch project_id for {}, using fallback: {}\",\n                            token.email, e\n                        );\n                        // [FIX #1794] 为 503 问题提供稳定兜底，不跳过该账号\n                        \"bamboo-precept-lgxtn\".to_string()\n                    }\n                }\n            };\n\n            // 【优化】在成功返回前，统一更新 last_used_account（如果需要）\n            if let Some((new_account_id, new_time)) = need_update_last_used {\n                if quota_group != \"image_gen\" {\n                    let mut last_used = self.last_used_account.lock().await;\n                    if new_account_id.is_empty() {\n                        // 空字符串表示需要清除锁定\n                        *last_used = None;\n                    } else {\n                        *last_used = Some((new_account_id, new_time));\n                    }\n                }\n            }\n\n            return Ok((token.access_token, project_id, token.email, token.account_id, 0));\n        }\n\n        Err(last_error.unwrap_or_else(|| \"All accounts failed\".to_string()))\n    }\n\n    async fn disable_account(&self, account_id: &str, reason: &str) -> Result<(), String> {\n        let path = if let Some(entry) = self.tokens.get(account_id) {\n            entry.account_path.clone()\n        } else {\n            self.data_dir\n                .join(\"accounts\")\n                .join(format!(\"{}.json\", account_id))\n        };\n\n        let mut content: serde_json::Value = serde_json::from_str(\n            &std::fs::read_to_string(&path).map_err(|e| format!(\"读取文件失败: {}\", e))?,\n        )\n        .map_err(|e| format!(\"解析 JSON 失败: {}\", e))?;\n\n        let now = chrono::Utc::now().timestamp();\n        content[\"disabled\"] = serde_json::Value::Bool(true);\n        content[\"disabled_at\"] = serde_json::Value::Number(now.into());\n        content[\"disabled_reason\"] = serde_json::Value::String(truncate_reason(reason, 800));\n\n        std::fs::write(&path, serde_json::to_string_pretty(&content).unwrap())\n            .map_err(|e| format!(\"写入文件失败: {}\", e))?;\n\n        // 【修复 Issue #3】从内存中移除禁用的账号，防止被60s锁定逻辑继续使用\n        self.tokens.remove(account_id);\n\n        tracing::warn!(\"Account disabled: {} ({:?})\", account_id, path);\n        Ok(())\n    }\n\n    /// 保存 project_id 到账号文件\n    async fn save_project_id(&self, account_id: &str, project_id: &str) -> Result<(), String> {\n        let entry = self.tokens.get(account_id)\n            .ok_or(\"账号不存在\")?;\n\n        let path = &entry.account_path;\n\n        let mut content: serde_json::Value = serde_json::from_str(\n            &std::fs::read_to_string(path).map_err(|e| format!(\"读取文件失败: {}\", e))?\n        ).map_err(|e| format!(\"解析 JSON 失败: {}\", e))?;\n\n        content[\"token\"][\"project_id\"] = serde_json::Value::String(project_id.to_string());\n\n        std::fs::write(path, serde_json::to_string_pretty(&content).unwrap())\n            .map_err(|e| format!(\"写入文件失败: {}\", e))?;\n\n        tracing::debug!(\"已保存 project_id 到账号 {}\", account_id);\n        Ok(())\n    }\n\n    /// 保存刷新后的 token 到账号文件\n    async fn save_refreshed_token(&self, account_id: &str, token_response: &crate::modules::oauth::TokenResponse) -> Result<(), String> {\n        let entry = self.tokens.get(account_id)\n            .ok_or(\"账号不存在\")?;\n\n        let path = &entry.account_path;\n\n        let mut content: serde_json::Value = serde_json::from_str(\n            &std::fs::read_to_string(path).map_err(|e| format!(\"读取文件失败: {}\", e))?\n        ).map_err(|e| format!(\"解析 JSON 失败: {}\", e))?;\n\n        let now = chrono::Utc::now().timestamp();\n\n        content[\"token\"][\"access_token\"] = serde_json::Value::String(token_response.access_token.clone());\n        content[\"token\"][\"expires_in\"] = serde_json::Value::Number(token_response.expires_in.into());\n        content[\"token\"][\"expiry_timestamp\"] = serde_json::Value::Number((now + token_response.expires_in).into());\n\n        std::fs::write(path, serde_json::to_string_pretty(&content).unwrap())\n            .map_err(|e| format!(\"写入文件失败: {}\", e))?;\n\n        tracing::debug!(\"已保存刷新后的 token 到账号 {}\", account_id);\n        Ok(())\n    }\n\n    pub fn len(&self) -> usize {\n        self.tokens.len()\n    }\n\n    /// 通过 email 获取指定账号的 Token（用于预热等需要指定账号的场景）\n    /// 此方法会自动刷新过期的 token\n    pub async fn get_token_by_email(\n        &self,\n        email: &str,\n    ) -> Result<(String, String, String, String, u64), String> {\n        // 查找账号信息\n        let token_info = {\n            let mut found = None;\n            for entry in self.tokens.iter() {\n                let token = entry.value();\n                if token.email == email {\n                    found = Some((\n                        token.account_id.clone(),\n                        token.access_token.clone(),\n                        token.refresh_token.clone(),\n                        token.timestamp,\n                        token.expires_in,\n                        chrono::Utc::now().timestamp(),\n                        token.project_id.clone(),\n                    ));\n                    break;\n                }\n            }\n            found\n        };\n\n        let (\n            account_id,\n            current_access_token,\n            refresh_token,\n            timestamp,\n            expires_in,\n            now,\n            project_id_opt,\n        ) = match token_info {\n            Some(info) => info,\n            None => return Err(format!(\"未找到账号: {}\", email)),\n        };\n\n        let project_id = project_id_opt\n            .filter(|s| !s.is_empty())\n            .unwrap_or_else(|| \"bamboo-precept-lgxtn\".to_string());\n\n        // 检查是否过期 (提前5分钟)\n        if now < timestamp + expires_in - 300 {\n            return Ok((current_access_token, project_id, email.to_string(), account_id, 0));\n        }\n\n        tracing::info!(\"[Warmup] Token for {} is expiring, refreshing...\", email);\n\n        // 调用 OAuth 刷新 token\n        match crate::modules::oauth::refresh_access_token(&refresh_token, Some(&account_id)).await {\n            Ok(token_response) => {\n                tracing::info!(\"[Warmup] Token refresh successful for {}\", email);\n                let new_now = chrono::Utc::now().timestamp();\n\n                // 更新缓存\n                if let Some(mut entry) = self.tokens.get_mut(&account_id) {\n                    entry.access_token = token_response.access_token.clone();\n                    entry.expires_in = token_response.expires_in;\n                    entry.timestamp = new_now;\n                }\n\n                // 保存到磁盘\n                let _ = self\n                    .save_refreshed_token(&account_id, &token_response)\n                    .await;\n\n                Ok((\n                    token_response.access_token,\n                    project_id,\n                    email.to_string(),\n                    account_id,\n                    0,\n                ))\n            }\n            Err(e) => Err(format!(\n                \"[Warmup] Token refresh failed for {}: {}\",\n                email, e\n            )),\n        }\n    }\n\n    // ===== 限流管理方法 =====\n\n    /// 标记账号限流(从外部调用,通常在 handler 中)\n    /// 参数为 email，内部会自动转换为 account_id\n    pub async fn mark_rate_limited(\n        &self,\n        email: &str,\n        status: u16,\n        retry_after_header: Option<&str>,\n        error_body: &str,\n    ) {\n        // [NEW] 检查熔断是否启用 (使用内存缓存，极快)\n        let config = self.circuit_breaker_config.read().await.clone();\n        if !config.enabled {\n            return;\n        }\n\n        // 【替代方案】转换 email -> account_id\n        let key = self.email_to_account_id(email).unwrap_or_else(|| email.to_string());\n\n        self.rate_limit_tracker.parse_from_error(\n            &key,\n            status,\n            retry_after_header,\n            error_body,\n            None,\n            &config.backoff_steps, // [NEW] 传入配置\n        );\n    }\n\n    /// 检查账号是否在限流中 (支持模型级)\n    pub async fn is_rate_limited(&self, account_id: &str, model: Option<&str>) -> bool {\n        // [NEW] 检查熔断是否启用\n        let config = self.circuit_breaker_config.read().await;\n        if !config.enabled {\n            return false;\n        }\n        self.rate_limit_tracker.is_rate_limited(account_id, model)\n    }\n\n    /// [NEW] 检查账号是否在限流中 (同步版本，仅用于 Iterator)\n    pub fn is_rate_limited_sync(&self, account_id: &str, model: Option<&str>) -> bool {\n        // 同步版本无法读取 async RwLock，这里使用 blocking_read\n        let config = self.circuit_breaker_config.blocking_read();\n        if !config.enabled {\n            return false;\n        }\n        self.rate_limit_tracker.is_rate_limited(account_id, model)\n    }\n\n    /// 获取距离限流重置还有多少秒\n    #[allow(dead_code)]\n    pub fn get_rate_limit_reset_seconds(&self, account_id: &str) -> Option<u64> {\n        self.rate_limit_tracker.get_reset_seconds(account_id)\n    }\n\n    /// 清除过期的限流记录\n    #[allow(dead_code)]\n    pub fn clean_expired_rate_limits(&self) {\n        self.rate_limit_tracker.cleanup_expired();\n    }\n\n    /// 【替代方案】通过 email 查找对应的 account_id\n    /// 用于将 handlers 传入的 email 转换为 tracker 使用的 account_id\n    fn email_to_account_id(&self, email: &str) -> Option<String> {\n        self.tokens\n            .iter()\n            .find(|entry| entry.value().email == email)\n            .map(|entry| entry.value().account_id.clone())\n    }\n\n    /// 清除指定账号的限流记录\n    pub fn clear_rate_limit(&self, account_id: &str) -> bool {\n        self.rate_limit_tracker.clear(account_id)\n    }\n\n    /// 清除所有限流记录\n    pub fn clear_all_rate_limits(&self) {\n        self.rate_limit_tracker.clear_all();\n    }\n\n    /// 标记账号请求成功，重置连续失败计数\n    ///\n    /// 在请求成功完成后调用，将该账号的失败计数归零，\n    /// 下次失败时从最短的锁定时间开始（智能限流）。\n    pub fn mark_account_success(&self, account_id: &str) {\n        self.rate_limit_tracker.mark_success(account_id);\n    }\n\n    /// 检查是否有可用的 Google 账号\n    ///\n    /// 用于\"仅兜底\"模式的智能判断:当所有 Google 账号不可用时才使用外部提供商。\n    ///\n    /// # 参数\n    /// - `quota_group`: 配额组(\"claude\" 或 \"gemini\"),暂未使用但保留用于未来扩展\n    /// - `target_model`: 目标模型名称(已归一化),用于配额保护检查\n    ///\n    /// # 返回值\n    /// - `true`: 至少有一个可用账号(未限流且未被配额保护)\n    /// - `false`: 所有账号都不可用(被限流或被配额保护)\n    ///\n    /// # 示例\n    /// ```ignore\n    /// // 检查是否有可用账号处理 claude-sonnet 请求\n    /// let has_available = token_manager.has_available_account(\"claude\", \"claude-sonnet-4-20250514\").await;\n    /// if !has_available {\n    ///     // 切换到外部提供商\n    /// }\n    /// ```\n    pub async fn has_available_account(&self, _quota_group: &str, target_model: &str) -> bool {\n        // 检查配额保护是否启用\n        let quota_protection_enabled = crate::modules::config::load_app_config()\n            .map(|cfg| cfg.quota_protection.enabled)\n            .unwrap_or(false);\n\n        // 遍历所有账号,检查是否有可用的\n        for entry in self.tokens.iter() {\n            let token = entry.value();\n\n            // 1. 检查是否被限流\n            if self.is_rate_limited(&token.account_id, None).await {\n                tracing::debug!(\n                    \"[Fallback Check] Account {} is rate-limited, skipping\",\n                    token.email\n                );\n                continue;\n            }\n\n            // 2. 检查是否被配额保护(如果启用)\n            if quota_protection_enabled && token.protected_models.contains(target_model) {\n                tracing::debug!(\n                    \"[Fallback Check] Account {} is quota-protected for model {}, skipping\",\n                    token.email,\n                    target_model\n                );\n                continue;\n            }\n\n            // 找到至少一个可用账号\n            tracing::debug!(\n                \"[Fallback Check] Found available account: {} for model {}\",\n                token.email,\n                target_model\n            );\n            return true;\n        }\n\n        // 所有账号都不可用\n        tracing::info!(\n            \"[Fallback Check] No available Google accounts for model {}, fallback should be triggered\",\n            target_model\n        );\n        false\n    }\n\n    /// 从账号文件获取配额刷新时间\n    ///\n    /// 返回该账号最近的配额刷新时间字符串（ISO 8601 格式）\n    ///\n    /// # 参数\n    /// - `account_id`: 账号 ID（用于查找账号文件）\n    pub fn get_quota_reset_time(&self, account_id: &str) -> Option<String> {\n        // 直接用 account_id 查找账号文件（文件名是 {account_id}.json）\n        let account_path = self.data_dir.join(\"accounts\").join(format!(\"{}.json\", account_id));\n\n        let content = std::fs::read_to_string(&account_path).ok()?;\n        let account: serde_json::Value = serde_json::from_str(&content).ok()?;\n\n        // 获取 quota.models 中最早的 reset_time（最保守的锁定策略）\n        account\n            .get(\"quota\")\n            .and_then(|q| q.get(\"models\"))\n            .and_then(|m| m.as_array())\n            .and_then(|models| {\n                models.iter()\n                    .filter_map(|m| m.get(\"reset_time\").and_then(|r| r.as_str()))\n                    .filter(|s| !s.is_empty())\n                    .min()\n                    .map(|s| s.to_string())\n            })\n    }\n\n    /// 使用配额刷新时间精确锁定账号\n    ///\n    /// 当 API 返回 429 但没有 quotaResetDelay 时,尝试使用账号的配额刷新时间\n    ///\n    /// # 参数\n    /// - `account_id`: 账号 ID\n    /// - `reason`: 限流原因（QuotaExhausted/ServerError 等）\n    /// - `model`: 可选的模型名称,用于模型级别限流\n    pub fn set_precise_lockout(&self, account_id: &str, reason: crate::proxy::rate_limit::RateLimitReason, model: Option<String>) -> bool {\n        // [FIX #2209] 统一归一化模型名称\n        let normalized_model = model.as_deref().and_then(|m| crate::proxy::common::model_mapping::normalize_to_standard_id(m));\n        let model_to_lock = normalized_model.or(model);\n\n        if let Some(reset_time_str) = self.get_quota_reset_time(account_id) {\n            tracing::info!(\"找到账号 {} 的配额刷新时间: {}\", account_id, reset_time_str);\n            self.rate_limit_tracker.set_lockout_until_iso(account_id, &reset_time_str, reason, model_to_lock)\n        } else {\n            tracing::debug!(\"未找到账号 {} 的配额刷新时间,将使用默认退避策略\", account_id);\n            false\n        }\n    }\n\n    /// 实时刷新配额并精确锁定账号\n    ///\n    /// 当 429 发生时调用此方法:\n    /// 1. 实时调用配额刷新 API 获取最新的 reset_time\n    /// 2. 使用最新的 reset_time 精确锁定账号\n    /// 3. 如果获取失败,返回 false 让调用方使用回退策略\n    ///\n    /// # 参数\n    /// - `model`: 可选的模型名称,用于模型级别限流\n    pub async fn fetch_and_lock_with_realtime_quota(\n        &self,\n        email: &str,\n        reason: crate::proxy::rate_limit::RateLimitReason,\n        model: Option<String>,\n    ) -> bool {\n        // 1. 从 tokens 中获取该账号的 access_token 和 account_id\n        // 同时获取 account_id，确保锁定 key 与检查 key 一致\n        let (access_token, account_id) = {\n            let mut found: Option<(String, String)> = None;\n            for entry in self.tokens.iter() {\n                if entry.value().email == email {\n                    found = Some((\n                        entry.value().access_token.clone(),\n                        entry.value().account_id.clone(),\n                    ));\n                    break;\n                }\n            }\n            found\n        }.unzip();\n\n        let (access_token, account_id) = match (access_token, account_id) {\n            (Some(token), Some(id)) => (token, id),\n            _ => {\n                tracing::warn!(\"无法找到账号 {} 的 access_token,无法实时刷新配额\", email);\n                return false;\n            }\n        };\n\n        // 2. 调用配额刷新 API\n        tracing::info!(\"账号 {} 正在实时刷新配额...\", email);\n        match crate::modules::quota::fetch_quota(&access_token, email, Some(&account_id)).await {\n            Ok((quota_data, _project_id)) => {\n                // 3. 从最新配额中提取 reset_time\n                let earliest_reset = quota_data\n                    .models\n                    .iter()\n                    .filter_map(|m| {\n                        if !m.reset_time.is_empty() {\n                            Some(m.reset_time.as_str())\n                        } else {\n                            None\n                        }\n                    })\n                    .min();\n\n                if let Some(reset_time_str) = earliest_reset {\n                    tracing::info!(\n                        \"账号 {} 实时配额刷新成功,reset_time: {}\",\n                        email,\n                        reset_time_str\n                    );\n                    \n                    // [FIX #2209] 统一归一化模型名称\n                    let normalized_model = model.as_deref().and_then(|m| crate::proxy::common::model_mapping::normalize_to_standard_id(m));\n                    let model_to_lock = normalized_model.or(model);\n\n                    // [FIX] 使用 account_id 作为 key，与 is_rate_limited 检查一致\n                    self.rate_limit_tracker.set_lockout_until_iso(&account_id, reset_time_str, reason, model_to_lock)\n                } else {\n                    tracing::warn!(\"账号 {} 配额刷新成功但未找到 reset_time\", email);\n                    false\n                }\n            }\n            Err(e) => {\n                tracing::warn!(\"账号 {} 实时配额刷新失败: {:?}\", email, e);\n                false\n            }\n        }\n    }\n\n    /// 标记账号限流(异步版本,支持实时配额刷新)\n    ///\n    /// 三级降级策略:\n    /// 1. 优先: API 返回 quotaResetDelay → 直接使用\n    /// 2. 次优: 实时刷新配额 → 获取最新 reset_time\n    /// 3. 保底: 使用本地缓存配额 → 读取账号文件\n    /// 4. 兜底: 指数退避策略 → 默认锁定时间\n    ///\n    /// # 参数\n    /// - `email`: 账号邮箱,用于查找账号信息\n    /// - `status`: HTTP 状态码（如 429、500 等）\n    /// - `retry_after_header`: 可选的 Retry-After 响应头\n    /// - `error_body`: 错误响应体,用于解析 quotaResetDelay\n    /// - `model`: 可选的模型名称,用于模型级别限流\n    pub async fn mark_rate_limited_async(\n        &self,\n        email: &str,\n        status: u16,\n        retry_after_header: Option<&str>,\n        error_body: &str,\n        model: Option<&str>, // 🆕 新增模型参数\n    ) {\n        // [FIX #2209] 统一归一化模型名称，确保锁定 Key 与负载均衡检查 Key 一致\n        let normalized_model = model.and_then(|m| crate::proxy::common::model_mapping::normalize_to_standard_id(m));\n        let model_to_track = normalized_model.as_deref().or(model);\n\n        // [NEW] 检查熔断是否启用\n        let config = self.circuit_breaker_config.read().await.clone();\n        if !config.enabled {\n            return;\n        }\n\n        // [FIX] Convert email to account_id for consistent tracking\n        let account_id = self.email_to_account_id(email).unwrap_or_else(|| email.to_string());\n\n        // 检查 API 是否返回了精确的重试时间\n        let has_explicit_retry_time = retry_after_header.is_some() ||\n            error_body.contains(\"quotaResetDelay\");\n\n        if has_explicit_retry_time {\n            // API 返回了精确时间(quotaResetDelay),直接使用,无需实时刷新\n            if let Some(m) = model {\n                tracing::debug!(\n                    \"账号 {} 的模型 {} 的 429 响应包含 quotaResetDelay,直接使用 API 返回的时间\",\n                    account_id,\n                    m\n                );\n            } else {\n                tracing::debug!(\n                    \"账号 {} 的 429 响应包含 quotaResetDelay,直接使用 API 返回的时间\",\n                    account_id\n                );\n            }\n            self.rate_limit_tracker.parse_from_error(\n                &account_id,\n                status,\n                retry_after_header,\n                error_body,\n                model_to_track.map(|s| s.to_string()),\n                &config.backoff_steps, // [NEW] 传入配置\n            );\n            return;\n        }\n\n        // 确定限流原因\n        let reason = if error_body.to_lowercase().contains(\"model_capacity\") {\n            crate::proxy::rate_limit::RateLimitReason::ModelCapacityExhausted\n        } else if error_body.to_lowercase().contains(\"exhausted\")\n            || error_body.to_lowercase().contains(\"quota\")\n        {\n            crate::proxy::rate_limit::RateLimitReason::QuotaExhausted\n        } else {\n            crate::proxy::rate_limit::RateLimitReason::Unknown\n        };\n\n        // API 未返回 quotaResetDelay,需要实时刷新配额获取精确锁定时间\n        if let Some(m) = model_to_track {\n            tracing::info!(\n                \"账号 {} 的模型 {} 的 429 响应未包含 quotaResetDelay,尝试实时刷新配额...\",\n                account_id,\n                m\n            );\n        } else {\n            tracing::info!(\n                \"账号 {} 的 429 响应未包含 quotaResetDelay,尝试实时刷新配额...\",\n                account_id\n            );\n        }\n\n        // [FIX] 传入 email 而不是 account_id，因为 fetch_and_lock_with_realtime_quota 期望 email\n        if self.fetch_and_lock_with_realtime_quota(email, reason, model_to_track.map(|s| s.to_string())).await {\n            tracing::info!(\"账号 {} 已使用实时配额精确锁定\", email);\n            return;\n        }\n\n        // 实时刷新失败,尝试使用本地缓存的配额刷新时间\n        if self.set_precise_lockout(&account_id, reason, model_to_track.map(|s| s.to_string())) {\n            tracing::info!(\"账号 {} 已使用本地缓存配额锁定\", account_id);\n            return;\n        }\n\n        // 都失败了,回退到指数退避策略\n        tracing::warn!(\"账号 {} 无法获取配额刷新时间,使用指数退避策略\", account_id);\n        self.rate_limit_tracker.parse_from_error(\n            &account_id,\n            status,\n            retry_after_header,\n            error_body,\n            model_to_track.map(|s| s.to_string()),\n            &config.backoff_steps, // [NEW] 传入配置\n        );\n    }\n\n    // ===== 调度配置相关方法 =====\n\n    /// 获取当前调度配置\n    pub async fn get_sticky_config(&self) -> StickySessionConfig {\n        self.sticky_config.read().await.clone()\n    }\n\n    /// 更新调度配置\n    pub async fn update_sticky_config(&self, new_config: StickySessionConfig) {\n        let mut config = self.sticky_config.write().await;\n        *config = new_config;\n        tracing::debug!(\"Scheduling configuration updated: {:?}\", *config);\n    }\n\n    /// [NEW] 更新熔断器配置\n    pub async fn update_circuit_breaker_config(&self, config: crate::models::CircuitBreakerConfig) {\n        let mut lock = self.circuit_breaker_config.write().await;\n        *lock = config;\n        tracing::debug!(\"Circuit breaker configuration updated\");\n    }\n\n    /// [NEW] 获取熔断器配置\n    pub async fn get_circuit_breaker_config(&self) -> crate::models::CircuitBreakerConfig {\n        self.circuit_breaker_config.read().await.clone()\n    }\n\n    /// 清除特定会话的粘性映射\n    #[allow(dead_code)]\n    pub fn clear_session_binding(&self, session_id: &str) {\n        self.session_accounts.remove(session_id);\n    }\n\n    /// 清除所有会话的粘性映射\n    pub fn clear_all_sessions(&self) {\n        self.session_accounts.clear();\n    }\n\n    // ===== [FIX #820] 固定账号模式相关方法 =====\n\n    /// 设置优先使用的账号ID（固定账号模式）\n    /// 传入 Some(account_id) 启用固定账号模式，传入 None 恢复轮询模式\n    pub async fn set_preferred_account(&self, account_id: Option<String>) {\n        let mut preferred = self.preferred_account_id.write().await;\n        if let Some(ref id) = account_id {\n            tracing::info!(\"🔒 [FIX #820] Fixed account mode enabled: {}\", id);\n        } else {\n            tracing::info!(\"🔄 [FIX #820] Round-robin mode enabled (no preferred account)\");\n        }\n        *preferred = account_id;\n    }\n\n    /// 获取当前优先使用的账号ID\n    pub async fn get_preferred_account(&self) -> Option<String> {\n        self.preferred_account_id.read().await.clone()\n    }\n\n    /// 使用 Authorization Code 交换 Refresh Token (Web OAuth)\n    pub async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result<String, String> {\n        crate::modules::oauth::exchange_code(code, redirect_uri)\n            .await\n            .and_then(|t| {\n                t.refresh_token\n                    .ok_or_else(|| \"No refresh token returned by Google\".to_string())\n            })\n    }\n\n    /// 获取 OAuth URL (支持自定义 Redirect URI)\n    pub fn get_oauth_url_with_redirect(&self, redirect_uri: &str, state: &str) -> String {\n        crate::modules::oauth::get_auth_url(redirect_uri, state)\n    }\n\n    /// 获取用户信息 (Email 等)\n    pub async fn get_user_info(\n        &self,\n        refresh_token: &str,\n    ) -> Result<crate::modules::oauth::UserInfo, String> {\n        // 先获取 Access Token\n        let token = crate::modules::oauth::refresh_access_token(refresh_token, None)\n            .await\n            .map_err(|e| format!(\"刷新 Access Token 失败: {}\", e))?;\n\n        crate::modules::oauth::get_user_info(&token.access_token, None).await\n    }\n\n    /// 添加新账号 (纯后端实现，不依赖 Tauri AppHandle)\n    pub async fn add_account(&self, email: &str, refresh_token: &str) -> Result<(), String> {\n        // 1. 获取 Access Token (验证 refresh_token 有效性)\n        let token_info = crate::modules::oauth::refresh_access_token(refresh_token, None)\n            .await\n            .map_err(|e| format!(\"Invalid refresh token: {}\", e))?;\n\n        // 2. 获取项目 ID (Project ID)\n        let project_id = crate::proxy::project_resolver::fetch_project_id(&token_info.access_token)\n            .await\n            .unwrap_or_else(|_| \"bamboo-precept-lgxtn\".to_string()); // Fallback\n\n        // 3. 委托给 modules::account::add_account 处理 (包含文件写入、索引更新、锁)\n        let email_clone = email.to_string();\n        let refresh_token_clone = refresh_token.to_string();\n\n        tokio::task::spawn_blocking(move || {\n            let token_data = crate::models::TokenData::new(\n                token_info.access_token,\n                refresh_token_clone,\n                token_info.expires_in,\n                Some(email_clone.clone()),\n                Some(project_id),\n                None, // session_id\n            );\n\n            crate::modules::account::upsert_account(email_clone, None, token_data)\n        })\n        .await\n        .map_err(|e| format!(\"Task join error: {}\", e))?\n        .map_err(|e| format!(\"Failed to save account: {}\", e))?;\n\n        // 4. 重新加载 (更新内存)\n        self.reload_all_accounts().await.map(|_| ())\n    }\n\n    /// 记录请求成功，增加健康分\n    pub fn record_success(&self, account_id: &str) {\n        self.health_scores\n            .entry(account_id.to_string())\n            .and_modify(|s| *s = (*s + 0.05).min(1.0))\n            .or_insert(1.0);\n        tracing::debug!(\"📈 Health score increased for account {}\", account_id);\n    }\n\n    /// 记录请求失败，降低健康分\n    pub fn record_failure(&self, account_id: &str) {\n        self.health_scores\n            .entry(account_id.to_string())\n            .and_modify(|s| *s = (*s - 0.2).max(0.0))\n            .or_insert(0.8);\n        tracing::warn!(\"📉 Health score decreased for account {}\", account_id);\n    }\n\n    /// [NEW] 从账号配额信息中提取最近的刷新时间戳\n    ///\n    /// Claude 模型（sonnet/opus）共用同一个刷新时间，只需取 claude 系列的 reset_time\n    /// 返回 Unix 时间戳（秒），用于排序时比较\n    fn extract_earliest_reset_time(&self, account: &serde_json::Value) -> Option<i64> {\n        let models = account\n            .get(\"quota\")\n            .and_then(|q| q.get(\"models\"))\n            .and_then(|m| m.as_array())?;\n\n        let mut earliest_ts: Option<i64> = None;\n\n        for model in models {\n            // 优先取 claude 系列的 reset_time（sonnet/opus 共用）\n            let model_name = model.get(\"name\").and_then(|n| n.as_str()).unwrap_or(\"\");\n            if !model_name.contains(\"claude\") {\n                continue;\n            }\n\n            if let Some(reset_time_str) = model.get(\"reset_time\").and_then(|r| r.as_str()) {\n                if reset_time_str.is_empty() {\n                    continue;\n                }\n                // 解析 ISO 8601 时间字符串为时间戳\n                if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(reset_time_str) {\n                    let ts = dt.timestamp();\n                    if earliest_ts.is_none() || ts < earliest_ts.unwrap() {\n                        earliest_ts = Some(ts);\n                    }\n                }\n            }\n        }\n\n        // 如果没有 claude 模型的时间，尝试取任意模型的最近时间\n        if earliest_ts.is_none() {\n            for model in models {\n                if let Some(reset_time_str) = model.get(\"reset_time\").and_then(|r| r.as_str()) {\n                    if reset_time_str.is_empty() {\n                        continue;\n                    }\n                    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(reset_time_str) {\n                        let ts = dt.timestamp();\n                        if earliest_ts.is_none() || ts < earliest_ts.unwrap() {\n                            earliest_ts = Some(ts);\n                        }\n                    }\n                }\n            }\n        }\n\n        earliest_ts\n    }\n\n    /// 获取当前所有可用账号中收集到的官方下发的所有动态模型集合\n    pub fn get_all_collected_models(&self) -> std::collections::HashSet<String> {\n        let mut all_models = std::collections::HashSet::new();\n        for entry in self.tokens.iter() {\n            let token = entry.value();\n            for model_id in token.model_quotas.keys() {\n                all_models.insert(model_id.clone());\n            }\n        }\n        all_models\n    }\n\n    /// [NEW] 从指定账号的动态额度数据中获取特定模型的 max_output_tokens\n    ///\n    /// # 返回\n    /// - `Some(u64)`: 找到了动态限额数据\n    /// - `None`: 账号不存在或该模型无数据（调用方应继续查静态默认表）\n    pub fn get_model_output_limit_for_account(&self, account_id: &str, model_name: &str) -> Option<u64> {\n        self.tokens\n            .get(account_id)\n            .and_then(|token| token.model_limits.get(model_name).copied())\n    }\n\n    /// Helper to find account ID by email\n    pub fn get_account_id_by_email(&self, email: &str) -> Option<String> {\n        for entry in self.tokens.iter() {\n            if entry.value().email == email {\n                return Some(entry.key().clone());\n            }\n        }\n        None\n    }\n\n    /// Set validation blocked status for an account (internal)\n    pub async fn set_validation_block(&self, account_id: &str, block_until: i64, reason: &str) -> Result<(), String> {\n        // 1. Update memory\n        if let Some(mut token) = self.tokens.get_mut(account_id) {\n             token.validation_blocked = true;\n             token.validation_blocked_until = block_until;\n        }\n\n        // 2. Persist to disk\n        let path = self.data_dir.join(\"accounts\").join(format!(\"{}.json\", account_id));\n        if !path.exists() {\n             return Err(format!(\"Account file not found: {:?}\", path));\n        }\n\n        let content = std::fs::read_to_string(&path)\n             .map_err(|e| format!(\"Failed to read account file: {}\", e))?;\n\n        let mut account: serde_json::Value = serde_json::from_str(&content)\n             .map_err(|e| format!(\"Failed to parse account JSON: {}\", e))?;\n\n        account[\"validation_blocked\"] = serde_json::Value::Bool(true);\n        account[\"validation_blocked_until\"] = serde_json::Value::Number(serde_json::Number::from(block_until));\n        account[\"validation_blocked_reason\"] = serde_json::Value::String(reason.to_string());\n\n        // [NEW] 尝试从消息中提取验证链接 (#1522)\n        let extracted_url = if let Ok(parsed_json) = serde_json::from_str::<serde_json::Value>(reason) {\n             // 尝试从特定的 Google RPC error 结构中取\n             let mut url = None;\n             if let Some(details) = parsed_json.pointer(\"/error/details\") {\n                 if let Some(arr) = details.as_array() {\n                     for detail in arr {\n                         if let Some(meta) = detail.get(\"metadata\") {\n                             if let Some(v_url) = meta.get(\"validation_url\").and_then(|v| v.as_str()) {\n                                 url = Some(v_url.to_string());\n                                 break;\n                             }\n                             if let Some(a_url) = meta.get(\"appeal_url\").and_then(|v| v.as_str()) {\n                                 url = Some(a_url.to_string());\n                                 break;\n                             }\n                         }\n                     }\n                 }\n             }\n             url\n        } else {\n             // 回退方案：通过更严格的正则及反序列化解码可能的 \\u0026\n             let url_regex = regex::Regex::new(r#\"https://[^\\s\"'\\\\]+\"#).unwrap();\n             url_regex.find(reason).map(|m| {\n                 let raw_url = m.as_str().to_string();\n                 raw_url.replace(\"\\\\u0026\", \"&\")\n             })\n        };\n        \n        if let Some(url) = extracted_url {\n             account[\"validation_url\"] = serde_json::Value::String(url.clone());\n             if let Some(mut token) = self.tokens.get_mut(account_id) {\n                 token.validation_url = Some(url);\n             }\n        }\n\n        // Clear sticky session if blocked\n        self.session_accounts.retain(|_, v| *v != account_id);\n\n        let json_str = serde_json::to_string_pretty(&account)\n             .map_err(|e| format!(\"Failed to serialize account JSON: {}\", e))?;\n\n        std::fs::write(&path, json_str)\n             .map_err(|e| format!(\"Failed to write account file: {}\", e))?;\n\n        tracing::info!(\n             \"🚫 Account {} validation blocked until {} (reason: {})\",\n             account_id,\n             block_until,\n             reason\n        );\n\n        Ok(())\n    }\n\n    /// Public method to set validation block (called from handlers)\n    pub async fn set_validation_block_public(&self, account_id: &str, block_until: i64, reason: &str) -> Result<(), String> {\n        self.set_validation_block(account_id, block_until, reason).await\n    }\n\n    /// Set is_forbidden status for an account (called when proxy encounters 403)\n    pub async fn set_forbidden(&self, account_id: &str, reason: &str) -> Result<(), String> {\n        // [FIX] 调用封装好的模块函数，确保线程安全地更新账号文件和索引\n        crate::modules::account::mark_account_forbidden(account_id, reason)?;\n\n        // Clear sticky session if forbidden\n        self.session_accounts.retain(|_, v| *v != account_id);\n\n        // [FIX] 从内存池中移除账号，避免重试时再次选中\n        self.remove_account(account_id);\n\n        tracing::warn!(\n            \"🚫 Account {} marked as forbidden (403): {}\",\n            account_id,\n            truncate_reason(reason, 1000)\n        );\n\n        Ok(())\n    }\n}\n\n/// 截断过长的原因字符串\nfn truncate_reason(reason: &str, max_len: usize) -> String {\n    if reason.len() <= max_len {\n        reason.to_string()\n    } else {\n        // [FIX] 确保字符截断在有效边界，防止 panic\n        let end = reason\n            .char_indices()\n            .map(|(i, _)| i)\n            .filter(|&i| i <= max_len - 3)\n            .last()\n            .unwrap_or(0);\n        format!(\"{}...\", &reason[..end])\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::cmp::Ordering;\n\n    #[tokio::test]\n    async fn test_reload_account_purges_cache_when_account_becomes_proxy_disabled() {\n        let tmp_root = std::env::temp_dir().join(format!(\n            \"antigravity-token-manager-test-{}\",\n            uuid::Uuid::new_v4()\n        ));\n        let accounts_dir = tmp_root.join(\"accounts\");\n        std::fs::create_dir_all(&accounts_dir).unwrap();\n\n        let account_id = \"acc1\";\n        let email = \"a@test.com\";\n        let now = chrono::Utc::now().timestamp();\n        let account_path = accounts_dir.join(format!(\"{}.json\", account_id));\n\n        let account_json = serde_json::json!({\n            \"id\": account_id,\n            \"email\": email,\n            \"token\": {\n                \"access_token\": \"atk\",\n                \"refresh_token\": \"rtk\",\n                \"expires_in\": 3600,\n                \"expiry_timestamp\": now + 3600\n            },\n            \"disabled\": false,\n            \"proxy_disabled\": false,\n            \"created_at\": now,\n            \"last_used\": now\n        });\n        std::fs::write(&account_path, serde_json::to_string_pretty(&account_json).unwrap()).unwrap();\n\n        let manager = TokenManager::new(tmp_root.clone());\n        manager.load_accounts().await.unwrap();\n        assert!(manager.tokens.get(account_id).is_some());\n\n        // Prime extra caches to ensure remove_account() is really called.\n        manager\n            .session_accounts\n            .insert(\"sid1\".to_string(), account_id.to_string());\n        {\n            let mut preferred = manager.preferred_account_id.write().await;\n            *preferred = Some(account_id.to_string());\n        }\n\n        // Mark account as proxy-disabled on disk (manual disable).\n        let mut disabled_json = account_json.clone();\n        disabled_json[\"proxy_disabled\"] = serde_json::Value::Bool(true);\n        disabled_json[\"proxy_disabled_reason\"] = serde_json::Value::String(\"manual\".to_string());\n        disabled_json[\"proxy_disabled_at\"] = serde_json::Value::Number(now.into());\n        std::fs::write(&account_path, serde_json::to_string_pretty(&disabled_json).unwrap()).unwrap();\n\n        manager.reload_account(account_id).await.unwrap();\n\n        assert!(manager.tokens.get(account_id).is_none());\n        assert!(manager.session_accounts.get(\"sid1\").is_none());\n        assert!(manager.preferred_account_id.read().await.is_none());\n\n        let _ = std::fs::remove_dir_all(&tmp_root);\n    }\n\n    #[tokio::test]\n    async fn test_fixed_account_mode_skips_preferred_when_disabled_on_disk_without_reload() {\n        let tmp_root = std::env::temp_dir().join(format!(\n            \"antigravity-token-manager-test-fixed-mode-{}\",\n            uuid::Uuid::new_v4()\n        ));\n        let accounts_dir = tmp_root.join(\"accounts\");\n        std::fs::create_dir_all(&accounts_dir).unwrap();\n\n        let now = chrono::Utc::now().timestamp();\n\n        let write_account = |id: &str, email: &str, proxy_disabled: bool| {\n            let account_path = accounts_dir.join(format!(\"{}.json\", id));\n            let json = serde_json::json!({\n                \"id\": id,\n                \"email\": email,\n                \"token\": {\n                    \"access_token\": format!(\"atk-{}\", id),\n                    \"refresh_token\": format!(\"rtk-{}\", id),\n                    \"expires_in\": 3600,\n                    \"expiry_timestamp\": now + 3600,\n                    \"project_id\": format!(\"pid-{}\", id)\n                },\n                \"disabled\": false,\n                \"proxy_disabled\": proxy_disabled,\n                \"proxy_disabled_reason\": if proxy_disabled { \"manual\" } else { \"\" },\n                \"created_at\": now,\n                \"last_used\": now\n            });\n            std::fs::write(&account_path, serde_json::to_string_pretty(&json).unwrap()).unwrap();\n        };\n\n        // Two accounts in pool.\n        write_account(\"acc1\", \"a@test.com\", false);\n        write_account(\"acc2\", \"b@test.com\", false);\n\n        let manager = TokenManager::new(tmp_root.clone());\n        manager.load_accounts().await.unwrap();\n\n        // Enable fixed account mode for acc1.\n        manager.set_preferred_account(Some(\"acc1\".to_string())).await;\n\n        // Disable acc1 on disk WITHOUT reloading the in-memory pool (simulates stale cache).\n        write_account(\"acc1\", \"a@test.com\", true);\n\n        let (_token, _project_id, email, account_id, _wait_ms) = manager\n            .get_token(\"gemini\", false, Some(\"sid1\"), \"gemini-1.5-flash\")\n            .await\n            .unwrap();\n\n        // Should fall back to another account instead of using the disabled preferred one.\n        assert_eq!(account_id, \"acc2\");\n        assert_eq!(email, \"b@test.com\");\n        assert!(manager.tokens.get(\"acc1\").is_none());\n        assert!(manager.get_preferred_account().await.is_none());\n\n        let _ = std::fs::remove_dir_all(&tmp_root);\n    }\n\n    #[tokio::test]\n    async fn test_sticky_session_skips_bound_account_when_disabled_on_disk_without_reload() {\n        let tmp_root = std::env::temp_dir().join(format!(\n            \"antigravity-token-manager-test-sticky-disabled-{}\",\n            uuid::Uuid::new_v4()\n        ));\n        let accounts_dir = tmp_root.join(\"accounts\");\n        std::fs::create_dir_all(&accounts_dir).unwrap();\n\n        let now = chrono::Utc::now().timestamp();\n\n        let write_account = |id: &str, email: &str, percentage: i64, proxy_disabled: bool| {\n            let account_path = accounts_dir.join(format!(\"{}.json\", id));\n            let json = serde_json::json!({\n                \"id\": id,\n                \"email\": email,\n                \"token\": {\n                    \"access_token\": format!(\"atk-{}\", id),\n                    \"refresh_token\": format!(\"rtk-{}\", id),\n                    \"expires_in\": 3600,\n                    \"expiry_timestamp\": now + 3600,\n                    \"project_id\": format!(\"pid-{}\", id)\n                },\n                \"quota\": {\n                    \"models\": [\n                        { \"name\": \"gemini-1.5-flash\", \"percentage\": percentage }\n                    ]\n                },\n                \"disabled\": false,\n                \"proxy_disabled\": proxy_disabled,\n                \"proxy_disabled_reason\": if proxy_disabled { \"manual\" } else { \"\" },\n                \"created_at\": now,\n                \"last_used\": now\n            });\n            std::fs::write(&account_path, serde_json::to_string_pretty(&json).unwrap()).unwrap();\n        };\n\n        // Two accounts in pool. acc1 has higher quota -> should be selected and bound first.\n        write_account(\"acc1\", \"a@test.com\", 90, false);\n        write_account(\"acc2\", \"b@test.com\", 10, false);\n\n        let manager = TokenManager::new(tmp_root.clone());\n        manager.load_accounts().await.unwrap();\n\n        // Prime: first request should bind the session to acc1.\n        let (_token, _project_id, _email, account_id, _wait_ms) = manager\n            .get_token(\"gemini\", false, Some(\"sid1\"), \"gemini-1.5-flash\")\n            .await\n            .unwrap();\n        assert_eq!(account_id, \"acc1\");\n        assert_eq!(\n            manager.session_accounts.get(\"sid1\").map(|v| v.clone()),\n            Some(\"acc1\".to_string())\n        );\n\n        // Disable acc1 on disk WITHOUT reloading the in-memory pool (simulates stale cache).\n        write_account(\"acc1\", \"a@test.com\", 90, true);\n\n        let (_token, _project_id, email, account_id, _wait_ms) = manager\n            .get_token(\"gemini\", false, Some(\"sid1\"), \"gemini-1.5-flash\")\n            .await\n            .unwrap();\n\n        // Should fall back to another account instead of reusing the disabled bound one.\n        assert_eq!(account_id, \"acc2\");\n        assert_eq!(email, \"b@test.com\");\n        assert!(manager.tokens.get(\"acc1\").is_none());\n        assert_ne!(\n            manager.session_accounts.get(\"sid1\").map(|v| v.clone()),\n            Some(\"acc1\".to_string())\n        );\n\n        let _ = std::fs::remove_dir_all(&tmp_root);\n    }\n\n    /// 创建测试用的 ProxyToken\n    fn create_test_token(\n        email: &str,\n        tier: Option<&str>,\n        health_score: f32,\n        reset_time: Option<i64>,\n        remaining_quota: Option<i32>,\n    ) -> ProxyToken {\n        ProxyToken {\n            account_id: email.to_string(),\n            access_token: \"test_token\".to_string(),\n            refresh_token: \"test_refresh\".to_string(),\n            expires_in: 3600,\n            timestamp: chrono::Utc::now().timestamp() + 3600,\n            email: email.to_string(),\n            account_path: PathBuf::from(\"/tmp/test\"),\n            project_id: None,\n            subscription_tier: tier.map(|s| s.to_string()),\n            remaining_quota,\n            protected_models: HashSet::new(),\n            health_score,\n            reset_time,\n            validation_blocked: false,\n            validation_blocked_until: 0,\n            validation_url: None,\n            model_quotas: HashMap::new(),\n            model_limits: HashMap::new(),\n        }\n    }\n\n    /// 测试排序比较函数（与 get_token_internal 中的逻辑一致）\n    fn compare_tokens(a: &ProxyToken, b: &ProxyToken) -> Ordering {\n        const RESET_TIME_THRESHOLD_SECS: i64 = 600; // 10 分钟阈值\n\n        let tier_priority = |tier: &Option<String>| {\n            let t = tier.as_deref().unwrap_or(\"\").to_lowercase();\n            if t.contains(\"ultra\") { 0 }\n            else if t.contains(\"pro\") { 1 }\n            else if t.contains(\"free\") { 2 }\n            else { 3 }\n        };\n\n        // First: compare by subscription tier\n        let tier_cmp = tier_priority(&a.subscription_tier).cmp(&tier_priority(&b.subscription_tier));\n        if tier_cmp != Ordering::Equal {\n            return tier_cmp;\n        }\n\n        // Second: compare by health score (higher is better)\n        let health_cmp = b.health_score.partial_cmp(&a.health_score).unwrap_or(Ordering::Equal);\n        if health_cmp != Ordering::Equal {\n            return health_cmp;\n        }\n\n        // Third: compare by reset time (earlier/closer is better)\n        let reset_a = a.reset_time.unwrap_or(i64::MAX);\n        let reset_b = b.reset_time.unwrap_or(i64::MAX);\n        let reset_diff = (reset_a - reset_b).abs();\n\n        if reset_diff >= RESET_TIME_THRESHOLD_SECS {\n            let reset_cmp = reset_a.cmp(&reset_b);\n            if reset_cmp != Ordering::Equal {\n                return reset_cmp;\n            }\n        }\n\n        // Fourth: compare by remaining quota percentage (higher is better)\n        let quota_a = a.remaining_quota.unwrap_or(0);\n        let quota_b = b.remaining_quota.unwrap_or(0);\n        quota_b.cmp(&quota_a)\n    }\n\n    #[test]\n    fn test_sorting_tier_priority() {\n        // ULTRA > PRO > FREE\n        let ultra = create_test_token(\"ultra@test.com\", Some(\"ULTRA\"), 1.0, None, Some(50));\n        let pro = create_test_token(\"pro@test.com\", Some(\"PRO\"), 1.0, None, Some(50));\n        let free = create_test_token(\"free@test.com\", Some(\"FREE\"), 1.0, None, Some(50));\n\n        assert_eq!(compare_tokens(&ultra, &pro), Ordering::Less);\n        assert_eq!(compare_tokens(&pro, &free), Ordering::Less);\n        assert_eq!(compare_tokens(&ultra, &free), Ordering::Less);\n        assert_eq!(compare_tokens(&free, &ultra), Ordering::Greater);\n    }\n\n    #[test]\n    fn test_sorting_health_score_priority() {\n        // 同等级下，健康分高的优先\n        let high_health = create_test_token(\"high@test.com\", Some(\"PRO\"), 1.0, None, Some(50));\n        let low_health = create_test_token(\"low@test.com\", Some(\"PRO\"), 0.5, None, Some(50));\n\n        assert_eq!(compare_tokens(&high_health, &low_health), Ordering::Less);\n        assert_eq!(compare_tokens(&low_health, &high_health), Ordering::Greater);\n    }\n\n    #[test]\n    fn test_sorting_reset_time_priority() {\n        let now = chrono::Utc::now().timestamp();\n\n        // 刷新时间更近（30分钟后）的优先于更远（5小时后）的\n        let soon_reset = create_test_token(\"soon@test.com\", Some(\"PRO\"), 1.0, Some(now + 1800), Some(50));  // 30分钟后\n        let late_reset = create_test_token(\"late@test.com\", Some(\"PRO\"), 1.0, Some(now + 18000), Some(50)); // 5小时后\n\n        assert_eq!(compare_tokens(&soon_reset, &late_reset), Ordering::Less);\n        assert_eq!(compare_tokens(&late_reset, &soon_reset), Ordering::Greater);\n    }\n\n    #[test]\n    fn test_sorting_reset_time_threshold() {\n        let now = chrono::Utc::now().timestamp();\n\n        // 差异小于10分钟（600秒）视为相同优先级，此时按配额排序\n        let reset_a = create_test_token(\"a@test.com\", Some(\"PRO\"), 1.0, Some(now + 1800), Some(80));  // 30分钟后, 80%配额\n        let reset_b = create_test_token(\"b@test.com\", Some(\"PRO\"), 1.0, Some(now + 2100), Some(50));  // 35分钟后, 50%配额\n\n        // 差5分钟 < 10分钟阈值，视为相同，按配额排序（80% > 50%）\n        assert_eq!(compare_tokens(&reset_a, &reset_b), Ordering::Less);\n    }\n\n    #[test]\n    fn test_sorting_reset_time_beyond_threshold() {\n        let now = chrono::Utc::now().timestamp();\n\n        // 差异超过10分钟，按刷新时间排序（忽略配额）\n        let soon_low_quota = create_test_token(\"soon@test.com\", Some(\"PRO\"), 1.0, Some(now + 1800), Some(20));   // 30分钟后, 20%\n        let late_high_quota = create_test_token(\"late@test.com\", Some(\"PRO\"), 1.0, Some(now + 18000), Some(90)); // 5小时后, 90%\n\n        // 差4.5小时 > 10分钟，刷新时间优先，30分钟 < 5小时\n        assert_eq!(compare_tokens(&soon_low_quota, &late_high_quota), Ordering::Less);\n    }\n\n    #[test]\n    fn test_sorting_quota_fallback() {\n        // 其他条件相同时，配额高的优先\n        let high_quota = create_test_token(\"high@test.com\", Some(\"PRO\"), 1.0, None, Some(80));\n        let low_quota = create_test_token(\"low@test.com\", Some(\"PRO\"), 1.0, None, Some(20));\n\n        assert_eq!(compare_tokens(&high_quota, &low_quota), Ordering::Less);\n        assert_eq!(compare_tokens(&low_quota, &high_quota), Ordering::Greater);\n    }\n\n    #[test]\n    fn test_sorting_missing_reset_time() {\n        let now = chrono::Utc::now().timestamp();\n\n        // 没有 reset_time 的账号应该排在有 reset_time 的后面\n        let with_reset = create_test_token(\"with@test.com\", Some(\"PRO\"), 1.0, Some(now + 1800), Some(50));\n        let without_reset = create_test_token(\"without@test.com\", Some(\"PRO\"), 1.0, None, Some(50));\n\n        assert_eq!(compare_tokens(&with_reset, &without_reset), Ordering::Less);\n    }\n\n    #[test]\n    fn test_full_sorting_integration() {\n        let now = chrono::Utc::now().timestamp();\n\n        let mut tokens = vec![\n            create_test_token(\"free_high@test.com\", Some(\"FREE\"), 1.0, Some(now + 1800), Some(90)),\n            create_test_token(\"pro_low_health@test.com\", Some(\"PRO\"), 0.5, Some(now + 1800), Some(90)),\n            create_test_token(\"pro_soon@test.com\", Some(\"PRO\"), 1.0, Some(now + 1800), Some(50)),   // 30分钟后\n            create_test_token(\"pro_late@test.com\", Some(\"PRO\"), 1.0, Some(now + 18000), Some(90)),  // 5小时后\n            create_test_token(\"ultra@test.com\", Some(\"ULTRA\"), 1.0, Some(now + 36000), Some(10)),\n        ];\n\n        tokens.sort_by(compare_tokens);\n\n        // 预期顺序:\n        // 1. ULTRA (最高等级，即使刷新时间最远)\n        // 2. PRO + 高健康分 + 30分钟后刷新\n        // 3. PRO + 高健康分 + 5小时后刷新\n        // 4. PRO + 低健康分\n        // 5. FREE (最低等级，即使配额最高)\n        assert_eq!(tokens[0].email, \"ultra@test.com\");\n        assert_eq!(tokens[1].email, \"pro_soon@test.com\");\n        assert_eq!(tokens[2].email, \"pro_late@test.com\");\n        assert_eq!(tokens[3].email, \"pro_low_health@test.com\");\n        assert_eq!(tokens[4].email, \"free_high@test.com\");\n    }\n\n    #[test]\n    fn test_realistic_scenario() {\n        // 模拟用户描述的场景:\n        // a 账号 claude 4h55m 后刷新\n        // b 账号 claude 31m 后刷新\n        // 应该优先使用 b（31分钟后刷新）\n        let now = chrono::Utc::now().timestamp();\n\n        let account_a = create_test_token(\"a@test.com\", Some(\"PRO\"), 1.0, Some(now + 295 * 60), Some(80)); // 4h55m\n        let account_b = create_test_token(\"b@test.com\", Some(\"PRO\"), 1.0, Some(now + 31 * 60), Some(30));  // 31m\n\n        // b 应该排在 a 前面（刷新时间更近）\n        assert_eq!(compare_tokens(&account_b, &account_a), Ordering::Less);\n\n        let mut tokens = vec![account_a.clone(), account_b.clone()];\n        tokens.sort_by(compare_tokens);\n\n        assert_eq!(tokens[0].email, \"b@test.com\");\n        assert_eq!(tokens[1].email, \"a@test.com\");\n    }\n\n    #[test]\n    fn test_extract_earliest_reset_time() {\n        let manager = TokenManager::new(PathBuf::from(\"/tmp/test\"));\n\n        // 测试包含 claude 模型的 reset_time 提取\n        let account_with_claude = serde_json::json!({\n            \"quota\": {\n                \"models\": [\n                    {\"name\": \"gemini-flash\", \"reset_time\": \"2025-01-31T10:00:00Z\"},\n                    {\"name\": \"claude-sonnet\", \"reset_time\": \"2025-01-31T08:00:00Z\"},\n                    {\"name\": \"claude-opus\", \"reset_time\": \"2025-01-31T08:00:00Z\"}\n                ]\n            }\n        });\n\n        let result = manager.extract_earliest_reset_time(&account_with_claude);\n        assert!(result.is_some());\n        // 应该返回 claude 的时间（08:00）而不是 gemini 的（10:00）\n        let expected_ts = chrono::DateTime::parse_from_rfc3339(\"2025-01-31T08:00:00Z\")\n            .unwrap()\n            .timestamp();\n        assert_eq!(result.unwrap(), expected_ts);\n    }\n\n    #[test]\n    fn test_extract_reset_time_no_claude() {\n        let manager = TokenManager::new(PathBuf::from(\"/tmp/test\"));\n\n        // 没有 claude 模型时，应该取任意模型的最近时间\n        let account_no_claude = serde_json::json!({\n            \"quota\": {\n                \"models\": [\n                    {\"name\": \"gemini-flash\", \"reset_time\": \"2025-01-31T10:00:00Z\"},\n                    {\"name\": \"gemini-pro\", \"reset_time\": \"2025-01-31T08:00:00Z\"}\n                ]\n            }\n        });\n\n        let result = manager.extract_earliest_reset_time(&account_no_claude);\n        assert!(result.is_some());\n        let expected_ts = chrono::DateTime::parse_from_rfc3339(\"2025-01-31T08:00:00Z\")\n            .unwrap()\n            .timestamp();\n        assert_eq!(result.unwrap(), expected_ts);\n    }\n\n    #[test]\n    fn test_extract_reset_time_missing_quota() {\n        let manager = TokenManager::new(PathBuf::from(\"/tmp/test\"));\n\n        // 没有 quota 字段时应返回 None\n        let account_no_quota = serde_json::json!({\n            \"email\": \"test@test.com\"\n        });\n\n        assert!(manager.extract_earliest_reset_time(&account_no_quota).is_none());\n    }\n\n    // ===== P2C 算法测试 =====\n\n    /// 创建带 protected_models 的测试 Token\n    fn create_test_token_with_protected(\n        email: &str,\n        remaining_quota: Option<i32>,\n        protected_models: HashSet<String>,\n    ) -> ProxyToken {\n        ProxyToken {\n            account_id: email.to_string(),\n            access_token: \"test_token\".to_string(),\n            refresh_token: \"test_refresh\".to_string(),\n            expires_in: 3600,\n            timestamp: chrono::Utc::now().timestamp() + 3600,\n            email: email.to_string(),\n            account_path: PathBuf::from(\"/tmp/test\"),\n            project_id: None,\n            subscription_tier: Some(\"PRO\".to_string()),\n            remaining_quota,\n            protected_models,\n            health_score: 1.0,\n            reset_time: None,\n            validation_blocked: false,\n            validation_blocked_until: 0,\n            validation_url: None,\n            model_quotas: HashMap::new(),\n            model_limits: HashMap::new(),\n        }\n    }\n\n    #[test]\n    fn test_p2c_selects_higher_quota() {\n        // P2C 应选择配额更高的账号\n        let manager = TokenManager::new(PathBuf::from(\"/tmp/test\"));\n\n        let low_quota = create_test_token(\"low@test.com\", Some(\"PRO\"), 1.0, None, Some(20));\n        let high_quota = create_test_token(\"high@test.com\", Some(\"PRO\"), 1.0, None, Some(80));\n\n        let candidates = vec![low_quota, high_quota];\n        let attempted: HashSet<String> = HashSet::new();\n\n        // 运行多次确保选择高配额账号\n        for _ in 0..10 {\n            let result = manager.select_with_p2c(&candidates, &attempted, \"claude-sonnet\", false);\n            assert!(result.is_some());\n            // P2C 从两个候选中选择配额更高的\n            // 由于只有两个候选，应该总是选择 high_quota\n            assert_eq!(result.unwrap().email, \"high@test.com\");\n        }\n    }\n\n    #[test]\n    fn test_p2c_skips_attempted() {\n        // P2C 应跳过已尝试的账号\n        let manager = TokenManager::new(PathBuf::from(\"/tmp/test\"));\n\n        let token_a = create_test_token(\"a@test.com\", Some(\"PRO\"), 1.0, None, Some(80));\n        let token_b = create_test_token(\"b@test.com\", Some(\"PRO\"), 1.0, None, Some(50));\n\n        let candidates = vec![token_a, token_b];\n        let mut attempted: HashSet<String> = HashSet::new();\n        attempted.insert(\"a@test.com\".to_string());\n\n        let result = manager.select_with_p2c(&candidates, &attempted, \"claude-sonnet\", false);\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().email, \"b@test.com\");\n    }\n\n    #[test]\n    fn test_p2c_skips_protected_models() {\n        // P2C 应跳过对目标模型有保护的账号 (quota_protection_enabled = true)\n        let manager = TokenManager::new(PathBuf::from(\"/tmp/test\"));\n\n        let mut protected = HashSet::new();\n        protected.insert(\"claude-sonnet\".to_string());\n\n        let protected_account = create_test_token_with_protected(\"protected@test.com\", Some(90), protected);\n        let normal_account = create_test_token_with_protected(\"normal@test.com\", Some(50), HashSet::new());\n\n        let candidates = vec![protected_account, normal_account];\n        let attempted: HashSet<String> = HashSet::new();\n\n        let result = manager.select_with_p2c(&candidates, &attempted, \"claude-sonnet\", true);\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().email, \"normal@test.com\");\n    }\n\n    #[test]\n    fn test_p2c_single_candidate() {\n        // 单候选时直接返回\n        let manager = TokenManager::new(PathBuf::from(\"/tmp/test\"));\n\n        let token = create_test_token(\"single@test.com\", Some(\"PRO\"), 1.0, None, Some(50));\n        let candidates = vec![token];\n        let attempted: HashSet<String> = HashSet::new();\n\n        let result = manager.select_with_p2c(&candidates, &attempted, \"claude-sonnet\", false);\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().email, \"single@test.com\");\n    }\n\n    #[test]\n    fn test_p2c_empty_candidates() {\n        // 空候选返回 None\n        let manager = TokenManager::new(PathBuf::from(\"/tmp/test\"));\n\n        let candidates: Vec<ProxyToken> = vec![];\n        let attempted: HashSet<String> = HashSet::new();\n\n        let result = manager.select_with_p2c(&candidates, &attempted, \"claude-sonnet\", false);\n        assert!(result.is_none());\n    }\n\n    #[test]\n    fn test_p2c_all_attempted() {\n        // 所有账号都已尝试时返回 None\n        let manager = TokenManager::new(PathBuf::from(\"/tmp/test\"));\n\n        let token_a = create_test_token(\"a@test.com\", Some(\"PRO\"), 1.0, None, Some(80));\n        let token_b = create_test_token(\"b@test.com\", Some(\"PRO\"), 1.0, None, Some(50));\n\n        let candidates = vec![token_a, token_b];\n        let mut attempted: HashSet<String> = HashSet::new();\n        attempted.insert(\"a@test.com\".to_string());\n        attempted.insert(\"b@test.com\".to_string());\n\n        let result = manager.select_with_p2c(&candidates, &attempted, \"claude-sonnet\", false);\n        assert!(result.is_none());\n    }\n\n    // ===== Ultra 优先逻辑测试 =====\n\n    /// 测试 is_ultra_required_model 辅助函数\n    #[test]\n    fn test_is_ultra_required_model() {\n        // 需要 Ultra 账号的高端模型\n        const ULTRA_REQUIRED_MODELS: &[&str] = &[\n            \"claude-opus-4-6\",\n            \"claude-opus-4-5\",\n            \"opus\",\n        ];\n\n        fn is_ultra_required_model(model: &str) -> bool {\n            let lower = model.to_lowercase();\n            ULTRA_REQUIRED_MODELS.iter().any(|m| lower.contains(m))\n        }\n\n        // 应该识别为高端模型\n        assert!(is_ultra_required_model(\"claude-opus-4-6\"));\n        assert!(is_ultra_required_model(\"claude-opus-4-5\"));\n        assert!(is_ultra_required_model(\"Claude-Opus-4-6\")); // 大小写不敏感\n        assert!(is_ultra_required_model(\"CLAUDE-OPUS-4-5\")); // 大小写不敏感\n        assert!(is_ultra_required_model(\"opus\")); // 通配匹配\n        assert!(is_ultra_required_model(\"opus-4-6-latest\"));\n        assert!(is_ultra_required_model(\"models/claude-opus-4-6\"));\n\n        // 应该识别为普通模型\n        assert!(!is_ultra_required_model(\"claude-sonnet-4-5\"));\n        assert!(!is_ultra_required_model(\"claude-sonnet\"));\n        assert!(!is_ultra_required_model(\"gemini-1.5-flash\"));\n        assert!(!is_ultra_required_model(\"gemini-2.0-pro\"));\n        assert!(!is_ultra_required_model(\"claude-haiku\"));\n    }\n\n    /// 测试高端模型排序：Ultra 账号优先于 Pro 账号（即使 Pro 配额更高）\n    #[test]\n    fn test_ultra_priority_for_high_end_models() {\n        const RESET_TIME_THRESHOLD_SECS: i64 = 600;\n\n        // 模拟高端模型排序逻辑\n        fn compare_tokens_for_model(a: &ProxyToken, b: &ProxyToken, target_model: &str) -> Ordering {\n            const ULTRA_REQUIRED_MODELS: &[&str] = &[\"claude-opus-4-6\", \"claude-opus-4-5\", \"opus\"];\n            let requires_ultra = {\n                let lower = target_model.to_lowercase();\n                ULTRA_REQUIRED_MODELS.iter().any(|m| lower.contains(m))\n            };\n\n            let tier_priority = |tier: &Option<String>| {\n                let t = tier.as_deref().unwrap_or(\"\").to_lowercase();\n                if t.contains(\"ultra\") { 0 }\n                else if t.contains(\"pro\") { 1 }\n                else if t.contains(\"free\") { 2 }\n                else { 3 }\n            };\n\n            // Priority 0: 高端模型时，订阅等级优先\n            if requires_ultra {\n                let tier_cmp = tier_priority(&a.subscription_tier)\n                    .cmp(&tier_priority(&b.subscription_tier));\n                if tier_cmp != Ordering::Equal {\n                    return tier_cmp;\n                }\n            }\n\n            // Priority 1: Quota (higher is better)\n            let quota_a = a.remaining_quota.unwrap_or(0);\n            let quota_b = b.remaining_quota.unwrap_or(0);\n            let quota_cmp = quota_b.cmp(&quota_a);\n            if quota_cmp != Ordering::Equal {\n                return quota_cmp;\n            }\n\n            // Priority 2: Health score\n            let health_cmp = b.health_score.partial_cmp(&a.health_score)\n                .unwrap_or(Ordering::Equal);\n            if health_cmp != Ordering::Equal {\n                return health_cmp;\n            }\n\n            // Priority 3: Tier (for non-high-end models)\n            if !requires_ultra {\n                let tier_cmp = tier_priority(&a.subscription_tier)\n                    .cmp(&tier_priority(&b.subscription_tier));\n                if tier_cmp != Ordering::Equal {\n                    return tier_cmp;\n                }\n            }\n\n            Ordering::Equal\n        }\n\n        // 创建测试账号：Ultra 低配额 vs Pro 高配额\n        let ultra_low_quota = create_test_token(\"ultra@test.com\", Some(\"ULTRA\"), 1.0, None, Some(20));\n        let pro_high_quota = create_test_token(\"pro@test.com\", Some(\"PRO\"), 1.0, None, Some(80));\n\n        // 高端模型 (Opus 4.6): Ultra 应该优先，即使配额低\n        assert_eq!(\n            compare_tokens_for_model(&ultra_low_quota, &pro_high_quota, \"claude-opus-4-6\"),\n            Ordering::Less, // Ultra 排在前面\n            \"Opus 4.6 should prefer Ultra account over Pro even with lower quota\"\n        );\n\n        // 高端模型 (Opus 4.5): Ultra 应该优先\n        assert_eq!(\n            compare_tokens_for_model(&ultra_low_quota, &pro_high_quota, \"claude-opus-4-5\"),\n            Ordering::Less,\n            \"Opus 4.5 should prefer Ultra account over Pro\"\n        );\n\n        // 普通模型 (Sonnet): 高配额 Pro 应该优先\n        assert_eq!(\n            compare_tokens_for_model(&ultra_low_quota, &pro_high_quota, \"claude-sonnet-4-5\"),\n            Ordering::Greater, // Pro (高配额) 排在前面\n            \"Sonnet should prefer high-quota Pro over low-quota Ultra\"\n        );\n\n        // 普通模型 (Flash): 高配额 Pro 应该优先\n        assert_eq!(\n            compare_tokens_for_model(&ultra_low_quota, &pro_high_quota, \"gemini-1.5-flash\"),\n            Ordering::Greater,\n            \"Flash should prefer high-quota Pro over low-quota Ultra\"\n        );\n    }\n\n    /// 测试排序：同为 Ultra 时按配额排序\n    #[test]\n    fn test_ultra_accounts_sorted_by_quota() {\n        fn compare_tokens_for_model(a: &ProxyToken, b: &ProxyToken, target_model: &str) -> Ordering {\n            const ULTRA_REQUIRED_MODELS: &[&str] = &[\"claude-opus-4-6\", \"claude-opus-4-5\", \"opus\"];\n            let requires_ultra = {\n                let lower = target_model.to_lowercase();\n                ULTRA_REQUIRED_MODELS.iter().any(|m| lower.contains(m))\n            };\n\n            let tier_priority = |tier: &Option<String>| {\n                let t = tier.as_deref().unwrap_or(\"\").to_lowercase();\n                if t.contains(\"ultra\") { 0 }\n                else if t.contains(\"pro\") { 1 }\n                else if t.contains(\"free\") { 2 }\n                else { 3 }\n            };\n\n            if requires_ultra {\n                let tier_cmp = tier_priority(&a.subscription_tier)\n                    .cmp(&tier_priority(&b.subscription_tier));\n                if tier_cmp != Ordering::Equal {\n                    return tier_cmp;\n                }\n            }\n\n            let quota_a = a.remaining_quota.unwrap_or(0);\n            let quota_b = b.remaining_quota.unwrap_or(0);\n            quota_b.cmp(&quota_a)\n        }\n\n        let ultra_high = create_test_token(\"ultra_high@test.com\", Some(\"ULTRA\"), 1.0, None, Some(80));\n        let ultra_low = create_test_token(\"ultra_low@test.com\", Some(\"ULTRA\"), 1.0, None, Some(20));\n\n        // Opus 4.6: 同为 Ultra，高配额优先\n        assert_eq!(\n            compare_tokens_for_model(&ultra_high, &ultra_low, \"claude-opus-4-6\"),\n            Ordering::Less, // ultra_high 排在前面\n            \"Among Ultra accounts, higher quota should come first\"\n        );\n    }\n\n    /// 测试完整排序场景：混合账号池\n    #[test]\n    fn test_full_sorting_mixed_accounts() {\n        fn sort_tokens_for_model(tokens: &mut Vec<ProxyToken>, target_model: &str) {\n            const ULTRA_REQUIRED_MODELS: &[&str] = &[\"claude-opus-4-6\", \"claude-opus-4-5\", \"opus\"];\n            let requires_ultra = {\n                let lower = target_model.to_lowercase();\n                ULTRA_REQUIRED_MODELS.iter().any(|m| lower.contains(m))\n            };\n\n            tokens.sort_by(|a, b| {\n                let tier_priority = |tier: &Option<String>| {\n                    let t = tier.as_deref().unwrap_or(\"\").to_lowercase();\n                    if t.contains(\"ultra\") { 0 }\n                    else if t.contains(\"pro\") { 1 }\n                    else if t.contains(\"free\") { 2 }\n                    else { 3 }\n                };\n\n                if requires_ultra {\n                    let tier_cmp = tier_priority(&a.subscription_tier)\n                        .cmp(&tier_priority(&b.subscription_tier));\n                    if tier_cmp != Ordering::Equal {\n                        return tier_cmp;\n                    }\n                }\n\n                let quota_a = a.remaining_quota.unwrap_or(0);\n                let quota_b = b.remaining_quota.unwrap_or(0);\n                let quota_cmp = quota_b.cmp(&quota_a);\n                if quota_cmp != Ordering::Equal {\n                    return quota_cmp;\n                }\n\n                if !requires_ultra {\n                    let tier_cmp = tier_priority(&a.subscription_tier)\n                        .cmp(&tier_priority(&b.subscription_tier));\n                    if tier_cmp != Ordering::Equal {\n                        return tier_cmp;\n                    }\n                }\n\n                Ordering::Equal\n            });\n        }\n\n        // 创建混合账号池\n        let ultra_high = create_test_token(\"ultra_high@test.com\", Some(\"ULTRA\"), 1.0, None, Some(80));\n        let ultra_low = create_test_token(\"ultra_low@test.com\", Some(\"ULTRA\"), 1.0, None, Some(20));\n        let pro_high = create_test_token(\"pro_high@test.com\", Some(\"PRO\"), 1.0, None, Some(90));\n        let pro_low = create_test_token(\"pro_low@test.com\", Some(\"PRO\"), 1.0, None, Some(30));\n        let free = create_test_token(\"free@test.com\", Some(\"FREE\"), 1.0, None, Some(100));\n\n        // 高端模型 (Opus 4.6) 排序\n        let mut tokens_opus = vec![pro_high.clone(), free.clone(), ultra_low.clone(), pro_low.clone(), ultra_high.clone()];\n        sort_tokens_for_model(&mut tokens_opus, \"claude-opus-4-6\");\n\n        let emails_opus: Vec<&str> = tokens_opus.iter().map(|t| t.email.as_str()).collect();\n        // 期望顺序: Ultra(高配额) > Ultra(低配额) > Pro(高配额) > Pro(低配额) > Free\n        assert_eq!(\n            emails_opus,\n            vec![\"ultra_high@test.com\", \"ultra_low@test.com\", \"pro_high@test.com\", \"pro_low@test.com\", \"free@test.com\"],\n            \"Opus 4.6 should sort Ultra first, then by quota within each tier\"\n        );\n\n        // 普通模型 (Sonnet) 排序\n        let mut tokens_sonnet = vec![pro_high.clone(), free.clone(), ultra_low.clone(), pro_low.clone(), ultra_high.clone()];\n        sort_tokens_for_model(&mut tokens_sonnet, \"claude-sonnet-4-5\");\n\n        let emails_sonnet: Vec<&str> = tokens_sonnet.iter().map(|t| t.email.as_str()).collect();\n        // 期望顺序: Free(100%) > Pro(90%) > Ultra(80%) > Pro(30%) > Ultra(20%) - 按配额优先\n        assert_eq!(\n            emails_sonnet,\n            vec![\"free@test.com\", \"pro_high@test.com\", \"ultra_high@test.com\", \"pro_low@test.com\", \"ultra_low@test.com\"],\n            \"Sonnet should sort by quota first, then by tier as tiebreaker\"\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/upstream/client.rs",
    "content": "// 上游客户端实现\n// 基于高性能通讯接口封装\n\nuse dashmap::DashMap;\nuse rquest::{header, Client, Response, StatusCode};\nuse serde_json::Value;\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\nuse tokio::time::Duration;\n\n/// 端点降级尝试的记录信息\n#[derive(Debug, Clone)]\npub struct FallbackAttemptLog {\n    /// 尝试的端点 URL\n    pub endpoint_url: String,\n    /// HTTP 状态码 (网络错误时为 None)\n    pub status: Option<u16>,\n    /// 错误描述\n    pub error: String,\n}\n\n/// 上游调用结果，包含响应和降级尝试记录\npub struct UpstreamCallResult {\n    /// 最终的 HTTP 响应\n    pub response: Response,\n    /// 降级过程中失败的端点尝试记录 (成功时为空)\n    pub fallback_attempts: Vec<FallbackAttemptLog>,\n}\n\n/// 邮箱脱敏：只显示前3位 + *** + @域名前2位 + ***\n/// 例: \"userexample@gmail.com\" → \"use***@gm***\"\npub fn mask_email(email: &str) -> String {\n    if let Some(at_pos) = email.find('@') {\n        let local = &email[..at_pos];\n        let domain = &email[at_pos + 1..];\n        let local_prefix: String = local.chars().take(3).collect();\n        let domain_prefix: String = domain.chars().take(2).collect();\n        format!(\"{}***@{}***\", local_prefix, domain_prefix)\n    } else {\n        // 不是合法邮箱格式，直接截取前5位\n        let prefix: String = email.chars().take(5).collect();\n        format!(\"{}***\", prefix)\n    }\n}\n\n// Cloud Code v1internal endpoints (fallback order: Sandbox → Daily → Prod)\n// 优先使用 Sandbox/Daily 环境以避免 Prod环境的 429 错误 (Ref: Issue #1176)\nconst V1_INTERNAL_BASE_URL_PROD: &str = \"https://cloudcode-pa.googleapis.com/v1internal\";\nconst V1_INTERNAL_BASE_URL_DAILY: &str = \"https://daily-cloudcode-pa.googleapis.com/v1internal\";\nconst V1_INTERNAL_BASE_URL_SANDBOX: &str =\n    \"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal\";\n\nconst V1_INTERNAL_BASE_URL_FALLBACKS: [&str; 3] = [\n    V1_INTERNAL_BASE_URL_SANDBOX, // 优先级 1: Sandbox (已知有效且稳定)\n    V1_INTERNAL_BASE_URL_DAILY,   // 优先级 2: Daily (备用)\n    V1_INTERNAL_BASE_URL_PROD,    // 优先级 3: Prod (仅作为兜底)\n];\n\npub struct UpstreamClient {\n    default_client: Client,\n    proxy_pool: Option<Arc<crate::proxy::proxy_pool::ProxyPoolManager>>,\n    client_cache: DashMap<String, Client>, // proxy_id -> Client\n    user_agent_override: RwLock<Option<String>>,\n}\n\nimpl UpstreamClient {\n    pub fn new(\n        proxy_config: Option<crate::proxy::config::UpstreamProxyConfig>,\n        proxy_pool: Option<Arc<crate::proxy::proxy_pool::ProxyPoolManager>>,\n    ) -> Self {\n        let default_client = match Self::build_client_internal(proxy_config.clone()) {\n            Ok(client) => client,\n            Err(err_with_proxy) => {\n                tracing::error!(\n                    error = %err_with_proxy,\n                    \"Failed to create default HTTP client with configured upstream proxy; retrying without proxy\"\n                );\n                match Self::build_client_internal(None) {\n                    Ok(client) => client,\n                    Err(err_without_proxy) => {\n                        tracing::error!(\n                            error = %err_without_proxy,\n                            \"Failed to create default HTTP client without proxy; falling back to bare client\"\n                        );\n                        Client::new()\n                    }\n                }\n            }\n        };\n\n        Self {\n            default_client,\n            proxy_pool,\n            client_cache: DashMap::new(),\n            user_agent_override: RwLock::new(None),\n        }\n    }\n\n    /// Internal helper to build a client with optional upstream proxy config\n    fn build_client_internal(\n        proxy_config: Option<crate::proxy::config::UpstreamProxyConfig>,\n    ) -> Result<Client, rquest::Error> {\n        let mut builder = Client::builder()\n            .emulation(rquest_util::Emulation::Chrome123)\n            // Connection settings (优化连接复用，减少建立开销)\n            .connect_timeout(Duration::from_secs(20))\n            .pool_max_idle_per_host(16) // 每主机最多 16 个空闲连接\n            .pool_idle_timeout(Duration::from_secs(90)) // 空闲连接保持 90 秒\n            .tcp_keepalive(Duration::from_secs(60)) // TCP 保活探测 60 秒\n            .timeout(Duration::from_secs(600));\n\n        builder = Self::apply_default_user_agent(builder);\n\n        if let Some(config) = proxy_config {\n            if config.enabled && !config.url.is_empty() {\n                let url = crate::proxy::config::normalize_proxy_url(&config.url);\n                if let Ok(proxy) = rquest::Proxy::all(&url) {\n                    builder = builder.proxy(proxy);\n                    tracing::info!(\"UpstreamClient enabled proxy: {}\", url);\n                }\n            }\n        }\n\n        builder.build()\n    }\n\n    /// Build a client with a specific PoolProxyConfig (from ProxyPool)\n    fn build_client_with_proxy(\n        &self,\n        proxy_config: crate::proxy::proxy_pool::PoolProxyConfig,\n    ) -> Result<Client, rquest::Error> {\n        // Reuse base settings similar to default client but with specific proxy\n        let builder = Client::builder()\n            .emulation(rquest_util::Emulation::Chrome123)\n            .connect_timeout(Duration::from_secs(20))\n            .pool_max_idle_per_host(16)\n            .pool_idle_timeout(Duration::from_secs(90))\n            .tcp_keepalive(Duration::from_secs(60))\n            .timeout(Duration::from_secs(600))\n            .proxy(proxy_config.proxy); // Apply the specific proxy\n\n        Self::apply_default_user_agent(builder).build()\n    }\n\n    fn apply_default_user_agent(builder: rquest::ClientBuilder) -> rquest::ClientBuilder {\n        let ua = crate::constants::USER_AGENT.as_str();\n        if header::HeaderValue::from_str(ua).is_ok() {\n            builder.user_agent(ua)\n        } else {\n            tracing::warn!(\n                user_agent = %ua,\n                \"Invalid default User-Agent value, using fallback\"\n            );\n            builder.user_agent(\"antigravity\")\n        }\n    }\n\n    /// Set dynamic User-Agent override\n    pub async fn set_user_agent_override(&self, ua: Option<String>) {\n        let mut lock = self.user_agent_override.write().await;\n        *lock = ua;\n        tracing::debug!(\"UpstreamClient User-Agent override updated: {:?}\", lock);\n    }\n\n    /// Get current User-Agent\n    pub async fn get_user_agent(&self) -> String {\n        let ua_override = self.user_agent_override.read().await;\n        ua_override\n            .as_ref()\n            .cloned()\n            .unwrap_or_else(|| crate::constants::USER_AGENT.clone())\n    }\n\n    /// Get client for a specific account (or default if no proxy bound)\n    pub async fn get_client(&self, account_id: Option<&str>) -> Client {\n        if let Some(pool) = &self.proxy_pool {\n            if let Some(acc_id) = account_id {\n                // Try to get per-account proxy\n                match pool.get_proxy_for_account(acc_id).await {\n                    Ok(Some(proxy_cfg)) => {\n                        // Check cache\n                        if let Some(client) = self.client_cache.get(&proxy_cfg.entry_id) {\n                            return client.clone();\n                        }\n                        // Build new client and cache it\n                        match self.build_client_with_proxy(proxy_cfg.clone()) {\n                            Ok(client) => {\n                                self.client_cache\n                                    .insert(proxy_cfg.entry_id.clone(), client.clone());\n                                tracing::info!(\n                                    \"Using ProxyPool proxy ID: {} for account: {}\",\n                                    proxy_cfg.entry_id,\n                                    acc_id\n                                );\n                                return client;\n                            }\n                            Err(e) => {\n                                tracing::error!(\"Failed to build client for proxy {}: {}, falling back to default\", proxy_cfg.entry_id, e);\n                            }\n                        }\n                    }\n                    Ok(None) => {\n                        // No proxy found or required for this account, use default\n                    }\n                    Err(e) => {\n                        tracing::error!(\n                            \"Error getting proxy for account {}: {}, falling back to default\",\n                            acc_id,\n                            e\n                        );\n                    }\n                }\n            }\n        }\n        // Fallback to default client\n        self.default_client.clone()\n    }\n\n    /// Build v1internal URL\n    fn build_url(base_url: &str, method: &str, query_string: Option<&str>) -> String {\n        if let Some(qs) = query_string {\n            format!(\"{}:{}?{}\", base_url, method, qs)\n        } else {\n            format!(\"{}:{}\", base_url, method)\n        }\n    }\n\n    /// Determine if we should try next endpoint (fallback logic)\n    fn should_try_next_endpoint(status: StatusCode) -> bool {\n        status == StatusCode::TOO_MANY_REQUESTS\n            || status == StatusCode::REQUEST_TIMEOUT\n            || status == StatusCode::NOT_FOUND\n            || status.is_server_error()\n    }\n\n    /// Call v1internal API (Basic Method)\n    ///\n    /// Initiates a basic network request, supporting multi-endpoint auto-fallback.\n    /// [UPDATED] Takes optional account_id for per-account proxy selection.\n    pub async fn call_v1_internal(\n        &self,\n        method: &str,\n        access_token: &str,\n        body: Value,\n        query_string: Option<&str>,\n        account_id: Option<&str>, // [NEW] Account ID for proxy selection\n    ) -> Result<UpstreamCallResult, String> {\n        self.call_v1_internal_with_headers(\n            method,\n            access_token,\n            body,\n            query_string,\n            std::collections::HashMap::new(),\n            account_id,\n        )\n        .await\n    }\n\n    /// [FIX #765] 调用 v1internal API，支持透传额外的 Headers\n    /// [ENHANCED] 返回 UpstreamCallResult，包含降级尝试记录，用于 debug 日志\n    pub async fn call_v1_internal_with_headers(\n        &self,\n        method: &str,\n        access_token: &str,\n        body: Value,\n        query_string: Option<&str>,\n        extra_headers: std::collections::HashMap<String, String>,\n        account_id: Option<&str>, // [NEW] Account ID\n    ) -> Result<UpstreamCallResult, String> {\n        // [NEW] Get client based on account (cached in proxy pool manager)\n        let client = self.get_client(account_id).await;\n\n        // 构建 Headers (所有端点复用)\n        let mut headers = header::HeaderMap::new();\n        headers.insert(\n            header::CONTENT_TYPE,\n            header::HeaderValue::from_static(\"application/json\"),\n        );\n        headers.insert(\n            header::AUTHORIZATION,\n            header::HeaderValue::from_str(&format!(\"Bearer {}\", access_token))\n                .map_err(|e| e.to_string())?,\n        );\n\n        headers.insert(\n            header::USER_AGENT,\n            header::HeaderValue::from_str(&self.get_user_agent().await).unwrap_or_else(|e| {\n                tracing::warn!(\"Invalid User-Agent header value, using fallback: {}\", e);\n                header::HeaderValue::from_static(\"antigravity\")\n            }),\n        );\n\n        // [ENHANCED] 注入 Antigravity 官方客户端关键特征 Headers\n        // 1. Client Identity\n        headers.insert(\n            \"x-client-name\",\n            header::HeaderValue::from_static(\"antigravity\"),\n        );\n        if let Ok(ver) = header::HeaderValue::from_str(&crate::constants::CURRENT_VERSION) {\n            headers.insert(\"x-client-version\", ver);\n        }\n\n        // 2. Device & Session Identity\n        // Machine ID (Persistent)\n        if let Ok(mid) = machine_uid::get() {\n             if let Ok(mid_val) = header::HeaderValue::from_str(&mid) {\n                 headers.insert(\"x-machine-id\", mid_val);\n             }\n        }\n        // Session ID (Per App Launch)\n        if let Ok(sess_val) = header::HeaderValue::from_str(&crate::constants::SESSION_ID) {\n            headers.insert(\"x-vscode-sessionid\", sess_val);\n        }\n\n        // [REMOVED v4.1.24] x-goog-api-client (gl-node/fire/grpc) header has been removed.\n        // This header belongs to the IDE's JS layer, not the official client's egress.\n        // Sending it creates a contradictory \"Electron + Node.js\" fingerprint.\n\n        // 注入额外的 Headers (如 anthropic-beta)\n        for (k, v) in extra_headers {\n            if let Ok(hk) = header::HeaderName::from_bytes(k.as_bytes()) {\n                if let Ok(hv) = header::HeaderValue::from_str(&v) {\n                    headers.insert(hk, hv);\n                }\n            }\n        }\n\n        // [DEBUG] Log headers for verification\n        tracing::debug!(?headers, \"Final Upstream Request Headers\");\n\n        let mut last_err: Option<String> = None;\n        // [NEW] 收集降级尝试记录\n        let mut fallback_attempts: Vec<FallbackAttemptLog> = Vec::new();\n\n        // 遍历所有端点，失败时自动切换\n        for (idx, base_url) in V1_INTERNAL_BASE_URL_FALLBACKS.iter().enumerate() {\n            let url = Self::build_url(base_url, method, query_string);\n            let has_next = idx + 1 < V1_INTERNAL_BASE_URL_FALLBACKS.len();\n\n            let response = client\n                .post(&url)\n                .headers(headers.clone())\n                .json(&body)\n                .send()\n                .await;\n\n            match response {\n                Ok(resp) => {\n                    let status = resp.status();\n                    if status.is_success() {\n                        if idx > 0 {\n                            tracing::info!(\n                                \"✓ Upstream fallback succeeded | Endpoint: {} | Status: {} | Next endpoints available: {}\",\n                                base_url,\n                                status,\n                                V1_INTERNAL_BASE_URL_FALLBACKS.len() - idx - 1\n                            );\n                        } else {\n                            tracing::debug!(\n                                \"✓ Upstream request succeeded | Endpoint: {} | Status: {}\",\n                                base_url,\n                                status\n                            );\n                        }\n                        return Ok(UpstreamCallResult {\n                            response: resp,\n                            fallback_attempts,\n                        });\n                    }\n\n                    // 如果有下一个端点且当前错误可重试，则切换\n                    if has_next && Self::should_try_next_endpoint(status) {\n                        let err_msg = format!(\"Upstream {} returned {}\", base_url, status);\n                        tracing::warn!(\n                            \"Upstream endpoint returned {} at {} (method={}), trying next endpoint\",\n                            status,\n                            base_url,\n                            method\n                        );\n                        // [NEW] 记录降级尝试\n                        fallback_attempts.push(FallbackAttemptLog {\n                            endpoint_url: url.clone(),\n                            status: Some(status.as_u16()),\n                            error: err_msg.clone(),\n                        });\n                        last_err = Some(err_msg);\n                        continue;\n                    }\n\n                    // 不可重试的错误或已是最后一个端点，直接返回\n                    return Ok(UpstreamCallResult {\n                        response: resp,\n                        fallback_attempts,\n                    });\n                }\n                Err(e) => {\n                    let msg = format!(\"HTTP request failed at {}: {}\", base_url, e);\n                    tracing::debug!(\"{}\", msg);\n                    // [NEW] 记录网络错误的降级尝试\n                    fallback_attempts.push(FallbackAttemptLog {\n                        endpoint_url: url.clone(),\n                        status: None,\n                        error: msg.clone(),\n                    });\n                    last_err = Some(msg);\n\n                    // 如果是最后一个端点，退出循环\n                    if !has_next {\n                        break;\n                    }\n                    continue;\n                }\n            }\n        }\n\n        Err(last_err.unwrap_or_else(|| \"All endpoints failed\".to_string()))\n    }\n\n    /// 调用 v1internal API（带 429 重试,支持闭包）\n    ///\n    /// 带容错和重试的核心请求逻辑\n    ///\n    /// # Arguments\n    /// * `method` - API method (e.g., \"generateContent\")\n    /// * `query_string` - Optional query string (e.g., \"?alt=sse\")\n    /// * `get_credentials` - 闭包，获取凭证（支持账号轮换）\n    /// * `build_body` - 闭包，接收 project_id 构建请求体\n    /// * `max_attempts` - 最大重试次数\n    ///\n    /// # Returns\n    /// HTTP Response\n    // 已移除弃用的重试方法 (call_v1_internal_with_retry)\n\n    // 已移除弃用的辅助方法 (parse_retry_delay)\n\n    // 已移除弃用的辅助方法 (parse_duration_ms)\n\n    /// 获取可用模型列表\n    ///\n    /// 获取远端模型列表，支持多端点自动 Fallback\n    #[allow(dead_code)] // API ready for future model discovery feature\n    pub async fn fetch_available_models(\n        &self,\n        access_token: &str,\n        account_id: Option<&str>,\n    ) -> Result<Value, String> {\n        // 复用 call_v1_internal，然后解析 JSON\n        let result = self\n            .call_v1_internal(\n                \"fetchAvailableModels\",\n                access_token,\n                serde_json::json!({}),\n                None,\n                account_id,\n            )\n            .await?;\n        let json: Value = result\n            .response\n            .json()\n            .await\n            .map_err(|e| format!(\"Parse json failed: {}\", e))?;\n        Ok(json)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_build_url() {\n        let base_url = \"https://cloudcode-pa.googleapis.com/v1internal\";\n\n        let url1 = UpstreamClient::build_url(base_url, \"generateContent\", None);\n        assert_eq!(\n            url1,\n            \"https://cloudcode-pa.googleapis.com/v1internal:generateContent\"\n        );\n\n        let url2 = UpstreamClient::build_url(base_url, \"streamGenerateContent\", Some(\"alt=sse\"));\n        assert_eq!(\n            url2,\n            \"https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse\"\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/upstream/mod.rs",
    "content": "// Upstream 模块 - 上游客户端\n// 对应上游通讯接口\n\npub mod client;\npub mod retry;\npub mod models;\n"
  },
  {
    "path": "src-tauri/src/proxy/upstream/models.rs",
    "content": "// 上游 API 模型\n#[allow(dead_code)]\npub struct UpstreamModels {\n    // TODO: Phase 3\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/upstream/retry.rs",
    "content": "// 429 重试策略\n// Duration 解析\n\nuse regex::Regex;\nuse once_cell::sync::Lazy;\n\nstatic DURATION_RE: Lazy<Regex> = Lazy::new(|| {\n    Regex::new(r\"([\\d.]+)\\s*(ms|s|m|h)\").unwrap()\n});\n\n/// 解析 Duration 字符串 (e.g., \"1.5s\", \"200ms\", \"1h16m0.667s\")\npub fn parse_duration_ms(duration_str: &str) -> Option<u64> {\n    let mut total_ms: f64 = 0.0;\n    let mut matched = false;\n\n    for cap in DURATION_RE.captures_iter(duration_str) {\n        matched = true;\n        let value: f64 = cap[1].parse().ok()?;\n        let unit = &cap[2];\n\n        match unit {\n            \"ms\" => total_ms += value,\n            \"s\" => total_ms += value * 1000.0,\n            \"m\" => total_ms += value * 60.0 * 1000.0,\n            \"h\" => total_ms += value * 60.0 * 60.0 * 1000.0,\n            _ => {}\n        }\n    }\n\n    if !matched {\n        return None;\n    }\n\n    Some(total_ms.round() as u64)\n}\n\n/// 从 429 错误中提取 retry delay\npub fn parse_retry_delay(error_text: &str) -> Option<u64> {\n    use serde_json::Value;\n\n    let json: Value = serde_json::from_str(error_text).ok()?;\n    let details = json.get(\"error\")?.get(\"details\")?.as_array()?;\n\n    // 方式1: RetryInfo.retryDelay\n    for detail in details {\n        if let Some(type_str) = detail.get(\"@type\").and_then(|v| v.as_str()) {\n            if type_str.contains(\"RetryInfo\") {\n                if let Some(retry_delay) = detail.get(\"retryDelay\").and_then(|v| v.as_str()) {\n                    return parse_duration_ms(retry_delay);\n                }\n            }\n        }\n    }\n\n    // 方式2: metadata.quotaResetDelay\n    for detail in details {\n        if let Some(quota_delay) = detail\n            .get(\"metadata\")\n            .and_then(|m| m.get(\"quotaResetDelay\"))\n            .and_then(|v| v.as_str())\n        {\n            return parse_duration_ms(quota_delay);\n        }\n    }\n\n    None\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_parse_duration_ms() {\n        assert_eq!(parse_duration_ms(\"1.5s\"), Some(1500));\n        assert_eq!(parse_duration_ms(\"200ms\"), Some(200));\n        assert_eq!(parse_duration_ms(\"1h16m0.667s\"), Some(4560667));\n        assert_eq!(parse_duration_ms(\"invalid\"), None);\n    }\n\n    #[test]\n    fn test_parse_retry_delay() {\n        let error_json = r#\"{\n            \"error\": {\n                \"details\": [{\n                    \"@type\": \"type.googleapis.com/google.rpc.RetryInfo\",\n                    \"retryDelay\": \"1.203608125s\"\n                }]\n            }\n        }\"#;\n\n        assert_eq!(parse_retry_delay(error_json), Some(1204));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/zai_vision_mcp.rs",
    "content": "use std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::Mutex;\n\n#[derive(Debug, Clone, Default)]\npub struct ZaiVisionMcpState {\n    sessions: Arc<Mutex<HashMap<String, ZaiVisionSession>>>,\n}\n\n#[derive(Debug, Clone)]\nstruct ZaiVisionSession {\n    #[allow(dead_code)]\n    created_at: std::time::Instant,\n}\n\nimpl ZaiVisionMcpState {\n    pub fn new() -> Self {\n        Self::default()\n    }\n\n    pub async fn create_session(&self) -> String {\n        let session_id = uuid::Uuid::new_v4().to_string();\n        let mut sessions = self.sessions.lock().await;\n        sessions.insert(\n            session_id.clone(),\n            ZaiVisionSession {\n                created_at: std::time::Instant::now(),\n            },\n        );\n        session_id\n    }\n\n    pub async fn has_session(&self, session_id: &str) -> bool {\n        let sessions = self.sessions.lock().await;\n        sessions.contains_key(session_id)\n    }\n\n    pub async fn remove_session(&self, session_id: &str) {\n        let mut sessions = self.sessions.lock().await;\n        sessions.remove(session_id);\n    }\n}\n\n"
  },
  {
    "path": "src-tauri/src/proxy/zai_vision_tools.rs",
    "content": "use base64::Engine;\nuse serde_json::{json, Value};\nuse tokio::time::Duration;\n\nuse crate::proxy::config::UpstreamProxyConfig;\nuse crate::proxy::ZaiConfig;\n\nconst ZAI_PAAZ_CHAT_COMPLETIONS_URL: &str = \"https://api.z.ai/api/paas/v4/chat/completions\";\n\nfn build_client(upstream_proxy: UpstreamProxyConfig, timeout_secs: u64) -> Result<reqwest::Client, String> {\n    let mut builder = reqwest::Client::builder()\n        .timeout(Duration::from_secs(timeout_secs.max(5)));\n\n    if upstream_proxy.enabled && !upstream_proxy.url.is_empty() {\n        let url = crate::proxy::config::normalize_proxy_url(&upstream_proxy.url);\n        let proxy = reqwest::Proxy::all(&url)\n            .map_err(|e| format!(\"Invalid upstream proxy url: {}\", e))?;\n        builder = builder.proxy(proxy);\n    }\n\n    builder.build().map_err(|e| format!(\"Failed to build HTTP client: {}\", e))\n}\n\nfn is_http_url(value: &str) -> bool {\n    let v = value.trim();\n    v.starts_with(\"http://\") || v.starts_with(\"https://\")\n}\n\nfn mime_for_image_extension(ext: &str) -> Option<&'static str> {\n    match ext.to_ascii_lowercase().as_str() {\n        \"png\" => Some(\"image/png\"),\n        \"jpg\" | \"jpeg\" => Some(\"image/jpeg\"),\n        _ => None,\n    }\n}\n\nfn mime_for_video_extension(ext: &str) -> Option<&'static str> {\n    match ext.to_ascii_lowercase().as_str() {\n        \"mp4\" => Some(\"video/mp4\"),\n        \"mov\" => Some(\"video/quicktime\"),\n        \"m4v\" => Some(\"video/x-m4v\"),\n        _ => None,\n    }\n}\n\nfn file_ext(path: &std::path::Path) -> Option<String> {\n    path.extension()\n        .and_then(|s| s.to_str())\n        .map(|s| s.to_string())\n}\n\nfn encode_file_as_data_url(path: &std::path::Path, mime: &str) -> Result<String, String> {\n    let bytes = std::fs::read(path).map_err(|e| format!(\"Failed to read file: {}\", e))?;\n    let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);\n    Ok(format!(\"data:{};base64,{}\", mime, encoded))\n}\n\nfn image_source_to_content(image_source: &str, max_size_mb: u64) -> Result<Value, String> {\n    if is_http_url(image_source) {\n        return Ok(json!({\n            \"type\": \"image_url\",\n            \"image_url\": { \"url\": image_source }\n        }));\n    }\n\n    let path = std::path::Path::new(image_source);\n    let meta = std::fs::metadata(path).map_err(|_| \"Image file not found\".to_string())?;\n    let max_size = max_size_mb * 1024 * 1024;\n    if meta.len() > max_size {\n        return Err(format!(\n            \"Image file too large ({} bytes), max {} MB\",\n            meta.len(),\n            max_size_mb\n        ));\n    }\n\n    let ext = file_ext(path).ok_or(\"Unsupported image format\".to_string())?;\n    let mime = mime_for_image_extension(&ext).ok_or(\"Unsupported image format\".to_string())?;\n    let data_url = encode_file_as_data_url(path, mime)?;\n    Ok(json!({\n        \"type\": \"image_url\",\n        \"image_url\": { \"url\": data_url }\n    }))\n}\n\nfn video_source_to_content(video_source: &str, max_size_mb: u64) -> Result<Value, String> {\n    if is_http_url(video_source) {\n        return Ok(json!({\n            \"type\": \"video_url\",\n            \"video_url\": { \"url\": video_source }\n        }));\n    }\n\n    let path = std::path::Path::new(video_source);\n    let meta = std::fs::metadata(path).map_err(|_| \"Video file not found\".to_string())?;\n    let max_size = max_size_mb * 1024 * 1024;\n    if meta.len() > max_size {\n        return Err(format!(\n            \"Video file too large ({} bytes), max {} MB\",\n            meta.len(),\n            max_size_mb\n        ));\n    }\n\n    let ext = file_ext(path).ok_or(\"Unsupported video format\".to_string())?;\n    let mime = mime_for_video_extension(&ext).ok_or(\"Unsupported video format\".to_string())?;\n    let data_url = encode_file_as_data_url(path, mime)?;\n    Ok(json!({\n        \"type\": \"video_url\",\n        \"video_url\": { \"url\": data_url }\n    }))\n}\n\nfn user_message_with_content(mut content: Vec<Value>, prompt: &str) -> Value {\n    content.push(json!({ \"type\": \"text\", \"text\": prompt }));\n    json!({ \"role\": \"user\", \"content\": content })\n}\n\nasync fn vision_chat_completion(\n    client: &reqwest::Client,\n    api_key: &str,\n    system_prompt: &str,\n    user_content: Vec<Value>,\n    prompt: &str,\n) -> Result<String, String> {\n    let body = json!({\n        \"model\": \"glm-4.6v\",\n        \"messages\": [\n            { \"role\": \"system\", \"content\": system_prompt },\n            user_message_with_content(user_content, prompt),\n        ],\n        \"thinking\": { \"type\": \"enabled\" },\n        \"stream\": false,\n        \"temperature\": 0.8,\n        \"top_p\": 0.6,\n        \"max_tokens\": 32768\n    });\n\n    let resp = client\n        .post(ZAI_PAAZ_CHAT_COMPLETIONS_URL)\n        .bearer_auth(api_key)\n        .header(\"X-Title\", \"Vision MCP Local\")\n        .header(\"Accept-Language\", \"en-US,en\")\n        .json(&body)\n        .send()\n        .await\n        .map_err(|e| format!(\"Upstream request failed: {}\", e))?;\n\n    if !resp.status().is_success() {\n        let status = resp.status().as_u16();\n        let text = resp.text().await.unwrap_or_default();\n        return Err(format!(\"HTTP {}: {}\", status, text));\n    }\n\n    let v: Value = resp.json().await.map_err(|e| format!(\"Invalid JSON response: {}\", e))?;\n    let content = v\n        .get(\"choices\")\n        .and_then(|c| c.get(0))\n        .and_then(|c| c.get(\"message\"))\n        .and_then(|m| m.get(\"content\"))\n        .and_then(|c| c.as_str())\n        .ok_or_else(|| \"Invalid API response: missing choices[0].message.content\".to_string())?;\n\n    Ok(content.to_string())\n}\n\npub fn tool_specs() -> Vec<Value> {\n    vec![\n        json!({\n            \"name\": \"ui_to_artifact\",\n            \"description\": \"Convert UI screenshots into artifacts (code/prompt/spec/description).\",\n            \"inputSchema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"image_source\": { \"type\": \"string\", \"description\": \"Local file path or remote URL to the image\" },\n                    \"output_type\": { \"type\": \"string\", \"enum\": [\"code\",\"prompt\",\"spec\",\"description\"] },\n                    \"prompt\": { \"type\": \"string\" }\n                },\n                \"required\": [\"image_source\",\"output_type\",\"prompt\"]\n            }\n        }),\n        json!({\n            \"name\": \"extract_text_from_screenshot\",\n            \"description\": \"Extract text/code from screenshots (OCR-like).\",\n            \"inputSchema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"image_source\": { \"type\": \"string\" },\n                    \"prompt\": { \"type\": \"string\" },\n                    \"language_hint\": { \"type\": \"string\" }\n                },\n                \"required\": [\"image_source\",\"prompt\"]\n            }\n        }),\n        json!({\n            \"name\": \"diagnose_error_screenshot\",\n            \"description\": \"Diagnose error screenshots (stack traces, logs, runtime errors).\",\n            \"inputSchema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"image_source\": { \"type\": \"string\" },\n                    \"prompt\": { \"type\": \"string\" },\n                    \"context\": { \"type\": \"string\" }\n                },\n                \"required\": [\"image_source\",\"prompt\"]\n            }\n        }),\n        json!({\n            \"name\": \"understand_technical_diagram\",\n            \"description\": \"Analyze architecture/flow/UML/ER diagrams.\",\n            \"inputSchema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"image_source\": { \"type\": \"string\" },\n                    \"prompt\": { \"type\": \"string\" },\n                    \"diagram_type\": { \"type\": \"string\" }\n                },\n                \"required\": [\"image_source\",\"prompt\"]\n            }\n        }),\n        json!({\n            \"name\": \"analyze_data_visualization\",\n            \"description\": \"Analyze charts/dashboards to extract insights and trends.\",\n            \"inputSchema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"image_source\": { \"type\": \"string\" },\n                    \"prompt\": { \"type\": \"string\" },\n                    \"analysis_focus\": { \"type\": \"string\" }\n                },\n                \"required\": [\"image_source\",\"prompt\"]\n            }\n        }),\n        json!({\n            \"name\": \"ui_diff_check\",\n            \"description\": \"Compare two UI screenshots and report visual differences.\",\n            \"inputSchema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"expected_image_source\": { \"type\": \"string\" },\n                    \"actual_image_source\": { \"type\": \"string\" },\n                    \"prompt\": { \"type\": \"string\" }\n                },\n                \"required\": [\"expected_image_source\",\"actual_image_source\",\"prompt\"]\n            }\n        }),\n        json!({\n            \"name\": \"analyze_image\",\n            \"description\": \"General-purpose image analysis.\",\n            \"inputSchema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"image_source\": { \"type\": \"string\" },\n                    \"prompt\": { \"type\": \"string\" }\n                },\n                \"required\": [\"image_source\",\"prompt\"]\n            }\n        }),\n        json!({\n            \"name\": \"analyze_video\",\n            \"description\": \"Analyze video content.\",\n            \"inputSchema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"video_source\": { \"type\": \"string\" },\n                    \"prompt\": { \"type\": \"string\" }\n                },\n                \"required\": [\"video_source\",\"prompt\"]\n            }\n        }),\n    ]\n}\n\npub async fn call_tool(\n    zai: &ZaiConfig,\n    upstream_proxy: UpstreamProxyConfig,\n    timeout_secs: u64,\n    tool_name: &str,\n    arguments: &Value,\n) -> Result<Value, String> {\n    let api_key = zai.api_key.trim();\n    if api_key.is_empty() {\n        return Err(\"z.ai api_key is missing\".to_string());\n    }\n\n    let client = build_client(upstream_proxy, timeout_secs)?;\n\n    let tool_result = match tool_name {\n        \"ui_to_artifact\" => {\n            let image_source = arguments\n                .get(\"image_source\")\n                .and_then(|v| v.as_str())\n                .ok_or(\"Missing image_source\")?;\n            let output_type = arguments\n                .get(\"output_type\")\n                .and_then(|v| v.as_str())\n                .ok_or(\"Missing output_type\")?;\n            let prompt = arguments.get(\"prompt\").and_then(|v| v.as_str()).ok_or(\"Missing prompt\")?;\n\n            let system_prompt = match output_type {\n                \"code\" => \"You are a frontend engineer. Generate clean, accessible, responsive frontend code from the UI screenshot.\",\n                \"prompt\" => \"You generate precise prompts to recreate UI screenshots.\",\n                \"spec\" => \"You are a design systems architect. Produce a detailed UI specification from the screenshot.\",\n                \"description\" => \"You describe UI screenshots clearly and completely in natural language.\",\n                _ => return Err(\"Invalid output_type\".to_string()),\n            };\n\n            let image = image_source_to_content(image_source, 5)?;\n            vision_chat_completion(&client, api_key, system_prompt, vec![image], prompt).await?\n        }\n        \"extract_text_from_screenshot\" => {\n            let image_source = arguments\n                .get(\"image_source\")\n                .and_then(|v| v.as_str())\n                .ok_or(\"Missing image_source\")?;\n            let mut prompt = arguments.get(\"prompt\").and_then(|v| v.as_str()).ok_or(\"Missing prompt\")?.to_string();\n            if let Some(lang) = arguments.get(\"language_hint\").and_then(|v| v.as_str()) {\n                if !lang.trim().is_empty() {\n                    prompt.push_str(&format!(\"\\n\\nLanguage hint: {}\", lang.trim()));\n                }\n            }\n            let image = image_source_to_content(image_source, 5)?;\n            let system_prompt = \"Extract text from the screenshot accurately. Preserve code formatting. If unsure, say what is uncertain.\";\n            vision_chat_completion(&client, api_key, system_prompt, vec![image], &prompt).await?\n        }\n        \"diagnose_error_screenshot\" => {\n            let image_source = arguments\n                .get(\"image_source\")\n                .and_then(|v| v.as_str())\n                .ok_or(\"Missing image_source\")?;\n            let mut prompt = arguments.get(\"prompt\").and_then(|v| v.as_str()).ok_or(\"Missing prompt\")?.to_string();\n            if let Some(ctx) = arguments.get(\"context\").and_then(|v| v.as_str()) {\n                if !ctx.trim().is_empty() {\n                    prompt.push_str(&format!(\"\\n\\nContext: {}\", ctx.trim()));\n                }\n            }\n            let image = image_source_to_content(image_source, 5)?;\n            let system_prompt = \"Diagnose the error shown in the screenshot. Identify root cause, propose fixes and verification steps.\";\n            vision_chat_completion(&client, api_key, system_prompt, vec![image], &prompt).await?\n        }\n        \"understand_technical_diagram\" => {\n            let image_source = arguments\n                .get(\"image_source\")\n                .and_then(|v| v.as_str())\n                .ok_or(\"Missing image_source\")?;\n            let mut prompt = arguments.get(\"prompt\").and_then(|v| v.as_str()).ok_or(\"Missing prompt\")?.to_string();\n            if let Some(diagram_type) = arguments.get(\"diagram_type\").and_then(|v| v.as_str()) {\n                if !diagram_type.trim().is_empty() {\n                    prompt.push_str(&format!(\"\\n\\nDiagram type: {}\", diagram_type.trim()));\n                }\n            }\n            let image = image_source_to_content(image_source, 5)?;\n            let system_prompt = \"Explain the technical diagram. Describe components, relationships, data flows, and key assumptions.\";\n            vision_chat_completion(&client, api_key, system_prompt, vec![image], &prompt).await?\n        }\n        \"analyze_data_visualization\" => {\n            let image_source = arguments\n                .get(\"image_source\")\n                .and_then(|v| v.as_str())\n                .ok_or(\"Missing image_source\")?;\n            let mut prompt = arguments.get(\"prompt\").and_then(|v| v.as_str()).ok_or(\"Missing prompt\")?.to_string();\n            if let Some(focus) = arguments.get(\"analysis_focus\").and_then(|v| v.as_str()) {\n                if !focus.trim().is_empty() {\n                    prompt.push_str(&format!(\"\\n\\nFocus: {}\", focus.trim()));\n                }\n            }\n            let image = image_source_to_content(image_source, 5)?;\n            let system_prompt = \"Analyze the chart/dashboard and extract insights, trends, anomalies, and recommendations.\";\n            vision_chat_completion(&client, api_key, system_prompt, vec![image], &prompt).await?\n        }\n        \"ui_diff_check\" => {\n            let expected = arguments\n                .get(\"expected_image_source\")\n                .and_then(|v| v.as_str())\n                .ok_or(\"Missing expected_image_source\")?;\n            let actual = arguments\n                .get(\"actual_image_source\")\n                .and_then(|v| v.as_str())\n                .ok_or(\"Missing actual_image_source\")?;\n            let prompt = arguments.get(\"prompt\").and_then(|v| v.as_str()).ok_or(\"Missing prompt\")?;\n\n            let expected_img = image_source_to_content(expected, 5)?;\n            let actual_img = image_source_to_content(actual, 5)?;\n            let system_prompt = \"Compare the two UI screenshots and report differences grouped by severity. Include actionable fix suggestions.\";\n            vision_chat_completion(\n                &client,\n                api_key,\n                system_prompt,\n                vec![expected_img, actual_img],\n                prompt,\n            )\n            .await?\n        }\n        \"analyze_image\" => {\n            let image_source = arguments\n                .get(\"image_source\")\n                .and_then(|v| v.as_str())\n                .ok_or(\"Missing image_source\")?;\n            let prompt = arguments.get(\"prompt\").and_then(|v| v.as_str()).ok_or(\"Missing prompt\")?;\n            let image = image_source_to_content(image_source, 5)?;\n            let system_prompt = \"Analyze the image. Be precise and include relevant details.\";\n            vision_chat_completion(&client, api_key, system_prompt, vec![image], prompt).await?\n        }\n        \"analyze_video\" => {\n            let video_source = arguments\n                .get(\"video_source\")\n                .and_then(|v| v.as_str())\n                .ok_or(\"Missing video_source\")?;\n            let prompt = arguments.get(\"prompt\").and_then(|v| v.as_str()).ok_or(\"Missing prompt\")?;\n            let video = video_source_to_content(video_source, 8)?;\n            let system_prompt = \"Analyze the video content according to the user's request.\";\n            vision_chat_completion(&client, api_key, system_prompt, vec![video], prompt).await?\n        }\n        _ => return Err(\"Unknown tool\".to_string()),\n    };\n\n    Ok(json!({\n        \"content\": [\n            { \"type\": \"text\", \"text\": tool_result }\n        ]\n    }))\n}\n"
  },
  {
    "path": "src-tauri/src/utils/command.rs",
    "content": "use std::process::Command as StdCommand;\nuse tokio::process::Command as TokioCommand;\n\n#[cfg(target_os = \"windows\")]\nuse std::os::windows::process::CommandExt;\n\nconst CREATE_NO_WINDOW: u32 = 0x08000000;\n\npub trait CommandExtWrapper {\n    /// 在 Windows 下为命令添加 CREATE_NO_WINDOW 标志，隐藏黑框\n    fn creation_flags_windows(&mut self) -> &mut Self;\n}\n\nimpl CommandExtWrapper for StdCommand {\n    fn creation_flags_windows(&mut self) -> &mut Self {\n        #[cfg(target_os = \"windows\")]\n        self.creation_flags(CREATE_NO_WINDOW);\n\n        self\n    }\n}\n\nimpl CommandExtWrapper for TokioCommand {\n    fn creation_flags_windows(&mut self) -> &mut Self {\n        #[cfg(target_os = \"windows\")]\n        self.creation_flags(CREATE_NO_WINDOW);\n\n        self\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/utils/crypto.rs",
    "content": "use aes_gcm::{\n    aead::{Aead, KeyInit},\n    Aes256Gcm, Nonce,\n};\nuse base64::{engine::general_purpose, Engine as _};\nuse serde::{Deserialize, Deserializer, Serializer};\nuse sha2::Digest;\n\nconst FIXED_NONCE: &[u8; 12] = b\"antigravsalt\";\nconst ENCRYPTED_PREFIX: &str = \"ag_enc_\";\n\n/// 生成加密密钥 (基于设备 ID)\nfn get_encryption_key() -> [u8; 32] {\n    // 使用设备唯一标识生成密钥\n    let device_id = machine_uid::get().unwrap_or_else(|_| \"default\".to_string());\n    let mut key = [0u8; 32];\n    let hash = sha2::Sha256::digest(device_id.as_bytes());\n    key.copy_from_slice(&hash);\n    key\n}\n\npub fn serialize_password<S>(password: &str, serializer: S) -> Result<S::Ok, S::Error>\nwhere\n    S: Serializer,\n{\n    // [FIX #1738] 防止双重加密：检查是否已包含魔术前缀\n    if password.starts_with(ENCRYPTED_PREFIX) {\n        return serializer.serialize_str(password);\n    }\n\n    let encrypted = encrypt_string(password).map_err(serde::ser::Error::custom)?;\n    serializer.serialize_str(&encrypted)\n}\n\npub fn deserialize_password<'de, D>(deserializer: D) -> Result<String, D::Error>\nwhere\n    D: Deserializer<'de>,\n{\n    let raw = String::deserialize(deserializer)?;\n    if raw.is_empty() {\n        return Ok(raw);\n    }\n\n    // [FIX #1738] 检查魔术前缀\n    if raw.starts_with(ENCRYPTED_PREFIX) {\n        // 新版格式：去前缀后解密\n        let ciphertext = &raw[ENCRYPTED_PREFIX.len()..];\n        match decrypt_string_internal(ciphertext) {\n            Ok(plaintext) => Ok(plaintext),\n            Err(_) => {\n                // 解密失败（如密钥变更），返回原始密文以防止数据丢失\n                Ok(raw)\n            }\n        }\n    } else {\n        // 兼容旧版：尝试直接解密\n        match decrypt_string_internal(&raw) {\n            Ok(plaintext) => {\n                // 只有当解密出有效的 UTF-8 且看起来像合理个字符串时才认为是旧版密文\n                // 这里 decrypt_string_internal 已经保证了 UTF-8，\n                // 如果是用户输入的明文，通常解密会失败（Base64 错误或 Tag 校验错误）。\n                Ok(plaintext)\n            }\n            Err(_) => {\n                // 解密失败，认为是普通明文（用户输入的无前缀密码）\n                Ok(raw)\n            }\n        }\n    }\n}\n\npub fn encrypt_string(password: &str) -> Result<String, String> {\n    let key = get_encryption_key();\n    let cipher = Aes256Gcm::new(&key.into());\n    // In production, we should use a random nonce and prepend it to the ciphertext\n    // For simplicity in this demo, we use a fixed nonce (NOT SECURE for repeats)\n    // improving security: use random nonce\n    let nonce = Nonce::from_slice(FIXED_NONCE);\n\n    let ciphertext = cipher\n        .encrypt(nonce, password.as_bytes())\n        .map_err(|e| format!(\"Encryption failed: {}\", e))?;\n\n    let base64_ciphertext = general_purpose::STANDARD.encode(ciphertext);\n    // [FIX #1738] 添加魔术前缀\n    Ok(format!(\"{}{}\", ENCRYPTED_PREFIX, base64_ciphertext))\n}\n\n/// 内部解密函数 (输入必须是纯 Base64 密文，不含前缀)\nfn decrypt_string_internal(encrypted_base64: &str) -> Result<String, String> {\n    let key = get_encryption_key();\n    let cipher = Aes256Gcm::new(&key.into());\n    let nonce = Nonce::from_slice(FIXED_NONCE);\n\n    let ciphertext = general_purpose::STANDARD\n        .decode(encrypted_base64)\n        .map_err(|e| format!(\"Base64 decode failed: {}\", e))?;\n\n    let plaintext = cipher\n        .decrypt(nonce, ciphertext.as_ref())\n        .map_err(|e| format!(\"Decryption failed: {}\", e))?;\n\n    String::from_utf8(plaintext).map_err(|e| format!(\"UTF-8 conversion failed: {}\", e))\n}\n\npub fn decrypt_string(encrypted: &str) -> Result<String, String> {\n    if encrypted.starts_with(ENCRYPTED_PREFIX) {\n        decrypt_string_internal(&encrypted[ENCRYPTED_PREFIX.len()..])\n    } else {\n        decrypt_string_internal(encrypted)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_encrypt_decrypt_cycle() {\n        let password = \"my_secret_password\";\n        let encrypted = encrypt_string(password).unwrap();\n        \n        assert!(encrypted.starts_with(ENCRYPTED_PREFIX));\n        assert_ne!(password, encrypted);\n\n        let decrypted = decrypt_string(&encrypted).unwrap();\n        assert_eq!(password, decrypted);\n    }\n\n    #[test]\n    fn test_legacy_compatibility() {\n        // 模拟旧版加密（手动调用内部逻辑生成无前缀密文）\n        let password = \"legacy_password\";\n        let key = get_encryption_key();\n        let cipher = Aes256Gcm::new(&key.into());\n        let nonce = Nonce::from_slice(FIXED_NONCE);\n        let ciphertext = cipher.encrypt(nonce, password.as_bytes()).unwrap();\n        let legacy_encrypted = general_purpose::STANDARD.encode(ciphertext);\n\n        assert!(!legacy_encrypted.starts_with(ENCRYPTED_PREFIX));\n\n        // 使用新版解密逻辑\n        let decrypted = decrypt_string(&legacy_encrypted).unwrap();\n        assert_eq!(password, decrypted);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/utils/http.rs",
    "content": "use crate::modules::config::load_app_config;\nuse once_cell::sync::Lazy;\nuse rquest::{Client, Proxy};\nuse rquest_util::Emulation;\n\n/// Global shared HTTP client (15s timeout)\n/// Client has a built-in connection pool; cloning it is light and shares the pool\npub static SHARED_CLIENT: Lazy<Client> = Lazy::new(|| create_base_client(15));\n\n/// Global shared HTTP client (Long timeout: 60s, for warmup etc.)\npub static SHARED_CLIENT_LONG: Lazy<Client> = Lazy::new(|| create_base_client(60));\n\n/// Global shared standard HTTP client (15s timeout, NO JA3 Emulation)\npub static SHARED_STANDARD_CLIENT: Lazy<Client> = Lazy::new(|| create_standard_client(15));\n\n/// Global shared standard HTTP client (Long timeout: 60s, NO JA3 Emulation)\npub static SHARED_STANDARD_CLIENT_LONG: Lazy<Client> = Lazy::new(|| create_standard_client(60));\n\n/// Base client creation logic with JA3 Emulation\nfn create_base_client(timeout_secs: u64) -> Client {\n    let mut builder = Client::builder()\n        .emulation(Emulation::Chrome123)\n        .timeout(std::time::Duration::from_secs(timeout_secs));\n\n    if let Ok(config) = load_app_config() {\n        let proxy_config = config.proxy.upstream_proxy;\n        if proxy_config.enabled && !proxy_config.url.is_empty() {\n            match Proxy::all(&proxy_config.url) {\n                Ok(proxy) => {\n                    builder = builder.proxy(proxy);\n                    tracing::info!(\n                        \"HTTP shared client enabled upstream proxy: {}\",\n                        proxy_config.url\n                    );\n                }\n                Err(e) => {\n                    tracing::error!(\"invalid_proxy_url: {}, error: {}\", proxy_config.url, e);\n                }\n            }\n        }\n    }\n\n    tracing::info!(\"Initialized JA3/TLS Impersonation (Chrome123)\");\n    builder.build().unwrap_or_else(|_| Client::new())\n}\n\n/// Get uniformly configured HTTP client (15s timeout)\npub fn get_client() -> Client {\n    SHARED_CLIENT.clone()\n}\n\n/// Get long timeout HTTP client (60s timeout)\npub fn get_long_client() -> Client {\n    SHARED_CLIENT_LONG.clone()\n}\n\n/// Base client creation logic strictly WITHOUT JA3 Emulation (Pure Native)\nfn create_standard_client(timeout_secs: u64) -> Client {\n    let mut builder = Client::builder()\n        // No .emulation(Emulation::Chrome123) here!\n        .timeout(std::time::Duration::from_secs(timeout_secs));\n\n    if let Ok(config) = load_app_config() {\n        let proxy_config = config.proxy.upstream_proxy;\n        if proxy_config.enabled && !proxy_config.url.is_empty() {\n            match Proxy::all(&proxy_config.url) {\n                Ok(proxy) => {\n                    builder = builder.proxy(proxy);\n                    tracing::info!(\n                        \"HTTP standard client enabled upstream proxy: {}\",\n                        proxy_config.url\n                    );\n                }\n                Err(e) => {\n                    tracing::error!(\"invalid_proxy_url: {}, error: {}\", proxy_config.url, e);\n                }\n            }\n        }\n    }\n\n    tracing::info!(\"Initialized Pure Native Standard Client\");\n    builder.build().unwrap_or_else(|_| Client::new())\n}\n\n/// Get standard HTTP client without JA3 Emulation (15s timeout)\npub fn get_standard_client() -> Client {\n    SHARED_STANDARD_CLIENT.clone()\n}\n\n/// Get long timeout standard HTTP client without JA3 Emulation (60s timeout)\npub fn get_long_standard_client() -> Client {\n    SHARED_STANDARD_CLIENT_LONG.clone()\n}\n"
  },
  {
    "path": "src-tauri/src/utils/mod.rs",
    "content": "pub mod http;\npub mod protobuf;\npub mod crypto;\npub mod command;\n"
  },
  {
    "path": "src-tauri/src/utils/protobuf.rs",
    "content": "/// Protobuf Varint Encoding\npub fn encode_varint(mut value: u64) -> Vec<u8> {\n    let mut buf = Vec::new();\n    while value >= 0x80 {\n        buf.push((value & 0x7F | 0x80) as u8);\n        value >>= 7;\n    }\n    buf.push(value as u8);\n    buf\n}\n\n/// Read Protobuf Varint\npub fn read_varint(data: &[u8], offset: usize) -> Result<(u64, usize), String> {\n    let mut result = 0u64;\n    let mut shift = 0;\n    let mut pos = offset;\n\n    loop {\n        if pos >= data.len() {\n            return Err(\"incomplete_data\".to_string());\n        }\n        let byte = data[pos];\n        result |= ((byte & 0x7F) as u64) << shift;\n        pos += 1;\n        if byte & 0x80 == 0 {\n            break;\n        }\n        shift += 7;\n    }\n\n    Ok((result, pos))\n}\n\n/// Skip Protobuf Field\npub fn skip_field(data: &[u8], offset: usize, wire_type: u8) -> Result<usize, String> {\n    match wire_type {\n        0 => {\n            // Varint\n            let (_, new_offset) = read_varint(data, offset)?;\n            Ok(new_offset)\n        }\n        1 => {\n            // 64-bit\n            Ok(offset + 8)\n        }\n        2 => {\n            // Length-delimited\n            let (length, content_offset) = read_varint(data, offset)?;\n            Ok(content_offset + length as usize)\n        }\n        5 => {\n            // 32-bit\n            Ok(offset + 4)\n        }\n        _ => Err(format!(\"unknown_wire_type: {}\", wire_type)),\n    }\n}\n\n/// Remove specified Protobuf field\npub fn remove_field(data: &[u8], field_num: u32) -> Result<Vec<u8>, String> {\n    let mut result = Vec::new();\n    let mut offset = 0;\n\n    while offset < data.len() {\n        let start_offset = offset;\n        let (tag, new_offset) = read_varint(data, offset)?;\n        let wire_type = (tag & 7) as u8;\n        let current_field = (tag >> 3) as u32;\n\n        if current_field == field_num {\n            // Skip this field\n            offset = skip_field(data, new_offset, wire_type)?;\n        } else {\n            // Keep other fields\n            let next_offset = skip_field(data, new_offset, wire_type)?;\n            result.extend_from_slice(&data[start_offset..next_offset]);\n            offset = next_offset;\n        }\n    }\n\n    Ok(result)\n}\n\n/// Find specified Protobuf field content (Length-Delimited only)\npub fn find_field(data: &[u8], target_field: u32) -> Result<Option<Vec<u8>>, String> {\n    let mut offset = 0;\n\n    while offset < data.len() {\n        let (tag, new_offset) = match read_varint(data, offset) {\n            Ok(v) => v,\n            Err(_) => break, // Incomplete data, stop\n        };\n\n        let wire_type = (tag & 7) as u8;\n        let field_num = (tag >> 3) as u32;\n\n        if field_num == target_field && wire_type == 2 {\n            let (length, content_offset) = read_varint(data, new_offset)?;\n            return Ok(Some(data[content_offset..content_offset + length as usize].to_vec()));\n        }\n\n        // Skip field\n        offset = skip_field(data, new_offset, wire_type)?;\n    }\n\n    Ok(None)\n}\n\n/// Create OAuthTokenInfo (Field 6)\n/// \n/// Structure:\n/// message OAuthTokenInfo {\n///     optional string access_token = 1;\n///     optional string token_type = 2;\n///     optional string refresh_token = 3;\n///     optional Timestamp expiry = 4;\n/// }\npub fn create_oauth_field(access_token: &str, refresh_token: &str, expiry: i64) -> Vec<u8> {\n    // Field 1: access_token (string, wire_type = 2)\n    let tag1 = (1 << 3) | 2;\n    let field1 = {\n        let mut f = encode_varint(tag1);\n        f.extend(encode_varint(access_token.len() as u64));\n        f.extend(access_token.as_bytes());\n        f\n    };\n\n    // Field 2: token_type (string, fixed value \"Bearer\", wire_type = 2)\n    let tag2 = (2 << 3) | 2;\n    let token_type = \"Bearer\";\n    let field2 = {\n        let mut f = encode_varint(tag2);\n        f.extend(encode_varint(token_type.len() as u64));\n        f.extend(token_type.as_bytes());\n        f\n    };\n\n    // Field 3: refresh_token (string, wire_type = 2)\n    let tag3 = (3 << 3) | 2;\n    let field3 = {\n        let mut f = encode_varint(tag3);\n        f.extend(encode_varint(refresh_token.len() as u64));\n        f.extend(refresh_token.as_bytes());\n        f\n    };\n\n    // Field 4: expiry (Nested Timestamp message, wire_type = 2)\n    // Timestamp message contains: Field 1: seconds (int64, wire_type = 0)\n    let timestamp_tag = (1 << 3) | 0;  // Field 1, varint\n    let timestamp_msg = {\n        let mut m = encode_varint(timestamp_tag);\n        m.extend(encode_varint(expiry as u64));\n        m\n    };\n    \n    let tag4 = (4 << 3) | 2;  // Field 4, length-delimited\n    let field4 = {\n        let mut f = encode_varint(tag4);\n        f.extend(encode_varint(timestamp_msg.len() as u64));\n        f.extend(timestamp_msg);\n        f\n    };\n\n    // Merge all fields into OAuthTokenInfo message\n    let oauth_info = [field1, field2, field3, field4].concat();\n\n    // Wrap as Field 6 (length-delimited)\n    let tag6 = (6 << 3) | 2;\n    let mut field6 = encode_varint(tag6);\n    field6.extend(encode_varint(oauth_info.len() as u64));\n    field6.extend(oauth_info);\n\n    field6\n}\n\n\n/// Create Email (Field 2)\npub fn create_email_field(email: &str) -> Vec<u8> {\n    let tag = (2 << 3) | 2;\n    let mut f = encode_varint(tag);\n    f.extend(encode_varint(email.len() as u64));\n    f.extend(email.as_bytes());\n    f\n}\n\n/// 编码长度分隔字段 (wire_type = 2)\npub fn encode_len_delim_field(field_num: u32, data: &[u8]) -> Vec<u8> {\n    let tag = (field_num << 3) | 2;\n    let mut f = encode_varint(tag as u64);\n    f.extend(encode_varint(data.len() as u64));\n    f.extend_from_slice(data);\n    f\n}\n\n/// 编码字符串字段 (wire_type = 2)\npub fn encode_string_field(field_num: u32, value: &str) -> Vec<u8> {\n    encode_len_delim_field(field_num, value.as_bytes())\n}\n\n/// 创建 OAuthTokenInfo 消息（不包含 Field 6 包装，用于新格式）\npub fn create_oauth_info(access_token: &str, refresh_token: &str, expiry: i64) -> Vec<u8> {\n    // Field 1: access_token\n    let field1 = encode_string_field(1, access_token);\n    \n    // Field 2: token_type = \"Bearer\"\n    let field2 = encode_string_field(2, \"Bearer\");\n    \n    // Field 3: refresh_token\n    let field3 = encode_string_field(3, refresh_token);\n    \n    // Field 4: expiry (嵌套的 Timestamp 消息)\n    let timestamp_tag = (1 << 3) | 0;\n    let mut timestamp_msg = encode_varint(timestamp_tag);\n    timestamp_msg.extend(encode_varint(expiry as u64));\n    let field4 = encode_len_delim_field(4, &timestamp_msg);\n    \n    // 合并所有字段为 OAuthTokenInfo 消息\n    [field1, field2, field3, field4].concat()\n}\n\n"
  },
  {
    "path": "src-tauri/tauri.conf.json",
    "content": "{\n  \"$schema\": \"https://schema.tauri.app/config/2\",\n  \"productName\": \"Antigravity Tools\",\n  \"version\": \"4.1.30\",\n  \"identifier\": \"com.lbjlaq.antigravity-tools\",\n  \"build\": {\n    \"beforeDevCommand\": \"npm run dev\",\n    \"devUrl\": \"http://localhost:1420\",\n    \"beforeBuildCommand\": \"npm run build\",\n    \"frontendDist\": \"../dist\"\n  },\n  \"app\": {\n    \"withGlobalTauri\": false,\n    \"windows\": [\n      {\n        \"title\": \"Antigravity Tools\",\n        \"width\": 1024,\n        \"height\": 700,\n        \"titleBarStyle\": \"Overlay\",\n        \"hiddenTitle\": true,\n        \"transparent\": true,\n        \"visible\": false\n      }\n    ],\n    \"security\": {\n      \"csp\": \"default-src 'self'; img-src 'self' asset: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src ipc: http://ipc.localhost\"\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"targets\": \"all\",\n    \"createUpdaterArtifacts\": true,\n    \"icon\": [\n      \"icons/32x32.png\",\n      \"icons/128x128.png\",\n      \"icons/128x128@2x.png\",\n      \"icons/icon.icns\",\n      \"icons/icon.ico\"\n    ],\n    \"macOS\": {\n      \"entitlements\": \"Entitlements.plist\"\n    }\n  },\n  \"plugins\": {\n    \"updater\": {\n      \"active\": true,\n      \"endpoints\": [\n        \"https://github.com/lbjlaq/Antigravity-Manager/releases/latest/download/updater.json\"\n      ],\n      \"dialog\": true,\n      \"pubkey\": \"dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJFRjVDRjdCREE1Rjg2NkYKUldSdmhsL2FlOC8xdnJuTzBtaTRhZkk1VWJ1cW5QWGU3ZWEwU20yZHRlZStxMnRWcUIzc3FwT2IK\"\n    },\n    \"process\": null,\n    \"fs\": null,\n    \"dialog\": null,\n    \"opener\": null,\n    \"single-instance\": null\n  }\n}"
  },
  {
    "path": "tailwind.config.js",
    "content": "import daisyui from \"daisyui\";\nimport containerQueries from \"@tailwindcss/container-queries\";\n\n/** @type {import('tailwindcss').Config} */\nexport default {\n    content: [\n        \"./index.html\",\n        \"./src/**/*.{js,ts,jsx,tsx}\",\n    ],\n    darkMode: 'class',\n    theme: {\n        extend: {},\n    },\n    plugins: [daisyui, containerQueries],\n    daisyui: {\n        themes: [\n            {\n                light: {\n                    \"primary\": \"#3b82f6\",\n                    \"secondary\": \"#64748b\",\n                    \"accent\": \"#10b981\",\n                    \"neutral\": \"#1f2937\",\n                    \"base-100\": \"#ffffff\",\n                    \"info\": \"#0ea5e9\",\n                    \"success\": \"#10b981\",\n                    \"warning\": \"#f59e0b\",\n                    \"error\": \"#ef4444\",\n                },\n            },\n            {\n                dark: {\n                    \"primary\": \"#3b82f6\",\n                    \"secondary\": \"#94a3b8\",\n                    \"accent\": \"#10b981\",\n                    \"neutral\": \"#1f2937\",\n                    \"base-100\": \"#0f172a\", // Slate-900\n                    \"base-200\": \"#1e293b\", // Slate-800\n                    \"base-300\": \"#334155\", // Slate-700\n                    \"info\": \"#0ea5e9\",\n                    \"success\": \"#10b981\",\n                    \"warning\": \"#f59e0b\",\n                    \"error\": \"#ef4444\",\n                },\n            },\n        ],\n        darkTheme: \"dark\",\n    },\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true\n  },\n  \"include\": [\"src\"],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n"
  },
  {
    "path": "tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "vite.config.ts",
    "content": "import { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\n\n// @ts-expect-error process is a nodejs global\nconst host = process.env.TAURI_DEV_HOST;\n\n// https://vite.dev/config/\nexport default defineConfig(async () => ({\n  plugins: [react()],\n\n  // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`\n  //\n  // 1. prevent Vite from obscuring rust errors\n  clearScreen: false,\n  // 2. tauri expects a fixed port, fail if that port is not available\n  server: {\n    port: 1420,\n    strictPort: true,\n    host: host || false,\n    hmr: host\n      ? {\n        protocol: \"ws\",\n        host,\n        port: 1421,\n      }\n      : undefined,\n    watch: {\n      // 3. tell Vite to ignore watching `src-tauri`\n      ignored: [\"**/src-tauri/**\"],\n    },\n    proxy: {\n      \"/api/\": {\n        target: \"http://127.0.0.1:8045\",\n        changeOrigin: true,\n      },\n    },\n  },\n}));\n"
  },
  {
    "path": "web_site/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title data-i18n=\"pageTitle\">Antigravity Tools - 高性能代理与智能适配</title>\n    <meta name=\"description\"\n        content=\"Antigravity Tools - Only for Geeks. High-performance proxy and intelligent adapter stack.\">\n    <meta name=\"keywords\" content=\"proxy, adapter, claude, gemini, llm, ai, developer tools\">\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n    <script src=\"https://unpkg.com/lucide@latest\"></script>\n    <style>\n        @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap');\n        @import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&display=swap');\n\n        body {\n            font-family: 'Inter', sans-serif;\n            background-color: #0a0a0a;\n            color: #d1d1d1;\n            overflow-x: hidden;\n        }\n\n        .hero-bg {\n            background-image: linear-gradient(to bottom, rgba(10, 10, 10, 0.3), rgba(10, 10, 10, 1)), url('hero-bg.png');\n            background-size: cover;\n            background-position: center;\n        }\n\n        .glass {\n            background: rgba(18, 18, 18, 0.6);\n            backdrop-filter: blur(12px);\n            border: 1px solid rgba(16, 185, 129, 0.1);\n        }\n\n        .glass-dark {\n            background: rgba(10, 10, 10, 0.8);\n            backdrop-filter: blur(16px);\n            border: 1px solid rgba(255, 255, 255, 0.05);\n        }\n\n        .emerald-gradient {\n            background: linear-gradient(135deg, #10b981 0%, #059669 100%);\n            -webkit-background-clip: text;\n            background-clip: text;\n            -webkit-text-fill-color: transparent;\n        }\n\n        .neon-border {\n            box-shadow: 0 0 15px rgba(16, 185, 129, 0.2);\n            border: 1px solid rgba(16, 185, 129, 0.3);\n        }\n\n        .feature-card:hover {\n            transform: translateY(-5px);\n            border-color: rgba(16, 185, 129, 0.5);\n            box-shadow: 0 10px 30px rgba(16, 185, 129, 0.1);\n        }\n\n        .glow-btn:hover {\n            box-shadow: 0 0 20px rgba(16, 185, 129, 0.4);\n        }\n\n        .fade-in-up {\n            animation: fadeInUp 0.8s ease-out forwards;\n            opacity: 0;\n            transform: translateY(20px);\n        }\n\n        @keyframes fadeInUp {\n            to {\n                opacity: 1;\n                transform: translateY(0);\n            }\n        }\n\n        /* Marquee Animation */\n        .marquee-container {\n            overflow: hidden;\n            white-space: nowrap;\n            mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);\n            -webkit-mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);\n        }\n\n        .marquee-content {\n            display: inline-block;\n            animation: marquee 30s linear infinite;\n        }\n\n        @keyframes marquee {\n            0% {\n                transform: translateX(0);\n            }\n\n            100% {\n                transform: translateX(-50%);\n            }\n        }\n\n        /* Code Block Styles */\n        .code-font {\n            font-family: 'Fira Code', monospace;\n        }\n\n        .tab-btn.active {\n            background-color: rgba(16, 185, 129, 0.1);\n            color: #10b981;\n            border-bottom: 2px solid #10b981;\n        }\n    </style>\n</head>\n\n<body>\n    <!-- 导航栏 -->\n    <nav\n        class=\"fixed top-0 w-full z-50 glass border-b border-emerald-900/30 px-6 py-4 flex justify-between items-center transition-all duration-300\">\n        <a href=\"#\" class=\"flex items-center space-x-3 group\">\n            <img src=\"logo.png\" alt=\"Logo\"\n                class=\"w-8 h-8 rounded-lg shadow-lg shadow-emerald-500/20 group-hover:shadow-emerald-500/40 transition-shadow\">\n            <span\n                class=\"text-xl font-bold emerald-gradient uppercase tracking-wider group-hover:brightness-110 transition-all\">Antigravity</span>\n        </a>\n        <div class=\"flex items-center space-x-4 md:space-x-8\">\n            <div class=\"hidden md:flex space-x-8 text-sm font-medium\">\n                <a href=\"#\" class=\"text-emerald-400 font-bold\" data-i18n=\"navHome\">首页</a>\n                <a href=\"qa.html\" class=\"hover:text-emerald-400 transition-colors\" data-i18n=\"navFaq\">常见问题</a>\n                <a href=\"https://github.com/lbjlaq/Antigravity-Manager\"\n                    class=\"hover:text-emerald-400 transition-colors\">GitHub</a>\n            </div>\n            <button id=\"lang-toggle\" onclick=\"toggleLanguage()\"\n                class=\"glass px-3 py-1 rounded-lg text-xs font-bold border border-emerald-500/30 hover:bg-emerald-500/10 transition-all uppercase\">\n                EN\n            </button>\n        </div>\n    </nav>\n\n    <!-- Hero Section -->\n    <section class=\"relative min-h-[90vh] flex flex-col items-center justify-center hero-bg px-6 pt-20\">\n        <div class=\"max-w-5xl text-center z-10 fade-in-up\">\n            <div\n                class=\"inline-block px-4 py-1.5 mb-6 rounded-full glass border border-emerald-500/30 text-emerald-400 text-xs font-bold uppercase tracking-widest\">\n                v4.1.22 Available Now\n            </div>\n            <h1 class=\"text-5xl md:text-7xl font-extrabold text-white mb-6 uppercase tracking-tighter leading-none\"\n                data-i18n=\"heroSlogan\">\n                打破 <span class=\"emerald-gradient\">重力</span> 束缚<br>让 API 自由流转\n            </h1>\n            <p class=\"text-gray-400 text-lg md:text-xl mb-10 max-w-2xl mx-auto font-light leading-relaxed\"\n                data-i18n=\"heroSub\">\n                专业级 AI 账号智能调度与协议代理系统。\n                完美兼容 Claude Code, OpenAI 生态，为您打造极速、隐私、低成本的本地 AI 算力中枢。\n            </p>\n            <div class=\"flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6\">\n                <a href=\"https://github.com/lbjlaq/Antigravity-Manager/releases\"\n                    class=\"glow-btn bg-emerald-600 hover:bg-emerald-500 text-white px-8 py-4 rounded-full font-bold transition-all flex items-center shadow-lg shadow-emerald-500/20\">\n                    <i data-lucide=\"download\" class=\"w-5 h-5 mr-2\"></i> <span data-i18n=\"btnDownload\">立即下载</span>\n                </a>\n                <a href=\"https://github.com/lbjlaq/Antigravity-Manager\"\n                    class=\"glass px-8 py-4 rounded-full font-bold text-white hover:bg-emerald-900/20 transition-all flex items-center\">\n                    <i data-lucide=\"github\" class=\"w-5 h-5 mr-2\"></i> <span data-i18n=\"btnViewDocs\">查看文档</span>\n                </a>\n            </div>\n        </div>\n\n        <!-- Supported Models Marquee -->\n        <div class=\"w-full max-w-6xl mt-20 fade-in-up\" style=\"animation-delay: 0.2s;\">\n            <p class=\"text-center text-gray-500 text-xs uppercase tracking-widest mb-6\" data-i18n=\"supportedModels\">\n                Supported Models</p>\n            <div class=\"marquee-container relative\">\n                <div\n                    class=\"marquee-content space-x-12 opacity-50 grayscale hover:grayscale-0 hover:opacity-100 transition-all duration-500\">\n                    <!-- Duplicate items for seamless loop -->\n                    <span class=\"text-xl font-bold\">Claude 3.7 Sonnet</span>\n                    <span class=\"text-xl font-bold\">Claude 3.5 Opus</span>\n                    <span class=\"text-xl font-bold\">Gemini 1.5 Pro</span>\n                    <span class=\"text-xl font-bold\">Gemini 1.5 Flash</span>\n                    <span class=\"text-xl font-bold\">Imagen 3</span>\n                    <span class=\"text-xl font-bold\">GPT-4o Compatible</span>\n                    <span class=\"text-xl font-bold\">DeepSeek R1 (Via Proxy)</span>\n\n                    <span class=\"text-xl font-bold\">Claude 3.7 Sonnet</span>\n                    <span class=\"text-xl font-bold\">Claude 3.5 Opus</span>\n                    <span class=\"text-xl font-bold\">Gemini 1.5 Pro</span>\n                    <span class=\"text-xl font-bold\">Gemini 1.5 Flash</span>\n                    <span class=\"text-xl font-bold\">Imagen 3</span>\n                    <span class=\"text-xl font-bold\">GPT-4o Compatible</span>\n                    <span class=\"text-xl font-bold\">DeepSeek R1 (Via Proxy)</span>\n                </div>\n            </div>\n        </div>\n    </section>\n\n    <!-- 特性展示 -->\n    <section class=\"py-24 px-6 max-w-7xl mx-auto\">\n        <div class=\"text-center mb-16\">\n            <h2 class=\"text-3xl md:text-5xl font-bold text-white mb-4\" data-i18n=\"featuresTitle\">不仅仅是代理，更是智能中枢</h2>\n            <p class=\"text-gray-500 max-w-2xl mx-auto\" data-i18n=\"featuresSub\">全自动化的账号调度与协议转换，为您节省 90% 的运维精力。</p>\n        </div>\n\n        <div class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">\n            <!-- Feature 1 -->\n            <div class=\"feature-card glass p-8 rounded-3xl border border-emerald-900/20 transition-all duration-300\">\n                <div class=\"w-12 h-12 bg-emerald-500/10 rounded-2xl flex items-center justify-center mb-6 neon-border\">\n                    <i data-lucide=\"layout-dashboard\" class=\"text-emerald-400\"></i>\n                </div>\n                <h3 class=\"text-xl font-bold text-white mb-3\" data-i18n=\"feat1Title\">智能账号管家</h3>\n                <p class=\"text-gray-500 text-sm leading-relaxed\" data-i18n=\"feat1Sub\">\n                    实时监控所有账号的配额健康度。内置算法自动优选最佳账号，支持一键热切换，告别手工管理 Token 的烦恼。\n                </p>\n            </div>\n            <!-- Feature 2 -->\n            <div class=\"feature-card glass p-8 rounded-3xl border border-emerald-900/20 transition-all duration-300\">\n                <div class=\"w-12 h-12 bg-emerald-500/10 rounded-2xl flex items-center justify-center mb-6 neon-border\">\n                    <i data-lucide=\"plug-zap\" class=\"text-emerald-400\"></i>\n                </div>\n                <h3 class=\"text-xl font-bold text-white mb-3\" data-i18n=\"feat2Title\">全协议完美适配</h3>\n                <p class=\"text-gray-500 text-sm leading-relaxed\" data-i18n=\"feat2Sub\">\n                    原生支持 OpenAI, Claude (Anthropic), Gemini 协议格式。无缝接入 Claude Code CLI, Cursor, NextChat 等所有工具。\n                </p>\n            </div>\n            <!-- Feature 3 -->\n            <div class=\"feature-card glass p-8 rounded-3xl border border-emerald-900/20 transition-all duration-300\">\n                <div class=\"w-12 h-12 bg-emerald-500/10 rounded-2xl flex items-center justify-center mb-6 neon-border\">\n                    <i data-lucide=\"route\" class=\"text-emerald-400\"></i>\n                </div>\n                <h3 class=\"text-xl font-bold text-white mb-3\" data-i18n=\"feat3Title\">模型路由中心</h3>\n                <p class=\"text-gray-500 text-sm leading-relaxed\" data-i18n=\"feat3Sub\">\n                    支持正则级模型 ID 映射与重定向。智能分级路由策略 (Ultra/Pro/Free)，确保关键任务使用最高优先级资源。\n                </p>\n            </div>\n            <!-- Feature 4 -->\n            <div class=\"feature-card glass p-8 rounded-3xl border border-emerald-900/20 transition-all duration-300\">\n                <div class=\"w-12 h-12 bg-emerald-500/10 rounded-2xl flex items-center justify-center mb-6 neon-border\">\n                    <i data-lucide=\"activity\" class=\"text-emerald-400\"></i>\n                </div>\n                <h3 class=\"text-xl font-bold text-white mb-3\" data-i18n=\"feat4Title\">企业级稳定性</h3>\n                <p class=\"text-gray-500 text-sm leading-relaxed\" data-i18n=\"feat4Sub\">\n                    内置 429/401 错误自动重试与静默轮换机制。当某个账号风控或耗尽时，毫秒级切换至备用账号，业务零中断。\n                </p>\n            </div>\n            <!-- Feature 5 -->\n            <div class=\"feature-card glass p-8 rounded-3xl border border-emerald-900/20 transition-all duration-300\">\n                <div class=\"w-12 h-12 bg-emerald-500/10 rounded-2xl flex items-center justify-center mb-6 neon-border\">\n                    <i data-lucide=\"shield-check\" class=\"text-emerald-400\"></i>\n                </div>\n                <h3 class=\"text-xl font-bold text-white mb-3\" data-i18n=\"feat5Title\">隐私安全优先</h3>\n                <p class=\"text-gray-500 text-sm leading-relaxed\" data-i18n=\"feat5Sub\">\n                    所有数据、配置文件、API Key 均仅存储于您本地机器。无云端同步，无数据上传，给你最彻底的安全感。\n                </p>\n            </div>\n            <!-- Feature 6 -->\n            <div class=\"feature-card glass p-8 rounded-3xl border border-emerald-900/20 transition-all duration-300\">\n                <div class=\"w-12 h-12 bg-emerald-500/10 rounded-2xl flex items-center justify-center mb-6 neon-border\">\n                    <i data-lucide=\"cpu\" class=\"text-emerald-400\"></i>\n                </div>\n                <h3 class=\"text-xl font-bold text-white mb-3\" data-i18n=\"feat6Title\">高性能 Rust 核心</h3>\n                <p class=\"text-gray-500 text-sm leading-relaxed\" data-i18n=\"feat6Sub\">\n                    基于 Rust + Axum + Tauri v2 打造。拥有极致的内存占用与并发处理能力，让每一次 API 调用都快如闪电。\n                </p>\n            </div>\n        </div>\n    </section>\n\n    <!-- 快速接入 Tabs -->\n    <section class=\"px-6 pb-24 max-w-5xl mx-auto\">\n        <div class=\"text-center mb-10\">\n            <h2 class=\"text-2xl md:text-3xl font-bold text-white mb-3\" data-i18n=\"quickStartTitle\">三行代码，极速接入</h2>\n            <p class=\"text-gray-500\" data-i18n=\"quickStartSub\">兼容所有支持 OpenAI 或 Anthropic 协议的工具。</p>\n        </div>\n\n        <div class=\"glass-dark rounded-2xl overflow-hidden border border-emerald-900/30 shadow-2xl\">\n            <!-- Tabs Header -->\n            <div class=\"flex border-b border-emerald-900/30 bg-black/20\">\n                <button onclick=\"switchTab('cli')\"\n                    class=\"tab-btn active px-6 py-4 text-sm font-bold text-gray-400 hover:text-white transition-all\">Claude\n                    CLI</button>\n                <button onclick=\"switchTab('python')\"\n                    class=\"tab-btn px-6 py-4 text-sm font-bold text-gray-400 hover:text-white transition-all\">Python\n                    SDK</button>\n                <button onclick=\"switchTab('opencode')\"\n                    class=\"tab-btn px-6 py-4 text-sm font-bold text-gray-400 hover:text-white transition-all\">OpenCode</button>\n            </div>\n\n            <!-- Tabs Content -->\n            <div class=\"p-6 md:p-8 bg-[#0d1117]\">\n                <div id=\"tab-cli\" class=\"tab-content block\">\n                    <div class=\"flex justify-between items-center mb-2\">\n                        <span class=\"text-xs text-gray-500\">Terminal (Bash/Zsh)</span>\n                        <button onclick=\"copyCode('code-cli')\"\n                            class=\"text-xs text-emerald-500 hover:text-emerald-400 flex items-center\">\n                            <i data-lucide=\"copy\" class=\"w-3 h-3 mr-1\"></i> Copy\n                        </button>\n                    </div>\n                    <pre class=\"code-font text-sm text-gray-300 overflow-x-auto\"><code id=\"code-cli\"># 1. 开启 Antigravity 代理服务\n# 2. 配置环境变量\nexport ANTHROPIC_API_KEY=\"sk-antigravity\"\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:8045\"\n\n# 3. 开始使用\nclaude</code></pre>\n                </div>\n\n                <div id=\"tab-python\" class=\"tab-content hidden\">\n                    <div class=\"flex justify-between items-center mb-2\">\n                        <span class=\"text-xs text-gray-500\">Python</span>\n                        <button onclick=\"copyCode('code-python')\"\n                            class=\"text-xs text-emerald-500 hover:text-emerald-400 flex items-center\">\n                            <i data-lucide=\"copy\" class=\"w-3 h-3 mr-1\"></i> Copy\n                        </button>\n                    </div>\n                    <pre class=\"code-font text-sm text-gray-300 overflow-x-auto\"><code id=\"code-python\">import openai\n\nclient = openai.OpenAI(\n    api_key=\"sk-antigravity\",\n    base_url=\"http://127.0.0.1:8045/v1\"\n)\n\nresponse = client.chat.completions.create(\n    model=\"gemini-3.1-pro-high\",\n    messages=[{\"role\": \"user\", \"content\": \"Hello, Antigravity!\"}]\n)\nprint(response.choices[0].message.content)</code></pre>\n                </div>\n\n                <div id=\"tab-opencode\" class=\"tab-content hidden\">\n                    <div class=\"flex justify-between items-center mb-2\">\n                        <span class=\"text-xs text-gray-500\">opencode.json</span>\n                        <button onclick=\"copyCode('code-opencode')\"\n                            class=\"text-xs text-emerald-500 hover:text-emerald-400 flex items-center\">\n                            <i data-lucide=\"copy\" class=\"w-3 h-3 mr-1\"></i> Copy\n                        </button>\n                    </div>\n                    <pre class=\"code-font text-sm text-gray-300 overflow-x-auto\"><code id=\"code-opencode\">{\n  \"providers\": {\n    \"antigravity-manager\": {\n      \"type\": \"openai\",\n      \"api_key\": \"sk-antigravity\",\n      \"base_url\": \"http://127.0.0.1:8045/v1\",\n      \"models\": [\n        \"claude-sonnet-4-6-thinking\",\n        \"gemini-3.1-pro-high\"\n      ]\n    }\n  }\n}</code></pre>\n                </div>\n            </div>\n        </div>\n    </section>\n\n    <footer class=\"py-12 border-t border-emerald-900/30 px-6 text-center\">\n        <div class=\"mb-6\">\n            <img src=\"logo.png\" alt=\"Logo\"\n                class=\"w-8 h-8 mx-auto mb-4 opacity-50 grayscale hover:grayscale-0 transition-all duration-500\">\n            <p class=\"text-gray-600 text-sm mb-2\">© 2026 Antigravity Tools. Built for performance.</p>\n        </div>\n        <div class=\"flex justify-center space-x-6 text-xs text-gray-500 font-medium uppercase tracking-widest\">\n            <a href=\"https://github.com/lbjlaq/Antigravity-Manager\"\n                class=\"hover:text-emerald-500 transition-colors\">GitHub</a>\n            <a href=\"qa.html\" class=\"hover:text-emerald-500 transition-colors\" data-i18n=\"navFaq\">常见问题</a>\n            <a href=\"#\" class=\"hover:text-emerald-500 transition-colors\">v4.1.22</a>\n        </div>\n    </footer>\n\n    <script>\n        const translations = {\n            zh: {\n                pageTitle: \"Antigravity Tools - 高性能代理与智能适配\",\n                navHome: \"首页\",\n                navFaq: \"常见问题\",\n                heroSlogan: \"打破 <span class='emerald-gradient'>重力</span> 束缚<br>让 API 自由流转\",\n                heroSub: \"专业级 AI 账号智能调度与协议代理系统。完美兼容 Claude Code, OpenAI 生态，为您打造极速、隐私、低成本的本地 AI 算力中枢。\",\n                btnDownload: \"立即下载\",\n                btnViewDocs: \"查看文档\",\n                supportedModels: \"支持的主流模型\",\n                featuresTitle: \"不仅仅是代理，更是智能中枢\",\n                featuresSub: \"全自动化的账号调度与协议转换，为您节省 90% 的运维精力。\",\n                feat1Title: \"智能账号管家\",\n                feat1Sub: \"实时监控所有账号的配额健康度。内置算法自动优选最佳账号，支持一键热切换，告别手工管理 Token 的烦恼。\",\n                feat2Title: \"全协议完美适配\",\n                feat2Sub: \"原生支持 OpenAI, Claude (Anthropic), Gemini 协议格式。无缝接入 Claude Code CLI, Cursor, NextChat 等所有工具。\",\n                feat3Title: \"模型路由中心\",\n                feat3Sub: \"支持正则级模型 ID 映射与重定向。智能分级路由策略 (Ultra/Pro/Free)，确保关键任务使用最高优先级资源。\",\n                feat4Title: \"企业级稳定性\",\n                feat4Sub: \"内置 429/401 错误自动重试与静默轮换机制。当某个账号风控或耗尽时，毫秒级切换至备用账号，业务零中断。\",\n                feat5Title: \"隐私安全优先\",\n                feat5Sub: \"所有数据、配置文件、API Key 均仅存储于您本地机器。无云端同步，无数据上传，给你最彻底的安全感。\",\n                feat6Title: \"高性能 Rust 核心\",\n                feat6Sub: \"基于 Rust + Axum + Tauri v2 打造。拥有极致的内存占用与并发处理能力，让每一次 API 调用都快如闪电。\",\n                quickStartTitle: \"三行代码，极速接入\",\n                quickStartSub: \"兼容所有支持 OpenAI 或 Anthropic 协议的工具。\",\n                toggleLabel: \"EN\"\n            },\n            en: {\n                pageTitle: \"Antigravity Tools - High Performance Proxy Stack\",\n                navHome: \"Home\",\n                navFaq: \"FAQ\",\n                heroSlogan: \"Defy <span class='emerald-gradient'>Gravity</span><br>Unleash Your API\",\n                heroSub: \"Professional AI account scheduler and protocol adapter system. Perfectly compatible with Claude Code and OpenAI ecosystem.\",\n                btnDownload: \"Download Now\",\n                btnViewDocs: \"Documentation\",\n                supportedModels: \"Supported Models\",\n                featuresTitle: \"More Than A Proxy\",\n                featuresSub: \"Fully automated account scheduling and protocol conversion, saving 90% of your maintenance effort.\",\n                feat1Title: \"Smart Account Manager\",\n                feat1Sub: \"Real-time monitoring of quota health. Auto-selects the best account with one-click hot switching.\",\n                feat2Title: \"Full Protocol Support\",\n                feat2Sub: \"Native support for OpenAI, Claude, Gemini protocols. Seamless integration with Claude Code CLI, Cursor, etc.\",\n                feat3Title: \"Model Router\",\n                feat3Sub: \"Regex-level mapping. Smart tiered routing (Ultra/Pro/Free) ensures critical tasks get priority resources.\",\n                feat4Title: \"Enterprise Stability\",\n                feat4Sub: \"Auto-retry and silent rotation on 429/401 errors. Millisecond-level failover keeps your business running.\",\n                feat5Title: \"Privacy First\",\n                feat5Sub: \"All data and keys are stored locally. No cloud sync, no uploads. Complete peace of mind.\",\n                feat6Title: \"High Performance Rust\",\n                feat6Sub: \"Built on Rust + Axum + Tauri v2. Extreme memory efficiency and concurrency for lightning-fast API calls.\",\n                quickStartTitle: \"3 Lines to Connect\",\n                quickStartSub: \"Compatible with any tool supporting OpenAI or Anthropic protocols.\",\n                toggleLabel: \"中文\"\n            }\n        };\n\n        let currentLang = 'zh';\n\n        function initLanguage() {\n            const browserLang = navigator.language.split('-')[0];\n            currentLang = (browserLang === 'zh') ? 'zh' : 'en';\n            updateUI();\n        }\n\n        function toggleLanguage() {\n            currentLang = (currentLang === 'zh') ? 'en' : 'zh';\n            updateUI();\n        }\n\n        function updateUI() {\n            document.querySelectorAll('[data-i18n]').forEach(el => {\n                const key = el.getAttribute('data-i18n');\n                if (key === 'heroSlogan') {\n                    el.innerHTML = translations[currentLang][key];\n                } else {\n                    el.innerText = translations[currentLang][key];\n                }\n            });\n            document.getElementById('lang-toggle').innerText = translations[currentLang].toggleLabel;\n            lucide.createIcons();\n        }\n\n        // Tab Switching Logic\n        function switchTab(tabId) {\n            // Update buttons\n            document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));\n            event.target.classList.add('active');\n\n            // Update content\n            document.querySelectorAll('.tab-content').forEach(content => content.classList.add('hidden'));\n            document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('block'));\n\n            document.getElementById('tab-' + tabId).classList.remove('hidden');\n            document.getElementById('tab-' + tabId).classList.add('block');\n        }\n\n        function copyCode(elementId) {\n            const code = document.getElementById(elementId).innerText;\n            navigator.clipboard.writeText(code).then(() => {\n                alert('Copied to clipboard!');\n            });\n        }\n\n        initLanguage();\n    </script>\n</body>\n\n</html>\n"
  },
  {
    "path": "web_site/qa.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title data-i18n=\"pageTitle\">常见问题 - Antigravity Tools</title>\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n    <script src=\"https://unpkg.com/lucide@latest\"></script>\n    <style>\n        @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap');\n\n        body {\n            font-family: 'Inter', sans-serif;\n            background-color: #0a0a0a;\n            color: #d1d1d1;\n        }\n\n        .glass {\n            background: rgba(18, 18, 18, 0.7);\n            backdrop-filter: blur(12px);\n            border: 1px solid rgba(16, 185, 129, 0.1);\n        }\n\n        .emerald-gradient {\n            background: linear-gradient(135deg, #10b981 0%, #059669 100%);\n            -webkit-background-clip: text;\n            background-clip: text;\n            -webkit-text-fill-color: transparent;\n        }\n\n        .faq-item {\n            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n        }\n\n        .faq-item:hover {\n            border-color: rgba(16, 185, 129, 0.4);\n            background: rgba(16, 185, 129, 0.05);\n        }\n\n        .faq-answer {\n            max-height: 0;\n            overflow: hidden;\n            transition: max-height 0.3s ease-out;\n        }\n\n        .faq-item.active .faq-answer {\n            max-height: 800px;\n            margin-top: 1rem;\n        }\n\n        .faq-item.active .chevron {\n            transform: rotate(180deg);\n        }\n\n        .search-bar:focus-within {\n            border-color: #10b981;\n            box-shadow: 0 0 15px rgba(16, 185, 129, 0.1);\n        }\n    </style>\n</head>\n\n<body class=\"pb-20\">\n    <!-- 导航栏 -->\n    <nav\n        class=\"sticky top-0 w-full z-50 glass border-b border-emerald-900/30 px-6 py-4 flex justify-between items-center\">\n        <div class=\"flex items-center space-x-3 cursor-pointer\" onclick=\"location.href='index.html'\">\n            <img src=\"logo.png\" alt=\"Logo\" class=\"w-8 h-8 rounded-lg shadow-lg shadow-emerald-500/20\">\n            <span class=\"text-xl font-bold emerald-gradient uppercase tracking-wider\">Antigravity</span>\n        </div>\n        <div class=\"flex items-center space-x-4 md:space-x-8\">\n            <div class=\"hidden md:flex space-x-8 text-sm font-medium\">\n                <a href=\"index.html\" class=\"hover:text-emerald-400 transition-colors\" data-i18n=\"navHome\">首页</a>\n                <a href=\"#\" class=\"text-emerald-400 transition-colors font-bold\" data-i18n=\"navFaq\">常见问题</a>\n            </div>\n            <!-- 语言切换器 -->\n            <button id=\"lang-toggle\" onclick=\"toggleLanguage()\"\n                class=\"glass px-3 py-1 rounded-lg text-xs font-bold border border-emerald-500/30 hover:bg-emerald-500/10 transition-all uppercase\">\n                EN\n            </button>\n        </div>\n    </nav>\n\n    <!-- 页头 -->\n    <header class=\"py-20 px-6 text-center max-w-4xl mx-auto\">\n        <h1 class=\"text-4xl md:text-5xl font-bold mb-6 text-white uppercase tracking-tight\">\n            <span data-i18n=\"headerTitlePre\">QA 专题页</span> / <span class=\"emerald-gradient\"\n                data-i18n=\"headerTitlePost\">常见问题</span>\n        </h1>\n        <p class=\"text-gray-500 text-lg mb-10\" data-i18n=\"headerSub\">在这里查找您在使用过程中遇到的各类问题及解决方案。</p>\n\n        <!-- 搜索框 -->\n        <div\n            class=\"search-bar max-w-2xl mx-auto flex items-center glass rounded-2xl p-2 border border-emerald-900/30 transition-all\">\n            <i data-lucide=\"search\" class=\"w-5 h-5 ml-4 text-emerald-500/50\"></i>\n            <input type=\"text\" id=\"faq-search\" placeholder=\"搜索问题关键字...\" data-i18n-placeholder=\"searchPlaceholder\"\n                class=\"bg-transparent border-none focus:ring-0 text-white w-full px-4 py-3 placeholder-gray-600 outline-none\">\n        </div>\n    </header>\n\n    <!-- 问题列表容器 -->\n    <main class=\"max-w-4xl mx-auto px-6\">\n        <div class=\"space-y-4\" id=\"faq-container\">\n            <!-- 动态渲染内容 -->\n        </div>\n\n        <!-- 空状态展示 -->\n        <div id=\"no-results\" class=\"hidden py-20 text-center text-gray-600\">\n            <i data-lucide=\"circle-dashed\" class=\"w-12 h-12 mx-auto mb-4 opacity-20\"></i>\n            <p data-i18n=\"noResults\">未找到相关问题，请尝试更换关键词。</p>\n        </div>\n    </main>\n\n    <footer class=\"mt-20 py-12 border-t border-emerald-900/30 px-6 text-center\">\n        <p class=\"text-gray-600 text-sm\">\n            <span data-i18n=\"footerHelp\">需要更多帮助？</span>\n            <a href=\"https://github.com/lbjlaq/Antigravity-Manager/issues\" class=\"text-emerald-500 hover:underline ml-1\"\n                data-i18n=\"footerIssue\">前往 GitHub 提交 Issue</a>\n        </p>\n    </footer>\n\n    <script>\n        const translations = {\n            zh: {\n                pageTitle: \"常见问题 - Antigravity Tools\",\n                navHome: \"首页\",\n                navFaq: \"常见问题\",\n                headerTitlePre: \"QA 专题页\",\n                headerTitlePost: \"常见问题\",\n                headerSub: \"在这里查找您在使用过程中遇到的各类问题及解决方案。\",\n                searchPlaceholder: \"搜索问题关键字...\",\n                noResults: \"未找到相关问题，请尝试更换关键词。\",\n                footerHelp: \"需要更多帮助？\",\n                footerIssue: \"前往 GitHub 提交 Issue\",\n                toggleLabel: \"EN\"\n            },\n            en: {\n                pageTitle: \"FAQ - Antigravity Tools\",\n                navHome: \"Home\",\n                navFaq: \"FAQ\",\n                headerTitlePre: \"QA Portal\",\n                headerTitlePost: \"FAQ\",\n                headerSub: \"Find answers and solutions to common questions here.\",\n                searchPlaceholder: \"Search for keywords...\",\n                noResults: \"No results found. Try different keywords.\",\n                footerHelp: \"Need more help?\",\n                footerIssue: \"Open an Issue on GitHub\",\n                toggleLabel: \"中文\"\n            }\n        };\n\n        const faqData = [\n            {\n                id: 1,\n                title: {\n                    zh: \"macOS 提示“应用已损坏，无法打开”？\",\n                    en: \"macOS says 'The app is damaged and can't be opened'?\"\n                },\n                answer: {\n                    zh: `\n                        <p class=\"mb-4\">由于 macOS 的安全机制，非 App Store 下载的应用可能会触发此提示。您可以执行以下命令快速修复：</p>\n                        <code class=\"block bg-black/50 p-3 rounded-lg border border-emerald-900/30 text-emerald-400 font-mono mb-4 text-xs\">\n                            sudo xattr -rd com.apple.quarantine \"/Applications/Antigravity Tools.app\"\n                        </code>\n                        <p>如果您使用 Homebrew 安装，建议添加 <code class=\"text-emerald-500\">--no-quarantine</code> 参数。</p>\n                    `,\n                    en: `\n                        <p class=\"mb-4\">Due to macOS security mechanisms, apps not from the App Store may trigger this. Fix it with this command:</p>\n                        <code class=\"block bg-black/50 p-3 rounded-lg border border-emerald-900/30 text-emerald-400 font-mono mb-4 text-xs\">\n                            sudo xattr -rd com.apple.quarantine \"/Applications/Antigravity Tools.app\"\n                        </code>\n                        <p>If using Homebrew, try adding the <code class=\"text-emerald-500\">--no-quarantine</code> flag.</p>\n                    `\n                }\n            },\n            {\n                id: 2,\n                title: {\n                    zh: \"遇到 HTTP 403 Forbidden 错误怎么办？\",\n                    en: \"What to do with HTTP 403 Forbidden errors?\"\n                },\n                answer: {\n                    zh: `\n                        <p class=\"mb-4\">403 错误通常分以下几种情况：</p>\n                        <div class=\"space-y-4\">\n                            <div class=\"bg-black/30 p-4 rounded-xl border border-emerald-900/20\">\n                                <h4 class=\"text-emerald-400 font-bold mb-1 text-sm\">情况 A: VALIDATION_REQUIRED (身份验证)</h4>\n                                <p class=\"text-xs mb-2\">表现：能登录应用但无法发消息，收到包含验证链接的提示。</p>\n                                <p class=\"text-xs font-bold text-gray-300\">解决方法：点击链接并在浏览器配合手机验证码完成验证即可。</p>\n                            </div>\n                            <div class=\"bg-black/30 p-4 rounded-xl border border-emerald-900/20\">\n                                <h4 class=\"text-emerald-400 font-bold mb-1 text-sm\">情况 B: 临时风控 / Unexpected Issue</h4>\n                                <p class=\"text-xs mb-2\">表现：登录时提示 \"There was an unexpected issue...\"。</p>\n                                <p class=\"text-xs font-bold text-gray-300 italic\">经验建议：静置账号 1-2 周，谷歌系统通常会周期性评估并自动恢复权限。</p>\n                            </div>\n                            <div class=\"bg-black/30 p-4 rounded-xl border border-red-900/20\">\n                                <h4 class=\"text-red-400 font-bold mb-1 text-sm\">情况 C: Violation of Terms of Service (封禁)</h4>\n                                <p class=\"text-xs font-bold text-gray-300 italic\">建议：邮件申述或更换账号。</p>\n                            </div>\n                        </div>\n                    `,\n                    en: `\n                        <p class=\"mb-4\">403 errors usually fall into these categories:</p>\n                        <div class=\"space-y-4\">\n                            <div class=\"bg-black/30 p-4 rounded-xl border border-emerald-900/20\">\n                                <h4 class=\"text-emerald-400 font-bold mb-1 text-sm\">Case A: VALIDATION_REQUIRED</h4>\n                                <p class=\"text-xs mb-2\">Symptom: Can log in but fail to send messages, receiving a link.</p>\n                                <p class=\"text-xs font-bold text-gray-300\">Fix: Click the link and complete mobile verification in browser.</p>\n                            </div>\n                            <div class=\"bg-black/30 p-4 rounded-xl border border-emerald-900/20\">\n                                <h4 class=\"text-emerald-400 font-bold mb-1 text-sm\">Case B: Account Risk / Unexpected Issue</h4>\n                                <p class=\"text-xs mb-2\">Symptom: Login prompt \"There was an unexpected issue...\"</p>\n                                <p class=\"text-xs font-bold text-gray-300 italic\">Advice: Wait 1-2 weeks. Google systems often restore access periodically.</p>\n                            </div>\n                            <div class=\"bg-black/30 p-4 rounded-xl border border-red-900/20\">\n                                <h4 class=\"text-red-400 font-bold mb-1 text-sm\">Case C: Violation of Terms (Banned)</h4>\n                                <p class=\"text-xs font-bold text-gray-300 italic\">Advice: Email appeal or switch account.</p>\n                            </div>\n                        </div>\n                    `\n                }\n            },\n            {\n                id: 3,\n                title: {\n                    zh: \"提示 “FAILED_PRECONDITION” 或 400 错误？\",\n                    en: \"Getting 'FAILED_PRECONDITION' or 400 error?\"\n                },\n                answer: {\n                    zh: `\n                        <p class=\"mb-2\">当显示 <strong>\"Agent terminated due to error\"</strong> 且返回 400 预检查失败时，通常是因为 IP 被风控。</p>\n                        <p class=\"emerald-gradient font-bold text-sm\">解决方法：切换一个干净的出口 IP 即可恢复。</p>\n                    `,\n                    en: `\n                        <p class=\"mb-2\">If you see <strong>\"Agent terminated due to error\"</strong> with a 400 check failure, it's usually an IP risk issue.</p>\n                        <p class=\"emerald-gradient font-bold text-sm\">Fix: Switch to a cleaner proxy/IP to restore service.</p>\n                    `\n                }\n            },\n            {\n                id: 4,\n                title: {\n                    zh: \"为什么提示 “Resource projects/... not found” (404)？\",\n                    en: \"Why 'Resource projects/... not found' (404)?\"\n                },\n                answer: {\n                    zh: `\n                        <p class=\"mb-4\">未找到有效项目。请尝试：</p>\n                        <ul class=\"list-disc pl-5 space-y-2 text-xs\">\n                            <li>在应用中“删除账户”并“重新添加”以重置会话。</li>\n                            <li>手动配置有效的 Google Cloud Project ID。</li>\n                        </ul>\n                    `,\n                    en: `\n                        <p class=\"mb-4\">Valid project not found. Try:</p>\n                        <ul class=\"list-disc pl-5 space-y-2 text-xs\">\n                            <li>Remove and re-add the account in the app.</li>\n                            <li>Manually configure a valid Google Cloud Project ID.</li>\n                        </ul>\n                    `\n                }\n            },\n            {\n                id: 5,\n                title: {\n                    zh: \"如何接入 Claude Code CLI？\",\n                    en: \"How to connect Claude Code CLI?\"\n                },\n                answer: {\n                    zh: `\n                        <p class=\"mb-4\">在开启代理后，在终端执行：</p>\n                        <code class=\"block bg-black/50 p-3 rounded-lg border border-emerald-900/30 text-emerald-400 font-mono text-xs mb-4\">\n                            export ANTHROPIC_API_KEY=\"sk-antigravity\"<br>\n                            export ANTHROPIC_BASE_URL=\"http://127.0.0.1:8045\"<br>\n                            claude\n                        </code>\n                    `,\n                    en: `\n                        <p class=\"mb-4\">Enable proxy, then run in terminal:</p>\n                        <code class=\"block bg-black/50 p-3 rounded-lg border border-emerald-900/30 text-emerald-400 font-mono text-xs mb-4\">\n                            export ANTHROPIC_API_KEY=\"sk-antigravity\"<br>\n                            export ANTHROPIC_BASE_URL=\"http://127.0.0.1:8045\"<br>\n                            claude\n                        </code>\n                    `\n                }\n            },\n            {\n                id: 6,\n                title: {\n                    zh: \"提示 “Invalid project resource name projects/” 错误？\",\n                    en: \"Getting 'Invalid project resource name projects/' error?\"\n                },\n                answer: {\n                    zh: `\n                        <p class=\"mb-4\">这通常是因为 Google 对于通过第三方工具登录的账号加强了风控，缺少官方客户端登录后的初始化操作，导致服务端的项目绑定关系缺失。</p>\n                        <div class=\"space-y-4\">\n                            <div class=\"bg-black/30 p-4 rounded-xl border border-emerald-900/20\">\n                                <h4 class=\"text-emerald-400 font-bold mb-2 text-sm\">方案一（最简单）：</h4>\n                                <p class=\"text-xs\">重新通过 antigravity 应用登录获取授权即可恢复。</p>\n                            </div>\n                            <div class=\"bg-black/30 p-4 rounded-xl border border-emerald-900/20\">\n                                <h4 class=\"text-emerald-400 font-bold mb-2 text-sm\">方案二：</h4>\n                                <ol class=\"list-decimal pl-5 space-y-2 text-xs\">\n                                    <li>使用官方客户端手动登录一次该账号，触发服务端的初始化和项目绑定。</li>\n                                    <li>登录成功后即可退出，之后再切回本工具使用旧的 Refresh Token 即可恢复正常。</li>\n                                </ol>\n                            </div>\n                        </div>\n                    `,\n                    en: `\n                        <p class=\"mb-4\">This usually occurs because Google has strengthened risk control for accounts logged in via third-party tools, skipping necessary initialization. This results in missing project bindings on the server side.</p>\n                        <div class=\"space-y-4\">\n                            <div class=\"bg-black/30 p-4 rounded-xl border border-emerald-900/20\">\n                                <h4 class=\"text-emerald-400 font-bold mb-2 text-sm\">Option 1 (Easiest):</h4>\n                                <p class=\"text-xs\">Simply re-login via the antigravity app to refresh your authorization.</p>\n                            </div>\n                            <div class=\"bg-black/30 p-4 rounded-xl border border-emerald-900/20\">\n                                <h4 class=\"text-emerald-400 font-bold mb-2 text-sm\">Option 2:</h4>\n                                <ol class=\"list-decimal pl-5 space-y-2 text-xs\">\n                                    <li>Manually log in to the account once using the official client to trigger server-side initialization and project binding.</li>\n                                    <li>After a successful login, you can log out and return to this tool. Using your existing Refresh Token should now work correctly.</li>\n                                </ol>\n                            </div>\n                        </div>\n                    `\n                }\n            }\n        ];\n\n        let currentLang = 'zh';\n\n        function initLanguage() {\n            const browserLang = navigator.language.split('-')[0];\n            currentLang = (browserLang === 'zh') ? 'zh' : 'en';\n            updateUI();\n        }\n\n        function toggleLanguage() {\n            currentLang = (currentLang === 'zh') ? 'en' : 'zh';\n            updateUI();\n        }\n\n        function updateUI() {\n            // 更新静态文本\n            document.querySelectorAll('[data-i18n]').forEach(el => {\n                const key = el.getAttribute('data-i18n');\n                el.innerText = translations[currentLang][key];\n            });\n\n            document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {\n                const key = el.getAttribute('data-i18n-placeholder');\n                el.placeholder = translations[currentLang][key];\n            });\n\n            document.getElementById('lang-toggle').innerText = translations[currentLang].toggleLabel;\n\n            renderFaqs();\n            lucide.createIcons();\n        }\n\n        function renderFaqs() {\n            const container = document.getElementById('faq-container');\n            const searchTerm = document.getElementById('faq-search').value.toLowerCase();\n            container.innerHTML = '';\n\n            let hasResults = false;\n            faqData.forEach(item => {\n                const title = item.title[currentLang];\n                const answer = item.answer[currentLang];\n\n                // 搜索过滤\n                if (title.toLowerCase().includes(searchTerm) || answer.toLowerCase().includes(searchTerm)) {\n                    hasResults = true;\n                    const el = document.createElement('div');\n                    el.className = 'faq-item glass rounded-2xl p-6 border border-emerald-900/20 cursor-pointer';\n                    el.onclick = () => toggleFaq(el);\n                    el.innerHTML = `\n                        <div class=\"flex justify-between items-center text-sm md:text-base\">\n                            <h3 class=\"font-bold text-white pr-4\">${title}</h3>\n                            <i data-lucide=\"chevron-down\" class=\"chevron w-5 h-5 text-emerald-500 transition-transform\"></i>\n                        </div>\n                        <div class=\"faq-answer text-gray-400 text-sm leading-relaxed border-t border-emerald-900/10 pt-4\">\n                            ${answer}\n                        </div>\n                    `;\n                    container.appendChild(el);\n                }\n            });\n\n            document.getElementById('no-results').classList.toggle('hidden', hasResults);\n            lucide.createIcons();\n        }\n\n        function toggleFaq(element) {\n            const isActive = element.classList.contains('active');\n            document.querySelectorAll('.faq-item').forEach(item => item.classList.remove('active'));\n            if (!isActive) element.classList.add('active');\n        }\n\n        document.getElementById('faq-search').addEventListener('input', renderFaqs);\n\n        // 初始化\n        initLanguage();\n    </script>\n</body>\n\n</html>"
  }
]