Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14964667ac | |||
| e143ef30e3 |
@@ -1,38 +0,0 @@
|
|||||||
# Auto detect text files and perform LF normalization
|
|
||||||
* text=auto
|
|
||||||
|
|
||||||
# Explicitly declare text files you want to always be normalized and converted
|
|
||||||
# to native line endings on checkout.
|
|
||||||
*.rs text eol=lf
|
|
||||||
*.toml text eol=lf
|
|
||||||
*.json text eol=lf
|
|
||||||
*.md text eol=lf
|
|
||||||
*.yml text eol=lf
|
|
||||||
*.yaml text eol=lf
|
|
||||||
*.txt text eol=lf
|
|
||||||
|
|
||||||
# TypeScript/JavaScript files
|
|
||||||
*.ts text eol=lf
|
|
||||||
*.tsx text eol=lf
|
|
||||||
*.js text eol=lf
|
|
||||||
*.jsx text eol=lf
|
|
||||||
|
|
||||||
# HTML/CSS files
|
|
||||||
*.html text eol=lf
|
|
||||||
*.css text eol=lf
|
|
||||||
*.scss text eol=lf
|
|
||||||
|
|
||||||
# Shell scripts
|
|
||||||
*.sh text eol=lf
|
|
||||||
|
|
||||||
# Denote all files that are truly binary and should not be modified.
|
|
||||||
*.png binary
|
|
||||||
*.jpg binary
|
|
||||||
*.jpeg binary
|
|
||||||
*.gif binary
|
|
||||||
*.ico binary
|
|
||||||
*.woff binary
|
|
||||||
*.woff2 binary
|
|
||||||
*.ttf binary
|
|
||||||
*.exe binary
|
|
||||||
*.dll binary
|
|
||||||
@@ -3,21 +3,18 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: release-${{ github.ref_name }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
|
# 固定 runner 版本,避免 latest 漂移导致 ABI 升级
|
||||||
- os: windows-2022
|
- os: windows-2022
|
||||||
- os: ubuntu-22.04
|
- os: ubuntu-22.04
|
||||||
- os: macos-14
|
- os: macos-14
|
||||||
@@ -29,13 +26,13 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: "20"
|
||||||
|
|
||||||
- name: Setup Rust
|
- name: Setup Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Add macOS targets
|
- name: Add macOS targets (ARM64 only for universal)
|
||||||
if: runner.os == 'macOS'
|
if: runner.os == 'macOS' && runner.arch == 'ARM64'
|
||||||
run: |
|
run: |
|
||||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||||
|
|
||||||
@@ -87,66 +84,12 @@ jobs:
|
|||||||
- name: Install frontend deps
|
- name: Install frontend deps
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Prepare Tauri signing key
|
- name: Build Tauri App (macOS, universal on ARM64)
|
||||||
shell: bash
|
if: runner.os == 'macOS' && runner.arch == 'ARM64'
|
||||||
run: |
|
|
||||||
# 调试:检查 Secret 是否存在
|
|
||||||
if [ -z "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}" ]; then
|
|
||||||
echo "❌ TAURI_SIGNING_PRIVATE_KEY Secret 为空或不存在" >&2
|
|
||||||
echo "请检查 GitHub 仓库 Settings > Secrets and variables > Actions" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
RAW="${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}"
|
|
||||||
# 目标:提供正确的私钥“文件路径”给 Tauri CLI,避免内容解码歧义
|
|
||||||
KEY_PATH="$RUNNER_TEMP/tauri_signing.key"
|
|
||||||
# 情况 1:原始两行文本(第一行以 "untrusted comment:" 开头)
|
|
||||||
if echo "$RAW" | head -n1 | grep -q '^untrusted comment:'; then
|
|
||||||
printf '%s\n' "$RAW" > "$KEY_PATH"
|
|
||||||
echo "✅ 使用原始两行密钥文件格式"
|
|
||||||
else
|
|
||||||
# 情况 2:整体被 base64 包裹(解包后应当是两行)
|
|
||||||
if DECODED=$(printf '%s' "$RAW" | (base64 --decode 2>/dev/null || base64 -D 2>/dev/null)) \
|
|
||||||
&& echo "$DECODED" | head -n1 | grep -q '^untrusted comment:'; then
|
|
||||||
printf '%s\n' "$DECODED" > "$KEY_PATH"
|
|
||||||
echo "✅ 成功解码 base64 包裹密钥,已还原为两行文件"
|
|
||||||
else
|
|
||||||
# 情况 3:已是第二行(纯 Base64 一行)→ 构造两行文件
|
|
||||||
if echo "$RAW" | grep -Eq '^[A-Za-z0-9+/=]+$'; then
|
|
||||||
ONE=$(printf '%s' "$RAW" | tr -d '\r\n')
|
|
||||||
printf '%s\n%s\n' "untrusted comment: tauri signing key" "$ONE" > "$KEY_PATH"
|
|
||||||
echo "✅ 使用一行 Base64 私钥,已构造两行文件"
|
|
||||||
else
|
|
||||||
echo "❌ TAURI_SIGNING_PRIVATE_KEY 格式无法识别:既不是两行原文,也不是其 base64,亦非一行 base64" >&2
|
|
||||||
echo "密钥前10个字符: $(echo "$RAW" | head -c 10)..." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
# 将“完整两行内容”作为环境变量注入(Tauri 支持传入完整私钥文本或文件路径)
|
|
||||||
# 使用多行写入语法,保持换行以便解析
|
|
||||||
# 将完整两行私钥内容进行 base64 编码,作为单行内容注入环境变量
|
|
||||||
if command -v base64 >/dev/null 2>&1; then
|
|
||||||
KEY_B64=$(base64 < "$KEY_PATH" | tr -d '\r\n')
|
|
||||||
elif command -v openssl >/dev/null 2>&1; then
|
|
||||||
KEY_B64=$(openssl base64 -A -in "$KEY_PATH")
|
|
||||||
else
|
|
||||||
KEY_B64=$(KEY_PATH="$KEY_PATH" node -e "process.stdout.write(require('fs').readFileSync(process.env.KEY_PATH).toString('base64'))")
|
|
||||||
fi
|
|
||||||
if [ -z "$KEY_B64" ]; then
|
|
||||||
echo "❌ 无法生成私钥 base64 内容" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "TAURI_SIGNING_PRIVATE_KEY=$KEY_B64" >> "$GITHUB_ENV"
|
|
||||||
if [ -n "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" ]; then
|
|
||||||
echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
echo "✅ Tauri signing key prepared"
|
|
||||||
|
|
||||||
- name: Build Tauri App (macOS)
|
|
||||||
if: runner.os == 'macOS'
|
|
||||||
run: pnpm tauri build --target universal-apple-darwin
|
run: pnpm tauri build --target universal-apple-darwin
|
||||||
|
|
||||||
|
# macOS 仅保留通用包(在 macOS-14 / ARM64 运行器上构建)
|
||||||
|
|
||||||
- name: Build Tauri App (Windows)
|
- name: Build Tauri App (Windows)
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
run: pnpm tauri build
|
run: pnpm tauri build
|
||||||
@@ -161,38 +104,29 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
mkdir -p release-assets
|
mkdir -p release-assets
|
||||||
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
|
echo "Looking for .app bundle..."
|
||||||
echo "Looking for updater artifact (.tar.gz) and .app for zip..."
|
APP_PATH=""
|
||||||
TAR_GZ=""; APP_PATH=""
|
|
||||||
for path in \
|
for path in \
|
||||||
|
"src-tauri/target/release/bundle/macos" \
|
||||||
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
|
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
|
||||||
"src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \
|
"src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \
|
||||||
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos" \
|
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos"; do
|
||||||
"src-tauri/target/release/bundle/macos"; do
|
|
||||||
if [ -d "$path" ]; then
|
if [ -d "$path" ]; then
|
||||||
[ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true)
|
APP_PATH=$(find "$path" -name "*.app" -type d | head -1)
|
||||||
[ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
|
[ -n "$APP_PATH" ] && break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -z "$TAR_GZ" ]; then
|
if [ -z "$APP_PATH" ]; then
|
||||||
echo "No macOS .tar.gz updater artifact found" >&2
|
echo "No .app found" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# 重命名 tar.gz 为统一格式
|
APP_DIR=$(dirname "$APP_PATH")
|
||||||
NEW_TAR_GZ="CC-Switch-${VERSION}-macOS.tar.gz"
|
APP_NAME=$(basename "$APP_PATH")
|
||||||
cp "$TAR_GZ" "release-assets/$NEW_TAR_GZ"
|
cd "$APP_DIR"
|
||||||
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" "release-assets/$NEW_TAR_GZ.sig" || echo ".sig for macOS not found yet"
|
# 使用 ditto 打包更兼容资源分叉
|
||||||
echo "macOS updater artifact copied: $NEW_TAR_GZ"
|
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
|
||||||
if [ -n "$APP_PATH" ]; then
|
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
|
||||||
APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
|
echo "macOS zip ready"
|
||||||
NEW_ZIP="CC-Switch-${VERSION}-macOS.zip"
|
|
||||||
cd "$APP_DIR"
|
|
||||||
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "$NEW_ZIP"
|
|
||||||
mv "$NEW_ZIP" "$GITHUB_WORKSPACE/release-assets/"
|
|
||||||
echo "macOS zip ready: $NEW_ZIP"
|
|
||||||
else
|
|
||||||
echo "No .app found to zip (optional)" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Prepare Windows Assets
|
- name: Prepare Windows Assets
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
@@ -200,28 +134,18 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
|
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
|
||||||
$VERSION = $env:GITHUB_REF_NAME # e.g., v3.5.0
|
# 安装器(优先 NSIS,其次 MSI)
|
||||||
# 仅打包 MSI 安装器 + .sig(用于 Updater)
|
$installer = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.exe,*.msi -ErrorAction SilentlyContinue |
|
||||||
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
Where-Object { $_.FullName -match '\\bundle\\(nsis|msi)\\' } |
|
||||||
if ($null -eq $msi) {
|
Select-Object -First 1
|
||||||
# 兜底:全局搜索 .msi
|
if ($null -ne $installer) {
|
||||||
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
$dest = if ($installer.Extension -ieq '.msi') { 'CC-Switch-Setup.msi' } else { 'CC-Switch-Setup.exe' }
|
||||||
}
|
Copy-Item $installer.FullName (Join-Path release-assets $dest)
|
||||||
if ($null -ne $msi) {
|
|
||||||
$dest = "CC-Switch-$VERSION-Windows.msi"
|
|
||||||
Copy-Item $msi.FullName (Join-Path release-assets $dest)
|
|
||||||
Write-Host "Installer copied: $dest"
|
Write-Host "Installer copied: $dest"
|
||||||
$sigPath = "$($msi.FullName).sig"
|
|
||||||
if (Test-Path $sigPath) {
|
|
||||||
Copy-Item $sigPath (Join-Path release-assets ("$dest.sig"))
|
|
||||||
Write-Host "Signature copied: $dest.sig"
|
|
||||||
} else {
|
|
||||||
Write-Warning "Signature not found for $($msi.Name)"
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Write-Warning 'No Windows MSI installer found'
|
Write-Warning 'No Windows installer found'
|
||||||
}
|
}
|
||||||
# 绿色版(portable):仅可执行文件打 zip(不参与 Updater)
|
# 绿色版(portable):仅可执行文件
|
||||||
$exeCandidates = @(
|
$exeCandidates = @(
|
||||||
'src-tauri/target/release/cc-switch.exe',
|
'src-tauri/target/release/cc-switch.exe',
|
||||||
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
|
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
|
||||||
@@ -231,16 +155,9 @@ jobs:
|
|||||||
$portableDir = 'release-assets/CC-Switch-Portable'
|
$portableDir = 'release-assets/CC-Switch-Portable'
|
||||||
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
|
||||||
Copy-Item $exePath $portableDir
|
Copy-Item $exePath $portableDir
|
||||||
$portableIniPath = Join-Path $portableDir 'portable.ini'
|
Compress-Archive -Path "$portableDir/*" -DestinationPath 'release-assets/CC-Switch-Windows-Portable.zip' -Force
|
||||||
$portableContent = @(
|
|
||||||
'# CC Switch portable build marker',
|
|
||||||
'portable=true'
|
|
||||||
)
|
|
||||||
$portableContent | Set-Content -Path $portableIniPath -Encoding UTF8
|
|
||||||
$portableZip = "release-assets/CC-Switch-$VERSION-Windows-Portable.zip"
|
|
||||||
Compress-Archive -Path "$portableDir/*" -DestinationPath $portableZip -Force
|
|
||||||
Remove-Item -Recurse -Force $portableDir
|
Remove-Item -Recurse -Force $portableDir
|
||||||
Write-Host "Windows portable zip created: CC-Switch-$VERSION-Windows-Portable.zip"
|
Write-Host 'Windows portable zip created'
|
||||||
} else {
|
} else {
|
||||||
Write-Warning 'Portable exe not found'
|
Write-Warning 'Portable exe not found'
|
||||||
}
|
}
|
||||||
@@ -251,25 +168,21 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
mkdir -p release-assets
|
mkdir -p release-assets
|
||||||
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
|
# 优先 DEB,同时若存在 AppImage 一并上传
|
||||||
# Updater artifact: AppImage(含对应 .sig)
|
|
||||||
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
|
|
||||||
if [ -n "$APPIMAGE" ]; then
|
|
||||||
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux.AppImage"
|
|
||||||
cp "$APPIMAGE" "release-assets/$NEW_APPIMAGE"
|
|
||||||
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" "release-assets/$NEW_APPIMAGE.sig" || echo ".sig for AppImage not found"
|
|
||||||
echo "AppImage copied: $NEW_APPIMAGE"
|
|
||||||
else
|
|
||||||
echo "No AppImage found under target/release/bundle" >&2
|
|
||||||
fi
|
|
||||||
# 额外上传 .deb(用于手动安装,不参与 Updater)
|
|
||||||
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
|
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
|
||||||
if [ -n "$DEB" ]; then
|
if [ -n "$DEB" ]; then
|
||||||
NEW_DEB="CC-Switch-${VERSION}-Linux.deb"
|
cp "$DEB" release-assets/
|
||||||
cp "$DEB" "release-assets/$NEW_DEB"
|
echo "Deb package copied"
|
||||||
echo "Deb package copied: $NEW_DEB"
|
|
||||||
else
|
else
|
||||||
echo "No .deb found (optional)"
|
echo "No .deb found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
|
||||||
|
if [ -n "$APPIMAGE" ]; then
|
||||||
|
cp "$APPIMAGE" release-assets/
|
||||||
|
echo "AppImage copied"
|
||||||
|
else
|
||||||
|
echo "No AppImage found (optional)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: List prepared assets
|
- name: List prepared assets
|
||||||
@@ -277,19 +190,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
ls -la release-assets || true
|
ls -la release-assets || true
|
||||||
|
|
||||||
- name: Collect Signatures
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
echo "Collected signatures (if any alongside artifacts):"
|
|
||||||
ls -la release-assets/*.sig || echo "No signatures found"
|
|
||||||
|
|
||||||
- name: Upload Release Assets
|
- name: Upload Release Assets
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
name: CC Switch ${{ github.ref_name }}
|
name: CC Switch ${{ github.ref_name }}
|
||||||
prerelease: true
|
|
||||||
body: |
|
body: |
|
||||||
## CC Switch ${{ github.ref_name }}
|
## CC Switch ${{ github.ref_name }}
|
||||||
|
|
||||||
@@ -297,12 +202,12 @@ jobs:
|
|||||||
|
|
||||||
### 下载
|
### 下载
|
||||||
|
|
||||||
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`(Homebrew)
|
- macOS: `CC-Switch-macOS.zip`(解压即用)
|
||||||
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
|
- Windows: `CC-Switch-Setup.exe` 或 `CC-Switch-Setup.msi`(安装版);`CC-Switch-Windows-Portable.zip`(绿色版)
|
||||||
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`(AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`(Debian/Ubuntu)
|
- Linux: `*.deb`(Debian/Ubuntu 安装包)
|
||||||
|
|
||||||
---
|
---
|
||||||
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
提示:macOS 如遇“已损坏”提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
||||||
files: release-assets/*
|
files: release-assets/*
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -313,92 +218,3 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "Listing bundles in src-tauri/target..."
|
echo "Listing bundles in src-tauri/target..."
|
||||||
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true
|
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true
|
||||||
|
|
||||||
assemble-latest-json:
|
|
||||||
name: Assemble latest.json
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
needs: release
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Prepare GH
|
|
||||||
run: |
|
|
||||||
gh --version || (type -p curl >/dev/null && sudo apt-get update && sudo apt-get install -y gh || true)
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Download all release assets
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
TAG="${GITHUB_REF_NAME}"
|
|
||||||
mkdir -p dl
|
|
||||||
gh release download "$TAG" --dir dl --repo "$GITHUB_REPOSITORY"
|
|
||||||
ls -la dl || true
|
|
||||||
- name: Generate latest.json
|
|
||||||
env:
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
TAG: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
VERSION="${TAG#v}"
|
|
||||||
PUB_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
||||||
base_url="https://github.com/$REPO/releases/download/$TAG"
|
|
||||||
# 初始化空平台映射
|
|
||||||
mac_url=""; mac_sig=""
|
|
||||||
win_url=""; win_sig=""
|
|
||||||
linux_url=""; linux_sig=""
|
|
||||||
shopt -s nullglob
|
|
||||||
for sig in dl/*.sig; do
|
|
||||||
base=${sig%.sig}
|
|
||||||
fname=$(basename "$base")
|
|
||||||
url="$base_url/$fname"
|
|
||||||
sig_content=$(cat "$sig")
|
|
||||||
case "$fname" in
|
|
||||||
*.tar.gz)
|
|
||||||
# 视为 macOS updater artifact
|
|
||||||
mac_url="$url"; mac_sig="$sig_content";;
|
|
||||||
*.AppImage|*.appimage)
|
|
||||||
linux_url="$url"; linux_sig="$sig_content";;
|
|
||||||
*.msi|*.exe)
|
|
||||||
win_url="$url"; win_sig="$sig_content";;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
# 构造 JSON(仅包含存在的目标)
|
|
||||||
tmp_json=$(mktemp)
|
|
||||||
{
|
|
||||||
echo '{'
|
|
||||||
echo " \"version\": \"$VERSION\",";
|
|
||||||
echo " \"notes\": \"Release $TAG\",";
|
|
||||||
echo " \"pub_date\": \"$PUB_DATE\",";
|
|
||||||
echo ' "platforms": {'
|
|
||||||
first=1
|
|
||||||
if [ -n "$mac_url" ] && [ -n "$mac_sig" ]; then
|
|
||||||
# 为兼容 arm64 / x64,重复写入两个键,指向同一 universal 包
|
|
||||||
for key in darwin-aarch64 darwin-x86_64; do
|
|
||||||
[ $first -eq 0 ] && echo ','
|
|
||||||
echo " \"$key\": {\"signature\": \"$mac_sig\", \"url\": \"$mac_url\"}"
|
|
||||||
first=0
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
if [ -n "$win_url" ] && [ -n "$win_sig" ]; then
|
|
||||||
[ $first -eq 0 ] && echo ','
|
|
||||||
echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}"
|
|
||||||
first=0
|
|
||||||
fi
|
|
||||||
if [ -n "$linux_url" ] && [ -n "$linux_sig" ]; then
|
|
||||||
[ $first -eq 0 ] && echo ','
|
|
||||||
echo " \"linux-x86_64\": {\"signature\": \"$linux_sig\", \"url\": \"$linux_url\"}"
|
|
||||||
first=0
|
|
||||||
fi
|
|
||||||
echo ' }'
|
|
||||||
echo '}'
|
|
||||||
} > "$tmp_json"
|
|
||||||
echo "Generated latest.json:" && cat "$tmp_json"
|
|
||||||
mv "$tmp_json" latest.json
|
|
||||||
- name: Upload latest.json to release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
gh release upload "$GITHUB_REF_NAME" latest.json --clobber --repo "$GITHUB_REPOSITORY"
|
|
||||||
|
|||||||
@@ -9,12 +9,3 @@ release/
|
|||||||
.npmrc
|
.npmrc
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
GEMINI.md
|
|
||||||
/.claude
|
|
||||||
/.codex
|
|
||||||
/.gemini
|
|
||||||
/.cc-switch
|
|
||||||
/.idea
|
|
||||||
/.vscode
|
|
||||||
vitest-report.json
|
|
||||||
nul
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
v22.4.1
|
|
||||||
@@ -5,638 +5,18 @@ All notable changes to CC Switch will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [3.9.0-1] - 2025-12-18
|
|
||||||
|
|
||||||
### Beta Release
|
|
||||||
|
|
||||||
This beta release introduces the **Local API Proxy** feature, along with Skills multi-app support, UI improvements, and numerous bug fixes.
|
|
||||||
|
|
||||||
### Major Features
|
|
||||||
|
|
||||||
#### Local Proxy Server
|
|
||||||
- **Local HTTP Proxy** - High-performance proxy server built on Axum framework
|
|
||||||
- **Multi-app Support** - Unified proxy for Claude Code, Codex, and Gemini CLI API requests
|
|
||||||
- **Per-app Takeover** - Independent control over which apps route through the proxy
|
|
||||||
- **Live Config Takeover** - Automatically backs up and redirects CLI configurations to local proxy
|
|
||||||
|
|
||||||
#### Auto Failover
|
|
||||||
- **Circuit Breaker** - Automatically detects provider failures and triggers protection
|
|
||||||
- **Smart Failover** - Automatically switches to backup provider when current one is unavailable
|
|
||||||
- **Health Tracking** - Real-time monitoring of provider availability
|
|
||||||
- **Independent Failover Queues** - Each app maintains its own failover queue
|
|
||||||
|
|
||||||
#### Monitoring
|
|
||||||
- **Request Logging** - Detailed logging of all proxy requests
|
|
||||||
- **Usage Statistics** - Token consumption, latency, success rate metrics
|
|
||||||
- **Real-time Status** - Frontend displays proxy status and statistics
|
|
||||||
|
|
||||||
#### Skills Multi-App Support
|
|
||||||
- **Multi-app Support** - Skills now support both Claude and Codex (#365)
|
|
||||||
- **Multi-app Migration** - Existing Skills auto-migrate to multi-app structure (#378)
|
|
||||||
- **Installation Path Fix** - Use directory basename for skill installation path (#358)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Provider Icon Colors** - Customize provider icon colors (#385)
|
|
||||||
- **Deeplink Usage Config** - Import usage query config via deeplink (#400)
|
|
||||||
- **Error Request Logging** - Detailed logging for proxy requests (#401)
|
|
||||||
- **Closable Toast** - Added close button to switch notification toast (#350)
|
|
||||||
- **Icon Color Component** - ProviderIcon component supports color prop (#384)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
#### Proxy Related
|
|
||||||
- Takeover Codex base_url via model_provider
|
|
||||||
- Harden crash recovery with fallback detection
|
|
||||||
- Sync UI when active provider differs from current setting
|
|
||||||
- Resolve circuit breaker race condition and error classification
|
|
||||||
- Stabilize live takeover and provider editing
|
|
||||||
- Reset health badges when proxy stops
|
|
||||||
- Retry failover for all HTTP errors including 4xx
|
|
||||||
- Fix HalfOpen counter underflow and config field inconsistencies
|
|
||||||
- Resolve circuit breaker state persistence and HalfOpen deadlock
|
|
||||||
- Auto-recover live config after abnormal exit
|
|
||||||
- Update live backup when hot-switching provider in proxy mode
|
|
||||||
- Wait for server shutdown before exiting app
|
|
||||||
- Disable auto-start on app launch by resetting enabled flag on stop
|
|
||||||
- Sync live config tokens to database before takeover
|
|
||||||
- Resolve 404 error and auto-setup proxy targets
|
|
||||||
|
|
||||||
#### MCP Related
|
|
||||||
- Skip sync when target CLI app is not installed
|
|
||||||
- Improve upsert and import robustness
|
|
||||||
- Use browser-compatible platform detection for MCP presets
|
|
||||||
|
|
||||||
#### UI Related
|
|
||||||
- Restore fade transition for Skills button
|
|
||||||
- Add close button to all success toasts
|
|
||||||
- Prevent card jitter when health badge appears
|
|
||||||
- Update SettingsPage tab styles (#342)
|
|
||||||
|
|
||||||
#### Other
|
|
||||||
- Fix Azure website link (#407)
|
|
||||||
- Add fallback to provider config for usage credentials (#360)
|
|
||||||
- Fix Windows black screen on startup (use system titlebar)
|
|
||||||
- Add fallback for crypto.randomUUID() on older WebViews
|
|
||||||
- Use correct npm package for Codex CLI version check
|
|
||||||
- Security fixes for JavaScript executor and usage script (#151)
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active
|
|
||||||
- **Card Animation** - Improved provider card hover animation
|
|
||||||
- **Remove Restart Prompt** - No longer prompts restart when switching providers
|
|
||||||
|
|
||||||
### Technical
|
|
||||||
- Implement per-app takeover mode
|
|
||||||
- Proxy module contains 20+ Rust files with complete layered architecture
|
|
||||||
- Add 5 new database tables for proxy functionality
|
|
||||||
- Modularize handlers.rs to reduce code duplication
|
|
||||||
- Remove is_proxy_target in favor of failover_queue
|
|
||||||
|
|
||||||
### Stats
|
|
||||||
- 55 commits since v3.8.2
|
|
||||||
- 164 files changed
|
|
||||||
- +22,164 / -570 lines
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [3.8.0] - 2025-11-28
|
|
||||||
|
|
||||||
### Major Updates
|
|
||||||
|
|
||||||
- **Persistence architecture upgrade** - Moved from single JSON storage to SQLite + JSON dual-layer; added schema versioning, transactions, and SQL import/export; first launch auto-migrates `config.json` to SQLite while keeping originals safe.
|
|
||||||
- **Brand new UI** - Full layout redesign, unified component/ConfirmDialog styles, smoother animations, overscroll disabled; Tailwind CSS downgraded to v3.4 for compatibility.
|
|
||||||
- **Japanese language support** - UI now localized in Chinese/English/Japanese.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Skills recursive scanning** - Discovers nested `SKILL.md` files across multi-level directories; same-name skills allowed by full-path dedup.
|
|
||||||
- **Provider icons** - Presets ship with default icons; custom icon colors; icons retained when duplicating providers.
|
|
||||||
- **Auto launch on startup** - One-click enable/disable using Registry/LaunchAgent/XDG autostart.
|
|
||||||
- **Provider preset** - Added MiniMax partner preset.
|
|
||||||
- **Form validation** - Required fields get real-time validation and unified toast messaging.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Custom endpoints loss** - Switched provider updates to `UPDATE` to avoid cascade deletes from `INSERT OR REPLACE`.
|
|
||||||
- **Gemini config writing** - Correctly writes custom env vars to `.env` and keeps auth configs isolated.
|
|
||||||
- **Provider validation** - Handles missing current provider IDs and preserves icon fields on duplicate.
|
|
||||||
- **Linux rendering** - Fixed WebKitGTK DMA-BUF rendering and preserved user `.desktop` customizations.
|
|
||||||
- **Misc** - Removed redundant usage queries; corrected DMXAPI auth token field; restored missing deeplink translations; fixed usage script template init.
|
|
||||||
|
|
||||||
### Technical
|
|
||||||
|
|
||||||
- **Database modules** - Added `schema`, `backup`, `migration`, and DAO layers for providers/MCP/prompts/skills/settings.
|
|
||||||
- **Service modularization** - Split provider service into live/auth/endpoints/usage modules; deeplink parsing/import logic modularized.
|
|
||||||
- **Code cleanup** - Removed legacy JSON-era import/export, unused MCP types; unified error handling; tests migrated to SQLite backend and MSW handlers updated.
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
|
|
||||||
- First launch auto-migrates data from `config.json` to SQLite and device settings to `settings.json`; originals kept; error dialog on failure; dry-run supported.
|
|
||||||
|
|
||||||
### Stats
|
|
||||||
|
|
||||||
- 51 commits since v3.7.1; 207 files changed; +17,297 / -6,870 lines. See [release-note-v3.8.0](docs/release-note-v3.8.0-en.md) for details.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [3.7.1] - 2025-11-22
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Skills third-party repository installation** (#268) - Fixed installation failure for skills repositories with custom subdirectories (e.g., `ComposioHQ/awesome-claude-skills`)
|
|
||||||
- **Gemini configuration persistence** - Resolved issue where settings.json edits were lost when switching providers
|
|
||||||
- **Dialog overlay click protection** - Prevented dialogs from closing when clicking outside, avoiding accidental form data loss (affects 11 dialog components)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Gemini configuration directory support** (#255) - Added custom configuration directory option for Gemini in settings
|
|
||||||
- **ArchLinux installation support** (#259) - Added AUR installation via `paru -S cc-switch-bin`
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
|
|
||||||
- **Skills error messages i18n** - Added 28+ detailed error messages (English & Chinese) with specific resolution suggestions
|
|
||||||
- **Download timeout** - Extended from 15s to 60s to reduce network-related false positives
|
|
||||||
- **Code formatting** - Applied unified Rust (`cargo fmt`) and TypeScript (`prettier`) formatting standards
|
|
||||||
|
|
||||||
### Reverted
|
|
||||||
|
|
||||||
- **Auto-launch on system startup** - Temporarily reverted feature pending further testing and optimization
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [3.7.0] - 2025-11-19
|
|
||||||
|
|
||||||
### Major Features
|
|
||||||
|
|
||||||
#### Gemini CLI Integration
|
|
||||||
|
|
||||||
- **Complete Gemini CLI support** - Third major application added alongside Claude Code and Codex
|
|
||||||
- **Dual-file configuration** - Support for both `.env` and `settings.json` file formats
|
|
||||||
- **Environment variable detection** - Auto-detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
|
|
||||||
- **MCP management** - Full MCP configuration capabilities for Gemini
|
|
||||||
- **Provider presets**
|
|
||||||
- Google Official (OAuth authentication)
|
|
||||||
- PackyCode (partner integration)
|
|
||||||
- Custom endpoint support
|
|
||||||
- **Deep link support** - Import Gemini providers via `ccswitch://` protocol
|
|
||||||
- **System tray integration** - Quick-switch Gemini providers from tray menu
|
|
||||||
- **Backend modules** - New `gemini_config.rs` (20KB) and `gemini_mcp.rs`
|
|
||||||
|
|
||||||
#### MCP v3.7.0 Unified Architecture
|
|
||||||
|
|
||||||
- **Unified management panel** - Single interface for Claude/Codex/Gemini MCP servers
|
|
||||||
- **SSE transport type** - New Server-Sent Events support alongside stdio/http
|
|
||||||
- **Smart JSON parser** - Fault-tolerant parsing of various MCP config formats
|
|
||||||
- **Extended field support** - Preserve custom fields in Codex TOML conversion
|
|
||||||
- **Codex format correction** - Proper `[mcp_servers]` format (auto-cleanup of incorrect `[mcp.servers]`)
|
|
||||||
- **Import/export system** - Unified import from Claude/Codex/Gemini live configs
|
|
||||||
- **UX improvements**
|
|
||||||
- Default app selection in forms
|
|
||||||
- JSON formatter for config validation
|
|
||||||
- Improved layout and visual hierarchy
|
|
||||||
- Better validation error messages
|
|
||||||
|
|
||||||
#### Claude Skills Management System
|
|
||||||
|
|
||||||
- **GitHub repository integration** - Auto-scan and discover skills from GitHub repos
|
|
||||||
- **Pre-configured repositories**
|
|
||||||
- `ComposioHQ/awesome-claude-skills` (curated collection)
|
|
||||||
- `anthropics/skills` (official Anthropic skills)
|
|
||||||
- `cexll/myclaude` (community, with subdirectory scanning)
|
|
||||||
- **Lifecycle management**
|
|
||||||
- One-click install to `~/.claude/skills/`
|
|
||||||
- Safe uninstall with state tracking
|
|
||||||
- Update checking (infrastructure ready)
|
|
||||||
- **Custom repository support** - Add any GitHub repo as a skill source
|
|
||||||
- **Subdirectory scanning** - Optional `skillsPath` for repos with nested skill directories
|
|
||||||
- **Backend architecture** - `SkillService` (526 lines) with GitHub API integration
|
|
||||||
- **Frontend interface**
|
|
||||||
- SkillsPage: Browse and manage skills
|
|
||||||
- SkillCard: Visual skill presentation
|
|
||||||
- RepoManager: Repository management dialog
|
|
||||||
- **State persistence** - Installation state stored in `skills.json`
|
|
||||||
- **Full i18n support** - Complete Chinese/English translations (47+ keys)
|
|
||||||
|
|
||||||
#### Prompts (System Prompts) Management
|
|
||||||
|
|
||||||
- **Multi-preset management** - Create, edit, and switch between multiple system prompts
|
|
||||||
- **Cross-app support**
|
|
||||||
- Claude: `~/.claude/CLAUDE.md`
|
|
||||||
- Codex: `~/.codex/AGENTS.md`
|
|
||||||
- Gemini: `~/.gemini/GEMINI.md`
|
|
||||||
- **Markdown editor** - Full-featured CodeMirror 6 editor with syntax highlighting
|
|
||||||
- **Smart synchronization**
|
|
||||||
- Auto-write to live files on enable
|
|
||||||
- Content backfill protection (save current before switching)
|
|
||||||
- First-launch auto-import from live files
|
|
||||||
- **Single-active enforcement** - Only one prompt can be active at a time
|
|
||||||
- **Delete protection** - Cannot delete active prompts
|
|
||||||
- **Backend service** - `PromptService` (213 lines) with CRUD operations
|
|
||||||
- **Frontend components**
|
|
||||||
- PromptPanel: Main management interface (177 lines)
|
|
||||||
- PromptFormModal: Edit dialog with validation (160 lines)
|
|
||||||
- MarkdownEditor: CodeMirror integration (159 lines)
|
|
||||||
- usePromptActions: Business logic hook (152 lines)
|
|
||||||
- **Full i18n support** - Complete Chinese/English translations (41+ keys)
|
|
||||||
|
|
||||||
#### Deep Link Protocol (ccswitch://)
|
|
||||||
|
|
||||||
- **Protocol registration** - `ccswitch://` URL scheme for one-click imports
|
|
||||||
- **Provider import** - Import provider configurations from URLs or shared links
|
|
||||||
- **Lifecycle integration** - Deep link handling integrated into app startup
|
|
||||||
- **Cross-platform support** - Works on Windows, macOS, and Linux
|
|
||||||
|
|
||||||
#### Environment Variable Conflict Detection
|
|
||||||
|
|
||||||
- **Claude & Codex detection** - Identify conflicting environment variables
|
|
||||||
- **Gemini auto-detection** - Automatic environment variable discovery
|
|
||||||
- **Conflict management** - UI for resolving configuration conflicts
|
|
||||||
- **Prevention system** - Warn before overwriting existing configurations
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
|
|
||||||
#### Provider Management
|
|
||||||
|
|
||||||
- **DouBaoSeed preset** - Added ByteDance's DouBao provider
|
|
||||||
- **Kimi For Coding** - Moonshot AI coding assistant
|
|
||||||
- **BaiLing preset** - BaiLing AI integration
|
|
||||||
- **Removed AnyRouter preset** - Discontinued provider
|
|
||||||
- **Model configuration** - Support for custom model names in Codex and Gemini
|
|
||||||
- **Provider notes field** - Add custom notes to providers for better organization
|
|
||||||
|
|
||||||
#### Configuration Management
|
|
||||||
|
|
||||||
- **Common config migration** - Moved Claude common config snippets from localStorage to `config.json`
|
|
||||||
- **Unified persistence** - Common config snippets now shared across all apps
|
|
||||||
- **Auto-import on first launch** - Automatically import configs from live files on first run
|
|
||||||
- **Backfill priority fix** - Correct priority handling when enabling prompts
|
|
||||||
|
|
||||||
#### UI/UX Improvements
|
|
||||||
|
|
||||||
- **macOS native design** - Migrated color scheme to macOS native design system
|
|
||||||
- **Window centering** - Default window position centered on screen
|
|
||||||
- **Password input fixes** - Disabled Edge/IE reveal and clear buttons
|
|
||||||
- **URL overflow prevention** - Fixed overflow in provider cards
|
|
||||||
- **Error notification enhancement** - Copy-to-clipboard for error messages
|
|
||||||
- **Tray menu sync** - Real-time sync after drag-and-drop sorting
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
|
|
||||||
#### Architecture
|
|
||||||
|
|
||||||
- **MCP v3.7.0 cleanup** - Removed legacy code and warnings
|
|
||||||
- **Unified structure** - Default initialization with v3.7.0 unified structure
|
|
||||||
- **Backward compatibility** - Compilation fixes for older configs
|
|
||||||
- **Code formatting** - Applied consistent formatting across backend and frontend
|
|
||||||
|
|
||||||
#### Platform Compatibility
|
|
||||||
|
|
||||||
- **Windows fix** - Resolved winreg API compatibility issue (v0.52)
|
|
||||||
- **Safe pattern matching** - Replaced `unwrap()` with safe patterns in tray menu
|
|
||||||
|
|
||||||
#### Configuration
|
|
||||||
|
|
||||||
- **MCP sync on switch** - Sync MCP configs for all apps when switching providers
|
|
||||||
- **Gemini form sync** - Fixed form fields syncing with environment editor
|
|
||||||
- **Gemini config reading** - Read from both `.env` and `settings.json`
|
|
||||||
- **Validation improvements** - Enhanced input validation and boundary checks
|
|
||||||
|
|
||||||
#### Internationalization
|
|
||||||
|
|
||||||
- **JSON syntax fixes** - Resolved syntax errors in locale files
|
|
||||||
- **App name i18n** - Added internationalization support for app names
|
|
||||||
- **Deduplicated labels** - Reused providerForm keys to reduce duplication
|
|
||||||
- **Gemini MCP title** - Added missing Gemini MCP panel title
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
#### Critical Fixes
|
|
||||||
|
|
||||||
- **Usage script validation** - Added input validation and boundary checks
|
|
||||||
- **Gemini validation** - Relaxed validation when adding providers
|
|
||||||
- **TOML quote normalization** - Handle CJK quotes to prevent parsing errors
|
|
||||||
- **MCP field preservation** - Preserve custom fields in Codex TOML editor
|
|
||||||
- **Password input** - Fixed white screen crash (FormLabel → Label)
|
|
||||||
|
|
||||||
#### Stability
|
|
||||||
|
|
||||||
- **Tray menu safety** - Replaced unwrap with safe pattern matching
|
|
||||||
- **Error isolation** - Tray menu update failures don't block main operations
|
|
||||||
- **Import classification** - Set category to custom for imported default configs
|
|
||||||
|
|
||||||
#### UI Fixes
|
|
||||||
|
|
||||||
- **Model placeholders** - Removed misleading model input placeholders
|
|
||||||
- **Base URL population** - Auto-fill base URL for non-official providers
|
|
||||||
- **Drag sort sync** - Fixed tray menu order after drag-and-drop
|
|
||||||
|
|
||||||
### Technical Improvements
|
|
||||||
|
|
||||||
#### Code Quality
|
|
||||||
|
|
||||||
- **Type safety** - Complete TypeScript type coverage across codebase
|
|
||||||
- **Test improvements** - Simplified boolean assertions in tests
|
|
||||||
- **Clippy warnings** - Fixed `uninlined_format_args` warnings
|
|
||||||
- **Code refactoring** - Extracted templates, optimized logic flows
|
|
||||||
|
|
||||||
#### Dependencies
|
|
||||||
|
|
||||||
- **Tauri** - Updated to 2.8.x series
|
|
||||||
- **Rust dependencies** - Added `anyhow`, `zip`, `serde_yaml`, `tempfile` for Skills
|
|
||||||
- **Frontend dependencies** - Added CodeMirror 6 packages for Markdown editor
|
|
||||||
- **winreg** - Updated to v0.52 (Windows compatibility)
|
|
||||||
|
|
||||||
#### Performance
|
|
||||||
|
|
||||||
- **Startup optimization** - Removed legacy migration scanning
|
|
||||||
- **Lock management** - Improved RwLock usage to prevent deadlocks
|
|
||||||
- **Background query** - Enabled background mode for usage polling
|
|
||||||
|
|
||||||
### Statistics
|
|
||||||
|
|
||||||
- **Total commits**: 85 commits from v3.6.0 to v3.7.0
|
|
||||||
- **Code changes**: 152 files changed, 18,104 insertions(+), 3,732 deletions(-)
|
|
||||||
- **New modules**:
|
|
||||||
- Skills: 2,034 lines (21 files)
|
|
||||||
- Prompts: 1,302 lines (20 files)
|
|
||||||
- Gemini: ~1,000 lines (multiple files)
|
|
||||||
- MCP refactor: ~3,000 lines (refactored)
|
|
||||||
|
|
||||||
### Strategic Positioning
|
|
||||||
|
|
||||||
v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI CLI Management Platform"**:
|
|
||||||
|
|
||||||
1. **Capability Extension** - Skills provide external ability integration
|
|
||||||
2. **Behavior Customization** - Prompts enable AI personality presets
|
|
||||||
3. **Configuration Unification** - MCP v3.7.0 eliminates app silos
|
|
||||||
4. **Ecosystem Openness** - Deep links enable community sharing
|
|
||||||
5. **Multi-AI Support** - Claude/Codex/Gemini trinity
|
|
||||||
6. **Intelligent Detection** - Auto-discovery of environment conflicts
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
|
|
||||||
- Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x for one-time migration
|
|
||||||
- Skills and Prompts management are new features requiring no migration
|
|
||||||
- Gemini CLI support requires Gemini CLI to be installed separately
|
|
||||||
- MCP v3.7.0 unified structure is backward compatible with previous configs
|
|
||||||
|
|
||||||
## [3.6.0] - 2025-11-07
|
|
||||||
|
|
||||||
### ✨ New Features
|
|
||||||
|
|
||||||
- **Provider Duplicate** - Quick duplicate existing provider configurations for easy variant creation
|
|
||||||
- **Edit Mode Toggle** - Show/hide drag handles to optimize editing experience
|
|
||||||
- **Custom Endpoint Management** - Support multi-endpoint configuration for aggregator providers
|
|
||||||
- **Usage Query Enhancements**
|
|
||||||
- Auto-refresh interval: Support periodic automatic usage query
|
|
||||||
- Test Script API: Validate JavaScript scripts before execution
|
|
||||||
- Template system expansion: Custom blank template, support for access token and user ID parameters
|
|
||||||
- **Configuration Editor Improvements**
|
|
||||||
- Add JSON format button
|
|
||||||
- Real-time TOML syntax validation for Codex configuration
|
|
||||||
- **Auto-sync on Directory Change** - When switching Claude/Codex config directories (e.g., WSL environment), automatically sync current provider to new directory without manual operation
|
|
||||||
- **Load Live Config When Editing Active Provider** - When editing the currently active provider, prioritize displaying the actual effective configuration to protect user manual modifications
|
|
||||||
- **New Provider Presets** - DMXAPI, Azure Codex, AnyRouter, AiHubMix, MiniMax
|
|
||||||
- **Partner Promotion Mechanism** - Support ecosystem partner promotion (e.g., Zhipu GLM Z.ai)
|
|
||||||
|
|
||||||
### 🔧 Improvements
|
|
||||||
|
|
||||||
- **Configuration Directory Switching**
|
|
||||||
- Introduced unified post-change sync utility (`postChangeSync.ts`)
|
|
||||||
- Auto-sync current providers to new directory when changing Claude/Codex config directories
|
|
||||||
- Perfect support for WSL environment switching
|
|
||||||
- Auto-sync after config import to ensure immediate effectiveness
|
|
||||||
- Use Result pattern for graceful error handling without blocking main flow
|
|
||||||
- Distinguish "fully successful" and "partially successful" states for precise user feedback
|
|
||||||
- **UI/UX Enhancements**
|
|
||||||
- Provider cards: Unique icons and color identification
|
|
||||||
- Unified border design system across all components
|
|
||||||
- Drag interaction optimization: Push effect animation, improved handle icons
|
|
||||||
- Enhanced current provider visual feedback
|
|
||||||
- Dialog size standardization and layout consistency
|
|
||||||
- Form experience: Optimized model placeholders, simplified provider hints, category-specific hints
|
|
||||||
- **Complete Internationalization Coverage**
|
|
||||||
- Error messages internationalization
|
|
||||||
- Tray menu internationalization
|
|
||||||
- All UI components internationalization
|
|
||||||
- **Usage Display Moved Inline** - Usage display moved next to enable button
|
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
|
||||||
|
|
||||||
- **Configuration Sync**
|
|
||||||
- Fixed `apiKeyUrl` priority issue
|
|
||||||
- Fixed MCP sync-to-other-side functionality failure
|
|
||||||
- Fixed sync issues after config import
|
|
||||||
- Prevent silent fallback and data loss on config error
|
|
||||||
- **Usage Query**
|
|
||||||
- Fixed auto-query interval timing issue
|
|
||||||
- Ensure refresh button shows loading animation on click
|
|
||||||
- **UI Issues**
|
|
||||||
- Fixed name collision error (`get_init_error` command)
|
|
||||||
- Fixed language setting rollback after successful save
|
|
||||||
- Fixed language switch state reset (dependency cycle)
|
|
||||||
- Fixed edit mode button alignment
|
|
||||||
- **Configuration Management**
|
|
||||||
- Fixed Codex API Key auto-sync
|
|
||||||
- Fixed endpoint speed test functionality
|
|
||||||
- Fixed provider duplicate insertion position (next to original provider)
|
|
||||||
- Fixed custom endpoint preservation in edit mode
|
|
||||||
- **Startup Issues**
|
|
||||||
- Force exit on config error (no silent fallback)
|
|
||||||
- Eliminate code duplication causing initialization errors
|
|
||||||
|
|
||||||
### 🏗️ Technical Improvements (For Developers)
|
|
||||||
|
|
||||||
**Backend Refactoring (Rust)** - Completed 5-phase refactoring:
|
|
||||||
|
|
||||||
- **Phase 1**: Unified error handling (`AppError` + i18n error messages)
|
|
||||||
- **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
|
|
||||||
- **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)
|
|
||||||
- **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)
|
|
||||||
- **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
|
|
||||||
|
|
||||||
**Frontend Refactoring (React + TypeScript)** - Completed 4-stage refactoring:
|
|
||||||
|
|
||||||
- **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)
|
|
||||||
- **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)
|
|
||||||
- **Stage 3**: Component splitting and business logic extraction
|
|
||||||
- **Stage 4**: Code cleanup and formatting unification
|
|
||||||
|
|
||||||
**Testing System**:
|
|
||||||
|
|
||||||
- Hooks unit tests 100% coverage
|
|
||||||
- Integration tests covering key processes (App, SettingsDialog, MCP Panel)
|
|
||||||
- MSW mocking backend API to ensure test independence
|
|
||||||
|
|
||||||
**Code Quality**:
|
|
||||||
|
|
||||||
- Unified parameter format: All Tauri commands migrated to camelCase (Tauri 2 specification)
|
|
||||||
- `AppType` renamed to `AppId`: Semantically clearer
|
|
||||||
- Unified parsing with `FromStr` trait: Centralized `app` parameter parsing
|
|
||||||
- Eliminate code duplication: DRY violations cleanup
|
|
||||||
- Remove unused code: `missing_param` helper function, deprecated `tauri-api.ts`, redundant `KimiModelSelector` component
|
|
||||||
|
|
||||||
**Internal Optimizations**:
|
|
||||||
|
|
||||||
- **Removed Legacy Migration Logic**: v3.6 removed v1 config auto-migration and copy file scanning logic
|
|
||||||
- ✅ **Impact**: Improved startup performance, cleaner code
|
|
||||||
- ✅ **Compatibility**: v2 format configs fully compatible, no action required
|
|
||||||
- ⚠️ **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6
|
|
||||||
- **Command Parameter Standardization**: Backend unified to use `app` parameter (values: `claude` or `codex`)
|
|
||||||
- ✅ **Impact**: More standardized code, friendlier error prompts
|
|
||||||
- ✅ **Compatibility**: Frontend fully adapted, users don't need to care about this change
|
|
||||||
|
|
||||||
### 📦 Dependencies
|
|
||||||
|
|
||||||
- Updated to Tauri 2.8.x
|
|
||||||
- Updated to TailwindCSS 4.x
|
|
||||||
- Updated to TanStack Query v5.90.x
|
|
||||||
- Maintained React 18.2.x and TypeScript 5.3.x
|
|
||||||
|
|
||||||
## [3.5.0] - 2025-01-15
|
|
||||||
|
|
||||||
### ⚠ Breaking Changes
|
|
||||||
|
|
||||||
- Tauri 命令仅接受参数 `app`(取值:`claude`/`codex`);移除对 `app_type`/`appType` 的兼容。
|
|
||||||
- 前端类型命名统一为 `AppId`(移除 `AppType` 导出),变量命名统一为 `appId`。
|
|
||||||
|
|
||||||
### ✨ New Features
|
|
||||||
|
|
||||||
- **MCP (Model Context Protocol) Management** - Complete MCP server configuration management system
|
|
||||||
- Add, edit, delete, and toggle MCP servers in `~/.claude.json`
|
|
||||||
- Support for stdio and http server types with command validation
|
|
||||||
- Built-in templates for popular MCP servers (mcp-fetch, etc.)
|
|
||||||
- Real-time enable/disable toggle for MCP servers
|
|
||||||
- Atomic file writing to prevent configuration corruption
|
|
||||||
- **Configuration Import/Export** - Backup and restore your provider configurations
|
|
||||||
- Export all configurations to JSON file with one click
|
|
||||||
- Import configurations with validation and automatic backup
|
|
||||||
- Automatic backup rotation (keeps 10 most recent backups)
|
|
||||||
- Progress modal with detailed status feedback
|
|
||||||
- **Endpoint Speed Testing** - Test API endpoint response times
|
|
||||||
- Measure latency to different provider endpoints
|
|
||||||
- Visual indicators for connection quality
|
|
||||||
- Help users choose the fastest provider
|
|
||||||
|
|
||||||
### 🔧 Improvements
|
|
||||||
|
|
||||||
- Complete internationalization (i18n) coverage for all UI components
|
|
||||||
- Enhanced error handling and user feedback throughout the application
|
|
||||||
- Improved configuration file management with better validation
|
|
||||||
- Added new provider presets: Longcat, kat-coder
|
|
||||||
- Updated GLM provider configurations with latest models
|
|
||||||
- Refined UI/UX with better spacing, icons, and visual feedback
|
|
||||||
- Enhanced tray menu functionality and responsiveness
|
|
||||||
- **Standardized release artifact naming** - All platform releases now use consistent version-tagged filenames:
|
|
||||||
- macOS: `CC-Switch-v{version}-macOS.tar.gz` / `.zip`
|
|
||||||
- Windows: `CC-Switch-v{version}-Windows.msi` / `-Portable.zip`
|
|
||||||
- Linux: `CC-Switch-v{version}-Linux.AppImage` / `.deb`
|
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
|
||||||
|
|
||||||
- Fixed layout shifts during provider switching
|
|
||||||
- Improved config file path handling across different platforms
|
|
||||||
- Better error messages for configuration validation failures
|
|
||||||
- Fixed various edge cases in configuration import/export
|
|
||||||
|
|
||||||
### 📦 Technical Details
|
|
||||||
|
|
||||||
- Enhanced `import_export.rs` module with backup management
|
|
||||||
- New `claude_mcp.rs` module for MCP configuration handling
|
|
||||||
- Improved state management and lock handling in Rust backend
|
|
||||||
- Better TypeScript type safety across the codebase
|
|
||||||
|
|
||||||
## [3.4.0] - 2025-10-01
|
|
||||||
|
|
||||||
### ✨ Features
|
|
||||||
|
|
||||||
- Enable internationalization via i18next with a Chinese default and English fallback, plus an in-app language switcher
|
|
||||||
- Add Claude plugin sync while retiring the legacy VS Code integration controls (Codex no longer requires settings.json edits)
|
|
||||||
- Extend provider presets with optional API key URLs and updated models, including DeepSeek-V3.1-Terminus and Qwen3-Max
|
|
||||||
- Support portable mode launches and enforce a single running instance to avoid conflicts
|
|
||||||
|
|
||||||
### 🔧 Improvements
|
|
||||||
|
|
||||||
- Allow minimizing the window to the system tray and add macOS Dock visibility management for tray workflows
|
|
||||||
- Refresh the Settings modal with a scrollable layout, save icon, and cleaner language section
|
|
||||||
- Smooth provider toggle states with consistent button widths/icons and prevent layout shifts when switching between Claude and Codex
|
|
||||||
- Adjust the Windows MSI installer to target per-user LocalAppData and improve component tracking reliability
|
|
||||||
|
|
||||||
### 🐛 Fixes
|
|
||||||
|
|
||||||
- Remove the unnecessary OpenAI auth requirement from third-party provider configurations
|
|
||||||
- Fix layout shifts while switching app types with Claude plugin sync enabled
|
|
||||||
- Align Enable/In Use button states to avoid visual jank across app views
|
|
||||||
|
|
||||||
## [3.3.0] - 2025-09-22
|
|
||||||
|
|
||||||
### ✨ Features
|
|
||||||
|
|
||||||
- Add “Apply to VS Code / Remove from VS Code” actions on provider cards, writing settings for Code/Insiders/VSCodium variants _(Removed in 3.4.x)_
|
|
||||||
- Enable VS Code auto-sync by default with window broadcast and tray hooks so Codex switches sync silently _(Removed in 3.4.x)_
|
|
||||||
- Extend the Codex provider wizard with display name, dedicated API key URL, and clearer guidance
|
|
||||||
- Introduce shared common config snippets with JSON/TOML reuse, validation, and consistent error surfaces
|
|
||||||
|
|
||||||
### 🔧 Improvements
|
|
||||||
|
|
||||||
- Keep the tray menu responsive when the window is hidden and standardize button styling and copy
|
|
||||||
- Disable modal backdrop blur on Linux (WebKitGTK/Wayland) to avoid freezes; restore the window when clicking the macOS Dock icon
|
|
||||||
- Support overriding config directories on WSL, refine placeholders/descriptions, and fix VS Code button wrapping on Windows
|
|
||||||
- Add a `created_at` timestamp to provider records for future sorting and analytics
|
|
||||||
|
|
||||||
### 🐛 Fixes
|
|
||||||
|
|
||||||
- Correct regex escapes and common snippet trimming in the Codex wizard to prevent validation issues
|
|
||||||
- Harden the VS Code sync flow with more reliable TOML/JSON parsing while reducing layout jank
|
|
||||||
- Bundle `@codemirror/lint` to reinstate live linting in config editors
|
|
||||||
|
|
||||||
## [3.2.0] - 2025-09-13
|
|
||||||
|
|
||||||
### ✨ New Features
|
|
||||||
|
|
||||||
- System tray provider switching with dynamic menu for Claude/Codex
|
|
||||||
- Frontend receives `provider-switched` events and refreshes active app
|
|
||||||
- Built-in update flow via Tauri Updater plugin with dismissible UpdateBadge
|
|
||||||
|
|
||||||
### 🔧 Improvements
|
|
||||||
|
|
||||||
- Single source of truth for provider configs; no duplicate copy files
|
|
||||||
- One-time migration imports existing copies into `config.json` and archives originals
|
|
||||||
- Duplicate provider de-duplication by name + API key at startup
|
|
||||||
- Atomic writes for Codex `auth.json` + `config.toml` with rollback on failure
|
|
||||||
- Logging standardized (Rust): use `log::{info,warn,error}` instead of stdout prints
|
|
||||||
- Tailwind v4 integration and refined dark mode handling
|
|
||||||
|
|
||||||
### 🐛 Fixes
|
|
||||||
|
|
||||||
- Remove/minimize debug console logs in production builds
|
|
||||||
- Fix CSS minifier warnings for scrollbar pseudo-elements
|
|
||||||
- Prettier formatting across codebase for consistent style
|
|
||||||
|
|
||||||
### 📦 Dependencies
|
|
||||||
|
|
||||||
- Tauri: 2.8.x (core, updater, process, opener, log plugins)
|
|
||||||
- React: 18.2.x · TypeScript: 5.3.x · Vite: 5.x
|
|
||||||
|
|
||||||
### 🔄 Notes
|
|
||||||
|
|
||||||
- `connect-src` CSP remains permissive for compatibility; can be tightened later as needed
|
|
||||||
|
|
||||||
## [3.1.1] - 2025-09-03
|
## [3.1.1] - 2025-09-03
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
- Fixed the default codex config.toml to match the latest modifications
|
- Fixed the default codex config.toml to match the latest modifications
|
||||||
- Improved provider configuration UX with custom option
|
- Improved provider configuration UX with custom option
|
||||||
|
|
||||||
### 📝 Documentation
|
### 📝 Documentation
|
||||||
|
|
||||||
- Updated README with latest information
|
- Updated README with latest information
|
||||||
|
|
||||||
## [3.1.0] - 2025-09-01
|
## [3.1.0] - 2025-09-01
|
||||||
|
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
|
||||||
- **Added Codex application support** - Now supports both Claude Code and Codex configuration management
|
- **Added Codex application support** - Now supports both Claude Code and Codex configuration management
|
||||||
- Manage auth.json and config.toml for Codex
|
- Manage auth.json and config.toml for Codex
|
||||||
- Support for backup and restore operations
|
- Support for backup and restore operations
|
||||||
@@ -653,14 +33,12 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
|
|||||||
- TOML syntax validation for config.toml
|
- TOML syntax validation for config.toml
|
||||||
|
|
||||||
### 🔧 Technical Improvements
|
### 🔧 Technical Improvements
|
||||||
|
|
||||||
- Unified Tauri command API with app_type parameter
|
- Unified Tauri command API with app_type parameter
|
||||||
- Backward compatibility for app/appType parameters
|
- Backward compatibility for app/appType parameters
|
||||||
- Added get_config_status/open_config_folder/open_external commands
|
- Added get_config_status/open_config_folder/open_external commands
|
||||||
- Improved error handling for empty config.toml
|
- Improved error handling for empty config.toml
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
- Fixed config path reporting and folder opening for Codex
|
- Fixed config path reporting and folder opening for Codex
|
||||||
- Corrected default import behavior when main config is missing
|
- Corrected default import behavior when main config is missing
|
||||||
- Fixed non_snake_case warnings in commands.rs
|
- Fixed non_snake_case warnings in commands.rs
|
||||||
@@ -668,7 +46,6 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
|
|||||||
## [3.0.0] - 2025-08-27
|
## [3.0.0] - 2025-08-27
|
||||||
|
|
||||||
### 🚀 Major Changes
|
### 🚀 Major Changes
|
||||||
|
|
||||||
- **Complete migration from Electron to Tauri 2.0** - The application has been completely rewritten using Tauri, resulting in:
|
- **Complete migration from Electron to Tauri 2.0** - The application has been completely rewritten using Tauri, resulting in:
|
||||||
- **90% reduction in bundle size** (from ~150MB to ~15MB)
|
- **90% reduction in bundle size** (from ~150MB to ~15MB)
|
||||||
- **Significantly improved startup performance**
|
- **Significantly improved startup performance**
|
||||||
@@ -676,14 +53,12 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
|
|||||||
- **Enhanced security** with Rust backend
|
- **Enhanced security** with Rust backend
|
||||||
|
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
|
||||||
- **Native window controls** with transparent title bar on macOS
|
- **Native window controls** with transparent title bar on macOS
|
||||||
- **Improved file system operations** using Rust for better performance
|
- **Improved file system operations** using Rust for better performance
|
||||||
- **Enhanced security model** with explicit permission declarations
|
- **Enhanced security model** with explicit permission declarations
|
||||||
- **Better platform detection** using Tauri's native APIs
|
- **Better platform detection** using Tauri's native APIs
|
||||||
|
|
||||||
### 🔧 Technical Improvements
|
### 🔧 Technical Improvements
|
||||||
|
|
||||||
- Migrated from Electron IPC to Tauri command system
|
- Migrated from Electron IPC to Tauri command system
|
||||||
- Replaced Node.js file operations with Rust implementations
|
- Replaced Node.js file operations with Rust implementations
|
||||||
- Implemented proper CSP (Content Security Policy) for enhanced security
|
- Implemented proper CSP (Content Security Policy) for enhanced security
|
||||||
@@ -691,34 +66,28 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
|
|||||||
- Integrated Rust cargo fmt and clippy for code quality
|
- Integrated Rust cargo fmt and clippy for code quality
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
- Fixed bundle identifier conflict on macOS (changed from .app to .desktop)
|
- Fixed bundle identifier conflict on macOS (changed from .app to .desktop)
|
||||||
- Resolved platform detection issues
|
- Resolved platform detection issues
|
||||||
- Improved error handling in configuration management
|
- Improved error handling in configuration management
|
||||||
|
|
||||||
### 📦 Dependencies
|
### 📦 Dependencies
|
||||||
|
|
||||||
- **Tauri**: 2.8.2
|
- **Tauri**: 2.8.2
|
||||||
- **React**: 18.2.0
|
- **React**: 18.2.0
|
||||||
- **TypeScript**: 5.3.0
|
- **TypeScript**: 5.3.0
|
||||||
- **Vite**: 5.0.0
|
- **Vite**: 5.0.0
|
||||||
|
|
||||||
### 🔄 Migration Notes
|
### 🔄 Migration Notes
|
||||||
|
|
||||||
For users upgrading from v2.x (Electron version):
|
For users upgrading from v2.x (Electron version):
|
||||||
|
|
||||||
- Configuration files remain compatible - no action required
|
- Configuration files remain compatible - no action required
|
||||||
- The app will automatically migrate your existing provider configurations
|
- The app will automatically migrate your existing provider configurations
|
||||||
- Window position and size preferences have been reset to defaults
|
- Window position and size preferences have been reset to defaults
|
||||||
|
|
||||||
#### Backup on v1→v2 Migration (cc-switch internal config)
|
#### Backup on v1→v2 Migration (cc-switch internal config)
|
||||||
|
|
||||||
- When the app detects an old v1 config structure at `~/.cc-switch/config.json`, it now creates a timestamped backup before writing the new v2 structure.
|
- When the app detects an old v1 config structure at `~/.cc-switch/config.json`, it now creates a timestamped backup before writing the new v2 structure.
|
||||||
- Backup location: `~/.cc-switch/config.v1.backup.<timestamp>.json`
|
- Backup location: `~/.cc-switch/config.v1.backup.<timestamp>.json`
|
||||||
- This only concerns cc-switch's own metadata file; your actual provider files under `~/.claude/` and `~/.codex/` are untouched.
|
- This only concerns cc-switch's own metadata file; your actual provider files under `~/.claude/` and `~/.codex/` are untouched.
|
||||||
|
|
||||||
### 🛠️ Development
|
### 🛠️ Development
|
||||||
|
|
||||||
- Added `pnpm typecheck` command for TypeScript validation
|
- Added `pnpm typecheck` command for TypeScript validation
|
||||||
- Added `pnpm format` and `pnpm format:check` for code formatting
|
- Added `pnpm format` and `pnpm format:check` for code formatting
|
||||||
- Rust code now uses cargo fmt for consistent formatting
|
- Rust code now uses cargo fmt for consistent formatting
|
||||||
@@ -726,7 +95,6 @@ For users upgrading from v2.x (Electron version):
|
|||||||
## [2.0.0] - Previous Electron Release
|
## [2.0.0] - Previous Electron Release
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- Multi-provider configuration management
|
- Multi-provider configuration management
|
||||||
- Quick provider switching
|
- Quick provider switching
|
||||||
- Import/export configurations
|
- Import/export configurations
|
||||||
@@ -737,44 +105,6 @@ For users upgrading from v2.x (Electron version):
|
|||||||
## [1.0.0] - Initial Release
|
## [1.0.0] - Initial Release
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- Basic provider management
|
- Basic provider management
|
||||||
- Claude Code integration
|
- Claude Code integration
|
||||||
- Configuration file handling
|
- Configuration file handling
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
### ⚠️ Breaking Changes
|
|
||||||
|
|
||||||
- **Runtime auto-migration from v1 to v2 config format has been removed**
|
|
||||||
- `MultiAppConfig::load()` no longer automatically migrates v1 configs
|
|
||||||
- When a v1 config is detected, the app now returns a clear error with migration instructions
|
|
||||||
- **Migration path**: Install v3.2.x to perform one-time auto-migration, OR manually edit `~/.cc-switch/config.json` to v2 format
|
|
||||||
- **Rationale**: Separates concerns (load() should be read-only), fail-fast principle, simplifies maintenance
|
|
||||||
- Related: `app_config.rs` (v1 detection improved with structural analysis), `app_config_load.rs` (comprehensive test coverage added)
|
|
||||||
|
|
||||||
- **Legacy v1 copy file migration logic has been removed**
|
|
||||||
- Removed entire `migration.rs` module (435 lines) that handled one-time migration from v3.1.0 to v3.2.0
|
|
||||||
- No longer scans/merges legacy copy files (`settings-*.json`, `auth-*.json`, `config-*.toml`)
|
|
||||||
- No longer archives copy files or performs automatic deduplication
|
|
||||||
- **Migration path**: Users upgrading from v3.1.0 must first upgrade to v3.2.x to automatically migrate their configurations
|
|
||||||
- **Benefits**: Improved startup performance (no file scanning), reduced code complexity, cleaner codebase
|
|
||||||
|
|
||||||
- **Tauri commands now only accept `app` parameter**
|
|
||||||
- Removed legacy `app_type`/`appType` compatibility paths
|
|
||||||
- Explicit error with available values when unknown `app` is provided
|
|
||||||
|
|
||||||
### 🔧 Improvements
|
|
||||||
|
|
||||||
- Unified `AppType` parsing: centralized to `FromStr` implementation, command layer no longer implements separate `parse_app()`, reducing code duplication and drift
|
|
||||||
- Localized and user-friendly error messages: returns bilingual (Chinese/English) hints for unsupported `app` values with a list of available options
|
|
||||||
- Simplified startup logic: Only ensures config structure exists, no migration overhead
|
|
||||||
|
|
||||||
### 🧪 Tests
|
|
||||||
|
|
||||||
- Added unit tests covering `AppType::from_str`: case sensitivity, whitespace trimming, unknown value error messages
|
|
||||||
- Added comprehensive config loading tests:
|
|
||||||
- `load_v1_config_returns_error_and_does_not_write`
|
|
||||||
- `load_v1_with_extra_version_still_treated_as_v1`
|
|
||||||
- `load_invalid_json_returns_parse_error_and_does_not_write`
|
|
||||||
- `load_valid_v2_config_succeeds`
|
|
||||||
|
|||||||
@@ -1,472 +1,181 @@
|
|||||||
<div align="center">
|
# Claude Code & Codex 供应商切换器
|
||||||
|
|
||||||
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
[](https://github.com/jasonyoung/cc-switch/releases)
|
||||||
|
[](https://github.com/jasonyoung/cc-switch/releases)
|
||||||
|
[](https://tauri.app/)
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
|
||||||
[](https://tauri.app/)
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文““迁移与备份”)。
|
||||||
|
|
||||||
English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md)
|
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB),启动速度提升 10 倍!
|
||||||
|
|
||||||
</div>
|
## 功能特性
|
||||||
|
|
||||||
## ❤️Sponsor
|
- **极速启动** - 基于 Tauri 2.0,原生性能,秒开应用
|
||||||
|
- 一键切换不同供应商
|
||||||
|
- 同时支持 Claude Code 与 Codex 的供应商切换与导入
|
||||||
|
- Qwen coder、kimi k2、智谱 GLM、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
|
||||||
|
- 支持添加自定义供应商
|
||||||
|
- 随时切换官方登录
|
||||||
|
- 简洁美观的图形界面
|
||||||
|
- 信息存储在本地 ~/.cc-switch/config.json,无隐私风险
|
||||||
|
- 超小体积 - 仅 ~5MB 安装包
|
||||||
|
|
||||||

|
## 界面预览
|
||||||
|
|
||||||
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
|
### 主界面
|
||||||
|
|
||||||
---
|

|
||||||
|
|
||||||
<table>
|
### 添加供应商
|
||||||
<tr>
|
|
||||||
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
|
||||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr>
|

|
||||||
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
|
|
||||||
<td>Thanks to ShanDianShuo for sponsoring this project! ShanDianShuo is a local-first AI voice input: Millisecond latency, data stays on device, 4x faster than typing, AI-powered correction, Privacy-first, completely free. Doubles your coding efficiency with Claude Code! <a href="https://www.shandianshuo.cn">Free download</a> for Mac/Win</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr>
|
## 下载安装
|
||||||
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
|
|
||||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses.AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you’ll receive an extra 10% bonus credit on your first top-up!
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
### 系统要求
|
||||||
|
|
||||||
## Screenshots
|
- **Windows**: Windows 10 及以上
|
||||||
|
- **macOS**: macOS 10.15 (Catalina) 及以上
|
||||||
|
- **Linux**: Ubuntu 20.04+ / Debian 11+ / Fedora 34+ 等主流发行版
|
||||||
|
|
||||||
| Main Interface | Add Provider |
|
### Windows 用户
|
||||||
| :-----------------------------------------------: | :--------------------------------------------: |
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
## Features
|
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-Setup.msi` 安装包或者 `CC-Switch-Windows-Portable.zip` 绿色版。
|
||||||
|
|
||||||
### Current Version: v3.8.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
|
### macOS 用户
|
||||||
|
|
||||||
**v3.8.0 Major Update (2025-11-28)**
|
从 [Releases](../../releases) 页面下载 `CC-Switch-macOS.zip` 解压使用。
|
||||||
|
|
||||||
**Persistence Architecture Upgrade & Brand New UI**
|
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||||
|
|
||||||
- **SQLite + JSON Dual-layer Architecture**
|
### Linux 用户
|
||||||
- Migrated from JSON file storage to SQLite + JSON dual-layer structure
|
|
||||||
- Syncable data (providers, MCP, Prompts, Skills) stored in SQLite
|
|
||||||
- Device-level data (window state, local paths) stored in JSON
|
|
||||||
- Lays the foundation for future cloud sync functionality
|
|
||||||
- Schema version management for database migrations
|
|
||||||
|
|
||||||
- **Brand New User Interface**
|
从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包。
|
||||||
- Completely redesigned interface layout
|
|
||||||
- Unified component styles and smoother animations
|
|
||||||
- Optimized visual hierarchy
|
|
||||||
- Tailwind CSS downgraded from v4 to v3.4 for better browser compatibility
|
|
||||||
|
|
||||||
- **Japanese Language Support**
|
## 使用说明
|
||||||
- Added Japanese interface support (now supports Chinese/English/Japanese)
|
|
||||||
|
|
||||||
- **Auto Launch on Startup**
|
1. 点击"添加供应商"添加你的 API 配置
|
||||||
- One-click enable/disable in settings
|
2. 选择要使用的供应商,点击单选按钮切换
|
||||||
- Platform-native APIs (Registry/LaunchAgent/XDG autostart)
|
3. 配置会自动保存到对应应用的配置文件中
|
||||||
|
4. 重启或者新打开终端以生效
|
||||||
|
5. 如果需要切回 Claude 官方登录,可以添加预设供应商里的“Claude 官方登录”并切换,重启终端后即可进行正常的 /login 登录
|
||||||
|
|
||||||
- **Skills Recursive Scanning**
|
### Codex 说明
|
||||||
- Support for multi-level directory structures
|
|
||||||
- Allow same-named skills from different repositories
|
|
||||||
|
|
||||||
- **Critical Bug Fixes**
|
- 配置目录:`~/.codex/`
|
||||||
- Fixed custom endpoints lost when updating providers
|
- 主配置文件:`auth.json`(必需)、`config.toml`(可为空)
|
||||||
- Fixed Gemini configuration write issues
|
- 供应商副本:`auth-<name>.json`、`config-<name>.toml`
|
||||||
- Fixed Linux WebKitGTK rendering issues
|
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
|
||||||
|
- 切换策略:将选中供应商的副本覆盖到主配置(`auth.json`、`config.toml`)。若供应商没有 `config-*.toml`,会创建空的 `config.toml`。
|
||||||
|
- 导入默认:若 `~/.codex/auth.json` 存在,会将当前主配置导入为 `default` 供应商;`config.toml` 不存在时按空处理。
|
||||||
|
- 官方登录:可切换到预设“Codex 官方登录”,重启终端后可选择使用 ChatGPT 账号完成登录。
|
||||||
|
|
||||||
**v3.7.0 Highlights**
|
### Claude Code 说明
|
||||||
|
|
||||||
**Six Core Features, 18,000+ Lines of New Code**
|
- 配置目录:`~/.claude/`
|
||||||
|
- 主配置文件:`settings.json`(推荐)或 `claude.json`(旧版兼容,若存在则继续使用)
|
||||||
|
- 供应商副本:`settings-<name>.json`
|
||||||
|
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
|
||||||
|
- 切换策略:将选中供应商的副本覆盖到主配置(`settings.json`/`claude.json`)。如当前有配置且存在“当前供应商”,会先将主配置备份回该供应商的副本文件。
|
||||||
|
- 导入默认:若 `~/.claude/settings.json` 或 `~/.claude/claude.json` 存在,会将当前主配置导入为 `default` 供应商副本。
|
||||||
|
- 官方登录:可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录。
|
||||||
|
|
||||||
- **Gemini CLI Integration**
|
### 迁移与备份
|
||||||
- Third supported AI CLI (Claude Code / Codex / Gemini)
|
|
||||||
- Dual-file configuration support (`.env` + `settings.json`)
|
|
||||||
- Complete MCP server management
|
|
||||||
- Presets: Google Official (OAuth) / PackyCode / Custom
|
|
||||||
|
|
||||||
- **Claude Skills Management System**
|
- cc-switch 自身配置从 v1 → v2 迁移时,将在 `~/.cc-switch/` 目录自动创建时间戳备份:`config.v1.backup.<timestamp>.json`。
|
||||||
- Auto-scan skills from GitHub repositories (3 pre-configured curated repos)
|
- 实际生效的应用配置文件(如 `~/.claude/settings.json`、`~/.codex/auth.json`/`config.toml`)不会被修改,切换仅在用户点击“切换”时按副本覆盖到主配置。
|
||||||
- One-click install/uninstall to `~/.claude/skills/`
|
|
||||||
- Custom repository support + subdirectory scanning
|
|
||||||
- Complete lifecycle management (discover/install/update)
|
|
||||||
|
|
||||||
- **Prompts Management System**
|
## 开发
|
||||||
- Multi-preset system prompt management (unlimited presets, quick switching)
|
|
||||||
- Cross-app support (Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`)
|
|
||||||
- Markdown editor (CodeMirror 6 + real-time preview)
|
|
||||||
- Smart backfill protection, preserves manual modifications
|
|
||||||
|
|
||||||
- **MCP v3.7.0 Unified Architecture**
|
### 环境要求
|
||||||
- Single panel manages MCP servers across three applications
|
|
||||||
- New SSE (Server-Sent Events) transport type
|
|
||||||
- Smart JSON parser + Codex TOML format auto-correction
|
|
||||||
- Unified import/export + bidirectional sync
|
|
||||||
|
|
||||||
- **Deep Link Protocol**
|
|
||||||
- `ccswitch://` protocol registration (all platforms)
|
|
||||||
- One-click import provider configs via shared links
|
|
||||||
- Security validation + lifecycle integration
|
|
||||||
|
|
||||||
- **Environment Variable Conflict Detection**
|
|
||||||
- Auto-detect cross-app configuration conflicts (Claude/Codex/Gemini/MCP)
|
|
||||||
- Visual conflict indicators + resolution suggestions
|
|
||||||
- Override warnings + backup before changes
|
|
||||||
|
|
||||||
**Core Capabilities**
|
|
||||||
|
|
||||||
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
|
|
||||||
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
|
|
||||||
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
|
|
||||||
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
|
|
||||||
- **Claude Plugin Sync**: One-click apply/restore Claude plugin configurations
|
|
||||||
|
|
||||||
**v3.6 Highlights**
|
|
||||||
|
|
||||||
- Provider duplication & drag-and-drop sorting
|
|
||||||
- Multi-endpoint management & custom config directory (cloud sync ready)
|
|
||||||
- Granular model configuration (4-tier: Haiku/Sonnet/Opus/Custom)
|
|
||||||
- WSL environment support with auto-sync on directory change
|
|
||||||
- 100% hooks test coverage & complete architecture refactoring
|
|
||||||
|
|
||||||
**System Features**
|
|
||||||
|
|
||||||
- System tray with quick switching
|
|
||||||
- Single instance daemon
|
|
||||||
- Built-in auto-updater
|
|
||||||
- Atomic writes with rollback protection
|
|
||||||
|
|
||||||
## Download & Installation
|
|
||||||
|
|
||||||
### System Requirements
|
|
||||||
|
|
||||||
- **Windows**: Windows 10 and above
|
|
||||||
- **macOS**: macOS 10.15 (Catalina) and above
|
|
||||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ and other mainstream distributions
|
|
||||||
|
|
||||||
### Windows Users
|
|
||||||
|
|
||||||
Download the latest `CC-Switch-v{version}-Windows.msi` installer or `CC-Switch-v{version}-Windows-Portable.zip` portable version from the [Releases](../../releases) page.
|
|
||||||
|
|
||||||
### macOS Users
|
|
||||||
|
|
||||||
**Method 1: Install via Homebrew (Recommended)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
brew install --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
Update:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew upgrade --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
**Method 2: Manual Download**
|
|
||||||
|
|
||||||
Download `CC-Switch-v{version}-macOS.zip` from the [Releases](../../releases) page and extract to use.
|
|
||||||
|
|
||||||
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it first, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and you'll be able to open it normally afterwards.
|
|
||||||
|
|
||||||
### ArchLinux 用户
|
|
||||||
|
|
||||||
**Install via paru (Recommended)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
paru -S cc-switch-bin
|
|
||||||
```
|
|
||||||
|
|
||||||
### Linux Users
|
|
||||||
|
|
||||||
Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{version}-Linux.AppImage` from the [Releases](../../releases) page.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Basic Usage
|
|
||||||
|
|
||||||
1. **Add Provider**: Click "Add Provider" → Choose preset or create custom configuration
|
|
||||||
2. **Switch Provider**:
|
|
||||||
- Main UI: Select provider → Click "Enable"
|
|
||||||
- System Tray: Click provider name directly (instant effect)
|
|
||||||
3. **Takes Effect**: Restart your terminal or Claude Code / Codex / Gemini clients to apply changes
|
|
||||||
4. **Back to Official**: Select the "Official Login" preset (Claude/Codex) or "Google Official" preset (Gemini), restart the corresponding client, then follow its login/OAuth flow
|
|
||||||
|
|
||||||
### MCP Management
|
|
||||||
|
|
||||||
- **Location**: Click "MCP" button in top-right corner
|
|
||||||
- **Add Server**:
|
|
||||||
- Use built-in templates (mcp-fetch, mcp-filesystem, etc.)
|
|
||||||
- Support stdio / http / sse transport types
|
|
||||||
- Configure independent MCP servers for different apps
|
|
||||||
- **Enable/Disable**: Toggle switches to control which servers sync to live config
|
|
||||||
- **Sync**: Enabled servers auto-sync to each app's live files
|
|
||||||
- **Import/Export**: Import existing MCP servers from Claude/Codex/Gemini config files
|
|
||||||
|
|
||||||
### Skills Management (v3.7.0 New)
|
|
||||||
|
|
||||||
- **Location**: Click "Skills" button in top-right corner
|
|
||||||
- **Discover Skills**:
|
|
||||||
- Auto-scan pre-configured GitHub repositories (Anthropic official, ComposioHQ, community, etc.)
|
|
||||||
- Add custom repositories (supports subdirectory scanning)
|
|
||||||
- **Install Skills**: Click "Install" to one-click install to `~/.claude/skills/`
|
|
||||||
- **Uninstall Skills**: Click "Uninstall" to safely remove and clean up state
|
|
||||||
- **Manage Repositories**: Add/remove custom GitHub repositories
|
|
||||||
|
|
||||||
### Prompts Management (v3.7.0 New)
|
|
||||||
|
|
||||||
- **Location**: Click "Prompts" button in top-right corner
|
|
||||||
- **Create Presets**:
|
|
||||||
- Create unlimited system prompt presets
|
|
||||||
- Use Markdown editor to write prompts (syntax highlighting + real-time preview)
|
|
||||||
- **Switch Presets**: Select preset → Click "Activate" to apply immediately
|
|
||||||
- **Sync Mechanism**:
|
|
||||||
- Claude: `~/.claude/CLAUDE.md`
|
|
||||||
- Codex: `~/.codex/AGENTS.md`
|
|
||||||
- Gemini: `~/.gemini/GEMINI.md`
|
|
||||||
- **Protection Mechanism**: Auto-save current prompt content before switching, preserves manual modifications
|
|
||||||
|
|
||||||
### Configuration Files
|
|
||||||
|
|
||||||
**Claude Code**
|
|
||||||
|
|
||||||
- Live config: `~/.claude/settings.json` (or `claude.json`)
|
|
||||||
- API key field: `env.ANTHROPIC_AUTH_TOKEN` or `env.ANTHROPIC_API_KEY`
|
|
||||||
- MCP servers: `~/.claude.json` → `mcpServers`
|
|
||||||
|
|
||||||
**Codex**
|
|
||||||
|
|
||||||
- Live config: `~/.codex/auth.json` (required) + `config.toml` (optional)
|
|
||||||
- API key field: `OPENAI_API_KEY` in `auth.json`
|
|
||||||
- MCP servers: `~/.codex/config.toml` → `[mcp_servers]` tables
|
|
||||||
|
|
||||||
**Gemini**
|
|
||||||
|
|
||||||
- Live config: `~/.gemini/.env` (API key) + `~/.gemini/settings.json` (auth mode)
|
|
||||||
- API key field: `GEMINI_API_KEY` or `GOOGLE_GEMINI_API_KEY` in `.env`
|
|
||||||
- Environment variables: Support `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
|
|
||||||
- MCP servers: `~/.gemini/settings.json` → `mcpServers`
|
|
||||||
- Tray quick switch: Each provider switch rewrites `~/.gemini/.env`, no need to restart Gemini CLI
|
|
||||||
|
|
||||||
**CC Switch Storage (v3.8.0 New Architecture)**
|
|
||||||
|
|
||||||
- Database (SSOT): `~/.cc-switch/cc-switch.db` (SQLite, stores providers, MCP, Prompts, Skills)
|
|
||||||
- Local settings: `~/.cc-switch/settings.json` (device-level settings)
|
|
||||||
- Backups: `~/.cc-switch/backups/` (auto-rotate, keep 10)
|
|
||||||
|
|
||||||
### Cloud Sync Setup
|
|
||||||
|
|
||||||
1. Go to Settings → "Custom Configuration Directory"
|
|
||||||
2. Choose your cloud sync folder (Dropbox, OneDrive, iCloud, etc.)
|
|
||||||
3. Restart app to apply
|
|
||||||
4. Repeat on other devices to enable cross-device sync
|
|
||||||
|
|
||||||
> **Note**: First launch auto-imports existing Claude/Codex configs as default provider.
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
### Design Principles
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Frontend (React + TS) │
|
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
|
||||||
│ │ Components │ │ Hooks │ │ TanStack Query │ │
|
|
||||||
│ │ (UI) │──│ (Bus. Logic) │──│ (Cache/Sync) │ │
|
|
||||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
|
||||||
└────────────────────────┬────────────────────────────────────┘
|
|
||||||
│ Tauri IPC
|
|
||||||
┌────────────────────────▼────────────────────────────────────┐
|
|
||||||
│ Backend (Tauri + Rust) │
|
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
|
||||||
│ │ Commands │ │ Services │ │ Models/Config │ │
|
|
||||||
│ │ (API Layer) │──│ (Bus. Layer) │──│ (Data) │ │
|
|
||||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Core Design Patterns**
|
|
||||||
|
|
||||||
- **SSOT** (Single Source of Truth): All data stored in `~/.cc-switch/cc-switch.db` (SQLite)
|
|
||||||
- **Dual-layer Storage**: SQLite for syncable data, JSON for device-level settings
|
|
||||||
- **Dual-way Sync**: Write to live files on switch, backfill from live when editing active provider
|
|
||||||
- **Atomic Writes**: Temp file + rename pattern prevents config corruption
|
|
||||||
- **Concurrency Safe**: Mutex-protected database connection avoids race conditions
|
|
||||||
- **Layered Architecture**: Clear separation (Commands → Services → DAO → Database)
|
|
||||||
|
|
||||||
**Key Components**
|
|
||||||
|
|
||||||
- **ProviderService**: Provider CRUD, switching, backfill, sorting
|
|
||||||
- **McpService**: MCP server management, import/export, live file sync
|
|
||||||
- **ConfigService**: Config import/export, backup rotation
|
|
||||||
- **SpeedtestService**: API endpoint latency measurement
|
|
||||||
|
|
||||||
**v3.6 Refactoring**
|
|
||||||
|
|
||||||
- Backend: 5-phase refactoring (error handling → command split → tests → services → concurrency)
|
|
||||||
- Frontend: 4-stage refactoring (test infra → hooks → components → cleanup)
|
|
||||||
- Testing: 100% hooks coverage + integration tests (vitest + MSW)
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Environment Requirements
|
|
||||||
|
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- pnpm 8+
|
- pnpm 8+
|
||||||
- Rust 1.85+
|
- Rust 1.75+
|
||||||
- Tauri CLI 2.8+
|
- Tauri CLI 2.0+
|
||||||
|
|
||||||
### Development Commands
|
### 开发命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# 安装依赖
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# Dev mode (hot reload)
|
# 开发模式(热重载)
|
||||||
pnpm dev
|
pnpm dev
|
||||||
|
|
||||||
# Type check
|
# 类型检查
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
|
|
||||||
# Format code
|
# 代码格式化
|
||||||
pnpm format
|
pnpm format
|
||||||
|
|
||||||
# Check code format
|
# 检查代码格式
|
||||||
pnpm format:check
|
pnpm format:check
|
||||||
|
|
||||||
# Run frontend unit tests
|
# 构建应用
|
||||||
pnpm test:unit
|
|
||||||
|
|
||||||
# Run tests in watch mode (recommended for development)
|
|
||||||
pnpm test:unit:watch
|
|
||||||
|
|
||||||
# Build application
|
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
# Build debug version
|
# 构建调试版本
|
||||||
pnpm tauri build --debug
|
pnpm tauri build --debug
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rust Backend Development
|
### Rust 后端开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src-tauri
|
cd src-tauri
|
||||||
|
|
||||||
# Format Rust code
|
# 格式化 Rust 代码
|
||||||
cargo fmt
|
cargo fmt
|
||||||
|
|
||||||
# Run clippy checks
|
# 运行 clippy 检查
|
||||||
cargo clippy
|
cargo clippy
|
||||||
|
|
||||||
# Run backend tests
|
# 运行测试
|
||||||
cargo test
|
cargo test
|
||||||
|
|
||||||
# Run specific tests
|
|
||||||
cargo test test_name
|
|
||||||
|
|
||||||
# Run tests with test-hooks feature
|
|
||||||
cargo test --features test-hooks
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing Guide (v3.6 New)
|
## 技术栈
|
||||||
|
|
||||||
**Frontend Testing**:
|
- **[Tauri 2.0](https://tauri.app/)** - 跨平台桌面应用框架
|
||||||
|
- **[React 18](https://react.dev/)** - 用户界面库
|
||||||
|
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
|
||||||
|
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
|
||||||
|
- **[Rust](https://www.rust-lang.org/)** - 系统级编程语言(后端)
|
||||||
|
|
||||||
- Uses **vitest** as test framework
|
## 项目结构
|
||||||
- Uses **MSW (Mock Service Worker)** to mock Tauri API calls
|
|
||||||
- Uses **@testing-library/react** for component testing
|
|
||||||
|
|
||||||
**Test Coverage**:
|
|
||||||
|
|
||||||
- Hooks unit tests (100% coverage)
|
|
||||||
- `useProviderActions` - Provider operations
|
|
||||||
- `useMcpActions` - MCP management
|
|
||||||
- `useSettings` series - Settings management
|
|
||||||
- `useImportExport` - Import/export
|
|
||||||
- Integration tests
|
|
||||||
- App main application flow
|
|
||||||
- SettingsDialog complete interaction
|
|
||||||
- MCP panel functionality
|
|
||||||
|
|
||||||
**Running Tests**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
pnpm test:unit
|
|
||||||
|
|
||||||
# Watch mode (auto re-run)
|
|
||||||
pnpm test:unit:watch
|
|
||||||
|
|
||||||
# With coverage report
|
|
||||||
pnpm test:unit --coverage
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
**Frontend**: React 18 · TypeScript · Vite · TailwindCSS 4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit
|
|
||||||
|
|
||||||
**Backend**: Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log
|
|
||||||
|
|
||||||
**Testing**: vitest · MSW · @testing-library/react
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
├── src/ # Frontend (React + TypeScript)
|
├── src/ # 前端代码 (React + TypeScript)
|
||||||
│ ├── components/ # UI components (providers/settings/mcp/ui)
|
│ ├── components/ # React 组件
|
||||||
│ ├── hooks/ # Custom hooks (business logic)
|
│ ├── config/ # 预设供应商配置
|
||||||
│ ├── lib/
|
│ ├── lib/ # Tauri API 封装
|
||||||
│ │ ├── api/ # Tauri API wrapper (type-safe)
|
│ └── utils/ # 工具函数
|
||||||
│ │ └── query/ # TanStack Query config
|
├── src-tauri/ # 后端代码 (Rust)
|
||||||
│ ├── i18n/locales/ # Translations (zh/en)
|
│ ├── src/ # Rust 源代码
|
||||||
│ ├── config/ # Presets (providers/mcp)
|
│ │ ├── commands.rs # Tauri 命令定义
|
||||||
│ └── types/ # TypeScript definitions
|
│ │ ├── config.rs # 配置文件管理
|
||||||
├── src-tauri/ # Backend (Rust)
|
│ │ ├── provider.rs # 供应商管理逻辑
|
||||||
│ └── src/
|
│ │ └── store.rs # 状态管理
|
||||||
│ ├── commands/ # Tauri command layer (by domain)
|
│ ├── capabilities/ # 权限配置
|
||||||
│ ├── services/ # Business logic layer
|
│ └── icons/ # 应用图标资源
|
||||||
│ ├── app_config.rs # Config data models
|
└── screenshots/ # 界面截图
|
||||||
│ ├── provider.rs # Provider domain models
|
|
||||||
│ ├── mcp.rs # MCP sync & validation
|
|
||||||
│ └── lib.rs # App entry & tray menu
|
|
||||||
├── tests/ # Frontend tests
|
|
||||||
│ ├── hooks/ # Unit tests
|
|
||||||
│ └── components/ # Integration tests
|
|
||||||
└── assets/ # Screenshots & partner resources
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Changelog
|
## 更新日志
|
||||||
|
|
||||||
See [CHANGELOG.md](CHANGELOG.md) for version update details.
|
查看 [CHANGELOG.md](CHANGELOG.md) 了解版本更新详情。
|
||||||
|
|
||||||
## Legacy Electron Version
|
## Electron 旧版
|
||||||
|
|
||||||
[Releases](../../releases) retains v2.0.3 legacy Electron version
|
[Releases](../../releases) 里保留 v2.0.3 Electron 旧版
|
||||||
|
|
||||||
If you need legacy Electron code, you can pull the electron-legacy branch
|
如果需要旧版 Electron 代码,可以拉取 electron-legacy 分支
|
||||||
|
|
||||||
## Contributing
|
## 贡献
|
||||||
|
|
||||||
Issues and suggestions are welcome!
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|
||||||
Before submitting PRs, please ensure:
|
|
||||||
|
|
||||||
- Pass type check: `pnpm typecheck`
|
|
||||||
- Pass format check: `pnpm format:check`
|
|
||||||
- Pass unit tests: `pnpm test:unit`
|
|
||||||
- 💡 For new features, please open an issue for discussion before submitting a PR
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#farion1231/cc-switch&Date)
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,474 +0,0 @@
|
|||||||
<div align="center">
|
|
||||||
|
|
||||||
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
|
||||||
[](https://tauri.app/)
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
|
||||||
|
|
||||||
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.8.0 リリースノート](docs/release-note-v3.8.0-en.md)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## ❤️スポンサー
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
|
||||||
<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
|
|
||||||
<td>ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。<a href="https://www.shandianshuo.cn">Mac/Win 版を無料ダウンロード</a></td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
|
|
||||||
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## スクリーンショット
|
|
||||||
|
|
||||||
| メイン画面 | プロバイダ追加 |
|
|
||||||
| :-------------------------------------------: | :----------------------------------------------: |
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
## 特長
|
|
||||||
|
|
||||||
### 現在のバージョン:v3.8.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
|
|
||||||
|
|
||||||
**v3.8.0 メジャーアップデート (2025-11-28)**
|
|
||||||
|
|
||||||
**永続化アーキテクチャ刷新 & 新 UI**
|
|
||||||
|
|
||||||
- **SQLite + JSON 二層構造**
|
|
||||||
- JSON 単独保存から SQLite + JSON の二層構造へ移行
|
|
||||||
- 同期対象データ(プロバイダ、MCP、Prompts、Skills)は SQLite に保存
|
|
||||||
- デバイス固有データ(ウィンドウ状態、ローカルパス)は JSON に保存
|
|
||||||
- 将来のクラウド同期の土台を用意
|
|
||||||
- DB マイグレーション用にスキーマバージョンを管理
|
|
||||||
|
|
||||||
- **新しいユーザーインターフェース**
|
|
||||||
- レイアウトを全面再設計
|
|
||||||
- コンポーネントスタイルとアニメーションを統一
|
|
||||||
- 視覚的な階層を最適化
|
|
||||||
- ブラウザ互換性向上のため Tailwind CSS を v4 から v3.4 にダウングレード
|
|
||||||
|
|
||||||
- **日本語対応**
|
|
||||||
- UI が中国語/英語/日本語の 3 言語対応に
|
|
||||||
|
|
||||||
- **自動起動**
|
|
||||||
- 設定画面でワンクリック ON/OFF
|
|
||||||
- プラットフォームネイティブ API(Registry/LaunchAgent/XDG autostart)を使用
|
|
||||||
|
|
||||||
- **Skills 再帰スキャン**
|
|
||||||
- 多階層ディレクトリをサポート
|
|
||||||
- リポジトリが異なる同名スキルを許可
|
|
||||||
|
|
||||||
- **重要なバグ修正**
|
|
||||||
- プロバイダ更新時にカスタムエンドポイントが失われる問題を修正
|
|
||||||
- Gemini 設定の書き込み問題を修正
|
|
||||||
- Linux WebKitGTK の描画問題を修正
|
|
||||||
|
|
||||||
**v3.7.0 ハイライト**
|
|
||||||
|
|
||||||
**6 つのコア機能、18,000 行超の新コード**
|
|
||||||
|
|
||||||
- **Gemini CLI 統合**
|
|
||||||
- Claude Code / Codex / Gemini の 3 番目のサポート AI CLI
|
|
||||||
- 2 つの設定ファイル(`.env` + `settings.json`)に対応
|
|
||||||
- MCP サーバー管理を完備
|
|
||||||
- プリセット:Google 公式(OAuth)/ PackyCode / カスタム
|
|
||||||
|
|
||||||
- **Claude Skills 管理システム**
|
|
||||||
- GitHub リポジトリを自動スキャン(3 つのキュレーション済みリポジトリを同梱)
|
|
||||||
- `~/.claude/skills/` へワンクリックでインストール/アンインストール
|
|
||||||
- カスタムリポジトリ + サブディレクトリスキャンをサポート
|
|
||||||
- ライフサイクル管理(検出/インストール/更新)を完備
|
|
||||||
|
|
||||||
- **Prompts 管理システム**
|
|
||||||
- 無制限のシステムプロンプトプリセットを作成
|
|
||||||
- Markdown エディタ(CodeMirror 6 + リアルタイムプレビュー)付き
|
|
||||||
- スマートなバックフィル保護で手動変更を保持
|
|
||||||
- 複数アプリに同時対応(Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`)
|
|
||||||
|
|
||||||
- **MCP v3.7.0 統合アーキテクチャ**
|
|
||||||
- 1 つのパネルで 3 アプリの MCP を管理
|
|
||||||
- 新たに SSE(Server-Sent Events)トランスポートを追加
|
|
||||||
- スマート JSON パーサー + Codex TOML 自動修正
|
|
||||||
- 双方向のインポート/エクスポート + 双方向同期
|
|
||||||
|
|
||||||
- **ディープリンクプロトコル**
|
|
||||||
- `ccswitch://` を全プラットフォームで登録
|
|
||||||
- 共有リンクからプロバイダ設定をワンクリックでインポート
|
|
||||||
- セキュリティ検証 + ライフサイクル統合
|
|
||||||
|
|
||||||
- **環境変数の競合検知**
|
|
||||||
- Claude/Codex/Gemini/MCP 間の設定競合を自動検出
|
|
||||||
- 競合表示 + 解決ガイド
|
|
||||||
- 上書き前の警告 + バックアップ
|
|
||||||
|
|
||||||
**コア機能**
|
|
||||||
|
|
||||||
- **プロバイダ管理**:Claude Code、Codex、Gemini の API 設定をワンクリックで切り替え
|
|
||||||
- **速度テスト**:エンドポイント遅延を計測し、品質を可視化
|
|
||||||
- **インポート/エクスポート**:設定をバックアップ・復元(最新 10 件を自動ローテーション)
|
|
||||||
- **多言語対応**:UI/エラー/トレイを含む中国語・英語・日本語ローカライズ
|
|
||||||
- **Claude プラグイン同期**:Claude プラグイン設定をワンクリックで適用/復元
|
|
||||||
|
|
||||||
**v3.6 ハイライト**
|
|
||||||
|
|
||||||
- プロバイダの複製とドラッグ&ドロップ並び替え
|
|
||||||
- 複数エンドポイント管理とカスタム設定ディレクトリ(クラウド同期準備済み)
|
|
||||||
- 4 階層のモデル設定(Haiku/Sonnet/Opus/Custom)
|
|
||||||
- WSL 環境をサポートし、ディレクトリ変更時に自動同期
|
|
||||||
- Hooks テスト 100% カバレッジ + アーキテクチャ全面リファクタ
|
|
||||||
|
|
||||||
**システム機能**
|
|
||||||
|
|
||||||
- クイックスイッチ付きシステムトレイ
|
|
||||||
- シングルインスタンス常駐
|
|
||||||
- ビルトイン自動アップデータ
|
|
||||||
- ロールバック保護付きのアトミック書き込み
|
|
||||||
|
|
||||||
## ダウンロード & インストール
|
|
||||||
|
|
||||||
### システム要件
|
|
||||||
|
|
||||||
- **Windows**: Windows 10 以上
|
|
||||||
- **macOS**: macOS 10.15 (Catalina) 以上
|
|
||||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ など主要ディストリビューション
|
|
||||||
|
|
||||||
### Windows ユーザー
|
|
||||||
|
|
||||||
[Releases](../../releases) ページから最新版の `CC-Switch-v{version}-Windows.msi` インストーラー、またはポータブル版 `CC-Switch-v{version}-Windows-Portable.zip` をダウンロード。
|
|
||||||
|
|
||||||
### macOS ユーザー
|
|
||||||
|
|
||||||
**方法 1: Homebrew でインストール(推奨)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
brew install --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
アップデート:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew upgrade --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
**方法 2: 手動ダウンロード**
|
|
||||||
|
|
||||||
[Releases](../../releases) から `CC-Switch-v{version}-macOS.zip` をダウンロードして展開。
|
|
||||||
|
|
||||||
> **注意**: 開発者アカウント未登録のため、初回起動時に「開発元を確認できません」と表示される場合があります。一度閉じてから「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックしてください。以降は通常通り起動できます。
|
|
||||||
|
|
||||||
### ArchLinux ユーザー
|
|
||||||
|
|
||||||
**paru でインストール(推奨)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
paru -S cc-switch-bin
|
|
||||||
```
|
|
||||||
|
|
||||||
### Linux ユーザー
|
|
||||||
|
|
||||||
[Releases](../../releases) から最新版の `CC-Switch-v{version}-Linux.deb` または `CC-Switch-v{version}-Linux.AppImage` をダウンロード。
|
|
||||||
|
|
||||||
## クイックスタート
|
|
||||||
|
|
||||||
### 基本的な使い方
|
|
||||||
|
|
||||||
1. **プロバイダ追加**:「Add Provider」をクリック → プリセットを選ぶかカスタム設定を作成
|
|
||||||
2. **プロバイダ切り替え**:
|
|
||||||
- メイン UI: プロバイダを選択 → 「Enable」をクリック
|
|
||||||
- システムトレイ: プロバイダ名をクリック(即時反映)
|
|
||||||
3. **反映**: ターミナルや Claude Code / Codex / Gemini クライアントを再起動して適用
|
|
||||||
4. **公式設定に戻す**: 「Official Login」プリセット(Claude/Codex)または「Google Official」プリセット(Gemini)を選び、対応クライアントを再起動してログイン/OAuth を実行
|
|
||||||
|
|
||||||
### MCP 管理
|
|
||||||
|
|
||||||
- **入口**: 右上の「MCP」ボタンをクリック
|
|
||||||
- **サーバー追加**:
|
|
||||||
- 組み込みテンプレート(mcp-fetch、mcp-filesystem など)を使用
|
|
||||||
- stdio / http / sse の各トランスポートをサポート
|
|
||||||
- アプリごとに独立した MCP を設定可能
|
|
||||||
- **有効/無効**: トグルでライブ設定への同期を切り替え
|
|
||||||
- **同期**: 有効なサーバーは各アプリのライブファイルへ自動同期
|
|
||||||
- **インポート/エクスポート**: Claude/Codex/Gemini の設定ファイルから既存 MCP を取り込み
|
|
||||||
|
|
||||||
### Skills 管理 (v3.7.0 新機能)
|
|
||||||
|
|
||||||
- **入口**: 右上の「Skills」ボタンをクリック
|
|
||||||
- **スキル探索**:
|
|
||||||
- 事前設定済みの GitHub リポジトリを自動スキャン(Anthropic 公式、ComposioHQ、コミュニティなど)
|
|
||||||
- カスタムリポジトリを追加(サブディレクトリスキャン対応)
|
|
||||||
- **インストール**: 「Install」を押すだけで `~/.claude/skills/` に配置
|
|
||||||
- **アンインストール**: 「Uninstall」で安全に削除と状態クリーンアップ
|
|
||||||
- **リポジトリ管理**: カスタム GitHub リポジトリを追加/削除
|
|
||||||
|
|
||||||
### Prompts 管理 (v3.7.0 新機能)
|
|
||||||
|
|
||||||
- **入口**: 右上の「Prompts」ボタンをクリック
|
|
||||||
- **プリセット作成**:
|
|
||||||
- 無制限のシステムプロンプトプリセットを作成
|
|
||||||
- Markdown エディタで記述(シンタックスハイライト + リアルタイムプレビュー)
|
|
||||||
- **プリセット切り替え**: プリセットを選択 → 「Activate」で即適用
|
|
||||||
- **同期先**:
|
|
||||||
- Claude: `~/.claude/CLAUDE.md`
|
|
||||||
- Codex: `~/.codex/AGENTS.md`
|
|
||||||
- Gemini: `~/.gemini/GEMINI.md`
|
|
||||||
- **保護機構**: 切り替え前に現在の内容を自動保存し、手動変更を保持
|
|
||||||
|
|
||||||
### 設定ファイルパス
|
|
||||||
|
|
||||||
**Claude Code**
|
|
||||||
|
|
||||||
- ライブ設定: `~/.claude/settings.json`(または `claude.json`)
|
|
||||||
- API キーフィールド: `env.ANTHROPIC_AUTH_TOKEN` または `env.ANTHROPIC_API_KEY`
|
|
||||||
- MCP サーバー: `~/.claude.json` → `mcpServers`
|
|
||||||
|
|
||||||
**Codex**
|
|
||||||
|
|
||||||
- ライブ設定: `~/.codex/auth.json`(必須)+ `config.toml`(任意)
|
|
||||||
- API キーフィールド: `auth.json` 内の `OPENAI_API_KEY`
|
|
||||||
- MCP サーバー: `~/.codex/config.toml` → `[mcp_servers]` テーブル
|
|
||||||
|
|
||||||
**Gemini**
|
|
||||||
|
|
||||||
- ライブ設定: `~/.gemini/.env`(API キー)+ `~/.gemini/settings.json`(認証モード)
|
|
||||||
- API キーフィールド: `.env` 内の `GEMINI_API_KEY` または `GOOGLE_GEMINI_API_KEY`
|
|
||||||
- 環境変数: `GOOGLE_GEMINI_BASE_URL`、`GEMINI_MODEL` などをサポート
|
|
||||||
- MCP サーバー: `~/.gemini/settings.json` → `mcpServers`
|
|
||||||
- トレイでのクイックスイッチ: プロバイダ切り替えごとに `~/.gemini/.env` を書き換えるため Gemini CLI の再起動は不要
|
|
||||||
|
|
||||||
**CC Switch 保存先 (v3.8.0 新アーキテクチャ)**
|
|
||||||
|
|
||||||
- データベース (SSOT): `~/.cc-switch/cc-switch.db`(SQLite。プロバイダ、MCP、Prompts、Skills を保存)
|
|
||||||
- ローカル設定: `~/.cc-switch/settings.json`(デバイスレベル設定)
|
|
||||||
- バックアップ: `~/.cc-switch/backups/`(自動ローテーション、最新 10 件を保持)
|
|
||||||
|
|
||||||
### クラウド同期の設定
|
|
||||||
|
|
||||||
1. 設定 → 「Custom Configuration Directory」へ進む
|
|
||||||
2. クラウド同期フォルダ(Dropbox、OneDrive、iCloud など)を選択
|
|
||||||
3. アプリを再起動して反映
|
|
||||||
4. 他のデバイスでも同じフォルダを指定すればクロスデバイス同期が有効に
|
|
||||||
|
|
||||||
> **補足**: 初回起動時に既存の Claude/Codex 設定をデフォルトプロバイダとして自動インポートします。
|
|
||||||
|
|
||||||
## アーキテクチャ概要
|
|
||||||
|
|
||||||
### 設計原則
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Frontend (React + TS) │
|
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
|
||||||
│ │ Components │ │ Hooks │ │ TanStack Query │ │
|
|
||||||
│ │ (UI) │──│ (Bus. Logic) │──│ (Cache/Sync) │ │
|
|
||||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
|
||||||
└────────────────────────┬────────────────────────────────────┘
|
|
||||||
│ Tauri IPC
|
|
||||||
┌────────────────────────▼────────────────────────────────────┐
|
|
||||||
│ Backend (Tauri + Rust) │
|
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
|
||||||
│ │ Commands │ │ Services │ │ Models/Config │ │
|
|
||||||
│ │ (API Layer) │──│ (Bus. Layer) │──│ (Data) │ │
|
|
||||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**コア設計パターン**
|
|
||||||
|
|
||||||
- **SSOT** (Single Source of Truth): すべてのデータを `~/.cc-switch/cc-switch.db`(SQLite)に集約
|
|
||||||
- **二層ストレージ**: 同期データは SQLite、デバイスデータは JSON
|
|
||||||
- **双方向同期**: 切り替え時はライブファイルへ書き込み、編集時はアクティブプロバイダから逆同期
|
|
||||||
- **アトミック書き込み**: 一時ファイル + rename パターンで設定破損を防止
|
|
||||||
- **並行安全**: Mutex で保護された DB 接続でレースを防ぐ
|
|
||||||
- **レイヤードアーキテクチャ**: Commands → Services → DAO → Database を明確に分離
|
|
||||||
|
|
||||||
**主要コンポーネント**
|
|
||||||
|
|
||||||
- **ProviderService**: プロバイダの CRUD、切り替え、バックフィル、ソート
|
|
||||||
- **McpService**: MCP サーバー管理、インポート/エクスポート、ライブファイル同期
|
|
||||||
- **ConfigService**: 設定のインポート/エクスポート、バックアップローテーション
|
|
||||||
- **SpeedtestService**: API エンドポイントの遅延計測
|
|
||||||
|
|
||||||
**v3.6 リファクタリング**
|
|
||||||
|
|
||||||
- バックエンド: エラーハンドリング → コマンド分割 → テスト → サービス層 → 並行性の 5 フェーズ
|
|
||||||
- フロントエンド: テスト基盤 → hooks → コンポーネント → クリーンアップの 4 ステージ
|
|
||||||
- テスト: hooks 100% カバレッジ + 統合テスト(vitest + MSW)
|
|
||||||
|
|
||||||
## 開発
|
|
||||||
|
|
||||||
### 開発環境
|
|
||||||
|
|
||||||
- Node.js 18+
|
|
||||||
- pnpm 8+
|
|
||||||
- Rust 1.85+
|
|
||||||
- Tauri CLI 2.8+
|
|
||||||
|
|
||||||
### 開発コマンド
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 依存関係をインストール
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# ホットリロード付き開発モード
|
|
||||||
pnpm dev
|
|
||||||
|
|
||||||
# 型チェック
|
|
||||||
pnpm typecheck
|
|
||||||
|
|
||||||
# コード整形
|
|
||||||
pnpm format
|
|
||||||
|
|
||||||
# フォーマット検証
|
|
||||||
pnpm format:check
|
|
||||||
|
|
||||||
# フロントエンド単体テスト
|
|
||||||
pnpm test:unit
|
|
||||||
|
|
||||||
# ウォッチモード(開発に推奨)
|
|
||||||
pnpm test:unit:watch
|
|
||||||
|
|
||||||
# アプリをビルド
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# デバッグビルド
|
|
||||||
pnpm tauri build --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rust バックエンド開発
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd src-tauri
|
|
||||||
|
|
||||||
# Rust コード整形
|
|
||||||
cargo fmt
|
|
||||||
|
|
||||||
# clippy チェック
|
|
||||||
cargo clippy
|
|
||||||
|
|
||||||
# バックエンドテスト
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
# 特定テストのみ実行
|
|
||||||
cargo test test_name
|
|
||||||
|
|
||||||
# test-hooks フィーチャー付きでテスト
|
|
||||||
cargo test --features test-hooks
|
|
||||||
```
|
|
||||||
|
|
||||||
### テストガイド (v3.6)
|
|
||||||
|
|
||||||
**フロントエンドテスト**:
|
|
||||||
|
|
||||||
- テストフレームワークに **vitest** を使用
|
|
||||||
- **MSW (Mock Service Worker)** で Tauri API 呼び出しをモック
|
|
||||||
- コンポーネントテストに **@testing-library/react** を採用
|
|
||||||
|
|
||||||
**テストカバレッジ**:
|
|
||||||
|
|
||||||
- Hooks の単体テスト(100% カバレッジ)
|
|
||||||
- `useProviderActions` - プロバイダ操作
|
|
||||||
- `useMcpActions` - MCP 管理
|
|
||||||
- `useSettings` 系 - 設定管理
|
|
||||||
- `useImportExport` - インポート/エクスポート
|
|
||||||
- 統合テスト
|
|
||||||
- アプリのメインフロー
|
|
||||||
- SettingsDialog の一連操作
|
|
||||||
- MCP パネルの機能
|
|
||||||
|
|
||||||
**テスト実行**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 全テストを実行
|
|
||||||
pnpm test:unit
|
|
||||||
|
|
||||||
# ウォッチモード(自動再実行)
|
|
||||||
pnpm test:unit:watch
|
|
||||||
|
|
||||||
# カバレッジレポート付き
|
|
||||||
pnpm test:unit --coverage
|
|
||||||
```
|
|
||||||
|
|
||||||
## 技術スタック
|
|
||||||
|
|
||||||
**フロントエンド**: React 18 · TypeScript · Vite · TailwindCSS 4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit
|
|
||||||
|
|
||||||
**バックエンド**: Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log
|
|
||||||
|
|
||||||
**テスト**: vitest · MSW · @testing-library/react
|
|
||||||
|
|
||||||
## プロジェクト構成
|
|
||||||
|
|
||||||
```
|
|
||||||
├── src/ # フロントエンド (React + TypeScript)
|
|
||||||
│ ├── components/ # UI コンポーネント (providers/settings/mcp/ui)
|
|
||||||
│ ├── hooks/ # ビジネスロジック用カスタムフック
|
|
||||||
│ ├── lib/
|
|
||||||
│ │ ├── api/ # Tauri API ラッパー (型安全)
|
|
||||||
│ │ └── query/ # TanStack Query 設定
|
|
||||||
│ ├── i18n/locales/ # 翻訳 (zh/en)
|
|
||||||
│ ├── config/ # プリセット (providers/mcp)
|
|
||||||
│ └── types/ # TypeScript 型定義
|
|
||||||
├── src-tauri/ # バックエンド (Rust)
|
|
||||||
│ └── src/
|
|
||||||
│ ├── commands/ # Tauri コマンド層 (ドメイン別)
|
|
||||||
│ ├── services/ # ビジネスロジック層
|
|
||||||
│ ├── app_config.rs # 設定モデル
|
|
||||||
│ ├── provider.rs # プロバイダドメインモデル
|
|
||||||
│ ├── mcp.rs # MCP 同期 & 検証
|
|
||||||
│ └── lib.rs # アプリエントリ & トレイメニュー
|
|
||||||
├── tests/ # フロントエンドテスト
|
|
||||||
│ ├── hooks/ # 単体テスト
|
|
||||||
│ └── components/ # 統合テスト
|
|
||||||
└── assets/ # スクリーンショット & スポンサーリソース
|
|
||||||
```
|
|
||||||
|
|
||||||
## 更新履歴
|
|
||||||
|
|
||||||
詳細は [CHANGELOG.md](CHANGELOG.md) をご覧ください。
|
|
||||||
|
|
||||||
## 旧 Electron 版
|
|
||||||
|
|
||||||
[Releases](../../releases) に v2.0.3 の Electron 旧版を残しています。
|
|
||||||
|
|
||||||
旧版コードが必要な場合は `electron-legacy` ブランチを取得してください。
|
|
||||||
|
|
||||||
## 貢献
|
|
||||||
|
|
||||||
Issue や提案を歓迎します!
|
|
||||||
|
|
||||||
PR を送る前に以下をご確認ください:
|
|
||||||
|
|
||||||
- 型チェック: `pnpm typecheck`
|
|
||||||
- フォーマットチェック: `pnpm format:check`
|
|
||||||
- 単体テスト: `pnpm test:unit`
|
|
||||||
- 💡 新機能の場合は、事前に Issue でディスカッションしていただけると助かります
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#farion1231/cc-switch&Date)
|
|
||||||
|
|
||||||
## ライセンス
|
|
||||||
|
|
||||||
MIT © Jason Young
|
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
<div align="center">
|
|
||||||
|
|
||||||
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
|
||||||
[](https://tauri.app/)
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
|
||||||
|
|
||||||
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## ❤️赞助商
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
|
||||||
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠。</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td width="180"><img src="assets/partners/logos/sds-zh.png" alt="ShanDianShuo" width="150"></td>
|
|
||||||
<td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍,AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
|
|
||||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## 界面预览
|
|
||||||
|
|
||||||
| 主界面 | 添加供应商 |
|
|
||||||
| :---------------------------------------: | :------------------------------------------: |
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
## 功能特性
|
|
||||||
|
|
||||||
### 当前版本:v3.8.2 | [完整更新日志](CHANGELOG.md)
|
|
||||||
|
|
||||||
**v3.8.0 重大更新(2025-11-28)**
|
|
||||||
|
|
||||||
**持久化架构升级 & 全新用户界面**
|
|
||||||
|
|
||||||
- **SQLite + JSON 双层架构**
|
|
||||||
- 从 JSON 文件存储迁移到 SQLite + JSON 双层结构
|
|
||||||
- 可同步数据(供应商、MCP、Prompts、Skills)存入 SQLite
|
|
||||||
- 设备级数据(窗口状态、本地路径)保留在 JSON
|
|
||||||
- 为未来云同步功能奠定基础
|
|
||||||
- Schema 版本管理支持数据库迁移
|
|
||||||
|
|
||||||
- **全新用户界面**
|
|
||||||
- 完全重新设计的界面布局
|
|
||||||
- 统一的组件样式和更流畅的动画
|
|
||||||
- 优化的视觉层次
|
|
||||||
- Tailwind CSS 从 v4 降级到 v3.4 以提升浏览器兼容性
|
|
||||||
|
|
||||||
- **日语支持**
|
|
||||||
- 新增日语界面支持(现支持中文/英文/日语)
|
|
||||||
|
|
||||||
- **开机自启**
|
|
||||||
- 在设置中一键开启/关闭
|
|
||||||
- 使用平台原生 API(注册表/LaunchAgent/XDG autostart)
|
|
||||||
|
|
||||||
- **Skills 递归扫描**
|
|
||||||
- 支持多层目录结构
|
|
||||||
- 允许不同仓库的同名技能
|
|
||||||
|
|
||||||
- **关键 Bug 修复**
|
|
||||||
- 修复更新供应商时自定义端点丢失问题
|
|
||||||
- 修复 Gemini 配置写入问题
|
|
||||||
- 修复 Linux WebKitGTK 渲染问题
|
|
||||||
|
|
||||||
**v3.7.0 亮点**
|
|
||||||
|
|
||||||
**六大核心功能,18,000+ 行新增代码**
|
|
||||||
|
|
||||||
- **Gemini CLI 集成**
|
|
||||||
- 第三个支持的 AI CLI(Claude Code / Codex / Gemini)
|
|
||||||
- 双文件配置支持(`.env` + `settings.json`)
|
|
||||||
- 完整 MCP 服务器管理
|
|
||||||
- 预设:Google Official (OAuth) / PackyCode / 自定义
|
|
||||||
|
|
||||||
- **Claude Skills 管理系统**
|
|
||||||
- 从 GitHub 仓库自动扫描技能(预配置 3 个精选仓库)
|
|
||||||
- 一键安装/卸载到 `~/.claude/skills/`
|
|
||||||
- 自定义仓库支持 + 子目录扫描
|
|
||||||
- 完整生命周期管理(发现/安装/更新)
|
|
||||||
|
|
||||||
- **Prompts 管理系统**
|
|
||||||
- 多预设系统提示词管理(无限数量,快速切换)
|
|
||||||
- 跨应用支持(Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`)
|
|
||||||
- Markdown 编辑器(CodeMirror 6 + 实时预览)
|
|
||||||
- 智能回填保护,保留手动修改
|
|
||||||
|
|
||||||
- **MCP v3.7.0 统一架构**
|
|
||||||
- 单一面板管理三个应用的 MCP 服务器
|
|
||||||
- 新增 SSE (Server-Sent Events) 传输类型
|
|
||||||
- 智能 JSON 解析器 + Codex TOML 格式自动修正
|
|
||||||
- 统一导入/导出 + 双向同步
|
|
||||||
|
|
||||||
- **深度链接协议**
|
|
||||||
- `ccswitch://` 协议注册(全平台)
|
|
||||||
- 通过共享链接一键导入供应商配置
|
|
||||||
- 安全验证 + 生命周期集成
|
|
||||||
|
|
||||||
- **环境变量冲突检测**
|
|
||||||
- 自动检测跨应用配置冲突(Claude/Codex/Gemini/MCP)
|
|
||||||
- 可视化冲突指示器 + 解决建议
|
|
||||||
- 覆盖警告 + 更改前备份
|
|
||||||
|
|
||||||
**核心功能**
|
|
||||||
|
|
||||||
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
|
|
||||||
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
|
|
||||||
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
|
|
||||||
- **国际化支持**:完整的中英文本地化(UI、错误、托盘)
|
|
||||||
- **Claude 插件同步**:一键应用或恢复 Claude 插件配置
|
|
||||||
|
|
||||||
**v3.6 亮点**
|
|
||||||
|
|
||||||
- 供应商复制 & 拖拽排序
|
|
||||||
- 多端点管理 & 自定义配置目录(支持云同步)
|
|
||||||
- 细粒度模型配置(四层:Haiku/Sonnet/Opus/自定义)
|
|
||||||
- WSL 环境支持,配置目录切换自动同步
|
|
||||||
- 100% hooks 测试覆盖 & 完整架构重构
|
|
||||||
|
|
||||||
**系统功能**
|
|
||||||
|
|
||||||
- 系统托盘快速切换
|
|
||||||
- 单实例守护
|
|
||||||
- 内置自动更新器
|
|
||||||
- 原子写入与回滚保护
|
|
||||||
|
|
||||||
## 下载安装
|
|
||||||
|
|
||||||
### 系统要求
|
|
||||||
|
|
||||||
- **Windows**: Windows 10 及以上
|
|
||||||
- **macOS**: macOS 10.15 (Catalina) 及以上
|
|
||||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ 等主流发行版
|
|
||||||
|
|
||||||
### Windows 用户
|
|
||||||
|
|
||||||
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Windows.msi` 安装包或者 `CC-Switch-v{版本号}-Windows-Portable.zip` 绿色版。
|
|
||||||
|
|
||||||
### macOS 用户
|
|
||||||
|
|
||||||
**方式一:通过 Homebrew 安装(推荐)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
brew install --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
更新:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew upgrade --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
**方式二:手动下载**
|
|
||||||
|
|
||||||
从 [Releases](../../releases) 页面下载 `CC-Switch-v{版本号}-macOS.zip` 解压使用。
|
|
||||||
|
|
||||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
|
||||||
|
|
||||||
### ArchLinux 用户
|
|
||||||
|
|
||||||
**通过 paru 安装(推荐)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
paru -S cc-switch-bin
|
|
||||||
```
|
|
||||||
|
|
||||||
### Linux 用户
|
|
||||||
|
|
||||||
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包。
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 基本使用
|
|
||||||
|
|
||||||
1. **添加供应商**:点击"添加供应商" → 选择预设或创建自定义配置
|
|
||||||
2. **切换供应商**:
|
|
||||||
- 主界面:选择供应商 → 点击"启用"
|
|
||||||
- 系统托盘:直接点击供应商名称(立即生效)
|
|
||||||
3. **生效方式**:重启终端或 Claude Code / Codex / Gemini 客户端以应用更改
|
|
||||||
4. **恢复官方登录**:选择"官方登录"预设(Claude/Codex)或"Google 官方"预设(Gemini),重启对应客户端后按照其登录/OAuth 流程操作
|
|
||||||
|
|
||||||
### MCP 管理
|
|
||||||
|
|
||||||
- **位置**:点击右上角"MCP"按钮
|
|
||||||
- **添加服务器**:
|
|
||||||
- 使用内置模板(mcp-fetch、mcp-filesystem 等)
|
|
||||||
- 支持 stdio / http / sse 三种传输类型
|
|
||||||
- 为不同应用配置独立的 MCP 服务器
|
|
||||||
- **启用/禁用**:切换开关以控制哪些服务器同步到 live 配置
|
|
||||||
- **同步**:启用的服务器自动同步到各应用的 live 文件
|
|
||||||
- **导入/导出**:支持从 Claude/Codex/Gemini 配置文件导入现有 MCP 服务器
|
|
||||||
|
|
||||||
### Skills 管理(v3.7.0 新增)
|
|
||||||
|
|
||||||
- **位置**:点击右上角"Skills"按钮
|
|
||||||
- **发现技能**:
|
|
||||||
- 自动扫描预配置的 GitHub 仓库(Anthropic 官方、ComposioHQ、社区等)
|
|
||||||
- 添加自定义仓库(支持子目录扫描)
|
|
||||||
- **安装技能**:点击"安装"一键安装到 `~/.claude/skills/`
|
|
||||||
- **卸载技能**:点击"卸载"安全移除并清理状态
|
|
||||||
- **管理仓库**:添加/删除自定义 GitHub 仓库
|
|
||||||
|
|
||||||
### Prompts 管理(v3.7.0 新增)
|
|
||||||
|
|
||||||
- **位置**:点击右上角"Prompts"按钮
|
|
||||||
- **创建预设**:
|
|
||||||
- 创建无限数量的系统提示词预设
|
|
||||||
- 使用 Markdown 编辑器编写提示词(语法高亮 + 实时预览)
|
|
||||||
- **切换预设**:选择预设 → 点击"激活"立即应用
|
|
||||||
- **同步机制**:
|
|
||||||
- Claude: `~/.claude/CLAUDE.md`
|
|
||||||
- Codex: `~/.codex/AGENTS.md`
|
|
||||||
- Gemini: `~/.gemini/GEMINI.md`
|
|
||||||
- **保护机制**:切换前自动保存当前提示词内容,保留手动修改
|
|
||||||
|
|
||||||
### 配置文件
|
|
||||||
|
|
||||||
**Claude Code**
|
|
||||||
|
|
||||||
- Live 配置:`~/.claude/settings.json`(或 `claude.json`)
|
|
||||||
- API key 字段:`env.ANTHROPIC_AUTH_TOKEN` 或 `env.ANTHROPIC_API_KEY`
|
|
||||||
- MCP 服务器:`~/.claude.json` → `mcpServers`
|
|
||||||
|
|
||||||
**Codex**
|
|
||||||
|
|
||||||
- Live 配置:`~/.codex/auth.json`(必需)+ `config.toml`(可选)
|
|
||||||
- API key 字段:`auth.json` 中的 `OPENAI_API_KEY`
|
|
||||||
- MCP 服务器:`~/.codex/config.toml` → `[mcp_servers]` 表
|
|
||||||
|
|
||||||
**Gemini**
|
|
||||||
|
|
||||||
- Live 配置:`~/.gemini/.env`(API Key)+ `~/.gemini/settings.json`(保存认证模式)
|
|
||||||
- API key 字段:`.env` 文件中的 `GEMINI_API_KEY` 或 `GOOGLE_GEMINI_API_KEY`
|
|
||||||
- 环境变量:支持 `GOOGLE_GEMINI_BASE_URL`、`GEMINI_MODEL` 等自定义变量
|
|
||||||
- MCP 服务器:`~/.gemini/settings.json` → `mcpServers`
|
|
||||||
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`,无需重启 Gemini CLI 即可生效
|
|
||||||
|
|
||||||
**CC Switch 存储(v3.8.0 新架构)**
|
|
||||||
|
|
||||||
- 数据库(SSOT):`~/.cc-switch/cc-switch.db`(SQLite,存储供应商、MCP、Prompts、Skills)
|
|
||||||
- 本地设置:`~/.cc-switch/settings.json`(设备级设置)
|
|
||||||
- 备份:`~/.cc-switch/backups/`(自动轮换,保留 10 个)
|
|
||||||
|
|
||||||
### 云同步设置
|
|
||||||
|
|
||||||
1. 前往设置 → "自定义配置目录"
|
|
||||||
2. 选择您的云同步文件夹(Dropbox、OneDrive、iCloud、坚果云等)
|
|
||||||
3. 重启应用以应用
|
|
||||||
4. 在其他设备上重复操作以启用跨设备同步
|
|
||||||
|
|
||||||
> **注意**:首次启动会自动导入现有 Claude/Codex 配置作为默认供应商。
|
|
||||||
|
|
||||||
## 架构总览
|
|
||||||
|
|
||||||
### 设计原则
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ 前端 (React + TS) │
|
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
|
||||||
│ │ Components │ │ Hooks │ │ TanStack Query │ │
|
|
||||||
│ │ (UI) │──│ (业务逻辑) │──│ (缓存/同步) │ │
|
|
||||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
|
||||||
└────────────────────────┬────────────────────────────────────┘
|
|
||||||
│ Tauri IPC
|
|
||||||
┌────────────────────────▼────────────────────────────────────┐
|
|
||||||
│ 后端 (Tauri + Rust) │
|
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
|
||||||
│ │ Commands │ │ Services │ │ Models/Config │ │
|
|
||||||
│ │ (API 层) │──│ (业务层) │──│ (数据) │ │
|
|
||||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**核心设计模式**
|
|
||||||
|
|
||||||
- **SSOT**(单一事实源):所有数据存储在 `~/.cc-switch/cc-switch.db`(SQLite)
|
|
||||||
- **双层存储**:SQLite 存储可同步数据,JSON 存储设备级设置
|
|
||||||
- **双向同步**:切换时写入 live 文件,编辑当前供应商时从 live 回填
|
|
||||||
- **原子写入**:临时文件 + 重命名模式防止配置损坏
|
|
||||||
- **并发安全**:Mutex 保护的数据库连接避免竞态条件
|
|
||||||
- **分层架构**:清晰分离(Commands → Services → DAO → Database)
|
|
||||||
|
|
||||||
**核心组件**
|
|
||||||
|
|
||||||
- **ProviderService**:供应商增删改查、切换、回填、排序
|
|
||||||
- **McpService**:MCP 服务器管理、导入导出、live 文件同步
|
|
||||||
- **ConfigService**:配置导入导出、备份轮换
|
|
||||||
- **SpeedtestService**:API 端点延迟测量
|
|
||||||
|
|
||||||
**v3.6 重构**
|
|
||||||
|
|
||||||
- 后端:5 阶段重构(错误处理 → 命令拆分 → 测试 → 服务 → 并发)
|
|
||||||
- 前端:4 阶段重构(测试基础 → hooks → 组件 → 清理)
|
|
||||||
- 测试:100% hooks 覆盖 + 集成测试(vitest + MSW)
|
|
||||||
|
|
||||||
## 开发
|
|
||||||
|
|
||||||
### 环境要求
|
|
||||||
|
|
||||||
- Node.js 18+
|
|
||||||
- pnpm 8+
|
|
||||||
- Rust 1.85+
|
|
||||||
- Tauri CLI 2.8+
|
|
||||||
|
|
||||||
### 开发命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 安装依赖
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# 开发模式(热重载)
|
|
||||||
pnpm dev
|
|
||||||
|
|
||||||
# 类型检查
|
|
||||||
pnpm typecheck
|
|
||||||
|
|
||||||
# 代码格式化
|
|
||||||
pnpm format
|
|
||||||
|
|
||||||
# 检查代码格式
|
|
||||||
pnpm format:check
|
|
||||||
|
|
||||||
# 运行前端单元测试
|
|
||||||
pnpm test:unit
|
|
||||||
|
|
||||||
# 监听模式运行测试(推荐开发时使用)
|
|
||||||
pnpm test:unit:watch
|
|
||||||
|
|
||||||
# 构建应用
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# 构建调试版本
|
|
||||||
pnpm tauri build --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rust 后端开发
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd src-tauri
|
|
||||||
|
|
||||||
# 格式化 Rust 代码
|
|
||||||
cargo fmt
|
|
||||||
|
|
||||||
# 运行 clippy 检查
|
|
||||||
cargo clippy
|
|
||||||
|
|
||||||
# 运行后端测试
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
# 运行特定测试
|
|
||||||
cargo test test_name
|
|
||||||
|
|
||||||
# 运行带测试 hooks 的测试
|
|
||||||
cargo test --features test-hooks
|
|
||||||
```
|
|
||||||
|
|
||||||
### 测试说明(v3.6 新增)
|
|
||||||
|
|
||||||
**前端测试**:
|
|
||||||
|
|
||||||
- 使用 **vitest** 作为测试框架
|
|
||||||
- 使用 **MSW (Mock Service Worker)** 模拟 Tauri API 调用
|
|
||||||
- 使用 **@testing-library/react** 进行组件测试
|
|
||||||
|
|
||||||
**测试覆盖**:
|
|
||||||
|
|
||||||
- Hooks 单元测试(100% 覆盖)
|
|
||||||
- `useProviderActions` - 供应商操作
|
|
||||||
- `useMcpActions` - MCP 管理
|
|
||||||
- `useSettings` 系列 - 设置管理
|
|
||||||
- `useImportExport` - 导入导出
|
|
||||||
- 集成测试
|
|
||||||
- App 主应用流程
|
|
||||||
- SettingsDialog 完整交互
|
|
||||||
- MCP 面板功能
|
|
||||||
|
|
||||||
**运行测试**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 运行所有测试
|
|
||||||
pnpm test:unit
|
|
||||||
|
|
||||||
# 监听模式(自动重跑)
|
|
||||||
pnpm test:unit:watch
|
|
||||||
|
|
||||||
# 带覆盖率报告
|
|
||||||
pnpm test:unit --coverage
|
|
||||||
```
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
**前端**:React 18 · TypeScript · Vite · TailwindCSS 4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit
|
|
||||||
|
|
||||||
**后端**:Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log
|
|
||||||
|
|
||||||
**测试**:vitest · MSW · @testing-library/react
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
├── src/ # 前端 (React + TypeScript)
|
|
||||||
│ ├── components/ # UI 组件 (providers/settings/mcp/ui)
|
|
||||||
│ ├── hooks/ # 自定义 hooks (业务逻辑)
|
|
||||||
│ ├── lib/
|
|
||||||
│ │ ├── api/ # Tauri API 封装(类型安全)
|
|
||||||
│ │ └── query/ # TanStack Query 配置
|
|
||||||
│ ├── i18n/locales/ # 翻译 (zh/en)
|
|
||||||
│ ├── config/ # 预设 (providers/mcp)
|
|
||||||
│ └── types/ # TypeScript 类型定义
|
|
||||||
├── src-tauri/ # 后端 (Rust)
|
|
||||||
│ └── src/
|
|
||||||
│ ├── commands/ # Tauri 命令层(按领域)
|
|
||||||
│ ├── services/ # 业务逻辑层
|
|
||||||
│ ├── app_config.rs # 配置数据模型
|
|
||||||
│ ├── provider.rs # 供应商领域模型
|
|
||||||
│ ├── mcp.rs # MCP 同步与校验
|
|
||||||
│ └── lib.rs # 应用入口 & 托盘菜单
|
|
||||||
├── tests/ # 前端测试
|
|
||||||
│ ├── hooks/ # 单元测试
|
|
||||||
│ └── components/ # 集成测试
|
|
||||||
└── assets/ # 截图 & 合作商资源
|
|
||||||
```
|
|
||||||
|
|
||||||
## 更新日志
|
|
||||||
|
|
||||||
查看 [CHANGELOG.md](CHANGELOG.md) 了解版本更新详情。
|
|
||||||
|
|
||||||
## Electron 旧版
|
|
||||||
|
|
||||||
[Releases](../../releases) 里保留 v2.0.3 Electron 旧版
|
|
||||||
|
|
||||||
如果需要旧版 Electron 代码,可以拉取 electron-legacy 分支
|
|
||||||
|
|
||||||
## 贡献
|
|
||||||
|
|
||||||
欢迎提交 Issue 反馈问题和建议!
|
|
||||||
|
|
||||||
提交 PR 前请确保:
|
|
||||||
|
|
||||||
- 通过类型检查:`pnpm typecheck`
|
|
||||||
- 通过格式检查:`pnpm format:check`
|
|
||||||
- 通过单元测试:`pnpm test:unit`
|
|
||||||
- 💡 新功能开发前,欢迎先开 issue 讨论实现方案
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#farion1231/cc-switch&Date)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT © Jason Young
|
|
||||||
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 225 KiB |
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "default",
|
|
||||||
"rsc": false,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "tailwind.config.js",
|
|
||||||
"css": "src/index.css",
|
|
||||||
"baseColor": "neutral",
|
|
||||||
"cssVariables": true,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"iconLibrary": "lucide",
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"utils": "@/lib/utils",
|
|
||||||
"ui": "@/components/ui",
|
|
||||||
"lib": "@/lib",
|
|
||||||
"hooks": "@/hooks"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
# CC Switch Rust 后端重构方案
|
|
||||||
|
|
||||||
## 目录
|
|
||||||
- [背景与现状](#背景与现状)
|
|
||||||
- [问题确认](#问题确认)
|
|
||||||
- [方案评估](#方案评估)
|
|
||||||
- [渐进式重构路线](#渐进式重构路线)
|
|
||||||
- [测试策略](#测试策略)
|
|
||||||
- [风险与对策](#风险与对策)
|
|
||||||
- [总结](#总结)
|
|
||||||
|
|
||||||
## 背景与现状
|
|
||||||
- 前端已完成重构,后端 (Tauri + Rust) 仍维持历史结构。
|
|
||||||
- 核心文件集中在 `src-tauri/src/commands.rs`、`lib.rs` 等超大文件中,业务逻辑与界面事件耦合严重。
|
|
||||||
- 测试覆盖率低,只有零散单元测试,缺乏集成验证。
|
|
||||||
|
|
||||||
## 问题确认
|
|
||||||
|
|
||||||
| 提案问题 | 实际情况 | 严重程度 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `commands.rs` 过长 | ✅ 1526 行,包含 32 个命令,职责混杂 | 🔴 高 |
|
|
||||||
| `lib.rs` 缺少服务层 | ✅ 541 行,托盘/事件/业务逻辑耦合 | 🟡 中 |
|
|
||||||
| `Result<T, String>` 泛滥 | ✅ 118 处,错误上下文丢失 | 🟡 中 |
|
|
||||||
| 全局 `Mutex` 阻塞 | ✅ 31 处 `.lock()` 调用,读写不分离 | 🟡 中 |
|
|
||||||
| 配置逻辑分散 | ✅ 分布在 5 个文件 (`config`/`app_config`/`app_store`/`settings`/`codex_config`) | 🟢 低 |
|
|
||||||
|
|
||||||
代码规模分布(约 5.4k SLOC):
|
|
||||||
- `commands.rs`: 1526 行(28%)→ 第一优先级 🎯
|
|
||||||
- `lib.rs`: 541 行(10%)→ 托盘逻辑与业务耦合
|
|
||||||
- `mcp.rs`: 732 行(14%)→ 相对清晰
|
|
||||||
- `migration.rs`: 431 行(8%)→ 一次性逻辑
|
|
||||||
- 其他文件合计:2156 行(40%)
|
|
||||||
|
|
||||||
## 方案评估
|
|
||||||
|
|
||||||
### ✅ 优点
|
|
||||||
1. **分层架构清晰**
|
|
||||||
- `commands/`:Tauri 命令薄层
|
|
||||||
- `services/`:业务流程,如供应商切换、MCP 同步
|
|
||||||
- `infrastructure/`:配置读写、外设交互
|
|
||||||
- `domain/`:数据模型 (`Provider`, `AppType` 等)
|
|
||||||
→ 提升可测试性、降低耦合度、方便团队协作。
|
|
||||||
|
|
||||||
2. **统一错误处理**
|
|
||||||
- 引入 `AppError`(`thiserror`),保留错误链和上下文。
|
|
||||||
- Tauri 命令仍返回 `Result<T, String>`,通过 `From<AppError>` 自动转换。
|
|
||||||
- 改善日志可读性,利于排查。
|
|
||||||
|
|
||||||
3. **并发优化**
|
|
||||||
- `AppState` 切换为 `RwLock<MultiAppConfig>`。
|
|
||||||
- 读多写少的场景提升吞吐(如频繁查询供应商列表)。
|
|
||||||
|
|
||||||
### ⚠️ 风险
|
|
||||||
1. **过度设计**
|
|
||||||
- 完整 DDD 四层在 5k 行项目中会增加 30-50% 维护成本。
|
|
||||||
- Rust trait + repository 样板较多,收益不足。
|
|
||||||
- 推荐“轻量分层”而非正统 DDD。
|
|
||||||
|
|
||||||
2. **迁移成本高**
|
|
||||||
- `commands.rs` 拆分、错误统一、锁改造触及多文件。
|
|
||||||
- 测试缺失导致重构风险高,需先补测试。
|
|
||||||
- 估算完整改造需 5-6 周;建议分阶段输出可落地价值。
|
|
||||||
|
|
||||||
3. **技术选型需谨慎**
|
|
||||||
- `parking_lot` 相比标准库 `RwLock` 提升有限,不必引入。
|
|
||||||
- `spawn_blocking` 仅用于 >100ms 的阻塞任务,避免滥用。
|
|
||||||
- 以现有依赖为主,控制复杂度。
|
|
||||||
|
|
||||||
## 实施进度
|
|
||||||
- **阶段 1:统一错误处理 ✅**
|
|
||||||
- 引入 `thiserror` 并在 `src-tauri/src/error.rs` 定义 `AppError`,提供常用构造函数和 `From<AppError> for String`,保留错误链路。
|
|
||||||
- 配置、存储、同步等核心模块(`config.rs`、`app_config.rs`、`app_store.rs`、`store.rs`、`codex_config.rs`、`claude_mcp.rs`、`claude_plugin.rs`、`import_export.rs`、`mcp.rs`、`migration.rs`、`speedtest.rs`、`usage_script.rs`、`settings.rs`、`lib.rs` 等)已统一返回 `Result<_, AppError>`,避免字符串错误丢失上下文。
|
|
||||||
- Tauri 命令层继续返回 `Result<_, String>`,通过 `?` + `Into<String>` 统一转换,前端无需调整。
|
|
||||||
- `cargo check` 通过,`rg "Result<[^>]+, String"` 巡检确认除命令层外已无字符串错误返回。
|
|
||||||
- **阶段 2:拆分命令层 ✅**
|
|
||||||
- 已将单一 `src-tauri/src/commands.rs` 拆分为 `commands/{provider,mcp,config,settings,misc,plugin}.rs` 并通过 `commands/mod.rs` 统一导出,保持对外 API 不变。
|
|
||||||
- 每个文件聚焦单一功能域(供应商、MCP、配置、设置、杂项、插件),命令函数平均 150-250 行,可读性与后续维护性显著提升。
|
|
||||||
- 相关依赖调整后 `cargo check` 通过,静态巡检确认无重复定义或未注册命令。
|
|
||||||
- **阶段 3:补充测试 ✅**
|
|
||||||
- `tests/import_export_sync.rs` 集成测试涵盖配置备份、Claude/Codex live 同步、MCP 投影与 Codex/Claude 双向导入流程,并新增启用项清理、非法 TOML 抛错等失败场景验证;统一使用隔离 HOME 目录避免污染真实用户环境。
|
|
||||||
- 扩展 `lib.rs` re-export,暴露 `AppType`、`MultiAppConfig`、`AppError`、配置 IO 以及 Codex/Claude MCP 路径与同步函数,方便服务层及测试直接复用核心逻辑。
|
|
||||||
- 新增负向测试验证 Codex 供应商缺少 `auth` 字段时的错误返回,并补充备份数量上限测试;顺带修复 `create_backup` 采用内存读写避免拷贝继承旧的修改时间,确保最新备份不会在清理阶段被误删。
|
|
||||||
- 针对 `codex_config::write_codex_live_atomic` 补充成功与失败场景测试,覆盖 auth/config 原子写入与失败回滚逻辑(模拟目标路径为目录时的 rename 失败),降低 Codex live 写入回归风险。
|
|
||||||
- 新增 `tests/provider_commands.rs` 覆盖 `switch_provider` 的 Codex 正常流程与供应商缺失分支,并抽取 `switch_provider_internal` 以复用 `AppError`,通过 `switch_provider_test_hook` 暴露测试入口;同时共享 `tests/support.rs` 提供隔离 HOME / 互斥工具函数。
|
|
||||||
- 补充 Claude 切换集成测试,验证 live `settings.json` 覆写、新旧供应商快照回填以及 `.cc-switch/config.json` 持久化结果,确保阶段四提取服务层时拥有可回归的用例。
|
|
||||||
- 增加 Codex 缺失 `auth` 场景测试,确认 `switch_provider_internal` 在关键字段缺失时返回带上下文的 `AppError`,同时保持内存状态未被污染。
|
|
||||||
- 为配置导入命令抽取复用逻辑 `import_config_from_path` 并补充成功/失败集成测试,校验备份生成、状态同步、JSON 解析与文件缺失等错误回退路径;`export_config_to_file` 亦具备成功/缺失源文件的命令级回归。
|
|
||||||
- 新增 `tests/mcp_commands.rs`,通过测试钩子覆盖 `import_default_config`、`import_mcp_from_claude`、`set_mcp_enabled` 等命令层行为,验证缺失文件/非法 JSON 的错误回滚以及成功路径落盘效果;阶段三目标达成,命令层关键边界已具备回归保障。
|
|
||||||
- **阶段 4:服务层抽象 🚧(进行中)**
|
|
||||||
- 新增 `services/provider.rs` 并实现 `ProviderService::switch` / `delete`,集中处理供应商切换、回填、MCP 同步等核心业务;命令层改为薄封装并在 `tests/provider_service.rs`、`tests/provider_commands.rs` 中完成成功与失败路径的集成验证。
|
|
||||||
- 新增 `services/mcp.rs` 提供 `McpService`,封装 MCP 服务器的查询、增删改、启用同步与导入流程;命令层改为参数解析 + 调用服务,`tests/mcp_commands.rs` 直接使用 `McpService` 验证成功与失败路径,阶段三测试继续适配。
|
|
||||||
- `McpService` 在内部先复制内存快照、释放写锁,再执行文件同步,避免阶段五升级后的 `RwLock` 在 I/O 场景被长时间占用;`upsert/delete/set_enabled/sync_enabled` 均已修正。
|
|
||||||
- 新增 `services/config.rs` 提供 `ConfigService`,统一处理配置导入导出、备份与 live 同步;命令层迁移至 `commands/import_export.rs`,在落盘操作前释放锁并复用现有集成测试。
|
|
||||||
- 新增 `services/speedtest.rs` 并实现 `SpeedtestService::test_endpoints`,将 URL 校验、超时裁剪与网络请求封装在服务层,命令改为薄封装;补充单元测试覆盖空列表与非法 URL 分支。
|
|
||||||
- 后续可选:应用设置(Store)命令仍较薄,可按需评估是否抽象;当前阶段四核心服务已基本齐备。
|
|
||||||
- **阶段 5:锁与阻塞优化 ✅(首轮)**
|
|
||||||
- `AppState` 已由 `Mutex<MultiAppConfig>` 切换为 `RwLock<MultiAppConfig>`,托盘、命令与测试均按读写语义区分 `read()` / `write()`;`cargo test` 全量通过验证并未破坏现有流程。
|
|
||||||
- 针对高开销 IO 的配置导入/导出命令提取 `load_config_for_import`,并通过 `tauri::async_runtime::spawn_blocking` 将文件读写与备份迁至阻塞线程,保持命令处理线程轻量。
|
|
||||||
- 其余命令梳理后确认仍属轻量同步操作,暂不额外引入 `spawn_blocking`;若后续出现新的长耗时流程,再按同一模式扩展。
|
|
||||||
|
|
||||||
## 渐进式重构路线
|
|
||||||
|
|
||||||
### 阶段 1:统一错误处理(高收益 / 低风险)
|
|
||||||
- 新增 `src-tauri/src/error.rs`,定义 `AppError`。
|
|
||||||
- 底层文件 IO、配置解析等函数返回 `Result<T, AppError>`。
|
|
||||||
- 命令层通过 `?` 自动传播,最终 `.map_err(Into::into)`。
|
|
||||||
- 预估 3-5 天,立即启动。
|
|
||||||
|
|
||||||
### 阶段 2:拆分 `commands.rs`(高收益 / 中风险)
|
|
||||||
- 按业务拆分为 `commands/provider.rs`、`commands/mcp.rs`、`commands/config.rs`、`commands/settings.rs`、`commands/misc.rs`。
|
|
||||||
- `commands/mod.rs` 统一导出和注册。
|
|
||||||
- 文件行数降低到 200-300 行/文件,职责单一。
|
|
||||||
- 预估 5-7 天,可并行进行部分重构。
|
|
||||||
|
|
||||||
### 阶段 3:补充测试(中收益 / 中风险)
|
|
||||||
- 引入 `tests/` 或 `src-tauri/tests/` 集成测试,覆盖供应商切换、MCP 同步、配置迁移。
|
|
||||||
- 使用 `tempfile`/`tempdir` 隔离文件系统,组合少量回归脚本。
|
|
||||||
- 预估 5-7 天,为后续重构提供安全网。
|
|
||||||
|
|
||||||
### 阶段 4:提取轻量服务层(中收益 / 中风险)
|
|
||||||
- 新增 `services/provider_service.rs`、`services/mcp_service.rs`。
|
|
||||||
- 不强制使用 trait;直接以自由函数/结构体实现业务流程。
|
|
||||||
```rust
|
|
||||||
pub struct ProviderService;
|
|
||||||
impl ProviderService {
|
|
||||||
pub fn switch(config: &mut MultiAppConfig, app: AppType, id: &str) -> Result<(), AppError> {
|
|
||||||
// 业务流程:验证、回填、落盘、更新 current、触发事件
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- 命令层负责参数解析,服务层处理业务逻辑,托盘逻辑重用同一接口。
|
|
||||||
- 预估 7-10 天,可在测试补齐后执行。
|
|
||||||
|
|
||||||
### 阶段 5:锁与阻塞优化(低收益 / 低风险)
|
|
||||||
- ✅ `AppState` 已从 `Mutex` 切换为 `RwLock`,命令与托盘读写按需区分,现有测试全部通过。
|
|
||||||
- ✅ 配置导入/导出命令通过 `spawn_blocking` 处理高开销文件 IO;其他命令维持同步执行以避免不必要调度。
|
|
||||||
- 🔄 持续监控:若后续引入新的批量迁移或耗时任务,再按相同模式扩展到阻塞线程;观察运行时锁竞争情况,必要时考虑进一步拆分状态或引入缓存。
|
|
||||||
|
|
||||||
## 测试策略
|
|
||||||
- **优先覆盖场景**
|
|
||||||
- 供应商切换:状态更新 + live 配置同步
|
|
||||||
- MCP 同步:enabled 服务器快照与落盘
|
|
||||||
- 配置迁移:归档、备份与版本升级
|
|
||||||
- **推荐结构**
|
|
||||||
```rust
|
|
||||||
#[cfg(test)]
|
|
||||||
mod integration {
|
|
||||||
use super::*;
|
|
||||||
#[test]
|
|
||||||
fn switch_provider_updates_live_config() { /* ... */ }
|
|
||||||
#[test]
|
|
||||||
fn sync_mcp_to_codex_updates_claude_config() { /* ... */ }
|
|
||||||
#[test]
|
|
||||||
fn migration_preserves_backup() { /* ... */ }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- 目标覆盖率:关键路径 >80%,文件 IO/迁移 >70%。
|
|
||||||
|
|
||||||
## 风险与对策
|
|
||||||
- **测试不足** → 阶段 3 强制补齐,建立基础集成测试。
|
|
||||||
- **重构跨度大** → 按阶段在独立分支推进(如 `refactor/backend-step1` 等)。
|
|
||||||
- **回滚困难** → 每阶段结束打 tag(如 `v3.6.0-backend-step1`),保留回滚点。
|
|
||||||
- **功能回归** → 重构后执行手动冒烟流程:供应商切换、托盘操作、MCP 同步、配置导入导出。
|
|
||||||
|
|
||||||
## 总结
|
|
||||||
- 当前规模下不建议整体引入完整 DDD/四层架构,避免过度设计。
|
|
||||||
- 建议遵循“错误统一 → 命令拆分 → 补测试 → 服务层抽象 → 锁优化”的渐进式策略。
|
|
||||||
- 完成阶段 1-3 后即可显著提升可维护性与可靠性;阶段 4-5 可根据资源灵活安排。
|
|
||||||
- 重构过程中同步维护文档与测试,确保团队成员对架构演进保持一致认知。
|
|
||||||
@@ -1,490 +0,0 @@
|
|||||||
# CC Switch 重构实施清单
|
|
||||||
|
|
||||||
> 用于跟踪重构进度的详细检查清单
|
|
||||||
|
|
||||||
**开始日期**: ___________
|
|
||||||
**预计完成**: ___________
|
|
||||||
**当前阶段**: ___________
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 阶段 0: 准备阶段 (预计 1 天)
|
|
||||||
|
|
||||||
### 环境准备
|
|
||||||
|
|
||||||
- [ ] 创建新分支 `refactor/modernization`
|
|
||||||
- [ ] 创建备份标签 `git tag backup-before-refactor`
|
|
||||||
- [ ] 备份用户配置文件 `~/.cc-switch/config.json`
|
|
||||||
- [ ] 通知团队成员重构开始
|
|
||||||
|
|
||||||
### 依赖安装
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm add @tanstack/react-query
|
|
||||||
pnpm add react-hook-form @hookform/resolvers
|
|
||||||
pnpm add zod
|
|
||||||
pnpm add sonner
|
|
||||||
pnpm add next-themes
|
|
||||||
pnpm add @radix-ui/react-dialog @radix-ui/react-dropdown-menu
|
|
||||||
pnpm add @radix-ui/react-label @radix-ui/react-select
|
|
||||||
pnpm add @radix-ui/react-slot @radix-ui/react-switch @radix-ui/react-tabs
|
|
||||||
pnpm add class-variance-authority clsx tailwind-merge tailwindcss-animate
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] 安装核心依赖 (上述命令)
|
|
||||||
- [ ] 验证依赖安装成功 `pnpm install`
|
|
||||||
- [ ] 验证编译通过 `pnpm typecheck`
|
|
||||||
|
|
||||||
### 配置文件
|
|
||||||
|
|
||||||
- [ ] 创建 `components.json`
|
|
||||||
- [ ] 更新 `tsconfig.json` 添加路径别名
|
|
||||||
- [ ] 更新 `vite.config.mts` 添加路径解析
|
|
||||||
- [ ] 验证开发服务器启动 `pnpm dev`
|
|
||||||
|
|
||||||
**完成时间**: ___________
|
|
||||||
**遇到的问题**: ___________
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 阶段 1: 基础设施 (预计 2-3 天)
|
|
||||||
|
|
||||||
### 1.1 工具函数和基础组件
|
|
||||||
|
|
||||||
- [ ] 创建 `src/lib/utils.ts` (cn 函数)
|
|
||||||
- [ ] 创建 `src/components/ui/button.tsx`
|
|
||||||
- [ ] 创建 `src/components/ui/dialog.tsx`
|
|
||||||
- [ ] 创建 `src/components/ui/input.tsx`
|
|
||||||
- [ ] 创建 `src/components/ui/label.tsx`
|
|
||||||
- [ ] 创建 `src/components/ui/textarea.tsx`
|
|
||||||
- [ ] 创建 `src/components/ui/select.tsx`
|
|
||||||
- [ ] 创建 `src/components/ui/switch.tsx`
|
|
||||||
- [ ] 创建 `src/components/ui/tabs.tsx`
|
|
||||||
- [ ] 创建 `src/components/ui/sonner.tsx`
|
|
||||||
- [ ] 创建 `src/components/ui/form.tsx`
|
|
||||||
|
|
||||||
**测试**:
|
|
||||||
- [ ] 验证所有 UI 组件可以正常导入
|
|
||||||
- [ ] 创建一个测试页面验证组件样式
|
|
||||||
|
|
||||||
### 1.2 Query Client 设置
|
|
||||||
|
|
||||||
- [ ] 创建 `src/lib/query/queryClient.ts`
|
|
||||||
- [ ] 配置默认选项 (retry, staleTime 等)
|
|
||||||
- [ ] 导出 queryClient 实例
|
|
||||||
|
|
||||||
### 1.3 API 层
|
|
||||||
|
|
||||||
- [ ] 创建 `src/lib/api/providers.ts`
|
|
||||||
- [ ] getAll
|
|
||||||
- [ ] getCurrent
|
|
||||||
- [ ] add
|
|
||||||
- [ ] update
|
|
||||||
- [ ] delete
|
|
||||||
- [ ] switch
|
|
||||||
- [ ] importDefault
|
|
||||||
- [ ] updateTrayMenu
|
|
||||||
|
|
||||||
- [ ] 创建 `src/lib/api/settings.ts`
|
|
||||||
- [ ] get
|
|
||||||
- [ ] save
|
|
||||||
|
|
||||||
- [ ] 创建 `src/lib/api/mcp.ts`
|
|
||||||
- [ ] getConfig
|
|
||||||
- [ ] upsertServer
|
|
||||||
- [ ] deleteServer
|
|
||||||
|
|
||||||
- [ ] 创建 `src/lib/api/index.ts` (聚合导出)
|
|
||||||
|
|
||||||
**测试**:
|
|
||||||
- [ ] 验证 API 调用不会出现运行时错误
|
|
||||||
- [ ] 确认类型定义正确
|
|
||||||
|
|
||||||
### 1.4 Query Hooks
|
|
||||||
|
|
||||||
- [ ] 创建 `src/lib/query/queries.ts`
|
|
||||||
- [ ] useProvidersQuery
|
|
||||||
- [ ] useSettingsQuery
|
|
||||||
- [ ] useMcpConfigQuery
|
|
||||||
|
|
||||||
- [ ] 创建 `src/lib/query/mutations.ts`
|
|
||||||
- [ ] useAddProviderMutation
|
|
||||||
- [ ] useSwitchProviderMutation
|
|
||||||
- [ ] useDeleteProviderMutation
|
|
||||||
- [ ] useUpdateProviderMutation
|
|
||||||
- [ ] useSaveSettingsMutation
|
|
||||||
|
|
||||||
- [ ] 创建 `src/lib/query/index.ts` (聚合导出)
|
|
||||||
|
|
||||||
**测试**:
|
|
||||||
- [ ] 在临时组件中测试每个 hook
|
|
||||||
- [ ] 验证 loading/error 状态正确
|
|
||||||
- [ ] 验证缓存和自动刷新工作
|
|
||||||
|
|
||||||
**完成时间**: ___________
|
|
||||||
**遇到的问题**: ___________
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 阶段 2: 核心功能重构 (预计 3-4 天)
|
|
||||||
|
|
||||||
### 2.1 主题系统
|
|
||||||
|
|
||||||
- [ ] 创建 `src/components/theme-provider.tsx`
|
|
||||||
- [ ] 创建 `src/components/mode-toggle.tsx`
|
|
||||||
- [ ] 更新 `src/index.css` 添加主题变量
|
|
||||||
- [ ] 删除 `src/hooks/useDarkMode.ts`
|
|
||||||
- [ ] 更新所有组件使用新的主题系统
|
|
||||||
|
|
||||||
**测试**:
|
|
||||||
- [ ] 验证主题切换正常工作
|
|
||||||
- [ ] 验证系统主题跟随功能
|
|
||||||
- [ ] 验证主题持久化
|
|
||||||
|
|
||||||
### 2.2 更新 main.tsx
|
|
||||||
|
|
||||||
- [ ] 引入 QueryClientProvider
|
|
||||||
- [ ] 引入 ThemeProvider
|
|
||||||
- [ ] 添加 Toaster 组件
|
|
||||||
- [ ] 移除旧的 API 导入
|
|
||||||
|
|
||||||
**测试**:
|
|
||||||
- [ ] 验证应用可以正常启动
|
|
||||||
- [ ] 验证 Context 正确传递
|
|
||||||
|
|
||||||
### 2.3 重构 App.tsx
|
|
||||||
|
|
||||||
- [ ] 使用 useProvidersQuery 替代手动状态管理
|
|
||||||
- [ ] 移除所有 loadProviders 相关代码
|
|
||||||
- [ ] 移除手动 notification 状态
|
|
||||||
- [ ] 简化事件监听逻辑
|
|
||||||
- [ ] 更新对话框为新的 Dialog 组件
|
|
||||||
|
|
||||||
**目标**: 将 412 行代码减少到 ~100 行
|
|
||||||
|
|
||||||
**测试**:
|
|
||||||
- [ ] 验证供应商列表正常加载
|
|
||||||
- [ ] 验证切换 Claude/Codex 正常工作
|
|
||||||
- [ ] 验证事件监听正常工作
|
|
||||||
|
|
||||||
### 2.4 重构 ProviderList
|
|
||||||
|
|
||||||
- [ ] 创建 `src/components/providers/ProviderList.tsx`
|
|
||||||
- [ ] 使用 mutation hooks 处理操作
|
|
||||||
- [ ] 移除 onNotify prop
|
|
||||||
- [ ] 移除手动状态管理
|
|
||||||
|
|
||||||
**测试**:
|
|
||||||
- [ ] 验证供应商列表渲染
|
|
||||||
- [ ] 验证切换操作
|
|
||||||
- [ ] 验证删除操作
|
|
||||||
|
|
||||||
### 2.5 重构表单系统
|
|
||||||
|
|
||||||
- [ ] 创建 `src/lib/schemas/provider.ts` (Zod schema)
|
|
||||||
- [ ] 创建 `src/components/providers/ProviderForm.tsx`
|
|
||||||
- [ ] 使用 react-hook-form
|
|
||||||
- [ ] 使用 zodResolver
|
|
||||||
- [ ] 字段级验证
|
|
||||||
|
|
||||||
- [ ] 创建 `src/components/providers/AddProviderDialog.tsx`
|
|
||||||
- [ ] 使用新的 Dialog 组件
|
|
||||||
- [ ] 集成 ProviderForm
|
|
||||||
- [ ] 使用 useAddProviderMutation
|
|
||||||
|
|
||||||
- [ ] 创建 `src/components/providers/EditProviderDialog.tsx`
|
|
||||||
- [ ] 使用新的 Dialog 组件
|
|
||||||
- [ ] 集成 ProviderForm
|
|
||||||
- [ ] 使用 useUpdateProviderMutation
|
|
||||||
|
|
||||||
**测试**:
|
|
||||||
- [ ] 验证表单验证正常工作
|
|
||||||
- [ ] 验证错误提示显示正确
|
|
||||||
- [ ] 验证提交操作成功
|
|
||||||
- [ ] 验证表单重置功能
|
|
||||||
|
|
||||||
### 2.6 清理旧组件
|
|
||||||
|
|
||||||
- [x] 删除 `src/components/AddProviderModal.tsx`
|
|
||||||
- [x] 删除 `src/components/EditProviderModal.tsx`
|
|
||||||
- [x] 更新所有引用这些组件的地方
|
|
||||||
- [x] 删除 `src/components/ProviderForm.tsx` 及 `src/components/ProviderForm/`
|
|
||||||
|
|
||||||
**完成时间**: ___________
|
|
||||||
**遇到的问题**: ___________
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 阶段 3: 设置和辅助功能 (预计 2-3 天)
|
|
||||||
|
|
||||||
### 3.1 重构 SettingsDialog
|
|
||||||
|
|
||||||
- [ ] 创建 `src/components/settings/SettingsDialog.tsx`
|
|
||||||
- [ ] 使用 Tabs 组件
|
|
||||||
- [ ] 集成各个设置子组件
|
|
||||||
|
|
||||||
- [ ] 创建 `src/components/settings/GeneralSettings.tsx`
|
|
||||||
- [ ] 语言设置
|
|
||||||
- [ ] 配置目录设置
|
|
||||||
- [ ] 其他通用设置
|
|
||||||
|
|
||||||
- [ ] 创建 `src/components/settings/AboutSection.tsx`
|
|
||||||
- [ ] 版本信息
|
|
||||||
- [ ] 更新检查
|
|
||||||
- [ ] 链接
|
|
||||||
|
|
||||||
- [ ] 创建 `src/components/settings/ImportExportSection.tsx`
|
|
||||||
- [ ] 导入功能
|
|
||||||
- [ ] 导出功能
|
|
||||||
|
|
||||||
**目标**: 将 643 行拆分为 4-5 个小组件,每个 100-150 行
|
|
||||||
|
|
||||||
**测试**:
|
|
||||||
- [ ] 验证设置保存功能
|
|
||||||
- [ ] 验证导入导出功能
|
|
||||||
- [ ] 验证更新检查功能
|
|
||||||
|
|
||||||
### 3.2 重构通知系统
|
|
||||||
|
|
||||||
- [ ] 在所有 mutations 中使用 `toast` 替代 `showNotification`
|
|
||||||
- [ ] 移除 App.tsx 中的 notification 状态
|
|
||||||
- [ ] 移除自定义通知组件
|
|
||||||
|
|
||||||
**测试**:
|
|
||||||
- [ ] 验证成功通知显示
|
|
||||||
- [ ] 验证错误通知显示
|
|
||||||
- [ ] 验证通知自动消失
|
|
||||||
|
|
||||||
### 3.3 重构确认对话框
|
|
||||||
|
|
||||||
- [ ] 更新 `src/components/ConfirmDialog.tsx` 使用新的 Dialog
|
|
||||||
- [ ] 或者直接使用 shadcn/ui 的 AlertDialog
|
|
||||||
|
|
||||||
**测试**:
|
|
||||||
- [ ] 验证删除确认对话框
|
|
||||||
- [ ] 验证其他确认场景
|
|
||||||
|
|
||||||
**完成时间**: ___________
|
|
||||||
**遇到的问题**: ___________
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 阶段 4: 清理和优化 (预计 1-2 天)
|
|
||||||
|
|
||||||
### 4.1 移除旧代码
|
|
||||||
|
|
||||||
- [x] 删除 `src/lib/styles.ts`
|
|
||||||
- [x] 从 `src/lib/tauri-api.ts` 移除 `window.api` 绑定
|
|
||||||
- [x] 精简 `src/lib/tauri-api.ts`,只保留事件监听相关
|
|
||||||
- [x] 删除或更新 `src/vite-env.d.ts` 中的过时类型
|
|
||||||
|
|
||||||
### 4.2 代码审查
|
|
||||||
|
|
||||||
- [ ] 检查所有 TODO 注释
|
|
||||||
- [x] 检查是否还有 `window.api` 调用
|
|
||||||
- [ ] 检查是否还有手动状态管理
|
|
||||||
- [x] 统一代码风格
|
|
||||||
|
|
||||||
### 4.3 类型检查
|
|
||||||
|
|
||||||
- [x] 运行 `pnpm typecheck` 确保无错误
|
|
||||||
- [x] 修复所有类型错误
|
|
||||||
- [x] 更新类型定义
|
|
||||||
|
|
||||||
### 4.4 性能优化
|
|
||||||
|
|
||||||
- [ ] 检查是否有不必要的重渲染
|
|
||||||
- [ ] 添加必要的 React.memo
|
|
||||||
- [ ] 优化 Query 缓存配置
|
|
||||||
|
|
||||||
**完成时间**: ___________
|
|
||||||
**遇到的问题**: ___________
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 阶段 5: 测试和修复 (预计 2-3 天)
|
|
||||||
|
|
||||||
### 5.1 功能测试
|
|
||||||
|
|
||||||
#### 供应商管理
|
|
||||||
- [ ] 添加供应商 (Claude)
|
|
||||||
- [ ] 添加供应商 (Codex)
|
|
||||||
- [ ] 编辑供应商
|
|
||||||
- [ ] 删除供应商
|
|
||||||
- [ ] 切换供应商
|
|
||||||
- [ ] 导入默认配置
|
|
||||||
|
|
||||||
#### 应用切换
|
|
||||||
- [ ] Claude <-> Codex 切换
|
|
||||||
- [ ] 切换后数据正确加载
|
|
||||||
- [ ] 切换后托盘菜单更新
|
|
||||||
|
|
||||||
#### 设置
|
|
||||||
- [ ] 保存通用设置
|
|
||||||
- [ ] 切换语言
|
|
||||||
- [ ] 配置目录选择
|
|
||||||
- [ ] 导入配置
|
|
||||||
- [ ] 导出配置
|
|
||||||
|
|
||||||
#### UI 交互
|
|
||||||
- [ ] 主题切换 (亮色/暗色)
|
|
||||||
- [ ] 对话框打开/关闭
|
|
||||||
- [ ] 表单验证
|
|
||||||
- [ ] Toast 通知
|
|
||||||
|
|
||||||
#### MCP 管理
|
|
||||||
- [ ] 列表显示
|
|
||||||
- [ ] 添加 MCP
|
|
||||||
- [ ] 编辑 MCP
|
|
||||||
- [ ] 删除 MCP
|
|
||||||
- [ ] 启用/禁用 MCP
|
|
||||||
|
|
||||||
### 5.2 边界情况测试
|
|
||||||
|
|
||||||
- [ ] 空供应商列表
|
|
||||||
- [ ] 无效配置文件
|
|
||||||
- [ ] 网络错误
|
|
||||||
- [ ] 后端错误响应
|
|
||||||
- [ ] 并发操作
|
|
||||||
- [ ] 表单输入边界值
|
|
||||||
|
|
||||||
### 5.3 兼容性测试
|
|
||||||
|
|
||||||
- [ ] Windows 测试
|
|
||||||
- [ ] macOS 测试
|
|
||||||
- [ ] Linux 测试
|
|
||||||
|
|
||||||
### 5.4 性能测试
|
|
||||||
|
|
||||||
- [ ] 100+ 供应商加载速度
|
|
||||||
- [ ] 快速切换供应商
|
|
||||||
- [ ] 内存使用情况
|
|
||||||
- [ ] CPU 使用情况
|
|
||||||
|
|
||||||
### 5.5 Bug 修复
|
|
||||||
|
|
||||||
**Bug 列表** (发现后记录):
|
|
||||||
|
|
||||||
1. ___________
|
|
||||||
- [ ] 已修复
|
|
||||||
- [ ] 已验证
|
|
||||||
|
|
||||||
2. ___________
|
|
||||||
- [ ] 已修复
|
|
||||||
- [ ] 已验证
|
|
||||||
|
|
||||||
**完成时间**: ___________
|
|
||||||
**遇到的问题**: ___________
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 最终检查
|
|
||||||
|
|
||||||
### 代码质量
|
|
||||||
|
|
||||||
- [ ] 所有 TypeScript 错误已修复
|
|
||||||
- [ ] 运行 `pnpm format` 格式化代码
|
|
||||||
- [ ] 运行 `pnpm typecheck` 通过
|
|
||||||
- [ ] 代码审查完成
|
|
||||||
|
|
||||||
### 文档更新
|
|
||||||
|
|
||||||
- [ ] 更新 `CLAUDE.md` 反映新架构
|
|
||||||
- [ ] 更新 `README.md` (如有必要)
|
|
||||||
- [ ] 添加 Migration Guide (可选)
|
|
||||||
|
|
||||||
### 性能基准
|
|
||||||
|
|
||||||
记录性能数据:
|
|
||||||
|
|
||||||
**旧版本**:
|
|
||||||
- 启动时间: _____ms
|
|
||||||
- 供应商加载: _____ms
|
|
||||||
- 内存占用: _____MB
|
|
||||||
|
|
||||||
**新版本**:
|
|
||||||
- 启动时间: _____ms
|
|
||||||
- 供应商加载: _____ms
|
|
||||||
- 内存占用: _____MB
|
|
||||||
|
|
||||||
### 代码统计
|
|
||||||
|
|
||||||
**代码行数对比**:
|
|
||||||
|
|
||||||
| 文件 | 旧版本 | 新版本 | 减少 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| App.tsx | 412 | ~100 | -76% |
|
|
||||||
| tauri-api.ts | 712 | ~50 | -93% |
|
|
||||||
| ProviderForm.tsx | 271 | ~150 | -45% |
|
|
||||||
| settings 模块 | 1046 | ~470 (拆分) | -55% |
|
|
||||||
| **总计** | 2038 | ~700 | **-66%** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 发布准备
|
|
||||||
|
|
||||||
### Pre-release 测试
|
|
||||||
|
|
||||||
- [ ] 创建 beta 版本 `v4.0.0-beta.1`
|
|
||||||
- [ ] 在测试环境验证
|
|
||||||
- [ ] 收集用户反馈
|
|
||||||
|
|
||||||
### 正式发布
|
|
||||||
|
|
||||||
- [ ] 合并到 main 分支
|
|
||||||
- [ ] 创建 Release Tag `v4.0.0`
|
|
||||||
- [ ] 更新 Changelog
|
|
||||||
- [ ] 发布 GitHub Release
|
|
||||||
- [ ] 通知用户更新
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 回滚触发条件
|
|
||||||
|
|
||||||
如果出现以下情况,考虑回滚:
|
|
||||||
|
|
||||||
- [ ] 重大功能无法使用
|
|
||||||
- [ ] 用户数据丢失
|
|
||||||
- [ ] 严重性能问题
|
|
||||||
- [ ] 无法修复的兼容性问题
|
|
||||||
|
|
||||||
**回滚命令**:
|
|
||||||
```bash
|
|
||||||
git reset --hard backup-before-refactor
|
|
||||||
# 或
|
|
||||||
git revert <commit-range>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 总结报告
|
|
||||||
|
|
||||||
### 成功指标
|
|
||||||
|
|
||||||
- [ ] 所有现有功能正常工作
|
|
||||||
- [ ] 代码量减少 40%+
|
|
||||||
- [ ] 无用户数据丢失
|
|
||||||
- [ ] 性能未下降
|
|
||||||
|
|
||||||
### 经验教训
|
|
||||||
|
|
||||||
**遇到的主要挑战**:
|
|
||||||
1. ___________
|
|
||||||
2. ___________
|
|
||||||
3. ___________
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
1. ___________
|
|
||||||
2. ___________
|
|
||||||
3. ___________
|
|
||||||
|
|
||||||
**未来改进**:
|
|
||||||
1. ___________
|
|
||||||
2. ___________
|
|
||||||
3. ___________
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**重构完成日期**: ___________
|
|
||||||
**总耗时**: _____ 天
|
|
||||||
**参与人员**: ___________
|
|
||||||
@@ -1,834 +0,0 @@
|
|||||||
# 重构快速参考指南
|
|
||||||
|
|
||||||
> 常见模式和代码示例的速查表
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📑 目录
|
|
||||||
|
|
||||||
1. [React Query 使用](#react-query-使用)
|
|
||||||
2. [react-hook-form 使用](#react-hook-form-使用)
|
|
||||||
3. [shadcn/ui 组件使用](#shadcnui-组件使用)
|
|
||||||
4. [代码迁移示例](#代码迁移示例)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## React Query 使用
|
|
||||||
|
|
||||||
### 基础查询
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 定义查询 Hook
|
|
||||||
export const useProvidersQuery = (appId: AppId) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['providers', appId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const data = await providersApi.getAll(appId)
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在组件中使用
|
|
||||||
function MyComponent() {
|
|
||||||
const { data, isLoading, error } = useProvidersQuery('claude')
|
|
||||||
|
|
||||||
if (isLoading) return <div>Loading...</div>
|
|
||||||
if (error) return <div>Error: {error.message}</div>
|
|
||||||
|
|
||||||
return <div>{/* 使用 data */}</div>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mutation (变更操作)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 定义 Mutation Hook
|
|
||||||
export const useAddProviderMutation = (appId: AppId) => {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (provider: Provider) => {
|
|
||||||
return await providersApi.add(provider, appId)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
// 重新获取数据
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['providers', appId] })
|
|
||||||
toast.success('添加成功')
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
toast.error(`添加失败: ${error.message}`)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在组件中使用
|
|
||||||
function AddProviderDialog() {
|
|
||||||
const mutation = useAddProviderMutation('claude')
|
|
||||||
|
|
||||||
const handleSubmit = (data: Provider) => {
|
|
||||||
mutation.mutate(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => handleSubmit(formData)}
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
>
|
|
||||||
{mutation.isPending ? '添加中...' : '添加'}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 乐观更新
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const useSwitchProviderMutation = (appId: AppId) => {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (providerId: string) => {
|
|
||||||
return await providersApi.switch(providerId, appId)
|
|
||||||
},
|
|
||||||
// 乐观更新: 在请求发送前立即更新 UI
|
|
||||||
onMutate: async (providerId) => {
|
|
||||||
// 取消正在进行的查询
|
|
||||||
await queryClient.cancelQueries({ queryKey: ['providers', appId] })
|
|
||||||
|
|
||||||
// 保存当前数据(以便回滚)
|
|
||||||
const previousData = queryClient.getQueryData(['providers', appId])
|
|
||||||
|
|
||||||
// 乐观更新
|
|
||||||
queryClient.setQueryData(['providers', appId], (old: any) => ({
|
|
||||||
...old,
|
|
||||||
currentProviderId: providerId,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return { previousData }
|
|
||||||
},
|
|
||||||
// 如果失败,回滚
|
|
||||||
onError: (err, providerId, context) => {
|
|
||||||
queryClient.setQueryData(['providers', appId], context?.previousData)
|
|
||||||
toast.error('切换失败')
|
|
||||||
},
|
|
||||||
// 无论成功失败,都重新获取数据
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['providers', appId] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 依赖查询
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 第二个查询依赖第一个查询的结果
|
|
||||||
const { data: providers } = useProvidersQuery(appId)
|
|
||||||
const currentProviderId = providers?.currentProviderId
|
|
||||||
|
|
||||||
const { data: currentProvider } = useQuery({
|
|
||||||
queryKey: ['provider', currentProviderId],
|
|
||||||
queryFn: () => providersApi.getById(currentProviderId!),
|
|
||||||
enabled: !!currentProviderId, // 只有当 ID 存在时才执行
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## react-hook-form 使用
|
|
||||||
|
|
||||||
### 基础表单
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
// 定义验证 schema
|
|
||||||
const schema = z.object({
|
|
||||||
name: z.string().min(1, '请输入名称'),
|
|
||||||
email: z.string().email('邮箱格式不正确'),
|
|
||||||
age: z.number().min(18, '年龄必须大于18'),
|
|
||||||
})
|
|
||||||
|
|
||||||
type FormData = z.infer<typeof schema>
|
|
||||||
|
|
||||||
function MyForm() {
|
|
||||||
const form = useForm<FormData>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
age: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const onSubmit = (data: FormData) => {
|
|
||||||
console.log(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<input {...form.register('name')} />
|
|
||||||
{form.formState.errors.name && (
|
|
||||||
<span>{form.formState.errors.name.message}</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button type="submit">提交</button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 使用 shadcn/ui Form 组件
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@/components/ui/form'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
|
|
||||||
function MyForm() {
|
|
||||||
const form = useForm<FormData>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>名称</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="请输入名称" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button type="submit">提交</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 动态表单验证
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 根据条件动态验证
|
|
||||||
const schema = z.object({
|
|
||||||
type: z.enum(['official', 'custom']),
|
|
||||||
apiKey: z.string().optional(),
|
|
||||||
baseUrl: z.string().optional(),
|
|
||||||
}).refine(
|
|
||||||
(data) => {
|
|
||||||
// 如果是自定义供应商,必须填写 baseUrl
|
|
||||||
if (data.type === 'custom') {
|
|
||||||
return !!data.baseUrl
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: '自定义供应商必须填写 Base URL',
|
|
||||||
path: ['baseUrl'],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 手动触发验证
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function MyForm() {
|
|
||||||
const form = useForm<FormData>()
|
|
||||||
|
|
||||||
const handleBlur = async () => {
|
|
||||||
// 验证单个字段
|
|
||||||
await form.trigger('name')
|
|
||||||
|
|
||||||
// 验证多个字段
|
|
||||||
await form.trigger(['name', 'email'])
|
|
||||||
|
|
||||||
// 验证所有字段
|
|
||||||
const isValid = await form.trigger()
|
|
||||||
}
|
|
||||||
|
|
||||||
return <form>...</form>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## shadcn/ui 组件使用
|
|
||||||
|
|
||||||
### Dialog (对话框)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
|
|
||||||
function MyDialog() {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>标题</DialogTitle>
|
|
||||||
<DialogDescription>描述信息</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* 内容 */}
|
|
||||||
<div>对话框内容</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleConfirm}>确认</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Select (选择器)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
|
|
||||||
function MySelect() {
|
|
||||||
const [value, setValue] = useState('')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select value={value} onValueChange={setValue}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="请选择" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="option1">选项1</SelectItem>
|
|
||||||
<SelectItem value="option2">选项2</SelectItem>
|
|
||||||
<SelectItem value="option3">选项3</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tabs (标签页)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
|
|
||||||
function MyTabs() {
|
|
||||||
return (
|
|
||||||
<Tabs defaultValue="tab1">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="tab1">标签1</TabsTrigger>
|
|
||||||
<TabsTrigger value="tab2">标签2</TabsTrigger>
|
|
||||||
<TabsTrigger value="tab3">标签3</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="tab1">
|
|
||||||
<div>标签1的内容</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="tab2">
|
|
||||||
<div>标签2的内容</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="tab3">
|
|
||||||
<div>标签3的内容</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Toast 通知 (Sonner)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
// 成功通知
|
|
||||||
toast.success('操作成功')
|
|
||||||
|
|
||||||
// 错误通知
|
|
||||||
toast.error('操作失败')
|
|
||||||
|
|
||||||
// 加载中
|
|
||||||
const toastId = toast.loading('处理中...')
|
|
||||||
// 完成后更新
|
|
||||||
toast.success('处理完成', { id: toastId })
|
|
||||||
// 或
|
|
||||||
toast.dismiss(toastId)
|
|
||||||
|
|
||||||
// 自定义持续时间
|
|
||||||
toast.success('消息', { duration: 5000 })
|
|
||||||
|
|
||||||
// 带操作按钮
|
|
||||||
toast('确认删除?', {
|
|
||||||
action: {
|
|
||||||
label: '删除',
|
|
||||||
onClick: () => handleDelete(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 代码迁移示例
|
|
||||||
|
|
||||||
### 示例 1: 状态管理迁移
|
|
||||||
|
|
||||||
**旧代码** (手动状态管理):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [providers, setProviders] = useState<Record<string, Provider>>({})
|
|
||||||
const [currentProviderId, setCurrentProviderId] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<Error | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const load = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const data = await window.api.getProviders(appType)
|
|
||||||
const currentId = await window.api.getCurrentProvider(appType)
|
|
||||||
setProviders(data)
|
|
||||||
setCurrentProviderId(currentId)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err as Error)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load()
|
|
||||||
}, [appId])
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码** (React Query):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const { data, isLoading, error } = useProvidersQuery(appId)
|
|
||||||
const providers = data?.providers || {}
|
|
||||||
const currentProviderId = data?.currentProviderId || ''
|
|
||||||
```
|
|
||||||
|
|
||||||
**减少**: 从 20+ 行到 3 行
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 示例 2: 表单验证迁移
|
|
||||||
|
|
||||||
**旧代码** (手动验证):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [nameError, setNameError] = useState('')
|
|
||||||
const [apiKey, setApiKey] = useState('')
|
|
||||||
const [apiKeyError, setApiKeyError] = useState('')
|
|
||||||
|
|
||||||
const validate = () => {
|
|
||||||
let valid = true
|
|
||||||
|
|
||||||
if (!name.trim()) {
|
|
||||||
setNameError('请输入名称')
|
|
||||||
valid = false
|
|
||||||
} else {
|
|
||||||
setNameError('')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!apiKey.trim()) {
|
|
||||||
setApiKeyError('请输入 API Key')
|
|
||||||
valid = false
|
|
||||||
} else if (apiKey.length < 10) {
|
|
||||||
setApiKeyError('API Key 长度不足')
|
|
||||||
valid = false
|
|
||||||
} else {
|
|
||||||
setApiKeyError('')
|
|
||||||
}
|
|
||||||
|
|
||||||
return valid
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (validate()) {
|
|
||||||
// 提交
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form>
|
|
||||||
<input value={name} onChange={e => setName(e.target.value)} />
|
|
||||||
{nameError && <span>{nameError}</span>}
|
|
||||||
|
|
||||||
<input value={apiKey} onChange={e => setApiKey(e.target.value)} />
|
|
||||||
{apiKeyError && <span>{apiKeyError}</span>}
|
|
||||||
|
|
||||||
<button onClick={handleSubmit}>提交</button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码** (react-hook-form + zod):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const schema = z.object({
|
|
||||||
name: z.string().min(1, '请输入名称'),
|
|
||||||
apiKey: z.string().min(10, 'API Key 长度不足'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="apiKey"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button type="submit">提交</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**减少**: 从 40+ 行到 30 行,且更健壮
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 示例 3: 通知系统迁移
|
|
||||||
|
|
||||||
**旧代码** (自定义通知):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [notification, setNotification] = useState<{
|
|
||||||
message: string
|
|
||||||
type: 'success' | 'error'
|
|
||||||
} | null>(null)
|
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
|
||||||
|
|
||||||
const showNotification = (message: string, type: 'success' | 'error') => {
|
|
||||||
setNotification({ message, type })
|
|
||||||
setIsVisible(true)
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsVisible(false)
|
|
||||||
setTimeout(() => setNotification(null), 300)
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{notification && (
|
|
||||||
<div className={`notification ${isVisible ? 'visible' : ''} ${notification.type}`}>
|
|
||||||
{notification.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* 其他内容 */}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码** (Sonner):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
// 在需要的地方直接调用
|
|
||||||
toast.success('操作成功')
|
|
||||||
toast.error('操作失败')
|
|
||||||
|
|
||||||
// 在 main.tsx 中只需添加一次
|
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
|
||||||
|
|
||||||
<Toaster />
|
|
||||||
```
|
|
||||||
|
|
||||||
**减少**: 从 20+ 行到 1 行调用
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 示例 4: 对话框迁移
|
|
||||||
|
|
||||||
**旧代码** (自定义 Modal):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button onClick={() => setIsOpen(true)}>打开</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div className="modal-backdrop" onClick={() => setIsOpen(false)}>
|
|
||||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
|
||||||
<div className="modal-header">
|
|
||||||
<h2>标题</h2>
|
|
||||||
<button onClick={() => setIsOpen(false)}>×</button>
|
|
||||||
</div>
|
|
||||||
<div className="modal-body">
|
|
||||||
{/* 内容 */}
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button onClick={() => setIsOpen(false)}>取消</button>
|
|
||||||
<button onClick={handleConfirm}>确认</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码** (shadcn/ui Dialog):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button onClick={() => setIsOpen(true)}>打开</Button>
|
|
||||||
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>标题</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
{/* 内容 */}
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setIsOpen(false)}>取消</Button>
|
|
||||||
<Button onClick={handleConfirm}>确认</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**优势**:
|
|
||||||
- 无需自定义样式
|
|
||||||
- 内置无障碍支持
|
|
||||||
- 自动管理焦点和 ESC 键
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 示例 5: API 调用迁移
|
|
||||||
|
|
||||||
**旧代码** (window.api):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 添加供应商
|
|
||||||
const handleAdd = async (provider: Provider) => {
|
|
||||||
try {
|
|
||||||
await window.api.addProvider(provider, appType)
|
|
||||||
await loadProviders()
|
|
||||||
showNotification('添加成功', 'success')
|
|
||||||
} catch (error) {
|
|
||||||
showNotification('添加失败', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码** (React Query Mutation):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 在组件中
|
|
||||||
const addMutation = useAddProviderMutation(appId)
|
|
||||||
|
|
||||||
const handleAdd = (provider: Provider) => {
|
|
||||||
addMutation.mutate(provider)
|
|
||||||
// 成功和错误处理已在 mutation 定义中处理
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**优势**:
|
|
||||||
- 自动处理 loading 状态
|
|
||||||
- 统一的错误处理
|
|
||||||
- 自动刷新数据
|
|
||||||
- 更少的样板代码
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### Q: 如何在 mutation 成功后关闭对话框?
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const mutation = useAddProviderMutation(appId)
|
|
||||||
|
|
||||||
const handleSubmit = (data: Provider) => {
|
|
||||||
mutation.mutate(data, {
|
|
||||||
onSuccess: () => {
|
|
||||||
setIsOpen(false) // 关闭对话框
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q: 如何在表单中使用异步验证?
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const schema = z.object({
|
|
||||||
name: z.string().refine(
|
|
||||||
async (name) => {
|
|
||||||
// 检查名称是否已存在
|
|
||||||
const exists = await checkNameExists(name)
|
|
||||||
return !exists
|
|
||||||
},
|
|
||||||
{ message: '名称已存在' }
|
|
||||||
),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q: 如何手动刷新 Query 数据?
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
// 方式1: 使缓存失效,触发重新获取
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['providers', appId] })
|
|
||||||
|
|
||||||
// 方式2: 直接刷新
|
|
||||||
queryClient.refetchQueries({ queryKey: ['providers', appId] })
|
|
||||||
|
|
||||||
// 方式3: 更新缓存数据
|
|
||||||
queryClient.setQueryData(['providers', appId], newData)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q: 如何在组件外部使用 toast?
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 直接导入并使用即可
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
export const someUtil = () => {
|
|
||||||
toast.success('工具函数中的通知')
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 调试技巧
|
|
||||||
|
|
||||||
### React Query DevTools
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 在 main.tsx 中添加
|
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<App />
|
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
|
||||||
</QueryClientProvider>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 查看表单状态
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const form = useForm()
|
|
||||||
|
|
||||||
// 在开发模式下打印表单状态
|
|
||||||
console.log('Form values:', form.watch())
|
|
||||||
console.log('Form errors:', form.formState.errors)
|
|
||||||
console.log('Is valid:', form.formState.isValid)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 性能优化建议
|
|
||||||
|
|
||||||
### 1. 避免不必要的重渲染
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 使用 React.memo
|
|
||||||
export const ProviderCard = React.memo(({ provider, onEdit }: Props) => {
|
|
||||||
// ...
|
|
||||||
})
|
|
||||||
|
|
||||||
// 或使用 useMemo
|
|
||||||
const sortedProviders = useMemo(
|
|
||||||
() => Object.values(providers).sort(...),
|
|
||||||
[providers]
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Query 配置优化
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const { data } = useQuery({
|
|
||||||
queryKey: ['providers', appId],
|
|
||||||
queryFn: fetchProviders,
|
|
||||||
staleTime: 1000 * 60 * 5, // 5分钟内不重新获取
|
|
||||||
gcTime: 1000 * 60 * 10, // 10分钟后清除缓存
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 表单性能优化
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 使用 mode 控制验证时机
|
|
||||||
const form = useForm({
|
|
||||||
mode: 'onBlur', // 失去焦点时验证
|
|
||||||
// mode: 'onChange', // 每次输入都验证(较慢)
|
|
||||||
// mode: 'onSubmit', // 提交时验证(最快)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**提示**: 将此文档保存在浏览器书签或编辑器中,方便随时查阅!
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# 前端测试开发计划
|
|
||||||
|
|
||||||
## 1. 背景与目标
|
|
||||||
- **背景**:v3.5.0 起前端功能快速扩张(供应商管理、MCP、导入导出、端点测速、国际化),缺失系统化测试导致回归风险与人工验证成本攀升。
|
|
||||||
- **目标**:在 3 个迭代内建立覆盖关键业务的自动化测试体系,形成稳定的手动冒烟流程,并将测试执行纳入 CI/CD。
|
|
||||||
|
|
||||||
## 2. 范围与优先级
|
|
||||||
| 范围 | 内容 | 优先级 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 供应商管理 | 列表、排序、预设/自定义表单、切换、复制、删除 | P0 |
|
|
||||||
| 配置导入导出 | JSON 校验、备份、进度反馈、失败回滚 | P0 |
|
|
||||||
| MCP 管理 | 列表、启停、模板、命令校验 | P1 |
|
|
||||||
| 设置面板 | 主题/语言切换、目录设置、关于、更新检查 | P1 |
|
|
||||||
| 端点速度测试 & 使用脚本 | 启动测试、状态指示、脚本保存 | P2 |
|
|
||||||
| 国际化 | 中英切换、缺省文案回退 | P2 |
|
|
||||||
|
|
||||||
## 3. 测试分层策略
|
|
||||||
- **单元测试(Vitest)**:纯函数与 Hook(`useProviderActions`、`useSettingsForm`、`useDragSort`、`useImportExport` 等)验证数据处理、错误分支、排序逻辑。
|
|
||||||
- **组件测试(React Testing Library)**:关键组件(`ProviderList`、`AddProviderDialog`、`SettingsDialog`、`McpPanel`)模拟交互、校验、提示;结合 MSW 模拟 API。
|
|
||||||
- **集成测试(App 级别)**:挂载 `App.tsx`,覆盖应用切换、编辑模式、导入导出回调、语言切换,验证状态同步与 toast 提示。
|
|
||||||
- **端到端测试(Playwright)**:依赖 `pnpm dev:renderer`,串联供应商 CRUD、排序拖拽、MCP 启停、语言切换即时刷新、更新检查跳转。
|
|
||||||
- **手动冒烟**:Tauri 桌面包 + dev server 双通道,验证托盘、系统权限、真实文件写入。
|
|
||||||
|
|
||||||
## 4. 环境与工具
|
|
||||||
- 依赖:Node 18+、pnpm 8+、Vitest、React Testing Library、MSW、Playwright、Testing Library User Event、Playwright Trace Viewer。
|
|
||||||
- 配置要点:
|
|
||||||
- 在 `tsconfig` 中共享别名,Vitest 配合 `vite.config.mts`。
|
|
||||||
- `setupTests.ts` 统一注册 MSW/RTL、自定义 matcher。
|
|
||||||
- Playwright 使用多浏览器矩阵(Chromium 必选,WebKit 可选),并共享 `.env.test`。
|
|
||||||
- Mock `@tauri-apps/api` 与 `providersApi`/`settingsApi`,隔离 Rust 层。
|
|
||||||
|
|
||||||
## 5. 自动化建设里程碑
|
|
||||||
| 周期 | 目标 | 交付 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Sprint 1 | Vitest 基础设施、核心 Hook 单测(P0) | `pnpm test:unit`、覆盖率报告、10+ 用例 |
|
|
||||||
| Sprint 2 | 组件/集成测试、MSW Mock 层 | `pnpm test:component`、App 主流程用例 |
|
|
||||||
| Sprint 3 | Playwright E2E、CI 接入 | `pnpm test:e2e`、CI job、冒烟脚本 |
|
|
||||||
| 持续 | 回归用例补齐、视觉比对探索 | Playwright Trace、截图基线 |
|
|
||||||
|
|
||||||
## 6. 用例规划概览
|
|
||||||
- **供应商管理**:新增(预设+自定义)、编辑校验、复制排序、切换失败回退、删除确认、使用脚本保存。
|
|
||||||
- **导入导出**:成功、重复导入、校验失败、备份失败提示、导入后托盘刷新。
|
|
||||||
- **MCP**:模板应用、协议切换(stdio/http)、命令校验、启停状态持久化。
|
|
||||||
- **设置**:主题/语言即时生效、目录路径更新、更新检查按钮外链、关于信息渲染。
|
|
||||||
- **端点速度测试**:触发测试、loading/成功/失败状态、指示器颜色、测速数据排序。
|
|
||||||
- **国际化**:默认中文、切换英文后主界面/对话框文案变化、缺失 key fallback。
|
|
||||||
|
|
||||||
## 7. 数据与 Mock 策略
|
|
||||||
- 在 `tests/fixtures/` 维护标准供应商、MCP、设置数据集。
|
|
||||||
- 使用 MSW 拦截 `providersApi`、`settingsApi`、`providersApi.onSwitched` 等调用;提供延迟/错误注入接口以覆盖异常分支。
|
|
||||||
- Playwright 端提供临时用户目录(`TMP_CC_SWITCH_HOME`)+ 伪配置文件,以验证真实文件交互路径。
|
|
||||||
|
|
||||||
## 8. 质量门禁与指标
|
|
||||||
- 覆盖率目标:单元 ≥75%,分支 ≥70%,逐步提升至 80%+。
|
|
||||||
- CI 阶段:`pnpm typecheck` → `pnpm format:check` → `pnpm test:unit` → `pnpm test:component` → `pnpm test:e2e`(可在 nightly 执行)。
|
|
||||||
- 缺陷处理:修复前补充最小复现测试;E2E 冒烟必须陪跑重大功能发布。
|
|
||||||
|
|
||||||
## 9. 工作流与职责
|
|
||||||
- **测试负责人**:前端工程师轮值;负责测试计划维护、PR 流水线健康。
|
|
||||||
- **开发者职责**:提交功能需附新增/更新测试、列出手动验证步骤、如涉及 UI 提交截图。
|
|
||||||
- **Code Review 检查**:测试覆盖说明、mock 合理性、易读性。
|
|
||||||
|
|
||||||
## 10. 风险与缓解
|
|
||||||
| 风险 | 影响 | 缓解 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Tauri API Mock 难度高 | 单测无法稳定 | 抽象 API 适配层 + MSW 统一模拟 |
|
|
||||||
| Playwright 运行时间长 | CI 变慢 | 拆分冒烟/完整版,冒烟只跑关键路径 |
|
|
||||||
| 国际化文案频繁变化 | 用例脆弱 | 优先断言 data-testid/结构,文案使用翻译 key |
|
|
||||||
|
|
||||||
## 11. 输出与维护
|
|
||||||
- 文档维护者:前端团队;每个版本更新后检查测试覆盖清单。
|
|
||||||
- 交付物:测试报告(CI artifact)、Playwright Trace、覆盖率摘要。
|
|
||||||
- 复盘:每次发布后召开 30 分钟测试复盘,记录缺陷、补齐用例。
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
# CC Switch 代理功能使用指南
|
|
||||||
|
|
||||||
## 功能介绍
|
|
||||||
|
|
||||||
CC Switch 的代理功能是一个本地 HTTP 代理服务器,可以统一管理 Claude Code、Codex 和 Gemini CLI 的 API 请求。主要特性包括:
|
|
||||||
|
|
||||||
- **统一代理入口** - 所有 CLI 应用的请求通过本地代理转发
|
|
||||||
- **自动故障转移** - 当前供应商故障时自动切换到备用供应商
|
|
||||||
- **按应用控制** - 可独立控制每个应用是否启用代理
|
|
||||||
- **配置保护** - 自动备份原始配置,停止代理时安全恢复
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 1. 启动代理
|
|
||||||
|
|
||||||
在 CC Switch 主界面,点击右上角的 **Proxy** 按钮,可以看到代理控制面板。
|
|
||||||
|
|
||||||
点击 **启动代理** 按钮启动本地代理服务器。代理默认监听 `127.0.0.1:15721`。
|
|
||||||
|
|
||||||
### 2. 启用应用接管
|
|
||||||
|
|
||||||
代理启动后,你可以选择让哪些应用的请求通过代理:
|
|
||||||
|
|
||||||
- **Claude** - 接管 Claude Code 的 API 请求
|
|
||||||
- **Codex** - 接管 Codex CLI 的 API 请求
|
|
||||||
- **Gemini** - 接管 Gemini CLI 的 API 请求
|
|
||||||
|
|
||||||
点击对应应用的开关即可启用/禁用接管。
|
|
||||||
|
|
||||||
> **注意**:启用接管后,CC Switch 会自动修改对应应用的配置文件,将 API 端点指向本地代理。原始配置会被安全备份。
|
|
||||||
|
|
||||||
### 3. 正常使用 CLI
|
|
||||||
|
|
||||||
启用接管后,你可以正常使用各个 CLI 工具。所有请求都会经过 CC Switch 代理转发到配置的供应商。
|
|
||||||
|
|
||||||
### 4. 停止代理
|
|
||||||
|
|
||||||
当你不再需要代理时,点击 **停止代理** 按钮。CC Switch 会:
|
|
||||||
|
|
||||||
1. 安全关闭代理服务器
|
|
||||||
2. 自动恢复所有应用的原始配置
|
|
||||||
3. 清除代理状态
|
|
||||||
|
|
||||||
## 自动故障转移
|
|
||||||
|
|
||||||
### 工作原理
|
|
||||||
|
|
||||||
代理功能内置了智能故障转移机制:
|
|
||||||
|
|
||||||
1. **健康监控** - 实时监控每个供应商的响应状态
|
|
||||||
2. **熔断器** - 连续失败 5 次后触发熔断,暂停使用该供应商
|
|
||||||
3. **自动切换** - 熔断后自动切换到列表中的下一个供应商
|
|
||||||
4. **自动恢复** - 30 秒后尝试恢复熔断的供应商
|
|
||||||
|
|
||||||
### 配置故障转移
|
|
||||||
|
|
||||||
要使用故障转移功能,你需要:
|
|
||||||
|
|
||||||
1. 在对应应用下添加多个供应商(至少 2 个)
|
|
||||||
2. 启动代理并启用接管
|
|
||||||
3. 当主供应商故障时,代理会自动切换到备用供应商
|
|
||||||
|
|
||||||
### 健康状态指示
|
|
||||||
|
|
||||||
在供应商卡片上可以看到健康状态指示:
|
|
||||||
|
|
||||||
- **绿色** - 供应商正常
|
|
||||||
- **红色** - 供应商故障/熔断中
|
|
||||||
- **灰色** - 未使用代理或未检测
|
|
||||||
|
|
||||||
## 按应用接管
|
|
||||||
|
|
||||||
v3.9.0 新增了按应用分粒度控制功能:
|
|
||||||
|
|
||||||
- 你可以只接管 Claude,而让 Codex 使用原始配置
|
|
||||||
- 每个应用的接管状态独立管理
|
|
||||||
- 启用/禁用不会影响其他应用
|
|
||||||
|
|
||||||
### 接管状态检测
|
|
||||||
|
|
||||||
CC Switch 通过检测配置备份来判断接管状态:
|
|
||||||
|
|
||||||
- 存在备份 = 已接管
|
|
||||||
- 无备份 = 未接管
|
|
||||||
|
|
||||||
这确保了即使 CC Switch 异常退出,重新启动后也能正确识别状态。
|
|
||||||
|
|
||||||
## 代理配置
|
|
||||||
|
|
||||||
在代理面板中,你可以配置以下参数:
|
|
||||||
|
|
||||||
| 参数 | 默认值 | 说明 |
|
|
||||||
|------|--------|------|
|
|
||||||
| 监听地址 | 127.0.0.1 | 代理服务器绑定地址 |
|
|
||||||
| 监听端口 | 15721 | 代理服务器端口 |
|
|
||||||
| 最大重试 | 3 | 请求失败时的最大重试次数 |
|
|
||||||
| 请求超时 | 120 秒 | 单个请求的超时时间 |
|
|
||||||
| 启用日志 | 是 | 是否记录请求日志 |
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### Q: 代理启动失败,提示端口被占用?
|
|
||||||
|
|
||||||
A: 默认端口 15721 可能被其他程序占用。你可以:
|
|
||||||
- 关闭占用该端口的程序
|
|
||||||
- 在代理配置中修改端口号
|
|
||||||
|
|
||||||
### Q: 启用接管后 CLI 无法使用?
|
|
||||||
|
|
||||||
A: 请检查:
|
|
||||||
1. 代理服务器是否正常运行(查看代理面板状态)
|
|
||||||
2. 供应商配置是否正确(API Key 等)
|
|
||||||
3. 网络连接是否正常
|
|
||||||
|
|
||||||
### Q: 如何恢复原始配置?
|
|
||||||
|
|
||||||
A: 点击 **停止代理** 按钮,CC Switch 会自动恢复所有应用的原始配置。
|
|
||||||
|
|
||||||
如果 CC Switch 异常退出,重新启动后会检测到之前的备份,你可以:
|
|
||||||
- 点击停止代理来恢复配置
|
|
||||||
- 或继续使用代理功能
|
|
||||||
|
|
||||||
### Q: 故障转移没有生效?
|
|
||||||
|
|
||||||
A: 请确保:
|
|
||||||
1. 配置了至少 2 个供应商
|
|
||||||
2. 代理已启动且接管已启用
|
|
||||||
3. 故障转移只在代理模式下工作
|
|
||||||
|
|
||||||
### Q: 代理会影响性能吗?
|
|
||||||
|
|
||||||
A: 本地代理的延迟开销非常小(通常 < 1ms)。但如果启用了请求日志,在高频请求场景下可能会有少量性能影响。
|
|
||||||
|
|
||||||
## 技术细节
|
|
||||||
|
|
||||||
### 配置文件位置
|
|
||||||
|
|
||||||
启用接管后,CC Switch 会修改以下配置文件:
|
|
||||||
|
|
||||||
| 应用 | 配置文件 | 修改内容 |
|
|
||||||
|------|----------|----------|
|
|
||||||
| Claude | `~/.claude/settings.json` | `apiBaseUrl` 指向代理 |
|
|
||||||
| Codex | `~/.codex/config.toml` | `[api] baseUrl` 指向代理 |
|
|
||||||
| Gemini | `~/.gemini/.env` | `GEMINI_BASE_URL` 指向代理 |
|
|
||||||
|
|
||||||
原始配置备份在 CC Switch 数据库中,停止代理时自动恢复。
|
|
||||||
|
|
||||||
### 代理模式
|
|
||||||
|
|
||||||
代理服务器运行在接管模式下,会:
|
|
||||||
|
|
||||||
1. 接收来自 CLI 的 HTTPS 请求
|
|
||||||
2. 根据当前供应商配置转发到真实 API 端点
|
|
||||||
3. 返回响应给 CLI
|
|
||||||
4. 记录请求日志和健康状态
|
|
||||||
|
|
||||||
### 数据库表
|
|
||||||
|
|
||||||
代理功能使用以下数据库表:
|
|
||||||
|
|
||||||
- `proxy_config` - 代理配置
|
|
||||||
- `provider_health` - 供应商健康状态
|
|
||||||
- `proxy_request_logs` - 请求日志
|
|
||||||
- `circuit_breaker_config` - 熔断器配置
|
|
||||||
- `proxy_live_backup` - Live 配置备份
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
## Major architecture refactoring with enhanced config sync and data protection
|
|
||||||
|
|
||||||
**[中文更新说明 Chinese Documentation →](https://github.com/farion1231/cc-switch/blob/main/docs/release-note-v3.6.0-zh.md)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What's New
|
|
||||||
|
|
||||||
### Edit Mode & Provider Management
|
|
||||||
|
|
||||||
- **Provider Duplication** - Quickly duplicate existing provider configurations to create variants with one click
|
|
||||||
- **Manual Sorting** - Drag and drop to reorder providers, with visual push effect animations. Thanks to @ZyphrZero
|
|
||||||
- **Edit Mode Toggle** - Show/hide drag handles to optimize editing experience
|
|
||||||
|
|
||||||
### Custom Endpoint Management
|
|
||||||
|
|
||||||
- **Multi-Endpoint Configuration** - Support for aggregator providers with multiple API endpoints
|
|
||||||
- **Endpoint Input Visibility** - Shows endpoint field for all non-official providers automatically
|
|
||||||
|
|
||||||
### Usage Query Enhancements
|
|
||||||
|
|
||||||
- **Auto-Refresh Interval** - Configure periodic automatic usage queries with customizable intervals
|
|
||||||
- **Test Script API** - Validate JavaScript usage query scripts before execution
|
|
||||||
- **Enhanced Templates** - Custom blank templates with access token and user ID parameter support
|
|
||||||
Thanks to @Sirhexs
|
|
||||||
|
|
||||||
### Custom Configuration Directory (Cloud Sync)
|
|
||||||
|
|
||||||
- **Customizable Storage Location** - Customize CC Switch's configuration storage directory
|
|
||||||
- **Cloud Sync Support** - Point to cloud sync folders (Dropbox, OneDrive, iCloud Drive, etc.) to enable automatic config synchronization across devices
|
|
||||||
- **Independent Management** - Managed via Tauri Store for better isolation and reliability
|
|
||||||
Thanks to @ZyphrZero
|
|
||||||
|
|
||||||
### Configuration Directory Switching (WSL Support)
|
|
||||||
|
|
||||||
- **Auto-Sync on Directory Change** - When switching Claude/Codex config directories (e.g., WSL environment), automatically sync current provider to the new directory without manual operation
|
|
||||||
- **Post-Change Sync Utility** - Unified `postChangeSync.ts` utility for graceful error handling without blocking main flow
|
|
||||||
- **Import Config Auto-Sync** - Automatically sync after config import to ensure immediate effectiveness
|
|
||||||
- **Smart Conflict Resolution** - Distinguishes "fully successful" and "partially successful" states for precise user feedback
|
|
||||||
|
|
||||||
### Configuration Editor Improvements
|
|
||||||
|
|
||||||
- **JSON Format Button** - One-click JSON formatting in configuration editors
|
|
||||||
- **Real-Time TOML Validation** - Live syntax validation for Codex configuration with error highlighting
|
|
||||||
|
|
||||||
### Load Live Config When Editing
|
|
||||||
|
|
||||||
- **Protect Manual Modifications** - When editing the currently active provider, prioritize displaying the actual effective configuration from live files
|
|
||||||
- **Dual-Source Strategy** - Automatically loads from live config for active provider, SSOT for inactive ones
|
|
||||||
|
|
||||||
### Claude Configuration Data Structure Enhancements
|
|
||||||
|
|
||||||
- **Granular Model Configuration** - Migrated from dual-key to quad-key system for better model tier differentiation
|
|
||||||
- New fields: `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_MODEL`
|
|
||||||
- Replaces legacy `ANTHROPIC_SMALL_FAST_MODEL` with automatic migration
|
|
||||||
- Backend normalizes old configs on first read/write with smart fallback chain
|
|
||||||
- UI expanded from 2 to 4 model input fields with intelligent defaults
|
|
||||||
- **ANTHROPIC_API_KEY Support** - Providers can now use `ANTHROPIC_API_KEY` field in addition to `ANTHROPIC_AUTH_TOKEN`
|
|
||||||
- **Template Variable System** - Support for dynamic configuration replacement (e.g., KAT-Coder's `ENDPOINT_ID` parameter)
|
|
||||||
- **Endpoint Candidates** - Predefined endpoint list for speed testing and endpoint management
|
|
||||||
- **Visual Theme Configuration** - Custom icons and colors for provider cards
|
|
||||||
|
|
||||||
### Updated Provider Models
|
|
||||||
|
|
||||||
- **Kimi k2** - Updated to latest `kimi-k2-thinking` model
|
|
||||||
|
|
||||||
### New Provider Presets
|
|
||||||
|
|
||||||
Added 5 new provider presets:
|
|
||||||
|
|
||||||
- **DMXAPI** - Multi-model aggregation service
|
|
||||||
- **Azure Codex** - Microsoft Azure OpenAI endpoint
|
|
||||||
- **AnyRouter** - None-profit routing service
|
|
||||||
- **AiHubMix** - Multi-model aggregation service
|
|
||||||
- **MiniMax** - Open source AI model provider
|
|
||||||
|
|
||||||
### Partner Promotion Mechanism
|
|
||||||
|
|
||||||
- Support for ecosystem partner promotion (Zhipu GLM Z.ai)
|
|
||||||
- Sponsored banner integration in README
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Improvements
|
|
||||||
|
|
||||||
### Configuration & Sync
|
|
||||||
|
|
||||||
- **Unified Error Handling** - AppError with internationalized error messages throughout backend
|
|
||||||
- **Fixed apiKeyUrl Priority** - Correct priority order for API key URL resolution
|
|
||||||
- **Fixed MCP Sync Issues** - Resolved sync-to-other-side functionality failures
|
|
||||||
- **Import Config Sync** - Fixed sync issues after configuration import
|
|
||||||
- **Config Error Handling** - Force exit on config error to prevent silent fallback and data loss
|
|
||||||
|
|
||||||
### UI/UX Enhancements
|
|
||||||
|
|
||||||
- **Unique Provider Icons** - Each provider card now has unique icons and color identification
|
|
||||||
- **Unified Border System** - Consistent border design across all components
|
|
||||||
- **Drag Interaction** - Push effect animation and improved drag handle icons
|
|
||||||
- **Enhanced Visual Feedback** - Better current provider visual indication
|
|
||||||
- **Dialog Standardization** - Unified dialog sizes and layout consistency
|
|
||||||
- **Form Improvements** - Optimized model placeholders, simplified provider hints, category-specific hints
|
|
||||||
- **Usage Display Inline** - Usage info moved next to enable button for better space utilization
|
|
||||||
|
|
||||||
### Complete Internationalization
|
|
||||||
|
|
||||||
- **Error Messages i18n** - All backend error messages support Chinese/English
|
|
||||||
- **Tray Menu i18n** - System tray menu fully internationalized
|
|
||||||
- **UI Components i18n** - 100% coverage across all user-facing components
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bug Fixes
|
|
||||||
|
|
||||||
### Configuration Management
|
|
||||||
|
|
||||||
- Fixed `apiKeyUrl` priority issue
|
|
||||||
- Fixed MCP sync-to-other-side functionality failure
|
|
||||||
- Fixed sync issues after config import
|
|
||||||
- Fixed Codex API Key auto-sync
|
|
||||||
- Fixed endpoint speed test functionality
|
|
||||||
- Fixed provider duplicate insertion position (now inserts next to original)
|
|
||||||
- Fixed custom endpoint preservation in edit mode
|
|
||||||
- Prevent silent fallback and data loss on config error
|
|
||||||
|
|
||||||
### Usage Query
|
|
||||||
|
|
||||||
- Fixed auto-query interval timing issue
|
|
||||||
- Ensured refresh button shows loading animation on click
|
|
||||||
|
|
||||||
### UI Issues
|
|
||||||
|
|
||||||
- Fixed name collision error (`get_init_error` command)
|
|
||||||
- Fixed language setting rollback after successful save
|
|
||||||
- Fixed language switch state reset (dependency cycle)
|
|
||||||
- Fixed edit mode button alignment
|
|
||||||
|
|
||||||
### Startup Issues
|
|
||||||
|
|
||||||
- Force exit on config error (no silent fallback)
|
|
||||||
- Eliminated code duplication causing initialization errors
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Refactoring
|
|
||||||
|
|
||||||
### Backend (Rust) - 5 Phase Refactoring
|
|
||||||
|
|
||||||
1. **Phase 1**: Unified error handling (`AppError` + i18n error messages)
|
|
||||||
2. **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
|
|
||||||
3. **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)
|
|
||||||
4. **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)
|
|
||||||
5. **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
|
|
||||||
|
|
||||||
### Frontend (React + TypeScript) - 4 Stage Refactoring
|
|
||||||
|
|
||||||
1. **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)
|
|
||||||
2. **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)
|
|
||||||
3. **Stage 3**: Component splitting and business logic extraction
|
|
||||||
4. **Stage 4**: Code cleanup and formatting unification
|
|
||||||
|
|
||||||
### Testing System
|
|
||||||
|
|
||||||
- **Hooks Unit Tests** - 100% coverage for all custom hooks
|
|
||||||
- **Integration Tests** - Coverage for key processes (App, SettingsDialog, MCP Panel)
|
|
||||||
- **MSW Mocking** - Backend API mocking to ensure test independence
|
|
||||||
- **Test Infrastructure** - vitest + MSW + @testing-library/react
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
- **Unified Parameter Format** - All Tauri commands migrated to camelCase (Tauri 2 specification)
|
|
||||||
- **Semantic Clarity** - `AppType` renamed to `AppId` for better semantics
|
|
||||||
- **Centralized Parsing** - Unified `app` parameter parsing with `FromStr` trait
|
|
||||||
- **DRY Violations Cleanup** - Eliminated code duplication throughout codebase
|
|
||||||
- **Dead Code Removal** - Removed unused `missing_param` helper, deprecated `tauri-api.ts`, redundant `KimiModelSelector`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Internal Optimizations (User Transparent)
|
|
||||||
|
|
||||||
### Removed Legacy Migration Logic
|
|
||||||
|
|
||||||
v3.6.0 removed v1 config auto-migration and copy file scanning logic:
|
|
||||||
|
|
||||||
- **Impact**: Improved startup performance, cleaner codebase
|
|
||||||
- **Compatibility**: v2 format configs fully compatible, no action required
|
|
||||||
- **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6.0
|
|
||||||
|
|
||||||
### Command Parameter Standardization
|
|
||||||
|
|
||||||
Backend unified to use `app` parameter (values: `claude` or `codex`):
|
|
||||||
|
|
||||||
- **Impact**: More standardized code, friendlier error prompts
|
|
||||||
- **Compatibility**: Frontend fully adapted, users don't need to care about this change
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Updated to **Tauri 2.8.x**
|
|
||||||
- Updated to **TailwindCSS 4.x**
|
|
||||||
- Updated to **TanStack Query v5.90.x**
|
|
||||||
- Maintained **React 18.2.x** and **TypeScript 5.3.x**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
|
|
||||||
**Via Homebrew (Recommended):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
brew install --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manual Download:**
|
|
||||||
|
|
||||||
- Download `CC-Switch-v3.6.0-macOS.zip` from [Assets](#assets) below
|
|
||||||
|
|
||||||
> **Note**: Due to lack of Apple Developer account, you may see "unidentified developer" warning. Go to System Settings → Privacy & Security → Click "Open Anyway"
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
- **Installer**: `CC-Switch-v3.6.0-Windows.msi`
|
|
||||||
- **Portable**: `CC-Switch-v3.6.0-Windows-Portable.zip`
|
|
||||||
|
|
||||||
### Linux
|
|
||||||
|
|
||||||
- **AppImage**: `CC-Switch-v3.6.0-Linux.AppImage`
|
|
||||||
- **Debian**: `CC-Switch-v3.6.0-Linux.deb`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [中文文档 (Chinese)](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)
|
|
||||||
- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)
|
|
||||||
- [完整更新日志 (Full Changelog)](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acknowledgments
|
|
||||||
|
|
||||||
Special thanks to **Zhipu AI** for sponsoring this project with their GLM CODING PLAN!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Full Changelog**: https://github.com/farion1231/cc-switch/compare/v3.5.1...v3.6.0
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
# CC Switch v3.6.0
|
|
||||||
|
|
||||||
> 全栈架构重构,增强配置同步与数据保护
|
|
||||||
|
|
||||||
**[English Version →](../release-note-v3.6.0.md)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 新增功能
|
|
||||||
|
|
||||||
### 编辑模式与供应商管理
|
|
||||||
|
|
||||||
- **供应商复制功能** - 一键快速复制现有供应商配置,轻松创建变体配置
|
|
||||||
- **手动排序功能** - 通过拖拽对供应商进行重新排序,带有视觉推送效果动画
|
|
||||||
- **编辑模式切换** - 显示/隐藏拖拽手柄,优化编辑体验
|
|
||||||
|
|
||||||
### 自定义端点管理
|
|
||||||
|
|
||||||
- **多端点配置** - 支持聚合类供应商的多 API 端点配置
|
|
||||||
- **端点输入可见性** - 为所有非官方供应商自动显示端点字段
|
|
||||||
|
|
||||||
### 自定义配置目录(云同步)
|
|
||||||
|
|
||||||
- **自定义存储位置** - 自定义 CC Switch 的配置存储目录
|
|
||||||
- **云同步支持** - 指定到云同步文件夹(Dropbox、OneDrive、iCloud Drive、坚果云等)即可实现跨设备配置自动同步
|
|
||||||
- **独立管理** - 通过 Tauri Store 管理,更好的隔离性和可靠性
|
|
||||||
|
|
||||||
### 使用量查询增强
|
|
||||||
|
|
||||||
- **自动刷新间隔** - 配置定时自动使用量查询,支持自定义间隔时间
|
|
||||||
- **测试脚本 API** - 在执行前验证 JavaScript 使用量查询脚本
|
|
||||||
- **增强模板系统** - 自定义空白模板,支持 access token 和 user ID 参数
|
|
||||||
|
|
||||||
### 配置目录切换(WSL 支持)
|
|
||||||
|
|
||||||
- **目录变更自动同步** - 切换 Claude/Codex 配置目录(如 WSL 环境)时,自动同步当前供应商到新目录,无需手动操作
|
|
||||||
- **后置同步工具** - 统一的 `postChangeSync.ts` 工具,优雅处理错误而不阻塞主流程
|
|
||||||
- **导入配置自动同步** - 配置导入后自动同步,确保立即生效
|
|
||||||
- **智能冲突解决** - 区分"完全成功"和"部分成功"状态,提供精确的用户反馈
|
|
||||||
|
|
||||||
### 配置编辑器改进
|
|
||||||
|
|
||||||
- **JSON 格式化按钮** - 配置编辑器中一键 JSON 格式化
|
|
||||||
- **实时 TOML 验证** - Codex 配置的实时语法验证,带有错误高亮
|
|
||||||
|
|
||||||
### 编辑时加载 Live 配置
|
|
||||||
|
|
||||||
- **保护手动修改** - 编辑当前激活的供应商时,优先显示来自 live 文件的实际生效配置
|
|
||||||
- **双源策略** - 活动供应商自动从 live 配置加载,非活动供应商从 SSOT 加载
|
|
||||||
|
|
||||||
### Claude 配置数据结构增强
|
|
||||||
|
|
||||||
- **细粒度模型配置** - 从双键系统升级到四键系统,以匹配官方最新数据结构
|
|
||||||
- 新增字段:`ANTHROPIC_DEFAULT_HAIKU_MODEL`、`ANTHROPIC_DEFAULT_SONNET_MODEL`、`ANTHROPIC_DEFAULT_OPUS_MODEL`、`ANTHROPIC_MODEL`
|
|
||||||
- 替换旧版 `ANTHROPIC_SMALL_FAST_MODEL`,支持自动迁移
|
|
||||||
- 后端在首次读写时自动规范化旧配置,带有智能回退链
|
|
||||||
- UI 从 2 个模型输入字段扩展到 4 个,具有智能默认值
|
|
||||||
- **ANTHROPIC_API_KEY 支持** - 供应商现可使用 `ANTHROPIC_API_KEY` 字段(除 `ANTHROPIC_AUTH_TOKEN` 外)
|
|
||||||
- **模板变量系统** - 支持动态配置替换(如 KAT-Coder 的 `ENDPOINT_ID` 参数)
|
|
||||||
- **端点候选列表** - 预定义端点列表,用于速度测试和端点管理
|
|
||||||
- **视觉主题配置** - 供应商卡片自定义图标和颜色
|
|
||||||
|
|
||||||
### 供应商模型更新
|
|
||||||
|
|
||||||
- **Kimi k2** - 更新到最新的 `kimi-k2-thinking` 模型
|
|
||||||
|
|
||||||
### 新增供应商预设
|
|
||||||
|
|
||||||
新增 5 个供应商预设:
|
|
||||||
|
|
||||||
- **DMXAPI** - 多模型聚合服务
|
|
||||||
- **Azure Codex** - 微软 Azure OpenAI 端点
|
|
||||||
- **AnyRouter** - API 路由服务
|
|
||||||
- **AiHubMix** - AI 模型集合
|
|
||||||
- **MiniMax** - 国产 AI 模型提供商
|
|
||||||
|
|
||||||
### 合作伙伴推广机制
|
|
||||||
|
|
||||||
- 支持生态合作伙伴推广(智谱 GLM Z.ai)
|
|
||||||
- README 中集成赞助商横幅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 改进优化
|
|
||||||
|
|
||||||
### 配置与同步
|
|
||||||
|
|
||||||
- **统一错误处理** - 后端全面使用 AppError 与国际化错误消息
|
|
||||||
- **修复 apiKeyUrl 优先级** - 修正 API key URL 解析的优先级顺序
|
|
||||||
- **修复 MCP 同步问题** - 解决同步到另一端功能失效的问题
|
|
||||||
- **导入配置同步** - 修复配置导入后的同步问题
|
|
||||||
- **配置错误处理** - 配置错误时强制退出,防止静默回退和数据丢失
|
|
||||||
|
|
||||||
### UI/UX 增强
|
|
||||||
|
|
||||||
- **独特的供应商图标** - 每个供应商卡片现在都有独特的图标和颜色识别
|
|
||||||
- **统一边框系统** - 所有组件采用一致的边框设计
|
|
||||||
- **拖拽交互** - 推送效果动画和改进的拖拽手柄图标
|
|
||||||
- **增强视觉反馈** - 更好的当前供应商视觉指示
|
|
||||||
- **对话框标准化** - 统一的对话框尺寸和布局一致性
|
|
||||||
- **表单改进** - 优化模型占位符,简化供应商提示,分类特定提示
|
|
||||||
- **使用量内联显示** - 使用量信息移至启用按钮旁边,更好地利用空间
|
|
||||||
|
|
||||||
### 完整国际化
|
|
||||||
|
|
||||||
- **错误消息国际化** - 所有后端错误消息支持中英文
|
|
||||||
- **托盘菜单国际化** - 系统托盘菜单完全国际化
|
|
||||||
- **UI 组件国际化** - 所有面向用户的组件 100% 覆盖
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bug 修复
|
|
||||||
|
|
||||||
### 配置管理
|
|
||||||
|
|
||||||
- 修复 `apiKeyUrl` 优先级问题
|
|
||||||
- 修复 MCP 同步到另一端功能失效
|
|
||||||
- 修复配置导入后的同步问题
|
|
||||||
- 修复 Codex API Key 自动同步
|
|
||||||
- 修复端点速度测试功能
|
|
||||||
- 修复供应商复制插入位置(现在插入到原供应商旁边)
|
|
||||||
- 修复编辑模式下自定义端点保留问题
|
|
||||||
- 防止配置错误时的静默回退和数据丢失
|
|
||||||
|
|
||||||
### 使用量查询
|
|
||||||
|
|
||||||
- 修复自动查询间隔时间问题
|
|
||||||
- 确保刷新按钮点击时显示加载动画
|
|
||||||
|
|
||||||
### UI 问题
|
|
||||||
|
|
||||||
- 修复名称冲突错误(`get_init_error` 命令)
|
|
||||||
- 修复保存成功后语言设置回滚
|
|
||||||
- 修复语言切换状态重置(依赖循环)
|
|
||||||
- 修复编辑模式按钮对齐
|
|
||||||
|
|
||||||
### 启动问题
|
|
||||||
|
|
||||||
- 配置错误时强制退出(不再静默回退)
|
|
||||||
- 消除导致初始化错误的代码重复
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 架构重构
|
|
||||||
|
|
||||||
### 后端(Rust)- 5 阶段重构
|
|
||||||
|
|
||||||
1. **阶段 1**:统一错误处理(`AppError` + 国际化错误消息)
|
|
||||||
2. **阶段 2**:命令层按领域拆分(`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
|
|
||||||
3. **阶段 3**:集成测试和事务机制(配置快照 + 失败回滚)
|
|
||||||
4. **阶段 4**:提取 Service 层(`services/{provider,mcp,config,speedtest}.rs`)
|
|
||||||
5. **阶段 5**:并发优化(`RwLock` 替代 `Mutex`,作用域 guard 避免死锁)
|
|
||||||
|
|
||||||
### 前端(React + TypeScript)- 4 阶段重构
|
|
||||||
|
|
||||||
1. **阶段 1**:测试基础设施(vitest + MSW + @testing-library/react)
|
|
||||||
2. **阶段 2**:提取自定义 hooks(`useProviderActions`、`useMcpActions`、`useSettings`、`useImportExport` 等)
|
|
||||||
3. **阶段 3**:组件拆分和业务逻辑提取
|
|
||||||
4. **阶段 4**:代码清理和格式化统一
|
|
||||||
|
|
||||||
### 测试体系
|
|
||||||
|
|
||||||
- **Hooks 单元测试** - 所有自定义 hooks 100% 覆盖
|
|
||||||
- **集成测试** - 关键流程覆盖(App、SettingsDialog、MCP 面板)
|
|
||||||
- **MSW 模拟** - 后端 API 模拟确保测试独立性
|
|
||||||
- **测试基础设施** - vitest + MSW + @testing-library/react
|
|
||||||
|
|
||||||
### 代码质量
|
|
||||||
|
|
||||||
- **统一参数格式** - 所有 Tauri 命令迁移到 camelCase(Tauri 2 规范)
|
|
||||||
- **语义清晰** - `AppType` 重命名为 `AppId` 以获得更好的语义
|
|
||||||
- **集中解析** - 使用 `FromStr` trait 统一 `app` 参数解析
|
|
||||||
- **DRY 违规清理** - 消除整个代码库中的代码重复
|
|
||||||
- **死代码移除** - 移除未使用的 `missing_param` 辅助函数、废弃的 `tauri-api.ts`、冗余的 `KimiModelSelector`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 内部优化(用户无感知)
|
|
||||||
|
|
||||||
### 移除遗留迁移逻辑
|
|
||||||
|
|
||||||
v3.6.0 移除了 v1 配置自动迁移和副本文件扫描逻辑:
|
|
||||||
|
|
||||||
- **影响**:提升启动性能,代码更简洁
|
|
||||||
- **兼容性**:v2 格式配置完全兼容,无需任何操作
|
|
||||||
- **注意**:从 v3.1.0 或更早版本升级的用户,请先升级到 v3.2.x 或 v3.5.x 进行一次性迁移,然后再升级到 v3.6.0
|
|
||||||
|
|
||||||
### 命令参数标准化
|
|
||||||
|
|
||||||
后端统一使用 `app` 参数(取值:`claude` 或 `codex`):
|
|
||||||
|
|
||||||
- **影响**:代码更规范,错误提示更友好
|
|
||||||
- **兼容性**:前端已完全适配,用户无需关心此变更
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 依赖更新
|
|
||||||
|
|
||||||
- 更新到 **Tauri 2.8.x**
|
|
||||||
- 更新到 **TailwindCSS 4.x**
|
|
||||||
- 更新到 **TanStack Query v5.90.x**
|
|
||||||
- 保持 **React 18.2.x** 和 **TypeScript 5.3.x**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 安装方式
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
|
|
||||||
**通过 Homebrew 安装(推荐):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
brew install --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
**手动下载:**
|
|
||||||
|
|
||||||
- 从下方 [Assets](#assets) 下载 `CC-Switch-v3.6.0-macOS.zip`
|
|
||||||
|
|
||||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告。请前往"系统设置" → "隐私与安全性" → 点击"仍要打开"
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
- **安装包**:`CC-Switch-v3.6.0-Windows.msi`
|
|
||||||
- **便携版**:`CC-Switch-v3.6.0-Windows-Portable.zip`
|
|
||||||
|
|
||||||
### Linux
|
|
||||||
|
|
||||||
- **AppImage**:`CC-Switch-v3.6.0-Linux.AppImage`
|
|
||||||
- **Debian**:`CC-Switch-v3.6.0-Linux.deb`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 文档
|
|
||||||
|
|
||||||
- [中文文档](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)
|
|
||||||
- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)
|
|
||||||
- [完整更新日志](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 致谢
|
|
||||||
|
|
||||||
特别感谢**智谱 AI** 通过 GLM CODING PLAN 赞助本项目!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**完整变更记录**: https://github.com/farion1231/cc-switch/compare/v3.5.1...v3.6.0
|
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
# CC Switch v3.6.1
|
|
||||||
|
|
||||||
> Stability improvements and user experience optimization (based on v3.6.0)
|
|
||||||
|
|
||||||
**[中文更新说明 Chinese Documentation →](https://github.com/farion1231/cc-switch/blob/main/docs/release-note-v3.6.1-zh.md)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 What's New in v3.6.1 (2025-11-10)
|
|
||||||
|
|
||||||
This release focuses on **user experience optimization** and **configuration parsing robustness**, fixing several critical bugs and enhancing the usage query system.
|
|
||||||
|
|
||||||
### ✨ New Features
|
|
||||||
|
|
||||||
#### Usage Query System Enhancements
|
|
||||||
|
|
||||||
- **Credential Decoupling** - Usage queries can now use independent API Key and Base URL, no longer dependent on provider configuration
|
|
||||||
- Support for different query endpoints and authentication methods
|
|
||||||
- Automatically displays credential input fields based on template type
|
|
||||||
- General template: API Key + Base URL
|
|
||||||
- NewAPI template: Base URL + Access Token + User ID
|
|
||||||
- Custom template: Fully customizable
|
|
||||||
- **UI Component Upgrade** - Replaced native checkbox with shadcn/ui Switch component for modern experience
|
|
||||||
- **Form Unification** - Unified use of shadcn/ui Input components, consistent styling with the application
|
|
||||||
- **Password Visibility Toggle** - Added show/hide password functionality (API Key, Access Token)
|
|
||||||
|
|
||||||
#### Form Validation Infrastructure
|
|
||||||
|
|
||||||
- **Common Schema Library** - New JSON/TOML generic validators to reduce code duplication
|
|
||||||
- `jsonConfigSchema`: Generic JSON object validator
|
|
||||||
- `tomlConfigSchema`: Generic TOML format validator
|
|
||||||
- `mcpJsonConfigSchema`: MCP-specific JSON validator
|
|
||||||
- **MCP Conditional Field Validation** - Strict type checking
|
|
||||||
- stdio type requires `command` field
|
|
||||||
- http type requires `url` field
|
|
||||||
|
|
||||||
#### Partner Integration
|
|
||||||
|
|
||||||
- **PackyCode** - New official partner
|
|
||||||
- Added to Claude and Codex provider presets
|
|
||||||
- 10% discount promotion support
|
|
||||||
- New logo and partner identification
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔧 Improvements
|
|
||||||
|
|
||||||
#### User Experience
|
|
||||||
|
|
||||||
- **Drag Sort Sync** - Tray menu order now syncs with drag-and-drop sorting in real-time
|
|
||||||
- **Enhanced Error Notifications** - Provider switch failures now display copyable error messages
|
|
||||||
- **Removed Misleading Placeholders** - Deleted example text from model input fields to avoid user confusion
|
|
||||||
- **Auto-fill Base URL** - All non-official provider categories automatically populate the Base URL input field
|
|
||||||
|
|
||||||
#### Configuration Parsing
|
|
||||||
|
|
||||||
- **CJK Quote Normalization** - Automatically handles IME-input fullwidth quotes to prevent TOML parsing errors
|
|
||||||
- Supports automatic conversion of Chinese quotes (" " ' ') to ASCII quotes
|
|
||||||
- Applied in TOML input handlers
|
|
||||||
- Disabled browser auto-correction in Textarea component
|
|
||||||
- **Preserve Custom Fields** - Editing Codex MCP TOML configuration now preserves unknown fields
|
|
||||||
- Supports extension fields like timeout_ms, retry_count
|
|
||||||
- Forward compatibility with future MCP protocol extensions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
|
||||||
|
|
||||||
#### Critical Fixes
|
|
||||||
|
|
||||||
- **Fixed usage script panel white screen crash** - FormLabel component missing FormField context caused entire app to crash
|
|
||||||
- Replaced with standalone Label component
|
|
||||||
- Root cause: FormLabel internally calls useFormField() hook which requires FormFieldContext
|
|
||||||
- **Fixed CJK input quote parsing failure** - IME-input fullwidth quotes caused TOML parsing errors
|
|
||||||
- Added textNormalization utility function
|
|
||||||
- Automatically normalizes quotes before parsing
|
|
||||||
- **Fixed drag sort tray desync** (#179) - Tray menu order not updated after drag-and-drop sorting
|
|
||||||
- Automatically calls updateTrayMenu after sorting completes
|
|
||||||
- Ensures UI and tray menu stay consistent
|
|
||||||
- **Fixed MCP custom field loss** - Custom fields silently dropped when editing Codex MCP configuration
|
|
||||||
- Uses spread operator to retain all fields
|
|
||||||
- Preserves unknown fields in normalizeServerConfig
|
|
||||||
|
|
||||||
#### Stability Improvements
|
|
||||||
|
|
||||||
- **Error Isolation** - Tray menu update failures no longer affect main operations
|
|
||||||
- Decoupled tray update errors from main operations
|
|
||||||
- Provides warning when main operation succeeds but tray update fails
|
|
||||||
- **Safe Pattern Matching** - Replaced `unwrap()` with safe pattern matching
|
|
||||||
- Avoids panic-induced app crashes
|
|
||||||
- Tray menu event handling uses match patterns
|
|
||||||
- **Import Config Classification** - Importing from default config now automatically sets category to `custom`
|
|
||||||
- Avoids imported configs being mistaken for official presets
|
|
||||||
- Provides clearer configuration source identification
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📊 Technical Statistics
|
|
||||||
|
|
||||||
```
|
|
||||||
Commits: 17 commits
|
|
||||||
Code Changes: 31 files
|
|
||||||
- Additions: 1,163 lines
|
|
||||||
- Deletions: 811 lines
|
|
||||||
- Net Growth: +352 lines
|
|
||||||
Contributors: Jason (16), ZyphrZero (1)
|
|
||||||
```
|
|
||||||
|
|
||||||
**By Module**:
|
|
||||||
- UI/User Interface: 3 commits
|
|
||||||
- Usage Query System: 3 commits
|
|
||||||
- Configuration Parsing: 2 commits
|
|
||||||
- Form Validation: 1 commit
|
|
||||||
- Other Improvements: 8 commits
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📥 Installation
|
|
||||||
|
|
||||||
#### macOS
|
|
||||||
|
|
||||||
**Via Homebrew (Recommended):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
brew install --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manual Download:**
|
|
||||||
|
|
||||||
- Download `CC-Switch-v3.6.1-macOS.zip` from [Assets](#assets) below
|
|
||||||
|
|
||||||
> **Note**: Due to lack of Apple Developer account, you may see "unidentified developer" warning. Go to System Settings → Privacy & Security → Click "Open Anyway"
|
|
||||||
|
|
||||||
#### Windows
|
|
||||||
|
|
||||||
- **Installer**: `CC-Switch-v3.6.1-Windows.msi`
|
|
||||||
- **Portable**: `CC-Switch-v3.6.1-Windows-Portable.zip`
|
|
||||||
|
|
||||||
#### Linux
|
|
||||||
|
|
||||||
- **AppImage**: `CC-Switch-v3.6.1-Linux.AppImage`
|
|
||||||
- **Debian**: `CC-Switch-v3.6.1-Linux.deb`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📚 Documentation
|
|
||||||
|
|
||||||
- [中文文档 (Chinese)](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)
|
|
||||||
- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)
|
|
||||||
- [完整更新日志 (Full Changelog)](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🙏 Acknowledgments
|
|
||||||
|
|
||||||
Special thanks to:
|
|
||||||
- **Zhipu AI** - For sponsoring this project with GLM CODING PLAN
|
|
||||||
- **PackyCode** - New official partner
|
|
||||||
- **ZyphrZero** - For contributing tray menu sync fix (#179)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Full Changelog**: https://github.com/farion1231/cc-switch/compare/v3.6.0...v3.6.1
|
|
||||||
|
|
||||||
---
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📜 v3.6.0 Complete Feature Review
|
|
||||||
|
|
||||||
> Content below is from v3.6.0 (2025-11-07), helping you understand the complete feature set
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Click to expand v3.6.0 detailed content →</b></summary>
|
|
||||||
|
|
||||||
## What's New
|
|
||||||
|
|
||||||
### Edit Mode & Provider Management
|
|
||||||
|
|
||||||
- **Provider Duplication** - Quickly duplicate existing provider configurations to create variants with one click
|
|
||||||
- **Manual Sorting** - Drag and drop to reorder providers, with visual push effect animations. Thanks to @ZyphrZero
|
|
||||||
- **Edit Mode Toggle** - Show/hide drag handles to optimize editing experience
|
|
||||||
|
|
||||||
### Custom Endpoint Management
|
|
||||||
|
|
||||||
- **Multi-Endpoint Configuration** - Support for aggregator providers with multiple API endpoints
|
|
||||||
- **Endpoint Input Visibility** - Shows endpoint field for all non-official providers automatically
|
|
||||||
|
|
||||||
### Usage Query Enhancements
|
|
||||||
|
|
||||||
- **Auto-Refresh Interval** - Configure periodic automatic usage queries with customizable intervals
|
|
||||||
- **Test Script API** - Validate JavaScript usage query scripts before execution
|
|
||||||
- **Enhanced Templates** - Custom blank templates with access token and user ID parameter support
|
|
||||||
Thanks to @Sirhexs
|
|
||||||
|
|
||||||
### Custom Configuration Directory (Cloud Sync)
|
|
||||||
|
|
||||||
- **Customizable Storage Location** - Customize CC Switch's configuration storage directory
|
|
||||||
- **Cloud Sync Support** - Point to cloud sync folders (Dropbox, OneDrive, iCloud Drive, etc.) to enable automatic config synchronization across devices
|
|
||||||
- **Independent Management** - Managed via Tauri Store for better isolation and reliability
|
|
||||||
Thanks to @ZyphrZero
|
|
||||||
|
|
||||||
### Configuration Directory Switching (WSL Support)
|
|
||||||
|
|
||||||
- **Auto-Sync on Directory Change** - When switching Claude/Codex config directories (e.g., WSL environment), automatically sync current provider to the new directory without manual operation
|
|
||||||
- **Post-Change Sync Utility** - Unified `postChangeSync.ts` utility for graceful error handling without blocking main flow
|
|
||||||
- **Import Config Auto-Sync** - Automatically sync after config import to ensure immediate effectiveness
|
|
||||||
- **Smart Conflict Resolution** - Distinguishes "fully successful" and "partially successful" states for precise user feedback
|
|
||||||
|
|
||||||
### Configuration Editor Improvements
|
|
||||||
|
|
||||||
- **JSON Format Button** - One-click JSON formatting in configuration editors
|
|
||||||
- **Real-Time TOML Validation** - Live syntax validation for Codex configuration with error highlighting
|
|
||||||
|
|
||||||
### Load Live Config When Editing
|
|
||||||
|
|
||||||
- **Protect Manual Modifications** - When editing the currently active provider, prioritize displaying the actual effective configuration from live files
|
|
||||||
- **Dual-Source Strategy** - Automatically loads from live config for active provider, SSOT for inactive ones
|
|
||||||
|
|
||||||
### Claude Configuration Data Structure Enhancements
|
|
||||||
|
|
||||||
- **Granular Model Configuration** - Migrated from dual-key to quad-key system for better model tier differentiation
|
|
||||||
- New fields: `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_MODEL`
|
|
||||||
- Replaces legacy `ANTHROPIC_SMALL_FAST_MODEL` with automatic migration
|
|
||||||
- Backend normalizes old configs on first read/write with smart fallback chain
|
|
||||||
- UI expanded from 2 to 4 model input fields with intelligent defaults
|
|
||||||
- **ANTHROPIC_API_KEY Support** - Providers can now use `ANTHROPIC_API_KEY` field in addition to `ANTHROPIC_AUTH_TOKEN`
|
|
||||||
- **Template Variable System** - Support for dynamic configuration replacement (e.g., KAT-Coder's `ENDPOINT_ID` parameter)
|
|
||||||
- **Endpoint Candidates** - Predefined endpoint list for speed testing and endpoint management
|
|
||||||
- **Visual Theme Configuration** - Custom icons and colors for provider cards
|
|
||||||
|
|
||||||
### Updated Provider Models
|
|
||||||
|
|
||||||
- **Kimi k2** - Updated to latest `kimi-k2-thinking` model
|
|
||||||
|
|
||||||
### New Provider Presets
|
|
||||||
|
|
||||||
Added 5 new provider presets:
|
|
||||||
|
|
||||||
- **DMXAPI** - Multi-model aggregation service
|
|
||||||
- **Azure Codex** - Microsoft Azure OpenAI endpoint
|
|
||||||
- **AnyRouter** - None-profit routing service
|
|
||||||
- **AiHubMix** - Multi-model aggregation service
|
|
||||||
- **MiniMax** - Open source AI model provider
|
|
||||||
|
|
||||||
### Partner Promotion Mechanism
|
|
||||||
|
|
||||||
- Support for ecosystem partner promotion (Zhipu GLM Z.ai)
|
|
||||||
- Sponsored banner integration in README
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Improvements
|
|
||||||
|
|
||||||
### Configuration & Sync
|
|
||||||
|
|
||||||
- **Unified Error Handling** - AppError with internationalized error messages throughout backend
|
|
||||||
- **Fixed apiKeyUrl Priority** - Correct priority order for API key URL resolution
|
|
||||||
- **Fixed MCP Sync Issues** - Resolved sync-to-other-side functionality failures
|
|
||||||
- **Import Config Sync** - Fixed sync issues after configuration import
|
|
||||||
- **Config Error Handling** - Force exit on config error to prevent silent fallback and data loss
|
|
||||||
|
|
||||||
### UI/UX Enhancements
|
|
||||||
|
|
||||||
- **Unique Provider Icons** - Each provider card now has unique icons and color identification
|
|
||||||
- **Unified Border System** - Consistent border design across all components
|
|
||||||
- **Drag Interaction** - Push effect animation and improved drag handle icons
|
|
||||||
- **Enhanced Visual Feedback** - Better current provider visual indication
|
|
||||||
- **Dialog Standardization** - Unified dialog sizes and layout consistency
|
|
||||||
- **Form Improvements** - Optimized model placeholders, simplified provider hints, category-specific hints
|
|
||||||
- **Usage Display Inline** - Usage info moved next to enable button for better space utilization
|
|
||||||
|
|
||||||
### Complete Internationalization
|
|
||||||
|
|
||||||
- **Error Messages i18n** - All backend error messages support Chinese/English
|
|
||||||
- **Tray Menu i18n** - System tray menu fully internationalized
|
|
||||||
- **UI Components i18n** - 100% coverage across all user-facing components
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bug Fixes
|
|
||||||
|
|
||||||
### Configuration Management
|
|
||||||
|
|
||||||
- Fixed `apiKeyUrl` priority issue
|
|
||||||
- Fixed MCP sync-to-other-side functionality failure
|
|
||||||
- Fixed sync issues after config import
|
|
||||||
- Fixed Codex API Key auto-sync
|
|
||||||
- Fixed endpoint speed test functionality
|
|
||||||
- Fixed provider duplicate insertion position (now inserts next to original)
|
|
||||||
- Fixed custom endpoint preservation in edit mode
|
|
||||||
- Prevent silent fallback and data loss on config error
|
|
||||||
|
|
||||||
### Usage Query
|
|
||||||
|
|
||||||
- Fixed auto-query interval timing issue
|
|
||||||
- Ensured refresh button shows loading animation on click
|
|
||||||
|
|
||||||
### UI Issues
|
|
||||||
|
|
||||||
- Fixed name collision error (`get_init_error` command)
|
|
||||||
- Fixed language setting rollback after successful save
|
|
||||||
- Fixed language switch state reset (dependency cycle)
|
|
||||||
- Fixed edit mode button alignment
|
|
||||||
|
|
||||||
### Startup Issues
|
|
||||||
|
|
||||||
- Force exit on config error (no silent fallback)
|
|
||||||
- Eliminated code duplication causing initialization errors
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Refactoring
|
|
||||||
|
|
||||||
### Backend (Rust) - 5 Phase Refactoring
|
|
||||||
|
|
||||||
1. **Phase 1**: Unified error handling (`AppError` + i18n error messages)
|
|
||||||
2. **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
|
|
||||||
3. **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)
|
|
||||||
4. **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)
|
|
||||||
5. **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
|
|
||||||
|
|
||||||
### Frontend (React + TypeScript) - 4 Stage Refactoring
|
|
||||||
|
|
||||||
1. **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)
|
|
||||||
2. **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)
|
|
||||||
3. **Stage 3**: Component splitting and business logic extraction
|
|
||||||
4. **Stage 4**: Code cleanup and formatting unification
|
|
||||||
|
|
||||||
### Testing System
|
|
||||||
|
|
||||||
- **Hooks Unit Tests** - 100% coverage for all custom hooks
|
|
||||||
- **Integration Tests** - Coverage for key processes (App, SettingsDialog, MCP Panel)
|
|
||||||
- **MSW Mocking** - Backend API mocking to ensure test independence
|
|
||||||
- **Test Infrastructure** - vitest + MSW + @testing-library/react
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
- **Unified Parameter Format** - All Tauri commands migrated to camelCase (Tauri 2 specification)
|
|
||||||
- **Semantic Clarity** - `AppType` renamed to `AppId` for better semantics
|
|
||||||
- **Centralized Parsing** - Unified `app` parameter parsing with `FromStr` trait
|
|
||||||
- **DRY Violations Cleanup** - Eliminated code duplication throughout codebase
|
|
||||||
- **Dead Code Removal** - Removed unused `missing_param` helper, deprecated `tauri-api.ts`, redundant `KimiModelSelector`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Internal Optimizations (User Transparent)
|
|
||||||
|
|
||||||
### Removed Legacy Migration Logic
|
|
||||||
|
|
||||||
v3.6.0 removed v1 config auto-migration and copy file scanning logic:
|
|
||||||
|
|
||||||
- **Impact**: Improved startup performance, cleaner codebase
|
|
||||||
- **Compatibility**: v2 format configs fully compatible, no action required
|
|
||||||
- **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6.0
|
|
||||||
|
|
||||||
### Command Parameter Standardization
|
|
||||||
|
|
||||||
Backend unified to use `app` parameter (values: `claude` or `codex`):
|
|
||||||
|
|
||||||
- **Impact**: More standardized code, friendlier error prompts
|
|
||||||
- **Compatibility**: Frontend fully adapted, users don't need to care about this change
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Updated to **Tauri 2.8.x**
|
|
||||||
- Updated to **TailwindCSS 4.x**
|
|
||||||
- Updated to **TanStack Query v5.90.x**
|
|
||||||
- Maintained **React 18.2.x** and **TypeScript 5.3.x**
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌟 About CC Switch
|
|
||||||
|
|
||||||
CC Switch is a cross-platform desktop application for managing and switching between different provider configurations for Claude Code and Codex. Built with Tauri 2.0 + React 18 + TypeScript, supporting Windows, macOS, and Linux.
|
|
||||||
|
|
||||||
**Core Features**:
|
|
||||||
- 🔄 One-click switching between multiple AI providers
|
|
||||||
- 📦 Support for both Claude Code and Codex applications
|
|
||||||
- 🎨 Modern UI with complete Chinese/English internationalization
|
|
||||||
- 🔐 Local storage, secure and reliable data
|
|
||||||
- ☁️ Support for cloud sync configurations
|
|
||||||
- 🧩 Unified MCP server management
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Project Repository**: https://github.com/farion1231/cc-switch
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
# CC Switch v3.6.1
|
|
||||||
|
|
||||||
> 稳定性提升与用户体验优化(基于 v3.6.0)
|
|
||||||
|
|
||||||
**[English Version →](../release-note-v3.6.1.md)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 v3.6.1 新增内容 (2025-11-10)
|
|
||||||
|
|
||||||
本次更新主要聚焦于**用户体验优化**和**配置解析健壮性**,修复了多个关键 Bug,并增强了用量查询系统。
|
|
||||||
|
|
||||||
### ✨ 新增功能
|
|
||||||
|
|
||||||
#### 用量查询系统增强
|
|
||||||
|
|
||||||
- **凭证解耦** - 用量查询可使用独立的 API Key 和 Base URL,不再依赖供应商配置
|
|
||||||
- 支持不同的查询端点和认证方式
|
|
||||||
- 根据模板类型自动显示对应的凭证输入框
|
|
||||||
- General 模板:API Key + Base URL
|
|
||||||
- NewAPI 模板:Base URL + Access Token + User ID
|
|
||||||
- Custom 模板:完全自定义
|
|
||||||
- **UI 组件升级** - 使用 shadcn/ui Switch 替代原生 checkbox,体验更现代
|
|
||||||
- **表单统一化** - 统一使用 shadcn/ui 输入组件,样式与应用保持一致
|
|
||||||
- **密码显示切换** - 添加查看/隐藏密码功能(API Key、Access Token)
|
|
||||||
|
|
||||||
#### 表单验证基础设施
|
|
||||||
|
|
||||||
- **通用 Schema 库** - 新增 JSON/TOML 通用验证器,减少重复代码
|
|
||||||
- `jsonConfigSchema`:通用 JSON 对象验证器
|
|
||||||
- `tomlConfigSchema`:通用 TOML 格式验证器
|
|
||||||
- `mcpJsonConfigSchema`:MCP 专用 JSON 验证器
|
|
||||||
- **MCP 条件字段验证** - 严格的类型检查
|
|
||||||
- stdio 类型强制要求 `command` 字段
|
|
||||||
- http 类型强制要求 `url` 字段
|
|
||||||
|
|
||||||
#### 合作伙伴集成
|
|
||||||
|
|
||||||
- **PackyCode** - 新增官方合作伙伴
|
|
||||||
- 添加到 Claude 和 Codex 供应商预设
|
|
||||||
- 支持 10% 折扣优惠(促销信息集成)
|
|
||||||
- 新增 Logo 和合作伙伴标识
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔧 改进优化
|
|
||||||
|
|
||||||
#### 用户体验
|
|
||||||
|
|
||||||
- **拖拽排序同步** - 托盘菜单顺序实时同步拖拽排序结果
|
|
||||||
- **错误通知增强** - 切换供应商失败时显示可复制的错误信息
|
|
||||||
- **移除误导性占位符** - 删除模型输入框的示例文本,避免用户混淆
|
|
||||||
- **Base URL 自动填充** - 所有非官方供应商类别自动填充 Base URL 输入框
|
|
||||||
|
|
||||||
#### 配置解析
|
|
||||||
|
|
||||||
- **中文引号规范化** - 自动处理 IME 输入的全角引号,防止 TOML 解析错误
|
|
||||||
- 支持中文引号(" " ' ')自动转换为 ASCII 引号
|
|
||||||
- 在 TOML 输入处理器中应用
|
|
||||||
- Textarea 组件禁用浏览器自动纠正
|
|
||||||
- **自定义字段保留** - 编辑 Codex MCP TOML 配置时保留未知字段
|
|
||||||
- 支持 timeout_ms、retry_count 等扩展字段
|
|
||||||
- 向前兼容未来的 MCP 协议扩展
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🐛 Bug 修复
|
|
||||||
|
|
||||||
#### 关键修复
|
|
||||||
|
|
||||||
- **修复用量脚本面板白屏崩溃** - FormLabel 组件缺少 FormField context 导致整个应用崩溃
|
|
||||||
- 替换为独立的 Label 组件
|
|
||||||
- 根本原因:FormLabel 内部调用 useFormField() hook 需要 FormFieldContext
|
|
||||||
- **修复中文输入法引号解析失败** - IME 输入的全角引号导致 TOML 解析错误
|
|
||||||
- 新增 textNormalization 工具函数
|
|
||||||
- 在解析前自动规范化引号
|
|
||||||
- **修复拖拽排序托盘不同步** (#179) - 拖拽排序后托盘菜单顺序未更新
|
|
||||||
- 在排序完成后自动调用 updateTrayMenu
|
|
||||||
- 确保 UI 和托盘菜单保持一致
|
|
||||||
- **修复 MCP 自定义字段丢失** - 编辑 Codex MCP 配置时自定义字段被静默丢弃
|
|
||||||
- 使用 spread 操作符保留所有字段
|
|
||||||
- normalizeServerConfig 中保留未知字段
|
|
||||||
|
|
||||||
#### 稳定性改进
|
|
||||||
|
|
||||||
- **错误隔离** - 托盘菜单更新失败不再影响主操作流程
|
|
||||||
- 将托盘更新错误与主操作解耦
|
|
||||||
- 主操作成功但托盘更新失败时给出警告
|
|
||||||
- **安全模式匹配** - 替换 `unwrap()` 为安全的 pattern matching
|
|
||||||
- 避免 panic 导致应用崩溃
|
|
||||||
- 托盘菜单事件处理使用 match 模式
|
|
||||||
- **导入配置分类** - 从默认配置导入时自动设置 category 为 `custom`
|
|
||||||
- 避免导入的配置被误认为官方预设
|
|
||||||
- 提供更清晰的配置来源标识
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📊 技术统计
|
|
||||||
|
|
||||||
```
|
|
||||||
提交数: 17 commits
|
|
||||||
代码变更: 31 个文件
|
|
||||||
- 新增: 1,163 行
|
|
||||||
- 删除: 811 行
|
|
||||||
- 净增长: +352 行
|
|
||||||
贡献者: Jason (16), ZyphrZero (1)
|
|
||||||
```
|
|
||||||
|
|
||||||
**按模块分类**:
|
|
||||||
- UI/用户界面:3 commits
|
|
||||||
- 用量查询系统:3 commits
|
|
||||||
- 配置解析:2 commits
|
|
||||||
- 表单验证:1 commit
|
|
||||||
- 其他改进:8 commits
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📥 安装方式
|
|
||||||
|
|
||||||
#### macOS
|
|
||||||
|
|
||||||
**通过 Homebrew 安装(推荐):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
brew install --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
**手动下载:**
|
|
||||||
|
|
||||||
- 从下方 [Assets](#assets) 下载 `CC-Switch-v3.6.1-macOS.zip`
|
|
||||||
|
|
||||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告。请前往"系统设置" → "隐私与安全性" → 点击"仍要打开"
|
|
||||||
|
|
||||||
#### Windows
|
|
||||||
|
|
||||||
- **安装包**:`CC-Switch-v3.6.1-Windows.msi`
|
|
||||||
- **便携版**:`CC-Switch-v3.6.1-Windows-Portable.zip`
|
|
||||||
|
|
||||||
#### Linux
|
|
||||||
|
|
||||||
- **AppImage**:`CC-Switch-v3.6.1-Linux.AppImage`
|
|
||||||
- **Debian**:`CC-Switch-v3.6.1-Linux.deb`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📚 文档
|
|
||||||
|
|
||||||
- [中文文档](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)
|
|
||||||
- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)
|
|
||||||
- [完整更新日志](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🙏 致谢
|
|
||||||
|
|
||||||
特别感谢:
|
|
||||||
- **智谱 AI** - 通过 GLM CODING PLAN 赞助本项目
|
|
||||||
- **PackyCode** - 新加入的官方合作伙伴
|
|
||||||
- **ZyphrZero** - 贡献托盘菜单同步修复 (#179)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**完整变更记录**: https://github.com/farion1231/cc-switch/compare/v3.6.0...v3.6.1
|
|
||||||
|
|
||||||
---
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📜 v3.6.0 完整功能回顾
|
|
||||||
|
|
||||||
> 以下内容来自 v3.6.0 (2025-11-07),帮助您了解完整的功能集
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>点击展开 v3.6.0 的详细内容 →</b></summary>
|
|
||||||
|
|
||||||
## 新增功能
|
|
||||||
|
|
||||||
### 编辑模式与供应商管理
|
|
||||||
|
|
||||||
- **供应商复制功能** - 一键快速复制现有供应商配置,轻松创建变体配置
|
|
||||||
- **手动排序功能** - 通过拖拽对供应商进行重新排序,带有视觉推送效果动画
|
|
||||||
- **编辑模式切换** - 显示/隐藏拖拽手柄,优化编辑体验
|
|
||||||
|
|
||||||
### 自定义端点管理
|
|
||||||
|
|
||||||
- **多端点配置** - 支持聚合类供应商的多 API 端点配置
|
|
||||||
- **端点输入可见性** - 为所有非官方供应商自动显示端点字段
|
|
||||||
|
|
||||||
### 自定义配置目录(云同步)
|
|
||||||
|
|
||||||
- **自定义存储位置** - 自定义 CC Switch 的配置存储目录
|
|
||||||
- **云同步支持** - 指定到云同步文件夹(Dropbox、OneDrive、iCloud Drive、坚果云等)即可实现跨设备配置自动同步
|
|
||||||
- **独立管理** - 通过 Tauri Store 管理,更好的隔离性和可靠性
|
|
||||||
|
|
||||||
### 使用量查询增强
|
|
||||||
|
|
||||||
- **自动刷新间隔** - 配置定时自动使用量查询,支持自定义间隔时间
|
|
||||||
- **测试脚本 API** - 在执行前验证 JavaScript 使用量查询脚本
|
|
||||||
- **增强模板系统** - 自定义空白模板,支持 access token 和 user ID 参数
|
|
||||||
|
|
||||||
### 配置目录切换(WSL 支持)
|
|
||||||
|
|
||||||
- **目录变更自动同步** - 切换 Claude/Codex 配置目录(如 WSL 环境)时,自动同步当前供应商到新目录,无需手动操作
|
|
||||||
- **后置同步工具** - 统一的 `postChangeSync.ts` 工具,优雅处理错误而不阻塞主流程
|
|
||||||
- **导入配置自动同步** - 配置导入后自动同步,确保立即生效
|
|
||||||
- **智能冲突解决** - 区分"完全成功"和"部分成功"状态,提供精确的用户反馈
|
|
||||||
|
|
||||||
### 配置编辑器改进
|
|
||||||
|
|
||||||
- **JSON 格式化按钮** - 配置编辑器中一键 JSON 格式化
|
|
||||||
- **实时 TOML 验证** - Codex 配置的实时语法验证,带有错误高亮
|
|
||||||
|
|
||||||
### 编辑时加载 Live 配置
|
|
||||||
|
|
||||||
- **保护手动修改** - 编辑当前激活的供应商时,优先显示来自 live 文件的实际生效配置
|
|
||||||
- **双源策略** - 活动供应商自动从 live 配置加载,非活动供应商从 SSOT 加载
|
|
||||||
|
|
||||||
### Claude 配置数据结构增强
|
|
||||||
|
|
||||||
- **细粒度模型配置** - 从双键系统升级到四键系统,以匹配官方最新数据结构
|
|
||||||
- 新增字段:`ANTHROPIC_DEFAULT_HAIKU_MODEL`、`ANTHROPIC_DEFAULT_SONNET_MODEL`、`ANTHROPIC_DEFAULT_OPUS_MODEL`、`ANTHROPIC_MODEL`
|
|
||||||
- 替换旧版 `ANTHROPIC_SMALL_FAST_MODEL`,支持自动迁移
|
|
||||||
- 后端在首次读写时自动规范化旧配置,带有智能回退链
|
|
||||||
- UI 从 2 个模型输入字段扩展到 4 个,具有智能默认值
|
|
||||||
- **ANTHROPIC_API_KEY 支持** - 供应商现可使用 `ANTHROPIC_API_KEY` 字段(除 `ANTHROPIC_AUTH_TOKEN` 外)
|
|
||||||
- **模板变量系统** - 支持动态配置替换(如 KAT-Coder 的 `ENDPOINT_ID` 参数)
|
|
||||||
- **端点候选列表** - 预定义端点列表,用于速度测试和端点管理
|
|
||||||
- **视觉主题配置** - 供应商卡片自定义图标和颜色
|
|
||||||
|
|
||||||
### 供应商模型更新
|
|
||||||
|
|
||||||
- **Kimi k2** - 更新到最新的 `kimi-k2-thinking` 模型
|
|
||||||
|
|
||||||
### 新增供应商预设
|
|
||||||
|
|
||||||
新增 5 个供应商预设:
|
|
||||||
|
|
||||||
- **DMXAPI** - 多模型聚合服务
|
|
||||||
- **Azure Codex** - 微软 Azure OpenAI 端点
|
|
||||||
- **AnyRouter** - API 路由服务
|
|
||||||
- **AiHubMix** - AI 模型集合
|
|
||||||
- **MiniMax** - 国产 AI 模型提供商
|
|
||||||
|
|
||||||
### 合作伙伴推广机制
|
|
||||||
|
|
||||||
- 支持生态合作伙伴推广(智谱 GLM Z.ai)
|
|
||||||
- README 中集成赞助商横幅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 改进优化
|
|
||||||
|
|
||||||
### 配置与同步
|
|
||||||
|
|
||||||
- **统一错误处理** - 后端全面使用 AppError 与国际化错误消息
|
|
||||||
- **修复 apiKeyUrl 优先级** - 修正 API key URL 解析的优先级顺序
|
|
||||||
- **修复 MCP 同步问题** - 解决同步到另一端功能失效的问题
|
|
||||||
- **导入配置同步** - 修复配置导入后的同步问题
|
|
||||||
- **配置错误处理** - 配置错误时强制退出,防止静默回退和数据丢失
|
|
||||||
|
|
||||||
### UI/UX 增强
|
|
||||||
|
|
||||||
- **独特的供应商图标** - 每个供应商卡片现在都有独特的图标和颜色识别
|
|
||||||
- **统一边框系统** - 所有组件采用一致的边框设计
|
|
||||||
- **拖拽交互** - 推送效果动画和改进的拖拽手柄图标
|
|
||||||
- **增强视觉反馈** - 更好的当前供应商视觉指示
|
|
||||||
- **对话框标准化** - 统一的对话框尺寸和布局一致性
|
|
||||||
- **表单改进** - 优化模型占位符,简化供应商提示,分类特定提示
|
|
||||||
- **使用量内联显示** - 使用量信息移至启用按钮旁边,更好地利用空间
|
|
||||||
|
|
||||||
### 完整国际化
|
|
||||||
|
|
||||||
- **错误消息国际化** - 所有后端错误消息支持中英文
|
|
||||||
- **托盘菜单国际化** - 系统托盘菜单完全国际化
|
|
||||||
- **UI 组件国际化** - 所有面向用户的组件 100% 覆盖
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bug 修复
|
|
||||||
|
|
||||||
### 配置管理
|
|
||||||
|
|
||||||
- 修复 `apiKeyUrl` 优先级问题
|
|
||||||
- 修复 MCP 同步到另一端功能失效
|
|
||||||
- 修复配置导入后的同步问题
|
|
||||||
- 修复 Codex API Key 自动同步
|
|
||||||
- 修复端点速度测试功能
|
|
||||||
- 修复供应商复制插入位置(现在插入到原供应商旁边)
|
|
||||||
- 修复编辑模式下自定义端点保留问题
|
|
||||||
- 防止配置错误时的静默回退和数据丢失
|
|
||||||
|
|
||||||
### 使用量查询
|
|
||||||
|
|
||||||
- 修复自动查询间隔时间问题
|
|
||||||
- 确保刷新按钮点击时显示加载动画
|
|
||||||
|
|
||||||
### UI 问题
|
|
||||||
|
|
||||||
- 修复名称冲突错误(`get_init_error` 命令)
|
|
||||||
- 修复保存成功后语言设置回滚
|
|
||||||
- 修复语言切换状态重置(依赖循环)
|
|
||||||
- 修复编辑模式按钮对齐
|
|
||||||
|
|
||||||
### 启动问题
|
|
||||||
|
|
||||||
- 配置错误时强制退出(不再静默回退)
|
|
||||||
- 消除导致初始化错误的代码重复
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 架构重构
|
|
||||||
|
|
||||||
### 后端(Rust)- 5 阶段重构
|
|
||||||
|
|
||||||
1. **阶段 1**:统一错误处理(`AppError` + 国际化错误消息)
|
|
||||||
2. **阶段 2**:命令层按领域拆分(`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
|
|
||||||
3. **阶段 3**:集成测试和事务机制(配置快照 + 失败回滚)
|
|
||||||
4. **阶段 4**:提取 Service 层(`services/{provider,mcp,config,speedtest}.rs`)
|
|
||||||
5. **阶段 5**:并发优化(`RwLock` 替代 `Mutex`,作用域 guard 避免死锁)
|
|
||||||
|
|
||||||
### 前端(React + TypeScript)- 4 阶段重构
|
|
||||||
|
|
||||||
1. **阶段 1**:测试基础设施(vitest + MSW + @testing-library/react)
|
|
||||||
2. **阶段 2**:提取自定义 hooks(`useProviderActions`、`useMcpActions`、`useSettings`、`useImportExport` 等)
|
|
||||||
3. **阶段 3**:组件拆分和业务逻辑提取
|
|
||||||
4. **阶段 4**:代码清理和格式化统一
|
|
||||||
|
|
||||||
### 测试体系
|
|
||||||
|
|
||||||
- **Hooks 单元测试** - 所有自定义 hooks 100% 覆盖
|
|
||||||
- **集成测试** - 关键流程覆盖(App、SettingsDialog、MCP 面板)
|
|
||||||
- **MSW 模拟** - 后端 API 模拟确保测试独立性
|
|
||||||
- **测试基础设施** - vitest + MSW + @testing-library/react
|
|
||||||
|
|
||||||
### 代码质量
|
|
||||||
|
|
||||||
- **统一参数格式** - 所有 Tauri 命令迁移到 camelCase(Tauri 2 规范)
|
|
||||||
- **语义清晰** - `AppType` 重命名为 `AppId` 以获得更好的语义
|
|
||||||
- **集中解析** - 使用 `FromStr` trait 统一 `app` 参数解析
|
|
||||||
- **DRY 违规清理** - 消除整个代码库中的代码重复
|
|
||||||
- **死代码移除** - 移除未使用的 `missing_param` 辅助函数、废弃的 `tauri-api.ts`、冗余的 `KimiModelSelector`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 内部优化(用户无感知)
|
|
||||||
|
|
||||||
### 移除遗留迁移逻辑
|
|
||||||
|
|
||||||
v3.6.0 移除了 v1 配置自动迁移和副本文件扫描逻辑:
|
|
||||||
|
|
||||||
- **影响**:提升启动性能,代码更简洁
|
|
||||||
- **兼容性**:v2 格式配置完全兼容,无需任何操作
|
|
||||||
- **注意**:从 v3.1.0 或更早版本升级的用户,请先升级到 v3.2.x 或 v3.5.x 进行一次性迁移,然后再升级到 v3.6.0
|
|
||||||
|
|
||||||
### 命令参数标准化
|
|
||||||
|
|
||||||
后端统一使用 `app` 参数(取值:`claude` 或 `codex`):
|
|
||||||
|
|
||||||
- **影响**:代码更规范,错误提示更友好
|
|
||||||
- **兼容性**:前端已完全适配,用户无需关心此变更
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 依赖更新
|
|
||||||
|
|
||||||
- 更新到 **Tauri 2.8.x**
|
|
||||||
- 更新到 **TailwindCSS 4.x**
|
|
||||||
- 更新到 **TanStack Query v5.90.x**
|
|
||||||
- 保持 **React 18.2.x** 和 **TypeScript 5.3.x**
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌟 关于 CC Switch
|
|
||||||
|
|
||||||
CC Switch 是一个跨平台桌面应用,用于管理和切换 Claude Code 与 Codex 的不同供应商配置。基于 Tauri 2.0 + React 18 + TypeScript 构建,支持 Windows、macOS、Linux。
|
|
||||||
|
|
||||||
**核心特性**:
|
|
||||||
- 🔄 一键切换多个 AI 供应商
|
|
||||||
- 📦 支持 Claude Code 和 Codex 双应用
|
|
||||||
- 🎨 现代化 UI,完整的中英文国际化
|
|
||||||
- 🔐 本地存储,数据安全可靠
|
|
||||||
- ☁️ 支持云同步配置
|
|
||||||
- 🧩 MCP 服务器统一管理
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**项目地址**: https://github.com/farion1231/cc-switch
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
# CC Switch v3.7.0
|
|
||||||
|
|
||||||
> From Provider Switcher to All-in-One AI CLI Management Platform
|
|
||||||
|
|
||||||
**[中文更新说明 Chinese Documentation →](release-note-v3.7.0-zh.md)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
CC Switch v3.7.0 introduces six major features with over 18,000 lines of new code.
|
|
||||||
|
|
||||||
**Release Date**: 2025-11-19
|
|
||||||
**Commits**: 85 from v3.6.0
|
|
||||||
**Code Changes**: 152 files, +18,104 / -3,732 lines
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Features
|
|
||||||
|
|
||||||
### Gemini CLI Integration
|
|
||||||
|
|
||||||
Complete support for Google Gemini CLI, becoming the third supported application (Claude Code, Codex, Gemini).
|
|
||||||
|
|
||||||
**Core Capabilities**:
|
|
||||||
|
|
||||||
- **Dual-file configuration** - Support for both `.env` and `settings.json` formats
|
|
||||||
- **Auto-detection** - Automatically detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
|
|
||||||
- **Full MCP support** - Complete MCP server management for Gemini
|
|
||||||
- **Deep link integration** - Import via `ccswitch://` protocol
|
|
||||||
- **System tray** - Quick-switch from tray menu
|
|
||||||
|
|
||||||
**Provider Presets**:
|
|
||||||
|
|
||||||
- **Google Official** - OAuth authentication support
|
|
||||||
- **PackyCode** - Partner integration
|
|
||||||
- **Custom** - Full customization support
|
|
||||||
|
|
||||||
**Technical Implementation**:
|
|
||||||
|
|
||||||
- New backend modules: `gemini_config.rs` (20KB), `gemini_mcp.rs`
|
|
||||||
- Form synchronization with environment editor
|
|
||||||
- Dual-file atomic writes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### MCP v3.7.0 Unified Architecture
|
|
||||||
|
|
||||||
Complete refactoring of MCP management system for cross-application unification.
|
|
||||||
|
|
||||||
**Architecture Improvements**:
|
|
||||||
|
|
||||||
- **Unified panel** - Single interface for Claude/Codex/Gemini MCP servers
|
|
||||||
- **SSE transport** - New Server-Sent Events support
|
|
||||||
- **Smart parser** - Fault-tolerant JSON parsing
|
|
||||||
- **Format correction** - Auto-fix Codex `[mcp_servers]` format
|
|
||||||
- **Extended fields** - Preserve custom TOML fields
|
|
||||||
|
|
||||||
**User Experience**:
|
|
||||||
|
|
||||||
- Default app selection in forms
|
|
||||||
- JSON formatter for validation
|
|
||||||
- Improved visual hierarchy
|
|
||||||
- Better error messages
|
|
||||||
|
|
||||||
**Import/Export**:
|
|
||||||
|
|
||||||
- Unified import from all three apps
|
|
||||||
- Bidirectional synchronization
|
|
||||||
- State preservation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Claude Skills Management System
|
|
||||||
|
|
||||||
**Approximately 2,000 lines of code** - A complete skill ecosystem platform.
|
|
||||||
|
|
||||||
**GitHub Integration**:
|
|
||||||
|
|
||||||
- Auto-scan skills from GitHub repositories
|
|
||||||
- Pre-configured repos:
|
|
||||||
- `ComposioHQ/awesome-claude-skills` - Curated collection
|
|
||||||
- `anthropics/skills` - Official Anthropic skills
|
|
||||||
- `cexll/myclaude` - Community contributions
|
|
||||||
- Add custom repositories
|
|
||||||
- Subdirectory scanning support (`skillsPath`)
|
|
||||||
|
|
||||||
**Lifecycle Management**:
|
|
||||||
|
|
||||||
- **Discover** - Auto-detect `SKILL.md` files
|
|
||||||
- **Install** - One-click to `~/.claude/skills/`
|
|
||||||
- **Uninstall** - Safe removal with tracking
|
|
||||||
- **Update** - Check for updates (infrastructure ready)
|
|
||||||
|
|
||||||
**Technical Architecture**:
|
|
||||||
|
|
||||||
- **Backend**: `SkillService` (526 lines) with GitHub API integration
|
|
||||||
- **Frontend**: SkillsPage, SkillCard, RepoManager
|
|
||||||
- **UI Components**: Badge, Card, Table (shadcn/ui)
|
|
||||||
- **State**: Persistent storage in `skills.json`
|
|
||||||
- **i18n**: 47+ translation keys
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Prompts Management System
|
|
||||||
|
|
||||||
**Approximately 1,300 lines of code** - Complete system prompt management.
|
|
||||||
|
|
||||||
**Multi-Preset Management**:
|
|
||||||
|
|
||||||
- Create unlimited prompt presets
|
|
||||||
- Quick switch between presets
|
|
||||||
- One active prompt at a time
|
|
||||||
- Delete protection for active prompts
|
|
||||||
|
|
||||||
**Cross-App Support**:
|
|
||||||
|
|
||||||
- **Claude**: `~/.claude/CLAUDE.md`
|
|
||||||
- **Codex**: `~/.codex/AGENTS.md`
|
|
||||||
- **Gemini**: `~/.gemini/GEMINI.md`
|
|
||||||
|
|
||||||
**Markdown Editor**:
|
|
||||||
|
|
||||||
- Full-featured CodeMirror 6 integration
|
|
||||||
- Syntax highlighting
|
|
||||||
- Dark theme (One Dark)
|
|
||||||
- Real-time preview
|
|
||||||
|
|
||||||
**Smart Synchronization**:
|
|
||||||
|
|
||||||
- **Auto-write** - Immediately write to live files
|
|
||||||
- **Backfill protection** - Save current content before switching
|
|
||||||
- **Auto-import** - Import from live files on first launch
|
|
||||||
- **Modification protection** - Preserve manual modifications
|
|
||||||
|
|
||||||
**Technical Implementation**:
|
|
||||||
|
|
||||||
- **Backend**: `PromptService` (213 lines)
|
|
||||||
- **Frontend**: PromptPanel (177), PromptFormModal (160), MarkdownEditor (159)
|
|
||||||
- **Hooks**: usePromptActions (152 lines)
|
|
||||||
- **i18n**: 41+ translation keys
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Deep Link Protocol (ccswitch://)
|
|
||||||
|
|
||||||
One-click provider configuration import via URL scheme.
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
|
|
||||||
- Protocol registration on all platforms
|
|
||||||
- Import from shared links
|
|
||||||
- Lifecycle integration
|
|
||||||
- Security validation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Environment Variable Conflict Detection
|
|
||||||
|
|
||||||
Intelligent detection and management of configuration conflicts.
|
|
||||||
|
|
||||||
**Detection Scope**:
|
|
||||||
|
|
||||||
- **Claude & Codex** - Cross-app conflicts
|
|
||||||
- **Gemini** - Auto-discovery
|
|
||||||
- **MCP** - Server configuration conflicts
|
|
||||||
|
|
||||||
**Management Features**:
|
|
||||||
|
|
||||||
- Visual conflict indicators
|
|
||||||
- Resolution suggestions
|
|
||||||
- Override warnings
|
|
||||||
- Backup before changes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Improvements
|
|
||||||
|
|
||||||
### Provider Management
|
|
||||||
|
|
||||||
**New Presets**:
|
|
||||||
|
|
||||||
- **DouBaoSeed** - ByteDance's DouBao
|
|
||||||
- **Kimi For Coding** - Moonshot AI
|
|
||||||
- **BaiLing** - BaiLing AI
|
|
||||||
- **Removed AnyRouter** - To avoid confusion
|
|
||||||
|
|
||||||
**Enhancements**:
|
|
||||||
|
|
||||||
- Model name configuration for Codex and Gemini
|
|
||||||
- Provider notes field for organization
|
|
||||||
- Enhanced preset metadata
|
|
||||||
|
|
||||||
### Configuration Management
|
|
||||||
|
|
||||||
- **Common config migration** - From localStorage to `config.json`
|
|
||||||
- **Unified persistence** - Shared across all apps
|
|
||||||
- **Auto-import** - First launch configuration import
|
|
||||||
- **Backfill priority** - Correct handling of live files
|
|
||||||
|
|
||||||
### UI/UX Improvements
|
|
||||||
|
|
||||||
**Design System**:
|
|
||||||
|
|
||||||
- **macOS native** - System-aligned color scheme
|
|
||||||
- **Window centering** - Default centered position
|
|
||||||
- **Visual polish** - Improved spacing and hierarchy
|
|
||||||
|
|
||||||
**Interactions**:
|
|
||||||
|
|
||||||
- **Password input** - Fixed Edge/IE reveal buttons
|
|
||||||
- **URL overflow** - Fixed card overflow
|
|
||||||
- **Error copying** - Copy-to-clipboard errors
|
|
||||||
- **Tray sync** - Real-time drag-and-drop sync
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bug Fixes
|
|
||||||
|
|
||||||
### Critical Fixes
|
|
||||||
|
|
||||||
- **Usage script validation** - Boundary checks
|
|
||||||
- **Gemini validation** - Relaxed constraints
|
|
||||||
- **TOML parsing** - CJK quote handling
|
|
||||||
- **MCP fields** - Custom field preservation
|
|
||||||
- **White screen** - FormLabel crash fix
|
|
||||||
|
|
||||||
### Stability
|
|
||||||
|
|
||||||
- **Tray safety** - Pattern matching instead of unwrap
|
|
||||||
- **Error isolation** - Tray failures don't block operations
|
|
||||||
- **Import classification** - Correct category assignment
|
|
||||||
|
|
||||||
### UI Fixes
|
|
||||||
|
|
||||||
- **Model placeholders** - Removed misleading hints
|
|
||||||
- **Base URL** - Auto-fill for third-party providers
|
|
||||||
- **Drag sort** - Tray menu synchronization
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technical Improvements
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
**MCP v3.7.0**:
|
|
||||||
|
|
||||||
- Removed legacy code (~1,000 lines)
|
|
||||||
- Unified initialization structure
|
|
||||||
- Backward compatibility maintained
|
|
||||||
- Comprehensive code formatting
|
|
||||||
|
|
||||||
**Platform Compatibility**:
|
|
||||||
|
|
||||||
- Windows winreg API fix (v0.52)
|
|
||||||
- Safe pattern matching (no `unwrap()`)
|
|
||||||
- Cross-platform tray handling
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
**Synchronization**:
|
|
||||||
|
|
||||||
- MCP sync across all apps
|
|
||||||
- Gemini form-editor sync
|
|
||||||
- Dual-file reading (.env + settings.json)
|
|
||||||
|
|
||||||
**Validation**:
|
|
||||||
|
|
||||||
- Input boundary checks
|
|
||||||
- TOML quote normalization (CJK)
|
|
||||||
- Custom field preservation
|
|
||||||
- Enhanced error messages
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
**Type Safety**:
|
|
||||||
|
|
||||||
- Complete TypeScript coverage
|
|
||||||
- Rust type refinements
|
|
||||||
- API contract validation
|
|
||||||
|
|
||||||
**Testing**:
|
|
||||||
|
|
||||||
- Simplified assertions
|
|
||||||
- Better test coverage
|
|
||||||
- Integration test updates
|
|
||||||
|
|
||||||
**Dependencies**:
|
|
||||||
|
|
||||||
- Tauri 2.8.x
|
|
||||||
- Rust: `anyhow`, `zip`, `serde_yaml`, `tempfile`
|
|
||||||
- Frontend: CodeMirror 6 packages
|
|
||||||
- winreg 0.52 (Windows)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technical Statistics
|
|
||||||
|
|
||||||
```
|
|
||||||
Total Changes:
|
|
||||||
- Commits: 85
|
|
||||||
- Files: 152 changed
|
|
||||||
- Additions: +18,104 lines
|
|
||||||
- Deletions: -3,732 lines
|
|
||||||
|
|
||||||
New Modules:
|
|
||||||
- Skills Management: 2,034 lines (21 files)
|
|
||||||
- Prompts Management: 1,302 lines (20 files)
|
|
||||||
- Gemini Integration: ~1,000 lines
|
|
||||||
- MCP Refactor: ~3,000 lines refactored
|
|
||||||
|
|
||||||
Code Distribution:
|
|
||||||
- Backend (Rust): ~4,500 lines new
|
|
||||||
- Frontend (React): ~3,000 lines new
|
|
||||||
- Configuration: ~1,500 lines refactored
|
|
||||||
- Tests: ~500 lines
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Strategic Positioning
|
|
||||||
|
|
||||||
### From Tool to Platform
|
|
||||||
|
|
||||||
v3.7.0 represents a shift in CC Switch's positioning:
|
|
||||||
|
|
||||||
| Aspect | v3.6 | v3.7.0 |
|
|
||||||
| ----------------- | ------------------------ | ---------------------------- |
|
|
||||||
| **Identity** | Provider Switcher | AI CLI Management Platform |
|
|
||||||
| **Scope** | Configuration Management | Ecosystem Management |
|
|
||||||
| **Applications** | Claude + Codex | Claude + Codex + Gemini |
|
|
||||||
| **Capabilities** | Switch configs | Extend capabilities (Skills) |
|
|
||||||
| **Customization** | Manual editing | Visual management (Prompts) |
|
|
||||||
| **Integration** | Isolated apps | Unified management (MCP) |
|
|
||||||
|
|
||||||
### Six Pillars of AI CLI Management
|
|
||||||
|
|
||||||
1. **Configuration Management** - Provider switching and management
|
|
||||||
2. **Capability Extension** - Skills installation and lifecycle
|
|
||||||
3. **Behavior Customization** - System prompt presets
|
|
||||||
4. **Ecosystem Integration** - Deep links and sharing
|
|
||||||
5. **Multi-AI Support** - Claude/Codex/Gemini
|
|
||||||
6. **Intelligent Detection** - Conflict prevention
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Download & Installation
|
|
||||||
|
|
||||||
### System Requirements
|
|
||||||
|
|
||||||
- **Windows**: Windows 10+
|
|
||||||
- **macOS**: macOS 10.15 (Catalina)+
|
|
||||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
|
||||||
|
|
||||||
### Download Links
|
|
||||||
|
|
||||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:
|
|
||||||
|
|
||||||
- **Windows**: `CC-Switch-v3.7.0-Windows.msi` or `-Portable.zip`
|
|
||||||
- **macOS**: `CC-Switch-v3.7.0-macOS.tar.gz` or `.zip`
|
|
||||||
- **Linux**: `CC-Switch-v3.7.0-Linux.AppImage` or `.deb`
|
|
||||||
|
|
||||||
### Homebrew (macOS)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
brew install --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
Update:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew upgrade --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
### From v3.6.x
|
|
||||||
|
|
||||||
**Automatic migration** - No action required, configs are fully compatible
|
|
||||||
|
|
||||||
### From v3.1.x or Earlier
|
|
||||||
|
|
||||||
**Two-step migration required**:
|
|
||||||
|
|
||||||
1. First upgrade to v3.2.x (performs one-time migration)
|
|
||||||
2. Then upgrade to v3.7.0
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
|
|
||||||
- **Skills**: No migration needed, start fresh
|
|
||||||
- **Prompts**: Auto-import from live files on first launch
|
|
||||||
- **Gemini**: Install Gemini CLI separately if needed
|
|
||||||
- **MCP v3.7.0**: Backward compatible with previous configs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acknowledgments
|
|
||||||
|
|
||||||
### Contributors
|
|
||||||
|
|
||||||
Thanks to all contributors who made this release possible:
|
|
||||||
|
|
||||||
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini integration implementation
|
|
||||||
- [@farion1231](https://github.com/farion1231) - From developer to issue responder
|
|
||||||
- Community members for testing and feedback
|
|
||||||
|
|
||||||
### Sponsors
|
|
||||||
|
|
||||||
**Z.ai** - GLM CODING PLAN sponsor
|
|
||||||
[Get 10% OFF with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
|
||||||
|
|
||||||
**PackyCode** - API relay service partner
|
|
||||||
[Register with "cc-switch" code for 10% discount](https://www.packyapi.com/register?aff=cc-switch)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feedback & Support
|
|
||||||
|
|
||||||
- **Issues**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
|
||||||
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
|
||||||
- **Documentation**: [README](../README.md)
|
|
||||||
- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What's Next
|
|
||||||
|
|
||||||
**v3.8.0 Preview** (Tentative):
|
|
||||||
|
|
||||||
- Local proxy functionality
|
|
||||||
|
|
||||||
Stay tuned for more updates!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Happy Coding!**
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
# CC Switch v3.7.0
|
|
||||||
|
|
||||||
> 从供应商切换器到 AI CLI 一体化管理平台
|
|
||||||
|
|
||||||
**[English Version →](release-note-v3.7.0-en.md)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 概览
|
|
||||||
|
|
||||||
CC Switch v3.7.0 新增六大核心功能,新增超过 18,000 行代码。
|
|
||||||
|
|
||||||
**发布日期**:2025-11-19
|
|
||||||
**提交数量**:从 v3.6.0 开始 85 个提交
|
|
||||||
**代码变更**:152 个文件,+18,104 / -3,732 行
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 新增功能
|
|
||||||
|
|
||||||
### Gemini CLI 集成
|
|
||||||
|
|
||||||
完整支持 Google Gemini CLI,成为第三个支持的应用(Claude Code、Codex、Gemini)。
|
|
||||||
|
|
||||||
**核心能力**:
|
|
||||||
|
|
||||||
- **双文件配置** - 同时支持 `.env` 和 `settings.json` 格式
|
|
||||||
- **自动检测** - 自动检测 `GOOGLE_GEMINI_BASE_URL`、`GEMINI_MODEL` 等环境变量
|
|
||||||
- **完整 MCP 支持** - 为 Gemini 提供完整的 MCP 服务器管理
|
|
||||||
- **深度链接集成** - 通过 `ccswitch://` 协议导入配置
|
|
||||||
- **系统托盘** - 从托盘菜单快速切换
|
|
||||||
|
|
||||||
**供应商预设**:
|
|
||||||
|
|
||||||
- **Google Official** - 支持 OAuth 认证
|
|
||||||
- **PackyCode** - 合作伙伴集成
|
|
||||||
- **自定义** - 完全自定义支持
|
|
||||||
|
|
||||||
**技术实现**:
|
|
||||||
|
|
||||||
- 新增后端模块:`gemini_config.rs`(20KB)、`gemini_mcp.rs`
|
|
||||||
- 表单与环境编辑器同步
|
|
||||||
- 双文件原子写入
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### MCP v3.7.0 统一架构
|
|
||||||
|
|
||||||
MCP 管理系统完整重构,实现跨应用统一管理。
|
|
||||||
|
|
||||||
**架构改进**:
|
|
||||||
|
|
||||||
- **统一管理面板** - 单一界面管理 Claude/Codex/Gemini MCP 服务器
|
|
||||||
- **SSE 传输类型** - 新增 Server-Sent Events 支持
|
|
||||||
- **智能解析器** - 容错性 JSON 解析
|
|
||||||
- **格式修正** - 自动修复 Codex `[mcp_servers]` 格式
|
|
||||||
- **扩展字段** - 保留自定义 TOML 字段
|
|
||||||
|
|
||||||
**用户体验**:
|
|
||||||
|
|
||||||
- 表单中的默认应用选择
|
|
||||||
- JSON 格式化器用于验证
|
|
||||||
- 改进的视觉层次
|
|
||||||
- 更好的错误消息
|
|
||||||
|
|
||||||
**导入/导出**:
|
|
||||||
|
|
||||||
- 统一从三个应用导入
|
|
||||||
- 双向同步
|
|
||||||
- 状态保持
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Claude Skills 管理系统
|
|
||||||
|
|
||||||
**约 2,000 行代码** - 完整的技能生态平台。
|
|
||||||
|
|
||||||
**GitHub 集成**:
|
|
||||||
|
|
||||||
- 从 GitHub 仓库自动扫描技能
|
|
||||||
- 预配置仓库:
|
|
||||||
- `ComposioHQ/awesome-claude-skills` - 精选集合
|
|
||||||
- `anthropics/skills` - Anthropic 官方技能
|
|
||||||
- `cexll/myclaude` - 社区贡献
|
|
||||||
- 添加自定义仓库
|
|
||||||
- 子目录扫描支持(`skillsPath`)
|
|
||||||
|
|
||||||
**生命周期管理**:
|
|
||||||
|
|
||||||
- **发现** - 自动检测 `SKILL.md` 文件
|
|
||||||
- **安装** - 一键安装到 `~/.claude/skills/`
|
|
||||||
- **卸载** - 安全移除并跟踪状态
|
|
||||||
- **更新** - 检查更新(基础设施已就绪)
|
|
||||||
|
|
||||||
**技术架构**:
|
|
||||||
|
|
||||||
- **后端**:`SkillService`(526 行)集成 GitHub API
|
|
||||||
- **前端**:SkillsPage、SkillCard、RepoManager
|
|
||||||
- **UI 组件**:Badge、Card、Table(shadcn/ui)
|
|
||||||
- **状态**:持久化存储在 `skills.json`
|
|
||||||
- **国际化**:47+ 个翻译键
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Prompts 管理系统
|
|
||||||
|
|
||||||
**约 1,300 行代码** - 完整的系统提示词管理。
|
|
||||||
|
|
||||||
**多预设管理**:
|
|
||||||
|
|
||||||
- 创建无限数量的提示词预设
|
|
||||||
- 快速在预设间切换
|
|
||||||
- 同时只能激活一个提示词
|
|
||||||
- 活动提示词删除保护
|
|
||||||
|
|
||||||
**跨应用支持**:
|
|
||||||
|
|
||||||
- **Claude**:`~/.claude/CLAUDE.md`
|
|
||||||
- **Codex**:`~/.codex/AGENTS.md`
|
|
||||||
- **Gemini**:`~/.gemini/GEMINI.md`
|
|
||||||
|
|
||||||
**Markdown 编辑器**:
|
|
||||||
|
|
||||||
- 完整的 CodeMirror 6 集成
|
|
||||||
- 语法高亮
|
|
||||||
- 暗色主题(One Dark)
|
|
||||||
- 实时预览
|
|
||||||
|
|
||||||
**智能同步**:
|
|
||||||
|
|
||||||
- **自动写入** - 立即写入 live 文件
|
|
||||||
- **回填保护** - 切换前保存当前内容
|
|
||||||
- **自动导入** - 首次启动从 live 文件导入
|
|
||||||
- **修改保护** - 保留手动修改
|
|
||||||
|
|
||||||
**技术实现**:
|
|
||||||
|
|
||||||
- **后端**:`PromptService`(213 行)
|
|
||||||
- **前端**:PromptPanel(177)、PromptFormModal(160)、MarkdownEditor(159)
|
|
||||||
- **Hooks**:usePromptActions(152 行)
|
|
||||||
- **国际化**:41+ 个翻译键
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 深度链接协议(ccswitch://)
|
|
||||||
|
|
||||||
通过 URL 方案一键导入供应商配置。
|
|
||||||
|
|
||||||
**功能特性**:
|
|
||||||
|
|
||||||
- 所有平台的协议注册
|
|
||||||
- 从共享链接导入
|
|
||||||
- 生命周期集成
|
|
||||||
- 安全验证
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 环境变量冲突检测
|
|
||||||
|
|
||||||
智能检测和管理配置冲突。
|
|
||||||
|
|
||||||
**检测范围**:
|
|
||||||
|
|
||||||
- **Claude & Codex** - 跨应用冲突
|
|
||||||
- **Gemini** - 自动发现
|
|
||||||
- **MCP** - 服务器配置冲突
|
|
||||||
|
|
||||||
**管理功能**:
|
|
||||||
|
|
||||||
- 可视化冲突指示器
|
|
||||||
- 解决建议
|
|
||||||
- 覆盖警告
|
|
||||||
- 更改前备份
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 改进优化
|
|
||||||
|
|
||||||
### 供应商管理
|
|
||||||
|
|
||||||
**新增预设**:
|
|
||||||
|
|
||||||
- **DouBaoSeed** - 字节跳动的豆包
|
|
||||||
- **Kimi For Coding** - 月之暗面
|
|
||||||
- **BaiLing** - 百灵 AI
|
|
||||||
- **移除 AnyRouter** - 避免误导
|
|
||||||
|
|
||||||
**增强功能**:
|
|
||||||
|
|
||||||
- Codex 和 Gemini 的模型名称配置
|
|
||||||
- 供应商备注字段用于组织
|
|
||||||
- 增强的预设元数据
|
|
||||||
|
|
||||||
### 配置管理
|
|
||||||
|
|
||||||
- **通用配置迁移** - 从 localStorage 迁移到 `config.json`
|
|
||||||
- **统一持久化** - 跨所有应用共享
|
|
||||||
- **自动导入** - 首次启动配置导入
|
|
||||||
- **回填优先级** - 正确处理 live 文件
|
|
||||||
|
|
||||||
### UI/UX 改进
|
|
||||||
|
|
||||||
**设计系统**:
|
|
||||||
|
|
||||||
- **macOS 原生** - 与系统对齐的配色方案
|
|
||||||
- **窗口居中** - 默认居中位置
|
|
||||||
- **视觉优化** - 改进的间距和层次
|
|
||||||
|
|
||||||
**交互优化**:
|
|
||||||
|
|
||||||
- **密码输入** - 修复 Edge/IE 显示按钮
|
|
||||||
- **URL 溢出** - 修复卡片溢出
|
|
||||||
- **错误复制** - 可复制到剪贴板的错误
|
|
||||||
- **托盘同步** - 实时拖放同步
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bug 修复
|
|
||||||
|
|
||||||
### 关键修复
|
|
||||||
|
|
||||||
- **用量脚本验证** - 边界检查
|
|
||||||
- **Gemini 验证** - 放宽约束
|
|
||||||
- **TOML 解析** - CJK 引号处理
|
|
||||||
- **MCP 字段** - 自定义字段保留
|
|
||||||
- **白屏** - FormLabel 崩溃修复
|
|
||||||
|
|
||||||
### 稳定性
|
|
||||||
|
|
||||||
- **托盘安全** - 模式匹配替代 unwrap
|
|
||||||
- **错误隔离** - 托盘失败不阻塞操作
|
|
||||||
- **导入分类** - 正确的类别分配
|
|
||||||
|
|
||||||
### UI 修复
|
|
||||||
|
|
||||||
- **模型占位符** - 移除误导性提示
|
|
||||||
- **Base URL** - 第三方供应商自动填充
|
|
||||||
- **拖拽排序** - 托盘菜单同步
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 技术改进
|
|
||||||
|
|
||||||
### 架构
|
|
||||||
|
|
||||||
**MCP v3.7.0**:
|
|
||||||
|
|
||||||
- 移除遗留代码(约 1,000 行)
|
|
||||||
- 统一初始化结构
|
|
||||||
- 保持向后兼容性
|
|
||||||
- 全面的代码格式化
|
|
||||||
|
|
||||||
**平台兼容性**:
|
|
||||||
|
|
||||||
- Windows winreg API 修复(v0.52)
|
|
||||||
- 安全模式匹配(无 `unwrap()`)
|
|
||||||
- 跨平台托盘处理
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
|
|
||||||
**同步机制**:
|
|
||||||
|
|
||||||
- 跨所有应用的 MCP 同步
|
|
||||||
- Gemini 表单-编辑器同步
|
|
||||||
- 双文件读取(.env + settings.json)
|
|
||||||
|
|
||||||
**验证增强**:
|
|
||||||
|
|
||||||
- 输入边界检查
|
|
||||||
- TOML 引号规范化(CJK)
|
|
||||||
- 自定义字段保留
|
|
||||||
- 增强的错误消息
|
|
||||||
|
|
||||||
### 代码质量
|
|
||||||
|
|
||||||
**类型安全**:
|
|
||||||
|
|
||||||
- 完整的 TypeScript 覆盖
|
|
||||||
- Rust 类型改进
|
|
||||||
- API 契约验证
|
|
||||||
|
|
||||||
**测试**:
|
|
||||||
|
|
||||||
- 简化的断言
|
|
||||||
- 更好的测试覆盖
|
|
||||||
- 集成测试更新
|
|
||||||
|
|
||||||
**依赖项**:
|
|
||||||
|
|
||||||
- Tauri 2.8.x
|
|
||||||
- Rust:`anyhow`、`zip`、`serde_yaml`、`tempfile`
|
|
||||||
- 前端:CodeMirror 6 包
|
|
||||||
- winreg 0.52(Windows)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 技术统计
|
|
||||||
|
|
||||||
```
|
|
||||||
总体变更:
|
|
||||||
- 提交数:85
|
|
||||||
- 文件数:152 个文件变更
|
|
||||||
- 新增:+18,104 行
|
|
||||||
- 删除:-3,732 行
|
|
||||||
|
|
||||||
新增模块:
|
|
||||||
- Skills 管理:2,034 行(21 个文件)
|
|
||||||
- Prompts 管理:1,302 行(20 个文件)
|
|
||||||
- Gemini 集成:约 1,000 行
|
|
||||||
- MCP 重构:约 3,000 行重构
|
|
||||||
|
|
||||||
代码分布:
|
|
||||||
- 后端(Rust):约 4,500 行新增
|
|
||||||
- 前端(React):约 3,000 行新增
|
|
||||||
- 配置:约 1,500 行重构
|
|
||||||
- 测试:约 500 行
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 战略定位
|
|
||||||
|
|
||||||
### 从工具到平台
|
|
||||||
|
|
||||||
v3.7.0 代表了 CC Switch 定位的转变:
|
|
||||||
|
|
||||||
| 方面 | v3.6 | v3.7.0 |
|
|
||||||
| -------- | -------------- | ----------------------- |
|
|
||||||
| **身份** | 供应商切换器 | AI CLI 管理平台 |
|
|
||||||
| **范围** | 配置管理 | 生态系统管理 |
|
|
||||||
| **应用** | Claude + Codex | Claude + Codex + Gemini |
|
|
||||||
| **能力** | 切换配置 | 扩展能力(Skills) |
|
|
||||||
| **定制** | 手动编辑 | 可视化管理(Prompts) |
|
|
||||||
| **集成** | 孤立应用 | 统一管理(MCP) |
|
|
||||||
|
|
||||||
### AI CLI 管理六大支柱
|
|
||||||
|
|
||||||
1. **配置管理** - 供应商切换和管理
|
|
||||||
2. **能力扩展** - Skills 安装和生命周期
|
|
||||||
3. **行为定制** - 系统提示词预设
|
|
||||||
4. **生态集成** - 深度链接和共享
|
|
||||||
5. **多 AI 支持** - Claude/Codex/Gemini
|
|
||||||
6. **智能检测** - 冲突预防
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 下载与安装
|
|
||||||
|
|
||||||
### 系统要求
|
|
||||||
|
|
||||||
- **Windows**:Windows 10+
|
|
||||||
- **macOS**:macOS 10.15(Catalina)+
|
|
||||||
- **Linux**:Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
|
||||||
|
|
||||||
### 下载链接
|
|
||||||
|
|
||||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载:
|
|
||||||
|
|
||||||
- **Windows**:`CC-Switch-v3.7.0-Windows.msi` 或 `-Portable.zip`
|
|
||||||
- **macOS**:`CC-Switch-v3.7.0-macOS.tar.gz` 或 `.zip`
|
|
||||||
- **Linux**:`CC-Switch-v3.7.0-Linux.AppImage` 或 `.deb`
|
|
||||||
|
|
||||||
### Homebrew(macOS)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
brew install --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
更新:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew upgrade --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 迁移说明
|
|
||||||
|
|
||||||
### 从 v3.6.x 升级
|
|
||||||
|
|
||||||
**自动迁移** - 无需任何操作,配置完全兼容
|
|
||||||
|
|
||||||
### 从 v3.1.x 或更早版本升级
|
|
||||||
|
|
||||||
**需要两步迁移**:
|
|
||||||
|
|
||||||
1. 首先升级到 v3.2.x(执行一次性迁移)
|
|
||||||
2. 然后升级到 v3.7.0
|
|
||||||
|
|
||||||
### 新功能
|
|
||||||
|
|
||||||
- **Skills**:无需迁移,全新开始
|
|
||||||
- **Prompts**:首次启动时从 live 文件自动导入
|
|
||||||
- **Gemini**:需要单独安装 Gemini CLI
|
|
||||||
- **MCP v3.7.0**:与之前的配置向后兼容
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 致谢
|
|
||||||
|
|
||||||
### 贡献者
|
|
||||||
|
|
||||||
感谢所有让这个版本成为可能的贡献者:
|
|
||||||
|
|
||||||
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Geimini 集成实现
|
|
||||||
- [@farion1231](https://github.com/farion1231) - 从开发沦为 issue 回复机
|
|
||||||
- 社区成员的测试和反馈
|
|
||||||
|
|
||||||
### 赞助商
|
|
||||||
|
|
||||||
**Z.ai** - GLM CODING PLAN 赞助商
|
|
||||||
[通过此链接获得 10% 折扣](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
|
||||||
|
|
||||||
**PackyCode** - API 中继服务合作伙伴
|
|
||||||
[使用 "cc-switch" 代码注册可享受 10% 折扣](https://www.packyapi.com/register?aff=cc-switch)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 反馈与支持
|
|
||||||
|
|
||||||
- **问题反馈**:[GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
|
||||||
- **讨论**:[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
|
||||||
- **文档**:[README](../README_ZH.md)
|
|
||||||
- **更新日志**:[CHANGELOG.md](../CHANGELOG.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 未来展望
|
|
||||||
|
|
||||||
**v3.8.0 预览**(暂定):
|
|
||||||
|
|
||||||
- 本地代理功能
|
|
||||||
|
|
||||||
敬请期待更多更新!
|
|
||||||
@@ -1,481 +0,0 @@
|
|||||||
# CC Switch v3.7.1
|
|
||||||
|
|
||||||
> Stability Enhancements and User Experience Improvements
|
|
||||||
|
|
||||||
**[中文更新说明 Chinese Documentation →](release-note-v3.7.1-zh.md)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v3.7.1 Updates
|
|
||||||
|
|
||||||
**Release Date**: 2025-11-22
|
|
||||||
**Code Changes**: 17 files, +524 / -81 lines
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **Fix Third-Party Skills Installation Failure** (#268)
|
|
||||||
Fixed installation issues for skills repositories with custom subdirectories, now supports repos like `ComposioHQ/awesome-claude-skills` with subdirectories
|
|
||||||
|
|
||||||
- **Fix Gemini Configuration Persistence Issue**
|
|
||||||
Resolved the issue where settings.json edits in Gemini form were lost when switching providers
|
|
||||||
|
|
||||||
- **Prevent Dialogs from Closing on Overlay Click**
|
|
||||||
Added protection against clicking overlay/backdrop, preventing accidental form data loss across all 11 dialog components
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
|
|
||||||
- **Gemini Configuration Directory Support** (#255)
|
|
||||||
Added Gemini configuration directory option in settings, supports customizing `~/.gemini/` path
|
|
||||||
|
|
||||||
- **ArchLinux Installation Support** (#259)
|
|
||||||
Added AUR installation method: `paru -S cc-switch-bin`
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
|
|
||||||
- **Skills Error Message i18n Enhancement**
|
|
||||||
Added 28+ detailed error messages (English & Chinese) with specific resolution suggestions, extended download timeout from 15s to 60s
|
|
||||||
|
|
||||||
- **Code Formatting**
|
|
||||||
Applied unified Rust and TypeScript code formatting standards
|
|
||||||
|
|
||||||
### Download
|
|
||||||
|
|
||||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the latest version
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v3.7.0 Complete Release Notes
|
|
||||||
|
|
||||||
> From Provider Switcher to All-in-One AI CLI Management Platform
|
|
||||||
|
|
||||||
**Release Date**: 2025-11-19
|
|
||||||
**Commits**: 85 from v3.6.0
|
|
||||||
**Code Changes**: 152 files, +18,104 / -3,732 lines
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Features
|
|
||||||
|
|
||||||
### Gemini CLI Integration
|
|
||||||
|
|
||||||
Complete support for Google Gemini CLI, becoming the third supported application (Claude Code, Codex, Gemini).
|
|
||||||
|
|
||||||
**Core Capabilities**:
|
|
||||||
|
|
||||||
- **Dual-file configuration** - Support for both `.env` and `settings.json` formats
|
|
||||||
- **Auto-detection** - Automatically detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
|
|
||||||
- **Full MCP support** - Complete MCP server management for Gemini
|
|
||||||
- **Deep link integration** - Import via `ccswitch://` protocol
|
|
||||||
- **System tray** - Quick-switch from tray menu
|
|
||||||
|
|
||||||
**Provider Presets**:
|
|
||||||
|
|
||||||
- **Google Official** - OAuth authentication support
|
|
||||||
- **PackyCode** - Partner integration
|
|
||||||
- **Custom** - Full customization support
|
|
||||||
|
|
||||||
**Technical Implementation**:
|
|
||||||
|
|
||||||
- New backend modules: `gemini_config.rs` (20KB), `gemini_mcp.rs`
|
|
||||||
- Form synchronization with environment editor
|
|
||||||
- Dual-file atomic writes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### MCP v3.7.0 Unified Architecture
|
|
||||||
|
|
||||||
Complete refactoring of MCP management system for cross-application unification.
|
|
||||||
|
|
||||||
**Architecture Improvements**:
|
|
||||||
|
|
||||||
- **Unified panel** - Single interface for Claude/Codex/Gemini MCP servers
|
|
||||||
- **SSE transport** - New Server-Sent Events support
|
|
||||||
- **Smart parser** - Fault-tolerant JSON parsing
|
|
||||||
- **Format correction** - Auto-fix Codex `[mcp_servers]` format
|
|
||||||
- **Extended fields** - Preserve custom TOML fields
|
|
||||||
|
|
||||||
**User Experience**:
|
|
||||||
|
|
||||||
- Default app selection in forms
|
|
||||||
- JSON formatter for validation
|
|
||||||
- Improved visual hierarchy
|
|
||||||
- Better error messages
|
|
||||||
|
|
||||||
**Import/Export**:
|
|
||||||
|
|
||||||
- Unified import from all three apps
|
|
||||||
- Bidirectional synchronization
|
|
||||||
- State preservation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Claude Skills Management System
|
|
||||||
|
|
||||||
**Approximately 2,000 lines of code** - A complete skill ecosystem platform.
|
|
||||||
|
|
||||||
**GitHub Integration**:
|
|
||||||
|
|
||||||
- Auto-scan skills from GitHub repositories
|
|
||||||
- Pre-configured repos:
|
|
||||||
- `ComposioHQ/awesome-claude-skills` - Curated collection
|
|
||||||
- `anthropics/skills` - Official Anthropic skills
|
|
||||||
- `cexll/myclaude` - Community contributions
|
|
||||||
- Add custom repositories
|
|
||||||
- Subdirectory scanning support (`skillsPath`)
|
|
||||||
|
|
||||||
**Lifecycle Management**:
|
|
||||||
|
|
||||||
- **Discover** - Auto-detect `SKILL.md` files
|
|
||||||
- **Install** - One-click to `~/.claude/skills/`
|
|
||||||
- **Uninstall** - Safe removal with tracking
|
|
||||||
- **Update** - Check for updates (infrastructure ready)
|
|
||||||
|
|
||||||
**Technical Architecture**:
|
|
||||||
|
|
||||||
- **Backend**: `SkillService` (526 lines) with GitHub API integration
|
|
||||||
- **Frontend**: SkillsPage, SkillCard, RepoManager
|
|
||||||
- **UI Components**: Badge, Card, Table (shadcn/ui)
|
|
||||||
- **State**: Persistent storage in `config.json`
|
|
||||||
- **i18n**: 47+ translation keys
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Prompts Management System
|
|
||||||
|
|
||||||
**Approximately 1,300 lines of code** - Complete system prompt management.
|
|
||||||
|
|
||||||
**Multi-Preset Management**:
|
|
||||||
|
|
||||||
- Create unlimited prompt presets
|
|
||||||
- Quick switch between presets
|
|
||||||
- One active prompt at a time
|
|
||||||
- Delete protection for active prompts
|
|
||||||
|
|
||||||
**Cross-App Support**:
|
|
||||||
|
|
||||||
- **Claude**: `~/.claude/CLAUDE.md`
|
|
||||||
- **Codex**: `~/.codex/AGENTS.md`
|
|
||||||
- **Gemini**: `~/.gemini/GEMINI.md`
|
|
||||||
|
|
||||||
**Markdown Editor**:
|
|
||||||
|
|
||||||
- Full-featured CodeMirror 6 integration
|
|
||||||
- Syntax highlighting
|
|
||||||
- Dark theme (One Dark)
|
|
||||||
- Real-time preview
|
|
||||||
|
|
||||||
**Smart Synchronization**:
|
|
||||||
|
|
||||||
- **Auto-write** - Immediately write to live files
|
|
||||||
- **Backfill protection** - Save current content before switching
|
|
||||||
- **Auto-import** - Import from live files on first launch
|
|
||||||
- **Modification protection** - Preserve manual modifications
|
|
||||||
|
|
||||||
**Technical Implementation**:
|
|
||||||
|
|
||||||
- **Backend**: `PromptService` (213 lines)
|
|
||||||
- **Frontend**: PromptPanel (177), PromptFormModal (160), MarkdownEditor (159)
|
|
||||||
- **Hooks**: usePromptActions (152 lines)
|
|
||||||
- **i18n**: 41+ translation keys
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Deep Link Protocol (ccswitch://)
|
|
||||||
|
|
||||||
One-click provider configuration import via URL scheme.
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
|
|
||||||
- Protocol registration on all platforms
|
|
||||||
- Import from shared links
|
|
||||||
- Lifecycle integration
|
|
||||||
- Security validation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Environment Variable Conflict Detection
|
|
||||||
|
|
||||||
Intelligent detection and management of configuration conflicts.
|
|
||||||
|
|
||||||
**Detection Scope**:
|
|
||||||
|
|
||||||
- **Claude & Codex** - Cross-app conflicts
|
|
||||||
- **Gemini** - Auto-discovery
|
|
||||||
- **MCP** - Server configuration conflicts
|
|
||||||
|
|
||||||
**Management Features**:
|
|
||||||
|
|
||||||
- Visual conflict indicators
|
|
||||||
- Resolution suggestions
|
|
||||||
- Override warnings
|
|
||||||
- Backup before changes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Improvements
|
|
||||||
|
|
||||||
### Provider Management
|
|
||||||
|
|
||||||
**New Presets**:
|
|
||||||
|
|
||||||
- **DouBaoSeed** - ByteDance's DouBao
|
|
||||||
- **Kimi For Coding** - Moonshot AI
|
|
||||||
- **BaiLing** - BaiLing AI
|
|
||||||
- **Removed AnyRouter** - To avoid confusion
|
|
||||||
|
|
||||||
**Enhancements**:
|
|
||||||
|
|
||||||
- Model name configuration for Codex and Gemini
|
|
||||||
- Provider notes field for organization
|
|
||||||
- Enhanced preset metadata
|
|
||||||
|
|
||||||
### Configuration Management
|
|
||||||
|
|
||||||
- **Common config migration** - From localStorage to `config.json`
|
|
||||||
- **Unified persistence** - Shared across all apps
|
|
||||||
- **Auto-import** - First launch configuration import
|
|
||||||
- **Backfill priority** - Correct handling of live files
|
|
||||||
|
|
||||||
### UI/UX Improvements
|
|
||||||
|
|
||||||
**Design System**:
|
|
||||||
|
|
||||||
- **macOS native** - System-aligned color scheme
|
|
||||||
- **Window centering** - Default centered position
|
|
||||||
- **Visual polish** - Improved spacing and hierarchy
|
|
||||||
|
|
||||||
**Interactions**:
|
|
||||||
|
|
||||||
- **Password input** - Fixed Edge/IE reveal buttons
|
|
||||||
- **URL overflow** - Fixed card overflow
|
|
||||||
- **Error copying** - Copy-to-clipboard errors
|
|
||||||
- **Tray sync** - Real-time drag-and-drop sync
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bug Fixes
|
|
||||||
|
|
||||||
### Critical Fixes
|
|
||||||
|
|
||||||
- **Usage script validation** - Boundary checks
|
|
||||||
- **Gemini validation** - Relaxed constraints
|
|
||||||
- **TOML parsing** - CJK quote handling
|
|
||||||
- **MCP fields** - Custom field preservation
|
|
||||||
- **White screen** - FormLabel crash fix
|
|
||||||
|
|
||||||
### Stability
|
|
||||||
|
|
||||||
- **Tray safety** - Pattern matching instead of unwrap
|
|
||||||
- **Error isolation** - Tray failures don't block operations
|
|
||||||
- **Import classification** - Correct category assignment
|
|
||||||
|
|
||||||
### UI Fixes
|
|
||||||
|
|
||||||
- **Model placeholders** - Removed misleading hints
|
|
||||||
- **Base URL** - Auto-fill for third-party providers
|
|
||||||
- **Drag sort** - Tray menu synchronization
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technical Improvements
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
**MCP v3.7.0**:
|
|
||||||
|
|
||||||
- Removed legacy code (~1,000 lines)
|
|
||||||
- Unified initialization structure
|
|
||||||
- Backward compatibility maintained
|
|
||||||
- Comprehensive code formatting
|
|
||||||
|
|
||||||
**Platform Compatibility**:
|
|
||||||
|
|
||||||
- Windows winreg API fix (v0.52)
|
|
||||||
- Safe pattern matching (no `unwrap()`)
|
|
||||||
- Cross-platform tray handling
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
**Synchronization**:
|
|
||||||
|
|
||||||
- MCP sync across all apps
|
|
||||||
- Gemini form-editor sync
|
|
||||||
- Dual-file reading (.env + settings.json)
|
|
||||||
|
|
||||||
**Validation**:
|
|
||||||
|
|
||||||
- Input boundary checks
|
|
||||||
- TOML quote normalization (CJK)
|
|
||||||
- Custom field preservation
|
|
||||||
- Enhanced error messages
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
**Type Safety**:
|
|
||||||
|
|
||||||
- Complete TypeScript coverage
|
|
||||||
- Rust type refinements
|
|
||||||
- API contract validation
|
|
||||||
|
|
||||||
**Testing**:
|
|
||||||
|
|
||||||
- Simplified assertions
|
|
||||||
- Better test coverage
|
|
||||||
- Integration test updates
|
|
||||||
|
|
||||||
**Dependencies**:
|
|
||||||
|
|
||||||
- Tauri 2.8.x
|
|
||||||
- Rust: `anyhow`, `zip`, `serde_yaml`, `tempfile`
|
|
||||||
- Frontend: CodeMirror 6 packages
|
|
||||||
- winreg 0.52 (Windows)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technical Statistics
|
|
||||||
|
|
||||||
```
|
|
||||||
Total Changes:
|
|
||||||
- Commits: 85
|
|
||||||
- Files: 152 changed
|
|
||||||
- Additions: +18,104 lines
|
|
||||||
- Deletions: -3,732 lines
|
|
||||||
|
|
||||||
New Modules:
|
|
||||||
- Skills Management: 2,034 lines (21 files)
|
|
||||||
- Prompts Management: 1,302 lines (20 files)
|
|
||||||
- Gemini Integration: ~1,000 lines
|
|
||||||
- MCP Refactor: ~3,000 lines refactored
|
|
||||||
|
|
||||||
Code Distribution:
|
|
||||||
- Backend (Rust): ~4,500 lines new
|
|
||||||
- Frontend (React): ~3,000 lines new
|
|
||||||
- Configuration: ~1,500 lines refactored
|
|
||||||
- Tests: ~500 lines
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Strategic Positioning
|
|
||||||
|
|
||||||
### From Tool to Platform
|
|
||||||
|
|
||||||
v3.7.0 represents a shift in CC Switch's positioning:
|
|
||||||
|
|
||||||
| Aspect | v3.6 | v3.7.0 |
|
|
||||||
| ----------------- | ------------------------ | ---------------------------- |
|
|
||||||
| **Identity** | Provider Switcher | AI CLI Management Platform |
|
|
||||||
| **Scope** | Configuration Management | Ecosystem Management |
|
|
||||||
| **Applications** | Claude + Codex | Claude + Codex + Gemini |
|
|
||||||
| **Capabilities** | Switch configs | Extend capabilities (Skills) |
|
|
||||||
| **Customization** | Manual editing | Visual management (Prompts) |
|
|
||||||
| **Integration** | Isolated apps | Unified management (MCP) |
|
|
||||||
|
|
||||||
### Six Pillars of AI CLI Management
|
|
||||||
|
|
||||||
1. **Configuration Management** - Provider switching and management
|
|
||||||
2. **Capability Extension** - Skills installation and lifecycle
|
|
||||||
3. **Behavior Customization** - System prompt presets
|
|
||||||
4. **Ecosystem Integration** - Deep links and sharing
|
|
||||||
5. **Multi-AI Support** - Claude/Codex/Gemini
|
|
||||||
6. **Intelligent Detection** - Conflict prevention
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Download & Installation
|
|
||||||
|
|
||||||
### System Requirements
|
|
||||||
|
|
||||||
- **Windows**: Windows 10+
|
|
||||||
- **macOS**: macOS 10.15 (Catalina)+
|
|
||||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ / ArchLinux
|
|
||||||
|
|
||||||
### Download Links
|
|
||||||
|
|
||||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:
|
|
||||||
|
|
||||||
- **Windows**: `CC-Switch-Windows.msi` or `-Portable.zip`
|
|
||||||
- **macOS**: `CC-Switch-macOS.tar.gz` or `.zip`
|
|
||||||
- **Linux**: `CC-Switch-Linux.AppImage` or `.deb`
|
|
||||||
- **ArchLinux**: `paru -S cc-switch-bin`
|
|
||||||
|
|
||||||
### Homebrew (macOS)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
brew install --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
Update:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew upgrade --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
### From v3.6.x
|
|
||||||
|
|
||||||
**Automatic migration** - No action required, configs are fully compatible
|
|
||||||
|
|
||||||
### From v3.1.x or Earlier
|
|
||||||
|
|
||||||
**Two-step migration required**:
|
|
||||||
|
|
||||||
1. First upgrade to v3.2.x (performs one-time migration)
|
|
||||||
2. Then upgrade to v3.7.0
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
|
|
||||||
- **Skills**: No migration needed, start fresh
|
|
||||||
- **Prompts**: Auto-import from live files on first launch
|
|
||||||
- **Gemini**: Install Gemini CLI separately if needed
|
|
||||||
- **MCP v3.7.0**: Backward compatible with previous configs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acknowledgments
|
|
||||||
|
|
||||||
### Contributors
|
|
||||||
|
|
||||||
Thanks to all contributors who made this release possible:
|
|
||||||
|
|
||||||
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini integration implementation
|
|
||||||
- [@farion1231](https://github.com/farion1231) - From developer to issue responder
|
|
||||||
- Community members for testing and feedback
|
|
||||||
|
|
||||||
### Sponsors
|
|
||||||
|
|
||||||
**Z.ai** - GLM CODING PLAN sponsor
|
|
||||||
[Get 10% OFF with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
|
||||||
|
|
||||||
**PackyCode** - API relay service partner
|
|
||||||
[Register with "cc-switch" code for 10% discount](https://www.packyapi.com/register?aff=cc-switch)
|
|
||||||
|
|
||||||
**ShanDianShuo** - Local-first AI voice input
|
|
||||||
[Free download](https://shandianshuo.cn) for Mac/Win
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feedback & Support
|
|
||||||
|
|
||||||
- **Issues**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
|
||||||
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
|
||||||
- **Documentation**: [README](../README.md)
|
|
||||||
- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What's Next
|
|
||||||
|
|
||||||
**v3.8.0 Preview** (Tentative):
|
|
||||||
|
|
||||||
- Local proxy functionality
|
|
||||||
|
|
||||||
Stay tuned for more updates!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Happy Coding!**
|
|
||||||
@@ -1,481 +0,0 @@
|
|||||||
# CC Switch v3.7.1
|
|
||||||
|
|
||||||
> 稳定性增强与用户体验改进
|
|
||||||
|
|
||||||
**[English Version →](release-note-v3.7.1-en.md)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v3.7.1 更新内容
|
|
||||||
|
|
||||||
**发布日期**:2025-11-22
|
|
||||||
**代码变更**:17 个文件,+524 / -81 行
|
|
||||||
|
|
||||||
### Bug 修复
|
|
||||||
|
|
||||||
- **修复 Skills 第三方仓库安装失败** (#268)
|
|
||||||
修复使用自定义子目录的 skills 仓库无法安装的问题,支持类似 `ComposioHQ/awesome-claude-skills` 这样带子目录的仓库
|
|
||||||
|
|
||||||
- **修复 Gemini 配置持久化问题**
|
|
||||||
解决在 Gemini 表单中编辑 settings.json 后,切换供应商时修改丢失的问题
|
|
||||||
|
|
||||||
- **防止对话框意外关闭**
|
|
||||||
添加点击遮罩时的保护,避免误操作导致表单数据丢失,影响所有 11 个对话框组件
|
|
||||||
|
|
||||||
### 新增功能
|
|
||||||
|
|
||||||
- **Gemini 配置目录支持** (#255)
|
|
||||||
在设置中添加 Gemini 配置目录选项,支持自定义 `~/.gemini/` 路径
|
|
||||||
|
|
||||||
- **ArchLinux 安装支持** (#259)
|
|
||||||
添加 AUR 安装方式:`paru -S cc-switch-bin`
|
|
||||||
|
|
||||||
### 改进
|
|
||||||
|
|
||||||
- **Skills 错误消息国际化增强**
|
|
||||||
新增 28+ 条详细错误消息(中英文),提供具体的解决建议,下载超时从 15 秒延长到 60 秒
|
|
||||||
|
|
||||||
- **代码格式化**
|
|
||||||
应用统一的 Rust 和 TypeScript 代码格式化标准
|
|
||||||
|
|
||||||
### 下载
|
|
||||||
|
|
||||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载最新版本
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v3.7.0 完整更新说明
|
|
||||||
|
|
||||||
> 从供应商切换器到 AI CLI 一体化管理平台
|
|
||||||
|
|
||||||
**发布日期**:2025-11-19
|
|
||||||
**提交数量**:从 v3.6.0 开始 85 个提交
|
|
||||||
**代码变更**:152 个文件,+18,104 / -3,732 行
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 新增功能
|
|
||||||
|
|
||||||
### Gemini CLI 集成
|
|
||||||
|
|
||||||
完整支持 Google Gemini CLI,成为第三个支持的应用(Claude Code、Codex、Gemini)。
|
|
||||||
|
|
||||||
**核心能力**:
|
|
||||||
|
|
||||||
- **双文件配置** - 同时支持 `.env` 和 `settings.json` 格式
|
|
||||||
- **自动检测** - 自动检测 `GOOGLE_GEMINI_BASE_URL`、`GEMINI_MODEL` 等环境变量
|
|
||||||
- **完整 MCP 支持** - 为 Gemini 提供完整的 MCP 服务器管理
|
|
||||||
- **深度链接集成** - 通过 `ccswitch://` 协议导入配置
|
|
||||||
- **系统托盘** - 从托盘菜单快速切换
|
|
||||||
|
|
||||||
**供应商预设**:
|
|
||||||
|
|
||||||
- **Google Official** - 支持 OAuth 认证
|
|
||||||
- **PackyCode** - 合作伙伴集成
|
|
||||||
- **自定义** - 完全自定义支持
|
|
||||||
|
|
||||||
**技术实现**:
|
|
||||||
|
|
||||||
- 新增后端模块:`gemini_config.rs`(20KB)、`gemini_mcp.rs`
|
|
||||||
- 表单与环境编辑器同步
|
|
||||||
- 双文件原子写入
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### MCP v3.7.0 统一架构
|
|
||||||
|
|
||||||
MCP 管理系统完整重构,实现跨应用统一管理。
|
|
||||||
|
|
||||||
**架构改进**:
|
|
||||||
|
|
||||||
- **统一管理面板** - 单一界面管理 Claude/Codex/Gemini MCP 服务器
|
|
||||||
- **SSE 传输类型** - 新增 Server-Sent Events 支持
|
|
||||||
- **智能解析器** - 容错性 JSON 解析
|
|
||||||
- **格式修正** - 自动修复 Codex `[mcp_servers]` 格式
|
|
||||||
- **扩展字段** - 保留自定义 TOML 字段
|
|
||||||
|
|
||||||
**用户体验**:
|
|
||||||
|
|
||||||
- 表单中的默认应用选择
|
|
||||||
- JSON 格式化器用于验证
|
|
||||||
- 改进的视觉层次
|
|
||||||
- 更好的错误消息
|
|
||||||
|
|
||||||
**导入/导出**:
|
|
||||||
|
|
||||||
- 统一从三个应用导入
|
|
||||||
- 双向同步
|
|
||||||
- 状态保持
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Claude Skills 管理系统
|
|
||||||
|
|
||||||
**约 2,000 行代码** - 完整的技能生态平台。
|
|
||||||
|
|
||||||
**GitHub 集成**:
|
|
||||||
|
|
||||||
- 从 GitHub 仓库自动扫描技能
|
|
||||||
- 预配置仓库:
|
|
||||||
- `ComposioHQ/awesome-claude-skills` - 精选集合
|
|
||||||
- `anthropics/skills` - Anthropic 官方技能
|
|
||||||
- `cexll/myclaude` - 社区贡献
|
|
||||||
- 添加自定义仓库
|
|
||||||
- 子目录扫描支持(`skillsPath`)
|
|
||||||
|
|
||||||
**生命周期管理**:
|
|
||||||
|
|
||||||
- **发现** - 自动检测 `SKILL.md` 文件
|
|
||||||
- **安装** - 一键安装到 `~/.claude/skills/`
|
|
||||||
- **卸载** - 安全移除并跟踪状态
|
|
||||||
- **更新** - 检查更新(基础设施已就绪)
|
|
||||||
|
|
||||||
**技术架构**:
|
|
||||||
|
|
||||||
- **后端**:`SkillService`(526 行)集成 GitHub API
|
|
||||||
- **前端**:SkillsPage、SkillCard、RepoManager
|
|
||||||
- **UI 组件**:Badge、Card、Table(shadcn/ui)
|
|
||||||
- **状态**:持久化存储在 `config.json`
|
|
||||||
- **国际化**:47+ 个翻译键
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Prompts 管理系统
|
|
||||||
|
|
||||||
**约 1,300 行代码** - 完整的系统提示词管理。
|
|
||||||
|
|
||||||
**多预设管理**:
|
|
||||||
|
|
||||||
- 创建无限数量的提示词预设
|
|
||||||
- 快速在预设间切换
|
|
||||||
- 同时只能激活一个提示词
|
|
||||||
- 活动提示词删除保护
|
|
||||||
|
|
||||||
**跨应用支持**:
|
|
||||||
|
|
||||||
- **Claude**:`~/.claude/CLAUDE.md`
|
|
||||||
- **Codex**:`~/.codex/AGENTS.md`
|
|
||||||
- **Gemini**:`~/.gemini/GEMINI.md`
|
|
||||||
|
|
||||||
**Markdown 编辑器**:
|
|
||||||
|
|
||||||
- 完整的 CodeMirror 6 集成
|
|
||||||
- 语法高亮
|
|
||||||
- 暗色主题(One Dark)
|
|
||||||
- 实时预览
|
|
||||||
|
|
||||||
**智能同步**:
|
|
||||||
|
|
||||||
- **自动写入** - 立即写入 live 文件
|
|
||||||
- **回填保护** - 切换前保存当前内容
|
|
||||||
- **自动导入** - 首次启动从 live 文件导入
|
|
||||||
- **修改保护** - 保留手动修改
|
|
||||||
|
|
||||||
**技术实现**:
|
|
||||||
|
|
||||||
- **后端**:`PromptService`(213 行)
|
|
||||||
- **前端**:PromptPanel(177)、PromptFormModal(160)、MarkdownEditor(159)
|
|
||||||
- **Hooks**:usePromptActions(152 行)
|
|
||||||
- **国际化**:41+ 个翻译键
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 深度链接协议(ccswitch://)
|
|
||||||
|
|
||||||
通过 URL 方案一键导入供应商配置。
|
|
||||||
|
|
||||||
**功能特性**:
|
|
||||||
|
|
||||||
- 所有平台的协议注册
|
|
||||||
- 从共享链接导入
|
|
||||||
- 生命周期集成
|
|
||||||
- 安全验证
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 环境变量冲突检测
|
|
||||||
|
|
||||||
智能检测和管理配置冲突。
|
|
||||||
|
|
||||||
**检测范围**:
|
|
||||||
|
|
||||||
- **Claude & Codex** - 跨应用冲突
|
|
||||||
- **Gemini** - 自动发现
|
|
||||||
- **MCP** - 服务器配置冲突
|
|
||||||
|
|
||||||
**管理功能**:
|
|
||||||
|
|
||||||
- 可视化冲突指示器
|
|
||||||
- 解决建议
|
|
||||||
- 覆盖警告
|
|
||||||
- 更改前备份
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 改进优化
|
|
||||||
|
|
||||||
### 供应商管理
|
|
||||||
|
|
||||||
**新增预设**:
|
|
||||||
|
|
||||||
- **DouBaoSeed** - 字节跳动的豆包
|
|
||||||
- **Kimi For Coding** - 月之暗面
|
|
||||||
- **BaiLing** - 百灵 AI
|
|
||||||
- **移除 AnyRouter** - 避免误导
|
|
||||||
|
|
||||||
**增强功能**:
|
|
||||||
|
|
||||||
- Codex 和 Gemini 的模型名称配置
|
|
||||||
- 供应商备注字段用于组织
|
|
||||||
- 增强的预设元数据
|
|
||||||
|
|
||||||
### 配置管理
|
|
||||||
|
|
||||||
- **通用配置迁移** - 从 localStorage 迁移到 `config.json`
|
|
||||||
- **统一持久化** - 跨所有应用共享
|
|
||||||
- **自动导入** - 首次启动配置导入
|
|
||||||
- **回填优先级** - 正确处理 live 文件
|
|
||||||
|
|
||||||
### UI/UX 改进
|
|
||||||
|
|
||||||
**设计系统**:
|
|
||||||
|
|
||||||
- **macOS 原生** - 与系统对齐的配色方案
|
|
||||||
- **窗口居中** - 默认居中位置
|
|
||||||
- **视觉优化** - 改进的间距和层次
|
|
||||||
|
|
||||||
**交互优化**:
|
|
||||||
|
|
||||||
- **密码输入** - 修复 Edge/IE 显示按钮
|
|
||||||
- **URL 溢出** - 修复卡片溢出
|
|
||||||
- **错误复制** - 可复制到剪贴板的错误
|
|
||||||
- **托盘同步** - 实时拖放同步
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bug 修复
|
|
||||||
|
|
||||||
### 关键修复
|
|
||||||
|
|
||||||
- **用量脚本验证** - 边界检查
|
|
||||||
- **Gemini 验证** - 放宽约束
|
|
||||||
- **TOML 解析** - CJK 引号处理
|
|
||||||
- **MCP 字段** - 自定义字段保留
|
|
||||||
- **白屏** - FormLabel 崩溃修复
|
|
||||||
|
|
||||||
### 稳定性
|
|
||||||
|
|
||||||
- **托盘安全** - 模式匹配替代 unwrap
|
|
||||||
- **错误隔离** - 托盘失败不阻塞操作
|
|
||||||
- **导入分类** - 正确的类别分配
|
|
||||||
|
|
||||||
### UI 修复
|
|
||||||
|
|
||||||
- **模型占位符** - 移除误导性提示
|
|
||||||
- **Base URL** - 第三方供应商自动填充
|
|
||||||
- **拖拽排序** - 托盘菜单同步
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 技术改进
|
|
||||||
|
|
||||||
### 架构
|
|
||||||
|
|
||||||
**MCP v3.7.0**:
|
|
||||||
|
|
||||||
- 移除遗留代码(约 1,000 行)
|
|
||||||
- 统一初始化结构
|
|
||||||
- 保持向后兼容性
|
|
||||||
- 全面的代码格式化
|
|
||||||
|
|
||||||
**平台兼容性**:
|
|
||||||
|
|
||||||
- Windows winreg API 修复(v0.52)
|
|
||||||
- 安全模式匹配(无 `unwrap()`)
|
|
||||||
- 跨平台托盘处理
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
|
|
||||||
**同步机制**:
|
|
||||||
|
|
||||||
- 跨所有应用的 MCP 同步
|
|
||||||
- Gemini 表单-编辑器同步
|
|
||||||
- 双文件读取(.env + settings.json)
|
|
||||||
|
|
||||||
**验证增强**:
|
|
||||||
|
|
||||||
- 输入边界检查
|
|
||||||
- TOML 引号规范化(CJK)
|
|
||||||
- 自定义字段保留
|
|
||||||
- 增强的错误消息
|
|
||||||
|
|
||||||
### 代码质量
|
|
||||||
|
|
||||||
**类型安全**:
|
|
||||||
|
|
||||||
- 完整的 TypeScript 覆盖
|
|
||||||
- Rust 类型改进
|
|
||||||
- API 契约验证
|
|
||||||
|
|
||||||
**测试**:
|
|
||||||
|
|
||||||
- 简化的断言
|
|
||||||
- 更好的测试覆盖
|
|
||||||
- 集成测试更新
|
|
||||||
|
|
||||||
**依赖项**:
|
|
||||||
|
|
||||||
- Tauri 2.8.x
|
|
||||||
- Rust:`anyhow`、`zip`、`serde_yaml`、`tempfile`
|
|
||||||
- 前端:CodeMirror 6 包
|
|
||||||
- winreg 0.52(Windows)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 技术统计
|
|
||||||
|
|
||||||
```
|
|
||||||
总体变更:
|
|
||||||
- 提交数:85
|
|
||||||
- 文件数:152 个文件变更
|
|
||||||
- 新增:+18,104 行
|
|
||||||
- 删除:-3,732 行
|
|
||||||
|
|
||||||
新增模块:
|
|
||||||
- Skills 管理:2,034 行(21 个文件)
|
|
||||||
- Prompts 管理:1,302 行(20 个文件)
|
|
||||||
- Gemini 集成:约 1,000 行
|
|
||||||
- MCP 重构:约 3,000 行重构
|
|
||||||
|
|
||||||
代码分布:
|
|
||||||
- 后端(Rust):约 4,500 行新增
|
|
||||||
- 前端(React):约 3,000 行新增
|
|
||||||
- 配置:约 1,500 行重构
|
|
||||||
- 测试:约 500 行
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 战略定位
|
|
||||||
|
|
||||||
### 从工具到平台
|
|
||||||
|
|
||||||
v3.7.0 代表了 CC Switch 定位的转变:
|
|
||||||
|
|
||||||
| 方面 | v3.6 | v3.7.0 |
|
|
||||||
| -------- | -------------- | ----------------------- |
|
|
||||||
| **身份** | 供应商切换器 | AI CLI 管理平台 |
|
|
||||||
| **范围** | 配置管理 | 生态系统管理 |
|
|
||||||
| **应用** | Claude + Codex | Claude + Codex + Gemini |
|
|
||||||
| **能力** | 切换配置 | 扩展能力(Skills) |
|
|
||||||
| **定制** | 手动编辑 | 可视化管理(Prompts) |
|
|
||||||
| **集成** | 孤立应用 | 统一管理(MCP) |
|
|
||||||
|
|
||||||
### AI CLI 管理六大支柱
|
|
||||||
|
|
||||||
1. **配置管理** - 供应商切换和管理
|
|
||||||
2. **能力扩展** - Skills 安装和生命周期
|
|
||||||
3. **行为定制** - 系统提示词预设
|
|
||||||
4. **生态集成** - 深度链接和共享
|
|
||||||
5. **多 AI 支持** - Claude/Codex/Gemini
|
|
||||||
6. **智能检测** - 冲突预防
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 下载与安装
|
|
||||||
|
|
||||||
### 系统要求
|
|
||||||
|
|
||||||
- **Windows**:Windows 10+
|
|
||||||
- **macOS**:macOS 10.15(Catalina)+
|
|
||||||
- **Linux**:Ubuntu 22.04+ / Debian 11+ / Fedora 34+ / ArchLinux
|
|
||||||
|
|
||||||
### 下载链接
|
|
||||||
|
|
||||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载:
|
|
||||||
|
|
||||||
- **Windows**:`CC-Switch-Windows.msi` 或 `-Portable.zip`
|
|
||||||
- **macOS**:`CC-Switch-macOS.tar.gz` 或 `.zip`
|
|
||||||
- **Linux**:`CC-Switch-Linux.AppImage` 或 `.deb`
|
|
||||||
- **ArchLinux**:`paru -S cc-switch-bin`
|
|
||||||
|
|
||||||
### Homebrew(macOS)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
brew install --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
更新:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew upgrade --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 迁移说明
|
|
||||||
|
|
||||||
### 从 v3.6.x 升级
|
|
||||||
|
|
||||||
**自动迁移** - 无需任何操作,配置完全兼容
|
|
||||||
|
|
||||||
### 从 v3.1.x 或更早版本升级
|
|
||||||
|
|
||||||
**需要两步迁移**:
|
|
||||||
|
|
||||||
1. 首先升级到 v3.2.x(执行一次性迁移)
|
|
||||||
2. 然后升级到 v3.7.0
|
|
||||||
|
|
||||||
### 新功能
|
|
||||||
|
|
||||||
- **Skills**:无需迁移,全新开始
|
|
||||||
- **Prompts**:首次启动时从 live 文件自动导入
|
|
||||||
- **Gemini**:需要单独安装 Gemini CLI
|
|
||||||
- **MCP v3.7.0**:与之前的配置向后兼容
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 致谢
|
|
||||||
|
|
||||||
### 贡献者
|
|
||||||
|
|
||||||
感谢所有让这个版本成为可能的贡献者:
|
|
||||||
|
|
||||||
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini 集成实现
|
|
||||||
- [@farion1231](https://github.com/farion1231) - 从开发沦为 issue 回复机
|
|
||||||
- 社区成员的测试和反馈
|
|
||||||
|
|
||||||
### 赞助商
|
|
||||||
|
|
||||||
**智谱AI** - GLM CODING PLAN 赞助商
|
|
||||||
[使用此链接购买可享九折优惠](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
|
|
||||||
|
|
||||||
**PackyCode** - API 中转服务合作伙伴
|
|
||||||
[使用 "cc-switch" 优惠码注册享 9 折优惠](https://www.packyapi.com/register?aff=cc-switch)
|
|
||||||
|
|
||||||
**闪电说** - 本地优先的 AI 语音输入法
|
|
||||||
[免费下载](https://shandianshuo.cn) Mac/Win 双平台
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 反馈与支持
|
|
||||||
|
|
||||||
- **问题反馈**:[GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
|
||||||
- **讨论**:[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
|
||||||
- **文档**:[README](../README_ZH.md)
|
|
||||||
- **更新日志**:[CHANGELOG.md](../CHANGELOG.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 未来展望
|
|
||||||
|
|
||||||
**v3.8.0 预览**(暂定):
|
|
||||||
|
|
||||||
- 本地代理功能
|
|
||||||
|
|
||||||
敬请期待更多更新!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Happy Coding!**
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
# CC Switch v3.8.0
|
|
||||||
|
|
||||||
> Persistence Architecture Upgrade, Laying the Foundation for Cloud Sync
|
|
||||||
|
|
||||||
**[中文版 →](release-note-v3.8.0-zh.md) | [日本語版 →](release-note-v3.8.0-ja.md)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
CC Switch v3.8.0 is a major architectural upgrade that restructures the data persistence layer and user interface, laying the foundation for future cloud sync and local proxy features.
|
|
||||||
|
|
||||||
**Release Date**: 2025-11-28
|
|
||||||
**Commits**: 51 commits since v3.7.1
|
|
||||||
**Code Changes**: 207 files, +17,297 / -6,870 lines
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Major Updates
|
|
||||||
|
|
||||||
### Persistence Architecture Upgrade
|
|
||||||
|
|
||||||
Migrated from single JSON file storage to SQLite + JSON dual-layer architecture for hierarchical data management.
|
|
||||||
|
|
||||||
**Architecture Changes**:
|
|
||||||
|
|
||||||
```
|
|
||||||
v3.7.x (Old) v3.8.0 (New)
|
|
||||||
┌─────────────────┐ ┌─────────────────────────────────┐
|
|
||||||
│ config.json │ │ SQLite (Syncable Data) │
|
|
||||||
│ ┌───────────┐ │ │ ├─ providers Provider cfg │
|
|
||||||
│ │ providers │ │ │ ├─ mcp_servers MCP servers │
|
|
||||||
│ │ mcp │ │ ──> │ ├─ prompts Prompts │
|
|
||||||
│ │ prompts │ │ │ ├─ skills Skills │
|
|
||||||
│ │ settings │ │ │ └─ settings General cfg │
|
|
||||||
│ └───────────┘ │ ├─────────────────────────────────┤
|
|
||||||
└─────────────────┘ │ JSON (Device-level Data) │
|
|
||||||
│ └─ settings.json Local settings│
|
|
||||||
│ ├─ Window position │
|
|
||||||
│ ├─ Path overrides │
|
|
||||||
│ └─ Current provider ID │
|
|
||||||
└─────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dual-layer Structure Design**:
|
|
||||||
|
|
||||||
| Layer | Storage | Data Types | Sync Strategy |
|
|
||||||
| ---------- | ------- | ------------------------------- | --------------- |
|
|
||||||
| Cloud Sync | SQLite | Providers, MCP, Prompts, Skills | Future syncable |
|
|
||||||
| Device | JSON | Window state, local paths | Stays local |
|
|
||||||
|
|
||||||
**Technical Implementation**:
|
|
||||||
|
|
||||||
- **Schema Version Management** - Supports database structure upgrade migrations
|
|
||||||
- **SQL Import/Export** - `backup.rs` supports SQL dump for cloud storage
|
|
||||||
- **Transaction Support** - SQLite native transactions ensure data consistency
|
|
||||||
- **Auto Migration** - Automatically migrates from `config.json` on first launch
|
|
||||||
|
|
||||||
**Modular Refactoring**:
|
|
||||||
|
|
||||||
```
|
|
||||||
database/
|
|
||||||
├── mod.rs Core Database struct and initialization
|
|
||||||
├── schema.rs Table definitions, schema version migrations
|
|
||||||
├── backup.rs SQL import/export, binary snapshot backup
|
|
||||||
├── migration.rs JSON → SQLite data migration engine
|
|
||||||
└── dao/ Data Access Object layer
|
|
||||||
├── providers.rs Provider CRUD
|
|
||||||
├── mcp.rs MCP server CRUD
|
|
||||||
├── prompts.rs Prompts CRUD
|
|
||||||
├── skills.rs Skills CRUD
|
|
||||||
└── settings.rs Key-value settings storage
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Brand New User Interface
|
|
||||||
|
|
||||||
Completely redesigned UI providing a more modern visual experience.
|
|
||||||
|
|
||||||
**Visual Improvements**:
|
|
||||||
|
|
||||||
- Redesigned interface layout
|
|
||||||
- Unified component styles
|
|
||||||
- Smoother transition animations
|
|
||||||
- Optimized visual hierarchy
|
|
||||||
|
|
||||||
**Interaction Enhancements**:
|
|
||||||
|
|
||||||
- Redesigned header toolbar
|
|
||||||
- Unified ConfirmDialog styling
|
|
||||||
- Disabled overscroll bounce effect on main view
|
|
||||||
- Improved form validation feedback
|
|
||||||
|
|
||||||
**Compatibility Adjustments**:
|
|
||||||
|
|
||||||
- Downgraded Tailwind CSS from v4 to v3.4 for better browser compatibility
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Japanese Language Support
|
|
||||||
|
|
||||||
Added Japanese interface support, expanding internationalization to three languages.
|
|
||||||
|
|
||||||
**Supported Languages**:
|
|
||||||
|
|
||||||
- Simplified Chinese
|
|
||||||
- English
|
|
||||||
- Japanese (New)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Features
|
|
||||||
|
|
||||||
### Skills Recursive Scanning
|
|
||||||
|
|
||||||
Skills management system now supports recursive scanning of repository directories, automatically discovering nested skill files.
|
|
||||||
|
|
||||||
**Improvements**:
|
|
||||||
|
|
||||||
- Support for multi-level directory structures
|
|
||||||
- Automatic discovery of all `SKILL.md` files
|
|
||||||
- Allow same-named skills from different repositories (using full path for deduplication)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Provider Icon Configuration
|
|
||||||
|
|
||||||
Provider presets now support custom icon configuration.
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
|
|
||||||
- Preset providers include default icons
|
|
||||||
- Icon settings preserved when duplicating providers
|
|
||||||
- Custom icon colors
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Enhanced Form Validation
|
|
||||||
|
|
||||||
Provider forms now include required field validation with friendlier error messages.
|
|
||||||
|
|
||||||
**Improvements**:
|
|
||||||
|
|
||||||
- Real-time validation for required fields
|
|
||||||
- Unified Toast notifications for validation errors
|
|
||||||
- Clearer error messages
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Auto Launch on Startup
|
|
||||||
|
|
||||||
Added auto-launch functionality supporting Windows, macOS, and Linux platforms.
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
|
|
||||||
- One-click enable/disable in settings
|
|
||||||
- Implemented using platform-native APIs
|
|
||||||
- Windows uses Registry, macOS uses LaunchAgent, Linux uses XDG autostart
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### New Provider Presets
|
|
||||||
|
|
||||||
- **MiniMax** - Official partner
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bug Fixes
|
|
||||||
|
|
||||||
### Critical Fixes
|
|
||||||
|
|
||||||
**Custom Endpoints Lost Issue**
|
|
||||||
|
|
||||||
Fixed an issue where custom request URLs were unexpectedly lost when updating providers.
|
|
||||||
|
|
||||||
- Root Cause: `INSERT OR REPLACE` executes `DELETE + INSERT` under the hood in SQLite, triggering foreign key cascade deletion
|
|
||||||
- Fix: Changed to use `UPDATE` statement for existing providers
|
|
||||||
|
|
||||||
**Gemini Configuration Issues**
|
|
||||||
|
|
||||||
- Fixed custom provider environment variables not correctly written to `.env` file
|
|
||||||
- Fixed security auth config incorrectly written to other config files
|
|
||||||
|
|
||||||
**Provider Validation Issues**
|
|
||||||
|
|
||||||
- Fixed validation error when current provider ID doesn't exist
|
|
||||||
- Fixed icon fields lost when duplicating providers
|
|
||||||
|
|
||||||
### Platform Compatibility
|
|
||||||
|
|
||||||
**Linux**
|
|
||||||
|
|
||||||
- Resolved WebKitGTK DMA-BUF rendering issue
|
|
||||||
- Preserve user `.desktop` file customizations
|
|
||||||
|
|
||||||
### Other Fixes
|
|
||||||
|
|
||||||
- Fixed redundant usage queries when switching apps
|
|
||||||
- Fixed DMXAPI preset using wrong auth token field
|
|
||||||
- Fixed missing translation keys in deeplink components
|
|
||||||
- Fixed usage script template initialization logic
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technical Improvements
|
|
||||||
|
|
||||||
### Architecture Refactoring
|
|
||||||
|
|
||||||
**Provider Service Modularization**:
|
|
||||||
|
|
||||||
```
|
|
||||||
services/provider/
|
|
||||||
├── mod.rs Core service - add/update/delete/switch/validate
|
|
||||||
├── live.rs Live config file operations
|
|
||||||
├── gemini_auth.rs Gemini auth type detection
|
|
||||||
├── endpoints.rs Custom endpoint management
|
|
||||||
└── usage.rs Usage script execution
|
|
||||||
```
|
|
||||||
|
|
||||||
**Deeplink Modularization**:
|
|
||||||
|
|
||||||
```
|
|
||||||
deeplink/
|
|
||||||
├── mod.rs Module exports
|
|
||||||
├── parser.rs URL parsing
|
|
||||||
├── provider.rs Provider import logic
|
|
||||||
├── mcp.rs MCP import logic
|
|
||||||
├── prompt.rs Prompt import
|
|
||||||
├── skill.rs Skills import
|
|
||||||
└── utils.rs Utility functions
|
|
||||||
```
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
**Cleanup**:
|
|
||||||
|
|
||||||
- Removed legacy JSON-era import/export dead code
|
|
||||||
- Removed unused MCP type exports
|
|
||||||
- Unified error handling approach
|
|
||||||
|
|
||||||
**Test Updates**:
|
|
||||||
|
|
||||||
- Migrated tests to SQLite database architecture
|
|
||||||
- Updated component tests to match current implementation
|
|
||||||
- Fixed MSW handlers to adapt to new API
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technical Statistics
|
|
||||||
|
|
||||||
```
|
|
||||||
Overall Changes:
|
|
||||||
- Commits: 51
|
|
||||||
- Files: 207 files changed
|
|
||||||
- Additions: +17,297 lines
|
|
||||||
- Deletions: -6,870 lines
|
|
||||||
- Net: +10,427 lines
|
|
||||||
|
|
||||||
Commit Type Distribution:
|
|
||||||
- fix: 25 (Bug fixes)
|
|
||||||
- refactor: 11 (Code refactoring)
|
|
||||||
- feat: 9 (New features)
|
|
||||||
- test: 1 (Testing)
|
|
||||||
- other: 5
|
|
||||||
|
|
||||||
Change Area Distribution:
|
|
||||||
- Frontend source: 112 files
|
|
||||||
- Rust backend: 63 files
|
|
||||||
- Test files: 20 files
|
|
||||||
- i18n files: 3 files
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Guide
|
|
||||||
|
|
||||||
### Upgrading from v3.7.x
|
|
||||||
|
|
||||||
**Auto Migration** - Executes automatically on first launch:
|
|
||||||
|
|
||||||
1. Detects if `config.json` exists
|
|
||||||
2. Migrates all data to SQLite within a transaction
|
|
||||||
3. Migrates device-level settings to `settings.json`
|
|
||||||
4. Shows migration success notification
|
|
||||||
|
|
||||||
**Data Safety**:
|
|
||||||
|
|
||||||
- Original `config.json` file is preserved (not deleted)
|
|
||||||
- Error dialog displayed on migration failure, `config.json` preserved
|
|
||||||
- Supports Dry-run mode to verify migration logic
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Download & Installation
|
|
||||||
|
|
||||||
### System Requirements
|
|
||||||
|
|
||||||
- **Windows**: Windows 10+
|
|
||||||
- **macOS**: macOS 10.15 (Catalina)+
|
|
||||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
|
||||||
|
|
||||||
### Download Links
|
|
||||||
|
|
||||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:
|
|
||||||
|
|
||||||
- **Windows**: `CC-Switch-v3.8.0-Windows.msi` or `-Portable.zip`
|
|
||||||
- **macOS**: `CC-Switch-v3.8.0-macOS.tar.gz` or `.zip`
|
|
||||||
- **Linux**: `CC-Switch-v3.8.0-Linux.AppImage` or `.deb`
|
|
||||||
|
|
||||||
### Homebrew (macOS)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
brew install --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
Update:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew upgrade --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
## Acknowledgments
|
|
||||||
|
|
||||||
### Contributors
|
|
||||||
|
|
||||||
Thanks to all contributors who made this release possible:
|
|
||||||
|
|
||||||
- [@YoVinchen](https://github.com/YoVinchen) - UI and database refactoring
|
|
||||||
- [@farion1231](https://github.com/farion1231) - Bug fixes and feature enhancements
|
|
||||||
- Community members for testing and feedback
|
|
||||||
|
|
||||||
### Sponsors
|
|
||||||
|
|
||||||
**Zhipu AI** - GLM CODING PLAN Sponsor
|
|
||||||
[Get 10% off with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
|
||||||
|
|
||||||
**PackyCode** - API Relay Service Partner
|
|
||||||
[Use code "cc-switch" for 10% off registration](https://www.packyapi.com/register?aff=cc-switch)
|
|
||||||
|
|
||||||
**ShandianShuo** - Local-first AI Voice Input
|
|
||||||
[Free download](https://shandianshuo.cn) for Mac/Windows
|
|
||||||
|
|
||||||
**MiniMax** - MiniMax M2 CODING PLAN Sponsor
|
|
||||||
[Black Friday sale, plans starting at $2](https://platform.minimax.io/subscribe/coding-plan)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feedback & Support
|
|
||||||
|
|
||||||
- **Issue Reports**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
|
||||||
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
|
||||||
- **Documentation**: [README](../README.md)
|
|
||||||
- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Roadmap
|
|
||||||
|
|
||||||
**v3.9.0 Preview** (Tentative):
|
|
||||||
|
|
||||||
- Local proxy feature
|
|
||||||
|
|
||||||
Stay tuned for more updates!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Happy Coding!**
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
# CC Switch v3.8.0
|
|
||||||
|
|
||||||
> 永続化アーキテクチャを刷新し、クラウド同期の土台を構築
|
|
||||||
|
|
||||||
**[English →](release-note-v3.8.0-en.md) | [中文版 →](release-note-v3.8.0-zh.md)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 概要
|
|
||||||
|
|
||||||
CC Switch v3.8.0 はデータ永続化レイヤーと UI を大幅に作り替え、今後のクラウド同期やローカルプロキシ機能に向けた基盤を整えたメジャーアップデートです。
|
|
||||||
|
|
||||||
**リリース日**: 2025-11-28
|
|
||||||
**コミット数**: v3.7.1 以降 51 commits
|
|
||||||
**変更量**: 207 files, +17,297 / -6,870 lines
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 主要アップデート
|
|
||||||
|
|
||||||
### 永続化アーキテクチャの刷新
|
|
||||||
|
|
||||||
単一の JSON 保存から、階層化された SQLite + JSON の二層構造へ移行。
|
|
||||||
|
|
||||||
**アーキテクチャ変更**:
|
|
||||||
|
|
||||||
```
|
|
||||||
v3.7.x (旧) v3.8.0 (新)
|
|
||||||
┌─────────────────┐ ┌─────────────────────────────────┐
|
|
||||||
│ config.json │ │ SQLite (同期対象データ) │
|
|
||||||
│ ┌───────────┐ │ │ ├─ providers プロバイダ設定 │
|
|
||||||
│ │ providers │ │ │ ├─ mcp_servers MCP サーバー │
|
|
||||||
│ │ mcp │ │ ──> │ ├─ prompts プロンプト │
|
|
||||||
│ │ prompts │ │ │ ├─ skills Skills │
|
|
||||||
│ │ settings │ │ │ └─ settings 汎用設定 │
|
|
||||||
│ └───────────┘ │ ├─────────────────────────────────┤
|
|
||||||
└─────────────────┘ │ JSON (デバイス固有データ) │
|
|
||||||
│ └─ settings.json ローカル設定 │
|
|
||||||
│ ├─ ウィンドウ位置 │
|
|
||||||
│ ├─ パスの上書き │
|
|
||||||
│ └─ 現在のプロバイダ ID │
|
|
||||||
└─────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**二層構造の設計**:
|
|
||||||
|
|
||||||
| レイヤー | ストレージ | データ種別 | 同期戦略 |
|
|
||||||
| -------- | ---------- | ----------------------------------- | ---------------- |
|
|
||||||
| クラウド | SQLite | Providers, MCP, Prompts, Skills | 将来同期対象 |
|
|
||||||
| デバイス | JSON | ウィンドウ状態、ローカルパス | ローカル保持 |
|
|
||||||
|
|
||||||
**実装ポイント**:
|
|
||||||
|
|
||||||
- **スキーマバージョン管理**: DB 構造のマイグレーションに対応
|
|
||||||
- **SQL インポート/エクスポート**: `backup.rs` が SQL ダンプをサポート
|
|
||||||
- **トランザクション対応**: SQLite ネイティブで整合性を確保
|
|
||||||
- **自動マイグレーション**: 初回起動で `config.json` から自動移行
|
|
||||||
|
|
||||||
**モジュール分割**:
|
|
||||||
|
|
||||||
```
|
|
||||||
database/
|
|
||||||
├── mod.rs Database 構造体と初期化
|
|
||||||
├── schema.rs テーブル定義とスキーマ移行
|
|
||||||
├── backup.rs SQL インポート/エクスポートとスナップショット
|
|
||||||
├── migration.rs JSON → SQLite 変換エンジン
|
|
||||||
└── dao/ DAO レイヤー
|
|
||||||
├── providers.rs プロバイダ CRUD
|
|
||||||
├── mcp.rs MCP CRUD
|
|
||||||
├── prompts.rs プロンプト CRUD
|
|
||||||
├── skills.rs Skills CRUD
|
|
||||||
└── settings.rs 設定 Key-Value 保存
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 新しいユーザーインターフェース
|
|
||||||
|
|
||||||
よりモダンな見た目と操作感に再設計。
|
|
||||||
|
|
||||||
- レイアウト全面刷新、コンポーネントスタイルを統一
|
|
||||||
- トランジションを滑らかにし、視覚的階層を最適化
|
|
||||||
- メインビューのオーバースクロールバウンスを無効化
|
|
||||||
- ブラウザ互換性向上のため Tailwind CSS を v4→v3.4 にダウングレード
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 日語化
|
|
||||||
|
|
||||||
UI が日本語に対応し、国際化が 3 言語(中/英/日)へ拡大。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 新機能
|
|
||||||
|
|
||||||
### Skills 递帰スキャン
|
|
||||||
|
|
||||||
Skills 管理がリポジトリを再帰的に走査し、ネストされた `SKILL.md` を自動検出。
|
|
||||||
|
|
||||||
- 複数階層のディレクトリに対応
|
|
||||||
- すべての `SKILL.md` を自動発見
|
|
||||||
- パスをキーにした重複排除で同名スキルを許容
|
|
||||||
|
|
||||||
### プロバイダアイコン設定
|
|
||||||
|
|
||||||
プリセットがデフォルトアイコンを持ち、複製してもアイコンを保持。カスタム色も設定可能。
|
|
||||||
|
|
||||||
### フォームバリデーション強化
|
|
||||||
|
|
||||||
必須項目にリアルタイム検証と分かりやすいエラーメッセージを追加し、トースト通知を統一。
|
|
||||||
|
|
||||||
### 自動起動
|
|
||||||
|
|
||||||
Windows/macOS/Linux で自動起動をサポート。
|
|
||||||
|
|
||||||
- 設定画面からワンクリックで ON/OFF
|
|
||||||
- Registry / LaunchAgent / XDG autostart を使用
|
|
||||||
|
|
||||||
### 新プロバイダプリセット
|
|
||||||
|
|
||||||
- **MiniMax** - 公式パートナー
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## バグ修正
|
|
||||||
|
|
||||||
### 重要修正
|
|
||||||
|
|
||||||
**カスタムエンドポイント消失**
|
|
||||||
|
|
||||||
- 原因: SQLite の `INSERT OR REPLACE` が内部で `DELETE + INSERT` を実行し、外部キーのカスケード削除が発生
|
|
||||||
- 対応: 既存プロバイダ更新を `UPDATE` に変更
|
|
||||||
|
|
||||||
**Gemini 設定**
|
|
||||||
|
|
||||||
- カスタム環境変数が `.env` に正しく書き込まれない問題を修正
|
|
||||||
- 認証設定が他ファイルに誤って書き込まれる問題を修正
|
|
||||||
|
|
||||||
**プロバイダ検証**
|
|
||||||
|
|
||||||
- 現在プロバイダ ID が欠落している場合のバリデーションエラーを修正
|
|
||||||
- 複製時にアイコンフィールドが失われる問題を修正
|
|
||||||
|
|
||||||
### プラットフォーム互換性
|
|
||||||
|
|
||||||
**Linux**
|
|
||||||
|
|
||||||
- WebKitGTK の DMA-BUF 描画問題を解消
|
|
||||||
- ユーザーの `.desktop` カスタマイズを保持
|
|
||||||
|
|
||||||
### その他修正
|
|
||||||
|
|
||||||
- アプリ切り替え時の不要な使用量クエリを削減
|
|
||||||
- DMXAPI プリセットの誤ったトークンフィールドを修正
|
|
||||||
- Deeplink コンポーネントの欠損翻訳キーを補完
|
|
||||||
- 使用量スクリプトテンプレート初期化を修正
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 技術的改善
|
|
||||||
|
|
||||||
### アーキテクチャ再編
|
|
||||||
|
|
||||||
**Provider Service のモジュール化**:
|
|
||||||
|
|
||||||
```
|
|
||||||
services/provider/
|
|
||||||
├── mod.rs 追加/更新/削除/切替/検証の中核
|
|
||||||
├── live.rs ライブ設定ファイル操作
|
|
||||||
├── gemini_auth.rs Gemini 認証タイプ検出
|
|
||||||
├── endpoints.rs カスタムエンドポイント管理
|
|
||||||
└── usage.rs 使用量スクリプト実行
|
|
||||||
```
|
|
||||||
|
|
||||||
**Deeplink のモジュール化**:
|
|
||||||
|
|
||||||
```
|
|
||||||
deeplink/
|
|
||||||
├── mod.rs エクスポート
|
|
||||||
├── parser.rs URL パース
|
|
||||||
├── provider.rs プロバイダ取り込み
|
|
||||||
├── mcp.rs MCP 取り込み
|
|
||||||
├── prompt.rs プロンプト取り込み
|
|
||||||
├── skill.rs Skills 取り込み
|
|
||||||
└── utils.rs ユーティリティ
|
|
||||||
```
|
|
||||||
|
|
||||||
### コード品質
|
|
||||||
|
|
||||||
- レガシーな JSON 時代のインポート/エクスポート死蔵コードを削除
|
|
||||||
- 使われていない MCP 型を削除し、エラーハンドリングを統一
|
|
||||||
- テストを SQLite バックエンドに移行し、MSW ハンドラを最新 API に合わせて更新
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 技術統計
|
|
||||||
|
|
||||||
```
|
|
||||||
全体変更:
|
|
||||||
- コミット: 51
|
|
||||||
- 変更ファイル: 207
|
|
||||||
- 追加: +17,297 行
|
|
||||||
- 削除: -6,870 行
|
|
||||||
- 純増: +10,427 行
|
|
||||||
|
|
||||||
コミット種別:
|
|
||||||
- fix: 25
|
|
||||||
- refactor: 11
|
|
||||||
- feat: 9
|
|
||||||
- test: 1
|
|
||||||
- other: 5
|
|
||||||
|
|
||||||
変更箇所:
|
|
||||||
- フロントエンド: 112 files
|
|
||||||
- Rust バックエンド: 63 files
|
|
||||||
- テスト: 20 files
|
|
||||||
- i18n: 3 files
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## マイグレーションガイド
|
|
||||||
|
|
||||||
### v3.7.x からのアップグレード
|
|
||||||
|
|
||||||
**自動マイグレーション**(初回起動時):
|
|
||||||
|
|
||||||
1. `config.json` の存在を検出
|
|
||||||
2. 全データをトランザクションで SQLite に移行
|
|
||||||
3. デバイス設定を `settings.json` へ移行
|
|
||||||
4. 移行成功の通知を表示
|
|
||||||
|
|
||||||
**データ保護**:
|
|
||||||
|
|
||||||
- 元の `config.json` は保持(削除しない)
|
|
||||||
- 失敗時はエラーダイアログを表示し、`config.json` を温存
|
|
||||||
- Dry-run モードで検証可能
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ダウンロード & インストール
|
|
||||||
|
|
||||||
### システム要件
|
|
||||||
|
|
||||||
- **Windows**: Windows 10+
|
|
||||||
- **macOS**: macOS 10.15 (Catalina)+
|
|
||||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
|
||||||
|
|
||||||
### ダウンロード
|
|
||||||
|
|
||||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から入手:
|
|
||||||
|
|
||||||
- **Windows**: `CC-Switch-v3.8.0-Windows.msi` または `-Portable.zip`
|
|
||||||
- **macOS**: `CC-Switch-v3.8.0-macOS.tar.gz` または `.zip`
|
|
||||||
- **Linux**: `CC-Switch-v3.8.0-Linux.AppImage` または `.deb`
|
|
||||||
|
|
||||||
### Homebrew (macOS)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
brew install --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
アップデート:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew upgrade --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 謝辞
|
|
||||||
|
|
||||||
### コントリビューター
|
|
||||||
|
|
||||||
- [@YoVinchen](https://github.com/YoVinchen) - UI とデータベースリファクタ
|
|
||||||
- [@farion1231](https://github.com/farion1231) - バグ修正と機能拡張
|
|
||||||
- コミュニティの皆さん - テストとフィードバック
|
|
||||||
|
|
||||||
### スポンサー
|
|
||||||
|
|
||||||
**Zhipu AI** - GLM CODING PLAN スポンサー
|
|
||||||
[10% オフリンク](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
|
||||||
|
|
||||||
**PackyCode** - API リレーサービスパートナー
|
|
||||||
[登録時に「cc-switch」で 10% オフ](https://www.packyapi.com/register?aff=cc-switch)
|
|
||||||
|
|
||||||
**ShandianShuo** - ローカルファースト音声入力
|
|
||||||
[Mac/Windows 無料ダウンロード](https://shandianshuo.cn)
|
|
||||||
|
|
||||||
**MiniMax** - MiniMax M2 CODING PLAN スポンサー
|
|
||||||
[ブラックフライデーセール中、$2 から](https://platform.minimax.io/subscribe/coding-plan)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## フィードバック & サポート
|
|
||||||
|
|
||||||
- **Issue**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
|
||||||
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
|
||||||
- **ドキュメント**: [README](../README.md)
|
|
||||||
- **更新履歴**: [CHANGELOG.md](../CHANGELOG.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 今後のロードマップ
|
|
||||||
|
|
||||||
**v3.9.0 予告(予定)**:
|
|
||||||
|
|
||||||
- ローカルプロキシ機能
|
|
||||||
|
|
||||||
続報にご期待ください!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Happy Coding!**
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
# CC Switch v3.8.0
|
|
||||||
|
|
||||||
> 持久化架构升级,为云同步奠定基础
|
|
||||||
|
|
||||||
**[English Version →](release-note-v3.8.0-en.md)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 概览
|
|
||||||
|
|
||||||
CC Switch v3.8.0 是一次重大的架构升级版本,重构了数据持久化层和用户界面,为未来的云同步和本地代理功能奠定基础。
|
|
||||||
|
|
||||||
**发布日期**:2025-11-28
|
|
||||||
**提交数量**:从 v3.7.1 开始 51 个提交
|
|
||||||
**代码变更**:207 个文件,+17,297 / -6,870 行
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 重大更新
|
|
||||||
|
|
||||||
### 持久化架构升级
|
|
||||||
|
|
||||||
从单一 JSON 文件存储迁移到 SQLite + JSON 双层架构,实现数据分层管理。
|
|
||||||
|
|
||||||
**架构变更**:
|
|
||||||
|
|
||||||
```
|
|
||||||
v3.7.x (旧) v3.8.0 (新)
|
|
||||||
┌─────────────────┐ ┌─────────────────────────────────┐
|
|
||||||
│ config.json │ │ SQLite (可同步数据) │
|
|
||||||
│ ┌───────────┐ │ │ ├─ providers 供应商配置 │
|
|
||||||
│ │ providers │ │ │ ├─ mcp_servers MCP 服务器 │
|
|
||||||
│ │ mcp │ │ ──> │ ├─ prompts 提示词 │
|
|
||||||
│ │ prompts │ │ │ ├─ skills 技能 │
|
|
||||||
│ │ settings │ │ │ └─ settings 通用设置 │
|
|
||||||
│ └───────────┘ │ ├─────────────────────────────────┤
|
|
||||||
└─────────────────┘ │ JSON (设备级数据) │
|
|
||||||
│ └─ settings.json 本地设置 │
|
|
||||||
│ ├─ 窗口位置 │
|
|
||||||
│ ├─ 路径覆盖 │
|
|
||||||
│ └─ 当前选中供应商 ID │
|
|
||||||
└─────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**双层结构设计**:
|
|
||||||
|
|
||||||
| 层级 | 存储方式 | 数据类型 | 同步策略 |
|
|
||||||
| -------- | -------- | ---------------------------- | ---------- |
|
|
||||||
| 云同步层 | SQLite | 供应商、MCP、Prompts、Skills | 未来可同步 |
|
|
||||||
| 设备层 | JSON | 窗口状态、本地路径、当前选择 | 保持本地 |
|
|
||||||
|
|
||||||
**技术实现**:
|
|
||||||
|
|
||||||
- **Schema 版本管理** - 支持数据库结构升级迁移
|
|
||||||
- **SQL 导入导出** - `backup.rs` 支持 SQL dump,便于云端存储
|
|
||||||
- **事务支持** - SQLite 原生事务保证数据一致性
|
|
||||||
- **自动迁移** - 首次启动自动从 `config.json` 迁移数据
|
|
||||||
|
|
||||||
**模块化重构**:
|
|
||||||
|
|
||||||
```
|
|
||||||
database/
|
|
||||||
├── mod.rs 核心 Database 结构体和初始化
|
|
||||||
├── schema.rs 表结构定义、Schema 版本迁移
|
|
||||||
├── backup.rs SQL 导入导出、二进制快照备份
|
|
||||||
├── migration.rs JSON → SQLite 数据迁移引擎
|
|
||||||
└── dao/ 数据访问对象层
|
|
||||||
├── providers.rs 供应商 CRUD
|
|
||||||
├── mcp.rs MCP 服务器 CRUD
|
|
||||||
├── prompts.rs 提示词 CRUD
|
|
||||||
├── skills.rs Skills CRUD
|
|
||||||
└── settings.rs 键值对设置存储
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 全新用户界面
|
|
||||||
|
|
||||||
完整重构的 UI 设计,提供更现代化的视觉体验。
|
|
||||||
|
|
||||||
**视觉改进**:
|
|
||||||
|
|
||||||
- 重新设计的界面布局
|
|
||||||
- 统一的组件样式
|
|
||||||
- 更流畅的过渡动画
|
|
||||||
- 优化的视觉层次
|
|
||||||
|
|
||||||
**交互优化**:
|
|
||||||
|
|
||||||
- Header toolbar 重新设计
|
|
||||||
- ConfirmDialog 样式统一
|
|
||||||
- 禁用主视图 overscroll 弹跳效果
|
|
||||||
- 改进的表单验证反馈
|
|
||||||
|
|
||||||
**兼容性调整**:
|
|
||||||
|
|
||||||
- Tailwind CSS 从 v4 降级到 v3.4,提升浏览器兼容性
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 日语支持
|
|
||||||
|
|
||||||
新增日语(日本語)界面支持,国际化语言扩展到三种。
|
|
||||||
|
|
||||||
**支持语言**:
|
|
||||||
|
|
||||||
- 简体中文
|
|
||||||
- English
|
|
||||||
- 日本語(新增)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 新增功能
|
|
||||||
|
|
||||||
### Skills 递归扫描
|
|
||||||
|
|
||||||
Skills 管理系统支持递归扫描仓库目录,自动发现嵌套的技能文件。
|
|
||||||
|
|
||||||
**改进内容**:
|
|
||||||
|
|
||||||
- 支持多层目录结构
|
|
||||||
- 自动发现所有 `SKILL.md` 文件
|
|
||||||
- 允许不同仓库的同名技能(使用完整路径去重)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 供应商图标配置
|
|
||||||
|
|
||||||
供应商预设支持自定义图标配置。
|
|
||||||
|
|
||||||
**功能特性**:
|
|
||||||
|
|
||||||
- 预设供应商包含默认图标
|
|
||||||
- 复制供应商时保留图标设置
|
|
||||||
- 图标颜色自定义
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 表单验证增强
|
|
||||||
|
|
||||||
供应商表单新增必填字段验证,提供更友好的错误提示。
|
|
||||||
|
|
||||||
**改进内容**:
|
|
||||||
|
|
||||||
- 必填字段实时校验
|
|
||||||
- 统一使用 Toast 通知显示验证错误
|
|
||||||
- 更清晰的错误信息
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 开机自启
|
|
||||||
|
|
||||||
新增开机自动启动功能,支持 Windows、macOS 和 Linux 三个平台。
|
|
||||||
|
|
||||||
**功能特性**:
|
|
||||||
|
|
||||||
- 在设置中一键开启/关闭
|
|
||||||
- 使用平台原生 API 实现
|
|
||||||
- Windows 使用注册表、macOS 使用 LaunchAgent、Linux 使用 XDG autostart
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 新增供应商预设
|
|
||||||
|
|
||||||
- **MiniMax** - 官方合作伙伴
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bug 修复
|
|
||||||
|
|
||||||
### 关键修复
|
|
||||||
|
|
||||||
**自定义端点丢失问题**
|
|
||||||
|
|
||||||
修复更新供应商时自定义请求地址意外丢失的问题。
|
|
||||||
|
|
||||||
- 根因:`INSERT OR REPLACE` 在 SQLite 底层执行 `DELETE + INSERT`,触发外键级联删除
|
|
||||||
- 修复:改用 `UPDATE` 语句更新已存在的供应商
|
|
||||||
|
|
||||||
**Gemini 配置问题**
|
|
||||||
|
|
||||||
- 修复自定义供应商环境变量未正确写入 `.env` 文件
|
|
||||||
- 修复安全认证配置错误写入到其他配置文件
|
|
||||||
|
|
||||||
**供应商验证问题**
|
|
||||||
|
|
||||||
- 修复当前供应商 ID 不存在时的验证错误
|
|
||||||
- 修复供应商复制时图标字段丢失
|
|
||||||
|
|
||||||
### 平台兼容性
|
|
||||||
|
|
||||||
**Linux**
|
|
||||||
|
|
||||||
- 解决 WebKitGTK DMA-BUF 渲染问题
|
|
||||||
- 保留用户 `.desktop` 文件自定义
|
|
||||||
|
|
||||||
### 其他修复
|
|
||||||
|
|
||||||
- 修复切换应用时的冗余用量查询
|
|
||||||
- 修复 DMXAPI 预设使用错误的认证令牌字段
|
|
||||||
- 修复深链接组件缺少翻译键
|
|
||||||
- 修复用量脚本模板初始化逻辑
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 技术改进
|
|
||||||
|
|
||||||
### 架构重构
|
|
||||||
|
|
||||||
**供应商服务模块化**:
|
|
||||||
|
|
||||||
```
|
|
||||||
services/provider/
|
|
||||||
├── mod.rs 核心服务 - add/update/delete/switch/validate
|
|
||||||
├── live.rs Live 配置文件操作
|
|
||||||
├── gemini_auth.rs Gemini 认证类型检测
|
|
||||||
├── endpoints.rs 自定义端点管理
|
|
||||||
└── usage.rs 用量脚本执行
|
|
||||||
```
|
|
||||||
|
|
||||||
**深链接模块化**:
|
|
||||||
|
|
||||||
```
|
|
||||||
deeplink/
|
|
||||||
├── mod.rs 模块导出
|
|
||||||
├── parser.rs URL 解析
|
|
||||||
├── provider.rs 供应商导入逻辑
|
|
||||||
├── mcp.rs MCP 导入逻辑
|
|
||||||
├── prompt.rs 提示词导入
|
|
||||||
├── skill.rs Skills 导入
|
|
||||||
└── utils.rs 工具函数
|
|
||||||
```
|
|
||||||
|
|
||||||
### 代码质量
|
|
||||||
|
|
||||||
**清理工作**:
|
|
||||||
|
|
||||||
- 移除 JSON 时代遗留的导入导出死代码
|
|
||||||
- 移除未使用的 MCP 类型导出
|
|
||||||
- 统一错误处理方式
|
|
||||||
|
|
||||||
**测试更新**:
|
|
||||||
|
|
||||||
- 迁移测试到 SQLite 数据库架构
|
|
||||||
- 更新组件测试匹配当前实现
|
|
||||||
- 修复 MSW handlers 适配新 API
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 技术统计
|
|
||||||
|
|
||||||
```
|
|
||||||
总体变更:
|
|
||||||
- 提交数:51
|
|
||||||
- 文件数:207 个文件变更
|
|
||||||
- 新增:+17,297 行
|
|
||||||
- 删除:-6,870 行
|
|
||||||
- 净增:+10,427 行
|
|
||||||
|
|
||||||
提交类型分布:
|
|
||||||
- fix:25 个(Bug 修复)
|
|
||||||
- refactor:11 个(代码重构)
|
|
||||||
- feat:9 个(新功能)
|
|
||||||
- test:1 个(测试)
|
|
||||||
- 其他:5 个
|
|
||||||
|
|
||||||
改动区域分布:
|
|
||||||
- 前端源码:112 个文件
|
|
||||||
- Rust 后端:63 个文件
|
|
||||||
- 测试文件:20 个文件
|
|
||||||
- 国际化文件:3 个文件
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 迁移说明
|
|
||||||
|
|
||||||
### 从 v3.7.x 升级
|
|
||||||
|
|
||||||
**自动迁移** - 首次启动时自动执行:
|
|
||||||
|
|
||||||
1. 检测 `config.json` 是否存在
|
|
||||||
2. 在事务中迁移所有数据到 SQLite
|
|
||||||
3. 设备级设置迁移到 `settings.json`
|
|
||||||
4. 显示迁移成功通知
|
|
||||||
|
|
||||||
**数据安全**:
|
|
||||||
|
|
||||||
- 原 `config.json` 文件保留不删除
|
|
||||||
- 迁移失败时显示错误对话框,保留`config.json`
|
|
||||||
- 支持 Dry-run 模式验证迁移逻辑
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 下载与安装
|
|
||||||
|
|
||||||
### 系统要求
|
|
||||||
|
|
||||||
- **Windows**:Windows 10+
|
|
||||||
- **macOS**:macOS 10.15(Catalina)+
|
|
||||||
- **Linux**:Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
|
||||||
|
|
||||||
### 下载链接
|
|
||||||
|
|
||||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载:
|
|
||||||
|
|
||||||
- **Windows**:`CC-Switch-v3.8.0-Windows.msi` 或 `-Portable.zip`
|
|
||||||
- **macOS**:`CC-Switch-v3.8.0-macOS.tar.gz` 或 `.zip`
|
|
||||||
- **Linux**:`CC-Switch-v3.8.0-Linux.AppImage` 或 `.deb`
|
|
||||||
|
|
||||||
### Homebrew(macOS)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
brew install --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
更新:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew upgrade --cask cc-switch
|
|
||||||
```
|
|
||||||
|
|
||||||
## 致谢
|
|
||||||
|
|
||||||
### 贡献者
|
|
||||||
|
|
||||||
感谢所有让这个版本成为可能的贡献者:
|
|
||||||
|
|
||||||
- [@YoVinchen](https://github.com/YoVinchen) - UI 和数据库重构
|
|
||||||
- [@farion1231](https://github.com/farion1231) - BUG 修复和功能增强
|
|
||||||
- 社区成员的测试和反馈
|
|
||||||
|
|
||||||
### 赞助商
|
|
||||||
|
|
||||||
**智谱AI** - GLM CODING PLAN 赞助商
|
|
||||||
[使用此链接购买可享九折优惠](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
|
|
||||||
|
|
||||||
**PackyCode** - API 中转服务合作伙伴
|
|
||||||
[使用 "cc-switch" 优惠码注册享 9 折优惠](https://www.packyapi.com/register?aff=cc-switch)
|
|
||||||
|
|
||||||
**闪电说** - 本地优先的 AI 语音输入法
|
|
||||||
[免费下载](https://shandianshuo.cn) Mac/Win 双平台
|
|
||||||
|
|
||||||
**MiniMax** - MiniMax M2 CODING PLAN 赞助商
|
|
||||||
[黑五优惠进行中,套餐9.9元起](https://platform.minimaxi.com/subscribe/coding-plan)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 反馈与支持
|
|
||||||
|
|
||||||
- **问题反馈**:[GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
|
||||||
- **讨论**:[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
|
||||||
- **文档**:[README](../README_ZH.md)
|
|
||||||
- **更新日志**:[CHANGELOG.md](../CHANGELOG.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 未来展望
|
|
||||||
|
|
||||||
**v3.9.0 预览**(暂定):
|
|
||||||
|
|
||||||
- 本地代理功能
|
|
||||||
|
|
||||||
敬请期待更多更新!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Happy Coding!**
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
- 自动升级自定义路径 ✅
|
|
||||||
- win 绿色版报毒问题 ✅
|
|
||||||
- mcp 管理器 ✅
|
|
||||||
- i18n ✅
|
|
||||||
- gemini cli
|
|
||||||
- homebrew 支持 ✅
|
|
||||||
- memory 管理
|
|
||||||
- codex 更多预设供应商
|
|
||||||
- 云同步
|
|
||||||
- 本地代理
|
|
||||||
@@ -1,863 +0,0 @@
|
|||||||
# v3.7.0 统一 MCP 管理重构计划
|
|
||||||
|
|
||||||
## 📋 项目概述
|
|
||||||
|
|
||||||
**目标**:将原有的按应用分离的 MCP 管理(Claude/Codex/Gemini 各自独立管理)重构为统一管理面板,每个 MCP 服务器通过多选框控制应用到哪些客户端。
|
|
||||||
|
|
||||||
**版本**:v3.6.2 → v3.7.0
|
|
||||||
|
|
||||||
**开始时间**:2025-11-14
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 核心需求
|
|
||||||
|
|
||||||
### 原有架构(v3.6.x)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
||||||
│ Claude面板 │ │ Codex面板 │ │ Gemini面板 │
|
|
||||||
│ MCP管理 │ │ MCP管理 │ │ MCP管理 │
|
|
||||||
└─────────────┘ └─────────────┘ └─────────────┘
|
|
||||||
↓ ↓ ↓
|
|
||||||
mcp.claude mcp.codex mcp.gemini
|
|
||||||
{servers} {servers} {servers}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 新架构(v3.7.0)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌───────────────────────────────────────┐
|
|
||||||
│ 统一 MCP 管理面板 │
|
|
||||||
│ ┌────────┬────────┬────────┬────┐ │
|
|
||||||
│ │ 服务器 │ Claude │ Codex │Gem │ │
|
|
||||||
│ ├────────┼────────┼────────┼────┤ │
|
|
||||||
│ │ mcp-1 │ ✓ │ ✓ │ │ │
|
|
||||||
│ │ mcp-2 │ ✓ │ │ ✓ │ │
|
|
||||||
│ └────────┴────────┴────────┴────┘ │
|
|
||||||
└───────────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
mcp.servers
|
|
||||||
{
|
|
||||||
"mcp-1": {
|
|
||||||
apps: {claude: true, codex: true, gemini: false}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📐 技术架构
|
|
||||||
|
|
||||||
### 数据结构设计
|
|
||||||
|
|
||||||
#### 新增:McpApps(应用启用状态)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
|
||||||
pub struct McpApps {
|
|
||||||
pub claude: bool,
|
|
||||||
pub codex: bool,
|
|
||||||
pub gemini: bool,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 更新:McpServer(统一服务器定义)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct McpServer {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub server: serde_json::Value, // 连接配置(stdio/http)
|
|
||||||
pub apps: McpApps, // 新增:标记应用到哪些客户端
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub homepage: Option<String>,
|
|
||||||
pub docs: Option<String>,
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 更新:McpRoot(新旧结构并存)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct McpRoot {
|
|
||||||
// v3.7.0 新结构
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub servers: Option<HashMap<String, McpServer>>,
|
|
||||||
|
|
||||||
// v3.6.x 旧结构(保留用于迁移)
|
|
||||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
|
||||||
pub claude: McpConfig,
|
|
||||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
|
||||||
pub codex: McpConfig,
|
|
||||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
|
||||||
pub gemini: McpConfig,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 迁移策略
|
|
||||||
|
|
||||||
```
|
|
||||||
旧配置 (v3.6.x) 新配置 (v3.7.0)
|
|
||||||
───────────────── ─────────────────
|
|
||||||
mcp: mcp:
|
|
||||||
claude: servers:
|
|
||||||
servers: mcp-fetch:
|
|
||||||
mcp-fetch: {...} → id: "mcp-fetch"
|
|
||||||
codex: server: {...}
|
|
||||||
servers: apps:
|
|
||||||
mcp-filesystem: {...} claude: true
|
|
||||||
codex: true
|
|
||||||
gemini: false
|
|
||||||
```
|
|
||||||
|
|
||||||
**迁移逻辑**:
|
|
||||||
1. 检测 `mcp.servers` 是否存在
|
|
||||||
2. 若不存在,从 `mcp.claude/codex/gemini.servers` 收集所有服务器
|
|
||||||
3. 合并同 id 服务器的 apps 字段
|
|
||||||
4. 清空旧结构字段
|
|
||||||
5. 保存配置(自动触发)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 开发进度
|
|
||||||
|
|
||||||
### Phase 1: 后端数据结构与迁移 ✅ 已完成
|
|
||||||
|
|
||||||
#### 1.1 修改数据结构(app_config.rs)✅
|
|
||||||
|
|
||||||
**文件**:`src-tauri/src/app_config.rs`
|
|
||||||
|
|
||||||
**变更**:
|
|
||||||
- ✅ 新增 `McpApps` 结构体(lines 30-62)
|
|
||||||
- ✅ 新增 `McpServer` 结构体(lines 64-79)
|
|
||||||
- ✅ 更新 `McpRoot` 支持新旧结构(lines 81-96)
|
|
||||||
- ✅ 添加辅助方法:`is_enabled_for`, `set_enabled_for`, `enabled_apps`
|
|
||||||
|
|
||||||
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
|
||||||
|
|
||||||
#### 1.2 实现迁移逻辑 ✅
|
|
||||||
|
|
||||||
**文件**:`src-tauri/src/app_config.rs`
|
|
||||||
|
|
||||||
**实现**:
|
|
||||||
- ✅ `migrate_mcp_to_unified()` 方法(lines 380-509)
|
|
||||||
- 从旧结构收集所有服务器
|
|
||||||
- 按 id 合并重复服务器
|
|
||||||
- 处理冲突(合并 apps 字段)
|
|
||||||
- 清空旧结构
|
|
||||||
- ✅ 集成到 `MultiAppConfig::load()` 方法(lines 252-257)
|
|
||||||
- 自动检测并执行迁移
|
|
||||||
- 迁移后保存配置
|
|
||||||
|
|
||||||
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 2: 后端服务层重构 ✅ 已完成
|
|
||||||
|
|
||||||
#### 2.1 重写 McpService ✅
|
|
||||||
|
|
||||||
**文件**:`src-tauri/src/services/mcp.rs`
|
|
||||||
|
|
||||||
**新增方法**:
|
|
||||||
- ✅ `get_all_servers()` - 获取所有服务器(lines 13-27)
|
|
||||||
- ✅ `upsert_server()` - 添加/更新服务器(lines 30-52)
|
|
||||||
- ✅ `delete_server()` - 删除服务器(lines 55-75)
|
|
||||||
- ✅ `toggle_app()` - 切换应用启用状态(lines 78-111)
|
|
||||||
- ✅ `sync_all_enabled()` - 同步所有启用的服务器(lines 180-188)
|
|
||||||
|
|
||||||
**兼容层方法**(已废弃):
|
|
||||||
- ✅ `get_servers()` - 按应用过滤服务器(lines 196-210)
|
|
||||||
- ✅ `set_enabled()` - 委托到 toggle_app(lines 213-222)
|
|
||||||
- ✅ `sync_enabled()` - 同步特定应用(lines 225-236)
|
|
||||||
- ✅ `import_from_claude/codex/gemini()` - 导入包装(lines 239-266)
|
|
||||||
|
|
||||||
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
|
||||||
|
|
||||||
#### 2.2 新增同步函数(mcp.rs)✅
|
|
||||||
|
|
||||||
**文件**:`src-tauri/src/mcp.rs`
|
|
||||||
|
|
||||||
**新增函数**(lines 800-965):
|
|
||||||
- ✅ `json_server_to_toml_table()` - JSON → TOML 转换助手(lines 828-889)
|
|
||||||
- ✅ `sync_single_server_to_claude()` - 同步单个服务器到 Claude(lines 800-814)
|
|
||||||
- ✅ `remove_server_from_claude()` - 从 Claude 移除服务器(lines 817-826)
|
|
||||||
- ✅ `sync_single_server_to_codex()` - 同步单个服务器到 Codex(lines 891-936)
|
|
||||||
- ✅ `remove_server_from_codex()` - 从 Codex 移除服务器(lines 939-965)
|
|
||||||
- ✅ `sync_single_server_to_gemini()` - 同步单个服务器到 Gemini(lines 967-977)
|
|
||||||
- ✅ `remove_server_from_gemini()` - 从 Gemini 移除服务器(lines 980-989)
|
|
||||||
|
|
||||||
**关键修复**:
|
|
||||||
- ✅ 修复 toml_edit 类型转换(使用手动构建而非 serde 转换)
|
|
||||||
- ✅ 修复 get_codex_config_path() 调用(返回 PathBuf 而非 Result)
|
|
||||||
|
|
||||||
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
|
||||||
**修复提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
|
||||||
|
|
||||||
#### 2.3 新增 Tauri Commands ✅
|
|
||||||
|
|
||||||
**文件**:`src-tauri/src/commands/mcp.rs`
|
|
||||||
|
|
||||||
**新增命令**(lines 147-196):
|
|
||||||
- ✅ `get_mcp_servers()` - 获取所有服务器(lines 154-159)
|
|
||||||
- ✅ `upsert_mcp_server()` - 添加/更新服务器(lines 162-168)
|
|
||||||
- ✅ `delete_mcp_server()` - 删除服务器(lines 171-177)
|
|
||||||
- ✅ `toggle_mcp_app()` - 切换应用状态(lines 180-189)
|
|
||||||
- ✅ `sync_all_mcp_servers()` - 同步所有服务器(lines 192-195)
|
|
||||||
|
|
||||||
**更新旧命令**(兼容层):
|
|
||||||
- ✅ `upsert_mcp_server_in_config()` - 转换为统一结构(lines 68-131)
|
|
||||||
- ✅ `delete_mcp_server_in_config()` - 忽略 app 参数(lines 134-141)
|
|
||||||
|
|
||||||
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
|
||||||
**修复提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
|
||||||
|
|
||||||
#### 2.4 注册新命令(lib.rs)✅
|
|
||||||
|
|
||||||
**文件**:`src-tauri/src/lib.rs`
|
|
||||||
|
|
||||||
**变更**:
|
|
||||||
- ✅ 导出 `McpServer` 类型(line 21)
|
|
||||||
- ✅ 导出新增的 mcp 同步函数(lines 26-31)
|
|
||||||
- ✅ 注册 5 个新命令到 invoke_handler(lines 550-555)
|
|
||||||
|
|
||||||
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
|
||||||
|
|
||||||
#### 2.5 添加缺失的函数(claude_mcp.rs & gemini_mcp.rs)✅
|
|
||||||
|
|
||||||
**文件**:
|
|
||||||
- `src-tauri/src/claude_mcp.rs` (lines 234-253)
|
|
||||||
- `src-tauri/src/gemini_mcp.rs` (lines 160-179)
|
|
||||||
|
|
||||||
**新增**:
|
|
||||||
- ✅ `read_mcp_servers_map()` - 读取现有 MCP 服务器映射
|
|
||||||
|
|
||||||
**提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
|
||||||
|
|
||||||
#### 2.6 编译验证 ✅
|
|
||||||
|
|
||||||
**状态**:✅ 编译成功
|
|
||||||
- ⚠️ 16 个警告(8 个废弃警告 + 8 个未使用函数警告 - 预期内)
|
|
||||||
- ✅ 0 个错误
|
|
||||||
|
|
||||||
**提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 3: 前端开发 ⚠️ 部分完成
|
|
||||||
|
|
||||||
#### 3.1 TypeScript 类型定义 ✅
|
|
||||||
|
|
||||||
**文件**:`src/types.ts`
|
|
||||||
|
|
||||||
**变更**:
|
|
||||||
- ✅ 新增 `McpApps` 接口(lines 129-133)
|
|
||||||
- ✅ 更新 `McpServer` 接口(lines 136-149)
|
|
||||||
- 新增 `apps: McpApps` 字段
|
|
||||||
- `name` 改为必填
|
|
||||||
- 标记 `enabled` 为废弃
|
|
||||||
- ✅ 新增 `McpServersMap` 类型别名(line 152)
|
|
||||||
- ✅ 保持向后兼容(保留 `enabled`, `source` 等旧字段)
|
|
||||||
|
|
||||||
**提交**:`ac09551` - "feat(frontend): add unified MCP types and API layer for v3.7.0"
|
|
||||||
|
|
||||||
#### 3.2 API 层更新 ✅
|
|
||||||
|
|
||||||
**文件**:`src/lib/api/mcp.ts`
|
|
||||||
|
|
||||||
**新增方法**(lines 99-141):
|
|
||||||
- ✅ `getAllServers()` - 获取所有服务器(lines 106-108)
|
|
||||||
- ✅ `upsertUnifiedServer()` - 添加/更新服务器(lines 113-115)
|
|
||||||
- ✅ `deleteUnifiedServer()` - 删除服务器(lines 120-122)
|
|
||||||
- ✅ `toggleApp()` - 切换应用状态(lines 127-133)
|
|
||||||
- ✅ `syncAllServers()` - 同步所有服务器(lines 138-140)
|
|
||||||
|
|
||||||
**导入更新**:
|
|
||||||
- ✅ 导入 `McpServersMap` 类型(line 6)
|
|
||||||
|
|
||||||
**提交**:`ac09551` - "feat(frontend): add unified MCP types and API layer for v3.7.0"
|
|
||||||
|
|
||||||
#### 3.3 React Query Hooks 📝 待开发
|
|
||||||
|
|
||||||
**计划文件**:`src/hooks/useMcp.ts`
|
|
||||||
|
|
||||||
**需要实现的 Hooks**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 查询 hooks
|
|
||||||
export function useAllMcpServers() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['mcp', 'all'],
|
|
||||||
queryFn: () => mcpApi.getAllServers(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 变更 hooks
|
|
||||||
export function useUpsertMcpServer() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (server: McpServer) => mcpApi.upsertUnifiedServer(server),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useToggleMcpApp() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ serverId, app, enabled }: {
|
|
||||||
serverId: string;
|
|
||||||
app: AppId;
|
|
||||||
enabled: boolean;
|
|
||||||
}) => mcpApi.toggleApp(serverId, app, enabled),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteMcpServer() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) => mcpApi.deleteUnifiedServer(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSyncAllMcpServers() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: () => mcpApi.syncAllServers(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**依赖**:
|
|
||||||
- `@tanstack/react-query` (已安装)
|
|
||||||
- `src/lib/api/mcp.ts` (✅ 已完成)
|
|
||||||
- `src/types.ts` (✅ 已完成)
|
|
||||||
|
|
||||||
#### 3.4 统一 MCP 面板组件 📝 待开发
|
|
||||||
|
|
||||||
**计划文件**:`src/components/mcp/UnifiedMcpPanel.tsx`
|
|
||||||
|
|
||||||
**组件结构**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface UnifiedMcpPanelProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UnifiedMcpPanel({ className }: UnifiedMcpPanelProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: servers, isLoading } = useAllMcpServers();
|
|
||||||
const toggleApp = useToggleMcpApp();
|
|
||||||
const deleteServer = useDeleteMcpServer();
|
|
||||||
const syncAll = useSyncAllMcpServers();
|
|
||||||
|
|
||||||
// 组件实现...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**UI 设计**:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────┐
|
|
||||||
│ MCP 服务器管理 ┌──────────┐ │
|
|
||||||
│ │ 添加服务器 │ │
|
|
||||||
│ ┌─────┐ ┌──────────────┐ ┌─────────┐ └──────────┘ │
|
|
||||||
│ │ 搜索 │ │ 导入自...▼ │ │ 同步全部 │ │
|
|
||||||
│ └─────┘ └──────────────┘ └─────────┘ │
|
|
||||||
├─────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────────────────────┐ │
|
|
||||||
│ │ 名称 │ Claude │ Codex │ Gemini │操作│ │
|
|
||||||
│ ├─────────────────────────────────────────────┤ │
|
|
||||||
│ │ mcp-fetch │ ✓ │ ✓ │ │ ⚙️ │ │
|
|
||||||
│ │ filesystem │ ✓ │ │ ✓ │ ⚙️ │ │
|
|
||||||
│ │ brave-search │ │ ✓ │ ✓ │ ⚙️ │ │
|
|
||||||
│ └─────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**功能特性**:
|
|
||||||
- 📋 服务器列表展示(名称、描述、标签)
|
|
||||||
- ☑️ 三个复选框控制应用启用状态(Claude/Codex/Gemini)
|
|
||||||
- ➕ 添加新服务器(表单模态框)
|
|
||||||
- ✏️ 编辑服务器(表单模态框)
|
|
||||||
- 🗑️ 删除服务器(确认对话框)
|
|
||||||
- 📥 导入功能(从 Claude/Codex/Gemini 导入)
|
|
||||||
- 🔄 同步全部(手动触发同步到 live 配置)
|
|
||||||
- 🔍 搜索过滤
|
|
||||||
- 🏷️ 标签过滤
|
|
||||||
|
|
||||||
**子组件**:
|
|
||||||
|
|
||||||
1. **McpServerTable** (`McpServerTable.tsx`)
|
|
||||||
- 服务器列表表格
|
|
||||||
- 应用复选框
|
|
||||||
- 操作按钮(编辑、删除)
|
|
||||||
|
|
||||||
2. **McpServerFormModal** (`McpServerFormModal.tsx`)
|
|
||||||
- 添加/编辑表单
|
|
||||||
- stdio/http 类型切换
|
|
||||||
- 应用选择(多选)
|
|
||||||
- 元信息编辑(描述、标签、链接)
|
|
||||||
|
|
||||||
3. **McpImportDialog** (`McpImportDialog.tsx`)
|
|
||||||
- 选择导入来源(Claude/Codex/Gemini)
|
|
||||||
- 服务器预览
|
|
||||||
- 批量导入
|
|
||||||
|
|
||||||
**依赖组件**(来自 shadcn/ui):
|
|
||||||
- `Table`, `TableBody`, `TableCell`, `TableHead`, `TableHeader`, `TableRow`
|
|
||||||
- `Checkbox`
|
|
||||||
- `Button`
|
|
||||||
- `Dialog`, `DialogContent`, `DialogHeader`, `DialogTitle`
|
|
||||||
- `Input`, `Textarea`, `Label`
|
|
||||||
- `Select`, `SelectContent`, `SelectItem`, `SelectTrigger`, `SelectValue`
|
|
||||||
- `Badge`
|
|
||||||
- `Tooltip`
|
|
||||||
|
|
||||||
#### 3.5 主界面集成 📝 待开发
|
|
||||||
|
|
||||||
**文件**:`src/App.tsx`
|
|
||||||
|
|
||||||
**变更计划**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 原有代码(v3.6.x)
|
|
||||||
{currentApp === 'claude' && <ClaudeMcpPanel />}
|
|
||||||
{currentApp === 'codex' && <CodexMcpPanel />}
|
|
||||||
{currentApp === 'gemini' && <GeminiMcpPanel />}
|
|
||||||
|
|
||||||
// 新代码(v3.7.0)
|
|
||||||
<UnifiedMcpPanel />
|
|
||||||
```
|
|
||||||
|
|
||||||
**移除的组件**:
|
|
||||||
- `ClaudeMcpPanel.tsx`
|
|
||||||
- `CodexMcpPanel.tsx`
|
|
||||||
- `GeminiMcpPanel.tsx`
|
|
||||||
|
|
||||||
**注意**:保留旧组件文件备份,以便回滚
|
|
||||||
|
|
||||||
#### 3.6 国际化文本更新 📝 待开发
|
|
||||||
|
|
||||||
**文件**:
|
|
||||||
- `src/locales/zh/translation.json`
|
|
||||||
- `src/locales/en/translation.json`
|
|
||||||
|
|
||||||
**需要添加的翻译键**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcp": {
|
|
||||||
"unifiedPanel": {
|
|
||||||
"title": "MCP 服务器管理 / MCP Server Management",
|
|
||||||
"addServer": "添加服务器 / Add Server",
|
|
||||||
"editServer": "编辑服务器 / Edit Server",
|
|
||||||
"deleteServer": "删除服务器 / Delete Server",
|
|
||||||
"deleteConfirm": "确定要删除此服务器吗?/ Are you sure to delete this server?",
|
|
||||||
"syncAll": "同步全部 / Sync All",
|
|
||||||
"syncAllSuccess": "已同步所有启用的服务器 / All enabled servers synced",
|
|
||||||
"importFrom": "导入自... / Import from...",
|
|
||||||
"search": "搜索服务器... / Search servers...",
|
|
||||||
"noServers": "暂无服务器 / No servers yet",
|
|
||||||
"enabledApps": "启用的应用 / Enabled Apps",
|
|
||||||
"apps": {
|
|
||||||
"claude": "Claude",
|
|
||||||
"codex": "Codex",
|
|
||||||
"gemini": "Gemini"
|
|
||||||
},
|
|
||||||
"form": {
|
|
||||||
"id": "服务器 ID / Server ID",
|
|
||||||
"name": "显示名称 / Display Name",
|
|
||||||
"type": "类型 / Type",
|
|
||||||
"stdio": "本地进程 / Local Process",
|
|
||||||
"http": "远程服务 / Remote Service",
|
|
||||||
"command": "命令 / Command",
|
|
||||||
"args": "参数 / Arguments",
|
|
||||||
"env": "环境变量 / Environment Variables",
|
|
||||||
"cwd": "工作目录 / Working Directory",
|
|
||||||
"url": "URL",
|
|
||||||
"headers": "请求头 / Headers",
|
|
||||||
"description": "描述 / Description",
|
|
||||||
"tags": "标签 / Tags",
|
|
||||||
"homepage": "主页 / Homepage",
|
|
||||||
"docs": "文档 / Documentation",
|
|
||||||
"selectApps": "选择应用 / Select Apps",
|
|
||||||
"selectAppsHint": "勾选此服务器要应用到哪些客户端 / Check which clients this server applies to"
|
|
||||||
},
|
|
||||||
"table": {
|
|
||||||
"name": "名称 / Name",
|
|
||||||
"type": "类型 / Type",
|
|
||||||
"apps": "应用 / Apps",
|
|
||||||
"actions": "操作 / Actions",
|
|
||||||
"edit": "编辑 / Edit",
|
|
||||||
"delete": "删除 / Delete"
|
|
||||||
},
|
|
||||||
"import": {
|
|
||||||
"title": "导入 MCP 服务器 / Import MCP Servers",
|
|
||||||
"fromClaude": "从 Claude 导入 / Import from Claude",
|
|
||||||
"fromCodex": "从 Codex 导入 / Import from Codex",
|
|
||||||
"fromGemini": "从 Gemini 导入 / Import from Gemini",
|
|
||||||
"success": "成功导入 {{count}} 个服务器 / Successfully imported {{count}} server(s)",
|
|
||||||
"noServersFound": "未找到可导入的服务器 / No servers found to import"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 迁移流程
|
|
||||||
|
|
||||||
### 用户体验
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 用户升级到 v3.7.0
|
|
||||||
↓
|
|
||||||
2. 首次启动应用
|
|
||||||
↓
|
|
||||||
3. 后端自动执行迁移
|
|
||||||
- 检测旧结构 (mcp.claude/codex/gemini.servers)
|
|
||||||
- 合并到统一结构 (mcp.servers)
|
|
||||||
- 保存迁移后的配置
|
|
||||||
- 日志记录迁移详情
|
|
||||||
↓
|
|
||||||
4. 前端加载新面板
|
|
||||||
- 显示所有服务器
|
|
||||||
- 三个复选框显示各应用启用状态
|
|
||||||
↓
|
|
||||||
5. 用户无缝使用
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据完整性保证
|
|
||||||
|
|
||||||
1. **迁移前验证**:
|
|
||||||
- ✅ 校验旧结构合法性
|
|
||||||
- ✅ 记录迁移前状态
|
|
||||||
|
|
||||||
2. **迁移中处理**:
|
|
||||||
- ✅ 合并同 id 服务器的 apps 字段
|
|
||||||
- ✅ 处理 id 冲突(保留第一个,记录警告)
|
|
||||||
- ✅ 保留所有元信息(描述、标签、链接)
|
|
||||||
|
|
||||||
3. **迁移后清理**:
|
|
||||||
- ✅ 清空旧结构(claude/codex/gemini)
|
|
||||||
- ✅ 自动保存新配置
|
|
||||||
- ✅ 日志记录迁移完成
|
|
||||||
|
|
||||||
4. **回滚机制**:
|
|
||||||
- 配置文件有备份(`config.v1.backup.<timestamp>.json`)
|
|
||||||
- 迁移失败时可手动回滚
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 测试计划
|
|
||||||
|
|
||||||
### 后端测试 ✅ 已验证
|
|
||||||
|
|
||||||
- [x] 编译测试(cargo check)
|
|
||||||
- [x] 数据结构序列化/反序列化
|
|
||||||
- [ ] 迁移逻辑单元测试
|
|
||||||
- [ ] 服务层方法测试
|
|
||||||
- [ ] 同步函数测试
|
|
||||||
|
|
||||||
### 前端测试 ⏳ 待进行
|
|
||||||
|
|
||||||
- [ ] TypeScript 类型检查
|
|
||||||
- [ ] API 调用测试
|
|
||||||
- [ ] 组件渲染测试
|
|
||||||
- [ ] 用户交互测试
|
|
||||||
- [ ] 国际化文本检查
|
|
||||||
|
|
||||||
### 集成测试 ⏳ 待进行
|
|
||||||
|
|
||||||
- [ ] 完整迁移流程测试
|
|
||||||
- [ ] 从空配置启动
|
|
||||||
- [ ] 从 v3.6.x 配置升级
|
|
||||||
- [ ] 多服务器合并场景
|
|
||||||
- [ ] 冲突处理验证
|
|
||||||
- [ ] 多应用同步测试
|
|
||||||
- [ ] 启用单个应用
|
|
||||||
- [ ] 启用多个应用
|
|
||||||
- [ ] 动态切换应用
|
|
||||||
- [ ] 同步到 live 配置验证
|
|
||||||
- [ ] 边界情况测试
|
|
||||||
- [ ] 空服务器列表
|
|
||||||
- [ ] 超长服务器名称
|
|
||||||
- [ ] 特殊字符处理
|
|
||||||
- [ ] 并发操作
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 交付清单
|
|
||||||
|
|
||||||
### 代码文件
|
|
||||||
|
|
||||||
#### 后端(Rust)✅ 已完成
|
|
||||||
|
|
||||||
- [x] `src-tauri/src/app_config.rs` - 数据结构定义与迁移
|
|
||||||
- [x] `src-tauri/src/services/mcp.rs` - 服务层重构
|
|
||||||
- [x] `src-tauri/src/mcp.rs` - 同步函数实现
|
|
||||||
- [x] `src-tauri/src/commands/mcp.rs` - Tauri 命令
|
|
||||||
- [x] `src-tauri/src/lib.rs` - 命令注册
|
|
||||||
- [x] `src-tauri/src/claude_mcp.rs` - Claude MCP 操作
|
|
||||||
- [x] `src-tauri/src/gemini_mcp.rs` - Gemini MCP 操作
|
|
||||||
|
|
||||||
#### 前端(TypeScript/React)⚠️ 部分完成
|
|
||||||
|
|
||||||
- [x] `src/types.ts` - 类型定义更新
|
|
||||||
- [x] `src/lib/api/mcp.ts` - API 层更新
|
|
||||||
- [ ] `src/hooks/useMcp.ts` - React Query Hooks
|
|
||||||
- [ ] `src/components/mcp/UnifiedMcpPanel.tsx` - 统一面板组件
|
|
||||||
- [ ] `src/components/mcp/McpServerTable.tsx` - 服务器表格
|
|
||||||
- [ ] `src/components/mcp/McpServerFormModal.tsx` - 表单模态框
|
|
||||||
- [ ] `src/components/mcp/McpImportDialog.tsx` - 导入对话框
|
|
||||||
- [ ] `src/App.tsx` - 主界面集成
|
|
||||||
- [ ] `src/locales/zh/translation.json` - 中文翻译
|
|
||||||
- [ ] `src/locales/en/translation.json` - 英文翻译
|
|
||||||
|
|
||||||
### 文档
|
|
||||||
|
|
||||||
- [x] 本重构计划文档 (`docs/v3.7.0-unified-mcp-refactor.md`)
|
|
||||||
- [ ] 用户升级指南 (`docs/upgrade-to-v3.7.0.md`)
|
|
||||||
- [ ] API 变更说明 (`docs/api-changes-v3.7.0.md`)
|
|
||||||
|
|
||||||
### Git 提交记录 ✅
|
|
||||||
|
|
||||||
- [x] `c7b235b` - feat(mcp): implement unified MCP management for v3.7.0
|
|
||||||
- [x] `7ae2a9f` - fix(mcp): resolve compilation errors and add backward compatibility
|
|
||||||
- [x] `ac09551` - feat(frontend): add unified MCP types and API layer for v3.7.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 下一步行动
|
|
||||||
|
|
||||||
### 立即任务(优先级 P0)
|
|
||||||
|
|
||||||
1. ⬜ **实现 useMcp Hook**
|
|
||||||
- 文件:`src/hooks/useMcp.ts`
|
|
||||||
- 估时:1-2 小时
|
|
||||||
- 依赖:API 层(已完成)
|
|
||||||
|
|
||||||
2. ⬜ **创建 UnifiedMcpPanel 核心组件**
|
|
||||||
- 文件:`src/components/mcp/UnifiedMcpPanel.tsx`
|
|
||||||
- 估时:3-4 小时
|
|
||||||
- 依赖:useMcp Hook
|
|
||||||
|
|
||||||
3. ⬜ **添加国际化文本**
|
|
||||||
- 文件:`src/locales/{zh,en}/translation.json`
|
|
||||||
- 估时:30 分钟
|
|
||||||
|
|
||||||
4. ⬜ **集成到主界面**
|
|
||||||
- 文件:`src/App.tsx`
|
|
||||||
- 估时:30 分钟
|
|
||||||
- 依赖:UnifiedMcpPanel 组件
|
|
||||||
|
|
||||||
### 次要任务(优先级 P1)
|
|
||||||
|
|
||||||
5. ⬜ **实现子组件**
|
|
||||||
- McpServerTable
|
|
||||||
- McpServerFormModal
|
|
||||||
- McpImportDialog
|
|
||||||
- 估时:4-6 小时
|
|
||||||
|
|
||||||
6. ⬜ **编写测试用例**
|
|
||||||
- 后端单元测试
|
|
||||||
- 前端组件测试
|
|
||||||
- 集成测试
|
|
||||||
- 估时:6-8 小时
|
|
||||||
|
|
||||||
7. ⬜ **编写用户文档**
|
|
||||||
- 升级指南
|
|
||||||
- API 变更说明
|
|
||||||
- 估时:2-3 小时
|
|
||||||
|
|
||||||
### 优化任务(优先级 P2)
|
|
||||||
|
|
||||||
8. ⬜ **性能优化**
|
|
||||||
- 服务器列表虚拟滚动
|
|
||||||
- 批量操作优化
|
|
||||||
- 估时:2-3 小时
|
|
||||||
|
|
||||||
9. ⬜ **用户体验增强**
|
|
||||||
- 添加加载状态
|
|
||||||
- 添加错误提示
|
|
||||||
- 添加操作确认
|
|
||||||
- 估时:2-3 小时
|
|
||||||
|
|
||||||
10. ⬜ **代码清理**
|
|
||||||
- 移除旧的分应用面板组件
|
|
||||||
- 清理废弃代码
|
|
||||||
- 代码格式化
|
|
||||||
- 估时:1-2 小时
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 技术亮点
|
|
||||||
|
|
||||||
### 1. 平滑迁移机制
|
|
||||||
|
|
||||||
- ✅ 自动检测旧配置并迁移
|
|
||||||
- ✅ 新旧结构并存(过渡期)
|
|
||||||
- ✅ 无需用户手动操作
|
|
||||||
- ✅ 保留所有历史数据
|
|
||||||
|
|
||||||
### 2. 向后兼容
|
|
||||||
|
|
||||||
- ✅ 旧命令继续可用(带废弃警告)
|
|
||||||
- ✅ 前端可增量更新
|
|
||||||
- ✅ 渐进式重构策略
|
|
||||||
|
|
||||||
### 3. 类型安全
|
|
||||||
|
|
||||||
- ✅ Rust 强类型保证数据完整性
|
|
||||||
- ✅ TypeScript 类型定义与后端一致
|
|
||||||
- ✅ serde 序列化/反序列化自动处理
|
|
||||||
|
|
||||||
### 4. 清晰的架构分层
|
|
||||||
|
|
||||||
```
|
|
||||||
Frontend (React)
|
|
||||||
↓ (Tauri IPC)
|
|
||||||
Commands Layer
|
|
||||||
↓
|
|
||||||
Services Layer
|
|
||||||
↓
|
|
||||||
Data Layer (Config + Live Sync)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. SSOT 原则
|
|
||||||
|
|
||||||
- 单一配置源:`~/.cc-switch/config.json`
|
|
||||||
- 统一管理:`mcp.servers` 字段
|
|
||||||
- 按需同步:写入各应用 live 配置
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 参考资源
|
|
||||||
|
|
||||||
### 内部文档
|
|
||||||
|
|
||||||
- [项目 README](../README.md)
|
|
||||||
- [CLAUDE.md](../CLAUDE.md) - Claude Code 工作指南
|
|
||||||
- [架构文档](../CLAUDE.md#架构概述)
|
|
||||||
|
|
||||||
### 相关 Issues/PRs
|
|
||||||
|
|
||||||
- 无(新功能开发)
|
|
||||||
|
|
||||||
### 技术栈文档
|
|
||||||
|
|
||||||
- [Tauri 2.0](https://tauri.app/v1/guides/)
|
|
||||||
- [React 18](https://react.dev/)
|
|
||||||
- [TanStack Query](https://tanstack.com/query/latest)
|
|
||||||
- [shadcn/ui](https://ui.shadcn.com/)
|
|
||||||
- [serde](https://serde.rs/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 变更日志
|
|
||||||
|
|
||||||
### 2025-11-14
|
|
||||||
|
|
||||||
- ✅ 完成后端 Phase 1 & 2(数据结构、服务层、命令层)
|
|
||||||
- ✅ 修复所有编译错误
|
|
||||||
- ✅ 完成前端类型定义和 API 层
|
|
||||||
- ✅ 创建本重构计划文档
|
|
||||||
|
|
||||||
### 待更新...
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 👥 团队协作
|
|
||||||
|
|
||||||
**开发者**:Claude Code (AI Assistant) + User
|
|
||||||
|
|
||||||
**审查者**:User
|
|
||||||
|
|
||||||
**测试者**:User
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 风险与对策
|
|
||||||
|
|
||||||
### 风险 1:迁移数据丢失
|
|
||||||
|
|
||||||
**概率**:低
|
|
||||||
**影响**:高
|
|
||||||
**对策**:
|
|
||||||
- ✅ 迁移前自动备份配置
|
|
||||||
- ✅ 详细日志记录
|
|
||||||
- ✅ 测试各种边界情况
|
|
||||||
|
|
||||||
### 风险 2:性能问题(大量服务器)
|
|
||||||
|
|
||||||
**概率**:中
|
|
||||||
**影响**:中
|
|
||||||
**对策**:
|
|
||||||
- ⬜ 实现虚拟滚动
|
|
||||||
- ⬜ 分页或懒加载
|
|
||||||
- ⬜ 性能测试
|
|
||||||
|
|
||||||
### 风险 3:兼容性问题
|
|
||||||
|
|
||||||
**概率**:中
|
|
||||||
**影响**:中
|
|
||||||
**对策**:
|
|
||||||
- ✅ 保留旧命令兼容层
|
|
||||||
- ✅ 前端增量更新
|
|
||||||
- ⬜ 多版本测试
|
|
||||||
|
|
||||||
### 风险 4:用户学习成本
|
|
||||||
|
|
||||||
**概率**:低
|
|
||||||
**影响**:低
|
|
||||||
**对策**:
|
|
||||||
- ⬜ 清晰的 UI 设计
|
|
||||||
- ⬜ 详细的升级指南
|
|
||||||
- ⬜ 操作提示和引导
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 预期收益
|
|
||||||
|
|
||||||
### 用户体验提升
|
|
||||||
|
|
||||||
- ⭐ **简化操作**:不再需要在不同应用面板切换
|
|
||||||
- ⭐ **统一视图**:一目了然看到所有 MCP 配置
|
|
||||||
- ⭐ **灵活配置**:轻松控制每个 MCP 应用到哪些客户端
|
|
||||||
|
|
||||||
### 代码质量提升
|
|
||||||
|
|
||||||
- ⭐ **架构优化**:统一数据源,消除冗余
|
|
||||||
- ⭐ **维护性**:单一面板组件,代码更简洁
|
|
||||||
- ⭐ **扩展性**:未来添加新应用(如 Cursor)更容易
|
|
||||||
|
|
||||||
### 性能提升
|
|
||||||
|
|
||||||
- ⭐ **减少重复加载**:统一管理减少配置文件读写
|
|
||||||
- ⭐ **更快同步**:批量操作更高效
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 联系方式
|
|
||||||
|
|
||||||
**问题反馈**:[GitHub Issues](https://github.com/jasonyoungyang/cc-switch/issues)
|
|
||||||
|
|
||||||
**功能建议**:[GitHub Discussions](https://github.com/jasonyoungyang/cc-switch/discussions)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**文档版本**:v1.0
|
|
||||||
**最后更新**:2025-11-14
|
|
||||||
**状态**:🟡 开发中(后端完成 ✅,前端进行中 ⚠️)
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.9.0-1",
|
"version": "3.1.2",
|
||||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
"description": "Claude Code & Codex 供应商切换工具",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm tauri dev",
|
"dev": "pnpm tauri dev",
|
||||||
"build": "pnpm tauri build",
|
"build": "pnpm tauri build",
|
||||||
@@ -10,79 +10,24 @@
|
|||||||
"build:renderer": "vite build",
|
"build:renderer": "vite build",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
|
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
|
||||||
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
|
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\""
|
||||||
"test:unit": "vitest run",
|
|
||||||
"test:unit:watch": "vitest watch"
|
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Jason Young",
|
"author": "Jason Young",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.8.0",
|
"@tauri-apps/cli": "^2.8.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
|
||||||
"@testing-library/react": "^16.0.1",
|
|
||||||
"@testing-library/user-event": "^14.5.2",
|
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"cross-fetch": "^4.1.0",
|
|
||||||
"jsdom": "^25.0.0",
|
|
||||||
"msw": "^2.11.6",
|
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"tailwindcss": "^3.4.17",
|
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0"
|
||||||
"vitest": "^2.0.5"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
|
||||||
"@codemirror/lint": "^6.8.5",
|
|
||||||
"@codemirror/state": "^6.5.2",
|
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
|
||||||
"@codemirror/view": "^6.38.2",
|
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"@hookform/resolvers": "^5.2.2",
|
|
||||||
"@lobehub/icons-static-svg": "^1.73.0",
|
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
|
||||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
|
||||||
"@tanstack/react-query": "^5.90.3",
|
|
||||||
"@tauri-apps/api": "^2.8.0",
|
"@tauri-apps/api": "^2.8.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
|
||||||
"@tauri-apps/plugin-process": "^2.0.0",
|
|
||||||
"@tauri-apps/plugin-store": "^2.0.0",
|
|
||||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"cmdk": "^1.1.1",
|
|
||||||
"codemirror": "^6.0.2",
|
|
||||||
"framer-motion": "^12.23.25",
|
|
||||||
"i18next": "^25.5.2",
|
|
||||||
"jsonc-parser": "^3.2.1",
|
|
||||||
"lucide-react": "^0.542.0",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0"
|
||||||
"react-hook-form": "^7.65.0",
|
}
|
||||||
"react-i18next": "^16.0.0",
|
|
||||||
"recharts": "^3.5.1",
|
|
||||||
"smol-toml": "^1.4.2",
|
|
||||||
"sonner": "^2.0.7",
|
|
||||||
"tailwind-merge": "^3.3.1",
|
|
||||||
"zod": "^4.1.12"
|
|
||||||
},
|
|
||||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
packages: []
|
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
|
||||||
- '@tailwindcss/oxide'
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 247 KiB |
@@ -1,208 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// 要提取的图标列表(按分类组织)
|
|
||||||
const ICONS_TO_EXTRACT = {
|
|
||||||
// AI 服务商(必需)
|
|
||||||
aiProviders: [
|
|
||||||
'openai', 'anthropic', 'claude', 'google', 'gemini',
|
|
||||||
'deepseek', 'kimi', 'moonshot', 'zhipu', 'minimax',
|
|
||||||
'baidu', 'alibaba', 'tencent', 'meta', 'microsoft',
|
|
||||||
'cohere', 'perplexity', 'mistral', 'huggingface'
|
|
||||||
],
|
|
||||||
|
|
||||||
// 云平台
|
|
||||||
cloudPlatforms: [
|
|
||||||
'aws', 'azure', 'huawei', 'cloudflare'
|
|
||||||
],
|
|
||||||
|
|
||||||
// 开发工具
|
|
||||||
devTools: [
|
|
||||||
'github', 'gitlab', 'docker', 'kubernetes', 'vscode'
|
|
||||||
],
|
|
||||||
|
|
||||||
// 其他
|
|
||||||
others: [
|
|
||||||
'settings', 'folder', 'file', 'link'
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// 合并所有图标
|
|
||||||
const ALL_ICONS = [
|
|
||||||
...ICONS_TO_EXTRACT.aiProviders,
|
|
||||||
...ICONS_TO_EXTRACT.cloudPlatforms,
|
|
||||||
...ICONS_TO_EXTRACT.devTools,
|
|
||||||
...ICONS_TO_EXTRACT.others
|
|
||||||
];
|
|
||||||
|
|
||||||
// 提取逻辑
|
|
||||||
const OUTPUT_DIR = path.join(__dirname, '../src/icons/extracted');
|
|
||||||
const SOURCE_DIR = path.join(__dirname, '../node_modules/@lobehub/icons-static-svg/icons');
|
|
||||||
|
|
||||||
// 确保输出目录存在
|
|
||||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
|
||||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🎨 CC-Switch Icon Extractor\n');
|
|
||||||
console.log('========================================');
|
|
||||||
console.log('📦 Extracting icons...\n');
|
|
||||||
|
|
||||||
let extracted = 0;
|
|
||||||
let notFound = [];
|
|
||||||
|
|
||||||
// 提取图标
|
|
||||||
ALL_ICONS.forEach(iconName => {
|
|
||||||
const sourceFile = path.join(SOURCE_DIR, `${iconName}.svg`);
|
|
||||||
const targetFile = path.join(OUTPUT_DIR, `${iconName}.svg`);
|
|
||||||
|
|
||||||
if (fs.existsSync(sourceFile)) {
|
|
||||||
fs.copyFileSync(sourceFile, targetFile);
|
|
||||||
console.log(` ✓ ${iconName}.svg`);
|
|
||||||
extracted++;
|
|
||||||
} else {
|
|
||||||
console.log(` ✗ ${iconName}.svg (not found)`);
|
|
||||||
notFound.push(iconName);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 生成索引文件
|
|
||||||
console.log('\n📝 Generating index file...\n');
|
|
||||||
|
|
||||||
const indexContent = `// Auto-generated icon index
|
|
||||||
// Do not edit manually
|
|
||||||
|
|
||||||
export const icons: Record<string, string> = {
|
|
||||||
${ALL_ICONS.filter(name => !notFound.includes(name))
|
|
||||||
.map(name => {
|
|
||||||
const svg = fs.readFileSync(path.join(OUTPUT_DIR, `${name}.svg`), 'utf-8');
|
|
||||||
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
||||||
return ` '${name}': \`${escaped}\`,`;
|
|
||||||
})
|
|
||||||
.join('\n')}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const iconList = Object.keys(icons);
|
|
||||||
|
|
||||||
export function getIcon(name: string): string {
|
|
||||||
return icons[name.toLowerCase()] || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasIcon(name: string): boolean {
|
|
||||||
return name.toLowerCase() in icons;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'index.ts'), indexContent);
|
|
||||||
console.log('✓ Generated: src/icons/extracted/index.ts');
|
|
||||||
|
|
||||||
// 生成图标元数据
|
|
||||||
const metadataContent = `// Icon metadata for search and categorization
|
|
||||||
import { IconMetadata } from '@/types/icon';
|
|
||||||
|
|
||||||
export const iconMetadata: Record<string, IconMetadata> = {
|
|
||||||
// AI Providers
|
|
||||||
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
|
|
||||||
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
|
|
||||||
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
|
|
||||||
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
|
|
||||||
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
|
|
||||||
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
|
|
||||||
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
|
|
||||||
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
|
|
||||||
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
|
|
||||||
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
|
|
||||||
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
|
|
||||||
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
|
|
||||||
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
|
|
||||||
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
|
|
||||||
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
|
|
||||||
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
|
|
||||||
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
|
|
||||||
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
|
|
||||||
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
|
|
||||||
|
|
||||||
// Cloud Platforms
|
|
||||||
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
|
|
||||||
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
|
|
||||||
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
|
|
||||||
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
|
|
||||||
|
|
||||||
// Dev Tools
|
|
||||||
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
|
|
||||||
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
|
|
||||||
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
|
|
||||||
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
|
|
||||||
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
|
|
||||||
|
|
||||||
// Others
|
|
||||||
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
|
|
||||||
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
|
|
||||||
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
|
|
||||||
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getIconMetadata(name: string): IconMetadata | undefined {
|
|
||||||
return iconMetadata[name.toLowerCase()];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function searchIcons(query: string): string[] {
|
|
||||||
const lowerQuery = query.toLowerCase();
|
|
||||||
return Object.values(iconMetadata)
|
|
||||||
.filter(meta =>
|
|
||||||
meta.name.includes(lowerQuery) ||
|
|
||||||
meta.displayName.toLowerCase().includes(lowerQuery) ||
|
|
||||||
meta.keywords.some(k => k.includes(lowerQuery))
|
|
||||||
)
|
|
||||||
.map(meta => meta.name);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'metadata.ts'), metadataContent);
|
|
||||||
console.log('✓ Generated: src/icons/extracted/metadata.ts');
|
|
||||||
|
|
||||||
// 生成 README
|
|
||||||
const readmeContent = `# Extracted Icons
|
|
||||||
|
|
||||||
This directory contains extracted icons from @lobehub/icons-static-svg.
|
|
||||||
|
|
||||||
## Statistics
|
|
||||||
- Total extracted: ${extracted} icons
|
|
||||||
- Not found: ${notFound.length} icons
|
|
||||||
|
|
||||||
## Extracted Icons
|
|
||||||
${ALL_ICONS.filter(name => !notFound.includes(name)).map(name => `- ${name}`).join('\n')}
|
|
||||||
|
|
||||||
${notFound.length > 0 ? `\n## Not Found\n${notFound.map(name => `- ${name}`).join('\n')}` : ''}
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
\`\`\`typescript
|
|
||||||
import { getIcon, hasIcon, iconList } from './extracted';
|
|
||||||
|
|
||||||
// Get icon SVG
|
|
||||||
const svg = getIcon('openai');
|
|
||||||
|
|
||||||
// Check if icon exists
|
|
||||||
if (hasIcon('openai')) {
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all available icons
|
|
||||||
console.log(iconList);
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
---
|
|
||||||
Last updated: ${new Date().toISOString()}
|
|
||||||
Generated by: scripts/extract-icons.js
|
|
||||||
`;
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'README.md'), readmeContent);
|
|
||||||
console.log('✓ Generated: src/icons/extracted/README.md');
|
|
||||||
|
|
||||||
console.log('\n========================================');
|
|
||||||
console.log('✅ Extraction complete!\n');
|
|
||||||
console.log(` ✓ Extracted: ${extracted} icons`);
|
|
||||||
console.log(` ✗ Not found: ${notFound.length} icons`);
|
|
||||||
console.log(` 📉 Bundle size reduction: ~${Math.round((1 - extracted / 723) * 100)}%`);
|
|
||||||
console.log('========================================\n');
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
|
|
||||||
|
|
||||||
// List of "Famous" icons to keep
|
|
||||||
// Based on common AI providers and tools
|
|
||||||
const KEEP_LIST = [
|
|
||||||
// AI Providers
|
|
||||||
'openai', 'anthropic', 'claude', 'google', 'gemini', 'gemma', 'palm',
|
|
||||||
'microsoft', 'azure', 'copilot', 'meta', 'llama',
|
|
||||||
'alibaba', 'qwen', 'tencent', 'hunyuan', 'baidu', 'wenxin',
|
|
||||||
'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi',
|
|
||||||
'zhipu', 'chatglm', 'glm', 'minimax', 'mistral', 'cohere',
|
|
||||||
'perplexity', 'huggingface', 'midjourney', 'stability',
|
|
||||||
'xai', 'grok', 'yi', 'zeroone', 'ollama',
|
|
||||||
'packycode',
|
|
||||||
|
|
||||||
// Cloud/Tools
|
|
||||||
'aws', 'googlecloud', 'huawei', 'cloudflare',
|
|
||||||
'github', 'githubcopilot', 'vercel', 'notion', 'discord',
|
|
||||||
'gitlab', 'docker', 'kubernetes', 'vscode', 'settings', 'folder', 'file', 'link'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Get all SVG files
|
|
||||||
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
|
|
||||||
|
|
||||||
console.log(`Scanning ${files.length} files...`);
|
|
||||||
|
|
||||||
let keptCount = 0;
|
|
||||||
let deletedCount = 0;
|
|
||||||
let renamedCount = 0;
|
|
||||||
|
|
||||||
// First pass: Identify files to keep and prefer color versions
|
|
||||||
const fileMap = {}; // name -> { hasColor: bool, hasMono: bool }
|
|
||||||
|
|
||||||
files.forEach(file => {
|
|
||||||
const isColor = file.endsWith('-color.svg');
|
|
||||||
const baseName = isColor ? file.replace('-color.svg', '') : file.replace('.svg', '');
|
|
||||||
|
|
||||||
if (!fileMap[baseName]) {
|
|
||||||
fileMap[baseName] = { hasColor: false, hasMono: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isColor) {
|
|
||||||
fileMap[baseName].hasColor = true;
|
|
||||||
} else {
|
|
||||||
fileMap[baseName].hasMono = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Second pass: Process files
|
|
||||||
Object.keys(fileMap).forEach(baseName => {
|
|
||||||
const info = fileMap[baseName];
|
|
||||||
const shouldKeep = KEEP_LIST.includes(baseName);
|
|
||||||
|
|
||||||
if (!shouldKeep) {
|
|
||||||
// Delete both versions if not in keep list
|
|
||||||
if (info.hasColor) {
|
|
||||||
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}-color.svg`));
|
|
||||||
deletedCount++;
|
|
||||||
}
|
|
||||||
if (info.hasMono) {
|
|
||||||
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}.svg`));
|
|
||||||
deletedCount++;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If keeping, prefer color
|
|
||||||
if (info.hasColor) {
|
|
||||||
// Rename color version to base version (overwrite mono if exists)
|
|
||||||
const colorPath = path.join(ICONS_DIR, `${baseName}-color.svg`);
|
|
||||||
const targetPath = path.join(ICONS_DIR, `${baseName}.svg`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If mono exists, it will be overwritten/replaced
|
|
||||||
fs.renameSync(colorPath, targetPath);
|
|
||||||
renamedCount++;
|
|
||||||
keptCount++;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Error renaming ${baseName}:`, e);
|
|
||||||
}
|
|
||||||
} else if (info.hasMono) {
|
|
||||||
// Keep mono if no color version
|
|
||||||
keptCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\nCleanup complete:`);
|
|
||||||
console.log(`- Kept: ${keptCount}`);
|
|
||||||
console.log(`- Deleted: ${deletedCount}`);
|
|
||||||
console.log(`- Renamed (Color -> Standard): ${renamedCount}`);
|
|
||||||
|
|
||||||
// Regenerate index and metadata
|
|
||||||
require('./generate-icon-index.js');
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
|
|
||||||
const INDEX_FILE = path.join(ICONS_DIR, 'index.ts');
|
|
||||||
const METADATA_FILE = path.join(ICONS_DIR, 'metadata.ts');
|
|
||||||
|
|
||||||
// Known metadata from previous configuration
|
|
||||||
const KNOWN_METADATA = {
|
|
||||||
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
|
|
||||||
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
|
|
||||||
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
|
|
||||||
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
|
|
||||||
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
|
|
||||||
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
|
|
||||||
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
|
|
||||||
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
|
|
||||||
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
|
|
||||||
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
|
|
||||||
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
|
|
||||||
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
|
|
||||||
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
|
|
||||||
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
|
|
||||||
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
|
|
||||||
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
|
|
||||||
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
|
|
||||||
packycode: { name: 'packycode', displayName: 'PackyCode', category: 'ai-provider', keywords: ['packycode', 'packy', 'packyapi'], defaultColor: 'currentColor' },
|
|
||||||
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
|
|
||||||
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
|
|
||||||
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
|
|
||||||
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
|
|
||||||
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
|
|
||||||
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
|
|
||||||
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
|
|
||||||
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
|
|
||||||
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
|
|
||||||
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
|
|
||||||
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
|
|
||||||
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
|
|
||||||
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
|
|
||||||
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
|
|
||||||
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get all SVG files
|
|
||||||
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
|
|
||||||
|
|
||||||
console.log(`Found ${files.length} SVG files.`);
|
|
||||||
|
|
||||||
// Generate index.ts
|
|
||||||
const indexContent = `// Auto-generated icon index
|
|
||||||
// Do not edit manually
|
|
||||||
|
|
||||||
export const icons: Record<string, string> = {
|
|
||||||
${files.map(file => {
|
|
||||||
const name = path.basename(file, '.svg');
|
|
||||||
const svg = fs.readFileSync(path.join(ICONS_DIR, file), 'utf-8');
|
|
||||||
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
||||||
return ` '${name}': \`${escaped}\`,`;
|
|
||||||
}).join('\n')}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const iconList = Object.keys(icons);
|
|
||||||
|
|
||||||
export function getIcon(name: string): string {
|
|
||||||
return icons[name.toLowerCase()] || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasIcon(name: string): boolean {
|
|
||||||
return name.toLowerCase() in icons;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
fs.writeFileSync(INDEX_FILE, indexContent);
|
|
||||||
console.log(`Generated ${INDEX_FILE}`);
|
|
||||||
|
|
||||||
// Generate metadata.ts
|
|
||||||
const metadataEntries = files.map(file => {
|
|
||||||
const name = path.basename(file, '.svg').toLowerCase();
|
|
||||||
const known = KNOWN_METADATA[name];
|
|
||||||
|
|
||||||
if (known) {
|
|
||||||
return ` ${name}: ${JSON.stringify(known)},`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default metadata for unknown icons
|
|
||||||
return ` '${name}': { name: '${name}', displayName: '${name}', category: 'other', keywords: [], defaultColor: 'currentColor' },`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const metadataContent = `// Icon metadata for search and categorization
|
|
||||||
import { IconMetadata } from '@/types/icon';
|
|
||||||
|
|
||||||
export const iconMetadata: Record<string, IconMetadata> = {
|
|
||||||
${metadataEntries.join('\n')}
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getIconMetadata(name: string): IconMetadata | undefined {
|
|
||||||
return iconMetadata[name.toLowerCase()];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function searchIcons(query: string): string[] {
|
|
||||||
const lowerQuery = query.toLowerCase();
|
|
||||||
return Object.values(iconMetadata)
|
|
||||||
.filter(meta =>
|
|
||||||
meta.name.includes(lowerQuery) ||
|
|
||||||
meta.displayName.toLowerCase().includes(lowerQuery) ||
|
|
||||||
meta.keywords.some(k => k.includes(lowerQuery))
|
|
||||||
)
|
|
||||||
.map(meta => meta.name);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
fs.writeFileSync(METADATA_FILE, metadataContent);
|
|
||||||
console.log(`Generated ${METADATA_FILE}`);
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.9.0-1"
|
version = "3.1.2"
|
||||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
description = "Claude Code & Codex 供应商配置管理工具"
|
||||||
authors = ["Jason Young"]
|
authors = ["Jason Young"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/farion1231/cc-switch"
|
repository = "https://github.com/farion1231/cc-switch"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
rust-version = "1.85.0"
|
rust-version = "1.85.0"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
@@ -14,10 +14,6 @@ rust-version = "1.85.0"
|
|||||||
name = "cc_switch_lib"
|
name = "cc_switch_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
test-hooks = []
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.4.0", features = [] }
|
tauri-build = { version = "2.4.0", features = [] }
|
||||||
|
|
||||||
@@ -25,61 +21,12 @@ tauri-build = { version = "2.4.0", features = [] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
tauri = { version = "2.8.2", features = [] }
|
||||||
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] }
|
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-process = "2"
|
|
||||||
tauri-plugin-updater = "2"
|
|
||||||
tauri-plugin-dialog = "2"
|
|
||||||
tauri-plugin-store = "2"
|
|
||||||
tauri-plugin-deep-link = "2"
|
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
toml_edit = "0.22"
|
|
||||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
|
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
|
||||||
futures = "0.3"
|
|
||||||
async-stream = "0.3"
|
|
||||||
bytes = "1.5"
|
|
||||||
axum = "0.7"
|
|
||||||
tower = "0.4"
|
|
||||||
tower-http = { version = "0.5", features = ["cors"] }
|
|
||||||
hyper = { version = "1.0", features = ["full"] }
|
|
||||||
regex = "1.10"
|
|
||||||
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
|
||||||
thiserror = "2.0"
|
|
||||||
anyhow = "1.0"
|
|
||||||
zip = "2.2"
|
|
||||||
serde_yaml = "0.9"
|
|
||||||
tempfile = "3"
|
|
||||||
url = "2.5"
|
|
||||||
auto-launch = "0.5"
|
|
||||||
once_cell = "1.21.3"
|
|
||||||
base64 = "0.22"
|
|
||||||
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
|
||||||
indexmap = { version = "2", features = ["serde"] }
|
|
||||||
rust_decimal = "1.33"
|
|
||||||
uuid = { version = "1.11", features = ["v4"] }
|
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
|
||||||
tauri-plugin-single-instance = "2"
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
|
||||||
winreg = "0.52"
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.5"
|
objc2 = "0.5"
|
||||||
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
||||||
|
|
||||||
# Optimize release binary size to help reduce AppImage footprint
|
|
||||||
[profile.release]
|
|
||||||
codegen-units = 1
|
|
||||||
lto = "thin"
|
|
||||||
opt-level = "s"
|
|
||||||
panic = "abort"
|
|
||||||
strip = "symbols"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
serial_test = "3"
|
|
||||||
tempfile = "3"
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<!-- 注册 ccswitch:// 自定义 URL 协议,用于深链接导入 -->
|
|
||||||
<key>CFBundleURLTypes</key>
|
|
||||||
<array>
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleURLName</key>
|
|
||||||
<string>CC Switch Deep Link</string>
|
|
||||||
<key>CFBundleURLSchemes</key>
|
|
||||||
<array>
|
|
||||||
<string>ccswitch</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
|
|
||||||
@@ -7,11 +7,6 @@
|
|||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
"opener:default"
|
||||||
"updater:default",
|
|
||||||
"core:window:allow-set-skip-taskbar",
|
|
||||||
"core:window:allow-start-dragging",
|
|
||||||
"process:allow-restart",
|
|
||||||
"dialog:default"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 572 KiB |
@@ -1,151 +1,15 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use crate::services::skill::SkillStore;
|
|
||||||
|
|
||||||
/// MCP 服务器应用状态(标记应用到哪些客户端)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
|
||||||
pub struct McpApps {
|
|
||||||
#[serde(default)]
|
|
||||||
pub claude: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub codex: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub gemini: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpApps {
|
|
||||||
/// 检查指定应用是否启用
|
|
||||||
pub fn is_enabled_for(&self, app: &AppType) -> bool {
|
|
||||||
match app {
|
|
||||||
AppType::Claude => self.claude,
|
|
||||||
AppType::Codex => self.codex,
|
|
||||||
AppType::Gemini => self.gemini,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置指定应用的启用状态
|
|
||||||
pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
|
|
||||||
match app {
|
|
||||||
AppType::Claude => self.claude = enabled,
|
|
||||||
AppType::Codex => self.codex = enabled,
|
|
||||||
AppType::Gemini => self.gemini = enabled,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取所有启用的应用列表
|
|
||||||
pub fn enabled_apps(&self) -> Vec<AppType> {
|
|
||||||
let mut apps = Vec::new();
|
|
||||||
if self.claude {
|
|
||||||
apps.push(AppType::Claude);
|
|
||||||
}
|
|
||||||
if self.codex {
|
|
||||||
apps.push(AppType::Codex);
|
|
||||||
}
|
|
||||||
if self.gemini {
|
|
||||||
apps.push(AppType::Gemini);
|
|
||||||
}
|
|
||||||
apps
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查是否所有应用都未启用
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
!self.claude && !self.codex && !self.gemini
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// MCP 服务器定义(v3.7.0 统一结构)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct McpServer {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub server: serde_json::Value,
|
|
||||||
pub apps: McpApps,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub description: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub homepage: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub docs: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// MCP 配置:单客户端维度(v3.6.x 及以前,保留用于向后兼容)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct McpConfig {
|
|
||||||
/// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段)
|
|
||||||
#[serde(default)]
|
|
||||||
pub servers: HashMap<String, serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpConfig {
|
|
||||||
/// 检查配置是否为空
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.servers.is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// MCP 根配置(v3.7.0 新旧结构并存)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct McpRoot {
|
|
||||||
/// 统一的 MCP 服务器存储(v3.7.0+)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub servers: Option<HashMap<String, McpServer>>,
|
|
||||||
|
|
||||||
/// 旧的分应用存储(v3.6.x 及以前,保留用于迁移)
|
|
||||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
|
||||||
pub claude: McpConfig,
|
|
||||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
|
||||||
pub codex: McpConfig,
|
|
||||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
|
||||||
pub gemini: McpConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for McpRoot {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
// v3.7.0+ 默认使用新的统一结构(空 HashMap)
|
|
||||||
servers: Some(HashMap::new()),
|
|
||||||
// 旧结构保持空,仅用于反序列化旧配置时的迁移
|
|
||||||
claude: McpConfig::default(),
|
|
||||||
codex: McpConfig::default(),
|
|
||||||
gemini: McpConfig::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prompt 配置:单客户端维度
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct PromptConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub prompts: HashMap<String, crate::prompt::Prompt>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prompt 根:按客户端分开维护
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct PromptRoot {
|
|
||||||
#[serde(default)]
|
|
||||||
pub claude: PromptConfig,
|
|
||||||
#[serde(default)]
|
|
||||||
pub codex: PromptConfig,
|
|
||||||
#[serde(default)]
|
|
||||||
pub gemini: PromptConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::prompt_files::prompt_file_path;
|
|
||||||
use crate::provider::ProviderManager;
|
use crate::provider::ProviderManager;
|
||||||
|
|
||||||
/// 应用类型
|
/// 应用类型
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum AppType {
|
pub enum AppType {
|
||||||
Claude,
|
Claude,
|
||||||
Codex,
|
Codex,
|
||||||
Gemini, // 新增
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppType {
|
impl AppType {
|
||||||
@@ -153,58 +17,15 @@ impl AppType {
|
|||||||
match self {
|
match self {
|
||||||
AppType::Claude => "claude",
|
AppType::Claude => "claude",
|
||||||
AppType::Codex => "codex",
|
AppType::Codex => "codex",
|
||||||
AppType::Gemini => "gemini", // 新增
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for AppType {
|
impl From<&str> for AppType {
|
||||||
type Err = AppError;
|
fn from(s: &str) -> Self {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
"codex" => AppType::Codex,
|
||||||
let normalized = s.trim().to_lowercase();
|
_ => AppType::Claude, // 默认为 Claude
|
||||||
match normalized.as_str() {
|
|
||||||
"claude" => Ok(AppType::Claude),
|
|
||||||
"codex" => Ok(AppType::Codex),
|
|
||||||
"gemini" => Ok(AppType::Gemini), // 新增
|
|
||||||
other => Err(AppError::localized(
|
|
||||||
"unsupported_app",
|
|
||||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
|
|
||||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 通用配置片段(按应用分治)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct CommonConfigSnippets {
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub claude: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub codex: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub gemini: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommonConfigSnippets {
|
|
||||||
/// 获取指定应用的通用配置片段
|
|
||||||
pub fn get(&self, app: &AppType) -> Option<&String> {
|
|
||||||
match app {
|
|
||||||
AppType::Claude => self.claude.as_ref(),
|
|
||||||
AppType::Codex => self.codex.as_ref(),
|
|
||||||
AppType::Gemini => self.gemini.as_ref(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置指定应用的通用配置片段
|
|
||||||
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
|
|
||||||
match app {
|
|
||||||
AppType::Claude => self.claude = snippet,
|
|
||||||
AppType::Codex => self.codex = snippet,
|
|
||||||
AppType::Gemini => self.gemini = snippet,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,24 +35,8 @@ impl CommonConfigSnippets {
|
|||||||
pub struct MultiAppConfig {
|
pub struct MultiAppConfig {
|
||||||
#[serde(default = "default_version")]
|
#[serde(default = "default_version")]
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
/// 应用管理器(claude/codex)
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub apps: HashMap<String, ProviderManager>,
|
pub apps: HashMap<String, ProviderManager>,
|
||||||
/// MCP 配置(按客户端分治)
|
|
||||||
#[serde(default)]
|
|
||||||
pub mcp: McpRoot,
|
|
||||||
/// Prompt 配置(按客户端分治)
|
|
||||||
#[serde(default)]
|
|
||||||
pub prompts: PromptRoot,
|
|
||||||
/// Claude Skills 配置
|
|
||||||
#[serde(default)]
|
|
||||||
pub skills: SkillStore,
|
|
||||||
/// 通用配置片段(按应用分治)
|
|
||||||
#[serde(default)]
|
|
||||||
pub common_config_snippets: CommonConfigSnippets,
|
|
||||||
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub claude_common_config_snippet: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_version() -> u32 {
|
fn default_version() -> u32 {
|
||||||
@@ -243,139 +48,66 @@ impl Default for MultiAppConfig {
|
|||||||
let mut apps = HashMap::new();
|
let mut apps = HashMap::new();
|
||||||
apps.insert("claude".to_string(), ProviderManager::default());
|
apps.insert("claude".to_string(), ProviderManager::default());
|
||||||
apps.insert("codex".to_string(), ProviderManager::default());
|
apps.insert("codex".to_string(), ProviderManager::default());
|
||||||
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
|
|
||||||
|
|
||||||
Self {
|
Self { version: 2, apps }
|
||||||
version: 2,
|
|
||||||
apps,
|
|
||||||
mcp: McpRoot::default(),
|
|
||||||
prompts: PromptRoot::default(),
|
|
||||||
skills: SkillStore::default(),
|
|
||||||
common_config_snippets: CommonConfigSnippets::default(),
|
|
||||||
claude_common_config_snippet: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MultiAppConfig {
|
impl MultiAppConfig {
|
||||||
/// 从文件加载配置(仅支持 v2 结构)
|
/// 从文件加载配置(处理v1到v2的迁移)
|
||||||
pub fn load() -> Result<Self, AppError> {
|
pub fn load() -> Result<Self, String> {
|
||||||
let config_path = get_app_config_path();
|
let config_path = get_app_config_path();
|
||||||
|
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
log::info!("配置文件不存在,创建新的多应用配置并自动导入提示词");
|
log::info!("配置文件不存在,创建新的多应用配置");
|
||||||
// 使用新的方法,支持自动导入提示词
|
return Ok(Self::default());
|
||||||
let config = Self::default_with_auto_import()?;
|
}
|
||||||
// 立即保存到磁盘
|
|
||||||
|
// 尝试读取文件
|
||||||
|
let content = std::fs::read_to_string(&config_path)
|
||||||
|
.map_err(|e| format!("读取配置文件失败: {}", e))?;
|
||||||
|
|
||||||
|
// 检查是否是旧版本格式(v1)
|
||||||
|
if let Ok(v1_config) = serde_json::from_str::<ProviderManager>(&content) {
|
||||||
|
log::info!("检测到v1配置,自动迁移到v2");
|
||||||
|
|
||||||
|
// 迁移到新格式
|
||||||
|
let mut apps = HashMap::new();
|
||||||
|
apps.insert("claude".to_string(), v1_config);
|
||||||
|
apps.insert("codex".to_string(), ProviderManager::default());
|
||||||
|
|
||||||
|
let config = Self { version: 2, apps };
|
||||||
|
|
||||||
|
// 迁移前备份旧版(v1)配置文件
|
||||||
|
let backup_dir = get_app_config_dir();
|
||||||
|
let ts = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let backup_path = backup_dir.join(format!("config.v1.backup.{}.json", ts));
|
||||||
|
|
||||||
|
match copy_file(&config_path, &backup_path) {
|
||||||
|
Ok(()) => log::info!(
|
||||||
|
"已备份旧版配置文件: {} -> {}",
|
||||||
|
config_path.display(),
|
||||||
|
backup_path.display()
|
||||||
|
),
|
||||||
|
Err(e) => log::warn!("备份旧版配置文件失败: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存迁移后的配置
|
||||||
config.save()?;
|
config.save()?;
|
||||||
return Ok(config);
|
return Ok(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试读取文件
|
// 尝试读取v2格式
|
||||||
let content =
|
serde_json::from_str::<Self>(&content).map_err(|e| format!("解析配置文件失败: {}", e))
|
||||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
|
||||||
|
|
||||||
// 先解析为 Value,以便严格判定是否为 v1 结构;
|
|
||||||
// 满足:顶层同时包含 providers(object) + current(string),且不包含 version/apps/mcp 关键键,即视为 v1
|
|
||||||
let value: serde_json::Value =
|
|
||||||
serde_json::from_str(&content).map_err(|e| AppError::json(&config_path, e))?;
|
|
||||||
let is_v1 = value.as_object().is_some_and(|map| {
|
|
||||||
let has_providers = map.get("providers").map(|v| v.is_object()).unwrap_or(false);
|
|
||||||
let has_current = map.get("current").map(|v| v.is_string()).unwrap_or(false);
|
|
||||||
// v1 的充分必要条件:有 providers 和 current,且 apps 不存在(version/mcp 可能存在但不作为 v2 判据)
|
|
||||||
let has_apps = map.contains_key("apps");
|
|
||||||
has_providers && has_current && !has_apps
|
|
||||||
});
|
|
||||||
if is_v1 {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"config.unsupported_v1",
|
|
||||||
"检测到旧版 v1 配置格式。当前版本已不再支持运行时自动迁移。\n\n解决方案:\n1. 安装 v3.2.x 版本进行一次性自动迁移\n2. 或手动编辑 ~/.cc-switch/config.json,将顶层结构调整为:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
|
|
||||||
"Detected legacy v1 config. Runtime auto-migration is no longer supported.\n\nSolutions:\n1. Install v3.2.x for one-time auto-migration\n2. Or manually edit ~/.cc-switch/config.json to adjust the top-level structure:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let has_skills_in_config = value
|
|
||||||
.as_object()
|
|
||||||
.is_some_and(|map| map.contains_key("skills"));
|
|
||||||
|
|
||||||
// 解析 v2 结构
|
|
||||||
let mut config: Self =
|
|
||||||
serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
|
|
||||||
let mut updated = false;
|
|
||||||
|
|
||||||
if !has_skills_in_config {
|
|
||||||
let skills_path = get_app_config_dir().join("skills.json");
|
|
||||||
if skills_path.exists() {
|
|
||||||
match std::fs::read_to_string(&skills_path) {
|
|
||||||
Ok(content) => match serde_json::from_str::<SkillStore>(&content) {
|
|
||||||
Ok(store) => {
|
|
||||||
config.skills = store;
|
|
||||||
updated = true;
|
|
||||||
log::info!("已从旧版 skills.json 导入 Claude Skills 配置");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("解析旧版 skills.json 失败: {e}");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("读取旧版 skills.json 失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保 gemini 应用存在(兼容旧配置文件)
|
|
||||||
if !config.apps.contains_key("gemini") {
|
|
||||||
config
|
|
||||||
.apps
|
|
||||||
.insert("gemini".to_string(), ProviderManager::default());
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行 MCP 迁移(v3.6.x → v3.7.0)
|
|
||||||
let migrated = config.migrate_mcp_to_unified()?;
|
|
||||||
if migrated {
|
|
||||||
log::info!("MCP 配置已迁移到 v3.7.0 统一结构,保存配置...");
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 对于已经存在的配置文件,如果此前版本还没有 Prompt 功能,
|
|
||||||
// 且 prompts 仍然是空的,则尝试自动导入现有提示词文件。
|
|
||||||
let imported_prompts = config.maybe_auto_import_prompts_for_existing_config()?;
|
|
||||||
if imported_prompts {
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
|
|
||||||
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
|
|
||||||
log::info!(
|
|
||||||
"迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"
|
|
||||||
);
|
|
||||||
config.common_config_snippets.claude = Some(old_claude_snippet);
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if updated {
|
|
||||||
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
|
|
||||||
config.save()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存配置到文件
|
/// 保存配置到文件
|
||||||
pub fn save(&self) -> Result<(), AppError> {
|
pub fn save(&self) -> Result<(), String> {
|
||||||
let config_path = get_app_config_path();
|
let config_path = get_app_config_path();
|
||||||
// 先备份旧版(若存在)到 ~/.cc-switch/config.json.bak,再写入新内容
|
write_json_file(&config_path, self)
|
||||||
if config_path.exists() {
|
|
||||||
let backup_path = get_app_config_dir().join("config.json.bak");
|
|
||||||
if let Err(e) = copy_file(&config_path, &backup_path) {
|
|
||||||
log::warn!("备份 config.json 到 .bak 失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
write_json_file(&config_path, self)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取指定应用的管理器
|
/// 获取指定应用的管理器
|
||||||
@@ -395,469 +127,4 @@ impl MultiAppConfig {
|
|||||||
.insert(app.as_str().to_string(), ProviderManager::default());
|
.insert(app.as_str().to_string(), ProviderManager::default());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取指定客户端的 MCP 配置(不可变引用)
|
|
||||||
pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
|
|
||||||
match app {
|
|
||||||
AppType::Claude => &self.mcp.claude,
|
|
||||||
AppType::Codex => &self.mcp.codex,
|
|
||||||
AppType::Gemini => &self.mcp.gemini,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取指定客户端的 MCP 配置(可变引用)
|
|
||||||
pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
|
|
||||||
match app {
|
|
||||||
AppType::Claude => &mut self.mcp.claude,
|
|
||||||
AppType::Codex => &mut self.mcp.codex,
|
|
||||||
AppType::Gemini => &mut self.mcp.gemini,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 创建默认配置并自动导入已存在的提示词文件
|
|
||||||
fn default_with_auto_import() -> Result<Self, AppError> {
|
|
||||||
log::info!("首次启动,创建默认配置并检测提示词文件");
|
|
||||||
|
|
||||||
let mut config = Self::default();
|
|
||||||
|
|
||||||
// 为每个应用尝试自动导入提示词
|
|
||||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
|
|
||||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
|
||||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
|
||||||
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 已存在配置文件时的 Prompt 自动导入逻辑
|
|
||||||
///
|
|
||||||
/// 适用于「老版本已经生成过 config.json,但当时还没有 Prompt 功能」的升级场景。
|
|
||||||
/// 判定规则:
|
|
||||||
/// - 仅当所有应用的 prompts 都为空时才尝试导入(避免打扰已经在使用 Prompt 功能的用户)
|
|
||||||
/// - 每个应用最多导入一次,对应各自的提示词文件(如 CLAUDE.md/AGENTS.md/GEMINI.md)
|
|
||||||
///
|
|
||||||
/// 返回值:
|
|
||||||
/// - Ok(true) 表示至少有一个应用成功导入了提示词
|
|
||||||
/// - Ok(false) 表示无需导入或未导入任何内容
|
|
||||||
fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {
|
|
||||||
// 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入
|
|
||||||
if !self.prompts.claude.prompts.is_empty()
|
|
||||||
|| !self.prompts.codex.prompts.is_empty()
|
|
||||||
|| !self.prompts.gemini.prompts.is_empty()
|
|
||||||
{
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
|
|
||||||
|
|
||||||
let mut imported = false;
|
|
||||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
|
||||||
// 复用已有的单应用导入逻辑
|
|
||||||
if Self::auto_import_prompt_if_exists(self, app)? {
|
|
||||||
imported = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(imported)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查并自动导入单个应用的提示词文件
|
|
||||||
///
|
|
||||||
/// 返回值:
|
|
||||||
/// - Ok(true) 表示成功导入了非空文件
|
|
||||||
/// - Ok(false) 表示未导入(文件不存在、内容为空或读取失败)
|
|
||||||
fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result<bool, AppError> {
|
|
||||||
let file_path = prompt_file_path(&app)?;
|
|
||||||
|
|
||||||
// 检查文件是否存在
|
|
||||||
if !file_path.exists() {
|
|
||||||
log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}");
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取文件内容
|
|
||||||
let content = match std::fs::read_to_string(&file_path) {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}");
|
|
||||||
return Ok(false); // 失败时不中断,继续处理其他应用
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查内容是否为空
|
|
||||||
if content.trim().is_empty() {
|
|
||||||
log::debug!("提示词文件内容为空,跳过导入: {file_path:?}");
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("发现提示词文件,自动导入: {file_path:?}");
|
|
||||||
|
|
||||||
// 创建提示词对象
|
|
||||||
let timestamp = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs() as i64)
|
|
||||||
.unwrap_or_else(|_| {
|
|
||||||
log::warn!("Failed to get system time, using 0 as timestamp");
|
|
||||||
0
|
|
||||||
});
|
|
||||||
|
|
||||||
let id = format!("auto-imported-{timestamp}");
|
|
||||||
let prompt = crate::prompt::Prompt {
|
|
||||||
id: id.clone(),
|
|
||||||
name: format!(
|
|
||||||
"Auto-imported Prompt {}",
|
|
||||||
chrono::Local::now().format("%Y-%m-%d %H:%M")
|
|
||||||
),
|
|
||||||
content,
|
|
||||||
description: Some("Automatically imported on first launch".to_string()),
|
|
||||||
enabled: true, // 自动启用
|
|
||||||
created_at: Some(timestamp),
|
|
||||||
updated_at: Some(timestamp),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 插入到对应的应用配置中
|
|
||||||
let prompts = match app {
|
|
||||||
AppType::Claude => &mut config.prompts.claude.prompts,
|
|
||||||
AppType::Codex => &mut config.prompts.codex.prompts,
|
|
||||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
|
||||||
};
|
|
||||||
|
|
||||||
prompts.insert(id, prompt);
|
|
||||||
|
|
||||||
log::info!("自动导入完成: {}", app.as_str());
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 v3.6.x 的分应用 MCP 结构迁移到 v3.7.0 的统一结构
|
|
||||||
///
|
|
||||||
/// 迁移策略:
|
|
||||||
/// 1. 检查是否已经迁移(mcp.servers 是否存在)
|
|
||||||
/// 2. 收集所有应用的 MCP,按 ID 去重合并
|
|
||||||
/// 3. 生成统一的 McpServer 结构,标记应用到哪些客户端
|
|
||||||
/// 4. 清空旧的分应用配置
|
|
||||||
pub fn migrate_mcp_to_unified(&mut self) -> Result<bool, AppError> {
|
|
||||||
// 检查是否已经是新结构
|
|
||||||
if self.mcp.servers.is_some() {
|
|
||||||
log::debug!("MCP 配置已是统一结构,跳过迁移");
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("检测到旧版 MCP 配置格式,开始迁移到 v3.7.0 统一结构...");
|
|
||||||
|
|
||||||
let mut unified_servers: HashMap<String, McpServer> = HashMap::new();
|
|
||||||
let mut conflicts = Vec::new();
|
|
||||||
|
|
||||||
// 收集所有应用的 MCP
|
|
||||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
|
||||||
let old_servers = match app {
|
|
||||||
AppType::Claude => &self.mcp.claude.servers,
|
|
||||||
AppType::Codex => &self.mcp.codex.servers,
|
|
||||||
AppType::Gemini => &self.mcp.gemini.servers,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (id, entry) in old_servers {
|
|
||||||
let enabled = entry
|
|
||||||
.get("enabled")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(true);
|
|
||||||
|
|
||||||
if let Some(existing) = unified_servers.get_mut(id) {
|
|
||||||
// 该 ID 已存在,合并 apps 字段
|
|
||||||
existing.apps.set_enabled_for(&app, enabled);
|
|
||||||
|
|
||||||
// 检测配置冲突(同 ID 但配置不同)
|
|
||||||
if existing.server != *entry.get("server").unwrap_or(&serde_json::json!({})) {
|
|
||||||
conflicts.push(format!(
|
|
||||||
"MCP '{id}' 在 {} 和之前的应用中配置不同,将使用首次遇到的配置",
|
|
||||||
app.as_str()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 首次遇到该 MCP,创建新条目
|
|
||||||
let name = entry
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or(id)
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let server = entry
|
|
||||||
.get("server")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or(serde_json::json!({}));
|
|
||||||
|
|
||||||
let description = entry
|
|
||||||
.get("description")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
let homepage = entry
|
|
||||||
.get("homepage")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
let docs = entry
|
|
||||||
.get("docs")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
let tags = entry
|
|
||||||
.get("tags")
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
.map(|arr| {
|
|
||||||
arr.iter()
|
|
||||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let mut apps = McpApps::default();
|
|
||||||
apps.set_enabled_for(&app, enabled);
|
|
||||||
|
|
||||||
unified_servers.insert(
|
|
||||||
id.clone(),
|
|
||||||
McpServer {
|
|
||||||
id: id.clone(),
|
|
||||||
name,
|
|
||||||
server,
|
|
||||||
apps,
|
|
||||||
description,
|
|
||||||
homepage,
|
|
||||||
docs,
|
|
||||||
tags,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录冲突警告
|
|
||||||
if !conflicts.is_empty() {
|
|
||||||
log::warn!("MCP 迁移过程中检测到配置冲突:");
|
|
||||||
for conflict in &conflicts {
|
|
||||||
log::warn!(" - {conflict}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"MCP 迁移完成,共迁移 {} 个服务器{}",
|
|
||||||
unified_servers.len(),
|
|
||||||
if !conflicts.is_empty() {
|
|
||||||
format!("(存在 {} 个冲突)", conflicts.len())
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 替换为新结构
|
|
||||||
self.mcp.servers = Some(unified_servers);
|
|
||||||
|
|
||||||
// 清空旧的分应用配置
|
|
||||||
self.mcp.claude = McpConfig::default();
|
|
||||||
self.mcp.codex = McpConfig::default();
|
|
||||||
self.mcp.gemini = McpConfig::default();
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use serial_test::serial;
|
|
||||||
use std::env;
|
|
||||||
use std::fs;
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
struct TempHome {
|
|
||||||
#[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期
|
|
||||||
dir: TempDir,
|
|
||||||
original_home: Option<String>,
|
|
||||||
original_userprofile: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TempHome {
|
|
||||||
fn new() -> Self {
|
|
||||||
let dir = TempDir::new().expect("failed to create temp home");
|
|
||||||
let original_home = env::var("HOME").ok();
|
|
||||||
let original_userprofile = env::var("USERPROFILE").ok();
|
|
||||||
|
|
||||||
env::set_var("HOME", dir.path());
|
|
||||||
env::set_var("USERPROFILE", dir.path());
|
|
||||||
|
|
||||||
Self {
|
|
||||||
dir,
|
|
||||||
original_home,
|
|
||||||
original_userprofile,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TempHome {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
match &self.original_home {
|
|
||||||
Some(value) => env::set_var("HOME", value),
|
|
||||||
None => env::remove_var("HOME"),
|
|
||||||
}
|
|
||||||
|
|
||||||
match &self.original_userprofile {
|
|
||||||
Some(value) => env::set_var("USERPROFILE", value),
|
|
||||||
None => env::remove_var("USERPROFILE"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_prompt_file(app: AppType, content: &str) {
|
|
||||||
let path = crate::prompt_files::prompt_file_path(&app).expect("prompt path");
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent).expect("create parent dir");
|
|
||||||
}
|
|
||||||
fs::write(path, content).expect("write prompt");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn auto_imports_existing_prompt_when_config_missing() {
|
|
||||||
let _home = TempHome::new();
|
|
||||||
write_prompt_file(AppType::Claude, "# hello");
|
|
||||||
|
|
||||||
let config = MultiAppConfig::load().expect("load config");
|
|
||||||
|
|
||||||
assert_eq!(config.prompts.claude.prompts.len(), 1);
|
|
||||||
let prompt = config
|
|
||||||
.prompts
|
|
||||||
.claude
|
|
||||||
.prompts
|
|
||||||
.values()
|
|
||||||
.next()
|
|
||||||
.expect("prompt exists");
|
|
||||||
assert!(prompt.enabled);
|
|
||||||
assert_eq!(prompt.content, "# hello");
|
|
||||||
|
|
||||||
let config_path = crate::config::get_app_config_path();
|
|
||||||
assert!(
|
|
||||||
config_path.exists(),
|
|
||||||
"auto import should persist config to disk"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn skips_empty_prompt_files_during_import() {
|
|
||||||
let _home = TempHome::new();
|
|
||||||
write_prompt_file(AppType::Claude, " \n ");
|
|
||||||
|
|
||||||
let config = MultiAppConfig::load().expect("load config");
|
|
||||||
assert!(
|
|
||||||
config.prompts.claude.prompts.is_empty(),
|
|
||||||
"empty files must be ignored"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn auto_import_happens_only_once() {
|
|
||||||
let _home = TempHome::new();
|
|
||||||
write_prompt_file(AppType::Claude, "first version");
|
|
||||||
|
|
||||||
let first = MultiAppConfig::load().expect("load config");
|
|
||||||
assert_eq!(first.prompts.claude.prompts.len(), 1);
|
|
||||||
let claude_prompt = first
|
|
||||||
.prompts
|
|
||||||
.claude
|
|
||||||
.prompts
|
|
||||||
.values()
|
|
||||||
.next()
|
|
||||||
.expect("prompt exists")
|
|
||||||
.content
|
|
||||||
.clone();
|
|
||||||
assert_eq!(claude_prompt, "first version");
|
|
||||||
|
|
||||||
// 覆盖文件内容,但保留 config.json
|
|
||||||
write_prompt_file(AppType::Claude, "second version");
|
|
||||||
let second = MultiAppConfig::load().expect("load config again");
|
|
||||||
|
|
||||||
assert_eq!(second.prompts.claude.prompts.len(), 1);
|
|
||||||
let prompt = second
|
|
||||||
.prompts
|
|
||||||
.claude
|
|
||||||
.prompts
|
|
||||||
.values()
|
|
||||||
.next()
|
|
||||||
.expect("prompt exists");
|
|
||||||
assert_eq!(
|
|
||||||
prompt.content, "first version",
|
|
||||||
"should not re-import when config already exists"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn auto_imports_gemini_prompt_on_first_launch() {
|
|
||||||
let _home = TempHome::new();
|
|
||||||
write_prompt_file(AppType::Gemini, "# Gemini Prompt\n\nTest content");
|
|
||||||
|
|
||||||
let config = MultiAppConfig::load().expect("load config");
|
|
||||||
|
|
||||||
assert_eq!(config.prompts.gemini.prompts.len(), 1);
|
|
||||||
let prompt = config
|
|
||||||
.prompts
|
|
||||||
.gemini
|
|
||||||
.prompts
|
|
||||||
.values()
|
|
||||||
.next()
|
|
||||||
.expect("gemini prompt exists");
|
|
||||||
assert!(prompt.enabled, "gemini prompt should be enabled");
|
|
||||||
assert_eq!(prompt.content, "# Gemini Prompt\n\nTest content");
|
|
||||||
assert_eq!(
|
|
||||||
prompt.description,
|
|
||||||
Some("Automatically imported on first launch".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn auto_imports_all_three_apps_prompts() {
|
|
||||||
let _home = TempHome::new();
|
|
||||||
write_prompt_file(AppType::Claude, "# Claude prompt");
|
|
||||||
write_prompt_file(AppType::Codex, "# Codex prompt");
|
|
||||||
write_prompt_file(AppType::Gemini, "# Gemini prompt");
|
|
||||||
|
|
||||||
let config = MultiAppConfig::load().expect("load config");
|
|
||||||
|
|
||||||
// 验证所有三个应用的提示词都被导入
|
|
||||||
assert_eq!(config.prompts.claude.prompts.len(), 1);
|
|
||||||
assert_eq!(config.prompts.codex.prompts.len(), 1);
|
|
||||||
assert_eq!(config.prompts.gemini.prompts.len(), 1);
|
|
||||||
|
|
||||||
// 验证所有提示词都被启用
|
|
||||||
assert!(
|
|
||||||
config
|
|
||||||
.prompts
|
|
||||||
.claude
|
|
||||||
.prompts
|
|
||||||
.values()
|
|
||||||
.next()
|
|
||||||
.unwrap()
|
|
||||||
.enabled
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
config
|
|
||||||
.prompts
|
|
||||||
.codex
|
|
||||||
.prompts
|
|
||||||
.values()
|
|
||||||
.next()
|
|
||||||
.unwrap()
|
|
||||||
.enabled
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
config
|
|
||||||
.prompts
|
|
||||||
.gemini
|
|
||||||
.prompts
|
|
||||||
.values()
|
|
||||||
.next()
|
|
||||||
.unwrap()
|
|
||||||
.enabled
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
use serde_json::Value;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::{OnceLock, RwLock};
|
|
||||||
use tauri_plugin_store::StoreExt;
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
/// Store 中的键名
|
|
||||||
const STORE_KEY_APP_CONFIG_DIR: &str = "app_config_dir_override";
|
|
||||||
|
|
||||||
/// 缓存当前的 app_config_dir 覆盖路径,避免存储 AppHandle
|
|
||||||
static APP_CONFIG_DIR_OVERRIDE: OnceLock<RwLock<Option<PathBuf>>> = OnceLock::new();
|
|
||||||
|
|
||||||
fn override_cache() -> &'static RwLock<Option<PathBuf>> {
|
|
||||||
APP_CONFIG_DIR_OVERRIDE.get_or_init(|| RwLock::new(None))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_cached_override(value: Option<PathBuf>) {
|
|
||||||
if let Ok(mut guard) = override_cache().write() {
|
|
||||||
*guard = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取缓存中的 app_config_dir 覆盖路径
|
|
||||||
pub fn get_app_config_dir_override() -> Option<PathBuf> {
|
|
||||||
override_cache().read().ok()?.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_override_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {
|
|
||||||
let store = match app.store_builder("app_paths.json").build() {
|
|
||||||
Ok(store) => store,
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("无法创建 Store: {e}");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match store.get(STORE_KEY_APP_CONFIG_DIR) {
|
|
||||||
Some(Value::String(path_str)) => {
|
|
||||||
let path_str = path_str.trim();
|
|
||||||
if path_str.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = resolve_path(path_str);
|
|
||||||
|
|
||||||
if !path.exists() {
|
|
||||||
log::warn!(
|
|
||||||
"Store 中配置的 app_config_dir 不存在: {path:?}\n\
|
|
||||||
将使用默认路径。"
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("使用 Store 中的 app_config_dir: {path:?}");
|
|
||||||
Some(path)
|
|
||||||
}
|
|
||||||
Some(_) => {
|
|
||||||
log::warn!("Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
None => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 Store 刷新 app_config_dir 覆盖值并更新缓存
|
|
||||||
pub fn refresh_app_config_dir_override(app: &tauri::AppHandle) -> Option<PathBuf> {
|
|
||||||
let value = read_override_from_store(app);
|
|
||||||
update_cached_override(value.clone());
|
|
||||||
value
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 写入 app_config_dir 到 Tauri Store
|
|
||||||
pub fn set_app_config_dir_to_store(
|
|
||||||
app: &tauri::AppHandle,
|
|
||||||
path: Option<&str>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let store = app
|
|
||||||
.store_builder("app_paths.json")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| AppError::Message(format!("创建 Store 失败: {e}")))?;
|
|
||||||
|
|
||||||
match path {
|
|
||||||
Some(p) => {
|
|
||||||
let trimmed = p.trim();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string()));
|
|
||||||
log::info!("已将 app_config_dir 写入 Store: {trimmed}");
|
|
||||||
} else {
|
|
||||||
store.delete(STORE_KEY_APP_CONFIG_DIR);
|
|
||||||
log::info!("已从 Store 中删除 app_config_dir 配置");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
store.delete(STORE_KEY_APP_CONFIG_DIR);
|
|
||||||
log::info!("已从 Store 中删除 app_config_dir 配置");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
store
|
|
||||||
.save()
|
|
||||||
.map_err(|e| AppError::Message(format!("保存 Store 失败: {e}")))?;
|
|
||||||
|
|
||||||
refresh_app_config_dir_override(app);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析路径,支持 ~ 开头的相对路径
|
|
||||||
fn resolve_path(raw: &str) -> PathBuf {
|
|
||||||
if raw == "~" {
|
|
||||||
if let Some(home) = dirs::home_dir() {
|
|
||||||
return home;
|
|
||||||
}
|
|
||||||
} else if let Some(stripped) = raw.strip_prefix("~/") {
|
|
||||||
if let Some(home) = dirs::home_dir() {
|
|
||||||
return home.join(stripped);
|
|
||||||
}
|
|
||||||
} else if let Some(stripped) = raw.strip_prefix("~\\") {
|
|
||||||
if let Some(home) = dirs::home_dir() {
|
|
||||||
return home.join(stripped);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PathBuf::from(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从旧的 settings.json 迁移 app_config_dir 到 Store
|
|
||||||
pub fn migrate_app_config_dir_from_settings(app: &tauri::AppHandle) -> Result<(), AppError> {
|
|
||||||
// app_config_dir 已从 settings.json 移除,此函数保留但不再执行迁移
|
|
||||||
// 如果用户在旧版本设置过 app_config_dir,需要在 Store 中手动配置
|
|
||||||
log::info!("app_config_dir 迁移功能已移除,请在设置中重新配置");
|
|
||||||
|
|
||||||
let _ = refresh_app_config_dir_override(app);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
use crate::error::AppError;
|
|
||||||
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
|
|
||||||
|
|
||||||
/// 初始化 AutoLaunch 实例
|
|
||||||
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
|
|
||||||
let app_name = "CC Switch";
|
|
||||||
let app_path =
|
|
||||||
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
|
|
||||||
|
|
||||||
// 使用 AutoLaunchBuilder 消除平台差异
|
|
||||||
// Windows/Linux: new() 接受 3 参数
|
|
||||||
// macOS: new() 接受 4 参数(含 hidden 参数)
|
|
||||||
// Builder 模式自动处理这些差异
|
|
||||||
let auto_launch = AutoLaunchBuilder::new()
|
|
||||||
.set_app_name(app_name)
|
|
||||||
.set_app_path(&app_path.to_string_lossy())
|
|
||||||
.build()
|
|
||||||
.map_err(|e| AppError::Message(format!("创建 AutoLaunch 失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(auto_launch)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 启用开机自启
|
|
||||||
pub fn enable_auto_launch() -> Result<(), AppError> {
|
|
||||||
let auto_launch = get_auto_launch()?;
|
|
||||||
auto_launch
|
|
||||||
.enable()
|
|
||||||
.map_err(|e| AppError::Message(format!("启用开机自启失败: {e}")))?;
|
|
||||||
log::info!("已启用开机自启");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 禁用开机自启
|
|
||||||
pub fn disable_auto_launch() -> Result<(), AppError> {
|
|
||||||
let auto_launch = get_auto_launch()?;
|
|
||||||
auto_launch
|
|
||||||
.disable()
|
|
||||||
.map_err(|e| AppError::Message(format!("禁用开机自启失败: {e}")))?;
|
|
||||||
log::info!("已禁用开机自启");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查是否已启用开机自启
|
|
||||||
pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
|
|
||||||
let auto_launch = get_auto_launch()?;
|
|
||||||
auto_launch
|
|
||||||
.is_enabled()
|
|
||||||
.map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}")))
|
|
||||||
}
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::{Map, Value};
|
|
||||||
use std::env;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct McpStatus {
|
|
||||||
pub user_config_path: String,
|
|
||||||
pub user_config_exists: bool,
|
|
||||||
pub server_count: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn user_config_path() -> PathBuf {
|
|
||||||
ensure_mcp_override_migrated();
|
|
||||||
get_claude_mcp_path()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_mcp_override_migrated() {
|
|
||||||
if crate::settings::get_claude_override_dir().is_none() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_path = get_claude_mcp_path();
|
|
||||||
if new_path.exists() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let legacy_path = get_default_claude_mcp_path();
|
|
||||||
if !legacy_path.exists() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(parent) = new_path.parent() {
|
|
||||||
if let Err(err) = fs::create_dir_all(parent) {
|
|
||||||
log::warn!("创建 MCP 目录失败: {err}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match fs::copy(&legacy_path, &new_path) {
|
|
||||||
Ok(_) => {
|
|
||||||
log::info!(
|
|
||||||
"已根据覆盖目录复制 MCP 配置: {} -> {}",
|
|
||||||
legacy_path.display(),
|
|
||||||
new_path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
log::warn!(
|
|
||||||
"复制 MCP 配置失败: {} -> {}: {}",
|
|
||||||
legacy_path.display(),
|
|
||||||
new_path.display(),
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_json_value(path: &Path) -> Result<Value, AppError> {
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(serde_json::json!({}));
|
|
||||||
}
|
|
||||||
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
|
|
||||||
let value: Value = serde_json::from_str(&content).map_err(|e| AppError::json(path, e))?;
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
|
||||||
}
|
|
||||||
let json =
|
|
||||||
serde_json::to_string_pretty(value).map_err(|e| AppError::JsonSerialize { source: e })?;
|
|
||||||
atomic_write(path, json.as_bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_mcp_status() -> Result<McpStatus, AppError> {
|
|
||||||
let path = user_config_path();
|
|
||||||
let (exists, count) = if path.exists() {
|
|
||||||
let v = read_json_value(&path)?;
|
|
||||||
let servers = v.get("mcpServers").and_then(|x| x.as_object());
|
|
||||||
(true, servers.map(|m| m.len()).unwrap_or(0))
|
|
||||||
} else {
|
|
||||||
(false, 0)
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(McpStatus {
|
|
||||||
user_config_path: path.to_string_lossy().to_string(),
|
|
||||||
user_config_exists: exists,
|
|
||||||
server_count: count,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
|
||||||
let path = user_config_path();
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
|
||||||
Ok(Some(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
|
|
||||||
if id.trim().is_empty() {
|
|
||||||
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
|
||||||
}
|
|
||||||
// 基础字段校验(尽量宽松)
|
|
||||||
if !spec.is_object() {
|
|
||||||
return Err(AppError::McpValidation(
|
|
||||||
"MCP 服务器定义必须为 JSON 对象".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
|
||||||
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理)
|
|
||||||
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
|
||||||
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
|
|
||||||
if !(is_stdio || is_http || is_sse) {
|
|
||||||
return Err(AppError::McpValidation(
|
|
||||||
"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// stdio 类型必须有 command
|
|
||||||
if is_stdio {
|
|
||||||
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
|
|
||||||
if cmd.is_empty() {
|
|
||||||
return Err(AppError::McpValidation(
|
|
||||||
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// http/sse 类型必须有 url
|
|
||||||
if is_http || is_sse {
|
|
||||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
|
||||||
if url.is_empty() {
|
|
||||||
return Err(AppError::McpValidation(if is_http {
|
|
||||||
"http 类型的 MCP 服务器缺少 url 字段".into()
|
|
||||||
} else {
|
|
||||||
"sse 类型的 MCP 服务器缺少 url 字段".into()
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = user_config_path();
|
|
||||||
let mut root = if path.exists() {
|
|
||||||
read_json_value(&path)?
|
|
||||||
} else {
|
|
||||||
serde_json::json!({})
|
|
||||||
};
|
|
||||||
|
|
||||||
// 确保 mcpServers 对象存在
|
|
||||||
{
|
|
||||||
let obj = root
|
|
||||||
.as_object_mut()
|
|
||||||
.ok_or_else(|| AppError::Config("mcp.json 根必须是对象".into()))?;
|
|
||||||
if !obj.contains_key("mcpServers") {
|
|
||||||
obj.insert("mcpServers".into(), serde_json::json!({}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let before = root.clone();
|
|
||||||
if let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
|
|
||||||
servers.insert(id.to_string(), spec);
|
|
||||||
}
|
|
||||||
|
|
||||||
if before == root && path.exists() {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
write_json_value(&path, &root)?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete_mcp_server(id: &str) -> Result<bool, AppError> {
|
|
||||||
if id.trim().is_empty() {
|
|
||||||
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
|
||||||
}
|
|
||||||
let path = user_config_path();
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
let mut root = read_json_value(&path)?;
|
|
||||||
let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
let existed = servers.remove(id).is_some();
|
|
||||||
if !existed {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
write_json_value(&path, &root)?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate_command_in_path(cmd: &str) -> Result<bool, AppError> {
|
|
||||||
if cmd.trim().is_empty() {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
// 如果包含路径分隔符,直接判断是否存在可执行文件
|
|
||||||
if cmd.contains('/') || cmd.contains('\\') {
|
|
||||||
return Ok(Path::new(cmd).exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
let path_var = env::var_os("PATH").unwrap_or_default();
|
|
||||||
let paths = env::split_paths(&path_var);
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
let exts: Vec<String> = env::var("PATHEXT")
|
|
||||||
.unwrap_or(".COM;.EXE;.BAT;.CMD".into())
|
|
||||||
.split(';')
|
|
||||||
.map(|s| s.trim().to_uppercase())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for p in paths {
|
|
||||||
let candidate = p.join(cmd);
|
|
||||||
if candidate.is_file() {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
for ext in &exts {
|
|
||||||
let cand = p.join(format!("{}{}", cmd, ext));
|
|
||||||
if cand.is_file() {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取 ~/.claude.json 中的 mcpServers 映射
|
|
||||||
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
|
|
||||||
let path = user_config_path();
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(std::collections::HashMap::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let root = read_json_value(&path)?;
|
|
||||||
let servers = root
|
|
||||||
.get("mcpServers")
|
|
||||||
.and_then(|v| v.as_object())
|
|
||||||
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(servers)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段
|
|
||||||
/// 仅覆盖 mcpServers,其他字段保持不变
|
|
||||||
pub fn set_mcp_servers_map(
|
|
||||||
servers: &std::collections::HashMap<String, Value>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let path = user_config_path();
|
|
||||||
let mut root = if path.exists() {
|
|
||||||
read_json_value(&path)?
|
|
||||||
} else {
|
|
||||||
serde_json::json!({})
|
|
||||||
};
|
|
||||||
|
|
||||||
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
|
|
||||||
let mut out: Map<String, Value> = Map::new();
|
|
||||||
for (id, spec) in servers.iter() {
|
|
||||||
let mut obj = if let Some(map) = spec.as_object() {
|
|
||||||
map.clone()
|
|
||||||
} else {
|
|
||||||
return Err(AppError::McpValidation(format!(
|
|
||||||
"MCP 服务器 '{id}' 不是对象"
|
|
||||||
)));
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(server_val) = obj.remove("server") {
|
|
||||||
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
|
|
||||||
AppError::McpValidation(format!("MCP 服务器 '{id}' server 字段不是对象"))
|
|
||||||
})?;
|
|
||||||
obj = server_obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
obj.remove("enabled");
|
|
||||||
obj.remove("source");
|
|
||||||
obj.remove("id");
|
|
||||||
obj.remove("name");
|
|
||||||
obj.remove("description");
|
|
||||||
obj.remove("tags");
|
|
||||||
obj.remove("homepage");
|
|
||||||
obj.remove("docs");
|
|
||||||
|
|
||||||
out.insert(id.clone(), Value::Object(obj));
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let obj = root
|
|
||||||
.as_object_mut()
|
|
||||||
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
|
|
||||||
obj.insert("mcpServers".into(), Value::Object(out));
|
|
||||||
}
|
|
||||||
|
|
||||||
write_json_value(&path, &root)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
const CLAUDE_DIR: &str = ".claude";
|
|
||||||
const CLAUDE_CONFIG_FILE: &str = "config.json";
|
|
||||||
|
|
||||||
fn claude_dir() -> Result<PathBuf, AppError> {
|
|
||||||
// 优先使用设置中的覆盖目录
|
|
||||||
if let Some(dir) = crate::settings::get_claude_override_dir() {
|
|
||||||
return Ok(dir);
|
|
||||||
}
|
|
||||||
let home = dirs::home_dir().ok_or_else(|| AppError::Config("无法获取用户主目录".into()))?;
|
|
||||||
Ok(home.join(CLAUDE_DIR))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn claude_config_path() -> Result<PathBuf, AppError> {
|
|
||||||
Ok(claude_dir()?.join(CLAUDE_CONFIG_FILE))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ensure_claude_dir_exists() -> Result<PathBuf, AppError> {
|
|
||||||
let dir = claude_dir()?;
|
|
||||||
if !dir.exists() {
|
|
||||||
fs::create_dir_all(&dir).map_err(|e| AppError::io(&dir, e))?;
|
|
||||||
}
|
|
||||||
Ok(dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_claude_config() -> Result<Option<String>, AppError> {
|
|
||||||
let path = claude_config_path()?;
|
|
||||||
if path.exists() {
|
|
||||||
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
|
||||||
Ok(Some(content))
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_managed_config(content: &str) -> bool {
|
|
||||||
match serde_json::from_str::<serde_json::Value>(content) {
|
|
||||||
Ok(value) => value
|
|
||||||
.get("primaryApiKey")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|val| val == "any")
|
|
||||||
.unwrap_or(false),
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_claude_config() -> Result<bool, AppError> {
|
|
||||||
// 增量写入:仅设置 primaryApiKey = "any",保留其它字段
|
|
||||||
let path = claude_config_path()?;
|
|
||||||
ensure_claude_dir_exists()?;
|
|
||||||
|
|
||||||
// 尝试读取并解析为对象
|
|
||||||
let mut obj = match read_claude_config()? {
|
|
||||||
Some(existing) => match serde_json::from_str::<serde_json::Value>(&existing) {
|
|
||||||
Ok(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
|
|
||||||
_ => serde_json::json!({}),
|
|
||||||
},
|
|
||||||
None => serde_json::json!({}),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut changed = false;
|
|
||||||
if let Some(map) = obj.as_object_mut() {
|
|
||||||
let cur = map
|
|
||||||
.get("primaryApiKey")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
if cur != "any" {
|
|
||||||
map.insert(
|
|
||||||
"primaryApiKey".to_string(),
|
|
||||||
serde_json::Value::String("any".to_string()),
|
|
||||||
);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if changed || !path.exists() {
|
|
||||||
let serialized = serde_json::to_string_pretty(&obj)
|
|
||||||
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
|
||||||
fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?;
|
|
||||||
Ok(true)
|
|
||||||
} else {
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_claude_config() -> Result<bool, AppError> {
|
|
||||||
let path = claude_config_path()?;
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = match read_claude_config()? {
|
|
||||||
Some(content) => content,
|
|
||||||
None => return Ok(false),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut value = match serde_json::from_str::<serde_json::Value>(&content) {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(_) => return Ok(false),
|
|
||||||
};
|
|
||||||
|
|
||||||
let obj = match value.as_object_mut() {
|
|
||||||
Some(obj) => obj,
|
|
||||||
None => return Ok(false),
|
|
||||||
};
|
|
||||||
|
|
||||||
if obj.remove("primaryApiKey").is_none() {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
let serialized =
|
|
||||||
serde_json::to_string_pretty(&value).map_err(|e| AppError::JsonSerialize { source: e })?;
|
|
||||||
fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn claude_config_status() -> Result<(bool, PathBuf), AppError> {
|
|
||||||
let path = claude_config_path()?;
|
|
||||||
Ok((path.exists(), path))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_claude_config_applied() -> Result<bool, AppError> {
|
|
||||||
match read_claude_config()? {
|
|
||||||
Some(content) => Ok(is_managed_config(&content)),
|
|
||||||
None => Ok(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,13 @@
|
|||||||
// unused imports removed
|
use serde_json::Value;
|
||||||
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
|
copy_file, delete_file, read_json_file, sanitize_provider_name, write_json_file,
|
||||||
};
|
};
|
||||||
use crate::error::AppError;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
/// 获取 Codex 配置目录路径
|
/// 获取 Codex 配置目录路径
|
||||||
pub fn get_codex_config_dir() -> PathBuf {
|
pub fn get_codex_config_dir() -> PathBuf {
|
||||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
|
||||||
return custom;
|
|
||||||
}
|
|
||||||
|
|
||||||
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
|
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,27 +22,73 @@ pub fn get_codex_config_path() -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 Codex 供应商配置文件路径
|
/// 获取 Codex 供应商配置文件路径
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn get_codex_provider_paths(
|
pub fn get_codex_provider_paths(
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
provider_name: Option<&str>,
|
provider_name: Option<&str>,
|
||||||
) -> (PathBuf, PathBuf) {
|
) -> (PathBuf, PathBuf) {
|
||||||
let base_name = provider_name
|
let base_name = provider_name
|
||||||
.map(sanitize_provider_name)
|
.map(|name| sanitize_provider_name(name))
|
||||||
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
||||||
|
|
||||||
let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json"));
|
let auth_path = get_codex_config_dir().join(format!("auth-{}.json", base_name));
|
||||||
let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml"));
|
let config_path = get_codex_config_dir().join(format!("config-{}.toml", base_name));
|
||||||
|
|
||||||
(auth_path, config_path)
|
(auth_path, config_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除 Codex 供应商配置文件
|
/// 备份 Codex 当前配置
|
||||||
#[allow(dead_code)]
|
pub fn backup_codex_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
|
||||||
pub fn delete_codex_provider_config(
|
let auth_path = get_codex_auth_path();
|
||||||
|
let config_path = get_codex_config_path();
|
||||||
|
let (backup_auth_path, backup_config_path) =
|
||||||
|
get_codex_provider_paths(provider_id, Some(provider_name));
|
||||||
|
|
||||||
|
// 备份 auth.json
|
||||||
|
if auth_path.exists() {
|
||||||
|
copy_file(&auth_path, &backup_auth_path)?;
|
||||||
|
log::info!("已备份 Codex auth.json: {}", backup_auth_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备份 config.toml
|
||||||
|
if config_path.exists() {
|
||||||
|
copy_file(&config_path, &backup_config_path)?;
|
||||||
|
log::info!("已备份 Codex config.toml: {}", backup_config_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存 Codex 供应商配置副本
|
||||||
|
pub fn save_codex_provider_config(
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
provider_name: &str,
|
provider_name: &str,
|
||||||
) -> Result<(), AppError> {
|
settings_config: &Value,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
|
||||||
|
|
||||||
|
// 保存 auth.json
|
||||||
|
if let Some(auth) = settings_config.get("auth") {
|
||||||
|
write_json_file(&auth_path, auth)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 config.toml
|
||||||
|
if let Some(config) = settings_config.get("config") {
|
||||||
|
if let Some(config_str) = config.as_str() {
|
||||||
|
// 若非空则进行 TOML 语法校验
|
||||||
|
if !config_str.trim().is_empty() {
|
||||||
|
toml::from_str::<toml::Table>(config_str)
|
||||||
|
.map_err(|e| format!("config.toml 格式错误: {}", e))?;
|
||||||
|
}
|
||||||
|
fs::write(&config_path, config_str)
|
||||||
|
.map_err(|e| format!("写入供应商 config.toml 失败: {}", e))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 Codex 供应商配置文件
|
||||||
|
pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
|
||||||
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
|
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
|
||||||
|
|
||||||
delete_file(&auth_path).ok();
|
delete_file(&auth_path).ok();
|
||||||
@@ -58,79 +97,76 @@ pub fn delete_codex_provider_config(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
|
/// 从 Codex 供应商配置副本恢复到主配置
|
||||||
pub fn write_codex_live_atomic(
|
pub fn restore_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
|
||||||
auth: &Value,
|
let (provider_auth_path, provider_config_path) =
|
||||||
config_text_opt: Option<&str>,
|
get_codex_provider_paths(provider_id, Some(provider_name));
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let auth_path = get_codex_auth_path();
|
let auth_path = get_codex_auth_path();
|
||||||
let config_path = get_codex_config_path();
|
let config_path = get_codex_config_path();
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
if let Some(parent) = auth_path.parent() {
|
if let Some(parent) = auth_path.parent() {
|
||||||
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
fs::create_dir_all(parent).map_err(|e| format!("创建 Codex 目录失败: {}", e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取旧内容用于回滚
|
// 复制 auth.json(必需)
|
||||||
let old_auth = if auth_path.exists() {
|
if provider_auth_path.exists() {
|
||||||
Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?)
|
copy_file(&provider_auth_path, &auth_path)?;
|
||||||
|
log::info!("已恢复 Codex auth.json");
|
||||||
} else {
|
} else {
|
||||||
None
|
return Err(format!(
|
||||||
};
|
"供应商 auth.json 不存在: {}",
|
||||||
let _old_config = if config_path.exists() {
|
provider_auth_path.display()
|
||||||
Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?)
|
));
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// 准备写入内容
|
|
||||||
let cfg_text = match config_text_opt {
|
|
||||||
Some(s) => s.to_string(),
|
|
||||||
None => String::new(),
|
|
||||||
};
|
|
||||||
if !cfg_text.trim().is_empty() {
|
|
||||||
toml::from_str::<toml::Table>(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第一步:写 auth.json
|
// 复制 config.toml(可选,允许为空;不存在则创建空文件以保持一致性)
|
||||||
write_json_file(&auth_path, auth)?;
|
if provider_config_path.exists() {
|
||||||
|
copy_file(&provider_config_path, &config_path)?;
|
||||||
// 第二步:写 config.toml(失败则回滚 auth.json)
|
log::info!("已恢复 Codex config.toml");
|
||||||
if let Err(e) = write_text_file(&config_path, &cfg_text) {
|
} else {
|
||||||
// 回滚 auth.json
|
// 写入空文件
|
||||||
if let Some(bytes) = old_auth {
|
fs::write(&config_path, "").map_err(|e| format!("创建空的 config.toml 失败: {}", e))?;
|
||||||
let _ = atomic_write(&auth_path, &bytes);
|
log::info!("供应商 config.toml 缺失,已创建空文件");
|
||||||
} else {
|
|
||||||
let _ = delete_file(&auth_path);
|
|
||||||
}
|
|
||||||
return Err(e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取 `~/.codex/config.toml`,若不存在返回空字符串
|
/// 导入当前 Codex 配置为默认供应商
|
||||||
pub fn read_codex_config_text() -> Result<String, AppError> {
|
pub fn import_current_codex_config() -> Result<Value, String> {
|
||||||
let path = get_codex_config_path();
|
let auth_path = get_codex_auth_path();
|
||||||
if path.exists() {
|
let config_path = get_codex_config_path();
|
||||||
std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
|
|
||||||
|
// 行为放宽:仅要求 auth.json 存在;config.toml 可缺失
|
||||||
|
if !auth_path.exists() {
|
||||||
|
return Err("Codex 配置文件不存在".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 auth.json
|
||||||
|
let auth = read_json_file::<Value>(&auth_path)?;
|
||||||
|
|
||||||
|
// 读取 config.toml(允许不存在或读取失败时为空)
|
||||||
|
let config_str = if config_path.exists() {
|
||||||
|
let s = fs::read_to_string(&config_path)
|
||||||
|
.map_err(|e| format!("读取 config.toml 失败: {}", e))?;
|
||||||
|
if !s.trim().is_empty() {
|
||||||
|
toml::from_str::<toml::Table>(&s)
|
||||||
|
.map_err(|e| format!("config.toml 语法错误: {}", e))?;
|
||||||
|
}
|
||||||
|
s
|
||||||
} else {
|
} else {
|
||||||
Ok(String::new())
|
String::new()
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
/// 对非空的 TOML 文本进行语法校验
|
// 组合成完整配置
|
||||||
pub fn validate_config_toml(text: &str) -> Result<(), AppError> {
|
let settings_config = serde_json::json!({
|
||||||
if text.trim().is_empty() {
|
"auth": auth,
|
||||||
return Ok(());
|
"config": config_str
|
||||||
}
|
});
|
||||||
toml::from_str::<toml::Table>(text)
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|e| AppError::toml(Path::new("config.toml"), e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)
|
// 保存为默认供应商副本
|
||||||
pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
|
save_codex_provider_config("default", "default", &settings_config)?;
|
||||||
let s = read_codex_config_text()?;
|
|
||||||
validate_config_toml(&s)?;
|
Ok(settings_config)
|
||||||
Ok(s)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::codex_config;
|
||||||
|
use crate::config::{ConfigStatus, get_claude_settings_path, import_current_config_as_default};
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
/// 获取所有供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_providers(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_type: Option<AppType>,
|
||||||
|
app: Option<String>,
|
||||||
|
appType: Option<String>,
|
||||||
|
) -> Result<HashMap<String, Provider>, String> {
|
||||||
|
let app_type = app_type
|
||||||
|
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||||
|
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||||
|
.unwrap_or(AppType::Claude);
|
||||||
|
|
||||||
|
let config = state
|
||||||
|
.config
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||||
|
|
||||||
|
let manager = config
|
||||||
|
.get_manager(&app_type)
|
||||||
|
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||||
|
|
||||||
|
Ok(manager.get_all_providers().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前供应商ID
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_current_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_type: Option<AppType>,
|
||||||
|
app: Option<String>,
|
||||||
|
appType: Option<String>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let app_type = app_type
|
||||||
|
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||||
|
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||||
|
.unwrap_or(AppType::Claude);
|
||||||
|
|
||||||
|
let config = state
|
||||||
|
.config
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||||
|
|
||||||
|
let manager = config
|
||||||
|
.get_manager(&app_type)
|
||||||
|
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||||
|
|
||||||
|
Ok(manager.current.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn add_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_type: Option<AppType>,
|
||||||
|
app: Option<String>,
|
||||||
|
appType: Option<String>,
|
||||||
|
provider: Provider,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = app_type
|
||||||
|
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||||
|
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||||
|
.unwrap_or(AppType::Claude);
|
||||||
|
|
||||||
|
let mut config = state
|
||||||
|
.config
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||||
|
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&app_type)
|
||||||
|
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||||
|
|
||||||
|
// 根据应用类型保存配置文件
|
||||||
|
match app_type {
|
||||||
|
AppType::Codex => {
|
||||||
|
// Codex: 保存两个文件
|
||||||
|
codex_config::save_codex_provider_config(
|
||||||
|
&provider.id,
|
||||||
|
&provider.name,
|
||||||
|
&provider.settings_config,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
AppType::Claude => {
|
||||||
|
// Claude: 使用原有逻辑
|
||||||
|
use crate::config::{get_provider_config_path, write_json_file};
|
||||||
|
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
|
||||||
|
write_json_file(&config_path, &provider.settings_config)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.providers.insert(provider.id.clone(), provider);
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
drop(config); // 释放锁
|
||||||
|
state.save()?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_type: Option<AppType>,
|
||||||
|
app: Option<String>,
|
||||||
|
appType: Option<String>,
|
||||||
|
provider: Provider,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = app_type
|
||||||
|
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||||
|
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||||
|
.unwrap_or(AppType::Claude);
|
||||||
|
|
||||||
|
let mut config = state
|
||||||
|
.config
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||||
|
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&app_type)
|
||||||
|
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||||
|
|
||||||
|
// 检查供应商是否存在
|
||||||
|
if !manager.providers.contains_key(&provider.id) {
|
||||||
|
return Err(format!("供应商不存在: {}", provider.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果名称改变了,需要处理配置文件
|
||||||
|
if let Some(old_provider) = manager.providers.get(&provider.id) {
|
||||||
|
if old_provider.name != provider.name {
|
||||||
|
// 删除旧配置文件
|
||||||
|
match app_type {
|
||||||
|
AppType::Codex => {
|
||||||
|
codex_config::delete_codex_provider_config(&provider.id, &old_provider.name)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
AppType::Claude => {
|
||||||
|
use crate::config::{delete_file, get_provider_config_path};
|
||||||
|
let old_config_path =
|
||||||
|
get_provider_config_path(&provider.id, Some(&old_provider.name));
|
||||||
|
delete_file(&old_config_path).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存新配置文件
|
||||||
|
match app_type {
|
||||||
|
AppType::Codex => {
|
||||||
|
codex_config::save_codex_provider_config(
|
||||||
|
&provider.id,
|
||||||
|
&provider.name,
|
||||||
|
&provider.settings_config,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
AppType::Claude => {
|
||||||
|
use crate::config::{get_provider_config_path, write_json_file};
|
||||||
|
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
|
||||||
|
write_json_file(&config_path, &provider.settings_config)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.providers.insert(provider.id.clone(), provider);
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
drop(config); // 释放锁
|
||||||
|
state.save()?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_type: Option<AppType>,
|
||||||
|
app: Option<String>,
|
||||||
|
appType: Option<String>,
|
||||||
|
id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = app_type
|
||||||
|
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||||
|
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||||
|
.unwrap_or(AppType::Claude);
|
||||||
|
|
||||||
|
let mut config = state
|
||||||
|
.config
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||||
|
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&app_type)
|
||||||
|
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||||
|
|
||||||
|
// 检查是否为当前供应商
|
||||||
|
if manager.current == id {
|
||||||
|
return Err("不能删除当前正在使用的供应商".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取供应商信息
|
||||||
|
let provider = manager
|
||||||
|
.providers
|
||||||
|
.get(&id)
|
||||||
|
.ok_or_else(|| format!("供应商不存在: {}", id))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// 删除配置文件
|
||||||
|
match app_type {
|
||||||
|
AppType::Codex => {
|
||||||
|
codex_config::delete_codex_provider_config(&id, &provider.name)?;
|
||||||
|
}
|
||||||
|
AppType::Claude => {
|
||||||
|
use crate::config::{delete_file, get_provider_config_path};
|
||||||
|
let config_path = get_provider_config_path(&id, Some(&provider.name));
|
||||||
|
delete_file(&config_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从管理器删除
|
||||||
|
manager.providers.remove(&id);
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
drop(config); // 释放锁
|
||||||
|
state.save()?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn switch_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_type: Option<AppType>,
|
||||||
|
app: Option<String>,
|
||||||
|
appType: Option<String>,
|
||||||
|
id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = app_type
|
||||||
|
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||||
|
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||||
|
.unwrap_or(AppType::Claude);
|
||||||
|
|
||||||
|
let mut config = state
|
||||||
|
.config
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||||
|
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&app_type)
|
||||||
|
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||||
|
|
||||||
|
// 检查供应商是否存在
|
||||||
|
let provider = manager
|
||||||
|
.providers
|
||||||
|
.get(&id)
|
||||||
|
.ok_or_else(|| format!("供应商不存在: {}", id))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// 根据应用类型执行切换
|
||||||
|
match app_type {
|
||||||
|
AppType::Codex => {
|
||||||
|
// 备份当前配置(如果存在)
|
||||||
|
if !manager.current.is_empty() {
|
||||||
|
if let Some(current_provider) = manager.providers.get(&manager.current) {
|
||||||
|
codex_config::backup_codex_config(&manager.current, ¤t_provider.name)?;
|
||||||
|
log::info!("已备份当前 Codex 供应商配置: {}", current_provider.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复目标供应商配置
|
||||||
|
codex_config::restore_codex_provider_config(&id, &provider.name)?;
|
||||||
|
}
|
||||||
|
AppType::Claude => {
|
||||||
|
// 使用原有的 Claude 切换逻辑
|
||||||
|
use crate::config::{
|
||||||
|
backup_config, copy_file, get_claude_settings_path, get_provider_config_path,
|
||||||
|
};
|
||||||
|
|
||||||
|
let settings_path = get_claude_settings_path();
|
||||||
|
let provider_config_path = get_provider_config_path(&id, Some(&provider.name));
|
||||||
|
|
||||||
|
// 检查供应商配置文件是否存在
|
||||||
|
if !provider_config_path.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"供应商配置文件不存在: {}",
|
||||||
|
provider_config_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果当前有配置,先备份到当前供应商
|
||||||
|
if settings_path.exists() && !manager.current.is_empty() {
|
||||||
|
if let Some(current_provider) = manager.providers.get(&manager.current) {
|
||||||
|
let current_provider_path =
|
||||||
|
get_provider_config_path(&manager.current, Some(¤t_provider.name));
|
||||||
|
backup_config(&settings_path, ¤t_provider_path)?;
|
||||||
|
log::info!("已备份当前供应商配置: {}", current_provider.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保主配置父目录存在
|
||||||
|
if let Some(parent) = settings_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制新供应商配置到主配置
|
||||||
|
copy_file(&provider_config_path, &settings_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新当前供应商
|
||||||
|
manager.current = id;
|
||||||
|
|
||||||
|
log::info!("成功切换到供应商: {}", provider.name);
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
drop(config); // 释放锁
|
||||||
|
state.save()?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 导入当前配置为默认供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_default_config(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_type: Option<AppType>,
|
||||||
|
app: Option<String>,
|
||||||
|
appType: Option<String>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = app_type
|
||||||
|
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||||
|
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||||
|
.unwrap_or(AppType::Claude);
|
||||||
|
|
||||||
|
// 若已存在 default 供应商,则直接返回,避免重复导入
|
||||||
|
{
|
||||||
|
let config = state
|
||||||
|
.config
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||||
|
|
||||||
|
if let Some(manager) = config.get_manager(&app_type) {
|
||||||
|
if manager.get_all_providers().contains_key("default") {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据应用类型导入配置
|
||||||
|
let settings_config = match app_type {
|
||||||
|
AppType::Codex => codex_config::import_current_codex_config()?,
|
||||||
|
AppType::Claude => import_current_config_as_default()?,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建默认供应商
|
||||||
|
let provider = Provider::with_id(
|
||||||
|
"default".to_string(),
|
||||||
|
"default".to_string(),
|
||||||
|
settings_config,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 添加到管理器
|
||||||
|
let mut config = state
|
||||||
|
.config
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||||
|
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&app_type)
|
||||||
|
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||||
|
|
||||||
|
// 根据应用类型保存配置文件
|
||||||
|
match app_type {
|
||||||
|
AppType::Codex => {
|
||||||
|
codex_config::save_codex_provider_config(
|
||||||
|
&provider.id,
|
||||||
|
&provider.name,
|
||||||
|
&provider.settings_config,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
AppType::Claude => {
|
||||||
|
use crate::config::{get_provider_config_path, write_json_file};
|
||||||
|
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
|
||||||
|
write_json_file(&config_path, &provider.settings_config)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.providers.insert(provider.id.clone(), provider);
|
||||||
|
|
||||||
|
// 如果没有当前供应商,设置为 default
|
||||||
|
if manager.current.is_empty() {
|
||||||
|
manager.current = "default".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
drop(config); // 释放锁
|
||||||
|
state.save()?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Claude Code 配置状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||||
|
Ok(crate::config::get_claude_config_status())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取应用配置状态(通用)
|
||||||
|
/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_config_status(
|
||||||
|
app_type: Option<AppType>,
|
||||||
|
app: Option<String>,
|
||||||
|
appType: Option<String>,
|
||||||
|
) -> Result<ConfigStatus, String> {
|
||||||
|
let app = app_type
|
||||||
|
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||||
|
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||||
|
.unwrap_or(AppType::Claude);
|
||||||
|
|
||||||
|
match app {
|
||||||
|
AppType::Claude => Ok(crate::config::get_claude_config_status()),
|
||||||
|
AppType::Codex => {
|
||||||
|
use crate::codex_config::{get_codex_auth_path, get_codex_config_dir};
|
||||||
|
let auth_path = get_codex_auth_path();
|
||||||
|
|
||||||
|
// 放宽:只要 auth.json 存在即可认为已配置;config.toml 允许为空
|
||||||
|
let exists = auth_path.exists();
|
||||||
|
let path = get_codex_config_dir().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
Ok(ConfigStatus { exists, path })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Claude Code 配置文件路径
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_claude_code_config_path() -> Result<String, String> {
|
||||||
|
Ok(get_claude_settings_path().to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开配置文件夹
|
||||||
|
/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_config_folder(
|
||||||
|
handle: tauri::AppHandle,
|
||||||
|
app_type: Option<AppType>,
|
||||||
|
app: Option<String>,
|
||||||
|
appType: Option<String>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = app_type
|
||||||
|
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||||
|
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||||
|
.unwrap_or(AppType::Claude);
|
||||||
|
|
||||||
|
let config_dir = match app_type {
|
||||||
|
AppType::Claude => crate::config::get_claude_config_dir(),
|
||||||
|
AppType::Codex => crate::codex_config::get_codex_config_dir(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if !config_dir.exists() {
|
||||||
|
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 opener 插件打开文件夹
|
||||||
|
handle.opener()
|
||||||
|
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
||||||
|
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开外部链接
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, String> {
|
||||||
|
// 规范化 URL,缺少协议时默认加 https://
|
||||||
|
let url = if url.starts_with("http://") || url.starts_with("https://") {
|
||||||
|
url
|
||||||
|
} else {
|
||||||
|
format!("https://{}", url)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 opener 插件打开链接
|
||||||
|
app.opener()
|
||||||
|
.open_url(&url, None::<String>)
|
||||||
|
.map_err(|e| format!("打开链接失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
#![allow(non_snake_case)]
|
|
||||||
|
|
||||||
use tauri::AppHandle;
|
|
||||||
use tauri_plugin_dialog::DialogExt;
|
|
||||||
use tauri_plugin_opener::OpenerExt;
|
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
use crate::codex_config;
|
|
||||||
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
|
||||||
|
|
||||||
/// 获取 Claude Code 配置状态
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
|
||||||
Ok(config::get_claude_config_status())
|
|
||||||
}
|
|
||||||
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
|
||||||
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
|
||||||
AppType::Claude => Ok(config::get_claude_config_status()),
|
|
||||||
AppType::Codex => {
|
|
||||||
let auth_path = codex_config::get_codex_auth_path();
|
|
||||||
let exists = auth_path.exists();
|
|
||||||
let path = codex_config::get_codex_config_dir()
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(ConfigStatus { exists, path })
|
|
||||||
}
|
|
||||||
AppType::Gemini => {
|
|
||||||
let env_path = crate::gemini_config::get_gemini_env_path();
|
|
||||||
let exists = env_path.exists();
|
|
||||||
let path = crate::gemini_config::get_gemini_dir()
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(ConfigStatus { exists, path })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Claude Code 配置文件路径
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_claude_code_config_path() -> Result<String, String> {
|
|
||||||
Ok(get_claude_settings_path().to_string_lossy().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取当前生效的配置目录
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_config_dir(app: String) -> Result<String, String> {
|
|
||||||
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
|
||||||
AppType::Claude => config::get_claude_config_dir(),
|
|
||||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
|
||||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(dir.to_string_lossy().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 打开配置文件夹
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
|
|
||||||
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
|
||||||
AppType::Claude => config::get_claude_config_dir(),
|
|
||||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
|
||||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !config_dir.exists() {
|
|
||||||
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
handle
|
|
||||||
.opener()
|
|
||||||
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
|
||||||
.map_err(|e| format!("打开文件夹失败: {e}"))?;
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 弹出系统目录选择器并返回用户选择的路径
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn pick_directory(
|
|
||||||
app: AppHandle,
|
|
||||||
#[allow(non_snake_case)] defaultPath: Option<String>,
|
|
||||||
) -> Result<Option<String>, String> {
|
|
||||||
let initial = defaultPath
|
|
||||||
.map(|p| p.trim().to_string())
|
|
||||||
.filter(|p| !p.is_empty());
|
|
||||||
|
|
||||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
let mut builder = app.dialog().file();
|
|
||||||
if let Some(path) = initial {
|
|
||||||
builder = builder.set_directory(path);
|
|
||||||
}
|
|
||||||
builder.blocking_pick_folder()
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("弹出目录选择器失败: {e}"))?;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Some(file_path) => {
|
|
||||||
let resolved = file_path
|
|
||||||
.simplified()
|
|
||||||
.into_path()
|
|
||||||
.map_err(|e| format!("解析选择的目录失败: {e}"))?;
|
|
||||||
Ok(Some(resolved.to_string_lossy().to_string()))
|
|
||||||
}
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取应用配置文件路径
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_app_config_path() -> Result<String, String> {
|
|
||||||
let config_path = config::get_app_config_path();
|
|
||||||
Ok(config_path.to_string_lossy().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 打开应用配置文件夹
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
|
||||||
let config_dir = config::get_app_config_dir();
|
|
||||||
|
|
||||||
if !config_dir.exists() {
|
|
||||||
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
handle
|
|
||||||
.opener()
|
|
||||||
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
|
||||||
.map_err(|e| format!("打开文件夹失败: {e}"))?;
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_claude_common_config_snippet(
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<Option<String>, String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.get_config_snippet("claude")
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_claude_common_config_snippet(
|
|
||||||
snippet: String,
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
// 验证是否为有效的 JSON(如果不为空)
|
|
||||||
if !snippet.trim().is_empty() {
|
|
||||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
|
||||||
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let value = if snippet.trim().is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(snippet)
|
|
||||||
};
|
|
||||||
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.set_config_snippet("claude", value)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取通用配置片段(统一接口)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_common_config_snippet(
|
|
||||||
app_type: String,
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<Option<String>, String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.get_config_snippet(&app_type)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置通用配置片段(统一接口)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_common_config_snippet(
|
|
||||||
app_type: String,
|
|
||||||
snippet: String,
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
// 验证格式(根据应用类型)
|
|
||||||
if !snippet.trim().is_empty() {
|
|
||||||
match app_type.as_str() {
|
|
||||||
"claude" | "gemini" => {
|
|
||||||
// 验证 JSON 格式
|
|
||||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
|
||||||
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
|
|
||||||
}
|
|
||||||
"codex" => {
|
|
||||||
// TOML 格式暂不验证(或可使用 toml crate)
|
|
||||||
// 注意:TOML 验证较为复杂,暂时跳过
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let value = if snippet.trim().is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(snippet)
|
|
||||||
};
|
|
||||||
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.set_config_snippet(&app_type, value)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
use crate::deeplink::{
|
|
||||||
import_mcp_from_deeplink, import_prompt_from_deeplink, import_provider_from_deeplink,
|
|
||||||
import_skill_from_deeplink, parse_deeplink_url, DeepLinkImportRequest,
|
|
||||||
};
|
|
||||||
use crate::store::AppState;
|
|
||||||
use tauri::State;
|
|
||||||
|
|
||||||
/// Parse a deep link URL and return the parsed request for frontend confirmation
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn parse_deeplink(url: String) -> Result<DeepLinkImportRequest, String> {
|
|
||||||
log::info!("Parsing deep link URL: {url}");
|
|
||||||
parse_deeplink_url(&url).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge configuration from Base64/URL into a deep link request
|
|
||||||
/// This is used by the frontend to show the complete configuration in the confirmation dialog
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn merge_deeplink_config(
|
|
||||||
request: DeepLinkImportRequest,
|
|
||||||
) -> Result<DeepLinkImportRequest, String> {
|
|
||||||
log::info!("Merging config for deep link request: {:?}", request.name);
|
|
||||||
crate::deeplink::parse_and_merge_config(&request).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Import a provider from a deep link request (legacy, kept for compatibility)
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn import_from_deeplink(
|
|
||||||
state: State<AppState>,
|
|
||||||
request: DeepLinkImportRequest,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
log::info!(
|
|
||||||
"Importing provider from deep link: {:?} for app {:?}",
|
|
||||||
request.name,
|
|
||||||
request.app
|
|
||||||
);
|
|
||||||
|
|
||||||
let provider_id = import_provider_from_deeplink(&state, request).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
log::info!("Successfully imported provider with ID: {provider_id}");
|
|
||||||
|
|
||||||
Ok(provider_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Import resource from a deep link request (unified handler)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn import_from_deeplink_unified(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
request: DeepLinkImportRequest,
|
|
||||||
) -> Result<serde_json::Value, String> {
|
|
||||||
log::info!("Importing {} resource from deep link", request.resource);
|
|
||||||
|
|
||||||
match request.resource.as_str() {
|
|
||||||
"provider" => {
|
|
||||||
let provider_id =
|
|
||||||
import_provider_from_deeplink(&state, request).map_err(|e| e.to_string())?;
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"type": "provider",
|
|
||||||
"id": provider_id
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
"prompt" => {
|
|
||||||
let prompt_id =
|
|
||||||
import_prompt_from_deeplink(&state, request).map_err(|e| e.to_string())?;
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"type": "prompt",
|
|
||||||
"id": prompt_id
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
"mcp" => {
|
|
||||||
let result = import_mcp_from_deeplink(&state, request).map_err(|e| e.to_string())?;
|
|
||||||
// Add type field to the result
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"type": "mcp",
|
|
||||||
"importedCount": result.imported_count,
|
|
||||||
"importedIds": result.imported_ids,
|
|
||||||
"failed": result.failed
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
"skill" => {
|
|
||||||
let skill_key =
|
|
||||||
import_skill_from_deeplink(&state, request).map_err(|e| e.to_string())?;
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"type": "skill",
|
|
||||||
"key": skill_key
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
_ => Err(format!("Unsupported resource type: {}", request.resource)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
use crate::services::env_checker::{check_env_conflicts as check_conflicts, EnvConflict};
|
|
||||||
use crate::services::env_manager::{
|
|
||||||
delete_env_vars as delete_vars, restore_from_backup, BackupInfo,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Check environment variable conflicts for a specific app
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn check_env_conflicts(app: String) -> Result<Vec<EnvConflict>, String> {
|
|
||||||
check_conflicts(&app)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete environment variables with backup
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn delete_env_vars(conflicts: Vec<EnvConflict>) -> Result<BackupInfo, String> {
|
|
||||||
delete_vars(conflicts)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restore environment variables from backup file
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn restore_env_backup(backup_path: String) -> Result<(), String> {
|
|
||||||
restore_from_backup(backup_path)
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
//! 故障转移队列命令
|
|
||||||
//!
|
|
||||||
//! 管理代理模式下的故障转移队列
|
|
||||||
|
|
||||||
use crate::database::FailoverQueueItem;
|
|
||||||
use crate::provider::Provider;
|
|
||||||
use crate::store::AppState;
|
|
||||||
|
|
||||||
/// 获取故障转移队列
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_failover_queue(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<Vec<FailoverQueueItem>, String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.get_failover_queue(&app_type)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_available_providers_for_failover(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<Vec<Provider>, String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.get_available_providers_for_failover(&app_type)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加供应商到故障转移队列
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn add_to_failover_queue(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
app_type: String,
|
|
||||||
provider_id: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.add_to_failover_queue(&app_type, &provider_id)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从故障转移队列移除供应商
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn remove_from_failover_queue(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
app_type: String,
|
|
||||||
provider_id: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.remove_from_failover_queue(&app_type, &provider_id)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重新排序故障转移队列
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn reorder_failover_queue(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
app_type: String,
|
|
||||||
provider_ids: Vec<String>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.reorder_failover_queue(&app_type, &provider_ids)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置故障转移队列项的启用状态
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_failover_item_enabled(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
app_type: String,
|
|
||||||
provider_id: String,
|
|
||||||
enabled: bool,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.set_failover_item_enabled(&app_type, &provider_id, enabled)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
#![allow(non_snake_case)]
|
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use tauri::State;
|
|
||||||
use tauri_plugin_dialog::DialogExt;
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::services::provider::ProviderService;
|
|
||||||
use crate::store::AppState;
|
|
||||||
|
|
||||||
/// 导出数据库为 SQL 备份
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn export_config_to_file(
|
|
||||||
#[allow(non_snake_case)] filePath: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<Value, String> {
|
|
||||||
let db = state.db.clone();
|
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
let target_path = PathBuf::from(&filePath);
|
|
||||||
db.export_sql(&target_path)?;
|
|
||||||
Ok::<_, AppError>(json!({
|
|
||||||
"success": true,
|
|
||||||
"message": "SQL exported successfully",
|
|
||||||
"filePath": filePath
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("导出配置失败: {e}"))?
|
|
||||||
.map_err(|e: AppError| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 SQL 备份导入数据库
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn import_config_from_file(
|
|
||||||
#[allow(non_snake_case)] filePath: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<Value, String> {
|
|
||||||
let db = state.db.clone();
|
|
||||||
let db_for_state = db.clone();
|
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
let path_buf = PathBuf::from(&filePath);
|
|
||||||
let backup_id = db.import_sql(&path_buf)?;
|
|
||||||
|
|
||||||
// 导入后同步当前供应商到各自的 live 配置
|
|
||||||
let app_state = AppState::new(db_for_state);
|
|
||||||
if let Err(err) = ProviderService::sync_current_to_live(&app_state) {
|
|
||||||
log::warn!("导入后同步 live 配置失败: {err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重新加载设置到内存缓存,确保导入的设置生效
|
|
||||||
if let Err(err) = crate::settings::reload_settings() {
|
|
||||||
log::warn!("导入后重载设置失败: {err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok::<_, AppError>(json!({
|
|
||||||
"success": true,
|
|
||||||
"message": "SQL imported successfully",
|
|
||||||
"backupId": backup_id
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("导入配置失败: {e}"))?
|
|
||||||
.map_err(|e: AppError| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<Value, String> {
|
|
||||||
let db = state.db.clone();
|
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
let app_state = AppState::new(db);
|
|
||||||
ProviderService::sync_current_to_live(&app_state)?;
|
|
||||||
Ok::<_, AppError>(json!({
|
|
||||||
"success": true,
|
|
||||||
"message": "Live configuration synchronized"
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("同步当前供应商失败: {e}"))?
|
|
||||||
.map_err(|e: AppError| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存文件对话框
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn save_file_dialog<R: tauri::Runtime>(
|
|
||||||
app: tauri::AppHandle<R>,
|
|
||||||
#[allow(non_snake_case)] defaultName: String,
|
|
||||||
) -> Result<Option<String>, String> {
|
|
||||||
let dialog = app.dialog();
|
|
||||||
let result = dialog
|
|
||||||
.file()
|
|
||||||
.add_filter("SQL", &["sql"])
|
|
||||||
.set_file_name(&defaultName)
|
|
||||||
.blocking_save_file();
|
|
||||||
|
|
||||||
Ok(result.map(|p| p.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 打开文件对话框
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn open_file_dialog<R: tauri::Runtime>(
|
|
||||||
app: tauri::AppHandle<R>,
|
|
||||||
) -> Result<Option<String>, String> {
|
|
||||||
let dialog = app.dialog();
|
|
||||||
let result = dialog
|
|
||||||
.file()
|
|
||||||
.add_filter("SQL", &["sql"])
|
|
||||||
.blocking_pick_file();
|
|
||||||
|
|
||||||
Ok(result.map(|p| p.to_string()))
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
#![allow(non_snake_case)]
|
|
||||||
|
|
||||||
use indexmap::IndexMap;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
use tauri::State;
|
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
use crate::claude_mcp;
|
|
||||||
use crate::services::McpService;
|
|
||||||
use crate::store::AppState;
|
|
||||||
|
|
||||||
/// 获取 Claude MCP 状态
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_claude_mcp_status() -> Result<claude_mcp::McpStatus, String> {
|
|
||||||
claude_mcp::get_mcp_status().map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取 mcp.json 文本内容
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn read_claude_mcp_config() -> Result<Option<String>, String> {
|
|
||||||
claude_mcp::read_mcp_json().map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 新增或更新一个 MCP 服务器条目
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn upsert_claude_mcp_server(id: String, spec: serde_json::Value) -> Result<bool, String> {
|
|
||||||
claude_mcp::upsert_mcp_server(&id, spec).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除一个 MCP 服务器条目
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_claude_mcp_server(id: String) -> Result<bool, String> {
|
|
||||||
claude_mcp::delete_mcp_server(&id).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 校验命令是否在 PATH 中可用(不执行)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn validate_mcp_command(cmd: String) -> Result<bool, String> {
|
|
||||||
claude_mcp::validate_command_in_path(&cmd).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct McpConfigResponse {
|
|
||||||
pub config_path: String,
|
|
||||||
pub servers: HashMap<String, serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 MCP 配置(来自 ~/.cc-switch/config.json)
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
|
|
||||||
pub async fn get_mcp_config(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app: String,
|
|
||||||
) -> Result<McpConfigResponse, String> {
|
|
||||||
let config_path = crate::config::get_app_config_path()
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
let servers = McpService::get_servers(&state, app_ty).map_err(|e| e.to_string())?;
|
|
||||||
Ok(McpConfigResponse {
|
|
||||||
config_path,
|
|
||||||
servers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 在 config.json 中新增或更新一个 MCP 服务器定义
|
|
||||||
/// [已废弃] 该命令仍然使用旧的分应用API,会转换为统一结构
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn upsert_mcp_server_in_config(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app: String,
|
|
||||||
id: String,
|
|
||||||
spec: serde_json::Value,
|
|
||||||
sync_other_side: Option<bool>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
use crate::app_config::McpServer;
|
|
||||||
|
|
||||||
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// 读取现有的服务器(如果存在)
|
|
||||||
let existing_server = {
|
|
||||||
let servers = state.db.get_all_mcp_servers().map_err(|e| e.to_string())?;
|
|
||||||
servers.get(&id).cloned()
|
|
||||||
};
|
|
||||||
|
|
||||||
// 构建新的统一服务器结构
|
|
||||||
let mut new_server = if let Some(mut existing) = existing_server {
|
|
||||||
// 更新现有服务器
|
|
||||||
existing.server = spec.clone();
|
|
||||||
existing.apps.set_enabled_for(&app_ty, true);
|
|
||||||
existing
|
|
||||||
} else {
|
|
||||||
// 创建新服务器
|
|
||||||
let mut apps = crate::app_config::McpApps::default();
|
|
||||||
apps.set_enabled_for(&app_ty, true);
|
|
||||||
|
|
||||||
// 尝试从 spec 中提取 name,否则使用 id
|
|
||||||
let name = spec
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or(&id)
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
McpServer {
|
|
||||||
id: id.clone(),
|
|
||||||
name,
|
|
||||||
server: spec,
|
|
||||||
apps,
|
|
||||||
description: None,
|
|
||||||
homepage: None,
|
|
||||||
docs: None,
|
|
||||||
tags: Vec::new(),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 如果 sync_other_side 为 true,也启用其他应用
|
|
||||||
if sync_other_side.unwrap_or(false) {
|
|
||||||
new_server.apps.claude = true;
|
|
||||||
new_server.apps.codex = true;
|
|
||||||
new_server.apps.gemini = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
McpService::upsert_server(&state, new_server)
|
|
||||||
.map(|_| true)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 在 config.json 中删除一个 MCP 服务器定义
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_mcp_server_in_config(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
_app: String, // 参数保留用于向后兼容,但在统一结构中不再需要
|
|
||||||
id: String,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
McpService::delete_server(&state, &id).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置启用状态并同步到客户端配置
|
|
||||||
#[tauri::command]
|
|
||||||
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
|
|
||||||
pub async fn set_mcp_enabled(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app: String,
|
|
||||||
id: String,
|
|
||||||
enabled: bool,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
McpService::set_enabled(&state, app_ty, &id, enabled).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// v3.7.0 新增:统一 MCP 管理命令
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
use crate::app_config::McpServer;
|
|
||||||
|
|
||||||
/// 获取所有 MCP 服务器(统一结构)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_mcp_servers(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<IndexMap<String, McpServer>, String> {
|
|
||||||
McpService::get_all_servers(&state).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加或更新 MCP 服务器
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn upsert_mcp_server(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
server: McpServer,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
McpService::upsert_server(&state, server).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除 MCP 服务器
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_mcp_server(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
|
||||||
McpService::delete_server(&state, &id).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换 MCP 服务器在指定应用的启用状态
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn toggle_mcp_app(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
server_id: String,
|
|
||||||
app: String,
|
|
||||||
enabled: bool,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
#![allow(non_snake_case)]
|
|
||||||
|
|
||||||
use crate::init_status::InitErrorPayload;
|
|
||||||
use tauri::AppHandle;
|
|
||||||
use tauri_plugin_opener::OpenerExt;
|
|
||||||
|
|
||||||
/// 打开外部链接
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
|
|
||||||
let url = if url.starts_with("http://") || url.starts_with("https://") {
|
|
||||||
url
|
|
||||||
} else {
|
|
||||||
format!("https://{url}")
|
|
||||||
};
|
|
||||||
|
|
||||||
app.opener()
|
|
||||||
.open_url(&url, None::<String>)
|
|
||||||
.map_err(|e| format!("打开链接失败: {e}"))?;
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查更新
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
|
|
||||||
handle
|
|
||||||
.opener()
|
|
||||||
.open_url(
|
|
||||||
"https://github.com/farion1231/cc-switch/releases/latest",
|
|
||||||
None::<String>,
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("打开更新页面失败: {e}"))?;
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 判断是否为便携版(绿色版)运行
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn is_portable_mode() -> Result<bool, String> {
|
|
||||||
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {e}"))?;
|
|
||||||
if let Some(dir) = exe_path.parent() {
|
|
||||||
Ok(dir.join("portable.ini").is_file())
|
|
||||||
} else {
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取应用启动阶段的初始化错误(若有)。
|
|
||||||
/// 用于前端在早期主动拉取,避免事件订阅竞态导致的提示缺失。
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
|
|
||||||
Ok(crate::init_status::get_init_error())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 JSON→SQLite 迁移结果(若有)。
|
|
||||||
/// 只返回一次 true,之后返回 false,用于前端显示一次性 Toast 通知。
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_migration_result() -> Result<bool, String> {
|
|
||||||
Ok(crate::init_status::take_migration_success())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
pub struct ToolVersion {
|
|
||||||
name: String,
|
|
||||||
version: Option<String>,
|
|
||||||
latest_version: Option<String>, // 新增字段:最新版本
|
|
||||||
error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
|
||||||
let tools = vec!["claude", "codex", "gemini"];
|
|
||||||
let mut results = Vec::new();
|
|
||||||
|
|
||||||
// 用于获取远程版本的 client
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.user_agent("cc-switch/1.0")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
for tool in tools {
|
|
||||||
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
|
||||||
let (local_version, local_error) = {
|
|
||||||
// 先尝试直接执行
|
|
||||||
let direct_result = try_get_version(tool);
|
|
||||||
|
|
||||||
if direct_result.0.is_some() {
|
|
||||||
direct_result
|
|
||||||
} else {
|
|
||||||
// 扫描常见的 npm 全局安装路径
|
|
||||||
scan_cli_version(tool)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. 获取远程最新版本
|
|
||||||
let latest_version = match tool {
|
|
||||||
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
|
||||||
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
|
||||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
results.push(ToolVersion {
|
|
||||||
name: tool.to_string(),
|
|
||||||
version: local_version,
|
|
||||||
latest_version,
|
|
||||||
error: local_error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper function to fetch latest version from npm registry
|
|
||||||
async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Option<String> {
|
|
||||||
let url = format!("https://registry.npmjs.org/{package}");
|
|
||||||
match client.get(&url).send().await {
|
|
||||||
Ok(resp) => {
|
|
||||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
|
||||||
json.get("dist-tags")
|
|
||||||
.and_then(|tags| tags.get("latest"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从版本输出中提取纯版本号
|
|
||||||
fn extract_version(raw: &str) -> String {
|
|
||||||
// 匹配 semver 格式: x.y.z 或 x.y.z-xxx
|
|
||||||
let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").unwrap();
|
|
||||||
re.find(raw)
|
|
||||||
.map(|m| m.as_str().to_string())
|
|
||||||
.unwrap_or_else(|| raw.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 尝试直接执行命令获取版本
|
|
||||||
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
let output = if cfg!(target_os = "windows") {
|
|
||||||
Command::new("cmd")
|
|
||||||
.args(["/C", &format!("{tool} --version")])
|
|
||||||
.output()
|
|
||||||
} else {
|
|
||||||
Command::new("sh")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(format!("{tool} --version"))
|
|
||||||
.output()
|
|
||||||
};
|
|
||||||
|
|
||||||
match output {
|
|
||||||
Ok(out) => {
|
|
||||||
if out.status.success() {
|
|
||||||
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
||||||
(Some(extract_version(&raw)), None)
|
|
||||||
} else {
|
|
||||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
|
||||||
(
|
|
||||||
None,
|
|
||||||
Some(if err.is_empty() {
|
|
||||||
"未安装或无法执行".to_string()
|
|
||||||
} else {
|
|
||||||
err
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => (None, Some(e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描常见路径查找 CLI
|
|
||||||
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
let home = dirs::home_dir().unwrap_or_default();
|
|
||||||
|
|
||||||
// 常见的 npm 全局安装路径
|
|
||||||
let mut search_paths: Vec<std::path::PathBuf> = vec![
|
|
||||||
home.join(".npm-global/bin"),
|
|
||||||
home.join(".local/bin"),
|
|
||||||
home.join("n/bin"), // n version manager
|
|
||||||
];
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
search_paths.push(std::path::PathBuf::from("/opt/homebrew/bin"));
|
|
||||||
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
|
||||||
search_paths.push(std::path::PathBuf::from("/usr/bin"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
if let Some(appdata) = dirs::data_dir() {
|
|
||||||
search_paths.push(appdata.join("npm"));
|
|
||||||
}
|
|
||||||
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 扫描 nvm 目录下的所有 node 版本
|
|
||||||
let nvm_base = home.join(".nvm/versions/node");
|
|
||||||
if nvm_base.exists() {
|
|
||||||
if let Ok(entries) = std::fs::read_dir(&nvm_base) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let bin_path = entry.path().join("bin");
|
|
||||||
if bin_path.exists() {
|
|
||||||
search_paths.push(bin_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在每个路径中查找工具
|
|
||||||
for path in &search_paths {
|
|
||||||
let tool_path = if cfg!(target_os = "windows") {
|
|
||||||
path.join(format!("{tool}.cmd"))
|
|
||||||
} else {
|
|
||||||
path.join(tool)
|
|
||||||
};
|
|
||||||
|
|
||||||
if tool_path.exists() {
|
|
||||||
// 构建 PATH 环境变量,确保 node 可被找到
|
|
||||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
|
||||||
let new_path = format!("{}:{}", path.display(), current_path);
|
|
||||||
|
|
||||||
let output = Command::new(&tool_path)
|
|
||||||
.arg("--version")
|
|
||||||
.env("PATH", &new_path)
|
|
||||||
.output();
|
|
||||||
|
|
||||||
if let Ok(out) = output {
|
|
||||||
if out.status.success() {
|
|
||||||
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
||||||
return (Some(extract_version(&raw)), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(None, Some("未安装或无法执行".to_string()))
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
#![allow(non_snake_case)]
|
|
||||||
|
|
||||||
mod config;
|
|
||||||
mod deeplink;
|
|
||||||
mod env;
|
|
||||||
mod failover;
|
|
||||||
mod import_export;
|
|
||||||
mod mcp;
|
|
||||||
mod misc;
|
|
||||||
mod plugin;
|
|
||||||
mod prompt;
|
|
||||||
mod provider;
|
|
||||||
mod proxy;
|
|
||||||
mod settings;
|
|
||||||
pub mod skill;
|
|
||||||
mod stream_check;
|
|
||||||
mod usage;
|
|
||||||
|
|
||||||
pub use config::*;
|
|
||||||
pub use deeplink::*;
|
|
||||||
pub use env::*;
|
|
||||||
pub use failover::*;
|
|
||||||
pub use import_export::*;
|
|
||||||
pub use mcp::*;
|
|
||||||
pub use misc::*;
|
|
||||||
pub use plugin::*;
|
|
||||||
pub use prompt::*;
|
|
||||||
pub use provider::*;
|
|
||||||
pub use proxy::*;
|
|
||||||
pub use settings::*;
|
|
||||||
pub use skill::*;
|
|
||||||
pub use stream_check::*;
|
|
||||||
pub use usage::*;
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
#![allow(non_snake_case)]
|
|
||||||
|
|
||||||
use crate::config::ConfigStatus;
|
|
||||||
|
|
||||||
/// Claude 插件:获取 ~/.claude/config.json 状态
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_claude_plugin_status() -> Result<ConfigStatus, String> {
|
|
||||||
crate::claude_plugin::claude_config_status()
|
|
||||||
.map(|(exists, path)| ConfigStatus {
|
|
||||||
exists,
|
|
||||||
path: path.to_string_lossy().to_string(),
|
|
||||||
})
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Claude 插件:读取配置内容(若不存在返回 Ok(None))
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn read_claude_plugin_config() -> Result<Option<String>, String> {
|
|
||||||
crate::claude_plugin::read_claude_config().map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Claude 插件:写入/清除固定配置
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String> {
|
|
||||||
if official {
|
|
||||||
crate::claude_plugin::clear_claude_config().map_err(|e| e.to_string())
|
|
||||||
} else {
|
|
||||||
crate::claude_plugin::write_claude_config().map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Claude 插件:检测是否已写入目标配置
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
|
|
||||||
crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
use indexmap::IndexMap;
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use tauri::State;
|
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
use crate::prompt::Prompt;
|
|
||||||
use crate::services::PromptService;
|
|
||||||
use crate::store::AppState;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_prompts(
|
|
||||||
app: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<IndexMap<String, Prompt>, String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
PromptService::get_prompts(&state, app_type).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn upsert_prompt(
|
|
||||||
app: String,
|
|
||||||
id: String,
|
|
||||||
prompt: Prompt,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
PromptService::upsert_prompt(&state, app_type, &id, prompt).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_prompt(
|
|
||||||
app: String,
|
|
||||||
id: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
PromptService::delete_prompt(&state, app_type, &id).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn enable_prompt(
|
|
||||||
app: String,
|
|
||||||
id: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
PromptService::enable_prompt(&state, app_type, &id).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn import_prompt_from_file(
|
|
||||||
app: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
PromptService::import_from_file(&state, app_type).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_current_prompt_file_content(app: String) -> Result<Option<String>, String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
PromptService::get_current_file_content(app_type).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
use indexmap::IndexMap;
|
|
||||||
use tauri::State;
|
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::provider::Provider;
|
|
||||||
use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService};
|
|
||||||
use crate::store::AppState;
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
/// 获取所有供应商
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_providers(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app: String,
|
|
||||||
) -> Result<IndexMap<String, Provider>, String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取当前供应商ID
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<String, String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加供应商
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn add_provider(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app: String,
|
|
||||||
provider: Provider,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新供应商
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn update_provider(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app: String,
|
|
||||||
provider: Provider,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除供应商
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn delete_provider(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app: String,
|
|
||||||
id: String,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
ProviderService::delete(state.inner(), app_type, &id)
|
|
||||||
.map(|_| true)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换供应商
|
|
||||||
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
|
||||||
ProviderService::switch(state, app_type, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
|
||||||
pub fn switch_provider_test_hook(
|
|
||||||
state: &AppState,
|
|
||||||
app_type: AppType,
|
|
||||||
id: &str,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
switch_provider_internal(state, app_type, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn switch_provider(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app: String,
|
|
||||||
id: String,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
switch_provider_internal(&state, app_type, &id)
|
|
||||||
.map(|_| true)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
|
||||||
ProviderService::import_default_config(state, app_type)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
|
||||||
pub fn import_default_config_test_hook(
|
|
||||||
state: &AppState,
|
|
||||||
app_type: AppType,
|
|
||||||
) -> Result<bool, AppError> {
|
|
||||||
import_default_config_internal(state, app_type)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 导入当前配置为默认供应商
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
import_default_config_internal(&state, app_type).map_err(Into::into)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 查询供应商用量
|
|
||||||
#[allow(non_snake_case)]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn queryProviderUsage(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
|
|
||||||
app: String,
|
|
||||||
) -> Result<crate::provider::UsageResult, String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
ProviderService::query_usage(state.inner(), app_type, &providerId)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试用量脚本(使用当前编辑器中的脚本,不保存)
|
|
||||||
#[allow(non_snake_case)]
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn testUsageScript(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
#[allow(non_snake_case)] providerId: String,
|
|
||||||
app: String,
|
|
||||||
#[allow(non_snake_case)] scriptCode: String,
|
|
||||||
timeout: Option<u64>,
|
|
||||||
#[allow(non_snake_case)] apiKey: Option<String>,
|
|
||||||
#[allow(non_snake_case)] baseUrl: Option<String>,
|
|
||||||
#[allow(non_snake_case)] accessToken: Option<String>,
|
|
||||||
#[allow(non_snake_case)] userId: Option<String>,
|
|
||||||
) -> Result<crate::provider::UsageResult, String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
ProviderService::test_usage_script(
|
|
||||||
state.inner(),
|
|
||||||
app_type,
|
|
||||||
&providerId,
|
|
||||||
&scriptCode,
|
|
||||||
timeout.unwrap_or(10),
|
|
||||||
apiKey.as_deref(),
|
|
||||||
baseUrl.as_deref(),
|
|
||||||
accessToken.as_deref(),
|
|
||||||
userId.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取当前生效的配置内容
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试第三方/自定义供应商端点的网络延迟
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn test_api_endpoints(
|
|
||||||
urls: Vec<String>,
|
|
||||||
#[allow(non_snake_case)] timeoutSecs: Option<u64>,
|
|
||||||
) -> Result<Vec<EndpointLatency>, String> {
|
|
||||||
SpeedtestService::test_endpoints(urls, timeoutSecs)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取自定义端点列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_custom_endpoints(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app: String,
|
|
||||||
#[allow(non_snake_case)] providerId: String,
|
|
||||||
) -> Result<Vec<crate::settings::CustomEndpoint>, String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
ProviderService::get_custom_endpoints(state.inner(), app_type, &providerId)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加自定义端点
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn add_custom_endpoint(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app: String,
|
|
||||||
#[allow(non_snake_case)] providerId: String,
|
|
||||||
url: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
ProviderService::add_custom_endpoint(state.inner(), app_type, &providerId, url)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除自定义端点
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn remove_custom_endpoint(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app: String,
|
|
||||||
#[allow(non_snake_case)] providerId: String,
|
|
||||||
url: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
ProviderService::remove_custom_endpoint(state.inner(), app_type, &providerId, url)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新端点最后使用时间
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn update_endpoint_last_used(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app: String,
|
|
||||||
#[allow(non_snake_case)] providerId: String,
|
|
||||||
url: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
ProviderService::update_endpoint_last_used(state.inner(), app_type, &providerId, url)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新多个供应商的排序
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn update_providers_sort_order(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app: String,
|
|
||||||
updates: Vec<ProviderSortUpdate>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
||||||
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
//! 代理服务相关的 Tauri 命令
|
|
||||||
//!
|
|
||||||
//! 提供前端调用的 API 接口
|
|
||||||
|
|
||||||
use crate::proxy::types::*;
|
|
||||||
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
|
|
||||||
use crate::store::AppState;
|
|
||||||
|
|
||||||
/// 启动代理服务器(仅启动服务,不接管 Live 配置)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn start_proxy_server(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
) -> Result<ProxyServerInfo, String> {
|
|
||||||
state.proxy_service.start(true).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 启动代理服务器(带 Live 配置接管)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn start_proxy_with_takeover(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
) -> Result<ProxyServerInfo, String> {
|
|
||||||
state.proxy_service.start_with_takeover().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 停止代理服务器(恢复 Live 配置)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
|
||||||
state.proxy_service.stop_with_restore().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取各应用接管状态
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_proxy_takeover_status(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
) -> Result<ProxyTakeoverStatus, String> {
|
|
||||||
state.proxy_service.get_takeover_status().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 为指定应用开启/关闭接管
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_proxy_takeover_for_app(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
app_type: String,
|
|
||||||
enabled: bool,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state
|
|
||||||
.proxy_service
|
|
||||||
.set_takeover_for_app(&app_type, enabled)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取代理服务器状态
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result<ProxyStatus, String> {
|
|
||||||
state.proxy_service.get_status().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取代理配置
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_proxy_config(state: tauri::State<'_, AppState>) -> Result<ProxyConfig, String> {
|
|
||||||
state.proxy_service.get_config().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新代理配置
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn update_proxy_config(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
config: ProxyConfig,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.proxy_service.update_config(&config).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查代理服务器是否正在运行
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
|
||||||
Ok(state.proxy_service.is_running().await)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查是否处于 Live 接管模式
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn is_live_takeover_active(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
|
||||||
state.proxy_service.is_takeover_active().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 代理模式下切换供应商(热切换)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn switch_proxy_provider(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
app_type: String,
|
|
||||||
provider_id: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state
|
|
||||||
.proxy_service
|
|
||||||
.switch_proxy_target(&app_type, &provider_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 故障转移相关命令 ====================
|
|
||||||
|
|
||||||
/// 获取供应商健康状态
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_provider_health(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
provider_id: String,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<ProviderHealth, String> {
|
|
||||||
let db = &state.db;
|
|
||||||
db.get_provider_health(&provider_id, &app_type)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重置熔断器
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn reset_circuit_breaker(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
provider_id: String,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
// 1. 重置数据库健康状态
|
|
||||||
let db = &state.db;
|
|
||||||
db.update_provider_health(&provider_id, &app_type, true, None)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// 2. 如果代理正在运行,重置内存中的熔断器状态
|
|
||||||
state
|
|
||||||
.proxy_service
|
|
||||||
.reset_provider_circuit_breaker(&provider_id, &app_type)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取熔断器配置
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_circuit_breaker_config(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
) -> Result<CircuitBreakerConfig, String> {
|
|
||||||
let db = &state.db;
|
|
||||||
db.get_circuit_breaker_config()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新熔断器配置
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn update_circuit_breaker_config(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
config: CircuitBreakerConfig,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let db = &state.db;
|
|
||||||
|
|
||||||
// 1. 更新数据库配置
|
|
||||||
db.update_circuit_breaker_config(&config)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// 2. 如果代理正在运行,热更新内存中的熔断器配置
|
|
||||||
state
|
|
||||||
.proxy_service
|
|
||||||
.update_circuit_breaker_configs(config)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取熔断器统计信息(仅当代理服务器运行时)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_circuit_breaker_stats(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
provider_id: String,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<Option<CircuitBreakerStats>, String> {
|
|
||||||
// 这个功能需要访问运行中的代理服务器的内存状态
|
|
||||||
// 目前先返回 None,后续可以通过 ProxyService 暴露接口来实现
|
|
||||||
let _ = (state, provider_id, app_type);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
#![allow(non_snake_case)]
|
|
||||||
|
|
||||||
use tauri::AppHandle;
|
|
||||||
|
|
||||||
/// 获取设置
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
|
|
||||||
Ok(crate::settings::get_settings())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存设置
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
|
|
||||||
crate::settings::update_settings(settings).map_err(|e| e.to_string())?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重启应用程序(当 app_config_dir 变更后使用)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
|
|
||||||
// 在后台延迟重启,让函数有时间返回响应
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
|
||||||
app.restart();
|
|
||||||
});
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 app_config_dir 覆盖配置 (从 Store)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_app_config_dir_override(app: AppHandle) -> Result<Option<String>, String> {
|
|
||||||
Ok(crate::app_store::refresh_app_config_dir_override(&app)
|
|
||||||
.map(|p| p.to_string_lossy().to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置 app_config_dir 覆盖配置 (到 Store)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_app_config_dir_override(
|
|
||||||
app: AppHandle,
|
|
||||||
path: Option<String>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置开机自启
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
|
|
||||||
if enabled {
|
|
||||||
crate::auto_launch::enable_auto_launch().map_err(|e| format!("启用开机自启失败: {e}"))?;
|
|
||||||
} else {
|
|
||||||
crate::auto_launch::disable_auto_launch().map_err(|e| format!("禁用开机自启失败: {e}"))?;
|
|
||||||
}
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取开机自启状态
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_auto_launch_status() -> Result<bool, String> {
|
|
||||||
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
use crate::app_config::AppType;
|
|
||||||
use crate::error::format_skill_error;
|
|
||||||
use crate::services::skill::SkillState;
|
|
||||||
use crate::services::{Skill, SkillRepo, SkillService};
|
|
||||||
use crate::store::AppState;
|
|
||||||
use chrono::Utc;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tauri::State;
|
|
||||||
|
|
||||||
pub struct SkillServiceState(pub Arc<SkillService>);
|
|
||||||
|
|
||||||
/// 解析 app 参数为 AppType
|
|
||||||
fn parse_app_type(app: &str) -> Result<AppType, String> {
|
|
||||||
match app.to_lowercase().as_str() {
|
|
||||||
"claude" => Ok(AppType::Claude),
|
|
||||||
"codex" => Ok(AppType::Codex),
|
|
||||||
"gemini" => Ok(AppType::Gemini),
|
|
||||||
_ => Err(format!("不支持的 app 类型: {app}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 根据 app_type 生成带前缀的 skill key
|
|
||||||
fn get_skill_key(app_type: &AppType, directory: &str) -> String {
|
|
||||||
let prefix = match app_type {
|
|
||||||
AppType::Claude => "claude",
|
|
||||||
AppType::Codex => "codex",
|
|
||||||
AppType::Gemini => "gemini",
|
|
||||||
};
|
|
||||||
format!("{prefix}:{directory}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_skills(
|
|
||||||
service: State<'_, SkillServiceState>,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<Vec<Skill>, String> {
|
|
||||||
get_skills_for_app("claude".to_string(), service, app_state).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_skills_for_app(
|
|
||||||
app: String,
|
|
||||||
_service: State<'_, SkillServiceState>,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<Vec<Skill>, String> {
|
|
||||||
let app_type = parse_app_type(&app)?;
|
|
||||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let skills = service
|
|
||||||
.list_skills(repos)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// 自动同步本地已安装的 skills 到数据库
|
|
||||||
// 这样用户在首次运行时,已有的 skills 会被自动记录
|
|
||||||
let existing_states = app_state.db.get_skills().unwrap_or_default();
|
|
||||||
|
|
||||||
for skill in &skills {
|
|
||||||
if skill.installed {
|
|
||||||
let key = get_skill_key(&app_type, &skill.directory);
|
|
||||||
if !existing_states.contains_key(&key) {
|
|
||||||
// 本地有该 skill,但数据库中没有记录,自动添加
|
|
||||||
if let Err(e) = app_state.db.update_skill_state(
|
|
||||||
&key,
|
|
||||||
&SkillState {
|
|
||||||
installed: true,
|
|
||||||
installed_at: Utc::now(),
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
log::warn!("同步本地 skill {key} 状态到数据库失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(skills)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn install_skill(
|
|
||||||
directory: String,
|
|
||||||
service: State<'_, SkillServiceState>,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
install_skill_for_app("claude".to_string(), directory, service, app_state).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn install_skill_for_app(
|
|
||||||
app: String,
|
|
||||||
directory: String,
|
|
||||||
_service: State<'_, SkillServiceState>,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let app_type = parse_app_type(&app)?;
|
|
||||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// 先在不持有写锁的情况下收集仓库与技能信息
|
|
||||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let skills = service
|
|
||||||
.list_skills(repos)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let skill = skills
|
|
||||||
.iter()
|
|
||||||
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
|
|
||||||
.ok_or_else(|| {
|
|
||||||
format_skill_error(
|
|
||||||
"SKILL_NOT_FOUND",
|
|
||||||
&[("directory", &directory)],
|
|
||||||
Some("checkRepoUrl"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !skill.installed {
|
|
||||||
let repo = SkillRepo {
|
|
||||||
owner: skill.repo_owner.clone().ok_or_else(|| {
|
|
||||||
format_skill_error(
|
|
||||||
"MISSING_REPO_INFO",
|
|
||||||
&[("directory", &directory), ("field", "owner")],
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
})?,
|
|
||||||
name: skill.repo_name.clone().ok_or_else(|| {
|
|
||||||
format_skill_error(
|
|
||||||
"MISSING_REPO_INFO",
|
|
||||||
&[("directory", &directory), ("field", "name")],
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
})?,
|
|
||||||
branch: skill
|
|
||||||
.repo_branch
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "main".to_string()),
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
service
|
|
||||||
.install_skill(directory.clone(), repo)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let key = get_skill_key(&app_type, &directory);
|
|
||||||
app_state
|
|
||||||
.db
|
|
||||||
.update_skill_state(
|
|
||||||
&key,
|
|
||||||
&SkillState {
|
|
||||||
installed: true,
|
|
||||||
installed_at: Utc::now(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn uninstall_skill(
|
|
||||||
directory: String,
|
|
||||||
service: State<'_, SkillServiceState>,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
uninstall_skill_for_app("claude".to_string(), directory, service, app_state)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn uninstall_skill_for_app(
|
|
||||||
app: String,
|
|
||||||
directory: String,
|
|
||||||
_service: State<'_, SkillServiceState>,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let app_type = parse_app_type(&app)?;
|
|
||||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
service
|
|
||||||
.uninstall_skill(directory.clone())
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// Remove from database by setting installed = false
|
|
||||||
let key = get_skill_key(&app_type, &directory);
|
|
||||||
app_state
|
|
||||||
.db
|
|
||||||
.update_skill_state(
|
|
||||||
&key,
|
|
||||||
&SkillState {
|
|
||||||
installed: false,
|
|
||||||
installed_at: Utc::now(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_skill_repos(
|
|
||||||
_service: State<'_, SkillServiceState>,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<Vec<SkillRepo>, String> {
|
|
||||||
app_state.db.get_skill_repos().map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn add_skill_repo(
|
|
||||||
repo: SkillRepo,
|
|
||||||
_service: State<'_, SkillServiceState>,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
app_state
|
|
||||||
.db
|
|
||||||
.save_skill_repo(&repo)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn remove_skill_repo(
|
|
||||||
owner: String,
|
|
||||||
name: String,
|
|
||||||
_service: State<'_, SkillServiceState>,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
app_state
|
|
||||||
.db
|
|
||||||
.delete_skill_repo(&owner, &name)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
//! 流式健康检查命令
|
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::services::stream_check::{
|
|
||||||
HealthStatus, StreamCheckConfig, StreamCheckResult, StreamCheckService,
|
|
||||||
};
|
|
||||||
use crate::store::AppState;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use tauri::State;
|
|
||||||
|
|
||||||
/// 流式健康检查(单个供应商)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn stream_check_provider(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app_type: AppType,
|
|
||||||
provider_id: String,
|
|
||||||
) -> Result<StreamCheckResult, AppError> {
|
|
||||||
let config = state.db.get_stream_check_config()?;
|
|
||||||
|
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
|
||||||
let provider = providers
|
|
||||||
.get(&provider_id)
|
|
||||||
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
|
||||||
|
|
||||||
let result = StreamCheckService::check_with_retry(&app_type, provider, &config).await?;
|
|
||||||
|
|
||||||
// 记录日志
|
|
||||||
let _ =
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.save_stream_check_log(&provider_id, &provider.name, app_type.as_str(), &result);
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 批量流式健康检查
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn stream_check_all_providers(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app_type: AppType,
|
|
||||||
proxy_targets_only: bool,
|
|
||||||
) -> Result<Vec<(String, StreamCheckResult)>, AppError> {
|
|
||||||
let config = state.db.get_stream_check_config()?;
|
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
|
||||||
|
|
||||||
let mut results = Vec::new();
|
|
||||||
let allowed_ids: Option<HashSet<String>> = if proxy_targets_only {
|
|
||||||
let mut ids = HashSet::new();
|
|
||||||
if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) {
|
|
||||||
ids.insert(current_id);
|
|
||||||
}
|
|
||||||
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
|
|
||||||
for item in queue {
|
|
||||||
if item.enabled {
|
|
||||||
ids.insert(item.provider_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(ids)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
for (id, provider) in providers {
|
|
||||||
if let Some(ids) = &allowed_ids {
|
|
||||||
if !ids.contains(&id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = StreamCheckService::check_with_retry(&app_type, &provider, &config)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|e| StreamCheckResult {
|
|
||||||
status: HealthStatus::Failed,
|
|
||||||
success: false,
|
|
||||||
message: e.to_string(),
|
|
||||||
response_time_ms: None,
|
|
||||||
http_status: None,
|
|
||||||
model_used: String::new(),
|
|
||||||
tested_at: chrono::Utc::now().timestamp(),
|
|
||||||
retry_count: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
let _ = state
|
|
||||||
.db
|
|
||||||
.save_stream_check_log(&id, &provider.name, app_type.as_str(), &result);
|
|
||||||
|
|
||||||
results.push((id, result));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取流式检查配置
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_stream_check_config(state: State<'_, AppState>) -> Result<StreamCheckConfig, AppError> {
|
|
||||||
state.db.get_stream_check_config()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存流式检查配置
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn save_stream_check_config(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
config: StreamCheckConfig,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
state.db.save_stream_check_config(&config)
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
//! 使用统计相关命令
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::services::usage_stats::*;
|
|
||||||
use crate::store::AppState;
|
|
||||||
use tauri::State;
|
|
||||||
|
|
||||||
/// 获取使用量汇总
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_usage_summary(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
start_date: Option<i64>,
|
|
||||||
end_date: Option<i64>,
|
|
||||||
) -> Result<UsageSummary, AppError> {
|
|
||||||
state.db.get_usage_summary(start_date, end_date)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取每日趋势
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_usage_trends(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
days: u32,
|
|
||||||
) -> Result<Vec<DailyStats>, AppError> {
|
|
||||||
state.db.get_daily_trends(days)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Provider 统计
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_provider_stats(state: State<'_, AppState>) -> Result<Vec<ProviderStats>, AppError> {
|
|
||||||
state.db.get_provider_stats()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取模型统计
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_model_stats(state: State<'_, AppState>) -> Result<Vec<ModelStats>, AppError> {
|
|
||||||
state.db.get_model_stats()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取请求日志列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_request_logs(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
filters: LogFilters,
|
|
||||||
page: u32,
|
|
||||||
page_size: u32,
|
|
||||||
) -> Result<PaginatedLogs, AppError> {
|
|
||||||
state.db.get_request_logs(&filters, page, page_size)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取单个请求详情
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_request_detail(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
request_id: String,
|
|
||||||
) -> Result<Option<RequestLogDetail>, AppError> {
|
|
||||||
state.db.get_request_detail(&request_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取模型定价列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_model_pricing(state: State<'_, AppState>) -> Result<Vec<ModelPricingInfo>, AppError> {
|
|
||||||
log::info!("获取模型定价列表");
|
|
||||||
state.db.ensure_model_pricing_seeded()?;
|
|
||||||
|
|
||||||
let db = state.db.clone();
|
|
||||||
let conn = crate::database::lock_conn!(db.conn);
|
|
||||||
|
|
||||||
// 检查表是否存在
|
|
||||||
let table_exists: bool = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='model_pricing'",
|
|
||||||
[],
|
|
||||||
|row| row.get::<_, i64>(0).map(|count| count > 0),
|
|
||||||
)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !table_exists {
|
|
||||||
log::error!("model_pricing 表不存在,可能需要重启应用以触发数据库迁移");
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut stmt = conn.prepare(
|
|
||||||
"SELECT model_id, display_name, input_cost_per_million, output_cost_per_million,
|
|
||||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
|
||||||
FROM model_pricing
|
|
||||||
ORDER BY display_name",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let rows = stmt.query_map([], |row| {
|
|
||||||
Ok(ModelPricingInfo {
|
|
||||||
model_id: row.get(0)?,
|
|
||||||
display_name: row.get(1)?,
|
|
||||||
input_cost_per_million: row.get(2)?,
|
|
||||||
output_cost_per_million: row.get(3)?,
|
|
||||||
cache_read_cost_per_million: row.get(4)?,
|
|
||||||
cache_creation_cost_per_million: row.get(5)?,
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut pricing = Vec::new();
|
|
||||||
for row in rows {
|
|
||||||
pricing.push(row?);
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("成功获取 {} 条模型定价数据", pricing.len());
|
|
||||||
Ok(pricing)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新模型定价
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn update_model_pricing(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
model_id: String,
|
|
||||||
display_name: String,
|
|
||||||
input_cost: String,
|
|
||||||
output_cost: String,
|
|
||||||
cache_read_cost: String,
|
|
||||||
cache_creation_cost: String,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let db = state.db.clone();
|
|
||||||
let conn = crate::database::lock_conn!(db.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO model_pricing (
|
|
||||||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
|
||||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params![
|
|
||||||
model_id,
|
|
||||||
display_name,
|
|
||||||
input_cost,
|
|
||||||
output_cost,
|
|
||||||
cache_read_cost,
|
|
||||||
cache_creation_cost
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查 Provider 使用限额
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn check_provider_limits(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
provider_id: String,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<crate::services::usage_stats::ProviderLimitStatus, AppError> {
|
|
||||||
state.db.check_provider_limits(&provider_id, &app_type)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除模型定价
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Result<(), AppError> {
|
|
||||||
let db = state.db.clone();
|
|
||||||
let conn = crate::database::lock_conn!(db.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM model_pricing WHERE model_id = ?1",
|
|
||||||
rusqlite::params![model_id],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("删除模型定价失败: {e}")))?;
|
|
||||||
|
|
||||||
log::info!("已删除模型定价: {model_id}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 模型定价信息
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct ModelPricingInfo {
|
|
||||||
pub model_id: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub input_cost_per_million: String,
|
|
||||||
pub output_cost_per_million: String,
|
|
||||||
pub cache_read_cost_per_million: String,
|
|
||||||
pub cache_creation_cost_per_million: String,
|
|
||||||
}
|
|
||||||
@@ -1,51 +1,15 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
/// 获取 Claude Code 配置目录路径
|
/// 获取 Claude Code 配置目录路径
|
||||||
pub fn get_claude_config_dir() -> PathBuf {
|
pub fn get_claude_config_dir() -> PathBuf {
|
||||||
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
|
||||||
return custom;
|
|
||||||
}
|
|
||||||
|
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.expect("无法获取用户主目录")
|
.expect("无法获取用户主目录")
|
||||||
.join(".claude")
|
.join(".claude")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
|
|
||||||
pub fn get_default_claude_mcp_path() -> PathBuf {
|
|
||||||
dirs::home_dir()
|
|
||||||
.expect("无法获取用户主目录")
|
|
||||||
.join(".claude.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
|
|
||||||
let file_name = dir
|
|
||||||
.file_name()
|
|
||||||
.map(|name| name.to_string_lossy().to_string())?
|
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
if file_name.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let parent = dir.parent().unwrap_or_else(|| Path::new(""));
|
|
||||||
Some(parent.join(format!("{file_name}.json")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
|
|
||||||
pub fn get_claude_mcp_path() -> PathBuf {
|
|
||||||
if let Some(custom_dir) = crate::settings::get_claude_override_dir() {
|
|
||||||
if let Some(path) = derive_mcp_path_from_override(&custom_dir) {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
get_default_claude_mcp_path()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Claude Code 主配置文件路径
|
/// 获取 Claude Code 主配置文件路径
|
||||||
pub fn get_claude_settings_path() -> PathBuf {
|
pub fn get_claude_settings_path() -> PathBuf {
|
||||||
let dir = get_claude_config_dir();
|
let dir = get_claude_config_dir();
|
||||||
@@ -64,10 +28,6 @@ pub fn get_claude_settings_path() -> PathBuf {
|
|||||||
|
|
||||||
/// 获取应用配置目录路径 (~/.cc-switch)
|
/// 获取应用配置目录路径 (~/.cc-switch)
|
||||||
pub fn get_app_config_dir() -> PathBuf {
|
pub fn get_app_config_dir() -> PathBuf {
|
||||||
if let Some(custom) = crate::app_store::get_app_config_dir_override() {
|
|
||||||
return custom;
|
|
||||||
}
|
|
||||||
|
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.expect("无法获取用户主目录")
|
.expect("无法获取用户主目录")
|
||||||
.join(".cc-switch")
|
.join(".cc-switch")
|
||||||
@@ -79,7 +39,6 @@ pub fn get_app_config_path() -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 清理供应商名称,确保文件名安全
|
/// 清理供应商名称,确保文件名安全
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn sanitize_provider_name(name: &str) -> String {
|
pub fn sanitize_provider_name(name: &str) -> String {
|
||||||
name.chars()
|
name.chars()
|
||||||
.map(|c| match c {
|
.map(|c| match c {
|
||||||
@@ -91,153 +50,48 @@ pub fn sanitize_provider_name(name: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 获取供应商配置文件路径
|
/// 获取供应商配置文件路径
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
|
pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
|
||||||
let base_name = provider_name
|
let base_name = provider_name
|
||||||
.map(sanitize_provider_name)
|
.map(|name| sanitize_provider_name(name))
|
||||||
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
||||||
|
|
||||||
get_claude_config_dir().join(format!("settings-{base_name}.json"))
|
get_claude_config_dir().join(format!("settings-{}.json", base_name))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取 JSON 配置文件
|
/// 读取 JSON 配置文件
|
||||||
pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, AppError> {
|
pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, String> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(AppError::Config(format!("文件不存在: {}", path.display())));
|
return Err(format!("文件不存在: {}", path.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
|
let content = fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}", e))?;
|
||||||
|
|
||||||
serde_json::from_str(&content).map_err(|e| AppError::json(path, e))
|
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写入 JSON 配置文件
|
/// 写入 JSON 配置文件
|
||||||
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {
|
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String> {
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let json =
|
let json =
|
||||||
serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?;
|
serde_json::to_string_pretty(data).map_err(|e| format!("序列化 JSON 失败: {}", e))?;
|
||||||
|
|
||||||
atomic_write(path, json.as_bytes())
|
fs::write(path, json).map_err(|e| format!("写入文件失败: {}", e))
|
||||||
}
|
|
||||||
|
|
||||||
/// 原子写入文本文件(用于 TOML/纯文本)
|
|
||||||
pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
|
||||||
}
|
|
||||||
atomic_write(path, data.as_bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 原子写入:写入临时文件后 rename 替换,避免半写状态
|
|
||||||
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let parent = path
|
|
||||||
.parent()
|
|
||||||
.ok_or_else(|| AppError::Config("无效的路径".to_string()))?;
|
|
||||||
let mut tmp = parent.to_path_buf();
|
|
||||||
let file_name = path
|
|
||||||
.file_name()
|
|
||||||
.ok_or_else(|| AppError::Config("无效的文件名".to_string()))?
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
let ts = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_nanos();
|
|
||||||
tmp.push(format!("{file_name}.tmp.{ts}"));
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
|
|
||||||
f.write_all(data).map_err(|e| AppError::io(&tmp, e))?;
|
|
||||||
f.flush().map_err(|e| AppError::io(&tmp, e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
if let Ok(meta) = fs::metadata(path) {
|
|
||||||
let perm = meta.permissions().mode();
|
|
||||||
let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
// Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性)
|
|
||||||
if path.exists() {
|
|
||||||
let _ = fs::remove_file(path);
|
|
||||||
}
|
|
||||||
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
|
|
||||||
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
|
|
||||||
source: e,
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
{
|
|
||||||
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
|
|
||||||
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
|
|
||||||
source: e,
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn derive_mcp_path_from_override_preserves_folder_name() {
|
|
||||||
let override_dir = PathBuf::from("/tmp/profile/.claude");
|
|
||||||
let derived = derive_mcp_path_from_override(&override_dir)
|
|
||||||
.expect("should derive path for nested dir");
|
|
||||||
assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn derive_mcp_path_from_override_handles_non_hidden_folder() {
|
|
||||||
let override_dir = PathBuf::from("/data/claude-config");
|
|
||||||
let derived = derive_mcp_path_from_override(&override_dir)
|
|
||||||
.expect("should derive path for standard dir");
|
|
||||||
assert_eq!(derived, PathBuf::from("/data/claude-config.json"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn derive_mcp_path_from_override_supports_relative_rootless_dir() {
|
|
||||||
let override_dir = PathBuf::from("claude");
|
|
||||||
let derived = derive_mcp_path_from_override(&override_dir)
|
|
||||||
.expect("should derive path for single segment");
|
|
||||||
assert_eq!(derived, PathBuf::from("claude.json"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn derive_mcp_path_from_root_like_dir_returns_none() {
|
|
||||||
let override_dir = PathBuf::from("/");
|
|
||||||
assert!(derive_mcp_path_from_override(&override_dir).is_none());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 复制文件
|
/// 复制文件
|
||||||
pub fn copy_file(from: &Path, to: &Path) -> Result<(), AppError> {
|
pub fn copy_file(from: &Path, to: &Path) -> Result<(), String> {
|
||||||
fs::copy(from, to).map_err(|e| AppError::IoContext {
|
fs::copy(from, to).map_err(|e| format!("复制文件失败: {}", e))?;
|
||||||
context: format!("复制文件失败 ({} -> {})", from.display(), to.display()),
|
|
||||||
source: e,
|
|
||||||
})?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除文件
|
/// 删除文件
|
||||||
pub fn delete_file(path: &Path) -> Result<(), AppError> {
|
pub fn delete_file(path: &Path) -> Result<(), String> {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
|
fs::remove_file(path).map_err(|e| format!("删除文件失败: {}", e))?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -257,3 +111,31 @@ pub fn get_claude_config_status() -> ConfigStatus {
|
|||||||
path: path.to_string_lossy().to_string(),
|
path: path.to_string_lossy().to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 备份配置文件
|
||||||
|
pub fn backup_config(from: &Path, to: &Path) -> Result<(), String> {
|
||||||
|
if from.exists() {
|
||||||
|
copy_file(from, to)?;
|
||||||
|
log::info!("已备份配置文件: {} -> {}", from.display(), to.display());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 导入当前 Claude Code 配置为默认供应商
|
||||||
|
pub fn import_current_config_as_default() -> Result<Value, String> {
|
||||||
|
let settings_path = get_claude_settings_path();
|
||||||
|
|
||||||
|
if !settings_path.exists() {
|
||||||
|
return Err("Claude Code 配置文件不存在".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取当前配置
|
||||||
|
let settings_config: Value = read_json_file(&settings_path)?;
|
||||||
|
|
||||||
|
// 保存为 default 供应商
|
||||||
|
let default_provider_path = get_provider_config_path("default", Some("default"));
|
||||||
|
write_json_file(&default_provider_path, &settings_config)?;
|
||||||
|
|
||||||
|
log::info!("已导入当前配置为默认供应商");
|
||||||
|
Ok(settings_config)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,324 +0,0 @@
|
|||||||
//! 数据库备份和恢复
|
|
||||||
//!
|
|
||||||
//! 提供 SQL 导出/导入和二进制快照备份功能。
|
|
||||||
|
|
||||||
use super::{lock_conn, Database, DB_BACKUP_RETAIN};
|
|
||||||
use crate::config::get_app_config_dir;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use chrono::Utc;
|
|
||||||
use rusqlite::backup::Backup;
|
|
||||||
use rusqlite::types::ValueRef;
|
|
||||||
use rusqlite::Connection;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use tempfile::NamedTempFile;
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// 导出为 SQLite 兼容的 SQL 文本
|
|
||||||
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
|
|
||||||
let snapshot = self.snapshot_to_memory()?;
|
|
||||||
let dump = Self::dump_sql(&snapshot)?;
|
|
||||||
|
|
||||||
if let Some(parent) = target_path.parent() {
|
|
||||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
crate::config::atomic_write(target_path, dump.as_bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 SQL 文件导入,返回生成的备份 ID(若无备份则为空字符串)
|
|
||||||
pub fn import_sql(&self, source_path: &Path) -> Result<String, AppError> {
|
|
||||||
if !source_path.exists() {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"SQL 文件不存在: {}",
|
|
||||||
source_path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
|
|
||||||
let sql_content = Self::sanitize_import_sql(&sql_raw);
|
|
||||||
|
|
||||||
// 导入前备份现有数据库
|
|
||||||
let backup_path = self.backup_database_file()?;
|
|
||||||
|
|
||||||
// 在临时数据库执行导入,确保失败不会污染主库
|
|
||||||
let temp_file = NamedTempFile::new().map_err(|e| AppError::IoContext {
|
|
||||||
context: "创建临时数据库文件失败".to_string(),
|
|
||||||
source: e,
|
|
||||||
})?;
|
|
||||||
let temp_path = temp_file.path().to_path_buf();
|
|
||||||
let temp_conn =
|
|
||||||
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
temp_conn
|
|
||||||
.execute_batch(&sql_content)
|
|
||||||
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
|
|
||||||
|
|
||||||
// 补齐缺失表/索引并进行基础校验
|
|
||||||
Self::create_tables_on_conn(&temp_conn)?;
|
|
||||||
Self::apply_schema_migrations_on_conn(&temp_conn)?;
|
|
||||||
Self::validate_basic_state(&temp_conn)?;
|
|
||||||
|
|
||||||
// 使用 Backup 将临时库原子写回主库
|
|
||||||
{
|
|
||||||
let mut main_conn = lock_conn!(self.conn);
|
|
||||||
let backup = Backup::new(&temp_conn, &mut main_conn)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
backup
|
|
||||||
.step(-1)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let backup_id = backup_path
|
|
||||||
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(backup_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 创建内存快照以避免长时间持有数据库锁
|
|
||||||
pub(crate) fn snapshot_to_memory(&self) -> Result<Connection, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut snapshot =
|
|
||||||
Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
{
|
|
||||||
let backup =
|
|
||||||
Backup::new(&conn, &mut snapshot).map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
backup
|
|
||||||
.step(-1)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(snapshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错
|
|
||||||
fn sanitize_import_sql(sql: &str) -> String {
|
|
||||||
let mut cleaned = String::new();
|
|
||||||
let lower_keyword = "sqlite_sequence";
|
|
||||||
|
|
||||||
for stmt in sql.split(';') {
|
|
||||||
let trimmed = stmt.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if trimmed.to_ascii_lowercase().contains(lower_keyword) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
cleaned.push_str(trimmed);
|
|
||||||
cleaned.push_str(";\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
cleaned
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
|
||||||
fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
|
|
||||||
let db_path = get_app_config_dir().join("cc-switch.db");
|
|
||||||
if !db_path.exists() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let backup_dir = db_path
|
|
||||||
.parent()
|
|
||||||
.ok_or_else(|| AppError::Config("无效的数据库路径".to_string()))?
|
|
||||||
.join("backups");
|
|
||||||
|
|
||||||
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
|
|
||||||
|
|
||||||
let backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
|
|
||||||
let backup_path = backup_dir.join(format!("{backup_id}.db"));
|
|
||||||
|
|
||||||
{
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut dest_conn =
|
|
||||||
Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
let backup = Backup::new(&conn, &mut dest_conn)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
backup
|
|
||||||
.step(-1)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Self::cleanup_db_backups(&backup_dir)?;
|
|
||||||
Ok(Some(backup_path))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 清理旧的数据库备份,保留最新的 N 个
|
|
||||||
fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
|
|
||||||
let entries = match fs::read_dir(dir) {
|
|
||||||
Ok(iter) => iter
|
|
||||||
.filter_map(|entry| entry.ok())
|
|
||||||
.filter(|entry| {
|
|
||||||
entry
|
|
||||||
.path()
|
|
||||||
.extension()
|
|
||||||
.map(|ext| ext == "db")
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
Err(_) => return Ok(()),
|
|
||||||
};
|
|
||||||
|
|
||||||
if entries.len() <= DB_BACKUP_RETAIN {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let remove_count = entries.len().saturating_sub(DB_BACKUP_RETAIN);
|
|
||||||
let mut sorted = entries;
|
|
||||||
sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
|
|
||||||
|
|
||||||
for entry in sorted.into_iter().take(remove_count) {
|
|
||||||
if let Err(err) = fs::remove_file(entry.path()) {
|
|
||||||
log::warn!("删除旧数据库备份失败 {}: {}", entry.path().display(), err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 基础状态校验
|
|
||||||
fn validate_basic_state(conn: &Connection) -> Result<(), AppError> {
|
|
||||||
let provider_count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
let mcp_count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0))
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
if provider_count == 0 && mcp_count == 0 {
|
|
||||||
return Err(AppError::Config(
|
|
||||||
"导入的 SQL 未包含有效的供应商或 MCP 数据".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 导出数据库为 SQL 文本
|
|
||||||
fn dump_sql(conn: &Connection) -> Result<String, AppError> {
|
|
||||||
let mut output = String::new();
|
|
||||||
let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
||||||
let user_version: i64 = conn
|
|
||||||
.query_row("PRAGMA user_version;", [], |row| row.get(0))
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
output.push_str(&format!(
|
|
||||||
"-- CC Switch SQLite 导出\n-- 生成时间: {timestamp}\n-- user_version: {user_version}\n"
|
|
||||||
));
|
|
||||||
output.push_str("PRAGMA foreign_keys=OFF;\n");
|
|
||||||
output.push_str(&format!("PRAGMA user_version={user_version};\n"));
|
|
||||||
output.push_str("BEGIN TRANSACTION;\n");
|
|
||||||
|
|
||||||
// 导出 schema
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT type, name, tbl_name, sql
|
|
||||||
FROM sqlite_master
|
|
||||||
WHERE sql NOT NULL AND type IN ('table','index','trigger','view')
|
|
||||||
ORDER BY type='table' DESC, name",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut tables = Vec::new();
|
|
||||||
let mut rows = stmt
|
|
||||||
.query([])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
|
||||||
let obj_type: String = row.get(0).map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
let name: String = row.get(1).map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
let sql: String = row.get(3).map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 跳过 SQLite 内部对象(如 sqlite_sequence)
|
|
||||||
if name.starts_with("sqlite_") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
output.push_str(&sql);
|
|
||||||
output.push_str(";\n");
|
|
||||||
|
|
||||||
if obj_type == "table" && !name.starts_with("sqlite_") {
|
|
||||||
tables.push(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出数据
|
|
||||||
for table in tables {
|
|
||||||
let columns = Self::get_table_columns(conn, &table)?;
|
|
||||||
if columns.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&format!("SELECT * FROM \"{table}\""))
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
let mut rows = stmt
|
|
||||||
.query([])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
|
||||||
let mut values = Vec::with_capacity(columns.len());
|
|
||||||
for idx in 0..columns.len() {
|
|
||||||
let value = row
|
|
||||||
.get_ref(idx)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
values.push(Self::format_sql_value(value)?);
|
|
||||||
}
|
|
||||||
|
|
||||||
let cols = columns
|
|
||||||
.iter()
|
|
||||||
.map(|c| format!("\"{c}\""))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
output.push_str(&format!(
|
|
||||||
"INSERT INTO \"{table}\" ({cols}) VALUES ({});\n",
|
|
||||||
values.join(", ")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
output.push_str("COMMIT;\nPRAGMA foreign_keys=ON;\n");
|
|
||||||
Ok(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取表的列名列表
|
|
||||||
fn get_table_columns(conn: &Connection, table: &str) -> Result<Vec<String>, AppError> {
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&format!("PRAGMA table_info(\"{table}\")"))
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
let iter = stmt
|
|
||||||
.query_map([], |row| row.get::<_, String>(1))
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut columns = Vec::new();
|
|
||||||
for col in iter {
|
|
||||||
columns.push(col.map_err(|e| AppError::Database(e.to_string()))?);
|
|
||||||
}
|
|
||||||
Ok(columns)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 格式化 SQL 值
|
|
||||||
fn format_sql_value(value: ValueRef<'_>) -> Result<String, AppError> {
|
|
||||||
match value {
|
|
||||||
ValueRef::Null => Ok("NULL".to_string()),
|
|
||||||
ValueRef::Integer(i) => Ok(i.to_string()),
|
|
||||||
ValueRef::Real(f) => Ok(f.to_string()),
|
|
||||||
ValueRef::Text(t) => {
|
|
||||||
let text = std::str::from_utf8(t)
|
|
||||||
.map_err(|e| AppError::Database(format!("文本字段不是有效的 UTF-8: {e}")))?;
|
|
||||||
let escaped = text.replace('\'', "''");
|
|
||||||
Ok(format!("'{escaped}'"))
|
|
||||||
}
|
|
||||||
ValueRef::Blob(bytes) => {
|
|
||||||
let mut s = String::from("X'");
|
|
||||||
for b in bytes {
|
|
||||||
use std::fmt::Write;
|
|
||||||
let _ = write!(&mut s, "{b:02X}");
|
|
||||||
}
|
|
||||||
s.push('\'');
|
|
||||||
Ok(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
//! 故障转移队列 DAO
|
|
||||||
//!
|
|
||||||
//! 管理代理模式下的故障转移队列
|
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::provider::Provider;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
/// 故障转移队列条目
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct FailoverQueueItem {
|
|
||||||
pub provider_id: String,
|
|
||||||
pub provider_name: String,
|
|
||||||
pub queue_order: i32,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// 获取故障转移队列(按 queue_order 排序)
|
|
||||||
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT fq.provider_id, p.name, fq.queue_order, fq.enabled, fq.created_at
|
|
||||||
FROM failover_queue fq
|
|
||||||
JOIN providers p ON fq.provider_id = p.id AND fq.app_type = p.app_type
|
|
||||||
WHERE fq.app_type = ?1
|
|
||||||
ORDER BY fq.queue_order ASC",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let items = stmt
|
|
||||||
.query_map([app_type], |row| {
|
|
||||||
Ok(FailoverQueueItem {
|
|
||||||
provider_id: row.get(0)?,
|
|
||||||
provider_name: row.get(1)?,
|
|
||||||
queue_order: row.get(2)?,
|
|
||||||
enabled: row.get(3)?,
|
|
||||||
created_at: row.get(4)?,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
|
|
||||||
pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
|
||||||
let queue = self.get_failover_queue(app_type)?;
|
|
||||||
let all_providers = self.get_all_providers(app_type)?;
|
|
||||||
|
|
||||||
let mut result = Vec::new();
|
|
||||||
for item in queue {
|
|
||||||
if item.enabled {
|
|
||||||
if let Some(provider) = all_providers.get(&item.provider_id) {
|
|
||||||
result.push(provider.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加供应商到故障转移队列末尾
|
|
||||||
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
// 获取当前最大 queue_order
|
|
||||||
let max_order: i32 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COALESCE(MAX(queue_order), 0) FROM failover_queue WHERE app_type = ?1",
|
|
||||||
[app_type],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs() as i64;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR IGNORE INTO failover_queue (app_type, provider_id, queue_order, enabled, created_at)
|
|
||||||
VALUES (?1, ?2, ?3, 1, ?4)",
|
|
||||||
rusqlite::params![app_type, provider_id, max_order + 1, now],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从故障转移队列中移除供应商
|
|
||||||
pub fn remove_from_failover_queue(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider_id: &str,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
// 获取被删除项的 queue_order
|
|
||||||
let removed_order: Option<i32> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT queue_order FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
|
||||||
[app_type, provider_id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// 删除该项
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
|
||||||
[app_type, provider_id],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 重新排序后面的项(填补空隙)
|
|
||||||
if let Some(order) = removed_order {
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE failover_queue
|
|
||||||
SET queue_order = queue_order - 1
|
|
||||||
WHERE app_type = ?1 AND queue_order > ?2",
|
|
||||||
rusqlite::params![app_type, order],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重新排序故障转移队列
|
|
||||||
/// provider_ids: 按新顺序排列的 provider_id 列表
|
|
||||||
pub fn reorder_failover_queue(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider_ids: &[String],
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
// 使用事务确保原子性
|
|
||||||
conn.execute("BEGIN TRANSACTION", [])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let result = (|| {
|
|
||||||
for (index, provider_id) in provider_ids.iter().enumerate() {
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE failover_queue
|
|
||||||
SET queue_order = ?3
|
|
||||||
WHERE app_type = ?1 AND provider_id = ?2",
|
|
||||||
rusqlite::params![app_type, provider_id, (index + 1) as i32],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(_) => {
|
|
||||||
conn.execute("COMMIT", [])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
conn.execute("ROLLBACK", []).ok();
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置故障转移队列中供应商的启用状态
|
|
||||||
pub fn set_failover_item_enabled(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider_id: &str,
|
|
||||||
enabled: bool,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2",
|
|
||||||
rusqlite::params![app_type, provider_id, enabled],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 清空故障转移队列
|
|
||||||
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute("DELETE FROM failover_queue WHERE app_type = ?1", [app_type])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查供应商是否在故障转移队列中
|
|
||||||
pub fn is_in_failover_queue(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider_id: &str,
|
|
||||||
) -> Result<bool, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
let count: i32 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
|
||||||
[app_type, provider_id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(count > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
|
||||||
pub fn get_available_providers_for_failover(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<Vec<Provider>, AppError> {
|
|
||||||
let all_providers = self.get_all_providers(app_type)?;
|
|
||||||
let queue = self.get_failover_queue(app_type)?;
|
|
||||||
|
|
||||||
let queue_ids: std::collections::HashSet<_> =
|
|
||||||
queue.iter().map(|item| &item.provider_id).collect();
|
|
||||||
|
|
||||||
let available: Vec<Provider> = all_providers
|
|
||||||
.into_values()
|
|
||||||
.filter(|p| !queue_ids.contains(&p.id))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(available)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
//! MCP 服务器数据访问对象
|
|
||||||
//!
|
|
||||||
//! 提供 MCP 服务器的 CRUD 操作。
|
|
||||||
|
|
||||||
use crate::app_config::{McpApps, McpServer};
|
|
||||||
use crate::database::{lock_conn, Database};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use indexmap::IndexMap;
|
|
||||||
use rusqlite::params;
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// 获取所有 MCP 服务器
|
|
||||||
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn.prepare(
|
|
||||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini
|
|
||||||
FROM mcp_servers
|
|
||||||
ORDER BY name ASC, id ASC"
|
|
||||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let server_iter = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
let id: String = row.get(0)?;
|
|
||||||
let name: String = row.get(1)?;
|
|
||||||
let server_config_str: String = row.get(2)?;
|
|
||||||
let description: Option<String> = row.get(3)?;
|
|
||||||
let homepage: Option<String> = row.get(4)?;
|
|
||||||
let docs: Option<String> = row.get(5)?;
|
|
||||||
let tags_str: String = row.get(6)?;
|
|
||||||
let enabled_claude: bool = row.get(7)?;
|
|
||||||
let enabled_codex: bool = row.get(8)?;
|
|
||||||
let enabled_gemini: bool = row.get(9)?;
|
|
||||||
|
|
||||||
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
|
||||||
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
id.clone(),
|
|
||||||
McpServer {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
server,
|
|
||||||
apps: McpApps {
|
|
||||||
claude: enabled_claude,
|
|
||||||
codex: enabled_codex,
|
|
||||||
gemini: enabled_gemini,
|
|
||||||
},
|
|
||||||
description,
|
|
||||||
homepage,
|
|
||||||
docs,
|
|
||||||
tags,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut servers = IndexMap::new();
|
|
||||||
for server_res in server_iter {
|
|
||||||
let (id, server) = server_res.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
servers.insert(id, server);
|
|
||||||
}
|
|
||||||
Ok(servers)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存 MCP 服务器
|
|
||||||
pub fn save_mcp_server(&self, server: &McpServer) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO mcp_servers (
|
|
||||||
id, name, server_config, description, homepage, docs, tags,
|
|
||||||
enabled_claude, enabled_codex, enabled_gemini
|
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
|
||||||
params![
|
|
||||||
server.id,
|
|
||||||
server.name,
|
|
||||||
serde_json::to_string(&server.server).unwrap(),
|
|
||||||
server.description,
|
|
||||||
server.homepage,
|
|
||||||
server.docs,
|
|
||||||
serde_json::to_string(&server.tags).unwrap(),
|
|
||||||
server.apps.claude,
|
|
||||||
server.apps.codex,
|
|
||||||
server.apps.gemini,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除 MCP 服务器
|
|
||||||
pub fn delete_mcp_server(&self, id: &str) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.execute("DELETE FROM mcp_servers WHERE id = ?1", params![id])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
//! Data Access Object layer
|
|
||||||
//!
|
|
||||||
//! Database access operations for each domain
|
|
||||||
|
|
||||||
pub mod failover;
|
|
||||||
pub mod mcp;
|
|
||||||
pub mod prompts;
|
|
||||||
pub mod providers;
|
|
||||||
pub mod proxy;
|
|
||||||
pub mod settings;
|
|
||||||
pub mod skills;
|
|
||||||
pub mod stream_check;
|
|
||||||
|
|
||||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
|
||||||
// 导出 FailoverQueueItem 供外部使用
|
|
||||||
pub use failover::FailoverQueueItem;
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
//! 提示词数据访问对象
|
|
||||||
//!
|
|
||||||
//! 提供提示词(Prompt)的 CRUD 操作。
|
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::prompt::Prompt;
|
|
||||||
use indexmap::IndexMap;
|
|
||||||
use rusqlite::params;
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// 获取指定应用类型的所有提示词
|
|
||||||
pub fn get_prompts(&self, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, name, content, description, enabled, created_at, updated_at
|
|
||||||
FROM prompts WHERE app_type = ?1
|
|
||||||
ORDER BY created_at ASC, id ASC",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let prompt_iter = stmt
|
|
||||||
.query_map(params![app_type], |row| {
|
|
||||||
let id: String = row.get(0)?;
|
|
||||||
let name: String = row.get(1)?;
|
|
||||||
let content: String = row.get(2)?;
|
|
||||||
let description: Option<String> = row.get(3)?;
|
|
||||||
let enabled: bool = row.get(4)?;
|
|
||||||
let created_at: Option<i64> = row.get(5)?;
|
|
||||||
let updated_at: Option<i64> = row.get(6)?;
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
id.clone(),
|
|
||||||
Prompt {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
content,
|
|
||||||
description,
|
|
||||||
enabled,
|
|
||||||
created_at,
|
|
||||||
updated_at,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut prompts = IndexMap::new();
|
|
||||||
for prompt_res in prompt_iter {
|
|
||||||
let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
prompts.insert(id, prompt);
|
|
||||||
}
|
|
||||||
Ok(prompts)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存提示词
|
|
||||||
pub fn save_prompt(&self, app_type: &str, prompt: &Prompt) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO prompts (
|
|
||||||
id, app_type, name, content, description, enabled, created_at, updated_at
|
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
||||||
params![
|
|
||||||
prompt.id,
|
|
||||||
app_type,
|
|
||||||
prompt.name,
|
|
||||||
prompt.content,
|
|
||||||
prompt.description,
|
|
||||||
prompt.enabled,
|
|
||||||
prompt.created_at,
|
|
||||||
prompt.updated_at,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除提示词
|
|
||||||
pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM prompts WHERE id = ?1 AND app_type = ?2",
|
|
||||||
params![id, app_type],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
//! 供应商数据访问对象
|
|
||||||
//!
|
|
||||||
//! 提供供应商(Provider)的 CRUD 操作。
|
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::provider::{Provider, ProviderMeta};
|
|
||||||
use indexmap::IndexMap;
|
|
||||||
use rusqlite::params;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// 获取指定应用类型的所有供应商
|
|
||||||
pub fn get_all_providers(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<IndexMap<String, Provider>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn.prepare(
|
|
||||||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
|
||||||
FROM providers WHERE app_type = ?1
|
|
||||||
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
|
|
||||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let provider_iter = stmt
|
|
||||||
.query_map(params![app_type], |row| {
|
|
||||||
let id: String = row.get(0)?;
|
|
||||||
let name: String = row.get(1)?;
|
|
||||||
let settings_config_str: String = row.get(2)?;
|
|
||||||
let website_url: Option<String> = row.get(3)?;
|
|
||||||
let category: Option<String> = row.get(4)?;
|
|
||||||
let created_at: Option<i64> = row.get(5)?;
|
|
||||||
let sort_index: Option<usize> = row.get(6)?;
|
|
||||||
let notes: Option<String> = row.get(7)?;
|
|
||||||
let icon: Option<String> = row.get(8)?;
|
|
||||||
let icon_color: Option<String> = row.get(9)?;
|
|
||||||
let meta_str: String = row.get(10)?;
|
|
||||||
|
|
||||||
let settings_config =
|
|
||||||
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
|
||||||
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
id,
|
|
||||||
Provider {
|
|
||||||
id: "".to_string(), // Placeholder, set below
|
|
||||||
name,
|
|
||||||
settings_config,
|
|
||||||
website_url,
|
|
||||||
category,
|
|
||||||
created_at,
|
|
||||||
sort_index,
|
|
||||||
notes,
|
|
||||||
meta: Some(meta),
|
|
||||||
icon,
|
|
||||||
icon_color,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut providers = IndexMap::new();
|
|
||||||
for provider_res in provider_iter {
|
|
||||||
let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
provider.id = id.clone();
|
|
||||||
|
|
||||||
// 加载 endpoints
|
|
||||||
let mut stmt_endpoints = conn.prepare(
|
|
||||||
"SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC"
|
|
||||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let endpoints_iter = stmt_endpoints
|
|
||||||
.query_map(params![id, app_type], |row| {
|
|
||||||
let url: String = row.get(0)?;
|
|
||||||
let added_at: Option<i64> = row.get(1)?;
|
|
||||||
Ok((
|
|
||||||
url,
|
|
||||||
crate::settings::CustomEndpoint {
|
|
||||||
url: "".to_string(),
|
|
||||||
added_at: added_at.unwrap_or(0),
|
|
||||||
last_used: None,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut custom_endpoints = HashMap::new();
|
|
||||||
for ep_res in endpoints_iter {
|
|
||||||
let (url, mut ep) = ep_res.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
ep.url = url.clone();
|
|
||||||
custom_endpoints.insert(url, ep);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(meta) = &mut provider.meta {
|
|
||||||
meta.custom_endpoints = custom_endpoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
providers.insert(id, provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(providers)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取当前激活的供应商 ID
|
|
||||||
pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_current = 1 LIMIT 1")
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut rows = stmt
|
|
||||||
.query(params![app_type])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
|
||||||
Ok(Some(
|
|
||||||
row.get(0).map_err(|e| AppError::Database(e.to_string()))?,
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 根据 ID 获取单个供应商
|
|
||||||
pub fn get_provider_by_id(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<Option<Provider>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let result = conn.query_row(
|
|
||||||
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
|
||||||
FROM providers WHERE id = ?1 AND app_type = ?2",
|
|
||||||
params![id, app_type],
|
|
||||||
|row| {
|
|
||||||
let name: String = row.get(0)?;
|
|
||||||
let settings_config_str: String = row.get(1)?;
|
|
||||||
let website_url: Option<String> = row.get(2)?;
|
|
||||||
let category: Option<String> = row.get(3)?;
|
|
||||||
let created_at: Option<i64> = row.get(4)?;
|
|
||||||
let sort_index: Option<usize> = row.get(5)?;
|
|
||||||
let notes: Option<String> = row.get(6)?;
|
|
||||||
let icon: Option<String> = row.get(7)?;
|
|
||||||
let icon_color: Option<String> = row.get(8)?;
|
|
||||||
let meta_str: String = row.get(9)?;
|
|
||||||
|
|
||||||
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
|
||||||
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(Provider {
|
|
||||||
id: id.to_string(),
|
|
||||||
name,
|
|
||||||
settings_config,
|
|
||||||
website_url,
|
|
||||||
category,
|
|
||||||
created_at,
|
|
||||||
sort_index,
|
|
||||||
notes,
|
|
||||||
meta: Some(meta),
|
|
||||||
icon,
|
|
||||||
icon_color,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(provider) => Ok(Some(provider)),
|
|
||||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
|
||||||
Err(e) => Err(AppError::Database(e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存供应商(新增或更新)
|
|
||||||
///
|
|
||||||
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
|
|
||||||
/// (add_custom_endpoint / remove_custom_endpoint),避免覆盖用户的修改。
|
|
||||||
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
|
|
||||||
let mut conn = lock_conn!(self.conn);
|
|
||||||
let tx = conn
|
|
||||||
.transaction()
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 处理 meta:取出 endpoints 以便单独处理
|
|
||||||
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
|
||||||
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
|
||||||
|
|
||||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current)
|
|
||||||
let existing: Option<bool> = tx
|
|
||||||
.query_row(
|
|
||||||
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
|
|
||||||
params![provider.id, app_type],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
let is_update = existing.is_some();
|
|
||||||
let is_current = existing.unwrap_or(false);
|
|
||||||
|
|
||||||
if is_update {
|
|
||||||
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
|
||||||
tx.execute(
|
|
||||||
"UPDATE providers SET
|
|
||||||
name = ?1,
|
|
||||||
settings_config = ?2,
|
|
||||||
website_url = ?3,
|
|
||||||
category = ?4,
|
|
||||||
created_at = ?5,
|
|
||||||
sort_index = ?6,
|
|
||||||
notes = ?7,
|
|
||||||
icon = ?8,
|
|
||||||
icon_color = ?9,
|
|
||||||
meta = ?10,
|
|
||||||
is_current = ?11
|
|
||||||
WHERE id = ?12 AND app_type = ?13",
|
|
||||||
params![
|
|
||||||
provider.name,
|
|
||||||
serde_json::to_string(&provider.settings_config).unwrap(),
|
|
||||||
provider.website_url,
|
|
||||||
provider.category,
|
|
||||||
provider.created_at,
|
|
||||||
provider.sort_index,
|
|
||||||
provider.notes,
|
|
||||||
provider.icon,
|
|
||||||
provider.icon_color,
|
|
||||||
serde_json::to_string(&meta_clone).unwrap(),
|
|
||||||
is_current,
|
|
||||||
provider.id,
|
|
||||||
app_type,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
} else {
|
|
||||||
// 新增模式:使用 INSERT
|
|
||||||
tx.execute(
|
|
||||||
"INSERT INTO providers (
|
|
||||||
id, app_type, name, settings_config, website_url, category,
|
|
||||||
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
|
||||||
params![
|
|
||||||
provider.id,
|
|
||||||
app_type,
|
|
||||||
provider.name,
|
|
||||||
serde_json::to_string(&provider.settings_config).unwrap(),
|
|
||||||
provider.website_url,
|
|
||||||
provider.category,
|
|
||||||
provider.created_at,
|
|
||||||
provider.sort_index,
|
|
||||||
provider.notes,
|
|
||||||
provider.icon,
|
|
||||||
provider.icon_color,
|
|
||||||
serde_json::to_string(&meta_clone).unwrap(),
|
|
||||||
is_current,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 只有新增时才同步 endpoints
|
|
||||||
for (url, endpoint) in endpoints {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
params![provider.id, app_type, url, endpoint.added_at],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除供应商
|
|
||||||
pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
|
|
||||||
params![id, app_type],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置当前供应商
|
|
||||||
pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
|
||||||
let mut conn = lock_conn!(self.conn);
|
|
||||||
let tx = conn
|
|
||||||
.transaction()
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 重置所有为 0
|
|
||||||
tx.execute(
|
|
||||||
"UPDATE providers SET is_current = 0 WHERE app_type = ?1",
|
|
||||||
params![app_type],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 设置新的当前供应商
|
|
||||||
tx.execute(
|
|
||||||
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2",
|
|
||||||
params![id, app_type],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新供应商的 settings_config(仅更新配置,不改变其他字段)
|
|
||||||
pub fn update_provider_settings_config(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider_id: &str,
|
|
||||||
settings_config: &serde_json::Value,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
|
|
||||||
params![
|
|
||||||
serde_json::to_string(settings_config).unwrap(),
|
|
||||||
provider_id,
|
|
||||||
app_type
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加自定义端点
|
|
||||||
pub fn add_custom_endpoint(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider_id: &str,
|
|
||||||
url: &str,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let added_at = chrono::Utc::now().timestamp_millis();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
params![provider_id, app_type, url, added_at],
|
|
||||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 移除自定义端点
|
|
||||||
pub fn remove_custom_endpoint(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider_id: &str,
|
|
||||||
url: &str,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3",
|
|
||||||
params![provider_id, app_type, url],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
//! 代理功能数据访问层
|
|
||||||
//!
|
|
||||||
//! 处理代理配置、Provider健康状态和使用统计的数据库操作
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::proxy::types::*;
|
|
||||||
|
|
||||||
use super::super::{lock_conn, Database};
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
// ==================== Proxy Config ====================
|
|
||||||
|
|
||||||
/// 获取代理配置
|
|
||||||
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
|
|
||||||
// 在一个作用域内获取锁并查询,确保锁在await之前释放
|
|
||||||
let result = {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.query_row(
|
|
||||||
"SELECT enabled, listen_address, listen_port, max_retries,
|
|
||||||
request_timeout, enable_logging, live_takeover_active
|
|
||||||
FROM proxy_config WHERE id = 1",
|
|
||||||
[],
|
|
||||||
|row| {
|
|
||||||
Ok(ProxyConfig {
|
|
||||||
enabled: row.get::<_, i32>(0)? != 0,
|
|
||||||
listen_address: row.get(1)?,
|
|
||||||
listen_port: row.get::<_, i32>(2)? as u16,
|
|
||||||
max_retries: row.get::<_, i32>(3)? as u8,
|
|
||||||
request_timeout: row.get::<_, i32>(4)? as u64,
|
|
||||||
enable_logging: row.get::<_, i32>(5)? != 0,
|
|
||||||
live_takeover_active: row.get::<_, i32>(6).unwrap_or(0) != 0,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}; // conn锁在这里释放
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(config) => Ok(config),
|
|
||||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
|
||||||
// 如果不存在,插入默认配置
|
|
||||||
let default_config = ProxyConfig::default();
|
|
||||||
self.update_proxy_config(default_config.clone()).await?;
|
|
||||||
Ok(default_config)
|
|
||||||
}
|
|
||||||
Err(e) => Err(AppError::Database(e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新代理配置
|
|
||||||
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO proxy_config
|
|
||||||
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, live_takeover_active, target_app, created_at, updated_at)
|
|
||||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
|
|
||||||
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
|
|
||||||
datetime('now'))",
|
|
||||||
rusqlite::params![
|
|
||||||
if config.enabled { 1 } else { 0 },
|
|
||||||
config.listen_address,
|
|
||||||
config.listen_port as i32,
|
|
||||||
config.max_retries as i32,
|
|
||||||
config.request_timeout as i32,
|
|
||||||
if config.enable_logging { 1 } else { 0 },
|
|
||||||
if config.live_takeover_active { 1 } else { 0 },
|
|
||||||
"claude", // 兼容旧字段,写入默认值
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置 Live 接管状态
|
|
||||||
pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1",
|
|
||||||
rusqlite::params![if active { 1 } else { 0 }],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查是否处于 Live 接管模式
|
|
||||||
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
|
|
||||||
// v3.7.0+:以 proxy_live_backup 是否存在作为“接管状态”的真实来源(更贴近 per-app 接管)
|
|
||||||
self.has_any_live_backup().await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Provider Health ====================
|
|
||||||
|
|
||||||
/// 获取Provider健康状态
|
|
||||||
pub async fn get_provider_health(
|
|
||||||
&self,
|
|
||||||
provider_id: &str,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<ProviderHealth, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.query_row(
|
|
||||||
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
|
|
||||||
last_success_at, last_failure_at, last_error, updated_at
|
|
||||||
FROM provider_health
|
|
||||||
WHERE provider_id = ?1 AND app_type = ?2",
|
|
||||||
rusqlite::params![provider_id, app_type],
|
|
||||||
|row| {
|
|
||||||
Ok(ProviderHealth {
|
|
||||||
provider_id: row.get(0)?,
|
|
||||||
app_type: row.get(1)?,
|
|
||||||
is_healthy: row.get::<_, i64>(2)? != 0,
|
|
||||||
consecutive_failures: row.get::<_, i64>(3)? as u32,
|
|
||||||
last_success_at: row.get(4)?,
|
|
||||||
last_failure_at: row.get(5)?,
|
|
||||||
last_error: row.get(6)?,
|
|
||||||
updated_at: row.get(7)?,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新Provider健康状态
|
|
||||||
///
|
|
||||||
/// 使用默认阈值(5)判断是否健康,建议使用 `update_provider_health_with_threshold` 传入配置的阈值
|
|
||||||
pub async fn update_provider_health(
|
|
||||||
&self,
|
|
||||||
provider_id: &str,
|
|
||||||
app_type: &str,
|
|
||||||
success: bool,
|
|
||||||
error_msg: Option<String>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
// 默认阈值与 CircuitBreakerConfig::default() 保持一致
|
|
||||||
self.update_provider_health_with_threshold(provider_id, app_type, success, error_msg, 5)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新Provider健康状态(带阈值参数)
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `failure_threshold` - 连续失败多少次后标记为不健康
|
|
||||||
pub async fn update_provider_health_with_threshold(
|
|
||||||
&self,
|
|
||||||
provider_id: &str,
|
|
||||||
app_type: &str,
|
|
||||||
success: bool,
|
|
||||||
error_msg: Option<String>,
|
|
||||||
failure_threshold: u32,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
// 先查询当前状态
|
|
||||||
let current = conn.query_row(
|
|
||||||
"SELECT consecutive_failures FROM provider_health
|
|
||||||
WHERE provider_id = ?1 AND app_type = ?2",
|
|
||||||
rusqlite::params![provider_id, app_type],
|
|
||||||
|row| Ok(row.get::<_, i64>(0)? as u32),
|
|
||||||
);
|
|
||||||
|
|
||||||
let (is_healthy, consecutive_failures) = if success {
|
|
||||||
// 成功:重置失败计数
|
|
||||||
(1, 0)
|
|
||||||
} else {
|
|
||||||
// 失败:增加失败计数
|
|
||||||
let failures = current.unwrap_or(0) + 1;
|
|
||||||
// 使用传入的阈值而非硬编码
|
|
||||||
let healthy = if failures >= failure_threshold { 0 } else { 1 };
|
|
||||||
(healthy, failures)
|
|
||||||
};
|
|
||||||
|
|
||||||
let (last_success_at, last_failure_at) = if success {
|
|
||||||
(Some(now.clone()), None)
|
|
||||||
} else {
|
|
||||||
(None, Some(now.clone()))
|
|
||||||
};
|
|
||||||
|
|
||||||
// UPSERT
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO provider_health
|
|
||||||
(provider_id, app_type, is_healthy, consecutive_failures,
|
|
||||||
last_success_at, last_failure_at, last_error, updated_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4,
|
|
||||||
COALESCE(?5, (SELECT last_success_at FROM provider_health
|
|
||||||
WHERE provider_id = ?1 AND app_type = ?2)),
|
|
||||||
COALESCE(?6, (SELECT last_failure_at FROM provider_health
|
|
||||||
WHERE provider_id = ?1 AND app_type = ?2)),
|
|
||||||
?7, ?8)",
|
|
||||||
rusqlite::params![
|
|
||||||
provider_id,
|
|
||||||
app_type,
|
|
||||||
is_healthy,
|
|
||||||
consecutive_failures as i64,
|
|
||||||
last_success_at,
|
|
||||||
last_failure_at,
|
|
||||||
error_msg,
|
|
||||||
&now,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重置Provider健康状态
|
|
||||||
pub async fn reset_provider_health(
|
|
||||||
&self,
|
|
||||||
provider_id: &str,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
|
|
||||||
rusqlite::params![provider_id, app_type],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
log::debug!("Reset health status for provider {provider_id} (app: {app_type})");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 清空所有Provider健康状态(代理停止时调用)
|
|
||||||
pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute("DELETE FROM provider_health", [])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
log::debug!("Cleared all provider health records");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Circuit Breaker Config ====================
|
|
||||||
|
|
||||||
/// 获取熔断器配置
|
|
||||||
pub async fn get_circuit_breaker_config(
|
|
||||||
&self,
|
|
||||||
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
let config = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT failure_threshold, success_threshold, timeout_seconds,
|
|
||||||
error_rate_threshold, min_requests
|
|
||||||
FROM circuit_breaker_config WHERE id = 1",
|
|
||||||
[],
|
|
||||||
|row| {
|
|
||||||
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
|
||||||
failure_threshold: row.get::<_, i32>(0)? as u32,
|
|
||||||
success_threshold: row.get::<_, i32>(1)? as u32,
|
|
||||||
timeout_seconds: row.get::<_, i64>(2)? as u64,
|
|
||||||
error_rate_threshold: row.get(3)?,
|
|
||||||
min_requests: row.get::<_, i32>(4)? as u32,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新熔断器配置
|
|
||||||
pub async fn update_circuit_breaker_config(
|
|
||||||
&self,
|
|
||||||
config: &crate::proxy::circuit_breaker::CircuitBreakerConfig,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE circuit_breaker_config
|
|
||||||
SET failure_threshold = ?1,
|
|
||||||
success_threshold = ?2,
|
|
||||||
timeout_seconds = ?3,
|
|
||||||
error_rate_threshold = ?4,
|
|
||||||
min_requests = ?5,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = 1",
|
|
||||||
rusqlite::params![
|
|
||||||
config.failure_threshold as i32,
|
|
||||||
config.success_threshold as i32,
|
|
||||||
config.timeout_seconds as i64,
|
|
||||||
config.error_rate_threshold,
|
|
||||||
config.min_requests as i32,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Live Backup ====================
|
|
||||||
|
|
||||||
/// 保存 Live 配置备份
|
|
||||||
pub async fn save_live_backup(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
config_json: &str,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO proxy_live_backup (app_type, original_config, backed_up_at)
|
|
||||||
VALUES (?1, ?2, ?3)",
|
|
||||||
rusqlite::params![app_type, config_json, now],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
log::info!("已备份 {app_type} Live 配置");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查是否存在任意 Live 配置备份
|
|
||||||
pub async fn has_any_live_backup(&self) -> Result<bool, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM proxy_live_backup", [], |row| {
|
|
||||||
row.get(0)
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(count > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Live 配置备份
|
|
||||||
pub async fn get_live_backup(&self, app_type: &str) -> Result<Option<LiveBackup>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
let result = conn.query_row(
|
|
||||||
"SELECT app_type, original_config, backed_up_at FROM proxy_live_backup WHERE app_type = ?1",
|
|
||||||
rusqlite::params![app_type],
|
|
||||||
|row| {
|
|
||||||
Ok(LiveBackup {
|
|
||||||
app_type: row.get(0)?,
|
|
||||||
original_config: row.get(1)?,
|
|
||||||
backed_up_at: row.get(2)?,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(backup) => Ok(Some(backup)),
|
|
||||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
|
||||||
Err(e) => Err(AppError::Database(e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除 Live 配置备份
|
|
||||||
pub async fn delete_live_backup(&self, app_type: &str) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM proxy_live_backup WHERE app_type = ?1",
|
|
||||||
rusqlite::params![app_type],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
log::info!("已删除 {app_type} Live 配置备份");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除所有 Live 配置备份
|
|
||||||
pub async fn delete_all_live_backups(&self) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute("DELETE FROM proxy_live_backup", [])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
log::info!("已删除所有 Live 配置备份");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
//! 通用设置数据访问对象
|
|
||||||
//!
|
|
||||||
//! 提供键值对形式的通用设置存储。
|
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use rusqlite::params;
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// 获取设置值
|
|
||||||
pub fn get_setting(&self, key: &str) -> Result<Option<String>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare("SELECT value FROM settings WHERE key = ?1")
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut rows = stmt
|
|
||||||
.query(params![key])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
|
||||||
Ok(Some(
|
|
||||||
row.get(0).map_err(|e| AppError::Database(e.to_string()))?,
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置值
|
|
||||||
pub fn set_setting(&self, key: &str, value: &str) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
|
||||||
params![key, value],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Config Snippets 辅助方法 ---
|
|
||||||
|
|
||||||
/// 获取通用配置片段
|
|
||||||
pub fn get_config_snippet(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
|
||||||
self.get_setting(&format!("common_config_{app_type}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置通用配置片段
|
|
||||||
pub fn set_config_snippet(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
snippet: Option<String>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let key = format!("common_config_{app_type}");
|
|
||||||
if let Some(value) = snippet {
|
|
||||||
self.set_setting(&key, &value)
|
|
||||||
} else {
|
|
||||||
// 如果为 None 则删除
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
//! Skills 数据访问对象
|
|
||||||
//!
|
|
||||||
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
|
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::services::skill::{SkillRepo, SkillState};
|
|
||||||
use indexmap::IndexMap;
|
|
||||||
use rusqlite::params;
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// 获取所有 Skills 状态
|
|
||||||
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC")
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let skill_iter = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
let directory: String = row.get(0)?;
|
|
||||||
let app_type: String = row.get(1)?;
|
|
||||||
let installed: bool = row.get(2)?;
|
|
||||||
let installed_at_ts: i64 = row.get(3)?;
|
|
||||||
|
|
||||||
let installed_at =
|
|
||||||
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
|
|
||||||
|
|
||||||
// 构建复合 key:"app_type:directory"
|
|
||||||
let key = format!("{app_type}:{directory}");
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
key,
|
|
||||||
SkillState {
|
|
||||||
installed,
|
|
||||||
installed_at,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut skills = IndexMap::new();
|
|
||||||
for skill_res in skill_iter {
|
|
||||||
let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
skills.insert(key, skill);
|
|
||||||
}
|
|
||||||
Ok(skills)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新 Skill 状态
|
|
||||||
/// key 格式为 "app_type:directory"
|
|
||||||
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
|
|
||||||
// 解析 key
|
|
||||||
let (app_type, directory) = if let Some(idx) = key.find(':') {
|
|
||||||
let (app, dir) = key.split_at(idx);
|
|
||||||
(app, &dir[1..]) // 跳过冒号
|
|
||||||
} else {
|
|
||||||
// 向后兼容:如果没有前缀,默认为 claude
|
|
||||||
("claude", key)
|
|
||||||
};
|
|
||||||
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
params![directory, app_type, state.installed, state.installed_at.timestamp()],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取所有 Skill 仓库
|
|
||||||
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT owner, name, branch, enabled FROM skill_repos ORDER BY owner ASC, name ASC",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let repo_iter = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
Ok(SkillRepo {
|
|
||||||
owner: row.get(0)?,
|
|
||||||
name: row.get(1)?,
|
|
||||||
branch: row.get(2)?,
|
|
||||||
enabled: row.get(3)?,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut repos = Vec::new();
|
|
||||||
for repo_res in repo_iter {
|
|
||||||
repos.push(repo_res.map_err(|e| AppError::Database(e.to_string()))?);
|
|
||||||
}
|
|
||||||
Ok(repos)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存 Skill 仓库
|
|
||||||
pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
params![repo.owner, repo.name, repo.branch, repo.enabled],
|
|
||||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除 Skill 仓库
|
|
||||||
pub fn delete_skill_repo(&self, owner: &str, name: &str) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM skill_repos WHERE owner = ?1 AND name = ?2",
|
|
||||||
params![owner, name],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 初始化默认的 Skill 仓库(首次启动时调用)
|
|
||||||
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
|
|
||||||
// 检查是否已有仓库
|
|
||||||
let existing = self.get_skill_repos()?;
|
|
||||||
if !existing.is_empty() {
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取默认仓库列表
|
|
||||||
let default_store = crate::services::skill::SkillStore::default();
|
|
||||||
let mut count = 0;
|
|
||||||
|
|
||||||
for repo in &default_store.repos {
|
|
||||||
self.save_skill_repo(repo)?;
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("初始化默认 Skill 仓库完成,共 {count} 个");
|
|
||||||
Ok(count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
//! 流式健康检查日志 DAO
|
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::services::stream_check::{StreamCheckConfig, StreamCheckResult};
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// 保存流式检查日志
|
|
||||||
pub fn save_stream_check_log(
|
|
||||||
&self,
|
|
||||||
provider_id: &str,
|
|
||||||
provider_name: &str,
|
|
||||||
app_type: &str,
|
|
||||||
result: &StreamCheckResult,
|
|
||||||
) -> Result<i64, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO stream_check_logs
|
|
||||||
(provider_id, provider_name, app_type, status, success, message,
|
|
||||||
response_time_ms, http_status, model_used, retry_count, tested_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
|
||||||
rusqlite::params![
|
|
||||||
provider_id,
|
|
||||||
provider_name,
|
|
||||||
app_type,
|
|
||||||
format!("{:?}", result.status).to_lowercase(),
|
|
||||||
result.success,
|
|
||||||
result.message,
|
|
||||||
result.response_time_ms.map(|t| t as i64),
|
|
||||||
result.http_status.map(|s| s as i64),
|
|
||||||
result.model_used,
|
|
||||||
result.retry_count as i64,
|
|
||||||
result.tested_at,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取流式检查配置
|
|
||||||
pub fn get_stream_check_config(&self) -> Result<StreamCheckConfig, AppError> {
|
|
||||||
match self.get_setting("stream_check_config")? {
|
|
||||||
Some(json) => serde_json::from_str(&json)
|
|
||||||
.map_err(|e| AppError::Message(format!("解析配置失败: {e}"))),
|
|
||||||
None => Ok(StreamCheckConfig::default()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存流式检查配置
|
|
||||||
pub fn save_stream_check_config(&self, config: &StreamCheckConfig) -> Result<(), AppError> {
|
|
||||||
let json = serde_json::to_string(config)
|
|
||||||
.map_err(|e| AppError::Message(format!("序列化配置失败: {e}")))?;
|
|
||||||
self.set_setting("stream_check_config", &json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
//! JSON → SQLite 数据迁移
|
|
||||||
//!
|
|
||||||
//! 将旧版 config.json (MultiAppConfig) 数据迁移到 SQLite 数据库。
|
|
||||||
|
|
||||||
use super::{lock_conn, to_json_string, Database};
|
|
||||||
use crate::app_config::MultiAppConfig;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use rusqlite::{params, Connection};
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// 从 MultiAppConfig 迁移数据到数据库
|
|
||||||
pub fn migrate_from_json(&self, config: &MultiAppConfig) -> Result<(), AppError> {
|
|
||||||
let mut conn = lock_conn!(self.conn);
|
|
||||||
let tx = conn
|
|
||||||
.transaction()
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Self::migrate_from_json_tx(&tx, config)?;
|
|
||||||
|
|
||||||
tx.commit()
|
|
||||||
.map_err(|e| AppError::Database(format!("Commit migration failed: {e}")))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 运行迁移的 dry-run 模式(在内存数据库中验证,不写入磁盘)
|
|
||||||
///
|
|
||||||
/// 用于部署前验证迁移逻辑是否正确。
|
|
||||||
pub fn migrate_from_json_dry_run(config: &MultiAppConfig) -> Result<(), AppError> {
|
|
||||||
let mut conn =
|
|
||||||
Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Self::create_tables_on_conn(&conn)?;
|
|
||||||
Self::apply_schema_migrations_on_conn(&conn)?;
|
|
||||||
|
|
||||||
let tx = conn
|
|
||||||
.transaction()
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Self::migrate_from_json_tx(&tx, config)?;
|
|
||||||
|
|
||||||
// 显式 drop transaction 而不提交(内存数据库会被丢弃)
|
|
||||||
drop(tx);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 在事务中执行迁移
|
|
||||||
fn migrate_from_json_tx(
|
|
||||||
tx: &rusqlite::Transaction<'_>,
|
|
||||||
config: &MultiAppConfig,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
// 1. 迁移 Providers
|
|
||||||
Self::migrate_providers(tx, config)?;
|
|
||||||
|
|
||||||
// 2. 迁移 MCP Servers
|
|
||||||
Self::migrate_mcp_servers(tx, config)?;
|
|
||||||
|
|
||||||
// 3. 迁移 Prompts
|
|
||||||
Self::migrate_prompts(tx, config)?;
|
|
||||||
|
|
||||||
// 4. 迁移 Skills
|
|
||||||
Self::migrate_skills(tx, config)?;
|
|
||||||
|
|
||||||
// 5. 迁移 Common Config
|
|
||||||
Self::migrate_common_config(tx, config)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 迁移供应商数据
|
|
||||||
fn migrate_providers(
|
|
||||||
tx: &rusqlite::Transaction<'_>,
|
|
||||||
config: &MultiAppConfig,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
for (app_key, manager) in &config.apps {
|
|
||||||
let app_type = app_key;
|
|
||||||
let current_id = &manager.current;
|
|
||||||
|
|
||||||
for (id, provider) in &manager.providers {
|
|
||||||
let is_current = if id == current_id { 1 } else { 0 };
|
|
||||||
|
|
||||||
// 处理 meta 和 endpoints
|
|
||||||
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
|
||||||
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
|
||||||
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO providers (
|
|
||||||
id, app_type, name, settings_config, website_url, category,
|
|
||||||
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
|
||||||
params![
|
|
||||||
id,
|
|
||||||
app_type,
|
|
||||||
provider.name,
|
|
||||||
to_json_string(&provider.settings_config)?,
|
|
||||||
provider.website_url,
|
|
||||||
provider.category,
|
|
||||||
provider.created_at,
|
|
||||||
provider.sort_index,
|
|
||||||
provider.notes,
|
|
||||||
provider.icon,
|
|
||||||
provider.icon_color,
|
|
||||||
to_json_string(&meta_clone)?,
|
|
||||||
is_current,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate provider failed: {e}")))?;
|
|
||||||
|
|
||||||
// 迁移 Endpoints
|
|
||||||
for (url, endpoint) in endpoints {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
params![id, app_type, url, endpoint.added_at],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate endpoint failed: {e}")))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 迁移 MCP 服务器数据
|
|
||||||
fn migrate_mcp_servers(
|
|
||||||
tx: &rusqlite::Transaction<'_>,
|
|
||||||
config: &MultiAppConfig,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
if let Some(servers) = &config.mcp.servers {
|
|
||||||
for (id, server) in servers {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO mcp_servers (
|
|
||||||
id, name, server_config, description, homepage, docs, tags,
|
|
||||||
enabled_claude, enabled_codex, enabled_gemini
|
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
|
||||||
params![
|
|
||||||
id,
|
|
||||||
server.name,
|
|
||||||
to_json_string(&server.server)?,
|
|
||||||
server.description,
|
|
||||||
server.homepage,
|
|
||||||
server.docs,
|
|
||||||
to_json_string(&server.tags)?,
|
|
||||||
server.apps.claude,
|
|
||||||
server.apps.codex,
|
|
||||||
server.apps.gemini,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate mcp server failed: {e}")))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 迁移提示词数据
|
|
||||||
fn migrate_prompts(
|
|
||||||
tx: &rusqlite::Transaction<'_>,
|
|
||||||
config: &MultiAppConfig,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let migrate_app_prompts = |prompts_map: &std::collections::HashMap<
|
|
||||||
String,
|
|
||||||
crate::prompt::Prompt,
|
|
||||||
>,
|
|
||||||
app_type: &str|
|
|
||||||
-> Result<(), AppError> {
|
|
||||||
for (id, prompt) in prompts_map {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO prompts (
|
|
||||||
id, app_type, name, content, description, enabled, created_at, updated_at
|
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
||||||
params![
|
|
||||||
id,
|
|
||||||
app_type,
|
|
||||||
prompt.name,
|
|
||||||
prompt.content,
|
|
||||||
prompt.description,
|
|
||||||
prompt.enabled,
|
|
||||||
prompt.created_at,
|
|
||||||
prompt.updated_at,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate prompt failed: {e}")))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
};
|
|
||||||
|
|
||||||
migrate_app_prompts(&config.prompts.claude.prompts, "claude")?;
|
|
||||||
migrate_app_prompts(&config.prompts.codex.prompts, "codex")?;
|
|
||||||
migrate_app_prompts(&config.prompts.gemini.prompts, "gemini")?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 迁移 Skills 数据
|
|
||||||
fn migrate_skills(
|
|
||||||
tx: &rusqlite::Transaction<'_>,
|
|
||||||
config: &MultiAppConfig,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
for (key, state) in &config.skills.skills {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
|
||||||
params![key, state.installed, state.installed_at.timestamp()],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for repo in &config.skills.repos {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
params![repo.owner, repo.name, repo.branch, repo.enabled],
|
|
||||||
).map_err(|e| AppError::Database(format!("Migrate skill repo failed: {e}")))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 迁移通用配置片段
|
|
||||||
fn migrate_common_config(
|
|
||||||
tx: &rusqlite::Transaction<'_>,
|
|
||||||
config: &MultiAppConfig,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
if let Some(snippet) = &config.common_config_snippets.claude {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
|
||||||
params!["common_config_claude", snippet],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
|
||||||
}
|
|
||||||
if let Some(snippet) = &config.common_config_snippets.codex {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
|
||||||
params!["common_config_codex", snippet],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
|
||||||
}
|
|
||||||
if let Some(snippet) = &config.common_config_snippets.gemini {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
|
||||||
params!["common_config_gemini", snippet],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
//! 数据库模块 - SQLite 数据持久化
|
|
||||||
//!
|
|
||||||
//! 此模块提供应用的核心数据存储功能,包括:
|
|
||||||
//! - 供应商配置管理
|
|
||||||
//! - MCP 服务器配置
|
|
||||||
//! - 提示词管理
|
|
||||||
//! - Skills 管理
|
|
||||||
//! - 通用设置存储
|
|
||||||
//!
|
|
||||||
//! ## 架构设计
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! database/
|
|
||||||
//! ├── mod.rs - Database 结构体 + 初始化
|
|
||||||
//! ├── schema.rs - 表结构定义 + Schema 迁移
|
|
||||||
//! ├── backup.rs - SQL 导入导出 + 快照备份
|
|
||||||
//! ├── migration.rs - JSON → SQLite 数据迁移
|
|
||||||
//! └── dao/ - 数据访问对象
|
|
||||||
//! ├── providers.rs
|
|
||||||
//! ├── mcp.rs
|
|
||||||
//! ├── prompts.rs
|
|
||||||
//! ├── skills.rs
|
|
||||||
//! └── settings.rs
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
mod backup;
|
|
||||||
mod dao;
|
|
||||||
mod migration;
|
|
||||||
mod schema;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
|
|
||||||
// DAO 类型导出供外部使用
|
|
||||||
pub use dao::FailoverQueueItem;
|
|
||||||
|
|
||||||
use crate::config::get_app_config_dir;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use rusqlite::Connection;
|
|
||||||
use serde::Serialize;
|
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
// DAO 方法通过 impl Database 提供,无需额外导出
|
|
||||||
|
|
||||||
/// 数据库备份保留数量
|
|
||||||
const DB_BACKUP_RETAIN: usize = 10;
|
|
||||||
|
|
||||||
/// 当前 Schema 版本号
|
|
||||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
|
||||||
pub(crate) const SCHEMA_VERSION: i32 = 2;
|
|
||||||
|
|
||||||
/// 安全地序列化 JSON,避免 unwrap panic
|
|
||||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
|
||||||
serde_json::to_string(value)
|
|
||||||
.map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安全地获取 Mutex 锁,避免 unwrap panic
|
|
||||||
macro_rules! lock_conn {
|
|
||||||
($mutex:expr) => {
|
|
||||||
$mutex
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| AppError::Database(format!("Mutex lock failed: {}", e)))?
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出宏供子模块使用
|
|
||||||
pub(crate) use lock_conn;
|
|
||||||
|
|
||||||
/// 数据库连接封装
|
|
||||||
///
|
|
||||||
/// 使用 Mutex 包装 Connection 以支持在多线程环境(如 Tauri State)中共享。
|
|
||||||
/// rusqlite::Connection 本身不是 Sync 的,因此需要这层包装。
|
|
||||||
pub struct Database {
|
|
||||||
pub(crate) conn: Mutex<Connection>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// 初始化数据库连接并创建表
|
|
||||||
///
|
|
||||||
/// 数据库文件位于 `~/.cc-switch/cc-switch.db`
|
|
||||||
pub fn init() -> Result<Self, AppError> {
|
|
||||||
let db_path = get_app_config_dir().join("cc-switch.db");
|
|
||||||
|
|
||||||
// 确保父目录存在
|
|
||||||
if let Some(parent) = db_path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let conn = Connection::open(&db_path).map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 启用外键约束
|
|
||||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let db = Self {
|
|
||||||
conn: Mutex::new(conn),
|
|
||||||
};
|
|
||||||
db.create_tables()?;
|
|
||||||
db.apply_schema_migrations()?;
|
|
||||||
db.ensure_model_pricing_seeded()?;
|
|
||||||
|
|
||||||
Ok(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 创建内存数据库(用于测试)
|
|
||||||
pub fn memory() -> Result<Self, AppError> {
|
|
||||||
let conn = Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 启用外键约束
|
|
||||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let db = Self {
|
|
||||||
conn: Mutex::new(conn),
|
|
||||||
};
|
|
||||||
db.create_tables()?;
|
|
||||||
db.ensure_model_pricing_seeded()?;
|
|
||||||
|
|
||||||
Ok(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查 MCP 服务器表是否为空
|
|
||||||
pub fn is_mcp_table_empty(&self) -> Result<bool, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0))
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(count == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查提示词表是否为空
|
|
||||||
pub fn is_prompts_table_empty(&self) -> Result<bool, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(count == 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,864 +0,0 @@
|
|||||||
//! Schema 定义和迁移
|
|
||||||
//!
|
|
||||||
//! 负责数据库表结构的创建和版本迁移。
|
|
||||||
|
|
||||||
use super::{lock_conn, Database, SCHEMA_VERSION};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use rusqlite::Connection;
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// 创建所有数据库表
|
|
||||||
pub(crate) fn create_tables(&self) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
Self::create_tables_on_conn(&conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 在指定连接上创建表(供迁移和测试使用)
|
|
||||||
pub(crate) fn create_tables_on_conn(conn: &Connection) -> Result<(), AppError> {
|
|
||||||
// 1. Providers 表
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS providers (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
settings_config TEXT NOT NULL,
|
|
||||||
website_url TEXT,
|
|
||||||
category TEXT,
|
|
||||||
created_at INTEGER,
|
|
||||||
sort_index INTEGER,
|
|
||||||
notes TEXT,
|
|
||||||
icon TEXT,
|
|
||||||
icon_color TEXT,
|
|
||||||
meta TEXT NOT NULL DEFAULT '{}',
|
|
||||||
is_current BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (id, app_type)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 2. Provider Endpoints 表
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS provider_endpoints (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
url TEXT NOT NULL,
|
|
||||||
added_at INTEGER,
|
|
||||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 3. MCP Servers 表
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS mcp_servers (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
server_config TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
homepage TEXT,
|
|
||||||
docs TEXT,
|
|
||||||
tags TEXT NOT NULL DEFAULT '[]',
|
|
||||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 4. Prompts 表
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS prompts (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
||||||
created_at INTEGER,
|
|
||||||
updated_at INTEGER,
|
|
||||||
PRIMARY KEY (id, app_type)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 5. Skills 表
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS skills (
|
|
||||||
directory TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
installed BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (directory, app_type)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 6. Skill Repos 表
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS skill_repos (
|
|
||||||
owner TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
branch TEXT NOT NULL DEFAULT 'main',
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
||||||
PRIMARY KEY (owner, name)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 7. Settings 表 (通用配置)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 8. Proxy Config 表 (代理服务器配置)
|
|
||||||
// 代理配置表(单例)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS proxy_config (
|
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 0,
|
|
||||||
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
|
||||||
listen_port INTEGER NOT NULL DEFAULT 5000,
|
|
||||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
|
||||||
request_timeout INTEGER NOT NULL DEFAULT 300,
|
|
||||||
enable_logging INTEGER NOT NULL DEFAULT 1,
|
|
||||||
target_app TEXT NOT NULL DEFAULT 'claude',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 尝试添加 target_app 列(如果表已存在但缺少该列)
|
|
||||||
// 忽略 "duplicate column name" 错误
|
|
||||||
let _ = conn.execute(
|
|
||||||
"ALTER TABLE proxy_config ADD COLUMN target_app TEXT NOT NULL DEFAULT 'claude'",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 9. Provider Health 表 (Provider健康状态)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS provider_health (
|
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
is_healthy INTEGER NOT NULL DEFAULT 1,
|
|
||||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_success_at TEXT,
|
|
||||||
last_failure_at TEXT,
|
|
||||||
last_error TEXT,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (provider_id, app_type),
|
|
||||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 10. Proxy Request Logs 表 (详细请求日志)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
|
||||||
request_id TEXT PRIMARY KEY,
|
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
model TEXT NOT NULL,
|
|
||||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
input_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
output_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
latency_ms INTEGER NOT NULL,
|
|
||||||
first_token_ms INTEGER,
|
|
||||||
duration_ms INTEGER,
|
|
||||||
status_code INTEGER NOT NULL,
|
|
||||||
error_message TEXT,
|
|
||||||
session_id TEXT,
|
|
||||||
provider_type TEXT,
|
|
||||||
is_streaming INTEGER NOT NULL DEFAULT 0,
|
|
||||||
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
|
|
||||||
created_at INTEGER NOT NULL
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_provider
|
|
||||||
ON proxy_request_logs(provider_id, app_type)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_created_at
|
|
||||||
ON proxy_request_logs(created_at)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_model
|
|
||||||
ON proxy_request_logs(model)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_session
|
|
||||||
ON proxy_request_logs(session_id)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_status
|
|
||||||
ON proxy_request_logs(status_code)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 11. Model Pricing 表 (模型定价)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS model_pricing (
|
|
||||||
model_id TEXT PRIMARY KEY,
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
input_cost_per_million TEXT NOT NULL,
|
|
||||||
output_cost_per_million TEXT NOT NULL,
|
|
||||||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
|
||||||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 12. Stream Check Logs 表 (流式健康检查日志)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS stream_check_logs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
provider_name TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
success INTEGER NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
response_time_ms INTEGER,
|
|
||||||
http_status INTEGER,
|
|
||||||
model_used TEXT,
|
|
||||||
retry_count INTEGER DEFAULT 0,
|
|
||||||
tested_at INTEGER NOT NULL
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider
|
|
||||||
ON stream_check_logs(app_type, provider_id, tested_at DESC)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 13. Circuit Breaker Config 表 (熔断器配置)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS circuit_breaker_config (
|
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
||||||
failure_threshold INTEGER NOT NULL DEFAULT 5,
|
|
||||||
success_threshold INTEGER NOT NULL DEFAULT 2,
|
|
||||||
timeout_seconds INTEGER NOT NULL DEFAULT 60,
|
|
||||||
error_rate_threshold REAL NOT NULL DEFAULT 0.5,
|
|
||||||
min_requests INTEGER NOT NULL DEFAULT 10,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 插入默认熔断器配置
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR IGNORE INTO circuit_breaker_config (id) VALUES (1)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 16. Proxy Live Backup 表 (Live 配置备份)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS proxy_live_backup (
|
|
||||||
app_type TEXT PRIMARY KEY,
|
|
||||||
original_config TEXT NOT NULL,
|
|
||||||
backed_up_at TEXT NOT NULL
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 尝试添加 live_takeover_active 列到 proxy_config 表
|
|
||||||
let _ = conn.execute(
|
|
||||||
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 14. Failover Queue 表 (故障转移队列)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS failover_queue (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
queue_order INTEGER NOT NULL,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
UNIQUE (app_type, provider_id),
|
|
||||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 为故障转移队列创建索引
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_failover_queue_order
|
|
||||||
ON failover_queue(app_type, queue_order)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 应用 Schema 迁移
|
|
||||||
pub(crate) fn apply_schema_migrations(&self) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
Self::apply_schema_migrations_on_conn(&conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 在指定连接上应用 Schema 迁移
|
|
||||||
pub(crate) fn apply_schema_migrations_on_conn(conn: &Connection) -> Result<(), AppError> {
|
|
||||||
conn.execute("SAVEPOINT schema_migration;", [])
|
|
||||||
.map_err(|e| AppError::Database(format!("开启迁移 savepoint 失败: {e}")))?;
|
|
||||||
|
|
||||||
let mut version = Self::get_user_version(conn)?;
|
|
||||||
|
|
||||||
if version > SCHEMA_VERSION {
|
|
||||||
conn.execute("ROLLBACK TO schema_migration;", []).ok();
|
|
||||||
conn.execute("RELEASE schema_migration;", []).ok();
|
|
||||||
return Err(AppError::Database(format!(
|
|
||||||
"数据库版本过新({version}),当前应用仅支持 {SCHEMA_VERSION},请升级应用后再尝试。"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = (|| {
|
|
||||||
while version < SCHEMA_VERSION {
|
|
||||||
match version {
|
|
||||||
0 => {
|
|
||||||
log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)");
|
|
||||||
Self::migrate_v0_to_v1(conn)?;
|
|
||||||
Self::set_user_version(conn, 1)?;
|
|
||||||
}
|
|
||||||
1 => {
|
|
||||||
log::info!(
|
|
||||||
"迁移数据库从 v1 到 v2(添加使用统计表和完整字段,重构 skills 表)"
|
|
||||||
);
|
|
||||||
Self::migrate_v1_to_v2(conn)?;
|
|
||||||
Self::set_user_version(conn, 2)?;
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(AppError::Database(format!(
|
|
||||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
version = Self::get_user_version(conn)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(_) => {
|
|
||||||
conn.execute("RELEASE schema_migration;", [])
|
|
||||||
.map_err(|e| AppError::Database(format!("提交迁移 savepoint 失败: {e}")))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
conn.execute("ROLLBACK TO schema_migration;", []).ok();
|
|
||||||
conn.execute("RELEASE schema_migration;", []).ok();
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// v0 -> v1 迁移:补齐所有缺失列
|
|
||||||
fn migrate_v0_to_v1(conn: &Connection) -> Result<(), AppError> {
|
|
||||||
// providers 表
|
|
||||||
Self::add_column_if_missing(conn, "providers", "category", "TEXT")?;
|
|
||||||
Self::add_column_if_missing(conn, "providers", "created_at", "INTEGER")?;
|
|
||||||
Self::add_column_if_missing(conn, "providers", "sort_index", "INTEGER")?;
|
|
||||||
Self::add_column_if_missing(conn, "providers", "notes", "TEXT")?;
|
|
||||||
Self::add_column_if_missing(conn, "providers", "icon", "TEXT")?;
|
|
||||||
Self::add_column_if_missing(conn, "providers", "icon_color", "TEXT")?;
|
|
||||||
Self::add_column_if_missing(conn, "providers", "meta", "TEXT NOT NULL DEFAULT '{}'")?;
|
|
||||||
Self::add_column_if_missing(
|
|
||||||
conn,
|
|
||||||
"providers",
|
|
||||||
"is_current",
|
|
||||||
"BOOLEAN NOT NULL DEFAULT 0",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// provider_endpoints 表
|
|
||||||
Self::add_column_if_missing(conn, "provider_endpoints", "added_at", "INTEGER")?;
|
|
||||||
|
|
||||||
// mcp_servers 表
|
|
||||||
Self::add_column_if_missing(conn, "mcp_servers", "description", "TEXT")?;
|
|
||||||
Self::add_column_if_missing(conn, "mcp_servers", "homepage", "TEXT")?;
|
|
||||||
Self::add_column_if_missing(conn, "mcp_servers", "docs", "TEXT")?;
|
|
||||||
Self::add_column_if_missing(conn, "mcp_servers", "tags", "TEXT NOT NULL DEFAULT '[]'")?;
|
|
||||||
Self::add_column_if_missing(
|
|
||||||
conn,
|
|
||||||
"mcp_servers",
|
|
||||||
"enabled_codex",
|
|
||||||
"BOOLEAN NOT NULL DEFAULT 0",
|
|
||||||
)?;
|
|
||||||
Self::add_column_if_missing(
|
|
||||||
conn,
|
|
||||||
"mcp_servers",
|
|
||||||
"enabled_gemini",
|
|
||||||
"BOOLEAN NOT NULL DEFAULT 0",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// prompts 表
|
|
||||||
Self::add_column_if_missing(conn, "prompts", "description", "TEXT")?;
|
|
||||||
Self::add_column_if_missing(conn, "prompts", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
|
|
||||||
Self::add_column_if_missing(conn, "prompts", "created_at", "INTEGER")?;
|
|
||||||
Self::add_column_if_missing(conn, "prompts", "updated_at", "INTEGER")?;
|
|
||||||
|
|
||||||
// skills 表
|
|
||||||
Self::add_column_if_missing(conn, "skills", "installed_at", "INTEGER NOT NULL DEFAULT 0")?;
|
|
||||||
|
|
||||||
// skill_repos 表
|
|
||||||
Self::add_column_if_missing(
|
|
||||||
conn,
|
|
||||||
"skill_repos",
|
|
||||||
"branch",
|
|
||||||
"TEXT NOT NULL DEFAULT 'main'",
|
|
||||||
)?;
|
|
||||||
Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
|
|
||||||
// 注意: skills_path 字段已被移除,因为现在支持全仓库递归扫描
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// v1 -> v2 迁移:添加使用统计表和完整字段,重构 skills 表
|
|
||||||
fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {
|
|
||||||
// providers 表字段
|
|
||||||
Self::add_column_if_missing(
|
|
||||||
conn,
|
|
||||||
"providers",
|
|
||||||
"cost_multiplier",
|
|
||||||
"TEXT NOT NULL DEFAULT '1.0'",
|
|
||||||
)?;
|
|
||||||
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
|
|
||||||
Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
|
|
||||||
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
|
|
||||||
|
|
||||||
// proxy_request_logs 表(包含所有字段)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
|
||||||
request_id TEXT PRIMARY KEY,
|
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
model TEXT NOT NULL,
|
|
||||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
input_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
output_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
latency_ms INTEGER NOT NULL,
|
|
||||||
first_token_ms INTEGER,
|
|
||||||
duration_ms INTEGER,
|
|
||||||
status_code INTEGER NOT NULL,
|
|
||||||
error_message TEXT,
|
|
||||||
session_id TEXT,
|
|
||||||
provider_type TEXT,
|
|
||||||
is_streaming INTEGER NOT NULL DEFAULT 0,
|
|
||||||
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
|
|
||||||
created_at INTEGER NOT NULL
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 为已存在的表添加新字段
|
|
||||||
Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?;
|
|
||||||
Self::add_column_if_missing(
|
|
||||||
conn,
|
|
||||||
"proxy_request_logs",
|
|
||||||
"is_streaming",
|
|
||||||
"INTEGER NOT NULL DEFAULT 0",
|
|
||||||
)?;
|
|
||||||
Self::add_column_if_missing(
|
|
||||||
conn,
|
|
||||||
"proxy_request_logs",
|
|
||||||
"cost_multiplier",
|
|
||||||
"TEXT NOT NULL DEFAULT '1.0'",
|
|
||||||
)?;
|
|
||||||
Self::add_column_if_missing(conn, "proxy_request_logs", "first_token_ms", "INTEGER")?;
|
|
||||||
Self::add_column_if_missing(conn, "proxy_request_logs", "duration_ms", "INTEGER")?;
|
|
||||||
|
|
||||||
// model_pricing 表
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS model_pricing (
|
|
||||||
model_id TEXT PRIMARY KEY,
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
input_cost_per_million TEXT NOT NULL,
|
|
||||||
output_cost_per_million TEXT NOT NULL,
|
|
||||||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
|
||||||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 清空并重新插入模型定价
|
|
||||||
conn.execute("DELETE FROM model_pricing", [])
|
|
||||||
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
|
|
||||||
Self::seed_model_pricing(conn)?;
|
|
||||||
|
|
||||||
// 重构 skills 表(添加 app_type 字段)
|
|
||||||
Self::migrate_skills_table(conn)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键
|
|
||||||
fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> {
|
|
||||||
// 检查是否已经是新表结构
|
|
||||||
if Self::has_column(conn, "skills", "app_type")? {
|
|
||||||
log::info!("skills 表已经包含 app_type 字段,跳过迁移");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("开始迁移 skills 表...");
|
|
||||||
|
|
||||||
// 1. 重命名旧表
|
|
||||||
conn.execute("ALTER TABLE skills RENAME TO skills_old", [])
|
|
||||||
.map_err(|e| AppError::Database(format!("重命名旧 skills 表失败: {e}")))?;
|
|
||||||
|
|
||||||
// 2. 创建新表
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE skills (
|
|
||||||
directory TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
installed BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (directory, app_type)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
|
|
||||||
|
|
||||||
// 3. 迁移数据:解析 key 格式(如 "claude:my-skill" 或 "codex:foo")
|
|
||||||
// 旧数据如果没有前缀,默认为 claude
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare("SELECT key, installed, installed_at FROM skills_old")
|
|
||||||
.map_err(|e| AppError::Database(format!("查询旧 skills 数据失败: {e}")))?;
|
|
||||||
|
|
||||||
let old_skills: Vec<(String, bool, i64)> = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
Ok((
|
|
||||||
row.get::<_, String>(0)?,
|
|
||||||
row.get::<_, bool>(1)?,
|
|
||||||
row.get::<_, i64>(2)?,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(format!("读取旧 skills 数据失败: {e}")))?
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.map_err(|e| AppError::Database(format!("解析旧 skills 数据失败: {e}")))?;
|
|
||||||
|
|
||||||
let count = old_skills.len();
|
|
||||||
|
|
||||||
for (key, installed, installed_at) in old_skills {
|
|
||||||
// 解析 key: "app:directory" 或 "directory"(默认 claude)
|
|
||||||
let (app_type, directory) = if let Some(idx) = key.find(':') {
|
|
||||||
let (app, dir) = key.split_at(idx);
|
|
||||||
(app.to_string(), dir[1..].to_string()) // 跳过冒号
|
|
||||||
} else {
|
|
||||||
("claude".to_string(), key.clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
rusqlite::params![directory, app_type, installed, installed_at],
|
|
||||||
)
|
|
||||||
.map_err(|e| {
|
|
||||||
AppError::Database(format!("迁移 skill {key} 到新表失败: {e}"))
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 删除旧表
|
|
||||||
conn.execute("DROP TABLE skills_old", [])
|
|
||||||
.map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
|
|
||||||
|
|
||||||
log::info!("skills 表迁移完成,共迁移 {count} 条记录");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 插入默认模型定价数据
|
|
||||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
|
||||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
|
||||||
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
|
||||||
let pricing_data = [
|
|
||||||
// Claude 4.5 系列
|
|
||||||
(
|
|
||||||
"claude-opus-4-5",
|
|
||||||
"Claude Opus 4.5",
|
|
||||||
"5",
|
|
||||||
"25",
|
|
||||||
"0.50",
|
|
||||||
"6.25",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"claude-sonnet-4-5",
|
|
||||||
"Claude Sonnet 4.5",
|
|
||||||
"3",
|
|
||||||
"15",
|
|
||||||
"0.30",
|
|
||||||
"3.75",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"claude-haiku-4-5",
|
|
||||||
"Claude Haiku 4.5",
|
|
||||||
"1",
|
|
||||||
"5",
|
|
||||||
"0.10",
|
|
||||||
"1.25",
|
|
||||||
),
|
|
||||||
// Claude 4.1 系列
|
|
||||||
(
|
|
||||||
"claude-opus-4-1",
|
|
||||||
"Claude Opus 4.1",
|
|
||||||
"15",
|
|
||||||
"75",
|
|
||||||
"1.50",
|
|
||||||
"18.75",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"claude-sonnet-4-1",
|
|
||||||
"Claude Sonnet 4.1",
|
|
||||||
"3",
|
|
||||||
"15",
|
|
||||||
"0.30",
|
|
||||||
"3.75",
|
|
||||||
),
|
|
||||||
// Claude 3.7 系列
|
|
||||||
(
|
|
||||||
"claude-sonnet-3-7",
|
|
||||||
"Claude Sonnet 3.7",
|
|
||||||
"3",
|
|
||||||
"15",
|
|
||||||
"0.30",
|
|
||||||
"3.75",
|
|
||||||
),
|
|
||||||
// Claude 3.5 系列
|
|
||||||
(
|
|
||||||
"claude-sonnet-3-5",
|
|
||||||
"Claude Sonnet 3.5",
|
|
||||||
"3",
|
|
||||||
"15",
|
|
||||||
"0.30",
|
|
||||||
"3.75",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"claude-haiku-3-5",
|
|
||||||
"Claude Haiku 3.5",
|
|
||||||
"0.80",
|
|
||||||
"4",
|
|
||||||
"0.08",
|
|
||||||
"1",
|
|
||||||
),
|
|
||||||
// GPT-5 系列(model_id 使用短横线格式)
|
|
||||||
("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"),
|
|
||||||
("gpt-5-1", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
|
||||||
("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
|
|
||||||
("gpt-5-1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
|
|
||||||
// Gemini 3 系列
|
|
||||||
(
|
|
||||||
"gemini-3-pro-preview",
|
|
||||||
"Gemini 3 Pro Preview",
|
|
||||||
"2",
|
|
||||||
"12",
|
|
||||||
"0",
|
|
||||||
"0",
|
|
||||||
),
|
|
||||||
// Gemini 2.5 系列(model_id 使用短横线格式)
|
|
||||||
(
|
|
||||||
"gemini-2-5-pro",
|
|
||||||
"Gemini 2.5 Pro",
|
|
||||||
"1.25",
|
|
||||||
"10",
|
|
||||||
"0.125",
|
|
||||||
"0",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"gemini-2-5-flash",
|
|
||||||
"Gemini 2.5 Flash",
|
|
||||||
"0.3",
|
|
||||||
"2.5",
|
|
||||||
"0.03",
|
|
||||||
"0",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO model_pricing (
|
|
||||||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
|
||||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params![
|
|
||||||
model_id,
|
|
||||||
display_name,
|
|
||||||
input,
|
|
||||||
output,
|
|
||||||
cache_read,
|
|
||||||
cache_creation
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("已插入 {} 条默认模型定价数据", pricing_data.len());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 确保模型定价表具备默认数据
|
|
||||||
pub fn ensure_model_pricing_seeded(&self) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
Self::ensure_model_pricing_seeded_on_conn(&conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
|
|
||||||
.map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?;
|
|
||||||
|
|
||||||
if count == 0 {
|
|
||||||
Self::seed_model_pricing(conn)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 辅助方法 ---
|
|
||||||
|
|
||||||
pub(crate) fn get_user_version(conn: &Connection) -> Result<i32, AppError> {
|
|
||||||
conn.query_row("PRAGMA user_version;", [], |row| row.get(0))
|
|
||||||
.map_err(|e| AppError::Database(format!("读取 user_version 失败: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> {
|
|
||||||
if version < 0 {
|
|
||||||
return Err(AppError::Database("user_version 不能为负数".to_string()));
|
|
||||||
}
|
|
||||||
let sql = format!("PRAGMA user_version = {version};");
|
|
||||||
conn.execute(&sql, [])
|
|
||||||
.map_err(|e| AppError::Database(format!("写入 user_version 失败: {e}")))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_identifier(s: &str, kind: &str) -> Result<(), AppError> {
|
|
||||||
if s.is_empty() {
|
|
||||||
return Err(AppError::Database(format!("{kind} 不能为空")));
|
|
||||||
}
|
|
||||||
if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
|
||||||
return Err(AppError::Database(format!(
|
|
||||||
"非法{kind}: {s},仅允许字母、数字和下划线"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn table_exists(conn: &Connection, table: &str) -> Result<bool, AppError> {
|
|
||||||
Self::validate_identifier(table, "表名")?;
|
|
||||||
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
|
||||||
.map_err(|e| AppError::Database(format!("读取表名失败: {e}")))?;
|
|
||||||
let mut rows = stmt
|
|
||||||
.query([])
|
|
||||||
.map_err(|e| AppError::Database(format!("查询表名失败: {e}")))?;
|
|
||||||
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
|
||||||
let name: String = row
|
|
||||||
.get(0)
|
|
||||||
.map_err(|e| AppError::Database(format!("解析表名失败: {e}")))?;
|
|
||||||
if name.eq_ignore_ascii_case(table) {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn has_column(
|
|
||||||
conn: &Connection,
|
|
||||||
table: &str,
|
|
||||||
column: &str,
|
|
||||||
) -> Result<bool, AppError> {
|
|
||||||
Self::validate_identifier(table, "表名")?;
|
|
||||||
Self::validate_identifier(column, "列名")?;
|
|
||||||
|
|
||||||
let sql = format!("PRAGMA table_info(\"{table}\");");
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&sql)
|
|
||||||
.map_err(|e| AppError::Database(format!("读取表结构失败: {e}")))?;
|
|
||||||
let mut rows = stmt
|
|
||||||
.query([])
|
|
||||||
.map_err(|e| AppError::Database(format!("查询表结构失败: {e}")))?;
|
|
||||||
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
|
||||||
let name: String = row
|
|
||||||
.get(1)
|
|
||||||
.map_err(|e| AppError::Database(format!("读取列名失败: {e}")))?;
|
|
||||||
if name.eq_ignore_ascii_case(column) {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_column_if_missing(
|
|
||||||
conn: &Connection,
|
|
||||||
table: &str,
|
|
||||||
column: &str,
|
|
||||||
definition: &str,
|
|
||||||
) -> Result<bool, AppError> {
|
|
||||||
Self::validate_identifier(table, "表名")?;
|
|
||||||
Self::validate_identifier(column, "列名")?;
|
|
||||||
|
|
||||||
if !Self::table_exists(conn, table)? {
|
|
||||||
return Err(AppError::Database(format!(
|
|
||||||
"表 {table} 不存在,无法添加列 {column}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if Self::has_column(conn, table, column)? {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
let sql = format!("ALTER TABLE \"{table}\" ADD COLUMN \"{column}\" {definition};");
|
|
||||||
conn.execute(&sql, [])
|
|
||||||
.map_err(|e| AppError::Database(format!("为表 {table} 添加列 {column} 失败: {e}")))?;
|
|
||||||
log::info!("已为表 {table} 添加缺失列 {column}");
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
//! 数据库模块测试
|
|
||||||
//!
|
|
||||||
//! 包含 Schema 迁移和基本功能的测试。
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::app_config::MultiAppConfig;
|
|
||||||
use crate::provider::{Provider, ProviderManager};
|
|
||||||
use indexmap::IndexMap;
|
|
||||||
use rusqlite::Connection;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
const LEGACY_SCHEMA_SQL: &str = r#"
|
|
||||||
CREATE TABLE providers (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
settings_config TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (id, app_type)
|
|
||||||
);
|
|
||||||
CREATE TABLE provider_endpoints (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
url TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE mcp_servers (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
server_config TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE prompts (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (id, app_type)
|
|
||||||
);
|
|
||||||
CREATE TABLE skills (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
installed BOOLEAN NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE TABLE skill_repos (
|
|
||||||
owner TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (owner, name)
|
|
||||||
);
|
|
||||||
CREATE TABLE settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT
|
|
||||||
);
|
|
||||||
"#;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct ColumnInfo {
|
|
||||||
name: String,
|
|
||||||
r#type: String,
|
|
||||||
notnull: i64,
|
|
||||||
default: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo {
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&format!("PRAGMA table_info(\"{table}\");"))
|
|
||||||
.expect("prepare pragma");
|
|
||||||
let mut rows = stmt.query([]).expect("query pragma");
|
|
||||||
while let Some(row) = rows.next().expect("read row") {
|
|
||||||
let name: String = row.get(1).expect("name");
|
|
||||||
if name.eq_ignore_ascii_case(column) {
|
|
||||||
return ColumnInfo {
|
|
||||||
name,
|
|
||||||
r#type: row.get::<_, String>(2).expect("type"),
|
|
||||||
notnull: row.get::<_, i64>(3).expect("notnull"),
|
|
||||||
default: row.get::<_, Option<String>>(4).ok().flatten(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
panic!("column {table}.{column} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_default(default: &Option<String>) -> Option<String> {
|
|
||||||
default
|
|
||||||
.as_ref()
|
|
||||||
.map(|s| s.trim_matches('\'').trim_matches('"').to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_sets_user_version_when_missing() {
|
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
|
||||||
|
|
||||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
|
||||||
assert_eq!(
|
|
||||||
Database::get_user_version(&conn).expect("read version before"),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migration");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
Database::get_user_version(&conn).expect("read version after"),
|
|
||||||
SCHEMA_VERSION
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_rejects_future_version() {
|
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
|
||||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
|
||||||
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
|
|
||||||
|
|
||||||
let err =
|
|
||||||
Database::apply_schema_migrations_on_conn(&conn).expect_err("should reject higher version");
|
|
||||||
assert!(
|
|
||||||
err.to_string().contains("数据库版本过新"),
|
|
||||||
"unexpected error: {err}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_adds_missing_columns_for_providers() {
|
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
|
||||||
|
|
||||||
// 创建旧版 providers 表,缺少新增列
|
|
||||||
conn.execute_batch(LEGACY_SCHEMA_SQL)
|
|
||||||
.expect("seed old schema");
|
|
||||||
|
|
||||||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
|
||||||
|
|
||||||
// 验证关键新增列已补齐
|
|
||||||
for (table, column) in [
|
|
||||||
("providers", "meta"),
|
|
||||||
("providers", "is_current"),
|
|
||||||
("provider_endpoints", "added_at"),
|
|
||||||
("mcp_servers", "enabled_gemini"),
|
|
||||||
("prompts", "updated_at"),
|
|
||||||
("skills", "installed_at"),
|
|
||||||
("skill_repos", "enabled"),
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
Database::has_column(&conn, table, column).expect("check column"),
|
|
||||||
"{table}.{column} should exist after migration"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证 meta 列约束保持一致
|
|
||||||
let meta = get_column_info(&conn, "providers", "meta");
|
|
||||||
assert_eq!(meta.notnull, 1, "meta should be NOT NULL");
|
|
||||||
assert_eq!(
|
|
||||||
normalize_default(&meta.default).as_deref(),
|
|
||||||
Some("{}"),
|
|
||||||
"meta default should be '{{}}'"
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
Database::get_user_version(&conn).expect("version after migration"),
|
|
||||||
SCHEMA_VERSION
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_aligns_column_defaults_and_types() {
|
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
|
||||||
conn.execute_batch(LEGACY_SCHEMA_SQL)
|
|
||||||
.expect("seed old schema");
|
|
||||||
|
|
||||||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
|
||||||
|
|
||||||
let is_current = get_column_info(&conn, "providers", "is_current");
|
|
||||||
assert_eq!(is_current.r#type, "BOOLEAN");
|
|
||||||
assert_eq!(is_current.notnull, 1);
|
|
||||||
assert_eq!(normalize_default(&is_current.default).as_deref(), Some("0"));
|
|
||||||
|
|
||||||
let tags = get_column_info(&conn, "mcp_servers", "tags");
|
|
||||||
assert_eq!(tags.r#type, "TEXT");
|
|
||||||
assert_eq!(tags.notnull, 1);
|
|
||||||
assert_eq!(normalize_default(&tags.default).as_deref(), Some("[]"));
|
|
||||||
|
|
||||||
let enabled = get_column_info(&conn, "prompts", "enabled");
|
|
||||||
assert_eq!(enabled.r#type, "BOOLEAN");
|
|
||||||
assert_eq!(enabled.notnull, 1);
|
|
||||||
assert_eq!(normalize_default(&enabled.default).as_deref(), Some("1"));
|
|
||||||
|
|
||||||
let installed_at = get_column_info(&conn, "skills", "installed_at");
|
|
||||||
assert_eq!(installed_at.r#type, "INTEGER");
|
|
||||||
assert_eq!(installed_at.notnull, 1);
|
|
||||||
assert_eq!(
|
|
||||||
normalize_default(&installed_at.default).as_deref(),
|
|
||||||
Some("0")
|
|
||||||
);
|
|
||||||
|
|
||||||
let branch = get_column_info(&conn, "skill_repos", "branch");
|
|
||||||
assert_eq!(branch.r#type, "TEXT");
|
|
||||||
assert_eq!(normalize_default(&branch.default).as_deref(), Some("main"));
|
|
||||||
|
|
||||||
let skill_repo_enabled = get_column_info(&conn, "skill_repos", "enabled");
|
|
||||||
assert_eq!(skill_repo_enabled.r#type, "BOOLEAN");
|
|
||||||
assert_eq!(skill_repo_enabled.notnull, 1);
|
|
||||||
assert_eq!(
|
|
||||||
normalize_default(&skill_repo_enabled.default).as_deref(),
|
|
||||||
Some("1")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dry_run_does_not_write_to_disk() {
|
|
||||||
// Create minimal valid config for migration
|
|
||||||
let mut apps = HashMap::new();
|
|
||||||
apps.insert("claude".to_string(), ProviderManager::default());
|
|
||||||
|
|
||||||
let config = MultiAppConfig {
|
|
||||||
version: 2,
|
|
||||||
apps,
|
|
||||||
mcp: Default::default(),
|
|
||||||
prompts: Default::default(),
|
|
||||||
skills: Default::default(),
|
|
||||||
common_config_snippets: Default::default(),
|
|
||||||
claude_common_config_snippet: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Dry-run should succeed without any file I/O errors
|
|
||||||
let result = Database::migrate_from_json_dry_run(&config);
|
|
||||||
assert!(
|
|
||||||
result.is_ok(),
|
|
||||||
"Dry-run should succeed with valid config: {result:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dry_run_validates_schema_compatibility() {
|
|
||||||
// Create config with actual provider data
|
|
||||||
let mut providers = IndexMap::new();
|
|
||||||
providers.insert(
|
|
||||||
"test-provider".to_string(),
|
|
||||||
Provider {
|
|
||||||
id: "test-provider".to_string(),
|
|
||||||
name: "Test Provider".to_string(),
|
|
||||||
settings_config: json!({
|
|
||||||
"anthropicApiKey": "sk-test-123",
|
|
||||||
}),
|
|
||||||
website_url: None,
|
|
||||||
category: None,
|
|
||||||
created_at: Some(1234567890),
|
|
||||||
sort_index: None,
|
|
||||||
notes: None,
|
|
||||||
meta: None,
|
|
||||||
icon: None,
|
|
||||||
icon_color: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut manager = ProviderManager::default();
|
|
||||||
manager.providers = providers;
|
|
||||||
manager.current = "test-provider".to_string();
|
|
||||||
|
|
||||||
let mut apps = HashMap::new();
|
|
||||||
apps.insert("claude".to_string(), manager);
|
|
||||||
|
|
||||||
let config = MultiAppConfig {
|
|
||||||
version: 2,
|
|
||||||
apps,
|
|
||||||
mcp: Default::default(),
|
|
||||||
prompts: Default::default(),
|
|
||||||
skills: Default::default(),
|
|
||||||
common_config_snippets: Default::default(),
|
|
||||||
claude_common_config_snippet: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Dry-run should validate the full migration path
|
|
||||||
let result = Database::migrate_from_json_dry_run(&config);
|
|
||||||
assert!(
|
|
||||||
result.is_ok(),
|
|
||||||
"Dry-run should succeed with provider data: {result:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn model_pricing_is_seeded_on_init() {
|
|
||||||
let db = Database::memory().expect("create memory db");
|
|
||||||
|
|
||||||
let conn = db.conn.lock().expect("lock conn");
|
|
||||||
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
|
|
||||||
.expect("count pricing");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
count > 0,
|
|
||||||
"模型定价数据应该在初始化时自动填充,实际数量: {}",
|
|
||||||
count
|
|
||||||
);
|
|
||||||
|
|
||||||
// 验证包含 Claude 模型
|
|
||||||
let claude_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'claude-%'",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.expect("check claude");
|
|
||||||
assert!(
|
|
||||||
claude_count > 0,
|
|
||||||
"应该包含 Claude 模型定价,实际数量: {}",
|
|
||||||
claude_count
|
|
||||||
);
|
|
||||||
|
|
||||||
// 验证包含 GPT 模型
|
|
||||||
let gpt_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gpt-%'",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.expect("check gpt");
|
|
||||||
assert!(
|
|
||||||
gpt_count > 0,
|
|
||||||
"应该包含 GPT 模型定价,实际数量: {}",
|
|
||||||
gpt_count
|
|
||||||
);
|
|
||||||
|
|
||||||
// 验证包含 Gemini 模型
|
|
||||||
let gemini_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gemini-%'",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.expect("check gemini");
|
|
||||||
assert!(
|
|
||||||
gemini_count > 0,
|
|
||||||
"应该包含 Gemini 模型定价,实际数量: {}",
|
|
||||||
gemini_count
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
//! MCP server import from deep link
|
|
||||||
//!
|
|
||||||
//! Handles batch import of MCP server configurations via ccswitch:// URLs.
|
|
||||||
|
|
||||||
use super::utils::decode_base64_param;
|
|
||||||
use super::DeepLinkImportRequest;
|
|
||||||
use crate::app_config::{McpApps, McpServer};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::services::McpService;
|
|
||||||
use crate::store::AppState;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
/// MCP import result
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct McpImportResult {
|
|
||||||
/// Number of successfully imported MCP servers
|
|
||||||
pub imported_count: usize,
|
|
||||||
/// IDs of successfully imported MCP servers
|
|
||||||
pub imported_ids: Vec<String>,
|
|
||||||
/// Failed imports with error messages
|
|
||||||
pub failed: Vec<McpImportError>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// MCP import error
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct McpImportError {
|
|
||||||
/// MCP server ID
|
|
||||||
pub id: String,
|
|
||||||
/// Error message
|
|
||||||
pub error: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Import MCP servers from deep link request
|
|
||||||
///
|
|
||||||
/// This function handles batch import of MCP servers from standard MCP JSON format.
|
|
||||||
/// If a server already exists, only the apps flags are merged (existing config preserved).
|
|
||||||
pub fn import_mcp_from_deeplink(
|
|
||||||
state: &AppState,
|
|
||||||
request: DeepLinkImportRequest,
|
|
||||||
) -> Result<McpImportResult, AppError> {
|
|
||||||
// Verify this is an MCP request
|
|
||||||
if request.resource != "mcp" {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"Expected mcp resource, got '{}'",
|
|
||||||
request.resource
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract and validate apps parameter
|
|
||||||
let apps_str = request
|
|
||||||
.apps
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'apps' parameter for MCP".to_string()))?;
|
|
||||||
|
|
||||||
// Parse apps into McpApps struct
|
|
||||||
let target_apps = parse_mcp_apps(apps_str)?;
|
|
||||||
|
|
||||||
// Extract config
|
|
||||||
let config_b64 = request
|
|
||||||
.config
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'config' parameter for MCP".to_string()))?;
|
|
||||||
|
|
||||||
// Decode Base64 config
|
|
||||||
let decoded = decode_base64_param("config", config_b64)?;
|
|
||||||
|
|
||||||
let config_str = String::from_utf8(decoded)
|
|
||||||
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))?;
|
|
||||||
|
|
||||||
// Parse JSON
|
|
||||||
let config_json: Value = serde_json::from_str(&config_str)
|
|
||||||
.map_err(|e| AppError::InvalidInput(format!("Invalid JSON in MCP config: {e}")))?;
|
|
||||||
|
|
||||||
// Extract mcpServers object
|
|
||||||
let mcp_servers = config_json
|
|
||||||
.get("mcpServers")
|
|
||||||
.and_then(|v| v.as_object())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
AppError::InvalidInput("MCP config must contain 'mcpServers' object".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if mcp_servers.is_empty() {
|
|
||||||
return Err(AppError::InvalidInput(
|
|
||||||
"No MCP servers found in config".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get existing servers to check for duplicates
|
|
||||||
let existing_servers = state.db.get_all_mcp_servers()?;
|
|
||||||
|
|
||||||
// Import each MCP server
|
|
||||||
let mut imported_ids = Vec::new();
|
|
||||||
let mut failed = Vec::new();
|
|
||||||
|
|
||||||
for (id, server_spec) in mcp_servers.iter() {
|
|
||||||
// Check if server already exists
|
|
||||||
let server = if let Some(existing) = existing_servers.get(id) {
|
|
||||||
// Server exists - merge apps only, keep other fields unchanged
|
|
||||||
log::info!("MCP server '{id}' already exists, merging apps only");
|
|
||||||
|
|
||||||
let mut merged_apps = existing.apps.clone();
|
|
||||||
// Merge new apps into existing apps
|
|
||||||
if target_apps.claude {
|
|
||||||
merged_apps.claude = true;
|
|
||||||
}
|
|
||||||
if target_apps.codex {
|
|
||||||
merged_apps.codex = true;
|
|
||||||
}
|
|
||||||
if target_apps.gemini {
|
|
||||||
merged_apps.gemini = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
McpServer {
|
|
||||||
id: existing.id.clone(),
|
|
||||||
name: existing.name.clone(),
|
|
||||||
server: existing.server.clone(), // Keep existing server config
|
|
||||||
apps: merged_apps, // Merged apps
|
|
||||||
description: existing.description.clone(),
|
|
||||||
homepage: existing.homepage.clone(),
|
|
||||||
docs: existing.docs.clone(),
|
|
||||||
tags: existing.tags.clone(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// New server - create with provided config
|
|
||||||
log::info!("Creating new MCP server: {id}");
|
|
||||||
McpServer {
|
|
||||||
id: id.clone(),
|
|
||||||
name: id.clone(),
|
|
||||||
server: server_spec.clone(),
|
|
||||||
apps: target_apps.clone(),
|
|
||||||
description: None,
|
|
||||||
homepage: None,
|
|
||||||
docs: None,
|
|
||||||
tags: vec!["imported".to_string()],
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match McpService::upsert_server(state, server) {
|
|
||||||
Ok(_) => {
|
|
||||||
imported_ids.push(id.clone());
|
|
||||||
log::info!("Successfully imported/updated MCP server: {id}");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
failed.push(McpImportError {
|
|
||||||
id: id.clone(),
|
|
||||||
error: format!("{e}"),
|
|
||||||
});
|
|
||||||
log::warn!("Failed to import MCP server '{id}': {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(McpImportResult {
|
|
||||||
imported_count: imported_ids.len(),
|
|
||||||
imported_ids,
|
|
||||||
failed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse apps string into McpApps struct
|
|
||||||
pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
|
||||||
let mut apps = McpApps {
|
|
||||||
claude: false,
|
|
||||||
codex: false,
|
|
||||||
gemini: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
for app in apps_str.split(',') {
|
|
||||||
match app.trim() {
|
|
||||||
"claude" => apps.claude = true,
|
|
||||||
"codex" => apps.codex = true,
|
|
||||||
"gemini" => apps.gemini = true,
|
|
||||||
other => {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"Invalid app in 'apps': {other}"
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if apps.is_empty() {
|
|
||||||
return Err(AppError::InvalidInput(
|
|
||||||
"At least one app must be specified in 'apps'".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(apps)
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
//! Deep link import functionality for CC Switch
|
|
||||||
//!
|
|
||||||
//! This module implements the ccswitch:// protocol for importing configurations
|
|
||||||
//! via deep links. Supports importing:
|
|
||||||
//! - Provider configurations (Claude/Codex/Gemini)
|
|
||||||
//! - MCP server configurations
|
|
||||||
//! - Prompts
|
|
||||||
//! - Skills
|
|
||||||
//!
|
|
||||||
//! See docs/ccswitch-deeplink-design.md for detailed design.
|
|
||||||
|
|
||||||
mod mcp;
|
|
||||||
mod parser;
|
|
||||||
mod prompt;
|
|
||||||
mod provider;
|
|
||||||
mod skill;
|
|
||||||
mod utils;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
// Re-export public API
|
|
||||||
pub use mcp::import_mcp_from_deeplink;
|
|
||||||
pub use parser::parse_deeplink_url;
|
|
||||||
pub use prompt::import_prompt_from_deeplink;
|
|
||||||
pub use provider::{import_provider_from_deeplink, parse_and_merge_config};
|
|
||||||
pub use skill::import_skill_from_deeplink;
|
|
||||||
|
|
||||||
/// Deep link import request model
|
|
||||||
///
|
|
||||||
/// Represents a parsed ccswitch:// URL ready for processing.
|
|
||||||
/// This struct contains all possible fields for all resource types.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DeepLinkImportRequest {
|
|
||||||
/// Protocol version (e.g., "v1")
|
|
||||||
pub version: String,
|
|
||||||
/// Resource type to import: "provider" | "prompt" | "mcp" | "skill"
|
|
||||||
pub resource: String,
|
|
||||||
|
|
||||||
// ============ Common fields ============
|
|
||||||
/// Target application (claude/codex/gemini) - for provider, prompt, skill
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub app: Option<String>,
|
|
||||||
/// Resource name
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub name: Option<String>,
|
|
||||||
/// Whether to enable after import (default: false)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub enabled: Option<bool>,
|
|
||||||
|
|
||||||
// ============ Provider-specific fields ============
|
|
||||||
/// Provider homepage URL
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub homepage: Option<String>,
|
|
||||||
/// API endpoint/base URL
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub endpoint: Option<String>,
|
|
||||||
/// API key
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub api_key: Option<String>,
|
|
||||||
/// Optional provider icon name (maps to built-in SVG)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub icon: Option<String>,
|
|
||||||
/// Optional model name
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub model: Option<String>,
|
|
||||||
/// Optional notes/description
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub notes: Option<String>,
|
|
||||||
/// Optional Haiku model (Claude only, v3.7.1+)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub haiku_model: Option<String>,
|
|
||||||
/// Optional Sonnet model (Claude only, v3.7.1+)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub sonnet_model: Option<String>,
|
|
||||||
/// Optional Opus model (Claude only, v3.7.1+)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub opus_model: Option<String>,
|
|
||||||
|
|
||||||
// ============ Prompt-specific fields ============
|
|
||||||
/// Base64 encoded Markdown content
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub content: Option<String>,
|
|
||||||
/// Prompt description
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub description: Option<String>,
|
|
||||||
|
|
||||||
// ============ MCP-specific fields ============
|
|
||||||
/// Target applications for MCP (comma-separated: "claude,codex,gemini")
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub apps: Option<String>,
|
|
||||||
|
|
||||||
// ============ Skill-specific fields ============
|
|
||||||
/// GitHub repository (format: "owner/name")
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub repo: Option<String>,
|
|
||||||
/// Skill directory name
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub directory: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub branch: Option<String>,
|
|
||||||
|
|
||||||
// ============ Config file fields (v3.8+) ============
|
|
||||||
/// Base64 encoded config content
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub config: Option<String>,
|
|
||||||
/// Config format (json/toml)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub config_format: Option<String>,
|
|
||||||
/// Remote config URL
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub config_url: Option<String>,
|
|
||||||
|
|
||||||
// ============ Usage script fields (v3.9+) ============
|
|
||||||
/// Whether to enable usage query (default: true if usage_script is provided)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage_enabled: Option<bool>,
|
|
||||||
/// Base64 encoded usage query script code
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage_script: Option<String>,
|
|
||||||
/// Usage query API key (if different from provider API key)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage_api_key: Option<String>,
|
|
||||||
/// Usage query base URL (if different from provider endpoint)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage_base_url: Option<String>,
|
|
||||||
/// Usage query access token (for NewAPI template)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage_access_token: Option<String>,
|
|
||||||
/// Usage query user ID (for NewAPI template)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage_user_id: Option<String>,
|
|
||||||
/// Auto query interval in minutes (0 to disable)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage_auto_interval: Option<u64>,
|
|
||||||
}
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
//! Deep link URL parser
|
|
||||||
//!
|
|
||||||
//! Parses ccswitch:// URLs into DeepLinkImportRequest structures.
|
|
||||||
|
|
||||||
use super::utils::validate_url;
|
|
||||||
use super::DeepLinkImportRequest;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
/// Parse a ccswitch:// URL into a DeepLinkImportRequest
|
|
||||||
///
|
|
||||||
/// Expected format:
|
|
||||||
/// ccswitch://v1/import?resource={type}&...
|
|
||||||
pub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppError> {
|
|
||||||
// Parse URL
|
|
||||||
let url = Url::parse(url_str)
|
|
||||||
.map_err(|e| AppError::InvalidInput(format!("Invalid deep link URL: {e}")))?;
|
|
||||||
|
|
||||||
// Validate scheme
|
|
||||||
let scheme = url.scheme();
|
|
||||||
if scheme != "ccswitch" {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"Invalid scheme: expected 'ccswitch', got '{scheme}'"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract version from host
|
|
||||||
let version = url
|
|
||||||
.host_str()
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing version in URL host".to_string()))?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Validate version
|
|
||||||
if version != "v1" {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"Unsupported protocol version: {version}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract path (should be "/import")
|
|
||||||
let path = url.path();
|
|
||||||
if path != "/import" {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"Invalid path: expected '/import', got '{path}'"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse query parameters
|
|
||||||
let params: HashMap<String, String> = url.query_pairs().into_owned().collect();
|
|
||||||
|
|
||||||
// Extract and validate resource type
|
|
||||||
let resource = params
|
|
||||||
.get("resource")
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'resource' parameter".to_string()))?
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
// Dispatch to appropriate parser based on resource type
|
|
||||||
match resource.as_str() {
|
|
||||||
"provider" => parse_provider_deeplink(¶ms, version, resource),
|
|
||||||
"prompt" => parse_prompt_deeplink(¶ms, version, resource),
|
|
||||||
"mcp" => parse_mcp_deeplink(¶ms, version, resource),
|
|
||||||
"skill" => parse_skill_deeplink(¶ms, version, resource),
|
|
||||||
_ => Err(AppError::InvalidInput(format!(
|
|
||||||
"Unsupported resource type: {resource}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse provider deep link parameters
|
|
||||||
fn parse_provider_deeplink(
|
|
||||||
params: &HashMap<String, String>,
|
|
||||||
version: String,
|
|
||||||
resource: String,
|
|
||||||
) -> Result<DeepLinkImportRequest, AppError> {
|
|
||||||
let app = params
|
|
||||||
.get("app")
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter".to_string()))?
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
// Validate app type
|
|
||||||
if app != "claude" && app != "codex" && app != "gemini" {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = params
|
|
||||||
.get("name")
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter".to_string()))?
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
// Make these optional for config file auto-fill (v3.8+)
|
|
||||||
let homepage = params.get("homepage").cloned();
|
|
||||||
let endpoint = params.get("endpoint").cloned();
|
|
||||||
let api_key = params.get("apiKey").cloned();
|
|
||||||
|
|
||||||
// Validate URLs only if provided
|
|
||||||
if let Some(ref hp) = homepage {
|
|
||||||
if !hp.is_empty() {
|
|
||||||
validate_url(hp, "homepage")?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(ref ep) = endpoint {
|
|
||||||
if !ep.is_empty() {
|
|
||||||
validate_url(ep, "endpoint")?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract optional fields
|
|
||||||
let model = params.get("model").cloned();
|
|
||||||
let notes = params.get("notes").cloned();
|
|
||||||
let haiku_model = params.get("haikuModel").cloned();
|
|
||||||
let sonnet_model = params.get("sonnetModel").cloned();
|
|
||||||
let opus_model = params.get("opusModel").cloned();
|
|
||||||
let icon = params
|
|
||||||
.get("icon")
|
|
||||||
.map(|v| v.trim().to_lowercase())
|
|
||||||
.filter(|v| !v.is_empty());
|
|
||||||
let config = params.get("config").cloned();
|
|
||||||
let config_format = params.get("configFormat").cloned();
|
|
||||||
let config_url = params.get("configUrl").cloned();
|
|
||||||
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
|
|
||||||
|
|
||||||
// Extract usage script fields (v3.9+)
|
|
||||||
let usage_enabled = params
|
|
||||||
.get("usageEnabled")
|
|
||||||
.and_then(|v| v.parse::<bool>().ok());
|
|
||||||
let usage_script = params.get("usageScript").cloned();
|
|
||||||
let usage_api_key = params.get("usageApiKey").cloned();
|
|
||||||
let usage_base_url = params.get("usageBaseUrl").cloned();
|
|
||||||
let usage_access_token = params.get("usageAccessToken").cloned();
|
|
||||||
let usage_user_id = params.get("usageUserId").cloned();
|
|
||||||
let usage_auto_interval = params
|
|
||||||
.get("usageAutoInterval")
|
|
||||||
.and_then(|v| v.parse::<u64>().ok());
|
|
||||||
|
|
||||||
Ok(DeepLinkImportRequest {
|
|
||||||
version,
|
|
||||||
resource,
|
|
||||||
app: Some(app),
|
|
||||||
name: Some(name),
|
|
||||||
enabled,
|
|
||||||
homepage,
|
|
||||||
endpoint,
|
|
||||||
api_key,
|
|
||||||
icon,
|
|
||||||
model,
|
|
||||||
notes,
|
|
||||||
haiku_model,
|
|
||||||
sonnet_model,
|
|
||||||
opus_model,
|
|
||||||
content: None,
|
|
||||||
description: None,
|
|
||||||
apps: None,
|
|
||||||
repo: None,
|
|
||||||
directory: None,
|
|
||||||
branch: None,
|
|
||||||
config,
|
|
||||||
config_format,
|
|
||||||
config_url,
|
|
||||||
usage_enabled,
|
|
||||||
usage_script,
|
|
||||||
usage_api_key,
|
|
||||||
usage_base_url,
|
|
||||||
usage_access_token,
|
|
||||||
usage_user_id,
|
|
||||||
usage_auto_interval,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse prompt deep link parameters
|
|
||||||
fn parse_prompt_deeplink(
|
|
||||||
params: &HashMap<String, String>,
|
|
||||||
version: String,
|
|
||||||
resource: String,
|
|
||||||
) -> Result<DeepLinkImportRequest, AppError> {
|
|
||||||
let app = params
|
|
||||||
.get("app")
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter for prompt".to_string()))?
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
// Validate app type
|
|
||||||
if app != "claude" && app != "codex" && app != "gemini" {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = params
|
|
||||||
.get("name")
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter for prompt".to_string()))?
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
let content = params
|
|
||||||
.get("content")
|
|
||||||
.ok_or_else(|| {
|
|
||||||
AppError::InvalidInput("Missing 'content' parameter for prompt".to_string())
|
|
||||||
})?
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
let description = params.get("description").cloned();
|
|
||||||
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
|
|
||||||
|
|
||||||
Ok(DeepLinkImportRequest {
|
|
||||||
version,
|
|
||||||
resource,
|
|
||||||
app: Some(app),
|
|
||||||
name: Some(name),
|
|
||||||
enabled,
|
|
||||||
content: Some(content),
|
|
||||||
description,
|
|
||||||
icon: None,
|
|
||||||
homepage: None,
|
|
||||||
endpoint: None,
|
|
||||||
api_key: None,
|
|
||||||
model: None,
|
|
||||||
notes: None,
|
|
||||||
haiku_model: None,
|
|
||||||
sonnet_model: None,
|
|
||||||
opus_model: None,
|
|
||||||
apps: None,
|
|
||||||
repo: None,
|
|
||||||
directory: None,
|
|
||||||
branch: None,
|
|
||||||
config: None,
|
|
||||||
config_format: None,
|
|
||||||
config_url: None,
|
|
||||||
usage_enabled: None,
|
|
||||||
usage_script: None,
|
|
||||||
usage_api_key: None,
|
|
||||||
usage_base_url: None,
|
|
||||||
usage_access_token: None,
|
|
||||||
usage_user_id: None,
|
|
||||||
usage_auto_interval: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse MCP deep link parameters
|
|
||||||
fn parse_mcp_deeplink(
|
|
||||||
params: &HashMap<String, String>,
|
|
||||||
version: String,
|
|
||||||
resource: String,
|
|
||||||
) -> Result<DeepLinkImportRequest, AppError> {
|
|
||||||
let apps = params
|
|
||||||
.get("apps")
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'apps' parameter for MCP".to_string()))?
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
// Validate apps format
|
|
||||||
for app in apps.split(',') {
|
|
||||||
let trimmed = app.trim();
|
|
||||||
if trimmed != "claude" && trimmed != "codex" && trimmed != "gemini" {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"Invalid app in 'apps': must be 'claude', 'codex', or 'gemini', got '{trimmed}'"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = params
|
|
||||||
.get("config")
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'config' parameter for MCP".to_string()))?
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
|
|
||||||
|
|
||||||
Ok(DeepLinkImportRequest {
|
|
||||||
version,
|
|
||||||
resource,
|
|
||||||
apps: Some(apps),
|
|
||||||
enabled,
|
|
||||||
config: Some(config),
|
|
||||||
config_format: Some("json".to_string()), // MCP config is always JSON
|
|
||||||
app: None,
|
|
||||||
name: None,
|
|
||||||
icon: None,
|
|
||||||
homepage: None,
|
|
||||||
endpoint: None,
|
|
||||||
api_key: None,
|
|
||||||
model: None,
|
|
||||||
notes: None,
|
|
||||||
haiku_model: None,
|
|
||||||
sonnet_model: None,
|
|
||||||
opus_model: None,
|
|
||||||
content: None,
|
|
||||||
description: None,
|
|
||||||
repo: None,
|
|
||||||
directory: None,
|
|
||||||
branch: None,
|
|
||||||
config_url: None,
|
|
||||||
usage_enabled: None,
|
|
||||||
usage_script: None,
|
|
||||||
usage_api_key: None,
|
|
||||||
usage_base_url: None,
|
|
||||||
usage_access_token: None,
|
|
||||||
usage_user_id: None,
|
|
||||||
usage_auto_interval: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse skill deep link parameters
|
|
||||||
fn parse_skill_deeplink(
|
|
||||||
params: &HashMap<String, String>,
|
|
||||||
version: String,
|
|
||||||
resource: String,
|
|
||||||
) -> Result<DeepLinkImportRequest, AppError> {
|
|
||||||
let repo = params
|
|
||||||
.get("repo")
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'repo' parameter for skill".to_string()))?
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
// Validate repo format (should be "owner/name")
|
|
||||||
if !repo.contains('/') || repo.split('/').count() != 2 {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"Invalid repo format: expected 'owner/name', got '{repo}'"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let directory = params.get("directory").cloned();
|
|
||||||
let branch = params.get("branch").cloned();
|
|
||||||
|
|
||||||
Ok(DeepLinkImportRequest {
|
|
||||||
version,
|
|
||||||
resource,
|
|
||||||
repo: Some(repo),
|
|
||||||
directory,
|
|
||||||
branch,
|
|
||||||
icon: None,
|
|
||||||
app: Some("claude".to_string()), // Skills are Claude-only
|
|
||||||
name: None,
|
|
||||||
enabled: None,
|
|
||||||
homepage: None,
|
|
||||||
endpoint: None,
|
|
||||||
api_key: None,
|
|
||||||
model: None,
|
|
||||||
notes: None,
|
|
||||||
haiku_model: None,
|
|
||||||
sonnet_model: None,
|
|
||||||
opus_model: None,
|
|
||||||
content: None,
|
|
||||||
description: None,
|
|
||||||
apps: None,
|
|
||||||
config: None,
|
|
||||||
config_format: None,
|
|
||||||
config_url: None,
|
|
||||||
usage_enabled: None,
|
|
||||||
usage_script: None,
|
|
||||||
usage_api_key: None,
|
|
||||||
usage_base_url: None,
|
|
||||||
usage_access_token: None,
|
|
||||||
usage_user_id: None,
|
|
||||||
usage_auto_interval: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
//! Prompt import from deep link
|
|
||||||
//!
|
|
||||||
//! Handles importing prompt configurations via ccswitch:// URLs.
|
|
||||||
|
|
||||||
use super::utils::decode_base64_param;
|
|
||||||
use super::DeepLinkImportRequest;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::prompt::Prompt;
|
|
||||||
use crate::services::PromptService;
|
|
||||||
use crate::store::AppState;
|
|
||||||
use crate::AppType;
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
/// Import a prompt from deep link request
|
|
||||||
pub fn import_prompt_from_deeplink(
|
|
||||||
state: &AppState,
|
|
||||||
request: DeepLinkImportRequest,
|
|
||||||
) -> Result<String, AppError> {
|
|
||||||
// Verify this is a prompt request
|
|
||||||
if request.resource != "prompt" {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"Expected prompt resource, got '{}'",
|
|
||||||
request.resource
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract required fields
|
|
||||||
let app_str = request
|
|
||||||
.app
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for prompt".to_string()))?;
|
|
||||||
|
|
||||||
let name = request
|
|
||||||
.name
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for prompt".to_string()))?;
|
|
||||||
|
|
||||||
// Parse app type
|
|
||||||
let app_type = AppType::from_str(app_str)
|
|
||||||
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
|
|
||||||
|
|
||||||
// Decode content
|
|
||||||
let content_b64 = request
|
|
||||||
.content
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'content' field for prompt".to_string()))?;
|
|
||||||
|
|
||||||
let content = decode_base64_param("content", content_b64)?;
|
|
||||||
let content = String::from_utf8(content)
|
|
||||||
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in content: {e}")))?;
|
|
||||||
|
|
||||||
// Generate ID
|
|
||||||
let timestamp = chrono::Utc::now().timestamp_millis();
|
|
||||||
let sanitized_name = name
|
|
||||||
.chars()
|
|
||||||
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
|
||||||
.collect::<String>()
|
|
||||||
.to_lowercase();
|
|
||||||
let id = format!("{sanitized_name}-{timestamp}");
|
|
||||||
|
|
||||||
// Check if we should enable this prompt
|
|
||||||
let should_enable = request.enabled.unwrap_or(false);
|
|
||||||
|
|
||||||
// Create Prompt (initially disabled)
|
|
||||||
let prompt = Prompt {
|
|
||||||
id: id.clone(),
|
|
||||||
name: name.clone(),
|
|
||||||
content,
|
|
||||||
description: request.description,
|
|
||||||
enabled: false, // Always start as disabled, will be enabled later if needed
|
|
||||||
created_at: Some(timestamp),
|
|
||||||
updated_at: Some(timestamp),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save using PromptService
|
|
||||||
PromptService::upsert_prompt(state, app_type.clone(), &id, prompt)?;
|
|
||||||
|
|
||||||
// If enabled flag is set, enable this prompt (which will disable others)
|
|
||||||
if should_enable {
|
|
||||||
PromptService::enable_prompt(state, app_type, &id)?;
|
|
||||||
log::info!("Successfully imported and enabled prompt '{name}' for {app_str}");
|
|
||||||
} else {
|
|
||||||
log::info!("Successfully imported prompt '{name}' for {app_str} (disabled)");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
@@ -1,564 +0,0 @@
|
|||||||
//! Provider import from deep link
|
|
||||||
//!
|
|
||||||
//! Handles importing provider configurations via ccswitch:// URLs.
|
|
||||||
|
|
||||||
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
|
|
||||||
use super::DeepLinkImportRequest;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::provider::{Provider, ProviderMeta, UsageScript};
|
|
||||||
use crate::services::ProviderService;
|
|
||||||
use crate::store::AppState;
|
|
||||||
use crate::AppType;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
/// Import a provider from a deep link request
|
|
||||||
///
|
|
||||||
/// This function:
|
|
||||||
/// 1. Validates the request
|
|
||||||
/// 2. Merges config file if provided (v3.8+)
|
|
||||||
/// 3. Converts it to a Provider structure
|
|
||||||
/// 4. Delegates to ProviderService for actual import
|
|
||||||
/// 5. Optionally sets as current provider if enabled=true
|
|
||||||
pub fn import_provider_from_deeplink(
|
|
||||||
state: &AppState,
|
|
||||||
request: DeepLinkImportRequest,
|
|
||||||
) -> Result<String, AppError> {
|
|
||||||
// Verify this is a provider request
|
|
||||||
if request.resource != "provider" {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"Expected provider resource, got '{}'",
|
|
||||||
request.resource
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Merge config file if provided (v3.8+)
|
|
||||||
let merged_request = parse_and_merge_config(&request)?;
|
|
||||||
|
|
||||||
// Extract required fields (now as Option)
|
|
||||||
let app_str = merged_request
|
|
||||||
.app
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
|
|
||||||
|
|
||||||
let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
|
|
||||||
AppError::InvalidInput("API key is required (either in URL or config file)".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if api_key.is_empty() {
|
|
||||||
return Err(AppError::InvalidInput(
|
|
||||||
"API key cannot be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| {
|
|
||||||
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if endpoint.is_empty() {
|
|
||||||
return Err(AppError::InvalidInput(
|
|
||||||
"Endpoint cannot be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
|
|
||||||
AppError::InvalidInput("Homepage is required (either in URL or config file)".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if homepage.is_empty() {
|
|
||||||
return Err(AppError::InvalidInput(
|
|
||||||
"Homepage cannot be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = merged_request
|
|
||||||
.name
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
|
|
||||||
|
|
||||||
// Parse app type
|
|
||||||
let app_type = AppType::from_str(app_str)
|
|
||||||
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
|
|
||||||
|
|
||||||
// Build provider configuration based on app type
|
|
||||||
let mut provider = build_provider_from_request(&app_type, &merged_request)?;
|
|
||||||
|
|
||||||
// Generate a unique ID for the provider using timestamp + sanitized name
|
|
||||||
let timestamp = chrono::Utc::now().timestamp_millis();
|
|
||||||
let sanitized_name = name
|
|
||||||
.chars()
|
|
||||||
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
|
||||||
.collect::<String>()
|
|
||||||
.to_lowercase();
|
|
||||||
provider.id = format!("{sanitized_name}-{timestamp}");
|
|
||||||
|
|
||||||
let provider_id = provider.id.clone();
|
|
||||||
|
|
||||||
// Use ProviderService to add the provider
|
|
||||||
ProviderService::add(state, app_type.clone(), provider)?;
|
|
||||||
|
|
||||||
// If enabled=true, set as current provider
|
|
||||||
if merged_request.enabled.unwrap_or(false) {
|
|
||||||
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
|
||||||
log::info!("Provider '{provider_id}' set as current for {app_type:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(provider_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a Provider structure from a deep link request
|
|
||||||
pub(crate) fn build_provider_from_request(
|
|
||||||
app_type: &AppType,
|
|
||||||
request: &DeepLinkImportRequest,
|
|
||||||
) -> Result<Provider, AppError> {
|
|
||||||
let settings_config = match app_type {
|
|
||||||
AppType::Claude => build_claude_settings(request),
|
|
||||||
AppType::Codex => build_codex_settings(request),
|
|
||||||
AppType::Gemini => build_gemini_settings(request),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build usage script configuration if provided
|
|
||||||
let meta = build_provider_meta(request)?;
|
|
||||||
|
|
||||||
let provider = Provider {
|
|
||||||
id: String::new(), // Will be generated by caller
|
|
||||||
name: request.name.clone().unwrap_or_default(),
|
|
||||||
settings_config,
|
|
||||||
website_url: request.homepage.clone(),
|
|
||||||
category: None,
|
|
||||||
created_at: None,
|
|
||||||
sort_index: None,
|
|
||||||
notes: request.notes.clone(),
|
|
||||||
meta,
|
|
||||||
icon: request.icon.clone(),
|
|
||||||
icon_color: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build provider meta with usage script configuration
|
|
||||||
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
|
||||||
// Check if any usage script fields are provided
|
|
||||||
if request.usage_script.is_none()
|
|
||||||
&& request.usage_enabled.is_none()
|
|
||||||
&& request.usage_api_key.is_none()
|
|
||||||
&& request.usage_base_url.is_none()
|
|
||||||
&& request.usage_access_token.is_none()
|
|
||||||
&& request.usage_user_id.is_none()
|
|
||||||
&& request.usage_auto_interval.is_none()
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode usage script code if provided
|
|
||||||
let code = if let Some(script_b64) = &request.usage_script {
|
|
||||||
let decoded = decode_base64_param("usage_script", script_b64)?;
|
|
||||||
String::from_utf8(decoded)
|
|
||||||
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))?
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine enabled state: explicit param > has code > false
|
|
||||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
|
||||||
|
|
||||||
// Build UsageScript - use provider's API key and endpoint as defaults
|
|
||||||
let usage_script = UsageScript {
|
|
||||||
enabled,
|
|
||||||
language: "javascript".to_string(),
|
|
||||||
code,
|
|
||||||
timeout: Some(10),
|
|
||||||
api_key: request
|
|
||||||
.usage_api_key
|
|
||||||
.clone()
|
|
||||||
.or_else(|| request.api_key.clone()),
|
|
||||||
base_url: request
|
|
||||||
.usage_base_url
|
|
||||||
.clone()
|
|
||||||
.or_else(|| request.endpoint.clone()),
|
|
||||||
access_token: request.usage_access_token.clone(),
|
|
||||||
user_id: request.usage_user_id.clone(),
|
|
||||||
auto_query_interval: request.usage_auto_interval,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(ProviderMeta {
|
|
||||||
usage_script: Some(usage_script),
|
|
||||||
..Default::default()
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build Claude settings configuration
|
|
||||||
fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|
||||||
let mut env = serde_json::Map::new();
|
|
||||||
env.insert(
|
|
||||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
|
||||||
json!(request.api_key.clone().unwrap_or_default()),
|
|
||||||
);
|
|
||||||
env.insert(
|
|
||||||
"ANTHROPIC_BASE_URL".to_string(),
|
|
||||||
json!(request.endpoint.clone().unwrap_or_default()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add default model if provided
|
|
||||||
if let Some(model) = &request.model {
|
|
||||||
env.insert("ANTHROPIC_MODEL".to_string(), json!(model));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Claude-specific model fields (v3.7.1+)
|
|
||||||
if let Some(haiku_model) = &request.haiku_model {
|
|
||||||
env.insert(
|
|
||||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
|
|
||||||
json!(haiku_model),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(sonnet_model) = &request.sonnet_model {
|
|
||||||
env.insert(
|
|
||||||
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
|
|
||||||
json!(sonnet_model),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(opus_model) = &request.opus_model {
|
|
||||||
env.insert(
|
|
||||||
"ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(),
|
|
||||||
json!(opus_model),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
json!({ "env": env })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build Codex settings configuration
|
|
||||||
fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|
||||||
// Generate a safe provider name identifier
|
|
||||||
let clean_provider_name = {
|
|
||||||
let raw: String = request
|
|
||||||
.name
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "custom".to_string())
|
|
||||||
.chars()
|
|
||||||
.filter(|c| !c.is_control())
|
|
||||||
.collect();
|
|
||||||
let lower = raw.to_lowercase();
|
|
||||||
let mut key: String = lower
|
|
||||||
.chars()
|
|
||||||
.map(|c| match c {
|
|
||||||
'a'..='z' | '0'..='9' | '_' => c,
|
|
||||||
_ => '_',
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Remove leading/trailing underscores
|
|
||||||
while key.starts_with('_') {
|
|
||||||
key.remove(0);
|
|
||||||
}
|
|
||||||
while key.ends_with('_') {
|
|
||||||
key.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
if key.is_empty() {
|
|
||||||
"custom".to_string()
|
|
||||||
} else {
|
|
||||||
key
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Model name: use deeplink model or default
|
|
||||||
let model_name = request
|
|
||||||
.model
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("gpt-5-codex")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Endpoint: normalize trailing slashes
|
|
||||||
let endpoint = request
|
|
||||||
.endpoint
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim()
|
|
||||||
.trim_end_matches('/')
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Build config.toml content
|
|
||||||
let config_toml = format!(
|
|
||||||
r#"model_provider = "{clean_provider_name}"
|
|
||||||
model = "{model_name}"
|
|
||||||
model_reasoning_effort = "high"
|
|
||||||
disable_response_storage = true
|
|
||||||
|
|
||||||
[model_providers.{clean_provider_name}]
|
|
||||||
name = "{clean_provider_name}"
|
|
||||||
base_url = "{endpoint}"
|
|
||||||
wire_api = "responses"
|
|
||||||
requires_openai_auth = true
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
|
|
||||||
json!({
|
|
||||||
"auth": {
|
|
||||||
"OPENAI_API_KEY": request.api_key,
|
|
||||||
},
|
|
||||||
"config": config_toml
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build Gemini settings configuration
|
|
||||||
fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|
||||||
let mut env = serde_json::Map::new();
|
|
||||||
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
|
|
||||||
env.insert(
|
|
||||||
"GOOGLE_GEMINI_BASE_URL".to_string(),
|
|
||||||
json!(request.endpoint),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add model if provided
|
|
||||||
if let Some(model) = &request.model {
|
|
||||||
env.insert("GEMINI_MODEL".to_string(), json!(model));
|
|
||||||
}
|
|
||||||
|
|
||||||
json!({ "env": env })
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Config Merge Logic
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/// Parse and merge configuration from Base64 encoded config or remote URL
|
|
||||||
///
|
|
||||||
/// Priority: URL params > inline config > remote config
|
|
||||||
pub fn parse_and_merge_config(
|
|
||||||
request: &DeepLinkImportRequest,
|
|
||||||
) -> Result<DeepLinkImportRequest, AppError> {
|
|
||||||
// If no config provided, return original request
|
|
||||||
if request.config.is_none() && request.config_url.is_none() {
|
|
||||||
return Ok(request.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Get config content
|
|
||||||
let config_content = if let Some(config_b64) = &request.config {
|
|
||||||
// Decode Base64 inline config
|
|
||||||
let decoded = decode_base64_param("config", config_b64)?;
|
|
||||||
String::from_utf8(decoded)
|
|
||||||
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))?
|
|
||||||
} else if let Some(_config_url) = &request.config_url {
|
|
||||||
// Fetch remote config (TODO: implement remote fetching in next phase)
|
|
||||||
return Err(AppError::InvalidInput(
|
|
||||||
"Remote config URL is not yet supported. Use inline config instead.".to_string(),
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
return Ok(request.clone());
|
|
||||||
};
|
|
||||||
|
|
||||||
// Step 2: Parse config based on format
|
|
||||||
let format = request.config_format.as_deref().unwrap_or("json");
|
|
||||||
let config_value: serde_json::Value = match format {
|
|
||||||
"json" => serde_json::from_str(&config_content)
|
|
||||||
.map_err(|e| AppError::InvalidInput(format!("Invalid JSON config: {e}")))?,
|
|
||||||
"toml" => {
|
|
||||||
let toml_value: toml::Value = toml::from_str(&config_content)
|
|
||||||
.map_err(|e| AppError::InvalidInput(format!("Invalid TOML config: {e}")))?;
|
|
||||||
// Convert TOML to JSON for uniform processing
|
|
||||||
serde_json::to_value(toml_value)
|
|
||||||
.map_err(|e| AppError::Message(format!("Failed to convert TOML to JSON: {e}")))?
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"Unsupported config format: {format}"
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Step 3: Extract values from config based on app type and merge with URL params
|
|
||||||
let mut merged = request.clone();
|
|
||||||
|
|
||||||
// MCP, Skill and other resource types don't need config merging
|
|
||||||
if request.resource != "provider" {
|
|
||||||
return Ok(merged);
|
|
||||||
}
|
|
||||||
|
|
||||||
match request.app.as_deref().unwrap_or("") {
|
|
||||||
"claude" => merge_claude_config(&mut merged, &config_value)?,
|
|
||||||
"codex" => merge_codex_config(&mut merged, &config_value)?,
|
|
||||||
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
|
|
||||||
"" => {
|
|
||||||
// No app specified, skip merging
|
|
||||||
return Ok(merged);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"Invalid app type: {:?}",
|
|
||||||
request.app
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(merged)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge Claude configuration from config file
|
|
||||||
fn merge_claude_config(
|
|
||||||
request: &mut DeepLinkImportRequest,
|
|
||||||
config: &serde_json::Value,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let env = config
|
|
||||||
.get("env")
|
|
||||||
.and_then(|v| v.as_object())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
AppError::InvalidInput("Claude config must have 'env' object".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Auto-fill API key if not provided in URL
|
|
||||||
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
|
|
||||||
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
|
|
||||||
request.api_key = Some(token.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-fill endpoint if not provided in URL
|
|
||||||
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
|
|
||||||
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
|
||||||
request.endpoint = Some(base_url.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-fill homepage from endpoint if not provided
|
|
||||||
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
|
||||||
&& request.endpoint.is_some()
|
|
||||||
&& !request.endpoint.as_ref().unwrap().is_empty()
|
|
||||||
{
|
|
||||||
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
|
||||||
if request.homepage.is_none() {
|
|
||||||
request.homepage = Some("https://anthropic.com".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-fill model fields (URL params take priority)
|
|
||||||
if request.model.is_none() {
|
|
||||||
request.model = env
|
|
||||||
.get("ANTHROPIC_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
}
|
|
||||||
if request.haiku_model.is_none() {
|
|
||||||
request.haiku_model = env
|
|
||||||
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
}
|
|
||||||
if request.sonnet_model.is_none() {
|
|
||||||
request.sonnet_model = env
|
|
||||||
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
}
|
|
||||||
if request.opus_model.is_none() {
|
|
||||||
request.opus_model = env
|
|
||||||
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge Codex configuration from config file
|
|
||||||
fn merge_codex_config(
|
|
||||||
request: &mut DeepLinkImportRequest,
|
|
||||||
config: &serde_json::Value,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
// Auto-fill API key from auth.OPENAI_API_KEY
|
|
||||||
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
|
|
||||||
if let Some(api_key) = config
|
|
||||||
.get("auth")
|
|
||||||
.and_then(|v| v.get("OPENAI_API_KEY"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
request.api_key = Some(api_key.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-fill endpoint and model from config string
|
|
||||||
if let Some(config_str) = config.get("config").and_then(|v| v.as_str()) {
|
|
||||||
// Parse TOML config string to extract base_url and model
|
|
||||||
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
|
|
||||||
// Extract base_url from model_providers section
|
|
||||||
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
|
|
||||||
if let Some(base_url) = extract_codex_base_url(&toml_value) {
|
|
||||||
request.endpoint = Some(base_url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract model
|
|
||||||
if request.model.is_none() {
|
|
||||||
if let Some(model) = toml_value.get("model").and_then(|v| v.as_str()) {
|
|
||||||
request.model = Some(model.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-fill homepage from endpoint
|
|
||||||
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
|
||||||
&& request.endpoint.is_some()
|
|
||||||
&& !request.endpoint.as_ref().unwrap().is_empty()
|
|
||||||
{
|
|
||||||
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
|
||||||
if request.homepage.is_none() {
|
|
||||||
request.homepage = Some("https://openai.com".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge Gemini configuration from config file
|
|
||||||
fn merge_gemini_config(
|
|
||||||
request: &mut DeepLinkImportRequest,
|
|
||||||
config: &serde_json::Value,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
// Gemini uses flat env structure
|
|
||||||
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
|
|
||||||
if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
|
||||||
request.api_key = Some(api_key.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
|
|
||||||
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
|
|
||||||
request.endpoint = Some(base_url.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.model.is_none() {
|
|
||||||
request.model = config
|
|
||||||
.get("GEMINI_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-fill homepage from endpoint
|
|
||||||
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
|
||||||
&& request.endpoint.is_some()
|
|
||||||
&& !request.endpoint.as_ref().unwrap().is_empty()
|
|
||||||
{
|
|
||||||
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
|
||||||
if request.homepage.is_none() {
|
|
||||||
request.homepage = Some("https://ai.google.dev".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract base_url from Codex TOML config
|
|
||||||
fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
|
|
||||||
// Try to find base_url in model_providers section
|
|
||||||
if let Some(providers) = toml_value.get("model_providers").and_then(|v| v.as_table()) {
|
|
||||||
for (_key, provider) in providers.iter() {
|
|
||||||
if let Some(base_url) = provider.get("base_url").and_then(|v| v.as_str()) {
|
|
||||||
return Some(base_url.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||