Compare commits
81 Commits
feat/auto-
...
feature/vs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31cdc2a5cf | ||
|
|
7522ba3e03 | ||
|
|
3ac3f122eb | ||
|
|
67db492330 | ||
|
|
358d6e001e | ||
|
|
8a26cb51d8 | ||
|
|
9f8c745f8c | ||
|
|
3a9a8036d2 | ||
|
|
04e81ebbe3 | ||
|
|
c6e4f3599e | ||
|
|
60eb9ce2a4 | ||
|
|
50244f0055 | ||
|
|
eca14db58c | ||
|
|
463e430a3d | ||
|
|
32e66e054b | ||
|
|
2a9f093210 | ||
|
|
9bf216b102 | ||
|
|
b69d7f7979 | ||
|
|
efff780eea | ||
|
|
19dcc84c83 | ||
|
|
4e9e63f524 | ||
|
|
1d1440f52f | ||
|
|
36b78d1b4b | ||
|
|
2b59a5d51b | ||
|
|
15c12c8e65 | ||
|
|
3256b2f842 | ||
|
|
7374b934c7 | ||
|
|
d9d7c5c342 | ||
|
|
f4f7e10953 | ||
|
|
6ad7e04a95 | ||
|
|
7122e10646 | ||
|
|
bb685be43d | ||
|
|
c5b3b4027f | ||
|
|
daba6b094b | ||
|
|
711ad843ce | ||
|
|
189a70280f | ||
|
|
7ccef5f385 | ||
|
|
85ba24f1c3 | ||
|
|
0d2dedbb6d | ||
|
|
d76c675feb | ||
|
|
9372ecd3c6 | ||
|
|
d0b654f63e | ||
|
|
f035796654 | ||
|
|
160da2729e | ||
|
|
14db6b8a8f | ||
|
|
d91bbb122c | ||
|
|
6df5dfc123 | ||
|
|
c8327f7632 | ||
|
|
4a0e63d0b7 | ||
|
|
e63b4e069b | ||
|
|
687c7de111 | ||
|
|
876605e983 | ||
|
|
442b05507c | ||
|
|
eca9c02147 | ||
|
|
9fbce5d0cf | ||
|
|
c597b9b122 | ||
|
|
54b88d9c89 | ||
|
|
319e5fa61a | ||
|
|
310086d5c9 | ||
|
|
4297703ebe | ||
|
|
ca7ce99702 | ||
|
|
af8b9289fe | ||
|
|
bf7e13d4e9 | ||
|
|
b015af173a | ||
|
|
4a4779a7e7 | ||
|
|
92a39a1a34 | ||
|
|
ea56794a37 | ||
|
|
fd4864115c | ||
|
|
74d4b42936 | ||
|
|
a95f974787 | ||
|
|
29057c1fe0 | ||
|
|
63285acba8 | ||
|
|
f99b614888 | ||
|
|
41f3aa7d76 | ||
|
|
f23898a5c9 | ||
|
|
664391568c | ||
|
|
081aabe10f | ||
|
|
036069a5c1 | ||
|
|
9b7091ba88 | ||
|
|
2357d976dc | ||
|
|
df43692bb9 |
255
.github/workflows/release.yml
vendored
@@ -8,15 +8,19 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
- os: ubuntu-latest
|
||||
- os: macos-latest
|
||||
- os: windows-2022
|
||||
- os: ubuntu-22.04
|
||||
- os: macos-14
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -74,7 +78,7 @@ jobs:
|
||||
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
@@ -83,22 +87,72 @@ jobs:
|
||||
- name: Install frontend deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Prepare Tauri signing key
|
||||
shell: bash
|
||||
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'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
run: pnpm tauri build --target universal-apple-darwin
|
||||
|
||||
- name: Build Tauri App (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
run: pnpm tauri build
|
||||
|
||||
- name: Build Tauri App (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
run: pnpm tauri build
|
||||
|
||||
- name: Prepare macOS Assets
|
||||
@@ -107,29 +161,34 @@ jobs:
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p release-assets
|
||||
echo "Looking for .app bundle..."
|
||||
APP_PATH=""
|
||||
echo "Looking for updater artifact (.tar.gz) and .app for zip..."
|
||||
TAR_GZ=""; APP_PATH=""
|
||||
for path in \
|
||||
"src-tauri/target/release/bundle/macos" \
|
||||
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos"; do
|
||||
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/release/bundle/macos"; do
|
||||
if [ -d "$path" ]; then
|
||||
APP_PATH=$(find "$path" -name "*.app" -type d | head -1)
|
||||
[ -n "$APP_PATH" ] && break
|
||||
[ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true)
|
||||
[ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
|
||||
fi
|
||||
done
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "No .app found" >&2
|
||||
if [ -z "$TAR_GZ" ]; then
|
||||
echo "No macOS .tar.gz updater artifact found" >&2
|
||||
exit 1
|
||||
fi
|
||||
APP_DIR=$(dirname "$APP_PATH")
|
||||
APP_NAME=$(basename "$APP_PATH")
|
||||
cd "$APP_DIR"
|
||||
# 使用 ditto 打包更兼容资源分叉
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
|
||||
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
|
||||
echo "macOS zip ready"
|
||||
cp "$TAR_GZ" release-assets/
|
||||
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" release-assets/ || echo ".sig for macOS not found yet"
|
||||
echo "macOS updater artifact copied: $(basename "$TAR_GZ")"
|
||||
if [ -n "$APP_PATH" ]; then
|
||||
APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
|
||||
cd "$APP_DIR"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
|
||||
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
|
||||
echo "macOS zip ready: CC-Switch-macOS.zip"
|
||||
else
|
||||
echo "No .app found to zip (optional)" >&2
|
||||
fi
|
||||
|
||||
- name: Prepare Windows Assets
|
||||
if: runner.os == 'Windows'
|
||||
@@ -137,18 +196,27 @@ jobs:
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
|
||||
# 安装器(优先 NSIS,其次 MSI)
|
||||
$installer = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.exe,*.msi -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -match '\\bundle\\(nsis|msi)\\' } |
|
||||
Select-Object -First 1
|
||||
if ($null -ne $installer) {
|
||||
$dest = if ($installer.Extension -ieq '.msi') { 'CC-Switch-Setup.msi' } else { 'CC-Switch-Setup.exe' }
|
||||
Copy-Item $installer.FullName (Join-Path release-assets $dest)
|
||||
Write-Host "Installer copied: $dest"
|
||||
} else {
|
||||
Write-Warning 'No Windows installer found'
|
||||
# 仅打包 MSI 安装器 + .sig(用于 Updater)
|
||||
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -eq $msi) {
|
||||
# 兜底:全局搜索 .msi
|
||||
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
}
|
||||
# 绿色版(portable):仅可执行文件
|
||||
if ($null -ne $msi) {
|
||||
$dest = 'CC-Switch-Setup.msi'
|
||||
Copy-Item $msi.FullName (Join-Path release-assets $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 {
|
||||
Write-Warning 'No Windows MSI installer found'
|
||||
}
|
||||
# 绿色版(portable):仅可执行文件打 zip(不参与 Updater)
|
||||
$exeCandidates = @(
|
||||
'src-tauri/target/release/cc-switch.exe',
|
||||
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
|
||||
@@ -171,14 +239,22 @@ jobs:
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p release-assets
|
||||
# 仅上传安装包(deb)
|
||||
# Updater artifact: AppImage(含对应 .sig)
|
||||
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
|
||||
if [ -n "$APPIMAGE" ]; then
|
||||
cp "$APPIMAGE" release-assets/
|
||||
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" release-assets/ || echo ".sig for AppImage not found"
|
||||
echo "AppImage copied"
|
||||
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)
|
||||
if [ -n "$DEB" ]; then
|
||||
cp "$DEB" release-assets/
|
||||
echo "Deb package copied"
|
||||
else
|
||||
echo "No .deb found" >&2
|
||||
exit 1
|
||||
echo "No .deb found (optional)"
|
||||
fi
|
||||
|
||||
- name: List prepared assets
|
||||
@@ -189,18 +265,16 @@ jobs:
|
||||
- name: Collect Signatures
|
||||
shell: bash
|
||||
run: |
|
||||
# 查找并复制签名文件到 release-assets
|
||||
find src-tauri/target -name "*.sig" -type f 2>/dev/null | while read sig; do
|
||||
cp "$sig" release-assets/ || true
|
||||
done
|
||||
echo "Collected signatures:"
|
||||
set -euo pipefail
|
||||
echo "Collected signatures (if any alongside artifacts):"
|
||||
ls -la release-assets/*.sig || echo "No signatures found"
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: CC Switch ${{ github.ref_name }}
|
||||
prerelease: true
|
||||
body: |
|
||||
## CC Switch ${{ github.ref_name }}
|
||||
|
||||
@@ -209,7 +283,7 @@ jobs:
|
||||
### 下载
|
||||
|
||||
- macOS: `CC-Switch-macOS.zip`(解压即用)
|
||||
- Windows: `CC-Switch-Setup.exe` 或 `CC-Switch-Setup.msi`(安装版);`CC-Switch-Windows-Portable.zip`(绿色版)
|
||||
- Windows: `CC-Switch-Setup.msi`(安装版);`CC-Switch-Windows-Portable.zip`(绿色版)
|
||||
- Linux: `*.deb`(Debian/Ubuntu 安装包)
|
||||
|
||||
---
|
||||
@@ -224,3 +298,92 @@ jobs:
|
||||
run: |
|
||||
echo "Listing bundles in src-tauri/target..."
|
||||
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"
|
||||
|
||||
1
.gitignore
vendored
@@ -9,3 +9,4 @@ release/
|
||||
.npmrc
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
docs/
|
||||
|
||||
27
CHANGELOG.md
@@ -5,6 +5,33 @@ 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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [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
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
93
README.md
@@ -1,26 +1,28 @@
|
||||
# Claude Code & Codex 供应商切换器
|
||||
|
||||
[](https://github.com/jasonyoung/cc-switch/releases)
|
||||
[](https://github.com/jasonyoung/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
|
||||
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
|
||||
|
||||
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文““迁移与备份”)。
|
||||
> v3.2.0 重点:全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源(SSOT)与一次性迁移/归档(详见下文“迁移与归档 v3.2.0”)。
|
||||
|
||||
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB),启动速度提升 10 倍!
|
||||
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文“迁移与归档”)。
|
||||
|
||||
## 功能特性
|
||||
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
|
||||
|
||||
- **极速启动** - 基于 Tauri 2.0,原生性能,秒开应用
|
||||
- 一键切换不同供应商
|
||||
- 同时支持 Claude Code 与 Codex 的供应商切换与导入
|
||||
- Qwen coder、kimi k2、智谱 GLM、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
|
||||
- 支持添加自定义供应商
|
||||
- 随时切换官方登录
|
||||
- 简洁美观的图形界面
|
||||
- 信息存储在本地 ~/.cc-switch/config.json,无隐私风险
|
||||
- 超小体积 - 仅 ~5MB 安装包
|
||||
## 功能特性(v3.2.0)
|
||||
|
||||
- **全新 UI**:感谢 [TinsFox](https://github.com/TinsFox) 大佬设计的全新 UI
|
||||
- **系统托盘(菜单栏)快速切换**:按应用分组(Claude / Codex),勾选态展示当前供应商
|
||||
- **内置更新器**:集成 Tauri Updater,支持检测/下载/安装与一键重启
|
||||
- **单一事实源(SSOT)**:不再写每个供应商的“副本文件”,统一存于 `~/.cc-switch/config.json`
|
||||
- **一次性迁移/归档**:首次升级自动导入旧副本并归档原文件,之后不再持续归档
|
||||
- **原子写入与回滚**:写入 `auth.json`/`config.toml`/`settings.json` 时避免半写状态
|
||||
- **深色模式优化**:Tailwind v4 适配与选择器修正
|
||||
- **丰富预设与自定义**:Qwen coder、Kimi、GLM、DeepSeek、PackyCode 等;可自定义 Base URL
|
||||
- **本地优先与隐私**:全部信息存储在本地 `~/.cc-switch/config.json`
|
||||
|
||||
## 界面预览
|
||||
|
||||
@@ -57,35 +59,52 @@
|
||||
## 使用说明
|
||||
|
||||
1. 点击"添加供应商"添加你的 API 配置
|
||||
2. 选择要使用的供应商,点击单选按钮切换
|
||||
3. 配置会自动保存到对应应用的配置文件中
|
||||
4. 重启或者新打开终端以生效
|
||||
5. 如果需要切回 Claude 官方登录,可以添加预设供应商里的“Claude 官方登录”并切换,重启终端后即可进行正常的 /login 登录
|
||||
2. 切换方式:
|
||||
- 在主界面选择供应商后点击切换
|
||||
- 或通过“系统托盘(菜单栏)”直接选择目标供应商,立即生效
|
||||
3. 切换会写入对应应用的“live 配置文件”(Claude:`settings.json`;Codex:`auth.json` + `config.toml`)
|
||||
4. 重启或新开终端以确保生效
|
||||
5. 若需切回官方登录,在预设中选择“官方登录”并切换即可;重启终端后按官方流程登录
|
||||
|
||||
### Codex 说明
|
||||
### 检查更新
|
||||
|
||||
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
|
||||
|
||||
### Codex 说明(v3.2.0 SSOT)
|
||||
|
||||
- 配置目录:`~/.codex/`
|
||||
- 主配置文件:`auth.json`(必需)、`config.toml`(可为空)
|
||||
- 供应商副本:`auth-<name>.json`、`config-<name>.toml`
|
||||
- live 主配置:`auth.json`(必需)、`config.toml`(可为空)
|
||||
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
|
||||
- 切换策略:将选中供应商的副本覆盖到主配置(`auth.json`、`config.toml`)。若供应商没有 `config-*.toml`,会创建空的 `config.toml`。
|
||||
- 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前;`config.toml` 不存在时按空处理。
|
||||
- 官方登录:可切换到预设“Codex 官方登录”,重启终端后可选择使用 ChatGPT 账号完成登录。
|
||||
- 切换行为(不再写“副本文件”):
|
||||
- 供应商配置统一保存在 `~/.cc-switch/config.json`
|
||||
- 切换时将目标供应商写回 live 文件(`auth.json` + `config.toml`)
|
||||
- 采用“原子写入 + 失败回滚”,避免半写状态;`config.toml` 可为空
|
||||
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
|
||||
- 官方登录:可切换到预设“Codex 官方登录”,重启终端后按官方流程登录
|
||||
|
||||
### Claude Code 说明
|
||||
### Claude Code 说明(v3.2.0 SSOT)
|
||||
|
||||
- 配置目录:`~/.claude/`
|
||||
- 主配置文件:`settings.json`(推荐)或 `claude.json`(旧版兼容,若存在则继续使用)
|
||||
- 供应商副本:`settings-<name>.json`
|
||||
- live 主配置:`settings.json`(优先)或历史兼容 `claude.json`
|
||||
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
|
||||
- 切换策略:将选中供应商的副本覆盖到主配置(`settings.json`/`claude.json`)。如当前有配置且存在“当前供应商”,会先将主配置备份回该供应商的副本文件。
|
||||
- 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前。
|
||||
- 官方登录:可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录。
|
||||
- 切换行为(不再写“副本文件”):
|
||||
- 供应商配置统一保存在 `~/.cc-switch/config.json`
|
||||
- 切换时将目标供应商 JSON 直接写入 live 文件(优先 `settings.json`)
|
||||
- 编辑当前供应商时,先写 live 成功,再更新应用主配置,保证一致性
|
||||
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
|
||||
- 官方登录:可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录
|
||||
|
||||
### 迁移与备份
|
||||
### 迁移与归档(v3.2.0)
|
||||
|
||||
- cc-switch 自身配置从 v1 → v2 迁移时,将在 `~/.cc-switch/` 目录自动创建时间戳备份:`config.v1.backup.<timestamp>.json`。
|
||||
- 实际生效的应用配置文件(如 `~/.claude/settings.json`、`~/.codex/auth.json`/`config.toml`)不会被修改,切换仅在用户点击“切换”时按副本覆盖到主配置。
|
||||
- 一次性迁移:首次启动 3.2.0 会扫描旧的“副本文件”并合并到 `~/.cc-switch/config.json`
|
||||
- Claude:`~/.claude/settings-*.json`(排除 `settings.json` / 历史 `claude.json`)
|
||||
- Codex:`~/.codex/auth-*.json` 与 `config-*.toml`(按名称成对合并)
|
||||
- 去重与当前项:按“名称(忽略大小写)+ API Key”去重;若当前为空,将 live 合并项设为当前
|
||||
- 归档与清理:
|
||||
- 归档目录:`~/.cc-switch/archive/<timestamp>/<category>/...`
|
||||
- 归档成功后删除原副本;失败则保留原文件(保守策略)
|
||||
- v1 → v2 结构升级:会额外生成 `~/.cc-switch/config.v1.backup.<timestamp>.json` 以便回滚
|
||||
- 注意:迁移后不再持续归档日常切换/编辑操作,如需长期审计请自备备份方案
|
||||
|
||||
## 开发
|
||||
|
||||
@@ -138,7 +157,7 @@ cargo test
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **[Tauri 2.0](https://tauri.app/)** - 跨平台桌面应用框架
|
||||
- **[Tauri 2](https://tauri.app/)** - 跨平台桌面应用框架(集成 updater/process/opener/log/tray-icon)
|
||||
- **[React 18](https://react.dev/)** - 用户界面库
|
||||
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
|
||||
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
|
||||
@@ -177,6 +196,10 @@ cargo test
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#farion1231/cc-switch&Date)
|
||||
|
||||
## License
|
||||
|
||||
MIT © Jason Young
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.1.1",
|
||||
"version": "3.2.0",
|
||||
"description": "Claude Code & Codex 供应商切换工具",
|
||||
"scripts": {
|
||||
"dev": "pnpm tauri dev",
|
||||
@@ -34,6 +34,7 @@
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"jsonc-parser": "^3.2.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^18.2.0",
|
||||
|
||||
8
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
||||
codemirror:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
jsonc-parser:
|
||||
specifier: ^3.2.1
|
||||
version: 3.3.1
|
||||
lucide-react:
|
||||
specifier: ^0.542.0
|
||||
version: 0.542.0(react@18.3.1)
|
||||
@@ -755,6 +758,9 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonc-parser@3.3.1:
|
||||
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||
|
||||
lightningcss-darwin-arm64@1.30.1:
|
||||
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -1580,6 +1586,8 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonc-parser@3.3.1: {}
|
||||
|
||||
lightningcss-darwin-arm64@1.30.1:
|
||||
optional: true
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 162 KiB |
2
src-tauri/Cargo.lock
generated
@@ -559,7 +559,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.1.1"
|
||||
version = "3.2.0"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"log",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.1.1"
|
||||
version = "3.2.0"
|
||||
description = "Claude Code & Codex 供应商配置管理工具"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/jasonyoung/cc-switch"
|
||||
repository = "https://github.com/farion1231/cc-switch"
|
||||
edition = "2021"
|
||||
rust-version = "1.85.0"
|
||||
|
||||
@@ -32,3 +32,11 @@ toml = "0.8"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.5"
|
||||
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"
|
||||
|
||||
BIN
src-tauri/icons/tray/macos/statusTemplate.png
Normal file
|
After Width: | Height: | Size: 564 KiB |
BIN
src-tauri/icons/tray/macos/statusTemplate@2x.png
Normal file
|
After Width: | Height: | Size: 572 KiB |
@@ -7,9 +7,42 @@ use tauri_plugin_opener::OpenerExt;
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config;
|
||||
use crate::config::{get_claude_settings_path, ConfigStatus};
|
||||
use crate::vscode;
|
||||
use crate::config;
|
||||
use crate::provider::Provider;
|
||||
use crate::store::AppState;
|
||||
|
||||
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
if !provider.settings_config.is_object() {
|
||||
return Err("Claude 配置必须是 JSON 对象".to_string());
|
||||
}
|
||||
}
|
||||
AppType::Codex => {
|
||||
let settings = provider
|
||||
.settings_config
|
||||
.as_object()
|
||||
.ok_or_else(|| "Codex 配置必须是 JSON 对象".to_string())?;
|
||||
let auth = settings
|
||||
.get("auth")
|
||||
.ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?;
|
||||
if !auth.is_object() {
|
||||
return Err("Codex auth 配置必须是 JSON 对象".to_string());
|
||||
}
|
||||
if let Some(config_value) = settings.get("config") {
|
||||
if !(config_value.is_string() || config_value.is_null()) {
|
||||
return Err("Codex config 字段必须是字符串".to_string());
|
||||
}
|
||||
if let Some(cfg_text) = config_value.as_str() {
|
||||
codex_config::validate_config_toml(cfg_text)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取所有供应商
|
||||
#[tauri::command]
|
||||
pub async fn get_providers(
|
||||
@@ -74,6 +107,8 @@ pub async fn add_provider(
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
validate_provider_settings(&app_type, &provider)?;
|
||||
|
||||
// 读取当前是否是激活供应商(短锁)
|
||||
let is_current = {
|
||||
let config = state
|
||||
@@ -139,6 +174,8 @@ pub async fn update_provider(
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
validate_provider_settings(&app_type, &provider)?;
|
||||
|
||||
// 读取校验 & 是否当前(短锁)
|
||||
let (exists, is_current) = {
|
||||
let config = state
|
||||
@@ -567,9 +604,9 @@ pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, St
|
||||
/// 获取设置
|
||||
#[tauri::command]
|
||||
pub async fn get_settings(_state: State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||
// 暂时返回默认设置
|
||||
// 暂时返回默认设置:系统托盘(菜单栏)显示开关
|
||||
Ok(serde_json::json!({
|
||||
"showInDock": true
|
||||
"showInTray": true
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -579,7 +616,7 @@ pub async fn save_settings(
|
||||
_state: State<'_, AppState>,
|
||||
settings: serde_json::Value,
|
||||
) -> Result<bool, String> {
|
||||
// TODO: 实现设置保存逻辑
|
||||
// TODO: 实现系统托盘显示开关的保存与应用(显示/隐藏菜单栏托盘图标)
|
||||
log::info!("保存设置: {:?}", settings);
|
||||
Ok(true)
|
||||
}
|
||||
@@ -598,3 +635,36 @@ pub async fn check_for_updates(handle: tauri::AppHandle) -> Result<bool, String>
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// VS Code: 获取用户 settings.json 状态
|
||||
#[tauri::command]
|
||||
pub async fn get_vscode_settings_status() -> Result<ConfigStatus, String> {
|
||||
if let Some(p) = vscode::find_existing_settings() {
|
||||
Ok(ConfigStatus { exists: true, path: p.to_string_lossy().to_string() })
|
||||
} else {
|
||||
// 默认返回 macOS 稳定版路径(或其他平台首选项的第一个候选),但标记不存在
|
||||
let preferred = vscode::candidate_settings_paths().into_iter().next();
|
||||
Ok(ConfigStatus { exists: false, path: preferred.unwrap_or_default().to_string_lossy().to_string() })
|
||||
}
|
||||
}
|
||||
|
||||
/// VS Code: 读取 settings.json 文本(仅当文件存在)
|
||||
#[tauri::command]
|
||||
pub async fn read_vscode_settings() -> Result<String, String> {
|
||||
if let Some(p) = vscode::find_existing_settings() {
|
||||
std::fs::read_to_string(&p).map_err(|e| format!("读取 VS Code 设置失败: {}", e))
|
||||
} else {
|
||||
Err("未找到 VS Code 用户设置文件".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// VS Code: 写入 settings.json 文本(仅当文件存在;不自动创建)
|
||||
#[tauri::command]
|
||||
pub async fn write_vscode_settings(content: String) -> Result<bool, String> {
|
||||
if let Some(p) = vscode::find_existing_settings() {
|
||||
config::write_text_file(&p, &content)?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Err("未找到 VS Code 用户设置文件".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,19 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性)
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,17 @@ mod app_config;
|
||||
mod codex_config;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod vscode;
|
||||
mod migration;
|
||||
mod provider;
|
||||
mod store;
|
||||
|
||||
use store::AppState;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::RunEvent;
|
||||
use tauri::{
|
||||
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
tray::{TrayIconBuilder, TrayIconEvent},
|
||||
};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
@@ -25,6 +28,11 @@ fn create_tray_menu(
|
||||
|
||||
let mut menu_builder = MenuBuilder::new(app);
|
||||
|
||||
// 顶部:打开主界面
|
||||
let show_main_item = MenuItem::with_id(app, "show_main", "打开主界面", true, None::<&str>)
|
||||
.map_err(|e| format!("创建打开主界面菜单失败: {}", e))?;
|
||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||
|
||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||
if let Some(claude_manager) = config.get_manager(&crate::app_config::AppType::Claude) {
|
||||
// 添加Claude标题(禁用状态,仅作为分组标识)
|
||||
@@ -109,16 +117,23 @@ fn create_tray_menu(
|
||||
|
||||
/// 处理托盘菜单事件
|
||||
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
println!("处理托盘菜单事件: {}", event_id);
|
||||
log::info!("处理托盘菜单事件: {}", event_id);
|
||||
|
||||
match event_id {
|
||||
"show_main" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
println!("退出应用");
|
||||
log::info!("退出应用");
|
||||
app.exit(0);
|
||||
}
|
||||
id if id.starts_with("claude_") => {
|
||||
let provider_id = id.strip_prefix("claude_").unwrap();
|
||||
println!("切换到Claude供应商: {}", provider_id);
|
||||
log::info!("切换到Claude供应商: {}", provider_id);
|
||||
|
||||
// 执行切换
|
||||
let app_handle = app.clone();
|
||||
@@ -131,13 +146,13 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("切换Claude供应商失败: {}", e);
|
||||
log::error!("切换Claude供应商失败: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
id if id.starts_with("codex_") => {
|
||||
let provider_id = id.strip_prefix("codex_").unwrap();
|
||||
println!("切换到Codex供应商: {}", provider_id);
|
||||
log::info!("切换到Codex供应商: {}", provider_id);
|
||||
|
||||
// 执行切换
|
||||
let app_handle = app.clone();
|
||||
@@ -150,16 +165,18 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("切换Codex供应商失败: {}", e);
|
||||
log::error!("切换Codex供应商失败: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
println!("未处理的菜单事件: {}", event_id);
|
||||
log::warn!("未处理的菜单事件: {}", event_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// 内部切换供应商函数
|
||||
async fn switch_provider_internal(
|
||||
app: &tauri::AppHandle,
|
||||
@@ -184,7 +201,7 @@ async fn switch_provider_internal(
|
||||
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||
eprintln!("更新托盘菜单失败: {}", e);
|
||||
log::error!("更新托盘菜单失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,7 +212,7 @@ async fn switch_provider_internal(
|
||||
"providerId": provider_id_clone
|
||||
});
|
||||
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||
eprintln!("发射供应商切换事件失败: {}", e);
|
||||
log::error!("发射供应商切换事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -219,7 +236,15 @@ async fn update_tray_menu(
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
let builder = tauri::Builder::default()
|
||||
// 拦截窗口关闭:仅隐藏窗口,保持进程与托盘常驻
|
||||
.on_window_event(|window, event| match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(|app| {
|
||||
@@ -294,33 +319,23 @@ pub fn run() {
|
||||
// 创建动态托盘菜单
|
||||
let menu = create_tray_menu(&app.handle(), &app_state)?;
|
||||
|
||||
let _tray = TrayIconBuilder::with_id("main")
|
||||
.on_tray_icon_event(|tray, event| match event {
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} => {
|
||||
println!("left click pressed and released");
|
||||
// 在这个例子中,当点击托盘图标时,将展示并聚焦于主窗口
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
println!("unhandled event {event:?}");
|
||||
}
|
||||
// 构建托盘
|
||||
let mut tray_builder = TrayIconBuilder::with_id("main")
|
||||
.on_tray_icon_event(|_tray, event| match event {
|
||||
// 左键点击已通过 show_menu_on_left_click(true) 打开菜单,这里不再额外处理
|
||||
TrayIconEvent::Click { .. } => {}
|
||||
_ => log::debug!("unhandled event {event:?}"),
|
||||
})
|
||||
.menu(&menu)
|
||||
.on_menu_event(|app, event| {
|
||||
handle_tray_menu_event(app, &event.id.0);
|
||||
})
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.show_menu_on_left_click(true)
|
||||
.build(app)?;
|
||||
.show_menu_on_left_click(true);
|
||||
|
||||
// 统一使用应用默认图标;待托盘模板图标就绪后再启用
|
||||
tray_builder = tray_builder.icon(app.default_window_icon().unwrap().clone());
|
||||
|
||||
let _tray = tray_builder.build(app)?;
|
||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||
app.manage(app_state);
|
||||
Ok(())
|
||||
@@ -343,8 +358,33 @@ pub fn run() {
|
||||
commands::get_settings,
|
||||
commands::save_settings,
|
||||
commands::check_for_updates,
|
||||
commands::get_vscode_settings_status,
|
||||
commands::read_vscode_settings,
|
||||
commands::write_vscode_settings,
|
||||
update_tray_menu,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
app.run(|app_handle, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
|
||||
match event {
|
||||
RunEvent::Reopen { .. } => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = (app_handle, event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,8 +145,11 @@ fn scan_codex_copies() -> Vec<(String, Option<PathBuf>, Option<PathBuf>, Value)>
|
||||
}
|
||||
|
||||
pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, String> {
|
||||
// 如果已迁移过则跳过
|
||||
// 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败
|
||||
let marker = get_marker_path();
|
||||
if let Some(parent) = marker.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建迁移标记目录失败: {}", e))?;
|
||||
}
|
||||
if marker.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct Provider {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "websiteUrl")]
|
||||
pub website_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
@@ -29,6 +31,7 @@ impl Provider {
|
||||
name,
|
||||
settings_config,
|
||||
website_url,
|
||||
category: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
61
src-tauri/src/vscode.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use std::path::{PathBuf};
|
||||
|
||||
/// 枚举可能的 VS Code 发行版配置目录名称
|
||||
fn vscode_product_dirs() -> Vec<&'static str> {
|
||||
vec![
|
||||
"Code", // VS Code Stable
|
||||
"Code - Insiders", // VS Code Insiders
|
||||
"VSCodium", // VSCodium
|
||||
"Code - OSS", // OSS 发行版
|
||||
]
|
||||
}
|
||||
|
||||
/// 获取 VS Code 用户 settings.json 的候选路径列表(按优先级排序)
|
||||
pub fn candidate_settings_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
for prod in vscode_product_dirs() {
|
||||
paths.push(
|
||||
home.join("Library").join("Application Support").join(prod).join("User").join("settings.json")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows: %APPDATA%\Code\User\settings.json
|
||||
if let Some(roaming) = dirs::config_dir() {
|
||||
for prod in vscode_product_dirs() {
|
||||
paths.push(roaming.join(prod).join("User").join("settings.json"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
// Linux: ~/.config/Code/User/settings.json
|
||||
if let Some(config) = dirs::config_dir() {
|
||||
for prod in vscode_product_dirs() {
|
||||
paths.push(config.join(prod).join("User").join("settings.json"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
/// 返回第一个存在的 settings.json 路径
|
||||
pub fn find_existing_settings() -> Option<PathBuf> {
|
||||
for p in candidate_settings_paths() {
|
||||
if let Ok(meta) = std::fs::metadata(&p) {
|
||||
if meta.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.1.1",
|
||||
"version": "3.2.0",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -42,9 +42,9 @@
|
||||
,
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRERTRCNEUxQUE3MDA4QTYKUldTbUNIQ3E0YlRrVFF2cnFVVE1jczlNZFlmemxXd0h6cTdibXRJWjBDSytQODdZOTYvR3d3d2oK",
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
|
||||
"endpoints": [
|
||||
"https://github.com/jasonyoung/cc-switch/releases/latest/download/latest.json"
|
||||
"https://github.com/farion1231/cc-switch/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
172
src/App.tsx
@@ -7,12 +7,18 @@ import EditProviderModal from "./components/EditProviderModal";
|
||||
import { ConfirmDialog } from "./components/ConfirmDialog";
|
||||
import { AppSwitcher } from "./components/AppSwitcher";
|
||||
import SettingsModal from "./components/SettingsModal";
|
||||
import { UpdateBadge } from "./components/UpdateBadge";
|
||||
import { Plus, Settings, Moon, Sun } from "lucide-react";
|
||||
import { buttonStyles } from "./lib/styles";
|
||||
import { useDarkMode } from "./hooks/useDarkMode";
|
||||
import { extractErrorMessage } from "./utils/errorUtils";
|
||||
import { applyProviderToVSCode } from "./utils/vscodeSettings";
|
||||
import { getCodexBaseUrl } from "./utils/providerConfigUtils";
|
||||
import { useVSCodeAutoSync } from "./hooks/useVSCodeAutoSync";
|
||||
|
||||
function App() {
|
||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
||||
const { isAutoSyncEnabled } = useVSCodeAutoSync();
|
||||
const [activeApp, setActiveApp] = useState<AppType>("claude");
|
||||
const [providers, setProviders] = useState<Record<string, Provider>>({});
|
||||
const [currentProviderId, setCurrentProviderId] = useState<string>("");
|
||||
@@ -74,19 +80,26 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 监听托盘切换事件
|
||||
// 监听托盘切换事件(包括菜单切换)
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
const setupListener = async () => {
|
||||
try {
|
||||
unlisten = await window.api.onProviderSwitched(async (data) => {
|
||||
console.log("收到供应商切换事件:", data);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("收到供应商切换事件:", data);
|
||||
}
|
||||
|
||||
// 如果当前应用类型匹配,则重新加载数据
|
||||
if (data.appType === activeApp) {
|
||||
await loadProviders();
|
||||
}
|
||||
|
||||
// 若为 Codex 且开启自动同步,则静默同步到 VS Code(覆盖)
|
||||
if (data.appType === "codex" && isAutoSyncEnabled) {
|
||||
await syncCodexToVSCode(data.providerId, true);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("设置供应商切换监听器失败:", error);
|
||||
@@ -101,7 +114,7 @@ function App() {
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
}, [activeApp]); // 依赖activeApp,切换应用时重新设置监听器
|
||||
}, [activeApp, isAutoSyncEnabled]);
|
||||
|
||||
const loadProviders = async () => {
|
||||
const loadedProviders = await window.api.getProviders(activeApp);
|
||||
@@ -115,7 +128,6 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 生成唯一ID
|
||||
const generateId = () => {
|
||||
return crypto.randomUUID();
|
||||
@@ -146,7 +158,11 @@ function App() {
|
||||
} catch (error) {
|
||||
console.error("更新供应商失败:", error);
|
||||
setEditingProviderId(null);
|
||||
showNotification("保存失败,请重试", "error");
|
||||
const errorMessage = extractErrorMessage(error);
|
||||
const message = errorMessage
|
||||
? `保存失败:${errorMessage}`
|
||||
: "保存失败,请重试";
|
||||
showNotification(message, "error", errorMessage ? 6000 : 3000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -167,6 +183,64 @@ function App() {
|
||||
});
|
||||
};
|
||||
|
||||
// 同步Codex供应商到VS Code设置(静默覆盖)
|
||||
const syncCodexToVSCode = async (providerId: string, silent = false) => {
|
||||
try {
|
||||
const status = await window.api.getVSCodeSettingsStatus();
|
||||
if (!status.exists) {
|
||||
if (!silent) {
|
||||
showNotification(
|
||||
"未找到 VS Code 用户设置文件 (settings.json)",
|
||||
"error",
|
||||
3000,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = await window.api.readVSCodeSettings();
|
||||
const provider = providers[providerId];
|
||||
const isOfficial = provider?.category === "official";
|
||||
|
||||
// 非官方供应商需要解析 base_url(使用公共工具函数)
|
||||
let baseUrl: string | undefined = undefined;
|
||||
if (!isOfficial) {
|
||||
const parsed = getCodexBaseUrl(provider);
|
||||
if (!parsed) {
|
||||
if (!silent) {
|
||||
showNotification(
|
||||
"当前配置缺少 base_url,无法写入 VS Code",
|
||||
"error",
|
||||
4000,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
baseUrl = parsed;
|
||||
}
|
||||
|
||||
const updatedSettings = applyProviderToVSCode(raw, {
|
||||
baseUrl,
|
||||
isOfficial,
|
||||
});
|
||||
if (updatedSettings !== raw) {
|
||||
await window.api.writeVSCodeSettings(updatedSettings);
|
||||
if (!silent) {
|
||||
showNotification("已同步到 VS Code", "success", 1500);
|
||||
}
|
||||
}
|
||||
|
||||
// 触发providers重新加载,以更新VS Code按钮状态
|
||||
await loadProviders();
|
||||
} catch (error: any) {
|
||||
console.error("同步到VS Code失败:", error);
|
||||
if (!silent) {
|
||||
const errorMessage = error?.message || "同步 VS Code 失败";
|
||||
showNotification(errorMessage, "error", 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchProvider = async (id: string) => {
|
||||
const success = await window.api.switchProvider(id, activeApp);
|
||||
if (success) {
|
||||
@@ -180,6 +254,11 @@ function App() {
|
||||
);
|
||||
// 更新托盘菜单
|
||||
await window.api.updateTrayMenu();
|
||||
|
||||
// Codex: 切换供应商后,只在自动同步启用时同步到 VS Code
|
||||
if (activeApp === "codex" && isAutoSyncEnabled) {
|
||||
await syncCodexToVSCode(id, true); // silent模式,不显示通知
|
||||
}
|
||||
} else {
|
||||
showNotification("切换失败,请检查配置", "error");
|
||||
}
|
||||
@@ -203,16 +282,21 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
|
||||
{/* Linear 风格的顶部导航 */}
|
||||
<header className="bg-white border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800 px-6 py-4">
|
||||
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
|
||||
{/* 顶部导航区域 - 固定高度 */}
|
||||
<header className="flex-shrink-0 bg-white border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-semibold text-blue-500 dark:text-blue-400">
|
||||
<a
|
||||
href="https://github.com/farion1231/cc-switch"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xl font-semibold text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
|
||||
title="在 GitHub 上查看"
|
||||
>
|
||||
CC Switch
|
||||
</h1>
|
||||
</a>
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className={buttonStyles.icon}
|
||||
@@ -220,13 +304,16 @@ function App() {
|
||||
>
|
||||
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className={buttonStyles.icon}
|
||||
title="设置"
|
||||
>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className={buttonStyles.icon}
|
||||
title="设置"
|
||||
>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -243,30 +330,33 @@ function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<main className="flex-1 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 通知组件 */}
|
||||
{notification && (
|
||||
<div
|
||||
className={`fixed top-6 left-1/2 transform -translate-x-1/2 z-50 px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
|
||||
notification.type === "error"
|
||||
? "bg-red-500 text-white"
|
||||
: "bg-green-500 text-white"
|
||||
} ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`}
|
||||
>
|
||||
{notification.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
currentProviderId={currentProviderId}
|
||||
onSwitch={handleSwitchProvider}
|
||||
onDelete={handleDeleteProvider}
|
||||
onEdit={setEditingProviderId}
|
||||
/>
|
||||
{/* 主内容区域 - 独立滚动 */}
|
||||
<main className="flex-1 overflow-y-scroll">
|
||||
<div className="pt-3 px-6 pb-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 通知组件 - 相对于视窗定位 */}
|
||||
{notification && (
|
||||
<div
|
||||
className={`fixed top-20 left-1/2 transform -translate-x-1/2 z-50 px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
|
||||
notification.type === "error"
|
||||
? "bg-red-500 text-white"
|
||||
: "bg-green-500 text-white"
|
||||
} ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`}
|
||||
>
|
||||
{notification.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
currentProviderId={currentProviderId}
|
||||
onSwitch={handleSwitchProvider}
|
||||
onDelete={handleDeleteProvider}
|
||||
onEdit={setEditingProviderId}
|
||||
appType={activeApp}
|
||||
onNotify={showNotification}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
1
src/assets/icons/chatgpt.svg
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
1
src/assets/icons/claude.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1757750114641" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1475" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M202.112 678.656l200.64-112.64 3.392-9.792-3.392-5.44h-9.792l-33.6-2.048-114.624-3.072-99.456-4.224-96.384-5.12-24.192-5.12-22.72-29.952 2.304-14.976 20.48-13.696 29.12 2.56 64.576 4.416 96.832 6.72 70.208 4.096 104.064 10.88h16.576l2.304-6.72-5.696-4.16-4.352-4.096-100.224-67.968-108.48-71.744-56.768-41.344-30.72-20.928-15.488-19.584-6.72-42.88 27.84-30.72 37.504 2.56 9.536 2.56 37.952 29.184 81.088 62.784 105.856 77.952 15.488 12.928 6.208-4.352 0.768-3.136L395.264 360l-57.6-104.064-61.44-105.92-27.392-43.904-7.168-26.304c-2.56-10.88-4.48-19.904-4.48-30.976l31.808-43.136L286.592 0l42.304 5.696 17.856 15.488 26.304 60.16 42.624 94.72 66.112 128.896 19.392 38.208 10.24 35.392 3.904 10.88h6.72v-6.208l5.44-72.576 10.048-89.088 9.856-114.688 3.328-32.256 16-38.72 31.808-20.928 24.768 11.904 20.416 29.184-2.88 18.816-12.16 78.72-23.68 123.52-15.552 82.56h9.088l10.304-10.24 41.856-55.552 70.208-87.808 30.976-34.88 36.16-38.464 23.232-18.368h43.904l32.32 48.064-14.464 49.6-45.184 57.28-37.44 48.576-53.76 72.32-33.536 57.856 3.072 4.608 8-0.768 121.408-25.792 65.6-11.904 78.208-13.44 35.392 16.512 3.84 16.832-13.952 34.304-83.648 20.672-98.112 19.648-146.176 34.56-1.792 1.28 2.048 2.56 65.92 6.272 28.096 1.536h68.928l128.384 9.6 33.536 22.144 20.16 27.136-3.392 20.672-51.648 26.304-69.696-16.512-162.688-38.72-55.744-13.952h-7.744v4.672l46.464 45.44 85.184 76.928 106.688 99.2 5.376 24.512-13.632 19.328-14.464-2.048-93.76-70.464-36.16-31.808-81.856-68.928h-5.44v7.232l18.88 27.648 99.648 149.76 5.184 45.952-7.232 14.976-25.856 9.024-28.352-5.12L673.408 856l-60.16-92.16-48.576-82.624-5.952 3.392-28.672 308.544-13.44 15.744-30.976 11.904-25.792-19.648-13.696-31.744 13.696-62.72 16.512-81.92 13.44-65.024 12.16-80.832 7.232-26.88-0.512-1.792-5.952 0.768-60.928 83.648-92.736 125.248-73.344 78.528-17.536 6.976-30.464-15.808 2.816-28.16 17.024-24.96 101.504-129.152 61.184-80 39.552-46.272-0.256-6.72h-2.368L177.6 789.44l-48 6.144-20.736-19.328 2.56-31.744 9.856-10.368 81.088-55.744-0.256 0.256z" p-id="1476" fill="#bfbfbf"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -1,5 +1,5 @@
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import { Terminal, Code2 } from "lucide-react";
|
||||
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
|
||||
|
||||
interface AppSwitcherProps {
|
||||
activeApp: AppType;
|
||||
@@ -17,14 +17,21 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitch("claude")}
|
||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
activeApp === "claude"
|
||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
|
||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
<Code2 size={16} />
|
||||
<span>Claude Code</span>
|
||||
<ClaudeIcon
|
||||
size={16}
|
||||
className={
|
||||
activeApp === "claude"
|
||||
? "text-[#D97757] dark:text-[#D97757] transition-colors duration-200"
|
||||
: "text-gray-500 dark:text-gray-400 group-hover:text-[#D97757] dark:group-hover:text-[#D97757] transition-colors duration-200"
|
||||
}
|
||||
/>
|
||||
<span>Claude</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -36,7 +43,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
<Terminal size={16} />
|
||||
<CodexIcon size={16} />
|
||||
<span>Codex</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
34
src/components/BrandIcons.tsx
Normal file
@@ -1,9 +1,10 @@
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import React, { useRef, useEffect, useMemo } from "react";
|
||||
import { EditorView, basicSetup } from "codemirror";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { placeholder } from "@codemirror/view";
|
||||
import { linter, Diagnostic } from "@codemirror/lint";
|
||||
|
||||
interface JsonEditorProps {
|
||||
value: string;
|
||||
@@ -11,6 +12,7 @@ interface JsonEditorProps {
|
||||
placeholder?: string;
|
||||
darkMode?: boolean;
|
||||
rows?: number;
|
||||
showValidation?: boolean;
|
||||
}
|
||||
|
||||
const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
@@ -19,10 +21,50 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
placeholder: placeholderText = "",
|
||||
darkMode = false,
|
||||
rows = 12,
|
||||
showValidation = true,
|
||||
}) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
||||
// JSON linter 函数
|
||||
const jsonLinter = useMemo(
|
||||
() =>
|
||||
linter((view) => {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
if (!showValidation) return diagnostics;
|
||||
|
||||
const doc = view.state.doc.toString();
|
||||
if (!doc.trim()) return diagnostics;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(doc);
|
||||
// 检查是否是JSON对象
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
// 格式正确
|
||||
} else {
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: doc.length,
|
||||
severity: "error",
|
||||
message: "配置必须是JSON对象,不能是数组或其他类型",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// 简单处理JSON解析错误
|
||||
const message = e instanceof SyntaxError ? e.message : "JSON格式错误";
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: doc.length,
|
||||
severity: "error",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}),
|
||||
[showValidation],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
@@ -43,6 +85,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
json(),
|
||||
placeholder(placeholderText || ""),
|
||||
sizingTheme,
|
||||
jsonLinter,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const newValue = update.state.doc.toString();
|
||||
@@ -75,7 +118,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, [darkMode, rows]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
|
||||
}, [darkMode, rows, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
|
||||
|
||||
// 当 value 从外部改变时更新编辑器内容
|
||||
useEffect(() => {
|
||||
|
||||
@@ -28,15 +28,15 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||
|
||||
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
|
||||
disabled
|
||||
? "bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed"
|
||||
: "border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
|
||||
? "bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
: "border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-sm font-medium text-gray-900"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{label} {required && "*"}
|
||||
</label>
|
||||
@@ -56,7 +56,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleShowKey}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 hover:text-gray-900 transition-colors"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||
aria-label={showKey ? "隐藏API Key" : "显示API Key"}
|
||||
>
|
||||
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
|
||||
@@ -1,52 +1,196 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import JsonEditor from "../JsonEditor";
|
||||
import { X, Save } from "lucide-react";
|
||||
|
||||
interface ClaudeConfigEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disableCoAuthored: boolean;
|
||||
onCoAuthoredToggle: (checked: boolean) => void;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
commonConfigSnippet: string;
|
||||
onCommonConfigSnippetChange: (value: string) => void;
|
||||
commonConfigError: string;
|
||||
configError: string;
|
||||
}
|
||||
|
||||
const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
disableCoAuthored,
|
||||
onCoAuthoredToggle,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
commonConfigSnippet,
|
||||
onCommonConfigSnippetChange,
|
||||
commonConfigError,
|
||||
configError,
|
||||
}) => {
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 检测暗色模式
|
||||
const checkDarkMode = () => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
};
|
||||
|
||||
checkDarkMode();
|
||||
|
||||
// 监听暗色模式变化
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === "class") {
|
||||
checkDarkMode();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||
setIsCommonConfigModalOpen(true);
|
||||
}
|
||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||
|
||||
// 支持按下 ESC 关闭弹窗
|
||||
useEffect(() => {
|
||||
if (!isCommonConfigModalOpen) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [isCommonConfigModalOpen]);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCommonConfigModalOpen(false);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="settingsConfig"
|
||||
className="block text-sm font-medium text-gray-900"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
Claude Code 配置 (JSON) *
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 cursor-pointer">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disableCoAuthored}
|
||||
onChange={(e) => onCoAuthoredToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white border-gray-200 rounded focus:ring-blue-500 focus:ring-2"
|
||||
checked={useCommonConfig}
|
||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
禁止 Claude Code 签名
|
||||
写入通用配置
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCommonConfigModalOpen(true)}
|
||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
编辑通用配置
|
||||
</button>
|
||||
</div>
|
||||
{commonConfigError && !isCommonConfigModalOpen && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
darkMode={isDarkMode}
|
||||
placeholder={`{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-your-api-key-here"
|
||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
||||
}
|
||||
}`}
|
||||
rows={12}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
{configError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
完整的 Claude Code settings.json 配置内容
|
||||
</p>
|
||||
{isCommonConfigModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) closeModal();
|
||||
}}
|
||||
>
|
||||
{/* Backdrop - 统一背景样式 */}
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
|
||||
|
||||
{/* Modal - 统一窗口样式 */}
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header - 统一标题栏样式 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
编辑通用配置片段
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - 统一内容区域样式 */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
该片段会在勾选"写入通用配置"时合并到 settings.json 中
|
||||
</p>
|
||||
<JsonEditor
|
||||
value={commonConfigSnippet}
|
||||
onChange={onCommonConfigSnippetChange}
|
||||
darkMode={isDarkMode}
|
||||
rows={12}
|
||||
/>
|
||||
{commonConfigError && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - 统一底部按钮样式 */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { X, Save } from "lucide-react";
|
||||
|
||||
interface CodexConfigEditorProps {
|
||||
authValue: string;
|
||||
@@ -6,6 +7,12 @@ interface CodexConfigEditorProps {
|
||||
onAuthChange: (value: string) => void;
|
||||
onConfigChange: (value: string) => void;
|
||||
onAuthBlur?: () => void;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
commonConfigSnippet: string;
|
||||
onCommonConfigSnippetChange: (value: string) => void;
|
||||
commonConfigError: string;
|
||||
authError: string;
|
||||
}
|
||||
|
||||
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
@@ -14,52 +21,222 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
onAuthChange,
|
||||
onConfigChange,
|
||||
onAuthBlur,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
commonConfigSnippet,
|
||||
onCommonConfigSnippetChange,
|
||||
commonConfigError,
|
||||
authError,
|
||||
}) => {
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||
setIsCommonConfigModalOpen(true);
|
||||
}
|
||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||
|
||||
// 支持按下 ESC 关闭弹窗
|
||||
useEffect(() => {
|
||||
if (!isCommonConfigModalOpen) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [isCommonConfigModalOpen]);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCommonConfigModalOpen(false);
|
||||
};
|
||||
|
||||
const handleAuthChange = (value: string) => {
|
||||
onAuthChange(value);
|
||||
};
|
||||
|
||||
const handleConfigChange = (value: string) => {
|
||||
onConfigChange(value);
|
||||
};
|
||||
|
||||
const handleCommonConfigSnippetChange = (value: string) => {
|
||||
onCommonConfigSnippetChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="codexAuth"
|
||||
className="block text-sm font-medium text-gray-900"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
auth.json (JSON) *
|
||||
</label>
|
||||
<textarea
|
||||
id="codexAuth"
|
||||
value={authValue}
|
||||
onChange={(e) => onAuthChange(e.target.value)}
|
||||
onChange={(e) => handleAuthChange(e.target.value)}
|
||||
onBlur={onAuthBlur}
|
||||
placeholder={`{
|
||||
"OPENAI_API_KEY": "sk-your-api-key-here"
|
||||
}`}
|
||||
rows={6}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors resize-y min-h-[8rem]"
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[8rem]"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
{authError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{authError}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Codex auth.json 配置内容
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="codexConfig"
|
||||
className="block text-sm font-medium text-gray-900"
|
||||
>
|
||||
config.toml (TOML)
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="codexConfig"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
config.toml (TOML)
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useCommonConfig}
|
||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
写入通用配置
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCommonConfigModalOpen(true)}
|
||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
编辑通用配置
|
||||
</button>
|
||||
</div>
|
||||
{commonConfigError && !isCommonConfigModalOpen && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
id="codexConfig"
|
||||
value={configValue}
|
||||
onChange={(e) => onConfigChange(e.target.value)}
|
||||
onChange={(e) => handleConfigChange(e.target.value)}
|
||||
placeholder=""
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors resize-y min-h-[10rem]"
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[10rem]"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Codex config.toml 配置内容
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isCommonConfigModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) closeModal();
|
||||
}}
|
||||
>
|
||||
{/* Backdrop - 统一背景样式 */}
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
|
||||
|
||||
{/* Modal - 统一窗口样式 */}
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header - 统一标题栏样式 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
编辑 Codex 通用配置片段
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - 统一内容区域样式 */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
该片段会在勾选"写入通用配置"时追加到 config.toml 末尾
|
||||
</p>
|
||||
<textarea
|
||||
value={commonConfigSnippet}
|
||||
onChange={(e) =>
|
||||
handleCommonConfigSnippetChange(e.target.value)
|
||||
}
|
||||
placeholder={`# Common Codex config
|
||||
# Add your common TOML configuration here`}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
{commonConfigError && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - 统一底部按钮样式 */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -86,10 +86,10 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
||||
}
|
||||
}, [debouncedKey]);
|
||||
|
||||
const selectClass = `w-full px-3 py-2 border rounded-lg text-sm transition-colors appearance-none bg-white ${
|
||||
const selectClass = `w-full px-3 py-2 border rounded-lg text-sm transition-colors appearance-none bg-white dark:bg-gray-800 ${
|
||||
disabled
|
||||
? "bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed"
|
||||
: "border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
|
||||
? "bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
: "border-gray-200 dark:border-gray-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400"
|
||||
}`;
|
||||
|
||||
const ModelSelect: React.FC<{
|
||||
@@ -98,7 +98,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
||||
onChange: (value: string) => void;
|
||||
}> = ({ label, value, onChange }) => (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-900">
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -123,7 +123,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 pointer-events-none"
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,14 +132,14 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
模型配置
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => debouncedKey && fetchModelsWithKey(debouncedKey)}
|
||||
disabled={disabled || loading || !debouncedKey}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:text-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
刷新模型列表
|
||||
@@ -147,23 +147,23 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-100 border border-red-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 p-3 bg-red-100 dark:bg-red-900/20 border border-red-500/20 dark:border-red-500/30 rounded-lg">
|
||||
<AlertCircle
|
||||
size={16}
|
||||
className="text-red-500 flex-shrink-0"
|
||||
className="text-red-500 dark:text-red-400 flex-shrink-0"
|
||||
/>
|
||||
<p className="text-red-500 text-xs">{error}</p>
|
||||
<p className="text-red-500 dark:text-red-400 text-xs">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ModelSelect
|
||||
label="主模型 (ANTHROPIC_MODEL)"
|
||||
label="主模型"
|
||||
value={anthropicModel}
|
||||
onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)}
|
||||
/>
|
||||
<ModelSelect
|
||||
label="快速模型 (ANTHROPIC_SMALL_FAST_MODEL)"
|
||||
label="快速模型"
|
||||
value={anthropicSmallFastModel}
|
||||
onChange={(value) =>
|
||||
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value)
|
||||
@@ -172,9 +172,9 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
||||
</div>
|
||||
|
||||
{!apiKey.trim() && (
|
||||
<div className="p-3 bg-gray-100 border border-gray-200 rounded-lg">
|
||||
<p className="text-xs text-gray-500">
|
||||
📝 请先填写 API Key(格式:sk-xxx-api-key-here)以获取可用模型列表
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
💡 填写 API Key 后将自动获取可用模型列表
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React from "react";
|
||||
import { Zap } from "lucide-react";
|
||||
import { ProviderCategory } from "../../types";
|
||||
import { ClaudeIcon, CodexIcon } from "../BrandIcons";
|
||||
|
||||
interface Preset {
|
||||
name: string;
|
||||
isOfficial?: boolean;
|
||||
category?: ProviderCategory;
|
||||
}
|
||||
|
||||
interface PresetSelectorProps {
|
||||
@@ -23,18 +26,24 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
|
||||
onCustomClick,
|
||||
customLabel = "自定义",
|
||||
}) => {
|
||||
const getButtonClass = (index: number, isOfficial?: boolean) => {
|
||||
const getButtonClass = (index: number, preset?: Preset) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
const baseClass =
|
||||
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
|
||||
|
||||
if (isSelected) {
|
||||
return isOfficial
|
||||
? `${baseClass} bg-amber-500 text-white`
|
||||
: `${baseClass} bg-blue-500 text-white`;
|
||||
if (preset?.isOfficial || preset?.category === "official") {
|
||||
// Codex 官方使用黑色背景
|
||||
if (preset?.name.includes("Codex")) {
|
||||
return `${baseClass} bg-gray-900 text-white`;
|
||||
}
|
||||
// Claude 官方使用品牌色背景
|
||||
return `${baseClass} bg-[#D97757] text-white`;
|
||||
}
|
||||
return `${baseClass} bg-blue-500 text-white`;
|
||||
}
|
||||
|
||||
return `${baseClass} bg-gray-100 text-gray-500 hover:bg-gray-200`;
|
||||
return `${baseClass} bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700`;
|
||||
};
|
||||
|
||||
const getDescription = () => {
|
||||
@@ -44,8 +53,8 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
|
||||
|
||||
if (selectedIndex !== null && selectedIndex >= 0) {
|
||||
const preset = presets[selectedIndex];
|
||||
return preset?.isOfficial
|
||||
? "Claude 官方登录,不需要填写 API Key"
|
||||
return preset?.isOfficial || preset?.category === "official"
|
||||
? "官方登录,不需要填写 API Key"
|
||||
: "使用预设配置,只需填写 API Key";
|
||||
}
|
||||
|
||||
@@ -55,13 +64,13 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 mb-3">
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
{title}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? '' : ''}`}
|
||||
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? "" : ""}`}
|
||||
onClick={onCustomClick}
|
||||
>
|
||||
{customLabel}
|
||||
@@ -70,17 +79,27 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className={getButtonClass(index, preset.isOfficial)}
|
||||
className={getButtonClass(index, preset)}
|
||||
onClick={() => onSelectPreset(index)}
|
||||
>
|
||||
{preset.isOfficial && <Zap size={14} />}
|
||||
{(preset.isOfficial || preset.category === "official") && (
|
||||
<>
|
||||
{preset.name.includes("Claude") ? (
|
||||
<ClaudeIcon size={14} />
|
||||
) : preset.name.includes("Codex") ? (
|
||||
<CodexIcon size={14} />
|
||||
) : (
|
||||
<Zap size={14} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{getDescription() && (
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{getDescription()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Provider } from "../types";
|
||||
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
|
||||
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import {
|
||||
applyProviderToVSCode,
|
||||
detectApplied,
|
||||
normalizeBaseUrl,
|
||||
} from "../utils/vscodeSettings";
|
||||
import { getCodexBaseUrl } from "../utils/providerConfigUtils";
|
||||
import { useVSCodeAutoSync } from "../hooks/useVSCodeAutoSync";
|
||||
// 不再在列表中显示分类徽章,避免造成困惑
|
||||
|
||||
interface ProviderListProps {
|
||||
providers: Record<string, Provider>;
|
||||
@@ -9,6 +18,12 @@ interface ProviderListProps {
|
||||
onSwitch: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
appType?: AppType;
|
||||
onNotify?: (
|
||||
message: string,
|
||||
type: "success" | "error",
|
||||
duration?: number,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const ProviderList: React.FC<ProviderListProps> = ({
|
||||
@@ -17,6 +32,8 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
onSwitch,
|
||||
onDelete,
|
||||
onEdit,
|
||||
appType,
|
||||
onNotify,
|
||||
}) => {
|
||||
// 提取API地址(兼容不同供应商配置:Claude env / Codex TOML)
|
||||
const getApiUrl = (provider: Provider): string => {
|
||||
@@ -28,8 +45,9 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
}
|
||||
// Codex: 从 TOML 配置中解析 base_url
|
||||
if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) {
|
||||
const match = cfg.config.match(/base_url\s*=\s*"([^"]+)"/);
|
||||
if (match && match[1]) return match[1];
|
||||
// 支持单/双引号
|
||||
const match = cfg.config.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
|
||||
if (match && match[2]) return match[2];
|
||||
}
|
||||
return "未配置官网地址";
|
||||
} catch {
|
||||
@@ -45,6 +63,128 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 解析 Codex 配置中的 base_url(已提取到公共工具)
|
||||
|
||||
// VS Code 按钮:仅在 Codex + 当前供应商显示;按钮文案根据是否"已应用"变化
|
||||
const [vscodeAppliedFor, setVscodeAppliedFor] = useState<string | null>(null);
|
||||
const { enableAutoSync, disableAutoSync } = useVSCodeAutoSync();
|
||||
|
||||
// 当当前供应商或 appType 变化时,尝试读取 VS Code settings 并检测状态
|
||||
useEffect(() => {
|
||||
const check = async () => {
|
||||
if (appType !== "codex" || !currentProviderId) {
|
||||
setVscodeAppliedFor(null);
|
||||
return;
|
||||
}
|
||||
const status = await window.api.getVSCodeSettingsStatus();
|
||||
if (!status.exists) {
|
||||
setVscodeAppliedFor(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const content = await window.api.readVSCodeSettings();
|
||||
const detected = detectApplied(content);
|
||||
// 认为“已应用”的条件(非官方供应商):VS Code 中的 apiBase 与当前供应商的 base_url 完全一致
|
||||
const current = providers[currentProviderId];
|
||||
let applied = false;
|
||||
if (current && current.category !== "official") {
|
||||
const base = getCodexBaseUrl(current);
|
||||
if (detected.apiBase && base) {
|
||||
applied =
|
||||
normalizeBaseUrl(detected.apiBase) === normalizeBaseUrl(base);
|
||||
}
|
||||
}
|
||||
setVscodeAppliedFor(applied ? currentProviderId : null);
|
||||
} catch {
|
||||
setVscodeAppliedFor(null);
|
||||
}
|
||||
};
|
||||
check();
|
||||
}, [appType, currentProviderId, providers]);
|
||||
|
||||
const handleApplyToVSCode = async (provider: Provider) => {
|
||||
try {
|
||||
const status = await window.api.getVSCodeSettingsStatus();
|
||||
if (!status.exists) {
|
||||
onNotify?.(
|
||||
"未找到 VS Code 用户设置文件 (settings.json)",
|
||||
"error",
|
||||
3000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = await window.api.readVSCodeSettings();
|
||||
|
||||
const isOfficial = provider.category === "official";
|
||||
// 非官方且缺少 base_url 时直接报错并返回,避免“空写入”假成功
|
||||
if (!isOfficial) {
|
||||
const parsed = getCodexBaseUrl(provider);
|
||||
if (!parsed) {
|
||||
onNotify?.("当前配置缺少 base_url,无法写入 VS Code", "error", 4000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = isOfficial ? undefined : getCodexBaseUrl(provider);
|
||||
const next = applyProviderToVSCode(raw, { baseUrl, isOfficial });
|
||||
|
||||
if (next === raw) {
|
||||
// 幂等:没有变化也提示成功
|
||||
onNotify?.("已应用到 VS Code,重启 Codex 插件以生效", "success", 3000);
|
||||
setVscodeAppliedFor(provider.id);
|
||||
// 用户手动应用时,启用自动同步
|
||||
enableAutoSync();
|
||||
return;
|
||||
}
|
||||
|
||||
await window.api.writeVSCodeSettings(next);
|
||||
onNotify?.("已应用到 VS Code,重启 Codex 插件以生效", "success", 3000);
|
||||
setVscodeAppliedFor(provider.id);
|
||||
// 用户手动应用时,启用自动同步
|
||||
enableAutoSync();
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
const msg = e && e.message ? e.message : "应用到 VS Code 失败";
|
||||
onNotify?.(msg, "error", 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFromVSCode = async () => {
|
||||
try {
|
||||
const status = await window.api.getVSCodeSettingsStatus();
|
||||
if (!status.exists) {
|
||||
onNotify?.(
|
||||
"未找到 VS Code 用户设置文件 (settings.json)",
|
||||
"error",
|
||||
3000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const raw = await window.api.readVSCodeSettings();
|
||||
const next = applyProviderToVSCode(raw, {
|
||||
baseUrl: undefined,
|
||||
isOfficial: true,
|
||||
});
|
||||
if (next === raw) {
|
||||
onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000);
|
||||
setVscodeAppliedFor(null);
|
||||
// 用户手动移除时,禁用自动同步
|
||||
disableAutoSync();
|
||||
return;
|
||||
}
|
||||
await window.api.writeVSCodeSettings(next);
|
||||
onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000);
|
||||
setVscodeAppliedFor(null);
|
||||
// 用户手动移除时,禁用自动同步
|
||||
disableAutoSync();
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
const msg = e && e.message ? e.message : "移除失败";
|
||||
onNotify?.(msg, "error", 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// 对供应商列表进行排序
|
||||
const sortedProviders = Object.values(providers).sort((a, b) => {
|
||||
// 按添加时间排序
|
||||
@@ -52,16 +192,16 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
// 有时间戳的按时间升序排列
|
||||
const timeA = a.createdAt || 0;
|
||||
const timeB = b.createdAt || 0;
|
||||
|
||||
|
||||
// 如果都没有时间戳,按名称排序
|
||||
if (timeA === 0 && timeB === 0) {
|
||||
return a.name.localeCompare(b.name, 'zh-CN');
|
||||
return a.name.localeCompare(b.name, "zh-CN");
|
||||
}
|
||||
|
||||
|
||||
// 如果只有一个没有时间戳,没有时间戳的排在前面
|
||||
if (timeA === 0) return -1;
|
||||
if (timeB === 0) return 1;
|
||||
|
||||
|
||||
// 都有时间戳,按时间升序
|
||||
return timeA - timeB;
|
||||
});
|
||||
@@ -90,7 +230,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
<div
|
||||
key={provider.id}
|
||||
className={cn(
|
||||
isCurrent ? cardStyles.selected : cardStyles.interactive
|
||||
isCurrent ? cardStyles.selected : cardStyles.interactive,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -99,12 +239,16 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{provider.name}
|
||||
</h3>
|
||||
{isCurrent && (
|
||||
<div className={badgeStyles.success}>
|
||||
<CheckCircle2 size={12} />
|
||||
当前使用
|
||||
</div>
|
||||
)}
|
||||
{/* 分类徽章已移除 */}
|
||||
<div
|
||||
className={cn(
|
||||
badgeStyles.success,
|
||||
!isCurrent && "invisible",
|
||||
)}
|
||||
>
|
||||
<CheckCircle2 size={12} />
|
||||
当前使用
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
@@ -131,6 +275,32 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{appType === "codex" &&
|
||||
provider.category !== "official" && (
|
||||
<button
|
||||
onClick={() =>
|
||||
vscodeAppliedFor === provider.id
|
||||
? handleRemoveFromVSCode()
|
||||
: handleApplyToVSCode(provider)
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[130px] justify-center",
|
||||
!isCurrent && "invisible",
|
||||
vscodeAppliedFor === provider.id
|
||||
? "bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
: "bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
|
||||
)}
|
||||
title={
|
||||
vscodeAppliedFor === provider.id
|
||||
? "从 VS Code 移除我们写入的配置"
|
||||
: "将当前供应商应用到 VS Code"
|
||||
}
|
||||
>
|
||||
{vscodeAppliedFor === provider.id
|
||||
? "从 VS Code 移除"
|
||||
: "应用到 VS Code"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onSwitch(provider.id)}
|
||||
disabled={isCurrent}
|
||||
@@ -138,7 +308,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
|
||||
isCurrent
|
||||
? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed"
|
||||
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||
)}
|
||||
>
|
||||
<Play size={14} />
|
||||
@@ -160,7 +330,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
buttonStyles.icon,
|
||||
isCurrent
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10",
|
||||
)}
|
||||
title="删除供应商"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Info, RefreshCw, FolderOpen } from "lucide-react";
|
||||
import {
|
||||
X,
|
||||
RefreshCw,
|
||||
FolderOpen,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import "../lib/tauri-api";
|
||||
import { runUpdateFlow } from "../lib/updater";
|
||||
import { relaunchApp } from "../lib/updater";
|
||||
import { useUpdate } from "../contexts/UpdateContext";
|
||||
import type { Settings } from "../types";
|
||||
|
||||
interface SettingsModalProps {
|
||||
@@ -11,11 +19,15 @@ interface SettingsModalProps {
|
||||
|
||||
export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
showInDock: true,
|
||||
showInTray: true,
|
||||
});
|
||||
const [configPath, setConfigPath] = useState<string>("");
|
||||
const [version, setVersion] = useState<string>("");
|
||||
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [showUpToDate, setShowUpToDate] = useState(false);
|
||||
const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } =
|
||||
useUpdate();
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
@@ -29,15 +41,19 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
setVersion(appVersion);
|
||||
} catch (error) {
|
||||
console.error("获取版本信息失败:", error);
|
||||
setVersion("3.1.1"); // 降级使用默认版本
|
||||
// 失败时不硬编码版本号,显示为未知
|
||||
setVersion("未知");
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const loadedSettings = await window.api.getSettings();
|
||||
if (loadedSettings?.showInDock !== undefined) {
|
||||
setSettings({ showInDock: loadedSettings.showInDock });
|
||||
if ((loadedSettings as any)?.showInTray !== undefined) {
|
||||
setSettings({ showInTray: (loadedSettings as any).showInTray });
|
||||
} else if ((loadedSettings as any)?.showInDock !== undefined) {
|
||||
// 向后兼容:若历史上有 showInDock,则映射为 showInTray
|
||||
setSettings({ showInTray: (loadedSettings as any).showInDock });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载设置失败:", error);
|
||||
@@ -65,15 +81,49 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
};
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setIsCheckingUpdate(true);
|
||||
try {
|
||||
// 优先使用 Tauri Updater 流程;失败时回退到打开 Releases 页面
|
||||
await runUpdateFlow({ timeout: 30000 });
|
||||
} catch (error) {
|
||||
console.error("检查更新失败,回退到 Releases 页面:", error);
|
||||
await window.api.checkForUpdates();
|
||||
} finally {
|
||||
setIsCheckingUpdate(false);
|
||||
if (hasUpdate && updateHandle) {
|
||||
// 已检测到更新:直接复用 updateHandle 下载并安装,避免重复检查
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
resetDismiss();
|
||||
await updateHandle.downloadAndInstall();
|
||||
await relaunchApp();
|
||||
} catch (error) {
|
||||
console.error("更新失败:", error);
|
||||
// 更新失败时回退到打开 Releases 页面
|
||||
await window.api.checkForUpdates();
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
} else {
|
||||
// 尚未检测到更新:先检查
|
||||
setIsCheckingUpdate(true);
|
||||
setShowUpToDate(false);
|
||||
try {
|
||||
const hasNewUpdate = await checkUpdate();
|
||||
// 检查完成后,如果没有更新,显示"已是最新"
|
||||
if (!hasNewUpdate) {
|
||||
setShowUpToDate(true);
|
||||
// 3秒后恢复按钮文字
|
||||
setTimeout(() => {
|
||||
setShowUpToDate(false);
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("检查更新失败:", error);
|
||||
// 在开发模式下,模拟已是最新版本的响应
|
||||
if (import.meta.env.DEV) {
|
||||
setShowUpToDate(true);
|
||||
setTimeout(() => {
|
||||
setShowUpToDate(false);
|
||||
}, 3000);
|
||||
} else {
|
||||
// 生产环境下如果更新插件不可用,回退到打开 Releases 页面
|
||||
await window.api.checkForUpdates();
|
||||
}
|
||||
} finally {
|
||||
setIsCheckingUpdate(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,9 +135,36 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenReleaseNotes = async () => {
|
||||
try {
|
||||
const targetVersion = updateInfo?.availableVersion || version;
|
||||
// 如果未知或为空,回退到 releases 首页
|
||||
if (!targetVersion || targetVersion === "未知") {
|
||||
await window.api.openExternal(
|
||||
"https://github.com/farion1231/cc-switch/releases",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const tag = targetVersion.startsWith("v")
|
||||
? targetVersion
|
||||
: `v${targetVersion}`;
|
||||
await window.api.openExternal(
|
||||
`https://github.com/farion1231/cc-switch/releases/tag/${tag}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("打开更新日志失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] overflow-hidden">
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] overflow-hidden">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-blue-500 dark:text-blue-400">
|
||||
@@ -103,26 +180,29 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
|
||||
{/* 设置内容 */}
|
||||
<div className="px-6 py-4 space-y-6">
|
||||
{/* 显示设置 - 功能还未实现 */}
|
||||
{/* 系统托盘设置(未实现)
|
||||
说明:此开关用于控制是否在系统托盘/菜单栏显示应用图标。 */}
|
||||
{/* <div>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
显示设置
|
||||
显示设置(系统托盘)
|
||||
</h3>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">
|
||||
在 Dock 中显示(macOS)
|
||||
在菜单栏显示图标(系统托盘)
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showInDock}
|
||||
checked={settings.showInTray}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, showInDock: e.target.checked })
|
||||
setSettings({ ...settings, showInTray: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
|
||||
/>
|
||||
</label>
|
||||
</div> */}
|
||||
|
||||
{/* VS Code 自动同步设置已移除 */}
|
||||
|
||||
{/* 配置文件位置 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
@@ -154,11 +234,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
</h3>
|
||||
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info
|
||||
size={18}
|
||||
className="text-gray-500 mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
||||
CC Switch
|
||||
@@ -168,24 +244,57 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={isCheckingUpdate}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
|
||||
isCheckingUpdate
|
||||
? "bg-white dark:bg-gray-700 text-gray-400 dark:text-gray-500"
|
||||
: "bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-blue-500 dark:text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{isCheckingUpdate ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<RefreshCw size={12} className="animate-spin" />
|
||||
检查中...
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleOpenReleaseNotes}
|
||||
className="px-2 py-1 text-xs font-medium text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 rounded-lg hover:bg-blue-500/10 transition-colors"
|
||||
title={
|
||||
hasUpdate ? "查看该版本更新日志" : "查看当前版本更新日志"
|
||||
}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ExternalLink size={12} />
|
||||
更新日志
|
||||
</span>
|
||||
) : (
|
||||
"检查更新"
|
||||
)}
|
||||
</button>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={isCheckingUpdate || isDownloading}
|
||||
className={`min-w-[88px] px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
|
||||
isCheckingUpdate || isDownloading
|
||||
? "bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed border border-transparent"
|
||||
: hasUpdate
|
||||
? "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white border border-transparent"
|
||||
: showUpToDate
|
||||
? "bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border border-green-200 dark:border-green-800"
|
||||
: "bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-blue-500 dark:text-blue-400 border border-gray-200 dark:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Download size={12} className="animate-pulse" />
|
||||
更新中...
|
||||
</span>
|
||||
) : isCheckingUpdate ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<RefreshCw size={12} className="animate-spin" />
|
||||
检查中...
|
||||
</span>
|
||||
) : hasUpdate ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Download size={12} />
|
||||
更新到 v{updateInfo?.availableVersion}
|
||||
</span>
|
||||
) : showUpToDate ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Check size={12} />
|
||||
已是最新
|
||||
</span>
|
||||
) : (
|
||||
"检查更新"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
61
src/components/UpdateBadge.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { X, Download } from "lucide-react";
|
||||
import { useUpdate } from "../contexts/UpdateContext";
|
||||
|
||||
interface UpdateBadgeProps {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
||||
const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate();
|
||||
|
||||
// 如果没有更新或已关闭,不显示
|
||||
if (!hasUpdate || isDismissed || !updateInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-1.5 px-2.5 py-1
|
||||
bg-white dark:bg-gray-800
|
||||
border border-gray-200 dark:border-gray-700
|
||||
rounded-lg text-xs
|
||||
shadow-sm
|
||||
transition-all duration-200
|
||||
${onClick ? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750" : ""}
|
||||
${className}
|
||||
`}
|
||||
role={onClick ? "button" : undefined}
|
||||
tabIndex={onClick ? 0 : -1}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (!onClick) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="w-3 h-3 text-blue-500 dark:text-blue-400" />
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium">
|
||||
v{updateInfo.availableVersion}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
dismissUpdate();
|
||||
}}
|
||||
className="
|
||||
ml-1 -mr-0.5 p-0.5 rounded
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
transition-colors
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/20
|
||||
"
|
||||
aria-label="关闭更新提醒"
|
||||
>
|
||||
<X className="w-3 h-3 text-gray-400 dark:text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* Codex 预设供应商配置模板
|
||||
*/
|
||||
import { ProviderCategory } from "../types";
|
||||
|
||||
export interface CodexProviderPreset {
|
||||
name: string;
|
||||
websiteUrl: string;
|
||||
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
|
||||
config: string; // 将写入 ~/.codex/config.toml(TOML 字符串)
|
||||
isOfficial?: boolean; // 标识是否为官方预设
|
||||
category?: ProviderCategory; // 新增:分类
|
||||
}
|
||||
|
||||
export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
@@ -14,6 +17,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
name: "Codex官方",
|
||||
websiteUrl: "https://chatgpt.com/codex",
|
||||
isOfficial: true,
|
||||
category: "official",
|
||||
// 官方的 key 为null
|
||||
auth: {
|
||||
OPENAI_API_KEY: null,
|
||||
@@ -23,6 +27,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
{
|
||||
name: "PackyCode",
|
||||
websiteUrl: "https://codex.packycode.com/",
|
||||
category: "third_party",
|
||||
// PackyCode 一般通过 API Key;请将占位符替换为你的实际 key
|
||||
auth: {
|
||||
OPENAI_API_KEY: "sk-your-api-key-here",
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
/**
|
||||
* 预设供应商配置模板
|
||||
*/
|
||||
import { ProviderCategory } from "../types";
|
||||
|
||||
export interface ProviderPreset {
|
||||
name: string;
|
||||
websiteUrl: string;
|
||||
settingsConfig: object;
|
||||
isOfficial?: boolean; // 标识是否为官方预设
|
||||
category?: ProviderCategory; // 新增:分类
|
||||
}
|
||||
|
||||
export const providerPresets: ProviderPreset[] = [
|
||||
{
|
||||
name: "Claude官方登录",
|
||||
name: "Claude官方",
|
||||
websiteUrl: "https://www.anthropic.com/claude-code",
|
||||
settingsConfig: {
|
||||
env: {},
|
||||
},
|
||||
isOfficial: true, // 明确标识为官方预设
|
||||
category: "official",
|
||||
},
|
||||
{
|
||||
name: "DeepSeek v3.1",
|
||||
name: "DeepSeek",
|
||||
websiteUrl: "https://platform.deepseek.com",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
@@ -28,6 +32,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "deepseek-chat",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
},
|
||||
{
|
||||
name: "智谱GLM",
|
||||
@@ -36,19 +41,25 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "GLM-4.5",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "GLM-4.5-Air",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
},
|
||||
{
|
||||
name: "千问Qwen-Coder",
|
||||
name: "Qwen-Coder",
|
||||
websiteUrl: "https://bailian.console.aliyun.com",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL:
|
||||
"https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "qwen3-coder-plus",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "qwen3-coder-plus",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
},
|
||||
{
|
||||
name: "Kimi k2",
|
||||
@@ -61,18 +72,20 @@ export const providerPresets: ProviderPreset[] = [
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
},
|
||||
{
|
||||
name: "魔搭",
|
||||
websiteUrl: "https://modelscope.cn",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: "ms-your-api-key",
|
||||
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.5",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.5",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
},
|
||||
{
|
||||
name: "PackyCode",
|
||||
@@ -83,5 +96,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
},
|
||||
];
|
||||
|
||||
155
src/contexts/UpdateContext.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import type { UpdateInfo, UpdateHandle } from "../lib/updater";
|
||||
import { checkForUpdate } from "../lib/updater";
|
||||
|
||||
interface UpdateContextValue {
|
||||
// 更新状态
|
||||
hasUpdate: boolean;
|
||||
updateInfo: UpdateInfo | null;
|
||||
updateHandle: UpdateHandle | null;
|
||||
isChecking: boolean;
|
||||
error: string | null;
|
||||
|
||||
// 提示状态
|
||||
isDismissed: boolean;
|
||||
dismissUpdate: () => void;
|
||||
|
||||
// 操作方法
|
||||
checkUpdate: () => Promise<boolean>;
|
||||
resetDismiss: () => void;
|
||||
}
|
||||
|
||||
const UpdateContext = createContext<UpdateContextValue | undefined>(undefined);
|
||||
|
||||
export function UpdateProvider({ children }: { children: React.ReactNode }) {
|
||||
const DISMISSED_VERSION_KEY = "ccswitch:update:dismissedVersion";
|
||||
const LEGACY_DISMISSED_KEY = "dismissedUpdateVersion"; // 兼容旧键
|
||||
|
||||
const [hasUpdate, setHasUpdate] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [updateHandle, setUpdateHandle] = useState<UpdateHandle | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
// 从 localStorage 读取已关闭的版本
|
||||
useEffect(() => {
|
||||
const current = updateInfo?.availableVersion;
|
||||
if (!current) return;
|
||||
|
||||
// 读取新键;若不存在,尝试迁移旧键
|
||||
let dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY);
|
||||
if (!dismissedVersion) {
|
||||
const legacy = localStorage.getItem(LEGACY_DISMISSED_KEY);
|
||||
if (legacy) {
|
||||
localStorage.setItem(DISMISSED_VERSION_KEY, legacy);
|
||||
localStorage.removeItem(LEGACY_DISMISSED_KEY);
|
||||
dismissedVersion = legacy;
|
||||
}
|
||||
}
|
||||
|
||||
setIsDismissed(dismissedVersion === current);
|
||||
}, [updateInfo?.availableVersion]);
|
||||
|
||||
const isCheckingRef = useRef(false);
|
||||
|
||||
const checkUpdate = useCallback(async () => {
|
||||
if (isCheckingRef.current) return false;
|
||||
isCheckingRef.current = true;
|
||||
setIsChecking(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await checkForUpdate({ timeout: 30000 });
|
||||
|
||||
if (result.status === "available") {
|
||||
setHasUpdate(true);
|
||||
setUpdateInfo(result.info);
|
||||
setUpdateHandle(result.update);
|
||||
|
||||
// 检查是否已经关闭过这个版本的提醒
|
||||
let dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY);
|
||||
if (!dismissedVersion) {
|
||||
const legacy = localStorage.getItem(LEGACY_DISMISSED_KEY);
|
||||
if (legacy) {
|
||||
localStorage.setItem(DISMISSED_VERSION_KEY, legacy);
|
||||
localStorage.removeItem(LEGACY_DISMISSED_KEY);
|
||||
dismissedVersion = legacy;
|
||||
}
|
||||
}
|
||||
setIsDismissed(dismissedVersion === result.info.availableVersion);
|
||||
return true; // 有更新
|
||||
} else {
|
||||
setHasUpdate(false);
|
||||
setUpdateInfo(null);
|
||||
setUpdateHandle(null);
|
||||
setIsDismissed(false);
|
||||
return false; // 已是最新
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("检查更新失败:", err);
|
||||
setError(err instanceof Error ? err.message : "检查更新失败");
|
||||
setHasUpdate(false);
|
||||
throw err; // 抛出错误让调用方处理
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
isCheckingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissUpdate = useCallback(() => {
|
||||
setIsDismissed(true);
|
||||
if (updateInfo?.availableVersion) {
|
||||
localStorage.setItem(DISMISSED_VERSION_KEY, updateInfo.availableVersion);
|
||||
// 清理旧键
|
||||
localStorage.removeItem(LEGACY_DISMISSED_KEY);
|
||||
}
|
||||
}, [updateInfo?.availableVersion]);
|
||||
|
||||
const resetDismiss = useCallback(() => {
|
||||
setIsDismissed(false);
|
||||
localStorage.removeItem(DISMISSED_VERSION_KEY);
|
||||
localStorage.removeItem(LEGACY_DISMISSED_KEY);
|
||||
}, []);
|
||||
|
||||
// 应用启动时自动检查更新
|
||||
useEffect(() => {
|
||||
// 延迟1秒后检查,避免影响启动体验
|
||||
const timer = setTimeout(() => {
|
||||
checkUpdate().catch(console.error);
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [checkUpdate]);
|
||||
|
||||
const value: UpdateContextValue = {
|
||||
hasUpdate,
|
||||
updateInfo,
|
||||
updateHandle,
|
||||
isChecking,
|
||||
error,
|
||||
isDismissed,
|
||||
dismissUpdate,
|
||||
checkUpdate,
|
||||
resetDismiss,
|
||||
};
|
||||
|
||||
return (
|
||||
<UpdateContext.Provider value={value}>{children}</UpdateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUpdate() {
|
||||
const context = useContext(UpdateContext);
|
||||
if (!context) {
|
||||
throw new Error("useUpdate must be used within UpdateProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,55 +1,60 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useDarkMode() {
|
||||
// 初始设为 false,挂载后在 useEffect 中加载真实值
|
||||
const [isDarkMode, setIsDarkMode] = useState<boolean>(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
// 组件挂载后加载初始值(兼容 Tauri 环境)
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
// 尝试读取已保存的偏好
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
const saved = localStorage.getItem("darkMode");
|
||||
if (saved !== null) {
|
||||
const savedBool = saved === 'true';
|
||||
const savedBool = saved === "true";
|
||||
setIsDarkMode(savedBool);
|
||||
console.log('[DarkMode] Loaded from localStorage:', savedBool);
|
||||
if (isDev)
|
||||
console.log("[DarkMode] Loaded from localStorage:", savedBool);
|
||||
} else {
|
||||
// 回退到系统偏好
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const prefersDark =
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
setIsDarkMode(prefersDark);
|
||||
console.log('[DarkMode] Using system preference:', prefersDark);
|
||||
if (isDev)
|
||||
console.log("[DarkMode] Using system preference:", prefersDark);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DarkMode] Error loading preference:', error);
|
||||
console.error("[DarkMode] Error loading preference:", error);
|
||||
setIsDarkMode(false);
|
||||
}
|
||||
|
||||
|
||||
setIsInitialized(true);
|
||||
}, []); // 仅在首次挂载时运行
|
||||
|
||||
// 将 dark 类应用到文档根节点
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
|
||||
// 添加短暂延迟以确保 Tauri 中 DOM 已就绪
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
console.log('[DarkMode] Added dark class to document');
|
||||
document.documentElement.classList.add("dark");
|
||||
if (isDev) console.log("[DarkMode] Added dark class to document");
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
console.log('[DarkMode] Removed dark class from document');
|
||||
document.documentElement.classList.remove("dark");
|
||||
if (isDev) console.log("[DarkMode] Removed dark class from document");
|
||||
}
|
||||
|
||||
|
||||
// 检查类名是否已成功应用
|
||||
const hasClass = document.documentElement.classList.contains('dark');
|
||||
console.log('[DarkMode] Document has dark class:', hasClass);
|
||||
const hasClass = document.documentElement.classList.contains("dark");
|
||||
if (isDev) console.log("[DarkMode] Document has dark class:", hasClass);
|
||||
} catch (error) {
|
||||
console.error('[DarkMode] Error applying dark class:', error);
|
||||
console.error("[DarkMode] Error applying dark class:", error);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
@@ -59,19 +64,19 @@ export function useDarkMode() {
|
||||
// 将偏好保存到 localStorage
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
|
||||
try {
|
||||
localStorage.setItem('darkMode', isDarkMode.toString());
|
||||
console.log('[DarkMode] Saved to localStorage:', isDarkMode);
|
||||
localStorage.setItem("darkMode", isDarkMode.toString());
|
||||
if (isDev) console.log("[DarkMode] Saved to localStorage:", isDarkMode);
|
||||
} catch (error) {
|
||||
console.error('[DarkMode] Error saving preference:', error);
|
||||
console.error("[DarkMode] Error saving preference:", error);
|
||||
}
|
||||
}, [isDarkMode, isInitialized]);
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
setIsDarkMode(prev => {
|
||||
setIsDarkMode((prev) => {
|
||||
const newValue = !prev;
|
||||
console.log('[DarkMode] Toggling from', prev, 'to', newValue);
|
||||
if (isDev) console.log("[DarkMode] Toggling from", prev, "to", newValue);
|
||||
return newValue;
|
||||
});
|
||||
};
|
||||
|
||||
99
src/hooks/useVSCodeAutoSync.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
const VSCODE_AUTO_SYNC_KEY = "vscode-auto-sync-enabled";
|
||||
const VSCODE_AUTO_SYNC_EVENT = "vscode-auto-sync-changed";
|
||||
|
||||
export function useVSCodeAutoSync() {
|
||||
// 默认开启自动同步;若本地存储存在记录,则以记录为准
|
||||
const [isAutoSyncEnabled, setIsAutoSyncEnabled] = useState<boolean>(true);
|
||||
|
||||
// 从 localStorage 读取初始状态
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(VSCODE_AUTO_SYNC_KEY);
|
||||
if (saved !== null) {
|
||||
setIsAutoSyncEnabled(saved === "true");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("读取自动同步状态失败:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 订阅同窗口的自定义事件,以及跨窗口的 storage 事件,实现全局同步
|
||||
useEffect(() => {
|
||||
const onCustom = (e: Event) => {
|
||||
try {
|
||||
const detail = (e as CustomEvent).detail as
|
||||
| { enabled?: boolean }
|
||||
| undefined;
|
||||
if (detail && typeof detail.enabled === "boolean") {
|
||||
setIsAutoSyncEnabled(detail.enabled);
|
||||
} else {
|
||||
// 兜底:从 localStorage 读取
|
||||
const saved = localStorage.getItem(VSCODE_AUTO_SYNC_KEY);
|
||||
if (saved !== null) setIsAutoSyncEnabled(saved === "true");
|
||||
}
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
};
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === VSCODE_AUTO_SYNC_KEY) {
|
||||
setIsAutoSyncEnabled(e.newValue === "true");
|
||||
}
|
||||
};
|
||||
window.addEventListener(VSCODE_AUTO_SYNC_EVENT, onCustom as EventListener);
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
VSCODE_AUTO_SYNC_EVENT,
|
||||
onCustom as EventListener,
|
||||
);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 启用自动同步
|
||||
const enableAutoSync = useCallback(() => {
|
||||
try {
|
||||
localStorage.setItem(VSCODE_AUTO_SYNC_KEY, "true");
|
||||
setIsAutoSyncEnabled(true);
|
||||
// 通知同窗口其他订阅者
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(VSCODE_AUTO_SYNC_EVENT, { detail: { enabled: true } }),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("保存自动同步状态失败:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 禁用自动同步
|
||||
const disableAutoSync = useCallback(() => {
|
||||
try {
|
||||
localStorage.setItem(VSCODE_AUTO_SYNC_KEY, "false");
|
||||
setIsAutoSyncEnabled(false);
|
||||
// 通知同窗口其他订阅者
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(VSCODE_AUTO_SYNC_EVENT, { detail: { enabled: false } }),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("保存自动同步状态失败:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 切换自动同步状态
|
||||
const toggleAutoSync = useCallback(() => {
|
||||
if (isAutoSyncEnabled) {
|
||||
disableAutoSync();
|
||||
} else {
|
||||
enableAutoSync();
|
||||
}
|
||||
}, [isAutoSyncEnabled, enableAutoSync, disableAutoSync]);
|
||||
|
||||
return {
|
||||
isAutoSyncEnabled,
|
||||
enableAutoSync,
|
||||
disableAutoSync,
|
||||
toggleAutoSync,
|
||||
};
|
||||
}
|
||||
@@ -21,23 +21,33 @@ body {
|
||||
}
|
||||
|
||||
/* 暗色模式下启用暗色原生控件/滚动条配色 */
|
||||
html.dark { color-scheme: dark; }
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
/* 滚动条样式(避免在伪元素中使用自定义 dark 变体,消除构建警告) */
|
||||
::-webkit-scrollbar {
|
||||
@apply w-1.5 h-1.5;
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 dark:bg-gray-800;
|
||||
background-color: #f4f4f5;
|
||||
}
|
||||
html.dark ::-webkit-scrollbar-track {
|
||||
background-color: #27272a;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 rounded dark:bg-gray-600;
|
||||
background-color: #d4d4d8;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
html.dark ::-webkit-scrollbar-thumb {
|
||||
background-color: #52525b;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400 dark:bg-gray-500;
|
||||
background-color: #a1a1aa;
|
||||
}
|
||||
html.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #71717a;
|
||||
}
|
||||
|
||||
/* 焦点样式 */
|
||||
|
||||
@@ -5,20 +5,24 @@
|
||||
// 按钮样式
|
||||
export const buttonStyles = {
|
||||
// 主按钮:蓝底白字
|
||||
primary: "px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium",
|
||||
|
||||
primary:
|
||||
"px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium",
|
||||
|
||||
// 次按钮:灰背景,深色文本
|
||||
secondary: "px-4 py-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 rounded-lg transition-colors text-sm font-medium",
|
||||
|
||||
secondary:
|
||||
"px-4 py-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 rounded-lg transition-colors text-sm font-medium",
|
||||
|
||||
// 危险按钮:用于不可撤销/破坏性操作
|
||||
danger: "px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700 transition-colors text-sm font-medium",
|
||||
|
||||
danger:
|
||||
"px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700 transition-colors text-sm font-medium",
|
||||
|
||||
// 幽灵按钮:无背景,仅悬浮反馈
|
||||
ghost: "px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-sm font-medium",
|
||||
|
||||
ghost:
|
||||
"px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-sm font-medium",
|
||||
|
||||
// 图标按钮:小尺寸,仅图标
|
||||
icon: "p-1.5 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors",
|
||||
|
||||
|
||||
// 禁用态:可与其他样式组合
|
||||
disabled: "opacity-50 cursor-not-allowed pointer-events-none",
|
||||
} as const;
|
||||
@@ -27,42 +31,49 @@ export const buttonStyles = {
|
||||
export const cardStyles = {
|
||||
// 基础卡片容器
|
||||
base: "bg-white rounded-lg border border-gray-200 p-4 dark:bg-gray-900 dark:border-gray-700",
|
||||
|
||||
|
||||
// 带悬浮效果的卡片
|
||||
interactive: "bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 hover:shadow-sm dark:bg-gray-900 dark:border-gray-700 dark:hover:border-gray-600 transition-all duration-200",
|
||||
|
||||
interactive:
|
||||
"bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 hover:shadow-sm dark:bg-gray-900 dark:border-gray-700 dark:hover:border-gray-600 transition-all duration-200",
|
||||
|
||||
// 选中/激活态卡片
|
||||
selected: "bg-white rounded-lg border border-blue-500 ring-1 ring-blue-500/20 bg-blue-500/5 p-4 dark:bg-gray-900 dark:border-blue-400 dark:ring-blue-400/20 dark:bg-blue-400/10",
|
||||
selected:
|
||||
"bg-white rounded-lg border border-blue-500 shadow-sm bg-blue-50 p-4 dark:bg-gray-900 dark:border-blue-400 dark:bg-blue-400/10",
|
||||
} as const;
|
||||
|
||||
// 输入控件样式
|
||||
export const inputStyles = {
|
||||
// 文本输入框
|
||||
text: "w-full px-3 py-2 border border-gray-200 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 outline-none dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-400/20 transition-colors",
|
||||
|
||||
|
||||
// 下拉选择框
|
||||
select: "w-full px-3 py-2 border border-gray-200 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 outline-none bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-400/20 transition-colors",
|
||||
|
||||
select:
|
||||
"w-full px-3 py-2 border border-gray-200 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 outline-none bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-400/20 transition-colors",
|
||||
|
||||
// 复选框
|
||||
checkbox: "w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20 border-gray-300 dark:border-gray-600 dark:bg-gray-800",
|
||||
checkbox:
|
||||
"w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20 border-gray-300 dark:border-gray-600 dark:bg-gray-800",
|
||||
} as const;
|
||||
|
||||
// 徽标(Badge)样式
|
||||
export const badgeStyles = {
|
||||
// 成功徽标
|
||||
success: "inline-flex items-center gap-1 px-2 py-1 bg-green-500/10 text-green-500 rounded-md text-xs font-medium",
|
||||
|
||||
success:
|
||||
"inline-flex items-center gap-1 px-2 py-1 bg-green-500/10 text-green-500 rounded-md text-xs font-medium",
|
||||
|
||||
// 信息徽标
|
||||
info: "inline-flex items-center gap-1 px-2 py-1 bg-blue-500/10 text-blue-500 rounded-md text-xs font-medium",
|
||||
|
||||
|
||||
// 警告徽标
|
||||
warning: "inline-flex items-center gap-1 px-2 py-1 bg-amber-500/10 text-amber-500 rounded-md text-xs font-medium",
|
||||
|
||||
warning:
|
||||
"inline-flex items-center gap-1 px-2 py-1 bg-amber-500/10 text-amber-500 rounded-md text-xs font-medium",
|
||||
|
||||
// 错误徽标
|
||||
error: "inline-flex items-center gap-1 px-2 py-1 bg-red-500/10 text-red-500 rounded-md text-xs font-medium",
|
||||
error:
|
||||
"inline-flex items-center gap-1 px-2 py-1 bg-red-500/10 text-red-500 rounded-md text-xs font-medium",
|
||||
} as const;
|
||||
|
||||
// 组合类名的工具函数
|
||||
export function cn(...classes: (string | undefined | false)[]) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ export const tauriAPI = {
|
||||
return await invoke("get_settings");
|
||||
} catch (error) {
|
||||
console.error("获取设置失败:", error);
|
||||
return { showInDock: true };
|
||||
return { showInTray: true };
|
||||
}
|
||||
},
|
||||
|
||||
@@ -242,6 +242,38 @@ export const tauriAPI = {
|
||||
console.error("打开应用配置文件夹失败:", error);
|
||||
}
|
||||
},
|
||||
|
||||
// VS Code: 获取 settings.json 状态
|
||||
getVSCodeSettingsStatus: async (): Promise<{
|
||||
exists: boolean;
|
||||
path: string;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
return await invoke("get_vscode_settings_status");
|
||||
} catch (error) {
|
||||
console.error("获取 VS Code 设置状态失败:", error);
|
||||
return { exists: false, path: "", error: String(error) };
|
||||
}
|
||||
},
|
||||
|
||||
// VS Code: 读取 settings.json 文本
|
||||
readVSCodeSettings: async (): Promise<string> => {
|
||||
try {
|
||||
return await invoke("read_vscode_settings");
|
||||
} catch (error) {
|
||||
throw new Error(`读取 VS Code 设置失败: ${String(error)}`);
|
||||
}
|
||||
},
|
||||
|
||||
// VS Code: 写回 settings.json 文本(不自动创建)
|
||||
writeVSCodeSettings: async (content: string): Promise<boolean> => {
|
||||
try {
|
||||
return await invoke("write_vscode_settings", { content });
|
||||
} catch (error) {
|
||||
throw new Error(`写入 VS Code 设置失败: ${String(error)}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 创建全局 API 对象,兼容现有代码
|
||||
|
||||
@@ -122,25 +122,5 @@ export async function relaunchApp(): Promise<void> {
|
||||
await relaunch();
|
||||
}
|
||||
|
||||
export async function runUpdateFlow(
|
||||
opts: CheckOptions = {},
|
||||
): Promise<{ status: "up-to-date" | "done" }> {
|
||||
const result = await checkForUpdate(opts);
|
||||
if (result.status === "up-to-date") return result;
|
||||
|
||||
let downloaded = 0;
|
||||
let total = 0;
|
||||
await result.update.downloadAndInstall((e) => {
|
||||
if (e.event === "Started") {
|
||||
total = e.total ?? 0;
|
||||
downloaded = 0;
|
||||
} else if (e.event === "Progress") {
|
||||
downloaded += e.downloaded ?? 0;
|
||||
// 调用方可监听此处并更新 UI(目前设置页仅显示加载态)
|
||||
console.debug("update progress", { downloaded, total });
|
||||
}
|
||||
});
|
||||
|
||||
await relaunchApp();
|
||||
return { status: "done" };
|
||||
}
|
||||
// 旧的聚合更新流程已由调用方直接使用 updateHandle 取代
|
||||
// 如需单函数封装,可在需要时基于 checkForUpdate + updateHandle 复合调用
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { UpdateProvider } from "./contexts/UpdateContext";
|
||||
import "./index.css";
|
||||
// 导入 Tauri API(自动绑定到 window.api)
|
||||
import "./lib/tauri-api";
|
||||
@@ -19,6 +20,8 @@ try {
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<UpdateProvider>
|
||||
<App />
|
||||
</UpdateProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
12
src/types.ts
@@ -1,8 +1,17 @@
|
||||
export type ProviderCategory =
|
||||
| "official" // 官方
|
||||
| "cn_official" // 国产官方
|
||||
| "aggregator" // 聚合网站
|
||||
| "third_party" // 第三方供应商
|
||||
| "custom"; // 自定义
|
||||
|
||||
export interface Provider {
|
||||
id: string;
|
||||
name: string;
|
||||
settingsConfig: Record<string, any>; // 应用配置对象:Claude 为 settings.json;Codex 为 { auth, config }
|
||||
websiteUrl?: string;
|
||||
// 新增:供应商分类(用于差异化提示/能力开关)
|
||||
category?: ProviderCategory;
|
||||
createdAt?: number; // 添加时间戳(毫秒)
|
||||
}
|
||||
|
||||
@@ -13,5 +22,6 @@ export interface AppConfig {
|
||||
|
||||
// 应用设置类型(用于 SettingsModal 与 Tauri API)
|
||||
export interface Settings {
|
||||
showInDock: boolean;
|
||||
// 是否在系统托盘(macOS 菜单栏)显示图标
|
||||
showInTray: boolean;
|
||||
}
|
||||
|
||||
38
src/utils/errorUtils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 从各种错误对象中提取错误信息
|
||||
* @param error 错误对象
|
||||
* @returns 提取的错误信息字符串
|
||||
*/
|
||||
export const extractErrorMessage = (error: unknown): string => {
|
||||
if (!error) return "";
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === "object") {
|
||||
const errObject = error as Record<string, unknown>;
|
||||
|
||||
const candidate = errObject.message ?? errObject.error ?? errObject.detail;
|
||||
if (typeof candidate === "string" && candidate.trim()) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const payload = errObject.payload;
|
||||
if (typeof payload === "string" && payload.trim()) {
|
||||
return payload;
|
||||
}
|
||||
if (payload && typeof payload === "object") {
|
||||
const payloadObj = payload as Record<string, unknown>;
|
||||
const payloadCandidate =
|
||||
payloadObj.message ?? payloadObj.error ?? payloadObj.detail;
|
||||
if (typeof payloadCandidate === "string" && payloadCandidate.trim()) {
|
||||
return payloadCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
@@ -1,33 +1,162 @@
|
||||
// 供应商配置处理工具函数
|
||||
|
||||
// 处理includeCoAuthoredBy字段的添加/删除
|
||||
export const updateCoAuthoredSetting = (
|
||||
jsonString: string,
|
||||
disable: boolean,
|
||||
): string => {
|
||||
try {
|
||||
const config = JSON.parse(jsonString);
|
||||
const isPlainObject = (value: unknown): value is Record<string, any> => {
|
||||
return Object.prototype.toString.call(value) === "[object Object]";
|
||||
};
|
||||
|
||||
if (disable) {
|
||||
// 添加或更新includeCoAuthoredBy字段
|
||||
config.includeCoAuthoredBy = false;
|
||||
const deepMerge = (
|
||||
target: Record<string, any>,
|
||||
source: Record<string, any>,
|
||||
): Record<string, any> => {
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
if (isPlainObject(value)) {
|
||||
if (!isPlainObject(target[key])) {
|
||||
target[key] = {};
|
||||
}
|
||||
deepMerge(target[key], value);
|
||||
} else {
|
||||
// 删除includeCoAuthoredBy字段
|
||||
delete config.includeCoAuthoredBy;
|
||||
// 直接覆盖非对象字段(数组/基础类型)
|
||||
target[key] = value;
|
||||
}
|
||||
});
|
||||
return target;
|
||||
};
|
||||
|
||||
return JSON.stringify(config, null, 2);
|
||||
} catch (err) {
|
||||
// 如果JSON解析失败,返回原始字符串
|
||||
return jsonString;
|
||||
const deepRemove = (
|
||||
target: Record<string, any>,
|
||||
source: Record<string, any>,
|
||||
) => {
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
if (!(key in target)) return;
|
||||
|
||||
if (isPlainObject(value) && isPlainObject(target[key])) {
|
||||
// 只移除完全匹配的嵌套属性
|
||||
deepRemove(target[key], value);
|
||||
if (Object.keys(target[key]).length === 0) {
|
||||
delete target[key];
|
||||
}
|
||||
} else if (isSubset(target[key], value)) {
|
||||
// 只有当值完全匹配时才删除
|
||||
delete target[key];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const isSubset = (target: any, source: any): boolean => {
|
||||
if (isPlainObject(source)) {
|
||||
if (!isPlainObject(target)) return false;
|
||||
return Object.entries(source).every(([key, value]) =>
|
||||
isSubset(target[key], value),
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(source)) {
|
||||
if (!Array.isArray(target) || target.length !== source.length) return false;
|
||||
return source.every((item, index) => isSubset(target[index], item));
|
||||
}
|
||||
|
||||
return target === source;
|
||||
};
|
||||
|
||||
// 深拷贝函数
|
||||
const deepClone = <T>(obj: T): T => {
|
||||
if (obj === null || typeof obj !== "object") return obj;
|
||||
if (obj instanceof Date) return new Date(obj.getTime()) as T;
|
||||
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T;
|
||||
if (obj instanceof Object) {
|
||||
const clonedObj = {} as T;
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
clonedObj[key] = deepClone(obj[key]);
|
||||
}
|
||||
}
|
||||
return clonedObj;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
export interface UpdateCommonConfigResult {
|
||||
updatedConfig: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 验证JSON配置格式
|
||||
export const validateJsonConfig = (
|
||||
value: string,
|
||||
fieldName: string = "配置",
|
||||
): string => {
|
||||
if (!value.trim()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return `${fieldName}必须是 JSON 对象`;
|
||||
}
|
||||
return "";
|
||||
} catch {
|
||||
return `${fieldName}JSON格式错误,请检查语法`;
|
||||
}
|
||||
};
|
||||
|
||||
// 从JSON配置中检查是否包含includeCoAuthoredBy设置
|
||||
export const checkCoAuthoredSetting = (jsonString: string): boolean => {
|
||||
// 将通用配置片段写入/移除 settingsConfig
|
||||
export const updateCommonConfigSnippet = (
|
||||
jsonString: string,
|
||||
snippetString: string,
|
||||
enabled: boolean,
|
||||
): UpdateCommonConfigResult => {
|
||||
let config: Record<string, any>;
|
||||
try {
|
||||
const config = JSON.parse(jsonString);
|
||||
return config.includeCoAuthoredBy === false;
|
||||
config = jsonString ? JSON.parse(jsonString) : {};
|
||||
} catch (err) {
|
||||
return {
|
||||
updatedConfig: jsonString,
|
||||
error: "配置 JSON 解析失败,无法写入通用配置",
|
||||
};
|
||||
}
|
||||
|
||||
if (!snippetString.trim()) {
|
||||
return {
|
||||
updatedConfig: JSON.stringify(config, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
// 使用统一的验证函数
|
||||
const snippetError = validateJsonConfig(snippetString, "通用配置片段");
|
||||
if (snippetError) {
|
||||
return {
|
||||
updatedConfig: JSON.stringify(config, null, 2),
|
||||
error: snippetError,
|
||||
};
|
||||
}
|
||||
|
||||
const snippet = JSON.parse(snippetString) as Record<string, any>;
|
||||
|
||||
if (enabled) {
|
||||
const merged = deepMerge(deepClone(config), snippet);
|
||||
return {
|
||||
updatedConfig: JSON.stringify(merged, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
const cloned = deepClone(config);
|
||||
deepRemove(cloned, snippet);
|
||||
return {
|
||||
updatedConfig: JSON.stringify(cloned, null, 2),
|
||||
};
|
||||
};
|
||||
|
||||
// 检查当前配置是否已包含通用配置片段
|
||||
export const hasCommonConfigSnippet = (
|
||||
jsonString: string,
|
||||
snippetString: string,
|
||||
): boolean => {
|
||||
try {
|
||||
if (!snippetString.trim()) return false;
|
||||
const config = jsonString ? JSON.parse(jsonString) : {};
|
||||
const snippet = JSON.parse(snippetString);
|
||||
if (!isPlainObject(snippet)) return false;
|
||||
return isSubset(config, snippet);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
@@ -79,3 +208,113 @@ export const setApiKeyInConfig = (
|
||||
return jsonString;
|
||||
}
|
||||
};
|
||||
|
||||
// ========== TOML Config Utilities ==========
|
||||
|
||||
export interface UpdateTomlCommonConfigResult {
|
||||
updatedConfig: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 保存之前的通用配置片段,用于替换操作
|
||||
let previousCommonSnippet = "";
|
||||
|
||||
// 将通用配置片段写入/移除 TOML 配置
|
||||
export const updateTomlCommonConfigSnippet = (
|
||||
tomlString: string,
|
||||
snippetString: string,
|
||||
enabled: boolean,
|
||||
): UpdateTomlCommonConfigResult => {
|
||||
if (!snippetString.trim()) {
|
||||
// 如果片段为空,直接返回原始配置
|
||||
return {
|
||||
updatedConfig: tomlString,
|
||||
};
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
// 添加通用配置
|
||||
// 先移除旧的通用配置(如果有)
|
||||
let updatedConfig = tomlString;
|
||||
if (previousCommonSnippet && tomlString.includes(previousCommonSnippet)) {
|
||||
updatedConfig = tomlString.replace(previousCommonSnippet, "");
|
||||
}
|
||||
|
||||
// 在文件末尾添加新的通用配置
|
||||
// 确保有适当的换行
|
||||
const needsNewline = updatedConfig && !updatedConfig.endsWith("\n");
|
||||
updatedConfig =
|
||||
updatedConfig + (needsNewline ? "\n\n" : "\n") + snippetString;
|
||||
|
||||
// 保存当前通用配置片段
|
||||
previousCommonSnippet = snippetString;
|
||||
|
||||
return {
|
||||
updatedConfig: updatedConfig.trim() + "\n",
|
||||
};
|
||||
} else {
|
||||
// 移除通用配置
|
||||
if (tomlString.includes(snippetString)) {
|
||||
const updatedConfig = tomlString.replace(snippetString, "");
|
||||
// 清理多余的空行
|
||||
const cleaned = updatedConfig.replace(/\n{3,}/g, "\n\n").trim();
|
||||
|
||||
// 清空保存的状态
|
||||
previousCommonSnippet = "";
|
||||
|
||||
return {
|
||||
updatedConfig: cleaned ? cleaned + "\n" : "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
updatedConfig: tomlString,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 检查 TOML 配置是否已包含通用配置片段
|
||||
export const hasTomlCommonConfigSnippet = (
|
||||
tomlString: string,
|
||||
snippetString: string,
|
||||
): boolean => {
|
||||
if (!snippetString.trim()) return false;
|
||||
|
||||
// 简单检查配置是否包含片段内容
|
||||
// 去除空白字符后比较,避免格式差异影响
|
||||
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, " ").trim();
|
||||
|
||||
return normalizeWhitespace(tomlString).includes(
|
||||
normalizeWhitespace(snippetString),
|
||||
);
|
||||
};
|
||||
|
||||
// ========== Codex base_url utils ==========
|
||||
|
||||
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)
|
||||
export const extractCodexBaseUrl = (
|
||||
configText: string | undefined | null,
|
||||
): string | undefined => {
|
||||
try {
|
||||
const text = typeof configText === "string" ? configText : "";
|
||||
if (!text) return undefined;
|
||||
const m = text.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
|
||||
return m && m[2] ? m[2] : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// 从 Provider 对象中提取 Codex base_url(当 settingsConfig.config 为 TOML 字符串时)
|
||||
export const getCodexBaseUrl = (
|
||||
provider: { settingsConfig?: Record<string, any> } | undefined | null,
|
||||
): string | undefined => {
|
||||
try {
|
||||
const text =
|
||||
typeof provider?.settingsConfig?.config === "string"
|
||||
? (provider as any).settingsConfig.config
|
||||
: "";
|
||||
return extractCodexBaseUrl(text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
124
src/utils/vscodeSettings.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { applyEdits, modify, parse } from "jsonc-parser";
|
||||
|
||||
const fmt = { insertSpaces: true, tabSize: 2, eol: "\n" } as const;
|
||||
|
||||
export interface AppliedCheck {
|
||||
hasApiBase: boolean;
|
||||
apiBase?: string;
|
||||
hasPreferredAuthMethod: boolean;
|
||||
}
|
||||
|
||||
export function normalizeBaseUrl(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
const isDocEmpty = (s: string) => s.trim().length === 0;
|
||||
|
||||
// 检查 settings.json(JSONC 文本)中是否已经应用了我们的键
|
||||
export function detectApplied(content: string): AppliedCheck {
|
||||
try {
|
||||
// 允许 JSONC 的宽松解析:jsonc-parser 的 parse 可以直接处理注释
|
||||
const data = parse(content) as any;
|
||||
const apiBase = data?.["chatgpt.apiBase"];
|
||||
const method = data?.["chatgpt.config"]?.preferred_auth_method;
|
||||
return {
|
||||
hasApiBase: typeof apiBase === "string",
|
||||
apiBase,
|
||||
hasPreferredAuthMethod: typeof method === "string",
|
||||
};
|
||||
} catch {
|
||||
return { hasApiBase: false, hasPreferredAuthMethod: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 生成“清理我们管理的键”后的文本(仅删除我们写入的两个键)
|
||||
export function removeManagedKeys(content: string): string {
|
||||
if (isDocEmpty(content)) return content; // 空文档无需删除
|
||||
let out = content;
|
||||
// 删除 chatgpt.apiBase
|
||||
try {
|
||||
out = applyEdits(
|
||||
out,
|
||||
modify(out, ["chatgpt.apiBase"], undefined, { formattingOptions: fmt }),
|
||||
);
|
||||
} catch {
|
||||
// 忽略删除失败
|
||||
}
|
||||
// 删除 chatgpt.config.preferred_auth_method(注意 chatgpt.config 是顶层带点的键)
|
||||
try {
|
||||
out = applyEdits(
|
||||
out,
|
||||
modify(out, ["chatgpt.config", "preferred_auth_method"], undefined, {
|
||||
formattingOptions: fmt,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// 忽略删除失败
|
||||
}
|
||||
|
||||
// 兼容早期错误写入:若曾写成嵌套 chatgpt.config.preferred_auth_method,也一并清理
|
||||
try {
|
||||
out = applyEdits(
|
||||
out,
|
||||
modify(out, ["chatgpt", "config", "preferred_auth_method"], undefined, {
|
||||
formattingOptions: fmt,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// 忽略删除失败
|
||||
}
|
||||
|
||||
// 若 chatgpt.config 变为空对象,顺便移除(不影响其他 chatgpt* 键)
|
||||
try {
|
||||
const data = parse(out) as any;
|
||||
const cfg = data?.["chatgpt.config"];
|
||||
if (
|
||||
cfg &&
|
||||
typeof cfg === "object" &&
|
||||
!Array.isArray(cfg) &&
|
||||
Object.keys(cfg).length === 0
|
||||
) {
|
||||
out = applyEdits(
|
||||
out,
|
||||
modify(out, ["chatgpt.config"], undefined, { formattingOptions: fmt }),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析失败,保持已删除的键
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// 生成“应用供应商到 VS Code”后的文本:
|
||||
// - 先清理我们管理的键
|
||||
// - 再根据是否官方决定写入(官方:不写入;非官方:写入两个键)
|
||||
export function applyProviderToVSCode(
|
||||
content: string,
|
||||
opts: { baseUrl?: string | null; isOfficial?: boolean },
|
||||
): string {
|
||||
let out = removeManagedKeys(content);
|
||||
if (!opts.isOfficial && opts.baseUrl) {
|
||||
const apiBase = normalizeBaseUrl(opts.baseUrl);
|
||||
if (isDocEmpty(out)) {
|
||||
// 简化:空文档直接写入新对象
|
||||
const obj: any = {
|
||||
"chatgpt.apiBase": apiBase,
|
||||
"chatgpt.config": { preferred_auth_method: "apikey" },
|
||||
};
|
||||
out = JSON.stringify(obj, null, 2) + "\n";
|
||||
} else {
|
||||
out = applyEdits(
|
||||
out,
|
||||
modify(out, ["chatgpt.apiBase"], apiBase, { formattingOptions: fmt }),
|
||||
);
|
||||
out = applyEdits(
|
||||
out,
|
||||
modify(out, ["chatgpt.config", "preferred_auth_method"], "apikey", {
|
||||
formattingOptions: fmt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
4
src/vite-env.d.ts
vendored
@@ -40,6 +40,10 @@ declare global {
|
||||
checkForUpdates: () => Promise<void>;
|
||||
getAppConfigPath: () => Promise<string>;
|
||||
openAppConfigFolder: () => Promise<void>;
|
||||
// VS Code settings.json 能力
|
||||
getVSCodeSettingsStatus: () => Promise<ConfigStatus>;
|
||||
readVSCodeSettings: () => Promise<string>;
|
||||
writeVSCodeSettings: (content: string) => Promise<boolean>;
|
||||
};
|
||||
platform: {
|
||||
isMac: boolean;
|
||||
|
||||