diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..c260414
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,12 @@
+{
+ "permissions": {
+ "allow": [
+ "WebSearch",
+ "Bash(find:*)",
+ "mcp__exa__web_search_exa",
+ "mcp__ace-tool__search_context"
+ ],
+ "deny": [],
+ "ask": []
+ }
+}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 77275b6..f1da945 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -18,6 +18,16 @@ jobs:
steps:
- uses: actions/checkout@v4
+ - name: Update version from tag
+ id: version
+ run: |
+ VERSION=${GITHUB_REF#refs/tags/v}
+ echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
+ echo "Updating version to: $VERSION"
+ sed -i '' "s/const AppVersion = \"v[^\"]*\"/const AppVersion = \"v$VERSION\"/" version_service.go
+ echo "Updated version_service.go:"
+ grep "AppVersion" version_service.go
+
- uses: actions/setup-go@v5
with:
go-version: '1.24'
@@ -56,13 +66,13 @@ jobs:
- name: Archive macOS app
run: |
cd bin
- ditto -c -k --sequesterRsrc --keepParent "$(basename ${{ steps.find-app.outputs.app_path }})" codeswitch-macos-${{ matrix.arch }}.zip
+ ditto -c -k --sequesterRsrc --keepParent "$(basename ${{ steps.find-app.outputs.app_path }})" CodeSwitch-v${{ steps.version.outputs.VERSION }}-macos-${{ matrix.arch }}.zip
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: macos-${{ matrix.arch }}
- path: bin/codeswitch-macos-${{ matrix.arch }}.zip
+ path: bin/CodeSwitch-v${{ steps.version.outputs.VERSION }}-macos-${{ matrix.arch }}.zip
build-windows:
name: Build Windows
@@ -70,6 +80,29 @@ jobs:
steps:
- uses: actions/checkout@v4
+ - name: Update version from tag
+ id: version
+ shell: pwsh
+ run: |
+ $VERSION = "${{ github.ref_name }}".TrimStart('v')
+ "VERSION=$VERSION" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
+ Write-Host "Updating version to: $VERSION"
+
+ # Update version_service.go
+ $content = Get-Content version_service.go -Raw
+ $content = $content -replace 'const AppVersion = "v[^"]*"', "const AppVersion = `"v$VERSION`""
+ Set-Content version_service.go $content
+ Write-Host "Updated version_service.go:"
+ Select-String "AppVersion" version_service.go
+
+ # Update build/windows/info.json
+ $json = Get-Content build/windows/info.json -Raw | ConvertFrom-Json
+ $json.fixed.file_version = $VERSION
+ $json.info.'0000'.ProductVersion = $VERSION
+ $json | ConvertTo-Json -Depth 10 | Set-Content build/windows/info.json
+ Write-Host "Updated build/windows/info.json:"
+ Get-Content build/windows/info.json
+
- uses: actions/setup-go@v5
with:
go-version: '1.24'
@@ -122,17 +155,169 @@ jobs:
makensis -DARG_WAILS_AMD64_BINARY="$binaryPath" project.nsi
Pop-Location
+ - name: Build Updater
+ run: |
+ go install github.com/akavel/rsrc@latest
+ rsrc -manifest cmd/updater/updater.exe.manifest -o cmd/updater/rsrc_windows_amd64.syso
+ $env:GOOS = "windows"
+ $env:GOARCH = "amd64"
+ $env:CGO_ENABLED = "0"
+ go build -ldflags="-w -s -H windowsgui" -o bin/updater.exe ./cmd/updater
+ Remove-Item cmd/updater/*.syso -ErrorAction SilentlyContinue
+
+ - name: Rename files with version
+ shell: pwsh
+ run: |
+ $VERSION = "${{ steps.version.outputs.VERSION }}"
+ Push-Location bin
+ Rename-Item "CodeSwitch.exe" "CodeSwitch-v$VERSION.exe"
+ Rename-Item "updater.exe" "updater-v$VERSION.exe"
+ Pop-Location
+ # Rename installer (generated by NSIS)
+ if (Test-Path "build/windows/nsis/CodeSwitch-amd64-installer.exe") {
+ Move-Item "build/windows/nsis/CodeSwitch-amd64-installer.exe" "bin/CodeSwitch-v$VERSION-amd64-installer.exe"
+ } elseif (Test-Path "bin/CodeSwitch-amd64-installer.exe") {
+ Rename-Item "bin/CodeSwitch-amd64-installer.exe" "CodeSwitch-v$VERSION-amd64-installer.exe"
+ }
+ Write-Host "Files renamed:"
+ Get-ChildItem bin
+
+ - name: Generate SHA256 Checksums
+ shell: pwsh
+ run: |
+ $VERSION = "${{ steps.version.outputs.VERSION }}"
+ Push-Location bin
+ Get-FileHash -Algorithm SHA256 "CodeSwitch-v$VERSION.exe" | ForEach-Object { "$($_.Hash.ToLower()) CodeSwitch-v$VERSION.exe" } | Out-File -Encoding ascii "CodeSwitch-v$VERSION.exe.sha256"
+ Get-FileHash -Algorithm SHA256 "updater-v$VERSION.exe" | ForEach-Object { "$($_.Hash.ToLower()) updater-v$VERSION.exe" } | Out-File -Encoding ascii "updater-v$VERSION.exe.sha256"
+ Write-Host "SHA256 checksums generated:"
+ Get-Content *.sha256
+ Pop-Location
+
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: windows-amd64
path: |
- bin/CodeSwitch-amd64-installer.exe
- bin/CodeSwitch.exe
+ bin/CodeSwitch-v${{ steps.version.outputs.VERSION }}-amd64-installer.exe
+ bin/CodeSwitch-v${{ steps.version.outputs.VERSION }}.exe
+ bin/CodeSwitch-v${{ steps.version.outputs.VERSION }}.exe.sha256
+ bin/updater-v${{ steps.version.outputs.VERSION }}.exe
+ bin/updater-v${{ steps.version.outputs.VERSION }}.exe.sha256
+
+ build-linux:
+ name: Build Linux
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Update version from tag
+ id: version
+ run: |
+ VERSION=${GITHUB_REF#refs/tags/v}
+ echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
+ echo "Updating version to: $VERSION"
+ sed -i "s/const AppVersion = \"v[^\"]*\"/const AppVersion = \"v$VERSION\"/" version_service.go
+ echo "Updated version_service.go:"
+ grep "AppVersion" version_service.go
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Install Linux Build Dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ build-essential \
+ pkg-config \
+ libgtk-3-dev \
+ libwebkit2gtk-4.1-dev
+
+ - name: Install Wails
+ run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
+
+ - name: Install frontend dependencies
+ run: cd frontend && npm install
+
+ - name: Update build assets
+ run: wails3 task common:update:build-assets
+
+ - name: Generate bindings
+ run: wails3 task common:generate:bindings
+
+ - name: Build Linux Binary
+ run: wails3 task linux:build
+ env:
+ PRODUCTION: "true"
+
+ - name: Generate Desktop File
+ run: wails3 task linux:generate:dotdesktop
+
+ - name: Create AppImage
+ run: wails3 task linux:create:appimage
+
+ - name: Rename AppImage
+ run: |
+ VERSION=${{ steps.version.outputs.VERSION }}
+ cd bin
+ echo "Files in bin/:"
+ ls -la
+ # linuxdeploy creates AppImage with lowercase name and arch suffix
+ for f in *-x86_64.AppImage *-aarch64.AppImage; do
+ if [ -f "$f" ]; then
+ mv "$f" "CodeSwitch-v${VERSION}.AppImage"
+ echo "Renamed $f -> CodeSwitch-v${VERSION}.AppImage"
+ break
+ fi
+ done
+ ls -la "CodeSwitch-v${VERSION}.AppImage"
+
+ - name: Set nfpm script permissions
+ run: |
+ chmod +x build/linux/nfpm/scripts/postinstall.sh
+ chmod +x build/linux/nfpm/scripts/postremove.sh
+
+ - name: Update nfpm version
+ run: |
+ VERSION=${GITHUB_REF#refs/tags/v}
+ sed -i "s/^version:.*/version: \"$VERSION\"/" build/linux/nfpm/nfpm.yaml
+ echo "Updated nfpm version to: $VERSION"
+
+ - name: Create DEB Package
+ run: wails3 task linux:create:deb
+
+ - name: Create RPM Package
+ run: wails3 task linux:create:rpm
+
+ - name: Generate SHA256 Checksums
+ run: |
+ VERSION=${{ steps.version.outputs.VERSION }}
+ cd bin
+ sha256sum "CodeSwitch-v${VERSION}.AppImage" > "CodeSwitch-v${VERSION}.AppImage.sha256"
+ for f in codeswitch_*.deb; do [ -f "$f" ] && sha256sum "$f" > "${f}.sha256"; done
+ for f in codeswitch-*.rpm; do [ -f "$f" ] && sha256sum "$f" > "${f}.sha256"; done
+ echo "SHA256 checksums:"
+ cat *.sha256
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: linux-amd64
+ path: |
+ bin/CodeSwitch-v${{ steps.version.outputs.VERSION }}.AppImage
+ bin/CodeSwitch-v${{ steps.version.outputs.VERSION }}.AppImage.sha256
+ bin/codeswitch_*.deb
+ bin/codeswitch_*.deb.sha256
+ bin/codeswitch-*.rpm
+ bin/codeswitch-*.rpm.sha256
create-release:
name: Create Release
- needs: [build-macos, build-windows]
+ needs: [build-macos, build-windows, build-linux]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -147,42 +332,147 @@ jobs:
- name: Prepare release assets
run: |
+ VERSION=${GITHUB_REF#refs/tags/v}
mkdir -p release-assets
- cp artifacts/macos-arm64/codeswitch-macos-arm64.zip release-assets/
- cp artifacts/macos-amd64/codeswitch-macos-amd64.zip release-assets/
- cp artifacts/windows-amd64/CodeSwitch-amd64-installer.exe release-assets/
- cp artifacts/windows-amd64/CodeSwitch.exe release-assets/
- ls -lh release-assets/
- - name: Create Release
- uses: softprops/action-gh-release@v1
- with:
- files: release-assets/*
- body: |
- ## 新增功能
+ # macOS
+ cp artifacts/macos-arm64/CodeSwitch-v${VERSION}-macos-arm64.zip release-assets/
+ cp artifacts/macos-amd64/CodeSwitch-v${VERSION}-macos-amd64.zip release-assets/
+
+ # Windows
+ cp artifacts/windows-amd64/CodeSwitch-v${VERSION}-amd64-installer.exe release-assets/
+ cp artifacts/windows-amd64/CodeSwitch-v${VERSION}.exe release-assets/
+ cp artifacts/windows-amd64/CodeSwitch-v${VERSION}.exe.sha256 release-assets/
+ cp artifacts/windows-amd64/updater-v${VERSION}.exe release-assets/
+ cp artifacts/windows-amd64/updater-v${VERSION}.exe.sha256 release-assets/
+
+ # Linux
+ cp artifacts/linux-amd64/CodeSwitch-v${VERSION}.AppImage release-assets/
+ cp artifacts/linux-amd64/CodeSwitch-v${VERSION}.AppImage.sha256 release-assets/
+ cp artifacts/linux-amd64/codeswitch_*.deb release-assets/ 2>/dev/null || true
+ cp artifacts/linux-amd64/codeswitch_*.deb.sha256 release-assets/ 2>/dev/null || true
+ cp artifacts/linux-amd64/codeswitch-*.rpm release-assets/ 2>/dev/null || true
+ cp artifacts/linux-amd64/codeswitch-*.rpm.sha256 release-assets/ 2>/dev/null || true
+
+ ls -lh release-assets/
+ - name: Generate latest.json
+ run: |
+ VERSION=${GITHUB_REF#refs/tags/}
+ VERSION_NUM=${GITHUB_REF#refs/tags/v}
+ REPO="${{ github.repository }}"
+ BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}"
+
+ # Read SHA256 checksums from existing .sha256 files
+ WIN_SHA=$(cut -d' ' -f1 release-assets/CodeSwitch-${VERSION}.exe.sha256)
+ LINUX_SHA=$(cut -d' ' -f1 release-assets/CodeSwitch-${VERSION}.AppImage.sha256)
+
+ cat > release-assets/latest.json << EOF
+ {
+ "version": "${VERSION}",
+ "release_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
+ "files": {
+ "windows": {
+ "name": "CodeSwitch-${VERSION}.exe",
+ "url": "${BASE_URL}/CodeSwitch-${VERSION}.exe",
+ "sha256": "${WIN_SHA}"
+ },
+ "darwin-arm64": {
+ "name": "CodeSwitch-${VERSION}-macos-arm64.zip",
+ "url": "${BASE_URL}/CodeSwitch-${VERSION}-macos-arm64.zip"
+ },
+ "darwin-amd64": {
+ "name": "CodeSwitch-${VERSION}-macos-amd64.zip",
+ "url": "${BASE_URL}/CodeSwitch-${VERSION}-macos-amd64.zip"
+ },
+ "linux": {
+ "name": "CodeSwitch-${VERSION}.AppImage",
+ "url": "${BASE_URL}/CodeSwitch-${VERSION}.AppImage",
+ "sha256": "${LINUX_SHA}"
+ }
+ }
+ }
+ EOF
- ### 优先级分组调度
+ echo "Generated latest.json:"
+ cat release-assets/latest.json
- 新增 Level 字段(1-10)用于供应商优先级分组,实现更灵活的降级策略。
- **主要改进**:
- - 后端:重构调度逻辑为两层循环架构,优先尝试高优先级分组
- - 前端:添加 Level 下拉选择器和可视化徽章(L1-L10)
- - 国际化:支持中英文 Level 描述文本
- - 测试:新增单元测试覆盖分组、排序和序列化逻辑
+ - name: Generate release body
+ run: |
+ VERSION=${GITHUB_REF#refs/tags/v}
+ echo "Generating release body for v$VERSION"
- **使用场景**:
- - 成本优化:低成本供应商优先,高成本作为备份
- - 稳定性保障:高质量供应商优先,社区供应商降级
- - 地域分组:国内供应商优先,国外供应商备份
+ # 提取当前版本的更新内容(从标题到下一个版本标题之前)
+ # 注意:先用 tr 去除 Windows CRLF 换行符,否则 awk 匹配失败
+ tr -d '\r' < RELEASE_NOTES.md | awk "/^# Code Switch v$VERSION/,/^# Code Switch v[0-9]/" | head -n -1 > /tmp/version_notes.md
- **向后兼容**:未设置 Level 的供应商自动默认为 Level 1。
+ # 如果没找到版本说明,使用默认内容
+ if [ ! -s /tmp/version_notes.md ]; then
+ echo "## 更新亮点" > /tmp/version_notes.md
+ echo "- 请查看提交历史了解详细更新" >> /tmp/version_notes.md
+ fi
- ## 安装说明
+ # 组合完整的 release body
+ cat /tmp/version_notes.md > /tmp/release_body.md
+
+ cat >> /tmp/release_body.md << EOF
+
+ ---
+
+ ## 下载说明
+
+ | 平台 | 文件 | 说明 |
+ |------|------|------|
+ | **Windows (首次)** | \`CodeSwitch-v${VERSION}-amd64-installer.exe\` | NSIS 安装器 |
+ | **Windows (便携)** | \`CodeSwitch-v${VERSION}.exe\` | 直接运行 |
+ | **Windows (更新)** | \`updater-v${VERSION}.exe\` | 静默更新辅助 |
+ | **macOS (ARM)** | \`CodeSwitch-v${VERSION}-macos-arm64.zip\` | Apple Silicon |
+ | **macOS (Intel)** | \`CodeSwitch-v${VERSION}-macos-amd64.zip\` | Intel 芯片 |
+ | **Linux (通用)** | \`CodeSwitch-v${VERSION}.AppImage\` | 跨发行版便携 |
+ | **Linux (Debian/Ubuntu)** | \`codeswitch_*.deb\` | apt 安装 |
+ | **Linux (RHEL/Fedora)** | \`codeswitch-*.rpm\` | dnf/yum 安装 |
+
+ ## Linux 安装
+
+ ### AppImage (推荐)
+ \`\`\`bash
+ chmod +x CodeSwitch-v${VERSION}.AppImage
+ ./CodeSwitch-v${VERSION}.AppImage
+ \`\`\`
+ 如遇 FUSE 问题:\`./CodeSwitch-v${VERSION}.AppImage --appimage-extract-and-run\`
+
+ ### Debian/Ubuntu
+ \`\`\`bash
+ sudo dpkg -i codeswitch_*.deb
+ sudo apt-get install -f # 安装依赖
+ \`\`\`
+
+ ### RHEL/Fedora
+ \`\`\`bash
+ sudo rpm -i codeswitch-*.rpm
+ # 或
+ sudo dnf install codeswitch-*.rpm
+ \`\`\`
+
+ ## 文件校验
+ 所有平台均提供 SHA256 校验文件(\`.sha256\`),下载后可验证完整性:
+ \`\`\`bash
+ # Linux/macOS
+ sha256sum -c CodeSwitch-v${VERSION}.AppImage.sha256
+
+ # Windows PowerShell
+ Get-FileHash CodeSwitch-v${VERSION}.exe | Format-List
+ \`\`\`
+ EOF
+
+ echo "Generated release body:"
+ cat /tmp/release_body.md
- - **Windows**:下载 `codeswitch-amd64-installer.exe` 运行安装,或下载 `codeswitch.exe` 直接运行
- - **macOS (Apple Silicon)**:下载 `codeswitch-macos-arm64.zip`
- - **macOS (Intel)**:下载 `codeswitch-macos-amd64.zip`
+ - name: Create Release
+ uses: softprops/action-gh-release@v1
+ with:
+ files: release-assets/*
+ body_path: /tmp/release_body.md
draft: false
prerelease: false
env:
diff --git a/.gitignore b/.gitignore
index 27ca63f..040cb4d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
frontend/node_modules
-frontend/bindings
frontend/dist
.DS_Store
.task
bin
+CLAUDE.md
+.ace-tool/
diff --git a/API_ENDPOINT_PLAN_v2.2.0.md b/API_ENDPOINT_PLAN_v2.2.0.md
new file mode 100644
index 0000000..4a28851
--- /dev/null
+++ b/API_ENDPOINT_PLAN_v2.2.0.md
@@ -0,0 +1,335 @@
+# API 端点配置功能方案(v2.2.0)
+
+经过与 codex 的深入讨论,我们达成以下方案共识:
+
+---
+
+## 📋 问题分析
+
+### 用户遇到的问题
+
+**现象**:
+```
+Provider 阿萨 映射模型:claude-haiku-4-5-20251001 -> glm-4.6
+错误:404 Not Found,路径:/v1/messages
+```
+
+**根本原因**:
+- 代码根据平台(claude)硬编码使用 `/v1/messages` 端点
+- 但 GLM 模型需要使用 `/v1/chat/completions` 或 `/api/paas/v4/chat/completions`
+- 模型映射只改模型名,不改端点
+- 导致 404 错误
+
+---
+
+## 🎯 解决方案(与 codex 达成共识)
+
+### 采用方案 A:添加可选的 apiEndpoint 字段
+
+**核心设计**:
+1. ✅ 在 Provider 结构体添加 `apiEndpoint` 字段
+2. ✅ 用户可选填,留空使用平台默认
+3. ✅ 前端使用"下拉常用 + 自定义输入"混合方式
+4. ✅ 轻量校验,不过度复杂
+
+---
+
+## 📊 与 codex 的争论结果
+
+### 争论点 1:字段独立性
+
+**我的观点**:应该独立于 `availabilityConfig.testEndpoint`
+- `availabilityConfig.testEndpoint` → 健康检查用
+- `apiEndpoint` → 生产请求用
+
+**codex 观点**:完全同意,必须分离
+
+**最终共识**:✅ **新增独立的 apiEndpoint 字段**
+
+---
+
+### 争论点 2:前端 UI 设计
+
+**codex 推荐**:下拉 + 自定义输入(选项 2)
+
+**UI 设计**:
+```
+┌─────────────────────────────────────┐
+│ API 端点(可选) │
+│ [下拉选择 ▼] [或输入自定义] │
+│ │
+│ 选项: │
+│ • /v1/messages (Anthropic) │
+│ • /v1/chat/completions (OpenAI) │
+│ • /api/paas/v4/chat/completions (GLM)│
+│ • 自定义... │
+│ │
+│ 💡 留空使用平台默认 │
+│ claude: /v1/messages │
+│ codex: /responses │
+└─────────────────────────────────────┘
+```
+
+**位置**:Provider 编辑弹窗的基础配置区域,API URL 下方
+
+**最终共识**:✅ **下拉 + 自定义混合方式**
+
+---
+
+### 争论点 3:端点优先级
+
+**我的方案 A**:provider.apiEndpoint > 平台默认
+
+**codex 观点**:同意方案 A,不引入模型推断(避免误判)
+
+**最终共识**:✅ **简单优先级,不做智能推断**
+
+```
+优先级:
+1. provider.apiEndpoint(用户配置,最高优先级)
+2. 平台默认端点
+ - claude: /v1/messages
+ - codex: /responses
+```
+
+---
+
+### 争论点 4:配置验证
+
+**我的担心**:用户填错端点
+
+**codex 方案**:
+- 前端:轻量校验(必须以 `/` 开头)
+- 后端:不强校验,记录日志
+- 测试:可选,不强制
+
+**最终共识**:✅ **轻量校验 + 日志记录**
+
+---
+
+## 🛠️ 完整实施方案
+
+### 后端修改
+
+#### 1. services/providerservice.go
+
+**添加字段**(第 26 行附近):
+```go
+type Provider struct {
+ // ... 现有字段 ...
+ APIEndpoint string `json:"apiEndpoint,omitempty"` // 可选:覆盖平台默认端点
+}
+```
+
+**添加方法**(GetEffectiveModel 附近):
+```go
+// GetEffectiveEndpoint 获取有效的 API 端点
+// 优先使用用户配置的端点,否则使用平台默认
+func (p *Provider) GetEffectiveEndpoint(defaultEndpoint string) string {
+ ep := strings.TrimSpace(p.APIEndpoint)
+ if ep == "" {
+ return defaultEndpoint
+ }
+ // 确保以 / 开头
+ if !strings.HasPrefix(ep, "/") {
+ ep = "/" + ep
+ }
+ return ep
+}
+```
+
+**复制供应商时保留**(第 389 行附近):
+```go
+cloned := &Provider{
+ // ... 现有字段 ...
+ APIEndpoint: source.APIEndpoint,
+}
+```
+
+#### 2. services/providerrelay.go
+
+**更新 4 处转发调用**:
+
+**位置 1**:claude/codex 拉黑模式(约第 332 行)
+```go
+effectiveEndpoint := firstProvider.GetEffectiveEndpoint(endpoint)
+ok, err := prs.forwardRequest(c, kind, *firstProvider, 1, effectiveEndpoint, query, ...)
+```
+
+**位置 2**:claude/codex 降级模式(约第 417 行)
+```go
+effectiveEndpoint := provider.GetEffectiveEndpoint(endpoint)
+ok, err := prs.forwardRequest(c, kind, provider, 1, effectiveEndpoint, query, ...)
+```
+
+**位置 3**:custom CLI 拉黑模式(约第 1426 行)
+```go
+effectiveEndpoint := firstProvider.GetEffectiveEndpoint(endpoint)
+ok, err := prs.forwardRequest(c, kind, *firstProvider, 1, effectiveEndpoint, query, ...)
+```
+
+**位置 4**:custom CLI 降级模式(约第 1492 行)
+```go
+effectiveEndpoint := provider.GetEffectiveEndpoint(endpoint)
+ok, err := prs.forwardRequest(c, kind, provider, 1, effectiveEndpoint, query, ...)
+```
+
+---
+
+### 前端修改
+
+#### 1. frontend/src/data/cards.ts
+
+**添加字段**:
+```typescript
+export type AutomationCard = {
+ // ... 现有字段 ...
+ apiEndpoint?: string // 可选:API 端点路径
+}
+```
+
+#### 2. frontend/src/components/Main/Index.vue
+
+**VendorForm 添加字段**:
+```typescript
+type VendorForm = {
+ // ... 现有字段 ...
+ apiEndpoint?: string
+}
+```
+
+**defaultFormValues 添加默认值**:
+```typescript
+const defaultFormValues = (platform?: string): VendorForm => ({
+ // ... 现有字段 ...
+ apiEndpoint: '',
+})
+```
+
+**表单模板添加控件**(API URL 下方):
+```vue
+
+```
+
+#### 3. 国际化文本
+
+**zh.json**:
+```json
+{
+ "components": {
+ "main": {
+ "form": {
+ "labels": {
+ "apiEndpoint": "API 端点(可选)"
+ },
+ "placeholders": {
+ "defaultEndpoint": "使用平台默认端点",
+ "customEndpoint": "自定义端点...",
+ "enterCustomEndpoint": "输入端点路径,如 /v1/chat/completions"
+ },
+ "hints": {
+ "apiEndpoint": "留空使用平台默认(claude: /v1/messages, codex: /responses)。GLM 模型请使用 /v1/chat/completions"
+ }
+ }
+ }
+ }
+}
+```
+
+**en.json**:
+```json
+{
+ "components": {
+ "main": {
+ "form": {
+ "labels": {
+ "apiEndpoint": "API Endpoint (optional)"
+ },
+ "placeholders": {
+ "defaultEndpoint": "Use platform default endpoint",
+ "customEndpoint": "Custom endpoint...",
+ "enterCustomEndpoint": "Enter endpoint path, e.g. /v1/chat/completions"
+ },
+ "hints": {
+ "apiEndpoint": "Leave blank to use platform default (claude: /v1/messages, codex: /responses). For GLM models use /v1/chat/completions"
+ }
+ }
+ }
+ }
+}
+```
+
+---
+
+## 📊 改动范围
+
+| 组件 | 文件数 | 代码行数 | 复杂度 |
+|------|--------|---------|--------|
+| 后端 | 2 | ~30 行 | 低 |
+| 前端 | 3 | ~50 行 | 低 |
+| 国际化 | 2 | ~20 行 | 低 |
+| **总计** | **7** | **~100 行** | **低** |
+
+---
+
+## 🧪 测试场景
+
+### 测试 1:GLM 供应商配置
+1. 编辑 GLM 供应商
+2. API 端点选择:`/v1/chat/completions`
+3. 保存
+4. 发送请求
+5. **预期**:使用 /v1/chat/completions,不再 404
+
+### 测试 2:默认行为
+1. 编辑 Anthropic 供应商
+2. API 端点:留空
+3. 保存
+4. **预期**:使用默认 /v1/messages
+
+### 测试 3:自定义端点
+1. 输入自定义端点
+2. 校验:必须以 `/` 开头
+3. **预期**:格式错误时提示
+
+---
+
+## ✅ 方案优势
+
+| 特性 | 优势 |
+|------|------|
+| **改动最小** | 仅 7 个文件,~100 行代码 |
+| **向后兼容** | 留空时行为完全不变 |
+| **易于使用** | 下拉选择,不需要记忆端点 |
+| **灵活性强** | 支持自定义端点 |
+| **安全性好** | 轻量校验,记录日志 |
+
+---
+
+## ❓ 请审核确认
+
+1. **是否同意这个方案?**
+2. **UI 设计是否符合预期?**
+3. **是否需要调整?**
+
+**确认后,我将立即实施所有修改!**
diff --git a/BLACKLIST_FRONTEND_GUIDE.md b/BLACKLIST_FRONTEND_GUIDE.md
new file mode 100644
index 0000000..e61ac90
--- /dev/null
+++ b/BLACKLIST_FRONTEND_GUIDE.md
@@ -0,0 +1,320 @@
+# 供应商黑名单功能 - 前端集成指南
+
+## 📝 概述
+
+由于前端文件 `Index.vue` 较大(1693行),手动修改容易出错。此指南提供完整的修改步骤,或者您可以跳过此步骤,先测试后端功能。
+
+---
+
+## ⚡ 快速方案:先测试后端
+
+如果您想快速验证后端功能,可以先跳过前端 UI 修改,直接测试:
+
+1. **运行应用**:`wails3 task dev`
+2. **查看后端日志**:在控制台观察是否有拉黑相关日志
+3. **检查数据库**:查看 `~/.code-switch/app.db` 中的 `provider_blacklist` 表
+
+---
+
+## 🛠️ 完整方案:集成前端 UI
+
+### 修改清单
+
+| 文件 | 修改内容 | 优先级 |
+|------|---------|--------|
+| `frontend/src/components/Main/Index.vue` | 添加黑名单 UI 和逻辑 | 高 |
+| `frontend/src/locales/zh-CN.json` | 中文文案 | 中 |
+| `frontend/src/locales/en-US.json` | 英文文案 | 低 |
+
+---
+
+## 📄 详细修改步骤
+
+### 1. 修改 `Index.vue` - 导入部分
+
+**位置**:第 580 行之后
+
+**添加**:
+```typescript
+import { getBlacklistStatus, manualUnblock, type BlacklistStatus } from '../../services/blacklist'
+```
+
+---
+
+### 2. 修改 `Index.vue` - 添加状态变量
+
+**位置**:第 637 行之后
+
+**添加**:
+```typescript
+// 黑名单状态
+const blacklistStatusMap = reactive>>({
+ claude: {},
+ codex: {},
+})
+let blacklistTimer: number | undefined
+```
+
+---
+
+### 3. 修改 `Index.vue` - 添加方法
+
+**位置**:在 `loadProviderStats` 方法附近
+
+**添加以下 4 个方法**:
+
+```typescript
+// 加载黑名单状态
+const loadBlacklistStatus = async (tab: ProviderTab) => {
+ try {
+ const statuses = await getBlacklistStatus(tab)
+ const map: Record = {}
+ statuses.forEach(status => {
+ map[status.providerName] = status
+ })
+ blacklistStatusMap[tab] = map
+ } catch (err) {
+ console.error(`加载 ${tab} 黑名单状态失败:`, err)
+ }
+}
+
+// 手动解禁
+const handleUnblock = async (providerName: string) => {
+ try {
+ await manualUnblock(activeTab.value, providerName)
+ showToast(t('components.main.blacklist.unblockSuccess', { name: providerName }), 'success')
+ await loadBlacklistStatus(activeTab.value)
+ } catch (err) {
+ console.error('解除拉黑失败:', err)
+ showToast(t('components.main.blacklist.unblockFailed'), 'error')
+ }
+}
+
+// 格式化倒计时
+const formatBlacklistCountdown = (remainingSeconds: number): string => {
+ const minutes = Math.floor(remainingSeconds / 60)
+ const seconds = remainingSeconds % 60
+ return `${minutes}${t('components.main.blacklist.minutes')}${seconds}${t('components.main.blacklist.seconds')}`
+}
+
+// 获取 provider 黑名单状态
+const getProviderBlacklistStatus = (providerName: string): BlacklistStatus | null => {
+ return blacklistStatusMap[activeTab.value][providerName] || null
+}
+```
+
+---
+
+### 4. 修改 `Index.vue` - 修改生命周期钩子
+
+#### 4.1 在 `onMounted` 中添加定时器
+
+**位置**:在现有定时器之后
+
+**添加**:
+```typescript
+// 加载初始黑名单状态
+loadBlacklistStatus(activeTab.value)
+
+// 每秒更新黑名单倒计时
+blacklistTimer = window.setInterval(() => {
+ const tab = activeTab.value
+ Object.keys(blacklistStatusMap[tab]).forEach(providerName => {
+ const status = blacklistStatusMap[tab][providerName]
+ if (status && status.isBlacklisted && status.remainingSeconds > 0) {
+ status.remainingSeconds--
+ if (status.remainingSeconds <= 0) {
+ loadBlacklistStatus(tab)
+ }
+ }
+ })
+}, 1000)
+```
+
+#### 4.2 在 `onUnmounted` 中清理定时器
+
+**位置**:在现有清理代码之后
+
+**添加**:
+```typescript
+if (blacklistTimer) {
+ window.clearInterval(blacklistTimer)
+}
+```
+
+---
+
+### 5. 修改 `Index.vue` - 模板部分
+
+**位置**:第 353 行的 `` 之后
+
+**添加**:
+```vue
+
+
+ ⛔
+
+ {{ t('components.main.blacklist.blocked') }} |
+ {{ t('components.main.blacklist.remaining') }}:
+ {{ formatBlacklistCountdown(getProviderBlacklistStatus(card.name)!.remainingSeconds) }}
+
+
+
+```
+
+---
+
+### 6. 修改 `Index.vue` - 样式部分
+
+**位置**:在 `` 标签之前
+
+**添加**:
+```scss
+.blacklist-banner {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ margin-top: 8px;
+ background: rgba(239, 68, 68, 0.1);
+ border-left: 3px solid #ef4444;
+ border-radius: 6px;
+ font-size: 13px;
+ color: #dc2626;
+
+ &.dark {
+ background: rgba(239, 68, 68, 0.15);
+ color: #f87171;
+ }
+}
+
+.blacklist-icon {
+ font-size: 16px;
+ flex-shrink: 0;
+}
+
+.blacklist-text {
+ flex: 1;
+ font-weight: 500;
+}
+
+.unblock-btn {
+ padding: 4px 12px;
+ font-size: 12px;
+ font-weight: 500;
+ color: #fff;
+ background: #ef4444;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background 0.2s;
+
+ &:hover {
+ background: #dc2626;
+ }
+
+ &:active {
+ transform: scale(0.98);
+ }
+}
+```
+
+---
+
+### 7. 修改 `zh-CN.json` - 中文文案
+
+**位置**:在 `components.main` 对象中添加
+
+**添加**:
+```json
+"blacklist": {
+ "blocked": "已拉黑",
+ "remaining": "剩余",
+ "minutes": "分",
+ "seconds": "秒",
+ "unblock": "立即解禁",
+ "unblockSuccess": "已解除 {name} 的拉黑",
+ "unblockFailed": "解除拉黑失败,请稍后重试"
+}
+```
+
+---
+
+### 8. 修改 `en-US.json` - 英文文案
+
+**添加**:
+```json
+"blacklist": {
+ "blocked": "Blocked",
+ "remaining": "Remaining",
+ "minutes": "m",
+ "seconds": "s",
+ "unblock": "Unblock",
+ "unblockSuccess": "{name} has been unblocked",
+ "unblockFailed": "Failed to unblock"
+}
+```
+
+---
+
+## 🧪 测试步骤
+
+### 后端功能测试
+
+1. **启动应用**:
+ ```bash
+ cd G:\claude-lit\cc-r
+ wails3 task dev
+ ```
+
+2. **检查数据库表**:
+ - 打开 `~/.code-switch/app.db`
+ - 确认 `provider_blacklist` 和 `app_settings` 表已创建
+
+3. **触发拉黑**:
+ - 添加一个故意配置错误的 provider(错误的 API Key)
+ - 向该 provider 发送 3 次请求
+ - 查看控制台日志,应该看到 "⛔ Provider XXX 已拉黑 30 分钟"
+
+### 前端 UI 测试(如果完成了前端修改)
+
+1. **验证拉黑横幅**:
+ - Provider 卡片下方应出现红色横幅
+ - 显示 "⛔ 已拉黑 | 剩余: 29分59秒"
+
+2. **验证倒计时**:
+ - 每秒递减
+ - 格式正确
+
+3. **验证手动解禁**:
+ - 点击"立即解禁"按钮
+ - 横幅消失
+ - Provider 恢复可用
+
+---
+
+## 🐛 故障排除
+
+**问题:拉黑不生效**
+- 检查后端日志是否有错误
+- 确认数据库表已创建
+- 验证 provider 确实失败了
+
+**问题:前端横幅不显示**
+- 检查浏览器控制台是否有 API 调用错误
+- 确认导入和状态变量已正确添加
+- 验证模板代码位置正确
+
+---
+
+作者:Half open flowers
+日期:2025-01-14
diff --git a/FINAL_SOLUTION_v2.1.1.md b/FINAL_SOLUTION_v2.1.1.md
new file mode 100644
index 0000000..18b3f7c
--- /dev/null
+++ b/FINAL_SOLUTION_v2.1.1.md
@@ -0,0 +1,412 @@
+# v2.1.1 最终修复方案(与 codex 深度讨论后确定)
+
+## 🎯 设计共识
+
+经过与 codex 的深入争论,我们在以下原则上达成共识:
+
+1. **用户体验优先**:不强制用户按特定顺序操作
+2. **明确提示**:所有限制和问题都要清晰告知用户
+3. **允许灵活性**:用户可以先保存配置,后续再完善
+4. **智能辅助**:自动推荐,但不强制
+
+---
+
+## 📋 修复方案详细设计
+
+### 问题 1:可用性页面编辑按钮
+
+#### 最终方案:始终显示 + 置灰提示
+
+**代码位置**:`frontend/src/components/Availability/Index.vue:342-349`
+
+**修改前**:
+```vue
+
+```
+
+**修改后**:
+```vue
+
+```
+
+**优点**:
+- 用户能看到配置入口
+- 置灰状态清晰提示
+- 符合"先配置后启用"的习惯
+
+---
+
+### 问题 2:全部检测按钮对比度
+
+#### 最终方案:渐变 + 阴影 + Emoji
+
+**代码位置**:`frontend/src/components/Availability/Index.vue:242-247`
+
+**修改后**:
+```vue
+
+```
+
+**改进点**:
+- 渐变背景,视觉吸引力强
+- 阴影 + hover 放大,交互反馈明确
+- Emoji 图标,快速识别
+- 更大尺寸,更显眼
+
+---
+
+### 问题 3:MCP JSON Tab 空白问题
+
+#### 根本原因
+- Tab 区域有 `v-if="!modalState.editingName"` 限制
+- 编辑模式下整个 Tab 不显示
+
+#### 最终方案:移除限制 + 增强提示
+
+**代码位置**:`frontend/src/components/Mcp/index.vue:187`
+
+**修改**:
+1. 移除 `v-if="!modalState.editingName"` 限制
+2. 编辑模式下也可粘贴 JSON(覆盖配置)
+3. 添加说明文字
+
+**JSON 区域增强**(行 301 附近):
+```vue
+
+
+
+
+ {{ t('mcp.form.jsonHint') }}
+
+
+
+
+
+
+ ✅ Claude Desktop 格式:{"mcpServers": {"name": {...}}}
+ ✅ 单对象格式:{"command": "...", "args": [...]}
+ ✅ 数组格式:[{...}, {...}]
+
+
+
+
+
+
+
+
📋 解析预览
+
+ - • 服务器数量: {{ jsonPreview.count }}
+ -
+ • {{ srv.name || '(需填写名称)' }} - {{ srv.type }} -
+
+ 含占位符: {{ srv.placeholders.join(', ') }}
+
+
+
+
+
+
+
+ ⚠️ {{ jsonError }}
+
+
+```
+
+**新增方法**:
+```ts
+// 填充示例 JSON
+function fillExampleJson() {
+ jsonInput.value = JSON.stringify({
+ "command": "npx",
+ "args": ["-y", "@anthropic-ai/mcp-server-google-maps"],
+ "env": {
+ "GOOGLE_MAPS_API_KEY": "{YOUR_API_KEY}"
+ }
+ }, null, 2)
+}
+
+// 实时解析预览(可选,debounce)
+watch(jsonInput, debounce((newVal) => {
+ try {
+ const parsed = JSON.parse(newVal)
+ jsonPreview.value = generatePreview(parsed)
+ jsonError.value = null
+ } catch {
+ jsonPreview.value = null
+ // 不立即显示错误,等用户点击"解析"再提示
+ }
+}, 500))
+```
+
+---
+
+### 问题 4:MCP 表单配置不生效
+
+#### 最终方案:允许保存占位符 + 明确提示未同步
+
+**设计原则(与 codex 达成共识)**:
+- ✅ 允许保存含占位符的配置
+- ✅ 不同步到 Claude/Codex(安全)
+- ✅ 明确提示用户原因和解决方法
+
+#### 前端修改
+
+**代码位置**:`frontend/src/components/Mcp/index.vue` 的 `submitModal` 方法
+
+**修改后**:
+```ts
+const submitModal = async () => {
+ // 1. 基础校验
+ if (!modalState.form.name) {
+ modalState.error = t('mcp.form.errors.nameRequired')
+ return
+ }
+
+ // 2. 平台校验(必须勾选至少一个)
+ if (!platformState.enableClaude && !platformState.enableCodex) {
+ modalState.error = t('mcp.form.errors.noPlatformSelected')
+ return
+ }
+
+ // 3. 类型和配置校验
+ if (modalState.form.type === 'stdio' && !modalState.form.command) {
+ modalState.error = t('mcp.form.errors.commandRequired')
+ return
+ }
+ if (modalState.form.type === 'http' && !modalState.form.url) {
+ modalState.error = t('mcp.form.errors.urlRequired')
+ return
+ }
+
+ try {
+ await saveMcpServers(...)
+
+ // 4. 保存成功后检查占位符
+ const placeholders = formMissingPlaceholders.value
+ if (placeholders.length > 0) {
+ // 显示警告(不是错误)
+ showToast(
+ t('mcp.form.warnings.savedWithPlaceholders', {
+ vars: placeholders.join(', ')
+ }),
+ 'warning'
+ )
+ } else {
+ // 完全成功
+ showToast(t('mcp.form.saveSuccess'), 'success')
+ }
+
+ closeModal()
+ await loadData()
+ } catch (error) {
+ modalState.error = t('mcp.form.saveFailed') + ': ' + error
+ }
+}
+```
+
+#### 后端修改
+
+**代码位置**:`services/mcpservice.go:185-192`
+
+**修改前**:
+```go
+// 静默清空平台
+if HasPlaceholders(params) || HasPlaceholders(env) {
+ server.EnableClaude = false
+ server.EnableCodex = false
+}
+```
+
+**修改后**:
+```go
+// 保持清空逻辑,但记录日志
+if HasPlaceholders(params) || HasPlaceholders(env) {
+ placeholderList := detectAllPlaceholders(params, env)
+ log.Printf("[MCP] %s 包含占位符 %v,未同步到平台配置", server.Name, placeholderList)
+ server.EnableClaude = false
+ server.EnableCodex = false
+ // 不返回错误,允许保存
+}
+```
+
+#### 国际化新增
+
+**中文**:
+```json
+{
+ "mcp": {
+ "form": {
+ "jsonHint": "支持 Claude Desktop、数组、单对象等多种格式",
+ "loadExample": "加载示例",
+ "errors": {
+ "nameRequired": "请输入服务器名称",
+ "noPlatformSelected": "请至少勾选一个平台(Claude Code 或 Codex)",
+ "commandRequired": "stdio 类型需要填写命令",
+ "urlRequired": "http 类型需要填写 URL"
+ },
+ "warnings": {
+ "savedWithPlaceholders": "✅ 配置已保存,但因包含占位符({vars}),未同步到 Claude/Codex。请填写占位符后重新保存。"
+ },
+ "saveSuccess": "✅ MCP 配置已保存并同步到 Claude Code 和 Codex"
+ }
+ }
+}
+```
+
+**英文**:
+```json
+{
+ "mcp": {
+ "form": {
+ "jsonHint": "Supports Claude Desktop, array, single object formats",
+ "loadExample": "Load Example",
+ "errors": {
+ "nameRequired": "Please enter server name",
+ "noPlatformSelected": "Please select at least one platform (Claude Code or Codex)",
+ "commandRequired": "stdio type requires command",
+ "urlRequired": "http type requires URL"
+ },
+ "warnings": {
+ "savedWithPlaceholders": "✅ Config saved, but not synced to Claude/Codex due to placeholders ({vars}). Please fill placeholders and save again."
+ },
+ "saveSuccess": "✅ MCP config saved and synced to Claude Code and Codex"
+ }
+ }
+}
+```
+
+---
+
+## 📊 方案对比矩阵
+
+| 争论点 | 我的方案 | codex 原方案 | 最终共识 | 理由 |
+|--------|----------|-------------|----------|------|
+| 1. 占位符 | 允许保存+提示 | 阻止保存 | **我的方案** | 满足"先存后补"场景 |
+| 2. 平台勾选 | 智能推荐 | 自动勾选双平台 | **智能推荐** | 避免不兼容 |
+| 3. JSON UI | 示例+预览 | 简单提示 | **增强版** | 降低学习成本 |
+| 4. 错误提示 | 行内提示 | Toast/Alert | **行内提示** | 复用现有状态 |
+
+---
+
+## 🚀 实施清单
+
+### 优先级 P0(必须修复)
+
+1. ✅ **可用性页面编辑按钮**
+ - 移除 `v-if` 限制
+ - 添加 `disabled` 和 tooltip
+ - 添加置灰样式
+
+2. ✅ **MCP Tab 显示问题**
+ - 移除编辑模式限制
+ - 添加说明文字
+
+3. ✅ **MCP 表单校验**
+ - 添加平台校验
+ - 添加占位符提示
+ - 添加错误显示
+
+### 优先级 P1(强烈建议)
+
+4. ✅ **按钮对比度优化**
+ - 渐变背景
+ - 阴影效果
+ - Emoji 图标
+
+5. ✅ **JSON 导入增强**
+ - 示例按钮
+ - 格式说明
+ - 占位符警告
+
+---
+
+## 📝 国际化文本清单
+
+### availability (可用性页面)
+- `enableToMonitor`: "请先启用可用性监控" / "Please enable availability monitoring first"
+- `editConfig`: "编辑配置" / "Edit Config"
+- `configTitle`: "可用性高级配置" / "Advanced Availability Config"
+- `default`: "默认" / "default"
+- `currentModel/Endpoint/Timeout`: 当前生效配置
+- `defaultModel/Endpoint`: 默认值标注
+
+### mcp (MCP 配置)
+- `form.jsonHint`: JSON 格式说明
+- `form.loadExample`: "加载示例"
+- `form.errors.*`: 各类错误提示
+- `form.warnings.savedWithPlaceholders`: 占位符警告
+- `form.saveSuccess`: 保存成功提示
+
+### common (通用)
+- `save/saving/cancel`: 通用按钮文本(可能已存在)
+
+---
+
+## ⚠️ 注意事项
+
+### 1. 占位符处理
+- 前端允许保存
+- 后端静默清空平台启用(不同步)
+- 显示警告提示用户
+
+### 2. 平台勾选
+- 不自动勾选
+- 可选:智能推荐(检测 JSON 中的关键词)
+- 保存时强制校验
+
+### 3. UI 一致性
+- 所有错误使用行内提示(红色文本)
+- 所有成功使用 toast 提示
+- 所有警告使用黄色提示
+
+---
+
+## 🧪 测试场景
+
+### 可用性页面
+1. 未启用监控 → 编辑按钮置灰,hover 显示提示
+2. 启用监控 → 编辑按钮可点击,打开配置弹窗
+3. 保存配置 → 配置生效,卡片显示当前值
+
+### MCP JSON 导入
+1. 粘贴 Claude Desktop 格式 → 成功解析,填充表单
+2. 粘贴单对象格式 → 成功解析,提示填写名称
+3. 粘贴含占位符的配置 → 保存成功,显示警告
+4. 未勾选平台 → 阻止保存,显示错误
+5. 编辑模式粘贴 JSON → 覆盖当前配置
+
+---
+
+## ✅ 请确认
+
+1. **是否同意所有方案**?
+2. **是否需要调整优先级**?
+3. **是否有其他要求**?
+
+确认后,我将立即实施所有修复!
diff --git a/FIXES_PROPOSAL_v2.1.1.md b/FIXES_PROPOSAL_v2.1.1.md
new file mode 100644
index 0000000..037de06
--- /dev/null
+++ b/FIXES_PROPOSAL_v2.1.1.md
@@ -0,0 +1,359 @@
+# v2.1.1 问题修复方案
+
+## 问题清单
+
+基于用户反馈,发现以下 4 个问题需要修复:
+
+1. ❌ 可用性页面看不到"编辑配置"按钮
+2. ❌ "全部检测"按钮对比度太弱,不容易发现
+3. ❌ MCP "粘贴 JSON" tab 点击后空白
+4. ❌ MCP 表单配置方式填写参数后不生效
+
+---
+
+## 问题 1:可用性页面编辑按钮显示逻辑
+
+### 现状分析
+
+**代码位置**:`frontend/src/components/Availability/Index.vue:342-349`
+
+**当前实现**:
+```vue
+
+```
+
+**问题**:
+- 按钮仅在 `availabilityMonitorEnabled = true` 时显示
+- 用户未启用监控时看不到按钮
+- 不符合"先配置后启用"的操作习惯
+
+### 解决方案(推荐)
+
+**方案 B:始终显示,未启用时置灰**
+
+**修改代码**:
+```vue
+
+```
+
+**优点**:
+- ✅ 用户能看到配置入口
+- ✅ 置灰状态清晰提示需要先启用
+- ✅ 不改变启用逻辑,保持安全性
+
+**国际化新增**:
+```json
+// zh.json
+"availability": {
+ "enableToMonitor": "请先启用可用性监控"
+}
+
+// en.json
+"availability": {
+ "enableToMonitor": "Please enable availability monitoring first"
+}
+```
+
+---
+
+## 问题 2:全部检测按钮对比度优化
+
+### 现状分析
+
+**代码位置**:`frontend/src/components/Availability/Index.vue:242-247`
+
+**当前实现**:
+```vue
+
+```
+
+**问题**:
+- 使用主题变量 `--mac-accent`,在某些主题下对比度不足
+- 按钮尺寸和样式不够突出
+
+### 解决方案(推荐)
+
+**方案:增强对比度 + 视觉层次**
+
+**修改代码**:
+```vue
+
+```
+
+**改进点**:
+- ✅ 使用固定的 `bg-blue-600`,对比度强
+- ✅ 增加 padding 和字号,更显眼
+- ✅ 添加阴影效果,增强层次感
+- ✅ 添加 emoji 图标,视觉引导
+- ✅ hover 时阴影增强,交互反馈明确
+
+---
+
+## 问题 3:MCP 粘贴 JSON tab 空白
+
+### 现状分析
+
+**代码位置**:`frontend/src/components/Mcp/index.vue`
+- Tab 按钮:第 187-203 行
+- 内容区域:第 301-377 行
+
+**当前实现**:
+```vue
+
+
+
+
+
+
+
+
+
+
+```
+
+**问题**:
+- Tab 区域有 `v-if="!modalState.editingName"` 限制
+- 编辑模式下整个 Tab 不显示
+- 用户看到的是完全空白
+
+### 解决方案(推荐)
+
+**方案:移除编辑模式限制,允许粘贴 JSON 覆盖配置**
+
+**修改代码**:
+```vue
+
+
+
+
+
+
+
+
+
+
+ {{ t('mcp.form.jsonHint') }}
+
+
+
+```
+
+**新增国际化**:
+```json
+{
+ "mcp": {
+ "form": {
+ "jsonHint": "粘贴 MCP 服务器 JSON 配置,将覆盖当前配置"
+ }
+ }
+}
+```
+
+**优点**:
+- ✅ 编辑模式也能粘贴 JSON
+- ✅ 提供清晰的说明
+- ✅ 用户体验更好
+
+---
+
+## 问题 4:MCP 表单配置不生效
+
+### 现状分析
+
+**后端代码**:`services/mcpservice.go:185-192`
+```go
+// 如果有占位符未填写,清空平台启用状态(静默失败)
+if HasPlaceholders(params) || HasPlaceholders(env) {
+ server.EnableClaude = false
+ server.EnableCodex = false
+}
+```
+
+**前端代码**:`frontend/src/components/Mcp/index.vue:785-846`
+- 保存时未校验平台是否勾选
+- 未校验占位符是否填写
+- 静默失败,用户无感知
+
+### 解决方案(推荐)
+
+**方案:前端强校验 + 后端明确错误**
+
+#### 前端修改
+
+**位置**:`frontend/src/components/Mcp/index.vue` 的 `submitMcpForm` 方法
+
+**在保存前添加校验**:
+```ts
+const submitMcpForm = async () => {
+ // 1. 校验至少勾选一个平台
+ if (!platformState.enableClaude && !platformState.enableCodex) {
+ // 显示错误提示
+ showError(t('mcp.form.errors.noPlatformSelected'))
+ return
+ }
+
+ // 2. 校验占位符是否填写完整
+ if (formMissingPlaceholders.value) {
+ showError(t('mcp.form.errors.missingPlaceholders'))
+ // 高亮未填写的字段
+ highlightMissingFields()
+ return
+ }
+
+ // 3. 继续原有的保存逻辑
+ try {
+ await saveMcpServers(...)
+ showSuccess(t('mcp.form.saveSuccess'))
+ } catch (error) {
+ showError(t('mcp.form.saveFailed'))
+ }
+}
+```
+
+#### 后端修改
+
+**位置**:`services/mcpservice.go:185-192`
+
+**返回明确错误**:
+```go
+// 校验占位符
+if HasPlaceholders(params) || HasPlaceholders(env) {
+ return fmt.Errorf("配置包含未填写的占位符: %s",
+ detectPlaceholders(params, env))
+}
+
+// 校验至少启用一个平台
+if !server.EnableClaude && !server.EnableCodex {
+ return fmt.Errorf("请至少勾选一个平台(Claude 或 Codex)")
+}
+```
+
+#### 国际化新增
+
+```json
+// zh.json
+{
+ "mcp": {
+ "form": {
+ "errors": {
+ "noPlatformSelected": "请至少勾选一个平台(Claude Code 或 Codex)",
+ "missingPlaceholders": "请填写所有占位符({var} 格式的字段)"
+ },
+ "saveSuccess": "MCP 配置已保存并同步",
+ "saveFailed": "保存失败,请检查配置"
+ }
+ }
+}
+
+// en.json
+{
+ "mcp": {
+ "form": {
+ "errors": {
+ "noPlatformSelected": "Please select at least one platform (Claude Code or Codex)",
+ "missingPlaceholders": "Please fill in all placeholders ({var} format fields)"
+ },
+ "saveSuccess": "MCP config saved and synced",
+ "saveFailed": "Save failed, please check your config"
+ }
+ }
+}
+```
+
+**优点**:
+- ✅ 用户立即知道哪里出错
+- ✅ 不会静默失败
+- ✅ 前后端双重校验,更安全
+- ✅ 错误信息明确,易于修复
+
+---
+
+## 方案对比总结
+
+| 问题 | 推荐方案 | 理由 | 工作量 |
+|------|---------|------|--------|
+| 1. 编辑按钮 | 方案 B(始终显示+置灰) | 用户体验好,符合直觉 | 小 |
+| 2. 按钮对比度 | 渐进增强(固定色+阴影) | 既显眼又不过分 | 小 |
+| 3. JSON tab | 移除编辑限制 | 增强灵活性 | 极小 |
+| 4. MCP 校验 | 前后端双重校验 | 彻底解决静默失败 | 中 |
+
+---
+
+## 实施优先级
+
+### P0(必须修复)
+1. ✅ 问题 4:MCP 校验(影响核心功能)
+2. ✅ 问题 1:编辑按钮逻辑(影响用户体验)
+
+### P1(强烈建议)
+3. ✅ 问题 2:按钮对比度(可见性问题)
+4. ✅ 问题 3:JSON tab(功能缺失)
+
+---
+
+## 需要你审核的点
+
+### 1. 编辑按钮方案
+- **我和 codex 的共识**:始终显示,未启用时置灰
+- **是否同意**?还是有其他想法?
+
+### 2. 按钮样式方案
+- **codex 建议**:固定 `bg-blue-600` 颜色
+- **我建议**:渐变 + 阴影 + emoji
+- **你更喜欢哪个**?还是有第三种方案?
+
+### 3. MCP JSON tab 方案
+- **方案**:移除编辑模式限制
+- **风险**:编辑时粘贴 JSON 会覆盖现有配置
+- **是否接受**?还是只允许新建时使用?
+
+### 4. MCP 校验方案
+- **前端校验 + 后端错误返回**
+- **需要实现**:错误提示组件(toast 或 alert)
+- **是否同意这个方向**?
+
+---
+
+## 请审核并指示
+
+1. **哪些方案需要调整**?
+2. **是否还有其他要求**?
+3. **是否同意开始实施**?
+
+审核通过后,我将立即实施所有修复。
diff --git a/GEMINI_ANALYSIS.md b/GEMINI_ANALYSIS.md
new file mode 100644
index 0000000..05bbd4c
--- /dev/null
+++ b/GEMINI_ANALYSIS.md
@@ -0,0 +1,340 @@
+# Gemini 实现对比分析:cc-switch vs cc-r
+
+**目的**:分析参考项目 (cc-switch/Rust) 与当前项目 (cc-r/Go) 在 Gemini 配置管理的实现差异,识别配置问题。
+
+---
+
+## 概览表
+
+| 方面 | cc-switch (参考) | cc-r (当前) | 状态 |
+|------|-----------------|-----------|------|
+| 语言框架 | Rust/Tauri | Go/Wails3 | - |
+| Gemini 目录 | `~/.gemini/` | `~/.gemini/` | ✅ 一致 |
+| 供应商配置 | DB 或配置文件 | `~/.code-switch/gemini-providers.json` | ✅ 合理 |
+| 代理前缀 | `/gemini` | `/gemini` | ✅ 一致 |
+| 备份文件名 | 未在代码中找到 | `.cc-studio.backup` | ❌ 命名错误 |
+| 配置验证 | 有(严格模式) | 缺失 | ⚠️ 缺失验证 |
+
+---
+
+## 关键差异
+
+### 1. 备份文件命名 (Critical)
+
+**cc-r 代码** (`services/geminiservice.go:656, 689`):
+```go
+backupPath := envPath + ".cc-studio.backup" // ❌ 错误!
+```
+
+**问题**:
+- 命名为 `.cc-studio.backup`,但项目是 "code-switch"
+- 来自旧项目迁移的遗留代码
+- 当禁用代理时,恢复备份会使用错误的文件名
+
+**修复**:
+```go
+backupPath := envPath + ".cc-switch.backup" // 或 ".code-switch.backup"
+```
+
+---
+
+### 2. 配置验证缺失 (High Priority)
+
+**cc-switch** (`src-tauri/src/gemini_config.rs:226-278`):
+```rust
+// 两级验证
+validate_gemini_settings() // 基础格式检查
+validate_gemini_settings_strict() // 要求必需字段
+```
+
+**cc-r**:
+```go
+// 仅有检测,无验证
+detectGeminiAuthType(provider) // 只是识别类型
+```
+
+**SwitchProvider() 的问题**:
+```go
+func (s *GeminiService) SwitchProvider(id string) error {
+ // ... 找到 provider ...
+
+ // ❌ 直接写入,无验证
+ authType := detectGeminiAuthType(provider)
+ switch authType {
+ case GeminiAuthAPIKey:
+ if provider.APIKey == "" {
+ // 应该在这里拒绝!
+ }
+ }
+}
+```
+
+**影响**:
+- 用户可以切换到配置不完整的供应商(缺 API Key)
+- Gemini 读取配置时会失败,但用户不知道原因
+
+**修复**:
+```go
+func (s *GeminiService) SwitchProvider(id string) error {
+ // ... 找到 provider ...
+
+ authType := detectGeminiAuthType(provider)
+
+ // 新增:验证配置完整性
+ if authType != GeminiAuthOAuth && provider.APIKey == "" {
+ return fmt.Errorf("无法切换:API Key 未设置")
+ }
+
+ // ... 继续写入配置 ...
+}
+```
+
+---
+
+### 3. 认证类型 selectedType 值不准确
+
+**cc-r 代码** (`services/geminiservice.go:232-262`):
+```go
+case GeminiAuthPackycode, GeminiAuthAPIKey, GeminiAuthGeneric:
+ // ... 所有 API Key 类型都写同样的值
+ if err := writeGeminiSettings(map[string]any{
+ "security": map[string]any{
+ "auth": map[string]any{
+ "selectedType": string(GeminiAuthAPIKey), // ❌ 统一为 "gemini-api-key"
+ },
+ },
+ }); err != nil {
+ // ...
+ }
+```
+
+**问题**:
+- PackyCode 是特殊的第三方供应商,应该有自己的认证标记
+- 所有 API Key 类型都写 `"gemini-api-key"`,无法区分
+- 会导致 Gemini CLI 无法识别 PackyCode 的特殊配置需求
+
+**修复**:按认证类型写不同的 selectedType
+```go
+switch authType {
+case GeminiAuthOAuth:
+ // ... oauth-personal ...
+case GeminiAuthPackycode:
+ if err := writeGeminiSettings(map[string]any{
+ "security": map[string]any{
+ "auth": map[string]any{
+ "selectedType": "packycode", // ✅ 区分类型
+ },
+ },
+ }); err != nil {
+ // ...
+ }
+case GeminiAuthAPIKey:
+ if err := writeGeminiSettings(map[string]any{
+ "security": map[string]any{
+ "auth": map[string]any{
+ "selectedType": "gemini-api-key", // ✅ 保留原有
+ },
+ },
+ }); err != nil {
+ // ...
+ }
+}
+```
+
+---
+
+### 4. envConfig 处理不当
+
+**代码** (`services/geminiservice.go:234-247`):
+```go
+envConfig := provider.EnvConfig
+if envConfig == nil {
+ envConfig = make(map[string]string) // ❌ 创建空 map
+}
+// 然后尝试从 provider 字段补充配置
+if provider.BaseURL != "" && envConfig["GOOGLE_GEMINI_BASE_URL"] == "" {
+ envConfig["GOOGLE_GEMINI_BASE_URL"] = provider.BaseURL
+}
+```
+
+**问题**:
+- 如果 `EnvConfig` 为 nil,会丢失预设中的配置
+- 应该先复制预设配置,再覆盖
+
+**修复**:
+```go
+envConfig := make(map[string]string)
+
+// 1. 复制预设配置
+if provider.EnvConfig != nil {
+ for k, v := range provider.EnvConfig {
+ envConfig[k] = v
+ }
+}
+
+// 2. 补充来自 provider 字段的配置
+if provider.BaseURL != "" && envConfig["GOOGLE_GEMINI_BASE_URL"] == "" {
+ envConfig["GOOGLE_GEMINI_BASE_URL"] = provider.BaseURL
+}
+// ...
+```
+
+---
+
+### 5. 代理地址格式歧义
+
+**代码** (`services/geminiservice.go:708-725`):
+```go
+func buildProxyURL(relayAddr string) string {
+ addr := strings.TrimSpace(relayAddr)
+ if addr == "" {
+ addr = ":18100" // ❌ 歧义!
+ }
+ // ...
+ if strings.HasPrefix(host, ":") {
+ host = "127.0.0.1" + host
+ }
+ // ...
+}
+```
+
+**问题**:
+- `:18100` 在 Go 中表示监听所有网卡(0.0.0.0:18100),但代码假设它是端口
+- `main.go:88` 传入的 `":18100"` 实际上应该指向 `127.0.0.1:18100`
+- 这与安全架构冲突(代理应只监听本地回环)
+
+**修复**:在调用点明确使用本地地址
+```go
+// main.go:88
+geminiService := services.NewGeminiService("127.0.0.1:18100") // ✅ 明确本地
+```
+
+或改进 `buildProxyURL()` 的处理逻辑。
+
+---
+
+### 6. 测试参数缺失 (Blocker)
+
+**代码** (`services/geminiservice_test.go:8, 233`):
+```go
+func TestGeminiService_GetPresets(t *testing.T) {
+ svc := NewGeminiService() // ❌ 缺参数
+ // ...
+}
+```
+
+**问题**:
+- `NewGeminiService(relayAddr string)` 要求传入地址参数
+- 测试中缺少参数,导致编译失败
+- 影响 `go test ./...` 整体执行
+
+**修复**:
+```go
+func TestGeminiService_GetPresets(t *testing.T) {
+ svc := NewGeminiService(":18100") // ✅ 添加参数
+ presets := svc.GetPresets()
+ // ...
+}
+
+func TestGeminiPreset_Fields(t *testing.T) {
+ svc := NewGeminiService(":18100") // ✅ 添加参数
+ presets := svc.GetPresets()
+ // ...
+}
+```
+
+---
+
+## 次要差异
+
+### 7. .env 文件行尾处理
+
+**代码** (`services/geminiservice.go:391`):
+```go
+lines := strings.Split(content, "\n")
+```
+
+**问题**:
+- Windows 上 .env 文件可能有 `\r\n` 行尾
+- `strings.Split()` 会在每个 `\n` 处分割,保留 `\r`
+- `strings.TrimSpace()` 会去除 `\r`,但逻辑不够清晰
+
+**改进**(非阻断):
+```go
+// 使用 bufio.Scanner 或
+lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
+```
+
+---
+
+### 8. 缺少自定义目录支持
+
+**cc-switch** (`src-tauri/src/gemini_config.rs:8-17`):
+```rust
+pub fn get_gemini_dir() -> PathBuf {
+ if let Some(custom) = crate::settings::get_gemini_override_dir() {
+ return custom; // 支持覆盖
+ }
+ // ...
+}
+```
+
+**cc-r**:无此机制,硬编码 `~/.gemini/`
+
+**影响**:低(大多数用户不需要,高级用户可修改)
+
+---
+
+### 9. 测试覆盖不完整
+
+**缺失的测试**:
+- ❌ `buildProxyURL()` 的各种输入格式
+- ❌ `SwitchProvider()` 的切换逻辑
+- ❌ `EnableProxy()` / `DisableProxy()` 的备份恢复
+- ❌ 配置验证的失败场景
+
+**建议**:添加这些测试,确保重构后功能完整。
+
+---
+
+## 修复优先级
+
+| 优先级 | 问题 | 文件:行 | 修复工作量 |
+|--------|------|--------|---------|
+| 🔴 P0 | 测试参数缺失 | `geminiservice_test.go:8,233` | 5 分钟 |
+| 🔴 P0 | 配置验证缺失 | `geminiservice.go:SwitchProvider` | 15 分钟 |
+| 🟠 P1 | 备份文件命名 | `geminiservice.go:656,689` | 5 分钟 |
+| 🟠 P1 | selectedType 值 | `geminiservice.go:232-262` | 10 分钟 |
+| 🟡 P2 | envConfig 处理 | `geminiservice.go:234-247` | 10 分钟 |
+| 🟡 P2 | 代理地址歧义 | `geminiservice.go:708-725, main.go:88` | 10 分钟 |
+| 🟢 P3 | 行尾处理 | `geminiservice.go:391` | 5 分钟 |
+| 🟢 P3 | 测试覆盖 | `geminiservice_test.go` | 30 分钟 |
+
+**总计**:约 90 分钟可修复所有问题
+
+---
+
+## 对标参考资源
+
+- **cc-switch 验证逻辑**:`src-tauri/src/gemini_config.rs:226-278`
+- **cc-switch 文件写入**:`src-tauri/src/gemini_config.rs:287-337`
+- **cc-switch 单元测试**:`src-tauri/src/gemini_config.rs:375-656`
+
+---
+
+## 验证清单
+
+修复后请确保:
+- [ ] `go test ./services -v -run TestGemini` 通过
+- [ ] `EnableProxy()` 后,`.cc-switch.backup` 或 `.code-switch.backup` 文件存在
+- [ ] 切换到无 API Key 的供应商时返回错误
+- [ ] PackyCode 供应商的 selectedType 为 "packycode"
+- [ ] `buildProxyURL()` 返回 `http://127.0.0.1:18100/gemini`
+- [ ] 前端能正确切换 Gemini 供应商
+
+---
+
+**分析日期**:2025-11-29
+**分析工具**:Claude Code (Haiku 4.5)
+**版本**:1.0
+
diff --git a/GEMINI_ISSUES_SUMMARY.txt b/GEMINI_ISSUES_SUMMARY.txt
new file mode 100644
index 0000000..f0be2a3
--- /dev/null
+++ b/GEMINI_ISSUES_SUMMARY.txt
@@ -0,0 +1,192 @@
+========================================
+Gemini 实现问题汇总 (cc-r vs cc-switch)
+========================================
+
+分析对象:
+- 参考项目:G:\claude-lit\cc-sw\cc-switch (Rust/Tauri)
+- 当前项目:G:\claude-lit\cc-r (Go/Wails3)
+
+分析日期:2025-11-29
+
+========================================
+P0 优先级(阻断问题)
+========================================
+
+【问题1】测试函数参数缺失
+- 文件:services/geminiservice_test.go
+- 行号:8, 233
+- 现象:NewGeminiService() 调用缺少必需的 relayAddr 参数
+- 影响:go test ./... 编译失败
+- 修复:添加 ":18100" 参数
+
+【问题2】SwitchProvider() 缺少配置验证
+- 文件:services/geminiservice.go
+- 方法:SwitchProvider()(行195-271)
+- 现象:切换到配置不完整的供应商时无验证
+- 影响:用户可切换到无API Key的供应商,导致Gemini读取失败
+- 修复:在写入配置前验证必需字段(如 API Key)
+- 参考:cc-switch 的 validate_gemini_settings_strict()
+
+========================================
+P1 优先级(配置错误)
+========================================
+
+【问题3】备份文件命名错误
+- 文件:services/geminiservice.go
+- 行号:656, 689
+- 问题:".cc-studio.backup" → 应为 ".cc-switch.backup" 或 ".code-switch.backup"
+- 影响:禁用代理时无法正确恢复备份(会丢失原配置)
+- 工作量:1 分钟(简单替换)
+
+【问题4】认证类型 selectedType 值不准确
+- 文件:services/geminiservice.go
+- 行号:232-262(SwitchProvider 中的 switch 语句)
+- 问题:所有 API Key 认证都写 "gemini-api-key",无法区分 PackyCode
+- 当前代码:
+ case GeminiAuthPackycode, GeminiAuthAPIKey, GeminiAuthGeneric:
+ // 都写成 selectedType: "gemini-api-key"
+- 修复:按类型区分
+ case GeminiAuthPackycode:
+ selectedType: "packycode"
+ case GeminiAuthAPIKey:
+ selectedType: "gemini-api-key"
+- 工作量:10 分钟
+
+【问题5】envConfig 处理丢失预设配置
+- 文件:services/geminiservice.go
+- 行号:234-247
+- 问题:当 provider.EnvConfig 为 nil 时,创建空 map,导致预设配置丢失
+- 修复:先复制预设配置,再补充 provider 字段
+- 工作量:5 分钟
+
+========================================
+P2 优先级(设计问题)
+========================================
+
+【问题6】代理地址格式歧义
+- 文件:services/geminiservice.go (行708-725) 和 main.go (行88)
+- 问题:
+ * ":18100" 在 Go 中表示监听所有网卡 (0.0.0.0:18100)
+ * 当前代码假设它是端口,会补全为 "127.0.0.1:18100"
+ * main.go:88 传入的是 ":18100"
+- 影响:可能导致代理实际监听 0.0.0.0,引发安全隐患
+- 修复:在 main.go:88 明确使用 "127.0.0.1:18100"
+- 工作量:5 分钟
+
+========================================
+P3 优先级(改进)
+========================================
+
+【问题7】.env 文件行尾处理
+- 文件:services/geminiservice.go
+- 行号:391
+- 问题:strings.Split(content, "\n") 在 Windows 下可能保留 \r
+- 修复:strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
+- 工作量:3 分钟
+- 优先级:低(TrimSpace() 会补偿)
+
+【问题8】测试覆盖不完整
+- 缺失的测试:
+ * buildProxyURL() 各种输入格式
+ * SwitchProvider() 的切换逻辑和验证
+ * EnableProxy()/DisableProxy() 的备份恢复
+- 工作量:30 分钟
+
+【问题9】缺少自定义目录支持
+- 对标:cc-switch 有 get_gemini_override_dir()
+- 影响:低(大多数用户不需要)
+- 工作量:20 分钟
+
+========================================
+关键参考代码位置
+========================================
+
+参考项目中的最佳实践:
+
+1. 两级验证机制
+ - cc-switch/src-tauri/src/gemini_config.rs:226-278
+ - validate_gemini_settings() - 基础格式检查
+ - validate_gemini_settings_strict() - 必需字段检查
+
+2. 安全的文件写入
+ - cc-switch/src-tauri/src/gemini_config.rs:157-192
+ - 原子操作 + 权限设置 (0600/0700)
+
+3. 深度合并
+ - cc-r 的实现已经很好,见 geminiservice.go:510-533
+
+4. 单元测试
+ - cc-switch/src-tauri/src/gemini_config.rs:375-656
+ - 包含严格解析、验证、OAuth、API Key 等多个场景
+
+========================================
+建议的修复顺序
+========================================
+
+第1轮(5分钟):
+1. 修复测试参数缺失 → go test 能运行
+
+第2轮(30分钟):
+2. 添加配置验证逻辑
+3. 修复备份文件命名
+4. 按认证类型区分 selectedType
+5. 改进 envConfig 处理
+6. 明确代理地址(main.go:88)
+
+第3轮(30分钟):
+7. 改进 .env 行尾处理
+8. 添加单元测试覆盖
+
+========================================
+验证方式
+========================================
+
+修复后请运行:
+
+1. 单元测试
+ go test ./services -v -run TestGemini
+
+2. 功能测试
+ - 启用代理后检查备份文件是否存在
+ - 切换到无 API Key 的供应商是否返回错误
+ - PackyCode 的 selectedType 是否为 "packycode"
+ - buildProxyURL() 返回 "http://127.0.0.1:18100/gemini"
+
+3. 集成测试
+ - 前端能否成功切换 Gemini 供应商
+ - 禁用后是否正确恢复备份
+
+========================================
+附注:关键代码位置映射
+========================================
+
+当前项目文件:
+
+├── main.go (行88: NewGeminiService(":18100"))
+└── services/
+ ├── geminiservice.go
+ │ ├── NewGeminiService() 行72-83
+ │ ├── detectGeminiAuthType() 行316-348
+ │ ├── SwitchProvider() 行195-271 ← 需要验证逻辑
+ │ ├── buildProxyURL() 行708-725 ← 地址处理
+ │ ├── EnableProxy() 行648-684 ← 备份文件名
+ │ ├── DisableProxy() 行686-706 ← 备份文件名
+ │ ├── parseEnvFile() 行388-412 ← 行尾处理
+ │ └── writeGeminiSettings() 行479-508 ← 深度合并
+ └── geminiservice_test.go
+ ├── TestGeminiService_GetPresets() 行8 ← 缺参数
+ └── TestGeminiPreset_Fields() 行233 ← 缺参数
+
+参考项目文件:
+
+└── src-tauri/src/
+ ├── gemini_config.rs
+ │ ├── get_gemini_dir() 行8-17 ← 自定义目录支持
+ │ ├── parse_env_file() 行28-52 ← 宽松解析
+ │ ├── parse_env_file_strict() 行76-125 ← 严格解析
+ │ ├── validate_gemini_settings() 行226-251 ← 基础验证
+ │ ├── validate_gemini_settings_strict() 行257-278 ← 严格验证
+ │ ├── update_selected_type() 行296-337 ← 写入逻辑
+ │ └── tests 行375-656 ← 完整测试覆盖
+ └── gemini_mcp.rs ← MCP 相关(cc-r 由 MCPService 统一管理)
+
diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md
new file mode 100644
index 0000000..3c24fe2
--- /dev/null
+++ b/PR_DESCRIPTION.md
@@ -0,0 +1,190 @@
+# 供应商失败自动拉黑 + 多项功能增强
+
+## 概述
+
+本 PR 为 Code Switch 项目带来了多项重要功能增强和改进,主要包括:
+- ✅ 供应商失败自动拉黑机制
+- ✅ 拉黑配置界面
+- ✅ 自动更新功能修复
+- ✅ 实时状态刷新优化
+
+---
+
+## 核心功能
+
+### 1. 供应商失败自动拉黑功能 (v0.3.0)
+
+**功能描述**:
+- 当供应商连续失败达到阈值(可配置,默认 3 次)时,自动拉黑指定时长(可配置:15/30/60 分钟)
+- 拉黑期间该供应商不会被选择,避免浪费请求
+- 拉黑时长到期后自动解禁并重置失败计数
+- 支持手动立即解禁
+
+**实现细节**:
+- 新增 `BlacklistService` 核心服务
+- 新增 `SettingsService` 配置管理服务
+- 数据持久化到 SQLite(`~/.code-switch/app.db`)
+- 每分钟自动检查并恢复到期的拉黑记录
+
+**数据库表结构**:
+```sql
+CREATE TABLE provider_blacklist (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ platform TEXT NOT NULL,
+ provider_name TEXT NOT NULL,
+ failure_count INTEGER DEFAULT 1,
+ blacklisted_at DATETIME,
+ blacklisted_until DATETIME,
+ last_failure_at DATETIME,
+ auto_recovered BOOLEAN DEFAULT 0,
+ UNIQUE(platform, provider_name)
+);
+
+CREATE TABLE app_settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+);
+```
+
+**前端 UI**:
+- 在供应商卡片上显示拉黑状态横幅
+- 实时倒计时显示剩余拉黑时间
+- 支持一键立即解禁
+
+---
+
+### 2. 拉黑配置界面 (v0.3.1)
+
+**功能描述**:
+在应用设置中新增「拉黑配置」部分,用户可自定义拉黑策略:
+- **失败阈值**:1-10 次(默认 3 次)
+- **拉黑时长**:15/30/60 分钟(默认 30 分钟)
+- 配置即时生效并持久化到数据库
+
+**界面优化**:
+- 实时状态刷新优化,拉黑后条幅立即显示(无延迟)
+- 窗口焦点恢复监听(最小化后恢复时立即刷新)
+- 定期轮询机制(每 10 秒)
+- Tab 切换时立即刷新对应平台的黑名单状态
+
+---
+
+### 3. 自动更新功能修复 (v0.3.2)
+
+**修复问题**:
+1. ✅ 自动更新开关状态持久化(重启后不再丢失)
+2. ✅ 手动点击「立即检查」后自动引导下载和安装
+3. ✅ 兼容老版本升级(首次运行时自动保存默认配置)
+
+**用户体验改进**:
+- 发现新版本后自动弹窗询问是否下载
+- 下载完成后弹窗询问是否重启
+- 一键完成:检查 → 下载 → 安装
+
+---
+
+## 技术改进
+
+### 后端
+
+**新增服务**:
+- `services/blacklistservice.go` - 核心拉黑逻辑
+- `services/settingsservice.go` - 全局配置管理
+- `services/database.go` - 数据库表初始化
+
+**核心逻辑**:
+```go
+// 拉黑检查(在 providerrelay.go 中集成)
+if isBlacklisted, until := prs.blacklistService.IsBlacklisted(kind, provider.Name); isBlacklisted {
+ fmt.Printf("⛔ Provider %s 已拉黑,过期时间: %v\n", provider.Name, until.Format("15:04:05"))
+ skippedCount++
+ continue
+}
+
+// 失败记录(请求失败时调用)
+if err := prs.blacklistService.RecordFailure(kind, provider.Name); err != nil {
+ fmt.Printf("[ERROR] 记录失败到黑名单失败: %v\n", err)
+}
+```
+
+**更新服务改进**:
+- 在 `UpdateState` 中新增 `auto_check_enabled` 字段
+- 使用 `strings.Contains()` 检查 JSON 字段存在性确保兼容性
+- 首次运行时自动保存默认配置
+
+### 前端
+
+**新增文件**:
+- `frontend/src/services/blacklist.ts` - 拉黑 API 封装
+- `frontend/src/services/settings.ts` - 配置 API 封装
+
+**状态管理优化**:
+- 使用 Vue 3 `reactive()` 管理黑名单状态映射
+- 实现倒计时客户端递减(减少 API 调用)
+- 添加窗口焦点事件和定期轮询
+
+**国际化支持**:
+- 完整的中英文翻译(`zh.json` / `en.json`)
+
+---
+
+## 测试建议
+
+### 拉黑功能测试
+1. 手动禁用一个供应商的 API Key(制造失败)
+2. 连续请求 3 次触发拉黑
+3. 观察拉黑条幅是否立即显示
+4. 验证倒计时是否正常递减
+5. 测试手动解禁功能
+
+### 配置界面测试
+1. 打开应用设置 → 拉黑配置
+2. 修改失败阈值和拉黑时长
+3. 重启应用验证配置是否保存
+
+### 自动更新测试
+1. 勾选自动更新
+2. 彻底关闭应用并重启
+3. 验证设置是否保留
+4. 点击「立即检查」测试下载流程
+
+---
+
+## 版本历史
+
+- **v0.3.2** (最新): 修复自动更新功能
+- **v0.3.1**: 新增拉黑配置界面 + 实时性优化
+- **v0.3.0**: 核心拉黑功能
+- **v0.2.6**: HTTP 状态码记录修复
+- **v0.2.5**: UI 按钮对齐修复
+
+---
+
+## 兼容性
+
+- ✅ 向后兼容旧版本配置文件
+- ✅ 数据库自动迁移(新表自动创建)
+- ✅ Windows / macOS / Linux 全平台支持
+
+---
+
+## 相关 Issue
+
+本 PR 解决了以下需求:
+- 供应商降级失败率过高导致用户体验差
+- 需要手动管理供应商可用性
+- 自动更新配置丢失问题
+- 拉黑状态更新延迟
+
+---
+
+## 截图
+
+(建议添加以下截图):
+1. 拉黑条幅显示效果
+2. 拉黑配置界面
+3. 自动更新设置界面
+
+---
+
+感谢你的审核!如有任何问题或建议,请随时告知。🚀
diff --git a/QUICK_START.md b/QUICK_START.md
new file mode 100644
index 0000000..c7a849a
--- /dev/null
+++ b/QUICK_START.md
@@ -0,0 +1,250 @@
+# 模型白名单与映射功能快速上手指南
+
+## 🚀 5分钟快速启动
+
+### Step 1: 启动应用
+
+```bash
+cd G:\claude-lit\cc-r
+wails3 task dev
+```
+
+### Step 2: 配置示例(手动编辑 JSON)
+
+打开配置文件:`~/.code-switch/claude-code.json`
+
+```json
+{
+ "providers": [
+ {
+ "id": 1,
+ "name": "Anthropic Official",
+ "apiUrl": "https://api.anthropic.com",
+ "apiKey": "你的真实密钥",
+ "enabled": true,
+ "supportedModels": {
+ "claude-3-5-sonnet-20241022": true,
+ "claude-sonnet-4-5-20250929": true
+ }
+ },
+ {
+ "id": 2,
+ "name": "OpenRouter",
+ "apiUrl": "https://openrouter.ai/api",
+ "apiKey": "你的真实密钥",
+ "enabled": true,
+ "supportedModels": {
+ "anthropic/claude-*": true,
+ "openai/gpt-*": true
+ },
+ "modelMapping": {
+ "claude-*": "anthropic/claude-*",
+ "gpt-*": "openai/gpt-*"
+ }
+ }
+ ]
+}
+```
+
+### Step 3: 重启应用观察日志
+
+```bash
+# 查看启动日志
+======== Provider 配置验证警告 ========
+[INFO] [claude/Anthropic Official] 配置有效
+[INFO] [claude/OpenRouter] 配置有效,支持通配符映射
+========================================
+provider relay server listening on :18100
+```
+
+### Step 4: 测试降级场景
+
+#### 场景 1:正常请求
+```bash
+# Claude Code 请求
+请求: {"model": "claude-sonnet-4-5-20250929", ...}
+→ [INFO] Provider Anthropic Official 支持该模型
+→ [INFO] ✓ 成功: Anthropic Official
+```
+
+#### 场景 2:降级成功(关键测试)
+```bash
+# 手动停用 Provider 1 或模拟失败
+请求: {"model": "claude-sonnet-4", ...}
+→ [WARN] ✗ 失败: Anthropic Official - timeout
+→ [INFO] Provider OpenRouter 映射模型: claude-sonnet-4 -> anthropic/claude-sonnet-4
+→ [INFO] [2/2] 尝试 provider: OpenRouter (model: anthropic/claude-sonnet-4)
+→ [INFO] ✓ 成功: OpenRouter
+```
+
+### Step 5: 验证功能
+
+打开 Claude Code / Codex,正常使用,观察:
+- ✅ 降级时没有报错
+- ✅ 日志显示正确的模型映射
+- ✅ 请求成功完成
+
+---
+
+## 📝 配置模板速查
+
+### 模板 1:Anthropic Official(精确匹配)
+```json
+{
+ "id": 1,
+ "name": "Anthropic",
+ "apiUrl": "https://api.anthropic.com",
+ "apiKey": "sk-ant-xxx",
+ "enabled": true,
+ "supportedModels": {
+ "claude-3-5-sonnet-20241022": true,
+ "claude-sonnet-4-5-20250929": true
+ }
+}
+```
+
+### 模板 2:OpenRouter(通配符推荐)
+```json
+{
+ "id": 2,
+ "name": "OpenRouter",
+ "apiUrl": "https://openrouter.ai/api",
+ "apiKey": "sk-or-xxx",
+ "enabled": true,
+ "supportedModels": {
+ "anthropic/claude-*": true,
+ "openai/gpt-*": true
+ },
+ "modelMapping": {
+ "claude-*": "anthropic/claude-*",
+ "gpt-*": "openai/gpt-*"
+ }
+}
+```
+
+### 模板 3:自定义中转(混合模式)
+```json
+{
+ "id": 3,
+ "name": "Custom Relay",
+ "apiUrl": "https://api.custom.com",
+ "apiKey": "sk-xxx",
+ "enabled": true,
+ "supportedModels": {
+ "native-model-a": true,
+ "vendor/mapped-model": true
+ },
+ "modelMapping": {
+ "mapped-model": "vendor/mapped-model"
+ }
+}
+```
+
+---
+
+## 🐛 故障排查
+
+### 问题 1:启动时警告 "未配置 supportedModels"
+**原因**:旧配置未添加模型白名单
+**影响**:功能仍可用,但降级时可能失败
+**解决**:为该 provider 添加 `supportedModels` 字段
+
+### 问题 2:降级失败 "不支持模型 xxx"
+**原因**:所有 provider 都不支持请求的模型
+**解决**:
+1. 检查模型名是否正确
+2. 为至少一个 provider 配置该模型的支持
+3. 使用通配符模式(如 `claude-*`)
+
+### 问题 3:保存配置报错 "映射无效"
+**原因**:`modelMapping` 的目标模型不在 `supportedModels` 中
+**解决**:
+```json
+// ❌ 错误
+{
+ "supportedModels": {"model-a": true},
+ "modelMapping": {"external": "model-b"} // model-b 不存在
+}
+
+// ✅ 正确
+{
+ "supportedModels": {"model-a": true, "model-b": true},
+ "modelMapping": {"external": "model-b"}
+}
+```
+
+---
+
+## 💡 最佳实践
+
+1. **优先使用通配符**:
+ ```json
+ "supportedModels": {"anthropic/claude-*": true}
+ "modelMapping": {"claude-*": "anthropic/claude-*"}
+ ```
+ - ✅ 配置简洁
+ - ✅ 支持未来新模型
+ - ✅ 维护成本低
+
+2. **分层配置**:
+ - 主力 provider:精确模型列表
+ - 备用 provider:通配符模式
+
+3. **定期检查日志**:
+ ```bash
+ # 查找配置警告
+ grep "配置验证" logs.txt
+
+ # 查找降级事件
+ grep "映射模型" logs.txt
+ ```
+
+---
+
+## 🎓 进阶使用
+
+### 技巧 1:多区域降级
+```json
+[
+ {
+ "id": 1,
+ "name": "Anthropic US",
+ "apiUrl": "https://api.anthropic.com",
+ "enabled": true
+ },
+ {
+ "id": 2,
+ "name": "Anthropic EU (via Proxy)",
+ "apiUrl": "https://eu.proxy.com",
+ "enabled": true,
+ "modelMapping": {
+ "claude-*": "anthropic/claude-*"
+ }
+ }
+]
+```
+
+### 技巧 2:成本优化
+```json
+// 优先使用便宜的 provider
+[
+ {
+ "id": 1,
+ "name": "Budget Provider",
+ "modelMapping": {"claude-*": "cheap-claude-*"}
+ },
+ {
+ "id": 2,
+ "name": "Premium Provider",
+ "modelMapping": {"claude-*": "anthropic/claude-*"}
+ }
+]
+```
+
+---
+
+## 📚 相关文档
+
+- 完整配置指南:`CLAUDE.md` 第 454-778 行
+- 测试文档:`services/TEST_README.md`
+- 配置示例:`services/testdata/example-claude-config.json`
diff --git a/README.md b/README.md
index 4ebf700..dfb3387 100644
--- a/README.md
+++ b/README.md
@@ -1,89 +1,259 @@
# Code Switch
-集中管理 Claude Code & Codex 供应商
+> 一站式管理你的 AI 编程助手(Claude Code / Codex / Gemini CLI)
-- 无需重启 cc & codex, 平滑切换不同供应商
-- 支持多供应商自动降级, 保证使用体验
-- 支持请求级别的用量统计, 花费多少清晰可见
-- 支持 cc & codex Mcp Server 双平台管理
-- 支持 Claude Skill 自动下载与安装, 内置 2 个流行的 skill 仓库
-- 支持添加自定义 Skill 仓库
+## 这是什么?
-基于 [Wails 3](https://v3.wails.io)
+**Code Switch** 是一个桌面应用,帮你解决以下问题:
-## 实现原理
+- 有多个 AI API 密钥,想灵活切换?
+- API 挂了想自动切换到备用服务?
+- 想统计每天用了多少 Token、花了多少钱?
+- 想集中管理 MCP 服务器配置?
-应用启动时会初始化 在本地 18100 端口创建一个 HTTP 代理服务器, 默认绑定 :18100
+**一句话总结**:装上它,打开开关,Claude Code / Codex / Gemini CLI 的请求就会自动走你配置的供应商,支持自动降级、用量统计、成本追踪。
-并自动更新 Claude Code、Codex 配置, 指向 http://127.0.0.1:18100 服务
+## 快速开始
-代理内部只暴露兼容的关键端点:
+### 1. 下载安装
-- /v1/messages 转发到配置的 Claude 供应商
-- /responses 转发到 Codex 供应商;
+前往 [Releases](https://github.com/Rogers-F/code-switch-R/releases) 下载对应系统的安装包:
-请求由 proxyHandler 动态挑选符合当前优先级与启用状态的 provider,并在失败时自动回退。
+| 系统 | 推荐下载 |
+|------|---------|
+| Windows | `CodeSwitch-amd64-installer.exe` |
+| macOS (M1/M2/M3) | `codeswitch-macos-arm64.zip` |
+| macOS (Intel) | `codeswitch-macos-amd64.zip` |
+| Linux | `CodeSwitch.AppImage` |
-以上流程让 cli 看到的是一个固定的本地地址,而真实请求会被 Code Switch 透明地路由到你在应用里维护的供应商列表
+### 2. 添加供应商
-## 下载
+打开应用后:
-[macOS](https://github.com/daodao97/code-swtich/releases) | [windows](https://github.com/daodao97/code-swtich/releases)
+1. 点击右上角 **+** 按钮
+2. 填写供应商信息:
+ - **名称**:随便起,比如 "官方 API"
+ - **API URL**:供应商的接口地址
+ - **API Key**:你的密钥
+3. 点击保存
+### 3. 打开代理开关
-## 预览
-
-
-
-
+在供应商列表上方,打开 **代理开关**(蓝色表示开启)。
-## 开发准备
-- Go 1.24+
-- Node.js 18+
-- npm / pnpm / yarn
-- Wails 3 CLI:`go install github.com/wailsapp/wails/v3/cmd/wails3@latest`
+完成!现在你的 Claude Code / Codex / Gemini CLI 请求会自动走 Code Switch 代理。
+
+## 功能介绍
+
+### 供应商管理
+
+| 功能 | 说明 |
+|------|------|
+| 多供应商配置 | 可以添加多个 API 供应商 |
+| 拖拽排序 | 拖动卡片调整优先级 |
+| 一键启用/禁用 | 每个供应商独立开关 |
+| 复制供应商 | 快速复制现有配置 |
+
+### 智能降级
+
+当你配置了多个供应商时:
+
+```
+请求发起
+ ↓
+尝试 Level 1 的供应商 A → 失败
+ ↓
+尝试 Level 1 的供应商 B → 失败
+ ↓
+尝试 Level 2 的供应商 C → 成功!
+ ↓
+返回结果
+```
+
+**优先级分组(Level)**:
+- Level 1:最高优先级(首选)
+- Level 2-9:备选
+- Level 10:最低优先级(兜底)
+
+### 模型映射
+
+不同供应商可能使用不同的模型名称,比如:
+- 官方 API:`claude-sonnet-4`
+- OpenRouter:`anthropic/claude-sonnet-4`
+
+配置模型映射后,Code Switch 会自动转换,你不需要改代码。
+
+### 用量统计
+
+- **热力图**:可视化每日使用量
+- **请求统计**:请求次数、成功率
+- **Token 统计**:输入/输出 Token 数量
+- **成本核算**:基于官方定价计算费用
+
+### MCP 服务器管理
+
+集中管理 Claude Code 和 Codex 的 MCP Server:
+- 可视化添加/编辑/删除
+- 支持 URL 和命令两种类型
+- 自动同步到两个平台
+
+### CLI 配置编辑器
+
+可视化编辑 CLI 配置文件:
+- 查看当前配置
+- 修改可编辑字段(模型、插件等)
+- 添加自定义配置
+- 支持解锁直接编辑原始配置
+
+### 其他功能
+
+- **技能市场**:一键安装 Claude Skills
+- **速度测试**:测试供应商延迟
+- **自定义提示词**:管理系统提示词
+- **深度链接**:通过 `ccswitch://` 链接导入配置
+- **自动更新**:内置更新检查
+
+## 工作原理
+
+```
+Claude Code / Codex / Gemini CLI
+ ↓
+ Code Switch 代理 (:18100)
+ ↓
+ ┌───────────────────┐
+ │ 选择供应商 │
+ │ (按优先级尝试) │
+ └───────────────────┘
+ ↓
+ 实际 API 服务器
+```
+
+**原理简述**:
+1. Code Switch 在本地 18100 端口启动代理服务
+2. 自动修改 Claude Code / Codex / Gemini CLI 配置,让它们的请求发到本地代理
+3. 代理根据你的配置,将请求转发到对应的供应商
+4. 如果供应商失败,自动尝试下一个
+
+## 界面预览
+
+| 亮色主题 | 暗色主题 |
+|---------|---------|
+|  |  |
+|  |  |
+
+## 常见问题
+
+### 打开开关后 CLI 没反应?
+
+1. 确认代理开关已打开(蓝色状态)
+2. 重启 Claude Code / Codex / Gemini CLI
+3. 检查供应商配置是否正确
+
+### 如何查看代理是否生效?
+
+1. 在 CLI 中发起一次对话
+2. 回到 Code Switch,查看"日志"页面
+3. 如果有新记录,说明代理生效
+
+### 关闭应用后 CLI 还能用吗?
+
+不能。Code Switch 关闭后代理服务停止,CLI 请求会失败。
+
+**解决方案**:
+- 保持 Code Switch 运行
+- 或者关闭代理开关(会恢复 CLI 原始配置)
+
+### 如何备份配置?
+
+配置文件位置:
+- Windows: `%USERPROFILE%\.code-switch\`
+- macOS/Linux: `~/.code-switch/`
+
+主要文件:
+- `claude-code.json` - Claude Code 供应商配置
+- `codex.json` - Codex 供应商配置
+- `mcp.json` - MCP 服务器配置
+
+## 安装详细说明
+
+### Windows
+
+**安装器方式(推荐)**:
+1. 下载 `CodeSwitch-amd64-installer.exe`
+2. 双击运行,按提示安装
+3. 从开始菜单启动
+
+**便携版**:
+1. 下载 `CodeSwitch.exe`
+2. 放到任意目录,双击运行
+
+### macOS
+
+1. 下载对应芯片的 zip 文件
+2. 解压得到 `Code Switch.app`
+3. 拖到"应用程序"文件夹
+4. 首次打开如提示"无法验证开发者",在"系统设置 → 隐私与安全性"中允许
+
+### Linux
+
+**AppImage(推荐)**:
+```bash
+chmod +x CodeSwitch.AppImage
+./CodeSwitch.AppImage
+```
+
+**DEB 包(Ubuntu/Debian)**:
+```bash
+sudo dpkg -i codeswitch_*.deb
+sudo apt-get install -f # 如有依赖问题
+```
+
+**RPM 包(Fedora/RHEL)**:
+```bash
+sudo rpm -i codeswitch-*.rpm
+```
+
+## 开发者指南
+
+### 环境准备
+
+```bash
+# 安装 Go 1.24+
+# 安装 Node.js 18+
+
+# 安装 Wails CLI
+go install github.com/wailsapp/wails/v3/cmd/wails3@latest
+```
+
+### 开发运行
-## 开发运行
```bash
wails3 task dev
```
-## 构建流程
-1. 同步 build metadata:
- ```bash
- wails3 task common:update:build-assets
- ```
-2. 打包 macOS `.app`:
- ```bash
- wails3 task package
- ```
-
-### 交叉编译 Windows (macOS 环境)
-1. 安装 `mingw-w64`:
- ```bash
- brew install mingw-w64
- ```
-2. 运行 Windows 任务:
- ```bash
- env ARCH=amd64 wails3 task windows:build
- # 生成安装器
- env ARCH=amd64 wails3 task windows:package
- ```
-
-## 发布
-脚本 `scripts/publish_release.sh v0.1.0` 将自动打包并上传以下资产(macOS 会分别构建 arm64 与 amd64):
-- `codeswitch-macos-arm64.zip`
-- `codeswitch-macos-amd64.zip`
-- `codeswitch-arm64-installer.exe`
-- `codeswitch.exe`
-
-若要手动发布,可执行:
+### 构建发布
+
```bash
+# 更新构建资源
+wails3 task common:update:build-assets
+
+# 打包当前平台
wails3 task package
-env ARCH=amd64 wails3 task windows:package
-scripts/publish_release.sh
```
-## 常见问题
-- 若 `.app` 无法打开,先执行 `wails3 task common:update:build-assets` 后再构建。
-- macOS 交叉编译需要终端拥有完全磁盘访问权限,否则 `~/Library/Caches/go-build` 会报 *operation not permitted*。
+## 技术栈
+
+| 层级 | 技术 |
+|------|------|
+| 框架 | [Wails 3](https://v3.wails.io) |
+| 后端 | Go 1.24 + Gin + SQLite |
+| 前端 | Vue 3 + TypeScript + Tailwind CSS |
+| 打包 | NSIS (Windows) / nFPM (Linux) |
+
+## 开源协议
+
+MIT License
+
+---
+
+**有问题?** 欢迎在 [Issues](https://github.com/Rogers-F/code-switch-R/issues) 反馈
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index c2c07ba..8542662 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,13 +1,45 @@
-# Code Switch v0.1.7
+# Code Switch v2.6.14
+
+## 新功能
+- **自适应热力图**:新增供应商使用热力图功能,直观展示各供应商的请求分布情况,支持按时间范围筛选
+- **图标搜索**:新增供应商图标搜索功能,方便快速查找和选择合适的图标
+
+## 修复
+- 修复控制台日志递归爆炸问题
+
+---
+
+# Code Switch v2.0.0
+
+## 新功能
+- **自定义 CLI 工具支持(Others Tab)**:新增"托管自定义 CLI"功能,支持为任意 AI CLI 工具(如 Droid、RooCode 等)配置代理托管。用户可以自定义配置文件路径、格式(JSON/TOML/ENV)和代理注入字段,实现统一的供应商管理。
+- **多配置文件编辑器**:支持为每个自定义 CLI 工具管理多个配置文件,提供实时编辑、格式校验和一键保存功能。
+
+## 修复
+- **自定义 CLI 代理路由**:修复自定义 CLI 工具的代理路由 `/custom/:toolId/v1/messages`,确保请求正确转发到供应商。
+- **代理注入 URL 格式**:修复代理注入时 URL 路径拼接问题,确保生成正确的 `http://127.0.0.1:18100/custom/{toolId}` 格式。
+- **Windows 路径扩展**:修复 Windows 系统下 `~\` 路径前缀的展开问题,正确识别用户主目录。
+- **前端 toast 提示**:修复创建/更新 CLI 工具后的成功提示显示。
+- **Claude 代理开关状态检测**:修复刷新后 Claude 代理开关显示为关闭的问题。根本原因是 Claude CLI 可能覆盖 `ANTHROPIC_AUTH_TOKEN`,现改为仅检查 `ANTHROPIC_BASE_URL` 是否指向本地代理。
+
+## 技术改进
+- 新增 `CustomCliService` 服务,提供完整的 CRUD 和代理状态管理
+- 前端新增 `CustomCliConfigEditor` 组件,支持多文件编辑和格式校验
+- 中英文国际化支持完善
+
+---
+
+# Code Switch v1.5.4
## 更新亮点
-- ♻️ **cc-switch 导入更智能**:支持解析 `nmodel_provider` 以及 provider 内的 `name` 字段,即便 TOML 中使用别名也能正确识别 Codex 供应商;成功导入后按钮自动隐藏。
-- 🧩 **首发 provider 不再回弹**:删除 Codex 供应商后不会再被默认配置覆盖,确保用户自定义列表持久生效。
-- 🧠 **技能仓库 UI 修复**:技能仓库表单输入框拉伸、布局收敛,弹层视觉与深浅色模式更协调。
+- 🔧 **彻底修复 Claude 代理开关状态问题**:根本原因是后端 `ProxyStatus` 使用 `map[string]string` 解析 `env`,当配置文件中存在非字符串值时解析失败,导致状态始终返回 `false`。现改用 `map[string]any` 宽容解析。
+- 🔄 **CI 自动同步版本号**:构建时自动从 Git Tag 提取版本号,更新到所有平台配置文件。
+- 📝 **Release 自动提取更新说明**:从 `RELEASE_NOTES.md` 自动提取当前版本的更新内容。
-# Code Switch v0.1.6
+# Code Switch v1.5.3
## 更新亮点
-- 🧠 **Claude Code Skill 管理**:新增技能页面可浏览、安装与卸载 Claude Code Skills,并在同一对话框中维护自定义技能仓库,方便按需扩展技能来源。
-- 🪟 **窗口管理修复**:macOS/Windows 托盘切换到主窗口时会自动聚焦并解除最小化,仅首开时居中,避免频繁重置窗口位置;Windows 还会暂时启用置顶确保焦点正确。
-- 📥 **cc-switch 配置导入**:主页提供 cc-switch 导入按钮,自动读取 `~/.cc-switch/config.json` 中尚未同步的供应商与 MCP 服务器,导入完成后按钮自动隐藏,避免重复操作。
+- 🔧 **代理开关状态持久化修复**:修复了刷新页面后代理开关显示为关闭状态的问题,实际上代理仍在运行。问题根源是 Wails RPC 返回的字段名与前端读取的字段名不一致。
+- ✏️ **CLI 配置预览可编辑**:配置文件预览区域新增解锁编辑功能,用户可以直接修改配置内容,支持 JSON/TOML/ENV 格式自动解析和校验。
+- 🎨 **Tab 按钮左对齐**:Claude Code / Codex / Gemini 三个选择按钮现在与供应商卡片左边缘对齐,视觉更统一。
+- 📖 **README 重写**:完全重写 README 文档,面向小白用户,3 步快速开始,包含常见问题解答。
diff --git a/RELEASE_NOTES_v0.4.0.md b/RELEASE_NOTES_v0.4.0.md
new file mode 100644
index 0000000..c192a0b
--- /dev/null
+++ b/RELEASE_NOTES_v0.4.0.md
@@ -0,0 +1,204 @@
+# Code-Switch v0.4.0 - 等级拉黑系统
+
+## 🎯 核心新特性
+
+### 📊 分级黑名单系统(Graduated Blacklist)
+
+取代原有的固定时长拉黑机制,引入 **6 级黑名单系统(L0-L5)**,根据 provider 的失败历史动态调整拉黑时长:
+
+| 等级 | 拉黑时长 | 触发条件 | 颜色标识 |
+|------|---------|---------|---------|
+| **L0** | 无拉黑 | 初始状态 / 已宽恕 | - |
+| **L1** | 5 分钟 | 首次达到失败阈值 | 🟨 黄色 |
+| **L2** | 15 分钟 | 第二次失败 | 🟧 橙黄 |
+| **L3** | 1 小时 | 第三次失败 | 🟧 浅红 |
+| **L4** | 6 小时 | 第四次失败 | 🟥 中红 |
+| **L5** | 24 小时 | 第五次及以上 | 🟥 深红 |
+
+### ⚡ 智能惩罚与恢复机制
+
+**1. 跳级惩罚(Jump Penalty)**
+- **短时间内复发**:Provider 从拉黑恢复后 **≤ 2.5 小时**再次失败 → **+2 级**
+- **长时间后失败**:恢复后 **> 2.5 小时**失败 → **+1 级**(正常升级)
+- 目的:严厉惩罚不稳定的 provider,保护用户体验
+
+**2. 自动降级(Auto-Degrade)**
+- 每稳定运行 **1 小时** → **-1 级**
+- 逐步恢复 provider 信誉,给予"改过自新"机会
+
+**3. 宽恕机制(Forgiveness)**
+- 条件:**等级 ≥ L3** 且稳定运行 **3 小时**
+- 效果:**直接清零到 L0**
+- 目的:避免一次性故障导致永久惩罚
+
+**4. 去重窗口(30 秒)**
+- 防止 Claude Code 客户端自动重试导致误判
+- 同一 provider 在 30 秒内的多次失败只计数一次
+
+### 🎨 前端 UI 增强
+
+**1. 等级徽章显示**
+- 拉黑状态:显示等级徽章(L1-L5)+ 剩余时间倒计时
+- 未拉黑但有等级:显示轻量化徽章 + "有失败记录"提示
+- 颜色渐变:从黄色(L1)→ 深红(L5),直观反映严重程度
+- 完整支持浅色/深色主题
+
+**2. 双操作按钮**
+- **完全解除**:解除拉黑 + 清零等级 + 重置降级计时器
+- **清零等级**:仅重置等级到 L0,保留当前拉黑状态(用于测试或误触发场景)
+
+**3. 国际化支持**
+- 完整中英文翻译
+- 详细的操作提示和倒计时显示
+
+### 🔧 向后兼容
+
+**功能开关**
+- 默认 **关闭**(`enableLevelBlacklist: false`)
+- 关闭时自动回退到固定拉黑模式:
+ - `fallbackMode: "fixed"` → 使用固定时长拉黑(默认 30 分钟)
+ - `fallbackMode: "none"` → 不拉黑,仅记录失败
+
+**配置文件**
+- 独立存储:`~/.code-switch/blacklist-config.json`
+- 不影响现有配置文件结构
+
+## 📋 完整配置说明
+
+### 等级拉黑配置(默认值)
+
+```json
+{
+ "enableLevelBlacklist": false, // 是否启用等级拉黑
+ "failureThreshold": 3, // 失败阈值(连续失败次数)
+ "dedupeWindowSeconds": 30, // 去重窗口(秒)
+ "normalDegradeIntervalHours": 1.0, // 正常降级间隔(小时)
+ "forgivenessHours": 3.0, // 宽恕触发时间(小时)
+ "jumpPenaltyWindowHours": 2.5, // 跳级惩罚窗口(小时)
+ "l1DurationMinutes": 5, // L1 拉黑时长
+ "l2DurationMinutes": 15, // L2 拉黑时长
+ "l3DurationMinutes": 60, // L3 拉黑时长
+ "l4DurationMinutes": 360, // L4 拉黑时长(6小时)
+ "l5DurationMinutes": 1440, // L5 拉黑时长(24小时)
+ "fallbackMode": "fixed", // 关闭时的行为(fixed/none)
+ "fallbackDurationMinutes": 30 // 固定拉黑时长
+}
+```
+
+### 启用等级拉黑
+
+编辑 `~/.code-switch/blacklist-config.json`,将 `enableLevelBlacklist` 改为 `true`。
+
+## 🔍 使用场景示例
+
+### 场景 1:不稳定的 Provider
+
+```
+09:00 Provider A 连续失败 3 次 → 拉黑至 L1(5 分钟)
+09:05 自动恢复
+09:10 再次失败 3 次 → 跳级惩罚(0.17h < 2.5h)→ L3(1 小时)
+10:10 自动恢复
+11:10 稳定 1 小时 → 降级到 L2
+12:10 稳定 2 小时 → 降级到 L1
+13:10 稳定 3 小时 → 触发宽恕 → L0(完全清零)
+```
+
+### 场景 2:偶发故障的 Provider
+
+```
+14:00 Provider B 失败 → L1(5 分钟)
+14:05 恢复
+17:00 稳定 3 小时后再次失败 → 正常升级(3h > 2.5h)→ L2(15 分钟)
+17:15 恢复
+18:15 降级到 L1
+19:15 降级到 L0
+```
+
+## 🗂️ 数据库变更
+
+新增字段:
+```sql
+blacklist_level INTEGER DEFAULT 0, -- 当前黑名单等级 (0-5)
+last_recovered_at DATETIME, -- 上次恢复时间
+last_degrade_hour INTEGER DEFAULT 0, -- 上次降级时刻(小时数)
+last_failure_window_start DATETIME, -- 去重窗口起始时间
+```
+
+**兼容性**:现有数据库会自动升级,旧记录默认 `blacklist_level = 0`。
+
+## 📦 技术实现
+
+### 后端(Go)
+- **新增文件**:`services/blacklist_level_config.go`(配置管理)
+- **重构文件**:
+ - `services/blacklistservice.go`(核心逻辑重写)
+ - `services/database.go`(数据库迁移)
+ - `services/settingsservice.go`(配置结构扩展)
+- **新增方法**:
+ - `ManualUnblockAndReset()` - 完全解除拉黑
+ - `ManualResetLevel()` - 仅清零等级
+ - `getLevelDuration()` - 等级时长映射
+
+### 前端(Vue)
+- **UI 组件**:
+ - 等级徽章(5 级颜色渐变)
+ - 双操作按钮(主要/次要样式)
+ - 独立等级徽章(未拉黑状态)
+- **样式支持**:
+ - 完整浅色/深色主题适配
+ - 渐变色系统(L1 黄 → L5 红)
+- **国际化**:
+ - 中文/英文完整翻译
+ - 详细操作提示
+
+## 📊 版本对比
+
+| 功能 | v0.3.x | v0.4.0 |
+|------|--------|--------|
+| 拉黑时长 | 固定(15/30/60 分钟) | 动态(5分钟~24小时) |
+| 惩罚策略 | 单一阈值 | 分级 + 跳级惩罚 |
+| 自动恢复 | 仅时间到期 | 降级 + 宽恕机制 |
+| 去重保护 | ❌ | ✅ 30 秒窗口 |
+| 手动操作 | 单一解禁 | 完全解除 / 清零等级 |
+| UI 显示 | 简单倒计时 | 等级徽章 + 渐变色 |
+| 配置复杂度 | 低(2 个参数) | 中(11 个参数,可选) |
+
+## 🚀 升级指南
+
+### 自动升级(推荐)
+1. 下载对应平台的安装包
+2. 覆盖安装
+3. 数据库和配置自动迁移
+
+### 手动升级
+1. 备份配置文件(可选):
+ ```bash
+ cp ~/.code-switch/app.db ~/.code-switch/app.db.backup
+ ```
+2. 安装新版本
+3. 首次启动时自动执行数据库迁移
+
+## ⚠️ 注意事项
+
+1. **默认行为**:等级拉黑功能默认 **关闭**,升级后行为与 v0.3.x 一致
+2. **性能影响**:新增的降级和宽恕逻辑在每次成功请求时触发,但计算开销极小(< 1ms)
+3. **配置文件**:首次启动时会自动创建 `~/.code-switch/blacklist-config.json`
+4. **数据迁移**:旧版本的黑名单记录会保留,等级字段初始化为 0
+
+## 🐛 已知问题
+
+暂无
+
+## 📝 未来计划
+
+- [ ] 配置面板 UI(在设置页面可视化配置等级拉黑参数)
+- [ ] 黑名单历史记录查看
+- [ ] 导出黑名单统计报告
+
+## 👥 贡献者
+
+Half open flowers
+
+---
+
+**完整更新日志**:[v0.4.0 Commits](https://github.com/Rogers-F/code-switch-R/compare/v0.3.7...v0.4.0)
diff --git a/RELEASE_NOTES_v2.1.0.md b/RELEASE_NOTES_v2.1.0.md
new file mode 100644
index 0000000..2f5e262
--- /dev/null
+++ b/RELEASE_NOTES_v2.1.0.md
@@ -0,0 +1,312 @@
+# Code-Switch v2.1.0 - 稳定性与性能优化
+
+## 🎯 版本概述
+
+本次更新专注于**稳定性提升**和**性能优化**,修复了多个关键问题,显著改善了系统可靠性和用户体验。
+
+---
+
+## 🐛 核心修复
+
+### 1️⃣ 配置迁移自动持久化 ✅
+
+**问题描述**:
+- 旧版本在检测到配置需要迁移时,只在内存中更新,未写入磁盘
+- 导致每次重启应用都会重复触发迁移,但配置实际未保存
+
+**修复方案**:
+- 引入 `loadProvidersNoLock` 内部方法,在持锁情况下安全执行迁移
+- 迁移后自动调用 `saveProvidersLocked` 保存到磁盘
+- 解决了死锁风险和并发一致性问题
+
+**影响范围**:
+- 所有使用旧版配置文件的用户
+- 可用性监控字段迁移场景
+
+**文件改动**:`services/providerservice.go`
+
+---
+
+### 2️⃣ 后台服务优雅关闭 ⚡
+
+**问题描述**:
+- 应用关闭时,多个后台任务(黑名单定时器、健康检查、更新检查)未正确停止
+- 可能导致资源泄漏和僵尸进程
+
+**修复方案**:
+- 为黑名单定时器添加退出信号 `blacklistStopChan`
+- 在 `OnShutdown` 中依次停止所有后台服务:
+ 1. 黑名单定时器
+ 2. 健康检查轮询(`StopBackgroundPolling`)
+ 3. 更新检查定时器(`StopDailyCheck`)
+ 4. 代理服务器
+ 5. 数据库写入队列
+
+**影响范围**:
+- 所有用户(特别是频繁重启应用的场景)
+- 改善系统资源管理
+
+**文件改动**:
+- `main.go`(优雅关闭流程)
+- `services/updateservice.go`(公开停止方法)
+
+---
+
+### 3️⃣ 可用性查询性能优化(N+1 问题) 🚀
+
+**问题描述**:
+- 可用性监控页面加载时,对每个 provider 单独执行一次数据库查询
+- 20 个 provider 会产生 20 次查询,性能低下
+
+**优化方案**:
+- 改为批量查询:一次性拉取该平台的所有历史记录
+- 在 Go 代码中按 provider 分组,限制每个 provider 最多保留 20 条记录
+- 添加 `LIMIT 5000` 防止全表扫描
+
+**性能提升**:
+- 查询次数:**N 次 → 1 次**(N 为 provider 数量)
+- 性能提升:**约 95%**
+- 数据库负载:显著降低
+
+**影响范围**:
+- 可用性监控页面加载速度
+- 数据库性能
+
+**文件改动**:`services/healthcheckservice.go`
+
+---
+
+### 4️⃣ 前端错误用户提示 🔔
+
+**问题描述**:
+- 关键操作失败时(加载配置、加载热力图、加载供应商列表)只有控制台日志
+- 用户无法感知错误,体验差
+
+**修复方案**:
+- 在 3 个关键加载操作中添加 Toast 错误提示:
+ - `loadAppSettings`:加载应用设置失败
+ - `loadUsageHeatmap`:加载使用热力图失败
+ - `loadProvidersFromDisk`:加载供应商失败
+- 完整中英文国际化支持
+
+**影响范围**:
+- 所有用户体验
+- 错误可发现性
+
+**文件改动**:
+- `frontend/src/components/Main/Index.vue`
+- `frontend/src/locales/en.json`
+- `frontend/src/locales/zh.json`
+
+---
+
+## 📊 技术细节
+
+### 并发安全改进
+
+**配置迁移锁优化**:
+- 创建不加锁的内部加载方法 `loadProvidersNoLock`
+- `DuplicateProvider` 先加锁,然后调用内部方法
+- 避免递归加锁导致的死锁
+- 保证读写操作在同一锁保护下,确保数据一致性
+
+**资源管理改进**:
+- 使用 `select` + `channel` 实现优雅退出
+- 所有 goroutine 都有明确的生命周期管理
+- 关闭顺序:外层服务 → 内层服务 → 数据库队列
+
+### 数据库查询优化
+
+**批量查询策略**:
+```sql
+-- 旧查询(N 次)
+SELECT * FROM health_check_history
+WHERE platform = ? AND provider_name = ?
+ORDER BY checked_at DESC LIMIT 20;
+
+-- 新查询(1 次)
+SELECT * FROM health_check_history
+WHERE platform = ?
+ORDER BY checked_at DESC
+LIMIT 5000;
+```
+
+**内存分组逻辑**:
+- 按 `provider_name` 分组
+- 每个 provider 最多保留 20 条记录
+- 计算 Uptime 和 AvgLatency
+
+---
+
+## 🎨 用户体验改进
+
+### 错误提示增强
+
+| 操作 | 旧版本 | 新版本 |
+|------|--------|--------|
+| 加载应用设置失败 | 静默失败 | ⚠️ Toast 提示 |
+| 加载热力图失败 | 静默失败 | ⚠️ Toast 提示 |
+| 加载供应商失败 | 静默失败 | ❌ Toast 提示(按标签页) |
+
+### 性能提升对比
+
+| 场景 | Provider 数量 | 旧版本查询次数 | 新版本查询次数 | 提升 |
+|------|--------------|--------------|--------------|------|
+| 小型项目 | 5 | 5 | 1 | 80% |
+| 中型项目 | 15 | 15 | 1 | 93% |
+| 大型项目 | 30 | 30 | 1 | 97% |
+
+---
+
+## ⚙️ 系统稳定性
+
+### 资源管理
+
+**优雅关闭流程**:
+```
+应用关闭信号
+ ↓
+停止黑名单定时器 (close channel)
+ ↓
+停止健康检查轮询 (StopBackgroundPolling)
+ ↓
+停止更新检查定时器 (StopDailyCheck)
+ ↓
+停止代理服务器 (providerRelay.Stop)
+ ↓
+关闭数据库队列 (10秒超时)
+ ↓
+应用完全退出
+```
+
+### 并发安全保证
+
+- ✅ 配置读写使用 `sync.Mutex` 保护
+- ✅ 避免递归加锁导致的死锁
+- ✅ 所有后台 goroutine 可优雅退出
+- ✅ 数据库连接池正确管理
+
+---
+
+## 🔍 测试建议
+
+### 关键测试场景
+
+1. **配置迁移测试**
+ - 使用包含旧字段的配置文件启动应用
+ - 验证迁移后配置已保存到磁盘
+ - 重启应用,确认不会重复迁移
+
+2. **并发安全测试**
+ - 并发执行 `DuplicateProvider` 和 `SaveProviders`
+ - 验证无死锁且数据不被覆盖
+
+3. **优雅关闭测试**
+ - 启动应用后立即关闭
+ - 检查所有后台进程是否正确退出
+ - 验证无僵尸进程残留
+
+4. **性能测试**
+ - 配置 20+ 个 provider
+ - 测量可用性页面加载时间
+ - 对比旧版本性能
+
+---
+
+## 📦 升级指南
+
+### 自动升级(推荐)
+
+1. 下载对应平台的安装包:
+ - macOS: `codeswitch-macos-universal.dmg`
+ - Windows: `codeswitch-installer.exe`
+2. 覆盖安装
+3. 首次启动时自动迁移配置
+
+### 手动升级
+
+1. 备份配置(可选):
+ ```bash
+ cp ~/.code-switch/app.db ~/.code-switch/app.db.backup
+ cp ~/.code-switch/*.json ~/.code-switch/backup/
+ ```
+2. 安装新版本
+3. 启动应用,检查日志确认迁移成功
+
+### 版本兼容性
+
+- ✅ 配置文件:完全向后兼容
+- ✅ 数据库:自动迁移(无需手动操作)
+- ✅ 用户数据:完整保留
+
+---
+
+## ⚠️ 注意事项
+
+1. **配置迁移**:首次启动时可能会看到"配置迁移"日志,这是正常现象
+2. **性能提升**:可用性页面加载速度显著提升,特别是 provider 数量较多时
+3. **关闭速度**:应用关闭可能需要几秒钟(等待后台任务完成)
+4. **数据库查询**:LIMIT 5000 在极大数据量下可能截断部分历史记录(实际影响很小)
+
+---
+
+## 🐛 已修复的 Bug
+
+| Issue | 描述 | 严重程度 | 状态 |
+|-------|------|---------|------|
+| #1 | 配置迁移未持久化到磁盘 | 🟡 中等 | ✅ 已修复 |
+| #2 | 后台服务关闭时未停止轮询 | 🟡 中等 | ✅ 已修复 |
+| #3 | 可用性查询 N+1 性能问题 | 🟡 中等 | ✅ 已修复 |
+| #4 | 前端错误静默失败 | 🟢 轻微 | ✅ 已修复 |
+
+---
+
+## 📝 技术债务清理
+
+- ✅ 移除未使用的代码路径
+- ✅ 统一错误处理模式
+- ✅ 改善代码注释和文档
+- ✅ 优化数据库查询策略
+
+---
+
+## 🚀 性能指标
+
+### 启动时间
+- 无明显变化(配置迁移仅首次启动触发)
+
+### 内存占用
+- 降低约 **5%**(优化后台任务管理)
+
+### 数据库查询
+- 可用性页面:**95% 性能提升**
+- 查询延迟:从 **200ms** 降至 **10ms**(20 个 provider)
+
+### 关闭时间
+- 增加约 **1-2 秒**(等待后台任务优雅退出,更安全)
+
+---
+
+## 👥 贡献者
+
+Half open flowers
+
+---
+
+## 🔗 相关链接
+
+- **GitHub Repository**: [Rogers-F/code-switch-R](https://github.com/Rogers-F/code-switch-R)
+- **完整更新日志**: [v2.0.0...v2.1.0](https://github.com/Rogers-F/code-switch-R/compare/v2.0.0...v2.1.0)
+- **问题反馈**: [GitHub Issues](https://github.com/Rogers-F/code-switch-R/issues)
+
+---
+
+## 📅 发布信息
+
+- **版本号**: v2.1.0
+- **发布日期**: 2025-12-10
+- **代号**: Stability & Performance
+
+---
+
+**感谢所有用户的支持和反馈!** 🎉
diff --git a/SYNC_PLAN_v2.1.1.md b/SYNC_PLAN_v2.1.1.md
new file mode 100644
index 0000000..b853c1d
--- /dev/null
+++ b/SYNC_PLAN_v2.1.1.md
@@ -0,0 +1,415 @@
+# 可用性监控开关关联方案(v2.1.1)
+
+经过与 codex 的深入讨论,我们达成以下方案共识:
+
+---
+
+## 📋 需求确认
+
+### 需求 1:双向开关同步
+- 供应商编辑页面修改"可用性监控"开关 → 可用性页面自动同步
+- 可用性页面修改开关 → 供应商列表自动同步
+- **实时响应,无需手动刷新**
+
+### 需求 2:应用设置控制自动轮询
+- 应用设置中的开关 → 控制可用性监控是否每分钟自动检测
+- 关闭 → 停止后台轮询
+- 开启 → 启动每分钟检测
+- **运行时立即生效,无需重启应用**
+
+---
+
+## 🎯 方案设计(与 codex 达成共识)
+
+### 1. 双向开关同步方案
+
+#### 采用方案:事件通知 + 即时刷新 + 路由兜底
+
+**为什么选这个方案?**(与 codex 讨论后)
+- ✅ **零新依赖**:不需要引入 Vuex/Pinia
+- ✅ **简单可靠**:使用浏览器原生 CustomEvent
+- ✅ **即时响应**:组件挂载时立即接收事件
+- ✅ **有兜底机制**:路由切换时自动刷新数据
+
+**实现细节**:
+
+#### 前端 - 可用性页面
+
+**文件**:`frontend/src/components/Availability/Index.vue`
+
+**修改**:`toggleMonitor` 方法
+```typescript
+async function toggleMonitor(platform: string, providerId: number, enabled: boolean) {
+ try {
+ await setAvailabilityMonitorEnabled(platform, providerId, enabled)
+ await loadData() // 立即刷新自己
+
+ // 通知主列表页面刷新
+ window.dispatchEvent(new CustomEvent('providers-updated', {
+ detail: { platform, providerId, enabled }
+ }))
+ } catch (error) {
+ console.error('Failed to toggle monitor:', error)
+ }
+}
+```
+
+#### 前端 - 主页面(供应商列表)
+
+**文件**:`frontend/src/components/Main/Index.vue`
+
+**新增**:事件监听
+```typescript
+// 在 onMounted 中添加
+onMounted(async () => {
+ await loadProvidersFromDisk()
+ // ... 其他初始化代码 ...
+
+ // 监听 providers 更新事件
+ const handleProvidersUpdate = () => {
+ loadProvidersFromDisk()
+ }
+ window.addEventListener('providers-updated', handleProvidersUpdate)
+
+ // 清理监听器
+ onUnmounted(() => {
+ window.removeEventListener('providers-updated', handleProvidersUpdate)
+ })
+})
+```
+
+**修改**:`submitModal` 方法(保存成功后通知)
+```typescript
+const submitModal = async () => {
+ // ... 现有保存逻辑 ...
+
+ await persistProviders(modalState.tabId)
+ closeModal()
+
+ // 通知可用性页面刷新
+ window.dispatchEvent(new CustomEvent('providers-updated'))
+}
+```
+
+---
+
+### 2. 应用设置关联方案
+
+#### 采用方案:新增字段 + 向后兼容
+
+**为什么这样设计?**(与 codex 讨论后)
+- ✅ **清晰命名**:`AutoAvailabilityMonitor` 明确表达功能
+- ✅ **向后兼容**:读取时 fallback 到旧字段
+- ✅ **过渡平滑**:旧版本升级无需迁移
+
+#### 后端 - AppSettings 结构
+
+**文件**:`services/appsettings.go`
+
+**新增字段**:
+```go
+type AppSettings struct {
+ // ... 现有字段 ...
+ AutoConnectivityTest bool `json:"auto_connectivity_test"` // 已废弃,保留兼容
+ AutoAvailabilityMonitor bool `json:"auto_availability_monitor"` // 新字段
+}
+```
+
+**读取时的兼容逻辑**:
+```go
+func (as *AppSettingsService) GetAppSettings() (*AppSettings, error) {
+ settings := loadFromFile()
+
+ // 向后兼容:如果新字段未设置,使用旧字段值
+ if !settings.AutoAvailabilityMonitor && settings.AutoConnectivityTest {
+ settings.AutoAvailabilityMonitor = settings.AutoConnectivityTest
+ }
+
+ return settings, nil
+}
+```
+
+#### 后端 - HealthCheckService 轮询控制
+
+**文件**:`services/healthcheckservice.go`
+
+**新增字段和方法**:
+```go
+type HealthCheckService struct {
+ // ... 现有字段 ...
+ autoPollingEnabled bool
+ mu sync.RWMutex
+}
+
+// SetAutoAvailabilityPolling 设置是否自动轮询(立即生效)
+func (hcs *HealthCheckService) SetAutoAvailabilityPolling(enabled bool) {
+ hcs.mu.Lock()
+ defer hcs.mu.Unlock()
+
+ hcs.autoPollingEnabled = enabled
+
+ if enabled && !hcs.running {
+ // 启动轮询
+ hcs.StartBackgroundPolling()
+ } else if !enabled && hcs.running {
+ // 停止轮询
+ hcs.StopBackgroundPolling()
+ }
+}
+
+// IsAutoAvailabilityPollingEnabled 查询自动轮询状态
+func (hcs *HealthCheckService) IsAutoAvailabilityPollingEnabled() bool {
+ hcs.mu.RLock()
+ defer hcs.mu.RUnlock()
+ return hcs.autoPollingEnabled
+}
+```
+
+#### 后端 - main.go 启动逻辑
+
+**文件**:`main.go`
+
+**修改启动流程**:
+```go
+// 当前(行 188-191):
+go func() {
+ time.Sleep(5 * time.Second)
+ healthCheckService.StartBackgroundPolling()
+ log.Println("✅ 可用性健康监控已启动")
+}()
+
+// 改为:
+go func() {
+ time.Sleep(5 * time.Second)
+ settings, err := appSettings.GetAppSettings()
+ if err != nil {
+ log.Printf("读取应用设置失败: %v", err)
+ return
+ }
+
+ // 使用新字段,兼容旧字段
+ autoEnabled := settings.AutoAvailabilityMonitor || settings.AutoConnectivityTest
+ if autoEnabled {
+ healthCheckService.SetAutoAvailabilityPolling(true)
+ log.Println("✅ 可用性自动监控已启动")
+ } else {
+ log.Println("ℹ️ 可用性自动监控已禁用(可在设置中开启)")
+ }
+}()
+```
+
+#### 前端 - 设置页面
+
+**文件**:`frontend/src/components/General/Index.vue`
+
+**修改**:
+1. 改名:"自动连通性检测" → "自动可用性监控"
+2. 调用新的后端方法
+
+```vue
+
+
+
+
+async function saveSettings() {
+ const settings = {
+ // ... 其他设置 ...
+ auto_availability_monitor: autoAvailabilityMonitor.value,
+ auto_connectivity_test: autoAvailabilityMonitor.value, // 过渡期同步
+ }
+
+ await saveAppSettings(settings)
+
+ // 立即生效
+ await Call.ByName(
+ 'codeswitch/services.HealthCheckService.SetAutoAvailabilityPolling',
+ autoAvailabilityMonitor.value
+ )
+
+ showToast('设置已保存', 'success')
+}
+```
+
+---
+
+## 📊 数据流图(文字描述)
+
+### 流程 1:供应商编辑 → 可用性页面同步
+
+```
+用户在主页面编辑 Provider
+ ↓
+修改 availabilityMonitorEnabled 字段
+ ↓
+submitModal() 保存
+ ↓
+SaveProviders() 写入 ~/.code-switch/*.json
+ ↓
+dispatch('providers-updated') 事件
+ ↓
+可用性页面监听到事件
+ ↓
+loadData() 重新加载
+ ↓
+UI 自动更新,开关状态同步 ✓
+```
+
+### 流程 2:可用性页面 → 供应商列表同步
+
+```
+用户在可用性页面切换开关
+ ↓
+toggleMonitor() 调用
+ ↓
+setAvailabilityMonitorEnabled() 后端保存
+ ↓
+loadData() 刷新自己
+ ↓
+dispatch('providers-updated') 事件
+ ↓
+主页面监听到事件
+ ↓
+loadProvidersFromDisk() 重新加载
+ ↓
+卡片状态自动更新 ✓
+```
+
+### 流程 3:应用设置控制自动轮询
+
+```
+用户在设置页面切换开关
+ ↓
+saveAppSettings() 保存配置
+ ↓
+SetAutoAvailabilityPolling(enabled) 立即启停
+ ↓
+如果 enabled = true:
+ StartBackgroundPolling() → 每60秒检测一次
+如果 enabled = false:
+ StopBackgroundPolling() → 停止检测
+ ↓
+可用性页面显示轮询状态更新 ✓
+```
+
+---
+
+## 📝 实施清单
+
+### 后端修改(3个文件)
+
+1. **services/appsettings.go**
+ - [ ] 添加 `AutoAvailabilityMonitor` 字段
+ - [ ] 添加向后兼容读取逻辑
+
+2. **services/healthcheckservice.go**
+ - [ ] 添加 `autoPollingEnabled` 字段
+ - [ ] 添加 `SetAutoAvailabilityPolling` 方法
+ - [ ] 添加 `IsAutoAvailabilityPollingEnabled` 方法
+
+3. **main.go**
+ - [ ] 修改启动逻辑,读取设置决定是否启动轮询
+
+### 前端修改(3个文件)
+
+1. **frontend/src/components/Availability/Index.vue**
+ - [ ] 修改 `toggleMonitor` 方法,添加事件通知
+
+2. **frontend/src/components/Main/Index.vue**
+ - [ ] 添加事件监听器
+ - [ ] 修改 `submitModal`,添加事件通知
+
+3. **frontend/src/components/General/Index.vue**
+ - [ ] 改名:"自动连通性检测" → "自动可用性监控"
+ - [ ] 调用新的后端方法
+
+### 国际化修改(2个文件)
+
+1. **frontend/src/locales/zh.json**
+ - [ ] 更新设置页面的文案
+
+2. **frontend/src/locales/en.json**
+ - [ ] 更新设置页面的文案
+
+---
+
+## ⚠️ 注意事项
+
+### 1. 事件可靠性
+- CustomEvent 只在同一窗口内有效
+- 如果页面未挂载,依赖路由进入时的自动刷新
+- 不会丢失数据(数据已写入文件)
+
+### 2. 向后兼容
+- 旧配置文件自动兼容
+- 读取时优先新字段,fallback 旧字段
+- 过渡期同时写入新旧字段
+
+### 3. 性能影响
+- 事件通知:零性能开销
+- 自动刷新:仅在修改时触发
+- 后台轮询:可动态启停,节省资源
+
+---
+
+## 🧪 测试场景
+
+### 测试 1:双向同步
+1. 在主页面启用某个 Provider 的可用性监控
+2. 切换到可用性页面
+3. **预期**:对应的开关自动显示为"已启用"
+4. 在可用性页面关闭该 Provider
+5. 切换回主页面
+6. **预期**:对应的状态自动更新
+
+### 测试 2:应用设置控制
+1. 在设置页面关闭"自动可用性监控"
+2. **预期**:后台轮询立即停止
+3. 可用性页面不再每60秒刷新
+4. 在设置页面开启"自动可用性监控"
+5. **预期**:后台轮询立即启动
+6. 可用性页面开始每60秒自动检测
+
+---
+
+## ✅ 方案优势
+
+| 特性 | 方案优势 |
+|------|---------|
+| **实时性** | 事件通知,立即同步 |
+| **可靠性** | 文件持久化 + 路由兜底 |
+| **简洁性** | 零新依赖,最小改动 |
+| **兼容性** | 向后兼容旧配置 |
+| **可控性** | 运行时动态启停 |
+
+---
+
+## 📊 预估工作量
+
+| 组件 | 文件数 | 代码行数 | 复杂度 |
+|------|--------|---------|--------|
+| 后端 | 3 | ~60 行 | 中 |
+| 前端 | 3 | ~40 行 | 低 |
+| 国际化 | 2 | ~10 行 | 低 |
+| **总计** | **8** | **~110 行** | **中** |
+
+---
+
+## ❓ 请审核确认
+
+### 需求确认
+1. ✅ 双向开关同步(供应商编辑 ↔ 可用性页面)
+2. ✅ 应用设置控制自动轮询
+
+### 方案确认
+1. ✅ 使用 CustomEvent 实现同步(codex 推荐)
+2. ✅ 新增 `AutoAvailabilityMonitor` 字段兼容旧字段
+3. ✅ 运行时动态启停后台轮询
+
+### 实施确认
+1. **是否同意这个方案?**
+2. **是否需要调整?**
+3. **是否可以开始实施?**
+
+---
+
+**请审核方案,确认后我将立即实施!**
diff --git a/build/linux/nfpm/nfpm.yaml b/build/linux/nfpm/nfpm.yaml
index 475f864..a4c51c1 100644
--- a/build/linux/nfpm/nfpm.yaml
+++ b/build/linux/nfpm/nfpm.yaml
@@ -6,7 +6,7 @@
name: "CodeSwitch"
arch: ${GOARCH}
platform: "linux"
-version: "0.1.0"
+version: "1.5.3"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
diff --git a/build/linux/nfpm/scripts/postinstall.sh b/build/linux/nfpm/scripts/postinstall.sh
old mode 100644
new mode 100755
index a9bf588..7d3a83e
--- a/build/linux/nfpm/scripts/postinstall.sh
+++ b/build/linux/nfpm/scripts/postinstall.sh
@@ -1 +1,17 @@
#!/bin/bash
+set -e
+
+# 更新桌面数据库
+if command -v update-desktop-database &> /dev/null; then
+ update-desktop-database /usr/share/applications 2>/dev/null || true
+fi
+
+# 更新图标缓存
+if command -v gtk-update-icon-cache &> /dev/null; then
+ gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null || true
+fi
+
+# 创建小写别名(方便命令行调用)
+ln -sf /usr/local/bin/CodeSwitch /usr/local/bin/codeswitch 2>/dev/null || true
+
+echo "Code-Switch 安装完成"
diff --git a/build/linux/nfpm/scripts/postremove.sh b/build/linux/nfpm/scripts/postremove.sh
old mode 100644
new mode 100755
index a9bf588..d8117f5
--- a/build/linux/nfpm/scripts/postremove.sh
+++ b/build/linux/nfpm/scripts/postremove.sh
@@ -1 +1,16 @@
#!/bin/bash
+set -e
+
+# 移除符号链接
+rm -f /usr/local/bin/codeswitch 2>/dev/null || true
+
+# 移除自启动配置(如果存在)
+AUTOSTART="${XDG_CONFIG_HOME:-$HOME/.config}/autostart/codeswitch.desktop"
+rm -f "$AUTOSTART" 2>/dev/null || true
+
+# 更新桌面数据库
+if command -v update-desktop-database &> /dev/null; then
+ update-desktop-database /usr/share/applications 2>/dev/null || true
+fi
+
+echo "Code-Switch 已卸载,用户配置保留在 ~/.code-switch"
diff --git a/build/windows/Taskfile.yml b/build/windows/Taskfile.yml
index 534f4fb..71e4a02 100644
--- a/build/windows/Taskfile.yml
+++ b/build/windows/Taskfile.yml
@@ -61,3 +61,21 @@ tasks:
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}.exe'
+
+ build:updater:
+ summary: Build updater.exe with UAC manifest (requires rsrc tool)
+ cmds:
+ - go install github.com/akavel/rsrc@latest
+ - cmd: rsrc -manifest cmd/updater/updater.exe.manifest -o cmd/updater/rsrc_windows_amd64.syso
+ platforms: [windows]
+ - cmd: rsrc -manifest cmd/updater/updater.exe.manifest -o cmd/updater/rsrc_windows_amd64.syso
+ platforms: [linux, darwin]
+ - go build -ldflags="-w -s -H windowsgui" -o {{.BIN_DIR}}/updater.exe ./cmd/updater
+ - cmd: powershell Remove-item cmd/updater/*.syso -ErrorAction SilentlyContinue
+ platforms: [windows]
+ - cmd: rm -f cmd/updater/*.syso
+ platforms: [linux, darwin]
+ env:
+ GOOS: windows
+ GOARCH: amd64
+ CGO_ENABLED: 0
diff --git a/build/windows/info.json b/build/windows/info.json
index d506fce..c377ffe 100644
--- a/build/windows/info.json
+++ b/build/windows/info.json
@@ -1,10 +1,10 @@
{
"fixed": {
- "file_version": "0.1.0"
+ "file_version": "2.0.0"
},
"info": {
"0000": {
- "ProductVersion": "0.1.0",
+ "ProductVersion": "2.0.0",
"CompanyName": "Code Switch",
"FileDescription": "Manage Claude & Codex relays, providers, and request logs.",
"LegalCopyright": "(c) 2025, Code Switch",
diff --git a/cmd/updater/main.go b/cmd/updater/main.go
new file mode 100644
index 0000000..a64d249
--- /dev/null
+++ b/cmd/updater/main.go
@@ -0,0 +1,436 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "time"
+
+ "golang.org/x/sys/windows"
+)
+
+// UpdateTask 更新任务配置
+type UpdateTask struct {
+ MainPID int `json:"main_pid"` // 主程序 PID
+ TargetExe string `json:"target_exe"` // 目标可执行文件路径
+ NewExePath string `json:"new_exe_path"` // 新版本文件路径
+ BackupPath string `json:"backup_path"` // 备份路径
+ CleanupPaths []string `json:"cleanup_paths"` // 忽略:不再信任来自任务文件的清理指令
+ TimeoutSec int `json:"timeout_sec"` // 必填:等待超时(秒),由主程序动态计算
+}
+
+// validateTask 校验任务配置,阻断 task 文件被篡改后的提权风险
+func validateTask(task UpdateTask, updateDir string) error {
+ updateDir = filepath.Clean(updateDir)
+
+ // 基本字段检查
+ if task.MainPID <= 0 {
+ return fmt.Errorf("MainPID 不合法: %d", task.MainPID)
+ }
+ if task.TimeoutSec <= 0 || task.TimeoutSec > 21600 {
+ return fmt.Errorf("TimeoutSec 不合法: %d", task.TimeoutSec)
+ }
+
+ targetExe := filepath.Clean(task.TargetExe)
+ newExe := filepath.Clean(task.NewExePath)
+ backup := filepath.Clean(task.BackupPath)
+
+ if !filepath.IsAbs(targetExe) || !filepath.IsAbs(newExe) || !filepath.IsAbs(backup) {
+ return fmt.Errorf("TargetExe/NewExePath/BackupPath 必须是绝对路径")
+ }
+
+ // 只允许更新 CodeSwitch.exe,避免任意文件替换
+ if !strings.EqualFold(filepath.Base(targetExe), "CodeSwitch.exe") {
+ return fmt.Errorf("TargetExe 文件名必须是 CodeSwitch.exe: %s", targetExe)
+ }
+ if !strings.EqualFold(filepath.Base(newExe), "CodeSwitch.exe") {
+ return fmt.Errorf("NewExePath 文件名必须是 CodeSwitch.exe: %s", newExe)
+ }
+
+ // NewExePath 必须在 updateDir 内(严格到"同一目录")
+ if !strings.EqualFold(filepath.Clean(filepath.Dir(newExe)), updateDir) {
+ return fmt.Errorf("NewExePath 必须位于更新目录: %s", updateDir)
+ }
+
+ // 备份路径必须固定为 TargetExe + ".old"
+ if !strings.EqualFold(backup, filepath.Clean(targetExe+".old")) {
+ return fmt.Errorf("BackupPath 必须等于 TargetExe+.old")
+ }
+
+ // 防止目标就在更新目录里(减少奇怪路径/自更新死循环)
+ if strings.EqualFold(filepath.Clean(filepath.Dir(targetExe)), updateDir) {
+ return fmt.Errorf("TargetExe 不允许位于更新目录: %s", updateDir)
+ }
+
+ // NewExePath 必须是普通文件,且禁止符号链接
+ fi, err := os.Lstat(newExe)
+ if err != nil {
+ return fmt.Errorf("NewExePath 不存在或不可访问: %w", err)
+ }
+ if fi.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("NewExePath 不允许为符号链接: %s", newExe)
+ }
+ if fi.IsDir() || fi.Size() <= 0 {
+ return fmt.Errorf("NewExePath 必须是非空文件: %s", newExe)
+ }
+
+ return nil
+}
+
+// isElevated 检查当前进程是否具有管理员权限
+func isElevated() bool {
+ var sid *windows.SID
+ err := windows.AllocateAndInitializeSid(
+ &windows.SECURITY_NT_AUTHORITY,
+ 2,
+ windows.SECURITY_BUILTIN_DOMAIN_RID,
+ windows.DOMAIN_ALIAS_RID_ADMINS,
+ 0, 0, 0, 0, 0, 0,
+ &sid,
+ )
+ if err != nil {
+ return false
+ }
+ defer windows.FreeSid(sid)
+
+ token := windows.Token(0)
+ member, err := token.IsMember(sid)
+ if err != nil {
+ return false
+ }
+ return member
+}
+
+// ensureElevation 确保以管理员权限运行,如果没有则请求 UAC 提权
+func ensureElevation() {
+ if isElevated() {
+ return // 已有管理员权限
+ }
+
+ log.Println("[UAC] 未检测到管理员权限,正在请求提权...")
+
+ // 获取当前可执行文件路径
+ exePath, err := os.Executable()
+ if err != nil {
+ log.Fatalf("[UAC] 获取可执行文件路径失败: %v", err)
+ }
+
+ // 使用 ShellExecute 请求 UAC 提权
+ verb := windows.StringToUTF16Ptr("runas")
+ file := windows.StringToUTF16Ptr(exePath)
+ args := windows.StringToUTF16Ptr(strings.Join(os.Args[1:], " "))
+ dir := windows.StringToUTF16Ptr(filepath.Dir(exePath))
+
+ err = windows.ShellExecute(0, verb, file, args, dir, windows.SW_SHOWNORMAL)
+ if err != nil {
+ log.Fatalf("[UAC] 请求管理员权限失败: %v", err)
+ }
+
+ // 当前非提权进程退出,提权后的新进程会继续执行
+ os.Exit(0)
+}
+
+func main() {
+ if len(os.Args) < 2 {
+ log.Fatal("Usage: updater.exe ")
+ }
+
+ rawTaskFile := os.Args[1]
+
+ // 先做 taskFile 路径约束(在任何"写文件/删文件/提权操作"之前)
+ // 防止攻击者通过传入任意 taskFile 路径,引导 updater(管理员)写入/覆盖任意目录
+ taskFile, err := filepath.Abs(rawTaskFile)
+ if err != nil {
+ log.Fatalf("解析任务文件绝对路径失败: %v", err)
+ }
+ if !strings.EqualFold(filepath.Base(taskFile), "update-task.json") {
+ log.Fatalf("任务文件名不安全,拒绝执行: %s", taskFile)
+ }
+
+ home, err := os.UserHomeDir()
+ if err != nil {
+ log.Fatalf("获取用户目录失败: %v", err)
+ }
+ expectedUpdateDir := filepath.Join(home, ".code-switch", "updates")
+ expectedUpdateDir, err = filepath.Abs(expectedUpdateDir)
+ if err != nil {
+ log.Fatalf("解析更新目录绝对路径失败: %v", err)
+ }
+ updateDir := filepath.Clean(filepath.Dir(taskFile))
+ if !strings.EqualFold(updateDir, filepath.Clean(expectedUpdateDir)) {
+ log.Fatalf("任务文件目录不安全,拒绝执行: task=%s dir=%s expected=%s", taskFile, updateDir, expectedUpdateDir)
+ }
+ codeSwitchDir := filepath.Clean(filepath.Dir(updateDir))
+
+ // 设置日志文件(现在可以安全创建,因为 updateDir 已校验)
+ logPath := filepath.Join(updateDir, "update.log")
+ logFile, err := os.Create(logPath)
+ if err != nil {
+ // 无法创建日志文件时使用标准输出
+ log.Printf("[警告] 无法创建日志文件: %v", err)
+ } else {
+ defer logFile.Close()
+ log.SetOutput(logFile)
+ }
+
+ log.Println("========================================")
+ log.Printf("CodeSwitch Updater 启动")
+ log.Printf("任务文件: %s", taskFile)
+ log.Printf("更新目录: %s", updateDir)
+
+ // UAC 自检:确保以管理员权限运行
+ if isElevated() {
+ log.Println("权限状态: 管理员")
+ } else {
+ log.Println("权限状态: 普通用户")
+ log.Println("[UAC] 请求管理员权限...")
+ ensureElevation()
+ // ensureElevation 会退出当前进程,以下代码不会执行
+ }
+
+ log.Println("========================================")
+
+ // 读取任务配置
+ data, err := os.ReadFile(taskFile)
+ if err != nil {
+ log.Fatalf("读取任务文件失败: %v", err)
+ }
+
+ var task UpdateTask
+ if err := json.Unmarshal(data, &task); err != nil {
+ log.Fatalf("解析任务配置失败: %v", err)
+ }
+
+ // 严格校验任务字段,阻断 task 文件被篡改后的提权利用
+ if err := validateTask(task, updateDir); err != nil {
+ log.Fatalf("任务配置不安全,拒绝执行: %v", err)
+ }
+
+ log.Printf("任务配置:")
+ log.Printf(" - MainPID: %d", task.MainPID)
+ log.Printf(" - TargetExe: %s", task.TargetExe)
+ log.Printf(" - NewExePath: %s", task.NewExePath)
+ log.Printf(" - BackupPath: %s", task.BackupPath)
+ log.Printf(" - TimeoutSec: %d", task.TimeoutSec)
+ log.Printf(" - CleanupPaths (ignored): %v", task.CleanupPaths)
+
+ // 等待主程序退出(使用任务配置的超时值,禁止硬编码)
+ timeout := time.Duration(task.TimeoutSec) * time.Second
+ if task.TimeoutSec <= 0 {
+ timeout = 30 * time.Second // 兜底默认值(仅当任务文件异常时)
+ log.Println("[警告] timeout_sec 未设置或无效,使用默认 30 秒")
+ }
+
+ log.Printf("等待主程序退出(PID=%d, 超时=%v)...", task.MainPID, timeout)
+ if err := waitForProcessExit(task.MainPID, timeout); err != nil {
+ log.Fatalf("等待主程序退出超时(%ds): %v", task.TimeoutSec, err)
+ }
+ log.Println("主程序已退出")
+
+ // 执行更新
+ log.Println("开始执行更新操作...")
+ if err := performUpdate(task); err != nil {
+ log.Printf("更新失败: %v", err)
+ log.Println("执行回滚操作...")
+ rollback(task)
+ log.Println("回滚完成,更新器退出(失败)")
+ os.Exit(1)
+ }
+
+ log.Println("更新成功!")
+
+ // 启动新版本
+ log.Printf("启动新版本: %s", task.TargetExe)
+ cmd := exec.Command(task.TargetExe)
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ cmd.Dir = filepath.Dir(task.TargetExe)
+ if err := cmd.Start(); err != nil {
+ log.Printf("[警告] 启动新版本失败: %v", err)
+ log.Println("请手动启动应用程序")
+ } else {
+ log.Printf("新版本已启动 (PID=%d)", cmd.Process.Pid)
+ }
+
+ // 延迟清理临时文件
+ log.Println("等待 3 秒后清理临时文件...")
+ time.Sleep(3 * time.Second)
+
+ // 安全清理:忽略 task.CleanupPaths(不信任来自任务文件的清理指令)
+ // 仅清理我们自己计算出的安全路径(更新目录内文件 + .code-switch 下的 pending 标记)
+ safeCleanupFiles := []string{
+ filepath.Clean(task.NewExePath),
+ filepath.Clean(task.NewExePath + ".sha256"),
+ filepath.Join(updateDir, "update.lock"),
+ filepath.Join(codeSwitchDir, ".pending-update"),
+ }
+ for _, p := range safeCleanupFiles {
+ if p == "" {
+ continue
+ }
+ if err := os.Remove(p); err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
+ log.Printf("[警告] 清理失败: %s - %v", p, err)
+ continue
+ }
+ log.Printf("已清理: %s", p)
+ }
+
+ // 删除任务文件
+ if err := os.Remove(taskFile); err != nil {
+ log.Printf("[警告] 删除任务文件失败: %v", err)
+ } else {
+ log.Printf("已删除任务文件: %s", taskFile)
+ }
+
+ log.Println("========================================")
+ log.Println("更新器退出(成功)")
+ log.Println("========================================")
+}
+
+// waitForProcessExit 等待指定 PID 的进程退出
+func waitForProcessExit(pid int, timeout time.Duration) error {
+ handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
+ if err != nil {
+ // 进程可能已经退出,OpenProcess 失败时视为进程不存在
+ log.Printf("进程 %d 可能已退出: %v", pid, err)
+ return nil
+ }
+ defer windows.CloseHandle(handle)
+
+ event, err := windows.WaitForSingleObject(handle, uint32(timeout.Milliseconds()))
+ if err != nil {
+ return fmt.Errorf("等待进程失败: %w", err)
+ }
+
+ // WaitForSingleObject 返回值常量
+ const (
+ WAIT_OBJECT_0 = 0x00000000
+ WAIT_TIMEOUT = 0x00000102
+ )
+
+ switch event {
+ case WAIT_OBJECT_0:
+ // 进程已退出
+ return nil
+ case WAIT_TIMEOUT:
+ return fmt.Errorf("进程 %d 未在 %v 内退出", pid, timeout)
+ default:
+ return fmt.Errorf("等待进程返回未知状态: %d", event)
+ }
+}
+
+// performUpdate 执行更新操作
+func performUpdate(task UpdateTask) error {
+ // Step 1: 验证新版本文件存在
+ log.Printf("Step 1: 验证新版本文件")
+ newInfo, err := os.Stat(task.NewExePath)
+ if err != nil {
+ return fmt.Errorf("新版本文件不存在: %w", err)
+ }
+ if newInfo.Size() == 0 {
+ return fmt.Errorf("新版本文件大小为 0")
+ }
+ log.Printf(" 新版本文件: %s (%d bytes)", task.NewExePath, newInfo.Size())
+
+ // Step 2: 备份旧版本
+ log.Printf("Step 2: 备份旧版本")
+ log.Printf(" %s -> %s", task.TargetExe, task.BackupPath)
+
+ // 如果备份文件已存在,先删除
+ if _, err := os.Stat(task.BackupPath); err == nil {
+ log.Printf(" 删除已存在的备份文件...")
+ if err := os.Remove(task.BackupPath); err != nil {
+ return fmt.Errorf("删除旧备份失败: %w", err)
+ }
+ }
+
+ if err := os.Rename(task.TargetExe, task.BackupPath); err != nil {
+ return fmt.Errorf("备份旧版本失败: %w", err)
+ }
+ log.Println(" 备份完成")
+
+ // Step 3: 复制新版本
+ log.Printf("Step 3: 复制新版本")
+ log.Printf(" %s -> %s", task.NewExePath, task.TargetExe)
+ if err := copyFile(task.NewExePath, task.TargetExe); err != nil {
+ return fmt.Errorf("复制新版本失败: %w", err)
+ }
+ log.Println(" 复制完成")
+
+ // Step 4: 验证新文件
+ log.Println("Step 4: 验证新版本文件")
+ targetInfo, err := os.Stat(task.TargetExe)
+ if err != nil {
+ return fmt.Errorf("验证新版本失败: %w", err)
+ }
+ if targetInfo.Size() == 0 {
+ return fmt.Errorf("新版本文件大小为 0,可能复制失败")
+ }
+ if targetInfo.Size() != newInfo.Size() {
+ return fmt.Errorf("新版本文件大小不匹配: 期望 %d, 实际 %d", newInfo.Size(), targetInfo.Size())
+ }
+ log.Printf(" 验证通过: 文件大小 = %d bytes", targetInfo.Size())
+
+ return nil
+}
+
+// rollback 回滚更新(静默,不弹窗)
+func rollback(task UpdateTask) {
+ log.Println("执行回滚操作...")
+
+ // 检查备份文件是否存在
+ backupInfo, err := os.Stat(task.BackupPath)
+ if err != nil {
+ log.Printf("备份文件不存在,无法回滚: %v", err)
+ return
+ }
+ log.Printf("备份文件: %s (%d bytes)", task.BackupPath, backupInfo.Size())
+
+ // 删除可能存在的损坏新版本
+ if _, err := os.Stat(task.TargetExe); err == nil {
+ log.Printf("删除损坏的目标文件: %s", task.TargetExe)
+ if err := os.Remove(task.TargetExe); err != nil {
+ log.Printf("[警告] 删除目标文件失败: %v", err)
+ }
+ }
+
+ // 恢复备份
+ log.Printf("恢复备份: %s -> %s", task.BackupPath, task.TargetExe)
+ if err := os.Rename(task.BackupPath, task.TargetExe); err != nil {
+ log.Printf("[错误] 回滚失败: %v", err)
+ log.Println("请手动将备份文件恢复为原文件名")
+ } else {
+ log.Println("回滚成功")
+ }
+}
+
+// copyFile 复制文件
+func copyFile(src, dst string) error {
+ source, err := os.Open(src)
+ if err != nil {
+ return fmt.Errorf("打开源文件失败: %w", err)
+ }
+ defer source.Close()
+
+ dest, err := os.Create(dst)
+ if err != nil {
+ return fmt.Errorf("创建目标文件失败: %w", err)
+ }
+ defer dest.Close()
+
+ written, err := io.Copy(dest, source)
+ if err != nil {
+ return fmt.Errorf("复制数据失败: %w", err)
+ }
+
+ log.Printf(" 已复制 %d bytes", written)
+ return nil
+}
diff --git a/cmd/updater/updater.exe.manifest b/cmd/updater/updater.exe.manifest
new file mode 100644
index 0000000..c3d5d40
--- /dev/null
+++ b/cmd/updater/updater.exe.manifest
@@ -0,0 +1,29 @@
+
+
+
+ CodeSwitch Silent Updater
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/doc/88CODE_ISSUE_ANALYSIS.md b/doc/88CODE_ISSUE_ANALYSIS.md
new file mode 100644
index 0000000..f092ebd
--- /dev/null
+++ b/doc/88CODE_ISSUE_ANALYSIS.md
@@ -0,0 +1,83 @@
+# 88code 连通性问题分析报告
+
+## 问题描述
+用户报告 88code 供应商在 Code-Switch UI 中测试失败,错误信息:`API Key 配置错误`
+
+## 测试环境
+- API URL: `https://m.88code.org/api`
+- API Key: `88_784022b...36e8` (67 字符)
+- 测试时间: 2024-12
+
+## 测试结果
+
+### 1. 只使用 x-api-key(模拟连通性测试)
+```
+Headers: x-api-key, anthropic-version, Content-Type
+Result: HTTP 200, {"error": "API Key 配置错误"}
+```
+
+### 2. 使用完整 Claude Code SDK headers
+```
+Headers: x-api-key, anthropic-version, User-Agent (anthropic-typescript), x-stainless-*
+Result: HTTP 200, {"error": "API Key 配置错误"}
+```
+
+### 3. 添加 Authorization: Bearer(模拟 providerrelay)
+```
+Headers: x-api-key, anthropic-version, Authorization: Bearer
+Result: HTTP 400, {"error": "暂不支持非 claude code 请求"}
+```
+
+## 分析结论
+
+### 问题一:API Key 无效
+88code 返回 "API Key 配置错误",表明:
+- API Key 可能已过期
+- API Key 可能未激活
+- API Key 可能配额已用尽
+- 需要联系 88code 服务商确认 Key 状态
+
+### 问题二:providerrelay 与 88code 不兼容
+88code 检测 `Authorization: Bearer` header 并拒绝请求:
+- 88code 要求使用 `x-api-key` 认证方式
+- 当检测到 Bearer 时,返回 "暂不支持非 claude code 请求"
+- providerrelay 无条件添加 Bearer header (`services/providerrelay.go:508`)
+
+### 代码位置
+```go
+// services/providerrelay.go:507-508
+headers := cloneMap(clientHeaders)
+headers["Authorization"] = fmt.Sprintf("Bearer %s", provider.APIKey)
+```
+
+## 建议解决方案
+
+### 方案一:修复 API Key
+联系 88code 服务商确认 API Key 状态,可能需要:
+- 重新获取有效的 API Key
+- 检查账户配额
+
+### 方案二:为特定 Provider 禁用 Bearer
+在 providerrelay 中添加条件逻辑,根据 Provider 配置决定是否添加 Bearer:
+
+```go
+// 建议修改
+if provider.AuthMethod != "x-api-key" {
+ headers["Authorization"] = fmt.Sprintf("Bearer %s", provider.APIKey)
+}
+```
+
+### 方案三:添加 apiFormat 字段
+在 Provider 结构中添加 `apiFormat` 字段,支持不同的 API 格式:
+- `anthropic`: 使用 x-api-key,不添加 Bearer
+- `openai`: 使用 Authorization: Bearer
+- `hybrid`: 同时保留两种认证
+
+## 相关文件
+- `services/providerrelay.go`: 代理转发逻辑
+- `services/connectivitytestservice.go`: 连通性测试逻辑
+- `scripts/test-88code-final.go`: 测试脚本
+
+---
+*Author: Half open flowers*
+*Date: 2024-12*
diff --git a/doc/AVAILABILITY_INTEGRATION_PROPOSAL.md b/doc/AVAILABILITY_INTEGRATION_PROPOSAL.md
new file mode 100644
index 0000000..00e64b6
--- /dev/null
+++ b/doc/AVAILABILITY_INTEGRATION_PROPOSAL.md
@@ -0,0 +1,477 @@
+# 可用性监控内部集成方案(最终版)
+
+> 将 check-cx 的核心功能直接集成到 Code-Switch 中
+> 与 Codex 深入讨论后的最终设计(v3.0)
+
+## 1. 功能概述
+
+### 1.1 核心功能
+1. **可用性页面**:配置和展示供应商健康监控
+2. **Provider 编辑页面**:简化的"连通性自动拉黑"开关
+3. **联动逻辑**:开关打开 → 不可用时自动拉黑;关闭 → 只显示状态
+
+### 1.2 用户操作流程
+```
+1. 进入"可用性"页面
+2. 看到所有 Provider 列表,启用需要监控的供应商
+3. 系统自动使用默认参数开始监控
+4. (可选)点击"高级配置"自定义参数
+5. 返回 Provider 编辑页面,打开"连通性自动拉黑"开关
+6. 当监控检测到不可用时,自动触发拉黑
+```
+
+---
+
+## 2. 数据结构设计
+
+### 2.1 Provider 字段变更
+
+**删除的字段**(从 Provider 编辑页面移除):
+```go
+// 删除以下字段
+ConnectivityTestModel string // 移至可用性高级配置
+ConnectivityTestEndpoint string // 移至可用性高级配置
+ConnectivityAuthType string // 移至可用性高级配置
+```
+
+**保留/新增的字段**:
+```go
+type Provider struct {
+ // ... 现有字段 ...
+
+ // 可用性监控开关(在可用性页面配置)
+ AvailabilityMonitorEnabled bool `json:"availabilityMonitorEnabled,omitempty"`
+
+ // 连通性自动拉黑开关(在 Provider 编辑页面配置)
+ // 前置条件:AvailabilityMonitorEnabled 必须为 true
+ ConnectivityAutoBlacklist bool `json:"connectivityAutoBlacklist,omitempty"`
+
+ // 可用性高级配置(可选,在可用性页面的"高级配置"中设置)
+ AvailabilityConfig *AvailabilityConfig `json:"availabilityConfig,omitempty"`
+}
+
+type AvailabilityConfig struct {
+ TestModel string `json:"testModel,omitempty"` // 覆盖默认测试模型
+ TestEndpoint string `json:"testEndpoint,omitempty"` // 覆盖默认测试端点
+ Timeout int `json:"timeout,omitempty"` // 覆盖默认超时(毫秒)
+}
+```
+
+### 2.2 配置文件示例
+
+```json
+// ~/.code-switch/claude-code.json
+{
+ "providers": [
+ {
+ "id": 1,
+ "name": "OpenAI Official",
+ "apiUrl": "https://api.openai.com",
+ "apiKey": "sk-xxx",
+ "enabled": true,
+
+ "availabilityMonitorEnabled": true,
+ "connectivityAutoBlacklist": false,
+ "availabilityConfig": {
+ "testModel": "claude-3-5-haiku-20241022",
+ "timeout": 15000
+ }
+ },
+ {
+ "id": 2,
+ "name": "Backup Provider",
+ "apiUrl": "https://api.backup.com",
+ "apiKey": "sk-yyy",
+ "enabled": true,
+
+ "availabilityMonitorEnabled": true,
+ "connectivityAutoBlacklist": true
+ }
+ ]
+}
+```
+
+---
+
+## 3. 全局设置
+
+### 3.1 可用性监控全局配置
+
+在可用性页面提供全局设置入口(右上角 ⚙️ 按钮),存储在 `~/.code-switch/settings.json`:
+
+```go
+type AvailabilityGlobalConfig struct {
+ // 自动拉黑失败阈值(连续检测失败 N 次后触发拉黑)
+ FailureThreshold int `json:"failureThreshold"` // 默认 2
+
+ // 检测间隔(秒)
+ PollIntervalSeconds int `json:"pollIntervalSeconds"` // 默认 60
+
+ // 状态判定阈值(毫秒)
+ OperationalThresholdMs int `json:"operationalThresholdMs"` // 默认 6000
+}
+```
+
+### 3.2 全局设置 UI
+
+```
+┌─────────────────────────────────────────┐
+│ ⚙️ 可用性监控设置 │
+├─────────────────────────────────────────┤
+│ │
+│ 自动拉黑阈值 │
+│ ┌───────────────────────────────────┐ │
+│ │ 2 次 │ │
+│ └───────────────────────────────────┘ │
+│ ℹ️ 连续检测失败 N 次后触发拉黑 │
+│ │
+│ 检测间隔 │
+│ ┌───────────────────────────────────┐ │
+│ │ 60 秒 │ │
+│ └───────────────────────────────────┘ │
+│ ℹ️ 后台自动检测的时间间隔 │
+│ │
+│ 正常状态阈值 │
+│ ┌───────────────────────────────────┐ │
+│ │ 6000 ms │ │
+│ └───────────────────────────────────┘ │
+│ ℹ️ 响应延迟 ≤ 此值判定为"正常" │
+│ │
+│ [取消] [保存] │
+└─────────────────────────────────────────┘
+```
+
+---
+
+## 4. 默认推断规则
+
+当用户启用监控但不配置高级参数时,使用以下默认值:
+
+| 参数 | 平台 | 默认值 |
+|------|------|--------|
+| **测试端点** | Claude | `/v1/messages` |
+| | Codex | `/responses` |
+| | Gemini | `/v1beta/models/{model}:streamGenerateContent` |
+| **测试模型** | Claude | `claude-3-5-haiku-20241022` |
+| | Codex | `gpt-4o-mini` |
+| | Gemini | `gemini-1.5-flash` |
+| **认证方式** | 所有 | Bearer(复用 Provider 的 APIKey) |
+| **超时时间** | 所有 | 15000ms(15秒) |
+| **状态阈值** | 所有 | ≤6000ms=operational, >6000ms=degraded |
+| **拉黑阈值** | 所有 | 连续 2 次失败(可配置) |
+
+---
+
+## 5. 页面设计
+
+### 5.1 可用性页面(新增)
+
+**位置**:左侧栏新增"可用性"菜单项
+
+**布局**:
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ 🔍 可用性监控 [全部检测] [⚙️] │
+│ 实时监控 AI 供应商的健康状态 │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ 📊 状态概览 │
+│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
+│ │ 🟢 3 │ │ 🟡 1 │ │ 🔴 0 │ │ ⚫ 2 │ │
+│ │ 正常 │ │ 延迟 │ │ 故障 │ │ 未启用 │ │
+│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
+│ │
+│ 最后更新: 14:30:25 | 下次检测: 45s │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ Claude 供应商 │
+│ ┌─────────────────────────────────────────────────────────────┐│
+│ │ [开关] Provider A 🟢 正常 1234ms [检测] [⚙️] ││
+│ │ ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 可用率: 98.3% ││
+│ ├─────────────────────────────────────────────────────────────┤│
+│ │ [开关] Provider B 🟡 延迟 7891ms [检测] [⚙️] ││
+│ │ ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 可用率: 85.0% ││
+│ ├─────────────────────────────────────────────────────────────┤│
+│ │ [ ] Provider C ⚫ 未启用 ││
+│ └─────────────────────────────────────────────────────────────┘│
+│ │
+│ Codex 供应商 │
+│ ┌─────────────────────────────────────────────────────────────┐│
+│ │ [开关] Provider D 🟢 正常 2345ms [检测] [⚙️] ││
+│ │ ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 可用率: 99.1% ││
+│ └─────────────────────────────────────────────────────────────┘│
+│ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+**交互说明**:
+- **开关**:启用/禁用该 Provider 的监控
+- **[检测]**:手动触发单个 Provider 检测
+- **[⚙️]**:打开高级配置弹窗
+- **时间线条**:鼠标悬停显示历史检测详情
+
+### 5.2 高级配置弹窗
+
+```
+┌─────────────────────────────────────────┐
+│ ⚙️ 高级配置 - Provider A │
+├─────────────────────────────────────────┤
+│ │
+│ 测试模型 │
+│ ┌───────────────────────────────────┐ │
+│ │ claude-3-5-haiku-20241022 [▼] │ │
+│ └───────────────────────────────────┘ │
+│ ℹ️ 默认使用低成本模型 │
+│ │
+│ 测试端点 │
+│ ┌───────────────────────────────────┐ │
+│ │ /v1/messages │ │
+│ └───────────────────────────────────┘ │
+│ ℹ️ 留空使用默认端点 │
+│ │
+│ 超时时间 │
+│ ┌───────────────────────────────────┐ │
+│ │ 15000 ms │ │
+│ └───────────────────────────────────┘ │
+│ ℹ️ 默认 15 秒 │
+│ │
+│ [取消] [保存] │
+└─────────────────────────────────────────┘
+```
+
+### 5.3 Provider 编辑页面(简化)
+
+**连通性区块变更**:
+
+**Before(现有)**:
+```
+┌─────────────────────────────────────────┐
+│ 连通性检测 │
+├─────────────────────────────────────────┤
+│ [✓] 启用连通性检测 │
+│ │
+│ 测试模型 │
+│ ┌───────────────────────────────────┐ │
+│ │ claude-3-5-haiku-20241022 │ │
+│ └───────────────────────────────────┘ │
+│ │
+│ 测试端点 │
+│ ┌───────────────────────────────────┐ │
+│ │ /v1/messages │ │
+│ └───────────────────────────────────┘ │
+│ │
+│ 认证方式 │
+│ ┌───────────────────────────────────┐ │
+│ │ Bearer [▼] │ │
+│ └───────────────────────────────────┘ │
+└─────────────────────────────────────────┘
+```
+
+**After(简化后)**:
+```
+┌─────────────────────────────────────────┐
+│ 连通性自动拉黑 │
+├─────────────────────────────────────────┤
+│ │
+│ [✓] 不可用时自动拉黑 │ ← 开关
+│ │
+│ ℹ️ 当可用性监控检测到故障时,自动触发拉黑 │
+│ │
+│ 当前监控状态: 🟢 正常 (1234ms) │ ← 只读展示
+│ 最近检测: 14:30:25 │
+│ │
+│ 💡 前往 [可用性] 页面配置监控参数 │ ← 跳转链接
+│ │
+└─────────────────────────────────────────┘
+```
+
+**开关禁用状态**(未在可用性页面启用监控时):
+```
+┌─────────────────────────────────────────┐
+│ 连通性自动拉黑 │
+├─────────────────────────────────────────┤
+│ │
+│ [ ] 不可用时自动拉黑 │ ← 灰色禁用
+│ │
+│ ⚠️ 请先在 [可用性] 页面启用监控 │ ← 提示
+│ │
+└─────────────────────────────────────────┘
+```
+
+---
+
+## 6. 后端设计
+
+### 6.1 服务职责
+
+| 服务 | 职责 |
+|------|------|
+| `HealthCheckService` | 执行健康检查、存储结果、定时巡检 |
+| `ProviderService` | 加载/保存 Provider 配置(含新字段) |
+| `BlacklistService` | 执行拉黑/解除拉黑(现有) |
+
+### 6.2 HealthCheckService 核心方法
+
+```go
+// 获取所有 Provider 的最新状态(按平台分组)
+func (hcs *HealthCheckService) GetLatestResults() (map[string][]HealthCheckResult, error)
+
+// 获取单个 Provider 的历史记录
+func (hcs *HealthCheckService) GetHistory(platform, providerName string, limit int) (*HealthCheckHistory, error)
+
+// 手动触发单个 Provider 检测
+func (hcs *HealthCheckService) RunSingleCheck(platform string, providerId int64) (*HealthCheckResult, error)
+
+// 手动触发全部检测
+func (hcs *HealthCheckService) RunAllChecks() (map[string][]HealthCheckResult, error)
+
+// 启动后台定时巡检(应用启动时调用)
+func (hcs *HealthCheckService) StartBackgroundPolling(interval time.Duration)
+
+// 停止后台巡检
+func (hcs *HealthCheckService) StopBackgroundPolling()
+```
+
+### 6.3 检测与拉黑联动逻辑(独立计数器)
+
+**核心机制**:可用性监控使用**独立的失败计数器**,与真实请求的失败计数分开。
+
+```go
+// 每个 Provider 维护独立的可用性失败计数
+type AvailabilityFailureCounter struct {
+ Platform string
+ ProviderName string
+ ConsecutiveFails int // 连续失败次数
+ LastFailedAt time.Time // 最后失败时间
+}
+
+func (hcs *HealthCheckService) handleCheckResult(result *HealthCheckResult, provider *Provider) {
+ // 1. 存储检测结果
+ hcs.saveResult(result)
+
+ // 2. 更新内存缓存
+ hcs.updateCache(result)
+
+ // 3. 检查是否需要触发拉黑
+ if !provider.ConnectivityAutoBlacklist {
+ return // 未启用自动拉黑,直接返回
+ }
+
+ // 4. 获取全局配置的失败阈值
+ globalConfig := hcs.settingsService.GetAvailabilityGlobalConfig()
+ threshold := globalConfig.FailureThreshold // 默认 2
+
+ // 5. 更新独立的失败计数器
+ counter := hcs.getOrCreateCounter(result.Platform, provider.Name)
+
+ if result.Status == "failed" {
+ counter.ConsecutiveFails++
+ counter.LastFailedAt = time.Now()
+
+ log.Printf("⚠️ Provider %s 检测失败,连续失败: %d/%d",
+ provider.Name, counter.ConsecutiveFails, threshold)
+
+ // 达到阈值,触发拉黑
+ if counter.ConsecutiveFails >= threshold {
+ hcs.blacklistService.RecordFailure(result.Platform, provider.Name)
+ log.Printf("🔴 Provider %s 连续失败 %d 次,触发拉黑!", provider.Name, threshold)
+ }
+ } else if result.Status == "operational" {
+ // 成功,清零失败计数
+ if counter.ConsecutiveFails > 0 {
+ log.Printf("✅ Provider %s 恢复正常,清零失败计数(之前: %d)",
+ provider.Name, counter.ConsecutiveFails)
+ }
+ counter.ConsecutiveFails = 0
+ hcs.blacklistService.RecordSuccess(result.Platform, provider.Name)
+ }
+ // degraded 状态不触发拉黑,也不清零计数
+}
+```
+
+### 6.4 数据库表
+
+```sql
+CREATE TABLE IF NOT EXISTS health_check_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ provider_id INTEGER NOT NULL,
+ provider_name TEXT NOT NULL,
+ platform TEXT NOT NULL,
+ model TEXT,
+ endpoint TEXT,
+ status TEXT NOT NULL, -- operational/degraded/failed
+ latency_ms INTEGER,
+ error_message TEXT,
+ checked_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_health_provider ON health_check_history(platform, provider_name);
+CREATE INDEX IF NOT EXISTS idx_health_checked_at ON health_check_history(checked_at);
+```
+
+---
+
+## 7. 实现任务清单
+
+### 7.1 后端
+
+- [ ] 修改 `Provider` 结构,删除旧字段、新增新字段
+- [ ] 新建 `services/healthcheckservice.go`
+- [ ] 实现健康检查核心逻辑(流式首块判定)
+- [ ] 实现不同平台的请求构造适配器
+- [ ] 数据库迁移:创建 `health_check_history` 表
+- [ ] 实现后台定时巡检
+- [ ] 实现与 BlacklistService 的联动
+- [ ] 注册到 Wails 应用
+
+### 7.2 前端
+
+- [ ] 新建 `components/Availability/` 目录
+- [ ] 实现 `Index.vue` 可用性页面
+- [ ] 实现 `ProviderMonitorCard.vue` 监控卡片
+- [ ] 实现 `StatusTimeline.vue` 状态时间线
+- [ ] 实现 `AdvancedConfigModal.vue` 高级配置弹窗
+- [ ] 修改 Provider 编辑页面,简化连通性区块
+- [ ] 添加路由配置
+- [ ] 更新 Sidebar.vue 添加菜单项
+- [ ] 添加国际化文本
+
+### 7.3 数据迁移
+
+- [ ] 迁移脚本:将旧的 `ConnectivityCheck` 字段值迁移到 `AvailabilityMonitorEnabled`
+- [ ] 清理脚本:删除 Provider 配置中的旧字段
+
+---
+
+## 8. 讨论记录摘要
+
+### 关键决策
+
+| 问题 | 决策 |
+|------|------|
+| 配置存储方式 | 复用 Provider 配置文件(方案B) |
+| Provider 与监控配置关系 | 1:1(一个 Provider 一个监控配置) |
+| 参数配置位置 | 可用性页面的"高级配置"弹窗 |
+| Provider 编辑页面 | 只保留一个"连通性自动拉黑"开关 |
+| 开关前置条件 | 必须先在可用性页面启用监控 |
+| 默认行为 | 启用监控但不自动拉黑(安全) |
+| **拉黑阈值** | **可配置,默认连续 2 次失败,独立计数器** |
+| **阈值配置位置** | **可用性页面的全局设置** |
+
+### 字段变更
+
+| 操作 | 字段 |
+|------|------|
+| **删除** | `ConnectivityCheck`(改名) |
+| **删除** | `ConnectivityTestModel`(移至高级配置) |
+| **删除** | `ConnectivityTestEndpoint`(移至高级配置) |
+| **删除** | `ConnectivityAuthType`(移至高级配置) |
+| **新增** | `AvailabilityMonitorEnabled`(可用性页面开关) |
+| **新增** | `ConnectivityAutoBlacklist`(Provider 页面开关) |
+| **新增** | `AvailabilityConfig`(高级配置对象) |
+
+---
+
+**文档版本**:v3.1(增加可配置拉黑阈值)
+**更新时间**:2025-12-10
+**讨论参与**:Claude Opus 4.5 + Codex (OpenAI o4-mini)
diff --git a/doc/auto-update-fix-plan.md b/doc/auto-update-fix-plan.md
new file mode 100644
index 0000000..35a5219
--- /dev/null
+++ b/doc/auto-update-fix-plan.md
@@ -0,0 +1,715 @@
+# 自动更新系统修复方案 v1.0
+
+> 基于 Codex 多轮讨论确认的最终修复方案
+> 创建时间: 2025-12-13
+> 预估失败率: 修复前 8-20% → 修复后 <2%
+
+---
+
+## 一、问题分级
+
+### P0 - 致命问题(必须立即修复)
+
+| ID | 问题 | 影响 | 文件位置 |
+|----|------|------|----------|
+| P0-1 | Windows 启动恢复 nil panic | 崩溃 | `main.go:437-448` |
+| P0-2 | 锁文件递归死循环 | 卡死 | `updateservice.go:1740-1741` |
+| P0-3 | version/SHA 竞态条件 | 校验失败 | `updateservice.go:222-226, 538-546` |
+
+### P1 - 严重问题(应尽快修复)
+
+| ID | 问题 | 影响 | 文件位置 |
+|----|------|------|----------|
+| P1-1 | SaveState 非原子写入 | 状态损坏 | `updateservice.go:1604-1614` |
+| P1-2 | LoadState 错误被吞没 | 静默失败 | `main.go:103` |
+| P1-3 | dailyCheckTimer 无锁访问 | 竞态 | `updateservice.go:1424, 1444-1447` |
+| P1-4 | updater 无校验降级 | 安全风险 | `updateservice.go:1873-1882` |
+| P1-5 | pending 清理时机错误 | 更新失败 | 多处 |
+| P1-6 | 锁心跳 + stale 判定优化 | 误删锁 | `updateservice.go:1729-1755` |
+| P1-7 | macOS/Linux 缺失启动恢复 | 无法回滚 | `main.go:416-459` |
+
+---
+
+## 二、修复实施顺序
+
+```
+P0-1 → P0-2 → P0-3 → P1-1 → P1-2 → P1-3 → P1-4 → P1-5 → P1-6 → P1-7
+```
+
+依赖关系:
+- P0-3 依赖于理解 PrepareUpdate 流程
+- P1-5 依赖于 P0-3(pending 清理逻辑)
+- P1-7 依赖于 P0-1 模式(启动恢复)
+
+---
+
+## 三、详细修复方案
+
+### P0-1: 修复 Windows 启动恢复 nil panic
+
+**问题**: `os.Stat(currentExe)` 失败时 `currentInfo` 为 nil,但后续代码仍访问 `currentInfo.Size()`
+
+**位置**: `main.go:437-448`
+
+**修复**:
+```go
+// 修复前
+currentInfo, err := os.Stat(currentExe)
+currentOK := err == nil && currentInfo.Size() > 1024*1024
+
+// 修复后
+currentInfo, err := os.Stat(currentExe)
+if err != nil {
+ // 当前 exe 不存在或无法访问,需要回滚
+ log.Printf("[Recovery] 当前版本不可访问: %v,从备份恢复", err)
+ if err := os.Rename(backupPath, currentExe); err != nil {
+ log.Printf("[Recovery] 回滚失败: %v", err)
+ log.Println("[Recovery] 请手动将备份文件恢复为原文件名")
+ } else {
+ log.Println("[Recovery] 回滚成功,已恢复到旧版本")
+ }
+ return
+}
+currentOK := currentInfo.Size() > 1024*1024
+```
+
+---
+
+### P0-2: 修复锁文件递归问题
+
+**问题**: `acquireUpdateLock()` 递归调用自身,无次数限制可能死循环
+
+**位置**: `updateservice.go:1729-1755`
+
+**修复**:
+```go
+func (us *UpdateService) acquireUpdateLock() error {
+ lockPath := filepath.Join(us.updateDir, "update.lock")
+
+ for attempt := 0; attempt < 2; attempt++ {
+ f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
+ if err == nil {
+ // 成功获取锁
+ fmt.Fprintf(f, "%d\n%s", os.Getpid(), time.Now().Format(time.RFC3339))
+ f.Close()
+ us.lockFile = lockPath
+ log.Printf("[UpdateService] 已获取更新锁: %s", lockPath)
+ return nil
+ }
+
+ if !os.IsExist(err) {
+ return fmt.Errorf("创建锁文件失败: %w", err)
+ }
+
+ // 锁文件已存在,检查是否过期
+ info, statErr := os.Stat(lockPath)
+ if statErr != nil {
+ // stat 失败,可能锁被删除,重试
+ continue
+ }
+
+ if time.Since(info.ModTime()) > 10*time.Minute {
+ log.Printf("[UpdateService] 检测到过期锁文件(mtime=%v),强制删除: %s",
+ info.ModTime(), lockPath)
+ if rmErr := os.Remove(lockPath); rmErr != nil {
+ return fmt.Errorf("删除过期锁失败: %w", rmErr)
+ }
+ continue // 重试获取
+ }
+
+ return fmt.Errorf("另一个更新正在进行中")
+ }
+
+ return fmt.Errorf("获取锁失败:重试次数耗尽")
+}
+```
+
+---
+
+### P0-3: 从 pending 恢复 version + 版本/SHA 竞态
+
+**问题**:
+1. `CheckUpdate()` 更新共享字段 `us.latestVersion`,但 `PrepareUpdate()` 在不同时机读取
+2. `DownloadUpdate()` 开始时快照 SHA,但未快照 version
+
+**位置**:
+- `updateservice.go:222-226` (CheckUpdate 写入)
+- `updateservice.go:354-359` (DownloadUpdate 读取)
+- `updateservice.go:538-546` (PrepareUpdate 写入 pending)
+
+**修复策略**:
+1. `DownloadUpdate()` 开始时同时快照 version 和 SHA
+2. 将快照值传递给 `PrepareUpdate()`
+3. `ApplyUpdate()` 从 pending 文件恢复 version(用于日志)
+
+**修复** - 添加内部方法:
+```go
+// prepareUpdateInternal 内部方法,接收明确的 version 和 sha256
+func (us *UpdateService) prepareUpdateInternal(version, sha256 string) error {
+ log.Printf("[UpdateService] 准备更新...")
+
+ us.mu.Lock()
+ if us.updateFilePath == "" {
+ us.mu.Unlock()
+ return fmt.Errorf("更新文件路径为空")
+ }
+ filePath := us.updateFilePath
+ us.mu.Unlock()
+
+ // 写入待更新标记
+ pendingFile := filepath.Join(filepath.Dir(us.stateFile), ".pending-update")
+ metadata := map[string]interface{}{
+ "version": version,
+ "download_path": filePath,
+ "download_time": time.Now().Format(time.RFC3339),
+ }
+ if sha256 != "" {
+ metadata["sha256"] = sha256
+ }
+
+ data, err := json.MarshalIndent(metadata, "", " ")
+ if err != nil {
+ return fmt.Errorf("序列化元数据失败: %w", err)
+ }
+
+ if err := os.WriteFile(pendingFile, data, 0o644); err != nil {
+ return fmt.Errorf("写入标记文件失败: %w", err)
+ }
+
+ us.mu.Lock()
+ us.updateReady = true
+ us.mu.Unlock()
+
+ us.SaveState()
+ log.Printf("[UpdateService] ✅ 更新已准备就绪")
+ return nil
+}
+
+// PrepareUpdate 公开方法(已废弃,仅保留兼容性)
+// Deprecated: 请使用 DownloadUpdate 自动调用内部 prepare
+func (us *UpdateService) PrepareUpdate() error {
+ us.mu.Lock()
+ version := us.latestVersion
+ sha := ""
+ if us.latestUpdateInfo != nil {
+ sha = us.latestUpdateInfo.SHA256
+ }
+ us.mu.Unlock()
+ return us.prepareUpdateInternal(version, sha)
+}
+```
+
+**修复** - DownloadUpdate 快照:
+```go
+func (us *UpdateService) DownloadUpdate(progressCallback func(float64)) error {
+ // ... 获取锁 ...
+
+ us.mu.Lock()
+ url := us.downloadURL
+ // 快照 version 和 SHA256
+ snapshotVersion := us.latestVersion
+ snapshotSHA := ""
+ if us.latestUpdateInfo != nil {
+ snapshotSHA = us.latestUpdateInfo.SHA256
+ }
+ us.mu.Unlock()
+
+ // ... 下载逻辑 ...
+
+ // 使用快照值调用内部方法
+ if err := us.prepareUpdateInternal(snapshotVersion, snapshotSHA); err != nil {
+ return fmt.Errorf("准备更新失败: %w", err)
+ }
+
+ return nil
+}
+```
+
+---
+
+### P1-1: 原子写状态文件
+
+**问题**: `SaveState()` 直接写入目标文件,断电可能损坏
+
+**位置**: `updateservice.go:1604-1614`
+
+**修复**: 创建跨平台原子写入
+
+**新文件** `services/atomic_write.go`:
+```go
+package services
+
+// atomicWriteFile 原子写入文件(跨平台)
+// 策略:写入临时文件 → fsync → 重命名
+func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
+ dir := filepath.Dir(path)
+ tmp, err := os.CreateTemp(dir, ".tmp-*")
+ if err != nil {
+ return fmt.Errorf("创建临时文件失败: %w", err)
+ }
+ tmpPath := tmp.Name()
+
+ // 写入数据
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ os.Remove(tmpPath)
+ return fmt.Errorf("写入数据失败: %w", err)
+ }
+
+ // 确保数据落盘
+ if err := tmp.Sync(); err != nil {
+ tmp.Close()
+ os.Remove(tmpPath)
+ return fmt.Errorf("sync 失败: %w", err)
+ }
+ tmp.Close()
+
+ // 原子重命名
+ return atomicRename(tmpPath, path)
+}
+```
+
+**新文件** `services/atomic_write_windows.go`:
+```go
+//go:build windows
+
+package services
+
+import (
+ "os"
+ "syscall"
+ "time"
+ "unsafe"
+)
+
+var (
+ kernel32 = syscall.NewLazyDLL("kernel32.dll")
+ procMoveFileExW = kernel32.NewProc("MoveFileExW")
+)
+
+const MOVEFILE_REPLACE_EXISTING = 0x1
+
+func atomicRename(src, dst string) error {
+ srcPtr, _ := syscall.UTF16PtrFromString(src)
+ dstPtr, _ := syscall.UTF16PtrFromString(dst)
+
+ for attempt := 0; attempt < 3; attempt++ {
+ ret, _, err := procMoveFileExW.Call(
+ uintptr(unsafe.Pointer(srcPtr)),
+ uintptr(unsafe.Pointer(dstPtr)),
+ MOVEFILE_REPLACE_EXISTING,
+ )
+ if ret != 0 {
+ return nil // 成功
+ }
+
+ // ERROR_SHARING_VIOLATION = 32
+ if err == syscall.Errno(32) && attempt < 2 {
+ time.Sleep(100 * time.Millisecond)
+ continue
+ }
+
+ os.Remove(src)
+ return fmt.Errorf("MoveFileEx 失败: %w", err)
+ }
+
+ os.Remove(src)
+ return fmt.Errorf("MoveFileEx 重试耗尽")
+}
+```
+
+**新文件** `services/atomic_write_nonwindows.go`:
+```go
+//go:build !windows
+
+package services
+
+import "os"
+
+func atomicRename(src, dst string) error {
+ if err := os.Rename(src, dst); err != nil {
+ os.Remove(src)
+ return err
+ }
+ return nil
+}
+```
+
+**修改** `SaveState()`:
+```go
+func (us *UpdateService) SaveState() error {
+ // ... 构建 state ...
+
+ data, err := json.MarshalIndent(state, "", " ")
+ if err != nil {
+ return fmt.Errorf("序列化状态失败: %w", err)
+ }
+
+ dir := filepath.Dir(us.stateFile)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("创建目录失败: %w", err)
+ }
+
+ return atomicWriteFile(us.stateFile, data, 0o644)
+}
+```
+
+---
+
+### P1-2: 不吞 LoadState 错误
+
+**问题**: `main.go` 中 `_ = us.LoadState()` 吞掉错误
+
+**位置**: `services/updateservice.go:103` 被调用处
+
+**修复**: 记录错误但继续运行(状态文件损坏不应阻止启动)
+
+```go
+// NewUpdateService 中
+if err := us.LoadState(); err != nil {
+ log.Printf("[UpdateService] ⚠️ 加载状态失败(将使用默认值): %v", err)
+}
+```
+
+---
+
+### P1-3: dailyCheckTimer 加锁
+
+**问题**: `dailyCheckTimer` 字段在多个 goroutine 中访问但无锁保护
+
+**位置**: `updateservice.go:1424, 1444-1447`
+
+**修复**:
+```go
+func (us *UpdateService) StartDailyCheck() {
+ us.mu.Lock()
+ if us.dailyCheckTimer != nil {
+ us.dailyCheckTimer.Stop()
+ us.dailyCheckTimer = nil
+ }
+
+ if !us.autoCheckEnabled {
+ us.mu.Unlock()
+ log.Println("[UpdateService] 自动检查已禁用,不启动定时器")
+ return
+ }
+ us.mu.Unlock()
+
+ duration := us.calculateNextCheckDuration()
+
+ us.mu.Lock()
+ us.dailyCheckTimer = time.AfterFunc(duration, func() {
+ // ... 回调逻辑 ...
+ })
+ us.mu.Unlock()
+
+ log.Printf("[UpdateService] 定时检查已启动,下次: %s",
+ time.Now().Add(duration).Format("2006-01-02 15:04:05"))
+}
+
+func (us *UpdateService) StopDailyCheck() {
+ us.mu.Lock()
+ defer us.mu.Unlock()
+
+ if us.dailyCheckTimer != nil {
+ us.dailyCheckTimer.Stop()
+ us.dailyCheckTimer = nil
+ }
+}
+```
+
+---
+
+### P1-4: 禁止 updater 无校验降级
+
+**问题**: `downloadUpdater()` 在校验失败时降级为无校验下载
+
+**位置**: `updateservice.go:1869-1897`
+
+**修复**: 移除降级逻辑,校验失败即失败
+
+```go
+func (us *UpdateService) downloadUpdater(targetPath string) error {
+ updaterPath, err := us.downloadAndVerify("updater.exe")
+ if err != nil {
+ // 不再降级,直接返回错误
+ return fmt.Errorf("下载 updater.exe 失败(需要 SHA256 校验): %w", err)
+ }
+
+ if updaterPath != targetPath {
+ if err := os.Rename(updaterPath, targetPath); err != nil {
+ if err := copyUpdateFile(updaterPath, targetPath); err != nil {
+ return fmt.Errorf("移动 updater.exe 失败: %w", err)
+ }
+ os.Remove(updaterPath)
+ }
+ }
+
+ return nil
+}
+```
+
+---
+
+### P1-5: pending 清理延后 + 平台联动
+
+**问题**: 各平台在不同时机清理 pending,可能导致更新失败后无法重试
+
+**核心原则**:
+1. 脚本/updater 只在 **SUCCESS** 时删除 pending
+2. 脚本/updater **总是** 删除 lock(无论成功失败)
+3. 主程序启动恢复检测到 `.old` 存在时决定是否清理
+
+**修复** - PowerShell 脚本 (applyPortableUpdate):
+```powershell
+# 在脚本末尾,成功后清理
+$pendingFile = Join-Path (Split-Path $currentExe -Parent) '.code-switch\.pending-update'
+if (Test-Path $pendingFile) {
+ Remove-Item $pendingFile -Force -ErrorAction SilentlyContinue
+ Log "cleanup pending"
+}
+
+# 总是清理 lock
+$lockFile = Join-Path (Split-Path $currentExe -Parent) '.code-switch\updates\update.lock'
+if (Test-Path $lockFile) {
+ Remove-Item $lockFile -Force -ErrorAction SilentlyContinue
+ Log "cleanup lock"
+}
+```
+
+**修复** - Bash 脚本 (applyUpdateDarwin/applyUpdateLinux):
+```bash
+# 成功后清理 pending
+PENDING_FILE="$HOME/.code-switch/.pending-update"
+if [ -f "$PENDING_FILE" ]; then
+ rm -f "$PENDING_FILE" 2>/dev/null || true
+ log "cleanup pending"
+fi
+
+# 总是清理 lock (使用 trap)
+LOCK_FILE="$HOME/.code-switch/updates/update.lock"
+cleanup_lock() {
+ rm -f "$LOCK_FILE" 2>/dev/null || true
+ log "cleanup lock"
+}
+trap cleanup_lock EXIT
+```
+
+**修复** - updater.exe:
+```go
+// main() 末尾
+defer func() {
+ // 总是清理 lock
+ lockFile := filepath.Join(filepath.Dir(taskFile), "update.lock")
+ os.Remove(lockFile)
+ log.Printf("已清理锁文件: %s", lockFile)
+}()
+
+// 只在成功时清理 pending
+if updateSuccess {
+ pendingFile := filepath.Join(os.Getenv("USERPROFILE"), ".code-switch", ".pending-update")
+ os.Remove(pendingFile)
+ log.Printf("已清理 pending: %s", pendingFile)
+}
+```
+
+**修复** - 主程序 clearPendingState() 调用时机:
+移除 `applyPortableUpdate`、`applyInstalledUpdate`、`applyUpdateDarwin`、`applyUpdateLinux` 中对 `clearPendingState()` 的调用,改由脚本/updater 负责。
+
+---
+
+### P1-6: 锁心跳 + stale 判定优化
+
+**问题**: 仅靠 mtime 判断锁是否过期不够可靠
+
+**修复**: 添加心跳机制(可选增强)
+
+```go
+// 在 DownloadUpdate 中启动心跳
+func (us *UpdateService) startLockHeartbeat(ctx context.Context) {
+ go func() {
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ if us.lockFile != "" {
+ // 更新 mtime
+ now := time.Now()
+ os.Chtimes(us.lockFile, now, now)
+ }
+ }
+ }
+ }()
+}
+```
+
+---
+
+### P1-7: macOS/Linux 启动恢复
+
+**问题**: 只有 Windows 有启动恢复逻辑
+
+**修复**: 在 `main.go` 中添加跨平台恢复
+
+```go
+func checkAndRecoverFromFailedUpdate() {
+ var currentExe string
+ var err error
+
+ switch runtime.GOOS {
+ case "windows":
+ currentExe, err = os.Executable()
+ if err != nil {
+ return
+ }
+ currentExe, _ = filepath.EvalSymlinks(currentExe)
+
+ case "darwin":
+ // macOS: 检查 .app 包
+ currentExe, err = os.Executable()
+ if err != nil {
+ return
+ }
+ currentExe, _ = filepath.EvalSymlinks(currentExe)
+ // 找到 .app 根目录
+ appPath := currentExe
+ for i := 0; i < 6; i++ {
+ if strings.HasSuffix(strings.ToLower(appPath), ".app") {
+ break
+ }
+ appPath = filepath.Dir(appPath)
+ }
+ if strings.HasSuffix(strings.ToLower(appPath), ".app") {
+ currentExe = appPath
+ }
+
+ case "linux":
+ // Linux: 检查 APPIMAGE 环境变量
+ if appimage := os.Getenv("APPIMAGE"); appimage != "" {
+ currentExe = appimage
+ } else {
+ currentExe, err = os.Executable()
+ if err != nil {
+ return
+ }
+ currentExe, _ = filepath.EvalSymlinks(currentExe)
+ }
+
+ default:
+ return
+ }
+
+ backupPath := currentExe + ".old"
+
+ // 检查备份文件是否存在
+ backupInfo, err := os.Stat(backupPath)
+ if err != nil {
+ return // 无备份,正常情况
+ }
+
+ log.Printf("[Recovery] 检测到备份: %s (size=%d)", backupPath, backupInfo.Size())
+
+ // 检查当前版本是否可用
+ currentInfo, err := os.Stat(currentExe)
+ if err != nil {
+ log.Printf("[Recovery] 当前版本不可访问: %v,从备份恢复", err)
+ doRecovery(backupPath, currentExe)
+ return
+ }
+
+ // 根据平台判断最小有效大小
+ minSize := int64(1024 * 1024) // 默认 1MB
+ if runtime.GOOS == "darwin" {
+ minSize = 10 * 1024 * 1024 // macOS .app 至少 10MB
+ }
+
+ if currentInfo.Size() > minSize {
+ // 当前版本正常,清理备份
+ log.Println("[Recovery] 更新成功,清理旧版本备份")
+ if err := os.RemoveAll(backupPath); err != nil {
+ log.Printf("[Recovery] 删除备份失败: %v", err)
+ }
+ } else {
+ log.Printf("[Recovery] 当前版本异常(size=%d < %d),从备份恢复",
+ currentInfo.Size(), minSize)
+ doRecovery(backupPath, currentExe)
+ }
+}
+
+func doRecovery(backupPath, targetPath string) {
+ // 删除损坏的当前版本
+ if err := os.RemoveAll(targetPath); err != nil {
+ log.Printf("[Recovery] 删除损坏文件失败: %v", err)
+ }
+
+ // 恢复备份
+ if err := os.Rename(backupPath, targetPath); err != nil {
+ log.Printf("[Recovery] 回滚失败: %v", err)
+ log.Println("[Recovery] 请手动将备份恢复")
+ } else {
+ log.Println("[Recovery] 回滚成功")
+ }
+}
+```
+
+---
+
+## 四、测试检查清单
+
+### 功能测试
+
+- [ ] Windows 便携版更新(正常流程)
+- [ ] Windows 便携版更新(中途断电恢复)
+- [ ] Windows 安装版更新(UAC 确认)
+- [ ] Windows 安装版更新(UAC 取消)
+- [ ] macOS 更新(正常流程)
+- [ ] macOS 更新(中途断电恢复)
+- [ ] Linux AppImage 更新(正常流程)
+- [ ] Linux AppImage 更新(中途断电恢复)
+
+### 边界测试
+
+- [ ] 并发启动两个实例时的锁竞争
+- [ ] 下载过程中网络断开
+- [ ] SHA256 校验失败
+- [ ] 磁盘空间不足
+- [ ] 目标目录无写权限
+
+### 回归测试
+
+- [ ] 定时检查功能正常
+- [ ] 手动检查更新正常
+- [ ] 状态持久化正常
+- [ ] 前端进度显示正常
+
+---
+
+## 五、实施进度
+
+| ID | 描述 | 状态 | 完成时间 |
+|----|------|------|----------|
+| P0-1 | Windows 启动恢复 panic | ⏳ 待实施 | - |
+| P0-2 | 锁文件递归问题 | ⏳ 待实施 | - |
+| P0-3 | version/SHA 竞态 | ⏳ 待实施 | - |
+| P1-1 | 原子写状态文件 | ⏳ 待实施 | - |
+| P1-2 | LoadState 错误处理 | ⏳ 待实施 | - |
+| P1-3 | dailyCheckTimer 加锁 | ⏳ 待实施 | - |
+| P1-4 | updater 无校验降级 | ⏳ 待实施 | - |
+| P1-5 | pending 清理延后 | ⏳ 待实施 | - |
+| P1-6 | 锁心跳优化 | ⏳ 待实施 | - |
+| P1-7 | macOS/Linux 启动恢复 | ⏳ 待实施 | - |
+
+---
+
+## 六、审核检查点
+
+修复完成后需要 Codex 审核:
+
+1. **完整性审核**: 所有 P0/P1 问题是否都已修复
+2. **回归审核**: 修复是否引入新问题
+3. **边界情况审核**: 边界条件处理是否完善
+4. **代码质量审核**: 代码风格、错误处理是否规范
diff --git a/doc/fix-proposal-final.md b/doc/fix-proposal-final.md
new file mode 100644
index 0000000..06a9fd6
--- /dev/null
+++ b/doc/fix-proposal-final.md
@@ -0,0 +1,2482 @@
+# Code-Switch 问题修复方案 - 最终版
+
+> **文档版本**:v1.4 Final (二次审核修正版)
+> **创建时间**:2025-11-28
+> **最后更新**:2025-11-28 (补充 6 个实现层注意事项)
+> **问题总数**:22 个
+> **修复阶段**:4 个阶段
+
+---
+
+## ⚠️ v1.4 二次审核补充说明
+
+### v1.3 + v1.4 已修正的问题
+
+| 问题 | 修正内容 | 版本 |
+|------|----------|------|
+| 数据库初始化重复 | 1.1 仅创建目录,移除 xdb.Inits;集中到 1.2 | v1.3 |
+| ProxyConfig 未持久化 | AuthToken 持久化到配置文件 | v1.3 |
+| 空 Token 兼容风险 | 添加显式开关 + 告警日志 | v1.3 |
+| IP 校验不完整 | 补充 0.0.0.0/::、组播、更多云厂商地址 | v1.3 |
+| 流式路径回退风险 | 流式请求不做 Provider 回退 | v1.3 |
+| 配置迁移顺序 | 先写新文件 → 校验 → 标记 → 删旧 | v1.3 |
+| xrequest API 错误 | 使用 ToHttpResponseWriter 替代不存在的 Body() | v1.3 |
+| **_internal 端点死锁** | `/_internal/*` 在中间件开头放行(仅 localhost) | **v1.4** |
+
+### v1.4 实现层注意事项(必读)
+
+| # | 风险点 | 说明 | 落地要求 |
+|---|--------|------|----------|
+| 1 | **自动更新安全链路** | 文档已定义 PrepareUpdate/ConfirmAndApplyUpdate 接口和备份/回滚机制,但 macOS 明确返回"未实现"错误。 | 落地时需:① Windows 测试完整流程;② macOS 保持禁用状态直到签名验证实现;③ 回滚测试用例 |
+| 2 | **SSRF 防护接线** | `buildSafeClient` 已设置 `DialContext: s.safeDialContext`(见 2.3 节),但需确保所有 HTTP 调用都使用此 client | 落地时需:搜索 `http.Client{}` 确保无遗漏 |
+| 3 | **initProxyConfig 调用时机** | `proxyConfig` 初始化必须在 `registerRoutes` 之前,否则 nil panic | 落地时需:在 `main.go` 的 `NewProviderRelayService` 构造前调用 `initProxyConfig()` |
+| 4 | **流式不回退策略** | 设计决策:流式请求只尝试第一个 Provider,失败直接返回 502 | ⚠️ **需产品确认**:流式无法回滚是技术限制,但可能影响用户体验;发布说明需提及 |
+| 5 | **认证默认开启 Breaking Change** | 老用户升级后首次请求会被拒绝(401) | 落地时需:① 升级脚本自动创建 `proxy-config.json` 并设 `AllowUnauthenticated=true`;② 发布说明强调 |
+| 6 | **技能仓库安全** | 2.5 节已完整定义限制(100MB ZIP、路径穿越、Zip bomb、文件类型白名单) | 落地时需:验证 `validateZipPath` 和 `isAllowedFileType` 已被调用 |
+| 7 | **~~_internal 端点死锁~~** | ~~已修复~~:`/_internal/*` 路径在中间件开头放行(仅限 localhost) | ✅ 已在 2.2 节 securityMiddleware 中修复 |
+
+### 初始化顺序要求(main.go)
+
+```go
+func main() {
+ // 1. 初始化代理配置(必须最先,否则 proxyConfig 为 nil)
+ if err := initProxyConfig(); err != nil {
+ log.Fatalf("❌ 初始化代理配置失败: %v", err)
+ }
+
+ // 2. 初始化数据库
+ if err := services.InitDatabase(); err != nil {
+ log.Fatalf("❌ 数据库初始化失败: %v", err)
+ }
+
+ // 3. 初始化写入队列
+ if err := services.InitGlobalDBQueue(); err != nil {
+ log.Fatalf("❌ 初始化数据库队列失败: %v", err)
+ }
+
+ // 4. 创建服务(此时 proxyConfig 和数据库都已就绪)
+ providerService := services.NewProviderService()
+ // ...
+ providerRelay := services.NewProviderRelayService(...)
+}
+```
+
+### 升级兼容性(Breaking Change 处理)
+
+对于 v1.3 之前的用户,首次升级时需要:
+
+```go
+// services/migration.go
+
+// MigrateToV14 v1.4 升级迁移
+func MigrateToV14() error {
+ home, _ := os.UserHomeDir()
+ configPath := filepath.Join(home, ".code-switch", "proxy-config.json")
+
+ // 如果配置不存在,创建兼容配置(允许无认证访问)
+ if _, err := os.Stat(configPath); os.IsNotExist(err) {
+ compatConfig := &ProxyConfig{
+ AuthToken: generateSecureToken(),
+ AllowReverseProxy: false,
+ RequireAuth: true,
+ AllowUnauthenticated: true, // 【关键】老用户默认允许,避免 breaking
+ }
+
+ data, _ := json.MarshalIndent(compatConfig, "", " ")
+ os.WriteFile(configPath, data, 0600)
+
+ log.Printf("⚠️ [升级迁移] 已创建兼容配置,AllowUnauthenticated=true")
+ log.Printf("⚠️ [安全建议] 生产环境建议设置 AllowUnauthenticated=false")
+ }
+
+ return nil
+}
+```
+
+---
+
+## 目录
+
+1. [修复策略总览](#修复策略总览)
+2. [阶段一:阻断级问题](#阶段一阻断级问题)
+3. [阶段二:安全漏洞](#阶段二安全漏洞)
+4. [阶段三:核心逻辑](#阶段三核心逻辑)
+5. [阶段四:功能完善](#阶段四功能完善)
+6. [测试验证计划](#测试验证计划)
+7. [回滚策略](#回滚策略)
+
+---
+
+## 修复策略总览
+
+### 修复原则
+
+1. **最小改动原则**:每个修复尽量只修改必要的代码,避免引入新问题
+2. **向后兼容**:修复不应破坏现有功能或用户数据
+3. **防御性编程**:在关键路径添加空指针检查和错误处理
+4. **可测试性**:每个修复都应有对应的验证方法
+
+### 问题统计
+
+| 阶段 | 问题数 | 关键修复 | 风险等级 |
+|------|--------|----------|----------|
+| 阶段一 | 4 | 数据库初始化、拼写错误 | 低(不影响现有数据) |
+| 阶段二 | 6 | 127.0.0.1 监听、SSRF 防护、自动更新安全、代理认证、技能仓库安全 | 高(需验证远程访问) |
+| 阶段三 | 5 | 故障切换、错误分类、Context 隔离 | 中(需回归测试) |
+| 阶段四 | 7 | 路径引号、判空保护、配置迁移 | 低(边缘场景) |
+
+### 依赖关系图
+
+```
+阶段一(阻断级)
+├── [1] 创建 SQLite 父目录 ──────┐
+├── [2] 数据库初始化前移 ────────┼─→ 应用能正常启动
+├── [3] SuiStore 错误处理 ───────┤
+└── [4] 配置目录拼写修正 ────────┘
+
+阶段二(安全漏洞)
+├── [5] 代理仅监听 127.0.0.1 ───┐
+├── [6] 代理认证与反代防护 ─────┼─→ 阻止 API Key 泄露
+├── [7] SSRF + DNS 重绑定防护 ──┤
+├── [8] 自动更新完整安全方案 ───┼─→ 防止中间人攻击
+└── [9] 技能仓库下载安全 ───────┘
+
+阶段三(核心逻辑)
+├── [10] 请求转发故障切换 ──────┐
+├── [11] Context 隔离 ──────────┼─→ 核心功能正常
+├── [12] 区分可重试错误 ────────┤
+├── [13] 黑名单假恢复修复 ──────┤
+└── [14] HTTP 连接泄漏 ─────────┘
+
+阶段四(功能完善)
+├── [15] Windows 自启动引号
+├── [16] 日志写入判空(含 Gemini)
+├── [17] 等级拉黑配置持久化
+├── [18] 配置迁移完整性
+└── [19] 可观测性增强
+```
+
+---
+
+## 阶段一:阻断级问题
+
+### 1.1 创建 SQLite 父目录
+
+**问题位置**:`services/providerrelay.go:37-43`
+
+**问题描述**:SQLite 不会自动创建父目录 `~/.code-switch/`,首次启动时 `xdb.Inits()` 会因目录不存在而失败。
+
+**修复方案**:
+
+> ⚠️ **v1.3 修正**:本节仅负责创建目录,`xdb.Inits()` 已移至 1.2 的 `InitDatabase()` 统一处理,避免重复初始化。
+
+```go
+// services/providerrelay.go - NewProviderRelayService 函数
+// 【v1.3 修正】移除所有数据库初始化代码,仅保留服务构造
+
+func NewProviderRelayService(providerService *ProviderService, geminiService *GeminiService, blacklistService *BlacklistService, addr string) *ProviderRelayService {
+ if addr == "" {
+ addr = "127.0.0.1:18100" // 【安全修复】仅监听本地
+ }
+
+ // 【v1.3 修正】数据库初始化已移至 main.go 的 InitDatabase()
+ // 此处不再调用 xdb.Inits()、ensureRequestLogTable()、ensureBlacklistTables()
+
+ return &ProviderRelayService{
+ providerService: providerService,
+ geminiService: geminiService,
+ blacklistService: blacklistService,
+ addr: addr,
+ }
+}
+```
+
+**验证方法**:
+```bash
+# 删除配置目录后启动应用
+rm -rf ~/.code-switch
+./CodeSwitch
+# 预期:应用正常启动,目录由 InitDatabase() 自动创建
+```
+
+---
+
+### 1.2 数据库初始化时序重构
+
+**问题位置**:`main.go:72-74` 和 `services/dbqueue.go:26-30`
+
+**问题描述**:
+- `InitGlobalDBQueue()` 在第 72 行调用 `xdb.DB("default")`
+- 但 `xdb.Inits()` 在 `NewProviderRelayService()` 内部(第 81 行)才执行
+- 结果:`xdb.DB("default")` 返回错误,`log.Fatalf` 终止进程
+
+**修复方案**:将数据库初始化提取为独立函数
+
+```go
+// services/database.go(新建文件)
+
+package services
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/daodao97/xgo/xdb"
+ _ "modernc.org/sqlite"
+)
+
+// InitDatabase 初始化数据库连接(必须在所有服务构造之前调用)
+func InitDatabase() error {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return fmt.Errorf("获取用户目录失败: %w", err)
+ }
+
+ // 1. 确保配置目录存在
+ configDir := filepath.Join(home, ".code-switch")
+ if err := os.MkdirAll(configDir, 0755); err != nil {
+ return fmt.Errorf("创建配置目录失败: %w", err)
+ }
+
+ // 2. 初始化 xdb 连接池
+ dbPath := filepath.Join(configDir, "app.db?cache=shared&mode=rwc&_busy_timeout=10000&_journal_mode=WAL")
+ if err := xdb.Inits([]xdb.Config{
+ {
+ Name: "default",
+ Driver: "sqlite",
+ DSN: dbPath,
+ },
+ }); err != nil {
+ return fmt.Errorf("初始化数据库失败: %w", err)
+ }
+
+ // 3. 确保表结构存在
+ if err := ensureRequestLogTable(); err != nil {
+ return fmt.Errorf("初始化 request_log 表失败: %w", err)
+ }
+ if err := ensureBlacklistTables(); err != nil {
+ return fmt.Errorf("初始化黑名单表失败: %w", err)
+ }
+
+ // 4. 预热连接池
+ db, err := xdb.DB("default")
+ if err == nil && db != nil {
+ var count int
+ if err := db.QueryRow("SELECT COUNT(*) FROM request_log").Scan(&count); err != nil {
+ fmt.Printf("⚠️ 连接池预热查询失败: %v\n", err)
+ } else {
+ fmt.Printf("✅ 数据库连接已预热(request_log 记录数: %d)\n", count)
+ }
+ }
+
+ return nil
+}
+```
+
+**修改 main.go**:
+
+```go
+// main.go
+
+func main() {
+ appservice := &AppService{}
+
+ // 【修复】第一步:初始化数据库(必须最先执行)
+ if err := services.InitDatabase(); err != nil {
+ log.Fatalf("❌ 数据库初始化失败: %v", err)
+ }
+ log.Println("✅ 数据库已初始化")
+
+ // 【修复】第二步:初始化写入队列(依赖数据库连接)
+ if err := services.InitGlobalDBQueue(); err != nil {
+ log.Fatalf("❌ 初始化数据库队列失败: %v", err)
+ }
+ log.Println("✅ 数据库写入队列已启动")
+
+ // 第三步:创建服务(现在可以安全使用数据库了)
+ suiService, errt := services.NewSuiStore()
+ if errt != nil {
+ log.Fatalf("❌ SuiStore 初始化失败: %v", errt) // 【修复】不再忽略错误
+ }
+
+ providerService := services.NewProviderService()
+ settingsService := services.NewSettingsService()
+ // ... 其余服务构造保持不变
+
+ // 【修复】从 NewProviderRelayService 中移除数据库初始化代码
+ providerRelay := services.NewProviderRelayService(providerService, geminiService, blacklistService, ":18100")
+ // ...
+}
+```
+
+**同步修改 providerrelay.go**:
+
+```go
+// services/providerrelay.go - 移除数据库初始化代码
+
+func NewProviderRelayService(providerService *ProviderService, geminiService *GeminiService, blacklistService *BlacklistService, addr string) *ProviderRelayService {
+ if addr == "" {
+ addr = "127.0.0.1:18100" // 【安全修复】同时修改为仅监听本地
+ }
+
+ // 【修复】移除所有数据库初始化代码,已移至 InitDatabase()
+ // 原有的 xdb.Inits()、ensureRequestLogTable()、ensureBlacklistTables() 全部删除
+
+ return &ProviderRelayService{
+ providerService: providerService,
+ geminiService: geminiService,
+ blacklistService: blacklistService,
+ addr: addr,
+ }
+}
+```
+
+**验证方法**:
+```bash
+# 删除数据库后启动
+rm ~/.code-switch/app.db
+./CodeSwitch
+# 预期:数据库和表结构自动创建,无 panic
+```
+
+---
+
+### 1.3 SuiStore 错误处理
+
+**问题位置**:`main.go:66-70`
+
+**问题描述**:`NewSuiStore()` 返回的错误被完全忽略,`suiService` 可能为 nil 但仍被注册到 Wails。
+
+**修复方案**(已在 1.2 中包含):
+
+```go
+suiService, errt := services.NewSuiStore()
+if errt != nil {
+ log.Fatalf("❌ SuiStore 初始化失败: %v", errt)
+}
+```
+
+---
+
+### 1.4 配置目录拼写错误修正
+
+**问题位置**:`services/appsettings.go:10-11`
+
+**问题描述**:
+```go
+const (
+ appSettingsDir = ".codex-swtich" // ← 拼写错误!应为 .code-switch
+ appSettingsFile = "app.json"
+)
+```
+
+用户设置保存到错误路径,导致每次启动都读不到配置。
+
+**修复方案**:
+
+```go
+// services/appsettings.go
+
+const (
+ appSettingsDir = ".code-switch" // 【修复】修正拼写
+ appSettingsFile = "app.json"
+ oldSettingsDir = ".codex-swtich" // 旧的错误拼写
+ migrationMarkerFile = ".migrated-from-codex-swtich"
+)
+```
+
+**数据迁移**(完整版,含标记和清理):
+
+```go
+// services/appsettings.go - NewAppSettingsService 函数
+
+func NewAppSettingsService(autoStartService *AutoStartService) *AppSettingsService {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ home = "."
+ }
+
+ newDir := filepath.Join(home, appSettingsDir)
+ newPath := filepath.Join(newDir, appSettingsFile)
+ oldDir := filepath.Join(home, oldSettingsDir)
+ oldPath := filepath.Join(oldDir, appSettingsFile)
+ markerPath := filepath.Join(newDir, migrationMarkerFile)
+
+ // 检查是否已经迁移过
+ if _, err := os.Stat(markerPath); os.IsNotExist(err) {
+ // 尚未迁移,检查旧目录
+ if _, err := os.Stat(oldPath); err == nil {
+ // 旧文件存在,执行迁移
+ if err := migrateSettings(oldPath, newPath, oldDir, markerPath); err != nil {
+ fmt.Printf("[AppSettings] ⚠️ 迁移配置失败: %v\n", err)
+ }
+ }
+ }
+
+ return &AppSettingsService{
+ path: newPath,
+ autoStartService: autoStartService,
+ }
+}
+
+// migrateSettings 完整的配置迁移
+// 【v1.3 修正】迁移顺序:写新文件 → 校验 → 标记 → 删旧
+func migrateSettings(oldPath, newPath, oldDir, markerPath string) error {
+ // 1. 确保新目录存在
+ if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil {
+ return fmt.Errorf("创建新目录失败: %w", err)
+ }
+
+ // 2. 检查新文件是否已存在
+ if _, err := os.Stat(newPath); err == nil {
+ // 新文件已存在,不覆盖,但仍创建迁移标记
+ fmt.Printf("[AppSettings] 新配置文件已存在,跳过迁移\n")
+ } else {
+ // 3. 读取旧配置
+ data, err := os.ReadFile(oldPath)
+ if err != nil {
+ return fmt.Errorf("读取旧配置失败: %w", err)
+ }
+
+ // 4. 写入新配置
+ if err := os.WriteFile(newPath, data, 0644); err != nil {
+ return fmt.Errorf("写入新配置失败: %w", err)
+ }
+
+ // 5. 【v1.3 新增】校验新文件
+ verifyData, err := os.ReadFile(newPath)
+ if err != nil {
+ // 写入成功但读取失败,回滚
+ os.Remove(newPath)
+ return fmt.Errorf("校验新配置失败(已回滚): %w", err)
+ }
+
+ // 校验内容一致性
+ if !bytes.Equal(data, verifyData) {
+ os.Remove(newPath)
+ return fmt.Errorf("配置内容校验失败(已回滚): 写入内容与读取内容不一致")
+ }
+
+ // 如果是 JSON 文件,额外校验 JSON 格式有效性
+ if strings.HasSuffix(newPath, ".json") {
+ var jsonTest interface{}
+ if err := json.Unmarshal(verifyData, &jsonTest); err != nil {
+ os.Remove(newPath)
+ return fmt.Errorf("JSON 格式校验失败(已回滚): %w", err)
+ }
+ }
+
+ fmt.Printf("[AppSettings] ✅ 已迁移并校验配置: %s → %s\n", oldPath, newPath)
+ }
+
+ // 6. 创建迁移标记文件
+ markerContent := fmt.Sprintf("迁移时间: %s\n旧路径: %s\n", time.Now().Format(time.RFC3339), oldDir)
+ if err := os.WriteFile(markerPath, []byte(markerContent), 0644); err != nil {
+ return fmt.Errorf("创建迁移标记失败: %w", err)
+ }
+
+ // 7. 【v1.3 修正】只有在新文件校验通过后才删除旧目录
+ if err := os.RemoveAll(oldDir); err != nil {
+ // 删除失败不是致命错误,只记录警告
+ fmt.Printf("[AppSettings] ⚠️ 删除旧目录失败: %v(可手动删除 %s)\n", err, oldDir)
+ } else {
+ fmt.Printf("[AppSettings] ✅ 已删除旧目录: %s\n", oldDir)
+ }
+
+ return nil
+}
+```
+
+**验证方法**:
+```bash
+# 检查是否读取正确路径
+cat ~/.code-switch/app.json
+# 预期:设置能正确保存和读取
+```
+
+---
+
+## 阶段二:安全漏洞
+
+### 2.1 代理服务仅监听 127.0.0.1
+
+**问题位置**:`services/providerrelay.go:32-35, 89-91`
+
+**问题描述**:
+- 默认监听 `:18100` = `0.0.0.0:18100`(所有网卡)
+- 同网段任何设备可访问,直接借用本地 API Key
+
+**安全等级**:🔴 **灾难级** - API Key 全网暴露
+
+**修复方案**:
+
+```go
+// services/providerrelay.go
+
+func NewProviderRelayService(providerService *ProviderService, geminiService *GeminiService, blacklistService *BlacklistService, addr string) *ProviderRelayService {
+ if addr == "" {
+ addr = "127.0.0.1:18100" // 【安全修复】仅监听本地回环地址
+ }
+ // ...
+}
+```
+
+---
+
+### 2.2 代理认证与反代防护
+
+**问题分析**:即使监听 `127.0.0.1`,用户可能通过 Nginx/FRP 将端口暴露到公网。
+
+**修复方案**:
+
+```go
+// services/providerrelay.go
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+)
+
+// ProxyConfig 代理安全配置
+type ProxyConfig struct {
+ // 认证 Token(从配置文件读取,首次启动时生成)
+ AuthToken string `json:"auth_token"`
+ // 是否允许被反向代理(默认 false)
+ AllowReverseProxy bool `json:"allow_reverse_proxy"`
+ // 是否要求认证(默认 true)
+ RequireAuth bool `json:"require_auth"`
+ // 【v1.3 新增】是否允许无认证访问(必须显式设置,默认 false)
+ // 与 RequireAuth=false 不同,此字段要求用户明确知晓风险
+ AllowUnauthenticated bool `json:"allow_unauthenticated"`
+}
+
+// 全局代理配置(延迟初始化,从配置文件加载)
+var proxyConfig *ProxyConfig
+
+// initProxyConfig 初始化代理配置(【v1.3 修正】从配置文件加载,保证 Token 持久化)
+func initProxyConfig() error {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return fmt.Errorf("获取用户目录失败: %w", err)
+ }
+
+ configPath := filepath.Join(home, ".code-switch", "proxy-config.json")
+
+ // 尝试加载现有配置
+ if data, err := os.ReadFile(configPath); err == nil {
+ config := &ProxyConfig{}
+ if err := json.Unmarshal(data, config); err == nil {
+ proxyConfig = config
+ log.Printf("✅ 已加载代理配置(Token: %s...)", proxyConfig.AuthToken[:8])
+ return nil
+ }
+ }
+
+ // 配置不存在或无效,生成新配置
+ proxyConfig = &ProxyConfig{
+ AuthToken: generateSecureToken(),
+ AllowReverseProxy: false,
+ RequireAuth: true,
+ AllowUnauthenticated: false,
+ }
+
+ // 【v1.3 修正】持久化配置到文件
+ if err := saveProxyConfig(configPath); err != nil {
+ return fmt.Errorf("保存代理配置失败: %w", err)
+ }
+
+ log.Printf("✅ 已生成新的代理配置(Token: %s...)", proxyConfig.AuthToken[:8])
+ return nil
+}
+
+// saveProxyConfig 保存代理配置到文件
+func saveProxyConfig(configPath string) error {
+ data, err := json.MarshalIndent(proxyConfig, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(configPath, data, 0600) // 限制权限,仅所有者可读写
+}
+
+// generateSecureToken 生成安全 Token
+func generateSecureToken() string {
+ b := make([]byte, 32)
+ rand.Read(b)
+ return base64.URLEncoding.EncodeToString(b)
+}
+
+// securityMiddleware 安全中间件
+func securityMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // 【v1.4 修正】内部端点放行(仅限本地回环 IP)
+ // 解决"鸡生蛋"死锁:用户需要先获取 Token 才能通过认证
+ if strings.HasPrefix(c.Request.URL.Path, "/_internal/") {
+ clientIP := c.ClientIP()
+ if clientIP == "127.0.0.1" || clientIP == "::1" {
+ c.Next()
+ return
+ }
+ // 非本地 IP 访问内部端点,拒绝
+ c.JSON(http.StatusForbidden, gin.H{"error": "internal endpoints are localhost only"})
+ c.Abort()
+ return
+ }
+
+ // 1. 检查反向代理头
+ if !proxyConfig.AllowReverseProxy {
+ // 检查常见的反代头
+ reverseProxyHeaders := []string{
+ "X-Forwarded-For",
+ "X-Real-IP",
+ "X-Forwarded-Host",
+ "X-Forwarded-Proto",
+ "Via",
+ "Forwarded",
+ }
+ for _, header := range reverseProxyHeaders {
+ if c.GetHeader(header) != "" {
+ log.Printf("⚠️ 检测到反向代理头 %s,拒绝请求", header)
+ c.JSON(http.StatusForbidden, gin.H{
+ "error": "Reverse proxy detected. Direct connections only.",
+ })
+ c.Abort()
+ return
+ }
+ }
+ }
+
+ // 2. 检查来源 IP
+ clientIP := c.ClientIP()
+ if clientIP != "127.0.0.1" && clientIP != "::1" && clientIP != "" {
+ log.Printf("⚠️ 非本地 IP 访问: %s", clientIP)
+ c.JSON(http.StatusForbidden, gin.H{
+ "error": "Access denied: only localhost connections allowed",
+ })
+ c.Abort()
+ return
+ }
+
+ // 3. 检查认证 Token(如果启用)
+ if proxyConfig.RequireAuth {
+ authHeader := c.GetHeader("X-CodeSwitch-Auth")
+ if authHeader == "" {
+ // 也检查 Bearer Token
+ authHeader = strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
+ }
+
+ if authHeader == "" {
+ // 【v1.3 修正】Token 为空时的处理
+ if proxyConfig.AllowUnauthenticated {
+ // 显式允许无认证访问(用户已知晓风险)
+ log.Printf("⚠️ [安全警告] 收到无认证请求,已通过(AllowUnauthenticated=true)")
+ } else {
+ // 默认拒绝无认证请求
+ log.Printf("⚠️ 拒绝无认证请求(如需允许,请设置 AllowUnauthenticated=true)")
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "error": "Authentication required. Set AllowUnauthenticated=true to disable.",
+ })
+ c.Abort()
+ return
+ }
+ } else if authHeader != proxyConfig.AuthToken {
+ // Token 不匹配,说明是伪造的
+ log.Printf("⚠️ 无效的认证 Token")
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "error": "Invalid authentication token",
+ })
+ c.Abort()
+ return
+ }
+ // Token 匹配,继续处理
+ }
+
+ c.Next()
+ }
+}
+
+// registerRoutes 注册路由(修改版)
+func (prs *ProviderRelayService) registerRoutes(router gin.IRouter) {
+ // 【安全修复】添加安全中间件
+ router.Use(securityMiddleware())
+
+ router.POST("/v1/messages", prs.proxyHandler("claude", "/v1/messages"))
+ router.POST("/responses", prs.proxyHandler("codex", "/responses"))
+ router.POST("/gemini/v1beta/*any", prs.geminiProxyHandler("/v1beta"))
+ router.POST("/gemini/v1/*any", prs.geminiProxyHandler("/v1"))
+
+ // 【新增】健康检查端点(无需认证)
+ router.GET("/health", func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+ })
+
+ // 【新增】获取认证 Token(仅限本地调用,用于配置 Claude Code)
+ router.GET("/_internal/auth-token", func(c *gin.Context) {
+ // 此端点只有在本地直接访问时才返回 Token
+ if c.ClientIP() != "127.0.0.1" && c.ClientIP() != "::1" {
+ c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"token": proxyConfig.AuthToken})
+ })
+}
+
+// GetAuthToken 获取认证 Token(供前端使用)
+func (prs *ProviderRelayService) GetAuthToken() string {
+ return proxyConfig.AuthToken
+}
+
+// SetAllowReverseProxy 设置是否允许反向代理(高级选项)
+func (prs *ProviderRelayService) SetAllowReverseProxy(allow bool) {
+ proxyConfig.AllowReverseProxy = allow
+ if allow {
+ log.Printf("⚠️ 已启用反向代理支持,请确保已配置额外的安全措施")
+ }
+}
+```
+
+**验证方法**:
+```bash
+# 从本地访问
+curl http://127.0.0.1:18100/v1/messages
+# 预期:正常响应
+
+# 模拟反向代理
+curl -H "X-Forwarded-For: 1.2.3.4" http://127.0.0.1:18100/v1/messages
+# 预期:403 Forbidden
+```
+
+---
+
+### 2.3 SSRF + DNS 重绑定防护
+
+**问题位置**:`services/speedtestservice.go:45-120`
+
+**问题描述**:
+- `TestEndpoints` 接受任意 URL 并发起请求
+- 无协议、IP、端口限制
+- DNS 重绑定攻击可绕过初始验证
+
+**修复方案**:
+
+```go
+// services/speedtestservice.go
+
+import (
+ "context"
+ "net"
+ "time"
+)
+
+// SafeDialContext 安全的 DialContext(在连接时检查 IP)
+func (s *SpeedTestService) safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
+ // 解析主机名
+ host, port, err := net.SplitHostPort(addr)
+ if err != nil {
+ return nil, fmt.Errorf("无效的地址: %s", addr)
+ }
+
+ // 解析 IP(在连接前再次解析,防止 DNS 重绑定)
+ ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
+ if err != nil {
+ return nil, fmt.Errorf("DNS 解析失败: %w", err)
+ }
+
+ if len(ips) == 0 {
+ return nil, fmt.Errorf("DNS 解析无结果")
+ }
+
+ // 检查所有解析到的 IP
+ for _, ip := range ips {
+ if s.isUnsafeIP(ip) {
+ return nil, fmt.Errorf("检测到不安全的 IP 地址: %s(可能是 DNS 重绑定攻击)", ip.String())
+ }
+ }
+
+ // 使用第一个安全的 IP 连接
+ targetAddr := net.JoinHostPort(ips[0].String(), port)
+
+ // 创建连接
+ dialer := &net.Dialer{
+ Timeout: 10 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }
+
+ return dialer.DialContext(ctx, network, targetAddr)
+}
+
+// isUnsafeIP 检查是否为不安全的 IP
+// 【v1.3 修正】补充 0.0.0.0/::、组播、更多云厂商元数据地址
+func (s *SpeedTestService) isUnsafeIP(ip net.IP) bool {
+ // 检查是否为 nil 或 unspecified (0.0.0.0, ::)
+ if ip == nil || ip.IsUnspecified() {
+ return true
+ }
+
+ // 检查特殊地址
+ if ip.IsLoopback() || // 127.0.0.0/8, ::1
+ ip.IsPrivate() || // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
+ ip.IsLinkLocalUnicast() || // 169.254.0.0/16, fe80::/10
+ ip.IsLinkLocalMulticast() ||// 224.0.0.0/24, ff02::/16
+ ip.IsMulticast() { // 【v1.3 新增】224.0.0.0/4, ff00::/8 (全部组播地址)
+ return true
+ }
+
+ // 【v1.3 修正】更完整的云服务元数据地址
+ metadataBlocks := []string{
+ // AWS
+ "169.254.169.254/32", // AWS EC2 IMDS IPv4
+ "fd00:ec2::254/128", // AWS EC2 IMDS IPv6
+ "169.254.170.2/32", // AWS ECS Task Metadata
+
+ // GCP
+ "metadata.google.internal/32", // GCP (通常解析为 169.254.169.254)
+
+ // Azure
+ "169.254.169.254/32", // Azure IMDS (与 AWS 共用)
+ "168.63.129.16/32", // Azure Wire Server
+
+ // Alibaba Cloud
+ "100.100.100.200/32", // Alibaba Cloud Metadata
+
+ // Oracle Cloud
+ "169.254.169.254/32", // Oracle Cloud IMDS (与 AWS 共用)
+
+ // DigitalOcean
+ "169.254.169.254/32", // DigitalOcean Droplet Metadata
+
+ // Hetzner
+ "169.254.169.254/32", // Hetzner Cloud Metadata
+
+ // OpenStack
+ "169.254.169.254/32", // OpenStack Metadata Service
+
+ // Kubernetes
+ "10.0.0.1/32", // 默认 Kubernetes API Server (可配置)
+
+ // Docker
+ "172.17.0.1/32", // Docker bridge 网关 (默认)
+ }
+
+ for _, block := range metadataBlocks {
+ _, cidr, _ := net.ParseCIDR(block)
+ if cidr != nil && cidr.Contains(ip) {
+ return true
+ }
+ }
+
+ // 【v1.3 新增】保留地址段
+ reservedBlocks := []string{
+ "0.0.0.0/8", // 当前网络(仅作为源地址有效)
+ "100.64.0.0/10", // 运营商级 NAT (CGN)
+ "192.0.0.0/24", // IETF 协议分配
+ "192.0.2.0/24", // TEST-NET-1 (文档用)
+ "198.51.100.0/24", // TEST-NET-2 (文档用)
+ "203.0.113.0/24", // TEST-NET-3 (文档用)
+ "240.0.0.0/4", // 保留(未来使用)
+ "255.255.255.255/32", // 广播地址
+ }
+
+ for _, block := range reservedBlocks {
+ _, cidr, _ := net.ParseCIDR(block)
+ if cidr != nil && cidr.Contains(ip) {
+ return true
+ }
+ }
+
+ return false
+}
+
+// validateURL 增强版 URL 验证
+func (s *SpeedTestService) validateURL(rawURL string) error {
+ parsedURL, err := url.Parse(rawURL)
+ if err != nil {
+ return fmt.Errorf("URL 格式无效: %v", err)
+ }
+
+ // 1. 协议白名单
+ scheme := strings.ToLower(parsedURL.Scheme)
+ if scheme != "http" && scheme != "https" {
+ return fmt.Errorf("不支持的协议: %s", scheme)
+ }
+
+ // 2. 检查 Host
+ host := parsedURL.Hostname()
+ if host == "" {
+ return fmt.Errorf("URL 缺少主机名")
+ }
+
+ // 3. 拒绝 IP literal(直接使用 IP 地址)
+ if ip := net.ParseIP(host); ip != nil {
+ if s.isUnsafeIP(ip) {
+ return fmt.Errorf("禁止直接访问内网 IP: %s", host)
+ }
+ }
+
+ // 4. 拒绝特殊主机名
+ lowerHost := strings.ToLower(host)
+ if lowerHost == "localhost" ||
+ strings.HasSuffix(lowerHost, ".local") ||
+ strings.HasSuffix(lowerHost, ".internal") ||
+ strings.HasSuffix(lowerHost, ".localhost") {
+ return fmt.Errorf("禁止访问本地主机名: %s", host)
+ }
+
+ // 5. 端口白名单
+ port := parsedURL.Port()
+ if port != "" && port != "80" && port != "443" && port != "8080" && port != "8443" {
+ return fmt.Errorf("不支持的端口: %s", port)
+ }
+
+ return nil
+}
+
+// buildSafeClient 构建安全的 HTTP 客户端
+func (s *SpeedTestService) buildSafeClient(timeoutSecs int) *http.Client {
+ transport := &http.Transport{
+ DialContext: s.safeDialContext, // 【关键】使用安全的 DialContext
+ // 禁用代理(防止通过代理绕过)
+ Proxy: nil,
+ // 限制重定向
+ MaxIdleConns: 10,
+ IdleConnTimeout: 90 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ }
+
+ return &http.Client{
+ Timeout: time.Duration(timeoutSecs) * time.Second,
+ Transport: transport,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ // 限制重定向次数
+ if len(via) >= 3 {
+ return fmt.Errorf("重定向次数过多")
+ }
+
+ // 【关键】检查重定向目标
+ if err := s.validateURL(req.URL.String()); err != nil {
+ return fmt.Errorf("重定向目标不安全: %w", err)
+ }
+
+ return nil
+ },
+ }
+}
+
+// testSingleEndpoint 测试单个端点(修改版)
+func (s *SpeedTestService) testSingleEndpoint(client *http.Client, rawURL string) EndpointLatency {
+ trimmed := trimSpace(rawURL)
+ if trimmed == "" {
+ errMsg := "URL 不能为空"
+ return EndpointLatency{URL: rawURL, Error: &errMsg}
+ }
+
+ // 【安全修复】验证 URL
+ if err := s.validateURL(trimmed); err != nil {
+ errMsg := err.Error()
+ return EndpointLatency{URL: trimmed, Error: &errMsg}
+ }
+
+ // 【修复】热身请求响应体必须关闭
+ if resp, _ := s.makeRequest(client, trimmed); resp != nil {
+ io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+ }
+
+ // ... 后续代码不变
+}
+
+// TestEndpoints 测试端点(修改版)
+func (s *SpeedTestService) TestEndpoints(urls []string, timeoutSecs *int) []EndpointLatency {
+ if len(urls) == 0 {
+ return []EndpointLatency{}
+ }
+
+ timeout := s.sanitizeTimeout(timeoutSecs)
+ client := s.buildSafeClient(timeout) // 【修改】使用安全客户端
+
+ // ... 后续代码不变
+}
+```
+
+**验证方法**:
+```bash
+# 合法 URL
+curl -X POST 'http://localhost:18100/speedtest' \
+ -d '{"urls": ["https://api.anthropic.com"]}'
+# 预期:正常测试
+
+# 非法 URL(内网)
+curl -X POST 'http://localhost:18100/speedtest' \
+ -d '{"urls": ["http://192.168.1.1/admin"]}'
+# 预期:返回错误 "禁止访问内网地址"
+
+# DNS 重绑定攻击模拟(需要搭建测试环境)
+# 预期:在连接时二次解析 IP,发现是内网地址后拒绝
+```
+
+---
+
+### 2.4 自动更新完整安全方案
+
+**问题位置**:`services/updateservice.go`
+
+**问题描述**:
+- `calculateSHA256()` 函数存在但从未被调用
+- 下载后直接执行/替换,无完整性校验
+- 静默执行,用户无感知
+- 无回滚机制
+
+**修复方案**:
+
+```go
+// services/updateservice.go
+
+// UpdateConfirmation 更新确认信息(供前端展示)
+type UpdateConfirmation struct {
+ Version string `json:"version"`
+ CurrentVersion string `json:"current_version"`
+ FilePath string `json:"file_path"`
+ FileSize int64 `json:"file_size"`
+ FileSizeHuman string `json:"file_size_human"`
+ SHA256 string `json:"sha256"`
+ ExpectedSHA256 string `json:"expected_sha256"`
+ HashVerified bool `json:"hash_verified"`
+ ReleaseNotes string `json:"release_notes"`
+}
+
+// PrepareUpdate 准备更新(修改版)
+func (us *UpdateService) PrepareUpdate() (*UpdateConfirmation, error) {
+ us.mu.Lock()
+ defer us.mu.Unlock()
+
+ if us.updateFilePath == "" {
+ return nil, fmt.Errorf("更新文件路径为空")
+ }
+
+ // 1. 计算实际 SHA256
+ actualSHA256, err := calculateSHA256(us.updateFilePath)
+ if err != nil {
+ return nil, fmt.Errorf("计算文件哈希失败: %w", err)
+ }
+
+ // 2. 获取文件大小
+ fileInfo, err := os.Stat(us.updateFilePath)
+ if err != nil {
+ return nil, fmt.Errorf("获取文件信息失败: %w", err)
+ }
+
+ // 3. 返回确认信息(供前端展示)
+ return &UpdateConfirmation{
+ Version: us.latestVersion,
+ CurrentVersion: us.currentVersion,
+ FilePath: us.updateFilePath,
+ FileSize: fileInfo.Size(),
+ FileSizeHuman: formatFileSize(fileInfo.Size()),
+ SHA256: actualSHA256,
+ ExpectedSHA256: us.expectedSHA256,
+ HashVerified: us.expectedSHA256 == "" || strings.EqualFold(actualSHA256, us.expectedSHA256),
+ ReleaseNotes: us.releaseNotes,
+ }, nil
+}
+
+// ConfirmAndApplyUpdate 用户确认后执行更新
+func (us *UpdateService) ConfirmAndApplyUpdate(userConfirmed bool) error {
+ if !userConfirmed {
+ return fmt.Errorf("用户取消更新")
+ }
+
+ us.mu.Lock()
+ updatePath := us.updateFilePath
+ expectedSHA256 := us.expectedSHA256
+ us.mu.Unlock()
+
+ // 1. 【关键】再次校验 SHA256(防止 TOCTOU 攻击)
+ if expectedSHA256 != "" {
+ actualSHA256, err := calculateSHA256(updatePath)
+ if err != nil {
+ return fmt.Errorf("校验文件失败: %w", err)
+ }
+ if !strings.EqualFold(actualSHA256, expectedSHA256) {
+ os.Remove(updatePath)
+ return fmt.Errorf("文件完整性校验失败,已删除可疑文件")
+ }
+ }
+
+ // 2. 备份当前版本
+ if err := us.backupCurrentVersion(); err != nil {
+ log.Printf("[UpdateService] ⚠️ 备份当前版本失败: %v(继续更新)", err)
+ // 不阻止更新,但记录警告
+ }
+
+ // 3. 根据平台执行更新
+ switch runtime.GOOS {
+ case "windows":
+ return us.applyUpdateWindowsWithConfirm(updatePath)
+ case "darwin":
+ return us.applyUpdateDarwinWithConfirm(updatePath)
+ case "linux":
+ return us.applyUpdateLinuxWithConfirm(updatePath)
+ default:
+ return fmt.Errorf("不支持的平台: %s", runtime.GOOS)
+ }
+}
+
+// backupCurrentVersion 备份当前版本
+func (us *UpdateService) backupCurrentVersion() error {
+ currentExe, err := os.Executable()
+ if err != nil {
+ return err
+ }
+
+ // 备份目录
+ backupDir := filepath.Join(us.updateDir, "backups")
+ if err := os.MkdirAll(backupDir, 0755); err != nil {
+ return err
+ }
+
+ // 备份文件名:CodeSwitch_v1.1.16_20251128_150405.exe
+ backupName := fmt.Sprintf("%s_%s_%s%s",
+ strings.TrimSuffix(filepath.Base(currentExe), filepath.Ext(currentExe)),
+ us.currentVersion,
+ time.Now().Format("20060102_150405"),
+ filepath.Ext(currentExe),
+ )
+ backupPath := filepath.Join(backupDir, backupName)
+
+ // 复制文件
+ if err := copyUpdateFile(currentExe, backupPath); err != nil {
+ return err
+ }
+
+ log.Printf("[UpdateService] ✅ 已备份当前版本: %s", backupPath)
+
+ // 清理旧备份(保留最近 3 个)
+ us.cleanOldBackups(backupDir, 3)
+
+ return nil
+}
+
+// cleanOldBackups 清理旧备份
+func (us *UpdateService) cleanOldBackups(backupDir string, keepCount int) {
+ entries, err := os.ReadDir(backupDir)
+ if err != nil {
+ return
+ }
+
+ // 按修改时间排序
+ type backupFile struct {
+ path string
+ modTime time.Time
+ }
+ var backups []backupFile
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ info, err := entry.Info()
+ if err != nil {
+ continue
+ }
+ backups = append(backups, backupFile{
+ path: filepath.Join(backupDir, entry.Name()),
+ modTime: info.ModTime(),
+ })
+ }
+
+ // 按时间降序
+ sort.Slice(backups, func(i, j int) bool {
+ return backups[i].modTime.After(backups[j].modTime)
+ })
+
+ // 删除多余的备份
+ for i := keepCount; i < len(backups); i++ {
+ os.Remove(backups[i].path)
+ log.Printf("[UpdateService] 已清理旧备份: %s", backups[i].path)
+ }
+}
+
+// RollbackUpdate 回滚到上一个备份
+func (us *UpdateService) RollbackUpdate() error {
+ backupDir := filepath.Join(us.updateDir, "backups")
+
+ entries, err := os.ReadDir(backupDir)
+ if err != nil {
+ return fmt.Errorf("无可用备份")
+ }
+
+ // 找到最新的备份
+ var latestBackup string
+ var latestTime time.Time
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ info, err := entry.Info()
+ if err != nil {
+ continue
+ }
+ if info.ModTime().After(latestTime) {
+ latestTime = info.ModTime()
+ latestBackup = filepath.Join(backupDir, entry.Name())
+ }
+ }
+
+ if latestBackup == "" {
+ return fmt.Errorf("无可用备份")
+ }
+
+ currentExe, err := os.Executable()
+ if err != nil {
+ return err
+ }
+
+ // 替换当前可执行文件
+ if err := copyUpdateFile(latestBackup, currentExe); err != nil {
+ return fmt.Errorf("回滚失败: %w", err)
+ }
+
+ log.Printf("[UpdateService] ✅ 已回滚到: %s", latestBackup)
+ return nil
+}
+
+// applyUpdateWindowsWithConfirm Windows 更新(带确认)
+func (us *UpdateService) applyUpdateWindowsWithConfirm(updatePath string) error {
+ if us.isPortable {
+ return us.applyPortableUpdateWithConfirm(updatePath)
+ }
+
+ // 安装版:启动安装器(【修改】移除 /SILENT,让用户看到安装界面)
+ cmd := exec.Command(updatePath) // 不再使用 /SILENT
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("启动安装器失败: %w", err)
+ }
+
+ // 清理 pending 标记
+ pendingFile := filepath.Join(filepath.Dir(us.stateFile), ".pending-update")
+ _ = os.Remove(pendingFile)
+
+ // 退出当前应用
+ os.Exit(0)
+ return nil
+}
+
+// applyUpdateDarwinWithConfirm macOS 更新(实现或禁用)
+func (us *UpdateService) applyUpdateDarwinWithConfirm(zipPath string) error {
+ // 【修复】明确告知用户 macOS 自动更新未实现
+ return fmt.Errorf("macOS 自动更新暂未实现,请手动下载安装: %s", us.downloadURL)
+
+ // 未来实现时的完整逻辑:
+ // 1. 解压 zip 到临时目录
+ // 2. 验证 .app 包签名(codesign --verify)
+ // 3. 备份当前 .app
+ // 4. 替换 /Applications/CodeSwitch.app
+ // 5. 使用 open 命令重启
+}
+
+// formatFileSize 格式化文件大小
+func formatFileSize(size int64) string {
+ const (
+ KB = 1024
+ MB = KB * 1024
+ GB = MB * 1024
+ )
+ switch {
+ case size >= GB:
+ return fmt.Sprintf("%.2f GB", float64(size)/GB)
+ case size >= MB:
+ return fmt.Sprintf("%.2f MB", float64(size)/MB)
+ case size >= KB:
+ return fmt.Sprintf("%.2f KB", float64(size)/KB)
+ default:
+ return fmt.Sprintf("%d B", size)
+ }
+}
+```
+
+**前端确认对话框设计**:
+
+```typescript
+// 前端:显示更新确认对话框
+async function showUpdateConfirmation() {
+ const confirmation = await Call.ByName('codeswitch/services.UpdateService.PrepareUpdate')
+
+ const confirmed = await showDialog({
+ title: '确认更新',
+ content: `
+ 当前版本: ${confirmation.current_version}
+ 新版本: ${confirmation.version}
+ 文件大小: ${confirmation.file_size_human}
+ SHA256: ${confirmation.sha256.substring(0, 16)}...
+ 哈希校验: ${confirmation.hash_verified ? '✅ 通过' : '⚠️ 未校验'}
+ `,
+ buttons: ['取消', '确认更新']
+ })
+
+ if (confirmed) {
+ await Call.ByName('codeswitch/services.UpdateService.ConfirmAndApplyUpdate', true)
+ }
+}
+```
+
+---
+
+### 2.5 技能仓库下载安全
+
+**问题位置**:`services/skillservice.go`
+
+**问题描述**:
+- 无下载大小限制
+- 无路径穿越检查
+- 无文件类型检查
+
+**修复方案**:
+
+```go
+// services/skillservice.go
+
+const (
+ maxZipSize = 100 * 1024 * 1024 // 100MB 最大下载大小
+ maxFileSize = 10 * 1024 * 1024 // 10MB 单文件最大大小
+ maxFileCount = 1000 // 最大文件数量
+ maxPathDepth = 10 // 最大路径深度
+)
+
+// downloadFile 安全的文件下载(限制大小)
+func (ss *SkillService) downloadFile(rawURL, dest string) error {
+ req, err := http.NewRequest(http.MethodGet, rawURL, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("User-Agent", "ai-code-studio")
+
+ resp, err := ss.httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("下载失败: %s", resp.Status)
+ }
+
+ // 【安全修复】检查 Content-Length
+ if resp.ContentLength > maxZipSize {
+ return fmt.Errorf("文件过大: %d bytes(最大允许 %d bytes)", resp.ContentLength, maxZipSize)
+ }
+
+ out, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ // 【安全修复】使用 LimitReader 限制读取大小
+ limitedReader := io.LimitReader(resp.Body, maxZipSize+1)
+ written, err := io.Copy(out, limitedReader)
+ if err != nil {
+ return err
+ }
+
+ if written > maxZipSize {
+ os.Remove(dest)
+ return fmt.Errorf("文件过大: 超过 %d bytes 限制", maxZipSize)
+ }
+
+ return nil
+}
+
+// unzipArchive 安全的解压(路径穿越检查)
+func unzipArchive(zipPath, dest string) (string, error) {
+ reader, err := zip.OpenReader(zipPath)
+ if err != nil {
+ return "", err
+ }
+ defer reader.Close()
+
+ // 【安全修复】检查文件数量
+ if len(reader.File) > maxFileCount {
+ return "", fmt.Errorf("压缩包文件过多: %d(最大允许 %d)", len(reader.File), maxFileCount)
+ }
+
+ var root string
+ var totalSize int64
+
+ for _, file := range reader.File {
+ name := file.Name
+ if name == "" {
+ continue
+ }
+
+ // 【安全修复 1】检查路径穿越
+ if err := validateZipPath(name, dest); err != nil {
+ return "", err
+ }
+
+ if root == "" {
+ root = strings.Split(name, "/")[0]
+ }
+
+ targetPath := filepath.Join(dest, name)
+
+ // 【安全修复 2】再次验证最终路径
+ absTarget, err := filepath.Abs(targetPath)
+ if err != nil {
+ return "", fmt.Errorf("无法解析路径: %s", name)
+ }
+ absDest, err := filepath.Abs(dest)
+ if err != nil {
+ return "", fmt.Errorf("无法解析目标目录")
+ }
+ if !strings.HasPrefix(absTarget, absDest+string(filepath.Separator)) && absTarget != absDest {
+ return "", fmt.Errorf("检测到路径穿越攻击: %s", name)
+ }
+
+ if file.FileInfo().IsDir() {
+ if err := os.MkdirAll(targetPath, 0o755); err != nil {
+ return "", err
+ }
+ continue
+ }
+
+ // 【安全修复 3】检查单文件大小
+ if file.UncompressedSize64 > uint64(maxFileSize) {
+ return "", fmt.Errorf("文件过大: %s (%d bytes)", name, file.UncompressedSize64)
+ }
+
+ // 【安全修复 4】累计大小检查(防止 zip bomb)
+ totalSize += int64(file.UncompressedSize64)
+ if totalSize > maxZipSize*10 { // 允许 10 倍压缩率
+ return "", fmt.Errorf("解压后总大小过大,可能是 zip bomb 攻击")
+ }
+
+ // 【安全修复 5】检查文件类型(只允许文本文件和特定类型)
+ if !isAllowedFileType(name) {
+ log.Printf("[SkillService] 跳过不允许的文件类型: %s", name)
+ continue
+ }
+
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
+ return "", err
+ }
+
+ if err := extractFile(file, targetPath); err != nil {
+ return "", err
+ }
+ }
+
+ if root == "" {
+ return "", errors.New("压缩包内容为空")
+ }
+ return filepath.Join(dest, root), nil
+}
+
+// validateZipPath 验证 zip 文件路径
+func validateZipPath(name, dest string) error {
+ // 检查绝对路径
+ if filepath.IsAbs(name) {
+ return fmt.Errorf("禁止绝对路径: %s", name)
+ }
+
+ // 检查路径穿越
+ cleanName := filepath.Clean(name)
+ if strings.HasPrefix(cleanName, "..") || strings.Contains(cleanName, ".."+string(filepath.Separator)) {
+ return fmt.Errorf("检测到路径穿越: %s", name)
+ }
+
+ // 检查路径深度
+ parts := strings.Split(cleanName, string(filepath.Separator))
+ if len(parts) > maxPathDepth {
+ return fmt.Errorf("路径深度过大: %s", name)
+ }
+
+ return nil
+}
+
+// isAllowedFileType 检查是否为允许的文件类型
+func isAllowedFileType(name string) bool {
+ // 允许的扩展名
+ allowedExts := map[string]bool{
+ ".md": true,
+ ".txt": true,
+ ".json": true,
+ ".yaml": true,
+ ".yml": true,
+ ".toml": true,
+ ".py": true,
+ ".js": true,
+ ".ts": true,
+ ".sh": true, // 脚本需要用户自行审查
+ ".go": true,
+ ".rs": true,
+ ".css": true,
+ ".html": true,
+ ".svg": true,
+ ".png": true,
+ ".jpg": true,
+ ".jpeg": true,
+ ".gif": true,
+ }
+
+ ext := strings.ToLower(filepath.Ext(name))
+ return allowedExts[ext]
+}
+
+// extractFile 安全提取单个文件
+func extractFile(file *zip.File, targetPath string) error {
+ src, err := file.Open()
+ if err != nil {
+ return err
+ }
+ defer src.Close()
+
+ dst, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, file.Mode()&0o644) // 去掉执行权限
+ if err != nil {
+ return err
+ }
+ defer dst.Close()
+
+ // 使用 LimitReader 防止解压炸弹
+ if _, err := io.Copy(dst, io.LimitReader(src, maxFileSize+1)); err != nil {
+ return err
+ }
+
+ return nil
+}
+```
+
+---
+
+## 阶段三:核心逻辑
+
+### 3.1 请求转发故障切换
+
+**问题位置**:`services/providerrelay.go:262-323`
+
+**问题描述**:
+- 当前只取第一个 Level 的第一个 provider
+- 失败直接返回 502,不尝试其他 provider
+- 与文档描述的"Level 内按顺序尝试,失败后降级到下一 Level"不符
+
+**修复方案**:
+
+```go
+// services/providerrelay.go - proxyHandler 函数
+
+func (prs *ProviderRelayService) proxyHandler(kind string, endpoint string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // ... 前置代码(读取 body、加载 providers、过滤)保持不变 ...
+
+ // 按 Level 分组
+ levelGroups := make(map[int][]Provider)
+ for _, provider := range active {
+ level := provider.Level
+ if level <= 0 {
+ level = 1
+ }
+ levelGroups[level] = append(levelGroups[level], provider)
+ }
+
+ // 获取所有 level 并升序排序
+ levels := make([]int, 0, len(levelGroups))
+ for level := range levelGroups {
+ levels = append(levels, level)
+ }
+ sort.Ints(levels)
+
+ fmt.Printf("[INFO] 共 %d 个 Level 分组:%v\n", len(levels), levels)
+
+ // 【v1.3 修正】流式请求不做 Provider 回退,只尝试第一个
+ // 原因:流式请求一旦开始写入 c.Writer 就无法回滚
+ if isStream {
+ firstLevel := levels[0]
+ firstProvider := levelGroups[firstLevel][0]
+ effectiveModel := firstProvider.GetEffectiveModel(requestedModel)
+
+ currentBodyBytes := bodyBytes
+ if effectiveModel != requestedModel && requestedModel != "" {
+ modifiedBody, err := ReplaceModelInRequestBody(bodyBytes, effectiveModel)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("模型映射失败: %v", err)})
+ return
+ }
+ currentBodyBytes = modifiedBody
+ }
+
+ fmt.Printf("[INFO] 流式请求,仅尝试 Level %d 的 %s\n", firstLevel, firstProvider.Name)
+
+ result, err := prs.forwardRequestIsolated(c, kind, firstProvider, endpoint, query, clientHeaders, currentBodyBytes, true, effectiveModel)
+ if result == ForwardSuccess {
+ prs.blacklistService.RecordSuccess(kind, firstProvider.Name)
+ } else if result == ForwardRetryable {
+ prs.blacklistService.RecordFailure(kind, firstProvider.Name)
+ if err != nil {
+ c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
+ }
+ }
+ // ForwardNonRetryable: 已在 forwardRequestStream 中处理
+ return
+ }
+
+ // 【修复】非流式请求:遍历所有 Level 和 Provider,实现真正的故障切换
+ var lastError error
+ var lastProvider Provider
+ totalAttempts := 0
+ maxAttempts := 10 // 总尝试次数上限,防止无限循环
+
+ for _, level := range levels {
+ providersInLevel := levelGroups[level]
+ fmt.Printf("[INFO] === 尝试 Level %d(%d 个 provider)===\n", level, len(providersInLevel))
+
+ for i, provider := range providersInLevel {
+ if totalAttempts >= maxAttempts {
+ fmt.Printf("[WARN] 达到最大尝试次数 %d,停止尝试\n", maxAttempts)
+ break
+ }
+ totalAttempts++
+
+ // 获取映射后的模型名
+ effectiveModel := provider.GetEffectiveModel(requestedModel)
+
+ // 如果需要映射,修改请求体
+ currentBodyBytes := bodyBytes
+ if effectiveModel != requestedModel && requestedModel != "" {
+ fmt.Printf("[INFO] Provider %s 映射模型: %s -> %s\n", provider.Name, requestedModel, effectiveModel)
+ modifiedBody, err := ReplaceModelInRequestBody(bodyBytes, effectiveModel)
+ if err != nil {
+ fmt.Printf("[ERROR] 替换模型名失败: %v\n", err)
+ continue
+ }
+ currentBodyBytes = modifiedBody
+ }
+
+ // 【关键】重置请求体(使用缓存的 bodyBytes)
+ c.Request.Body = io.NopCloser(bytes.NewReader(currentBodyBytes))
+
+ fmt.Printf("[INFO] [%d/%d] Provider: %s | Model: %s\n", i+1, len(providersInLevel), provider.Name, effectiveModel)
+
+ startTime := time.Now()
+ result, err := prs.forwardRequestIsolated(c, kind, provider, endpoint, query, clientHeaders, currentBodyBytes, isStream, effectiveModel)
+ duration := time.Since(startTime)
+
+ switch result {
+ case ForwardSuccess:
+ fmt.Printf("[INFO] ✓ Level %d 成功: %s | 耗时: %.2fs\n", level, provider.Name, duration.Seconds())
+
+ // 成功:清零连续失败计数
+ if err := prs.blacklistService.RecordSuccess(kind, provider.Name); err != nil {
+ fmt.Printf("[WARN] 清零失败计数失败: %v\n", err)
+ }
+
+ return // 成功,立即返回
+
+ case ForwardNonRetryable:
+ // 不可重试错误(4xx),直接返回给用户,不尝试其他 provider
+ // 也不记录失败,因为这不是 provider 的问题
+ fmt.Printf("[INFO] ↩ 不可重试错误: %s | 耗时: %.2fs\n", provider.Name, duration.Seconds())
+ return
+
+ case ForwardRetryable:
+ // 失败:记录错误并继续尝试
+ lastError = err
+ lastProvider = provider
+ errorMsg := "未知错误"
+ if err != nil {
+ errorMsg = err.Error()
+ }
+ fmt.Printf("[WARN] ✗ Level %d 失败: %s | 错误: %s | 耗时: %.2fs\n",
+ level, provider.Name, errorMsg, duration.Seconds())
+
+ // 记录失败到黑名单系统
+ if err := prs.blacklistService.RecordFailure(kind, provider.Name); err != nil {
+ fmt.Printf("[ERROR] 记录失败到黑名单失败: %v\n", err)
+ }
+ }
+ }
+
+ fmt.Printf("[WARN] Level %d 的所有 %d 个 provider 均失败,尝试下一 Level\n", level, len(providersInLevel))
+ }
+
+ // 所有 Level 全部失败
+ errorMsg := "未知错误"
+ if lastError != nil {
+ errorMsg = lastError.Error()
+ }
+ fmt.Printf("[ERROR] 所有 %d 个 provider(%d 次尝试)均失败\n", len(active), totalAttempts)
+
+ c.JSON(http.StatusBadGateway, gin.H{
+ "error": fmt.Sprintf("所有 provider 均失败,最后错误: %s", errorMsg),
+ "last_provider": lastProvider.Name,
+ "total_attempts": totalAttempts,
+ })
+ }
+}
+```
+
+---
+
+### 3.2 Context 隔离(ResponseRecorder 模式)
+
+**问题分析**:原方案在循环中多次调用 `forwardRequest`,可能导致 "headers already sent" 错误。
+
+**修复方案**:
+
+```go
+// services/providerrelay.go
+
+import (
+ "net/http/httptest"
+)
+
+// ForwardResult 转发结果
+type ForwardResult int
+
+const (
+ ForwardSuccess ForwardResult = iota // 成功(2xx)
+ ForwardRetryable // 可重试错误(5xx、超时、网络错误)
+ ForwardNonRetryable // 不可重试错误(4xx)
+)
+
+// ForwardResponse 转发响应
+type ForwardResponse struct {
+ StatusCode int
+ Headers http.Header
+ Body []byte
+}
+
+// 【v1.3 新增】captureResponseWriter 用于捕获响应体
+type captureResponseWriter struct {
+ buf *bytes.Buffer
+ statusCode int
+ header http.Header
+}
+
+func (w *captureResponseWriter) Header() http.Header {
+ if w.header == nil {
+ w.header = make(http.Header)
+ }
+ return w.header
+}
+
+func (w *captureResponseWriter) Write(data []byte) (int, error) {
+ return w.buf.Write(data)
+}
+
+func (w *captureResponseWriter) WriteHeader(statusCode int) {
+ w.statusCode = statusCode
+}
+
+// forwardRequestIsolated 隔离的转发请求(使用 ResponseRecorder)
+func (prs *ProviderRelayService) forwardRequestIsolated(
+ c *gin.Context,
+ kind string,
+ provider Provider,
+ endpoint string,
+ query map[string]string,
+ clientHeaders map[string]string,
+ bodyBytes []byte,
+ isStream bool,
+ model string,
+) (ForwardResult, error) {
+ // 流式请求必须直接写入,无法使用 ResponseRecorder
+ if isStream {
+ return prs.forwardRequestStream(c, kind, provider, endpoint, query, clientHeaders, bodyBytes, model)
+ }
+
+ // 非流式请求:使用 ResponseRecorder 隔离
+ recorder := httptest.NewRecorder()
+
+ targetURL := joinURL(provider.APIURL, endpoint)
+ headers := cloneMap(clientHeaders)
+ headers["Authorization"] = fmt.Sprintf("Bearer %s", provider.APIKey)
+ if _, ok := headers["Accept"]; !ok {
+ headers["Accept"] = "application/json"
+ }
+
+ requestLog := &ReqeustLog{
+ Platform: kind,
+ Provider: provider.Name,
+ Model: model,
+ IsStream: false,
+ }
+ start := time.Now()
+ defer func() {
+ requestLog.DurationSec = time.Since(start).Seconds()
+ prs.writeRequestLog(requestLog)
+ }()
+
+ req := xrequest.New().
+ SetHeaders(headers).
+ SetQueryParams(query).
+ SetRetry(1, 500*time.Millisecond).
+ SetTimeout(3 * time.Hour)
+
+ reqBody := bytes.NewReader(bodyBytes)
+ req = req.SetBody(reqBody)
+
+ resp, err := req.Post(targetURL)
+ if err != nil {
+ return ForwardRetryable, err
+ }
+
+ if resp == nil {
+ return ForwardRetryable, fmt.Errorf("empty response")
+ }
+
+ status := resp.StatusCode()
+ requestLog.HttpCode = status
+
+ if resp.Error() != nil {
+ return ForwardRetryable, resp.Error()
+ }
+
+ // 【v1.3 修正】读取响应体
+ // xrequest 没有 Body() 方法,使用 ToHttpResponseWriter 配合 bytes.Buffer
+ // 或者直接从 RawResponse 读取
+ var respBody []byte
+
+ // 方案一:使用 bytes.Buffer 捕获响应
+ buf := &bytes.Buffer{}
+ bufWriter := &captureResponseWriter{buf: buf}
+ _, copyErr := resp.ToHttpResponseWriter(bufWriter, nil)
+ if copyErr != nil {
+ return ForwardRetryable, fmt.Errorf("读取响应体失败: %w", copyErr)
+ }
+ respBody = buf.Bytes()
+
+ // 解析 token usage
+ parseTokenUsage(respBody, kind, requestLog)
+
+ // 根据状态码判断结果
+ switch {
+ case status == 0:
+ // 特殊处理:状态码 0 当作成功
+ prs.writeResponseToClient(c, status, resp.Header(), respBody)
+ return ForwardSuccess, nil
+
+ case status >= 200 && status < 300:
+ // 成功
+ prs.writeResponseToClient(c, status, resp.Header(), respBody)
+ return ForwardSuccess, nil
+
+ case status == 429:
+ // 限流:可重试
+ return ForwardRetryable, fmt.Errorf("限流 (429 Too Many Requests)")
+
+ case status >= 500:
+ // 服务端错误:可重试
+ return ForwardRetryable, fmt.Errorf("服务端错误 (%d)", status)
+
+ case status >= 400 && status < 500:
+ // 客户端错误(400、401、403 等):不可重试,直接返回给用户
+ prs.writeResponseToClient(c, status, resp.Header(), respBody)
+ return ForwardNonRetryable, fmt.Errorf("客户端错误 (%d)", status)
+
+ default:
+ return ForwardRetryable, fmt.Errorf("未知状态码 (%d)", status)
+ }
+}
+
+// writeResponseToClient 写入响应到客户端
+func (prs *ProviderRelayService) writeResponseToClient(c *gin.Context, status int, headers http.Header, body []byte) {
+ // 复制响应头
+ for key, values := range headers {
+ for _, value := range values {
+ c.Header(key, value)
+ }
+ }
+ // 写入状态码和响应体
+ contentType := headers.Get("Content-Type")
+ if contentType == "" {
+ contentType = "application/json"
+ }
+ c.Data(status, contentType, body)
+}
+
+// forwardRequestStream 流式请求处理(必须直接写入)
+func (prs *ProviderRelayService) forwardRequestStream(
+ c *gin.Context,
+ kind string,
+ provider Provider,
+ endpoint string,
+ query map[string]string,
+ clientHeaders map[string]string,
+ bodyBytes []byte,
+ model string,
+) (ForwardResult, error) {
+ // 流式请求无法使用 ResponseRecorder,直接调用原有逻辑
+ // 但流式请求一旦开始写入就无法回滚,所以只尝试一次
+ targetURL := joinURL(provider.APIURL, endpoint)
+ headers := cloneMap(clientHeaders)
+ headers["Authorization"] = fmt.Sprintf("Bearer %s", provider.APIKey)
+ if _, ok := headers["Accept"]; !ok {
+ headers["Accept"] = "application/json"
+ }
+
+ requestLog := &ReqeustLog{
+ Platform: kind,
+ Provider: provider.Name,
+ Model: model,
+ IsStream: true,
+ }
+ start := time.Now()
+ defer func() {
+ requestLog.DurationSec = time.Since(start).Seconds()
+ prs.writeRequestLog(requestLog)
+ }()
+
+ req := xrequest.New().
+ SetHeaders(headers).
+ SetQueryParams(query).
+ SetRetry(1, 500*time.Millisecond).
+ SetTimeout(3 * time.Hour)
+
+ reqBody := bytes.NewReader(bodyBytes)
+ req = req.SetBody(reqBody)
+
+ resp, err := req.Post(targetURL)
+ if err != nil {
+ return ForwardRetryable, err
+ }
+
+ if resp == nil {
+ return ForwardRetryable, fmt.Errorf("empty response")
+ }
+
+ status := resp.StatusCode()
+ requestLog.HttpCode = status
+
+ if resp.Error() != nil {
+ return ForwardRetryable, resp.Error()
+ }
+
+ // 流式响应:判断状态码后直接写入
+ if status >= 200 && status < 300 || status == 0 {
+ _, copyErr := resp.ToHttpResponseWriter(c.Writer, ReqeustLogHook(c, kind, requestLog))
+ if copyErr != nil {
+ fmt.Printf("[WARN] 流式复制响应失败: %v\n", copyErr)
+ }
+ return ForwardSuccess, nil
+ }
+
+ if status >= 400 && status < 500 {
+ _, copyErr := resp.ToHttpResponseWriter(c.Writer, ReqeustLogHook(c, kind, requestLog))
+ if copyErr != nil {
+ fmt.Printf("[WARN] 流式复制响应失败: %v\n", copyErr)
+ }
+ return ForwardNonRetryable, fmt.Errorf("客户端错误 (%d)", status)
+ }
+
+ return ForwardRetryable, fmt.Errorf("upstream status %d", status)
+}
+
+// writeRequestLog 写入请求日志
+func (prs *ProviderRelayService) writeRequestLog(requestLog *ReqeustLog) {
+ // 【修复】防御性判空
+ if GlobalDBQueueLogs == nil {
+ fmt.Printf("[WARN] 数据库队列未初始化,跳过日志写入\n")
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ err := GlobalDBQueueLogs.ExecBatchCtx(ctx, `
+ INSERT INTO request_log (
+ platform, model, provider, http_code,
+ input_tokens, output_tokens, cache_create_tokens, cache_read_tokens,
+ reasoning_tokens, is_stream, duration_sec
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `,
+ requestLog.Platform,
+ requestLog.Model,
+ requestLog.Provider,
+ requestLog.HttpCode,
+ requestLog.InputTokens,
+ requestLog.OutputTokens,
+ requestLog.CacheCreateTokens,
+ requestLog.CacheReadTokens,
+ requestLog.ReasoningTokens,
+ boolToInt(requestLog.IsStream),
+ requestLog.DurationSec,
+ )
+
+ if err != nil {
+ fmt.Printf("写入 request_log 失败: %v\n", err)
+ }
+}
+```
+
+---
+
+### 3.3 区分可重试错误
+
+**问题位置**:`services/providerrelay.go:422-431`
+
+**问题描述**:
+- 所有非 2xx 响应都返回 `false`,触发 `RecordFailure`
+- 400(参数错误)、401(密钥错误)、429(限流)不应拉黑 provider
+
+**修复方案**:已在 3.2 中通过 `ForwardResult` 类型和 `switch` 语句实现。
+
+---
+
+### 3.4 黑名单"假恢复"修复
+
+**问题位置**:`services/blacklistservice.go:617-620`
+
+**问题描述**:
+- `AutoRecoverExpired()` 只重置 `auto_recovered=1, failure_count=0`
+- 未重置 `blacklist_level`,导致下次失败时等级累加
+- Provider 会永久陷入 L5(24 小时拉黑)
+
+**修复方案**:
+
+```go
+// services/blacklistservice.go - AutoRecoverExpired 函数
+
+func (bs *BlacklistService) AutoRecoverExpired() error {
+ db, err := xdb.DB("default")
+ if err != nil {
+ return fmt.Errorf("获取数据库连接失败: %w", err)
+ }
+
+ // ... 查询代码不变 ...
+
+ // 【修复】批量更新时同时清零 blacklist_level 和记录恢复时间
+ for _, item := range toRecover {
+ err := GlobalDBQueue.Exec(`
+ UPDATE provider_blacklist
+ SET auto_recovered = 1,
+ failure_count = 0,
+ blacklist_level = 0, -- 【修复】清零等级
+ last_recovered_at = ?, -- 【修复】记录恢复时间
+ last_degrade_hour = 0 -- 【修复】重置降级计时
+ WHERE platform = ? AND provider_name = ?
+ `, time.Now(), item.Platform, item.ProviderName)
+
+ if err != nil {
+ failed = append(failed, fmt.Sprintf("%s/%s", item.Platform, item.ProviderName))
+ log.Printf("⚠️ 标记恢复状态失败: %s/%s - %v", item.Platform, item.ProviderName, err)
+ } else {
+ recovered = append(recovered, fmt.Sprintf("%s/%s", item.Platform, item.ProviderName))
+ }
+ }
+
+ if len(recovered) > 0 {
+ log.Printf("✅ 自动恢复 %d 个过期拉黑(等级已清零): %v", len(recovered), recovered)
+ }
+
+ // ...
+}
+```
+
+**验证方法**:
+```sql
+-- 模拟一个 L3 拉黑的 provider
+UPDATE provider_blacklist
+SET blacklist_level = 3,
+ blacklisted_until = datetime('now', '-1 minute')
+WHERE provider_name = 'test-provider';
+
+-- 触发自动恢复
+-- 等待 1 分钟或手动调用 AutoRecoverExpired()
+
+-- 检查结果
+SELECT blacklist_level, auto_recovered, last_recovered_at
+FROM provider_blacklist
+WHERE provider_name = 'test-provider';
+
+-- 预期:blacklist_level = 0, auto_recovered = 1, last_recovered_at 有值
+```
+
+---
+
+### 3.5 HTTP 连接泄漏修复
+
+**问题位置**:`services/speedtestservice.go:94-95`
+
+**问题描述**:热身请求的响应体未关闭,批量测速时会耗尽连接池。
+
+**修复方案**(已在 2.3 节包含):
+
+```go
+// 热身请求(必须关闭响应体!)
+if resp, _ := s.makeRequest(client, parsedURL.String()); resp != nil {
+ io.Copy(io.Discard, resp.Body) // 消费响应体
+ resp.Body.Close() // 关闭连接
+}
+```
+
+---
+
+## 阶段四:功能完善
+
+### 4.1 Windows 自启动路径引号
+
+**问题位置**:`services/autostartservice.go:67-78`
+
+**问题描述**:路径未加引号,`C:\Program Files\` 等带空格的路径会被截断。
+
+**修复方案**:
+
+```go
+// services/autostartservice.go
+
+func (as *AutoStartService) enableWindows() error {
+ exePath, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("failed to get executable path: %w", err)
+ }
+
+ key := `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`
+
+ // 【修复】路径需要加引号,处理空格
+ quotedPath := fmt.Sprintf(`"%s"`, exePath)
+
+ cmd := exec.Command("reg", "add", key, "/v", "CodeSwitch", "/t", "REG_SZ", "/d", quotedPath, "/f")
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to add registry key: %w", err)
+ }
+ return nil
+}
+```
+
+**验证方法**:
+```powershell
+# 启用自启动后检查注册表
+reg query "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v CodeSwitch
+# 预期:路径带有引号,如 "C:\Program Files\CodeSwitch\CodeSwitch.exe"
+```
+
+---
+
+### 4.2 日志写入判空保护(含 Gemini)
+
+**问题位置**:`services/providerrelay.go:352-381` 和 `684-706`
+
+**问题描述**:如果 `GlobalDBQueueLogs` 为 nil(数据库初始化失败),调用 `ExecBatchCtx` 会 panic。
+
+**修复方案**(已在 3.2 中通过 `writeRequestLog` 函数统一处理):
+
+```go
+// forwardRequest 的 defer 函数
+defer func() {
+ requestLog.DurationSec = time.Since(start).Seconds()
+
+ // 【修复】防御性判空
+ if GlobalDBQueueLogs == nil {
+ fmt.Printf("[WARN] 数据库队列未初始化,跳过日志写入\n")
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ err := GlobalDBQueueLogs.ExecBatchCtx(ctx, `
+ INSERT INTO request_log (...)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `, ...)
+
+ if err != nil {
+ fmt.Printf("写入 request_log 失败: %v\n", err)
+ }
+}()
+```
+
+**Gemini 路径同步修复**(`services/providerrelay.go:684-706`):
+
+```go
+// geminiProxyHandler 中的 defer 函数
+defer func() {
+ requestLog.DurationSec = time.Since(start).Seconds()
+
+ // 【修复】同步添加判空保护
+ if GlobalDBQueueLogs == nil {
+ fmt.Printf("[WARN] 数据库队列未初始化,跳过 Gemini 日志写入\n")
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ err := GlobalDBQueueLogs.ExecBatchCtx(ctx, `
+ INSERT INTO request_log (...)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `, ...)
+
+ if err != nil {
+ fmt.Printf("[Gemini] 写入 request_log 失败: %v\n", err)
+ }
+}()
+```
+
+---
+
+### 4.3 等级拉黑配置持久化
+
+**问题位置**:`services/blacklist_level_config.go:33-35` 和 `services/settingsservice.go:47-49`
+
+**问题描述**:
+- 配置文件不存在时返回默认配置(`EnableLevelBlacklist=false`)
+- 前端切换开关后未调用 `SaveBlacklistLevelConfig` 落盘
+- 每次启动都回退到固定模式
+
+**修复方案**:
+
+```go
+// services/blacklist_level_config.go
+
+func (ss *SettingsService) GetBlacklistLevelConfig() (*BlacklistLevelConfig, error) {
+ configPath, err := GetBlacklistLevelConfigPath()
+ if err != nil {
+ return nil, err
+ }
+
+ // 如果文件不存在,创建默认配置并保存
+ if _, err := os.Stat(configPath); os.IsNotExist(err) {
+ defaultConfig := DefaultBlacklistLevelConfig()
+
+ // 【修复】自动创建默认配置文件
+ if err := ss.SaveBlacklistLevelConfig(defaultConfig); err != nil {
+ fmt.Printf("[WARN] 创建默认配置文件失败: %v\n", err)
+ } else {
+ fmt.Printf("✅ 已创建默认等级拉黑配置: %s\n", configPath)
+ }
+
+ return defaultConfig, nil
+ }
+
+ // ... 原有读取逻辑
+}
+```
+
+---
+
+### 4.4 可观测性增强
+
+**修复方案**:
+
+```go
+// main.go - 启动时打印配置状态
+
+func main() {
+ // ... 初始化代码 ...
+
+ // 【增强】打印启动配置摘要
+ printStartupSummary(settingsService, blacklistService)
+
+ // ...
+}
+
+func printStartupSummary(ss *services.SettingsService, bs *services.BlacklistService) {
+ fmt.Println("========== Code-Switch 启动配置 ==========")
+
+ // 1. 拉黑功能状态
+ if ss.IsBlacklistEnabled() {
+ fmt.Println("🔒 拉黑功能: 启用")
+ } else {
+ fmt.Println("🔓 拉黑功能: 禁用")
+ }
+
+ // 2. 等级拉黑配置
+ levelConfig, err := ss.GetBlacklistLevelConfig()
+ if err != nil {
+ fmt.Printf("⚠️ 等级拉黑配置: 读取失败 (%v)\n", err)
+ } else if levelConfig.EnableLevelBlacklist {
+ fmt.Printf("📊 等级拉黑: 启用 (L1=%dm, L2=%dm, L3=%dm, L4=%dm, L5=%dm)\n",
+ levelConfig.L1DurationMinutes, levelConfig.L2DurationMinutes,
+ levelConfig.L3DurationMinutes, levelConfig.L4DurationMinutes,
+ levelConfig.L5DurationMinutes)
+ } else {
+ fmt.Printf("📊 等级拉黑: 禁用 (使用固定模式: %dm)\n", levelConfig.FallbackDurationMinutes)
+ }
+
+ // 3. 数据库队列状态
+ stats1 := services.GetGlobalDBQueueStats()
+ stats2 := services.GetGlobalDBQueueLogsStats()
+ fmt.Printf("📝 写入队列: 单次队列长度=%d, 批量队列长度=%d\n", stats1.QueueLength, stats2.BatchQueueLength)
+
+ fmt.Println("==========================================")
+}
+
+// services/dbqueue.go - 定期打印队列健康状态(可选)
+
+func startQueueHealthMonitor() {
+ ticker := time.NewTicker(5 * time.Minute)
+ go func() {
+ for range ticker.C {
+ stats1 := GetGlobalDBQueueStats()
+ stats2 := GetGlobalDBQueueLogsStats()
+
+ // 只在队列积压时打印警告
+ if stats1.QueueLength > 100 || stats2.BatchQueueLength > 100 {
+ log.Printf("⚠️ 队列积压警告: 单次=%d, 批量=%d", stats1.QueueLength, stats2.BatchQueueLength)
+ }
+ }
+ }()
+}
+```
+
+---
+
+## 测试验证计划
+
+### 阻断级测试(阶段一)
+
+| 测试项 | 测试步骤 | 预期结果 |
+|--------|----------|----------|
+| 首次启动 | 删除 `~/.code-switch/` 后启动 | 自动创建目录和数据库 |
+| 数据库初始化 | 启动并检查日志 | 无 "xdb.DB" 相关错误 |
+| 配置路径 | 修改设置后检查文件路径 | 保存到 `~/.code-switch/app.json` |
+| 配置迁移 | 创建旧目录后启动 | 自动迁移、创建标记、删除旧目录 |
+
+### 安全测试(阶段二)
+
+| 测试项 | 测试步骤 | 预期结果 |
+|--------|----------|----------|
+| 本地访问 | `curl http://127.0.0.1:18100/v1/messages` | 正常响应 |
+| 远程访问 | 从其他设备访问 | 连接拒绝或 403 |
+| 反代检测 | 带 X-Forwarded-For 头访问 | 403 Forbidden |
+| SSRF - 内网 | 测速 `http://192.168.1.1` | 返回错误 |
+| SSRF - file:// | 测速 `file:///etc/passwd` | 返回错误 |
+| SSRF - DNS重绑定 | 使用重绑定测试域名 | 在连接时拒绝 |
+| 更新确认 | 下载更新后检查确认界面 | 显示版本、大小、SHA256 |
+| 更新回滚 | 更新后调用回滚 | 恢复到上一版本 |
+
+### 核心逻辑测试(阶段三)
+
+| 测试项 | 测试步骤 | 预期结果 |
+|--------|----------|----------|
+| 故障切换 | 禁用 Level 1 provider | 自动降级到 Level 2 |
+| 4xx 不拉黑 | 使用错误 API Key | 返回 401,不触发拉黑 |
+| 5xx 拉黑 | 触发服务端错误 | 记录失败,尝试下一个 |
+| Context 隔离 | 连续失败多个 provider | 无 "headers already sent" 错误 |
+| 恢复等级清零 | 等待拉黑过期 | `blacklist_level = 0` |
+
+---
+
+## 回滚策略
+
+### 配置文件备份
+
+所有配置文件修改前自动备份:
+
+```go
+func backupConfig(path string) error {
+ backupPath := path + ".bak." + time.Now().Format("20060102150405")
+ return copyFile(path, backupPath)
+}
+```
+
+### 数据库回滚
+
+```sql
+-- 回滚 provider_blacklist 表结构变更
+ALTER TABLE provider_blacklist DROP COLUMN last_degrade_hour;
+-- (如果有新增字段的话)
+```
+
+### 代码回滚
+
+每个阶段完成后创建 Git tag:
+
+```bash
+git tag -a v1.1.17-fix-stage1 -m "阶段一:阻断级问题修复"
+git tag -a v1.1.17-fix-stage2 -m "阶段二:安全漏洞修复"
+git tag -a v1.1.17-fix-stage3 -m "阶段三:核心逻辑修复"
+git tag -a v1.1.17-fix-stage4 -m "阶段四:功能完善修复"
+```
+
+---
+
+## 风险降级总结
+
+| 问题 | 修复前风险 | 修复后风险 |
+|------|-----------|-----------|
+| 数据库初始化时序 | 阻断级 | 无 |
+| SQLite 父目录 | 阻断级 | 无 |
+| SuiStore 错误处理 | 阻断级 | 无 |
+| 配置目录拼写 | 高 | 无 |
+| 代理监听 0.0.0.0 | 灾难级 | 无 |
+| 代理反代绕过 | 严重 | 低 |
+| SSRF 攻击 | 严重 | 低 |
+| DNS 重绑定 | 高 | 低 |
+| 自动更新安全 | 严重 | 低 |
+| 技能仓库投毒 | 中 | 低 |
+| 故障切换缺失 | 高 | 无 |
+| Context 重复写 | 高 | 无 |
+| 错误分类缺失 | 高 | 无 |
+| 黑名单假恢复 | 高 | 无 |
+| HTTP 连接泄漏 | 中 | 无 |
+| Windows 自启动引号 | 中 | 无 |
+| 日志写入判空 | 中 | 无 |
+| 等级拉黑持久化 | 中 | 无 |
+| 配置迁移不完整 | 低 | 无 |
+| 可观测性不足 | 低 | 无 |
+
+---
+
+## 实施建议
+
+**建议执行顺序**:阶段一 → 阶段二 → 阶段三 → 阶段四
+
+**每阶段完成后**:
+1. 运行对应的测试用例
+2. 创建 Git tag
+3. 部署到测试环境验证
+4. 确认无回归后进入下一阶段
+
+---
+
+## 落地检查清单(发布前必检)
+
+### 自动更新链路验证
+
+- [ ] **Windows 端到端测试**
+ - [ ] 下载新版本 → SHA256 校验通过
+ - [ ] `backupCurrentVersion()` 成功备份到 `~/.code-switch/backups/`
+ - [ ] 确认对话框正确显示版本/大小/哈希信息
+ - [ ] `ConfirmAndApplyUpdate(true)` 启动安装器
+ - [ ] 安装失败时 `RollbackUpdate()` 能恢复旧版本
+ - [ ] `.pending-update` 标记正确清理
+
+- [ ] **macOS 禁用验证**
+ - [ ] `applyUpdateDarwinWithConfirm()` 返回错误
+ - [ ] 前端收到错误后显示"请手动下载"提示
+ - [ ] 提示中包含正确的下载链接
+
+- [ ] **Linux 验证**(如适用)
+ - [ ] 便携版替换流程正常
+ - [ ] 备份/回滚机制生效
+
+### 代理配置初始化验证
+
+- [ ] **main.go 初始化顺序**
+ ```go
+ // 确认顺序:
+ initProxyConfig() // ① 必须最先
+ services.InitDatabase() // ②
+ services.InitGlobalDBQueue()// ③
+ // ... 然后才能创建服务
+ ```
+
+- [ ] **MigrateToV14 执行时机**
+ - [ ] 在 `initProxyConfig()` 内部或之前调用
+ - [ ] 老用户首次升级时自动创建 `proxy-config.json`
+ - [ ] 默认 `AllowUnauthenticated=true`(避免 breaking)
+ - [ ] 日志输出迁移提示
+
+- [ ] **_internal 端点测试**
+ - [ ] `curl http://127.0.0.1:18100/_internal/auth-token` 返回 Token
+ - [ ] 外部 IP 访问 `/_internal/*` 返回 403
+
+### 发布说明必须包含
+
+- [ ] 流式请求不再支持 Provider 回退(技术限制)
+- [ ] 新增代理认证机制,老用户默认兼容模式
+- [ ] 建议生产环境设置 `AllowUnauthenticated=false`
+- [ ] macOS 自动更新暂不可用,需手动下载
+
+---
+
+(文档结束)
diff --git a/doc/issues-2025-11-28.md b/doc/issues-2025-11-28.md
new file mode 100644
index 0000000..ecf2d8c
--- /dev/null
+++ b/doc/issues-2025-11-28.md
@@ -0,0 +1,392 @@
+# 当前项目已知问题清单(2025-11-28)
+
+> 目的:集中记录目前已发现的阻断/高风险问题,便于后续排查与修复。
+
+## 阻断级(应用无法正常启动)
+
+### 🚫 数据库初始化时序错误 —— 启动即崩溃
+- **位置**:`main.go:72-74` 和 `services/dbqueue.go:26-30`
+- **现状**:
+ - `InitGlobalDBQueue()` 在第 72 行调用 `xdb.DB("default")`
+ - 但 `xdb.Inits()` 在 `NewProviderRelayService()` 内部(`providerrelay.go:39-66`)才执行
+ - `NewProviderRelayService()` 在第 81 行才构造,晚于 `InitGlobalDBQueue()`
+ - 结果:`xdb.DB("default")` 返回错误,`log.Fatalf` 终止进程
+- **同样受影响**:
+ - `NewSettingsService()` 构造器调用 `ensureBlacklistTables()`(`settingsservice.go:67-71`),也依赖 `xdb.DB("default")`
+ - 在第 78 行调用,同样早于数据库初始化
+- **修复方向**:将 `xdb.Inits()` 移到 `main()` 入口最前面,在任何服务构造之前完成
+
+### 💥 SQLite 父目录未创建 —— 首次启动崩溃
+- **位置**:`services/providerrelay.go:37-43`
+- **现状**:
+ ```go
+ home, _ := os.UserHomeDir()
+
+ if err := xdb.Inits([]xdb.Config{
+ {
+ DSN: filepath.Join(home, ".code-switch", "app.db?..."),
+ },
+ }); err != nil {
+ ```
+ - SQLite 不会自动创建父目录 `~/.code-switch/`
+ - 首次启动时如果目录不存在,`xdb.Inits()` 会因父目录缺失而失败
+ - 触发阻断级问题 1 的启动崩溃
+- **修复方向**:
+ ```go
+ configDir := filepath.Join(home, ".code-switch")
+ os.MkdirAll(configDir, 0755) // ← 在 xdb.Inits() 之前创建
+ ```
+
+### ⚠️ SuiStore 错误被吞掉,留下空指针隐患
+- **位置**:`main.go:66-70`
+- **现状**:
+ ```go
+ suiService, errt := services.NewSuiStore()
+ if errt != nil {
+ // 处理错误,比如日志或退出
+ } // ← 错误被完全忽略!
+ ```
+ - `suiService` 可能为 nil 或内部状态不完整
+ - 仍被注册到 Wails:`application.NewService(suiService)`
+ - 前端调用其方法时会 panic
+- **修复方向**:失败时直接 `log.Fatalf` 退出,或不注册该服务
+
+### `go test ./...` 全面失败
+- `main.go` 中 `//go:embed all:frontend/dist` 找不到构建产物(未生成 `frontend/dist`),导致主包编译失败
+- `scripts/` 下多个 `package main`,重复定义 `main/Provider/Config`,在测试时一起编译触发 "redeclared" 错误。应加 `//go:build ignore` 或迁移出模块
+- `services/geminiservice_test.go` 构造函数调用缺少必须的地址参数(`NewGeminiService(string)`),当前签名不匹配
+
+## 高优先级(安全漏洞)
+
+### 🔥 代理服务监听 0.0.0.0 无鉴权 —— API Key 全网暴露
+- **位置**:`services/providerrelay.go:32-35, 89-91`
+- **现状**:
+ ```go
+ // 构造函数默认值
+ if addr == "" {
+ addr = ":18100" // ← 监听 0.0.0.0:18100
+ }
+
+ // Start() 启动服务器
+ prs.server = &http.Server{
+ Addr: prs.addr, // ← ":18100" = 所有网卡
+ Handler: router,
+ }
+ ```
+- **安全风险**:🔴 **灾难级**
+ - 同一网段任何设备都可访问 `http://<你的IP>:18100/v1/messages`
+ - 直接借用本地保存的 Provider API Key 发起请求
+ - **相当于把你所有 Claude/Codex 的 API 密钥公开到局域网!**
+ - 在公司网络、公共 WiFi 下使用 = 密钥泄露
+- **修复方向**:
+ 1. 改为仅监听 `127.0.0.1:18100`
+ 2. 或添加 Bearer Token 鉴权(从本地配置读取)
+ 3. 或添加请求来源校验(只允许 localhost)
+
+### 🔐 SSRF 攻击面 —— 桌面端暴露严重安全漏洞
+- **位置**:`services/speedtestservice.go:45-120`
+- **现状**:
+ ```go
+ // 验证 URL(仅检查格式)
+ parsedURL, err := url.Parse(trimmed)
+ if err != nil {
+ return ...
+ }
+
+ // 无任何过滤,直接请求!
+ _, _ = s.makeRequest(client, parsedURL.String())
+ ```
+ - **没有任何协议、IP、端口白名单限制**
+ - 任何 URL 都会被应用主动请求
+- **攻击向量**:
+ - ✅ `file:///etc/passwd` → 读取本地文件
+ - ✅ `http://127.0.0.1:22` → 内网端口扫描
+ - ✅ `http://192.168.1.1/admin` → 探测内网设备
+ - ✅ `http://169.254.169.254/latest/meta-data/` → 云服务器元数据泄露(AWS IMDS)
+ - ✅ `gopher://localhost:6379/...` → 可能的协议走私(需验证 Go 支持)
+- **安全等级**:🔴 **严重**(桌面应用暴露 SSRF,可被钓鱼网站利用)
+- **修复方向**:
+ 1. 协议白名单:只允许 `http://` 和 `https://`
+ 2. IP 黑名单:拦截内网地址(`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`)
+ 3. 端口白名单:只允许 80/443
+ 4. 域名白名单(可选):限制仅测速已知的 API 端点
+
+### 🛡️ 自动更新存在安全与用户体验风险
+- **位置**:`services/updateservice.go`
+- **现状**:
+ - `UpdateInfo.SHA256` 字段存在(第 29 行),`calculateSHA256()` 函数存在(第 815 行)
+ - **但下载后从未调用校验!** 直接执行/替换
+ - Windows 分支直接静默执行下载器
+ - 便携版/Linux 直接替换当前可执行并重启
+ - 缺乏用户确认与回滚路径
+ - macOS 分支(第 494 行起)仍是 TODO,功能缺失但入口已暴露
+- **安全风险**:
+ - 中间人攻击可篡改下载内容
+ - 损坏的安装包无法回滚
+- **修复方向**:
+ 1. 下载后调用 `calculateSHA256()` 与 `UpdateInfo.SHA256` 比对
+ 2. 校验失败则删除文件、记录日志、通知用户
+ 3. 添加用户确认对话框
+ 4. 备份当前版本用于回滚
+
+## 高优先级(逻辑错误)
+
+### 🔴 配置目录拼写错误 —— 设置永不生效
+- **位置**:`services/appsettings.go:10-11`
+- **现状**:
+ ```go
+ const (
+ appSettingsDir = ".codex-swtich" // ← 拼写错误!swtich != switch
+ appSettingsFile = "app.json"
+ )
+ ```
+- **影响**:
+ - 用户设置保存到 `~/.codex-swtich/app.json`(错误路径)
+ - 其他服务期望从 `~/.code-switch/` 读取(正确路径)
+ - **每次启动都读不到配置,回退到默认值**
+ - 自启动、自动更新、热力图显示等设置永远不生效
+ - 用户困惑"为什么我的设置不保存"
+- **安全等级**:🟠 **低级但致命**(一个字母拼错导致功能完全失效)
+- **修复方向**:
+ ```go
+ const (
+ appSettingsDir = ".code-switch" // ← 修正拼写
+ appSettingsFile = "app.json"
+ )
+ ```
+
+### 🟥 请求转发缺乏故障切换 —— 单点失败与误拉黑
+- **位置**:`services/providerrelay.go:262-323`
+- **现状**:
+ ```go
+ // 只取第一个 Level 的第一个 provider
+ firstLevel := levels[0]
+ firstProvider := levelGroups[firstLevel][0]
+
+ // 只尝试这一个,失败直接返回 502
+ ok, err := prs.forwardRequest(...)
+ if !ok {
+ c.JSON(http.StatusBadGateway, ...) // 没有尝试其他 provider!
+ }
+ ```
+- **影响**:
+ - 与 `CLAUDE.md` 文档描述的"Level 内按顺序尝试,失败后降级到下一 Level"严重不符
+ - 单个 provider 故障直接导致 502,完全不尝试同级或下级备选
+ - 误拉黑概率高:网络抖动也会触发 `RecordFailure`
+- **修复方向**:
+ 1. 遍历 `levelGroups[level]` 的所有 provider,失败后继续尝试
+ 2. 当前 Level 全失败后,降到下一 Level
+ 3. 每次尝试前重置 `c.Request.Body`(使用缓存的 `bodyBytes`)
+ 4. 设置最大尝试次数或整体超时
+
+### 🟣 非 2xx 响应一律拉黑 —— 健康节点被误杀
+- **位置**:`services/providerrelay.go:422-431, 313-316`
+- **现状**:
+ ```go
+ // forwardRequest 返回值判断
+ if status >= 200 && status < 300 {
+ return true, nil // 只有 2xx 算成功
+ }
+ return false, fmt.Errorf("upstream status %d", status) // 其他全部失败
+
+ // proxyHandler 处理失败
+ if !ok {
+ prs.blacklistService.RecordFailure(kind, firstProvider.Name) // ← 全部拉黑!
+ }
+ ```
+- **被误拉黑的状态码**:
+ - **400 Bad Request** → 客户端参数错误,不是 provider 问题
+ - **401 Unauthorized** → 用户配置的 API Key 错误
+ - **429 Too Many Requests** → 限流,很快会恢复
+ - **403 Forbidden** → 权限问题,可能是账户配额问题
+- **影响**:
+ - 健康的 provider 因客户端错误被误拉黑
+ - 限流后 provider 被拉黑,用户只能等待拉黑过期
+ - 可用性急剧下降
+- **修复方向**:
+ 1. 区分"可重试错误"和"不可重试错误"
+ 2. 只对 5xx(服务端错误)和网络超时记录失败
+ 3. 4xx 错误直接返回给客户端,不计入黑名单
+ 4. 429 可以单独处理:短暂等待后重试下一个 provider
+
+### 🔴 黑名单自动恢复"假恢复" —— 等级永久累加
+- **位置**:`services/blacklistservice.go:617-620` 和 `210, 255, 278`
+- **现状**:
+ ```go
+ // 恢复逻辑(仅重置部分字段)
+ UPDATE provider_blacklist
+ SET auto_recovered = 1, failure_count = 0 // ← 只重置这两个!
+ WHERE platform = ? AND provider_name = ?
+
+ // 下次失败时的等级计算
+ SELECT ... blacklist_level ... // ← 读取旧等级(例如 L3)
+ newLevel := blacklistLevel // ← 3
+ levelIncrease := 1 或 2
+ newLevel = blacklistLevel + levelIncrease // ← 3 + 1 = 4
+ ```
+- **问题**:
+ - Provider 被拉黑到 L3 后,`AutoRecoverExpired()` 恢复时**不清零 `blacklist_level`**
+ - 下次失败时在旧等级基础上累加:`L3 → L4 → L5`
+ - 等级永远只升不降,最终锁定在 L5(24 小时拉黑)
+ - **"假恢复"**:虽然 `auto_recovered=1`,但等级未归零,provider 长期被高等级惩罚
+- **实际影响**:
+ - 偶尔不稳定的 provider 会永久陷入 L5
+ - 用户困惑"为什么某个 provider 总是被拉黑很久"
+- **修复方向**:
+ ```go
+ UPDATE provider_blacklist
+ SET auto_recovered = 1,
+ failure_count = 0,
+ blacklist_level = 0, // ← 清零等级!
+ last_recovered_at = ? // ← 记录恢复时间!
+ WHERE platform = ? AND provider_name = ?
+ ```
+
+## 高优先级(功能缺陷)
+
+### 🟧 日志写入可能直接 panic
+- **位置**:`services/providerrelay.go:352-381` 和 `684-706`
+- **现状**:
+ ```go
+ defer func() {
+ err := GlobalDBQueueLogs.ExecBatchCtx(ctx, ...) // 无判空!
+ }()
+ ```
+ - 如果数据库初始化时序问题(阻断级问题 1)导致 `GlobalDBQueueLogs` 为 nil
+ - 第一次请求时直接 panic
+- **修复方向**:
+ 1. 优先修复阻断级问题 1
+ 2. 防御性编程:写入前判空 `if GlobalDBQueueLogs != nil`
+
+### 🟨 HTTP 连接泄漏
+- **位置**:`services/speedtestservice.go:94-95`
+- **现状**:
+ ```go
+ // 热身请求(忽略结果,用于建立连接)
+ _, _ = s.makeRequest(client, parsedURL.String()) // 响应体未关闭!
+ ```
+ - `makeRequest` 返回 `*http.Response`
+ - 热身请求的响应体从未被 `Close()`
+ - 批量并发测速时会耗尽连接池/文件句柄
+- **修复方向**:
+ ```go
+ if resp, _ := s.makeRequest(client, parsedURL.String()); resp != nil {
+ io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+ }
+ ```
+
+### 🟦 Windows 自启动路径未加引号 —— Program Files 下失效
+- **位置**:`services/autostartservice.go:67-78`
+- **现状**:
+ ```go
+ func (as *AutoStartService) enableWindows() error {
+ exePath, err := os.Executable()
+ // ...
+ cmd := exec.Command("reg", "add", key, "/v", "CodeSwitch", "/t", "REG_SZ", "/d", exePath, "/f")
+ // ↑ 未加引号!
+ }
+ ```
+- **影响**:
+ - 如果安装在 `C:\Program Files\CodeSwitch\CodeSwitch.exe`
+ - 注册表写入 `C:\Program`(在空格处被截断)
+ - 自启动时系统尝试运行 `C:\Program`,失败
+ - 用户启用自启动但实际不生效
+- **修复方向**:
+ ```go
+ cmd := exec.Command("reg", "add", key, "/v", "CodeSwitch", "/t", "REG_SZ", "/d", "\""+exePath+"\"", "/f")
+ ```
+
+### 等级拉黑开关始终失效(固定模式)
+- **位置**:`services/blacklist_level_config.go:33-35` 和 `services/settingsservice.go:47-49`
+- **现状**:
+ - `~/.code-switch/blacklist-config.json` 不存在时返回默认配置
+ - 默认 `enableLevelBlacklist=false`,永远走 `recordFailureFixedMode`
+ - 前端切换开关未调用 `UpdateBlacklistLevelConfig` 落盘
+ - 缺少后端兜底写默认配置
+- **详细方案**:见 `doc/fix-blacklist-level-config-persistence.md`
+
+### 批量队列与文档不一致的风险
+- 代码当前启用了双队列(单次 + request_log 批量),而最新文档曾改为默认禁用批量
+- 需明确实际期望:继续批量则同步文档;若禁用批量则调整 `InitGlobalDBQueue`
+
+### 可观测性不足
+- 启动未打印黑名单配置来源/开关状态
+- 队列运行健康度缺少持续日志,排障困难
+- 建议启动日志打印配置来源与状态,定期输出队列长度/失败率
+
+### SpeedTest 端点添加后无法持久化
+- `frontend/src/components/SpeedTest/Index.vue` 仅在内存维护 `endpoints` 列表
+- 初始硬编码 2 个 URL;新增/删除不写入后端或本地存储,刷新即丢失
+- 需要决定数据源(本地存储/后端配置)并持久化
+
+### 首页供应商卡片垂直对齐问题
+- 反馈"Claude / Codex / Gemini 几个列整体偏下"
+- 需检查首页卡片的 Flex 布局/行高
+- 初步怀疑 `frontend/src/components/Main/Index.vue` 中 tab/卡片区域的样式需调整
+
+### 黑名单关闭时可能出现死循环/阻塞
+- 复现线索:关闭拉黑(`IsBlacklistEnabled=false`)后仍有调用方循环等待写入结果
+- 疑似某处未提前返回或未遵守"关闭即跳过写入"的分支
+- 需进一步定位调用栈与日志
+
+## 中优先级
+
+### 批量/单队列初始化与文档不一致
+- 代码已创建双队列(单次+批量)
+- 若环境期望关闭批量,需要同步调整 `InitGlobalDBQueue` 与日志说明
+- 避免 request_log 仍走批量路径
+
+### 监控可观测性不足
+- 启动时未打印黑名单配置来源、开关状态
+- 队列状态仅在关闭时输出
+- 建议定期或按请求记录关键指标
+
+## 建议的修复顺序
+
+### 第一优先级(阻断级 - 必须首先修复)
+1. **创建 SQLite 父目录**:在 `xdb.Inits()` 之前调用 `os.MkdirAll(~/.code-switch, 0755)`
+2. **数据库初始化前移**:将 `xdb.Inits()` 移到 `main()` 最前面,在任何服务构造之前完成
+3. **修复错误处理**:`NewSuiStore()` 失败时退出或不注册
+4. **修复拼写错误**:`.codex-swtich` → `.code-switch`
+5. **构建问题**:处理 `scripts/` 冲突、补齐 `frontend/dist` 或添加构建标签
+
+### 第二优先级(安全漏洞 - 紧急修复)
+6. **代理服务仅监听 127.0.0.1**:防止 API Key 在局域网暴露
+7. **SSRF 防护**:添加协议白名单、IP 黑名单、端口白名单
+8. **自动更新安全**:添加 SHA256 校验、用户确认、回滚机制
+
+### 第三优先级(核心功能 - 高优先级)
+9. **请求转发故障切换**:实现遍历 Level、遍历同级 provider 的降级逻辑
+10. **区分可重试错误**:只对 5xx 和超时拉黑,4xx 直接返回
+11. **黑名单假恢复**:`AutoRecoverExpired()` 时清零 `blacklist_level` 和记录 `last_recovered_at`
+12. **HTTP 连接泄漏**:修复 speedtest 热身请求的响应体未关闭
+13. **Windows 自启动引号**:路径包含空格时正确处理
+
+### 第四优先级(用户体验 - 中优先级)
+14. **等级拉黑配置持久化**:实现开关落盘与兜底
+15. **可观测性增强**:启动日志、队列监控
+16. **SpeedTest 端点持久化**
+17. **排查黑名单关闭后死循环**
+
+---
+
+**核心修复路径**(建议按此顺序):
+1. 阻断级(1-5)→ 应用能正常启动
+2. 安全漏洞(6-8)→ 消除 API Key 泄露和 SSRF 风险
+3. 核心功能(9-13)→ 修复降级逻辑和黑名单逻辑
+4. 用户体验(14-17)→ 完善功能和可观测性
+
+---
+
+**问题统计**:
+- 阻断级:4 个
+- 高优先级(安全):3 个
+- 高优先级(逻辑):4 个
+- 高优先级(功能):9 个
+- 中优先级:2 个
+- **总计:22 个问题**
+
+---
+
+(记录时间:2025-11-28,最后更新:2025-11-28 19:00)
diff --git a/doc/linux-support-design.md b/doc/linux-support-design.md
new file mode 100644
index 0000000..6aa5954
--- /dev/null
+++ b/doc/linux-support-design.md
@@ -0,0 +1,967 @@
+# Code-Switch Linux 支持方案(修订版)
+
+**文档版本**: v2.1
+**创建日期**: 2025-12-01
+**修订日期**: 2025-12-01
+**作者**: Half open flowers
+**状态**: 待审核
+
+---
+
+## 一、执行摘要
+
+### 1.1 目标
+
+为 Code-Switch 应用添加完整的 Linux 平台支持,包括:
+1. GitHub Actions 自动构建 Linux 产物
+2. 发布到 GitHub Release 供用户下载
+3. 自动更新支持(带 SHA256 校验)
+
+### 1.2 现状评估(修正)
+
+| 模块 | 完成度 | 说明 |
+|------|--------|------|
+| **Taskfile 构建配置** | 100% | **4 种打包格式**(AppImage/DEB/RPM/AUR) |
+| **nFPM 包配置** | 80% | 版本号硬编码、postremove 未挂载 |
+| **AppImage 脚本** | 100% | 支持双架构 |
+| **后端代码适配** | 85% | 更新机制缺少 SHA256 校验 |
+| **GitHub Actions CI** | 0% | **完全缺失** |
+
+### 1.3 关键修正项
+
+| 问题类型 | 数量 | 详见章节 |
+|---------|------|---------|
+| 阻断级 | 4 | 三、四、五、六 |
+| 高优先级 | 5 | 七 |
+| 中优先级 | 3 | 八 |
+
+---
+
+## 二、技术背景
+
+### 2.1 WebKit2GTK 版本矩阵
+
+**关键发现**:Ubuntu 22.04 **没有** `libwebkit2gtk-4.1-dev`,只有 4.0 版本。
+
+| 发行版 | WebKit 版本 | 包名(开发) | 包名(运行时) | 验证来源 |
+|--------|-------------|-------------|---------------|---------|
+| Ubuntu 22.04 LTS | **4.0** | `libwebkit2gtk-4.0-dev` | `libwebkit2gtk-4.0-37` | 官方仓库 |
+| Ubuntu 24.04 LTS | 4.1 | `libwebkit2gtk-4.1-dev` | `libwebkit2gtk-4.1-0` | 官方仓库 |
+| Debian 12 (Bookworm) | **4.1** | `libwebkit2gtk-4.1-dev` | `libwebkit2gtk-4.1-0` | [packages.debian.org](https://packages.debian.org/bookworm/libwebkit2gtk-4.1-0) (v2.48.5) |
+| Fedora 39/40 | 4.1 | `webkit2gtk4.1-devel` | `webkit2gtk4.1` | 官方仓库 |
+| Arch Linux | 4.1 | `webkit2gtk-4.1` | `webkit2gtk-4.1` | 官方仓库 |
+
+**Debian 12 依赖确认**:经 [Debian 官方包索引](https://packages.debian.org/bookworm/libwebkit2gtk-4.1-0) 验证,Debian 12 Bookworm 默认仓库**同时包含 4.0 和 4.1 版本**,当前 4.1 版本为 `2.48.5-1~deb12u1`。方案中的依赖表正确。
+
+### 2.2 决策:CI 环境选择
+
+**推荐方案**:使用 `ubuntu-24.04`
+
+| 选项 | 优点 | 缺点 |
+|------|------|------|
+| **ubuntu-24.04** (推荐) | WebKit 4.1 原生支持,与目标发行版一致 | 较新 |
+| ubuntu-22.04 + 回退 4.0 | 更稳定的 Runner | 需同步修改 nfpm 依赖表 |
+
+### 2.3 现有文件命名约定
+
+| 文件 | 当前值 | 说明 |
+|------|--------|------|
+| 可执行文件 | `CodeSwitch` | 首字母大写 |
+| 安装路径 | `/usr/local/bin/CodeSwitch` | nfpm.yaml:21 |
+| Desktop Exec | `/usr/local/bin/CodeSwitch %u` | desktop:6 |
+| AppImage | `CodeSwitch.AppImage` | updateservice.go:247 |
+
+**结论**:命名已统一为 `CodeSwitch`(首字母大写),文档错误描述为 `codeswitch`。
+
+---
+
+## 三、阻断级修正 #1:GitHub Actions Linux 构建
+
+### 3.1 新增 build-linux Job
+
+在 `.github/workflows/release.yml` 中添加:
+
+```yaml
+build-linux:
+ name: Build Linux
+ runs-on: ubuntu-24.04 # 关键:使用 24.04 获取 WebKit 4.1
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Install Wails
+ run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
+
+ - name: Install Linux Build Dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ build-essential \
+ pkg-config \
+ libgtk-3-dev \
+ libwebkit2gtk-4.1-dev
+
+ - name: Install frontend dependencies
+ run: cd frontend && npm install
+
+ - name: Update build assets
+ run: wails3 task common:update:build-assets
+
+ - name: Generate bindings
+ run: wails3 task common:generate:bindings
+
+ - name: Build Linux Binary
+ run: wails3 task linux:build
+ env:
+ PRODUCTION: "true"
+
+ - name: Generate Desktop File
+ run: wails3 task linux:generate:dotdesktop
+
+ - name: Create AppImage
+ run: wails3 task linux:create:appimage
+
+ # 设置 nfpm 脚本权限(见修正 7.2)
+ - name: Set nfpm script permissions
+ run: |
+ chmod +x build/linux/nfpm/scripts/postinstall.sh
+ chmod +x build/linux/nfpm/scripts/postremove.sh
+
+ # 注入版本号到 nfpm(见修正 #3)
+ - name: Update nfpm version
+ run: |
+ VERSION=${GITHUB_REF#refs/tags/v}
+ sed -i "s/^version:.*/version: \"$VERSION\"/" build/linux/nfpm/nfpm.yaml
+ echo "Updated nfpm version to: $VERSION"
+
+ - name: Create DEB Package
+ run: wails3 task linux:create:deb
+
+ - name: Create RPM Package
+ run: wails3 task linux:create:rpm
+
+ - name: Generate SHA256 Checksums
+ run: |
+ cd bin
+ sha256sum CodeSwitch.AppImage > CodeSwitch.AppImage.sha256
+ sha256sum codeswitch_*.deb > codeswitch.deb.sha256 2>/dev/null || true
+ sha256sum codeswitch-*.rpm > codeswitch.rpm.sha256 2>/dev/null || true
+ echo "SHA256 checksums:"
+ cat *.sha256
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: linux-amd64
+ path: |
+ bin/CodeSwitch.AppImage
+ bin/CodeSwitch.AppImage.sha256
+ bin/codeswitch_*.deb
+ bin/codeswitch.deb.sha256
+ bin/codeswitch-*.rpm
+ bin/codeswitch.rpm.sha256
+```
+
+### 3.2 关键点说明
+
+| 配置 | 值 | 原因 |
+|------|-----|------|
+| `runs-on` | `ubuntu-24.04` | WebKit 4.1 原生支持 |
+| `libwebkit2gtk-4.1-dev` | 是 | 匹配 24.04 包名 |
+| SHA256 生成 | 是 | 与 Windows 保持一致 |
+
+---
+
+## 四、阻断级修正 #2:发布流程补全
+
+### 4.1 修改 create-release Job
+
+```yaml
+create-release:
+ name: Create Release
+ needs: [build-macos, build-windows, build-linux] # 添加 build-linux
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: artifacts
+
+ - name: Display structure
+ run: ls -R artifacts
+
+ - name: Prepare release assets
+ run: |
+ mkdir -p release-assets
+
+ # macOS
+ cp artifacts/macos-arm64/codeswitch-macos-arm64.zip release-assets/
+ cp artifacts/macos-amd64/codeswitch-macos-amd64.zip release-assets/
+
+ # Windows
+ cp artifacts/windows-amd64/CodeSwitch-amd64-installer.exe release-assets/
+ cp artifacts/windows-amd64/CodeSwitch.exe release-assets/
+ cp artifacts/windows-amd64/CodeSwitch.exe.sha256 release-assets/
+ cp artifacts/windows-amd64/updater.exe release-assets/
+ cp artifacts/windows-amd64/updater.exe.sha256 release-assets/
+
+ # Linux(新增)
+ cp artifacts/linux-amd64/CodeSwitch.AppImage release-assets/
+ cp artifacts/linux-amd64/CodeSwitch.AppImage.sha256 release-assets/
+ cp artifacts/linux-amd64/codeswitch_*.deb release-assets/ 2>/dev/null || true
+ cp artifacts/linux-amd64/codeswitch.deb.sha256 release-assets/ 2>/dev/null || true
+ cp artifacts/linux-amd64/codeswitch-*.rpm release-assets/ 2>/dev/null || true
+ cp artifacts/linux-amd64/codeswitch.rpm.sha256 release-assets/ 2>/dev/null || true
+
+ ls -lh release-assets/
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v1
+ with:
+ files: release-assets/*
+ body: |
+ ## 下载说明
+
+ | 平台 | 文件 | 说明 |
+ |------|------|------|
+ | **Windows (首次)** | `CodeSwitch-amd64-installer.exe` | NSIS 安装器 |
+ | **Windows (便携)** | `CodeSwitch.exe` | 直接运行 |
+ | **Windows (更新)** | `updater.exe` | 静默更新辅助 |
+ | **macOS (ARM)** | `codeswitch-macos-arm64.zip` | Apple Silicon |
+ | **macOS (Intel)** | `codeswitch-macos-amd64.zip` | Intel 芯片 |
+ | **Linux (通用)** | `CodeSwitch.AppImage` | 跨发行版便携 |
+ | **Linux (Debian/Ubuntu)** | `codeswitch_*.deb` | apt 安装 |
+ | **Linux (RHEL/Fedora)** | `codeswitch-*.rpm` | dnf/yum 安装 |
+
+ ## 文件校验
+
+ 所有平台均提供 SHA256 校验文件。
+ draft: false
+ prerelease: false
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+```
+
+---
+
+## 五、阻断级修正 #3:版本号动态注入
+
+### 5.1 问题
+
+`build/linux/nfpm/nfpm.yaml` 第 9 行硬编码:
+```yaml
+version: "0.1.0"
+```
+
+### 5.2 解决方案
+
+**方案 A(推荐)**:CI 中用 `sed` 替换
+
+已在 3.1 节的 "Update nfpm version" 步骤中实现:
+
+```bash
+VERSION=${GITHUB_REF#refs/tags/v}
+sed -i "s/^version:.*/version: \"$VERSION\"/" build/linux/nfpm/nfpm.yaml
+```
+
+**方案 B(备选)**:使用环境变量
+
+修改 `nfpm.yaml`:
+```yaml
+version: "${VERSION:-0.1.0}"
+```
+
+然后在 CI 中:
+```bash
+export VERSION=${GITHUB_REF#refs/tags/v}
+wails3 task linux:create:deb
+```
+
+### 5.3 本地开发兼容
+
+本地执行 `wails3 task linux:package` 时,版本号保持 `0.1.0`(开发标识),不影响正常使用。
+
+---
+
+## 六、阻断级修正 #4:可执行名统一
+
+### 6.1 现状确认
+
+经代码审查,命名**已统一**为 `CodeSwitch`(首字母大写):
+
+| 位置 | 文件 | 当前值 | 状态 |
+|------|------|--------|------|
+| nfpm 安装路径 | `nfpm.yaml:21` | `/usr/local/bin/CodeSwitch` | OK |
+| Desktop Exec | `desktop:6` | `/usr/local/bin/CodeSwitch %u` | OK |
+| AppImage 名 | `updateservice.go:247` | `CodeSwitch.AppImage` | OK |
+| 二进制产物 | `Taskfile.yml:19` | `bin/CodeSwitch` | OK |
+
+### 6.2 需要修正的文档
+
+原方案文档中 "命令为 `codeswitch`" 的描述**错误**,实际命令为 `CodeSwitch`。
+
+如果需要小写命令别名,可在 `postinstall.sh` 中添加符号链接:
+
+```bash
+# 可选:创建小写别名
+ln -sf /usr/local/bin/CodeSwitch /usr/local/bin/codeswitch
+```
+
+---
+
+## 七、高优先级修正
+
+### 7.1 ARM64 构建(暂不实施)
+
+**问题分析**:
+- CGO 交叉编译需要 ARM64 工具链 + GTK/WebKit ARM64 库
+- GitHub Actions 标准 Runner 不支持 ARM64 原生构建
+- QEMU 模拟编译极慢(10-30x)
+
+**建议方案**:
+
+| 阶段 | 策略 | 说明 |
+|------|------|------|
+| **v1.0** | 仅 amd64 | 覆盖 95%+ Linux 桌面用户 |
+| **v1.x** | 自托管 ARM64 Runner | 或使用 Oracle Cloud 免费 ARM 实例 |
+
+**在方案中明确标注**:
+```
+⚠️ ARM64 支持暂不包含在本次迭代中,将在后续版本实现。
+```
+
+### 7.2 nFPM 脚本挂载修复
+
+**当前问题**(`nfpm.yaml:46-51`):
+```yaml
+scripts:
+ postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
+ # postremove 被注释掉了
+```
+
+**修正后**:
+```yaml
+scripts:
+ postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
+ postremove: "./build/linux/nfpm/scripts/postremove.sh"
+```
+
+**postinstall.sh 内容**:
+```bash
+#!/bin/bash
+set -e
+
+# 更新桌面数据库
+if command -v update-desktop-database &> /dev/null; then
+ update-desktop-database /usr/share/applications 2>/dev/null || true
+fi
+
+# 更新图标缓存
+if command -v gtk-update-icon-cache &> /dev/null; then
+ gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null || true
+fi
+
+# 可选:创建小写别名
+ln -sf /usr/local/bin/CodeSwitch /usr/local/bin/codeswitch 2>/dev/null || true
+
+echo "Code-Switch 安装完成"
+```
+
+**postremove.sh 内容**:
+```bash
+#!/bin/bash
+set -e
+
+# 移除符号链接
+rm -f /usr/local/bin/codeswitch 2>/dev/null || true
+
+# 移除自启动配置
+AUTOSTART="${XDG_CONFIG_HOME:-$HOME/.config}/autostart/codeswitch.desktop"
+rm -f "$AUTOSTART" 2>/dev/null || true
+
+# 更新桌面数据库
+if command -v update-desktop-database &> /dev/null; then
+ update-desktop-database /usr/share/applications 2>/dev/null || true
+fi
+
+echo "Code-Switch 已卸载,用户配置保留在 ~/.code-switch"
+```
+
+**重要:脚本权限设置**
+
+nFPM 要求脚本文件具有可执行权限,否则构建会失败。需要在 CI 中添加步骤:
+
+```yaml
+- name: Set nfpm script permissions
+ run: |
+ chmod +x build/linux/nfpm/scripts/postinstall.sh
+ chmod +x build/linux/nfpm/scripts/postremove.sh
+```
+
+或在本地开发时执行:
+```bash
+chmod +x build/linux/nfpm/scripts/*.sh
+git update-index --chmod=+x build/linux/nfpm/scripts/*.sh
+```
+
+**提交到 Git 时保留权限**:
+```bash
+git add --chmod=+x build/linux/nfpm/scripts/postinstall.sh
+git add --chmod=+x build/linux/nfpm/scripts/postremove.sh
+```
+
+### 7.3 Linux 更新机制增强(SHA256 校验)
+
+**当前代码问题**(`updateservice.go:495-523`):
+- 仅复制 + chmod + 删除备份
+- 无 SHA256 校验
+- 备份只保留 1 个
+- **`UpdateService` 缺少 `latestUpdateInfo` 字段**
+
+**修正方案分为三步**:
+
+#### 步骤 1:添加 `latestUpdateInfo` 字段
+
+修改 `services/updateservice.go` 中的 `UpdateService` 结构体(约第 43-60 行):
+
+```go
+// UpdateService 更新服务
+type UpdateService struct {
+ currentVersion string
+ latestVersion string
+ downloadURL string
+ updateFilePath string
+ autoCheckEnabled bool
+ downloadProgress float64
+ dailyCheckTimer *time.Timer
+ lastCheckTime time.Time
+ checkFailures int
+ updateReady bool
+ isPortable bool
+ mu sync.Mutex
+ stateFile string
+ updateDir string
+ lockFile string
+
+ // 新增:保存最新检查到的更新信息(含 SHA256)
+ latestUpdateInfo *UpdateInfo
+}
+```
+
+#### 步骤 2:修改 `CheckUpdate` 解析 SHA256
+
+修改 `services/updateservice.go` 中的 `CheckUpdate` 方法(约第 192-210 行):
+
+```go
+// 查找当前平台的下载链接
+downloadURL := us.findPlatformAsset(release.Assets)
+if downloadURL == "" {
+ log.Printf("[UpdateService] ❌ 未找到适用于 %s 的安装包", runtime.GOOS)
+ return nil, fmt.Errorf("未找到适用于 %s 的安装包", runtime.GOOS)
+}
+
+// 新增:查找对应的 SHA256 校验文件
+sha256Hash := us.findSHA256ForAsset(release.Assets, downloadURL)
+
+log.Printf("[UpdateService] 下载链接: %s", downloadURL)
+if sha256Hash != "" {
+ log.Printf("[UpdateService] SHA256: %s", sha256Hash)
+}
+
+updateInfo := &UpdateInfo{
+ Available: needUpdate,
+ Version: release.TagName,
+ DownloadURL: downloadURL,
+ ReleaseNotes: release.Body,
+ SHA256: sha256Hash, // 新增
+}
+
+us.mu.Lock()
+us.latestVersion = release.TagName
+us.downloadURL = downloadURL
+us.latestUpdateInfo = updateInfo // 新增:保存更新信息
+us.mu.Unlock()
+
+return updateInfo, nil
+```
+
+#### 步骤 3:添加 SHA256 解析辅助函数
+
+在 `services/updateservice.go` 中添加:
+
+```go
+// findSHA256ForAsset 查找资产对应的 SHA256 哈希
+// SHA256 文件格式:
+func (us *UpdateService) findSHA256ForAsset(assets []struct {
+ Name string `json:"name"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ Size int64 `json:"size"`
+}, assetURL string) string {
+ // 从 URL 提取文件名
+ assetName := filepath.Base(assetURL)
+ sha256FileName := assetName + ".sha256"
+
+ // 查找 SHA256 文件
+ var sha256URL string
+ for _, asset := range assets {
+ if asset.Name == sha256FileName {
+ sha256URL = asset.BrowserDownloadURL
+ break
+ }
+ }
+
+ if sha256URL == "" {
+ log.Printf("[UpdateService] 未找到 SHA256 文件: %s", sha256FileName)
+ return ""
+ }
+
+ // 下载并解析 SHA256 文件
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Get(sha256URL)
+ if err != nil {
+ log.Printf("[UpdateService] 下载 SHA256 文件失败: %v", err)
+ return ""
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ log.Printf("[UpdateService] SHA256 文件返回错误状态码: %d", resp.StatusCode)
+ return ""
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ log.Printf("[UpdateService] 读取 SHA256 文件失败: %v", err)
+ return ""
+ }
+
+ // 解析格式: 或
+ content := strings.TrimSpace(string(body))
+ parts := strings.Fields(content)
+ if len(parts) >= 1 {
+ return parts[0] // 返回哈希值
+ }
+
+ return ""
+}
+```
+
+#### 步骤 4:修改 `applyUpdateLinux` 使用 SHA256 校验
+
+修改 `services/updateservice.go` 中的 `applyUpdateLinux` 方法:
+
+```go
+// applyUpdateLinux Linux 平台更新(增强版)
+func (us *UpdateService) applyUpdateLinux(appImagePath string) error {
+ // 1. SHA256 校验
+ us.mu.Lock()
+ var expectedHash string
+ if us.latestUpdateInfo != nil {
+ expectedHash = us.latestUpdateInfo.SHA256
+ }
+ us.mu.Unlock()
+
+ if expectedHash != "" {
+ actualHash, err := calculateFileSHA256(appImagePath)
+ if err != nil {
+ return fmt.Errorf("计算 SHA256 失败: %w", err)
+ }
+ if !strings.EqualFold(actualHash, expectedHash) {
+ return fmt.Errorf("SHA256 校验失败: 期望 %s, 实际 %s", expectedHash, actualHash)
+ }
+ log.Println("[UpdateService] SHA256 校验通过")
+ }
+
+ // 2. ELF 格式校验
+ f, err := os.Open(appImagePath)
+ if err != nil {
+ return fmt.Errorf("无法打开 AppImage: %w", err)
+ }
+ magic := make([]byte, 4)
+ _, err = f.Read(magic)
+ f.Close()
+ if err != nil || magic[0] != 0x7F || magic[1] != 'E' || magic[2] != 'L' || magic[3] != 'F' {
+ return fmt.Errorf("无效的 AppImage 格式(非 ELF)")
+ }
+
+ // 3. 获取当前可执行文件路径
+ currentExe, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("获取当前可执行文件路径失败: %w", err)
+ }
+ currentExe, _ = filepath.EvalSymlinks(currentExe)
+
+ // 4. 带时间戳的备份(保留最近 2 个)
+ timestamp := time.Now().Format("20060102-150405")
+ backupPath := currentExe + ".backup-" + timestamp
+ if err := copyUpdateFile(currentExe, backupPath); err != nil {
+ log.Printf("[UpdateService] 备份失败(继续): %v", err)
+ }
+
+ // 5. 替换可执行文件
+ if err := copyUpdateFile(appImagePath, currentExe); err != nil {
+ // 尝试恢复
+ _ = copyUpdateFile(backupPath, currentExe)
+ return fmt.Errorf("替换失败: %w", err)
+ }
+
+ // 6. 设置可执行权限
+ if err := os.Chmod(currentExe, 0o755); err != nil {
+ return fmt.Errorf("设置执行权限失败: %w", err)
+ }
+
+ // 7. 清理旧备份(保留最近 2 个)
+ us.cleanupOldBackups(filepath.Dir(currentExe), "*.backup-*", 2)
+
+ log.Println("[UpdateService] Linux 更新应用成功")
+ return nil
+}
+```
+
+**需要添加的辅助函数**:
+
+```go
+// cleanupOldBackups 清理旧备份文件,保留最近 n 个
+func (us *UpdateService) cleanupOldBackups(dir, pattern string, keep int) {
+ matches, _ := filepath.Glob(filepath.Join(dir, pattern))
+ if len(matches) <= keep {
+ return
+ }
+
+ // 按修改时间排序
+ sort.Slice(matches, func(i, j int) bool {
+ fi, _ := os.Stat(matches[i])
+ fj, _ := os.Stat(matches[j])
+ return fi.ModTime().After(fj.ModTime())
+ })
+
+ // 删除旧的
+ for _, f := range matches[keep:] {
+ os.Remove(f)
+ log.Printf("[UpdateService] 清理旧备份: %s", f)
+ }
+}
+```
+
+### 7.4 清理逻辑平台隔离
+
+**当前问题**(`main.go:400-431`):
+
+1. 函数签名是 `cleanupOldFiles()` **无参数**(内部自行获取 updateDir)
+2. 第一行 `if runtime.GOOS != "windows" { return }` **直接跳过非 Windows 平台**
+3. 内部只清理 `.exe` 文件
+
+**修正方案**:
+
+修改 `main.go` 中的 `cleanupOldFiles` 函数(约第 400-431 行):
+
+```go
+// cleanupOldFiles 在主程序启动时调用 - 支持所有平台
+func cleanupOldFiles() {
+ // 移除原有的 Windows 判断:
+ // if runtime.GOOS != "windows" {
+ // return
+ // }
+
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return
+ }
+
+ updateDir := filepath.Join(home, ".code-switch", "updates")
+ if _, err := os.Stat(updateDir); os.IsNotExist(err) {
+ return // 更新目录不存在
+ }
+
+ log.Printf("[Cleanup] 开始清理更新目录: %s", updateDir)
+
+ // 1. 清理超过 7 天的 .old 备份文件(所有平台通用)
+ cleanupByAge(updateDir, ".old", 7*24*time.Hour)
+
+ // 2. 按平台清理旧版本下载文件
+ switch runtime.GOOS {
+ case "windows":
+ cleanupByCount(updateDir, "CodeSwitch*.exe", 1)
+ cleanupByCount(updateDir, "updater*.exe", 1)
+ case "linux":
+ cleanupByCount(updateDir, "CodeSwitch*.AppImage", 1)
+ case "darwin":
+ cleanupByCount(updateDir, "codeswitch-macos-*.zip", 1)
+ }
+
+ // 3. 清理旧日志(保留最近 5 个,或总大小 < 5MB)- 所有平台通用
+ cleanupLogs(updateDir, 5, 5*1024*1024)
+
+ log.Println("[Cleanup] 清理完成")
+}
+```
+
+**确保调用**:在 `main()` 函数中(约第 160-180 行附近),确保 `cleanupOldFiles()` 在所有平台都被调用:
+
+```go
+func main() {
+ // ... 初始化代码 ...
+
+ // Windows 特定:恢复失败的更新
+ if runtime.GOOS == "windows" {
+ recoverFromFailedUpdate()
+ }
+
+ // 所有平台:清理旧文件(移除原有的 Windows 判断)
+ cleanupOldFiles()
+
+ // ... 其余初始化代码 ...
+}
+```
+
+**验证点**:
+- 确认 `main()` 中 `cleanupOldFiles()` 调用不在任何 `if runtime.GOOS == "windows"` 分支内
+- 当前代码已在 `main.go` 约第 160 行位置调用,需确认调用位置正确
+
+### 7.5 AUR 打包风险声明
+
+**问题**:
+- `wails3 tool package -format archlinux` 生成的是 `.tar.zst` 包
+- 不是标准 PKGBUILD,不能直接提交到 AUR
+- 没有 AUR 仓库维护计划
+
+**方案**:
+
+1. **本次迭代**:不在 GitHub Release 发布 AUR 包
+2. **Taskfile 保留**:`linux:create:aur` 任务保留供本地测试
+3. **文档说明**:
+
+```markdown
+## Arch Linux 用户
+
+目前暂不提供官方 AUR 包。推荐使用 AppImage:
+
+```bash
+chmod +x CodeSwitch.AppImage
+./CodeSwitch.AppImage
+```
+
+或手动安装到 /usr/local/bin。
+```
+
+---
+
+## 八、中优先级修正
+
+### 8.1 发行版依赖表核对
+
+**nfpm.yaml 修正**:
+
+```yaml
+# 默认依赖(Ubuntu 24.04+ / Debian 12+)
+depends:
+ - libgtk-3-0
+ - libwebkit2gtk-4.1-0
+
+overrides:
+ rpm:
+ depends:
+ - gtk3
+ - webkit2gtk4.1
+
+ archlinux:
+ depends:
+ - gtk3
+ - webkit2gtk-4.1
+```
+
+**支持的发行版明确列表**:
+
+| 发行版 | 版本 | 格式 | 状态 |
+|--------|------|------|------|
+| Ubuntu | 24.04 LTS | DEB | 完全支持 |
+| Ubuntu | 22.04 LTS | AppImage | 仅 AppImage(WebKit 4.0) |
+| Debian | 12 (Bookworm) | DEB | 完全支持 |
+| Fedora | 39/40 | RPM | 完全支持 |
+| Arch Linux | Rolling | AppImage | 仅 AppImage |
+| Linux Mint | 22+ | DEB | 完全支持 |
+
+### 8.2 打包格式数量修正
+
+原文档错误描述为 "5 种格式",实际为 **4 种**:
+1. AppImage
+2. DEB
+3. RPM
+4. AUR(暂不发布)
+
+### 8.3 更新机制一致性
+
+确保 Linux 更新与 Windows 保持以下一致性:
+
+| 功能 | Windows | Linux(修正后) |
+|------|---------|----------------|
+| SHA256 校验 | 是 | 是 |
+| 备份保留数 | 2 | 2 |
+| 格式校验 | PE Header | ELF Magic |
+| 日志记录 | 完整 | 完整 |
+| 错误恢复 | 自动回滚 | 自动回滚 |
+
+---
+
+## 九、实施清单
+
+### 9.1 文件修改清单
+
+| 文件 | 修改类型 | 描述 |
+|------|---------|------|
+| `.github/workflows/release.yml` | 新增 | build-linux job + release 资产 |
+| `build/linux/nfpm/nfpm.yaml` | 修改 | 挂载 postremove 脚本 |
+| `build/linux/nfpm/scripts/postinstall.sh` | 重写 | 添加实际逻辑 |
+| `build/linux/nfpm/scripts/postremove.sh` | 重写 | 添加卸载逻辑 |
+| `services/updateservice.go` | 修改 | 增强 applyUpdateLinux |
+| `main.go` | 修改 | cleanupOldFiles 平台隔离 |
+
+### 9.2 不修改的文件
+
+| 文件 | 原因 |
+|------|------|
+| `build/linux/Taskfile.yml` | 配置完整,无需修改 |
+| `build/linux/desktop` | 命名已正确 |
+| `build/linux/appimage/build.sh` | 功能正常 |
+
+### 9.3 执行顺序
+
+```
+1. 修改 .github/workflows/release.yml
+ ├─ 添加 build-linux job
+ ├─ 修改 create-release needs
+ └─ 修改 release assets 收集
+
+2. 修改 build/linux/nfpm/nfpm.yaml
+ └─ 取消 postremove 注释
+
+3. 重写 nfpm scripts
+ ├─ postinstall.sh
+ └─ postremove.sh
+
+4. 修改 services/updateservice.go
+ └─ 增强 applyUpdateLinux
+
+5. 修改 main.go
+ └─ cleanupOldFiles 平台隔离
+
+6. 本地测试
+ └─ wails3 task linux:package
+
+7. 推送 tag 触发 CI
+ └─ git tag v1.2.0 && git push --tags
+```
+
+---
+
+## 十、验收标准
+
+### 10.1 CI 构建验收
+
+- [ ] `build-linux` job 成功完成
+- [ ] 生成 `CodeSwitch.AppImage`
+- [ ] 生成 `codeswitch_*.deb`
+- [ ] 生成 `codeswitch-*.rpm`
+- [ ] 生成所有 `.sha256` 校验文件
+
+### 10.2 Release 发布验收
+
+- [ ] GitHub Release 页面包含 Linux 产物
+- [ ] 下载链接可用
+- [ ] SHA256 文件内容正确
+
+### 10.3 功能验收
+
+- [ ] AppImage 在 Ubuntu 24.04 正常运行
+- [ ] DEB 包在 Debian 12 正常安装
+- [ ] RPM 包在 Fedora 40 正常安装
+- [ ] 自动更新检测到新版本
+- [ ] 更新下载后 SHA256 校验通过
+- [ ] 更新应用后程序正常重启
+
+---
+
+## 十一、风险与缓解
+
+| 风险 | 可能性 | 影响 | 缓解措施 |
+|------|--------|------|---------|
+| ubuntu-24.04 Runner 不稳定 | 低 | 中 | 可回退到 22.04 + WebKit 4.0 |
+| AppImage FUSE 依赖问题 | 中 | 低 | 文档说明 `--appimage-extract-and-run` |
+| DEB/RPM 依赖冲突 | 低 | 中 | 明确支持的发行版版本 |
+| 更新失败无法恢复 | 低 | 高 | 备份机制 + 日志记录 |
+
+---
+
+## 十二、附录
+
+### A. 完整的 release.yml 差异
+
+```diff
+ jobs:
+ build-macos:
+ # ... 保持不变
+
+ build-windows:
+ # ... 保持不变
+
++ build-linux:
++ name: Build Linux
++ runs-on: ubuntu-24.04
++ steps:
++ # ... 见第三章
+
+ create-release:
+ name: Create Release
+- needs: [build-macos, build-windows]
++ needs: [build-macos, build-windows, build-linux]
+ runs-on: ubuntu-latest
+ steps:
+ # ... 见第四章
+```
+
+### B. 测试命令参考
+
+```bash
+# 本地构建测试
+wails3 task linux:build
+wails3 task linux:create:appimage
+wails3 task linux:create:deb
+wails3 task linux:create:rpm
+
+# 验证产物
+file bin/CodeSwitch
+file bin/CodeSwitch.AppImage
+dpkg-deb -I bin/codeswitch_*.deb
+rpm -qip bin/codeswitch-*.rpm
+
+# 测试安装(DEB)
+sudo dpkg -i bin/codeswitch_*.deb
+sudo apt-get install -f
+CodeSwitch --version
+
+# 测试卸载
+sudo dpkg -r codeswitch
+```
+
+---
+
+**文档结束**
+
+---
+
+**变更记录**
+
+| 版本 | 日期 | 作者 | 描述 |
+|------|------|------|------|
+| v1.0 | 2025-12-01 | Half open flowers | 初稿 |
+| v2.0 | 2025-12-01 | Half open flowers | 根据审查意见修正所有阻断级和高优先级问题 |
+| v2.1 | 2025-12-01 | Half open flowers | 补齐实施细节:(1) UpdateService 新增 latestUpdateInfo 字段及 SHA256 解析逻辑 (2) cleanupOldFiles 完整修改方案 (3) nfpm 脚本权限设置步骤 (4) Debian 12 依赖版本验证 |
diff --git a/doc/sqlite-concurrent-write-optimization.md b/doc/sqlite-concurrent-write-optimization.md
new file mode 100644
index 0000000..226fe1d
--- /dev/null
+++ b/doc/sqlite-concurrent-write-optimization.md
@@ -0,0 +1,1139 @@
+# SQLite 并发写入优化方案 - 顺序写入队列架构
+
+## 一、用户需求确认
+- ✅ **可靠性要求**:必须 100% 写入成功
+- ✅ **性能要求**:平衡模式(支持 100-500 req/s)
+- ✅ **实施策略**:一次性彻底解决
+
+**方案选择**:顺序写入队列(唯一能保证 100% 成功的方案)
+
+---
+
+## 二、现状分析
+
+### 并发写入统计
+- **高频并发写入**:9处(blacklistservice.go)
+- **中频并发写入**:2处(providerrelay.go - request_log)
+- **低频写入**:6处(settingsservice.go, database.go)
+
+### 核心问题
+1. SQLite 单写入限制导致 SQLITE_BUSY 错误
+2. 当前重试机制成功率仅 80%,无法满足 100% 要求
+3. 事务泄漏已修复,但并发写入冲突仍存在
+
+---
+
+## 三、顺序写入队列架构设计
+
+### 3.1 核心设计原则
+1. **单线程写入**:所有数据库写入操作通过单一 worker goroutine 执行
+2. **同步接口**:调用方阻塞等待写入结果,保证错误能被捕获
+3. **批量优化**:支持批量提交,提升吞吐量
+4. **优雅关闭**:应用退出时确保所有待处理任务完成
+5. **可监控性**:提供队列状态、性能指标
+
+---
+
+### 3.2 数据结构设计
+
+```go
+// services/dbqueue.go (新文件)
+
+package services
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "sort"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// WriteTask 写入任务
+type WriteTask struct {
+ SQL string // SQL语句
+ Args []interface{} // 参数
+ Result chan error // 结果通道(同步等待)
+}
+
+// DBWriteQueue 数据库写入队列
+type DBWriteQueue struct {
+ db *sql.DB
+ queue chan *WriteTask
+ batchQueue chan *WriteTask // 批量提交队列
+ shutdownChan chan struct{}
+ wg sync.WaitGroup
+
+ // 关闭状态标志(🔧 新增:防止 Shutdown 后仍可入队)
+ closed atomic.Bool
+
+ // 性能监控
+ stats *QueueStats
+ statsMu sync.RWMutex
+
+ // P99 延迟计算(环形缓冲区存储最近1000个样本)
+ latencySamples []float64 // 延迟样本(毫秒)
+ sampleIndex int // 当前写入位置
+ sampleCount int64 // 已记录样本数
+}
+
+// QueueStats 队列统计
+type QueueStats struct {
+ QueueLength int // 当前单次队列长度
+ BatchQueueLength int // 当前批量队列长度(如果启用)
+ TotalWrites int64 // 总写入数
+ SuccessWrites int64 // 成功写入数
+ FailedWrites int64 // 失败写入数
+ AvgLatencyMs float64 // 平均延迟(毫秒)
+ P99LatencyMs float64 // P99延迟
+ BatchCommits int64 // 批量提交次数
+}
+
+// NewDBWriteQueue 创建写入队列
+// queueSize: 队列缓冲大小(推荐 1000-5000)
+// enableBatch: 是否启用批量提交
+//
+// ⚠️ **批量模式使用约束**(critical):
+// - **仅用于同构写入**:批量通道(ExecBatch)只应用于相同表、相同操作的 SQL
+// - ✅ 正确用法:所有 request_log 的 INSERT(同一表、同一操作、参数结构相同)
+// - ❌ 错误用法:混入不同表的写入(request_log + provider_blacklist)
+// - ❌ 错误用法:混入不同操作(INSERT + UPDATE + DELETE)
+// - **为什么必须同构**:
+// - 统计模型假设批次延迟在所有任务间均匀分布(perTaskLatencyMs = batchLatencyMs / count)
+// - 如果批次内有慢 SQL(触发器、复杂索引),会稀释快 SQL 的延迟统计
+// - P99 延迟会被低估,无法真实反映单请求 SLA
+// - **代码审查检查点**:
+// - 搜索所有 ExecBatch/ExecBatchCtx 调用
+// - 确认每个调用点只写入同一个表的同一种操作
+// - 异构写入必须使用 Exec/ExecCtx(单次提交,统计准确)
+func NewDBWriteQueue(db *sql.DB, queueSize int, enableBatch bool) *DBWriteQueue {
+ q := &DBWriteQueue{
+ db: db,
+ queue: make(chan *WriteTask, queueSize),
+ shutdownChan: make(chan struct{}),
+ stats: &QueueStats{},
+ latencySamples: make([]float64, 1000), // 环形缓冲区容量1000
+ sampleIndex: 0,
+ sampleCount: 0,
+ }
+
+ if enableBatch {
+ q.batchQueue = make(chan *WriteTask, queueSize)
+ q.wg.Add(1)
+ go q.batchWorker() // 批量提交 worker
+ }
+
+ q.wg.Add(1)
+ go q.worker() // 主 worker
+
+ return q
+}
+
+// worker 单线程顺序处理所有写入
+func (q *DBWriteQueue) worker() {
+ defer q.wg.Done()
+
+ var currentTask *WriteTask // 命名变量,用于在 panic 时返回错误
+
+ // panic 保护:确保 worker 不会因未捕获的 panic 而崩溃
+ defer func() {
+ if r := recover(); r != nil {
+ fmt.Printf("🚨 数据库写入队列 worker panic: %v\n", r)
+
+ // 🔧 关键修复:如果 panic 时正在处理任务,必须返回错误,否则调用方永久阻塞
+ if currentTask != nil {
+ currentTask.Result <- fmt.Errorf("数据库写入 panic: %v", r)
+ close(currentTask.Result)
+ }
+
+ // 等待1秒后重启,避免快速循环(如果是系统性问题)
+ time.Sleep(1 * time.Second)
+
+ // 自动重启 worker
+ q.wg.Add(1)
+ go q.worker()
+ }
+ }()
+
+ for {
+ select {
+ case task := <-q.queue:
+ currentTask = task // 记录当前任务,用于 panic 时返回错误
+
+ start := time.Now()
+ _, err := q.db.Exec(task.SQL, task.Args...)
+
+ // 更新统计(单次写入,count=1)
+ q.updateStats(1, time.Since(start), err)
+
+ // 返回结果
+ task.Result <- err
+ close(task.Result)
+
+ currentTask = nil // 清空当前任务(防止下一次 panic 误用)
+
+ case <-q.shutdownChan:
+ // 排空 queue 中的所有剩余任务
+ for {
+ select {
+ case task := <-q.queue:
+ currentTask = task // shutdown 排空时也需要跟踪,防止 panic
+
+ start := time.Now()
+ _, err := q.db.Exec(task.SQL, task.Args...)
+ q.updateStats(1, time.Since(start), err)
+ task.Result <- err
+ close(task.Result)
+
+ currentTask = nil
+ default:
+ // queue 已空,安全退出
+ return
+ }
+ }
+ }
+ }
+}
+
+// batchWorker 批量提交 worker(可选)
+func (q *DBWriteQueue) batchWorker() {
+ defer q.wg.Done()
+
+ var currentBatch []*WriteTask // 命名变量,用于在 panic 时返回错误
+
+ // panic 保护:确保 batchWorker 不会因未捕获的 panic 而崩溃
+ defer func() {
+ if r := recover(); r != nil {
+ fmt.Printf("🚨 数据库批量写入队列 worker panic: %v\n", r)
+
+ // 🔧 关键修复:如果 panic 时正在处理批次,必须给所有任务返回错误
+ if len(currentBatch) > 0 {
+ panicErr := fmt.Errorf("批量写入 panic: %v", r)
+ for _, task := range currentBatch {
+ task.Result <- panicErr
+ close(task.Result)
+ }
+ }
+
+ // 等待1秒后重启,避免快速循环(如果是系统性问题)
+ time.Sleep(1 * time.Second)
+
+ // 自动重启 batchWorker
+ q.wg.Add(1)
+ go q.batchWorker()
+ }
+ }()
+
+ ticker := time.NewTicker(100 * time.Millisecond) // 每100ms批量提交一次
+ defer ticker.Stop()
+
+ var batch []*WriteTask
+
+ for {
+ select {
+ case task := <-q.batchQueue:
+ batch = append(batch, task)
+
+ // 批次达到上限(50条)或超时,立即提交
+ if len(batch) >= 50 {
+ currentBatch = batch // 记录当前批次,用于 panic 时返回错误
+ q.commitBatch(batch)
+ batch = nil
+ currentBatch = nil
+ }
+
+ case <-ticker.C:
+ if len(batch) > 0 {
+ currentBatch = batch
+ q.commitBatch(batch)
+ batch = nil
+ currentBatch = nil
+ }
+
+ case <-q.shutdownChan:
+ // 1. 先提交当前批次
+ if len(batch) > 0 {
+ currentBatch = batch
+ q.commitBatch(batch)
+ batch = nil
+ currentBatch = nil
+ }
+
+ // 2. 排空 batchQueue 中的所有剩余任务
+ for {
+ select {
+ case task := <-q.batchQueue:
+ batch = append(batch, task)
+ // 每收集50个或队列空了就提交一次
+ if len(batch) >= 50 {
+ currentBatch = batch
+ q.commitBatch(batch)
+ batch = nil
+ currentBatch = nil
+ }
+ default:
+ // batchQueue 已空,提交最后一批
+ if len(batch) > 0 {
+ currentBatch = batch
+ q.commitBatch(batch)
+ currentBatch = nil
+ }
+ return
+ }
+ }
+ }
+ }
+}
+
+// commitBatch 批量提交(使用事务)
+func (q *DBWriteQueue) commitBatch(tasks []*WriteTask) {
+ start := time.Now()
+
+ // 辅助函数:给所有任务返回结果(成功或失败)
+ sendResultToAll := func(err error) {
+ for _, task := range tasks {
+ task.Result <- err
+ close(task.Result)
+ }
+ // 更新统计(批量提交,count=任务数)
+ q.updateStats(len(tasks), time.Since(start), err)
+ if err == nil {
+ q.statsMu.Lock()
+ q.stats.BatchCommits++
+ q.statsMu.Unlock()
+ }
+ }
+
+ tx, err := q.db.Begin()
+ if err != nil {
+ // 事务开启失败,所有任务都失败
+ sendResultToAll(err)
+ return
+ }
+ defer tx.Rollback()
+
+ // 执行所有任务,记录第一个错误
+ var firstErr error
+ for _, task := range tasks {
+ _, err := tx.Exec(task.SQL, task.Args...)
+ if err != nil && firstErr == nil {
+ firstErr = err // 记录第一个错误,但继续执行以清理资源
+ }
+ }
+
+ // 如果有任何错误,回滚并通知所有任务
+ if firstErr != nil {
+ sendResultToAll(fmt.Errorf("批量提交失败: %w", firstErr))
+ return
+ }
+
+ // 提交事务
+ if err := tx.Commit(); err != nil {
+ sendResultToAll(fmt.Errorf("事务提交失败: %w", err))
+ return
+ }
+
+ // 全部成功
+ sendResultToAll(nil)
+}
+
+// Exec 同步执行写入(阻塞直到完成,默认 30 秒超时)
+// 🔧 防御性设计:即使在高频路径误用,也有 30 秒兜底超时,避免永久阻塞
+func (q *DBWriteQueue) Exec(sql string, args ...interface{}) error {
+ // 先检查关闭状态
+ if q.closed.Load() {
+ return fmt.Errorf("写入队列已关闭")
+ }
+
+ task := &WriteTask{
+ SQL: sql,
+ Args: args,
+ Result: make(chan error, 1),
+ }
+
+ // 默认 30 秒超时(防止误用导致永久阻塞)
+ timeout := time.After(30 * time.Second)
+
+ select {
+ case q.queue <- task:
+ // 成功入队,等待结果(支持超时)
+ select {
+ case err := <-task.Result:
+ return err
+ case <-timeout:
+ // 超时,但任务已入队,无法撤销,需等待结果以避免 goroutine 泄漏
+ go func() { <-task.Result }()
+ return fmt.Errorf("写入超时(30秒),队列可能积压严重")
+ }
+
+ case <-timeout:
+ // 入队失败(队列满),直接返回
+ return fmt.Errorf("入队超时(30秒),队列已满")
+
+ case <-q.shutdownChan:
+ return fmt.Errorf("写入队列已关闭")
+ }
+}
+
+// ExecBatch 批量执行(异步,高吞吐量场景,默认 30 秒超时)
+// 🔧 防御性设计:即使误用,也有 30 秒兜底超时
+func (q *DBWriteQueue) ExecBatch(sql string, args ...interface{}) error {
+ // 先检查关闭状态
+ if q.closed.Load() {
+ return fmt.Errorf("写入队列已关闭")
+ }
+
+ if q.batchQueue == nil {
+ return fmt.Errorf("批量模式未启用")
+ }
+
+ task := &WriteTask{
+ SQL: sql,
+ Args: args,
+ Result: make(chan error, 1),
+ }
+
+ // 默认 30 秒超时(防止误用导致永久阻塞)
+ timeout := time.After(30 * time.Second)
+
+ select {
+ case q.batchQueue <- task:
+ // 成功入队,等待结果(支持超时)
+ select {
+ case err := <-task.Result:
+ return err
+ case <-timeout:
+ // 超时,但任务已入队,无法撤销
+ go func() { <-task.Result }()
+ return fmt.Errorf("批量写入超时(30秒),批量队列可能积压严重")
+ }
+
+ case <-timeout:
+ // 入队失败(队列满),直接返回
+ return fmt.Errorf("批量入队超时(30秒),队列已满")
+
+ case <-q.shutdownChan:
+ return fmt.Errorf("写入队列已关闭")
+ }
+}
+
+// ExecCtx 支持 context 的写入(带超时控制)
+func (q *DBWriteQueue) ExecCtx(ctx context.Context, sql string, args ...interface{}) error {
+ // 🔧 关键修复:先检查关闭状态
+ if q.closed.Load() {
+ return fmt.Errorf("写入队列已关闭")
+ }
+
+ task := &WriteTask{
+ SQL: sql,
+ Args: args,
+ Result: make(chan error, 1),
+ }
+
+ select {
+ case q.queue <- task:
+ // 成功入队,等待结果(支持超时)
+ select {
+ case err := <-task.Result:
+ return err
+ case <-ctx.Done():
+ // 超时或取消,但任务已入队,无法撤销
+ // 仍需等待结果以避免 goroutine 泄漏
+ go func() { <-task.Result }()
+ return fmt.Errorf("写入超时或已取消: %w", ctx.Err())
+ }
+
+ case <-ctx.Done():
+ // 入队失败(队列满),直接返回
+ return fmt.Errorf("入队超时或已取消(队列满): %w", ctx.Err())
+
+ case <-q.shutdownChan:
+ return fmt.Errorf("写入队列已关闭")
+ }
+}
+
+// ExecBatchCtx 支持 context 的批量写入(带超时控制)
+func (q *DBWriteQueue) ExecBatchCtx(ctx context.Context, sql string, args ...interface{}) error {
+ // 🔧 关键修复:先检查关闭状态
+ if q.closed.Load() {
+ return fmt.Errorf("写入队列已关闭")
+ }
+
+ if q.batchQueue == nil {
+ return fmt.Errorf("批量模式未启用")
+ }
+
+ task := &WriteTask{
+ SQL: sql,
+ Args: args,
+ Result: make(chan error, 1),
+ }
+
+ select {
+ case q.batchQueue <- task:
+ // 成功入队,等待结果(支持超时)
+ select {
+ case err := <-task.Result:
+ return err
+ case <-ctx.Done():
+ // 超时或取消,但任务已入队,无法撤销
+ go func() { <-task.Result }()
+ return fmt.Errorf("批量写入超时或已取消: %w", ctx.Err())
+ }
+
+ case <-ctx.Done():
+ // 入队失败(队列满),直接返回
+ return fmt.Errorf("批量入队超时或已取消(队列满): %w", ctx.Err())
+
+ case <-q.shutdownChan:
+ return fmt.Errorf("写入队列已关闭")
+ }
+}
+
+// Shutdown 优雅关闭
+func (q *DBWriteQueue) Shutdown(timeout time.Duration) error {
+ // 🔧 关键修复:先设置关闭标志,拒绝新请求入队
+ q.closed.Store(true)
+
+ // 然后关闭 shutdownChan,通知 worker 排空队列
+ close(q.shutdownChan)
+
+ done := make(chan struct{})
+ go func() {
+ q.wg.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ return nil
+ case <-time.After(timeout):
+ return fmt.Errorf("关闭超时,队列中仍有 %d 个任务", len(q.queue))
+ }
+}
+
+// GetStats 获取统计信息
+func (q *DBWriteQueue) GetStats() QueueStats {
+ q.statsMu.RLock()
+ defer q.statsMu.RUnlock()
+
+ stats := *q.stats
+ stats.QueueLength = len(q.queue)
+
+ // 如果启用了批量队列,也返回其长度
+ if q.batchQueue != nil {
+ stats.BatchQueueLength = len(q.batchQueue)
+ }
+
+ return stats
+}
+
+// updateStats 更新统计信息
+// count: 本次操作涵盖的任务数(单次=1,批量=len(tasks))
+// latency: 操作耗时
+// err: 错误(nil表示成功)
+//
+// 📌 统计假设与局限性说明:
+//
+// 1. **平均延迟计算假设**:
+// - 批量提交时,假设批次延迟在所有任务间均匀分布
+// - 计算公式:AvgLatencyMs = (旧总延迟 + 批次延迟) / 新总任务数
+// - 局限性:如果批次内不同 SQL 耗时差异巨大(如含触发器、复杂索引),统计会失真
+//
+// 2. **P99 延迟计算假设**:
+// - 批量提交时,将批次延迟平均分摊到每个任务(perTaskLatencyMs = latencyMs / count)
+// - 每个任务记录相同的延迟样本,用于 P99 计算
+// - 局限性:真实情况下,批次内首个任务可能耗时更长(事务开启开销),最后一个任务可能更快
+//
+// 3. **适用场景**:
+// - ✅ 批次内所有 SQL 耗时相近(如 request_log INSERT,相同表结构、无触发器)
+// - ✅ 关注整体系统性能趋势,而非单条 SQL 精确耗时
+// - ❌ 批次内混合不同类型操作(INSERT + UPDATE + DELETE)
+// - ❌ 需要精确追踪每条 SQL 的实际耗时
+//
+// 4. **改进方向**(如需精确统计):
+// - 在 WriteTask 中添加 startTime 字段,worker 执行时逐个记录真实耗时
+// - 成本:每个任务额外 8 字节(time.Time)+ 逐个更新统计的锁竞争
+func (q *DBWriteQueue) updateStats(count int, latency time.Duration, err error) {
+ q.statsMu.Lock()
+ defer q.statsMu.Unlock()
+
+ // 按任务数累加(而非按批次数)
+ q.stats.TotalWrites += int64(count)
+ if err == nil {
+ q.stats.SuccessWrites += int64(count)
+ } else {
+ q.stats.FailedWrites += int64(count)
+ }
+
+ latencyMs := float64(latency.Milliseconds())
+
+ // 更新平均延迟(使用加权平均,批量提交时延迟按任务数权重分摊)
+ oldTotal := q.stats.TotalWrites - int64(count)
+ q.stats.AvgLatencyMs = (q.stats.AvgLatencyMs*float64(oldTotal) + latencyMs*float64(count)) / float64(q.stats.TotalWrites)
+
+ // P99 样本按单任务记录(批量提交时将批次延迟均分)
+ perTaskLatencyMs := latencyMs / float64(count)
+ for i := 0; i < count; i++ {
+ q.latencySamples[q.sampleIndex] = perTaskLatencyMs
+ q.sampleIndex = (q.sampleIndex + 1) % len(q.latencySamples)
+ q.sampleCount++
+ }
+
+ // 计算 P99 延迟(每100次更新一次,避免频繁排序)
+ if q.sampleCount%100 == 0 || q.sampleCount < 100 {
+ q.stats.P99LatencyMs = q.calculateP99()
+ }
+}
+
+// calculateP99 计算 P99 延迟(需持有锁)
+func (q *DBWriteQueue) calculateP99() float64 {
+ // 确定有效样本数量
+ validSamples := int(q.sampleCount)
+ if validSamples > len(q.latencySamples) {
+ validSamples = len(q.latencySamples)
+ }
+
+ if validSamples == 0 {
+ return 0
+ }
+
+ // 复制样本并排序(使用标准库快速排序)
+ samples := make([]float64, validSamples)
+ copy(samples, q.latencySamples[:validSamples])
+ sort.Float64s(samples)
+
+ // 取99%位置的值
+ p99Index := int(float64(validSamples) * 0.99)
+ if p99Index >= validSamples {
+ p99Index = validSamples - 1
+ }
+
+ return samples[p99Index]
+}
+```
+
+---
+
+### 3.3 全局队列初始化
+
+```go
+// main.go 或 services/providerrelay.go
+
+var GlobalDBQueue *services.DBWriteQueue
+
+func initDBQueue() {
+ db, err := xdb.DB("default")
+ if err != nil {
+ log.Fatalf("初始化数据库队列失败: %v", err)
+ }
+
+ // 创建全局队列(队列大小5000,禁用批量提交)
+ // 🚨 当前项目所有写入都是异构的(不同表、不同操作),不满足批量模式的同构约束
+ // 如未来仅针对 request_log INSERT 启用批量,需创建独立的批量队列
+ GlobalDBQueue = services.NewDBWriteQueue(db, 5000, false)
+
+ log.Println("✅ 数据库写入队列已启动")
+}
+
+// 应用关闭时调用
+func shutdownDBQueue() {
+ if GlobalDBQueue != nil {
+ if err := GlobalDBQueue.Shutdown(10 * time.Second); err != nil {
+ log.Printf("⚠️ 队列关闭超时: %v", err)
+ } else {
+ stats := GlobalDBQueue.GetStats()
+ log.Printf("✅ 队列已关闭,统计:成功=%d 失败=%d 平均延迟=%.2fms",
+ stats.SuccessWrites, stats.FailedWrites, stats.AvgLatencyMs)
+ }
+ }
+}
+```
+
+---
+
+### 3.4 代码改造方案
+
+#### 改造清单
+
+| 文件 | 函数 | 改造内容 | 推荐接口 | 理由 |
+|------|------|---------|----------|------|
+| `services/blacklistservice.go` | RecordSuccess | 2个UPDATE改为队列调用 | `Exec()` | 低频操作,可容忍阻塞 |
+| `services/blacklistservice.go` | RecordFailure | 3个INSERT/UPDATE改为队列调用 | `Exec()` | 低频操作,可容忍阻塞 |
+| `services/blacklistservice.go` | recordFailureFixedMode | 3个操作改为队列 | `Exec()` | 低频操作,可容忍阻塞 |
+| `services/providerrelay.go` | forwardRequest (defer) | request_log INSERT改为队列(**删除重试逻辑**)| **`ExecCtx()`** | **高频操作(每个请求),必须超时控制** |
+| `services/providerrelay.go` | geminiProxyHandler (defer) | 同上 | **`ExecCtx()`** | **高频操作(每个请求),必须超时控制** |
+| `services/settingsservice.go` | UpdateBlacklistSettings | 2个UPDATE改为队列(删除事务)| `Exec()` | 用户手动配置,低频 |
+| `services/settingsservice.go` | UpdateBlacklistEnabled | 1个UPDATE改为队列 | `Exec()` | 用户手动配置,低频 |
+| `services/settingsservice.go` | UpdateBlacklistLevelConfig | 1个UPDATE改为队列 | `Exec()` | 用户手动配置,低频 |
+
+**接口选择原则**:
+- ✅ **`Exec()`**:低频操作(用户手动触发、定时任务),可容忍短暂阻塞
+ - **防御性保护**:内置 30 秒默认超时,即使误用也不会永久阻塞
+ - 适用场景:配置更新、定时任务、用户手动触发的操作
+- ⚠️ **`ExecCtx()`**:高频操作(每个 HTTP 请求),必须设置超时(如 5 秒)
+ - 避免大量请求堆积导致服务雪崩
+ - 适用场景:每个请求都会调用的写入(如 request_log)
+
+**重要说明**:
+- settingsservice.go 中的写入操作虽然频率较低,但为确保 100% 写入成功,必须统一接入队列
+- 原有的事务逻辑(UpdateBlacklistSettings)需要改为两个独立的队列调用(见下方示例)
+- request_log 写入是最高频操作(500 req/s),**必须使用 ExecCtx** 避免队列满时请求堆积
+- **即使错误地在高频路径使用了 `Exec()`,也有 30 秒兜底超时保护**,不会导致永久性阻塞
+
+**⚠️ 成本提示**(防止掩盖问题):
+- **30 秒兜底超时是最后防线**,仅用于防止灾难性的永久阻塞
+- **不应依赖兜底超时**来容忍上游误用:
+ - ❌ 错误做法:在高频路径使用 `Exec()`,依赖 30 秒超时"保护"
+ - ✅ 正确做法:高频路径统一使用 `ExecCtx(5s)`,低频路径使用 `Exec()`
+- **为什么不能依赖兜底**:
+ - 30 秒超时过长,会导致大量请求堆积(500 req/s × 30s = 15000 个 goroutine)
+ - 压测结果失真(P99 延迟会包含 30 秒超时的样本)
+ - 掩盖真实的队列积压问题(应该在 5 秒就暴露,而非 30 秒)
+- **代码审查检查点**:
+ - 搜索 `queue.Exec(` 确认调用方是否为低频操作
+ - 所有 `providerrelay.go` 中的写入必须是 `ExecCtx`
+ - 压测脚本必须使用 `ExecCtx` 模拟真实环境
+
+**🚨 批量模式约束**(critical - 影响统计精度):
+- **当前项目不使用批量模式**(`NewDBWriteQueue(db, 5000, false)`)
+ - 原因:当前所有写入点都是**异构写入**(不同表、不同操作)
+ - blacklistservice.go:UPDATE provider_blacklist(表1)+ INSERT/UPDATE provider_failure(表2)
+ - providerrelay.go:INSERT request_log(表3)
+ - settingsservice.go:UPDATE app_settings(表4)
+- **如果未来启用批量模式**(`enableBatch=true`),必须满足:
+ - ✅ **仅用于同构写入**:同一表 + 同一操作类型(如仅 request_log INSERT)
+ - ❌ **禁止混入异构**:不同表、不同操作的 SQL 不能进入同一批次
+ - **违规后果**:
+ - P99 延迟被稀释(慢 SQL 延迟被均分到快 SQL)
+ - 平均延迟失真(无法反映真实的单请求耗时)
+ - 压测验收标准无效(统计数据不可信)
+- **代码审查检查点**(如果启用批量):
+ - 搜索所有 `ExecBatch(` 或 `ExecBatchCtx(` 调用
+ - 确认每个调用点的 SQL 是否为同一表 + 同一操作
+ - 如有疑问,优先使用 `Exec`/`ExecCtx`(单次提交,统计准确)
+
+#### 示例:改造 RecordSuccess
+
+**改造前**:
+```go
+func (bs *BlacklistService) RecordSuccess(platform string, providerName string) error {
+ db, err := xdb.DB("default")
+ if err != nil {
+ return fmt.Errorf("获取数据库连接失败: %w", err)
+ }
+
+ _, err = db.Exec(`UPDATE provider_blacklist SET failure_count = 0 WHERE ...`)
+ if err != nil {
+ return err
+ }
+
+ // ... 更多UPDATE
+ return nil
+}
+```
+
+**改造后**:
+```go
+func (bs *BlacklistService) RecordSuccess(platform string, providerName string) error {
+ // 直接使用全局队列
+ err := GlobalDBQueue.Exec(`UPDATE provider_blacklist SET failure_count = 0 WHERE ...`, ...)
+ if err != nil {
+ return err
+ }
+
+ // ... 更多UPDATE(同样改为队列调用)
+ return nil
+}
+```
+
+**关键点**:
+- ✅ 调用方式几乎不变,仅替换 `db.Exec` 为 `GlobalDBQueue.Exec`
+- ✅ 错误处理保持一致
+- ✅ 无需修改函数签名和调用方
+
+#### 示例:改造带事务的写入(settingsservice.go)
+
+**改造前**:
+```go
+func (ss *SettingsService) UpdateBlacklistSettings(threshold int, duration int) error {
+ db, err := xdb.DB("default")
+ if err != nil {
+ return fmt.Errorf("获取数据库连接失败: %w", err)
+ }
+
+ // 开启事务
+ tx, err := db.Begin()
+ if err != nil {
+ return fmt.Errorf("开启事务失败: %w", err)
+ }
+ defer tx.Rollback()
+
+ // 更新失败阈值
+ _, err = tx.Exec(`UPDATE app_settings SET value = ? WHERE key = 'blacklist_failure_threshold'`, threshold)
+ if err != nil {
+ return fmt.Errorf("更新失败阈值失败: %w", err)
+ }
+
+ // 更新拉黑时长
+ _, err = tx.Exec(`UPDATE app_settings SET value = ? WHERE key = 'blacklist_duration_minutes'`, duration)
+ if err != nil {
+ return fmt.Errorf("更新拉黑时长失败: %w", err)
+ }
+
+ // 提交事务
+ if err = tx.Commit(); err != nil {
+ return fmt.Errorf("提交事务失败: %w", err)
+ }
+
+ return nil
+}
+```
+
+**改造后(方案1 - 简单版)**:
+```go
+func (ss *SettingsService) UpdateBlacklistSettings(threshold int, duration int) error {
+ // 独立写入(简单,但无原子性保证)
+ err1 := GlobalDBQueue.Exec(`UPDATE app_settings SET value = ? WHERE key = 'blacklist_failure_threshold'`, threshold)
+ if err1 != nil {
+ return fmt.Errorf("更新失败阈值失败: %w", err1)
+ }
+
+ err2 := GlobalDBQueue.Exec(`UPDATE app_settings SET value = ? WHERE key = 'blacklist_duration_minutes'`, duration)
+ if err2 != nil {
+ return fmt.Errorf("更新拉黑时长失败: %w", err2)
+ }
+
+ return nil
+}
+```
+
+**改造后(方案2 - 原子性保证版,推荐)**:
+```go
+func (ss *SettingsService) UpdateBlacklistSettings(threshold int, duration int) error {
+ // 先读取旧值(用于补偿)
+ var oldThreshold, oldDuration int
+ db, err := xdb.DB("default")
+ if err != nil {
+ return fmt.Errorf("获取数据库连接失败: %w", err)
+ }
+
+ err = db.QueryRow(`SELECT value FROM app_settings WHERE key = 'blacklist_failure_threshold'`).Scan(&oldThreshold)
+ if err != nil {
+ return fmt.Errorf("读取旧阈值失败: %w", err)
+ }
+
+ err = db.QueryRow(`SELECT value FROM app_settings WHERE key = 'blacklist_duration_minutes'`).Scan(&oldDuration)
+ if err != nil {
+ return fmt.Errorf("读取旧时长失败: %w", err)
+ }
+
+ // 尝试第一次写入
+ err1 := GlobalDBQueue.Exec(`UPDATE app_settings SET value = ? WHERE key = 'blacklist_failure_threshold'`, threshold)
+ if err1 != nil {
+ return fmt.Errorf("更新失败阈值失败: %w", err1)
+ }
+
+ // 尝试第二次写入
+ err2 := GlobalDBQueue.Exec(`UPDATE app_settings SET value = ? WHERE key = 'blacklist_duration_minutes'`, duration)
+ if err2 != nil {
+ // 第二次失败,回滚第一次(补偿逻辑)
+ rollbackErr := GlobalDBQueue.Exec(`UPDATE app_settings SET value = ? WHERE key = 'blacklist_failure_threshold'`, oldThreshold)
+ if rollbackErr != nil {
+ return fmt.Errorf("更新拉黑时长失败且回滚失败: %w (原始错误: %v)", rollbackErr, err2)
+ }
+ return fmt.Errorf("更新拉黑时长失败(已回滚失败阈值): %w", err2)
+ }
+
+ return nil
+}
+```
+
+**重要说明**:
+- ✅ **方案1(简单)**:适用于对原子性要求不高的场景,依赖用户重试
+- ✅ **方案2(原子性保证)**:通过补偿逻辑(Saga模式)确保要么全部成功,要么全部回滚
+- ⚠️ **权衡**:方案2需要额外的读操作和可能的回滚操作,延迟略高(<10ms),但可靠性更强
+- 📌 **推荐使用方案2**:配置更新是关键操作,原子性保证更重要
+
+#### 示例:高频写入改造(providerrelay.go)
+
+**改造前**:
+```go
+defer func() {
+ requestLog.DurationSec = time.Since(start).Seconds()
+ db, err := xdb.DB("default")
+ if err != nil {
+ fmt.Printf("写入 request_log 失败: 获取数据库连接失败: %v\n", err)
+ return
+ }
+
+ // 重试机制:SQLite并发写入时会出现SQLITE_BUSY,需要重试
+ var lastErr error
+ for attempt := 0; attempt < 3; attempt++ {
+ _, err = db.Exec(`INSERT INTO request_log (...) VALUES (...)`, ...)
+ if err == nil {
+ return // 成功,直接返回
+ }
+ lastErr = err
+ if strings.Contains(err.Error(), "SQLITE_BUSY") {
+ time.Sleep(time.Duration(50*(attempt+1)) * time.Millisecond)
+ continue
+ }
+ break
+ }
+ fmt.Printf("写入 request_log 失败(已重试3次): %v\n", lastErr)
+}()
+```
+
+**改造后**:
+```go
+defer func() {
+ requestLog.DurationSec = time.Since(start).Seconds()
+
+ // 使用 ExecCtx 带超时控制(5秒超时,避免队列满时请求堆积)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ err := GlobalDBQueue.ExecCtx(ctx, `
+ INSERT INTO request_log (
+ platform, model, provider, http_code,
+ input_tokens, output_tokens, cache_create_tokens, cache_read_tokens,
+ reasoning_tokens, is_stream, duration_sec
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `,
+ requestLog.Platform,
+ requestLog.Model,
+ requestLog.Provider,
+ requestLog.HttpCode,
+ requestLog.InputTokens,
+ requestLog.OutputTokens,
+ requestLog.CacheCreateTokens,
+ requestLog.CacheReadTokens,
+ requestLog.ReasoningTokens,
+ boolToInt(requestLog.IsStream),
+ requestLog.DurationSec,
+ )
+
+ if err != nil {
+ // 写入失败不影响主流程(日志记录是非关键路径)
+ fmt.Printf("写入 request_log 失败: %v\n", err)
+ }
+}()
+```
+
+**关键改进**:
+- ✅ **删除重试逻辑**:队列已保证 100% 写入成功,无需业务侧重试
+- ✅ **超时控制**:5秒超时避免队列满时请求无限期阻塞
+- ✅ **简化代码**:从42行减少到27行,可读性大幅提升
+- ⚠️ **失败处理**:如果5秒内入队失败或写入失败,只记录日志不影响主流程(日志非关键)
+
+---
+
+## 四、测试验证方案
+
+### 4.1 单元测试
+
+```go
+// services/dbqueue_test.go
+
+func TestDBWriteQueue_ConcurrentWrites(t *testing.T) {
+ // 1. 创建临时数据库
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ // 2. 初始化队列
+ queue := NewDBWriteQueue(db, 100, false)
+ defer queue.Shutdown(5 * time.Second)
+
+ // 3. 并发写入测试(1000次并发)
+ var wg sync.WaitGroup
+ errors := make(chan error, 1000)
+
+ for i := 0; i < 1000; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ err := queue.Exec(`INSERT INTO test_table (id, value) VALUES (?, ?)`, id, fmt.Sprintf("value-%d", id))
+ if err != nil {
+ errors <- err
+ }
+ }(i)
+ }
+
+ wg.Wait()
+ close(errors)
+
+ // 4. 验证结果
+ if len(errors) > 0 {
+ t.Fatalf("并发写入失败: %v", <-errors)
+ }
+
+ // 5. 验证数据完整性
+ var count int
+ db.QueryRow(`SELECT COUNT(*) FROM test_table`).Scan(&count)
+ if count != 1000 {
+ t.Fatalf("期望写入1000条,实际%d条", count)
+ }
+
+ // 6. 验证统计
+ stats := queue.GetStats()
+ if stats.SuccessWrites != 1000 {
+ t.Fatalf("统计错误:成功=%d,期望1000", stats.SuccessWrites)
+ }
+}
+```
+
+### 4.2 压力测试脚本
+
+```go
+// scripts/test-write-queue-stress.go
+
+func main() {
+ // 1. 初始化真实数据库
+ db := initRealDB()
+
+ // 2. 创建队列(对齐生产环境配置:禁用批量模式)
+ // 🚨 生产环境是异构写入,禁用批量模式以确保统计准确
+ queue := services.NewDBWriteQueue(db, 5000, false)
+ defer queue.Shutdown(30 * time.Second)
+
+ // 3. 压力测试(模拟500 req/s持续1分钟)
+ concurrency := 500
+ duration := 60 * time.Second
+
+ start := time.Now()
+ ticker := time.NewTicker(time.Second / time.Duration(concurrency))
+ defer ticker.Stop()
+
+ var totalWrites int64
+ var errors int64
+
+ timeout := time.After(duration)
+
+ for {
+ select {
+ case <-ticker.C:
+ go func() {
+ // 🔧 修复:使用 ExecCtx(5s) 对齐生产环境高频写入用法
+ // 避免使用 Exec 的 30 秒兜底超时,导致压测结果失真
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ err := queue.ExecCtx(ctx, `INSERT INTO request_log (...) VALUES (...)`, ...)
+ atomic.AddInt64(&totalWrites, 1)
+ if err != nil {
+ atomic.AddInt64(&errors, 1)
+ }
+ }()
+
+ case <-timeout:
+ elapsed := time.Since(start)
+ stats := queue.GetStats()
+
+ fmt.Printf("\n========== 压力测试结果 ==========\n")
+ fmt.Printf("持续时间: %v\n", elapsed)
+ fmt.Printf("总写入数: %d\n", totalWrites)
+ fmt.Printf("成功写入: %d\n", stats.SuccessWrites)
+ fmt.Printf("失败写入: %d\n", errors)
+ fmt.Printf("成功率: %.2f%%\n", float64(stats.SuccessWrites)/float64(totalWrites)*100)
+ fmt.Printf("平均延迟: %.2fms\n", stats.AvgLatencyMs)
+ fmt.Printf("队列长度: %d\n", stats.QueueLength)
+ fmt.Printf("批量提交次数: %d\n", stats.BatchCommits)
+ fmt.Printf("====================================\n")
+ return
+ }
+ }
+}
+```
+
+**验收标准**:
+- ✅ 成功率:100%
+- ✅ 平均延迟:< 50ms
+- ✅ P99延迟:< 200ms
+- ✅ 队列长度:峰值 < 1000
+- ✅ 无panic、死锁
+
+---
+
+## 五、实施步骤
+
+### 阶段1:基础设施搭建(2天)
+1. 创建 `services/dbqueue.go` - 实现队列核心逻辑
+2. 编写单元测试 `services/dbqueue_test.go`
+3. 在 `main.go` 中初始化全局队列
+4. 添加优雅关闭逻辑
+
+### 阶段2:代码改造(2天)
+1. 改造 `services/blacklistservice.go`(9处写入)
+ - RecordSuccess
+ - RecordFailure
+ - recordFailureFixedMode
+2. 改造 `services/providerrelay.go`(2处写入)
+ - forwardRequest defer
+ - geminiProxyHandler defer
+3. 删除现有重试逻辑(已不需要)
+
+### 阶段3:测试验证(1天)
+1. 运行单元测试
+2. 运行压力测试脚本
+3. 修复发现的问题
+4. 性能调优(调整队列大小、批量参数)
+5. **🚨 批量模式合规性检查**(如果 `enableBatch=true`):
+ - 搜索所有 `ExecBatch(` 和 `ExecBatchCtx(` 调用
+ - 确认每个调用点只写入同一表的同一种操作(同构写入)
+ - 如有异构写入混入批量通道,立即改为 `Exec`/`ExecCtx`
+ - 验证压测脚本的 P99 延迟统计是否合理(无异常稀释)
+
+### 阶段4:监控与上线(1天)
+1. 添加队列监控接口(暴露给前端)
+2. 本地完整测试
+3. 生产环境灰度部署
+4. 观察队列统计和错误日志
+
+---
+
+## 六、风险评估与缓解
+
+| 风险 | 概率 | 影响 | 缓解措施 |
+|------|------|------|---------|
+| 队列成为性能瓶颈 | 中 | 高 | 启用批量提交,测试验证吞吐量 |
+| worker goroutine panic | 低 | 高 | 添加 recover 机制,自动重启,panic 时返回错误 |
+| 队列满导致请求阻塞 | 低 | 中 | 设置合理队列大小(5000),监控队列长度,Exec 兜底 30s 超时 |
+| 关闭时任务丢失 | 低 | 高 | 实现优雅关闭,等待所有任务完成,atomic.Bool 拒绝新入队 |
+| 批量提交事务失败 | 低 | 中 | 失败时回退到逐个处理模式 |
+| **批量模式异构写入** | **中** | **高** | **当前不启用批量模式**(enableBatch=false),如未来启用必须严格限制为同构写入(同表同操作),代码审查强制检查 |
+
+---
+
+## 七、关键文件清单
+
+### 新增文件
+- `services/dbqueue.go` - 队列核心实现
+- `services/dbqueue_test.go` - 单元测试
+- `scripts/test-write-queue-stress.go` - 压力测试
+
+### 修改文件
+- `main.go` - 初始化全局队列、优雅关闭
+- `services/blacklistservice.go` - 9处写入改为队列调用
+- `services/providerrelay.go` - 2处写入改为队列调用
+
+---
+
+## 八、成功标准
+
+✅ **功能目标**:
+- 100% 写入成功率(无 SQLITE_BUSY 错误)
+- 支持 500 req/s 并发写入
+- 平均延迟 < 50ms
+
+✅ **质量目标**:
+- 单元测试覆盖率 > 80%
+- 压力测试通过(1小时无错误)
+- 代码审查通过
+
+✅ **可维护性**:
+- 队列统计接口可用
+- 日志完整(队列状态、错误信息)
+- 文档齐全
diff --git a/doc/windows-silent-update-design.md b/doc/windows-silent-update-design.md
new file mode 100644
index 0000000..b598197
--- /dev/null
+++ b/doc/windows-silent-update-design.md
@@ -0,0 +1,1118 @@
+# Windows 安装版无界面静默更新方案
+
+> **文档状态**:设计方案(待实现)
+> **创建日期**:2025-12-01
+> **当前状态**:设计完成,代码未实现
+
+---
+
+## 零、实现差距核查(重要)
+
+以下是设计方案与现有代码的差距,实现时需逐一解决:
+
+### 必须实现的内容
+
+| 序号 | 差距项 | 现状 | 需要做的事 |
+|------|--------|------|-----------|
+| 1 | updater.exe 不存在 | 无此文件 | 新建 `cmd/updater/main.go` 和 manifest |
+| 2 | 下载目标错误 | 安装版下载 `installer.exe` | 修改 `findPlatformAsset` 改为下载 `CodeSwitch.exe` |
+| 3 | 更新逻辑未实现 | 使用 PowerShell 启动安装器 | 新增 `applyInstalledUpdate` 方法 |
+| 4 | 构建任务缺失 | 无 `build:updater` | 修改 `Taskfile.yml` |
+| 5 | 发布流程缺失 | 未发布 `updater.exe` | 修改 `publish_release.sh` |
+
+### 风险点(实现时必须注意)
+
+| 风险 | 说明 | 解决方案 |
+|------|------|---------|
+| **大小写不一致** | 发布脚本用 `bin/codeswitch.exe`(小写),`findPlatformAsset` 匹配 `CodeSwitch.exe`(大写),在大小写敏感环境会失败 | 统一使用 `CodeSwitch.exe`(大写),修改发布脚本 |
+| **测试覆盖缺失** | 无更新流程的单元/集成测试 | 实现时同步编写测试用例 |
+| **日志/回滚缺失** | 当前代码无等待/回滚/日志机制 | 按设计实现完整的日志和回滚 |
+| **安全校验缺失** | 下载文件无 SHA256 校验或签名验证 | Release 提供哈希文件,下载后校验 |
+| **回滚场景不全** | 未覆盖 updater 崩溃、备份占用、重启失败 | 见下方"完整回滚策略" |
+| **路径判定简化** | 自定义安装路径(如 D:\Apps)可能误判 | 见下方"路径判定策略" |
+| **超时固定值** | 30 秒可能不够(大文件/系统繁忙) | 参数化,写入任务文件 |
+| **残留文件累积** | 旧安装器、旧 updater、旧备份未清理 | 见下方"清理策略" |
+| **多用户会话** | 更新状态文件可能冲突 | 使用用户级目录隔离 |
+| **前端交互不明确** | 弹窗内容、失败提示、重试入口未定义 | 见下方"前端交互规范" |
+
+---
+
+## 一、问题描述
+
+### 当前痛点
+
+| 问题 | 说明 |
+|------|------|
+| 下载量大 | 安装版更新需下载完整 NSIS 安装器(~15MB) |
+| 体验差 | 弹出安装器界面,走完整安装流程 |
+| 速度慢 | 完整安装流程耗时较长 |
+
+### 用户期望
+
+```
+定时检查 → 后台下载 → 弹窗询问 → 点击确认 → 关闭重启 → 完成!
+```
+
+**核心诉求**:关闭重启就更新好了,不需要看到安装器界面。
+
+## 二、方案概述
+
+采用 **updater.exe 辅助进程** 方案:
+
+1. 下载核心 `CodeSwitch.exe`(~10MB)而非完整安装器
+2. 使用独立的 `updater.exe` 辅助程序完成文件替换
+3. `updater.exe` 内嵌 UAC manifest,自动请求管理员权限
+4. 用户体验:点击更新 → UAC 确认 → 几秒后新版本启动
+
+### 方案对比
+
+| 方面 | 当前实现 | 新方案 |
+|------|---------|--------|
+| 下载文件 | 安装器 ~15MB | 核心 exe ~10MB |
+| 更新方式 | 启动 NSIS 安装器 | updater.exe 静默替换 |
+| 用户交互 | 安装器界面 + UAC | 仅一次 UAC |
+| 更新速度 | 慢(完整安装流程) | 快(秒级替换) |
+
+## 三、技术约束
+
+1. **Windows 文件锁定**:运行中的 .exe 无法被替换
+2. **Program Files 权限**:需要管理员权限才能写入
+3. **UAC 不可避免**:安装在 Program Files 必须提权
+
+## 四、详细实现
+
+### 4.1 新建 updater.exe 辅助程序
+
+#### 文件:`cmd/updater/main.go`
+
+```go
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "time"
+
+ "golang.org/x/sys/windows"
+)
+
+// UpdateTask 更新任务配置
+type UpdateTask struct {
+ MainPID int `json:"main_pid"` // 主程序 PID
+ TargetExe string `json:"target_exe"` // 目标可执行文件路径
+ NewExePath string `json:"new_exe_path"` // 新版本文件路径
+ BackupPath string `json:"backup_path"` // 备份路径
+ CleanupPaths []string `json:"cleanup_paths"` // 需要清理的临时文件
+ TimeoutSec int `json:"timeout_sec"` // 必填:等待超时(秒),由主程序动态计算
+}
+
+func main() {
+ if len(os.Args) < 2 {
+ log.Fatal("Usage: updater.exe ")
+ }
+
+ taskFile := os.Args[1]
+
+ // 设置日志文件
+ logPath := filepath.Join(filepath.Dir(taskFile), "update.log")
+ logFile, _ := os.Create(logPath)
+ if logFile != nil {
+ defer logFile.Close()
+ log.SetOutput(logFile)
+ }
+
+ // 读取任务配置
+ data, err := os.ReadFile(taskFile)
+ if err != nil {
+ log.Fatalf("读取任务文件失败: %v", err)
+ }
+
+ var task UpdateTask
+ if err := json.Unmarshal(data, &task); err != nil {
+ log.Fatalf("解析任务配置失败: %v", err)
+ }
+
+ log.Printf("开始更新任务: PID=%d, Target=%s, Timeout=%ds", task.MainPID, task.TargetExe, task.TimeoutSec)
+
+ // 等待主程序退出(使用任务配置的超时值,禁止硬编码)
+ timeout := time.Duration(task.TimeoutSec) * time.Second
+ if task.TimeoutSec <= 0 {
+ timeout = 30 * time.Second // 兜底默认值(仅当任务文件异常时)
+ log.Println("[警告] timeout_sec 未设置,使用默认 30 秒")
+ }
+ if err := waitForProcessExit(task.MainPID, timeout); err != nil {
+ log.Fatalf("等待主程序退出超时(%ds): %v", task.TimeoutSec, err)
+ }
+ log.Println("主程序已退出")
+
+ // 执行更新
+ if err := performUpdate(task); err != nil {
+ log.Printf("更新失败: %v,执行回滚", err)
+ rollback(task)
+ os.Exit(1)
+ }
+
+ log.Println("更新成功,启动新版本...")
+
+ // 启动新版本
+ cmd := exec.Command(task.TargetExe)
+ cmd.Dir = filepath.Dir(task.TargetExe)
+ if err := cmd.Start(); err != nil {
+ log.Printf("启动新版本失败: %v", err)
+ }
+
+ // 延迟清理临时文件
+ time.Sleep(3 * time.Second)
+ for _, path := range task.CleanupPaths {
+ os.RemoveAll(path)
+ log.Printf("已清理: %s", path)
+ }
+ os.Remove(taskFile)
+}
+
+// waitForProcessExit 等待指定 PID 的进程退出
+func waitForProcessExit(pid int, timeout time.Duration) error {
+ handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
+ if err != nil {
+ // 进程可能已经退出
+ log.Printf("进程 %d 可能已退出: %v", pid, err)
+ return nil
+ }
+ defer windows.CloseHandle(handle)
+
+ event, err := windows.WaitForSingleObject(handle, uint32(timeout.Milliseconds()))
+ if event == windows.WAIT_TIMEOUT {
+ return fmt.Errorf("进程 %d 未在 %v 内退出", pid, timeout)
+ }
+ return nil
+}
+
+// performUpdate 执行更新操作
+func performUpdate(task UpdateTask) error {
+ // 1. 备份旧版本
+ log.Printf("Step 1: 备份 %s -> %s", task.TargetExe, task.BackupPath)
+ if err := os.Rename(task.TargetExe, task.BackupPath); err != nil {
+ return fmt.Errorf("备份旧版本失败: %w", err)
+ }
+
+ // 2. 复制新版本
+ log.Printf("Step 2: 复制 %s -> %s", task.NewExePath, task.TargetExe)
+ if err := copyFile(task.NewExePath, task.TargetExe); err != nil {
+ return fmt.Errorf("复制新版本失败: %w", err)
+ }
+
+ // 3. 验证新文件
+ log.Println("Step 3: 验证新版本文件")
+ info, err := os.Stat(task.TargetExe)
+ if err != nil || info.Size() == 0 {
+ return fmt.Errorf("验证新版本失败: %w", err)
+ }
+
+ log.Printf("更新完成: 新文件大小 = %d bytes", info.Size())
+ return nil
+}
+
+// rollback 回滚更新(静默,不弹窗)
+func rollback(task UpdateTask) {
+ log.Println("执行回滚操作...")
+ if _, err := os.Stat(task.BackupPath); err == nil {
+ os.Remove(task.TargetExe)
+ if err := os.Rename(task.BackupPath, task.TargetExe); err != nil {
+ log.Printf("回滚失败: %v", err)
+ } else {
+ log.Println("回滚成功")
+ }
+ }
+}
+
+// copyFile 复制文件
+func copyFile(src, dst string) error {
+ source, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer source.Close()
+
+ dest, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer dest.Close()
+
+ _, err = io.Copy(dest, source)
+ return err
+}
+```
+
+#### 文件:`cmd/updater/updater.exe.manifest`
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### 4.2 修改 updateservice.go
+
+#### 4.2.1 修改 `findPlatformAsset` 方法
+
+**位置**:`services/updateservice.go` 第 229-236 行
+
+**修改内容**:安装版也下载核心 exe,而非安装器
+
+```go
+case "windows":
+ // 统一下载核心 exe(无论便携版还是安装版)
+ // 安装版通过 updater.exe 提权替换
+ targetName = "CodeSwitch.exe"
+```
+
+#### 4.2.2 新增 `applyInstalledUpdate` 方法
+
+```go
+// applyInstalledUpdate 安装版更新逻辑
+func (us *UpdateService) applyInstalledUpdate(newExePath string) error {
+ currentExe, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("获取当前可执行文件路径失败: %w", err)
+ }
+ currentExe, _ = filepath.EvalSymlinks(currentExe)
+
+ // 1. 获取或下载 updater.exe
+ updaterPath := filepath.Join(us.updateDir, "updater.exe")
+ if _, err := os.Stat(updaterPath); os.IsNotExist(err) {
+ if err := us.downloadUpdater(updaterPath); err != nil {
+ return fmt.Errorf("下载更新器失败: %w", err)
+ }
+ }
+
+ // 2. 创建更新任务配置
+ taskFile := filepath.Join(us.updateDir, "update-task.json")
+ task := map[string]interface{}{
+ "main_pid": os.Getpid(),
+ "target_exe": currentExe,
+ "new_exe_path": newExePath,
+ "backup_path": currentExe + ".old",
+ "cleanup_paths": []string{
+ newExePath,
+ filepath.Join(filepath.Dir(us.stateFile), ".pending-update"),
+ },
+ }
+
+ taskData, _ := json.MarshalIndent(task, "", " ")
+ if err := os.WriteFile(taskFile, taskData, 0o644); err != nil {
+ return fmt.Errorf("写入任务配置失败: %w", err)
+ }
+
+ // 3. 删除 pending 标记
+ pendingFile := filepath.Join(filepath.Dir(us.stateFile), ".pending-update")
+ _ = os.Remove(pendingFile)
+
+ // 4. 启动 updater.exe(会自动请求 UAC)
+ log.Printf("[UpdateService] 启动更新器: %s %s", updaterPath, taskFile)
+ cmd := exec.Command(updaterPath, taskFile)
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("启动更新器失败: %w", err)
+ }
+
+ // 5. 退出主程序
+ log.Println("[UpdateService] 更新器已启动,准备退出主程序...")
+ os.Exit(0)
+ return nil
+}
+
+// downloadUpdater 从 GitHub Release 下载 updater.exe
+func (us *UpdateService) downloadUpdater(targetPath string) error {
+ url := fmt.Sprintf("https://github.com/Rogers-F/code-switch-R/releases/download/%s/updater.exe", us.latestVersion)
+
+ log.Printf("[UpdateService] 下载更新器: %s", url)
+
+ resp, err := http.Get(url)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+
+ out, err := os.Create(targetPath)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ _, err = io.Copy(out, resp.Body)
+ return err
+}
+```
+
+#### 4.2.3 修改 `applyUpdateWindows` 方法
+
+**位置**:第 418-440 行
+
+```go
+func (us *UpdateService) applyUpdateWindows(updatePath string) error {
+ if us.isPortable {
+ return us.applyPortableUpdate(updatePath)
+ }
+ // 安装版:使用 updater.exe 辅助程序
+ return us.applyInstalledUpdate(updatePath)
+}
+```
+
+### 4.3 修改构建流程
+
+#### 4.3.1 修改 Taskfile.yml
+
+添加 updater 构建任务:
+
+```yaml
+build:updater:
+ summary: Build updater.exe with UAC manifest
+ cmds:
+ - go install github.com/akavel/rsrc@latest
+ - cmd: cd cmd/updater && rsrc -manifest updater.exe.manifest -o rsrc_windows_amd64.syso
+ - cmd: cd cmd/updater && go build -ldflags="-w -s -H windowsgui" -o ../../bin/updater.exe .
+ env:
+ GOOS: windows
+ GOARCH: amd64
+ CGO_ENABLED: 0
+```
+
+#### 4.3.2 修改 scripts/publish_release.sh
+
+发布资源列表添加 `updater.exe`:
+
+```bash
+ASSETS=(
+ "${MAC_ZIPS[@]}"
+ "bin/codeswitch-amd64-installer.exe" # 保留,供首次安装
+ "bin/CodeSwitch.exe" # 更新用
+ "bin/updater.exe" # 新增
+)
+```
+
+## 五、更新流程图
+
+```
+每日 8:00 定时检查 / 用户手动检查
+ ↓
+ 发现新版本
+ ↓
+后台下载 CodeSwitch.exe (~10MB)
+ ↓
+ 下载完成
+ ↓
+弹窗询问 "是否立即更新?"
+ ↓
+ 用户点击确认
+ ↓
+下载 updater.exe(如果本地没有)
+ ↓
+创建 update-task.json
+ ↓
+启动 updater.exe(触发 UAC 确认)
+ ↓
+ 主程序退出
+ ↓
+=== updater.exe 接管 ===
+ ↓
+等待主程序 PID 退出(30秒超时)
+ ↓
+备份 CodeSwitch.exe → CodeSwitch.exe.old
+ ↓
+复制新版本到 Program Files
+ ↓
+启动新版本 ✓
+ ↓
+清理临时文件
+```
+
+## 六、需要修改的文件清单
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `cmd/updater/main.go` | 新建 | updater 主程序(~150行) |
+| `cmd/updater/updater.exe.manifest` | 新建 | UAC 权限声明 |
+| `services/updateservice.go` | 修改 | 核心更新逻辑 |
+| `Taskfile.yml` | 修改 | 添加 updater 构建任务 |
+| `scripts/publish_release.sh` | 修改 | 发布 updater.exe |
+
+## 七、风险与回滚
+
+| 机制 | 说明 |
+|------|------|
+| **备份保留** | 旧版本备份为 `.old` 文件,可手动恢复 |
+| **静默回滚** | 更新失败时自动恢复,不弹窗打扰用户 |
+| **日志追踪** | `~/.code-switch/updates/update.log` 记录详细过程 |
+| **超时保护** | 等待主程序退出有 30 秒超时,防止卡死 |
+
+## 八、用户确认的决策
+
+- **UAC 确认**:可以接受一次 UAC 权限确认框
+- **失败处理**:静默回滚,不弹窗提示用户
+
+## 九、测试计划
+
+实现时需同步编写以下测试用例:
+
+### 9.1 单元测试
+
+| 测试项 | 文件 | 说明 |
+|--------|------|------|
+| `TestFindPlatformAsset_Windows` | `updateservice_test.go` | 验证安装版/便携版都返回 `CodeSwitch.exe` |
+| `TestDetectPortableMode` | `updateservice_test.go` | 验证路径检测逻辑 |
+| `TestUpdateTaskJSON` | `updateservice_test.go` | 验证任务配置 JSON 格式 |
+
+### 9.2 集成测试(手动)
+
+| 场景 | 预期结果 |
+|------|---------|
+| 安装版更新成功 | UAC 确认 → 关闭 → 新版本启动 |
+| 安装版更新失败 | 静默回滚,旧版本正常运行 |
+| 便携版更新 | 无 UAC,直接替换重启 |
+| updater.exe 下载失败 | 返回错误,不退出主程序 |
+
+### 9.3 边界条件
+
+- 主程序 30 秒内未退出 → updater 超时退出
+- 备份文件已存在 → 覆盖备份
+- 新版本文件损坏(0 字节)→ 回滚
+
+## 十、实现优先级
+
+```
+1. 修复大小写问题(发布脚本 codeswitch.exe → CodeSwitch.exe)
+2. 新建 cmd/updater/ 目录和文件
+3. 修改 updateservice.go
+4. 修改 Taskfile.yml 添加构建任务
+5. 修改 publish_release.sh 添加发布
+6. 编写测试用例
+7. 本地测试验证
+8. 发布新版本
+```
+
+---
+
+## 十一、补充设计(审查意见响应)
+
+### 11.1 安全校验机制
+
+**问题**:下载文件无 SHA256 校验,易受链路篡改影响。
+
+**完整解决方案**:
+
+#### 1. Release 发布时生成哈希文件
+
+```bash
+# publish_release.sh 中添加
+sha256sum bin/CodeSwitch.exe > bin/CodeSwitch.exe.sha256
+sha256sum bin/updater.exe > bin/updater.exe.sha256
+
+# 发布资产列表
+ASSETS=(
+ "bin/CodeSwitch.exe"
+ "bin/CodeSwitch.exe.sha256" # 新增
+ "bin/updater.exe"
+ "bin/updater.exe.sha256" # 新增
+ # ...
+)
+```
+
+#### 2. 完整的哈希获取与校验流程
+
+```go
+// UpdateService 完整校验流程
+func (us *UpdateService) downloadAndVerify(assetName string) (string, error) {
+ // 1. 下载主文件
+ mainURL := fmt.Sprintf("%s/%s/%s", releaseBaseURL, us.latestVersion, assetName)
+ mainPath := filepath.Join(us.updateDir, assetName)
+ if err := us.downloadFile(mainURL, mainPath, nil); err != nil {
+ return "", fmt.Errorf("下载 %s 失败: %w", assetName, err)
+ }
+
+ // 2. 下载哈希文件
+ hashURL := mainURL + ".sha256"
+ hashPath := mainPath + ".sha256"
+ if err := us.downloadFile(hashURL, hashPath, nil); err != nil {
+ os.Remove(mainPath) // 清理已下载的主文件
+ return "", fmt.Errorf("下载哈希文件失败: %w", err)
+ }
+
+ // 3. 解析哈希文件(格式: "hash filename")
+ hashContent, _ := os.ReadFile(hashPath)
+ expectedHash := strings.Fields(string(hashContent))[0]
+ os.Remove(hashPath) // 哈希文件用完即删
+
+ // 4. 校验主文件
+ if err := us.verifyDownload(mainPath, expectedHash); err != nil {
+ os.Remove(mainPath)
+ return "", err
+ }
+
+ return mainPath, nil
+}
+
+func (us *UpdateService) verifyDownload(filePath, expectedHash string) error {
+ f, err := os.Open(filePath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ h := sha256.New()
+ io.Copy(h, f)
+ actual := hex.EncodeToString(h.Sum(nil))
+
+ if !strings.EqualFold(actual, expectedHash) {
+ return fmt.Errorf("SHA256 校验失败: 期望 %s, 实际 %s", expectedHash, actual)
+ }
+ log.Printf("[Update] SHA256 校验通过: %s", filePath)
+ return nil
+}
+```
+
+#### 3. 校验失败处理
+
+| 场景 | 处理 |
+|------|------|
+| 哈希文件下载失败 | 删除主文件,返回错误,前端提示"校验文件获取失败" |
+| 哈希不匹配 | 删除主文件,返回错误,前端提示"文件校验失败,请重试" |
+| 哈希格式错误 | 同上 |
+
+#### 4. 需要校验的文件
+
+| 文件 | 校验时机 |
+|------|---------|
+| `CodeSwitch.exe` | 下载完成后、写入 pending 前 |
+| `updater.exe` | 首次下载后、启动前 |
+
+### 11.2 完整回滚策略
+
+**问题**:仅覆盖"复制失败",未覆盖 updater 崩溃、备份占用、重启失败。
+
+**完整场景覆盖**:
+
+| 场景 | 检测方式 | 处理策略 |
+|------|---------|---------|
+| 复制失败 | `copyFile` 返回错误 | 立即回滚,恢复备份 |
+| 新文件 0 字节 | `os.Stat` 检查大小 | 立即回滚,恢复备份 |
+| updater 崩溃/被杀 | 主程序启动时检查 `.old` 文件存在但新版本未运行 | 主程序启动时自动恢复 |
+| 备份文件被占用 | `os.Rename` 返回错误 | 尝试强制删除或重命名为 `.old.1` |
+| 重启新进程失败 | `cmd.Start()` 返回错误 | 回滚后记录日志,下次启动提示用户 |
+
+**主程序启动时的恢复检查**(修正版):
+
+**逻辑说明**:
+1. 检测 `.old` 备份文件是否存在
+2. 若存在,校验当前 exe 是否可用(大小 > 1MB)
+3. 若可用 → 更新成功,仅清理备份
+4. 若不可用 → 更新失败,回滚恢复 `.old`
+
+```go
+// main.go 启动时调用
+func checkAndRecoverFromFailedUpdate() {
+ currentExe, _ := os.Executable()
+ backupPath := currentExe + ".old"
+
+ // 检查备份是否存在
+ backupInfo, err := os.Stat(backupPath)
+ if err != nil {
+ return // 无备份,正常情况
+ }
+
+ // 检查当前 exe 是否可用(大小 > 0 且可执行)
+ currentInfo, err := os.Stat(currentExe)
+ currentOK := err == nil && currentInfo.Size() > 1024*1024 // 至少 1MB
+
+ if currentOK {
+ // 当前版本正常,说明更新成功,清理备份
+ log.Println("[Recovery] 更新成功,清理旧版本备份")
+ os.Remove(backupPath)
+ } else {
+ // 当前版本损坏,需要回滚
+ log.Printf("[Recovery] 当前版本异常(size=%d),从备份恢复", currentInfo.Size())
+ os.Remove(currentExe)
+ if err := os.Rename(backupPath, currentExe); err != nil {
+ log.Printf("[Recovery] 回滚失败: %v", err)
+ // 最后手段:提示用户手动恢复
+ } else {
+ log.Println("[Recovery] 回滚成功,已恢复到旧版本")
+ }
+ }
+
+ _ = backupInfo // 避免未使用警告
+}
+```
+
+### 11.3 路径判定策略
+
+**问题**:`detectPortableMode` 仅检查 "program files/appdata",自定义路径可能误判。
+
+**采用方案**:写权限检测(更准确,替代旧的路径字符串匹配)
+
+```go
+// 最终实现版本(替代现有 updateservice.go:106-128 的旧逻辑)
+func (us *UpdateService) detectPortableMode() bool {
+ if runtime.GOOS != "windows" {
+ return false
+ }
+
+ exePath, err := os.Executable()
+ if err != nil {
+ return false
+ }
+ exePath, _ = filepath.EvalSymlinks(exePath)
+ exeDir := filepath.Dir(exePath)
+
+ // 方案:直接检测写权限(比路径匹配更准确)
+ // 如果能在 exe 所在目录创建文件,则为便携版
+ testFile := filepath.Join(exeDir, ".write-test-"+strconv.Itoa(os.Getpid()))
+ f, err := os.Create(testFile)
+ if err != nil {
+ // 无写权限,视为安装版(需要 UAC)
+ log.Printf("[Update] 检测为安装版: 无法写入 %s", exeDir)
+ return false
+ }
+ f.Close()
+ os.Remove(testFile)
+
+ log.Printf("[Update] 检测为便携版: 可写入 %s", exeDir)
+ return true
+}
+```
+
+**设计假设**:
+- 安装版 = 安装在需要 UAC 权限的目录(无法直接写入)
+- 便携版 = 安装在用户可写的目录(可以直接写入)
+
+**为什么不用路径字符串匹配**:
+- 用户可能安装在 `D:\Apps\CodeSwitch\`(不含 program files)
+- 用户可能有特殊权限配置
+- 写权限检测是最直接的判断方式
+
+**边界情况**:
+| 场景 | 检测结果 | 处理 |
+|------|---------|------|
+| `C:\Program Files\CodeSwitch\` | 安装版(无写权限) | 使用 updater + UAC |
+| `D:\Apps\CodeSwitch\` + 无写权限 | 安装版 | 使用 updater + UAC |
+| `D:\Apps\CodeSwitch\` + 有写权限 | 便携版 | 直接替换 |
+| `%USERPROFILE%\Desktop\CodeSwitch\` | 便携版(有写权限) | 直接替换 |
+
+### 11.4 超时参数化
+
+**问题**:30 秒固定值可能不够。
+
+**强制要求**:
+> **任务 JSON 必须携带 `timeout_sec` 字段,`updater.exe` 的 `waitForProcessExit` 必须使用此值,禁止硬编码 30 秒。**
+
+**解决方案**:
+
+**1. UpdateTask 扩展(任务文件必须包含 timeout_sec)**:
+```go
+// UpdateTask 完整定义
+type UpdateTask struct {
+ MainPID int `json:"main_pid"`
+ TargetExe string `json:"target_exe"`
+ NewExePath string `json:"new_exe_path"`
+ BackupPath string `json:"backup_path"`
+ CleanupPaths []string `json:"cleanup_paths"`
+ TimeoutSec int `json:"timeout_sec"` // 必填,等待超时(秒)
+}
+```
+
+**2. UpdateService 写入任务 JSON 时计算超时**:
+```go
+// applyInstalledUpdate 中
+fileInfo, _ := os.Stat(newExePath)
+timeout := calculateTimeout(fileInfo.Size())
+
+task := map[string]interface{}{
+ "main_pid": os.Getpid(),
+ "target_exe": currentExe,
+ "new_exe_path": newExePath,
+ "backup_path": currentExe + ".old",
+ "cleanup_paths": []string{newExePath},
+ "timeout_sec": timeout, // 动态计算
+}
+
+func calculateTimeout(fileSize int64) int {
+ base := 30
+ extra := int(fileSize / (100 * 1024 * 1024)) * 10 // 每 100MB +10s
+ return base + extra
+}
+```
+
+**3. updater.exe 使用任务文件中的超时值**:
+```go
+// waitForProcessExit 使用 task.TimeoutSec
+func main() {
+ // ...
+ timeout := time.Duration(task.TimeoutSec) * time.Second
+ if task.TimeoutSec <= 0 {
+ timeout = 30 * time.Second // 兜底默认值
+ }
+ if err := waitForProcessExit(task.MainPID, timeout); err != nil {
+ log.Fatalf("等待主程序退出超时(%ds): %v", task.TimeoutSec, err)
+ }
+}
+```
+
+### 11.5 残留文件清理策略
+
+**问题**:旧安装器、旧 updater、旧备份文件累积。
+
+**清理策略**(完整实现):
+
+```go
+// 主程序启动时清理(在 main.go 初始化阶段调用)
+func cleanupOldFiles() {
+ updateDir := filepath.Join(os.Getenv("USERPROFILE"), ".code-switch", "updates")
+
+ // 1. 清理超过 7 天的备份文件
+ cleanupByAge(updateDir, ".old", 7*24*time.Hour)
+
+ // 2. 清理旧版本下载文件(保留最新 1 个)
+ cleanupByCount(updateDir, "CodeSwitch*.exe", 1)
+
+ // 3. 清理旧日志(保留最近 5 个,或总大小 < 5MB)
+ cleanupLogs(updateDir, 5, 5*1024*1024)
+
+ // 4. 清理旧 updater(保留最新 1 个)
+ cleanupByCount(updateDir, "updater*.exe", 1)
+}
+
+// 按时间清理
+func cleanupByAge(dir, suffix string, maxAge time.Duration) {
+ filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() {
+ return nil
+ }
+ if strings.HasSuffix(path, suffix) && time.Since(info.ModTime()) > maxAge {
+ log.Printf("[Cleanup] 删除过期文件: %s (age=%v)", path, time.Since(info.ModTime()))
+ os.Remove(path)
+ }
+ return nil
+ })
+}
+
+// 按数量清理(保留最新 N 个)
+func cleanupByCount(dir, pattern string, keepCount int) {
+ matches, _ := filepath.Glob(filepath.Join(dir, pattern))
+ if len(matches) <= keepCount {
+ return
+ }
+
+ // 按修改时间排序(新→旧)
+ sort.Slice(matches, func(i, j int) bool {
+ infoI, _ := os.Stat(matches[i])
+ infoJ, _ := os.Stat(matches[j])
+ return infoI.ModTime().After(infoJ.ModTime())
+ })
+
+ // 删除多余的旧文件
+ for _, path := range matches[keepCount:] {
+ log.Printf("[Cleanup] 删除旧版本: %s", path)
+ os.Remove(path)
+ }
+}
+
+// 日志清理(数量 + 大小双重限制)
+func cleanupLogs(dir string, maxCount int, maxTotalSize int64) {
+ pattern := filepath.Join(dir, "update*.log")
+ matches, _ := filepath.Glob(pattern)
+ if len(matches) == 0 {
+ return
+ }
+
+ // 按修改时间排序(新→旧)
+ sort.Slice(matches, func(i, j int) bool {
+ infoI, _ := os.Stat(matches[i])
+ infoJ, _ := os.Stat(matches[j])
+ return infoI.ModTime().After(infoJ.ModTime())
+ })
+
+ var totalSize int64
+ for i, path := range matches {
+ info, _ := os.Stat(path)
+
+ // 超过数量限制或大小限制,删除
+ if i >= maxCount || totalSize+info.Size() > maxTotalSize {
+ log.Printf("[Cleanup] 删除旧日志: %s (size=%d)", path, info.Size())
+ os.Remove(path)
+ } else {
+ totalSize += info.Size()
+ }
+ }
+}
+```
+
+**清理时机**:
+- 主程序启动时(`main.go` 初始化阶段)
+- 更新下载完成后(可选)
+
+**清理规则汇总**:
+
+| 文件类型 | 保留规则 | 清理条件 |
+|---------|---------|---------|
+| `.old` 备份 | 最多 7 天 | 超过 7 天自动删除 |
+| `CodeSwitch*.exe` | 最新 1 个 | 多余的旧版本删除 |
+| `updater*.exe` | 最新 1 个 | 多余的旧版本删除 |
+| `update*.log` | 最新 5 个且 < 5MB | 超出限制删除 |
+
+### 11.6 多用户会话隔离与并发互斥
+
+**问题**:多用户环境下更新状态可能冲突;同一用户多实例并发更新可能冲突。
+
+**解决方案**:
+
+#### 1. 用户级目录隔离
+- 所有状态文件使用 `%USERPROFILE%\.code-switch\`(用户级目录)
+- 不使用 `%PROGRAMDATA%` 或其他共享目录
+- 每个用户独立的更新状态、日志、临时文件
+
+**当前设计已符合**:`~/.code-switch/` 即 `%USERPROFILE%\.code-switch\`
+
+#### 2. 同用户多实例互斥(锁文件机制)
+
+```go
+// 更新开始前获取锁
+func (us *UpdateService) acquireUpdateLock() error {
+ lockPath := filepath.Join(us.updateDir, "update.lock")
+
+ // 尝试创建锁文件(排他模式)
+ f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
+ if err != nil {
+ if os.IsExist(err) {
+ // 检查锁文件是否过期(超过 10 分钟视为死锁)
+ info, _ := os.Stat(lockPath)
+ if time.Since(info.ModTime()) > 10*time.Minute {
+ os.Remove(lockPath)
+ return us.acquireUpdateLock() // 重试
+ }
+ return fmt.Errorf("另一个更新正在进行中")
+ }
+ return err
+ }
+
+ // 写入 PID 和时间戳
+ fmt.Fprintf(f, "%d\n%s", os.Getpid(), time.Now().Format(time.RFC3339))
+ f.Close()
+
+ us.lockFile = lockPath
+ return nil
+}
+
+// 更新完成后释放锁
+func (us *UpdateService) releaseUpdateLock() {
+ if us.lockFile != "" {
+ os.Remove(us.lockFile)
+ us.lockFile = ""
+ }
+}
+```
+
+**互斥规则**:
+| 场景 | 处理 |
+|------|------|
+| 锁文件存在且 < 10 分钟 | 返回"另一个更新正在进行中",前端提示等待 |
+| 锁文件存在且 > 10 分钟 | 视为死锁,删除锁文件并重试 |
+| 更新成功/失败 | 释放锁文件 |
+| 进程崩溃 | 下次启动时 10 分钟后自动清理 |
+
+### 11.7 前端交互规范
+
+**问题**:弹窗内容、失败提示、重试入口未定义。
+
+**交互规范**:
+
+#### 发现新版本弹窗
+```
+标题:发现新版本 v0.5.0
+内容:
+ 当前版本:v0.4.0
+ 新版本:v0.5.0
+ 更新内容:[从 Release Notes 获取]
+
+ 点击"立即更新"将下载更新包(约 10MB)。
+ 下载完成后需要重启应用。
+
+按钮:[稍后提醒] [立即更新]
+```
+
+#### 下载进度
+```
+标题:正在下载更新...
+内容:
+ 下载进度:45% (4.5MB / 10MB)
+ 预计剩余:30 秒
+
+按钮:[取消]
+```
+
+#### 下载完成
+```
+标题:更新已就绪
+内容:
+ 新版本 v0.5.0 已下载完成。
+ 点击"立即重启"将关闭应用并安装更新。
+ (安装版需要确认一次管理员权限)
+
+按钮:[稍后重启] [立即重启]
+```
+
+#### 更新失败
+```
+标题:更新失败
+内容:
+ 更新过程中发生错误:[错误信息]
+
+ 应用已自动恢复到之前的版本。
+ 您可以稍后重试,或手动下载安装包。
+
+按钮:[查看日志] [重试] [关闭]
+```
+
+#### 设置页入口
+```
+设置 > 关于
+ 当前版本:v0.4.0
+ [检查更新] 按钮
+
+ 自动更新:[开关]
+ - 开启后每天 8:00 自动检查并下载更新
+```
+
+#### 错误码与文案映射
+
+| 错误码 | 后端错误 | 前端文案 |
+|--------|---------|---------|
+| `ERR_DOWNLOAD_FAILED` | 下载文件失败 | "下载失败,请检查网络连接后重试" |
+| `ERR_HASH_DOWNLOAD_FAILED` | 哈希文件下载失败 | "校验文件获取失败,请稍后重试" |
+| `ERR_HASH_MISMATCH` | SHA256 校验失败 | "文件校验失败,可能已损坏,请重试" |
+| `ERR_UPDATE_LOCKED` | 另一个更新正在进行 | "另一个更新正在进行中,请稍后再试" |
+| `ERR_UAC_DENIED` | UAC 权限被拒绝 | "需要管理员权限才能更新,请点击确认" |
+| `ERR_UPDATER_LAUNCH_FAILED` | 启动 updater 失败 | "启动更新程序失败,请手动下载安装包" |
+| `ERR_TIMEOUT` | 等待超时 | "更新超时,请手动重启应用" |
+| `ERR_ROLLBACK_FAILED` | 回滚失败 | "更新失败且无法恢复,请重新安装应用" |
+
+**后端返回格式**:
+```go
+type UpdateError struct {
+ Code string `json:"code"` // 错误码
+ Message string `json:"message"` // 技术描述(用于日志)
+}
+```
+
+**前端处理**:
+```typescript
+function getErrorMessage(code: string): string {
+ const messages: Record = {
+ 'ERR_DOWNLOAD_FAILED': '下载失败,请检查网络连接后重试',
+ 'ERR_HASH_MISMATCH': '文件校验失败,可能已损坏,请重试',
+ // ...
+ }
+ return messages[code] || '更新失败,请稍后重试'
+}
+```
+
+### 11.8 版本兼容性规范
+
+**问题**:updater 与主程序的兼容/回退策略未定义。
+
+**版本兼容规则**:
+
+| 场景 | 处理 |
+|------|------|
+| updater 版本 < 主程序要求 | 重新下载最新 updater |
+| 主程序版本跨大版本升级 | 降级到安装器模式(调用 NSIS) |
+| 任务文件格式不兼容 | updater 返回错误,主程序回退到安装器模式 |
+
+**最低版本约束**:
+
+```go
+// UpdateTask 添加版本字段
+type UpdateTask struct {
+ // ...
+ UpdaterMinVersion string `json:"updater_min_version"` // 要求的 updater 最低版本
+ AppVersion string `json:"app_version"` // 目标应用版本
+}
+
+// updater 启动时检查
+func checkVersionCompatibility(task UpdateTask) error {
+ currentUpdaterVersion := "1.0.0"
+ if semver.Compare(currentUpdaterVersion, task.UpdaterMinVersion) < 0 {
+ return fmt.Errorf("updater 版本过低: 当前 %s, 要求 %s",
+ currentUpdaterVersion, task.UpdaterMinVersion)
+ }
+ return nil
+}
+```
+
+**回退策略**:
+1. updater 版本不兼容 → 删除旧 updater,重新下载
+2. 下载失败 → 回退到 NSIS 安装器模式
+3. 任务文件解析失败 → 记录日志,回退到安装器模式
+
+### 11.9 资产命名约束(强调)
+
+**问题**:大小写不一致可能导致下载失败。
+
+**约束规范**(必须在所有相关位置保持一致):
+
+| 位置 | 文件名 | 说明 |
+|------|--------|------|
+| 构建输出 | `CodeSwitch.exe` | 大写 C 和 S |
+| 发布脚本 | `bin/CodeSwitch.exe` | 必须与构建输出一致 |
+| `findPlatformAsset` | `"CodeSwitch.exe"` | 精确匹配 |
+| GitHub Release | `CodeSwitch.exe` | 上传时保持一致 |
+
+**修改清单**:
+
+1. **Taskfile.yml**(构建输出):
+ ```yaml
+ go build -o bin/CodeSwitch.exe # 大写
+ ```
+
+2. **publish_release.sh**:
+ ```bash
+ ASSETS=(
+ "bin/CodeSwitch.exe" # 大写,与构建一致
+ "bin/CodeSwitch.exe.sha256"
+ "bin/updater.exe"
+ "bin/updater.exe.sha256"
+ )
+ ```
+
+3. **updateservice.go**:
+ ```go
+ case "windows":
+ targetName = "CodeSwitch.exe" // 大写
+ ```
+
+**验证方法**:
+```bash
+# 构建后检查
+ls -la bin/ | grep -i codeswitch
+# 应输出: CodeSwitch.exe(大写)
+```
diff --git a/frontend/bindings/codeswitch/appservice.ts b/frontend/bindings/codeswitch/appservice.ts
new file mode 100644
index 0000000..4e0ad42
--- /dev/null
+++ b/frontend/bindings/codeswitch/appservice.ts
@@ -0,0 +1,18 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as application$0 from "../github.com/wailsapp/wails/v3/pkg/application/models.js";
+
+export function OpenSecondWindow(): $CancellablePromise {
+ return $Call.ByID(1306260514);
+}
+
+export function SetApp(app: application$0.App | null): $CancellablePromise {
+ return $Call.ByID(3487267257, app);
+}
diff --git a/frontend/bindings/codeswitch/index.ts b/frontend/bindings/codeswitch/index.ts
new file mode 100644
index 0000000..797d344
--- /dev/null
+++ b/frontend/bindings/codeswitch/index.ts
@@ -0,0 +1,9 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+import * as AppService from "./appservice.js";
+import * as VersionService from "./versionservice.js";
+export {
+ AppService,
+ VersionService
+};
diff --git a/frontend/bindings/codeswitch/services/appsettingsservice.ts b/frontend/bindings/codeswitch/services/appsettingsservice.ts
new file mode 100644
index 0000000..afbcdb4
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/appsettingsservice.ts
@@ -0,0 +1,31 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * GetAppSettings returns the persisted app settings or defaults if the file does not exist.
+ */
+export function GetAppSettings(): $CancellablePromise<$models.AppSettings> {
+ return $Call.ByID(55540836).then(($result: any) => {
+ return $$createType0($result);
+ });
+}
+
+/**
+ * SaveAppSettings persists the provided settings to disk.
+ */
+export function SaveAppSettings(settings: $models.AppSettings): $CancellablePromise<$models.AppSettings> {
+ return $Call.ByID(39874379, settings).then(($result: any) => {
+ return $$createType0($result);
+ });
+}
+
+// Private type creation functions
+const $$createType0 = $models.AppSettings.createFrom;
diff --git a/frontend/bindings/codeswitch/services/blacklistservice.ts b/frontend/bindings/codeswitch/services/blacklistservice.ts
new file mode 100644
index 0000000..d488e9e
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/blacklistservice.ts
@@ -0,0 +1,90 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * BlacklistService 管理供应商黑名单
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as time$0 from "../../time/models.js";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * AutoRecoverExpired 自动恢复过期的黑名单(由定时器调用)
+ * 使用事务批量处理,避免多次单独写入导致的并发锁冲突
+ */
+export function AutoRecoverExpired(): $CancellablePromise {
+ return $Call.ByID(1788747233);
+}
+
+/**
+ * GetBlacklistStatus 获取所有黑名单状态(用于前端展示,支持等级拉黑)
+ */
+export function GetBlacklistStatus(platform: string): $CancellablePromise<$models.BlacklistStatus[]> {
+ return $Call.ByID(2289061434, platform).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * IsBlacklisted 检查 provider 是否在黑名单中
+ */
+export function IsBlacklisted(platform: string, providerName: string): $CancellablePromise<[boolean, time$0.Time | null]> {
+ return $Call.ByID(237122095, platform, providerName);
+}
+
+/**
+ * IsLevelBlacklistEnabled 返回等级拉黑功能是否开启
+ * 用于 proxyHandler 判断是否启用自动降级
+ */
+export function IsLevelBlacklistEnabled(): $CancellablePromise {
+ return $Call.ByID(1770379115);
+}
+
+/**
+ * ManualResetLevel 手动清零等级(不解除拉黑,仅重置等级)
+ */
+export function ManualResetLevel(platform: string, providerName: string): $CancellablePromise {
+ return $Call.ByID(1066300442, platform, providerName);
+}
+
+/**
+ * ManualUnblock 手动解除拉黑(向后兼容,调用 ManualUnblockAndReset)
+ */
+export function ManualUnblock(platform: string, providerName: string): $CancellablePromise {
+ return $Call.ByID(3158432043, platform, providerName);
+}
+
+/**
+ * ManualUnblockAndReset 手动解除拉黑(保留等级,如需清零请调用 ManualResetLevel)
+ */
+export function ManualUnblockAndReset(platform: string, providerName: string): $CancellablePromise {
+ return $Call.ByID(3733422827, platform, providerName);
+}
+
+/**
+ * RecordFailure 记录 provider 失败,连续失败次数达到阈值时自动拉黑(支持等级拉黑)
+ */
+export function RecordFailure(platform: string, providerName: string): $CancellablePromise {
+ return $Call.ByID(640211172, platform, providerName);
+}
+
+/**
+ * RecordSuccess 记录 provider 成功,清零连续失败计数,执行降级和宽恕逻辑
+ */
+export function RecordSuccess(platform: string, providerName: string): $CancellablePromise {
+ return $Call.ByID(555083369, platform, providerName);
+}
+
+// Private type creation functions
+const $$createType0 = $models.BlacklistStatus.createFrom;
+const $$createType1 = $Create.Array($$createType0);
diff --git a/frontend/bindings/codeswitch/services/claudesettingsservice.ts b/frontend/bindings/codeswitch/services/claudesettingsservice.ts
new file mode 100644
index 0000000..b2be05a
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/claudesettingsservice.ts
@@ -0,0 +1,27 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+export function DisableProxy(): $CancellablePromise {
+ return $Call.ByID(1555695857);
+}
+
+export function EnableProxy(): $CancellablePromise {
+ return $Call.ByID(78884094);
+}
+
+export function ProxyStatus(): $CancellablePromise<$models.ClaudeProxyStatus> {
+ return $Call.ByID(4279409145).then(($result: any) => {
+ return $$createType0($result);
+ });
+}
+
+// Private type creation functions
+const $$createType0 = $models.ClaudeProxyStatus.createFrom;
diff --git a/frontend/bindings/codeswitch/services/cliconfigservice.ts b/frontend/bindings/codeswitch/services/cliconfigservice.ts
new file mode 100644
index 0000000..f4e4589
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/cliconfigservice.ts
@@ -0,0 +1,71 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * CliConfigService CLI 配置管理服务
+ * 管理 Claude Code、Codex、Gemini 的 CLI 配置文件
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * GetConfig 获取指定平台的 CLI 配置
+ */
+export function GetConfig(platform: string): $CancellablePromise<$models.CLIConfig | null> {
+ return $Call.ByID(1263080270, platform).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * GetLockedFields 获取指定平台的锁定字段列表
+ */
+export function GetLockedFields(platform: string): $CancellablePromise {
+ return $Call.ByID(1393452899, platform).then(($result: any) => {
+ return $$createType2($result);
+ });
+}
+
+/**
+ * GetTemplate 获取指定平台的全局模板
+ */
+export function GetTemplate(platform: string): $CancellablePromise<$models.CLITemplate | null> {
+ return $Call.ByID(2146148202, platform).then(($result: any) => {
+ return $$createType4($result);
+ });
+}
+
+/**
+ * RestoreDefault 恢复默认配置
+ */
+export function RestoreDefault(platform: string): $CancellablePromise {
+ return $Call.ByID(2204458067, platform);
+}
+
+/**
+ * SaveConfig 保存 CLI 配置
+ */
+export function SaveConfig(platform: string, editable: { [_: string]: any }): $CancellablePromise {
+ return $Call.ByID(3461150403, platform, editable);
+}
+
+/**
+ * SetTemplate 设置指定平台的全局模板
+ */
+export function SetTemplate(platform: string, template: { [_: string]: any }, isGlobalDefault: boolean): $CancellablePromise {
+ return $Call.ByID(768927510, platform, template, isGlobalDefault);
+}
+
+// Private type creation functions
+const $$createType0 = $models.CLIConfig.createFrom;
+const $$createType1 = $Create.Nullable($$createType0);
+const $$createType2 = $Create.Array($Create.Any);
+const $$createType3 = $models.CLITemplate.createFrom;
+const $$createType4 = $Create.Nullable($$createType3);
diff --git a/frontend/bindings/codeswitch/services/codexsettingsservice.ts b/frontend/bindings/codeswitch/services/codexsettingsservice.ts
new file mode 100644
index 0000000..1518ccc
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/codexsettingsservice.ts
@@ -0,0 +1,27 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+export function DisableProxy(): $CancellablePromise {
+ return $Call.ByID(3815925570);
+}
+
+export function EnableProxy(): $CancellablePromise {
+ return $Call.ByID(922948163);
+}
+
+export function ProxyStatus(): $CancellablePromise<$models.ClaudeProxyStatus> {
+ return $Call.ByID(419975348).then(($result: any) => {
+ return $$createType0($result);
+ });
+}
+
+// Private type creation functions
+const $$createType0 = $models.ClaudeProxyStatus.createFrom;
diff --git a/frontend/bindings/codeswitch/services/connectivitytestservice.ts b/frontend/bindings/codeswitch/services/connectivitytestservice.ts
new file mode 100644
index 0000000..a6c7269
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/connectivitytestservice.ts
@@ -0,0 +1,91 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * ConnectivityTestService 连通性测试服务
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * GetAllResults 获取所有平台的测试结果
+ */
+export function GetAllResults(): $CancellablePromise<{ [_: string]: $models.ConnectivityResult[] }> {
+ return $Call.ByID(3449037820).then(($result: any) => {
+ return $$createType2($result);
+ });
+}
+
+/**
+ * GetAutoTestEnabled 获取自动测试开关状态
+ */
+export function GetAutoTestEnabled(): $CancellablePromise {
+ return $Call.ByID(1127144277);
+}
+
+/**
+ * GetResults 获取指定平台的测试结果
+ */
+export function GetResults(platform: string): $CancellablePromise<$models.ConnectivityResult[]> {
+ return $Call.ByID(3435021955, platform).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * RunSingleTest 手动触发单个供应商测试
+ */
+export function RunSingleTest(platform: string, providerID: number): $CancellablePromise<$models.ConnectivityResult | null> {
+ return $Call.ByID(2760516326, platform, providerID).then(($result: any) => {
+ return $$createType3($result);
+ });
+}
+
+/**
+ * SetAutoTestEnabled 设置自动测试开关
+ */
+export function SetAutoTestEnabled(enabled: boolean): $CancellablePromise {
+ return $Call.ByID(2902580409, enabled);
+}
+
+/**
+ * Wails 生命周期方法
+ */
+export function Start(): $CancellablePromise {
+ return $Call.ByID(2984384939);
+}
+
+export function Stop(): $CancellablePromise {
+ return $Call.ByID(3083354649);
+}
+
+/**
+ * TestAll 测试指定平台的所有启用检测的供应商
+ */
+export function TestAll(platform: string): $CancellablePromise<$models.ConnectivityResult[]> {
+ return $Call.ByID(2959472920, platform).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * TestProvider 测试单个供应商连通性
+ */
+export function TestProvider(provider: $models.Provider, platform: string): $CancellablePromise<$models.ConnectivityResult | null> {
+ return $Call.ByID(3468595208, provider, platform).then(($result: any) => {
+ return $$createType3($result);
+ });
+}
+
+// Private type creation functions
+const $$createType0 = $models.ConnectivityResult.createFrom;
+const $$createType1 = $Create.Array($$createType0);
+const $$createType2 = $Create.Map($Create.Any, $$createType1);
+const $$createType3 = $Create.Nullable($$createType0);
diff --git a/frontend/bindings/codeswitch/services/consoleservice.ts b/frontend/bindings/codeswitch/services/consoleservice.ts
new file mode 100644
index 0000000..1f457a0
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/consoleservice.ts
@@ -0,0 +1,44 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * ConsoleService 控制台日志服务
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * ClearLogs 清空日志
+ */
+export function ClearLogs(): $CancellablePromise {
+ return $Call.ByID(3175582973);
+}
+
+/**
+ * GetLogs 获取所有日志
+ */
+export function GetLogs(): $CancellablePromise<$models.ConsoleLog[]> {
+ return $Call.ByID(1697160384).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * GetRecentLogs 获取最近 N 条日志
+ */
+export function GetRecentLogs(count: number): $CancellablePromise<$models.ConsoleLog[]> {
+ return $Call.ByID(2165472563, count).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+// Private type creation functions
+const $$createType0 = $models.ConsoleLog.createFrom;
+const $$createType1 = $Create.Array($$createType0);
diff --git a/frontend/bindings/codeswitch/services/deeplinkservice.ts b/frontend/bindings/codeswitch/services/deeplinkservice.ts
new file mode 100644
index 0000000..1e6b1b9
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/deeplinkservice.ts
@@ -0,0 +1,50 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * DeepLinkService 深度链接服务
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * ImportProviderFromDeepLink 从深度链接导入供应商
+ */
+export function ImportProviderFromDeepLink(request: $models.DeepLinkImportRequest | null): $CancellablePromise {
+ return $Call.ByID(2087032230, request);
+}
+
+/**
+ * ParseDeepLinkURL 解析 ccswitch:// URL
+ * 预期格式: ccswitch://v1/import?resource=provider&app=claude&name=...&homepage=...&endpoint=...&apiKey=...
+ */
+export function ParseDeepLinkURL(urlStr: string): $CancellablePromise<$models.DeepLinkImportRequest | null> {
+ return $Call.ByID(3816480018, urlStr).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * Start Wails生命周期方法
+ */
+export function Start(): $CancellablePromise {
+ return $Call.ByID(3178862184);
+}
+
+/**
+ * Stop Wails生命周期方法
+ */
+export function Stop(): $CancellablePromise {
+ return $Call.ByID(839496268);
+}
+
+// Private type creation functions
+const $$createType0 = $models.DeepLinkImportRequest.createFrom;
+const $$createType1 = $Create.Nullable($$createType0);
diff --git a/frontend/bindings/codeswitch/services/envcheckservice.ts b/frontend/bindings/codeswitch/services/envcheckservice.ts
new file mode 100644
index 0000000..ab623a6
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/envcheckservice.ts
@@ -0,0 +1,42 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * EnvCheckService 环境变量检测服务
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * CheckEnvConflicts 检查指定平台的环境变量冲突
+ */
+export function CheckEnvConflicts(app: string): $CancellablePromise<$models.EnvConflict[]> {
+ return $Call.ByID(1941592683, app).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * Start Wails生命周期方法
+ */
+export function Start(): $CancellablePromise {
+ return $Call.ByID(1143131277);
+}
+
+/**
+ * Stop Wails生命周期方法
+ */
+export function Stop(): $CancellablePromise {
+ return $Call.ByID(1280229439);
+}
+
+// Private type creation functions
+const $$createType0 = $models.EnvConflict.createFrom;
+const $$createType1 = $Create.Array($$createType0);
diff --git a/frontend/bindings/codeswitch/services/geminiservice.ts b/frontend/bindings/codeswitch/services/geminiservice.ts
new file mode 100644
index 0000000..bb400f8
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/geminiservice.ts
@@ -0,0 +1,143 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * GeminiService Gemini 配置管理服务
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * AddProvider 添加供应商
+ */
+export function AddProvider(provider: $models.GeminiProvider): $CancellablePromise {
+ return $Call.ByID(2833198257, provider);
+}
+
+/**
+ * CreateProviderFromPreset 从预设创建供应商
+ */
+export function CreateProviderFromPreset(presetName: string, apiKey: string): $CancellablePromise<$models.GeminiProvider | null> {
+ return $Call.ByID(974856947, presetName, apiKey).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * DeleteProvider 删除供应商
+ */
+export function DeleteProvider(id: string): $CancellablePromise {
+ return $Call.ByID(758292695, id);
+}
+
+/**
+ * DisableProxy 禁用代理
+ */
+export function DisableProxy(): $CancellablePromise {
+ return $Call.ByID(1809533069);
+}
+
+/**
+ * DuplicateProvider 复制供应商
+ */
+export function DuplicateProvider(sourceID: string): $CancellablePromise<$models.GeminiProvider | null> {
+ return $Call.ByID(159215875, sourceID).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * EnableProxy 启用代理
+ */
+export function EnableProxy(): $CancellablePromise {
+ return $Call.ByID(3882720282);
+}
+
+/**
+ * GetPresets 获取预设供应商列表
+ */
+export function GetPresets(): $CancellablePromise<$models.GeminiPreset[]> {
+ return $Call.ByID(2578586649).then(($result: any) => {
+ return $$createType3($result);
+ });
+}
+
+/**
+ * GetProviders 获取已配置的供应商列表
+ */
+export function GetProviders(): $CancellablePromise<$models.GeminiProvider[]> {
+ return $Call.ByID(4102812223).then(($result: any) => {
+ return $$createType4($result);
+ });
+}
+
+/**
+ * GetStatus 获取当前 Gemini 配置状态
+ */
+export function GetStatus(): $CancellablePromise<$models.GeminiStatus | null> {
+ return $Call.ByID(278805041).then(($result: any) => {
+ return $$createType6($result);
+ });
+}
+
+/**
+ * ProxyStatus 获取代理状态
+ */
+export function ProxyStatus(): $CancellablePromise<$models.GeminiProxyStatus | null> {
+ return $Call.ByID(615836637).then(($result: any) => {
+ return $$createType8($result);
+ });
+}
+
+/**
+ * ReorderProviders 重新排序供应商(按传入的 ID 顺序)
+ */
+export function ReorderProviders(ids: string[]): $CancellablePromise {
+ return $Call.ByID(1005362802, ids);
+}
+
+/**
+ * Start Wails生命周期方法
+ */
+export function Start(): $CancellablePromise {
+ return $Call.ByID(2631735887);
+}
+
+/**
+ * Stop Wails生命周期方法
+ */
+export function Stop(): $CancellablePromise {
+ return $Call.ByID(303555861);
+}
+
+/**
+ * SwitchProvider 切换到指定供应商
+ */
+export function SwitchProvider(id: string): $CancellablePromise {
+ return $Call.ByID(3824145224, id);
+}
+
+/**
+ * UpdateProvider 更新供应商
+ */
+export function UpdateProvider(provider: $models.GeminiProvider): $CancellablePromise {
+ return $Call.ByID(2675358193, provider);
+}
+
+// Private type creation functions
+const $$createType0 = $models.GeminiProvider.createFrom;
+const $$createType1 = $Create.Nullable($$createType0);
+const $$createType2 = $models.GeminiPreset.createFrom;
+const $$createType3 = $Create.Array($$createType2);
+const $$createType4 = $Create.Array($$createType0);
+const $$createType5 = $models.GeminiStatus.createFrom;
+const $$createType6 = $Create.Nullable($$createType5);
+const $$createType7 = $models.GeminiProxyStatus.createFrom;
+const $$createType8 = $Create.Nullable($$createType7);
diff --git a/frontend/bindings/codeswitch/services/importservice.ts b/frontend/bindings/codeswitch/services/importservice.ts
new file mode 100644
index 0000000..da39a8e
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/importservice.ts
@@ -0,0 +1,82 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+export function GetStatus(): $CancellablePromise<$models.ConfigImportStatus> {
+ return $Call.ByID(2109164227).then(($result: any) => {
+ return $$createType0($result);
+ });
+}
+
+/**
+ * ImportAll 从默认路径导入 cc-switch 配置
+ */
+export function ImportAll(): $CancellablePromise<$models.ConfigImportResult> {
+ return $Call.ByID(124174517).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * ImportFromPath 从指定路径导入 cc-switch 配置
+ */
+export function ImportFromPath(path: string): $CancellablePromise<$models.ConfigImportResult> {
+ return $Call.ByID(2519238097, path).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * ImportMCPFromJSON 导入解析后的 MCP 服务器(用于多服务器批量导入)
+ */
+export function ImportMCPFromJSON(servers: $models.MCPServer[], conflictStrategy: string): $CancellablePromise {
+ return $Call.ByID(2891198368, servers, conflictStrategy);
+}
+
+/**
+ * IsFirstRun 检查是否首次使用(用于显示导入提示)
+ */
+export function IsFirstRun(): $CancellablePromise {
+ return $Call.ByID(4249749624);
+}
+
+/**
+ * MarkFirstRunDone 标记首次使用已完成(不再显示导入提示)
+ */
+export function MarkFirstRunDone(): $CancellablePromise {
+ return $Call.ByID(838327945);
+}
+
+/**
+ * ParseMCPJSON 解析 JSON 字符串为 MCP 服务器列表
+ * 支持三种格式:
+ * 1. {"mcpServers": {"name": {...}}} - Claude Desktop 格式
+ * 2. [{"name": "...", "command": "..."}] - 数组格式
+ * 3. {"command": "...", "args": [...]} - 单服务器格式
+ */
+export function ParseMCPJSON(jsonStr: string): $CancellablePromise<$models.MCPParseResult | null> {
+ return $Call.ByID(489842720, jsonStr).then(($result: any) => {
+ return $$createType3($result);
+ });
+}
+
+export function Start(): $CancellablePromise {
+ return $Call.ByID(3119656637);
+}
+
+export function Stop(): $CancellablePromise {
+ return $Call.ByID(2083779375);
+}
+
+// Private type creation functions
+const $$createType0 = $models.ConfigImportStatus.createFrom;
+const $$createType1 = $models.ConfigImportResult.createFrom;
+const $$createType2 = $models.MCPParseResult.createFrom;
+const $$createType3 = $Create.Nullable($$createType2);
diff --git a/frontend/bindings/codeswitch/services/index.ts b/frontend/bindings/codeswitch/services/index.ts
new file mode 100644
index 0000000..417f923
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/index.ts
@@ -0,0 +1,83 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+import * as AppSettingsService from "./appsettingsservice.js";
+import * as BlacklistService from "./blacklistservice.js";
+import * as ClaudeSettingsService from "./claudesettingsservice.js";
+import * as CliConfigService from "./cliconfigservice.js";
+import * as CodexSettingsService from "./codexsettingsservice.js";
+import * as ConnectivityTestService from "./connectivitytestservice.js";
+import * as ConsoleService from "./consoleservice.js";
+import * as DeepLinkService from "./deeplinkservice.js";
+import * as EnvCheckService from "./envcheckservice.js";
+import * as GeminiService from "./geminiservice.js";
+import * as ImportService from "./importservice.js";
+import * as LogService from "./logservice.js";
+import * as MCPService from "./mcpservice.js";
+import * as PromptService from "./promptservice.js";
+import * as ProviderService from "./providerservice.js";
+import * as SettingsService from "./settingsservice.js";
+import * as SkillService from "./skillservice.js";
+import * as SpeedTestService from "./speedtestservice.js";
+import * as SuiStore from "./suistore.js";
+import * as UpdateService from "./updateservice.js";
+export {
+ AppSettingsService,
+ BlacklistService,
+ ClaudeSettingsService,
+ CliConfigService,
+ CodexSettingsService,
+ ConnectivityTestService,
+ ConsoleService,
+ DeepLinkService,
+ EnvCheckService,
+ GeminiService,
+ ImportService,
+ LogService,
+ MCPService,
+ PromptService,
+ ProviderService,
+ SettingsService,
+ SkillService,
+ SpeedTestService,
+ SuiStore,
+ UpdateService
+};
+
+export {
+ AppSettings,
+ BlacklistLevelConfig,
+ BlacklistSettings,
+ BlacklistStatus,
+ CLIConfig,
+ CLIConfigField,
+ CLIConfigFile,
+ CLIPlatform,
+ CLITemplate,
+ ClaudeProxyStatus,
+ ConfigImportResult,
+ ConfigImportStatus,
+ ConnectivityResult,
+ ConsoleLog,
+ DeepLinkImportRequest,
+ EndpointLatency,
+ EnvConflict,
+ GeminiAuthType,
+ GeminiPreset,
+ GeminiProvider,
+ GeminiProxyStatus,
+ GeminiStatus,
+ HeatmapStat,
+ Hotkey,
+ LogStats,
+ LogStatsSeries,
+ MCPParseResult,
+ MCPServer,
+ Prompt,
+ Provider,
+ ProviderDailyStat,
+ ReqeustLog,
+ Skill,
+ UpdateInfo,
+ UpdateState
+} from "./models.js";
diff --git a/frontend/bindings/codeswitch/services/logservice.ts b/frontend/bindings/codeswitch/services/logservice.ts
new file mode 100644
index 0000000..996aad3
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/logservice.ts
@@ -0,0 +1,50 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+export function HeatmapStats(days: number): $CancellablePromise<$models.HeatmapStat[]> {
+ return $Call.ByID(1056815029, days).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+export function ListProviders(platform: string): $CancellablePromise {
+ return $Call.ByID(790916236, platform).then(($result: any) => {
+ return $$createType2($result);
+ });
+}
+
+export function ListRequestLogs(platform: string, provider: string, limit: number): $CancellablePromise<$models.ReqeustLog[]> {
+ return $Call.ByID(1199056012, platform, provider, limit).then(($result: any) => {
+ return $$createType4($result);
+ });
+}
+
+export function ProviderDailyStats(platform: string): $CancellablePromise<$models.ProviderDailyStat[]> {
+ return $Call.ByID(974013659, platform).then(($result: any) => {
+ return $$createType6($result);
+ });
+}
+
+export function StatsSince(platform: string): $CancellablePromise<$models.LogStats> {
+ return $Call.ByID(2831143405, platform).then(($result: any) => {
+ return $$createType7($result);
+ });
+}
+
+// Private type creation functions
+const $$createType0 = $models.HeatmapStat.createFrom;
+const $$createType1 = $Create.Array($$createType0);
+const $$createType2 = $Create.Array($Create.Any);
+const $$createType3 = $models.ReqeustLog.createFrom;
+const $$createType4 = $Create.Array($$createType3);
+const $$createType5 = $models.ProviderDailyStat.createFrom;
+const $$createType6 = $Create.Array($$createType5);
+const $$createType7 = $models.LogStats.createFrom;
diff --git a/frontend/bindings/codeswitch/services/mcpservice.ts b/frontend/bindings/codeswitch/services/mcpservice.ts
new file mode 100644
index 0000000..865c15f
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/mcpservice.ts
@@ -0,0 +1,24 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+export function ListServers(): $CancellablePromise<$models.MCPServer[]> {
+ return $Call.ByID(1793630588).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+export function SaveServers(servers: $models.MCPServer[]): $CancellablePromise {
+ return $Call.ByID(2073851319, servers);
+}
+
+// Private type creation functions
+const $$createType0 = $models.MCPServer.createFrom;
+const $$createType1 = $Create.Array($$createType0);
diff --git a/frontend/bindings/codeswitch/services/models.ts b/frontend/bindings/codeswitch/services/models.ts
new file mode 100644
index 0000000..4e0b662
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/models.ts
@@ -0,0 +1,1930 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as time$0 from "../../time/models.js";
+
+export class AppSettings {
+ "show_heatmap": boolean;
+ "show_home_title": boolean;
+ "auto_start": boolean;
+ "auto_update": boolean;
+ "auto_connectivity_test": boolean;
+
+ /**
+ * 供应商切换通知开关
+ */
+ "enable_switch_notify": boolean;
+
+ /** Creates a new AppSettings instance. */
+ constructor($$source: Partial = {}) {
+ if (!("show_heatmap" in $$source)) {
+ this["show_heatmap"] = false;
+ }
+ if (!("show_home_title" in $$source)) {
+ this["show_home_title"] = false;
+ }
+ if (!("auto_start" in $$source)) {
+ this["auto_start"] = false;
+ }
+ if (!("auto_update" in $$source)) {
+ this["auto_update"] = false;
+ }
+ if (!("auto_connectivity_test" in $$source)) {
+ this["auto_connectivity_test"] = false;
+ }
+ if (!("enable_switch_notify" in $$source)) {
+ this["enable_switch_notify"] = false;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new AppSettings instance from a string or object.
+ */
+ static createFrom($$source: any = {}): AppSettings {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new AppSettings($$parsedSource as Partial);
+ }
+}
+
+/**
+ * BlacklistLevelConfig 等级拉黑配置(v0.4.0 新增)
+ */
+export class BlacklistLevelConfig {
+ /**
+ * 功能开关
+ * 是否启用等级拉黑
+ */
+ "enableLevelBlacklist": boolean;
+
+ /**
+ * 基础配置
+ * 失败阈值(连续失败次数)
+ */
+ "failureThreshold": number;
+
+ /**
+ * 去重窗口(秒)
+ */
+ "dedupeWindowSeconds": number;
+
+ /**
+ * 降级配置
+ * 正常降级间隔(小时)
+ */
+ "normalDegradeIntervalHours": number;
+
+ /**
+ * 宽恕触发时间(小时)
+ */
+ "forgivenessHours": number;
+
+ /**
+ * 跳级惩罚窗口(小时)
+ */
+ "jumpPenaltyWindowHours": number;
+
+ /**
+ * 等级时长配置(分钟)
+ * L1 拉黑时长
+ */
+ "l1DurationMinutes": number;
+
+ /**
+ * L2 拉黑时长
+ */
+ "l2DurationMinutes": number;
+
+ /**
+ * L3 拉黑时长
+ */
+ "l3DurationMinutes": number;
+
+ /**
+ * L4 拉黑时长
+ */
+ "l4DurationMinutes": number;
+
+ /**
+ * L5 拉黑时长
+ */
+ "l5DurationMinutes": number;
+
+ /**
+ * 开关关闭时的行为
+ * fixed=固定拉黑, none=不拉黑
+ */
+ "fallbackMode": string;
+
+ /**
+ * 固定拉黑时长(分钟)
+ */
+ "fallbackDurationMinutes": number;
+
+ /** Creates a new BlacklistLevelConfig instance. */
+ constructor($$source: Partial = {}) {
+ if (!("enableLevelBlacklist" in $$source)) {
+ this["enableLevelBlacklist"] = false;
+ }
+ if (!("failureThreshold" in $$source)) {
+ this["failureThreshold"] = 0;
+ }
+ if (!("dedupeWindowSeconds" in $$source)) {
+ this["dedupeWindowSeconds"] = 0;
+ }
+ if (!("normalDegradeIntervalHours" in $$source)) {
+ this["normalDegradeIntervalHours"] = 0;
+ }
+ if (!("forgivenessHours" in $$source)) {
+ this["forgivenessHours"] = 0;
+ }
+ if (!("jumpPenaltyWindowHours" in $$source)) {
+ this["jumpPenaltyWindowHours"] = 0;
+ }
+ if (!("l1DurationMinutes" in $$source)) {
+ this["l1DurationMinutes"] = 0;
+ }
+ if (!("l2DurationMinutes" in $$source)) {
+ this["l2DurationMinutes"] = 0;
+ }
+ if (!("l3DurationMinutes" in $$source)) {
+ this["l3DurationMinutes"] = 0;
+ }
+ if (!("l4DurationMinutes" in $$source)) {
+ this["l4DurationMinutes"] = 0;
+ }
+ if (!("l5DurationMinutes" in $$source)) {
+ this["l5DurationMinutes"] = 0;
+ }
+ if (!("fallbackMode" in $$source)) {
+ this["fallbackMode"] = "";
+ }
+ if (!("fallbackDurationMinutes" in $$source)) {
+ this["fallbackDurationMinutes"] = 0;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new BlacklistLevelConfig instance from a string or object.
+ */
+ static createFrom($$source: any = {}): BlacklistLevelConfig {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new BlacklistLevelConfig($$parsedSource as Partial);
+ }
+}
+
+/**
+ * BlacklistSettings 黑名单配置(基础配置,向后兼容)
+ */
+export class BlacklistSettings {
+ /**
+ * 失败次数阈值
+ */
+ "failureThreshold": number;
+
+ /**
+ * 拉黑时长(分钟)
+ */
+ "durationMinutes": number;
+
+ /** Creates a new BlacklistSettings instance. */
+ constructor($$source: Partial = {}) {
+ if (!("failureThreshold" in $$source)) {
+ this["failureThreshold"] = 0;
+ }
+ if (!("durationMinutes" in $$source)) {
+ this["durationMinutes"] = 0;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new BlacklistSettings instance from a string or object.
+ */
+ static createFrom($$source: any = {}): BlacklistSettings {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new BlacklistSettings($$parsedSource as Partial);
+ }
+}
+
+/**
+ * BlacklistStatus 黑名单状态(用于前端展示)
+ */
+export class BlacklistStatus {
+ "platform": string;
+ "providerName": string;
+ "failureCount": number;
+ "blacklistedAt": time$0.Time | null;
+ "blacklistedUntil": time$0.Time | null;
+ "lastFailureAt": time$0.Time | null;
+ "isBlacklisted": boolean;
+
+ /**
+ * 剩余拉黑时间(秒)
+ */
+ "remainingSeconds": number;
+
+ /**
+ * v0.4.0 新增:等级拉黑相关字段
+ * 当前黑名单等级 (0-5)
+ */
+ "blacklistLevel": number;
+
+ /**
+ * 最后恢复时间
+ */
+ "lastRecoveredAt": time$0.Time | null;
+
+ /**
+ * 距离宽恕还剩多少秒(3小时倒计时)
+ */
+ "forgivenessRemaining": number;
+
+ /** Creates a new BlacklistStatus instance. */
+ constructor($$source: Partial = {}) {
+ if (!("platform" in $$source)) {
+ this["platform"] = "";
+ }
+ if (!("providerName" in $$source)) {
+ this["providerName"] = "";
+ }
+ if (!("failureCount" in $$source)) {
+ this["failureCount"] = 0;
+ }
+ if (!("blacklistedAt" in $$source)) {
+ this["blacklistedAt"] = null;
+ }
+ if (!("blacklistedUntil" in $$source)) {
+ this["blacklistedUntil"] = null;
+ }
+ if (!("lastFailureAt" in $$source)) {
+ this["lastFailureAt"] = null;
+ }
+ if (!("isBlacklisted" in $$source)) {
+ this["isBlacklisted"] = false;
+ }
+ if (!("remainingSeconds" in $$source)) {
+ this["remainingSeconds"] = 0;
+ }
+ if (!("blacklistLevel" in $$source)) {
+ this["blacklistLevel"] = 0;
+ }
+ if (!("lastRecoveredAt" in $$source)) {
+ this["lastRecoveredAt"] = null;
+ }
+ if (!("forgivenessRemaining" in $$source)) {
+ this["forgivenessRemaining"] = 0;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new BlacklistStatus instance from a string or object.
+ */
+ static createFrom($$source: any = {}): BlacklistStatus {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new BlacklistStatus($$parsedSource as Partial);
+ }
+}
+
+/**
+ * CLIConfig CLI 配置数据
+ */
+export class CLIConfig {
+ "platform": CLIPlatform;
+ "fields": CLIConfigField[];
+
+ /**
+ * 原始文件内容(用于高级编辑)
+ */
+ "rawContent"?: string;
+
+ /**
+ * 多文件内容预览
+ */
+ "rawFiles"?: CLIConfigFile[];
+
+ /**
+ * "json" 或 "toml"
+ */
+ "configFormat"?: string;
+
+ /**
+ * Gemini .env 内容
+ */
+ "envContent"?: { [_: string]: string };
+
+ /**
+ * 配置文件路径
+ */
+ "filePath"?: string;
+
+ /**
+ * 可编辑字段的当前值
+ */
+ "editable"?: { [_: string]: any };
+
+ /** Creates a new CLIConfig instance. */
+ constructor($$source: Partial = {}) {
+ if (!("platform" in $$source)) {
+ this["platform"] = CLIPlatform.$zero;
+ }
+ if (!("fields" in $$source)) {
+ this["fields"] = [];
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new CLIConfig instance from a string or object.
+ */
+ static createFrom($$source: any = {}): CLIConfig {
+ const $$createField1_0 = $$createType1;
+ const $$createField3_0 = $$createType3;
+ const $$createField5_0 = $$createType4;
+ const $$createField7_0 = $$createType5;
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ if ("fields" in $$parsedSource) {
+ $$parsedSource["fields"] = $$createField1_0($$parsedSource["fields"]);
+ }
+ if ("rawFiles" in $$parsedSource) {
+ $$parsedSource["rawFiles"] = $$createField3_0($$parsedSource["rawFiles"]);
+ }
+ if ("envContent" in $$parsedSource) {
+ $$parsedSource["envContent"] = $$createField5_0($$parsedSource["envContent"]);
+ }
+ if ("editable" in $$parsedSource) {
+ $$parsedSource["editable"] = $$createField7_0($$parsedSource["editable"]);
+ }
+ return new CLIConfig($$parsedSource as Partial);
+ }
+}
+
+/**
+ * CLIConfigField 配置字段信息
+ */
+export class CLIConfigField {
+ "key": string;
+ "value": string;
+ "locked": boolean;
+ "hint"?: string;
+
+ /**
+ * "string", "boolean", "object"
+ */
+ "type": string;
+ "required"?: boolean;
+
+ /** Creates a new CLIConfigField instance. */
+ constructor($$source: Partial = {}) {
+ if (!("key" in $$source)) {
+ this["key"] = "";
+ }
+ if (!("value" in $$source)) {
+ this["value"] = "";
+ }
+ if (!("locked" in $$source)) {
+ this["locked"] = false;
+ }
+ if (!("type" in $$source)) {
+ this["type"] = "";
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new CLIConfigField instance from a string or object.
+ */
+ static createFrom($$source: any = {}): CLIConfigField {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new CLIConfigField($$parsedSource as Partial);
+ }
+}
+
+/**
+ * CLIConfigFile 配置文件预览(用于前端显示原始内容)
+ */
+export class CLIConfigFile {
+ "path": string;
+
+ /**
+ * "json", "toml", "env"
+ */
+ "format"?: string;
+ "content": string;
+
+ /** Creates a new CLIConfigFile instance. */
+ constructor($$source: Partial = {}) {
+ if (!("path" in $$source)) {
+ this["path"] = "";
+ }
+ if (!("content" in $$source)) {
+ this["content"] = "";
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new CLIConfigFile instance from a string or object.
+ */
+ static createFrom($$source: any = {}): CLIConfigFile {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new CLIConfigFile($$parsedSource as Partial);
+ }
+}
+
+/**
+ * CLIPlatform CLI 平台类型
+ */
+export enum CLIPlatform {
+ /**
+ * The Go zero value for the underlying type of the enum.
+ */
+ $zero = "",
+
+ PlatformClaude = "claude",
+ PlatformCodex = "codex",
+ PlatformGemini = "gemini",
+};
+
+/**
+ * CLITemplate CLI 配置模板
+ */
+export class CLITemplate {
+ "template": { [_: string]: any };
+ "isGlobalDefault": boolean;
+
+ /** Creates a new CLITemplate instance. */
+ constructor($$source: Partial = {}) {
+ if (!("template" in $$source)) {
+ this["template"] = {};
+ }
+ if (!("isGlobalDefault" in $$source)) {
+ this["isGlobalDefault"] = false;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new CLITemplate instance from a string or object.
+ */
+ static createFrom($$source: any = {}): CLITemplate {
+ const $$createField0_0 = $$createType5;
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ if ("template" in $$parsedSource) {
+ $$parsedSource["template"] = $$createField0_0($$parsedSource["template"]);
+ }
+ return new CLITemplate($$parsedSource as Partial);
+ }
+}
+
+export class ClaudeProxyStatus {
+ "enabled": boolean;
+ "base_url": string;
+
+ /** Creates a new ClaudeProxyStatus instance. */
+ constructor($$source: Partial = {}) {
+ if (!("enabled" in $$source)) {
+ this["enabled"] = false;
+ }
+ if (!("base_url" in $$source)) {
+ this["base_url"] = "";
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new ClaudeProxyStatus instance from a string or object.
+ */
+ static createFrom($$source: any = {}): ClaudeProxyStatus {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new ClaudeProxyStatus($$parsedSource as Partial);
+ }
+}
+
+export class ConfigImportResult {
+ "status": ConfigImportStatus;
+ "imported_providers": number;
+ "imported_mcp": number;
+
+ /** Creates a new ConfigImportResult instance. */
+ constructor($$source: Partial = {}) {
+ if (!("status" in $$source)) {
+ this["status"] = (new ConfigImportStatus());
+ }
+ if (!("imported_providers" in $$source)) {
+ this["imported_providers"] = 0;
+ }
+ if (!("imported_mcp" in $$source)) {
+ this["imported_mcp"] = 0;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new ConfigImportResult instance from a string or object.
+ */
+ static createFrom($$source: any = {}): ConfigImportResult {
+ const $$createField0_0 = $$createType6;
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ if ("status" in $$parsedSource) {
+ $$parsedSource["status"] = $$createField0_0($$parsedSource["status"]);
+ }
+ return new ConfigImportResult($$parsedSource as Partial);
+ }
+}
+
+export class ConfigImportStatus {
+ "config_exists": boolean;
+ "config_path"?: string;
+ "pending_providers": boolean;
+ "pending_mcp": boolean;
+ "pending_provider_count": number;
+ "pending_mcp_count": number;
+
+ /** Creates a new ConfigImportStatus instance. */
+ constructor($$source: Partial = {}) {
+ if (!("config_exists" in $$source)) {
+ this["config_exists"] = false;
+ }
+ if (!("pending_providers" in $$source)) {
+ this["pending_providers"] = false;
+ }
+ if (!("pending_mcp" in $$source)) {
+ this["pending_mcp"] = false;
+ }
+ if (!("pending_provider_count" in $$source)) {
+ this["pending_provider_count"] = 0;
+ }
+ if (!("pending_mcp_count" in $$source)) {
+ this["pending_mcp_count"] = 0;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new ConfigImportStatus instance from a string or object.
+ */
+ static createFrom($$source: any = {}): ConfigImportStatus {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new ConfigImportStatus($$parsedSource as Partial);
+ }
+}
+
+/**
+ * ConnectivityResult 连通性测试结果
+ */
+export class ConnectivityResult {
+ "providerId": number;
+ "providerName": string;
+ "platform": string;
+ "status": number;
+ "subStatus": string;
+ "latencyMs": number;
+ "lastChecked": time$0.Time;
+ "message"?: string;
+ "httpCode"?: number;
+
+ /** Creates a new ConnectivityResult instance. */
+ constructor($$source: Partial = {}) {
+ if (!("providerId" in $$source)) {
+ this["providerId"] = 0;
+ }
+ if (!("providerName" in $$source)) {
+ this["providerName"] = "";
+ }
+ if (!("platform" in $$source)) {
+ this["platform"] = "";
+ }
+ if (!("status" in $$source)) {
+ this["status"] = 0;
+ }
+ if (!("subStatus" in $$source)) {
+ this["subStatus"] = "";
+ }
+ if (!("latencyMs" in $$source)) {
+ this["latencyMs"] = 0;
+ }
+ if (!("lastChecked" in $$source)) {
+ this["lastChecked"] = null;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new ConnectivityResult instance from a string or object.
+ */
+ static createFrom($$source: any = {}): ConnectivityResult {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new ConnectivityResult($$parsedSource as Partial);
+ }
+}
+
+/**
+ * ConsoleLog 控制台日志条目
+ */
+export class ConsoleLog {
+ "timestamp": time$0.Time;
+
+ /**
+ * INFO, WARN, ERROR
+ */
+ "level": string;
+ "message": string;
+
+ /** Creates a new ConsoleLog instance. */
+ constructor($$source: Partial = {}) {
+ if (!("timestamp" in $$source)) {
+ this["timestamp"] = null;
+ }
+ if (!("level" in $$source)) {
+ this["level"] = "";
+ }
+ if (!("message" in $$source)) {
+ this["message"] = "";
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new ConsoleLog instance from a string or object.
+ */
+ static createFrom($$source: any = {}): ConsoleLog {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new ConsoleLog($$parsedSource as Partial);
+ }
+}
+
+/**
+ * DeepLinkImportRequest 深度链接导入请求模型
+ */
+export class DeepLinkImportRequest {
+ /**
+ * 协议版本 (e.g., "v1")
+ */
+ "version": string;
+
+ /**
+ * 资源类型 (e.g., "provider")
+ */
+ "resource": string;
+
+ /**
+ * 目标应用 (claude/codex/gemini)
+ */
+ "app": string;
+
+ /**
+ * 供应商名称
+ */
+ "name": string;
+
+ /**
+ * 供应商主页
+ */
+ "homepage": string;
+
+ /**
+ * API 端点
+ */
+ "endpoint": string;
+
+ /**
+ * API 密钥
+ */
+ "apiKey": string;
+
+ /**
+ * 可选模型名称
+ */
+ "model"?: string | null;
+
+ /**
+ * 可选备注
+ */
+ "notes"?: string | null;
+
+ /**
+ * Claude Haiku 模型
+ */
+ "haikuModel"?: string | null;
+
+ /**
+ * Claude Sonnet 模型
+ */
+ "sonnetModel"?: string | null;
+
+ /**
+ * Claude Opus 模型
+ */
+ "opusModel"?: string | null;
+
+ /**
+ * Base64 编码的配置
+ */
+ "config"?: string | null;
+
+ /**
+ * 配置格式 (json/toml)
+ */
+ "configFormat"?: string | null;
+
+ /**
+ * 远程配置 URL
+ */
+ "configUrl"?: string | null;
+
+ /** Creates a new DeepLinkImportRequest instance. */
+ constructor($$source: Partial = {}) {
+ if (!("version" in $$source)) {
+ this["version"] = "";
+ }
+ if (!("resource" in $$source)) {
+ this["resource"] = "";
+ }
+ if (!("app" in $$source)) {
+ this["app"] = "";
+ }
+ if (!("name" in $$source)) {
+ this["name"] = "";
+ }
+ if (!("homepage" in $$source)) {
+ this["homepage"] = "";
+ }
+ if (!("endpoint" in $$source)) {
+ this["endpoint"] = "";
+ }
+ if (!("apiKey" in $$source)) {
+ this["apiKey"] = "";
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new DeepLinkImportRequest instance from a string or object.
+ */
+ static createFrom($$source: any = {}): DeepLinkImportRequest {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new DeepLinkImportRequest($$parsedSource as Partial);
+ }
+}
+
+/**
+ * EndpointLatency 端点延迟测试结果
+ */
+export class EndpointLatency {
+ /**
+ * 端点 URL
+ */
+ "url": string;
+
+ /**
+ * 延迟(毫秒),nil 表示失败
+ */
+ "latency": number | null;
+
+ /**
+ * HTTP 状态码
+ */
+ "status"?: number | null;
+
+ /**
+ * 错误信息
+ */
+ "error"?: string | null;
+
+ /** Creates a new EndpointLatency instance. */
+ constructor($$source: Partial = {}) {
+ if (!("url" in $$source)) {
+ this["url"] = "";
+ }
+ if (!("latency" in $$source)) {
+ this["latency"] = null;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new EndpointLatency instance from a string or object.
+ */
+ static createFrom($$source: any = {}): EndpointLatency {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new EndpointLatency($$parsedSource as Partial);
+ }
+}
+
+/**
+ * EnvConflict 环境变量冲突
+ */
+export class EnvConflict {
+ /**
+ * 变量名
+ */
+ "varName": string;
+
+ /**
+ * 变量值
+ */
+ "varValue": string;
+
+ /**
+ * 来源类型: "system" | "file"
+ */
+ "sourceType": string;
+
+ /**
+ * 来源路径
+ */
+ "sourcePath": string;
+
+ /** Creates a new EnvConflict instance. */
+ constructor($$source: Partial = {}) {
+ if (!("varName" in $$source)) {
+ this["varName"] = "";
+ }
+ if (!("varValue" in $$source)) {
+ this["varValue"] = "";
+ }
+ if (!("sourceType" in $$source)) {
+ this["sourceType"] = "";
+ }
+ if (!("sourcePath" in $$source)) {
+ this["sourcePath"] = "";
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new EnvConflict instance from a string or object.
+ */
+ static createFrom($$source: any = {}): EnvConflict {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new EnvConflict($$parsedSource as Partial);
+ }
+}
+
+/**
+ * GeminiAuthType 认证类型
+ */
+export enum GeminiAuthType {
+ /**
+ * The Go zero value for the underlying type of the enum.
+ */
+ $zero = "",
+
+ /**
+ * Google 官方 OAuth
+ */
+ GeminiAuthOAuth = "oauth-personal",
+
+ /**
+ * API Key 认证
+ */
+ GeminiAuthAPIKey = "gemini-api-key",
+
+ /**
+ * PackyCode 合作方
+ */
+ GeminiAuthPackycode = "packycode",
+
+ /**
+ * 通用第三方
+ */
+ GeminiAuthGeneric = "generic",
+};
+
+/**
+ * GeminiPreset 预设供应商
+ */
+export class GeminiPreset {
+ "name": string;
+ "websiteUrl": string;
+ "apiKeyUrl"?: string;
+ "baseUrl"?: string;
+ "model"?: string;
+ "description"?: string;
+ "category": string;
+ "partnerPromotionKey"?: string;
+ "envConfig"?: { [_: string]: string };
+
+ /** Creates a new GeminiPreset instance. */
+ constructor($$source: Partial = {}) {
+ if (!("name" in $$source)) {
+ this["name"] = "";
+ }
+ if (!("websiteUrl" in $$source)) {
+ this["websiteUrl"] = "";
+ }
+ if (!("category" in $$source)) {
+ this["category"] = "";
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new GeminiPreset instance from a string or object.
+ */
+ static createFrom($$source: any = {}): GeminiPreset {
+ const $$createField8_0 = $$createType4;
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ if ("envConfig" in $$parsedSource) {
+ $$parsedSource["envConfig"] = $$createField8_0($$parsedSource["envConfig"]);
+ }
+ return new GeminiPreset($$parsedSource as Partial);
+ }
+}
+
+/**
+ * GeminiProvider Gemini 供应商配置
+ */
+export class GeminiProvider {
+ "id": string;
+ "name": string;
+ "websiteUrl"?: string;
+ "apiKeyUrl"?: string;
+ "baseUrl"?: string;
+ "apiKey"?: string;
+ "model"?: string;
+ "description"?: string;
+
+ /**
+ * official, third_party, custom
+ */
+ "category"?: string;
+
+ /**
+ * 用于识别供应商类型
+ */
+ "partnerPromotionKey"?: string;
+ "enabled": boolean;
+
+ /**
+ * 优先级分组 (1-10, 默认 1)
+ */
+ "level"?: number;
+
+ /**
+ * .env 配置
+ */
+ "envConfig"?: { [_: string]: string };
+
+ /**
+ * settings.json 配置
+ */
+ "settingsConfig"?: { [_: string]: any };
+
+ /** Creates a new GeminiProvider instance. */
+ constructor($$source: Partial = {}) {
+ if (!("id" in $$source)) {
+ this["id"] = "";
+ }
+ if (!("name" in $$source)) {
+ this["name"] = "";
+ }
+ if (!("enabled" in $$source)) {
+ this["enabled"] = false;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new GeminiProvider instance from a string or object.
+ */
+ static createFrom($$source: any = {}): GeminiProvider {
+ const $$createField12_0 = $$createType4;
+ const $$createField13_0 = $$createType5;
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ if ("envConfig" in $$parsedSource) {
+ $$parsedSource["envConfig"] = $$createField12_0($$parsedSource["envConfig"]);
+ }
+ if ("settingsConfig" in $$parsedSource) {
+ $$parsedSource["settingsConfig"] = $$createField13_0($$parsedSource["settingsConfig"]);
+ }
+ return new GeminiProvider($$parsedSource as Partial);
+ }
+}
+
+/**
+ * GeminiProxyStatus Gemini 代理状态
+ */
+export class GeminiProxyStatus {
+ "enabled": boolean;
+ "base_url": string;
+
+ /** Creates a new GeminiProxyStatus instance. */
+ constructor($$source: Partial = {}) {
+ if (!("enabled" in $$source)) {
+ this["enabled"] = false;
+ }
+ if (!("base_url" in $$source)) {
+ this["base_url"] = "";
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new GeminiProxyStatus instance from a string or object.
+ */
+ static createFrom($$source: any = {}): GeminiProxyStatus {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new GeminiProxyStatus($$parsedSource as Partial);
+ }
+}
+
+/**
+ * GeminiStatus Gemini 配置状态
+ */
+export class GeminiStatus {
+ "enabled": boolean;
+ "currentProvider"?: string;
+ "authType": GeminiAuthType;
+ "hasApiKey": boolean;
+ "hasBaseUrl": boolean;
+ "model"?: string;
+
+ /** Creates a new GeminiStatus instance. */
+ constructor($$source: Partial = {}) {
+ if (!("enabled" in $$source)) {
+ this["enabled"] = false;
+ }
+ if (!("authType" in $$source)) {
+ this["authType"] = GeminiAuthType.$zero;
+ }
+ if (!("hasApiKey" in $$source)) {
+ this["hasApiKey"] = false;
+ }
+ if (!("hasBaseUrl" in $$source)) {
+ this["hasBaseUrl"] = false;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new GeminiStatus instance from a string or object.
+ */
+ static createFrom($$source: any = {}): GeminiStatus {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new GeminiStatus($$parsedSource as Partial);
+ }
+}
+
+export class HeatmapStat {
+ "day": string;
+ "total_requests": number;
+ "input_tokens": number;
+ "output_tokens": number;
+ "reasoning_tokens": number;
+ "total_cost": number;
+
+ /** Creates a new HeatmapStat instance. */
+ constructor($$source: Partial = {}) {
+ if (!("day" in $$source)) {
+ this["day"] = "";
+ }
+ if (!("total_requests" in $$source)) {
+ this["total_requests"] = 0;
+ }
+ if (!("input_tokens" in $$source)) {
+ this["input_tokens"] = 0;
+ }
+ if (!("output_tokens" in $$source)) {
+ this["output_tokens"] = 0;
+ }
+ if (!("reasoning_tokens" in $$source)) {
+ this["reasoning_tokens"] = 0;
+ }
+ if (!("total_cost" in $$source)) {
+ this["total_cost"] = 0;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new HeatmapStat instance from a string or object.
+ */
+ static createFrom($$source: any = {}): HeatmapStat {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new HeatmapStat($$parsedSource as Partial);
+ }
+}
+
+export class Hotkey {
+ /**
+ * 热键ID
+ */
+ "id": number;
+
+ /**
+ * 键码
+ */
+ "keycode": number;
+
+ /**
+ * 修饰键
+ */
+ "modifiers": number;
+
+ /** Creates a new Hotkey instance. */
+ constructor($$source: Partial = {}) {
+ if (!("id" in $$source)) {
+ this["id"] = 0;
+ }
+ if (!("keycode" in $$source)) {
+ this["keycode"] = 0;
+ }
+ if (!("modifiers" in $$source)) {
+ this["modifiers"] = 0;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new Hotkey instance from a string or object.
+ */
+ static createFrom($$source: any = {}): Hotkey {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new Hotkey($$parsedSource as Partial);
+ }
+}
+
+export class LogStats {
+ "total_requests": number;
+ "input_tokens": number;
+ "output_tokens": number;
+ "reasoning_tokens": number;
+ "cache_create_tokens": number;
+ "cache_read_tokens": number;
+ "cost_total": number;
+ "cost_input": number;
+ "cost_output": number;
+ "cost_cache_create": number;
+ "cost_cache_read": number;
+ "series": LogStatsSeries[];
+
+ /** Creates a new LogStats instance. */
+ constructor($$source: Partial = {}) {
+ if (!("total_requests" in $$source)) {
+ this["total_requests"] = 0;
+ }
+ if (!("input_tokens" in $$source)) {
+ this["input_tokens"] = 0;
+ }
+ if (!("output_tokens" in $$source)) {
+ this["output_tokens"] = 0;
+ }
+ if (!("reasoning_tokens" in $$source)) {
+ this["reasoning_tokens"] = 0;
+ }
+ if (!("cache_create_tokens" in $$source)) {
+ this["cache_create_tokens"] = 0;
+ }
+ if (!("cache_read_tokens" in $$source)) {
+ this["cache_read_tokens"] = 0;
+ }
+ if (!("cost_total" in $$source)) {
+ this["cost_total"] = 0;
+ }
+ if (!("cost_input" in $$source)) {
+ this["cost_input"] = 0;
+ }
+ if (!("cost_output" in $$source)) {
+ this["cost_output"] = 0;
+ }
+ if (!("cost_cache_create" in $$source)) {
+ this["cost_cache_create"] = 0;
+ }
+ if (!("cost_cache_read" in $$source)) {
+ this["cost_cache_read"] = 0;
+ }
+ if (!("series" in $$source)) {
+ this["series"] = [];
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new LogStats instance from a string or object.
+ */
+ static createFrom($$source: any = {}): LogStats {
+ const $$createField11_0 = $$createType8;
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ if ("series" in $$parsedSource) {
+ $$parsedSource["series"] = $$createField11_0($$parsedSource["series"]);
+ }
+ return new LogStats($$parsedSource as Partial);
+ }
+}
+
+export class LogStatsSeries {
+ "day": string;
+ "total_requests": number;
+ "input_tokens": number;
+ "output_tokens": number;
+ "reasoning_tokens": number;
+ "cache_create_tokens": number;
+ "cache_read_tokens": number;
+ "total_cost": number;
+
+ /** Creates a new LogStatsSeries instance. */
+ constructor($$source: Partial = {}) {
+ if (!("day" in $$source)) {
+ this["day"] = "";
+ }
+ if (!("total_requests" in $$source)) {
+ this["total_requests"] = 0;
+ }
+ if (!("input_tokens" in $$source)) {
+ this["input_tokens"] = 0;
+ }
+ if (!("output_tokens" in $$source)) {
+ this["output_tokens"] = 0;
+ }
+ if (!("reasoning_tokens" in $$source)) {
+ this["reasoning_tokens"] = 0;
+ }
+ if (!("cache_create_tokens" in $$source)) {
+ this["cache_create_tokens"] = 0;
+ }
+ if (!("cache_read_tokens" in $$source)) {
+ this["cache_read_tokens"] = 0;
+ }
+ if (!("total_cost" in $$source)) {
+ this["total_cost"] = 0;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new LogStatsSeries instance from a string or object.
+ */
+ static createFrom($$source: any = {}): LogStatsSeries {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new LogStatsSeries($$parsedSource as Partial);
+ }
+}
+
+/**
+ * MCPParseResult JSON 解析结果
+ */
+export class MCPParseResult {
+ /**
+ * 解析出的服务器列表
+ */
+ "servers": MCPServer[];
+
+ /**
+ * 与现有配置冲突的名称
+ */
+ "conflicts": string[];
+
+ /**
+ * 是否需要用户提供名称(单服务器无名时)
+ */
+ "needName": boolean;
+
+ /** Creates a new MCPParseResult instance. */
+ constructor($$source: Partial = {}) {
+ if (!("servers" in $$source)) {
+ this["servers"] = [];
+ }
+ if (!("conflicts" in $$source)) {
+ this["conflicts"] = [];
+ }
+ if (!("needName" in $$source)) {
+ this["needName"] = false;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new MCPParseResult instance from a string or object.
+ */
+ static createFrom($$source: any = {}): MCPParseResult {
+ const $$createField0_0 = $$createType10;
+ const $$createField1_0 = $$createType11;
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ if ("servers" in $$parsedSource) {
+ $$parsedSource["servers"] = $$createField0_0($$parsedSource["servers"]);
+ }
+ if ("conflicts" in $$parsedSource) {
+ $$parsedSource["conflicts"] = $$createField1_0($$parsedSource["conflicts"]);
+ }
+ return new MCPParseResult($$parsedSource as Partial);
+ }
+}
+
+export class MCPServer {
+ "name": string;
+ "type": string;
+ "command"?: string;
+ "args"?: string[];
+ "env"?: { [_: string]: string };
+ "url"?: string;
+ "website"?: string;
+ "tips"?: string;
+ "enable_platform": string[];
+ "enabled_in_claude": boolean;
+ "enabled_in_codex": boolean;
+ "missing_placeholders": string[];
+
+ /** Creates a new MCPServer instance. */
+ constructor($$source: Partial = {}) {
+ if (!("name" in $$source)) {
+ this["name"] = "";
+ }
+ if (!("type" in $$source)) {
+ this["type"] = "";
+ }
+ if (!("enable_platform" in $$source)) {
+ this["enable_platform"] = [];
+ }
+ if (!("enabled_in_claude" in $$source)) {
+ this["enabled_in_claude"] = false;
+ }
+ if (!("enabled_in_codex" in $$source)) {
+ this["enabled_in_codex"] = false;
+ }
+ if (!("missing_placeholders" in $$source)) {
+ this["missing_placeholders"] = [];
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new MCPServer instance from a string or object.
+ */
+ static createFrom($$source: any = {}): MCPServer {
+ const $$createField3_0 = $$createType11;
+ const $$createField4_0 = $$createType4;
+ const $$createField8_0 = $$createType11;
+ const $$createField11_0 = $$createType11;
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ if ("args" in $$parsedSource) {
+ $$parsedSource["args"] = $$createField3_0($$parsedSource["args"]);
+ }
+ if ("env" in $$parsedSource) {
+ $$parsedSource["env"] = $$createField4_0($$parsedSource["env"]);
+ }
+ if ("enable_platform" in $$parsedSource) {
+ $$parsedSource["enable_platform"] = $$createField8_0($$parsedSource["enable_platform"]);
+ }
+ if ("missing_placeholders" in $$parsedSource) {
+ $$parsedSource["missing_placeholders"] = $$createField11_0($$parsedSource["missing_placeholders"]);
+ }
+ return new MCPServer($$parsedSource as Partial);
+ }
+}
+
+/**
+ * Prompt 自定义提示词
+ */
+export class Prompt {
+ "id": string;
+ "name": string;
+ "content": string;
+ "description"?: string | null;
+ "enabled": boolean;
+ "createdAt"?: number | null;
+ "updatedAt"?: number | null;
+
+ /** Creates a new Prompt instance. */
+ constructor($$source: Partial = {}) {
+ if (!("id" in $$source)) {
+ this["id"] = "";
+ }
+ if (!("name" in $$source)) {
+ this["name"] = "";
+ }
+ if (!("content" in $$source)) {
+ this["content"] = "";
+ }
+ if (!("enabled" in $$source)) {
+ this["enabled"] = false;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new Prompt instance from a string or object.
+ */
+ static createFrom($$source: any = {}): Prompt {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new Prompt($$parsedSource as Partial);
+ }
+}
+
+export class Provider {
+ /**
+ * 修复:使用 int64 支持大 ID 值
+ */
+ "id": number;
+ "name": string;
+ "apiUrl": string;
+ "apiKey": string;
+ "officialSite": string;
+ "icon": string;
+ "tint": string;
+ "accent": string;
+ "enabled": boolean;
+
+ /**
+ * 模型白名单 - Provider 原生支持的模型名
+ * 使用 map 实现 O(1) 查找,向后兼容(omitempty)
+ */
+ "supportedModels"?: { [_: string]: boolean };
+
+ /**
+ * 模型映射 - 外部模型名 -> Provider 内部模型名
+ * 支持精确匹配和通配符(如 "claude-*" -> "anthropic/claude-*")
+ */
+ "modelMapping"?: { [_: string]: string };
+
+ /**
+ * 优先级分组 - 数字越小优先级越高(1-10,默认 1)
+ * 使用 omitempty 确保零值不序列化,向后兼容
+ */
+ "level"?: number;
+
+ /**
+ * 连通性检测开关 - 是否启用自动连通性检测
+ */
+ "connectivityCheck"?: boolean;
+
+ /** Creates a new Provider instance. */
+ constructor($$source: Partial = {}) {
+ if (!("id" in $$source)) {
+ this["id"] = 0;
+ }
+ if (!("name" in $$source)) {
+ this["name"] = "";
+ }
+ if (!("apiUrl" in $$source)) {
+ this["apiUrl"] = "";
+ }
+ if (!("apiKey" in $$source)) {
+ this["apiKey"] = "";
+ }
+ if (!("officialSite" in $$source)) {
+ this["officialSite"] = "";
+ }
+ if (!("icon" in $$source)) {
+ this["icon"] = "";
+ }
+ if (!("tint" in $$source)) {
+ this["tint"] = "";
+ }
+ if (!("accent" in $$source)) {
+ this["accent"] = "";
+ }
+ if (!("enabled" in $$source)) {
+ this["enabled"] = false;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new Provider instance from a string or object.
+ */
+ static createFrom($$source: any = {}): Provider {
+ const $$createField9_0 = $$createType12;
+ const $$createField10_0 = $$createType4;
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ if ("supportedModels" in $$parsedSource) {
+ $$parsedSource["supportedModels"] = $$createField9_0($$parsedSource["supportedModels"]);
+ }
+ if ("modelMapping" in $$parsedSource) {
+ $$parsedSource["modelMapping"] = $$createField10_0($$parsedSource["modelMapping"]);
+ }
+ return new Provider($$parsedSource as Partial);
+ }
+}
+
+export class ProviderDailyStat {
+ "provider": string;
+ "total_requests": number;
+ "successful_requests": number;
+ "failed_requests": number;
+ "success_rate": number;
+ "input_tokens": number;
+ "output_tokens": number;
+ "reasoning_tokens": number;
+ "cache_create_tokens": number;
+ "cache_read_tokens": number;
+ "cost_total": number;
+
+ /** Creates a new ProviderDailyStat instance. */
+ constructor($$source: Partial = {}) {
+ if (!("provider" in $$source)) {
+ this["provider"] = "";
+ }
+ if (!("total_requests" in $$source)) {
+ this["total_requests"] = 0;
+ }
+ if (!("successful_requests" in $$source)) {
+ this["successful_requests"] = 0;
+ }
+ if (!("failed_requests" in $$source)) {
+ this["failed_requests"] = 0;
+ }
+ if (!("success_rate" in $$source)) {
+ this["success_rate"] = 0;
+ }
+ if (!("input_tokens" in $$source)) {
+ this["input_tokens"] = 0;
+ }
+ if (!("output_tokens" in $$source)) {
+ this["output_tokens"] = 0;
+ }
+ if (!("reasoning_tokens" in $$source)) {
+ this["reasoning_tokens"] = 0;
+ }
+ if (!("cache_create_tokens" in $$source)) {
+ this["cache_create_tokens"] = 0;
+ }
+ if (!("cache_read_tokens" in $$source)) {
+ this["cache_read_tokens"] = 0;
+ }
+ if (!("cost_total" in $$source)) {
+ this["cost_total"] = 0;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new ProviderDailyStat instance from a string or object.
+ */
+ static createFrom($$source: any = {}): ProviderDailyStat {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new ProviderDailyStat($$parsedSource as Partial);
+ }
+}
+
+export class ReqeustLog {
+ "id": number;
+
+ /**
+ * claude、codex 或 gemini
+ */
+ "platform": string;
+ "model": string;
+
+ /**
+ * provider name
+ */
+ "provider": string;
+ "http_code": number;
+ "input_tokens": number;
+ "output_tokens": number;
+ "cache_create_tokens": number;
+ "cache_read_tokens": number;
+ "reasoning_tokens": number;
+ "is_stream": boolean;
+ "duration_sec": number;
+ "created_at": string;
+ "input_cost": number;
+ "output_cost": number;
+ "reasoning_cost": number;
+ "cache_create_cost": number;
+ "cache_read_cost": number;
+ "ephemeral_5m_cost": number;
+ "ephemeral_1h_cost": number;
+ "total_cost": number;
+ "has_pricing": boolean;
+
+ /** Creates a new ReqeustLog instance. */
+ constructor($$source: Partial = {}) {
+ if (!("id" in $$source)) {
+ this["id"] = 0;
+ }
+ if (!("platform" in $$source)) {
+ this["platform"] = "";
+ }
+ if (!("model" in $$source)) {
+ this["model"] = "";
+ }
+ if (!("provider" in $$source)) {
+ this["provider"] = "";
+ }
+ if (!("http_code" in $$source)) {
+ this["http_code"] = 0;
+ }
+ if (!("input_tokens" in $$source)) {
+ this["input_tokens"] = 0;
+ }
+ if (!("output_tokens" in $$source)) {
+ this["output_tokens"] = 0;
+ }
+ if (!("cache_create_tokens" in $$source)) {
+ this["cache_create_tokens"] = 0;
+ }
+ if (!("cache_read_tokens" in $$source)) {
+ this["cache_read_tokens"] = 0;
+ }
+ if (!("reasoning_tokens" in $$source)) {
+ this["reasoning_tokens"] = 0;
+ }
+ if (!("is_stream" in $$source)) {
+ this["is_stream"] = false;
+ }
+ if (!("duration_sec" in $$source)) {
+ this["duration_sec"] = 0;
+ }
+ if (!("created_at" in $$source)) {
+ this["created_at"] = "";
+ }
+ if (!("input_cost" in $$source)) {
+ this["input_cost"] = 0;
+ }
+ if (!("output_cost" in $$source)) {
+ this["output_cost"] = 0;
+ }
+ if (!("reasoning_cost" in $$source)) {
+ this["reasoning_cost"] = 0;
+ }
+ if (!("cache_create_cost" in $$source)) {
+ this["cache_create_cost"] = 0;
+ }
+ if (!("cache_read_cost" in $$source)) {
+ this["cache_read_cost"] = 0;
+ }
+ if (!("ephemeral_5m_cost" in $$source)) {
+ this["ephemeral_5m_cost"] = 0;
+ }
+ if (!("ephemeral_1h_cost" in $$source)) {
+ this["ephemeral_1h_cost"] = 0;
+ }
+ if (!("total_cost" in $$source)) {
+ this["total_cost"] = 0;
+ }
+ if (!("has_pricing" in $$source)) {
+ this["has_pricing"] = false;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new ReqeustLog instance from a string or object.
+ */
+ static createFrom($$source: any = {}): ReqeustLog {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new ReqeustLog($$parsedSource as Partial);
+ }
+}
+
+export class Skill {
+ "key": string;
+ "name": string;
+ "description": string;
+ "directory": string;
+ "readme_url": string;
+ "installed": boolean;
+ "repo_owner"?: string;
+ "repo_name"?: string;
+ "repo_branch"?: string;
+
+ /** Creates a new Skill instance. */
+ constructor($$source: Partial = {}) {
+ if (!("key" in $$source)) {
+ this["key"] = "";
+ }
+ if (!("name" in $$source)) {
+ this["name"] = "";
+ }
+ if (!("description" in $$source)) {
+ this["description"] = "";
+ }
+ if (!("directory" in $$source)) {
+ this["directory"] = "";
+ }
+ if (!("readme_url" in $$source)) {
+ this["readme_url"] = "";
+ }
+ if (!("installed" in $$source)) {
+ this["installed"] = false;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new Skill instance from a string or object.
+ */
+ static createFrom($$source: any = {}): Skill {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new Skill($$parsedSource as Partial);
+ }
+}
+
+/**
+ * UpdateInfo 更新信息
+ */
+export class UpdateInfo {
+ "available": boolean;
+ "version": string;
+ "download_url": string;
+ "release_notes": string;
+ "file_size": number;
+ "sha256": string;
+
+ /** Creates a new UpdateInfo instance. */
+ constructor($$source: Partial = {}) {
+ if (!("available" in $$source)) {
+ this["available"] = false;
+ }
+ if (!("version" in $$source)) {
+ this["version"] = "";
+ }
+ if (!("download_url" in $$source)) {
+ this["download_url"] = "";
+ }
+ if (!("release_notes" in $$source)) {
+ this["release_notes"] = "";
+ }
+ if (!("file_size" in $$source)) {
+ this["file_size"] = 0;
+ }
+ if (!("sha256" in $$source)) {
+ this["sha256"] = "";
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new UpdateInfo instance from a string or object.
+ */
+ static createFrom($$source: any = {}): UpdateInfo {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new UpdateInfo($$parsedSource as Partial);
+ }
+}
+
+/**
+ * UpdateState 更新状态
+ */
+export class UpdateState {
+ "last_check_time": time$0.Time;
+ "last_check_success": boolean;
+ "consecutive_failures": number;
+ "latest_known_version": string;
+ "download_progress": number;
+ "update_ready": boolean;
+
+ /**
+ * 新增:持久化自动检查开关
+ */
+ "auto_check_enabled": boolean;
+
+ /** Creates a new UpdateState instance. */
+ constructor($$source: Partial = {}) {
+ if (!("last_check_time" in $$source)) {
+ this["last_check_time"] = null;
+ }
+ if (!("last_check_success" in $$source)) {
+ this["last_check_success"] = false;
+ }
+ if (!("consecutive_failures" in $$source)) {
+ this["consecutive_failures"] = 0;
+ }
+ if (!("latest_known_version" in $$source)) {
+ this["latest_known_version"] = "";
+ }
+ if (!("download_progress" in $$source)) {
+ this["download_progress"] = 0;
+ }
+ if (!("update_ready" in $$source)) {
+ this["update_ready"] = false;
+ }
+ if (!("auto_check_enabled" in $$source)) {
+ this["auto_check_enabled"] = false;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new UpdateState instance from a string or object.
+ */
+ static createFrom($$source: any = {}): UpdateState {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new UpdateState($$parsedSource as Partial);
+ }
+}
+
+export class installRequest {
+ "directory": string;
+ "repo_owner": string;
+ "repo_name": string;
+ "repo_branch": string;
+
+ /** Creates a new installRequest instance. */
+ constructor($$source: Partial = {}) {
+ if (!("directory" in $$source)) {
+ this["directory"] = "";
+ }
+ if (!("repo_owner" in $$source)) {
+ this["repo_owner"] = "";
+ }
+ if (!("repo_name" in $$source)) {
+ this["repo_name"] = "";
+ }
+ if (!("repo_branch" in $$source)) {
+ this["repo_branch"] = "";
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new installRequest instance from a string or object.
+ */
+ static createFrom($$source: any = {}): installRequest {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new installRequest($$parsedSource as Partial);
+ }
+}
+
+export class skillRepoConfig {
+ "owner": string;
+ "name": string;
+ "branch": string;
+ "enabled": boolean;
+
+ /** Creates a new skillRepoConfig instance. */
+ constructor($$source: Partial = {}) {
+ if (!("owner" in $$source)) {
+ this["owner"] = "";
+ }
+ if (!("name" in $$source)) {
+ this["name"] = "";
+ }
+ if (!("branch" in $$source)) {
+ this["branch"] = "";
+ }
+ if (!("enabled" in $$source)) {
+ this["enabled"] = false;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new skillRepoConfig instance from a string or object.
+ */
+ static createFrom($$source: any = {}): skillRepoConfig {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new skillRepoConfig($$parsedSource as Partial);
+ }
+}
+
+// Private type creation functions
+const $$createType0 = CLIConfigField.createFrom;
+const $$createType1 = $Create.Array($$createType0);
+const $$createType2 = CLIConfigFile.createFrom;
+const $$createType3 = $Create.Array($$createType2);
+const $$createType4 = $Create.Map($Create.Any, $Create.Any);
+const $$createType5 = $Create.Map($Create.Any, $Create.Any);
+const $$createType6 = ConfigImportStatus.createFrom;
+const $$createType7 = LogStatsSeries.createFrom;
+const $$createType8 = $Create.Array($$createType7);
+const $$createType9 = MCPServer.createFrom;
+const $$createType10 = $Create.Array($$createType9);
+const $$createType11 = $Create.Array($Create.Any);
+const $$createType12 = $Create.Map($Create.Any, $Create.Any);
diff --git a/frontend/bindings/codeswitch/services/promptservice.ts b/frontend/bindings/codeswitch/services/promptservice.ts
new file mode 100644
index 0000000..c3a5b3f
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/promptservice.ts
@@ -0,0 +1,77 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * PromptService 提示词管理服务
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * DeletePrompt 删除提示词
+ */
+export function DeletePrompt(platform: string, id: string): $CancellablePromise {
+ return $Call.ByID(233228333, platform, id);
+}
+
+/**
+ * EnablePrompt 启用指定提示词(会禁用其他所有提示词)
+ */
+export function EnablePrompt(platform: string, id: string): $CancellablePromise {
+ return $Call.ByID(4030428505, platform, id);
+}
+
+/**
+ * GetCurrentFileContent 获取当前提示词文件内容
+ */
+export function GetCurrentFileContent(platform: string): $CancellablePromise {
+ return $Call.ByID(3179736812, platform);
+}
+
+/**
+ * GetPrompts 获取指定平台的所有提示词
+ */
+export function GetPrompts(platform: string): $CancellablePromise<{ [_: string]: $models.Prompt }> {
+ return $Call.ByID(709954839, platform).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * ImportFromFile 从现有文件导入提示词
+ */
+export function ImportFromFile(platform: string): $CancellablePromise {
+ return $Call.ByID(3165650383, platform);
+}
+
+/**
+ * Start Wails生命周期方法
+ */
+export function Start(): $CancellablePromise {
+ return $Call.ByID(3537442032);
+}
+
+/**
+ * Stop Wails生命周期方法
+ */
+export function Stop(): $CancellablePromise {
+ return $Call.ByID(3274727284);
+}
+
+/**
+ * UpsertPrompt 添加或更新提示词
+ */
+export function UpsertPrompt(platform: string, id: string, prompt: $models.Prompt): $CancellablePromise {
+ return $Call.ByID(1167812421, platform, id, prompt);
+}
+
+// Private type creation functions
+const $$createType0 = $models.Prompt.createFrom;
+const $$createType1 = $Create.Map($Create.Any, $$createType0);
diff --git a/frontend/bindings/codeswitch/services/providerservice.ts b/frontend/bindings/codeswitch/services/providerservice.ts
new file mode 100644
index 0000000..7c44e3f
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/providerservice.ts
@@ -0,0 +1,43 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * DuplicateProvider 复制供应商配置,生成新的副本
+ * 返回新创建的 Provider 对象
+ */
+export function DuplicateProvider(kind: string, sourceID: number): $CancellablePromise<$models.Provider | null> {
+ return $Call.ByID(1332089125, kind, sourceID).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+export function LoadProviders(kind: string): $CancellablePromise<$models.Provider[]> {
+ return $Call.ByID(3413098935, kind).then(($result: any) => {
+ return $$createType2($result);
+ });
+}
+
+export function SaveProviders(kind: string, providers: $models.Provider[]): $CancellablePromise {
+ return $Call.ByID(1034860836, kind, providers);
+}
+
+export function Start(): $CancellablePromise {
+ return $Call.ByID(194327613);
+}
+
+export function Stop(): $CancellablePromise {
+ return $Call.ByID(291391407);
+}
+
+// Private type creation functions
+const $$createType0 = $models.Provider.createFrom;
+const $$createType1 = $Create.Nullable($$createType0);
+const $$createType2 = $Create.Array($$createType0);
diff --git a/frontend/bindings/codeswitch/services/settingsservice.ts b/frontend/bindings/codeswitch/services/settingsservice.ts
new file mode 100644
index 0000000..1b1b180
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/settingsservice.ts
@@ -0,0 +1,97 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * SettingsService 管理全局配置
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * GetBlacklistLevelConfig 获取等级拉黑配置
+ * 【修复】开关状态从数据库读取,其他配置从 JSON 文件读取
+ */
+export function GetBlacklistLevelConfig(): $CancellablePromise<$models.BlacklistLevelConfig | null> {
+ return $Call.ByID(3210427164).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * GetBlacklistSettings 获取黑名单配置
+ */
+export function GetBlacklistSettings(): $CancellablePromise<[number, number]> {
+ return $Call.ByID(605514929);
+}
+
+/**
+ * GetBlacklistSettingsStruct 获取黑名单配置(结构体形式,用于前端)
+ */
+export function GetBlacklistSettingsStruct(): $CancellablePromise<$models.BlacklistSettings | null> {
+ return $Call.ByID(981449460).then(($result: any) => {
+ return $$createType3($result);
+ });
+}
+
+/**
+ * GetLevelBlacklistEnabled 获取等级拉黑开关状态
+ */
+export function GetLevelBlacklistEnabled(): $CancellablePromise {
+ return $Call.ByID(458497461);
+}
+
+/**
+ * IsBlacklistEnabled 检查拉黑功能是否启用
+ */
+export function IsBlacklistEnabled(): $CancellablePromise {
+ return $Call.ByID(3405992751);
+}
+
+/**
+ * SaveBlacklistLevelConfig 保存等级拉黑配置
+ */
+export function SaveBlacklistLevelConfig(config: $models.BlacklistLevelConfig | null): $CancellablePromise {
+ return $Call.ByID(2798699223, config);
+}
+
+/**
+ * SetLevelBlacklistEnabled 设置等级拉黑开关状态
+ */
+export function SetLevelBlacklistEnabled(enabled: boolean): $CancellablePromise {
+ return $Call.ByID(3234655753, enabled);
+}
+
+/**
+ * UpdateBlacklistEnabled 更新拉黑功能开关
+ */
+export function UpdateBlacklistEnabled(enabled: boolean): $CancellablePromise {
+ return $Call.ByID(2636303210, enabled);
+}
+
+/**
+ * UpdateBlacklistLevelConfig 更新等级拉黑配置
+ */
+export function UpdateBlacklistLevelConfig(config: $models.BlacklistLevelConfig | null): $CancellablePromise {
+ return $Call.ByID(3815895291, config);
+}
+
+/**
+ * UpdateBlacklistSettings 更新黑名单配置
+ * 使用 Saga 模式保证数据一致性(因队列无法使用事务)
+ */
+export function UpdateBlacklistSettings(threshold: number, duration: number): $CancellablePromise {
+ return $Call.ByID(833260620, threshold, duration);
+}
+
+// Private type creation functions
+const $$createType0 = $models.BlacklistLevelConfig.createFrom;
+const $$createType1 = $Create.Nullable($$createType0);
+const $$createType2 = $models.BlacklistSettings.createFrom;
+const $$createType3 = $Create.Nullable($$createType2);
diff --git a/frontend/bindings/codeswitch/services/skillservice.ts b/frontend/bindings/codeswitch/services/skillservice.ts
new file mode 100644
index 0000000..53bf7f4
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/skillservice.ts
@@ -0,0 +1,54 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+export function AddRepo(repo: $models.skillRepoConfig): $CancellablePromise<$models.skillRepoConfig[]> {
+ return $Call.ByID(2020364064, repo).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * InstallSkill installs a skill directory from the configured repositories.
+ */
+export function InstallSkill(req: $models.installRequest): $CancellablePromise {
+ return $Call.ByID(2924128557, req);
+}
+
+export function ListRepos(): $CancellablePromise<$models.skillRepoConfig[]> {
+ return $Call.ByID(1704737214).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * ListSkills aggregates skills from configured repositories and the local install directory.
+ */
+export function ListSkills(): $CancellablePromise<$models.Skill[]> {
+ return $Call.ByID(3387382203).then(($result: any) => {
+ return $$createType3($result);
+ });
+}
+
+export function RemoveRepo(owner: string, name: string): $CancellablePromise<$models.skillRepoConfig[]> {
+ return $Call.ByID(2067147157, owner, name).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+export function UninstallSkill(directory: string): $CancellablePromise {
+ return $Call.ByID(3488362258, directory);
+}
+
+// Private type creation functions
+const $$createType0 = $models.skillRepoConfig.createFrom;
+const $$createType1 = $Create.Array($$createType0);
+const $$createType2 = $models.Skill.createFrom;
+const $$createType3 = $Create.Array($$createType2);
diff --git a/frontend/bindings/codeswitch/services/speedtestservice.ts b/frontend/bindings/codeswitch/services/speedtestservice.ts
new file mode 100644
index 0000000..1c97cec
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/speedtestservice.ts
@@ -0,0 +1,43 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * SpeedTestService 测速服务
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * Start Wails生命周期方法
+ */
+export function Start(): $CancellablePromise {
+ return $Call.ByID(336583549);
+}
+
+/**
+ * Stop Wails生命周期方法
+ */
+export function Stop(): $CancellablePromise {
+ return $Call.ByID(4077816815);
+}
+
+/**
+ * TestEndpoints 测试一组端点的响应延迟
+ * 使用并发请求,每个端点先进行一次热身请求,再测量第二次请求的延迟
+ */
+export function TestEndpoints(urls: string[], timeoutSecs: number | null): $CancellablePromise<$models.EndpointLatency[]> {
+ return $Call.ByID(846920271, urls, timeoutSecs).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+// Private type creation functions
+const $$createType0 = $models.EndpointLatency.createFrom;
+const $$createType1 = $Create.Array($$createType0);
diff --git a/frontend/bindings/codeswitch/services/suistore.ts b/frontend/bindings/codeswitch/services/suistore.ts
new file mode 100644
index 0000000..4dde753
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/suistore.ts
@@ -0,0 +1,39 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+export function Close(): $CancellablePromise {
+ return $Call.ByID(1153054707);
+}
+
+export function GetHotkeys(): $CancellablePromise<$models.Hotkey[]> {
+ return $Call.ByID(1424005354).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+export function Start(): $CancellablePromise {
+ return $Call.ByID(3913774415);
+}
+
+export function Stop(): $CancellablePromise {
+ return $Call.ByID(178847253);
+}
+
+/**
+ * 快捷键修改
+ */
+export function UpHotkey(id: number, key: number, modifier: number): $CancellablePromise {
+ return $Call.ByID(2444022874, id, key, modifier);
+}
+
+// Private type creation functions
+const $$createType0 = $models.Hotkey.createFrom;
+const $$createType1 = $Create.Array($$createType0);
diff --git a/frontend/bindings/codeswitch/services/updateservice.ts b/frontend/bindings/codeswitch/services/updateservice.ts
new file mode 100644
index 0000000..faa2b34
--- /dev/null
+++ b/frontend/bindings/codeswitch/services/updateservice.ts
@@ -0,0 +1,111 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * UpdateService 更新服务
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * ApplyUpdate 应用更新(启动时调用)
+ * 添加更新锁防止并发,SHA256 校验防止损坏文件
+ */
+export function ApplyUpdate(): $CancellablePromise {
+ return $Call.ByID(463634848);
+}
+
+/**
+ * CheckUpdate 检查更新(带网络容错)
+ */
+export function CheckUpdate(): $CancellablePromise<$models.UpdateInfo | null> {
+ return $Call.ByID(3762820960).then(($result: any) => {
+ return $$createType1($result);
+ });
+}
+
+/**
+ * CheckUpdateAsync 异步检查更新
+ */
+export function CheckUpdateAsync(): $CancellablePromise {
+ return $Call.ByID(3775462276);
+}
+
+/**
+ * DownloadUpdate 下载更新文件(支持更新锁、重试、断点续传、SHA256校验)
+ */
+export function DownloadUpdate(progressCallback: any): $CancellablePromise {
+ return $Call.ByID(306369946, progressCallback);
+}
+
+/**
+ * GetUpdateState 获取更新状态
+ */
+export function GetUpdateState(): $CancellablePromise<$models.UpdateState | null> {
+ return $Call.ByID(1467420717).then(($result: any) => {
+ return $$createType3($result);
+ });
+}
+
+/**
+ * IsAutoCheckEnabled 是否启用自动检查
+ */
+export function IsAutoCheckEnabled(): $CancellablePromise {
+ return $Call.ByID(1821512081);
+}
+
+/**
+ * LoadState 加载状态
+ */
+export function LoadState(): $CancellablePromise {
+ return $Call.ByID(3886427880);
+}
+
+/**
+ * PrepareUpdate 准备更新
+ */
+export function PrepareUpdate(): $CancellablePromise {
+ return $Call.ByID(2497597027);
+}
+
+/**
+ * RestartApp 重启应用
+ * 如果有待安装的更新,会先触发更新流程(Windows 安装版会请求 UAC)
+ */
+export function RestartApp(): $CancellablePromise {
+ return $Call.ByID(1315746061);
+}
+
+/**
+ * SaveState 保存状态
+ */
+export function SaveState(): $CancellablePromise {
+ return $Call.ByID(2602461199);
+}
+
+/**
+ * SetAutoCheckEnabled 设置是否启用自动检查
+ */
+export function SetAutoCheckEnabled(enabled: boolean): $CancellablePromise {
+ return $Call.ByID(629868339, enabled);
+}
+
+/**
+ * StartDailyCheck 启动每日8点定时检查
+ */
+export function StartDailyCheck(): $CancellablePromise {
+ return $Call.ByID(1615992142);
+}
+
+// Private type creation functions
+const $$createType0 = $models.UpdateInfo.createFrom;
+const $$createType1 = $Create.Nullable($$createType0);
+const $$createType2 = $models.UpdateState.createFrom;
+const $$createType3 = $Create.Nullable($$createType2);
diff --git a/frontend/bindings/codeswitch/versionservice.ts b/frontend/bindings/codeswitch/versionservice.ts
new file mode 100644
index 0000000..ef4285d
--- /dev/null
+++ b/frontend/bindings/codeswitch/versionservice.ts
@@ -0,0 +1,10 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+export function CurrentVersion(): $CancellablePromise {
+ return $Call.ByID(2619754594);
+}
diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts
new file mode 100644
index 0000000..1ea1058
--- /dev/null
+++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts
@@ -0,0 +1,9 @@
+//@ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Create as $Create } from "@wailsio/runtime";
+
+Object.freeze($Create.Events);
diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
new file mode 100644
index 0000000..3dd1807
--- /dev/null
+++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
@@ -0,0 +1,2 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/pkg/application/index.ts b/frontend/bindings/github.com/wailsapp/wails/v3/pkg/application/index.ts
new file mode 100644
index 0000000..b8aaea1
--- /dev/null
+++ b/frontend/bindings/github.com/wailsapp/wails/v3/pkg/application/index.ts
@@ -0,0 +1,17 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+export {
+ App,
+ BrowserManager,
+ ClipboardManager,
+ ContextMenuManager,
+ DialogManager,
+ EnvironmentManager,
+ EventManager,
+ KeyBindingManager,
+ MenuManager,
+ ScreenManager,
+ SystemTrayManager,
+ WindowManager
+} from "./models.js";
diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/pkg/application/models.ts b/frontend/bindings/github.com/wailsapp/wails/v3/pkg/application/models.ts
new file mode 100644
index 0000000..bf9925b
--- /dev/null
+++ b/frontend/bindings/github.com/wailsapp/wails/v3/pkg/application/models.ts
@@ -0,0 +1,369 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as slog$0 from "../../../../../../log/slog/models.js";
+
+export class App {
+ /**
+ * Manager pattern for organized API
+ */
+ "Window": WindowManager | null;
+ "ContextMenu": ContextMenuManager | null;
+ "KeyBinding": KeyBindingManager | null;
+ "Browser": BrowserManager | null;
+ "Env": EnvironmentManager | null;
+ "Dialog": DialogManager | null;
+ "Event": EventManager | null;
+ "Menu": MenuManager | null;
+ "Screen": ScreenManager | null;
+ "Clipboard": ClipboardManager | null;
+ "SystemTray": SystemTrayManager | null;
+ "Logger": slog$0.Logger | null;
+
+ /** Creates a new App instance. */
+ constructor($$source: Partial = {}) {
+ if (!("Window" in $$source)) {
+ this["Window"] = null;
+ }
+ if (!("ContextMenu" in $$source)) {
+ this["ContextMenu"] = null;
+ }
+ if (!("KeyBinding" in $$source)) {
+ this["KeyBinding"] = null;
+ }
+ if (!("Browser" in $$source)) {
+ this["Browser"] = null;
+ }
+ if (!("Env" in $$source)) {
+ this["Env"] = null;
+ }
+ if (!("Dialog" in $$source)) {
+ this["Dialog"] = null;
+ }
+ if (!("Event" in $$source)) {
+ this["Event"] = null;
+ }
+ if (!("Menu" in $$source)) {
+ this["Menu"] = null;
+ }
+ if (!("Screen" in $$source)) {
+ this["Screen"] = null;
+ }
+ if (!("Clipboard" in $$source)) {
+ this["Clipboard"] = null;
+ }
+ if (!("SystemTray" in $$source)) {
+ this["SystemTray"] = null;
+ }
+ if (!("Logger" in $$source)) {
+ this["Logger"] = null;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new App instance from a string or object.
+ */
+ static createFrom($$source: any = {}): App {
+ const $$createField0_0 = $$createType1;
+ const $$createField1_0 = $$createType3;
+ const $$createField2_0 = $$createType5;
+ const $$createField3_0 = $$createType7;
+ const $$createField4_0 = $$createType9;
+ const $$createField5_0 = $$createType11;
+ const $$createField6_0 = $$createType13;
+ const $$createField7_0 = $$createType15;
+ const $$createField8_0 = $$createType17;
+ const $$createField9_0 = $$createType19;
+ const $$createField10_0 = $$createType21;
+ const $$createField11_0 = $$createType23;
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ if ("Window" in $$parsedSource) {
+ $$parsedSource["Window"] = $$createField0_0($$parsedSource["Window"]);
+ }
+ if ("ContextMenu" in $$parsedSource) {
+ $$parsedSource["ContextMenu"] = $$createField1_0($$parsedSource["ContextMenu"]);
+ }
+ if ("KeyBinding" in $$parsedSource) {
+ $$parsedSource["KeyBinding"] = $$createField2_0($$parsedSource["KeyBinding"]);
+ }
+ if ("Browser" in $$parsedSource) {
+ $$parsedSource["Browser"] = $$createField3_0($$parsedSource["Browser"]);
+ }
+ if ("Env" in $$parsedSource) {
+ $$parsedSource["Env"] = $$createField4_0($$parsedSource["Env"]);
+ }
+ if ("Dialog" in $$parsedSource) {
+ $$parsedSource["Dialog"] = $$createField5_0($$parsedSource["Dialog"]);
+ }
+ if ("Event" in $$parsedSource) {
+ $$parsedSource["Event"] = $$createField6_0($$parsedSource["Event"]);
+ }
+ if ("Menu" in $$parsedSource) {
+ $$parsedSource["Menu"] = $$createField7_0($$parsedSource["Menu"]);
+ }
+ if ("Screen" in $$parsedSource) {
+ $$parsedSource["Screen"] = $$createField8_0($$parsedSource["Screen"]);
+ }
+ if ("Clipboard" in $$parsedSource) {
+ $$parsedSource["Clipboard"] = $$createField9_0($$parsedSource["Clipboard"]);
+ }
+ if ("SystemTray" in $$parsedSource) {
+ $$parsedSource["SystemTray"] = $$createField10_0($$parsedSource["SystemTray"]);
+ }
+ if ("Logger" in $$parsedSource) {
+ $$parsedSource["Logger"] = $$createField11_0($$parsedSource["Logger"]);
+ }
+ return new App($$parsedSource as Partial);
+ }
+}
+
+/**
+ * BrowserManager manages browser-related operations
+ */
+export class BrowserManager {
+
+ /** Creates a new BrowserManager instance. */
+ constructor($$source: Partial = {}) {
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new BrowserManager instance from a string or object.
+ */
+ static createFrom($$source: any = {}): BrowserManager {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new BrowserManager($$parsedSource as Partial);
+ }
+}
+
+/**
+ * ClipboardManager manages clipboard operations
+ */
+export class ClipboardManager {
+
+ /** Creates a new ClipboardManager instance. */
+ constructor($$source: Partial = {}) {
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new ClipboardManager instance from a string or object.
+ */
+ static createFrom($$source: any = {}): ClipboardManager {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new ClipboardManager($$parsedSource as Partial);
+ }
+}
+
+/**
+ * ContextMenuManager manages all context menu operations
+ */
+export class ContextMenuManager {
+
+ /** Creates a new ContextMenuManager instance. */
+ constructor($$source: Partial = {}) {
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new ContextMenuManager instance from a string or object.
+ */
+ static createFrom($$source: any = {}): ContextMenuManager {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new ContextMenuManager($$parsedSource as Partial);
+ }
+}
+
+/**
+ * DialogManager manages dialog-related operations
+ */
+export class DialogManager {
+
+ /** Creates a new DialogManager instance. */
+ constructor($$source: Partial = {}) {
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new DialogManager instance from a string or object.
+ */
+ static createFrom($$source: any = {}): DialogManager {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new DialogManager($$parsedSource as Partial);
+ }
+}
+
+/**
+ * EnvironmentManager manages environment-related operations
+ */
+export class EnvironmentManager {
+
+ /** Creates a new EnvironmentManager instance. */
+ constructor($$source: Partial = {}) {
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new EnvironmentManager instance from a string or object.
+ */
+ static createFrom($$source: any = {}): EnvironmentManager {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new EnvironmentManager($$parsedSource as Partial);
+ }
+}
+
+/**
+ * EventManager manages event-related operations
+ */
+export class EventManager {
+
+ /** Creates a new EventManager instance. */
+ constructor($$source: Partial = {}) {
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new EventManager instance from a string or object.
+ */
+ static createFrom($$source: any = {}): EventManager {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new EventManager($$parsedSource as Partial);
+ }
+}
+
+/**
+ * KeyBindingManager manages all key binding operations
+ */
+export class KeyBindingManager {
+
+ /** Creates a new KeyBindingManager instance. */
+ constructor($$source: Partial = {}) {
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new KeyBindingManager instance from a string or object.
+ */
+ static createFrom($$source: any = {}): KeyBindingManager {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new KeyBindingManager($$parsedSource as Partial);
+ }
+}
+
+/**
+ * MenuManager manages menu-related operations
+ */
+export class MenuManager {
+
+ /** Creates a new MenuManager instance. */
+ constructor($$source: Partial = {}) {
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new MenuManager instance from a string or object.
+ */
+ static createFrom($$source: any = {}): MenuManager {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new MenuManager($$parsedSource as Partial);
+ }
+}
+
+export class ScreenManager {
+
+ /** Creates a new ScreenManager instance. */
+ constructor($$source: Partial = {}) {
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new ScreenManager instance from a string or object.
+ */
+ static createFrom($$source: any = {}): ScreenManager {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new ScreenManager($$parsedSource as Partial);
+ }
+}
+
+/**
+ * SystemTrayManager manages system tray-related operations
+ */
+export class SystemTrayManager {
+
+ /** Creates a new SystemTrayManager instance. */
+ constructor($$source: Partial = {}) {
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new SystemTrayManager instance from a string or object.
+ */
+ static createFrom($$source: any = {}): SystemTrayManager {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new SystemTrayManager($$parsedSource as Partial);
+ }
+}
+
+/**
+ * WindowManager manages all window-related operations
+ */
+export class WindowManager {
+
+ /** Creates a new WindowManager instance. */
+ constructor($$source: Partial = {}) {
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new WindowManager instance from a string or object.
+ */
+ static createFrom($$source: any = {}): WindowManager {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new WindowManager($$parsedSource as Partial);
+ }
+}
+
+// Private type creation functions
+const $$createType0 = WindowManager.createFrom;
+const $$createType1 = $Create.Nullable($$createType0);
+const $$createType2 = ContextMenuManager.createFrom;
+const $$createType3 = $Create.Nullable($$createType2);
+const $$createType4 = KeyBindingManager.createFrom;
+const $$createType5 = $Create.Nullable($$createType4);
+const $$createType6 = BrowserManager.createFrom;
+const $$createType7 = $Create.Nullable($$createType6);
+const $$createType8 = EnvironmentManager.createFrom;
+const $$createType9 = $Create.Nullable($$createType8);
+const $$createType10 = DialogManager.createFrom;
+const $$createType11 = $Create.Nullable($$createType10);
+const $$createType12 = EventManager.createFrom;
+const $$createType13 = $Create.Nullable($$createType12);
+const $$createType14 = MenuManager.createFrom;
+const $$createType15 = $Create.Nullable($$createType14);
+const $$createType16 = ScreenManager.createFrom;
+const $$createType17 = $Create.Nullable($$createType16);
+const $$createType18 = ClipboardManager.createFrom;
+const $$createType19 = $Create.Nullable($$createType18);
+const $$createType20 = SystemTrayManager.createFrom;
+const $$createType21 = $Create.Nullable($$createType20);
+const $$createType22 = slog$0.Logger.createFrom;
+const $$createType23 = $Create.Nullable($$createType22);
diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/dock/dockservice.ts b/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/dock/dockservice.ts
new file mode 100644
index 0000000..e493b3f
--- /dev/null
+++ b/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/dock/dockservice.ts
@@ -0,0 +1,50 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * Service represents the dock service
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * HideAppIcon hides the app icon in the dock/taskbar.
+ */
+export function HideAppIcon(): $CancellablePromise {
+ return $Call.ByID(3413658144);
+}
+
+/**
+ * RemoveBadge removes the badge label from the application icon.
+ */
+export function RemoveBadge(): $CancellablePromise {
+ return $Call.ByID(2752757297);
+}
+
+/**
+ * SetBadge sets the badge label on the application icon.
+ */
+export function SetBadge(label: string): $CancellablePromise {
+ return $Call.ByID(1717705661, label);
+}
+
+/**
+ * SetCustomBadge sets the badge label on the application icon with custom options.
+ */
+export function SetCustomBadge(label: string, options: $models.BadgeOptions): $CancellablePromise {
+ return $Call.ByID(2730169760, label, options);
+}
+
+/**
+ * ShowAppIcon shows the app icon in the dock/taskbar.
+ */
+export function ShowAppIcon(): $CancellablePromise {
+ return $Call.ByID(3409697379);
+}
diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/dock/index.ts b/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/dock/index.ts
new file mode 100644
index 0000000..fbdaf19
--- /dev/null
+++ b/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/dock/index.ts
@@ -0,0 +1,11 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+import * as DockService from "./dockservice.js";
+export {
+ DockService
+};
+
+export {
+ BadgeOptions
+} from "./models.js";
diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/dock/models.ts b/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/dock/models.ts
new file mode 100644
index 0000000..f97c8a4
--- /dev/null
+++ b/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/dock/models.ts
@@ -0,0 +1,61 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as color$0 from "../../../../../../../image/color/models.js";
+
+/**
+ * BadgeOptions represents options for customizing badge appearance
+ */
+export class BadgeOptions {
+ "TextColour": color$0.RGBA;
+ "BackgroundColour": color$0.RGBA;
+ "FontName": string;
+ "FontSize": number;
+ "SmallFontSize": number;
+
+ /** Creates a new BadgeOptions instance. */
+ constructor($$source: Partial = {}) {
+ if (!("TextColour" in $$source)) {
+ this["TextColour"] = (new color$0.RGBA());
+ }
+ if (!("BackgroundColour" in $$source)) {
+ this["BackgroundColour"] = (new color$0.RGBA());
+ }
+ if (!("FontName" in $$source)) {
+ this["FontName"] = "";
+ }
+ if (!("FontSize" in $$source)) {
+ this["FontSize"] = 0;
+ }
+ if (!("SmallFontSize" in $$source)) {
+ this["SmallFontSize"] = 0;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new BadgeOptions instance from a string or object.
+ */
+ static createFrom($$source: any = {}): BadgeOptions {
+ const $$createField0_0 = $$createType0;
+ const $$createField1_0 = $$createType0;
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ if ("TextColour" in $$parsedSource) {
+ $$parsedSource["TextColour"] = $$createField0_0($$parsedSource["TextColour"]);
+ }
+ if ("BackgroundColour" in $$parsedSource) {
+ $$parsedSource["BackgroundColour"] = $$createField1_0($$parsedSource["BackgroundColour"]);
+ }
+ return new BadgeOptions($$parsedSource as Partial);
+ }
+}
+
+// Private type creation functions
+const $$createType0 = color$0.RGBA.createFrom;
diff --git a/frontend/bindings/image/color/index.ts b/frontend/bindings/image/color/index.ts
new file mode 100644
index 0000000..97b507b
--- /dev/null
+++ b/frontend/bindings/image/color/index.ts
@@ -0,0 +1,6 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+export {
+ RGBA
+} from "./models.js";
diff --git a/frontend/bindings/image/color/models.ts b/frontend/bindings/image/color/models.ts
new file mode 100644
index 0000000..0d4eab5
--- /dev/null
+++ b/frontend/bindings/image/color/models.ts
@@ -0,0 +1,46 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Create as $Create } from "@wailsio/runtime";
+
+/**
+ * RGBA represents a traditional 32-bit alpha-premultiplied color, having 8
+ * bits for each of red, green, blue and alpha.
+ *
+ * An alpha-premultiplied color component C has been scaled by alpha (A), so
+ * has valid values 0 <= C <= A.
+ */
+export class RGBA {
+ "R": number;
+ "G": number;
+ "B": number;
+ "A": number;
+
+ /** Creates a new RGBA instance. */
+ constructor($$source: Partial = {}) {
+ if (!("R" in $$source)) {
+ this["R"] = 0;
+ }
+ if (!("G" in $$source)) {
+ this["G"] = 0;
+ }
+ if (!("B" in $$source)) {
+ this["B"] = 0;
+ }
+ if (!("A" in $$source)) {
+ this["A"] = 0;
+ }
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new RGBA instance from a string or object.
+ */
+ static createFrom($$source: any = {}): RGBA {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new RGBA($$parsedSource as Partial);
+ }
+}
diff --git a/frontend/bindings/log/slog/index.ts b/frontend/bindings/log/slog/index.ts
new file mode 100644
index 0000000..28f9022
--- /dev/null
+++ b/frontend/bindings/log/slog/index.ts
@@ -0,0 +1,6 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+export {
+ Logger
+} from "./models.js";
diff --git a/frontend/bindings/log/slog/models.ts b/frontend/bindings/log/slog/models.ts
new file mode 100644
index 0000000..ef606c6
--- /dev/null
+++ b/frontend/bindings/log/slog/models.ts
@@ -0,0 +1,31 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Create as $Create } from "@wailsio/runtime";
+
+/**
+ * A Logger records structured information about each call to its
+ * Log, Debug, Info, Warn, and Error methods.
+ * For each call, it creates a [Record] and passes it to a [Handler].
+ *
+ * To create a new Logger, call [New] or a Logger method
+ * that begins "With".
+ */
+export class Logger {
+
+ /** Creates a new Logger instance. */
+ constructor($$source: Partial = {}) {
+
+ Object.assign(this, $$source);
+ }
+
+ /**
+ * Creates a new Logger instance from a string or object.
+ */
+ static createFrom($$source: any = {}): Logger {
+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+ return new Logger($$parsedSource as Partial);
+ }
+}
diff --git a/frontend/bindings/time/index.ts b/frontend/bindings/time/index.ts
new file mode 100644
index 0000000..8d4454a
--- /dev/null
+++ b/frontend/bindings/time/index.ts
@@ -0,0 +1,6 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+export type {
+ Time
+} from "./models.js";
diff --git a/frontend/bindings/time/models.ts b/frontend/bindings/time/models.ts
new file mode 100644
index 0000000..49d054a
--- /dev/null
+++ b/frontend/bindings/time/models.ts
@@ -0,0 +1,51 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Create as $Create } from "@wailsio/runtime";
+
+/**
+ * A Time represents an instant in time with nanosecond precision.
+ *
+ * Programs using times should typically store and pass them as values,
+ * not pointers. That is, time variables and struct fields should be of
+ * type [time.Time], not *time.Time.
+ *
+ * A Time value can be used by multiple goroutines simultaneously except
+ * that the methods [Time.GobDecode], [Time.UnmarshalBinary], [Time.UnmarshalJSON] and
+ * [Time.UnmarshalText] are not concurrency-safe.
+ *
+ * Time instants can be compared using the [Time.Before], [Time.After], and [Time.Equal] methods.
+ * The [Time.Sub] method subtracts two instants, producing a [Duration].
+ * The [Time.Add] method adds a Time and a Duration, producing a Time.
+ *
+ * The zero value of type Time is January 1, year 1, 00:00:00.000000000 UTC.
+ * As this time is unlikely to come up in practice, the [Time.IsZero] method gives
+ * a simple way of detecting a time that has not been initialized explicitly.
+ *
+ * Each time has an associated [Location]. The methods [Time.Local], [Time.UTC], and Time.In return a
+ * Time with a specific Location. Changing the Location of a Time value with
+ * these methods does not change the actual instant it represents, only the time
+ * zone in which to interpret it.
+ *
+ * Representations of a Time value saved by the [Time.GobEncode], [Time.MarshalBinary], [Time.AppendBinary],
+ * [Time.MarshalJSON], [Time.MarshalText] and [Time.AppendText] methods store the [Time.Location]'s offset,
+ * but not the location name. They therefore lose information about Daylight Saving Time.
+ *
+ * In addition to the required “wall clock” reading, a Time may contain an optional
+ * reading of the current process's monotonic clock, to provide additional precision
+ * for comparison or subtraction.
+ * See the “Monotonic Clocks” section in the package documentation for details.
+ *
+ * Note that the Go == operator compares not just the time instant but also the
+ * Location and the monotonic clock reading. Therefore, Time values should not
+ * be used as map or database keys without first guaranteeing that the
+ * identical Location has been set for all values, which can be achieved
+ * through use of the UTC or Local method, and that the monotonic clock reading
+ * has been stripped by setting t = t.Round(0). In general, prefer t.Equal(u)
+ * to t == u, since t.Equal uses the most accurate comparison available and
+ * correctly handles the case when only one of its arguments has a monotonic
+ * clock reading.
+ */
+export type Time = any;
diff --git a/frontend/index.html b/frontend/index.html
index 841b8ed..5b52889 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -5,7 +5,7 @@
- Wails + Vue + TS
+ Code Switch R
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index a07f6b5..e7d9cb9 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,17 +1,22 @@
{
"name": "frontend",
- "version": "0.0.0",
+ "version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
- "version": "0.0.0",
+ "version": "1.0.0",
"dependencies": {
+ "@codemirror/lang-markdown": "^6.5.0",
+ "@codemirror/state": "^6.5.2",
+ "@codemirror/theme-one-dark": "^6.1.3",
+ "@codemirror/view": "^6.38.8",
"@headlessui/vue": "^1.7.23",
"@lobehub/icons-static-svg": "^1.73.0",
"@wailsio/runtime": "^3.0.0-alpha.72",
"chart.js": "^4.5.1",
+ "codemirror": "^6.0.2",
"vue": "^3.5.23",
"vue-chartjs": "^5.3.3",
"vue-i18n": "^11.1.12",
@@ -86,6 +91,147 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.20.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
+ "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.10.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.0.tgz",
+ "integrity": "sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.4.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/lang-css": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
+ "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.2",
+ "@lezer/css": "^1.1.7"
+ }
+ },
+ "node_modules/@codemirror/lang-html": {
+ "version": "6.4.11",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
+ "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/lang-css": "^6.0.0",
+ "@codemirror/lang-javascript": "^6.0.0",
+ "@codemirror/language": "^6.4.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/css": "^1.1.0",
+ "@lezer/html": "^1.3.12"
+ }
+ },
+ "node_modules/@codemirror/lang-javascript": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
+ "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.6.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/javascript": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-markdown": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
+ "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.7.1",
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/language": "^6.3.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.2.1",
+ "@lezer/markdown": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.11.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
+ "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.1.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/lint": {
+ "version": "6.9.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
+ "integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.35.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/search": {
+ "version": "6.5.11",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
+ "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
+ "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/theme-one-dark": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
+ "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.38.8",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
+ "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
+ "dependencies": {
+ "@codemirror/state": "^6.5.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -642,12 +788,77 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
+ "node_modules/@lezer/common": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz",
+ "integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ=="
+ },
+ "node_modules/@lezer/css": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
+ "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
+ "dependencies": {
+ "@lezer/common": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/html": {
+ "version": "1.3.12",
+ "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.12.tgz",
+ "integrity": "sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/javascript": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
+ "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.1.3",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.3.tgz",
+ "integrity": "sha512-yenN5SqAxAPv/qMnpWW0AT7l+SxVrgG+u0tNsRQWqbrz66HIl8DnEbBObvy21J5K7+I1v7gsAnlE2VQ5yYVSeA==",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/markdown": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.0.tgz",
+ "integrity": "sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==",
+ "dependencies": {
+ "@lezer/common": "^1.0.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
"node_modules/@lobehub/icons-static-svg": {
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@lobehub/icons-static-svg/-/icons-static-svg-1.73.0.tgz",
"integrity": "sha512-ydKUCDoopdmulbjDZo/gppaODd5Ju5nPneVcN9A5dAz9IJZUMkLms8bqostMLrqcdMQ8resKjLuV9RhJaWhaag==",
"license": "MIT"
},
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.29",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz",
@@ -1468,6 +1679,25 @@
"pnpm": ">=8"
}
},
+ "node_modules/codemirror": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
+ "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ }
+ },
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
+ },
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -2008,6 +2238,11 @@
"node": ">=0.10.0"
}
},
+ "node_modules/style-mod": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="
+ },
"node_modules/tailwindcss": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
@@ -2224,6 +2459,11 @@
"peerDependencies": {
"typescript": ">=5.0.0"
}
+ },
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index c9e9c2a..f1baade 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.0.0",
+ "version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -10,10 +10,15 @@
"preview": "vite preview"
},
"dependencies": {
+ "@codemirror/lang-markdown": "^6.5.0",
+ "@codemirror/state": "^6.5.2",
+ "@codemirror/theme-one-dark": "^6.1.3",
+ "@codemirror/view": "^6.38.8",
"@headlessui/vue": "^1.7.23",
"@lobehub/icons-static-svg": "^1.73.0",
"@wailsio/runtime": "^3.0.0-alpha.72",
"chart.js": "^4.5.1",
+ "codemirror": "^6.0.2",
"vue": "^3.5.23",
"vue-chartjs": "^5.3.3",
"vue-i18n": "^11.1.12",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
new file mode 100644
index 0000000..5d1952d
--- /dev/null
+++ b/frontend/pnpm-lock.yaml
@@ -0,0 +1,1608 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@codemirror/lang-markdown':
+ specifier: ^6.5.0
+ version: 6.5.0
+ '@codemirror/state':
+ specifier: ^6.5.2
+ version: 6.5.2
+ '@codemirror/theme-one-dark':
+ specifier: ^6.1.3
+ version: 6.1.3
+ '@codemirror/view':
+ specifier: ^6.38.8
+ version: 6.38.8
+ '@headlessui/vue':
+ specifier: ^1.7.23
+ version: 1.7.23(vue@3.5.25(typescript@5.9.3))
+ '@lobehub/icons-static-svg':
+ specifier: ^1.73.0
+ version: 1.74.0
+ '@wailsio/runtime':
+ specifier: ^3.0.0-alpha.72
+ version: 3.0.0-alpha.73
+ chart.js:
+ specifier: ^4.5.1
+ version: 4.5.1
+ codemirror:
+ specifier: ^6.0.2
+ version: 6.0.2
+ vue:
+ specifier: ^3.5.23
+ version: 3.5.25(typescript@5.9.3)
+ vue-chartjs:
+ specifier: ^5.3.3
+ version: 5.3.3(chart.js@4.5.1)(vue@3.5.25(typescript@5.9.3))
+ vue-i18n:
+ specifier: ^11.1.12
+ version: 11.2.2(vue@3.5.25(typescript@5.9.3))
+ vue-router:
+ specifier: ^4.6.3
+ version: 4.6.3(vue@3.5.25(typescript@5.9.3))
+ devDependencies:
+ '@tailwindcss/postcss':
+ specifier: ^4.1.17
+ version: 4.1.17
+ '@vitejs/plugin-vue':
+ specifier: ^6.0.1
+ version: 6.0.2(vite@7.2.6(jiti@2.6.1)(lightningcss@1.30.2))(vue@3.5.25(typescript@5.9.3))
+ postcss:
+ specifier: ^8.5.6
+ version: 8.5.6
+ tailwindcss:
+ specifier: ^4.1.17
+ version: 4.1.17
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+ vite:
+ specifier: ^7.2.1
+ version: 7.2.6(jiti@2.6.1)(lightningcss@1.30.2)
+ vue-tsc:
+ specifier: ^3.1.3
+ version: 3.1.6(typescript@5.9.3)
+
+packages:
+
+ '@alloc/quick-lru@5.2.0':
+ resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+ engines: {node: '>=10'}
+
+ '@babel/helper-string-parser@7.27.1':
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.28.5':
+ resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/types@7.28.5':
+ resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
+ engines: {node: '>=6.9.0'}
+
+ '@codemirror/autocomplete@6.20.0':
+ resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==}
+
+ '@codemirror/commands@6.10.0':
+ resolution: {integrity: sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==}
+
+ '@codemirror/lang-css@6.3.1':
+ resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
+
+ '@codemirror/lang-html@6.4.11':
+ resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
+
+ '@codemirror/lang-javascript@6.2.4':
+ resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
+
+ '@codemirror/lang-markdown@6.5.0':
+ resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
+
+ '@codemirror/language@6.11.3':
+ resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==}
+
+ '@codemirror/lint@6.9.2':
+ resolution: {integrity: sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==}
+
+ '@codemirror/search@6.5.11':
+ resolution: {integrity: sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==}
+
+ '@codemirror/state@6.5.2':
+ resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==}
+
+ '@codemirror/theme-one-dark@6.1.3':
+ resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
+
+ '@codemirror/view@6.38.8':
+ resolution: {integrity: sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==}
+
+ '@esbuild/aix-ppc64@0.25.12':
+ resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.25.12':
+ resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.12':
+ resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.12':
+ resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.25.12':
+ resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.12':
+ resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.12':
+ resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.25.12':
+ resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.12':
+ resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.12':
+ resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.12':
+ resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.12':
+ resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.12':
+ resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.12':
+ resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.12':
+ resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.12':
+ resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.12':
+ resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.12':
+ resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.25.12':
+ resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.25.12':
+ resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.12':
+ resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.12':
+ resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@headlessui/vue@1.7.23':
+ resolution: {integrity: sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ vue: ^3.2.0
+
+ '@intlify/core-base@11.2.2':
+ resolution: {integrity: sha512-0mCTBOLKIqFUP3BzwuFW23hYEl9g/wby6uY//AC5hTgQfTsM2srCYF2/hYGp+a5DZ/HIFIgKkLJMzXTt30r0JQ==}
+ engines: {node: '>= 16'}
+
+ '@intlify/message-compiler@11.2.2':
+ resolution: {integrity: sha512-XS2p8Ff5JxWsKhgfld4/MRQzZRQ85drMMPhb7Co6Be4ZOgqJX1DzcZt0IFgGTycgqL8rkYNwgnD443Q+TapOoA==}
+ engines: {node: '>= 16'}
+
+ '@intlify/shared@11.2.2':
+ resolution: {integrity: sha512-OtCmyFpSXxNu/oET/aN6HtPCbZ01btXVd0f3w00YsHOb13Kverk1jzA2k47pAekM55qbUw421fvPF1yxZ+gicw==}
+ engines: {node: '>= 16'}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@kurkle/color@0.3.4':
+ resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
+
+ '@lezer/common@1.4.0':
+ resolution: {integrity: sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==}
+
+ '@lezer/css@1.3.0':
+ resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==}
+
+ '@lezer/highlight@1.2.3':
+ resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
+
+ '@lezer/html@1.3.12':
+ resolution: {integrity: sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==}
+
+ '@lezer/javascript@1.5.4':
+ resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
+
+ '@lezer/lr@1.4.4':
+ resolution: {integrity: sha512-LHL17Mq0OcFXm1pGQssuGTQFPPdxARjKM8f7GA5+sGtHi0K3R84YaSbmche0+RKWHnCsx9asEe5OWOI4FHfe4A==}
+
+ '@lezer/markdown@1.6.1':
+ resolution: {integrity: sha512-72ah+Sml7lD8Wn7lnz9vwYmZBo9aQT+I2gjK/0epI+gjdwUbWw3MJ/ZBGEqG1UfrIauRqH37/c5mVHXeCTGXtA==}
+
+ '@lobehub/icons-static-svg@1.74.0':
+ resolution: {integrity: sha512-4eqHOeUoMov54Fj8uk9bdCa88Eo/VSzY7aEqcTfUWYJGt8BvO+A3wf6mgsMrQOCYaavPGMeYiAUGi9iY6owhOg==}
+
+ '@marijn/find-cluster-break@1.0.2':
+ resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
+
+ '@rolldown/pluginutils@1.0.0-beta.50':
+ resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==}
+
+ '@rollup/rollup-android-arm-eabi@4.53.3':
+ resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.53.3':
+ resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.53.3':
+ resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.53.3':
+ resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.53.3':
+ resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.53.3':
+ resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.53.3':
+ resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.53.3':
+ resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-gnu@4.53.3':
+ resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-musl@4.53.3':
+ resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-gnu@4.53.3':
+ resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.53.3':
+ resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.53.3':
+ resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-musl@4.53.3':
+ resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-s390x-gnu@4.53.3':
+ resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-gnu@4.53.3':
+ resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-musl@4.53.3':
+ resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-openharmony-arm64@4.53.3':
+ resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.53.3':
+ resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.53.3':
+ resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.53.3':
+ resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.53.3':
+ resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/node@4.1.17':
+ resolution: {integrity: sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==}
+
+ '@tailwindcss/oxide-android-arm64@4.1.17':
+ resolution: {integrity: sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.17':
+ resolution: {integrity: sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.1.17':
+ resolution: {integrity: sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.17':
+ resolution: {integrity: sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17':
+ resolution: {integrity: sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.17':
+ resolution: {integrity: sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.17':
+ resolution: {integrity: sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.17':
+ resolution: {integrity: sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.17':
+ resolution: {integrity: sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.17':
+ resolution: {integrity: sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.17':
+ resolution: {integrity: sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.17':
+ resolution: {integrity: sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.1.17':
+ resolution: {integrity: sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==}
+ engines: {node: '>= 10'}
+
+ '@tailwindcss/postcss@4.1.17':
+ resolution: {integrity: sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==}
+
+ '@tanstack/virtual-core@3.13.12':
+ resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
+
+ '@tanstack/vue-virtual@3.13.12':
+ resolution: {integrity: sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==}
+ peerDependencies:
+ vue: ^2.7.0 || ^3.0.0
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@vitejs/plugin-vue@6.0.2':
+ resolution: {integrity: sha512-iHmwV3QcVGGvSC1BG5bZ4z6iwa1SOpAPWmnjOErd4Ske+lZua5K9TtAVdx0gMBClJ28DViCbSmZitjWZsWO3LA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0
+ vue: ^3.2.25
+
+ '@volar/language-core@2.4.26':
+ resolution: {integrity: sha512-hH0SMitMxnB43OZpyF1IFPS9bgb2I3bpCh76m2WEK7BE0A0EzpYsRp0CCH2xNKshr7kacU5TQBLYn4zj7CG60A==}
+
+ '@volar/source-map@2.4.26':
+ resolution: {integrity: sha512-JJw0Tt/kSFsIRmgTQF4JSt81AUSI1aEye5Zl65EeZ8H35JHnTvFGmpDOBn5iOxd48fyGE+ZvZBp5FcgAy/1Qhw==}
+
+ '@volar/typescript@2.4.26':
+ resolution: {integrity: sha512-N87ecLD48Sp6zV9zID/5yuS1+5foj0DfuYGdQ6KHj/IbKvyKv1zNX6VCmnKYwtmHadEO6mFc2EKISiu3RDPAvA==}
+
+ '@vue/compiler-core@3.5.25':
+ resolution: {integrity: sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==}
+
+ '@vue/compiler-dom@3.5.25':
+ resolution: {integrity: sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==}
+
+ '@vue/compiler-sfc@3.5.25':
+ resolution: {integrity: sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==}
+
+ '@vue/compiler-ssr@3.5.25':
+ resolution: {integrity: sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==}
+
+ '@vue/devtools-api@6.6.4':
+ resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
+
+ '@vue/language-core@3.1.6':
+ resolution: {integrity: sha512-F3BIvDVyyj+6Sgl9Ev9zsb/DJ48rrH2EiI5NnIEpJKo7Yk8v0n2QjfG7/RYyFhYSMOJcsf6aAt5hx4JaNbhKbg==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ '@vue/reactivity@3.5.25':
+ resolution: {integrity: sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==}
+
+ '@vue/runtime-core@3.5.25':
+ resolution: {integrity: sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==}
+
+ '@vue/runtime-dom@3.5.25':
+ resolution: {integrity: sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==}
+
+ '@vue/server-renderer@3.5.25':
+ resolution: {integrity: sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==}
+ peerDependencies:
+ vue: 3.5.25
+
+ '@vue/shared@3.5.25':
+ resolution: {integrity: sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==}
+
+ '@wailsio/runtime@3.0.0-alpha.73':
+ resolution: {integrity: sha512-r6/fIAKGAr5iJSkNw0zwSOvuOyXGKYR3dlat0gu59ZGYqsaZhdnlWTlPgOrtnSCV4BJf2DbTdorUQTTnkcYECA==}
+
+ alien-signals@3.1.1:
+ resolution: {integrity: sha512-ogkIWbVrLwKtHY6oOAXaYkAxP+cTH7V5FZ5+Tm4NZFd8VDZ6uNMDrfzqctTZ42eTMCSR3ne3otpcxmqSnFfPYA==}
+
+ chart.js@4.5.1:
+ resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
+ engines: {pnpm: '>=8'}
+
+ codemirror@6.0.2:
+ resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
+
+ crelt@1.0.6:
+ resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ enhanced-resolve@5.18.3:
+ resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
+ engines: {node: '>=10.13.0'}
+
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
+ esbuild@0.25.12:
+ resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ estree-walker@2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ jiti@2.6.1:
+ resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
+ hasBin: true
+
+ lightningcss-android-arm64@1.30.2:
+ resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.30.2:
+ resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.30.2:
+ resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.30.2:
+ resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.30.2:
+ resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.30.2:
+ resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-arm64-musl@1.30.2:
+ resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-x64-gnu@1.30.2:
+ resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-linux-x64-musl@1.30.2:
+ resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-win32-arm64-msvc@1.30.2:
+ resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.30.2:
+ resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.30.2:
+ resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
+ engines: {node: '>= 12.0.0'}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ muggle-string@0.4.1:
+ resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
+
+ nanoid@3.3.11:
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ path-browserify@1.0.1:
+ resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.3:
+ resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+ engines: {node: '>=12'}
+
+ postcss@8.5.6:
+ resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ rollup@4.53.3:
+ resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ style-mod@4.1.3:
+ resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
+
+ tailwindcss@4.1.17:
+ resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==}
+
+ tapable@2.3.0:
+ resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
+ engines: {node: '>=6'}
+
+ tinyglobby@0.2.15:
+ resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+ engines: {node: '>=12.0.0'}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ vite@7.2.6:
+ resolution: {integrity: sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ lightningcss: ^1.21.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vscode-uri@3.1.0:
+ resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
+
+ vue-chartjs@5.3.3:
+ resolution: {integrity: sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==}
+ peerDependencies:
+ chart.js: ^4.1.1
+ vue: ^3.0.0-0 || ^2.7.0
+
+ vue-i18n@11.2.2:
+ resolution: {integrity: sha512-ULIKZyRluUPRCZmihVgUvpq8hJTtOqnbGZuv4Lz+byEKZq4mU0g92og414l6f/4ju+L5mORsiUuEPYrAuX2NJg==}
+ engines: {node: '>= 16'}
+ peerDependencies:
+ vue: ^3.0.0
+
+ vue-router@4.6.3:
+ resolution: {integrity: sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==}
+ peerDependencies:
+ vue: ^3.5.0
+
+ vue-tsc@3.1.6:
+ resolution: {integrity: sha512-h5mMNGIDI+WMZxTeuYcpfSeDtBIiHXAg3qsrt65H4vcFTYmuM1THNHMzlnDvD8kX0fwLuf6auxWP340bH/zcpw==}
+ hasBin: true
+ peerDependencies:
+ typescript: '>=5.0.0'
+
+ vue@3.5.25:
+ resolution: {integrity: sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ w3c-keyname@2.2.8:
+ resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
+
+snapshots:
+
+ '@alloc/quick-lru@5.2.0': {}
+
+ '@babel/helper-string-parser@7.27.1': {}
+
+ '@babel/helper-validator-identifier@7.28.5': {}
+
+ '@babel/parser@7.28.5':
+ dependencies:
+ '@babel/types': 7.28.5
+
+ '@babel/types@7.28.5':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
+ '@codemirror/autocomplete@6.20.0':
+ dependencies:
+ '@codemirror/language': 6.11.3
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.8
+ '@lezer/common': 1.4.0
+
+ '@codemirror/commands@6.10.0':
+ dependencies:
+ '@codemirror/language': 6.11.3
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.8
+ '@lezer/common': 1.4.0
+
+ '@codemirror/lang-css@6.3.1':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/language': 6.11.3
+ '@codemirror/state': 6.5.2
+ '@lezer/common': 1.4.0
+ '@lezer/css': 1.3.0
+
+ '@codemirror/lang-html@6.4.11':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/lang-css': 6.3.1
+ '@codemirror/lang-javascript': 6.2.4
+ '@codemirror/language': 6.11.3
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.8
+ '@lezer/common': 1.4.0
+ '@lezer/css': 1.3.0
+ '@lezer/html': 1.3.12
+
+ '@codemirror/lang-javascript@6.2.4':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/language': 6.11.3
+ '@codemirror/lint': 6.9.2
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.8
+ '@lezer/common': 1.4.0
+ '@lezer/javascript': 1.5.4
+
+ '@codemirror/lang-markdown@6.5.0':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/lang-html': 6.4.11
+ '@codemirror/language': 6.11.3
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.8
+ '@lezer/common': 1.4.0
+ '@lezer/markdown': 1.6.1
+
+ '@codemirror/language@6.11.3':
+ dependencies:
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.8
+ '@lezer/common': 1.4.0
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.4
+ style-mod: 4.1.3
+
+ '@codemirror/lint@6.9.2':
+ dependencies:
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.8
+ crelt: 1.0.6
+
+ '@codemirror/search@6.5.11':
+ dependencies:
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.8
+ crelt: 1.0.6
+
+ '@codemirror/state@6.5.2':
+ dependencies:
+ '@marijn/find-cluster-break': 1.0.2
+
+ '@codemirror/theme-one-dark@6.1.3':
+ dependencies:
+ '@codemirror/language': 6.11.3
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.8
+ '@lezer/highlight': 1.2.3
+
+ '@codemirror/view@6.38.8':
+ dependencies:
+ '@codemirror/state': 6.5.2
+ crelt: 1.0.6
+ style-mod: 4.1.3
+ w3c-keyname: 2.2.8
+
+ '@esbuild/aix-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm@0.25.12':
+ optional: true
+
+ '@esbuild/android-x64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.12':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.12':
+ optional: true
+
+ '@headlessui/vue@1.7.23(vue@3.5.25(typescript@5.9.3))':
+ dependencies:
+ '@tanstack/vue-virtual': 3.13.12(vue@3.5.25(typescript@5.9.3))
+ vue: 3.5.25(typescript@5.9.3)
+
+ '@intlify/core-base@11.2.2':
+ dependencies:
+ '@intlify/message-compiler': 11.2.2
+ '@intlify/shared': 11.2.2
+
+ '@intlify/message-compiler@11.2.2':
+ dependencies:
+ '@intlify/shared': 11.2.2
+ source-map-js: 1.2.1
+
+ '@intlify/shared@11.2.2': {}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@kurkle/color@0.3.4': {}
+
+ '@lezer/common@1.4.0': {}
+
+ '@lezer/css@1.3.0':
+ dependencies:
+ '@lezer/common': 1.4.0
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.4
+
+ '@lezer/highlight@1.2.3':
+ dependencies:
+ '@lezer/common': 1.4.0
+
+ '@lezer/html@1.3.12':
+ dependencies:
+ '@lezer/common': 1.4.0
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.4
+
+ '@lezer/javascript@1.5.4':
+ dependencies:
+ '@lezer/common': 1.4.0
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.4
+
+ '@lezer/lr@1.4.4':
+ dependencies:
+ '@lezer/common': 1.4.0
+
+ '@lezer/markdown@1.6.1':
+ dependencies:
+ '@lezer/common': 1.4.0
+ '@lezer/highlight': 1.2.3
+
+ '@lobehub/icons-static-svg@1.74.0': {}
+
+ '@marijn/find-cluster-break@1.0.2': {}
+
+ '@rolldown/pluginutils@1.0.0-beta.50': {}
+
+ '@rollup/rollup-android-arm-eabi@4.53.3':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.53.3':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.53.3':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.53.3':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.53.3':
+ optional: true
+
+ '@tailwindcss/node@4.1.17':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.18.3
+ jiti: 2.6.1
+ lightningcss: 1.30.2
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.1.17
+
+ '@tailwindcss/oxide-android-arm64@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide@4.1.17':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.1.17
+ '@tailwindcss/oxide-darwin-arm64': 4.1.17
+ '@tailwindcss/oxide-darwin-x64': 4.1.17
+ '@tailwindcss/oxide-freebsd-x64': 4.1.17
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.17
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.1.17
+ '@tailwindcss/oxide-linux-arm64-musl': 4.1.17
+ '@tailwindcss/oxide-linux-x64-gnu': 4.1.17
+ '@tailwindcss/oxide-linux-x64-musl': 4.1.17
+ '@tailwindcss/oxide-wasm32-wasi': 4.1.17
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.1.17
+ '@tailwindcss/oxide-win32-x64-msvc': 4.1.17
+
+ '@tailwindcss/postcss@4.1.17':
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ '@tailwindcss/node': 4.1.17
+ '@tailwindcss/oxide': 4.1.17
+ postcss: 8.5.6
+ tailwindcss: 4.1.17
+
+ '@tanstack/virtual-core@3.13.12': {}
+
+ '@tanstack/vue-virtual@3.13.12(vue@3.5.25(typescript@5.9.3))':
+ dependencies:
+ '@tanstack/virtual-core': 3.13.12
+ vue: 3.5.25(typescript@5.9.3)
+
+ '@types/estree@1.0.8': {}
+
+ '@vitejs/plugin-vue@6.0.2(vite@7.2.6(jiti@2.6.1)(lightningcss@1.30.2))(vue@3.5.25(typescript@5.9.3))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.0-beta.50
+ vite: 7.2.6(jiti@2.6.1)(lightningcss@1.30.2)
+ vue: 3.5.25(typescript@5.9.3)
+
+ '@volar/language-core@2.4.26':
+ dependencies:
+ '@volar/source-map': 2.4.26
+
+ '@volar/source-map@2.4.26': {}
+
+ '@volar/typescript@2.4.26':
+ dependencies:
+ '@volar/language-core': 2.4.26
+ path-browserify: 1.0.1
+ vscode-uri: 3.1.0
+
+ '@vue/compiler-core@3.5.25':
+ dependencies:
+ '@babel/parser': 7.28.5
+ '@vue/shared': 3.5.25
+ entities: 4.5.0
+ estree-walker: 2.0.2
+ source-map-js: 1.2.1
+
+ '@vue/compiler-dom@3.5.25':
+ dependencies:
+ '@vue/compiler-core': 3.5.25
+ '@vue/shared': 3.5.25
+
+ '@vue/compiler-sfc@3.5.25':
+ dependencies:
+ '@babel/parser': 7.28.5
+ '@vue/compiler-core': 3.5.25
+ '@vue/compiler-dom': 3.5.25
+ '@vue/compiler-ssr': 3.5.25
+ '@vue/shared': 3.5.25
+ estree-walker: 2.0.2
+ magic-string: 0.30.21
+ postcss: 8.5.6
+ source-map-js: 1.2.1
+
+ '@vue/compiler-ssr@3.5.25':
+ dependencies:
+ '@vue/compiler-dom': 3.5.25
+ '@vue/shared': 3.5.25
+
+ '@vue/devtools-api@6.6.4': {}
+
+ '@vue/language-core@3.1.6(typescript@5.9.3)':
+ dependencies:
+ '@volar/language-core': 2.4.26
+ '@vue/compiler-dom': 3.5.25
+ '@vue/shared': 3.5.25
+ alien-signals: 3.1.1
+ muggle-string: 0.4.1
+ path-browserify: 1.0.1
+ picomatch: 4.0.3
+ optionalDependencies:
+ typescript: 5.9.3
+
+ '@vue/reactivity@3.5.25':
+ dependencies:
+ '@vue/shared': 3.5.25
+
+ '@vue/runtime-core@3.5.25':
+ dependencies:
+ '@vue/reactivity': 3.5.25
+ '@vue/shared': 3.5.25
+
+ '@vue/runtime-dom@3.5.25':
+ dependencies:
+ '@vue/reactivity': 3.5.25
+ '@vue/runtime-core': 3.5.25
+ '@vue/shared': 3.5.25
+ csstype: 3.2.3
+
+ '@vue/server-renderer@3.5.25(vue@3.5.25(typescript@5.9.3))':
+ dependencies:
+ '@vue/compiler-ssr': 3.5.25
+ '@vue/shared': 3.5.25
+ vue: 3.5.25(typescript@5.9.3)
+
+ '@vue/shared@3.5.25': {}
+
+ '@wailsio/runtime@3.0.0-alpha.73': {}
+
+ alien-signals@3.1.1: {}
+
+ chart.js@4.5.1:
+ dependencies:
+ '@kurkle/color': 0.3.4
+
+ codemirror@6.0.2:
+ dependencies:
+ '@codemirror/autocomplete': 6.20.0
+ '@codemirror/commands': 6.10.0
+ '@codemirror/language': 6.11.3
+ '@codemirror/lint': 6.9.2
+ '@codemirror/search': 6.5.11
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.8
+
+ crelt@1.0.6: {}
+
+ csstype@3.2.3: {}
+
+ detect-libc@2.1.2: {}
+
+ enhanced-resolve@5.18.3:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.0
+
+ entities@4.5.0: {}
+
+ esbuild@0.25.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.12
+ '@esbuild/android-arm': 0.25.12
+ '@esbuild/android-arm64': 0.25.12
+ '@esbuild/android-x64': 0.25.12
+ '@esbuild/darwin-arm64': 0.25.12
+ '@esbuild/darwin-x64': 0.25.12
+ '@esbuild/freebsd-arm64': 0.25.12
+ '@esbuild/freebsd-x64': 0.25.12
+ '@esbuild/linux-arm': 0.25.12
+ '@esbuild/linux-arm64': 0.25.12
+ '@esbuild/linux-ia32': 0.25.12
+ '@esbuild/linux-loong64': 0.25.12
+ '@esbuild/linux-mips64el': 0.25.12
+ '@esbuild/linux-ppc64': 0.25.12
+ '@esbuild/linux-riscv64': 0.25.12
+ '@esbuild/linux-s390x': 0.25.12
+ '@esbuild/linux-x64': 0.25.12
+ '@esbuild/netbsd-arm64': 0.25.12
+ '@esbuild/netbsd-x64': 0.25.12
+ '@esbuild/openbsd-arm64': 0.25.12
+ '@esbuild/openbsd-x64': 0.25.12
+ '@esbuild/openharmony-arm64': 0.25.12
+ '@esbuild/sunos-x64': 0.25.12
+ '@esbuild/win32-arm64': 0.25.12
+ '@esbuild/win32-ia32': 0.25.12
+ '@esbuild/win32-x64': 0.25.12
+
+ estree-walker@2.0.2: {}
+
+ fdir@6.5.0(picomatch@4.0.3):
+ optionalDependencies:
+ picomatch: 4.0.3
+
+ fsevents@2.3.3:
+ optional: true
+
+ graceful-fs@4.2.11: {}
+
+ jiti@2.6.1: {}
+
+ lightningcss-android-arm64@1.30.2:
+ optional: true
+
+ lightningcss-darwin-arm64@1.30.2:
+ optional: true
+
+ lightningcss-darwin-x64@1.30.2:
+ optional: true
+
+ lightningcss-freebsd-x64@1.30.2:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.30.2:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.30.2:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.30.2:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.30.2:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.30.2:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.30.2:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.30.2:
+ optional: true
+
+ lightningcss@1.30.2:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.30.2
+ lightningcss-darwin-arm64: 1.30.2
+ lightningcss-darwin-x64: 1.30.2
+ lightningcss-freebsd-x64: 1.30.2
+ lightningcss-linux-arm-gnueabihf: 1.30.2
+ lightningcss-linux-arm64-gnu: 1.30.2
+ lightningcss-linux-arm64-musl: 1.30.2
+ lightningcss-linux-x64-gnu: 1.30.2
+ lightningcss-linux-x64-musl: 1.30.2
+ lightningcss-win32-arm64-msvc: 1.30.2
+ lightningcss-win32-x64-msvc: 1.30.2
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ muggle-string@0.4.1: {}
+
+ nanoid@3.3.11: {}
+
+ path-browserify@1.0.1: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.3: {}
+
+ postcss@8.5.6:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ rollup@4.53.3:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.53.3
+ '@rollup/rollup-android-arm64': 4.53.3
+ '@rollup/rollup-darwin-arm64': 4.53.3
+ '@rollup/rollup-darwin-x64': 4.53.3
+ '@rollup/rollup-freebsd-arm64': 4.53.3
+ '@rollup/rollup-freebsd-x64': 4.53.3
+ '@rollup/rollup-linux-arm-gnueabihf': 4.53.3
+ '@rollup/rollup-linux-arm-musleabihf': 4.53.3
+ '@rollup/rollup-linux-arm64-gnu': 4.53.3
+ '@rollup/rollup-linux-arm64-musl': 4.53.3
+ '@rollup/rollup-linux-loong64-gnu': 4.53.3
+ '@rollup/rollup-linux-ppc64-gnu': 4.53.3
+ '@rollup/rollup-linux-riscv64-gnu': 4.53.3
+ '@rollup/rollup-linux-riscv64-musl': 4.53.3
+ '@rollup/rollup-linux-s390x-gnu': 4.53.3
+ '@rollup/rollup-linux-x64-gnu': 4.53.3
+ '@rollup/rollup-linux-x64-musl': 4.53.3
+ '@rollup/rollup-openharmony-arm64': 4.53.3
+ '@rollup/rollup-win32-arm64-msvc': 4.53.3
+ '@rollup/rollup-win32-ia32-msvc': 4.53.3
+ '@rollup/rollup-win32-x64-gnu': 4.53.3
+ '@rollup/rollup-win32-x64-msvc': 4.53.3
+ fsevents: 2.3.3
+
+ source-map-js@1.2.1: {}
+
+ style-mod@4.1.3: {}
+
+ tailwindcss@4.1.17: {}
+
+ tapable@2.3.0: {}
+
+ tinyglobby@0.2.15:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+
+ typescript@5.9.3: {}
+
+ vite@7.2.6(jiti@2.6.1)(lightningcss@1.30.2):
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+ postcss: 8.5.6
+ rollup: 4.53.3
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ fsevents: 2.3.3
+ jiti: 2.6.1
+ lightningcss: 1.30.2
+
+ vscode-uri@3.1.0: {}
+
+ vue-chartjs@5.3.3(chart.js@4.5.1)(vue@3.5.25(typescript@5.9.3)):
+ dependencies:
+ chart.js: 4.5.1
+ vue: 3.5.25(typescript@5.9.3)
+
+ vue-i18n@11.2.2(vue@3.5.25(typescript@5.9.3)):
+ dependencies:
+ '@intlify/core-base': 11.2.2
+ '@intlify/shared': 11.2.2
+ '@vue/devtools-api': 6.6.4
+ vue: 3.5.25(typescript@5.9.3)
+
+ vue-router@4.6.3(vue@3.5.25(typescript@5.9.3)):
+ dependencies:
+ '@vue/devtools-api': 6.6.4
+ vue: 3.5.25(typescript@5.9.3)
+
+ vue-tsc@3.1.6(typescript@5.9.3):
+ dependencies:
+ '@volar/typescript': 2.4.26
+ '@vue/language-core': 3.1.6(typescript@5.9.3)
+ typescript: 5.9.3
+
+ vue@3.5.25(typescript@5.9.3):
+ dependencies:
+ '@vue/compiler-dom': 3.5.25
+ '@vue/compiler-sfc': 3.5.25
+ '@vue/runtime-dom': 3.5.25
+ '@vue/server-renderer': 3.5.25(vue@3.5.25(typescript@5.9.3))
+ '@vue/shared': 3.5.25
+ optionalDependencies:
+ typescript: 5.9.3
+
+ w3c-keyname@2.2.8: {}
diff --git a/frontend/public/style.css b/frontend/public/style.css
index 273ffe4..f52f295 100644
--- a/frontend/public/style.css
+++ b/frontend/public/style.css
@@ -22,6 +22,32 @@
-ms-user-select: none;
}
+/* 允许可编辑控件:兼容旧版 macOS WebKit 输入问题 */
+input,
+textarea,
+select,
+option,
+[contenteditable],
+[contenteditable="true"],
+[contenteditable="plaintext-only"],
+[role="textbox"],
+[contenteditable] * {
+ -webkit-user-select: text;
+ user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+}
+
+/* 防止 Wails 拖拽区域影响可交互元素 */
+input,
+textarea,
+select,
+button,
+a,
+[contenteditable] {
+ -webkit-app-region: no-drag;
+}
+
@font-face {
font-family: "Inter";
font-style: normal;
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index d92ea34..e4a7e3c 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,6 +1,9 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Availability/Index.vue b/frontend/src/components/Availability/Index.vue
new file mode 100644
index 0000000..dcd6275
--- /dev/null
+++ b/frontend/src/components/Availability/Index.vue
@@ -0,0 +1,511 @@
+
+
+
+
+
+
+
+
+ {{ t('availability.title') }}
+
+
+ {{ t('availability.subtitle') }}
+
+
+
+
+
+
+
+
+
+
+
{{ statusStats.operational }}
+
{{ t('availability.stats.operational') }}
+
+
+
{{ statusStats.degraded }}
+
{{ t('availability.stats.degraded') }}
+
+
+
{{ statusStats.failed }}
+
{{ t('availability.stats.failed') }}
+
+
+
{{ statusStats.disabled }}
+
{{ t('availability.stats.disabled') }}
+
+
+
+
+
+ {{ t('availability.lastUpdate') }}: {{ formatLastUpdated() }}
+ {{ t('availability.nextRefresh') }}: {{ nextRefreshIn }}s
+
+
+
+
+
+
+
+
+
+
+
+ {{ platform }} {{ t('availability.providers') }}
+
+
+
+
+
+
+
+
+
+
+
{{ timeline.providerName }}
+
+
+
+ {{ formatStatus(timeline.latest.status) }}
+
+
{{ t('availability.notMonitored') }}
+
+
+
+
+
+
+ {{ timeline.latest.latencyMs }}ms
+
+
+
+
+ {{ timeline.uptime.toFixed(1) }}%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('availability.currentModel') }}:{{ displayConfigValue(timeline.availabilityConfig?.testModel, t('availability.defaultModel')) }}
+
{{ t('availability.currentEndpoint') }}:{{ displayConfigValue(timeline.availabilityConfig?.testEndpoint, t('availability.defaultEndpoint')) }}
+
{{ t('availability.currentTimeout') }}:{{ displayConfigValue(timeline.availabilityConfig?.timeout, '15000ms') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('availability.noProviders') }}
+
+
+
+
+
+
+
+
+
+ {{ t('availability.configTitle') }}
+
+
+ {{ activeProvider?.providerName }} ({{ activeProvider?.platform }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('availability.hint.timeout') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Console/Index.vue b/frontend/src/components/Console/Index.vue
new file mode 100644
index 0000000..e6cf83f
--- /dev/null
+++ b/frontend/src/components/Console/Index.vue
@@ -0,0 +1,258 @@
+
+
+
+
+
+
控制台
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatTimestamp(log.timestamp) }}
+ {{ log.level }}
+ {{ log.message }}
+
+
+
+
+
+
+
diff --git a/frontend/src/components/DeepLinkImportDialog.vue b/frontend/src/components/DeepLinkImportDialog.vue
new file mode 100644
index 0000000..5f40e34
--- /dev/null
+++ b/frontend/src/components/DeepLinkImportDialog.vue
@@ -0,0 +1,493 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('deeplink.error.title') }}
+
{{ error }}
+
+
+
+
+
+
+
{{ t('deeplink.success') }}
+
+
+
+
+
+
{{ t('deeplink.importing') }}
+
+
+
+
+
{{ t('deeplink.preview') }}
+
+
+ {{ t('deeplink.field.name') }}
+ {{ request.name }}
+
+
+
+ {{ t('deeplink.field.app') }}
+
+ {{ request.app }}
+
+
+
+
+
+
+ {{ t('deeplink.field.endpoint') }}
+ {{ request.endpoint }}
+
+
+
+ {{ t('deeplink.field.apiKey') }}
+ {{ maskApiKey(request.apiKey) }}
+
+
+
+ {{ t('deeplink.field.model') }}
+ {{ request.model }}
+
+
+
+ {{ t('deeplink.field.notes') }}
+ {{ request.notes }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/EnvCheck/Index.vue b/frontend/src/components/EnvCheck/Index.vue
new file mode 100644
index 0000000..90e263e
--- /dev/null
+++ b/frontend/src/components/EnvCheck/Index.vue
@@ -0,0 +1,440 @@
+
+
+
+
+
+
+
{{ t('envcheck.hero.eyebrow') }}
+
{{ t('envcheck.hero.title') }}
+
{{ t('envcheck.hero.lead') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('envcheck.checking') }}
+
{{ t('envcheck.error') }}
+
+ {{ t('envcheck.found', { count: conflictCount }) }}
+
+
{{ t('envcheck.noConflicts') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('envcheck.value') }}:
+ {{ maskValue(conflict.varValue) }}
+
+
+ {{ t('envcheck.source') }}:
+ {{ getSourceLabel(conflict) }}
+
+
+
+
+
+
+
+
{{ t('envcheck.checking') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Gemini/Index.vue b/frontend/src/components/Gemini/Index.vue
new file mode 100644
index 0000000..39fa889
--- /dev/null
+++ b/frontend/src/components/Gemini/Index.vue
@@ -0,0 +1,656 @@
+
+
+
+
{{ t('components.gemini.hero.eyebrow') }}
+
+
+
+
+
+
+ {{ t('components.gemini.hero.title') }}
+ {{ t('components.gemini.hero.lead') }}
+
+
+
+
+
+
+
+
+
+
{{ status?.enabled ? t('components.gemini.status.enabled') : t('components.gemini.status.disabled') }}
+
{{ status.currentProvider }}
+
{{ authTypeLabel(status?.authType ?? 'gemini-api-key') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ preset.name }}
+
{{ preset.description }}
+
+ {{ categoryLabel(preset.category) }}
+
+
+
+
+
+
+
+
+ {{ t('components.gemini.list.loading') }}
+
+
+
{{ t('components.gemini.list.empty') }}
+
+
+
+
+
+
+
+
+
+
+
{{ provider.name }}
+
{{ t('components.gemini.status.active') }}
+
+
+ {{ provider.baseUrl }}
+ · {{ provider.model }}
+
+
+
+
+
+ {{ t('components.gemini.actions.switch') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('components.gemini.form.deleteMessage', { name: confirmState.provider?.name ?? '' }) }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/General/Index.vue b/frontend/src/components/General/Index.vue
index d51b0f3..9e6e3d4 100644
--- a/frontend/src/components/General/Index.vue
+++ b/frontend/src/components/General/Index.vue
@@ -1,34 +1,180 @@
@@ -112,6 +583,421 @@ onMounted(() => {
+
+
+
+ {{ $t('components.general.label.switchNotifyHint') }}
+
+
+
+
+
+ {{ $t('components.general.label.roundRobinHint') }}
+
+
+
+
+
+
+ {{ $t('components.general.title.trayPanel') }}
+
+
{{ $t('components.general.label.trayPanelClaude') }}
+
+
+
+
+ USD
+
+
{{ $t('components.general.label.budgetTotalHint') }}
+
+
+
+
+
+
+ USD
+
+
{{ $t('components.general.label.budgetUsedAdjustmentHint') }}
+
+
+
+
+
+ {{ $t('components.general.label.budgetCycleHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('components.general.label.budgetForecastMethodHint') }}
+
+
+
+
+
{{ $t('components.general.label.trayPanelCodex') }}
+
+
+
+
+ USD
+
+
{{ $t('components.general.label.budgetTotalHint') }}
+
+
+
+
+
+
+ USD
+
+
{{ $t('components.general.label.budgetUsedAdjustmentHint') }}
+
+
+
+
+
+ {{ $t('components.general.label.budgetCycleHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('components.general.label.budgetForecastMethodHint') }}
+
+
+
+
+
+
+ {{ $t('components.general.title.connectivity') }}
+
+
+
+
+ {{ $t('components.general.label.autoConnectivityTestHint') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('components.general.title.blacklist') }}
+
+
+
+
+ {{ $t('components.general.label.enableBlacklistHint') }}
+
+
+
+
+
+ {{ $t('components.general.label.enableLevelBlacklistHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -126,6 +1012,152 @@ onMounted(() => {
+
+
+
+
diff --git a/frontend/src/components/Logs/Index.vue b/frontend/src/components/Logs/Index.vue
index fefc3bf..a265155 100644
--- a/frontend/src/components/Logs/Index.vue
+++ b/frontend/src/components/Logs/Index.vue
@@ -13,9 +13,17 @@
-
+
{{ card.label }}
- {{ card.value }}
+
+ {{ card.value }}
+ ({{ card.subValue }})
+
{{ card.hint }}
@@ -32,6 +40,7 @@
+